From 48fce09254312f4fc1067915069cad8d57a129b6 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sun, 31 May 2026 22:22:20 +1200 Subject: [PATCH] feat(provisioning): ShareSpec.DeleteData for full subvolume removal (#57) The live smoke test confirmed DSM shares are btrfs subvolumes, so the safe default delete (`synoshare --del FALSE`, keep data) leaves the subvolume behind. Add an opt-in DeleteData flag (default false) that emits `--del TRUE` for a destructive, data-removing delete. - ShareSpec.DeleteData (default false); reconciler wires it through and labels the plan reason "keep data" vs "incl. data". - Tests for both delete modes. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 ++- src/SynoSharp/Provisioning/Specs.cs | 8 +++++ .../Provisioning/SynologyReconciler.cs | 3 +- tests/SynoSharp.Tests/ReconcilerTests.cs | 32 +++++++++++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9021f5c..d8e2c1e 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ 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 +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). 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/Specs.cs b/src/SynoSharp/Provisioning/Specs.cs index 4c15feb..e4c0a48 100644 --- a/src/SynoSharp/Provisioning/Specs.cs +++ b/src/SynoSharp/Provisioning/Specs.cs @@ -10,6 +10,14 @@ public sealed record ShareSpec public required string Path { get; init; } public bool Present { get; init; } = true; + + /// + /// When deleting ( false), also remove the share's data. + /// DSM shares are btrfs subvolumes, so the default keep-data delete leaves the + /// subvolume behind (plain rm can't remove it) — set true for a full + /// synoshare --del TRUE. Destructive; off by default. + /// + public bool DeleteData { get; init; } } /// Desired state for a local user. false = ensure absent. diff --git a/src/SynoSharp/Provisioning/SynologyReconciler.cs b/src/SynoSharp/Provisioning/SynologyReconciler.cs index e3ea89e..b2b4c48 100644 --- a/src/SynoSharp/Provisioning/SynologyReconciler.cs +++ b/src/SynoSharp/Provisioning/SynologyReconciler.cs @@ -133,7 +133,8 @@ private static PlannedAction PlanShare(ShareSpec s, bool exists) } if (!s.Present && exists) { - return PlannedAction.Delete("share", s.Name, SynoShareTool.DeleteCommand(s.Name), "present → delete"); + 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"); } diff --git a/tests/SynoSharp.Tests/ReconcilerTests.cs b/tests/SynoSharp.Tests/ReconcilerTests.cs index 544b77b..2158339 100644 --- a/tests/SynoSharp.Tests/ReconcilerTests.cs +++ b/tests/SynoSharp.Tests/ReconcilerTests.cs @@ -73,6 +73,38 @@ public async Task Plan_deletes_when_present_false() Assert.Equal("synogroup --del http", delete.Command!.Render()); } + [Fact] + public async Task Plan_share_delete_keeps_data_by_default() + { + var reconciler = ReconcilerWith(out _); + var desired = new SynologyDesiredState + { + Shares = [new ShareSpec { Name = "Volume-1", Path = "/volume1/Volume-1", Present = false }], + }; + + var plan = await reconciler.PlanAsync(desired); + + var delete = Assert.Single(plan.Mutations); + Assert.Equal("synoshare --del FALSE Volume-1", delete.Command!.Render()); + Assert.Contains("keep data", delete.Reason); + } + + [Fact] + public async Task Plan_share_delete_with_DeleteData_removes_data() + { + var reconciler = ReconcilerWith(out _); + var desired = new SynologyDesiredState + { + Shares = [new ShareSpec { Name = "Volume-1", Path = "/volume1/Volume-1", Present = false, DeleteData = true }], + }; + + var plan = await reconciler.PlanAsync(desired); + + var delete = Assert.Single(plan.Mutations); + Assert.Equal("synoshare --del TRUE Volume-1", delete.Command!.Render()); + Assert.Contains("incl. data", delete.Reason); + } + [Fact] public async Task Plan_blocks_user_create_without_password() {