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
+25 -1
View File
@@ -4,7 +4,13 @@
### Added
- **`snapshot = true` now works on datasets that have child datasets.**
- **`snapshot = true` now works on datasets that have child datasets** —
**opt-in, off by default** (`install.sh --enable-nested-snapshots` /
`--disable-nested-snapshots`). It changes how backups read their source data,
so it is never enabled implicitly; with neither flag `install.sh` preserves
the existing setting, so a `git pull && bash install.sh` cannot silently flip
it. When disabled, `apply.sh` skips the patch entirely and the stock guard
remains. `uninstall.sh` tears down any staging mounts and removes the marker.
Stock TrueNAS refuses this with *"This option is only available for datasets
that have no further nesting"*, which makes the snapshot option unusable for
the single most common case on any box running Apps — every app is its own
@@ -46,6 +52,24 @@
`snapshot.py`, then `sync.py`, and only then `crud.py`. A partial failure
leaves the guard intact and the option merely unavailable — never
"guard removed, traversal missing".
- **The patch owns the whole snapshot lifecycle.** `zfs.snapshot.delete`
defaults to `recursive=False` and stock `restic_backup()` calls it with no
options. Stock gets away with that only because its validation means
`recursive` is never True in the field — but enabling nested datasets makes
recursive snapshots real, so the parent now has one child snapshot per
descendant dataset (160+ on a typical Apps pool). Relying on stock's delete
would therefore orphan every child snapshot **on every successful run**.
This patch sweeps the parent *and* all children, is idempotent against
stock's `finally` winning the race, records the snapshot in a sidecar file
(so a middlewared restart mid-backup cannot orphan it), reclaims the tree
left by a crashed run, and deletes the tree when staging fails — where
sync.py's own `finally` would otherwise delete nothing at all, because its
`snapshot` local never gets assigned.
- **The dataset list is enumerated *after* the snapshot, never before.** A
list read beforehand can miss a dataset created in the gap: the recursive
snapshot would capture it but the staging plan would not, silently omitting
its data. Read afterwards, an unsnapshotted dataset trips the staging check
and fails the run loudly instead.
- **Every injected block no-ops** if `_truecloud_nested` is absent.
- Datasets that cannot contribute to a file tree (`mountpoint=none|legacy`,
unmounted/locked, encrypted-and-locked) are skipped and **reported** —