Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
5 changes: 5 additions & 0 deletions src/SynoSharp/Provisioning/SynologyPlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace SynoSharp.Provisioning;
public enum ActionKind
{
Create,
Modify,
Delete,
Skip,
}
Expand All @@ -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 };

Expand All @@ -54,6 +58,7 @@ public string Render()
var marker = a.Kind switch
{
ActionKind.Create => "+ create",
ActionKind.Modify => "~ modify",
ActionKind.Delete => "- delete",
_ => "= skip ",
};
Expand Down
70 changes: 61 additions & 9 deletions src/SynoSharp/Provisioning/SynologyReconciler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,15 @@ public async Task<SynologyPlan> PlanAsync(SynologyDesiredState desired, Cancella
var actions = new List<PlannedAction>();
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 };
Expand Down Expand Up @@ -95,7 +95,10 @@ public async Task<SynologyApplyResult> ApplyAsync(SynologyPlan plan, bool apply
private static bool Contains(IReadOnlyList<string> 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<PlannedAction> PlanGroupAsync(GroupSpec g, bool exists, CancellationToken ct)
{
if (g.Present && !exists)
{
Expand All @@ -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<PlannedAction> PlanUserAsync(UserSpec u, bool exists, CancellationToken ct)
{
if (u.Present && !exists)
{
Expand All @@ -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<string>();
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<PlannedAction> PlanShareAsync(ShareSpec s, bool exists, CancellationToken ct)
{
if (s.Present && !exists)
{
Expand All @@ -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");
}
}
44 changes: 44 additions & 0 deletions src/SynoSharp/Tools/GetFields.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
namespace SynoSharp.Tools;

/// <summary>
/// Parses the <c>label … [value]</c> shape of <c>syno*&#160;--get</c> / <c>--descget</c>
/// output into a label→value map. Works for both <c>synoshare --get</c> (dotted:
/// <c>Comment ....[x]</c>) and <c>synouser --get</c> (colon: <c>User Mail : [x]</c>):
/// the value is the text inside the line's first <c>[...]</c>, and the label is what
/// precedes it (dots/colons/whitespace trimmed). Verified against DSM 7.1.1.
/// </summary>
internal static class GetFields
{
public static IReadOnlyDictionary<string, string> Parse(string standardOutput)
{
var map = new Dictionary<string, string>(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;
}

/// <summary>The text inside the first <c>[...]</c> anywhere in the output (e.g. <c>--descget</c>).</summary>
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;
}
}
18 changes: 18 additions & 0 deletions src/SynoSharp/Tools/ResourceInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace SynoSharp.Tools;

/// <summary>Current on-box state of a share (from <c>synoshare --get</c>) — for drift.</summary>
public sealed record ShareInfo
{
public required string Name { get; init; }
public string Description { get; init; } = "";
public string Path { get; init; } = "";
}

/// <summary>Current on-box state of a user (from <c>synouser --get</c>) — for drift.</summary>
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; }
}
14 changes: 14 additions & 0 deletions src/SynoSharp/Tools/SynoGroupTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,24 @@ public async Task<IReadOnlyList<string>> EnumAsync(CancellationToken cancellatio
return EnumOutput.ParseNames(result.StandardOutput);
}

/// <summary>
/// Read the group's description via <c>synogroup --descget</c>; null if the group
/// doesn't exist. (<c>--get</c> doesn't include the description on DSM 7.1.)
/// </summary>
public async Task<string?> 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;
}

/// <summary><c>synogroup --add groupname [members…]</c> — created empty here.</summary>
public static SynologyCommand AddCommand(GroupSpec spec)
=> SynologyCommand.Create("synogroup", "--add", spec.Name);

/// <summary><c>synogroup --descset groupname "desc"</c>.</summary>
public static SynologyCommand SetDescriptionCommand(string name, string description)
=> SynologyCommand.Create("synogroup", "--descset", name, description);

/// <summary><c>synogroup --del groupname</c>.</summary>
public static SynologyCommand DeleteCommand(string name)
=> SynologyCommand.Create("synogroup", "--del", name);
Expand Down
21 changes: 21 additions & 0 deletions src/SynoSharp/Tools/SynoShareTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,34 @@ public async Task<IReadOnlyList<string>> EnumAsync(CancellationToken cancellatio
return EnumOutput.ParseNames(result.StandardOutput);
}

/// <summary>Read current state via <c>synoshare --get</c>; null if the share doesn't exist.</summary>
public async Task<ShareInfo?> 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") ?? "",
};
}

/// <summary>
/// <c>synoshare --add name desc path na rw ro browsable{0|1} adv_privilege{0~7}</c>.
/// na/rw/ro are comma-separated user lists (empty = none); created browsable, basic privilege.
/// </summary>
public static SynologyCommand AddCommand(ShareSpec spec)
=> SynologyCommand.Create("synoshare", "--add", spec.Name, spec.Description, spec.Path, "", "", "", "1", "0");

/// <summary><c>synoshare --setdesc name desc</c>.</summary>
public static SynologyCommand SetDescriptionCommand(string name, string description)
=> SynologyCommand.Create("synoshare", "--setdesc", name, description);

/// <summary><c>synoshare --del {TRUE|FALSE} name</c> — FALSE keeps the underlying data dir.</summary>
public static SynologyCommand DeleteCommand(string name, bool deleteData = false)
=> SynologyCommand.Create("synoshare", "--del", deleteData ? "TRUE" : "FALSE", name);
Expand Down
30 changes: 30 additions & 0 deletions src/SynoSharp/Tools/SynoUserTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ public async Task<IReadOnlyList<string>> EnumAsync(CancellationToken cancellatio
return EnumOutput.ParseNames(result.StandardOutput);
}

/// <summary>Read current state via <c>synouser --get</c>; null if the user doesn't exist.</summary>
public async Task<UserInfo?> 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),
};
}

/// <summary>
/// <c>synouser --add username pwd "full name" expired{0|1} mail privilege</c>.
/// A password is required to create a user; <paramref name="spec"/>.Password must be set.
Expand All @@ -37,6 +55,18 @@ public static SynologyCommand AddCommand(UserSpec spec)
return SynologyCommand.Create("synouser", "--add", spec.Name, spec.Password, spec.FullName, "0", spec.Email ?? "", "");
}

/// <summary>
/// <c>synouser --modify username "full name" expired{0|1} mail</c>. Empty/null
/// spec fields fall back to <paramref name="current"/> so unmanaged values aren't
/// clobbered; <c>expired</c> is always preserved (not modelled in the spec).
/// </summary>
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);
}

/// <summary><c>synouser --del username</c>.</summary>
public static SynologyCommand DeleteCommand(string name)
=> SynologyCommand.Create("synouser", "--del", name);
Expand Down
Loading
Loading