Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
c2831cd
feat(plugin): scaffold NksDeploy plugin with IDeployBackend stub
lukyrys Apr 26, 2026
c4a2cb0
feat(plugin): wire NksDeployBackend.StartDeployAsync to nksdeploy.phar
lukyrys Apr 26, 2026
8860f60
feat(plugin): implement GetStatusAsync + GetHistoryAsync via deploy_runs
lukyrys Apr 26, 2026
d659f2c
feat(plugin): wire NksDeployBackend.RollbackAsync to nksdeploy rollback
lukyrys Apr 26, 2026
f8a15ed
fix(plugin): add CliWrap.Buffered using for ExecuteBufferedAsync
lukyrys Apr 26, 2026
df67aab
feat(plugin): wire 5 REST endpoints via RegisterEndpoints (deploy, st…
lukyrys Apr 26, 2026
a5004bf
feat(plugin): scaffold LocalRsyncBackend to validate IDeployBackend a…
lukyrys Apr 26, 2026
11b22ff
feat(nksdeploy): validate X-Intent-Token on destructive endpoints
lukyrys Apr 26, 2026
65d2640
feat(nksdeploy): cancel watchdog — force-kill PHP subprocess after 10…
lukyrys Apr 26, 2026
207ea3c
fix(nksdeploy): isolate per-line handler + best-effort DB mirror writes
lukyrys Apr 26, 2026
d6e6614
fix(nksdeploy): rollback returns 409 when another deploy in-flight on…
lukyrys Apr 26, 2026
607eff5
feat(nksdeploy): map pending_confirmation to 425 + honor X-Allow-Unco…
lukyrys Apr 26, 2026
a3dd61e
fix(nksdeploy): pass PHP -d display_errors=0 -d error_reporting=0 to …
lukyrys Apr 26, 2026
8c28b27
feat(nksdeploy): NksDeployGroupCoordinator — atomic multi-host fan-out
lukyrys Apr 26, 2026
d43f2e5
feat(nksdeploy): 3 REST endpoints for deploy groups (start/get/rollback)
lukyrys Apr 26, 2026
58e067e
feat(nksdeploy): wire pre-deploy snapshot before subprocess spawn + g…
lukyrys Apr 26, 2026
687288c
feat(nksdeploy): persist per-site settings + snapshot-info + snapshot…
lukyrys Apr 26, 2026
5070813
feat(nksdeploy): POST /snapshots/{deployId}/restore with intent + con…
lukyrys Apr 26, 2026
e102238
feat(nksdeploy): POST /sites/{domain}/snapshot-now — on-demand DB bac…
lukyrys Apr 26, 2026
cfa6d46
feat(nksdeploy): GET /sites/{domain}/groups — list deploy_groups for …
lukyrys Apr 26, 2026
097ae7a
feat(nksdeploy): coordinator stamps group_id on per-host runs + ListG…
lukyrys Apr 26, 2026
35c38d7
feat(nksdeploy): include GroupId in DeployResult mapping
lukyrys Apr 26, 2026
8958565
feat(nksdeploy): POST test-host-connection — phase B endpoint parity …
lukyrys Apr 27, 2026
51a1d19
feat(nksdeploy): POST sites/{domain}/deploy — phase B convenience wra…
lukyrys Apr 27, 2026
5656809
refactor(nksdeploy): register NksDeployBackend as concrete + IDeployB…
lukyrys Apr 27, 2026
7875bab
feat(nksdeploy): RollbackToAsync — accepts targetReleaseId, appends -…
lukyrys Apr 27, 2026
6f3c12e
feat(nksdeploy): POST sites/{domain}/deploys/{deployId}/rollback-to —…
lukyrys Apr 27, 2026
2893d0b
feat(nksdeploy): TestHookAsync — direct C# hook execution for hooks/t…
lukyrys Apr 27, 2026
c7b87ff
feat(nksdeploy): POST sites/{domain}/hooks/test — phase B endpoint, i…
lukyrys Apr 27, 2026
abf5090
feat(nksdeploy): TestNotificationAsync — Slack webhook smoke-test for…
lukyrys Apr 27, 2026
74c61c8
feat(nksdeploy): POST sites/{domain}/notifications/test — phase B Sla…
lukyrys Apr 27, 2026
6cd22a5
feat(nksdeploy): SnapshotCurrentReleaseAsync — direct C# ZIP of resol…
lukyrys Apr 27, 2026
6608838
feat(nksdeploy): PostSnapshotNow tries filesystem ZIP first, falls ba…
lukyrys Apr 27, 2026
967c6ef
feat(nksdeploy): SSE deploy:hook bridge — TestHookAsync broadcasts vi…
lukyrys Apr 27, 2026
b7b87f2
Merge remote-tracking branch 'origin/main' into feature/nksdeploy-wdc…
lukyrys May 3, 2026
6a95328
fix(deploy): isolate backend route handlers
lukyrys May 3, 2026
a769ff8
chore: align plugin sdk package version
lukyrys May 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -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. -->
<ItemGroup>
<PackageReference Include="NKS.WebDevConsole.Plugin.SDK" Version="0.2.4" />
<PackageReference Include="NKS.WebDevConsole.Plugin.SDK" Version="0.3.1" />
</ItemGroup>
</Project>
234 changes: 234 additions & 0 deletions NKS.WebDevConsole.Plugin.LocalRsync/LocalRsyncBackend.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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
/// <c>backend: localrsync</c> key AND a <c>localrsync.target</c> path. The
/// CanDeploy probe parses just enough NEON (line-grep — no schema) to
/// detect those keys; full parsing happens at deploy time.
///
/// Mechanism: <c>rsync -av --delete {siteRoot}/ {target}/</c>. 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.
/// </summary>
public sealed class LocalRsyncBackend : IDeployBackend
{
private readonly ISiteRegistry _siteRegistry;
private readonly IDeployRunsRepository _runs;
private readonly ILogger<LocalRsyncBackend> _logger;
private readonly ConcurrentDictionary<string, CancellationTokenSource> _active = new();

public LocalRsyncBackend(
ISiteRegistry siteRegistry,
IDeployRunsRepository runs,
ILogger<LocalRsyncBackend> 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<string> StartDeployAsync(
DeployRequest request,
IProgress<DeployEvent> 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<DeployResult> 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<IReadOnlyList<DeployHistoryEntry>> 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,
};
}
36 changes: 36 additions & 0 deletions NKS.WebDevConsole.Plugin.LocalRsync/LocalRsyncPlugin.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Plugin entry-point for the local-rsync deploy backend. Registers
/// <see cref="LocalRsyncBackend"/> as a SECOND IDeployBackend implementation
/// alongside <c>NksDeployBackend</c>. The daemon picks among multiple
/// registered backends by calling <c>CanDeploy(domain)</c> and selecting
/// the first that returns true — local-rsync only matches when the site's
/// deploy.neon explicitly opts in via a <c>backend: localrsync</c> key, so
/// it will not steal deploys away from NksDeploy.
/// </summary>
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<IDeployBackend> 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<IDeployBackend, LocalRsyncBackend>();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>NKS.WebDevConsole.Plugin.LocalRsync</AssemblyName>
<RootNamespace>NKS.WebDevConsole.Plugin.LocalRsync</RootNamespace>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliWrap" Version="3.10.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.6" />
</ItemGroup>

<!-- Local-dev override identical to NksDeploy's: prefer the unreleased
SDK from the sibling nks-ws checkout when present, fall back to the
Directory.Build.props PackageReference for CI builds. -->
<ItemGroup Condition="Exists('..\..\nks-ws\src\daemon\NKS.WebDevConsole.Plugin.SDK\NKS.WebDevConsole.Plugin.SDK.csproj')">
<PackageReference Remove="NKS.WebDevConsole.Plugin.SDK" />
<ProjectReference Include="..\..\nks-ws\src\daemon\NKS.WebDevConsole.Plugin.SDK\NKS.WebDevConsole.Plugin.SDK.csproj" />
</ItemGroup>

<ItemGroup>
<None Include="plugin.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
15 changes: 15 additions & 0 deletions NKS.WebDevConsole.Plugin.LocalRsync/plugin.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>NKS.WebDevConsole.Plugin.NksDeploy</AssemblyName>
<RootNamespace>NKS.WebDevConsole.Plugin.NksDeploy</RootNamespace>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>

<!-- Plugin contributes REST endpoints (IResult, HttpContext, Results.Ok …)
so it needs the ASP.NET Core framework reference. The daemon already
carries this — we use Private=false so the plugin DLL doesn't ship a
second copy that would resolve to a different type identity from the
host's. -->
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" Private="false" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CliWrap" Version="3.10.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.6" />
</ItemGroup>

<!-- Local-dev override: when the nks-ws monorepo is checked out as a sibling
at ../../nks-ws (the standard layout under C:\work\sources\), reference
the SDK csproj directly so unreleased SDK changes (e.g. the new
IDeployBackend interface from the integration branch) are picked up
without waiting for a publish-sdk.yml run. CI without the sibling repo
falls back to the Directory.Build.props PackageReference (currently 0.3.1
— must be bumped to a release containing IDeployBackend before this
plugin is published from CI). -->
<ItemGroup Condition="Exists('..\..\nks-ws\src\daemon\NKS.WebDevConsole.Plugin.SDK\NKS.WebDevConsole.Plugin.SDK.csproj')">
<PackageReference Remove="NKS.WebDevConsole.Plugin.SDK" />
<ProjectReference Include="..\..\nks-ws\src\daemon\NKS.WebDevConsole.Plugin.SDK\NKS.WebDevConsole.Plugin.SDK.csproj" />
</ItemGroup>

<ItemGroup>
<None Include="plugin.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
Loading
Loading