Files
dnsmasq-webui/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs
T
alex d910688742 Add dnsmasq version awareness, readiness endpoint, and Docker healthcheck
- Options: VersionCommand, VersionTimeoutSeconds, MinimumVersion, EnforceMinimumVersion
- Version probe service and parser; startup enforcement when EnforceMinimumVersion
- Save flow: block save on probe failure (VersionProbeFailed) or version below minimum (UnsupportedVersion)
- Status API and Dnsmasq page: show version with tooltip, link to CHANGELOG
- Readiness: GET /healthz/ready (version + dnsmasq running when StatusCommand set)
- MapReadyHealthCheck extension; Dockerfile HEALTHCHECK + curl; compose healthcheck and service_healthy
- Docs: README and agents.md updated
2026-03-08 23:12:38 +10:00

220 lines
9.1 KiB
C#

using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
{
private readonly IDnsmasqConfigSetService _configSetService;
private readonly IDnsmasqConfigService _configService;
private readonly IConfigSetCache _configSetCache;
private readonly IConfigValidationService _validationService;
private readonly IReloadService _reloadService;
private readonly IDnsmasqVersionService _versionService;
private readonly ILogger<EffectiveConfigSaveService> _logger;
public EffectiveConfigSaveService(
IDnsmasqConfigSetService configSetService,
IDnsmasqConfigService configService,
IConfigSetCache configSetCache,
IConfigValidationService validationService,
IReloadService reloadService,
IDnsmasqVersionService versionService,
ILogger<EffectiveConfigSaveService> logger)
{
_configSetService = configSetService;
_configService = configService;
_configSetCache = configSetCache;
_validationService = validationService;
_reloadService = reloadService;
_versionService = versionService;
_logger = logger;
}
/// <inheritdoc />
public async Task<EffectiveConfigSaveResult> SaveAsync(
IReadOnlyList<PendingEffectiveConfigChange> changes,
CancellationToken ct = default)
{
if (changes.Count == 0)
return EffectiveConfigSaveResult.NoChanges();
var version = await _versionService.GetVersionInfoAsync(ct);
if (!version.ProbeSucceeded)
{
return new EffectiveConfigSaveResult(
BackupCreated: false,
BackupPath: null,
Saved: false,
Validated: false,
ValidationExitCode: -1,
ValidationStdOut: null,
ValidationStdErr: null,
Restarted: false,
RestartExitCode: -1,
RestartStdOut: null,
RestartStdErr: null,
ErrorCode: EffectiveConfigSaveResult.ErrorCodes.VersionProbeFailed,
UserMessage: string.IsNullOrWhiteSpace(version.Error)
? "Cannot save: dnsmasq version could not be determined."
: $"Cannot save: dnsmasq version could not be determined. {version.Error}");
}
if (!version.IsSupported)
{
return new EffectiveConfigSaveResult(
BackupCreated: false,
BackupPath: null,
Saved: false,
Validated: false,
ValidationExitCode: -1,
ValidationStdOut: null,
ValidationStdErr: null,
Restarted: false,
RestartExitCode: -1,
RestartStdOut: null,
RestartStdErr: null,
ErrorCode: EffectiveConfigSaveResult.ErrorCodes.UnsupportedVersion,
UserMessage: $"Installed dnsmasq {version.InstalledVersion} is below required {version.MinimumVersion}.");
}
var set = await _configSetService.GetConfigSetAsync(ct);
if (string.IsNullOrWhiteSpace(set.ManagedFilePath))
{
_logger.LogWarning("Save skipped: managed config path is not configured");
return new EffectiveConfigSaveResult(
false, null, false, false, -1, null, null, false, -1, null, null,
EffectiveConfigSaveResult.ErrorCodes.MissingManagedPath, "Managed config path is not configured.");
}
var managedPath = set.ManagedFilePath!;
var backupPath = BuildBackupPath(managedPath);
try
{
CreateBackupIfSourceExists(managedPath, backupPath);
await _configService.ApplyEffectiveConfigChangesAsync(changes, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to write effective config");
var backupExists = File.Exists(backupPath);
return new EffectiveConfigSaveResult(
backupExists,
backupExists ? backupPath : null,
false,
false,
-1,
null,
null,
false,
-1,
null,
ex.Message,
EffectiveConfigSaveResult.ErrorCodes.WriteFailed,
"Failed to write config.");
}
var backupCreated = File.Exists(backupPath);
var validateResult = await _validationService.ValidateAsync(ct);
if (!validateResult.Success)
{
_logger.LogWarning("Config saved but validation failed: exit {ExitCode}, stderr: {Stderr}", validateResult.ExitCode, validateResult.StdErr);
return new EffectiveConfigSaveResult(
BackupCreated: backupCreated,
BackupPath: backupCreated ? backupPath : null,
Saved: true,
Validated: false,
ValidationExitCode: validateResult.ExitCode,
ValidationStdOut: validateResult.StdOut,
ValidationStdErr: validateResult.StdErr,
Restarted: false,
RestartExitCode: -1,
RestartStdOut: null,
RestartStdErr: null,
ErrorCode: EffectiveConfigSaveResult.ErrorCodes.ValidateFailed,
UserMessage: "Saved, but validation failed. Restart was not attempted.");
}
var restartResult = await _reloadService.ReloadAsync(ct);
if (!restartResult.Success)
_logger.LogWarning("Config saved but restart command failed: exit {ExitCode}, stderr: {Stderr}", restartResult.ExitCode, restartResult.StdErr);
return new EffectiveConfigSaveResult(
BackupCreated: backupCreated,
BackupPath: backupCreated ? backupPath : null,
Saved: true,
Validated: true,
ValidationExitCode: 0,
ValidationStdOut: validateResult.StdOut,
ValidationStdErr: validateResult.StdErr,
Restarted: restartResult.Success,
RestartExitCode: restartResult.ExitCode,
RestartStdOut: restartResult.StdOut,
RestartStdErr: restartResult.StdErr,
ErrorCode: restartResult.Success ? null : EffectiveConfigSaveResult.ErrorCodes.RestartFailed,
UserMessage: restartResult.Success
? "Saved, validated, and dnsmasq restarted."
: "Saved and validated, but the restart command failed.");
}
/// <inheritdoc />
public async Task<EffectiveConfigRestoreResult> RestoreAsync(string backupPath, CancellationToken ct = default)
{
var set = await _configSetService.GetConfigSetAsync(ct);
if (string.IsNullOrWhiteSpace(set.ManagedFilePath))
{
_logger.LogWarning("Restore skipped: managed config path is not configured");
return new EffectiveConfigRestoreResult(
false, false, -1, null, "Managed config path is not configured.");
}
if (!File.Exists(backupPath))
{
_logger.LogWarning("Restore skipped: backup file not found: {Path}", backupPath);
return new EffectiveConfigRestoreResult(
false, false, -1, null, "Backup file not found.");
}
var managedPath = set.ManagedFilePath!;
File.Copy(backupPath, managedPath, overwrite: true);
_configSetCache.Invalidate();
_logger.LogInformation("Restored managed config from backup: {BackupPath}", backupPath);
var restartResult = await _reloadService.ReloadAsync(ct);
if (!restartResult.Success)
_logger.LogWarning("Restore completed but restart command failed: exit {ExitCode}, stderr: {Stderr}", restartResult.ExitCode, restartResult.StdErr);
return new EffectiveConfigRestoreResult(
Restored: true,
Restarted: restartResult.Success,
RestartExitCode: restartResult.ExitCode,
RestartStdErr: restartResult.StdErr,
UserMessage: restartResult.Success
? "Backup restored and dnsmasq restarted."
: "Backup restored, but the restart command still failed.");
}
private static string BuildBackupPath(string managedPath) =>
$"{managedPath}.bak.{DateTime.UtcNow:yyyyMMdd-HHmmss}";
private static void CreateBackupIfSourceExists(string managedPath, string backupPath)
{
if (!File.Exists(managedPath))
return;
var dir = Path.GetDirectoryName(backupPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.Copy(managedPath, backupPath, overwrite: false);
}
}