diff --git a/Directory.Build.props b/Directory.Build.props index 9313f9e..1a616e6 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,6 +14,6 @@ UiSchemaBuilder, …). Published from the nks-ws monorepo's publish-sdk.yml workflow on each v* tag to nuget.pkg.github.com/nks-hub. --> - + diff --git a/NKS.WebDevConsole.Plugin.LocalRsync/LocalRsyncBackend.cs b/NKS.WebDevConsole.Plugin.LocalRsync/LocalRsyncBackend.cs new file mode 100644 index 0000000..caecf41 --- /dev/null +++ b/NKS.WebDevConsole.Plugin.LocalRsync/LocalRsyncBackend.cs @@ -0,0 +1,234 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text.Json; +using CliWrap; +using CliWrap.Buffered; +using Microsoft.Extensions.Logging; +using NKS.WebDevConsole.Core.Interfaces; +using NKS.WebDevConsole.Plugin.SDK.Deploy; + +namespace NKS.WebDevConsole.Plugin.LocalRsync; + +/// +/// Minimal IDeployBackend that publishes the site directory to a local +/// filesystem path via rsync. Intentionally narrow — exists to prove that +/// the IDeployBackend contract isn't accidentally tied to nksdeploy's +/// release-directory + symlink-switch model. A future Capistrano / Kamal +/// backend would slot in the same way. +/// +/// Activation: site's deploy.neon must contain a top-level +/// backend: localrsync key AND a localrsync.target path. The +/// CanDeploy probe parses just enough NEON (line-grep — no schema) to +/// detect those keys; full parsing happens at deploy time. +/// +/// Mechanism: rsync -av --delete {siteRoot}/ {target}/. No release +/// directories, no atomic switch — the target is updated in-place. There +/// is no rollback story (rsync doesn't keep a previous copy). RollbackAsync +/// returns NotSupportedException, exercising the "backend doesn't support +/// every operation" case the wdc UI must handle gracefully. +/// +public sealed class LocalRsyncBackend : IDeployBackend +{ + private readonly ISiteRegistry _siteRegistry; + private readonly IDeployRunsRepository _runs; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _active = new(); + + public LocalRsyncBackend( + ISiteRegistry siteRegistry, + IDeployRunsRepository runs, + ILogger logger) + { + _siteRegistry = siteRegistry; + _runs = runs; + _logger = logger; + } + + public string BackendId => "local-rsync"; + + public bool CanDeploy(string domain) + { + var site = _siteRegistry.GetSite(domain); + if (site is null) return false; + + var configPath = Path.Combine(site.DocumentRoot, "deploy.neon"); + if (!File.Exists(configPath)) + { + // Try parent of DocumentRoot too (Nette projects have www/ inside src tree). + configPath = Path.Combine(Directory.GetParent(site.DocumentRoot)?.FullName ?? "", "deploy.neon"); + if (!File.Exists(configPath)) return false; + } + + // Cheap line-grep probe — full NEON parsing happens during deploy. + // This is intentionally fragile for activation (typos in deploy.neon + // mean we just don't claim the deploy, falling through to nks-deploy). + try + { + var lines = File.ReadAllLines(configPath); + return lines.Any(l => l.TrimStart().StartsWith("backend:") && l.Contains("localrsync")); + } + catch { return false; } + } + + public async Task StartDeployAsync( + DeployRequest request, + IProgress progress, + CancellationToken ct) + { + var site = _siteRegistry.GetSite(request.Domain) + ?? throw new InvalidOperationException($"Unknown site: {request.Domain}"); + + var siteRoot = ResolveSiteRoot(site.DocumentRoot); + var target = ParseTargetOption(request.BackendOptions) + ?? throw new InvalidOperationException("localrsync requires `target` in backendOptions"); + + var deployId = Guid.NewGuid().ToString(); + var startedAt = DateTimeOffset.UtcNow; + await _runs.InsertAsync(new DeployRunRow( + Id: deployId, + Domain: request.Domain, + Host: request.Host, + ReleaseId: null, + Branch: null, + CommitSha: null, + Status: "running", + IsPastPonr: false, + StartedAt: startedAt, + CompletedAt: null, + ExitCode: null, + ErrorMessage: null, + DurationMs: null, + TriggeredBy: request.TriggeredBy, + BackendId: BackendId, + CreatedAt: startedAt, + UpdatedAt: startedAt + ), ct); + + var deployCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _active[deployId] = deployCts; + + var sw = Stopwatch.StartNew(); + var success = false; + int? exitCode = null; + string? errorMessage = null; + + progress.Report(new DeployEvent( + DeployId: deployId, Phase: DeployPhase.Building, Step: "rsync_start", + Message: $"rsync -av {siteRoot}/ {target}/", Timestamp: DateTimeOffset.UtcNow, + IsTerminal: false, IsPastPonr: false)); + + try + { + // rsync trailing-slash semantics: source/ → target/ copies CONTENTS. + // --delete removes files that no longer exist in source (the + // "this is a publish, not an append" semantic). + var args = new[] { "-av", "--delete", siteRoot.TrimEnd('/') + "/", target.TrimEnd('/') + "/" }; + var cmd = await Cli.Wrap("rsync") + .WithArguments(args) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(deployCts.Token); + + exitCode = cmd.ExitCode; + success = cmd.ExitCode == 0; + if (!success) + { + errorMessage = $"rsync exit code {cmd.ExitCode}: {cmd.StandardError}"; + } + } + catch (OperationCanceledException) when (deployCts.IsCancellationRequested) + { + errorMessage = "deploy cancelled"; + await _runs.UpdateStatusAsync(deployId, "cancelled", CancellationToken.None); + throw; + } + catch (Exception ex) + { + errorMessage = ex.Message; + _logger.LogError(ex, "Local rsync deploy {DeployId} threw", deployId); + } + finally + { + sw.Stop(); + _active.TryRemove(deployId, out _); + await _runs.MarkCompletedAsync(deployId, success, exitCode, errorMessage, sw.ElapsedMilliseconds, CancellationToken.None); + deployCts.Dispose(); + } + + progress.Report(new DeployEvent( + DeployId: deployId, + Phase: success ? DeployPhase.Done : DeployPhase.Failed, + Step: "deploy_complete", + Message: errorMessage ?? "rsync completed", + Timestamp: DateTimeOffset.UtcNow, + IsTerminal: true, + IsPastPonr: false)); + + return deployId; + } + + public async Task GetStatusAsync(string deployId, CancellationToken ct) + { + var row = await _runs.GetByIdAsync(deployId, ct) + ?? throw new KeyNotFoundException($"Unknown deploy id: {deployId}"); + return new DeployResult( + DeployId: row.Id, + Success: row.Status == "completed", + ErrorMessage: row.ErrorMessage, + StartedAt: row.StartedAt, + CompletedAt: row.CompletedAt, + ReleaseId: null, + CommitSha: null, + FinalPhase: row.Status == "completed" ? DeployPhase.Done : DeployPhase.Failed); + } + + public async Task> GetHistoryAsync(string domain, int limit, CancellationToken ct) + { + var rows = await _runs.ListForDomainAsync(domain, limit, ct); + return rows + .Where(r => r.BackendId == BackendId) + .Select(r => new DeployHistoryEntry( + DeployId: r.Id, Domain: r.Domain, Host: r.Host, + Branch: r.Branch ?? "", FinalPhase: MapStatusToPhase(r.Status), + StartedAt: r.StartedAt, CompletedAt: r.CompletedAt, + CommitSha: null, ReleaseId: null, Error: r.ErrorMessage)) + .ToList(); + } + + public Task RollbackAsync(string deployId, CancellationToken ct) => + // rsync overwrites in place — no "previous release" to roll back to. + // Throwing NotSupportedException exercises the wdc UI path that must + // gracefully handle backends without rollback capability. + throw new NotSupportedException("local-rsync backend has no rollback (in-place sync, no history kept)"); + + public Task CancelAsync(string deployId, CancellationToken ct) + { + if (_active.TryGetValue(deployId, out var cts)) + { + cts.Cancel(); + return Task.CompletedTask; + } + throw new InvalidOperationException($"No active deploy with id {deployId}"); + } + + private static string ResolveSiteRoot(string documentRoot) + { + var parent = Directory.GetParent(documentRoot)?.FullName; + if (parent is not null && File.Exists(Path.Combine(parent, "deploy.neon"))) return parent; + return documentRoot; + } + + private static string? ParseTargetOption(JsonElement opts) + { + if (opts.ValueKind != JsonValueKind.Object) return null; + if (!opts.TryGetProperty("target", out var t)) return null; + return t.ValueKind == JsonValueKind.String ? t.GetString() : null; + } + + private static DeployPhase MapStatusToPhase(string status) => status switch + { + "completed" => DeployPhase.Done, + "cancelled" => DeployPhase.Cancelled, + "running" => DeployPhase.Building, + _ => DeployPhase.Failed, + }; +} diff --git a/NKS.WebDevConsole.Plugin.LocalRsync/LocalRsyncPlugin.cs b/NKS.WebDevConsole.Plugin.LocalRsync/LocalRsyncPlugin.cs new file mode 100644 index 0000000..339f243 --- /dev/null +++ b/NKS.WebDevConsole.Plugin.LocalRsync/LocalRsyncPlugin.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.DependencyInjection; +using NKS.WebDevConsole.Core.Interfaces; +using NKS.WebDevConsole.Plugin.SDK; +using NKS.WebDevConsole.Plugin.SDK.Deploy; + +namespace NKS.WebDevConsole.Plugin.LocalRsync; + +/// +/// Plugin entry-point for the local-rsync deploy backend. Registers +/// as a SECOND IDeployBackend implementation +/// alongside NksDeployBackend. The daemon picks among multiple +/// registered backends by calling CanDeploy(domain) and selecting +/// the first that returns true — local-rsync only matches when the site's +/// deploy.neon explicitly opts in via a backend: localrsync key, so +/// it will not steal deploys away from NksDeploy. +/// +public sealed class LocalRsyncPlugin : PluginBase +{ + public override string Id => "nks.wdc.deploy.localrsync"; + public override string DisplayName => "Local rsync deploy"; + public override string Version => "0.1.0"; + + public string Description => + "IDeployBackend implementation that wraps `rsync -av {site_root} {target_path}`. " + + "Lives alongside NksDeploy as proof that IDeployBackend isn't tied to nksdeploy."; + + public override void Initialize(IServiceCollection services, IPluginContext context) + { + // Register as IDeployBackend AS WELL as NksDeployBackend. The host's + // DI container will resolve IEnumerable for the + // selector; resolving as a single IDeployBackend would only return + // the LAST registration. The daemon's deploy router enumerates and + // delegates per-domain via CanDeploy. + services.AddSingleton(); + } +} diff --git a/NKS.WebDevConsole.Plugin.LocalRsync/NKS.WebDevConsole.Plugin.LocalRsync.csproj b/NKS.WebDevConsole.Plugin.LocalRsync/NKS.WebDevConsole.Plugin.LocalRsync.csproj new file mode 100644 index 0000000..2aad0d6 --- /dev/null +++ b/NKS.WebDevConsole.Plugin.LocalRsync/NKS.WebDevConsole.Plugin.LocalRsync.csproj @@ -0,0 +1,28 @@ + + + net9.0 + enable + enable + NKS.WebDevConsole.Plugin.LocalRsync + NKS.WebDevConsole.Plugin.LocalRsync + true + + + + + + + + + + + + + + + PreserveNewest + + + diff --git a/NKS.WebDevConsole.Plugin.LocalRsync/plugin.json b/NKS.WebDevConsole.Plugin.LocalRsync/plugin.json new file mode 100644 index 0000000..ae544b6 --- /dev/null +++ b/NKS.WebDevConsole.Plugin.LocalRsync/plugin.json @@ -0,0 +1,15 @@ +{ + "id": "nks.wdc.deploy.localrsync", + "displayName": "Local rsync deploy", + "version": "0.1.0", + "description": "IDeployBackend that publishes the site directory to a local target via rsync. Exists to validate that the IDeployBackend abstraction is not shaped inside-out around nksdeploy's vocabulary — this backend has no PHP runtime, no SSH, no remote .deploy-lock, and no symlink-switch step. Useful for previews, dry-runs, and as a template for future backends (Capistrano, Kamal, custom).", + "author": "NKS", + "license": "MIT", + "entryAssembly": "NKS.WebDevConsole.Plugin.LocalRsync.dll", + "entryType": "NKS.WebDevConsole.Plugin.LocalRsync.LocalRsyncPlugin", + "serviceType": "Other", + "supportedPlatforms": ["macos", "linux"], + "minDaemonVersion": "1.0.0", + "dependencies": [], + "capabilities": ["deploy-backend"] +} diff --git a/NKS.WebDevConsole.Plugin.NksDeploy/NKS.WebDevConsole.Plugin.NksDeploy.csproj b/NKS.WebDevConsole.Plugin.NksDeploy/NKS.WebDevConsole.Plugin.NksDeploy.csproj new file mode 100644 index 0000000..e3dbee2 --- /dev/null +++ b/NKS.WebDevConsole.Plugin.NksDeploy/NKS.WebDevConsole.Plugin.NksDeploy.csproj @@ -0,0 +1,42 @@ + + + net9.0 + enable + enable + NKS.WebDevConsole.Plugin.NksDeploy + NKS.WebDevConsole.Plugin.NksDeploy + true + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + diff --git a/NKS.WebDevConsole.Plugin.NksDeploy/NksDeployBackend.cs b/NKS.WebDevConsole.Plugin.NksDeploy/NksDeployBackend.cs new file mode 100644 index 0000000..370938b --- /dev/null +++ b/NKS.WebDevConsole.Plugin.NksDeploy/NksDeployBackend.cs @@ -0,0 +1,997 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text.Json; +using CliWrap; +using CliWrap.Buffered; +using CliWrap.EventStream; +using Microsoft.Extensions.Logging; +using NKS.WebDevConsole.Core.Interfaces; +using NKS.WebDevConsole.Core.Services; +using NKS.WebDevConsole.Plugin.SDK.Deploy; + +namespace NKS.WebDevConsole.Plugin.NksDeploy; + +/// +/// IDeployBackend implementation that drives the bundled nksdeploy PHP CLI +/// via CliWrap. v0.2 ships a real ; the other +/// methods still throw NotImplementedException pending follow-up commits. +/// +/// Lifecycle: +/// 1. Mint a UUID DeployId, INSERT a deploy_runs row (status='running'). +/// 2. Resolve PHP binary (site-pinned via WdcPaths.BinariesRoot first, +/// then PATH). +/// 3. Resolve nksdeploy.phar (bundled with this plugin, then PATH). +/// 4. Spawn `php nksdeploy.phar deploy {host} --ndjson-events --format=json +/// -c {site_root}/deploy.neon`. Working dir = site root so the PHP +/// hook security guard's project-root check sees the right path. +/// 5. Stream stdout via CliWrap event stream; parse each line as NDJSON, +/// forward through IProgress as a DeployEvent. The terminal envelope +/// (event=deploy_complete) finalises the run. +/// 6. UPDATE deploy_runs to 'completed' / 'failed' on subprocess exit. +/// +/// In-flight subprocess handles live in keyed by +/// DeployId so can SIGTERM them. +/// +public sealed class NksDeployBackend : IDeployBackend +{ + private readonly ISiteRegistry _siteRegistry; + private readonly IDeployRunsRepository _runs; + private readonly IPreDeploySnapshotter? _snapshotter; + private readonly IDeployEventBroadcaster? _events; + private readonly ILogger _logger; + + /// + /// In-flight subprocess registry. Keyed by DeployId, value carries both + /// the linked CTS (for graceful cancel) and the subprocess PID (for + /// the watchdog force-kill path when the PHP child ignores the SIGTERM + /// CliWrap sends on token cancellation). PID is set the moment CliWrap + /// emits its StartedCommandEvent. + /// + private readonly ConcurrentDictionary _active = new(); + + /// Mutable so the deploy task can stamp the PID after spawn. + private sealed class ActiveDeploy + { + public ActiveDeploy(CancellationTokenSource cts) { Cts = cts; } + public CancellationTokenSource Cts { get; } + public int? Pid { get; set; } + } + + public NksDeployBackend( + ISiteRegistry siteRegistry, + IDeployRunsRepository runs, + ILogger logger, + IPreDeploySnapshotter? snapshotter = null, + IDeployEventBroadcaster? events = null) + { + _siteRegistry = siteRegistry; + _runs = runs; + _logger = logger; + // Phase C-2 (#109-C2) — broadcaster is optional so older daemons + // without the SSE bridge still resolve this backend cleanly. When + // present, deploy:hook events flow into the GUI's deploy drawer + // for parity with daemon's LocalDeployBackend. + _events = events; + // Snapshotter is optional — older daemons without Phase 6.2 wiring + // still resolve this backend without binding break. When null, + // request.Snapshot is silently ignored (logged at debug). + _snapshotter = snapshotter; + } + + public string BackendId => "nks-deploy"; + + public bool CanDeploy(string domain) + { + var site = _siteRegistry.GetSite(domain); + if (site is null) return false; + + // Site is deployable when its root holds a deploy.neon. We check both + // the parent of DocumentRoot (typical: /sites/myapp/www → /sites/myapp) + // and DocumentRoot itself (Nette projects put www/ inside src tree). + var siteRoot = ResolveSiteRoot(site.DocumentRoot); + return File.Exists(Path.Combine(siteRoot, "deploy.neon")); + } + + public async Task StartDeployAsync( + DeployRequest request, + IProgress progress, + CancellationToken ct) + { + var site = _siteRegistry.GetSite(request.Domain) + ?? throw new InvalidOperationException($"Unknown site: {request.Domain}"); + + var deployId = Guid.NewGuid().ToString(); + var siteRoot = ResolveSiteRoot(site.DocumentRoot); + var configPath = Path.Combine(siteRoot, "deploy.neon"); + if (!File.Exists(configPath)) + { + throw new FileNotFoundException($"deploy.neon not found at {configPath}", configPath); + } + + var php = ResolvePhpBinary(site.PhpVersion); + var phar = ResolveNksDeployPhar(); + + var startedAt = DateTimeOffset.UtcNow; + await _runs.InsertAsync(new DeployRunRow( + Id: deployId, + Domain: request.Domain, + Host: request.Host, + ReleaseId: null, + Branch: ParseBranchOption(request.BackendOptions), + CommitSha: null, + Status: "running", + IsPastPonr: false, + StartedAt: startedAt, + CompletedAt: null, + ExitCode: null, + ErrorMessage: null, + DurationMs: null, + TriggeredBy: request.TriggeredBy, + BackendId: BackendId, + CreatedAt: startedAt, + UpdatedAt: startedAt + ), ct); + + // Phase 6.2 — pre-deploy DB snapshot. Runs BEFORE we spawn the + // PHP subprocess so a snapshot failure aborts the deploy with + // zero side effects. Best-effort skip when no snapshotter is + // wired (older daemons) or Snapshot.Include is false. + if (request.Snapshot is { Include: true } && _snapshotter is not null) + { + progress.Report(new DeployEvent( + DeployId: deployId, + Phase: DeployPhase.PreflightChecks, + Step: "pre_deploy_backup", + Message: "Snapshotting database before deploy", + Timestamp: DateTimeOffset.UtcNow, + IsTerminal: false, + IsPastPonr: false)); + try + { + var snap = await _snapshotter.CreateAsync(request.Domain, deployId, ct); + await _runs.UpdatePreDeployBackupAsync(deployId, snap.Path, snap.SizeBytes, ct); + progress.Report(new DeployEvent( + DeployId: deployId, + Phase: DeployPhase.PreflightChecks, + Step: "pre_deploy_backup", + Message: $"Snapshot ok: {snap.Path} ({snap.SizeBytes} bytes, {snap.Duration.TotalMilliseconds:F0} ms)", + Timestamp: DateTimeOffset.UtcNow, + IsTerminal: false, + IsPastPonr: false)); + } + catch (Exception snapEx) + { + _logger.LogError(snapEx, "[{DeployId}] pre-deploy snapshot FAILED — aborting deploy", deployId); + await _runs.MarkCompletedAsync(deployId, + success: false, exitCode: null, + errorMessage: $"pre_deploy_backup_failed: {snapEx.Message}", + durationMs: 0, ct); + progress.Report(new DeployEvent( + DeployId: deployId, + Phase: DeployPhase.Failed, + Step: "pre_deploy_backup", + Message: $"Snapshot failed: {snapEx.Message}", + Timestamp: DateTimeOffset.UtcNow, + IsTerminal: true, + IsPastPonr: false)); + throw; + } + } + + // Per-deploy CTS so CancelAsync can signal without affecting the + // shared CT supplied by the caller. + var deployCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + var active = new ActiveDeploy(deployCts); + _active[deployId] = active; + + var sw = Stopwatch.StartNew(); + var success = false; + int? exitCode = null; + string? errorMessage = null; + + try + { + // -d flags MUST precede the script path so PHP applies them + // before parsing the phar. Suppresses any startup notices / + // warnings the host PHP install might emit (deprecated INI + // entries, missing extensions, php.ini paths) — those would + // otherwise leak into the NDJSON pipeline as non-JSON lines. + var args = new List + { + "-d", "display_errors=0", + "-d", "error_reporting=0", + "-d", "log_errors=0", + phar, + "deploy", + request.Host, + "-c", configPath, + "--ndjson-events", + "--format=json", + }; + var branch = ParseBranchOption(request.BackendOptions); + if (!string.IsNullOrEmpty(branch)) + { + args.Add("--branch"); + args.Add(branch); + } + + var cmd = Cli.Wrap(php) + .WithArguments(args) + .WithWorkingDirectory(siteRoot) + .WithValidation(CommandResultValidation.None); + + await foreach (var ev in cmd.ListenAsync(deployCts.Token)) + { + switch (ev) + { + case StartedCommandEvent started: + // Stamp the PID into the active registry as soon as + // CliWrap reports it — the watchdog in CancelAsync + // needs this to escalate from CTS-cancel to a real + // OS-level kill if the PHP child ignores SIGTERM. + active.Pid = started.ProcessId; + break; + case StandardOutputCommandEvent stdout: + // Per-line handler failures must not abort the + // event-stream loop — that would orphan the PHP + // subprocess (no exit code processed) and the + // user would see a permanently-running deploy. + try + { + await HandleStdoutLineAsync(deployId, request.Domain, stdout.Text, progress, deployCts.Token); + } + catch (OperationCanceledException) when (deployCts.IsCancellationRequested) + { + throw; // genuine cancel — let the outer catch handle + } + catch (Exception lineEx) + { + _logger.LogWarning(lineEx, + "[{DeployId}] stdout line handler threw; continuing pipeline", deployId); + } + break; + case StandardErrorCommandEvent stderr when !string.IsNullOrWhiteSpace(stderr.Text): + _logger.LogDebug("[{DeployId}] stderr: {Line}", deployId, stderr.Text); + break; + case ExitedCommandEvent exited: + exitCode = exited.ExitCode; + success = exited.ExitCode == 0; + if (!success) + { + errorMessage = $"nksdeploy exit code {exited.ExitCode}"; + } + break; + } + } + } + catch (OperationCanceledException) when (deployCts.IsCancellationRequested) + { + errorMessage = "deploy cancelled"; + await _runs.UpdateStatusAsync(deployId, "cancelled", CancellationToken.None); + throw; + } + catch (Exception ex) + { + errorMessage = ex.Message; + _logger.LogError(ex, "Deploy {DeployId} threw: {Msg}", deployId, ex.Message); + } + finally + { + sw.Stop(); + _active.TryRemove(deployId, out _); + await _runs.MarkCompletedAsync(deployId, success, exitCode, errorMessage, sw.ElapsedMilliseconds, CancellationToken.None); + deployCts.Dispose(); + } + + // Final terminal event for any IProgress consumer that didn't see + // deploy_complete on stdout (e.g. subprocess crashed without emitting it). + progress.Report(new DeployEvent( + DeployId: deployId, + Phase: success ? DeployPhase.Done : DeployPhase.Failed, + Step: "deploy_complete", + Message: errorMessage ?? (success ? "deploy succeeded" : "deploy failed"), + Timestamp: DateTimeOffset.UtcNow, + IsTerminal: true, + IsPastPonr: false)); + + return deployId; + } + + public async Task GetStatusAsync(string deployId, CancellationToken ct) + { + var row = await _runs.GetByIdAsync(deployId, ct) + ?? throw new KeyNotFoundException($"Unknown deploy id: {deployId}"); + + var phase = StatusToPhase(row.Status); + var success = row.Status == "completed"; + return new DeployResult( + DeployId: row.Id, + Success: success, + ErrorMessage: row.ErrorMessage, + StartedAt: row.StartedAt, + CompletedAt: row.CompletedAt, + ReleaseId: row.ReleaseId, + CommitSha: row.CommitSha, + FinalPhase: phase, + GroupId: row.GroupId); + } + + public async Task> GetHistoryAsync(string domain, int limit, CancellationToken ct) + { + // v0.2 reads the local deploy_runs journal — every deploy this wdc + // instance triggered. The richer "merge with remote /.dep/history.json" + // path lands in a follow-up so the daemon can show deploys triggered + // from another workstation; for now the local journal is the + // authoritative view of "what THIS wdc has done". + var rows = await _runs.ListForDomainAsync(domain, limit, ct); + return rows.Select(r => new DeployHistoryEntry( + DeployId: r.Id, + Domain: r.Domain, + Host: r.Host, + Branch: r.Branch ?? "", + FinalPhase: StatusToPhase(r.Status), + StartedAt: r.StartedAt, + CompletedAt: r.CompletedAt, + CommitSha: r.CommitSha, + ReleaseId: r.ReleaseId, + Error: r.ErrorMessage)).ToList(); + } + + private static DeployPhase StatusToPhase(string status) => status switch + { + "queued" => DeployPhase.Queued, + "running" => DeployPhase.PreflightChecks, + "awaiting_soak" => DeployPhase.AwaitingSoak, + "completed" => DeployPhase.Done, + "failed" => DeployPhase.Failed, + "cancelled" => DeployPhase.Cancelled, + "rolling_back" => DeployPhase.RollingBack, + "rolled_back" => DeployPhase.RolledBack, + _ => DeployPhase.Queued, + }; + + /// + /// Phase C (#109-C1) — ZIP the current/ release directory of the + /// resolved host into ~/.wdc/backups/manual/{domain}/{snapshotId}.zip. + /// Mirrors daemon's inline snapshot-now logic. Returns null when no + /// host has localTargetPath configured (caller falls back to DB + /// snapshotter). When the host's current/ is a symlink (nksdeploy + /// layout) the archive captures the link target's contents, not the + /// symlink metadata. + /// + /// Tuple (zipPath, sizeBytes, host) when a real ZIP was + /// written, null when no fs-snapshottable host exists. + public async Task<(string Path, long SizeBytes, string Host)?> SnapshotCurrentReleaseAsync( + string domain, string? bodyHost, string snapshotId, CancellationToken ct) + { + string? sourceCurrent = null; + string? hostName = null; + try + { + var settingsPath = System.IO.Path.Combine( + NKS.WebDevConsole.Core.Services.WdcPaths.SitesRoot, + domain, "deploy-settings.json"); + if (!File.Exists(settingsPath)) return null; + using var sdoc = System.Text.Json.JsonDocument.Parse(await File.ReadAllTextAsync(settingsPath, ct)); + if (!sdoc.RootElement.TryGetProperty("hosts", out var hostsEl) + || hostsEl.ValueKind != System.Text.Json.JsonValueKind.Array) + return null; + foreach (var hEl in hostsEl.EnumerateArray()) + { + if (!hEl.TryGetProperty("name", out var nEl)) continue; + var n = nEl.GetString() ?? ""; + if (!string.IsNullOrEmpty(bodyHost) && !string.Equals(n, bodyHost, StringComparison.OrdinalIgnoreCase)) + continue; + if (hEl.TryGetProperty("localTargetPath", out var ltEl)) + { + var tgt = ltEl.GetString(); + if (!string.IsNullOrEmpty(tgt)) + { + var candidate = System.IO.Path.Combine(tgt, "current"); + if (Directory.Exists(candidate)) + { + sourceCurrent = candidate; + hostName = n; + break; + } + } + } + if (!string.IsNullOrEmpty(bodyHost)) break; + } + } + catch { return null; } + if (sourceCurrent is null || hostName is null) return null; + + // Resolve symlink target so the archive captures real files. + var realRoot = sourceCurrent; + try + { + var info = new DirectoryInfo(sourceCurrent); + if (info.LinkTarget is not null && Directory.Exists(info.LinkTarget)) + realRoot = info.LinkTarget; + } + catch { /* keep sourceCurrent */ } + + var backupsDir = System.IO.Path.Combine( + NKS.WebDevConsole.Core.Services.WdcPaths.BackupsRoot, "manual", domain); + Directory.CreateDirectory(backupsDir); + var zipPath = System.IO.Path.Combine(backupsDir, $"{snapshotId}.zip"); + await Task.Run(() => + System.IO.Compression.ZipFile.CreateFromDirectory( + realRoot, zipPath, + System.IO.Compression.CompressionLevel.Fastest, + includeBaseDirectory: false), ct); + var size = new FileInfo(zipPath).Length; + return (zipPath, size, hostName); + } + + /// + /// Phase B (#109-B5) — fire a single Slack-shaped notification ad-hoc + /// so the operator can verify the webhook URL works without waiting + /// for a real deploy. is the explicit URL + /// (or null to fall back to per-site settings); + /// is for grant-scoping AND appears in the message body so the test + /// post is identifiable. Direct C# (no phar) — phar has no notify + /// command. + /// + public async Task<(bool ok, long durationMs, string? error)> TestNotificationAsync( + string domain, string? host, string? explicitWebhook, + CancellationToken ct) + { + var webhook = explicitWebhook; + if (string.IsNullOrWhiteSpace(webhook)) + { + // Fall back to per-site deploy-settings.json (same path as + // NksDeployRoutes.SettingsPath). Keep this best-effort: a + // missing/corrupt settings file just means "no fallback". + try + { + var sp = Path.Combine( + NKS.WebDevConsole.Core.Services.WdcPaths.SitesRoot, + domain, "deploy-settings.json"); + if (File.Exists(sp)) + { + using var sd = System.Text.Json.JsonDocument.Parse(await File.ReadAllTextAsync(sp, ct)); + if (sd.RootElement.TryGetProperty("notifications", out var nEl) + && nEl.TryGetProperty("slackWebhook", out var swEl)) + webhook = swEl.GetString(); + } + } + catch { /* best-effort */ } + } + if (string.IsNullOrWhiteSpace(webhook)) + return (false, 0, "slack_webhook_not_configured"); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + try + { + var deployId = "test-" + Guid.NewGuid().ToString("D")[..8]; + var text = $":bell: *NKS WDC test notification* — site `{domain}` host `{host ?? "test"}` deployId `{deployId}`"; + var payload = System.Text.Json.JsonSerializer.Serialize(new { text }); + using var http = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + using var content = new System.Net.Http.StringContent(payload, System.Text.Encoding.UTF8, "application/json"); + using var resp = await http.PostAsync(webhook, content, ct); + sw.Stop(); + if (!resp.IsSuccessStatusCode) + return (false, sw.ElapsedMilliseconds, + $"webhook returned {(int)resp.StatusCode} {resp.ReasonPhrase}"); + return (true, sw.ElapsedMilliseconds, null); + } + catch (Exception ex) + { + sw.Stop(); + return (false, sw.ElapsedMilliseconds, ex.Message); + } + } + + /// + /// Phase B (#109-B4) — execute a single hook ad-hoc (no deploy context). + /// Used by the GUI/MCP test-hook button to validate hook config before + /// it runs in a real deploy. Direct C# implementation — phar isn't + /// involved (phar has no hook-test command and would require deploy.neon + /// + host context that doesn't apply here). + /// + public async Task<(bool ok, long durationMs, string? error)> TestHookAsync( + string type, string command, int timeoutSeconds, + IReadOnlyDictionary? envVars, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(type)) throw new ArgumentException("type required", nameof(type)); + if (string.IsNullOrWhiteSpace(command)) throw new ArgumentException("command required", nameof(command)); + var timeout = TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds)); + var sw = System.Diagnostics.Stopwatch.StartNew(); + using var hookCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + hookCts.CancelAfter(timeout); + try + { + switch (type.ToLowerInvariant()) + { + case "http": + using (var http = new System.Net.Http.HttpClient { Timeout = timeout }) + { + var payload = System.Text.Json.JsonSerializer.Serialize( + new { test = true, source = "nksdeploy.test-hook" }); + using var req = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Post, command) + { + Content = new System.Net.Http.StringContent(payload, System.Text.Encoding.UTF8, "application/json"), + }; + using var resp = await http.SendAsync(req, hookCts.Token); + if (!resp.IsSuccessStatusCode) + throw new InvalidOperationException( + $"HTTP hook returned {(int)resp.StatusCode} {resp.ReasonPhrase}"); + } + break; + case "php": + { + var firstToken = command.Split(' ', 2)[0]; + var isPath = firstToken.EndsWith(".php", StringComparison.OrdinalIgnoreCase) + || File.Exists(firstToken); + var args = isPath ? command : $"-r \"{command.Replace("\"", "\\\"")}\""; + await RunHookProcessAsync("php", args, envVars, hookCts.Token); + } + break; + case "shell": + default: + if (OperatingSystem.IsWindows()) + await RunHookProcessAsync("cmd.exe", $"/c {command}", envVars, hookCts.Token); + else + await RunHookProcessAsync("sh", $"-c \"{command.Replace("\"", "\\\"")}\"", envVars, hookCts.Token); + break; + } + sw.Stop(); + // Phase C-2 (#109-C2) — broadcast deploy:hook for parity with + // daemon's LocalDeployBackend.TestHookAsync. deployId is + // synthetic ("test-…") so the GUI drawer can highlight ad-hoc + // tests separately from real-deploy hooks. + if (_events is not null) + { + try + { + await _events.BroadcastAsync("deploy:hook", new + { + deployId = "test-" + Guid.NewGuid().ToString("D")[..8], + evt = "test", + type, + label = command, + ok = true, + durationMs = sw.ElapsedMilliseconds, + }); + } + catch { /* SSE broadcast best-effort */ } + } + return (true, sw.ElapsedMilliseconds, null); + } + catch (Exception ex) + { + sw.Stop(); + if (_events is not null) + { + try + { + await _events.BroadcastAsync("deploy:hook", new + { + deployId = "test-" + Guid.NewGuid().ToString("D")[..8], + evt = "test", + type, + label = command, + ok = false, + error = ex.Message, + durationMs = sw.ElapsedMilliseconds, + }); + } + catch { /* SSE broadcast best-effort */ } + } + return (false, sw.ElapsedMilliseconds, ex.Message); + } + } + + private static async Task RunHookProcessAsync( + string fileName, string args, + IReadOnlyDictionary? envVars, + CancellationToken ct) + { + var psi = new System.Diagnostics.ProcessStartInfo(fileName, args) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + if (envVars is not null) + foreach (var (k, v) in envVars) psi.EnvironmentVariables[k] = v; + using var proc = System.Diagnostics.Process.Start(psi) + ?? throw new InvalidOperationException($"Failed to start {fileName}"); + await proc.WaitForExitAsync(ct); + if (proc.ExitCode != 0) + { + var stderr = await proc.StandardError.ReadToEndAsync(ct); + throw new InvalidOperationException( + $"{fileName} exit {proc.ExitCode}: {stderr.Trim()}"); + } + } + + /// + /// Phase B (#109-B3) — roll back to a specific release ID rather than + /// the default "previous_release". Wraps the same nksdeploy phar logic + /// as but appends -r {targetReleaseId} + /// to the CLI args. The phar's RollbackCommand already supports this + /// via ->addOption('release', 'r', ...) (verified iter 51). + /// + public Task RollbackToAsync(string deployId, string targetReleaseId, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(targetReleaseId)) + throw new ArgumentException("targetReleaseId required", nameof(targetReleaseId)); + return RollbackInternalAsync(deployId, targetReleaseId, ct); + } + + public async Task RollbackAsync(string deployId, CancellationToken ct) + => await RollbackInternalAsync(deployId, targetReleaseId: null, ct); + + private async Task RollbackInternalAsync(string deployId, string? targetReleaseId, CancellationToken ct) + { + // Look up the source deploy to find the host + the release we want + // to rewind FROM. The actual target release is "previous" (nksdeploy + // resolves it server-side from /releases/), so we don't pass --release + // explicitly — just rely on nksdeploy's "default to previous" logic. + var source = await _runs.GetByIdAsync(deployId, ct) + ?? throw new KeyNotFoundException($"Unknown deploy id: {deployId}"); + + var site = _siteRegistry.GetSite(source.Domain) + ?? throw new InvalidOperationException($"Site no longer registered: {source.Domain}"); + var siteRoot = ResolveSiteRoot(site.DocumentRoot); + var configPath = Path.Combine(siteRoot, "deploy.neon"); + if (!File.Exists(configPath)) + { + throw new FileNotFoundException($"deploy.neon not found at {configPath}", configPath); + } + + var php = ResolvePhpBinary(site.PhpVersion); + var phar = ResolveNksDeployPhar(); + + // Rollback gets its own deploy_runs row — separate audit entry, so + // the history page shows "deploy → rollback → deploy" as three + // distinct events even though they touch the same site/host. + var rollbackId = Guid.NewGuid().ToString(); + var startedAt = DateTimeOffset.UtcNow; + await _runs.InsertAsync(new DeployRunRow( + Id: rollbackId, + Domain: source.Domain, + Host: source.Host, + ReleaseId: source.ReleaseId, + Branch: source.Branch, + CommitSha: source.CommitSha, + Status: "rolling_back", + IsPastPonr: true, // a rollback IS the "past PONR" recovery action + StartedAt: startedAt, + CompletedAt: null, + ExitCode: null, + ErrorMessage: null, + DurationMs: null, + TriggeredBy: "gui", + BackendId: BackendId, + CreatedAt: startedAt, + UpdatedAt: startedAt + ), ct); + + var sw = Stopwatch.StartNew(); + var success = false; + int? exitCode = null; + string? errorMessage = null; + + try + { + // Buffered execution — rollback is short and we only need the + // final exit code. No NDJSON event stream needed (the wdc UI + // shows rollback as a single atomic operation). + var args = new List + { + "-d", "display_errors=0", + "-d", "error_reporting=0", + "-d", "log_errors=0", + phar, + "rollback", + source.Host, + "-c", configPath, + "--yes", + "--format=json", + }; + // Phase B #109-B3 — when caller supplies a specific target release, + // pass it via phar's --release flag (verified iter 51 in + // RollbackCommand.php: ->addOption('release', 'r', ...)). + if (!string.IsNullOrWhiteSpace(targetReleaseId)) + { + args.Add("-r"); + args.Add(targetReleaseId); + } + + var cmd = await Cli.Wrap(php) + .WithArguments(args) + .WithWorkingDirectory(siteRoot) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(ct); + + exitCode = cmd.ExitCode; + success = cmd.ExitCode == 0; + if (!success) + { + errorMessage = $"nksdeploy rollback exit code {cmd.ExitCode}: {cmd.StandardError}"; + } + } + catch (OperationCanceledException) + { + errorMessage = "rollback cancelled"; + await _runs.UpdateStatusAsync(rollbackId, "cancelled", CancellationToken.None); + throw; + } + catch (Exception ex) + { + errorMessage = ex.Message; + _logger.LogError(ex, "Rollback {RollbackId} (source {SourceId}) threw: {Msg}", + rollbackId, deployId, ex.Message); + } + finally + { + sw.Stop(); + // On success: write 'rolled_back' (final state) instead of the + // generic 'completed' that MarkCompletedAsync would write. + if (success) + { + await _runs.UpdateStatusAsync(rollbackId, "rolled_back", CancellationToken.None); + await _runs.MarkCompletedAsync(rollbackId, true, exitCode, null, sw.ElapsedMilliseconds, CancellationToken.None); + // Re-write status because MarkCompletedAsync clobbers it to 'completed'. + await _runs.UpdateStatusAsync(rollbackId, "rolled_back", CancellationToken.None); + } + else + { + await _runs.MarkCompletedAsync(rollbackId, false, exitCode, errorMessage, sw.ElapsedMilliseconds, CancellationToken.None); + } + } + } + + /// + /// Two-phase cancel: signal the linked CTS (CliWrap translates this to + /// a SIGTERM-equivalent on the child), then start a 10-second watchdog + /// task. If the deploy is still in after the + /// grace window we OS-kill the process tree by PID — this guards + /// against PHP scripts that swallow SIGTERM (long-blocking SSH + /// transfer, hook script with a sleep loop, etc.). + /// + /// Returns immediately so REST handlers don't block on the 10 s grace. + /// + public Task CancelAsync(string deployId, CancellationToken ct) + { + if (!_active.TryGetValue(deployId, out var active)) + { + // Unknown id (already completed / never started) — surface a soft error; + // the daemon REST handler maps this to 409. + throw new InvalidOperationException($"No active deploy with id {deployId}"); + } + + try { active.Cts.Cancel(); } + catch (ObjectDisposedException) { /* race with completion finally */ } + + // Watchdog: if the deploy is still registered after 10 seconds, + // SIGKILL the process tree. We snapshot the PID at fire time — + // even if the entry vanished between schedule and fire we just + // exit cleanly. + _ = Task.Run(async () => + { + try + { + await Task.Delay(TimeSpan.FromSeconds(10)); + if (!_active.TryGetValue(deployId, out var stillActive)) return; + var pid = stillActive.Pid; + if (pid is null) + { + _logger.LogWarning( + "Cancel watchdog for {DeployId} — no PID recorded; cannot escalate to kill", + deployId); + return; + } + try + { + using var proc = Process.GetProcessById(pid.Value); + _logger.LogWarning( + "Cancel watchdog: PHP subprocess for {DeployId} (pid={Pid}) ignored SIGTERM, force-killing process tree", + deployId, pid.Value); + proc.Kill(entireProcessTree: true); + } + catch (ArgumentException) + { + // Process already exited between the active-check and + // GetProcessById — happy path, nothing to do. + } + catch (Exception ex) + { + _logger.LogError(ex, + "Cancel watchdog failed to kill pid={Pid} for {DeployId}", pid.Value, deployId); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Cancel watchdog for {DeployId} threw", deployId); + } + }); + + return Task.CompletedTask; + } + + // ────────────────────────── helpers ────────────────────────── + + private static string ResolveSiteRoot(string documentRoot) + { + // Convention: deploy.neon sits next to composer.json at the project + // root, which is the parent of www/ for Nette projects. Fall back to + // documentRoot itself if no parent has deploy.neon. + var parent = Directory.GetParent(documentRoot)?.FullName; + if (parent is not null && File.Exists(Path.Combine(parent, "deploy.neon"))) + { + return parent; + } + return documentRoot; + } + + private static string ResolvePhpBinary(string phpVersion) + { + var candidate = Path.Combine(WdcPaths.BinariesRoot, "php", phpVersion, + OperatingSystem.IsWindows() ? "php.exe" : "php"); + if (File.Exists(candidate)) return candidate; + + // Fallback: rely on PATH. CliWrap will surface a clear error if + // even `php` is unavailable. + return OperatingSystem.IsWindows() ? "php.exe" : "php"; + } + + private static string ResolveNksDeployPhar() + { + // Bundled alongside the plugin DLL: plugins/{id}/nksdeploy.phar. + // The plugin loader copies plugin.json + DLL + sibling files into + // its own subdir, so AppContext.BaseDirectory resolves to the + // plugin's own dir at runtime. + var bundled = Path.Combine(AppContext.BaseDirectory, "nksdeploy.phar"); + if (File.Exists(bundled)) return bundled; + + // Dev fallback: the standalone deploy-skript checkout. The plugin + // installer ships the phar under the daemon binaries root in + // production, so this branch only fires during local dev when + // nksdeploy is run from source. + var devCheckout = Path.Combine(WdcPaths.BinariesRoot, "nksdeploy", "nksdeploy.phar"); + if (File.Exists(devCheckout)) return devCheckout; + + // Final fallback: PATH lookup via plain name. The subprocess will + // fail with a clear "command not found" if absent, which the + // daemon's error path maps to 503 nksdeploy_unavailable. + return "nksdeploy"; + } + + private async Task HandleStdoutLineAsync( + string deployId, + string domain, + string line, + IProgress progress, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(line)) return; + + // nksdeploy --ndjson-events emits one JSON object per line. PHP + // notices that leak to stdout (they shouldn't — DeployCommand sets + // error_reporting on entry — but defense-in-depth) are logged and + // skipped, not parsed. + if (!line.TrimStart().StartsWith('{')) + { + _logger.LogDebug("[{DeployId}] non-json stdout: {Line}", deployId, line); + return; + } + + Dictionary? evt; + try + { + evt = JsonSerializer.Deserialize>(line); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "[{DeployId}] malformed ndjson line, skipping: {Line}", deployId, line); + return; + } + if (evt is null) return; + + var eventName = evt.TryGetValue("event", out var en) && en.ValueKind == JsonValueKind.String ? en.GetString() : null; + var step = evt.TryGetValue("step", out var sv) && sv.ValueKind == JsonValueKind.String ? sv.GetString() ?? "" : ""; + var message = evt.TryGetValue("message", out var mv) && mv.ValueKind == JsonValueKind.String ? mv.GetString() ?? "" : ""; + + var (phase, isTerminal, isPastPonr) = MapPhase(eventName, step, evt); + + // Reflect macro-state changes in the persistent row so the daemon's + // GET /status returns coherent state even if the subprocess dies + // before emitting deploy_complete. + var statusForDb = phase switch + { + DeployPhase.AwaitingSoak => "awaiting_soak", + DeployPhase.RollingBack => "rolling_back", + DeployPhase.RolledBack => "rolled_back", + _ => null, + }; + // The DB mirror is best-effort — a SQLite write hiccup (lock + // contention with backup, disk-full mid-deploy, etc.) must NOT + // abort the live subprocess pipeline. The deploy_runs row will + // stay at the previous status; the terminal MarkCompletedAsync in + // the outer finally still runs and corrects the final state. + if (statusForDb is not null) + { + try { await _runs.UpdateStatusAsync(deployId, statusForDb, ct); } + catch (Exception ex) + { + _logger.LogWarning(ex, + "[{DeployId}] mirror UpdateStatusAsync({Status}) failed; continuing", deployId, statusForDb); + } + } + if (isPastPonr) + { + try { await _runs.MarkPastPonrAsync(deployId, ct); } + catch (Exception ex) + { + _logger.LogWarning(ex, + "[{DeployId}] mirror MarkPastPonrAsync failed; continuing", deployId); + } + } + + progress.Report(new DeployEvent( + DeployId: deployId, + Phase: phase, + Step: step, + Message: message, + Timestamp: DateTimeOffset.UtcNow, + IsTerminal: isTerminal, + IsPastPonr: isPastPonr)); + } + + /// + /// Map a raw NDJSON event from nksdeploy to a wdc DeployPhase + terminal / + /// PONR flags. The phase mapping is deliberately coarse — fine-grained + /// step names live in . + /// + private static (DeployPhase Phase, bool IsTerminal, bool IsPastPonr) MapPhase( + string? eventName, + string step, + Dictionary evt) + { + if (eventName == "deploy_complete") + { + var statusOk = evt.TryGetValue("status", out var s) && s.ValueKind == JsonValueKind.String && s.GetString() == "success"; + return (statusOk ? DeployPhase.Done : DeployPhase.Failed, IsTerminal: true, IsPastPonr: false); + } + + if (eventName == "step_started") + { + var phase = step switch + { + "git:pull" or "git_clone" or "git_pull" => DeployPhase.Fetching, + "composer:install" or "composer_install" or "npm:build" or "npm_build" => DeployPhase.Building, + "doctrine:schema-update" or "schema_update" or "schema_validate" => DeployPhase.Migrating, + "symlink_switch" => DeployPhase.AboutToSwitch, + "deploy:health-check" or "health_check" => DeployPhase.HealthCheck, + _ => DeployPhase.PreflightChecks, + }; + return (phase, IsTerminal: false, IsPastPonr: false); + } + + if (eventName == "step_done" && step == "symlink_switch") + { + var statusOk = evt.TryGetValue("status", out var s) && s.ValueKind == JsonValueKind.String && s.GetString() == "ok"; + return (DeployPhase.Switched, IsTerminal: false, IsPastPonr: statusOk); + } + + // Generic log/info lines stay in PreflightChecks bucket — the + // wdc UI's StepWaterfall keys off Step name, not Phase, for granular + // progress display, so the macro phase here just disambiguates the + // major UI sections. + return (DeployPhase.PreflightChecks, IsTerminal: false, IsPastPonr: false); + } + + private static string? ParseBranchOption(JsonElement opts) + { + if (opts.ValueKind != JsonValueKind.Object) return null; + if (!opts.TryGetProperty("branch", out var b)) return null; + return b.ValueKind == JsonValueKind.String ? b.GetString() : null; + } +} diff --git a/NKS.WebDevConsole.Plugin.NksDeploy/NksDeployGroupCoordinator.cs b/NKS.WebDevConsole.Plugin.NksDeploy/NksDeployGroupCoordinator.cs new file mode 100644 index 0000000..89a200e --- /dev/null +++ b/NKS.WebDevConsole.Plugin.NksDeploy/NksDeployGroupCoordinator.cs @@ -0,0 +1,353 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using NKS.WebDevConsole.Core.Interfaces; +using NKS.WebDevConsole.Plugin.SDK.Deploy; + +namespace NKS.WebDevConsole.Plugin.NksDeploy; + +/// +/// Orchestrates a fan-out deploy across N hosts of the same site +/// (Phase 6.1). Composes the existing single-host +/// in parallel — does NOT re-implement the +/// nksdeploy CLI invocation. +/// +/// State machine — see : +/// Initializing → Deploying → AwaitingAllSoak → AllSucceeded | RolledBack | PartialFailure +/// Initializing → Deploying → GroupFailed (any failure pre-PONR with no other hosts past PONR) +/// +/// Per-host events flow through the per-host backend's IProgress sink; +/// this coordinator wraps that sink to ALSO project them onto the group's +/// own IProgress so the GUI's group drawer sees a unified timeline. +/// +/// Design choice — NOT a separate CLI invocation: +/// We deliberately reuse +/// per host instead of teaching nksdeploy a "group" verb. That keeps +/// the PHP CLI ignorant of multi-host orchestration (single +/// responsibility) and lets the daemon coordinate cancel + rollback +/// centrally — including against backends that are NOT nksdeploy +/// (LocalRsync / future Capistrano / Kamal would just plug in their +/// own IDeployBackend). +/// +public sealed class NksDeployGroupCoordinator : IDeployGroupCoordinator +{ + private readonly IDeployBackend _backend; + private readonly IDeployGroupsRepository _groups; + private readonly IDeployRunsRepository _runs; + private readonly ILogger _logger; + + /// + /// In-flight group cancellation tokens. Keyed by groupId; cancel via + /// indirectly (rollback signals each + /// per-host backend, the orchestrator below sees the cascade). + /// + private readonly ConcurrentDictionary _activeGroups = new(); + + public NksDeployGroupCoordinator( + IDeployBackend backend, + IDeployGroupsRepository groups, + IDeployRunsRepository runs, + ILogger logger) + { + _backend = backend; + _groups = groups; + _runs = runs; + _logger = logger; + } + + public async Task StartGroupAsync( + DeployGroupRequest req, + IProgress progress, + CancellationToken ct) + { + if (req.Hosts.Count == 0) + throw new ArgumentException("Hosts list cannot be empty", nameof(req)); + // De-dupe just in case the caller didn't. + var hosts = req.Hosts.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + var groupId = Guid.NewGuid().ToString("D"); + var startedAt = DateTimeOffset.UtcNow; + await _groups.InsertAsync(new DeployGroupRow( + Id: groupId, + Domain: req.Domain, + Hosts: hosts, + HostDeployIds: new Dictionary(), + Phase: nameof(DeployGroupPhase.Initializing).ToLowerInvariant(), + StartedAt: startedAt, + CompletedAt: null, + ErrorMessage: null, + TriggeredBy: req.TriggeredBy, + CreatedAt: startedAt, + UpdatedAt: startedAt + ), ct); + + var groupCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _activeGroups[groupId] = groupCts; + + // Fire-and-forget orchestration loop — caller gets the groupId + // immediately, observers track via progress / status endpoint. + _ = Task.Run(() => RunGroupAsync(groupId, req.Domain, hosts, req, progress, groupCts.Token)); + + EmitGroupEvent(groupId, DeployGroupPhase.Initializing, "group_started", + $"Group fan-out started for {hosts.Count} host(s)", false, progress); + return groupId; + } + + public async Task GetGroupStatusAsync(string groupId, CancellationToken ct) + { + var row = await _groups.GetByIdAsync(groupId, ct); + if (row is null) return null; + return new DeployGroupStatus( + GroupId: row.Id, + Domain: row.Domain, + Hosts: row.Hosts, + Phase: ParsePhase(row.Phase), + HostDeployIds: row.HostDeployIds, + StartedAt: row.StartedAt, + CompletedAt: row.CompletedAt, + ErrorMessage: row.ErrorMessage); + } + + public async Task RollbackGroupAsync(string groupId, CancellationToken ct) + { + var row = await _groups.GetByIdAsync(groupId, ct) + ?? throw new KeyNotFoundException($"Unknown group id: {groupId}"); + + await _groups.UpdatePhaseAsync(groupId, + nameof(DeployGroupPhase.RollingBackAll).ToLowerInvariant(), + isTerminal: false, errorMessage: null, ct); + + var rollbackTasks = row.HostDeployIds.Select(async kvp => + { + try + { + await _backend.RollbackAsync(kvp.Value, ct); + return (kvp.Key, ok: true, error: (string?)null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Group {GroupId} rollback failed for host {Host}", groupId, kvp.Key); + return (kvp.Key, ok: false, error: (string?)ex.Message); + } + }).ToArray(); + + var results = await Task.WhenAll(rollbackTasks); + var anyFailed = results.Any(r => !r.ok); + var terminalPhase = anyFailed + ? nameof(DeployGroupPhase.PartialFailure).ToLowerInvariant() + : nameof(DeployGroupPhase.RolledBack).ToLowerInvariant(); + var msg = anyFailed + ? "rollback partial failure: " + string.Join("; ", + results.Where(r => !r.ok).Select(r => $"{r.Key}: {r.error}")) + : null; + await _groups.UpdatePhaseAsync(groupId, terminalPhase, + isTerminal: true, errorMessage: msg, ct); + } + + /// + /// The actual fan-out loop. Runs detached from the caller so the REST + /// handler can return 202 immediately. All exceptions are caught and + /// projected onto the group's terminal state — uncaught throws here + /// would leave the group row dangling in 'deploying' forever. + /// + private async Task RunGroupAsync( + string groupId, + string domain, + IReadOnlyList hosts, + DeployGroupRequest req, + IProgress progress, + CancellationToken ct) + { + try + { + await _groups.UpdatePhaseAsync(groupId, + nameof(DeployGroupPhase.Deploying).ToLowerInvariant(), + isTerminal: false, errorMessage: null, ct); + EmitGroupEvent(groupId, DeployGroupPhase.Deploying, "fan_out", + "Starting per-host deploys in parallel", false, progress); + + // Per-host deploys — each one's IProgress is wrapped so events + // also project to the group's IProgress sink with host context. + var hostTasks = hosts.Select(async host => + { + var perHostProgress = new Progress(evt => + { + progress.Report(new DeployGroupEvent( + GroupId: groupId, + DeployId: evt.DeployId, + Host: host, + Phase: MapHostPhaseToGroup(evt.Phase), + Step: evt.Step, + Message: evt.Message, + Timestamp: evt.Timestamp, + IsTerminal: false)); + }); + + try + { + var hostReq = new DeployRequest( + Domain: domain, + Host: host, + IdempotencyKey: $"{req.IdempotencyKey}::{host}", + TriggeredBy: req.TriggeredBy, + BackendOptions: req.BackendOptions, + // Phase 6.2 — fan out the snapshot opt-in. Each + // host snapshots its own DB; the coordinator does + // NOT assume hosts share a database. + Snapshot: req.Snapshot); + var deployId = await _backend.StartDeployAsync(hostReq, perHostProgress, ct); + await _groups.RecordHostDeployAsync(groupId, host, deployId, ct); + // Phase 6.15b — stamp the group_id FK on the per-host + // run row so ListByGroupAsync can find it. Best-effort + // (a stamp failure here doesn't justify aborting the + // deploy that's already in flight). + try { await _runs.SetGroupIdAsync(deployId, groupId, ct); } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Group {GroupId} — failed to stamp group_id on deploy {DeployId}", + groupId, deployId); + } + var status = await _backend.GetStatusAsync(deployId, ct); + return (host, deployId, success: status.Success, error: status.ErrorMessage); + } + catch (Exception ex) + { + _logger.LogError(ex, "Group {GroupId} host {Host} deploy threw", groupId, host); + return (host, deployId: (string?)null, success: false, error: ex.Message); + } + }).ToArray(); + + var results = await Task.WhenAll(hostTasks); + var failed = results.Where(r => !r.success).ToList(); + var succeeded = results.Where(r => r.success).ToList(); + + if (failed.Count == 0) + { + await _groups.UpdatePhaseAsync(groupId, + nameof(DeployGroupPhase.AllSucceeded).ToLowerInvariant(), + isTerminal: true, errorMessage: null, ct); + EmitGroupEvent(groupId, DeployGroupPhase.AllSucceeded, "group_complete", + $"All {hosts.Count} host(s) succeeded", true, progress); + return; + } + + // Some hosts failed. Determine if any succeeded ones are past PONR + // (committed releases that must be rolled back to keep the group + // atomic). Any committed-but-failed host stays in its failed state + // — we cannot un-fail a deploy that crashed mid-way. + var rollbackTargets = new List<(string Host, string DeployId)>(); + foreach (var s in succeeded) + { + if (s.deployId is null) continue; + rollbackTargets.Add((s.host, s.deployId)); + } + + if (rollbackTargets.Count == 0) + { + // No committed releases anywhere — clean group failure. + await _groups.UpdatePhaseAsync(groupId, + nameof(DeployGroupPhase.GroupFailed).ToLowerInvariant(), + isTerminal: true, + errorMessage: "All hosts failed before any release was switched: " + + string.Join("; ", failed.Select(f => $"{f.host}: {f.error}")), + ct); + EmitGroupEvent(groupId, DeployGroupPhase.GroupFailed, "group_failed", + $"All {failed.Count} host(s) failed pre-PONR", true, progress); + return; + } + + // Roll back the committed hosts in parallel. + await _groups.UpdatePhaseAsync(groupId, + nameof(DeployGroupPhase.RollingBackAll).ToLowerInvariant(), + isTerminal: false, errorMessage: null, ct); + EmitGroupEvent(groupId, DeployGroupPhase.RollingBackAll, "fan_out_rollback", + $"{failed.Count} host(s) failed; rolling back {rollbackTargets.Count} committed deploy(s)", + false, progress); + + var rollbackResults = await Task.WhenAll(rollbackTargets.Select(async t => + { + try + { + await _backend.RollbackAsync(t.DeployId, ct); + return (t.Host, ok: true, error: (string?)null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Group {GroupId} cascade rollback failed for {Host}", groupId, t.Host); + return (t.Host, ok: false, error: (string?)ex.Message); + } + })); + + var rollbackFailed = rollbackResults.Where(r => !r.ok).ToList(); + var terminal = rollbackFailed.Count == 0 + ? nameof(DeployGroupPhase.RolledBack).ToLowerInvariant() + : nameof(DeployGroupPhase.PartialFailure).ToLowerInvariant(); + var terminalMsg = "Original failures: " + + string.Join("; ", failed.Select(f => $"{f.host}: {f.error}")); + if (rollbackFailed.Count > 0) + { + terminalMsg += " | Rollback failures: " + + string.Join("; ", rollbackFailed.Select(r => $"{r.Host}: {r.error}")); + } + await _groups.UpdatePhaseAsync(groupId, terminal, + isTerminal: true, errorMessage: terminalMsg, ct); + EmitGroupEvent(groupId, ParsePhase(terminal), "group_terminal", terminalMsg, true, progress); + } + catch (Exception ex) + { + _logger.LogError(ex, "Group {GroupId} orchestrator threw", groupId); + await _groups.UpdatePhaseAsync(groupId, + nameof(DeployGroupPhase.GroupFailed).ToLowerInvariant(), + isTerminal: true, errorMessage: ex.Message, ct); + } + finally + { + if (_activeGroups.TryRemove(groupId, out var cts)) + { + try { cts.Dispose(); } catch (ObjectDisposedException) { } + } + } + } + + private void EmitGroupEvent( + string groupId, + DeployGroupPhase phase, + string step, + string message, + bool isTerminal, + IProgress progress) + { + progress.Report(new DeployGroupEvent( + GroupId: groupId, + DeployId: null, + Host: null, + Phase: phase, + Step: step, + Message: message, + Timestamp: DateTimeOffset.UtcNow, + IsTerminal: isTerminal)); + } + + private static DeployGroupPhase MapHostPhaseToGroup(DeployPhase hostPhase) => hostPhase switch + { + DeployPhase.Done => DeployGroupPhase.Deploying, // host done; group still aggregating + DeployPhase.Failed => DeployGroupPhase.Deploying, // host failed; group decides next + DeployPhase.AwaitingSoak => DeployGroupPhase.AwaitingAllSoak, + _ => DeployGroupPhase.Deploying, + }; + + private static DeployGroupPhase ParsePhase(string raw) => raw switch + { + "initializing" => DeployGroupPhase.Initializing, + "preflight" => DeployGroupPhase.Preflight, + "deploying" => DeployGroupPhase.Deploying, + "awaiting_all_soak" => DeployGroupPhase.AwaitingAllSoak, + "all_succeeded" => DeployGroupPhase.AllSucceeded, + "partial_failure" => DeployGroupPhase.PartialFailure, + "rolling_back_all" => DeployGroupPhase.RollingBackAll, + "rolled_back" => DeployGroupPhase.RolledBack, + "group_failed" => DeployGroupPhase.GroupFailed, + _ => DeployGroupPhase.Initializing, + }; +} diff --git a/NKS.WebDevConsole.Plugin.NksDeploy/NksDeployPlugin.cs b/NKS.WebDevConsole.Plugin.NksDeploy/NksDeployPlugin.cs new file mode 100644 index 0000000..cb28b46 --- /dev/null +++ b/NKS.WebDevConsole.Plugin.NksDeploy/NksDeployPlugin.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.DependencyInjection; +using NKS.WebDevConsole.Core.Interfaces; +using NKS.WebDevConsole.Plugin.SDK; +using NKS.WebDevConsole.Plugin.SDK.Deploy; + +namespace NKS.WebDevConsole.Plugin.NksDeploy; + +/// +/// IWdcPlugin entry point for the NksDeploy backend. +/// +/// Registers as the host's +/// implementation so the daemon's deploy routes resolve to it across the +/// AssemblyLoadContext boundary. The plugin id is the literal string +/// "nks.wdc.deploy" (NOT inferred from class name) — the +/// PluginLoader.WireEndpoints helper uses it as the URL prefix for any +/// endpoints the plugin registers (currently none — REST routes are wired +/// from the nks-ws daemon side under /api/nks.wdc.deploy/*). +/// +public sealed class NksDeployPlugin : PluginBase +{ + public override string Id => "nks.wdc.deploy"; + public override string DisplayName => "NksDeploy"; + public override string Version => "0.1.0"; + + // Description is a default interface method on IWdcPlugin (not virtual on + // PluginBase). Implement it as a regular property — the explicit interface + // dispatch picks it up. + public string Description => + "Zero-downtime deployment for Nette apps via the bundled nksdeploy " + + "PHP CLI. CliWrap subprocess, NDJSON progress stream into the wdc " + + "deploy drawer, SQLite-backed run journal."; + + public override void Initialize(IServiceCollection services, IPluginContext context) + { + // Register the backend as IDeployBackend. The shared assembly list in + // PluginLoadContext (Plugin.SDK is shared) means the daemon and the + // plugin agree on type identity, so the host can resolve + // IDeployBackend from this plugin's container or from the host's via + // a forwarding registration the daemon adds. + // Plugin-internal route handlers (phase B rollback-to) need the + // concrete type to call NksDeployBackend.RollbackToAsync without + // changing the SDK contract. Register both: IDeployBackend forwards + // to the same singleton via factory. + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + // Phase 6.1 — multi-host coordinator. Composes the per-host + // backend above; lives in this plugin so the SDK contract stays + // pluggable for future LocalRsync / Capistrano backends. + services.AddSingleton(); + } + + public override Task StartAsync(IPluginContext context, CancellationToken ct) + { + // No long-running work to spin up — the backend is stateless on + // start. Per-deploy state lives in deploy_runs; lock state lives + // on the remote host. We don't yet probe nksdeploy.phar presence + // here (that's deferred to first StartDeployAsync invocation so + // the daemon can boot on machines without PHP installed). + return Task.CompletedTask; + } + + public override void RegisterEndpoints(EndpointRegistration registration) + { + // 5 REST endpoints under /api/nks.wdc.deploy/. Auth covered by the + // daemon's existing Bearer middleware (it matches /api/*). DI on + // each handler resolves IDeployBackend (this plugin's instance) plus + // any cross-cutting services like IDeployEventBroadcaster. + NksDeployRoutes.Register(registration); + } +} diff --git a/NKS.WebDevConsole.Plugin.NksDeploy/NksDeployRoutes.cs b/NKS.WebDevConsole.Plugin.NksDeploy/NksDeployRoutes.cs new file mode 100644 index 0000000..958c1b7 --- /dev/null +++ b/NKS.WebDevConsole.Plugin.NksDeploy/NksDeployRoutes.cs @@ -0,0 +1,1100 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NKS.WebDevConsole.Core.Interfaces; +using NKS.WebDevConsole.Plugin.SDK.Deploy; + +namespace NKS.WebDevConsole.Plugin.NksDeploy; + +/// +/// REST handlers for the NksDeploy plugin. Registered via the plugin's +/// override which the +/// daemon's PluginLoader.WireEndpoints helper hooks into the routing +/// pipeline under /api/nks.wdc.deploy/. The daemon's Bearer-auth +/// middleware (matching /api/*) covers them automatically — no +/// per-handler auth attribute needed. +/// +/// Path templates use Minimal API route syntax ({domain} etc.); the +/// final URLs are e.g. /api/nks.wdc.deploy/sites/myapp.loc/hosts/production/deploy. +/// +internal static class NksDeployRoutes +{ + /// + /// Body for POST .../deploy. Backend-specific options ride along as a + /// freeform JsonElement so future flags (skip-tests, branch override) + /// don't need REST-layer changes. + /// + public sealed record StartDeployBody( + string? IdempotencyKey, + JsonElement? Options, + DeploySnapshotOptions? Snapshot = null); + + public static void Register(EndpointRegistration r) + { + r.MapPost("sites/{domain}/hosts/{host}/deploy", StartDeploy); + r.MapGet("sites/{domain}/deploys/{deployId}", GetDeploy); + r.MapGet("sites/{domain}/history", GetHistory); + r.MapPost("sites/{domain}/deploys/{deployId}/rollback", PostRollback); + r.MapDelete("sites/{domain}/deploys/{deployId}", DeleteDeploy); + // Phase 6.1 — atomic multi-host group fan-out. + r.MapPost("sites/{domain}/groups", StartGroup); + r.MapGet("sites/{domain}/groups/{groupId}", GetGroup); + r.MapPost("sites/{domain}/groups/{groupId}/rollback", PostGroupRollback); + // Phase 6.7 — group history list (newest first, limit-bounded). + r.MapGet("sites/{domain}/groups", ListGroups); + // Phase 6.3 — per-site settings persistence + snapshot inventory. + r.MapGet("sites/{domain}/settings", GetSettings); + r.MapPut("sites/{domain}/settings", PutSettings); + r.MapGet("sites/{domain}/snapshots", ListSnapshots); + r.MapGet("sites/{domain}/snapshot-info", GetSnapshotInfo); + // Phase 6.4 — operator-driven snapshot restore. Destructive — gated + // by intent token AND requires an explicit confirm flag in body. + r.MapPost("sites/{domain}/snapshots/{deployId}/restore", PostSnapshotRestore); + // Phase 6.6 — on-demand snapshot WITHOUT a deploy. Useful before + // manual DB ops (large migration via SQL client, schema rename). + r.MapPost("sites/{domain}/snapshot-now", PostSnapshotNow); + // Phase B (#109-B1) — operator utility: TCP-probe a host:port pair. + // Used by the host-edit dialog's "Test SSH" button. Pure utility, + // no NksDeployBackend deps. Site-scoped path lets future per-site + // probe policy (proxy, fallback) attach without breaking callers. + r.MapPost("test-host-connection", TestHostConnection); + // Phase B (#109-B2) — deploy WITHOUT /hosts/{host}/ in path. Host + // comes from body or defaults to "production". Mirrors daemon's + // /api/nks.wdc.deploy/sites/{domain}/deploy convenience wrapper. + // Lets GUI/MCP callers dispatch a deploy with just domain+body + // when the operator's per-site default-host is "production". + r.MapPost("sites/{domain}/deploy", StartDeployBodyHost); + // Phase B (#109-B3) — rollback to a SPECIFIC release (vs default + // "previous_release"). Body must include {targetReleaseId}. + // Uses concrete NksDeployBackend.RollbackToAsync — interface stays + // clean (no IDeployBackend.RollbackToAsync forced on all backends). + r.MapPost("sites/{domain}/deploys/{deployId}/rollback-to", PostRollbackTo); + // Phase B (#109-B4) — execute a single hook ad-hoc to validate + // the operator's hook config (shell/http/php) before it runs in a + // real deploy. Intent-gated under kind=test_hook because the hook + // body is arbitrary code execution on the daemon host. + r.MapPost("sites/{domain}/hooks/test", PostTestHook); + // Phase B (#109-B5) — fire a single Slack notification ad-hoc so + // operator can verify the configured webhook before a real + // deploy:complete fires it. No intent gate — the message body is + // canned ("test notification") so this isn't a code-execution + // surface, just a network egress to the configured URL. + r.MapPost("sites/{domain}/notifications/test", PostTestNotification); + } + + /// + /// Body for POST .../groups. Hosts is required; per-host idempotency + /// keys are derived as "{groupKey}::{host}" by the coordinator + /// so retries within the same group dedupe correctly. + /// + public sealed record StartGroupBody( + IReadOnlyList? Hosts, + string? IdempotencyKey, + JsonElement? Options, + DeploySnapshotOptions? Snapshot = null); + + /// + /// Common header used by the MCP server to attach a daemon-issued, + /// HMAC-signed intent token to a destructive call. Absent header = + /// GUI / direct caller; the daemon's bearer-auth still applies. + /// + private const string IntentTokenHeader = "X-Intent-Token"; + + /// + /// CI / headless escape hatch — when set to "true" the validator + /// pre-stamps confirmed_at instead of waiting for the GUI + /// banner approval. The MCP server only sets this when the operator + /// has explicitly opted-in via MCP_DEPLOY_AUTO_APPROVE=true. + /// + private const string AllowUnconfirmedHeader = "X-Allow-Unconfirmed"; + + /// + /// Returns "mcp" when the request carried a valid intent token, "gui" + /// otherwise. Drives the deploy_runs.triggered_by column so the audit + /// trail and history filter can distinguish AI-initiated deploys. + /// + private static string ResolveTriggeredBy(HttpContext ctx) + => ctx.Request.Headers.TryGetValue(IntentTokenHeader, out var v) && !string.IsNullOrWhiteSpace(v.ToString()) + ? "mcp" + : "gui"; + + /// + /// Validate the intent token (if present) against the (domain, host, kind) + /// the plugin is about to act on. Returns null on success; an IResult + /// representing the rejection response otherwise. When no token is + /// present this is a no-op pass — GUI and direct daemon calls still + /// only need bearer auth. + /// + private static async Task CheckIntentAsync( + HttpContext ctx, + IDeployIntentValidator validator, + string kind, + string domain, + string host, + CancellationToken ct) + { + if (!ctx.Request.Headers.TryGetValue(IntentTokenHeader, out var raw)) return null; + var token = raw.ToString(); + if (string.IsNullOrWhiteSpace(token)) return null; + + // Headless / CI bypass for the GUI confirm banner. The MCP server + // only attaches this header when MCP_DEPLOY_AUTO_APPROVE=true is + // set in its environment, so we trust the bearer-authenticated + // caller's intent here. + var allowUnconfirmed = ctx.Request.Headers.TryGetValue(AllowUnconfirmedHeader, out var hv) + && string.Equals(hv.ToString(), "true", StringComparison.OrdinalIgnoreCase); + + var result = await validator.ValidateAndConsumeAsync(token, kind, domain, host, allowUnconfirmed, ct); + if (result.Ok) return null; + + // Map the pending-confirmation reason to 425 Too Early so the MCP + // client can show a clear "approve in GUI" hint instead of treating + // the rejection as a permanent failure. + if (string.Equals(result.Reason, "pending_confirmation", StringComparison.Ordinal)) + { + return Results.Json( + new + { + error = "intent_pending_confirmation", + reason = result.Reason, + hint = "User must approve the deploy in the wdc GUI banner. Re-issue this call once approved.", + }, + statusCode: 425); // Too Early — RFC 8470, not in StatusCodes constants + } + return Results.Json( + new { error = "intent_invalid", reason = result.Reason }, + statusCode: StatusCodes.Status403Forbidden); + } + + private static async Task StartDeploy( + string domain, + string host, + StartDeployBody? body, + NksDeployBackend backend, + IDeployEventBroadcaster broadcaster, + IDeployIntentValidator intentValidator, + ILoggerFactory loggerFactory, + HttpContext ctx) + { + if (!backend.CanDeploy(domain)) + { + return Results.NotFound(new { error = "site_not_deployable", domain }); + } + + var rejection = await CheckIntentAsync(ctx, intentValidator, "deploy", domain, host, ctx.RequestAborted); + if (rejection is not null) return rejection; + + var idempotencyKey = body?.IdempotencyKey ?? Guid.NewGuid().ToString(); + var optsElement = body?.Options ?? JsonDocument.Parse("{}").RootElement; + var request = new DeployRequest( + Domain: domain, + Host: host, + IdempotencyKey: idempotencyKey, + TriggeredBy: ResolveTriggeredBy(ctx), + BackendOptions: optsElement, + Snapshot: body?.Snapshot); + + // IProgress that fans out to SSE so the wdc UI's deploy drawer + // updates in real time. The caller is fire-and-forget — we return + // 202 immediately with the deployId, then the background task runs + // through to the terminal event. + var logger = loggerFactory.CreateLogger("NksDeploy.Routes"); + var progress = new Progress(evt => + { + // Forward each event onto the daemon's SSE bus. Errors here are + // swallowed-and-logged: a failing SSE broadcast must NOT abort + // the deploy itself. + _ = broadcaster.BroadcastAsync("deploy:event", evt) + .ContinueWith(t => + { + if (t.IsFaulted) logger.LogWarning(t.Exception, "SSE broadcast for deploy event failed"); + }, TaskScheduler.Default); + }); + + // Pre-mint the deployId so we can return it RIGHT NOW; the background + // task does the real work. Use the request lifetime CT — once the + // daemon shuts down, in-flight deploys cancel cleanly. + var responseTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _ = Task.Run(async () => + { + try + { + var deployId = await backend.StartDeployAsync(request, progress, ctx.RequestAborted); + responseTcs.TrySetResult(deployId); + } + catch (Exception ex) + { + logger.LogError(ex, "Background deploy failed for {Domain}/{Host}", domain, host); + responseTcs.TrySetException(ex); + } + }); + + // We don't await responseTcs.Task — the deployId comes back to the + // caller via the IDeployBackend contract: each event carries it. + // Instead, we synchesise on a brief grace window (1 sec) for the + // backend to mint + INSERT the row so the response carries the id. + var idTask = await Task.WhenAny(responseTcs.Task, Task.Delay(TimeSpan.FromSeconds(1))); + if (idTask == responseTcs.Task && responseTcs.Task.Status == TaskStatus.RanToCompletion) + { + return Results.Accepted(value: new { deployId = responseTcs.Task.Result, idempotencyKey }); + } + + // Couldn't capture id in 1s — return Accepted with a placeholder; the + // SSE event stream will carry the real id. For v0.2 this is a known + // gap (callers must subscribe to SSE to get the id); v0.3 adds a + // synchronous mint-then-spawn split. + return Results.Accepted(value: new { deployId = (string?)null, idempotencyKey, note = "deployId arrives via SSE deploy:event" }); + } + + private static async Task GetDeploy( + string domain, + string deployId, + NksDeployBackend backend, + CancellationToken ct) + { + try + { + var result = await backend.GetStatusAsync(deployId, ct); + return Results.Ok(result); + } + catch (KeyNotFoundException) + { + return Results.NotFound(new { error = "deploy_not_found", deployId }); + } + } + + private static async Task GetHistory( + string domain, + NksDeployBackend backend, + CancellationToken ct, + int limit = 50) + { + var entries = await backend.GetHistoryAsync(domain, limit, ct); + return Results.Ok(new { domain, count = entries.Count, entries }); + } + + private static async Task PostRollback( + string domain, + string deployId, + NksDeployBackend backend, + IDeployIntentValidator intentValidator, + IDeployRunsRepository runs, + HttpContext ctx, + CancellationToken ct) + { + try + { + // Resolve host from the persisted run so the intent token can be + // bound to it. Failing here means the run vanished from the DB + // — surface as 404 before we touch anything destructive. + var run = await runs.GetByIdAsync(deployId, ct); + if (run is null) return Results.NotFound(new { error = "deploy_not_found", deployId }); + + var rejection = await CheckIntentAsync(ctx, intentValidator, "rollback", domain, run.Host, ct); + if (rejection is not null) return rejection; + + // Phase 5 hardening — refuse to rollback while another deploy / + // rollback is in-flight against the same (domain, host). The + // backend would otherwise race the symlink switch and leave the + // release tree in a half-written state. The in-flight set is + // small (typically 0-1 rows per host), so a full scan is fine. + var inFlight = await runs.ListInFlightAsync(ct); + var conflict = inFlight.FirstOrDefault(r => + string.Equals(r.Domain, domain, StringComparison.OrdinalIgnoreCase) && + string.Equals(r.Host, run.Host, StringComparison.OrdinalIgnoreCase) && + !string.Equals(r.Id, deployId, StringComparison.OrdinalIgnoreCase)); + if (conflict is not null) + { + return Results.Conflict(new + { + error = "deploy_in_flight", + message = "Another deploy or rollback is currently running on this host. Wait for it to finish or cancel it first.", + blockingDeployId = conflict.Id, + blockingStatus = conflict.Status, + }); + } + + await backend.RollbackAsync(deployId, ct); + return Results.Accepted(value: new { sourceDeployId = deployId, status = "rolled_back" }); + } + catch (KeyNotFoundException) + { + return Results.NotFound(new { error = "deploy_not_found", deployId }); + } + catch (Exception ex) + { + return Results.Problem( + title: "rollback_failed", + detail: ex.Message, + statusCode: StatusCodes.Status500InternalServerError); + } + } + + private static async Task DeleteDeploy( + string domain, + string deployId, + NksDeployBackend backend, + IDeployIntentValidator intentValidator, + IDeployRunsRepository runs, + HttpContext ctx, + CancellationToken ct) + { + try + { + // Daemon-side gate: refuse if the run has crossed PONR. Reads + // straight from the backend's status. + var status = await backend.GetStatusAsync(deployId, ct); + if (status.FinalPhase is DeployPhase.AboutToSwitch or DeployPhase.Switched + or DeployPhase.AwaitingSoak or DeployPhase.HealthCheck) + { + return Results.Conflict(new + { + error = "deploy_past_ponr", + message = "Deploy has crossed point of no return. Use rollback instead of cancel.", + deployId, + }); + } + + // Validate intent AFTER the PONR gate so AI clients get the same + // 409 a GUI user would (cheap to fail-fast on past-PONR; the + // intent token can still be re-used until consumed). + var run = await runs.GetByIdAsync(deployId, ct); + if (run is null) return Results.NotFound(new { error = "deploy_not_found", deployId }); + var rejection = await CheckIntentAsync(ctx, intentValidator, "cancel", domain, run.Host, ct); + if (rejection is not null) return rejection; + + await backend.CancelAsync(deployId, ct); + return Results.Accepted(value: new { deployId, status = "cancellation_requested" }); + } + catch (KeyNotFoundException) + { + return Results.NotFound(new { error = "deploy_not_found", deployId }); + } + catch (InvalidOperationException ex) + { + // Backend says no active deploy — treat as 409 (already terminal). + return Results.Conflict(new { error = "deploy_not_active", message = ex.Message, deployId }); + } + } + + // ──────────────────────── Phase 6.1 group endpoints ──────────────────────── + + private static async Task StartGroup( + string domain, + StartGroupBody? body, + IDeployGroupCoordinator coordinator, + IDeployEventBroadcaster broadcaster, + IDeployIntentValidator intentValidator, + ILoggerFactory loggerFactory, + HttpContext ctx) + { + if (body?.Hosts is null || body.Hosts.Count == 0) + { + return Results.BadRequest(new { error = "hosts_required", message = "Provide at least one host." }); + } + + // Group-level intent validation: bind the token to a synthetic + // host marker "*group*" so a single intent authorises the whole + // fan-out. Per-host intents would force the AI to mint N tokens + // for one user-visible action — bad UX. Operators issuing intents + // for groups MUST request kind=deploy with host="*group*". + var rejection = await CheckIntentAsync(ctx, intentValidator, "deploy", domain, "*group*", ctx.RequestAborted); + if (rejection is not null) return rejection; + + var idempotencyKey = body.IdempotencyKey ?? Guid.NewGuid().ToString(); + var optsElement = body.Options ?? JsonDocument.Parse("{}").RootElement; + var req = new DeployGroupRequest( + Domain: domain, + Hosts: body.Hosts, + IdempotencyKey: idempotencyKey, + TriggeredBy: ResolveTriggeredBy(ctx), + BackendOptions: optsElement, + Snapshot: body.Snapshot); + + var logger = loggerFactory.CreateLogger("NksDeploy.GroupRoutes"); + var progress = new Progress(evt => + { + _ = broadcaster.BroadcastAsync("deploy:group-event", evt) + .ContinueWith(t => + { + if (t.IsFaulted) logger.LogWarning(t.Exception, "SSE broadcast for group event failed"); + }, TaskScheduler.Default); + }); + + try + { + var groupId = await coordinator.StartGroupAsync(req, progress, ctx.RequestAborted); + return Results.Accepted(value: new { groupId, idempotencyKey, hostCount = body.Hosts.Count }); + } + catch (ArgumentException ex) + { + return Results.BadRequest(new { error = "invalid_request", message = ex.Message }); + } + } + + private static async Task GetGroup( + string domain, + string groupId, + IDeployGroupCoordinator coordinator, + CancellationToken ct) + { + var status = await coordinator.GetGroupStatusAsync(groupId, ct); + if (status is null) return Results.NotFound(new { error = "group_not_found", groupId }); + return Results.Ok(status); + } + + /// + /// Phase 6.7 — list groups for the site (newest started_at first). + /// limit defaults to 50, capped server-side at 200 by the repo. + /// + private static async Task ListGroups( + string domain, + IDeployGroupsRepository groupsRepo, + IDeployRunsRepository runsRepo, + CancellationToken ct, + int limit = 50) + { + var rows = await groupsRepo.ListForDomainAsync(domain, limit, ct); + var entries = new List(rows.Count); + foreach (var r in rows) + { + // Phase 6.15b — enrich with per-host run status so the GUI + // can offer "replay only failed hosts" subset. + // Only fetch when there are recorded host deploys (most rows + // pre-fan-out have empty maps and would just no-op). + var hostStatuses = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (r.HostDeployIds.Count > 0) + { + var perHost = await runsRepo.ListByGroupAsync(r.Id, ct); + foreach (var run in perHost) + { + hostStatuses[run.Host] = run.Status; + } + } + entries.Add(new + { + id = r.Id, + domain = r.Domain, + hosts = r.Hosts, + hostDeployIds = r.HostDeployIds, + hostStatuses, + phase = r.Phase, + startedAt = r.StartedAt.ToString("o"), + completedAt = r.CompletedAt?.ToString("o"), + errorMessage = r.ErrorMessage, + triggeredBy = r.TriggeredBy, + }); + } + return Results.Ok(new { domain, count = entries.Count, entries }); + } + + private static async Task PostGroupRollback( + string domain, + string groupId, + IDeployGroupCoordinator coordinator, + IDeployIntentValidator intentValidator, + HttpContext ctx, + CancellationToken ct) + { + // Same wildcard host marker as StartGroup so intent verbs map cleanly. + var rejection = await CheckIntentAsync(ctx, intentValidator, "rollback", domain, "*group*", ct); + if (rejection is not null) return rejection; + + try + { + await coordinator.RollbackGroupAsync(groupId, ct); + return Results.Accepted(value: new { groupId, status = "rollback_initiated" }); + } + catch (KeyNotFoundException) + { + return Results.NotFound(new { error = "group_not_found", groupId }); + } + catch (Exception ex) + { + return Results.Problem( + title: "group_rollback_failed", + detail: ex.Message, + statusCode: StatusCodes.Status500InternalServerError); + } + } + + // ──────────────────────── Phase 6.3 settings + snapshots ──────────────────────── + + /// + /// Returns the per-site deploy settings JSON blob, or HTTP 200 with an + /// empty object if no settings file exists yet (frontend supplies its + /// own defaults via defaultDeploySettings()). Pass-through JSON — the + /// daemon doesn't validate the structure; the frontend types are the + /// source of truth. + /// + private static IResult GetSettings(string domain) + { + var path = SettingsPath(domain); + if (!File.Exists(path)) return Results.Ok(new { }); + try + { + var text = File.ReadAllText(path); + using var doc = JsonDocument.Parse(text); + return Results.Json(doc.RootElement.Clone()); + } + catch (JsonException ex) + { + return Results.Problem( + title: "settings_corrupted", + detail: ex.Message, + statusCode: StatusCodes.Status500InternalServerError); + } + } + + /// + /// Atomic write of the per-site deploy settings. Body MUST be a JSON + /// object (we don't validate the shape — frontend owns the schema). + /// Uses temp+rename so a crashed write never corrupts an existing file. + /// + private static async Task PutSettings(string domain, HttpContext ctx) + { + try + { + using var doc = await JsonDocument.ParseAsync(ctx.Request.Body); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + return Results.BadRequest(new { error = "body_must_be_object" }); + } + var dir = SettingsDir(domain); + Directory.CreateDirectory(dir); + var path = SettingsPath(domain); + var tmp = path + ".tmp"; + await File.WriteAllTextAsync(tmp, doc.RootElement.GetRawText()); + File.Move(tmp, path, overwrite: true); + return Results.NoContent(); + } + catch (JsonException ex) + { + return Results.BadRequest(new { error = "invalid_json", detail = ex.Message }); + } + } + + /// + /// List pre-deploy snapshot files for this site by scanning the deploy + /// runs table for rows that wrote a pre_deploy_backup_path. Snapshots + /// belonging to other sites are filtered out by joining on domain. + /// + private static async Task ListSnapshots( + string domain, + IDeployRunsRepository runs, + CancellationToken ct) + { + var rows = await runs.ListForDomainAsync(domain, limit: 200, ct); + var entries = rows + .Where(r => !string.IsNullOrEmpty(r.PreDeployBackupPath)) + .Select(r => new + { + id = r.Id, + createdAt = r.StartedAt.ToString("o"), + sizeBytes = r.PreDeployBackupSizeBytes ?? 0L, + path = r.PreDeployBackupPath!, + }) + .ToList(); + return Results.Ok(entries); + } + + /// + /// Auto-detect snapshotable database for this site. Mirrors the scan + /// PreDeploySnapshotter does internally so the GUI can show a banner + /// like "SQLite detected at /var/.../app.sqlite" before the user opts + /// in. Returns { detected: bool, type: 'sqlite'|'mysql'|'pg'|null, + /// path?: string, hint: string }. + /// + private static IResult GetSnapshotInfo(string domain, ISiteRegistry sites) + { + var site = sites.GetSite(domain); + if (site is null) return Results.NotFound(new { error = "site_not_found", domain }); + + var siteRoot = Directory.GetParent(site.DocumentRoot)?.FullName ?? site.DocumentRoot; + string[] dirs = { siteRoot, Path.Combine(siteRoot, "app"), Path.Combine(siteRoot, "var"), + Path.Combine(siteRoot, "data"), Path.Combine(siteRoot, "db"), + Path.Combine(siteRoot, "database"), site.DocumentRoot }; + string[] exts = { "*.sqlite", "*.sqlite3", "*.db" }; + foreach (var dir in dirs) + { + if (!Directory.Exists(dir)) continue; + foreach (var ext in exts) + { + try + { + var hit = Directory.EnumerateFiles(dir, ext).FirstOrDefault(); + if (hit is not null) + { + return Results.Ok(new + { + detected = true, + type = "sqlite", + path = hit, + hint = "SQLite database detected — pre-deploy snapshot will copy + gzip this file.", + }); + } + } + catch (UnauthorizedAccessException) { /* skip */ } + } + } + return Results.Ok(new + { + detected = false, + type = (string?)null, + path = (string?)null, + hint = "No SQLite database found. MySQL/PostgreSQL snapshot integration ships in Phase 6.3 (mysqldump/pg_dump with credential resolution).", + }); + } + + private static string SettingsDir(string domain) => + Path.Combine(NKS.WebDevConsole.Core.Services.WdcPaths.SitesRoot, domain); + + private static string SettingsPath(string domain) => + Path.Combine(SettingsDir(domain), "deploy-settings.json"); + + // ──────────────────────── Phase 6.6 on-demand snapshot ──────────────────────── + + /// + /// Take a database snapshot WITHOUT firing a deploy. Persists a + /// synthetic deploy_runs row tagged backend_id="manual-snapshot" + /// + status="completed" so the snapshot shows up in the + /// per-site snapshot inventory and can be restored via the standard + /// flow. Operator must confirm via body since this still touches the + /// site's DB connection (reads only — no writes here). + /// + /// Useful before manual DB ops: large schema migrations, column + /// renames, ad-hoc data fixes via mysql client. The snapshot is then + /// available to restore via the existing endpoint if the manual op + /// goes wrong. + /// + private static async Task PostSnapshotNow( + string domain, + IPreDeploySnapshotter snapshotter, + NksDeployBackend backend, + IDeployRunsRepository runs, + HttpContext ctx, + CancellationToken ct) + { + // Optional body { host: "..." } — picks a specific host's current/. + // No body or no host → first host with localTargetPath wins. + string? bodyHost = null; + if (ctx.Request.ContentLength is > 0) + { + try + { + using var bdoc = await JsonDocument.ParseAsync(ctx.Request.Body, cancellationToken: ct); + if (bdoc.RootElement.TryGetProperty("host", out var hEl)) + bodyHost = hEl.GetString(); + } + catch { /* empty / non-JSON body is fine */ } + } + + // Synthetic id for the deploy_runs row — namespaced so reports can + // distinguish it from real deploys via the prefix. + var id = Guid.NewGuid().ToString("D"); + var now = DateTimeOffset.UtcNow; + var sw = System.Diagnostics.Stopwatch.StartNew(); + + // Phase C (#109-C1) — try filesystem-ZIP first (matches daemon's + // inline snapshot-now behaviour). Falls back to the DB-only + // IPreDeploySnapshotter when no host has localTargetPath. + var zipResult = await backend.SnapshotCurrentReleaseAsync(domain, bodyHost, id, ct); + + var row = new DeployRunRow( + Id: id, + Domain: domain, + Host: zipResult?.Host ?? "*manual*", + ReleaseId: zipResult is not null ? now.ToString("yyyyMMdd_HHmmss") + "-manual" : null, + Branch: null, + CommitSha: null, + Status: "running", // will flip to completed after snapshot succeeds + IsPastPonr: false, + StartedAt: now, + CompletedAt: null, + ExitCode: null, + ErrorMessage: null, + DurationMs: null, + TriggeredBy: ResolveTriggeredBy(ctx), + BackendId: "manual-snapshot", + CreatedAt: now, + UpdatedAt: now); + await runs.InsertAsync(row, ct); + + if (zipResult is not null) + { + sw.Stop(); + await runs.UpdatePreDeployBackupAsync(id, zipResult.Value.Path, zipResult.Value.SizeBytes, ct); + await runs.MarkCompletedAsync(id, + success: true, exitCode: 0, errorMessage: null, + durationMs: sw.ElapsedMilliseconds, ct); + return Results.Ok(new + { + snapshotId = id, + domain, + path = zipResult.Value.Path, + sizeBytes = zipResult.Value.SizeBytes, + durationMs = sw.ElapsedMilliseconds, + host = zipResult.Value.Host, + }); + } + + try + { + var result = await snapshotter.CreateAsync(domain, id, ct); + await runs.UpdatePreDeployBackupAsync(id, result.Path, result.SizeBytes, ct); + await runs.MarkCompletedAsync(id, + success: true, exitCode: 0, errorMessage: null, + durationMs: (long)result.Duration.TotalMilliseconds, ct); + return Results.Ok(new + { + snapshotId = id, + domain, + path = result.Path, + sizeBytes = result.SizeBytes, + durationMs = (long)result.Duration.TotalMilliseconds, + }); + } + catch (Exception ex) + { + // Surface the failure on the synthetic row so the snapshot list + // shows it as failed rather than dangling in 'running'. + await runs.MarkCompletedAsync(id, + success: false, exitCode: null, + errorMessage: $"snapshot_now_failed: {ex.Message}", + durationMs: 0, ct); + return Results.Problem( + title: "snapshot_now_failed", + detail: ex.Message, + statusCode: StatusCodes.Status500InternalServerError); + } + } + + // ──────────────────────── Phase 6.4 snapshot restore ──────────────────────── + + /// Body for POST .../snapshots/{deployId}/restore. + public sealed record RestoreSnapshotBody(bool Confirm); + + /// + /// Restore the snapshot recorded on a deploy_runs row. Two gates + /// enforce the destructive nature: (1) intent token validation + /// (kind=restore — see CheckIntentAsync), (2) explicit + /// confirm:true in the body so a stray POST cannot trigger + /// a live-data overwrite. + /// + /// SQLite restores create a safety .bak next to the live file + /// before overwrite; SQL restores have no such net (operator must + /// have a separate DB backup). The endpoint surfaces every failure + /// as a structured error rather than a generic 500. + /// + private static async Task PostSnapshotRestore( + string domain, + string deployId, + RestoreSnapshotBody? body, + ISnapshotRestorer restorer, + IDeployIntentValidator intentValidator, + HttpContext ctx, + CancellationToken ct) + { + if (body is null || !body.Confirm) + { + return Results.BadRequest(new + { + error = "confirm_required", + message = "Restore is irreversible — POST { \"confirm\": true } to proceed.", + }); + } + + // Snapshot restore is its own intent kind so a deploy/rollback + // intent can't be reused to silently overwrite live data. We bind + // to the synthetic host marker "*restore*" mirroring the *group* + // pattern from Phase 6.1. + var rejection = await CheckIntentAsync(ctx, intentValidator, "restore", domain, "*restore*", ct); + if (rejection is not null) return rejection; + + try + { + var result = await restorer.RestoreAsync(domain, deployId, ct); + return Results.Ok(new + { + deployId, + domain, + mode = result.Mode, + bytesProcessed = result.BytesProcessed, + durationMs = (long)result.Duration.TotalMilliseconds, + }); + } + catch (KeyNotFoundException) + { + return Results.NotFound(new { error = "deploy_not_found", deployId }); + } + catch (FileNotFoundException ex) + { + return Results.NotFound(new { error = "snapshot_archive_missing", message = ex.Message }); + } + catch (InvalidOperationException ex) + { + // Domain mismatch / no .env / scaffold archive / missing client + // — all surface as 400 because they're caller-resolvable rather + // than server faults. + return Results.BadRequest(new { error = "restore_refused", message = ex.Message }); + } + catch (Exception ex) + { + return Results.Problem( + title: "restore_failed", + detail: ex.Message, + statusCode: StatusCodes.Status500InternalServerError); + } + } + + /// + /// Body for POST .../deploy (no /hosts/ segment). Same as StartDeployBody + /// but adds optional Host field. When absent, defaults to "production" + /// — matching the daemon convenience wrapper at Program.cs:3502. + /// + public sealed record StartDeployBodyWithHost( + string? Host, + string? IdempotencyKey, + JsonElement? Options, + DeploySnapshotOptions? Snapshot = null); + + /// + /// Phase B (#109-B2) — deploy without /hosts/{host}/ in path. + /// Reads host from body or defaults "production", then delegates + /// to the standard StartDeploy handler with the resolved host. + /// + /// + /// Body for POST .../rollback-to. TargetReleaseId is required and + /// must match an existing release directory under the site's deploy root. + /// nksdeploy phar validates the release id; we just forward it via -r. + /// + public sealed record RollbackToBody(string? TargetReleaseId); + + /// + /// Phase B (#109-B3) — rollback to a specific release id. Mirrors the + /// daemon's /api/nks.wdc.deploy/sites/{domain}/deploys/{deployId}/rollback-to + /// endpoint. Uses the plugin-internal concrete NksDeployBackend so the + /// IDeployBackend SDK interface doesn't grow a new method. + /// + private static async Task PostRollbackTo( + string domain, + string deployId, + RollbackToBody? body, + NksDeployBackend backend, + IDeployIntentValidator intentValidator, + IDeployRunsRepository runs, + HttpContext ctx, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(body?.TargetReleaseId)) + return Results.BadRequest(new { error = "targetReleaseId required" }); + + try + { + var run = await runs.GetByIdAsync(deployId, ct); + if (run is null) return Results.NotFound(new { error = "deploy_not_found", deployId }); + + // Same intent kind as plain rollback — both flip the symlink, both + // need operator confirmation. The MCP gate kind is "rollback_to" + // server-side; aligning under "rollback" for the validator keeps + // existing grants applicable to both shapes. + var rejection = await CheckIntentAsync(ctx, intentValidator, "rollback", domain, run.Host, ct); + if (rejection is not null) return rejection; + + // Reuse the same in-flight guard as PostRollback — refuse to + // start a rollback-to while another deploy/rollback is running + // on the same host (race on the symlink swap). + var inFlight = await runs.ListInFlightAsync(ct); + var conflict = inFlight.FirstOrDefault(r => + string.Equals(r.Domain, domain, StringComparison.OrdinalIgnoreCase) && + string.Equals(r.Host, run.Host, StringComparison.OrdinalIgnoreCase) && + !string.Equals(r.Id, deployId, StringComparison.OrdinalIgnoreCase)); + if (conflict is not null) + { + return Results.Conflict(new + { + error = "deploy_in_flight", + message = "Another deploy or rollback is currently running on this host. Wait for it to finish or cancel it first.", + blockingDeployId = conflict.Id, + blockingStatus = conflict.Status, + }); + } + + await backend.RollbackToAsync(deployId, body.TargetReleaseId, ct); + return Results.Accepted(value: new + { + sourceDeployId = deployId, + targetReleaseId = body.TargetReleaseId, + status = "rolled_back", + }); + } + catch (KeyNotFoundException) + { + return Results.NotFound(new { error = "deploy_not_found", deployId }); + } + catch (Exception ex) + { + return Results.Problem( + title: "rollback_to_failed", + detail: ex.Message, + statusCode: StatusCodes.Status500InternalServerError); + } + } + + /// + /// Body for POST .../hooks/test. Mirrors daemon shape so the GUI's + /// per-card "Test" button doesn't need a different request between + /// built-in and plugin backends. Type is one of + /// shell|http|php; Command is the arbitrary payload (URL for + /// http, code/script-path for php, command-line for shell). + /// + public sealed record TestHookBody( + string? Type, + string? Command, + int? TimeoutSeconds, + IReadOnlyDictionary? EnvVars); + + /// + /// Phase B (#109-B4) — ad-hoc hook execution. Mirrors daemon's + /// /api/nks.wdc.deploy/sites/{domain}/hooks/test. Direct C# (no phar + /// involvement) because phar has no hook-test command and the test + /// has no per-deploy context to pass it. Intent-gated under + /// kind=test_hook (arbitrary code execution surface). Domain is + /// part of the path for grant-scoping; the test itself is site-agnostic. + /// + private static async Task PostTestHook( + string domain, + TestHookBody? body, + NksDeployBackend backend, + IDeployIntentValidator intentValidator, + HttpContext ctx, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(body?.Type)) + return Results.BadRequest(new { error = "type required (shell|http|php)" }); + if (string.IsNullOrWhiteSpace(body?.Command)) + return Results.BadRequest(new { error = "command required" }); + + // host = domain because there's no per-host scope for ad-hoc hook + // tests; the operator approves it for the site as a whole. + var rejection = await CheckIntentAsync(ctx, intentValidator, "test_hook", domain, domain, ct); + if (rejection is not null) return rejection; + + var (ok, durationMs, error) = await backend.TestHookAsync( + body.Type!, + body.Command!, + body.TimeoutSeconds ?? 5, + body.EnvVars, + ct); + + return Results.Json(new + { + ok, + durationMs, + error, + }); + } + + /// + /// Body for POST .../notifications/test. SlackWebhook is + /// optional — if absent the backend falls back to per-site + /// deploy-settings.json's notifications.slackWebhook. + /// + public sealed record TestNotificationBody( + string? SlackWebhook, + string? Host); + + /// + /// Phase B (#109-B5) — Slack webhook smoke test. Mirrors daemon's + /// /api/nks.wdc.deploy/sites/{domain}/notifications/test. Direct C# + /// HttpClient post (no phar). Returns the same shape as the daemon + /// so the GUI's per-channel "Test" button doesn't branch on backend. + /// + private static async Task PostTestNotification( + string domain, + TestNotificationBody? body, + NksDeployBackend backend, + CancellationToken ct) + { + var (ok, durationMs, error) = await backend.TestNotificationAsync( + domain, body?.Host, body?.SlackWebhook, ct); + if (!ok && string.Equals(error, "slack_webhook_not_configured", StringComparison.Ordinal)) + return Results.BadRequest(new { error }); + return Results.Json(new { ok, durationMs, error }); + } + + private static Task StartDeployBodyHost( + string domain, + StartDeployBodyWithHost? body, + NksDeployBackend backend, + IDeployEventBroadcaster broadcaster, + IDeployIntentValidator intentValidator, + ILoggerFactory loggerFactory, + HttpContext ctx) + { + var host = string.IsNullOrWhiteSpace(body?.Host) ? "production" : body.Host; + // Re-pack body without Host (StartDeploy gets host from path arg). + var inner = body is null + ? null + : new StartDeployBody(body.IdempotencyKey, body.Options, body.Snapshot); + return StartDeploy(domain, host!, inner, backend, broadcaster, intentValidator, loggerFactory, ctx); + } + + /// + /// Phase B (#109-B1) — TCP-probe a host:port. Mirrors daemon's + /// /api/nks.wdc.deploy/test-host-connection (Program.cs:2196). + /// Pure utility, no NksDeployBackend deps. Used by GUI host-edit + /// dialog's "Test SSH" button. 5s timeout, structured result. + /// + private static async Task TestHostConnection(HttpContext ctx, CancellationToken ct) + { + using var doc = await JsonDocument.ParseAsync(ctx.Request.Body, cancellationToken: ct); + var root = doc.RootElement; + var host = root.TryGetProperty("host", out var hEl) ? hEl.GetString() : null; + var port = root.TryGetProperty("port", out var pEl) && pEl.TryGetInt32(out var p) ? p : 22; + + if (string.IsNullOrWhiteSpace(host)) + return Results.BadRequest(new { error = "host is required" }); + if (port < 1 || port > 65535) + return Results.BadRequest(new { error = "port must be in [1, 65535]" }); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + using var probe = new System.Net.Sockets.TcpClient(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + try + { + await probe.ConnectAsync(host!, port, cts.Token); + sw.Stop(); + return Results.Ok(new { ok = true, latencyMs = sw.ElapsedMilliseconds }); + } + catch (OperationCanceledException) when (cts.IsCancellationRequested && !ct.IsCancellationRequested) + { + sw.Stop(); + return Results.Ok(new + { + ok = false, code = "timeout", + error = $"TCP probe to {host}:{port} timed out after 5s", + }); + } + catch (System.Net.Sockets.SocketException ex) + { + sw.Stop(); + return Results.Ok(new + { + ok = false, code = "socket_error", + error = $"{host}:{port} unreachable: {ex.Message}", + }); + } + catch (Exception ex) + { + sw.Stop(); + return Results.Ok(new + { + ok = false, code = "unexpected", + error = ex.Message, + }); + } + } +} diff --git a/NKS.WebDevConsole.Plugin.NksDeploy/plugin.json b/NKS.WebDevConsole.Plugin.NksDeploy/plugin.json new file mode 100644 index 0000000..4e715ef --- /dev/null +++ b/NKS.WebDevConsole.Plugin.NksDeploy/plugin.json @@ -0,0 +1,20 @@ +{ + "id": "nks.wdc.deploy", + "displayName": "NksDeploy", + "version": "0.1.0", + "description": "Zero-downtime deployment for Nette apps via the nksdeploy PHP CLI. Wraps the bundled nksdeploy.phar as a CliWrap subprocess, streams NDJSON progress events to the wdc UI, and persists run state in the deploy_runs SQLite table. First-class IDeployBackend implementation; alternative backends (Capistrano, Kamal) can ship side by side.", + "author": "NKS", + "license": "MIT", + "entryAssembly": "NKS.WebDevConsole.Plugin.NksDeploy.dll", + "entryType": "NKS.WebDevConsole.Plugin.NksDeploy.NksDeployPlugin", + "serviceType": "Other", + "supportedPlatforms": ["windows", "macos", "linux"], + "minDaemonVersion": "1.0.0", + "dependencies": ["nks.wdc.php"], + "capabilities": [ + "deploy-backend", + "deploy-rollback", + "deploy-history", + "deploy-lock-management" + ] +}