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:
@@ -78,11 +78,55 @@ def test_nested_blocks_degrade_safely_without_the_module(name):
|
||||
assert "if _tc_nested is not None:" in block
|
||||
|
||||
|
||||
class TestSnapshotLeak:
|
||||
"""zfs.snapshot.delete is non-recursive and stock calls it with no options.
|
||||
|
||||
A recursive snapshot has one child per descendant dataset (160+ here), so
|
||||
every path that creates one must also sweep the whole tree.
|
||||
"""
|
||||
|
||||
def test_staging_failure_deletes_the_snapshot_tree(self):
|
||||
# On a staging failure, sync.py's `snapshot, local_path = await
|
||||
# create_snapshot(...)` never completes, so its local `snapshot` stays
|
||||
# None and its finally deletes nothing. We must sweep it ourselves.
|
||||
block = extract_blocks()["SNAPSHOT_BLOCK"]
|
||||
assert "except Exception:" in block
|
||||
assert "delete_snapshot_tree" in block
|
||||
assert "raise" in block
|
||||
|
||||
def test_sync_block_cleans_up_on_every_path(self):
|
||||
block = extract_blocks()["SYNC_BLOCK"]
|
||||
assert "finally:" in block
|
||||
assert "cleanup_task" in block
|
||||
|
||||
|
||||
def test_crud_block_is_scoped_to_cloud_backup():
|
||||
# cloudsync has no staging teardown wired in, so its guard must stay.
|
||||
assert '!= "cloud_backup"' in extract_blocks()["CRUD_BLOCK"]
|
||||
|
||||
|
||||
class TestOptIn:
|
||||
"""Nested-snapshot support must be opt-in and must never self-enable."""
|
||||
|
||||
def test_heredoc_gates_on_the_opt_in_flag(self):
|
||||
src = heredoc_source()
|
||||
assert "nested_enabled = sys.argv[7]" in src
|
||||
assert "if not nested_enabled:" in src
|
||||
|
||||
def test_apply_sh_reads_the_marker_file(self):
|
||||
with open(APPLY_SH, encoding="utf-8") as fh:
|
||||
sh = fh.read()
|
||||
assert 'if [ -f "$PATCH_DIR/nested_snapshots_enabled" ]' in sh
|
||||
assert '"$_NESTED_ENABLED"' in sh
|
||||
|
||||
def test_patching_is_skipped_entirely_when_disabled(self):
|
||||
# The guard-relaxing crud.py patch must be inside the enabled branch.
|
||||
src = heredoc_source()
|
||||
gate = src.index("if not nested_enabled:")
|
||||
crud = src.index("patch_file(crud_py, CRUD_BLOCK)")
|
||||
assert gate < crud, "crud.py patch must sit inside the opt-in branch"
|
||||
|
||||
|
||||
def test_guard_is_relaxed_only_after_traversal_is_installed():
|
||||
# Ordering in apply.sh is a safety property: copy module -> patch snapshot.py
|
||||
# -> patch sync.py -> patch crud.py. crud.py (which unlocks the feature) must
|
||||
|
||||
Reference in New Issue
Block a user