feat(docker): Gracefully reload Caddy configuration
All checks were successful
Caddy Manager CI build / docker (push) Successful in 11m33s

This commit is contained in:
2026-07-26 11:39:59 +07:00
parent 284036b549
commit d0687c9134
10 changed files with 200 additions and 22 deletions

View File

@@ -17,6 +17,12 @@ public class DockerServiceConfiguration
/// </summary>
public string DockerHost { get; set; } = "unix:///var/run/docker.sock";
/// <summary>
/// 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.
/// </summary>
public string CaddyConfigPathInContainer { get; set; } = "/etc/caddy/Caddyfile";
/// <summary>
/// Returns the Docker host with environment check. If the environment variable DOCKER_HOST is set, it will return
/// that value, otherwise it will return the value of DockerHost

View File

@@ -0,0 +1,17 @@
namespace CaddyManager.Contracts.Docker;
/// <summary>
/// Result of a graceful Caddy configuration reload
/// </summary>
public class CaddyReloadResponse
{
/// <summary>
/// Indicates whether Caddy accepted and applied the configuration
/// </summary>
public bool Success { get; set; }
/// <summary>
/// Output from Caddy when the reload failed, typically the configuration validation error
/// </summary>
public string Message { get; set; } = string.Empty;
}

View File

@@ -10,4 +10,11 @@ public interface IDockerService
/// </summary>
/// <returns></returns>
Task RestartCaddyContainerAsync();
/// <summary>
/// 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.
/// </summary>
/// <returns>Whether the reload succeeded, along with Caddy's output when it did not</returns>
Task<CaddyReloadResponse> ReloadCaddyContainerAsync();
}

View File

@@ -15,12 +15,8 @@ public class DockerService(IConfigurationsService configurationsService) : IDock
/// Method to get the container id of the Caddy container by the name configured
/// </summary>
/// <returns></returns>
private async Task<string> GetCaddyContainerId()
private async Task<string> 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
@@ -31,18 +27,74 @@ public class DockerService(IConfigurationsService configurationsService) : IDock
}
private IDockerClient CreateClient() =>
new DockerClientConfiguration(new Uri(Configuration.DockerHostWithEnvCheck)).CreateClient();
/// <inheritdoc />
public async Task RestartCaddyContainerAsync()
{
var containerId = await GetCaddyContainerId();
using var client = CreateClient();
var containerId = await GetCaddyContainerId(client);
if (string.IsNullOrEmpty(containerId)) return;
var client = new DockerClientConfiguration(new Uri(Configuration.DockerHostWithEnvCheck)).CreateClient();
await client.Containers.RestartContainerAsync(containerId, new ContainerRestartParameters());
}
if (client != null)
/// <inheritdoc />
public async Task<CaddyReloadResponse> 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 };
}
}
}

View File

@@ -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");
}
/// <summary>
/// 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 <see cref="CaddyReloadResponse"/> carrying an explanatory
/// message, so the UI can surface the problem rather than the caller having to handle exceptions.
/// </summary>
[Fact]
public async Task ReloadCaddyContainerAsync_WithNonExistentContainer_ReturnsFailureResponse()
{
// Arrange
_mockConfigurationsService
.Setup(x => x.Get<DockerServiceConfiguration>())
.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();
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task ReloadCaddyContainerAsync_WithUnreachableDockerHost_ReturnsFailureResponse()
{
// Arrange
_mockConfigurationsService
.Setup(x => x.Get<DockerServiceConfiguration>())
.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();
}
/// <summary>

View File

@@ -32,7 +32,7 @@
@bind-SelectedValues="_selectedCaddyConfigurations">
@foreach (var (index, caddyConfig) in _availableCaddyConfigurations.Index())
{
<CaddyReverseProxyItem ConfigurationInfo="@caddyConfig" OnCaddyRestartRequired="@RestartCaddy"
<CaddyReverseProxyItem ConfigurationInfo="@caddyConfig" OnCaddyRestartRequired="@ReloadCaddy"
OnCaddyfileDuplicateRequested="@HandleDuplicateRequest" />
@if (index < _availableCaddyConfigurations.Count - 1)

View File

@@ -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
});
}
/// <summary>
/// Gracefully reload the Caddy configuration without bouncing the container
/// </summary>
/// <returns></returns>
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();
}
/// <summary>
/// Restart the Caddy container
/// </summary>

View File

@@ -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()

View File

@@ -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
/// </summary>
[Inject] private ISnackbar Snackbar { get; set; } = null!;
/// <summary>
/// Docker service used to have Caddy pick up the saved configuration
/// </summary>
[Inject] private IDockerService DockerService { get; set; } = null!;
/// <summary>
/// Initializes the component
/// </summary>
@@ -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);
}
/// <summary>

View File

@@ -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