From d0687c913490c41d57d7993b384102eb56087d15 Mon Sep 17 00:00:00 2001 From: ebolo Date: Sun, 26 Jul 2026 11:39:59 +0700 Subject: [PATCH] feat(docker): Gracefully reload Caddy configuration --- .../Docker/DockerServiceConfiguration.cs | 6 ++ .../Docker/CaddyReloadResponse.cs | 17 ++++ .../Docker/IDockerService.cs | 7 ++ CaddyManager.Services/Docker/DockerService.cs | 78 +++++++++++++++---- .../Services/Docker/DockerServiceTests.cs | 58 ++++++++++++++ .../CaddyReverseProxiesPage.razor | 2 +- .../CaddyReverseProxiesPage.razor.cs | 24 +++++- .../CaddyfileEditor/CaddyfileEditor.razor.cs | 2 - .../Pages/Caddy/CaddyfilePage.razor.cs | 23 ++++-- README.md | 5 ++ 10 files changed, 200 insertions(+), 22 deletions(-) create mode 100644 CaddyManager.Contracts/Docker/CaddyReloadResponse.cs diff --git a/CaddyManager.Contracts/Configurations/Docker/DockerServiceConfiguration.cs b/CaddyManager.Contracts/Configurations/Docker/DockerServiceConfiguration.cs index 7eb4e49..e0785c7 100644 --- a/CaddyManager.Contracts/Configurations/Docker/DockerServiceConfiguration.cs +++ b/CaddyManager.Contracts/Configurations/Docker/DockerServiceConfiguration.cs @@ -16,6 +16,12 @@ public class DockerServiceConfiguration /// Uri to the Docker host /// public string DockerHost { get; set; } = "unix:///var/run/docker.sock"; + + /// + /// Path to the Caddyfile as seen from inside the Caddy container, used by `caddy reload`. Must match the + /// container side of the Caddy container's config volume mount. + /// + public string CaddyConfigPathInContainer { get; set; } = "/etc/caddy/Caddyfile"; /// /// Returns the Docker host with environment check. If the environment variable DOCKER_HOST is set, it will return diff --git a/CaddyManager.Contracts/Docker/CaddyReloadResponse.cs b/CaddyManager.Contracts/Docker/CaddyReloadResponse.cs new file mode 100644 index 0000000..533931b --- /dev/null +++ b/CaddyManager.Contracts/Docker/CaddyReloadResponse.cs @@ -0,0 +1,17 @@ +namespace CaddyManager.Contracts.Docker; + +/// +/// Result of a graceful Caddy configuration reload +/// +public class CaddyReloadResponse +{ + /// + /// Indicates whether Caddy accepted and applied the configuration + /// + public bool Success { get; set; } + + /// + /// Output from Caddy when the reload failed, typically the configuration validation error + /// + public string Message { get; set; } = string.Empty; +} diff --git a/CaddyManager.Contracts/Docker/IDockerService.cs b/CaddyManager.Contracts/Docker/IDockerService.cs index 9669e6c..217dfcc 100644 --- a/CaddyManager.Contracts/Docker/IDockerService.cs +++ b/CaddyManager.Contracts/Docker/IDockerService.cs @@ -10,4 +10,11 @@ public interface IDockerService /// /// Task RestartCaddyContainerAsync(); + + /// + /// Method to gracefully reload the Caddy configuration without restarting the container. Caddy validates the + /// configuration before applying it, so a broken configuration leaves the running one untouched. + /// + /// Whether the reload succeeded, along with Caddy's output when it did not + Task ReloadCaddyContainerAsync(); } \ No newline at end of file diff --git a/CaddyManager.Services/Docker/DockerService.cs b/CaddyManager.Services/Docker/DockerService.cs index 8ffa5cf..98d09c7 100644 --- a/CaddyManager.Services/Docker/DockerService.cs +++ b/CaddyManager.Services/Docker/DockerService.cs @@ -15,12 +15,8 @@ public class DockerService(IConfigurationsService configurationsService) : IDock /// Method to get the container id of the Caddy container by the name configured /// /// - private async Task GetCaddyContainerId() + private async Task GetCaddyContainerId(IDockerClient client) { - var client = new DockerClientConfiguration(new Uri(Configuration.DockerHostWithEnvCheck)).CreateClient(); - - if (client == null) return string.Empty; - var containers = await client.Containers.ListContainersAsync(new ContainersListParameters { All = true @@ -30,19 +26,75 @@ public class DockerService(IConfigurationsService configurationsService) : IDock ?.ID ?? string.Empty; } - + + private IDockerClient CreateClient() => + new DockerClientConfiguration(new Uri(Configuration.DockerHostWithEnvCheck)).CreateClient(); + /// public async Task RestartCaddyContainerAsync() { - var containerId = await GetCaddyContainerId(); - - if (string.IsNullOrEmpty(containerId)) return; - - var client = new DockerClientConfiguration(new Uri(Configuration.DockerHostWithEnvCheck)).CreateClient(); + using var client = CreateClient(); - if (client != null) + var containerId = await GetCaddyContainerId(client); + + if (string.IsNullOrEmpty(containerId)) return; + + await client.Containers.RestartContainerAsync(containerId, new ContainerRestartParameters()); + } + + /// + public async Task ReloadCaddyContainerAsync() + { + try { - await client.Containers.RestartContainerAsync(containerId, new ContainerRestartParameters()); + using var client = CreateClient(); + + var containerId = await GetCaddyContainerId(client); + + if (string.IsNullOrEmpty(containerId)) + { + return new CaddyReloadResponse + { + Message = $"Caddy container '{Configuration.CaddyContainerName}' was not found" + }; + } + + var exec = await client.Exec.ExecCreateContainerAsync(containerId, new ContainerExecCreateParameters + { + Cmd = ["caddy", "reload", "--config", Configuration.CaddyConfigPathInContainer], + AttachStdout = true, + AttachStderr = true + }); + + string stdout, stderr; + + using (var stream = await client.Exec.StartAndAttachContainerExecAsync(exec.ID, false)) + { + (stdout, stderr) = await stream.ReadOutputToEndAsync(CancellationToken.None); + } + + var inspection = await client.Exec.InspectContainerExecAsync(exec.ID); + + if (inspection.ExitCode == 0) + { + return new CaddyReloadResponse { Success = true }; + } + + // Caddy reports errors on stderr, interleaved with its structured JSON progress logs which are noise here + var output = string.Join(Environment.NewLine, $"{stderr}{Environment.NewLine}{stdout}" + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(line => !line.StartsWith('{'))); + + return new CaddyReloadResponse + { + Message = string.IsNullOrWhiteSpace(output) + ? $"Caddy reload failed with exit code {inspection.ExitCode}" + : output.Trim() + }; + } + catch (Exception e) + { + return new CaddyReloadResponse { Message = e.Message }; } } } \ No newline at end of file diff --git a/CaddyManager.Tests/Services/Docker/DockerServiceTests.cs b/CaddyManager.Tests/Services/Docker/DockerServiceTests.cs index fd129cb..684f029 100644 --- a/CaddyManager.Tests/Services/Docker/DockerServiceTests.cs +++ b/CaddyManager.Tests/Services/Docker/DockerServiceTests.cs @@ -1,5 +1,6 @@ using CaddyManager.Contracts.Configurations.Docker; using CaddyManager.Contracts.Configurations; +using CaddyManager.Contracts.Docker; using CaddyManager.Services.Docker; namespace CaddyManager.Tests.Services.Docker; @@ -161,6 +162,63 @@ public class DockerServiceTests // Assert config.CaddyContainerName.Should().Be("caddy"); config.DockerHost.Should().Be("unix:///var/run/docker.sock"); + config.CaddyConfigPathInContainer.Should().Be("/etc/caddy/Caddyfile"); + } + + /// + /// Tests that a graceful reload against a container that does not exist reports a failure instead of throwing. + /// Setup: Points the service at a container name that is guaranteed not to be running. + /// Expectation: The service returns an unsuccessful carrying an explanatory + /// message, so the UI can surface the problem rather than the caller having to handle exceptions. + /// + [Fact] + public async Task ReloadCaddyContainerAsync_WithNonExistentContainer_ReturnsFailureResponse() + { + // Arrange + _mockConfigurationsService + .Setup(x => x.Get()) + .Returns(new DockerServiceConfiguration + { + CaddyContainerName = "non-existent-container", + DockerHost = "unix:///var/run/docker.sock" + }); + + var service = new DockerService(_mockConfigurationsService.Object); + + // Act + var response = await service.ReloadCaddyContainerAsync(); + + // Assert + response.Success.Should().BeFalse(); + response.Message.Should().NotBeNullOrWhiteSpace(); + } + + /// + /// Tests that a graceful reload against an unreachable Docker daemon reports a failure instead of throwing. + /// Setup: Points the service at a Docker host that cannot be contacted. + /// Expectation: The connection error is captured in the response message, keeping the failure handling uniform + /// regardless of whether Docker, the container, or Caddy itself is the cause. + /// + [Fact] + public async Task ReloadCaddyContainerAsync_WithUnreachableDockerHost_ReturnsFailureResponse() + { + // Arrange + _mockConfigurationsService + .Setup(x => x.Get()) + .Returns(new DockerServiceConfiguration + { + CaddyContainerName = "test-caddy", + DockerHost = "tcp://unreachable-host:2376" + }); + + var service = new DockerService(_mockConfigurationsService.Object); + + // Act + var response = await service.ReloadCaddyContainerAsync(); + + // Assert + response.Success.Should().BeFalse(); + response.Message.Should().NotBeNullOrWhiteSpace(); } /// diff --git a/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor b/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor index ad71ca1..009bded 100644 --- a/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor +++ b/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor @@ -32,7 +32,7 @@ @bind-SelectedValues="_selectedCaddyConfigurations"> @foreach (var (index, caddyConfig) in _availableCaddyConfigurations.Index()) { - @if (index < _availableCaddyConfigurations.Count - 1) diff --git a/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor.cs b/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor.cs index 127c059..ac9c9b3 100644 --- a/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor.cs +++ b/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor.cs @@ -78,7 +78,7 @@ public partial class CaddyReverseProxiesPage : ComponentBase if (result is { Data: bool, Canceled: false } && (bool)result.Data) { - await RestartCaddy(); + await ReloadCaddy(); } Refresh(); @@ -160,6 +160,28 @@ public partial class CaddyReverseProxiesPage : ComponentBase }); } + /// + /// Gracefully reload the Caddy configuration without bouncing the container + /// + /// + private async Task ReloadCaddy() + { + _isProcessing = true; + StateHasChanged(); + Snackbar.Add("Reloading Caddy configuration", Severity.Info); + // Added a small delay for debugging purposes to ensure UI renders + await Task.Delay(100); + + var response = await DockerService.ReloadCaddyContainerAsync(); + + Snackbar.Add( + response.Success ? "Caddy configuration reloaded successfully" : $"Failed to reload Caddy: {response.Message}", + response.Success ? Severity.Success : Severity.Error); + + _isProcessing = false; + StateHasChanged(); + } + /// /// Restart the Caddy container /// diff --git a/CaddyManager/Components/Pages/Caddy/CaddyfileEditor/CaddyfileEditor.razor.cs b/CaddyManager/Components/Pages/Caddy/CaddyfileEditor/CaddyfileEditor.razor.cs index 181cbfb..7708fad 100644 --- a/CaddyManager/Components/Pages/Caddy/CaddyfileEditor/CaddyfileEditor.razor.cs +++ b/CaddyManager/Components/Pages/Caddy/CaddyfileEditor/CaddyfileEditor.razor.cs @@ -3,7 +3,6 @@ using BlazorMonaco.Editor; using CaddyManager.Contracts.Models.Caddy; using Microsoft.AspNetCore.Components; using MudBlazor; -using CaddyManager.Contracts.Docker; namespace CaddyManager.Components.Pages.Caddy.CaddyfileEditor; @@ -34,7 +33,6 @@ public partial class CaddyfileEditor : ComponentBase [Inject] private ICaddyService CaddyService { get; set; } = null!; [Inject] private ISnackbar Snackbar { get; set; } = null!; - [Inject] private IDockerService DockerService { get; set; } = null!; [Inject] private IDialogService DialogService { get; set; } = null!; protected override Task OnInitializedAsync() diff --git a/CaddyManager/Components/Pages/Caddy/CaddyfilePage.razor.cs b/CaddyManager/Components/Pages/Caddy/CaddyfilePage.razor.cs index ba59de7..3c97cc5 100644 --- a/CaddyManager/Components/Pages/Caddy/CaddyfilePage.razor.cs +++ b/CaddyManager/Components/Pages/Caddy/CaddyfilePage.razor.cs @@ -1,5 +1,6 @@ using BlazorMonaco.Editor; using CaddyManager.Contracts.Caddy; +using CaddyManager.Contracts.Docker; using Microsoft.AspNetCore.Components; using MudBlazor; @@ -30,6 +31,11 @@ public partial class CaddyfilePage : ComponentBase /// [Inject] private ISnackbar Snackbar { get; set; } = null!; + /// + /// Docker service used to have Caddy pick up the saved configuration + /// + [Inject] private IDockerService DockerService { get; set; } = null!; + /// /// Initializes the component /// @@ -68,14 +74,21 @@ public partial class CaddyfilePage : ComponentBase { var response = CaddyService.SaveCaddyGlobalConfiguration(await _codeEditor.GetValue()); - if (response.Success) - { - Snackbar.Add("Caddy configuration saved successfully", Severity.Success); - } - else + if (!response.Success) { Snackbar.Add("Failed to save Caddy configuration", Severity.Error); + return; } + + Snackbar.Add("Caddy configuration saved successfully", Severity.Success); + + var reloadResponse = await DockerService.ReloadCaddyContainerAsync(); + + Snackbar.Add( + reloadResponse.Success + ? "Caddy configuration reloaded successfully" + : $"Failed to reload Caddy: {reloadResponse.Message}", + reloadResponse.Success ? Severity.Success : Severity.Error); } /// diff --git a/README.md b/README.md index eca0654..99f008d 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,9 @@ services: ASPNETCORE_ENVIRONMENT: "Production" CaddyService__ConfigDir: "/config" DockerService__CaddyContainerName: "caddy" + # Path to the Caddyfile as seen from inside the caddy container. Must match the container side of the + # caddy container's config volume. Defaults to /etc/caddy/Caddyfile, so this line is optional above. + DockerService__CaddyConfigPathInContainer: "/etc/caddy/Caddyfile" # To have the access to the caddy config file user: "1000:1000" ports: @@ -179,6 +182,8 @@ Currently, the Caddy Manager is able to: - Edit the content of the Caddy configuration files by clicking on the file name - Create and manage the caddy files - Edit the global Caddy configuration file by using the tab "Global Cadddyfile" +- Apply configuration changes with a graceful `caddy reload` (no dropped connections, and Caddy validates the + configuration first, so a broken file is reported back instead of taking the proxy down) - Restart caddy container on demand - Parse simple information from the caddy configurations