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:
@@ -17,6 +17,12 @@ public class DockerServiceConfiguration
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string DockerHost { get; set; } = "unix:///var/run/docker.sock";
|
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>
|
/// <summary>
|
||||||
/// Returns the Docker host with environment check. If the environment variable DOCKER_HOST is set, it will return
|
/// 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
|
/// that value, otherwise it will return the value of DockerHost
|
||||||
|
|||||||
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>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
Task RestartCaddyContainerAsync();
|
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
|
/// Method to get the container id of the Caddy container by the name configured
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <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
|
var containers = await client.Containers.ListContainersAsync(new ContainersListParameters
|
||||||
{
|
{
|
||||||
All = true
|
All = true
|
||||||
@@ -31,18 +27,74 @@ public class DockerService(IConfigurationsService configurationsService) : IDock
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private IDockerClient CreateClient() =>
|
||||||
|
new DockerClientConfiguration(new Uri(Configuration.DockerHostWithEnvCheck)).CreateClient();
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task RestartCaddyContainerAsync()
|
public async Task RestartCaddyContainerAsync()
|
||||||
{
|
{
|
||||||
var containerId = await GetCaddyContainerId();
|
using var client = CreateClient();
|
||||||
|
|
||||||
|
var containerId = await GetCaddyContainerId(client);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(containerId)) return;
|
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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using CaddyManager.Contracts.Configurations.Docker;
|
using CaddyManager.Contracts.Configurations.Docker;
|
||||||
using CaddyManager.Contracts.Configurations;
|
using CaddyManager.Contracts.Configurations;
|
||||||
|
using CaddyManager.Contracts.Docker;
|
||||||
using CaddyManager.Services.Docker;
|
using CaddyManager.Services.Docker;
|
||||||
|
|
||||||
namespace CaddyManager.Tests.Services.Docker;
|
namespace CaddyManager.Tests.Services.Docker;
|
||||||
@@ -161,6 +162,63 @@ public class DockerServiceTests
|
|||||||
// Assert
|
// Assert
|
||||||
config.CaddyContainerName.Should().Be("caddy");
|
config.CaddyContainerName.Should().Be("caddy");
|
||||||
config.DockerHost.Should().Be("unix:///var/run/docker.sock");
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
@bind-SelectedValues="_selectedCaddyConfigurations">
|
@bind-SelectedValues="_selectedCaddyConfigurations">
|
||||||
@foreach (var (index, caddyConfig) in _availableCaddyConfigurations.Index())
|
@foreach (var (index, caddyConfig) in _availableCaddyConfigurations.Index())
|
||||||
{
|
{
|
||||||
<CaddyReverseProxyItem ConfigurationInfo="@caddyConfig" OnCaddyRestartRequired="@RestartCaddy"
|
<CaddyReverseProxyItem ConfigurationInfo="@caddyConfig" OnCaddyRestartRequired="@ReloadCaddy"
|
||||||
OnCaddyfileDuplicateRequested="@HandleDuplicateRequest" />
|
OnCaddyfileDuplicateRequested="@HandleDuplicateRequest" />
|
||||||
|
|
||||||
@if (index < _availableCaddyConfigurations.Count - 1)
|
@if (index < _availableCaddyConfigurations.Count - 1)
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ public partial class CaddyReverseProxiesPage : ComponentBase
|
|||||||
|
|
||||||
if (result is { Data: bool, Canceled: false } && (bool)result.Data)
|
if (result is { Data: bool, Canceled: false } && (bool)result.Data)
|
||||||
{
|
{
|
||||||
await RestartCaddy();
|
await ReloadCaddy();
|
||||||
}
|
}
|
||||||
|
|
||||||
Refresh();
|
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>
|
/// <summary>
|
||||||
/// Restart the Caddy container
|
/// Restart the Caddy container
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using BlazorMonaco.Editor;
|
|||||||
using CaddyManager.Contracts.Models.Caddy;
|
using CaddyManager.Contracts.Models.Caddy;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using MudBlazor;
|
using MudBlazor;
|
||||||
using CaddyManager.Contracts.Docker;
|
|
||||||
|
|
||||||
namespace CaddyManager.Components.Pages.Caddy.CaddyfileEditor;
|
namespace CaddyManager.Components.Pages.Caddy.CaddyfileEditor;
|
||||||
|
|
||||||
@@ -34,7 +33,6 @@ public partial class CaddyfileEditor : ComponentBase
|
|||||||
[Inject] private ICaddyService CaddyService { get; set; } = null!;
|
[Inject] private ICaddyService CaddyService { get; set; } = null!;
|
||||||
|
|
||||||
[Inject] private ISnackbar Snackbar { get; set; } = null!;
|
[Inject] private ISnackbar Snackbar { get; set; } = null!;
|
||||||
[Inject] private IDockerService DockerService { get; set; } = null!;
|
|
||||||
[Inject] private IDialogService DialogService { get; set; } = null!;
|
[Inject] private IDialogService DialogService { get; set; } = null!;
|
||||||
|
|
||||||
protected override Task OnInitializedAsync()
|
protected override Task OnInitializedAsync()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using BlazorMonaco.Editor;
|
using BlazorMonaco.Editor;
|
||||||
using CaddyManager.Contracts.Caddy;
|
using CaddyManager.Contracts.Caddy;
|
||||||
|
using CaddyManager.Contracts.Docker;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using MudBlazor;
|
using MudBlazor;
|
||||||
|
|
||||||
@@ -30,6 +31,11 @@ public partial class CaddyfilePage : ComponentBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[Inject] private ISnackbar Snackbar { get; set; } = null!;
|
[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>
|
/// <summary>
|
||||||
/// Initializes the component
|
/// Initializes the component
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -68,14 +74,21 @@ public partial class CaddyfilePage : ComponentBase
|
|||||||
{
|
{
|
||||||
var response = CaddyService.SaveCaddyGlobalConfiguration(await _codeEditor.GetValue());
|
var response = CaddyService.SaveCaddyGlobalConfiguration(await _codeEditor.GetValue());
|
||||||
|
|
||||||
if (response.Success)
|
if (!response.Success)
|
||||||
{
|
|
||||||
Snackbar.Add("Caddy configuration saved successfully", Severity.Success);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
Snackbar.Add("Failed to save Caddy configuration", Severity.Error);
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -158,6 +158,9 @@ services:
|
|||||||
ASPNETCORE_ENVIRONMENT: "Production"
|
ASPNETCORE_ENVIRONMENT: "Production"
|
||||||
CaddyService__ConfigDir: "/config"
|
CaddyService__ConfigDir: "/config"
|
||||||
DockerService__CaddyContainerName: "caddy"
|
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
|
# To have the access to the caddy config file
|
||||||
user: "1000:1000"
|
user: "1000:1000"
|
||||||
ports:
|
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
|
- Edit the content of the Caddy configuration files by clicking on the file name
|
||||||
- Create and manage the caddy files
|
- Create and manage the caddy files
|
||||||
- Edit the global Caddy configuration file by using the tab "Global Cadddyfile"
|
- 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
|
- Restart caddy container on demand
|
||||||
- Parse simple information from the caddy configurations
|
- Parse simple information from the caddy configurations
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user