feat(docker): Gracefully reload Caddy configuration
All checks were successful
Caddy Manager CI build / docker (push) Successful in 11m33s
All checks were successful
Caddy Manager CI build / docker (push) Successful in 11m33s
This commit is contained in:
@@ -16,6 +16,12 @@ public class DockerServiceConfiguration
|
||||
/// Uri to the Docker host
|
||||
/// </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
|
||||
|
||||
17
CaddyManager.Contracts/Docker/CaddyReloadResponse.cs
Normal file
17
CaddyManager.Contracts/Docker/CaddyReloadResponse.cs
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -30,19 +26,75 @@ public class DockerService(IConfigurationsService configurationsService) : IDock
|
||||
?.ID ?? string.Empty;
|
||||
|
||||
}
|
||||
|
||||
|
||||
private IDockerClient CreateClient() =>
|
||||
new DockerClientConfiguration(new Uri(Configuration.DockerHostWithEnvCheck)).CreateClient();
|
||||
|
||||
/// <inheritdoc />
|
||||
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());
|
||||
}
|
||||
|
||||
/// <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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user