diff --git a/README.md b/README.md index 437bc48..9021f5c 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ this is a hand-written **read-API client** (now) + an **SSH-runner** for mutatio | Project | What | | --- | --- | -| `src/SynoSharp/` | The client — `SynologyApiClient` (Web-API read), `SynologyDiscovery` → `SynologySnapshot`, and `Ssh/` (the SSH-runner: `ISshRunner`/`SshRunner`, `SynologyCommand`). SemVer. | -| `src/SynoSharp.Cli/` | `synosharp` dotnet tool — `discover`, `ssh-check`. | -| `tests/SynoSharp.Tests/` | Unit + skippable live tests (Web-API + SSH). | +| `src/SynoSharp/` | The client — `SynologyApiClient` (Web-API read), `SynologyDiscovery` → `SynologySnapshot`, `Ssh/` (the SSH-runner), `Tools/` (typed `synoshare`/`synouser`/`synogroup` wrappers), `Provisioning/` (specs + reconciler). SemVer. | +| `src/SynoSharp.Cli/` | `synosharp` dotnet tool — `discover`, `ssh-check`, `plan`, `apply`. | +| `tests/SynoSharp.Tests/` | Unit (quoting + reconciler) + skippable live tests (Web-API + SSH). | ## Build / use @@ -54,6 +54,22 @@ proves the full stack end-to-end (SSH login → sudo-to-root → on-box `syno*`) (`ISshRunner`/`SshRunner` over SSH.NET) + structured `SynologyCommand` (shell-quoted argv) are the transport for the write path. -**Next:** the first typed mutation — `EnsureShareAsync` with **read-before-write** -(diff the discover snapshot, emit only the needed command) and **dry-run by default**. -NFS exports come last (highest-risk; prove on Virtual DSM, not the live box). +**Write path (Phase A) — reconciler for shares/users/groups, dry-run by default.** +Desired-state specs (`ShareSpec`/`UserSpec`/`GroupSpec`) are diffed against live +state by `SynologyReconciler` → a `SynologyPlan` of create/delete/skip actions; each +carries the exact `synoshare`/`synouser`/`synogroup` command. `ApplyAsync(apply:false)` +is a dry-run (the default). **Verified against the live NAS (2026-05-31):** `plan` +correctly skipped existing resources, planned creates for new ones, and blocked a +passwordless user create — **zero mutation** (reads only). + +```bash +synosharp plan spec.json # diff vs live → dry-run plan (read-only) +synosharp apply spec.json # still a dry-run… +synosharp apply spec.json --confirm # …only this mutates +``` + +The reconciler does **existence reconciliation** (create-if-missing / +delete-if-`present:false` / skip-if-present) and **never prunes** unmanaged +resources. **Next:** field-level drift (desc/ACLs), then NFS exports via +`synowebapi` — last, highest-risk, prove on Virtual DSM (needs an x86/KVM host). +See the [write-path plan](https://github.com/chrison-dev/Homelab/blob/main/docs/plans/057-synosharp-write-path.md). diff --git a/src/SynoSharp.Cli/Program.cs b/src/SynoSharp.Cli/Program.cs index 41d2667..06a2a41 100644 --- a/src/SynoSharp.Cli/Program.cs +++ b/src/SynoSharp.Cli/Program.cs @@ -1,12 +1,18 @@ using System.Text.Json; using SynoSharp; +using SynoSharp.Provisioning; using SynoSharp.Ssh; // synosharp — a thin CLI over the SynoSharp library. // -// Commands: discover (Web-API read), ssh-check (prove the SSH-runner transport) -// Config (env): SYNOLOGY_BASE_URL (e.g. https://nas:5001), SYNOLOGY_USER, -// SYNOLOGY_PASSWORD, SYNOLOGY_VERIFY_TLS (optional, 'false'), +// Commands: +// discover Web-API read → SynologySnapshot (JSON) +// ssh-check prove the SSH-runner transport (login + sudo + read) +// plan diff a desired-state spec vs live → dry-run plan +// apply [--confirm] apply the plan (dry-run unless --confirm) +// +// Config (env): SYNOLOGY_BASE_URL, SYNOLOGY_USER, SYNOLOGY_PASSWORD, +// SYNOLOGY_VERIFY_TLS (optional 'false'), // SYNOLOGY_SSH_HOST/PORT/KEY (optional; host falls back to BASE_URL) var command = args.Length > 0 ? args[0].ToLowerInvariant() : "help"; @@ -18,17 +24,20 @@ synosharp — Synology DSM client Usage: synosharp - discover Dump a SynologySnapshot (DSM version, shares, users) as JSON - ssh-check Prove the SSH-runner: SSH login + sudo-to-root + a read-only syno* read + discover Dump a SynologySnapshot (DSM version, shares, users) as JSON + ssh-check Prove the SSH-runner: login + sudo-to-root + read-only syno* read + plan Diff a desired-state spec against the live box (dry-run) + apply Apply the plan — dry-run unless --confirm is given + [--confirm] - Config (env): SYNOLOGY_BASE_URL (e.g. https://nas:5001), SYNOLOGY_USER, - SYNOLOGY_PASSWORD, SYNOLOGY_VERIFY_TLS (optional, 'false'), + Config (env): SYNOLOGY_BASE_URL, SYNOLOGY_USER, SYNOLOGY_PASSWORD, + SYNOLOGY_VERIFY_TLS (optional 'false'), SYNOLOGY_SSH_HOST/PORT/KEY (optional; host falls back to BASE_URL) """); return 0; } -if (command == "ssh-check") +if (command is "ssh-check" or "plan" or "apply") { var sshOptions = SynologySshOptions.TryFromEnvironment(); if (sshOptions is null) @@ -39,24 +48,68 @@ discover Dump a SynologySnapshot (DSM version, shares, users) as JSON using var runner = new SshRunner(sshOptions); - // 1. Transport, no root — proves SSH login works. - var id = await runner.RunAsync(new SynologyCommand { Executable = "id", RequiresRoot = false }); - Console.WriteLine($"id → exit {id.ExitCode}: {id.StandardOutput.Trim()}"); + if (command == "ssh-check") + { + // 1. Transport, no root — proves SSH login works. + var id = await runner.RunAsync(new SynologyCommand { Executable = "id", RequiresRoot = false }); + Console.WriteLine($"id → exit {id.ExitCode}: {id.StandardOutput.Trim()}"); + + // 2. sudo-to-root + a real read-only syno* read — the full risky stack, ZERO mutation. + var shares = await runner.RunAsync(SynologyCommand.Create("synoshare", "--enum", "ALL")); + Console.WriteLine($"synoshare ALL → exit {shares.ExitCode}"); + if (!string.IsNullOrWhiteSpace(shares.StandardOutput)) + { + Console.WriteLine(shares.StandardOutput.TrimEnd()); + } + return id.Success && shares.Success ? 0 : 1; + } + + // plan / apply + if (args.Length < 2) + { + Console.Error.WriteLine($"Usage: synosharp {command} {(command == "apply" ? " [--confirm]" : "")}"); + return 2; + } - // 2. sudo-to-root + a real read-only syno* read — proves the full risky stack - // (sudo via stdin → root → on-box CLI) with ZERO mutation. - var shares = await runner.RunAsync(SynologyCommand.Create("synoshare", "--enum", "ALL")); - Console.WriteLine($"synoshare ALL → exit {shares.ExitCode}"); - if (!string.IsNullOrWhiteSpace(shares.StandardOutput)) + var specPath = args[1]; + if (!File.Exists(specPath)) { - Console.WriteLine(shares.StandardOutput.TrimEnd()); + Console.Error.WriteLine($"Spec file not found: {specPath}"); + return 2; } - if (!string.IsNullOrWhiteSpace(shares.StandardError)) + + var desired = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(specPath), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (desired is null) { - Console.Error.WriteLine(shares.StandardError.TrimEnd()); + Console.Error.WriteLine("Spec file did not parse to a desired state."); + return 2; } - return id.Success && shares.Success ? 0 : 1; + var reconciler = new SynologyReconciler(runner); + var plan = await reconciler.PlanAsync(desired); + Console.WriteLine(plan.Render()); + + if (command == "plan") + { + return 0; + } + + var confirm = args.Contains("--confirm"); + if (!confirm) + { + Console.WriteLine("\n(dry-run — pass --confirm to apply)"); + return 0; + } + + Console.WriteLine($"\nApplying {plan.Mutations.Count()} change(s)…"); + var result = await reconciler.ApplyAsync(plan, apply: true); + foreach (var outcome in result.Outcomes.Where(o => o.Action.Kind != ActionKind.Skip)) + { + Console.WriteLine($" {outcome.Action.ResourceType} {outcome.Action.Name}: {outcome.Message}"); + } + return result.AllSucceeded ? 0 : 1; } var options = SynologyOptions.TryFromEnvironment(); @@ -80,6 +133,6 @@ discover Dump a SynologySnapshot (DSM version, shares, users) as JSON return 0; default: - Console.Error.WriteLine($"Unknown command '{command}'. Try: discover, ssh-check"); + Console.Error.WriteLine($"Unknown command '{command}'. Try: discover, ssh-check, plan, apply"); return 1; } diff --git a/src/SynoSharp/Provisioning/Specs.cs b/src/SynoSharp/Provisioning/Specs.cs new file mode 100644 index 0000000..4c15feb --- /dev/null +++ b/src/SynoSharp/Provisioning/Specs.cs @@ -0,0 +1,42 @@ +namespace SynoSharp.Provisioning; + +/// Desired state for a shared folder. false = ensure absent. +public sealed record ShareSpec +{ + public required string Name { get; init; } + public string Description { get; init; } = ""; + + /// Full on-box path, e.g. /volume1/MyShare. + public required string Path { get; init; } + + public bool Present { get; init; } = true; +} + +/// Desired state for a local user. false = ensure absent. +public sealed record UserSpec +{ + public required string Name { get; init; } + public string FullName { get; init; } = ""; + public string? Email { get; init; } + + /// Used only when creating the user (never read back); required for a create. + public string? Password { get; init; } + + public bool Present { get; init; } = true; +} + +/// Desired state for a local group. false = ensure absent. +public sealed record GroupSpec +{ + public required string Name { get; init; } + public string Description { get; init; } = ""; + public bool Present { get; init; } = true; +} + +/// A bundle of desired DSM state — the input to the reconciler. +public sealed record SynologyDesiredState +{ + public IReadOnlyList Groups { get; init; } = []; + public IReadOnlyList Users { get; init; } = []; + public IReadOnlyList Shares { get; init; } = []; +} diff --git a/src/SynoSharp/Provisioning/SynologyPlan.cs b/src/SynoSharp/Provisioning/SynologyPlan.cs new file mode 100644 index 0000000..1b6c8e8 --- /dev/null +++ b/src/SynoSharp/Provisioning/SynologyPlan.cs @@ -0,0 +1,81 @@ +using System.Text; +using SynoSharp.Ssh; + +namespace SynoSharp.Provisioning; + +public enum ActionKind +{ + Create, + Delete, + Skip, +} + +/// +/// One reconciled action. is the on-box command to run for a +/// mutation (null for ), and +/// explains the decision (for dry-run output). +/// +public sealed record PlannedAction +{ + public required ActionKind Kind { get; init; } + + /// group / user / share. + public required string ResourceType { get; init; } + + public required string Name { get; init; } + public required string Reason { get; init; } + public SynologyCommand? Command { get; init; } + + public static PlannedAction Create(string type, string name, SynologyCommand command, string reason) + => new() { Kind = ActionKind.Create, ResourceType = type, Name = name, Command = command, Reason = reason }; + + public static PlannedAction Delete(string type, string name, SynologyCommand command, string reason) + => new() { Kind = ActionKind.Delete, ResourceType = type, Name = name, Command = command, Reason = reason }; + + public static PlannedAction Skip(string type, string name, string reason) + => new() { Kind = ActionKind.Skip, ResourceType = type, Name = name, Reason = reason }; +} + +/// The reconciled plan — what would change. Render it for a dry-run. +public sealed record SynologyPlan +{ + public required IReadOnlyList Actions { get; init; } + + /// Just the actions that would mutate the box. + public IEnumerable Mutations => Actions.Where(a => a.Kind != ActionKind.Skip); + + public bool HasChanges => Mutations.Any(); + + public string Render() + { + var sb = new StringBuilder(); + foreach (var a in Actions) + { + var marker = a.Kind switch + { + ActionKind.Create => "+ create", + ActionKind.Delete => "- delete", + _ => "= skip ", + }; + sb.Append(marker).Append(' ').Append(a.ResourceType).Append(' ').Append(a.Name) + .Append(" (").Append(a.Reason).Append(')'); + if (a.Command is not null) + { + sb.Append("\n $ ").Append(a.Command.Render()); + } + sb.Append('\n'); + } + var changes = Mutations.Count(); + sb.Append(changes == 0 ? "No changes." : $"{changes} change(s) to apply."); + return sb.ToString(); + } +} + +/// Outcome of executing (or dry-running) a single planned action. +public sealed record ActionOutcome(PlannedAction Action, bool Applied, string Message); + +public sealed record SynologyApplyResult +{ + public required IReadOnlyList Outcomes { get; init; } + public bool AllSucceeded => Outcomes.All(o => o.Applied || o.Action.Kind == ActionKind.Skip); +} diff --git a/src/SynoSharp/Provisioning/SynologyReconciler.cs b/src/SynoSharp/Provisioning/SynologyReconciler.cs new file mode 100644 index 0000000..e3ea89e --- /dev/null +++ b/src/SynoSharp/Provisioning/SynologyReconciler.cs @@ -0,0 +1,140 @@ +using SynoSharp.Ssh; +using SynoSharp.Tools; + +namespace SynoSharp.Provisioning; + +/// +/// The IaC heart (ADR-0002): diffs desired state against what's live on the box and +/// emits only the needed actions — so the non-idempotent syno* CLIs become +/// safe (re-running --add on an existing resource would otherwise error). +/// +/// Dry-run by default (like Deploy-Shape.ps1): +/// only mutates when apply: true. Unmanaged resources are never pruned — a +/// resource is deleted only when a spec explicitly sets Present = false. +/// +/// +/// Phase A scope: existence reconciliation for shares/users/groups (create-if-missing, +/// delete-if-marked-absent, skip-if-present). Field-level drift (desc/ACLs) is a +/// follow-up — see the issue #57 plan. +/// +/// +public sealed class SynologyReconciler +{ + private readonly ISshRunner _runner; + private readonly SynoShareTool _shares; + private readonly SynoUserTool _users; + private readonly SynoGroupTool _groups; + + public SynologyReconciler(ISshRunner runner) + { + ArgumentNullException.ThrowIfNull(runner); + _runner = runner; + _shares = new SynoShareTool(runner); + _users = new SynoUserTool(runner); + _groups = new SynoGroupTool(runner); + } + + /// Read live state, diff against , and return the plan. + public async Task PlanAsync(SynologyDesiredState desired, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(desired); + + // Read-before-write. Groups → users → shares (a dependency-friendly order). + var existingGroups = await _groups.EnumAsync(cancellationToken).ConfigureAwait(false); + var existingUsers = await _users.EnumAsync(cancellationToken).ConfigureAwait(false); + var existingShares = await _shares.EnumAsync(cancellationToken).ConfigureAwait(false); + + var actions = new List(); + foreach (var g in desired.Groups) + { + actions.Add(PlanGroup(g, Contains(existingGroups, g.Name))); + } + foreach (var u in desired.Users) + { + actions.Add(PlanUser(u, Contains(existingUsers, u.Name))); + } + foreach (var s in desired.Shares) + { + actions.Add(PlanShare(s, Contains(existingShares, s.Name))); + } + + return new SynologyPlan { Actions = actions }; + } + + /// + /// Execute the plan. With false (default) this is a + /// dry-run: nothing runs, each mutation is reported as "would run". + /// + public async Task ApplyAsync(SynologyPlan plan, bool apply = false, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(plan); + + var outcomes = new List(); + foreach (var action in plan.Actions) + { + if (action.Command is null) + { + outcomes.Add(new ActionOutcome(action, Applied: false, $"skipped: {action.Reason}")); + continue; + } + if (!apply) + { + outcomes.Add(new ActionOutcome(action, Applied: false, $"dry-run: would run `{action.Command.Render()}`")); + continue; + } + + var result = await _runner.RunAsync(action.Command, cancellationToken).ConfigureAwait(false); + outcomes.Add(result.Success + ? new ActionOutcome(action, Applied: true, "applied") + : new ActionOutcome(action, Applied: false, $"FAILED (exit {result.ExitCode}): {result.StandardError.Trim()}")); + } + + return new SynologyApplyResult { Outcomes = outcomes }; + } + + private static bool Contains(IReadOnlyList names, string name) + => names.Contains(name, StringComparer.OrdinalIgnoreCase); + + private static PlannedAction PlanGroup(GroupSpec g, bool exists) + { + if (g.Present && !exists) + { + return PlannedAction.Create("group", g.Name, SynoGroupTool.AddCommand(g), "absent → create"); + } + if (!g.Present && exists) + { + return PlannedAction.Delete("group", g.Name, SynoGroupTool.DeleteCommand(g.Name), "present → delete"); + } + return PlannedAction.Skip("group", g.Name, g.Present ? "already present" : "already absent"); + } + + private static PlannedAction PlanUser(UserSpec u, bool exists) + { + if (u.Present && !exists) + { + if (string.IsNullOrEmpty(u.Password)) + { + return PlannedAction.Skip("user", u.Name, "BLOCKED: create needs a Password"); + } + return PlannedAction.Create("user", u.Name, SynoUserTool.AddCommand(u), "absent → create"); + } + if (!u.Present && exists) + { + return PlannedAction.Delete("user", u.Name, SynoUserTool.DeleteCommand(u.Name), "present → delete"); + } + return PlannedAction.Skip("user", u.Name, u.Present ? "already present" : "already absent"); + } + + private static PlannedAction PlanShare(ShareSpec s, bool exists) + { + if (s.Present && !exists) + { + return PlannedAction.Create("share", s.Name, SynoShareTool.AddCommand(s), "absent → create"); + } + if (!s.Present && exists) + { + return PlannedAction.Delete("share", s.Name, SynoShareTool.DeleteCommand(s.Name), "present → delete"); + } + return PlannedAction.Skip("share", s.Name, s.Present ? "already present" : "already absent"); + } +} diff --git a/src/SynoSharp/Tools/EnumOutput.cs b/src/SynoSharp/Tools/EnumOutput.cs new file mode 100644 index 0000000..f725f79 --- /dev/null +++ b/src/SynoSharp/Tools/EnumOutput.cs @@ -0,0 +1,17 @@ +namespace SynoSharp.Tools; + +/// +/// Parses the shared shape of syno* --enum output, which prints a +/// "N … Listed:" header followed by one name per line. Header and diagnostic +/// lines contain a colon; resource names don't — so colon-bearing lines are dropped. +/// (Verified against DSM 7.1.1 for synoshare/synouser/synogroup.) +/// +internal static class EnumOutput +{ + public static IReadOnlyList ParseNames(string standardOutput) + => standardOutput + .Split('\n') + .Select(line => line.Trim()) + .Where(line => line.Length > 0 && !line.Contains(':')) + .ToList(); +} diff --git a/src/SynoSharp/Tools/SynoGroupTool.cs b/src/SynoSharp/Tools/SynoGroupTool.cs new file mode 100644 index 0000000..45f7fc4 --- /dev/null +++ b/src/SynoSharp/Tools/SynoGroupTool.cs @@ -0,0 +1,34 @@ +using SynoSharp.Provisioning; +using SynoSharp.Ssh; + +namespace SynoSharp.Tools; + +/// Typed wrapper over the on-box synogroup CLI (DSM 7.1). +public sealed class SynoGroupTool +{ + private readonly ISshRunner _runner; + + public SynoGroupTool(ISshRunner runner) + { + ArgumentNullException.ThrowIfNull(runner); + _runner = runner; + } + + public async Task> EnumAsync(CancellationToken cancellationToken = default) + { + var result = await _runner.RunAsync(SynologyCommand.Create("synogroup", "--enum", "local"), cancellationToken).ConfigureAwait(false); + if (!result.Success) + { + throw new SynologyToolException("synogroup --enum local", result); + } + return EnumOutput.ParseNames(result.StandardOutput); + } + + /// synogroup --add groupname [members…] — created empty here. + public static SynologyCommand AddCommand(GroupSpec spec) + => SynologyCommand.Create("synogroup", "--add", spec.Name); + + /// synogroup --del groupname. + public static SynologyCommand DeleteCommand(string name) + => SynologyCommand.Create("synogroup", "--del", name); +} diff --git a/src/SynoSharp/Tools/SynoShareTool.cs b/src/SynoSharp/Tools/SynoShareTool.cs new file mode 100644 index 0000000..d186e5a --- /dev/null +++ b/src/SynoSharp/Tools/SynoShareTool.cs @@ -0,0 +1,42 @@ +using SynoSharp.Provisioning; +using SynoSharp.Ssh; + +namespace SynoSharp.Tools; + +/// +/// Typed wrapper over the on-box synoshare CLI — encodes its positional argv +/// once (DSM 7.1) so callers never hand-write it. Read (--enum) executes; +/// mutations are returned as s for the reconciler to +/// plan/apply, so nothing here mutates on its own. +/// +public sealed class SynoShareTool +{ + private readonly ISshRunner _runner; + + public SynoShareTool(ISshRunner runner) + { + ArgumentNullException.ThrowIfNull(runner); + _runner = runner; + } + + public async Task> EnumAsync(CancellationToken cancellationToken = default) + { + var result = await _runner.RunAsync(SynologyCommand.Create("synoshare", "--enum", "ALL"), cancellationToken).ConfigureAwait(false); + if (!result.Success) + { + throw new SynologyToolException("synoshare --enum ALL", result); + } + return EnumOutput.ParseNames(result.StandardOutput); + } + + /// + /// synoshare --add name desc path na rw ro browsable{0|1} adv_privilege{0~7}. + /// na/rw/ro are comma-separated user lists (empty = none); created browsable, basic privilege. + /// + public static SynologyCommand AddCommand(ShareSpec spec) + => SynologyCommand.Create("synoshare", "--add", spec.Name, spec.Description, spec.Path, "", "", "", "1", "0"); + + /// synoshare --del {TRUE|FALSE} name — FALSE keeps the underlying data dir. + public static SynologyCommand DeleteCommand(string name, bool deleteData = false) + => SynologyCommand.Create("synoshare", "--del", deleteData ? "TRUE" : "FALSE", name); +} diff --git a/src/SynoSharp/Tools/SynoUserTool.cs b/src/SynoSharp/Tools/SynoUserTool.cs new file mode 100644 index 0000000..deb25d0 --- /dev/null +++ b/src/SynoSharp/Tools/SynoUserTool.cs @@ -0,0 +1,43 @@ +using SynoSharp.Provisioning; +using SynoSharp.Ssh; + +namespace SynoSharp.Tools; + +/// Typed wrapper over the on-box synouser CLI (DSM 7.1). +public sealed class SynoUserTool +{ + private readonly ISshRunner _runner; + + public SynoUserTool(ISshRunner runner) + { + ArgumentNullException.ThrowIfNull(runner); + _runner = runner; + } + + public async Task> EnumAsync(CancellationToken cancellationToken = default) + { + var result = await _runner.RunAsync(SynologyCommand.Create("synouser", "--enum", "local"), cancellationToken).ConfigureAwait(false); + if (!result.Success) + { + throw new SynologyToolException("synouser --enum local", result); + } + return EnumOutput.ParseNames(result.StandardOutput); + } + + /// + /// synouser --add username pwd "full name" expired{0|1} mail privilege. + /// A password is required to create a user; .Password must be set. + /// + public static SynologyCommand AddCommand(UserSpec spec) + { + if (string.IsNullOrEmpty(spec.Password)) + { + throw new InvalidOperationException($"Cannot create user '{spec.Name}' without a Password."); + } + return SynologyCommand.Create("synouser", "--add", spec.Name, spec.Password, spec.FullName, "0", spec.Email ?? "", ""); + } + + /// synouser --del username. + public static SynologyCommand DeleteCommand(string name) + => SynologyCommand.Create("synouser", "--del", name); +} diff --git a/src/SynoSharp/Tools/SynologyToolException.cs b/src/SynoSharp/Tools/SynologyToolException.cs new file mode 100644 index 0000000..b855458 --- /dev/null +++ b/src/SynoSharp/Tools/SynologyToolException.cs @@ -0,0 +1,15 @@ +using SynoSharp.Ssh; + +namespace SynoSharp.Tools; + +/// Thrown when an on-box syno* tool returns a non-zero exit code. +public sealed class SynologyToolException : Exception +{ + public SshCommandResult Result { get; } + + public SynologyToolException(string what, SshCommandResult result) + : base($"{what} failed (exit {result.ExitCode}): {result.StandardError.Trim()}") + { + Result = result; + } +} diff --git a/tests/SynoSharp.Tests/ReconcilerTests.cs b/tests/SynoSharp.Tests/ReconcilerTests.cs new file mode 100644 index 0000000..544b77b --- /dev/null +++ b/tests/SynoSharp.Tests/ReconcilerTests.cs @@ -0,0 +1,129 @@ +using SynoSharp.Provisioning; +using SynoSharp.Ssh; +using Xunit; + +namespace SynoSharp.Tests; + +/// +/// A scripted — returns canned enum output for the read +/// commands and records every command it's asked to run, so the reconciler can be +/// tested without a NAS. +/// +internal sealed class FakeSshRunner : ISshRunner +{ + public List Ran { get; } = []; + + public Task RunAsync(SynologyCommand command, CancellationToken cancellationToken = default) + { + Ran.Add(command); + var args = string.Join(' ', command.Args); + var stdout = (command.Executable, args) switch + { + ("synogroup", "--enum local") => "3 Group Listed:\nadministrators\nhttp\nusers\n", + ("synouser", "--enum local") => "2 User Listed:\nadmin\nhomelab\n", + ("synoshare", "--enum ALL") => "Share Enum Arguments: [0xFF0F] ALL\n2 Listed:\nVolume-1\nweb\n", + _ => "", + }; + return Task.FromResult(new SshCommandResult(0, stdout, "")); + } +} + +public class ReconcilerTests +{ + private static SynologyReconciler ReconcilerWith(out FakeSshRunner fake) + { + fake = new FakeSshRunner(); + return new SynologyReconciler(fake); + } + + [Fact] + public async Task Plan_creates_missing_and_skips_existing() + { + var reconciler = ReconcilerWith(out _); + var desired = new SynologyDesiredState + { + Shares = + [ + new ShareSpec { Name = "Volume-1", Path = "/volume1/Volume-1" }, // exists → skip + new ShareSpec { Name = "NewShare", Path = "/volume1/NewShare" }, // missing → create + ], + }; + + var plan = await reconciler.PlanAsync(desired); + + var create = Assert.Single(plan.Mutations); + Assert.Equal(ActionKind.Create, create.Kind); + Assert.Equal("NewShare", create.Name); + Assert.Equal("synoshare --add NewShare '' /volume1/NewShare '' '' '' 1 0", create.Command!.Render()); + } + + [Fact] + public async Task Plan_deletes_when_present_false() + { + var reconciler = ReconcilerWith(out _); + var desired = new SynologyDesiredState + { + Groups = [new GroupSpec { Name = "http", Present = false }], // exists → delete + }; + + var plan = await reconciler.PlanAsync(desired); + + var delete = Assert.Single(plan.Mutations); + Assert.Equal(ActionKind.Delete, delete.Kind); + Assert.Equal("synogroup --del http", delete.Command!.Render()); + } + + [Fact] + public async Task Plan_blocks_user_create_without_password() + { + var reconciler = ReconcilerWith(out _); + var desired = new SynologyDesiredState + { + Users = [new UserSpec { Name = "svc-new" }], // missing + no password + }; + + var plan = await reconciler.PlanAsync(desired); + + Assert.False(plan.HasChanges); + var skip = Assert.Single(plan.Actions); + Assert.Equal(ActionKind.Skip, skip.Kind); + Assert.Contains("BLOCKED", skip.Reason); + } + + [Fact] + public async Task DryRun_apply_runs_no_mutating_commands() + { + var reconciler = ReconcilerWith(out var fake); + var desired = new SynologyDesiredState + { + Shares = [new ShareSpec { Name = "NewShare", Path = "/volume1/NewShare" }], + }; + + var plan = await reconciler.PlanAsync(desired); + var readsAfterPlan = fake.Ran.Count; + + var result = await reconciler.ApplyAsync(plan, apply: false); + + // Dry-run must not have run anything beyond the read/enum done during planning. + Assert.Equal(readsAfterPlan, fake.Ran.Count); + Assert.DoesNotContain(fake.Ran, c => c.Args.Contains("--add")); + Assert.All(result.Outcomes.Where(o => o.Action.Kind != ActionKind.Skip), + o => Assert.Contains("dry-run", o.Message)); + } + + [Fact] + public async Task Apply_runs_mutating_commands_when_confirmed() + { + var reconciler = ReconcilerWith(out var fake); + var desired = new SynologyDesiredState + { + Shares = [new ShareSpec { Name = "NewShare", Path = "/volume1/NewShare" }], + }; + + var plan = await reconciler.PlanAsync(desired); + var result = await reconciler.ApplyAsync(plan, apply: true); + + Assert.True(result.AllSucceeded); + Assert.Contains(fake.Ran, c => c.Executable == "synoshare" && c.Args.Contains("--add")); + } +}