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
+75
View File
@@ -23,6 +23,45 @@ VERSION="0.3.0"
# The directory containing install.sh is the permanent install location.
PATCH_DIR="$(cd "$(dirname "$0")" && pwd)"
_HOOK_COMMENT='TrueCloud provider patch (S3/B2)'
_NESTED_MARKER="$PATCH_DIR/nested_snapshots_enabled"
# ── Options ───────────────────────────────────────────────────────────────────
# Nested-dataset snapshot support is OPT-IN and off by default. It changes how
# backups read their source data, so an unattended re-run (e.g. after a
# `git pull`) must never flip it on or off by itself: with neither flag given,
# whatever was chosen previously is preserved.
_nested_choice=""
usage() {
cat <<USAGE
Usage: bash install.sh [options]
Options:
--enable-nested-snapshots Allow the "Take Snapshot" option on datasets that
have child datasets (every pool running Apps).
Stock TrueNAS refuses this; see README. Off by
default because it changes how backups read data.
--disable-nested-snapshots Turn it back off; the stock guard is restored.
-h, --help Show this help.
With neither flag, the current setting is left unchanged.
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--enable-nested-snapshots) _nested_choice="on" ;;
--disable-nested-snapshots) _nested_choice="off" ;;
-h|--help) usage; exit 0 ;;
*)
echo "ERROR: unknown option: $1" >&2
echo "" >&2
usage >&2
exit 1
;;
esac
shift
done
if [ ! -f "$PATCH_DIR/patch/apply.sh" ]; then
echo "ERROR: patch files not found at $PATCH_DIR/patch/" >&2
@@ -104,6 +143,42 @@ if [ -f "$PATCH_DIR/disabled" ]; then
echo ""
fi
# ── Nested-dataset snapshot support (opt-in) ──────────────────────────────────
case "$_nested_choice" in
on)
touch "$_NESTED_MARKER"
echo "Nested-dataset snapshots: ENABLED"
echo " The \"Take Snapshot\" option will be allowed on datasets that have"
echo " child datasets. Backups then read from a frozen, complete staging"
echo " tree instead of live files."
echo ""
echo " This changes how your backups read their source data. Verify that a"
echo " backup completes AND that its restic snapshot actually contains"
echo " child-dataset data before you rely on it."
;;
off)
if [ -f "$_NESTED_MARKER" ]; then
rm -f "$_NESTED_MARKER"
echo "Nested-dataset snapshots: DISABLED (stock guard restored)."
echo " Any task that already has snapshot=true on a nested dataset will"
echo " fail validation on its next edit. Turn the option off on those"
echo " tasks, or re-run with --enable-nested-snapshots."
else
echo "Nested-dataset snapshots: already disabled."
fi
;;
*)
if [ -f "$_NESTED_MARKER" ]; then
echo "Nested-dataset snapshots: enabled (unchanged)."
else
echo "Nested-dataset snapshots: disabled (default)."
echo " Enable with: bash install.sh --enable-nested-snapshots"
fi
;;
esac
echo ""
# ── Apply now ─────────────────────────────────────────────────────────────────
echo "Applying patches ..."