diff --git a/README.md b/README.md index d8e2c1e..7abb92c 100644 --- a/README.md +++ b/README.md @@ -68,10 +68,14 @@ 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. Share deletes **keep data by default** (DSM shares are btrfs subvolumes); -set `ShareSpec.DeleteData = true` for a destructive `synoshare --del TRUE`. -**Next:** field-level drift (desc/ACLs), then NFS exports via -`synowebapi` — last, highest-risk, prove on Virtual DSM (needs an x86/KVM host). +The reconciler does **existence + field reconciliation**: create-if-missing / +delete-if-`present:false` / **modify-on-drift** / skip-if-in-sync, and **never +prunes** unmanaged resources. Field drift is checked for share/group `Description` +and user `FullName`/`Email` (read via `--get`/`--descget`, set via +`--setdesc`/`--descset`/`--modify`); **empty/null spec fields are unmanaged** (never +clobbered), and `expired` is preserved. Share deletes **keep data by default** (DSM +shares are btrfs subvolumes); set `ShareSpec.DeleteData = true` for a destructive +`synoshare --del TRUE`. **Next:** list-valued fields (share ACLs, group membership), +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/Provisioning/SynologyPlan.cs b/src/SynoSharp/Provisioning/SynologyPlan.cs index 1b6c8e8..47522ab 100644 --- a/src/SynoSharp/Provisioning/SynologyPlan.cs +++ b/src/SynoSharp/Provisioning/SynologyPlan.cs @@ -6,6 +6,7 @@ namespace SynoSharp.Provisioning; public enum ActionKind { Create, + Modify, Delete, Skip, } @@ -29,6 +30,9 @@ public sealed record PlannedAction 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 Modify(string type, string name, SynologyCommand command, string reason) + => new() { Kind = ActionKind.Modify, 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 }; @@ -54,6 +58,7 @@ public string Render() var marker = a.Kind switch { ActionKind.Create => "+ create", + ActionKind.Modify => "~ modify", ActionKind.Delete => "- delete", _ => "= skip ", }; diff --git a/src/SynoSharp/Provisioning/SynologyReconciler.cs b/src/SynoSharp/Provisioning/SynologyReconciler.cs index b2b4c48..a964189 100644 --- a/src/SynoSharp/Provisioning/SynologyReconciler.cs +++ b/src/SynoSharp/Provisioning/SynologyReconciler.cs @@ -47,15 +47,15 @@ public async Task PlanAsync(SynologyDesiredState desired, Cancella var actions = new List(); foreach (var g in desired.Groups) { - actions.Add(PlanGroup(g, Contains(existingGroups, g.Name))); + actions.Add(await PlanGroupAsync(g, Contains(existingGroups, g.Name), cancellationToken).ConfigureAwait(false)); } foreach (var u in desired.Users) { - actions.Add(PlanUser(u, Contains(existingUsers, u.Name))); + actions.Add(await PlanUserAsync(u, Contains(existingUsers, u.Name), cancellationToken).ConfigureAwait(false)); } foreach (var s in desired.Shares) { - actions.Add(PlanShare(s, Contains(existingShares, s.Name))); + actions.Add(await PlanShareAsync(s, Contains(existingShares, s.Name), cancellationToken).ConfigureAwait(false)); } return new SynologyPlan { Actions = actions }; @@ -95,7 +95,10 @@ public async Task ApplyAsync(SynologyPlan plan, bool apply private static bool Contains(IReadOnlyList names, string name) => names.Contains(name, StringComparer.OrdinalIgnoreCase); - private static PlannedAction PlanGroup(GroupSpec g, bool exists) + private static bool Differs(string current, string desired) + => !string.Equals(current, desired, StringComparison.Ordinal); + + private async Task PlanGroupAsync(GroupSpec g, bool exists, CancellationToken ct) { if (g.Present && !exists) { @@ -105,10 +108,25 @@ private static PlannedAction PlanGroup(GroupSpec g, bool 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"); + if (!g.Present) + { + return PlannedAction.Skip("group", g.Name, "already absent"); + } + + // Present + exists → field drift. Empty Description = unmanaged (don't clobber). + if (!string.IsNullOrEmpty(g.Description)) + { + var current = await _groups.GetDescriptionAsync(g.Name, ct).ConfigureAwait(false); + if (current is not null && Differs(current, g.Description)) + { + return PlannedAction.Modify("group", g.Name, SynoGroupTool.SetDescriptionCommand(g.Name, g.Description), + $"desc '{current}' → '{g.Description}'"); + } + } + return PlannedAction.Skip("group", g.Name, "in sync"); } - private static PlannedAction PlanUser(UserSpec u, bool exists) + private async Task PlanUserAsync(UserSpec u, bool exists, CancellationToken ct) { if (u.Present && !exists) { @@ -122,10 +140,29 @@ private static PlannedAction PlanUser(UserSpec u, bool 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"); + if (!u.Present) + { + return PlannedAction.Skip("user", u.Name, "already absent"); + } + + // Present + exists → field drift on FullName / Email (empty/null = unmanaged). + var current = await _users.GetAsync(u.Name, ct).ConfigureAwait(false); + if (current is not null) + { + var fullNameDrift = !string.IsNullOrEmpty(u.FullName) && Differs(current.FullName, u.FullName); + var emailDrift = u.Email is not null && Differs(current.Email, u.Email); + if (fullNameDrift || emailDrift) + { + var reasons = new List(); + if (fullNameDrift) reasons.Add($"name '{current.FullName}' → '{u.FullName}'"); + if (emailDrift) reasons.Add($"mail '{current.Email}' → '{u.Email}'"); + return PlannedAction.Modify("user", u.Name, SynoUserTool.ModifyCommand(u, current), string.Join(", ", reasons)); + } + } + return PlannedAction.Skip("user", u.Name, "in sync"); } - private static PlannedAction PlanShare(ShareSpec s, bool exists) + private async Task PlanShareAsync(ShareSpec s, bool exists, CancellationToken ct) { if (s.Present && !exists) { @@ -136,6 +173,21 @@ private static PlannedAction PlanShare(ShareSpec s, bool exists) var reason = s.DeleteData ? "present → delete (incl. data)" : "present → delete (keep data)"; return PlannedAction.Delete("share", s.Name, SynoShareTool.DeleteCommand(s.Name, s.DeleteData), reason); } - return PlannedAction.Skip("share", s.Name, s.Present ? "already present" : "already absent"); + if (!s.Present) + { + return PlannedAction.Skip("share", s.Name, "already absent"); + } + + // Present + exists → field drift on Description (empty = unmanaged). + if (!string.IsNullOrEmpty(s.Description)) + { + var current = await _shares.GetAsync(s.Name, ct).ConfigureAwait(false); + if (current is not null && Differs(current.Description, s.Description)) + { + return PlannedAction.Modify("share", s.Name, SynoShareTool.SetDescriptionCommand(s.Name, s.Description), + $"desc '{current.Description}' → '{s.Description}'"); + } + } + return PlannedAction.Skip("share", s.Name, "in sync"); } } diff --git a/src/SynoSharp/Tools/GetFields.cs b/src/SynoSharp/Tools/GetFields.cs new file mode 100644 index 0000000..5184621 --- /dev/null +++ b/src/SynoSharp/Tools/GetFields.cs @@ -0,0 +1,44 @@ +namespace SynoSharp.Tools; + +/// +/// Parses the label … [value] shape of syno* --get / --descget +/// output into a label→value map. Works for both synoshare --get (dotted: +/// Comment ....[x]) and synouser --get (colon: User Mail : [x]): +/// the value is the text inside the line's first [...], and the label is what +/// precedes it (dots/colons/whitespace trimmed). Verified against DSM 7.1.1. +/// +internal static class GetFields +{ + public static IReadOnlyDictionary Parse(string standardOutput) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var raw in standardOutput.Split('\n')) + { + var line = raw.Trim(); + var open = line.IndexOf('['); + var close = line.LastIndexOf(']'); + if (open < 0 || close <= open) + { + continue; + } + var label = line[..open].Trim(' ', '.', ':', '\t'); + if (label.Length > 0 && !map.ContainsKey(label)) + { + map[label] = line[(open + 1)..close]; + } + } + return map; + } + + /// The text inside the first [...] anywhere in the output (e.g. --descget). + public static string? FirstBracket(string standardOutput) + { + var open = standardOutput.IndexOf('['); + if (open < 0) + { + return null; + } + var close = standardOutput.IndexOf(']', open + 1); + return close > open ? standardOutput[(open + 1)..close] : null; + } +} diff --git a/src/SynoSharp/Tools/ResourceInfo.cs b/src/SynoSharp/Tools/ResourceInfo.cs new file mode 100644 index 0000000..214905c --- /dev/null +++ b/src/SynoSharp/Tools/ResourceInfo.cs @@ -0,0 +1,18 @@ +namespace SynoSharp.Tools; + +/// Current on-box state of a share (from synoshare --get) — for drift. +public sealed record ShareInfo +{ + public required string Name { get; init; } + public string Description { get; init; } = ""; + public string Path { get; init; } = ""; +} + +/// Current on-box state of a user (from synouser --get) — for drift. +public sealed record UserInfo +{ + public required string Name { get; init; } + public string FullName { get; init; } = ""; + public string Email { get; init; } = ""; + public bool Expired { get; init; } +} diff --git a/src/SynoSharp/Tools/SynoGroupTool.cs b/src/SynoSharp/Tools/SynoGroupTool.cs index 45f7fc4..5b8d8b0 100644 --- a/src/SynoSharp/Tools/SynoGroupTool.cs +++ b/src/SynoSharp/Tools/SynoGroupTool.cs @@ -24,10 +24,24 @@ public async Task> EnumAsync(CancellationToken cancellatio return EnumOutput.ParseNames(result.StandardOutput); } + /// + /// Read the group's description via synogroup --descget; null if the group + /// doesn't exist. (--get doesn't include the description on DSM 7.1.) + /// + public async Task GetDescriptionAsync(string name, CancellationToken cancellationToken = default) + { + var result = await _runner.RunAsync(SynologyCommand.Create("synogroup", "--descget", name), cancellationToken).ConfigureAwait(false); + return result.Success ? GetFields.FirstBracket(result.StandardOutput) ?? "" : null; + } + /// synogroup --add groupname [members…] — created empty here. public static SynologyCommand AddCommand(GroupSpec spec) => SynologyCommand.Create("synogroup", "--add", spec.Name); + /// synogroup --descset groupname "desc". + public static SynologyCommand SetDescriptionCommand(string name, string description) + => SynologyCommand.Create("synogroup", "--descset", name, description); + /// 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 index d186e5a..238d353 100644 --- a/src/SynoSharp/Tools/SynoShareTool.cs +++ b/src/SynoSharp/Tools/SynoShareTool.cs @@ -29,6 +29,23 @@ public async Task> EnumAsync(CancellationToken cancellatio return EnumOutput.ParseNames(result.StandardOutput); } + /// Read current state via synoshare --get; null if the share doesn't exist. + public async Task GetAsync(string name, CancellationToken cancellationToken = default) + { + var result = await _runner.RunAsync(SynologyCommand.Create("synoshare", "--get", name), cancellationToken).ConfigureAwait(false); + if (!result.Success) + { + return null; + } + var fields = GetFields.Parse(result.StandardOutput); + return new ShareInfo + { + Name = fields.GetValueOrDefault("Name") ?? name, + Description = fields.GetValueOrDefault("Comment") ?? "", + Path = fields.GetValueOrDefault("Path") ?? "", + }; + } + /// /// 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. @@ -36,6 +53,10 @@ public async Task> EnumAsync(CancellationToken cancellatio public static SynologyCommand AddCommand(ShareSpec spec) => SynologyCommand.Create("synoshare", "--add", spec.Name, spec.Description, spec.Path, "", "", "", "1", "0"); + /// synoshare --setdesc name desc. + public static SynologyCommand SetDescriptionCommand(string name, string description) + => SynologyCommand.Create("synoshare", "--setdesc", name, description); + /// 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 index deb25d0..e4c4037 100644 --- a/src/SynoSharp/Tools/SynoUserTool.cs +++ b/src/SynoSharp/Tools/SynoUserTool.cs @@ -24,6 +24,24 @@ public async Task> EnumAsync(CancellationToken cancellatio return EnumOutput.ParseNames(result.StandardOutput); } + /// Read current state via synouser --get; null if the user doesn't exist. + public async Task GetAsync(string name, CancellationToken cancellationToken = default) + { + var result = await _runner.RunAsync(SynologyCommand.Create("synouser", "--get", name), cancellationToken).ConfigureAwait(false); + if (!result.Success) + { + return null; + } + var fields = GetFields.Parse(result.StandardOutput); + return new UserInfo + { + Name = fields.GetValueOrDefault("User Name") ?? name, + FullName = fields.GetValueOrDefault("Fullname") ?? "", + Email = fields.GetValueOrDefault("User Mail") ?? "", + Expired = string.Equals(fields.GetValueOrDefault("Expired"), "true", StringComparison.OrdinalIgnoreCase), + }; + } + /// /// synouser --add username pwd "full name" expired{0|1} mail privilege. /// A password is required to create a user; .Password must be set. @@ -37,6 +55,18 @@ public static SynologyCommand AddCommand(UserSpec spec) return SynologyCommand.Create("synouser", "--add", spec.Name, spec.Password, spec.FullName, "0", spec.Email ?? "", ""); } + /// + /// synouser --modify username "full name" expired{0|1} mail. Empty/null + /// spec fields fall back to so unmanaged values aren't + /// clobbered; expired is always preserved (not modelled in the spec). + /// + public static SynologyCommand ModifyCommand(UserSpec spec, UserInfo current) + { + var fullName = string.IsNullOrEmpty(spec.FullName) ? current.FullName : spec.FullName; + var email = spec.Email ?? current.Email; + return SynologyCommand.Create("synouser", "--modify", spec.Name, fullName, current.Expired ? "1" : "0", email); + } + /// synouser --del username. public static SynologyCommand DeleteCommand(string name) => SynologyCommand.Create("synouser", "--del", name); diff --git a/tests/SynoSharp.Tests/ReconcilerTests.cs b/tests/SynoSharp.Tests/ReconcilerTests.cs index 2158339..a6b9790 100644 --- a/tests/SynoSharp.Tests/ReconcilerTests.cs +++ b/tests/SynoSharp.Tests/ReconcilerTests.cs @@ -22,6 +22,10 @@ public Task RunAsync(SynologyCommand command, CancellationToke ("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", + // --get / --descget for drift (canned "current" state) + ("synoshare", "--get Volume-1") => "\t Name .......[Volume-1]\n\t Comment ....[old desc]\n\t Path .......[/volume1/Volume-1]\n", + ("synouser", "--get homelab") => "User Name : [homelab]\nFullname : [Old Name]\nExpired : [false]\nUser Mail : [old@x.test]\n", + ("synogroup", "--descget users") => "users:[old group desc]\n", _ => "", }; return Task.FromResult(new SshCommandResult(0, stdout, "")); @@ -73,6 +77,83 @@ public async Task Plan_deletes_when_present_false() Assert.Equal("synogroup --del http", delete.Command!.Render()); } + [Fact] + public async Task Plan_share_description_drift_emits_setdesc() + { + var reconciler = ReconcilerWith(out _); + var desired = new SynologyDesiredState + { + Shares = [new ShareSpec { Name = "Volume-1", Path = "/volume1/Volume-1", Description = "new desc" }], + }; + + var plan = await reconciler.PlanAsync(desired); + + var modify = Assert.Single(plan.Mutations); + Assert.Equal(ActionKind.Modify, modify.Kind); + Assert.Equal("synoshare --setdesc Volume-1 'new desc'", modify.Command!.Render()); + } + + [Fact] + public async Task Plan_no_drift_when_description_matches() + { + var reconciler = ReconcilerWith(out _); + var desired = new SynologyDesiredState + { + Shares = [new ShareSpec { Name = "Volume-1", Path = "/volume1/Volume-1", Description = "old desc" }], + }; + + var plan = await reconciler.PlanAsync(desired); + + Assert.False(plan.HasChanges); + Assert.Equal("in sync", Assert.Single(plan.Actions).Reason); + } + + [Fact] + public async Task Plan_empty_description_is_unmanaged() + { + var reconciler = ReconcilerWith(out _); + var desired = new SynologyDesiredState + { + Shares = [new ShareSpec { Name = "Volume-1", Path = "/volume1/Volume-1" }], // no Description + }; + + var plan = await reconciler.PlanAsync(desired); + + Assert.False(plan.HasChanges); // current "old desc" left untouched + } + + [Fact] + public async Task Plan_user_fullname_drift_emits_modify_preserving_email() + { + var reconciler = ReconcilerWith(out _); + var desired = new SynologyDesiredState + { + Users = [new UserSpec { Name = "homelab", FullName = "New Name" }], // email unset → preserve current + }; + + var plan = await reconciler.PlanAsync(desired); + + var modify = Assert.Single(plan.Mutations); + Assert.Equal(ActionKind.Modify, modify.Kind); + // expired preserved (0), email preserved (quoted — '@' isn't a bare-word char), fullname updated. + Assert.Equal("synouser --modify homelab 'New Name' 0 'old@x.test'", modify.Command!.Render()); + } + + [Fact] + public async Task Plan_group_description_drift_emits_descset() + { + var reconciler = ReconcilerWith(out _); + var desired = new SynologyDesiredState + { + Groups = [new GroupSpec { Name = "users", Description = "new group desc" }], + }; + + var plan = await reconciler.PlanAsync(desired); + + var modify = Assert.Single(plan.Mutations); + Assert.Equal("synogroup --descset users 'new group desc'", modify.Command!.Render()); + } + [Fact] public async Task Plan_share_delete_keeps_data_by_default() {