Make nested snapshots opt-in; fix snapshot leaks found in audit

Opt-in
------
Nested-dataset snapshot support changes how backups read their source data, so
it is now off by default and gated behind a marker file:

  install.sh --enable-nested-snapshots
  install.sh --disable-nested-snapshots

With neither flag install.sh preserves the current setting, so a routine
`git pull && bash install.sh` can never silently flip it. When disabled,
apply.sh skips the patch entirely and the stock guard remains. uninstall.sh
tears down staging mounts and removes the marker.

Snapshot lifecycle
------------------
zfs.snapshot.delete defaults to recursive=False and stock restic_backup() calls
it with no options. Stock is safe only because its validation means recursive is
never True in the field. Enabling nested datasets makes recursive snapshots real:
the parent then has one child snapshot per descendant dataset (160+ on an Apps
pool), so stock's delete would orphan every child on EVERY successful run.

The patch now owns the lifecycle end to end:

- delete_snapshot_tree() sweeps the parent and all children, and is idempotent
  against stock's finally winning the race once our mounts are released
- on a staging failure the tree is deleted here, because sync.py never completes
  `snapshot, local_path = await create_snapshot(...)` and so its finally deletes
  nothing at all
- the snapshot is recorded in a sidecar file before anything is mounted, so a
  middlewared restart mid-backup cannot orphan it
- a crashed run's snapshot tree is reclaimed on the next run instead of being
  overwritten and leaked

Silent-omission fix
-------------------
The dataset list is now enumerated AFTER the snapshot. Read beforehand it could
miss a dataset created in the gap, which the recursive snapshot would capture but
the staging plan would not -- silently omitting its data. Read afterwards, an
unsnapshotted dataset trips the staging check and fails the run loudly.

Also from the audit
-------------------
- plan_staging scopes by dataset name, so skipped-dataset warnings no longer
  include every mountpoint-less dataset on the box, which buried the ones that
  matter
- staging_root_for rejects "." / ".." components that would escape the staging
  base, and resolves STAGING_BASE at call time rather than freezing it into a
  default argument
- uninstall.sh no longer `rm -rf`s a tree that may still contain live bind
  mounts, and unmounts by path depth rather than string length
- apply_plan takes an injectable isdir; verify_staged drops an unused parameter
- pin the shellcheck action instead of tracking @master

61 tests, ruff and shellcheck clean.
This commit is contained in:
flan
2026-07-12 21:52:09 +00:00
parent a572eb2164
commit bb26edf351
10 changed files with 785 additions and 173 deletions
+46
View File
@@ -91,6 +91,52 @@ if [ "$_ov_found" -eq 0 ]; then
fi
echo ""
# ── Unmount nested-snapshot staging trees ─────────────────────────────────────
# These bind mounts pin their ZFS snapshots, so they must go before anything
# tries to destroy those snapshots. Deepest first.
echo "Unmounting nested-snapshot staging trees (if any) ..."
_stage_found=0
_stage_failed=0
# Deepest FIRST, by path depth (slash count) — not string length, which would
# let a long shallow path jump ahead of a short deep one and leave a child
# mounted (and its ZFS snapshot pinned).
while IFS= read -r _mp; do
[ -n "$_mp" ] || continue
if umount "$_mp" 2>/dev/null || umount -l "$_mp" 2>/dev/null; then
echo " Unmounted: $_mp"
else
echo " WARNING: Could not unmount $_mp"
_stage_failed=1
fi
_stage_found=1
done < <(awk '$2 == "/run/truecloud-nested" || index($2, "/run/truecloud-nested/") == 1 {
n = gsub(/\//, "/", $2); print n, $2
}' /proc/self/mounts 2>/dev/null | sort -rn | cut -d' ' -f2-)
if [ "$_stage_found" -eq 0 ]; then
echo " None active."
fi
# NEVER `rm -rf` here: if an unmount failed, that would recurse *through* a live
# bind mount into the ZFS snapshot behind it. Remove empty directories only.
if [ "$_stage_failed" -eq 0 ]; then
find /run/truecloud-nested -depth -type d -exec rmdir {} + 2>/dev/null || true
rm -f /run/truecloud-nested/*.snapshot 2>/dev/null || true
rmdir /run/truecloud-nested 2>/dev/null || true
else
echo " WARNING: staging mounts remain; leaving /run/truecloud-nested in place."
echo " Unmount them manually, then remove the directory."
fi
# The opt-in marker lives in the repo dir; remove it so a later re-install
# starts from the safe default (feature off).
if [ -f "$PATCH_DIR/nested_snapshots_enabled" ]; then
rm -f "$PATCH_DIR/nested_snapshots_enabled"
echo " Removed nested-snapshot opt-in marker."
fi
echo ""
if [ "$_restore_failed" -eq 1 ]; then
echo ""
echo "ERROR: One or more UI bundle backups could not be restored." >&2