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