Files
truenas-truecloud-patch/tests/test_apply_blocks.py
T
flan bb26edf351 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.
2026-07-12 21:52:09 +00:00

142 lines
5.1 KiB
Python

"""The *_BLOCK strings in apply.sh are Python source injected into middleware.
A syntax error in one of them would be appended to a live middlewared module and
break the box at boot. They are string literals, so nothing type-checks them --
these tests do.
"""
import ast
import os
import re
import pytest
APPLY_SH = os.path.join(os.path.dirname(__file__), "..", "patch", "apply.sh")
EXPECTED_BLOCKS = {
"B2_BLOCK",
"RESTIC_BLOCK",
"SNAPSHOT_BLOCK",
"CRUD_BLOCK",
"SYNC_BLOCK",
}
def heredoc_source():
with open(APPLY_SH, encoding="utf-8") as fh:
src = fh.read()
m = re.search(r"<< 'PYEOF'\n(.*?)\nPYEOF", src, re.S)
assert m, "could not find the PYEOF heredoc in apply.sh"
return m.group(1)
def extract_blocks():
tree = ast.parse(heredoc_source())
blocks = {}
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for tgt in node.targets:
if (
isinstance(tgt, ast.Name)
and tgt.id.endswith("_BLOCK")
and isinstance(node.value, ast.Constant)
and isinstance(node.value.value, str)
):
blocks[tgt.id] = node.value.value
return blocks
def test_heredoc_itself_compiles():
compile(heredoc_source(), "apply.sh:PYEOF", "exec")
def test_all_expected_blocks_present():
assert set(extract_blocks()) == EXPECTED_BLOCKS
@pytest.mark.parametrize("name", sorted(EXPECTED_BLOCKS))
def test_injected_block_is_valid_python(name):
block = extract_blocks()[name]
compile(block, f"apply.sh:{name}", "exec")
@pytest.mark.parametrize("name", sorted(EXPECTED_BLOCKS))
def test_injected_block_carries_the_idempotency_marker(name):
# patch_file() truncates each target file at "\n# TRUECLOUD_PATCH" before
# re-appending, so every block must start with that marker or repeated runs
# would stack duplicate copies into the middleware module.
assert extract_blocks()[name].lstrip("\n").startswith("# TRUECLOUD_PATCH")
@pytest.mark.parametrize("name", ["SNAPSHOT_BLOCK", "CRUD_BLOCK", "SYNC_BLOCK"])
def test_nested_blocks_degrade_safely_without_the_module(name):
# If _truecloud_nested failed to install, every nested block must no-op.
# Critically this includes CRUD_BLOCK: relaxing the guard without the
# traversal in place would mean silently-empty backups.
block = extract_blocks()[name]
assert "_tc_nested = None" in block
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
# come last, so a partial failure never leaves "guard removed, traversal gone".
src = heredoc_source()
order = [
src.index("shutil.copyfile(nested_src, nested_dst)"),
src.index("patch_file(snapshot_py, SNAPSHOT_BLOCK)"),
src.index("patch_file(sync_path, SYNC_BLOCK)"),
src.index("patch_file(crud_py, CRUD_BLOCK)"),
]
assert order == sorted(order), "crud.py must be patched last"