Skip to content

Commit a43011f

Browse files
mariotoffiaclaude
andauthored
feat(#318): expose compose restart at the service level (#338)
IComposeDriver.RestartAsync + ComposeRestartConfig (with a Services target list) already existed and emit `docker compose restart [service…]`, but IComposeService had no way to reach them: a user holding an IComposeService could not restart the project or target an individual service. Add IComposeService.RestartAsync() (whole project) and RestartAsync(IEnumerable<string> services) (specific services), implemented in ComposeService mirroring StopAsync: build a ComposeRestartConfig, delegate to driver.RestartAsync, throw DriverException on failure, transition to Running. Tests: whole-project (empty Services), specific-services list pass-through, and DriverException on failure. Added SetupComposeRestart mock helper. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c97e9f6 commit a43011f

4 files changed

Lines changed: 125 additions & 0 deletions

File tree

FluentDocker.Tests/CoreTests/Service/ComposeServiceTests.Operations.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,5 +426,73 @@ public void ServiceCapabilities_ReportsCorrectValues()
426426
}
427427

428428
#endregion
429+
430+
#region RestartAsync (issue #318)
431+
432+
[Fact]
433+
public async Task RestartAsync_WholeProject_CallsDriverWithNoServices()
434+
{
435+
var mockPack = new MockDriverPack();
436+
mockPack.SetupComposeRestart();
437+
438+
var kernel = await MockKernelBuilderExtensions.CreateWithMockDriverAsync("docker", mockPack);
439+
try
440+
{
441+
var service = CreateService(kernel);
442+
await service.RestartAsync(TestContext.Current.CancellationToken);
443+
444+
Assert.Equal(ServiceRunningState.Running, service.State);
445+
mockPack.ComposeDriver.Verify(d => d.RestartAsync(
446+
It.IsAny<DriverContext>(),
447+
It.Is<ComposeRestartConfig>(c => c.Services.Count == 0 && c.ProjectName == "test-project"),
448+
It.IsAny<CancellationToken>()), Times.Once);
449+
}
450+
finally { kernel.Dispose(); }
451+
}
452+
453+
[Fact]
454+
public async Task RestartAsync_SpecificServices_PassesServiceList()
455+
{
456+
var mockPack = new MockDriverPack();
457+
mockPack.SetupComposeRestart();
458+
459+
var kernel = await MockKernelBuilderExtensions.CreateWithMockDriverAsync("docker", mockPack);
460+
try
461+
{
462+
var service = CreateService(kernel);
463+
await service.RestartAsync(["web", "db"], TestContext.Current.CancellationToken);
464+
465+
mockPack.ComposeDriver.Verify(d => d.RestartAsync(
466+
It.IsAny<DriverContext>(),
467+
It.Is<ComposeRestartConfig>(c =>
468+
c.Services.Count == 2 && c.Services[0] == "web" && c.Services[1] == "db"),
469+
It.IsAny<CancellationToken>()), Times.Once);
470+
}
471+
finally { kernel.Dispose(); }
472+
}
473+
474+
[Fact]
475+
public async Task RestartAsync_Failure_ThrowsDriverException()
476+
{
477+
var mockPack = new MockDriverPack();
478+
mockPack.ComposeDriver
479+
.Setup(d => d.RestartAsync(
480+
It.IsAny<DriverContext>(),
481+
It.IsAny<ComposeRestartConfig>(),
482+
It.IsAny<CancellationToken>()))
483+
.ReturnsAsync(CommandResponse<Unit>.Fail("boom", "COMPOSE_RESTART_FAILED"));
484+
485+
var kernel = await MockKernelBuilderExtensions.CreateWithMockDriverAsync("docker", mockPack);
486+
try
487+
{
488+
var service = CreateService(kernel);
489+
var ex = await Assert.ThrowsAsync<DriverException>(
490+
() => service.RestartAsync(TestContext.Current.CancellationToken));
491+
Assert.Contains("restart compose project", ex.Message.ToLowerInvariant());
492+
}
493+
finally { kernel.Dispose(); }
494+
}
495+
496+
#endregion
429497
}
430498
}

FluentDocker.Tests/Mocks/MockDriverPack.Compose.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,20 @@ public MockDriverPack SetupComposeStop()
140140
return this;
141141
}
142142

143+
/// <summary>
144+
/// Sets up ComposeDriver.RestartAsync to return success.
145+
/// </summary>
146+
public MockDriverPack SetupComposeRestart()
147+
{
148+
ComposeDriver
149+
.Setup(d => d.RestartAsync(
150+
It.IsAny<DriverContext>(),
151+
It.IsAny<ComposeRestartConfig>(),
152+
It.IsAny<CancellationToken>()))
153+
.ReturnsAsync(FluentDocker.Model.Drivers.CommandResponse<Unit>.Ok(Unit.Default));
154+
return this;
155+
}
156+
143157
/// <summary>
144158
/// Sets up ComposeDriver.PauseAsync to return success.
145159
/// </summary>

FluentDocker/Services/IComposeService.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,20 @@ public interface IComposeService : IServiceAsync
3939
/// Scales a service to the specified number of instances.
4040
/// </summary>
4141
Task ScaleAsync(string service, int replicas, CancellationToken cancellationToken = default);
42+
43+
/// <summary>
44+
/// Restarts the whole compose project (<c>docker compose restart</c>).
45+
/// </summary>
46+
/// <param name="cancellationToken">Cancellation token.</param>
47+
Task RestartAsync(CancellationToken cancellationToken = default);
48+
49+
/// <summary>
50+
/// Restarts specific services in the compose project
51+
/// (<c>docker compose restart &lt;service&gt; …</c>).
52+
/// </summary>
53+
/// <param name="services">The services to restart. When null or empty, the whole project is restarted.</param>
54+
/// <param name="cancellationToken">Cancellation token.</param>
55+
Task RestartAsync(IEnumerable<string> services, CancellationToken cancellationToken = default);
4256
}
4357
}
4458

FluentDocker/Services/Impl/ComposeService.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,35 @@ public async Task StopAsync(CancellationToken cancellationToken = default)
236236
await ExecuteHooksAsync(ServiceRunningState.Stopped).ConfigureAwait(false);
237237
}
238238

239+
public Task RestartAsync(CancellationToken cancellationToken = default) =>
240+
RestartAsync(null, cancellationToken);
241+
242+
public async Task RestartAsync(IEnumerable<string> services, CancellationToken cancellationToken = default)
243+
{
244+
var driver = _kernel.SysCtl<IComposeDriver>(_driverId);
245+
var context = new DriverContext(_driverId);
246+
247+
var config = new ComposeRestartConfig
248+
{
249+
ComposeFiles = _composeFiles,
250+
ProjectName = _projectName,
251+
Services = services is null ? [] : [.. services]
252+
};
253+
254+
var response = await driver.RestartAsync(context, config, cancellationToken).ConfigureAwait(false);
255+
256+
if (!response.Success)
257+
{
258+
throw new DriverException(
259+
$"Failed to restart compose project '{_projectName}': {response.Error}",
260+
response.ErrorCode,
261+
response.ErrorContext);
262+
}
263+
264+
UpdateState(ServiceRunningState.Running);
265+
await ExecuteHooksAsync(ServiceRunningState.Running).ConfigureAwait(false);
266+
}
267+
239268
public async Task RemoveAsync(bool force = false, CancellationToken cancellationToken = default)
240269
{
241270
var driver = _kernel.SysCtl<IComposeDriver>(_driverId);

0 commit comments

Comments
 (0)