The test suite is not bloated -- 3,701 lines of test code against 3,760 lines of product code, one duplicate pair in 356 tests, 8% single-assertion tests. The waste was not volume, it was four tests that looked like coverage and provided none: - test_parents_are_mounted_before_children: the fixture was already in depth order, so the sort it exists for was never exercised. Deleting `mounts.sort(key=_depth)` passed the whole suite. It now uses datasets whose NAME order differs from their MOUNTPOINT depth, which is the only case the sort is for. - test_it_NEVER_touches_the_current_run: the "current" snapshot was a minute old, so the age floor excluded it regardless and the same-snapname guard never ran. That guard only matters for a run that OUTLIVES the floor -- which a first full upload easily does, and where collecting it would yank the snapshot out from under a backup that is still reading from it. Now tested with a 12-hour-old current run. - the fingerprint/unknown test put the unreadable file in `providers`, which is not broken -- so fingerprint() skipped the whole module via is_broken() and the filter under test never executed. The blip has to land in the module that IS broken. - test_the_real_truenas_versions was a byte-identical copy of test_async_middleware_is_detected under a name promising more. Replaced with the fact actually worth pinning: the flavour probe must read STOCK source, because apply.sh re-runs on an already-patched overlay and our own SNAPSHOT_SYNC block is a plain `def create_snapshot` -- reading it would report a 25.10 box as synchronous and inject the wrong wrapper. All four now fail when the code they name is broken.
2055 lines
88 KiB
Python
2055 lines
88 KiB
Python
"""Tests for nested-dataset snapshot staging.
|
|
|
|
Two rules are under test above all else:
|
|
|
|
1. A tree that cannot be staged completely must fail LOUDLY. A silently
|
|
incomplete backup is the exact failure that stock TrueNAS's "no further
|
|
nesting" guard exists to prevent.
|
|
|
|
2. Every snapshot we cause to exist must be cleaned up. ``zfs.snapshot.delete``
|
|
is non-recursive by default and stock calls it with no options, so a
|
|
recursive snapshot would otherwise orphan one snapshot per descendant dataset
|
|
on EVERY run.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "patch"))
|
|
|
|
import truecloud_nested as tn # noqa: E402
|
|
|
|
#: Captured at import, BEFORE the autouse fixture patches time.sleep --
|
|
#: otherwise the seam check below compares a frozen default against the
|
|
#: fixture's own stub and silently matches nothing.
|
|
_REAL_SLEEP = time.sleep
|
|
from truecloud_nested import ( # noqa: E402
|
|
StagingError,
|
|
apply_plan,
|
|
cleanup_all,
|
|
cleanup_task,
|
|
current_mounts_under,
|
|
delete_snapshot_tree,
|
|
plan_staging,
|
|
sidecar_for,
|
|
snapshot_tree_names,
|
|
staging_root_for,
|
|
teardown,
|
|
verify_staged,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def never_touch_the_real_system(monkeypatch):
|
|
"""A unit test must never shell out to the real box. This makes it impossible.
|
|
|
|
Five tests silently did. `gc_stale_snapshots`'s DEFAULT lister runs
|
|
`zfs list -t snapshot -r Tap` -- and on the NAS, `Tap` is the real pool, with
|
|
2148 snapshots and a genuine leaked `cloud_backup-5` snapshot in it. Those tests
|
|
passed on the dev box only because it has no `zfs` binary (FileNotFoundError,
|
|
swallowed by a broad except), and would have gone RED on the one machine the
|
|
release process requires them green on -- for reasons having nothing to do with
|
|
the code. The same code path calls `umount`.
|
|
|
|
Tests that need a system command inject one (`runner=` / `list_snapshots=`).
|
|
|
|
It RECORDS and asserts at teardown rather than raising, because raising would be
|
|
swallowed: `gc_stale_snapshots` catches broad `Exception` around its enumeration
|
|
(deliberately — it must fail toward collecting nothing). That swallow is exactly
|
|
what let five tests shell out unnoticed, so the check must survive it.
|
|
"""
|
|
attempted = []
|
|
|
|
def forbidden(cmd):
|
|
attempted.append(cmd)
|
|
raise AssertionError(f"real system command in a unit test: {cmd!r}")
|
|
|
|
monkeypatch.setattr(tn, "_run", forbidden)
|
|
|
|
# ...and the REAL mount table, which is the other half. Nineteen tests were
|
|
# reading /proc/self/mounts: harmless here, but on the NAS a name that happened to
|
|
# match would send `release_snapdirs`/`teardown` off to run a real `umount`.
|
|
#
|
|
# Patching the module attribute only works because these are late-bound now. A
|
|
# default argument (`mounts_file="/proc/self/mounts"`) is frozen into __defaults__
|
|
# at def time and monkeypatching cannot reach it -- which is exactly why the first
|
|
# version of this fixture looked like it worked and did not.
|
|
monkeypatch.setattr(tn, "MOUNTS_FILE", os.devnull)
|
|
|
|
# ...and never really sleep. The retry loop waits 5s between attempts for ZFS's
|
|
# automount window to expire; a test that hits it burns 20 real seconds and tells
|
|
# you nothing. (Same frozen-default trap: `sleep=time.sleep` in the signature could
|
|
# not be intercepted at all until it was late-bound.)
|
|
slept = []
|
|
monkeypatch.setattr(tn.time, "sleep", lambda s: slept.append(s))
|
|
|
|
yield
|
|
|
|
assert not attempted, (
|
|
"this test ran real system commands: "
|
|
+ "; ".join(repr(c) for c in attempted)
|
|
+ ". Inject a fake (runner= / list_snapshots=) -- on the NAS these hit the "
|
|
"REAL pool, and the suite must not depend on the machine it runs on."
|
|
)
|
|
|
|
SNAP = "cloud_backup-5-20260712030000"
|
|
ROOT = "/run/truecloud-nested/cloud_backup-5"
|
|
|
|
|
|
def ds(name, mountpoint, mounted="yes"):
|
|
return {
|
|
"name": name,
|
|
"properties": {
|
|
"mountpoint": {"value": mountpoint},
|
|
"mounted": {"value": mounted},
|
|
},
|
|
}
|
|
|
|
|
|
# Mirrors the real layout: apps are datasets, several with their own children.
|
|
DATASETS = [
|
|
ds("Tap", "/mnt/Tap"),
|
|
ds("Tap/apps", "/mnt/Tap/apps"),
|
|
ds("Tap/apps/lidarr", "/mnt/Tap/apps/lidarr"),
|
|
ds("Tap/apps/lidarr/config", "/mnt/Tap/apps/lidarr/config"),
|
|
ds("Tap/apps/immich", "/mnt/Tap/apps/immich"),
|
|
ds("Tap/apps/immich/pgdata", "/mnt/Tap/apps/immich/pgdata"),
|
|
]
|
|
|
|
|
|
def yes(_path):
|
|
return "ok"
|
|
|
|
|
|
def plan(datasets=DATASETS, base_dataset="Tap", base_mp="/mnt/Tap",
|
|
path="/mnt/Tap", probe=yes):
|
|
return plan_staging(base_dataset, base_mp, path, SNAP, datasets, ROOT, probe=probe)
|
|
|
|
|
|
class TestPlanStaging:
|
|
def test_stages_every_descendant_dataset(self):
|
|
mounts, skipped = plan()
|
|
assert skipped == []
|
|
assert len(mounts) == 6 # root + 5 descendants
|
|
assert mounts[0] == (f"/mnt/Tap/.zfs/snapshot/{SNAP}", ROOT)
|
|
|
|
by_target = {t: s for s, t in mounts}
|
|
assert by_target[f"{ROOT}/apps"] == f"/mnt/Tap/apps/.zfs/snapshot/{SNAP}"
|
|
assert by_target[f"{ROOT}/apps/immich/pgdata"] == (
|
|
f"/mnt/Tap/apps/immich/pgdata/.zfs/snapshot/{SNAP}"
|
|
)
|
|
|
|
def test_parents_are_mounted_before_children(self):
|
|
# A child's mountpoint dir only exists inside its PARENT's snapshot, so
|
|
# mounting a child first fails.
|
|
#
|
|
# The default fixture is already in depth order, so it never exercised the
|
|
# sort at all -- deleting `mounts.sort(key=_depth)` passed the whole suite.
|
|
# These datasets are deliberately in the WRONG order by name: `Tap/aaa` is
|
|
# three levels deep and `Tap/zzz` is one, so anything that preserves input
|
|
# order (or sorts by name) mounts the child first and would fail for real.
|
|
awkward = [
|
|
ds("Tap", "/mnt/Tap"),
|
|
ds("Tap/aaa", "/mnt/Tap/zzz/deep/deeper"),
|
|
ds("Tap/zzz", "/mnt/Tap/zzz"),
|
|
ds("Tap/mmm", "/mnt/Tap/zzz/deep"),
|
|
]
|
|
mounts, _ = plan(datasets=awkward)
|
|
|
|
seen = set()
|
|
for _src, target in mounts:
|
|
if target != ROOT:
|
|
assert os.path.dirname(target) in seen, (
|
|
f"{target} is mounted before its parent exists"
|
|
)
|
|
seen.add(target)
|
|
|
|
def test_backup_path_below_dataset_root(self):
|
|
mounts, _ = plan(base_dataset="Tap/apps", base_mp="/mnt/Tap/apps",
|
|
path="/mnt/Tap/apps")
|
|
assert mounts[0] == (f"/mnt/Tap/apps/.zfs/snapshot/{SNAP}", ROOT)
|
|
targets = [t for _s, t in mounts]
|
|
assert f"{ROOT}/lidarr" in targets
|
|
assert f"{ROOT}/apps/lidarr" not in targets
|
|
|
|
def test_base_dataset_is_not_a_descendant_of_itself(self):
|
|
mounts, _ = plan(datasets=[ds("Tap", "/mnt/Tap")])
|
|
assert len(mounts) == 1
|
|
|
|
|
|
class TestScoping:
|
|
def test_unrelated_datasets_are_ignored_silently(self):
|
|
# Regression: scoping by mountpoint first dragged in every
|
|
# mountpoint-less dataset on the box (all of Tank/.system/*), burying the
|
|
# warnings that actually matter.
|
|
noisy = DATASETS + [
|
|
ds("Tank/.system", "none"),
|
|
ds("Tank/.system/cores", "legacy"),
|
|
ds("Tank/backups", "/mnt/Tank/backups"),
|
|
]
|
|
mounts, skipped = plan(datasets=noisy)
|
|
assert len(mounts) == 6
|
|
assert skipped == [], "datasets outside the base dataset must not be reported"
|
|
|
|
def test_in_scope_dataset_without_mountpoint_is_reported(self):
|
|
datasets = DATASETS + [ds("Tap/apps/weird", "none")]
|
|
_mounts, skipped = plan(datasets=datasets)
|
|
assert ("Tap/apps/weird", "mountpoint is none") in skipped
|
|
|
|
def test_unmounted_dataset_is_skipped_but_never_silently(self):
|
|
datasets = DATASETS + [ds("Tap/apps/vault", "/mnt/Tap/apps/vault", mounted="no")]
|
|
mounts, skipped = plan(datasets=datasets)
|
|
assert f"{ROOT}/apps/vault" not in [t for _s, t in mounts]
|
|
assert ("Tap/apps/vault", "dataset is not mounted (locked/encrypted?)") in skipped
|
|
|
|
def test_descendant_mounted_outside_the_path_is_not_an_omission(self):
|
|
datasets = DATASETS + [ds("Tap/elsewhere", "/mnt/other")]
|
|
mounts, skipped = plan(datasets=datasets)
|
|
assert len(mounts) == 6
|
|
assert skipped == []
|
|
|
|
|
|
class TestSilentOmissionGuard:
|
|
"""The whole point of the feature. These are the tests that matter."""
|
|
|
|
@staticmethod
|
|
def _missing_pgdata(path):
|
|
return "missing" if "/mnt/Tap/apps/immich/pgdata/" in path else "ok"
|
|
|
|
@staticmethod
|
|
def _denied_pgdata(path):
|
|
if "/mnt/Tap/apps/immich/pgdata/" in path:
|
|
return "cannot be read (Permission denied)"
|
|
return "ok"
|
|
|
|
def test_missing_snapshot_on_descendant_raises(self):
|
|
with pytest.raises(StagingError, match="incomplete tree"):
|
|
plan(probe=self._missing_pgdata)
|
|
|
|
def test_error_names_the_offending_dataset(self):
|
|
with pytest.raises(StagingError, match="Tap/apps/immich/pgdata"):
|
|
plan(probe=self._missing_pgdata)
|
|
|
|
def test_missing_and_unreadable_are_reported_differently(self):
|
|
# os.path.isdir() collapses both into False, which would report a
|
|
# permission problem as "has no snapshot" and send you hunting for a
|
|
# snapshot that is sitting right there. Both abort -- but say which.
|
|
with pytest.raises(StagingError, match="has no snapshot"):
|
|
plan(probe=self._missing_pgdata)
|
|
with pytest.raises(StagingError, match="Permission denied"):
|
|
plan(probe=self._denied_pgdata)
|
|
|
|
|
|
class TestSnapshotTreeNames:
|
|
"""zfs.snapshot.delete is non-recursive; we must sweep children ourselves."""
|
|
|
|
ALL = [
|
|
"Tap@cloud_backup-5-20260712030000",
|
|
"Tap/apps@cloud_backup-5-20260712030000",
|
|
"Tap/apps/lidarr/config@cloud_backup-5-20260712030000",
|
|
"Tap@auto-2026-07-12_03-00", # unrelated periodic snapshot
|
|
"Tap/apps@cloud_backup-9-20260712030000", # another task
|
|
"Tank/backups@cloud_backup-5-20260712030000", # different pool
|
|
]
|
|
|
|
def test_returns_parent_and_all_children(self):
|
|
got = snapshot_tree_names("Tap@cloud_backup-5-20260712030000", self.ALL)
|
|
assert set(got) == {
|
|
"Tap@cloud_backup-5-20260712030000",
|
|
"Tap/apps@cloud_backup-5-20260712030000",
|
|
"Tap/apps/lidarr/config@cloud_backup-5-20260712030000",
|
|
}
|
|
|
|
def test_never_touches_periodic_or_other_tasks_or_other_pools(self):
|
|
got = snapshot_tree_names("Tap@cloud_backup-5-20260712030000", self.ALL)
|
|
assert "Tap@auto-2026-07-12_03-00" not in got
|
|
assert "Tap/apps@cloud_backup-9-20260712030000" not in got
|
|
assert "Tank/backups@cloud_backup-5-20260712030000" not in got
|
|
|
|
def test_malformed_snapshot_name_yields_nothing(self):
|
|
assert snapshot_tree_names("Tap", self.ALL) == []
|
|
|
|
|
|
class _FrameworkCRUDService:
|
|
"""Stands in for middlewared's real `CRUDService` base class.
|
|
|
|
This shape is load-bearing, and a fake without it is worse than no fake at all.
|
|
|
|
The real CRUDService defines `delete` on the BASE class and dispatches to
|
|
`self.do_delete` at call time, so a bound `delete` exists on EVERY subclass —
|
|
including one whose `do_delete` iX has deleted. A fake that is just a bare object
|
|
with a `delete` attribute cannot express that, so a runtime check that asks
|
|
`hasattr(service, "delete")` would look CORRECT against the fake while being
|
|
useless against the real thing. That is exactly what happened: the first version
|
|
of this fix passed its test and did nothing on a real box.
|
|
|
|
`__module__` is set to middlewared's real framework package because that is how
|
|
`_defines_delete` tells plumbing apart from an implementation.
|
|
"""
|
|
|
|
def delete(self, *args, **kwargs): # the generic dispatcher -> self.do_delete
|
|
raise NotImplementedError
|
|
|
|
|
|
_FrameworkCRUDService.__module__ = "middlewared.service.crud_service"
|
|
|
|
|
|
class _FakeSnapshotService(_FrameworkCRUDService):
|
|
"""What `middleware.get_service("<ns>")` hands back: a plugin CRUDService.
|
|
|
|
`delete_method=None` models the dangerous case — iX guts the concrete method but
|
|
leaves the service registered (they have already done this to
|
|
`pool.snapshot.do_update` on master). The inherited `delete` is still there and
|
|
still callable; only the implementation is gone.
|
|
"""
|
|
|
|
def __init__(self, delete_method):
|
|
if delete_method:
|
|
# Define it on the CLASS, not the instance: `_defines_delete` walks the
|
|
# MRO's __dict__s, exactly as it must against a real service.
|
|
cls = type(
|
|
"FakePluginSnapshotService", (_FrameworkCRUDService,),
|
|
{delete_method: lambda self, *a, **kw: None},
|
|
)
|
|
cls.__module__ = "middlewared.plugins.pool_.snapshot"
|
|
self.__class__ = cls
|
|
|
|
|
|
class FakeMiddleware:
|
|
"""middlewared as this module actually uses it: `call_sync`, from a thread.
|
|
|
|
The module is synchronous on purpose -- see the orchestration note in
|
|
truecloud_nested.py. TrueNAS <= 25.10 reaches it through
|
|
`await middleware.run_in_thread(...)` and TrueNAS 26 calls it directly, but the
|
|
logic below the boundary is the same code either way, so it is tested once.
|
|
|
|
`snapshot_ns` picks which middleware GENERATION this is, because they do not
|
|
agree on what the snapshot service is called:
|
|
|
|
pool.snapshot 25.10 and 26 (26 has ONLY this -- plugins/zfs_/ is gone)
|
|
zfs.snapshot 24.10 and 25.04 (pool.snapshot does not exist yet)
|
|
|
|
A method in the namespace this box does NOT have raises, exactly as middleware
|
|
does ("Method does not exist"). That is what makes the runtime picker testable:
|
|
a module that guessed wrong would blow up here instead of silently orphaning
|
|
snapshots on somebody's NAS.
|
|
"""
|
|
|
|
def __init__(self, snapshots=None, snapshot_ns="pool.snapshot",
|
|
delete_method="delete"):
|
|
self.snapshots = list(snapshots or [])
|
|
self.calls = []
|
|
self.logger = None
|
|
self.snapshot_ns = snapshot_ns
|
|
#: A CRUDService exposes `delete` from a method NAMED `do_delete`. Both
|
|
#: spellings are live across the matrix, and the runtime must accept either
|
|
#: -- it resolves the namespace by asking whether it can DELETE, not merely
|
|
#: whether the service is registered.
|
|
self.delete_method = delete_method
|
|
|
|
def get_service(self, name):
|
|
if name != self.snapshot_ns:
|
|
raise KeyError(name) # middleware raises KeyError for an unknown ns
|
|
return _FakeSnapshotService(self.delete_method)
|
|
|
|
def list_snapshots(self, dataset):
|
|
"""Stands in for `zfs list -t snapshot -r <dataset>`.
|
|
|
|
Enumeration comes from ZFS now, NOT from middleware -- middleware's query
|
|
hides internal datasets, and a sweep that cannot see a snapshot can never
|
|
collect it. Passed in as `list_snapshots=` so the seam is explicit.
|
|
"""
|
|
return [
|
|
n for n in self.snapshots
|
|
if n.split("@")[0] == dataset or n.startswith(dataset + "/")
|
|
]
|
|
|
|
def call_sync(self, method, *args):
|
|
self.calls.append((method, args))
|
|
namespace, _, op = method.rpartition(".")
|
|
|
|
if namespace != self.snapshot_ns:
|
|
raise RuntimeError("Method does not exist")
|
|
|
|
if op == "query":
|
|
return [{"name": n} for n in self.snapshots]
|
|
if op == "delete":
|
|
name = args[0]
|
|
opts = args[1] if len(args) > 1 else {}
|
|
if name not in self.snapshots:
|
|
raise RuntimeError("does not exist")
|
|
if opts.get("recursive"):
|
|
# Real `zfs destroy -r` takes the parent and every child snapshot.
|
|
for n in snapshot_tree_names(name, list(self.snapshots)):
|
|
self.snapshots.remove(n)
|
|
else:
|
|
self.snapshots.remove(name)
|
|
return True
|
|
raise AssertionError(f"unexpected call {method}")
|
|
|
|
|
|
def stub_core(monkeypatch, tn, *, plan=None, order=None, plan_raises=None):
|
|
"""Replace the blocking core (plan/apply/verify/teardown) with recorders.
|
|
|
|
stage_nested calls these directly now, so they are patched by NAME rather than
|
|
intercepted at a `run_in_thread` boundary that no longer exists.
|
|
"""
|
|
def record(name, result):
|
|
def fn(*args, **kwargs):
|
|
if order is not None:
|
|
order.append(name)
|
|
if name == "plan_staging" and plan_raises is not None:
|
|
raise plan_raises
|
|
return result() if callable(result) else result
|
|
fn.__name__ = name
|
|
return fn
|
|
|
|
real_write = tn._write_sidecar
|
|
|
|
def write_sidecar(*args, **kwargs):
|
|
if order is not None:
|
|
order.append("_write_sidecar")
|
|
return real_write(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(tn, "_write_sidecar", write_sidecar)
|
|
monkeypatch.setattr(tn, "plan_staging", record("plan_staging", plan or ([], [])))
|
|
monkeypatch.setattr(tn, "apply_plan", record("apply_plan", True))
|
|
monkeypatch.setattr(tn, "verify_staged", record("verify_staged", True))
|
|
monkeypatch.setattr(tn, "teardown", record("teardown", []))
|
|
|
|
|
|
class TestDeleteSnapshotTree:
|
|
def test_deletes_parent_and_every_child(self):
|
|
mw = FakeMiddleware([
|
|
"Tap@snap", "Tap/apps@snap", "Tap/apps/lidarr@snap", "Tap@keepme",
|
|
])
|
|
delete_snapshot_tree(mw, "Tap@snap", list_snapshots=mw.list_snapshots)
|
|
assert mw.snapshots == ["Tap@keepme"]
|
|
|
|
def test_is_idempotent_when_stock_already_removed_the_parent(self):
|
|
# Stock's finally can win the race once our mounts are released.
|
|
mw = FakeMiddleware(["Tap/apps@snap", "Tap/apps/lidarr@snap"])
|
|
delete_snapshot_tree(mw, "Tap@snap", list_snapshots=mw.list_snapshots)
|
|
assert mw.snapshots == []
|
|
|
|
def test_uses_a_single_recursive_delete_not_252_individual_ones(self):
|
|
# 252 sequential deletes are slow AND not atomic: a run killed part-way
|
|
# through leaves exactly the orphans this function exists to prevent.
|
|
mw = FakeMiddleware(["Tap@snap", "Tap/apps@snap", "Tap/apps/lidarr@snap"])
|
|
listed = []
|
|
|
|
def counting_lister(dataset):
|
|
listed.append(dataset)
|
|
return mw.list_snapshots(dataset)
|
|
|
|
delete_snapshot_tree(mw, "Tap@snap", list_snapshots=counting_lister)
|
|
assert mw.snapshots == []
|
|
deletes = [a for m, a in mw.calls if m.endswith(".delete")]
|
|
assert len(deletes) == 1, "should be ONE recursive call, not one per snapshot"
|
|
assert deletes[0][1] == {"recursive": True}
|
|
assert listed == ["Tap"], (
|
|
"the fast path must enumerate EXACTLY ONCE -- to CONFIRM the tree is gone. "
|
|
"Zero would mean trusting a delete that returned without raising, and a "
|
|
"silent no-op delete then makes cleanup_task drop the sidecar and orphan "
|
|
"~250 snapshots forever. More than once is waste."
|
|
)
|
|
|
|
def test_survives_recursive_and_enumeration_failure_by_deleting_the_parent(self):
|
|
# Both the recursive delete AND the ZFS enumeration fail. The sweep must still
|
|
# remove the parent rather than give up entirely.
|
|
class NoRecursive(FakeMiddleware):
|
|
def call_sync(self, method, *args):
|
|
if method.endswith(".delete") and len(args) > 1:
|
|
raise RuntimeError("recursive delete unavailable")
|
|
return super().call_sync(method, *args)
|
|
|
|
def broken_lister(_dataset):
|
|
raise tn.ZfsError("boom")
|
|
|
|
mw = NoRecursive(["Tap@snap"])
|
|
delete_snapshot_tree(mw, "Tap@snap", list_snapshots=broken_lister)
|
|
assert mw.snapshots == [], "must fall back to at least deleting the parent"
|
|
|
|
def test_leaves_unrelated_snapshots_alone_when_the_tree_is_gone(self):
|
|
mw = FakeMiddleware(["Tap@unrelated"])
|
|
delete_snapshot_tree(mw, "Tap@snap", list_snapshots=mw.list_snapshots)
|
|
assert mw.snapshots == ["Tap@unrelated"]
|
|
|
|
|
|
class TestStageNestedOrdering:
|
|
def test_sidecar_is_written_before_anything_is_mounted(self, tmp_path, monkeypatch):
|
|
# middlewared can die at any moment. If the snapshot were recorded only
|
|
# after apply_plan, a crash in that window would orphan a 160-snapshot
|
|
# tree -- the precise failure the sidecar exists to prevent.
|
|
import truecloud_nested as tn
|
|
|
|
monkeypatch.setattr(tn, "STAGING_BASE", str(tmp_path))
|
|
order = []
|
|
stub_core(monkeypatch, tn, order=order,
|
|
plan=([("/src", str(tmp_path / "cloud_backup-5"))], []))
|
|
|
|
_mw = FakeMiddleware()
|
|
tn.stage_nested(
|
|
_mw, "/mnt/Tap", "Tap@snap", "Tap", "/mnt/Tap",
|
|
"cloud_backup-5", DATASETS, list_snapshots=_mw.list_snapshots,
|
|
)
|
|
|
|
assert order.index("_write_sidecar") < order.index("apply_plan")
|
|
|
|
def test_reclaims_the_snapshot_tree_left_by_a_crashed_run(self, tmp_path,
|
|
monkeypatch):
|
|
# teardown() reclaims the crashed run's MOUNTS, but nothing else would
|
|
# ever reclaim its SNAPSHOTS -- and we are about to overwrite the only
|
|
# record of them. One crash would orphan 160+ snapshots permanently.
|
|
import truecloud_nested as tn
|
|
|
|
monkeypatch.setattr(tn, "STAGING_BASE", str(tmp_path))
|
|
root = tn.staging_root_for("cloud_backup-5")
|
|
os.makedirs(os.path.dirname(root), exist_ok=True)
|
|
with open(sidecar_for(root), "w", encoding="utf-8") as fh:
|
|
fh.write("Tap@old-crashed-run")
|
|
|
|
mw = FakeMiddleware(["Tap@old-crashed-run", "Tap/apps@old-crashed-run"])
|
|
stub_core(monkeypatch, tn, plan=([("/src", root)], []))
|
|
|
|
tn.stage_nested(
|
|
mw, "/mnt/Tap", "Tap@new", "Tap", "/mnt/Tap",
|
|
"cloud_backup-5", DATASETS, list_snapshots=mw.list_snapshots,
|
|
)
|
|
|
|
assert mw.snapshots == [], "the crashed run's snapshot tree must be reclaimed"
|
|
|
|
def test_sidecar_is_KEPT_when_staging_fails(self, tmp_path, monkeypatch):
|
|
# The caller sweeps the snapshot tree on the way out, and anything still busy
|
|
# SURVIVES that sweep -- with the sidecar as its only record. Removing the
|
|
# sidecar here would orphan those snapshots permanently.
|
|
#
|
|
# The asymmetry is the point: a sidecar left behind when the tree is already
|
|
# gone costs one no-op delete on the next run; a sidecar removed while the tree
|
|
# still exists is unrecoverable.
|
|
import truecloud_nested as tn
|
|
|
|
monkeypatch.setattr(tn, "STAGING_BASE", str(tmp_path))
|
|
root = tn.staging_root_for("cloud_backup-5")
|
|
stub_core(monkeypatch, tn, plan_raises=StagingError("boom"))
|
|
|
|
with pytest.raises(StagingError):
|
|
_mw = FakeMiddleware()
|
|
tn.stage_nested(
|
|
_mw, "/mnt/Tap", "Tap@snap", "Tap", "/mnt/Tap",
|
|
"cloud_backup-5", DATASETS, list_snapshots=_mw.list_snapshots,
|
|
)
|
|
|
|
assert os.path.exists(sidecar_for(root)), (
|
|
"sidecar removed on staging failure — any snapshot the caller's sweep "
|
|
"cannot delete is now orphaned forever"
|
|
)
|
|
with open(sidecar_for(root), encoding="utf-8") as fh:
|
|
assert fh.read().strip() == "Tap@snap"
|
|
|
|
|
|
class TestCleanupTask:
|
|
def test_recovers_snapshot_from_sidecar_after_middlewared_restart(self, tmp_path,
|
|
monkeypatch):
|
|
# The sidecar is the ONLY record of the pinned snapshot, precisely so a
|
|
# middlewared restart cannot orphan the tree.
|
|
import truecloud_nested as tn
|
|
|
|
monkeypatch.setattr(tn, "STAGING_BASE", str(tmp_path))
|
|
root = tn.staging_root_for("cloud_backup-5", base=str(tmp_path))
|
|
os.makedirs(root, exist_ok=True)
|
|
with open(sidecar_for(root), "w", encoding="utf-8") as fh:
|
|
fh.write("Tap@snap")
|
|
|
|
mw = FakeMiddleware(["Tap@snap", "Tap/apps@snap"])
|
|
monkeypatch.setattr(tn, "teardown", lambda *_a, **_k: [])
|
|
|
|
cleanup_task(mw, "cloud_backup-5", list_snapshots=mw.list_snapshots)
|
|
|
|
assert mw.snapshots == []
|
|
assert not os.path.exists(sidecar_for(root))
|
|
|
|
def test_is_a_noop_when_never_staged(self, tmp_path, monkeypatch):
|
|
import truecloud_nested as tn
|
|
|
|
monkeypatch.setattr(tn, "STAGING_BASE", str(tmp_path / "nope"))
|
|
mw = FakeMiddleware(["Tap@snap"])
|
|
cleanup_task(mw, "cloud_backup-5", list_snapshots=mw.list_snapshots)
|
|
assert mw.calls == []
|
|
assert mw.snapshots == ["Tap@snap"]
|
|
|
|
|
|
class TestVerifyStaged:
|
|
"""Anti-regression guard: proves the staged tree is real before we back it up."""
|
|
|
|
def test_passes_when_every_target_is_mounted_and_root_non_empty(self):
|
|
mounts = [("/src", ROOT), ("/src/a", f"{ROOT}/a")]
|
|
assert verify_staged(mounts, ismount=lambda p: True, listdir=lambda p: ["apps"])
|
|
|
|
def test_raises_when_a_target_is_not_actually_mounted(self):
|
|
# This is the case that would produce a silently-empty backup.
|
|
mounts = [("/src", ROOT), ("/src/a", f"{ROOT}/a")]
|
|
with pytest.raises(StagingError, match="not a mountpoint"):
|
|
verify_staged(mounts, ismount=lambda p: p == ROOT, listdir=lambda p: ["apps"])
|
|
|
|
def test_raises_when_staging_root_is_empty(self):
|
|
with pytest.raises(StagingError, match="empty"):
|
|
verify_staged([("/src", ROOT)], ismount=lambda p: True, listdir=lambda p: [])
|
|
|
|
def test_raises_on_empty_plan(self):
|
|
with pytest.raises(StagingError):
|
|
verify_staged([])
|
|
|
|
|
|
class FakeRunner:
|
|
def __init__(self, fail_on=None):
|
|
self.fail_on = fail_on
|
|
self.calls = []
|
|
|
|
def __call__(self, cmd):
|
|
self.calls.append(cmd)
|
|
|
|
class R:
|
|
returncode = 0
|
|
stderr = ""
|
|
|
|
if self.fail_on and cmd[0] == "mount" and cmd[2] == self.fail_on:
|
|
R.returncode = 32
|
|
R.stderr = "mount failed"
|
|
return R
|
|
|
|
|
|
class TestApplyPlanRollback:
|
|
def test_rolls_back_mounts_when_one_fails(self, tmp_path):
|
|
# A half-built tree must never reach the backup tool.
|
|
root = str(tmp_path / "root")
|
|
mounts = [("/src", root), ("/src/a", root + "/a"), ("/src/b", root + "/b")]
|
|
|
|
runner = FakeRunner(fail_on="/src/b")
|
|
with pytest.raises(StagingError, match="bind-mount"):
|
|
apply_plan(mounts, runner=runner, isdir=lambda _p: True)
|
|
|
|
umounts = [c[-1] for c in runner.calls if c[0] == "umount"]
|
|
assert umounts == [root + "/a", root]
|
|
|
|
def test_raises_when_target_missing(self, tmp_path):
|
|
root = str(tmp_path / "root")
|
|
with pytest.raises(StagingError, match="does not exist"):
|
|
apply_plan(
|
|
[("/src", root), ("/src/a", root + "/a")],
|
|
runner=FakeRunner(),
|
|
isdir=lambda p: p == root,
|
|
)
|
|
|
|
|
|
class TestTeardown:
|
|
def test_unmounts_deepest_first(self, tmp_path):
|
|
mounts_file = tmp_path / "mounts"
|
|
mounts_file.write_text(
|
|
f"tmpfs {ROOT} tmpfs rw 0 0\n"
|
|
f"tmpfs {ROOT}/apps tmpfs rw 0 0\n"
|
|
f"tmpfs {ROOT}/apps/lidarr/config tmpfs rw 0 0\n"
|
|
f"tmpfs {ROOT}/apps/lidarr tmpfs rw 0 0\n"
|
|
"tmpfs /somewhere/else tmpfs rw 0 0\n"
|
|
)
|
|
runner = FakeRunner()
|
|
teardown(ROOT, runner=runner, mounts_file=str(mounts_file))
|
|
|
|
order = [c[-1] for c in runner.calls if c[0] == "umount"]
|
|
assert order == [
|
|
f"{ROOT}/apps/lidarr/config",
|
|
f"{ROOT}/apps/lidarr",
|
|
f"{ROOT}/apps",
|
|
ROOT,
|
|
]
|
|
assert "/somewhere/else" not in order
|
|
|
|
def test_is_idempotent_when_nothing_mounted(self, tmp_path):
|
|
mounts_file = tmp_path / "mounts"
|
|
mounts_file.write_text("tmpfs /somewhere/else tmpfs rw 0 0\n")
|
|
runner = FakeRunner()
|
|
assert teardown(ROOT, runner=runner, mounts_file=str(mounts_file)) == []
|
|
assert runner.calls == []
|
|
|
|
def test_falls_back_to_lazy_umount(self, tmp_path):
|
|
mounts_file = tmp_path / "mounts"
|
|
mounts_file.write_text(f"tmpfs {ROOT} tmpfs rw 0 0\n")
|
|
|
|
class Busy(FakeRunner):
|
|
def __call__(self, cmd):
|
|
self.calls.append(cmd)
|
|
|
|
class R:
|
|
returncode = 0 if "-l" in cmd else 32
|
|
stderr = "target is busy"
|
|
|
|
return R
|
|
|
|
runner = Busy()
|
|
assert teardown(ROOT, runner=runner, mounts_file=str(mounts_file)) == []
|
|
assert ["umount", "-l", ROOT] in runner.calls
|
|
|
|
|
|
class TestCleanupAll:
|
|
"""uninstall.sh and recover.sh call this instead of reimplementing teardown."""
|
|
|
|
def test_reports_orphan_snapshots_before_deleting_their_sidecars(self, tmp_path):
|
|
# The sidecar is the only record that an interrupted run's snapshot tree
|
|
# is still on disk. Deleting it without naming the snapshot orphans the
|
|
# whole tree silently.
|
|
base = tmp_path / "stage"
|
|
base.mkdir()
|
|
(base / "cloud_backup-5.snapshot").write_text("Tap@interrupted")
|
|
|
|
mounts_file = tmp_path / "mounts"
|
|
mounts_file.write_text("")
|
|
|
|
lines, errors = cleanup_all(
|
|
base=str(base), runner=FakeRunner(), mounts_file=str(mounts_file)
|
|
)
|
|
assert errors == []
|
|
assert any("Tap@interrupted" in ln for ln in lines)
|
|
assert any("zfs destroy -r" in ln for ln in lines)
|
|
# Sidecar cleared only after being reported.
|
|
assert not (base / "cloud_backup-5.snapshot").exists()
|
|
|
|
def test_unmounts_everything_under_the_base_deepest_first(self, tmp_path):
|
|
base = tmp_path / "stage"
|
|
base.mkdir()
|
|
mounts_file = tmp_path / "mounts"
|
|
mounts_file.write_text(
|
|
f"tmpfs {base} tmpfs rw 0 0\n"
|
|
f"tmpfs {base}/cloud_backup-5 tmpfs rw 0 0\n"
|
|
f"tmpfs {base}/cloud_backup-5/apps tmpfs rw 0 0\n"
|
|
)
|
|
runner = FakeRunner()
|
|
_lines, errors = cleanup_all(
|
|
base=str(base), runner=runner, mounts_file=str(mounts_file)
|
|
)
|
|
assert errors == []
|
|
order = [c[-1] for c in runner.calls if c[0] == "umount"]
|
|
assert order == [
|
|
f"{base}/cloud_backup-5/apps",
|
|
f"{base}/cloud_backup-5",
|
|
str(base),
|
|
]
|
|
|
|
def test_keeps_sidecars_when_an_unmount_failed(self, tmp_path):
|
|
# If a mount is stuck, the snapshot is still pinned — so the record of it
|
|
# must survive for the next run (or the operator) to act on.
|
|
base = tmp_path / "stage"
|
|
base.mkdir()
|
|
(base / "cloud_backup-5.snapshot").write_text("Tap@stuck")
|
|
mounts_file = tmp_path / "mounts"
|
|
mounts_file.write_text(f"tmpfs {base}/cloud_backup-5 tmpfs rw 0 0\n")
|
|
|
|
class Stuck(FakeRunner):
|
|
def __call__(self, cmd):
|
|
self.calls.append(cmd)
|
|
|
|
class R:
|
|
returncode = 32
|
|
stderr = "target is busy"
|
|
|
|
return R
|
|
|
|
_lines, errors = cleanup_all(
|
|
base=str(base), runner=Stuck(), mounts_file=str(mounts_file)
|
|
)
|
|
assert errors, "a stuck unmount must be reported"
|
|
assert (base / "cloud_backup-5.snapshot").exists()
|
|
|
|
def test_is_a_noop_on_a_clean_system(self, tmp_path):
|
|
mounts_file = tmp_path / "mounts"
|
|
mounts_file.write_text("")
|
|
lines, errors = cleanup_all(
|
|
base=str(tmp_path / "absent"), runner=FakeRunner(),
|
|
mounts_file=str(mounts_file),
|
|
)
|
|
assert errors == []
|
|
assert lines == [" None active."]
|
|
|
|
|
|
class TestCurrentMountsUnder:
|
|
def test_matches_only_the_staging_subtree(self, tmp_path):
|
|
mounts_file = tmp_path / "mounts"
|
|
# "cloud_backup-50" must NOT match "cloud_backup-5".
|
|
mounts_file.write_text(
|
|
f"tmpfs {ROOT} tmpfs rw 0 0\n"
|
|
"tmpfs /run/truecloud-nested/cloud_backup-50 tmpfs rw 0 0\n"
|
|
)
|
|
assert current_mounts_under(ROOT, mounts_file=str(mounts_file)) == [ROOT]
|
|
|
|
|
|
class TestStagingRootFor:
|
|
def test_stable_per_task(self):
|
|
assert staging_root_for("cloud_backup-5") == "/run/truecloud-nested/cloud_backup-5"
|
|
|
|
def test_sanitises_path_separators(self):
|
|
assert "/" not in staging_root_for("evil/name").rsplit("/", 1)[-1]
|
|
|
|
@pytest.mark.parametrize("name", ["..", ".", "...", "/", ""])
|
|
def test_dot_components_cannot_escape_the_staging_base(self, name):
|
|
# os.path.join(BASE, "..") normalises to /run — teardown would rmdir it.
|
|
root = staging_root_for(name)
|
|
assert os.path.normpath(root).startswith("/run/truecloud-nested/")
|
|
|
|
|
|
class TestZfsAutomountKeepsSnapshotsBusy:
|
|
""""dataset is busy" is EXPECTED, TRANSIENT, and used to orphan snapshots forever.
|
|
|
|
Reading `<dataset>/.zfs/snapshot/<snap>/` makes ZFS **automount** that snapshot,
|
|
and it stays mounted for zfs_expire_snapshot seconds (300 by default) after the
|
|
last access. teardown() unmounts OUR bind mounts, but not the automount underneath
|
|
-- so `zfs destroy` refuses with EBUSY for everything restic read recently.
|
|
|
|
Observed on a real 256-snapshot tree: 253 swept cleanly, and the 3 datasets restic
|
|
had touched last failed with "dataset is busy". cleanup_task then removed the
|
|
sidecar anyway, so nothing would ever reclaim them. A few snapshots leaked per run,
|
|
forever.
|
|
"""
|
|
|
|
MOUNTS = (
|
|
"tmpfs /run tmpfs rw 0 0\n"
|
|
"Tap/apps/prometheus /mnt/Tap/apps/prometheus/.zfs/snapshot/snap1 zfs ro 0 0\n"
|
|
"Tap/apps/standing/data /mnt/Tap/apps/standing/data/.zfs/snapshot/snap1 zfs ro 0 0\n"
|
|
"Tap /mnt/Tap/.zfs/snapshot/snap1 zfs ro 0 0\n"
|
|
"Tap/other /mnt/Tap/other/.zfs/snapshot/OTHER zfs ro 0 0\n"
|
|
)
|
|
|
|
def _mounts_file(self, tmp_path):
|
|
p = tmp_path / "mounts"
|
|
p.write_text(self.MOUNTS)
|
|
return str(p)
|
|
|
|
def test_it_finds_the_automounts_for_this_snapshot_only(self, tmp_path):
|
|
import truecloud_nested as tn
|
|
found = tn.snapdir_automounts("snap1", mounts_file=self._mounts_file(tmp_path))
|
|
assert "/mnt/Tap/other/.zfs/snapshot/OTHER" not in found
|
|
assert len(found) == 3
|
|
|
|
def test_deepest_first(self, tmp_path):
|
|
# A child's automount must be released before its parent's.
|
|
import truecloud_nested as tn
|
|
found = tn.snapdir_automounts("snap1", mounts_file=self._mounts_file(tmp_path))
|
|
assert found[-1] == "/mnt/Tap/.zfs/snapshot/snap1"
|
|
|
|
def test_release_snapdirs_unmounts_them(self, tmp_path):
|
|
import truecloud_nested as tn
|
|
called = []
|
|
|
|
class R:
|
|
returncode = 0
|
|
stderr = ""
|
|
|
|
def runner(cmd):
|
|
called.append(cmd)
|
|
return R()
|
|
|
|
errs = tn.release_snapdirs("snap1", runner=runner,
|
|
mounts_file=self._mounts_file(tmp_path))
|
|
assert errs == []
|
|
assert all(c[0] == "umount" for c in called)
|
|
assert len(called) == 3
|
|
|
|
|
|
class BusyMiddleware(FakeMiddleware):
|
|
"""Deletes fail with EBUSY until `busy_until_attempt` passes -- like a ZFS
|
|
automount expiring."""
|
|
|
|
def __init__(self, snapshots, busy, busy_for=2):
|
|
super().__init__(snapshots)
|
|
self.busy = set(busy)
|
|
self.busy_for = busy_for
|
|
self.attempts = 0
|
|
|
|
def call_sync(self, method, *args):
|
|
if method.endswith(".delete"):
|
|
name = args[0]
|
|
opts = args[1] if len(args) > 1 else {}
|
|
if opts.get("recursive"):
|
|
raise RuntimeError("cannot destroy snapshot: dataset is busy")
|
|
if name in self.busy:
|
|
self.attempts += 1
|
|
if self.attempts <= self.busy_for * len(self.busy):
|
|
raise RuntimeError(f"cannot destroy '{name}': dataset is busy")
|
|
return super().call_sync(method, *args)
|
|
|
|
|
|
class TestDeleteRetriesAndReportsSurvivors:
|
|
def test_a_transient_busy_is_retried_and_wins(self, monkeypatch):
|
|
import truecloud_nested as tn
|
|
monkeypatch.setattr(tn, "release_snapdirs", lambda *a, **k: [])
|
|
|
|
mw = BusyMiddleware(
|
|
["Tap@snap", "Tap/apps@snap", "Tap/apps/prometheus@snap"],
|
|
busy=["Tap/apps/prometheus@snap"], busy_for=1,
|
|
)
|
|
survivors = tn.delete_snapshot_tree(mw, "Tap@snap", sleep=lambda _s: None, list_snapshots=mw.list_snapshots)
|
|
assert survivors == []
|
|
assert mw.snapshots == []
|
|
|
|
def test_a_permanently_busy_snapshot_is_REPORTED_not_swallowed(self, monkeypatch):
|
|
import truecloud_nested as tn
|
|
monkeypatch.setattr(tn, "release_snapdirs", lambda *a, **k: [])
|
|
|
|
mw = BusyMiddleware(
|
|
["Tap@snap", "Tap/apps/prometheus@snap"],
|
|
busy=["Tap/apps/prometheus@snap"], busy_for=99,
|
|
)
|
|
survivors = tn.delete_snapshot_tree(mw, "Tap@snap", sleep=lambda _s: None, list_snapshots=mw.list_snapshots)
|
|
assert survivors == ["Tap/apps/prometheus@snap"]
|
|
assert mw.snapshots == ["Tap/apps/prometheus@snap"]
|
|
|
|
def test_the_automounts_are_released_before_deleting(self, monkeypatch):
|
|
import truecloud_nested as tn
|
|
order = []
|
|
monkeypatch.setattr(tn, "release_snapdirs",
|
|
lambda name, **k: order.append(("release", name)) or [])
|
|
mw = FakeMiddleware(["Tap@snap"])
|
|
real = mw.call_sync
|
|
|
|
def spy(method, *args):
|
|
order.append((method, args[0] if args else None))
|
|
return real(method, *args)
|
|
|
|
mw.call_sync = spy
|
|
tn.delete_snapshot_tree(mw, "Tap@snap", sleep=lambda _s: None, list_snapshots=mw.list_snapshots)
|
|
assert order[0] == ("release", "snap"), order
|
|
|
|
|
|
class TestSidecarSurvivesAnIncompleteSweep:
|
|
def test_the_sidecar_is_KEPT_when_snapshots_could_not_be_deleted(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
# It is the ONLY record those snapshots exist. Removing it orphans them
|
|
# permanently -- which is exactly what happened on the real box.
|
|
import truecloud_nested as tn
|
|
|
|
monkeypatch.setattr(tn, "STAGING_BASE", str(tmp_path))
|
|
monkeypatch.setattr(tn, "release_snapdirs", lambda *a, **k: [])
|
|
root = tn.staging_root_for("cloud_backup-5")
|
|
os.makedirs(root, exist_ok=True)
|
|
with open(sidecar_for(root), "w", encoding="utf-8") as fh:
|
|
fh.write("Tap@snap")
|
|
|
|
mw = BusyMiddleware(["Tap@snap", "Tap/apps/prometheus@snap"],
|
|
busy=["Tap/apps/prometheus@snap"], busy_for=99)
|
|
monkeypatch.setattr(tn, "delete_snapshot_tree",
|
|
lambda m, s, logger=None, **kw: ["Tap/apps/prometheus@snap"])
|
|
|
|
tn.cleanup_task(mw, "cloud_backup-5", list_snapshots=mw.list_snapshots)
|
|
assert os.path.exists(sidecar_for(root)), (
|
|
"sidecar removed despite survivors — they are now orphaned forever"
|
|
)
|
|
|
|
def test_the_sidecar_is_removed_on_a_clean_sweep(self, tmp_path, monkeypatch):
|
|
import truecloud_nested as tn
|
|
|
|
monkeypatch.setattr(tn, "STAGING_BASE", str(tmp_path))
|
|
root = tn.staging_root_for("cloud_backup-5")
|
|
os.makedirs(root, exist_ok=True)
|
|
with open(sidecar_for(root), "w", encoding="utf-8") as fh:
|
|
fh.write("Tap@snap")
|
|
|
|
monkeypatch.setattr(tn, "delete_snapshot_tree", lambda m, s, logger=None, **kw: [])
|
|
_mw = FakeMiddleware()
|
|
tn.cleanup_task(_mw, "cloud_backup-5", list_snapshots=_mw.list_snapshots)
|
|
assert not os.path.exists(sidecar_for(root))
|
|
|
|
|
|
class TestTheSidecarCarriesEveryPendingTree:
|
|
"""The sidecar holds a LIST, and that is a bug fix, not a generalisation.
|
|
|
|
It used to hold ONE snapshot. So a run that reclaimed an older tree, FAILED to
|
|
finish reclaiming it, and then recorded its own snapshot would **overwrite the only
|
|
record of the survivor** — orphaning it permanently, via the exact code written to
|
|
prevent orphans.
|
|
|
|
Observed live: a snapshot survived one run; the next run's reclaim also failed
|
|
(ZFS's 300s automount window had not elapsed, because the two runs were minutes
|
|
apart); the record was overwritten; the snapshot was orphaned for good.
|
|
"""
|
|
|
|
def test_round_trips_a_list(self, tmp_path):
|
|
import truecloud_nested as tn
|
|
root = str(tmp_path / "cloud_backup-5")
|
|
tn._write_sidecar(root, ["Tap@a", "Tap@b"])
|
|
assert tn._read_sidecar(root) == ["Tap@a", "Tap@b"]
|
|
|
|
def test_reads_the_old_single_line_format(self, tmp_path):
|
|
# Boxes upgrading from an older version have a one-line sidecar on disk.
|
|
import truecloud_nested as tn
|
|
root = str(tmp_path / "cloud_backup-5")
|
|
os.makedirs(os.path.dirname(sidecar_for(root)), exist_ok=True)
|
|
with open(sidecar_for(root), "w", encoding="utf-8") as fh:
|
|
fh.write("Tap@legacy")
|
|
assert tn._read_sidecar(root) == ["Tap@legacy"]
|
|
|
|
def test_a_failed_reclaim_is_carried_forward_not_overwritten(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
# THE bug. stage_nested reclaims an old tree, cannot finish, then records its
|
|
# own snapshot -- the survivor must still be in the sidecar afterwards.
|
|
import truecloud_nested as tn
|
|
|
|
monkeypatch.setattr(tn, "STAGING_BASE", str(tmp_path))
|
|
root = tn.staging_root_for("cloud_backup-5")
|
|
os.makedirs(os.path.dirname(root), exist_ok=True)
|
|
tn._write_sidecar(root, ["Tap@old"])
|
|
|
|
# The reclaim of Tap@old leaves one snapshot behind (still busy).
|
|
monkeypatch.setattr(
|
|
tn, "delete_snapshot_tree",
|
|
lambda m, s, logger=None, **kw: ["Tap/apps/x@old"] if s == "Tap@old" else [],
|
|
)
|
|
stub_core(monkeypatch, tn, plan=([("/src", root)], []))
|
|
|
|
_mw = FakeMiddleware()
|
|
tn.stage_nested(_mw, "/mnt/Tap", "Tap@new", "Tap", "/mnt/Tap",
|
|
"cloud_backup-5", DATASETS,
|
|
list_snapshots=_mw.list_snapshots)
|
|
|
|
recorded = tn._read_sidecar(root)
|
|
assert "Tap/apps/x@old" in recorded, (
|
|
"the failed reclaim's survivor was dropped — orphaned forever"
|
|
)
|
|
assert "Tap@new" in recorded, "our own snapshot must also be recorded"
|
|
|
|
def test_cleanup_sweeps_every_pending_tree_and_records_only_survivors(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
import truecloud_nested as tn
|
|
|
|
monkeypatch.setattr(tn, "STAGING_BASE", str(tmp_path))
|
|
root = tn.staging_root_for("cloud_backup-5")
|
|
os.makedirs(root, exist_ok=True)
|
|
tn._write_sidecar(root, ["Tap@old", "Tap@new"])
|
|
|
|
swept = []
|
|
|
|
def fake_delete(m, s, logger=None, **kw):
|
|
swept.append(s)
|
|
return ["Tap/apps/x@new"] if s == "Tap@new" else []
|
|
|
|
monkeypatch.setattr(tn, "delete_snapshot_tree", fake_delete)
|
|
_mw = FakeMiddleware()
|
|
tn.cleanup_task(_mw, "cloud_backup-5", list_snapshots=_mw.list_snapshots)
|
|
|
|
assert swept == ["Tap@old", "Tap@new"], "both pending trees must be swept"
|
|
# Only the SURVIVOR is written back -- re-recording Tap@old would make every
|
|
# future run re-sweep a tree that is already gone.
|
|
assert tn._read_sidecar(root) == ["Tap/apps/x@new"]
|
|
|
|
def test_a_fully_clean_sweep_removes_the_sidecar(self, tmp_path, monkeypatch):
|
|
import truecloud_nested as tn
|
|
|
|
monkeypatch.setattr(tn, "STAGING_BASE", str(tmp_path))
|
|
root = tn.staging_root_for("cloud_backup-5")
|
|
os.makedirs(root, exist_ok=True)
|
|
tn._write_sidecar(root, ["Tap@a", "Tap@b"])
|
|
monkeypatch.setattr(tn, "delete_snapshot_tree", lambda m, s, logger=None, **kw: [])
|
|
|
|
_mw = FakeMiddleware()
|
|
tn.cleanup_task(_mw, "cloud_backup-5", list_snapshots=_mw.list_snapshots)
|
|
assert not os.path.exists(sidecar_for(root))
|
|
|
|
def test_cleanup_all_reports_each_pending_snapshot_on_its_own_line(self, tmp_path):
|
|
# It formats them for a human during uninstall. A list rendered into an
|
|
# f-string would print "['Tap@a', 'Tap@b']" at them.
|
|
import truecloud_nested as tn
|
|
root = str(tmp_path / "cloud_backup-5")
|
|
tn._write_sidecar(root, ["Tap@a", "Tap@b"])
|
|
|
|
lines, _errors = tn.cleanup_all(
|
|
base=str(tmp_path),
|
|
glob_fn=lambda _p: [sidecar_for(root)],
|
|
mounts_file=os.devnull,
|
|
)
|
|
notes = [ln for ln in lines if "left snapshot" in ln]
|
|
assert len(notes) == 2
|
|
assert "'Tap@a'" in notes[0] and "'Tap@b'" in notes[1]
|
|
assert "[" not in "".join(notes)
|
|
|
|
|
|
class TestGarbageCollectorSelection:
|
|
"""`stale_snapshot_names` DELETES DATA on a name match.
|
|
|
|
A name match is a weaker claim than a recorded fact, so every way it could be wrong
|
|
is a test. It exists because the sidecar — which IS a recorded fact — lives in /run,
|
|
which is tmpfs: a reboot mid-backup destroys it and orphans a 250-snapshot tree with
|
|
nothing left pointing at it. This is the only thing that would ever find those.
|
|
"""
|
|
|
|
import datetime as _dt
|
|
NOW = _dt.datetime(2026, 7, 14, 12, 0, 0, tzinfo=_dt.UTC)
|
|
CURRENT = "Tap@cloud_backup-5-20260714115900" # 1 minute ago
|
|
OLD = "Tap/apps/x@cloud_backup-5-20260713030000" # ~33 hours ago
|
|
|
|
def collect(self, names, **kw):
|
|
import truecloud_nested as tn
|
|
return tn.stale_snapshot_names(
|
|
"cloud_backup-5", self.CURRENT, names, self.NOW, **kw
|
|
)
|
|
|
|
def test_it_collects_our_own_leftovers(self):
|
|
assert self.collect([self.OLD]) == [self.OLD]
|
|
|
|
def test_it_NEVER_touches_the_current_run(self):
|
|
# Both the parent and its children share the current snapname.
|
|
names = [self.CURRENT, "Tap/apps/x@cloud_backup-5-20260714115900"]
|
|
assert self.collect(names) == []
|
|
|
|
def test_it_NEVER_touches_the_current_run_EVEN_WHEN_IT_IS_OLD(self):
|
|
# The one that matters, and the one that was not tested: a backup running
|
|
# longer than the age floor. The old test's `current` was a minute old, so the
|
|
# floor excluded it anyway and the same-snapname guard never ran -- deleting
|
|
# that guard passed the whole suite.
|
|
#
|
|
# A first full upload of a 100 GB pool takes hours. If the GC collected the
|
|
# snapshot of the run that is CURRENTLY READING FROM IT, restic would be
|
|
# yanked out from under itself mid-backup.
|
|
import datetime as dt
|
|
|
|
old_current = "Tap@cloud_backup-5-20260714000000" # 12 hours old
|
|
names = [old_current, "Tap/apps/x@cloud_backup-5-20260714000000"]
|
|
assert tn.stale_snapshot_names(
|
|
"cloud_backup-5", old_current, names,
|
|
dt.datetime(2026, 7, 14, 12, 0, 0, tzinfo=dt.UTC),
|
|
) == [], (
|
|
"the GC collected the snapshot of the run that is using it. A long first "
|
|
"upload would be destroyed mid-flight."
|
|
)
|
|
|
|
def test_it_NEVER_touches_a_periodic_snapshot(self):
|
|
assert self.collect(["Tap/apps/x@auto-2026-07-13_03-00"]) == []
|
|
|
|
def test_it_NEVER_touches_a_human_made_snapshot(self):
|
|
assert self.collect(["Tap@before-i-broke-everything"]) == []
|
|
|
|
def test_it_NEVER_touches_another_TASK(self):
|
|
# cloud_backup-5 must not match cloud_backup-50. This is why the prefix
|
|
# carries the trailing dash.
|
|
assert self.collect(["Tap/apps/x@cloud_backup-50-20260713030000"]) == []
|
|
assert self.collect(["Tap/apps/x@cloud_backup-7-20260713030000"]) == []
|
|
|
|
def test_it_NEVER_touches_a_one_time_backup(self):
|
|
assert self.collect(["Tap@cloud_backup-onetime-20260713030000"]) == []
|
|
|
|
def test_it_NEVER_touches_a_snapshot_that_is_MOUNTED(self):
|
|
# An in-flight run pins its own snapshots. This — not the age heuristic — is
|
|
# what actually protects a concurrent backup.
|
|
assert self.collect([self.OLD], in_use={self.OLD}) == []
|
|
|
|
def test_it_NEVER_touches_a_snapshot_younger_than_the_minimum_age(self):
|
|
# Covers the seconds-long window between `zfs snapshot -r` and the mounts
|
|
# appearing, when a live run's snapshots look exactly like garbage.
|
|
young = "Tap/apps/x@cloud_backup-5-20260714113000" # 30 minutes ago
|
|
assert self.collect([young]) == []
|
|
assert self.collect([young], min_age=60) == [young]
|
|
|
|
def test_a_name_it_cannot_parse_is_left_alone(self):
|
|
assert self.collect(["Tap@cloud_backup-5-not-a-timestamp"]) == []
|
|
assert self.collect(["Tap@cloud_backup-5-"]) == []
|
|
|
|
def test_a_realistic_mixed_pool(self):
|
|
names = [
|
|
self.CURRENT, # ours, running
|
|
"Tap/apps/x@cloud_backup-5-20260714115900", # ours, running (child)
|
|
self.OLD, # ours, orphaned <-
|
|
"Tap/apps/y@cloud_backup-5-20260712030000", # ours, orphaned <-
|
|
"Tap/apps/x@auto-2026-07-13_03-00", # periodic
|
|
"Tap/apps/x@cloud_backup-7-20260713030000", # another task
|
|
"Tap@manual-keepme", # human
|
|
]
|
|
assert sorted(self.collect(names)) == sorted(
|
|
[self.OLD, "Tap/apps/y@cloud_backup-5-20260712030000"]
|
|
)
|
|
|
|
|
|
class TestMountedSnapshots:
|
|
def test_it_reads_snapshot_names_out_of_the_mount_table(self, tmp_path):
|
|
import truecloud_nested as tn
|
|
mounts = tmp_path / "mounts"
|
|
mounts.write_text(
|
|
"tmpfs /run tmpfs rw 0 0\n"
|
|
"Tap/apps/x@snap1 /run/truecloud-nested/t/apps/x zfs ro 0 0\n"
|
|
"Tap/apps/y@snap1 /mnt/Tap/apps/y/.zfs/snapshot/snap1 zfs ro 0 0\n"
|
|
"Tap/live /mnt/Tap/live zfs rw 0 0\n"
|
|
)
|
|
live = tn.mounted_snapshots(str(mounts))
|
|
assert live == {"Tap/apps/x@snap1", "Tap/apps/y@snap1"}
|
|
assert "Tap/live" not in live # a live dataset is not a snapshot
|
|
|
|
|
|
class TestGarbageCollectorExecution:
|
|
def test_it_deletes_the_stale_ones_and_nothing_else(self, monkeypatch, tmp_path):
|
|
import datetime as dt
|
|
import truecloud_nested as tn
|
|
|
|
mounts = tmp_path / "mounts"
|
|
mounts.write_text("")
|
|
now = dt.datetime(2026, 7, 14, 12, 0, 0, tzinfo=dt.UTC)
|
|
|
|
mw = FakeMiddleware([
|
|
"Tap@cloud_backup-5-20260714115900", # current run
|
|
"Tap/apps/x@cloud_backup-5-20260713030000", # orphan <-
|
|
"Tap/apps/x@auto-2026-07-13_03-00", # periodic
|
|
"Tap/apps/x@cloud_backup-7-20260713030000", # other task
|
|
])
|
|
remaining = tn.gc_stale_snapshots(
|
|
mw, "cloud_backup-5", "Tap@cloud_backup-5-20260714115900",
|
|
now=now, mounts_file=str(mounts),
|
|
list_snapshots=mw.list_snapshots,
|
|
)
|
|
assert remaining == []
|
|
assert mw.snapshots == [
|
|
"Tap@cloud_backup-5-20260714115900",
|
|
"Tap/apps/x@auto-2026-07-13_03-00",
|
|
"Tap/apps/x@cloud_backup-7-20260713030000",
|
|
]
|
|
|
|
def test_a_busy_orphan_is_reported_not_swallowed(self, monkeypatch, tmp_path):
|
|
import datetime as dt
|
|
import truecloud_nested as tn
|
|
|
|
mounts = tmp_path / "mounts"
|
|
mounts.write_text("")
|
|
now = dt.datetime(2026, 7, 14, 12, 0, 0, tzinfo=dt.UTC)
|
|
orphan = "Tap/apps/x@cloud_backup-5-20260713030000"
|
|
|
|
mw = BusyMiddleware(
|
|
["Tap@cloud_backup-5-20260714115900", orphan],
|
|
busy=[orphan], busy_for=99,
|
|
)
|
|
remaining = tn.gc_stale_snapshots(
|
|
mw, "cloud_backup-5", "Tap@cloud_backup-5-20260714115900",
|
|
now=now, mounts_file=str(mounts),
|
|
list_snapshots=mw.list_snapshots,
|
|
)
|
|
assert remaining == [orphan]
|
|
|
|
def test_it_collects_NOTHING_when_the_query_fails(self, tmp_path):
|
|
# Cannot enumerate => cannot know what is ours => delete nothing.
|
|
import datetime as dt
|
|
import truecloud_nested as tn
|
|
|
|
mounts = tmp_path / "mounts"
|
|
mounts.write_text("")
|
|
|
|
# The ENUMERATION fails -- which is now a `zfs list` that cannot run, not a
|
|
# middleware query. (This test used to fake a failure of `.query`, a call
|
|
# production no longer makes, so it passed no matter what the code did.)
|
|
def broken_lister(_dataset):
|
|
raise tn.ZfsError("cannot open 'Tap': pool I/O is currently suspended")
|
|
|
|
mw = FakeMiddleware(["Tap/apps/x@cloud_backup-5-20260713030000"])
|
|
assert tn.gc_stale_snapshots(
|
|
mw,
|
|
"cloud_backup-5", "Tap@cloud_backup-5-20260714115900",
|
|
now=dt.datetime(2026, 7, 14, 12, 0, 0, tzinfo=dt.UTC),
|
|
mounts_file=str(mounts),
|
|
list_snapshots=broken_lister,
|
|
) == []
|
|
assert mw.snapshots, (
|
|
"cannot enumerate => cannot know what is ours => must delete NOTHING"
|
|
)
|
|
|
|
|
|
class TestEnumerationComesFromZfsNotMiddleware:
|
|
"""The bug that a source check can never catch, found only by running it.
|
|
|
|
Porting the deleted private `zfs.dataset.query` to the public
|
|
`pool.dataset.query` looked obviously right: the method exists, it is
|
|
documented, iX will not delete it. Every test passed and `compat.py` went green
|
|
on TrueNAS 26.
|
|
|
|
It was wrong. The public query applies a VISIBILITY POLICY -- on a real box it
|
|
returns 205 of 274 datasets, hiding `ix-apps/*`, `.system/*` and `.ix-virt/*`.
|
|
On the production pool that is 84 of 270, and `ix-apps` holds LIVE APPLICATION
|
|
DATA. The staging plan would have omitted every one of them, and `plan_staging`
|
|
would never have seen them, so they would not even appear in `skipped`. A green
|
|
backup, silently missing data -- the precise failure this module exists to
|
|
prevent.
|
|
|
|
The snapshot query lies the same way, so the sweep would orphan one snapshot per
|
|
hidden dataset, forever.
|
|
|
|
Hence: READ from ZFS, MUTATE through middleware. These tests hold that line.
|
|
"""
|
|
|
|
def test_query_filesystems_shells_out_to_zfs(self):
|
|
import truecloud_nested as tn
|
|
|
|
seen = []
|
|
|
|
class R:
|
|
returncode = 0
|
|
stdout = (
|
|
"scratch\t/mnt/scratch\tyes\n"
|
|
"scratch/ix-apps\t/mnt/scratch/ix-apps\tyes\n" # middleware HIDES this one
|
|
"scratch/.system\tlegacy\tno\n"
|
|
)
|
|
stderr = ""
|
|
|
|
def runner(cmd):
|
|
seen.append(cmd)
|
|
return R()
|
|
|
|
rows = tn.query_filesystems(runner=runner)
|
|
|
|
assert seen and seen[0][0] == "zfs", "must read ZFS, not call middleware"
|
|
names = [r["name"] for r in rows]
|
|
assert "scratch/ix-apps" in names, (
|
|
"ix-apps is exactly what pool.dataset.query hides, and exactly what "
|
|
"holds live app data. If it is not here the backup omits it silently."
|
|
)
|
|
# ...and it still speaks the shape the planner expects.
|
|
row = next(r for r in rows if r["name"] == "scratch/.system")
|
|
assert row["properties"]["mountpoint"]["value"] == "legacy"
|
|
assert row["properties"]["mounted"]["value"] == "no"
|
|
|
|
def test_a_failing_zfs_raises_rather_than_returning_an_empty_list(self):
|
|
# The whole failure class in one assertion. "No datasets" and "the command
|
|
# broke" must never look the same: a caller that cannot tell them apart
|
|
# stages nothing, sweeps nothing, and reports success.
|
|
import truecloud_nested as tn
|
|
|
|
class R:
|
|
returncode = 1
|
|
stdout = ""
|
|
stderr = "cannot open 'scratch': no such pool"
|
|
|
|
with pytest.raises(tn.ZfsError, match="no such pool"):
|
|
tn.query_filesystems(runner=lambda cmd: R())
|
|
|
|
with pytest.raises(tn.ZfsError):
|
|
tn.list_snapshot_names("scratch", runner=lambda cmd: R())
|
|
|
|
def test_list_snapshot_names_reads_the_whole_tree_from_zfs(self):
|
|
import truecloud_nested as tn
|
|
|
|
seen = []
|
|
|
|
class R:
|
|
returncode = 0
|
|
stdout = "scratch@s\nscratch/ix-apps@s\n"
|
|
stderr = ""
|
|
|
|
def runner(cmd):
|
|
seen.append(cmd)
|
|
return R()
|
|
|
|
names = tn.list_snapshot_names("scratch", runner=runner)
|
|
assert names == ["scratch@s", "scratch/ix-apps@s"]
|
|
assert "-t" in seen[0] and "snapshot" in seen[0] and "-r" in seen[0]
|
|
assert "scratch/ix-apps@s" in names, (
|
|
"pool.snapshot.query hides this; a sweep that cannot see it orphans it "
|
|
"on every single run, forever"
|
|
)
|
|
|
|
|
|
class TestTheProductionWiringIsWhatWeThinkItIs:
|
|
"""Tests of a seam prove nothing if production stops using the seam.
|
|
|
|
An audit mutation-tested this suite and found two regressions that reinstate the
|
|
exact bug this module exists to prevent, while all 293 tests still passed:
|
|
|
|
* swap `delete_snapshot_tree`/`gc_stale_snapshots`'s DEFAULT enumerator for one
|
|
that returns [] (which is what middleware's filtered query does for the 84
|
|
hidden datasets) -- green, because every test injected its own.
|
|
* put `pool.dataset.query` back into apply.sh's injected block -- green, because
|
|
nothing asserted what that block enumerates with.
|
|
|
|
Both are pinned here. These tests are about the WIRING, not the logic.
|
|
"""
|
|
|
|
def test_the_default_snapshot_enumerator_is_the_ZFS_one(self, monkeypatch, tmp_path):
|
|
# Called with no `list_snapshots=`, exactly as production calls it.
|
|
called = []
|
|
monkeypatch.setattr(tn, "list_snapshot_names",
|
|
lambda ds, **kw: called.append(ds) or [])
|
|
|
|
mw = FakeMiddleware(["Tap@snap"])
|
|
|
|
class NoRecursive(FakeMiddleware):
|
|
def call_sync(self, method, *args):
|
|
if method.endswith(".delete") and len(args) > 1:
|
|
raise RuntimeError("recursive delete unavailable")
|
|
return super().call_sync(method, *args)
|
|
|
|
tn.delete_snapshot_tree(NoRecursive(["Tap@snap"]), "Tap@snap")
|
|
assert called and set(called) == {"Tap"}, (
|
|
"delete_snapshot_tree's fallback sweep must enumerate from ZFS by default. "
|
|
"If it defaults to a middleware query, it cannot see the internal datasets "
|
|
"and orphans one snapshot per hidden dataset on every run."
|
|
)
|
|
|
|
called.clear()
|
|
mounts = tmp_path / "mounts"
|
|
mounts.write_text("")
|
|
monkeypatch.setattr(tn, "STAGING_BASE", str(tmp_path))
|
|
tn.gc_stale_snapshots(mw, "cloud_backup-5", "Tap@cloud_backup-5-2026",
|
|
mounts_file=str(mounts))
|
|
assert called and set(called) == {"Tap"}, (
|
|
"the GC must enumerate from ZFS by default too"
|
|
)
|
|
|
|
def test_the_injected_block_enumerates_from_ZFS_not_middleware(self):
|
|
# apply.sh is a shell file holding the Python that gets injected into
|
|
# middlewared. Assert on what that block actually says.
|
|
with open(os.path.join(os.path.dirname(__file__), "..", "patch", "apply.sh"),
|
|
encoding="utf-8") as fh:
|
|
src = fh.read()
|
|
|
|
assert "_tc_nested.query_filesystems(" in src, (
|
|
"the staging plan must be built from query_filesystems() (which reads ZFS)"
|
|
)
|
|
|
|
# Match the METHOD NAME however it is quoted. An earlier version of this test
|
|
# only looked for the double-quoted form, so a single-quoted
|
|
# `call_sync('pool.snapshot.query')` -- including one passed in as the sweep's
|
|
# lister, which is the catastrophic case -- sailed straight through it.
|
|
import re
|
|
code = "\n".join(
|
|
ln for ln in src.splitlines() if not ln.lstrip().startswith("#")
|
|
)
|
|
offenders = re.findall(r"\b(?:pool|zfs)\.(?:dataset|snapshot)\.query\b", code)
|
|
assert not offenders, (
|
|
f"apply.sh references {sorted(set(offenders))}. Middleware's queries apply "
|
|
f"a visibility policy and hide ix-apps/*, .system/*, .ix-virt/* -- 84 of "
|
|
f"270 datasets on a real pool, including live app data. Enumerating from "
|
|
f"them omits those datasets from the backup SILENTLY, and sweeping from "
|
|
f"them orphans one snapshot per hidden dataset on every run."
|
|
)
|
|
|
|
|
|
class TestTheSnapshotNamespaceIsResolvedNotAssumed:
|
|
"""24.10/25.04 have only `zfs.snapshot`; 26 has only `pool.snapshot`.
|
|
|
|
Every one of these mutations used to pass the whole suite, because no test ever
|
|
built a non-default middleware generation:
|
|
|
|
* `_can_delete` -> always True (breaks 24.10: picks a namespace that isn't there)
|
|
* SNAPSHOT_SERVICES reversed (breaks 26)
|
|
* `snapshot_service` guessing instead of raising
|
|
"""
|
|
|
|
def test_a_24_10_box_deletes_through_zfs_snapshot(self):
|
|
mw = FakeMiddleware(["Tap@snap", "Tap/apps@snap"], snapshot_ns="zfs.snapshot")
|
|
assert tn.delete_snapshot_tree(mw, "Tap@snap",
|
|
list_snapshots=mw.list_snapshots) == []
|
|
assert mw.snapshots == []
|
|
methods = {m for m, _a in mw.calls}
|
|
assert methods == {"zfs.snapshot.delete"}, (
|
|
f"a 24.10 box has no pool.snapshot; called {methods}"
|
|
)
|
|
|
|
def test_a_26_box_deletes_through_pool_snapshot(self):
|
|
mw = FakeMiddleware(["Tap@snap", "Tap/apps@snap"], snapshot_ns="pool.snapshot")
|
|
assert tn.delete_snapshot_tree(mw, "Tap@snap",
|
|
list_snapshots=mw.list_snapshots) == []
|
|
assert {m for m, _a in mw.calls} == {"pool.snapshot.delete"}
|
|
|
|
def test_a_CRUDService_that_only_defines_do_delete_is_usable(self):
|
|
# `delete` is exposed FROM a method named `do_delete`. compat.py accepts both,
|
|
# so the runtime must too, or they disagree about the same box.
|
|
mw = FakeMiddleware(["Tap@snap"], delete_method="do_delete")
|
|
assert tn.snapshot_service(mw) == "pool.snapshot"
|
|
|
|
def test_a_registered_service_that_CANNOT_delete_is_not_chosen(self):
|
|
# The subtle one. `get_service()` only proves the namespace is registered.
|
|
# iX has already gutted a method while keeping its service
|
|
# (`pool.snapshot.do_update` on master). If the runtime settled for "the
|
|
# service exists", it would pick pool.snapshot, fail every delete, and orphan
|
|
# the whole tree -- while compat.py, which checks the METHOD, fell through to
|
|
# zfs.snapshot and reported the box healthy.
|
|
class GuttedPoolSnapshot(FakeMiddleware):
|
|
def get_service(self, name):
|
|
if name == "pool.snapshot":
|
|
# Registered, and `delete` IS still there -- inherited from
|
|
# CRUDService, which dispatches to a `do_delete` that no longer
|
|
# exists. This is the shape middlewared actually produces, and a
|
|
# naive `hasattr(service, "delete")` says YES to it.
|
|
gutted = _FakeSnapshotService(None)
|
|
assert callable(gutted.delete), (
|
|
"the fake must keep the inherited dispatcher, or it cannot "
|
|
"reproduce the bug"
|
|
)
|
|
return gutted
|
|
if name == "zfs.snapshot":
|
|
return super().get_service("zfs.snapshot")
|
|
raise KeyError(name)
|
|
|
|
mw = GuttedPoolSnapshot(["Tap@snap"], snapshot_ns="zfs.snapshot")
|
|
assert tn.snapshot_service(mw) == "zfs.snapshot", (
|
|
"must fall through to a namespace that can actually delete"
|
|
)
|
|
|
|
def test_no_usable_namespace_REFUSES_rather_than_guessing(self):
|
|
class Neither(FakeMiddleware):
|
|
def get_service(self, name):
|
|
raise KeyError(name)
|
|
|
|
with pytest.raises(StagingError, match="no usable snapshot delete"):
|
|
tn.snapshot_service(Neither())
|
|
|
|
def test_the_runtime_list_and_the_compat_manifest_cannot_drift(self):
|
|
# tools/compat.py claims "the runtime picks the same way ... so what this
|
|
# checks and what the patch does cannot drift apart." Nothing enforced that,
|
|
# and reordering SNAPSHOT_SERVICES silently broke the claim. Now it is bound.
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "tools"))
|
|
import compat
|
|
|
|
call = next(c for c in compat.MIDDLEWARE_CALLS if c.id == "call-snapshot-delete")
|
|
checked = [compat.MiddlewareCall.namespace_of(m) for m, _p in call.options]
|
|
assert checked == list(tn.SNAPSHOT_SERVICES), (
|
|
f"compat checks {checked} but the runtime tries {list(tn.SNAPSHOT_SERVICES)} "
|
|
f"-- in THIS order. They must agree, or CI blesses a box that fails at run "
|
|
f"time."
|
|
)
|
|
assert set(compat.DELETE_NAMES) == set(tn.DELETE_METHODS), (
|
|
"compat and the runtime must accept the same delete spellings"
|
|
)
|
|
|
|
|
|
class TestWeOwnTheSweepEvenWhenWeDoNotStage:
|
|
"""TrueNAS 26 leak: stock's `recursive` rule is not the patch's `nested` rule.
|
|
|
|
<= 25.10 stock's create_snapshot calls get_dataset_recursive() -- the same
|
|
function this module vendors. "Stock went recursive" and "we have
|
|
something to stage" were the SAME question, so a non-staged snapshot
|
|
provably had no children and stock's non-recursive delete was correct.
|
|
|
|
26 stock uses filesystem.statfs: recursive = (path == the dataset's
|
|
mountpoint). Now the rules disagree. A dataset whose only descendants
|
|
are ZVOLs or legacy/none-mountpoint datasets gets a RECURSIVE snapshot,
|
|
while get_dataset_recursive() reports nothing to stage -- neither kind
|
|
is a mounted filesystem under `path`.
|
|
|
|
Stock then destroys the PARENT ONLY. With no staging tree there was no sidecar,
|
|
and the GC only ever ran from stage_nested -- so nothing on the box would ever
|
|
have found the children. One orphan per zvol/legacy descendant, on every run,
|
|
forever, while the backup reports SUCCESS.
|
|
|
|
Ownership of the sweep is therefore NOT conditional on staging.
|
|
"""
|
|
|
|
def test_own_snapshot_records_a_snapshot_it_did_not_stage(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(tn, "STAGING_BASE", str(tmp_path))
|
|
mw = FakeMiddleware(["Tap@cloud_backup-5-2026", "Tap/vm-zvol@cloud_backup-5-2026"])
|
|
root = tn.own_snapshot(mw, "cloud_backup-5", "Tap@cloud_backup-5-2026",
|
|
list_snapshots=mw.list_snapshots)
|
|
|
|
assert tn._read_sidecar(root) == ["Tap@cloud_backup-5-2026"], (
|
|
"the snapshot must be RECORDED even though nothing was staged -- the "
|
|
"sidecar is the only thing that makes the sweep happen"
|
|
)
|
|
|
|
def test_the_recorded_snapshot_is_then_actually_swept(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(tn, "STAGING_BASE", str(tmp_path))
|
|
|
|
# A recursive snapshot of a dataset whose only child is a ZVOL: exactly the
|
|
# 26 case. Nothing to stage, but the children are real.
|
|
mw = FakeMiddleware([
|
|
"Tap@cloud_backup-5-2026",
|
|
"Tap/vm-zvol@cloud_backup-5-2026",
|
|
"Tap/legacy-ds@cloud_backup-5-2026",
|
|
])
|
|
tn.own_snapshot(mw, "cloud_backup-5", "Tap@cloud_backup-5-2026",
|
|
list_snapshots=mw.list_snapshots)
|
|
|
|
# ...then the run finishes and cleanup fires, exactly as restic_backup's
|
|
# `finally` does.
|
|
tn.cleanup_task(mw, "cloud_backup-5", list_snapshots=mw.list_snapshots)
|
|
|
|
assert mw.snapshots == [], (
|
|
"the zvol/legacy children of an unstaged recursive snapshot were orphaned. "
|
|
"Stock deletes only the parent; if we do not own the sweep, nothing does."
|
|
)
|
|
|
|
def test_apply_sh_owns_the_snapshot_on_the_not_nested_path(self):
|
|
# The gate itself. It used to `return snapshot, snap_path` and hand the
|
|
# snapshot back to stock, whose delete is non-recursive.
|
|
with open(os.path.join(os.path.dirname(__file__), "..", "patch", "apply.sh"),
|
|
encoding="utf-8") as fh:
|
|
src = fh.read()
|
|
|
|
gate = src.index("if not nested:")
|
|
ret = src.index("return snapshot, snap_path", gate)
|
|
assert "_tc_nested.own_snapshot(" in src[gate:ret], (
|
|
"the not-nested path returns to stock without recording the snapshot. On "
|
|
"26 stock may have taken a RECURSIVE snapshot (its rule is statfs-based, "
|
|
"not ours) and deletes only the parent -- so every child is orphaned, with "
|
|
"no sidecar and no GC, on every run."
|
|
)
|
|
|
|
|
|
class TestASilentNoOpDeleteCannotDropTheSidecar:
|
|
"""A delete that returns without raising is not proof anything was destroyed.
|
|
|
|
iX has already gutted `pool.snapshot.do_update` on master into a no-op whose body
|
|
is commented out and which returns None. An AST check still sees the `def`; a
|
|
callable check still sees the method. If `do_delete` ever goes the same way, the
|
|
recursive delete returns cleanly, `delete_snapshot_tree` reports no survivors,
|
|
`cleanup_task` removes the sidecar -- the only record -- and ~250 snapshots are
|
|
orphaned forever with the backup reporting SUCCESS.
|
|
"""
|
|
|
|
def test_a_delete_that_does_nothing_is_caught_and_reported(self):
|
|
class NoOpDelete(FakeMiddleware):
|
|
def call_sync(self, method, *args):
|
|
self.calls.append((method, args))
|
|
return None # "succeeds", destroys nothing
|
|
|
|
mw = NoOpDelete(["Tap@snap", "Tap/apps@snap", "Tap/apps/lidarr@snap"])
|
|
survivors = tn.delete_snapshot_tree(
|
|
mw, "Tap@snap", list_snapshots=mw.list_snapshots, sleep=lambda _s: None,
|
|
)
|
|
|
|
assert sorted(survivors) == sorted(
|
|
["Tap@snap", "Tap/apps@snap", "Tap/apps/lidarr@snap"]), (
|
|
"a no-op delete must be REPORTED as survivors, so cleanup_task keeps the "
|
|
"sidecar and the next run reclaims them. Returning [] here silently "
|
|
"orphans the entire tree."
|
|
)
|
|
|
|
def test_an_unconfirmable_delete_keeps_owning_the_tree(self):
|
|
# If ZFS cannot be read we cannot confirm the delete did anything. Claiming a
|
|
# clean sweep makes cleanup_task DROP the sidecar -- and if the delete had in
|
|
# fact done nothing, the tree is orphaned with no record of it, forever.
|
|
#
|
|
# The two mistakes are not symmetric. A false survivor self-heals: the sidecar
|
|
# is kept, the next run reclaims it, the delete raises "does not exist", and
|
|
# the record clears. A lost record is permanent. So when in doubt, keep owning.
|
|
mw = FakeMiddleware(["Tap@snap"])
|
|
|
|
def cannot_enumerate(_dataset):
|
|
raise tn.ZfsError("pool I/O is currently suspended")
|
|
|
|
assert tn.delete_snapshot_tree(
|
|
mw, "Tap@snap", list_snapshots=cannot_enumerate, sleep=lambda _s: None,
|
|
) == ["Tap@snap"]
|
|
|
|
|
|
class TestTheMalformedRowGuard:
|
|
"""`zfs list -H` neither quotes nor escapes. A tab in a mountpoint splits wrong.
|
|
|
|
Dropping such a row would remove a dataset from the staging plan without it
|
|
appearing in `skipped` either -- the cardinal-rule failure, on the newest code
|
|
path. Two mutations (silently filtering the row; dropping the `fields=` argument)
|
|
used to pass the whole suite.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _runner(stdout):
|
|
class R:
|
|
returncode = 0
|
|
stderr = ""
|
|
R.stdout = stdout
|
|
return lambda cmd: R()
|
|
|
|
def test_a_row_with_the_wrong_field_count_RAISES(self):
|
|
# A mountpoint containing a tab -> 4 fields, not 3.
|
|
bad = "scratch\t/mnt/scratch\tyes\nscratch/odd\t/mnt/od\td\tyes\n"
|
|
with pytest.raises(tn.ZfsError, match="tab-separated"):
|
|
tn.query_filesystems(runner=self._runner(bad))
|
|
|
|
def test_the_error_names_the_offending_row(self):
|
|
bad = "a\tb\tc\nbroken\trow\n"
|
|
with pytest.raises(tn.ZfsError, match="broken"):
|
|
tn.query_filesystems(runner=self._runner(bad))
|
|
|
|
def test_the_field_count_is_actually_enforced_for_snapshots_too(self):
|
|
with pytest.raises(tn.ZfsError):
|
|
tn.list_snapshot_names("Tap", runner=self._runner("ok\nnot\tok\n"))
|
|
|
|
def test_query_filesystems_asks_zfs_for_filesystems_only(self):
|
|
# Dropping `-t filesystem` would drag in volumes and snapshots, which the
|
|
# planner would then try to stage.
|
|
seen = []
|
|
|
|
def runner(cmd):
|
|
seen.append(cmd)
|
|
return self._runner("Tap\t/mnt/Tap\tyes\n")(cmd)
|
|
|
|
tn.query_filesystems(runner=runner)
|
|
assert "-t" in seen[0] and "filesystem" in seen[0]
|
|
assert "name,mountpoint,mounted" in seen[0]
|
|
|
|
|
|
class TestTheGarbageCollectorIsActuallyWiredIn:
|
|
"""The GC is the ONLY recovery path when the sidecar itself is gone.
|
|
|
|
The sidecar lives in /run (tmpfs), so a reboot mid-backup destroys it and orphans
|
|
the whole tree with nothing pointing at it. `own_snapshot` is the GC's only
|
|
production caller -- and stubbing that call out used to pass all 304 tests, i.e.
|
|
the GC could have been silently disconnected.
|
|
"""
|
|
|
|
def test_own_snapshot_runs_the_collector(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(tn, "STAGING_BASE", str(tmp_path))
|
|
ran = []
|
|
monkeypatch.setattr(
|
|
tn, "gc_stale_snapshots",
|
|
lambda *a, **kw: ran.append(a[1]) or [],
|
|
)
|
|
mw = FakeMiddleware(["Tap@new"])
|
|
tn.own_snapshot(mw, "cloud_backup-5", "Tap@new",
|
|
list_snapshots=mw.list_snapshots)
|
|
assert ran == ["cloud_backup-5"], (
|
|
"own_snapshot did not run the garbage collector. It is the only thing that "
|
|
"ever finds a tree whose sidecar was lost to a reboot."
|
|
)
|
|
|
|
def test_a_collected_orphan_is_carried_into_the_sidecar_if_it_survives(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
monkeypatch.setattr(tn, "STAGING_BASE", str(tmp_path))
|
|
monkeypatch.setattr(
|
|
tn, "gc_stale_snapshots", lambda *a, **kw: ["Tap/x@busy-orphan"],
|
|
)
|
|
mw = FakeMiddleware(["Tap@new"])
|
|
root = tn.own_snapshot(mw, "cloud_backup-5", "Tap@new",
|
|
list_snapshots=mw.list_snapshots)
|
|
assert "Tap/x@busy-orphan" in tn._read_sidecar(root), (
|
|
"an orphan the GC could not delete must be RECORDED, or the next run has "
|
|
"no idea it exists"
|
|
)
|
|
|
|
|
|
class TestTheServiceIsResolvedLazily:
|
|
"""`_Snapshots.service` resolves on first use, not in the constructor.
|
|
|
|
Eager resolution looks harmless and is not: `gc_stale_snapshots` would raise inside
|
|
its own broad `except` and silently collect nothing, and `delete_snapshot_tree`
|
|
would raise `StagingError` out of the constructor -- outside its try -- instead of
|
|
returning survivors.
|
|
"""
|
|
|
|
def test_constructing_it_against_a_hopeless_middleware_does_not_raise(self):
|
|
class Neither(FakeMiddleware):
|
|
def get_service(self, name):
|
|
raise KeyError(name)
|
|
|
|
snaps = tn._Snapshots(Neither()) # must not raise
|
|
assert snaps.names is not None
|
|
|
|
with pytest.raises(StagingError):
|
|
snaps.delete("Tap@snap") # ...only the MUTATION refuses
|
|
|
|
|
|
class TestADatasetFromAnotherTreeMountedInsideThePath:
|
|
"""ZFS lets any dataset mount anywhere. A backup path is a DIRECTORY, not a tree.
|
|
|
|
Tank/photos mountpoint=/mnt/Tap/apps/photos
|
|
|
|
That dataset holds data inside the backed-up path, so its absence is a hole. And
|
|
`zfs snapshot -r Tap@...` does NOT cover it -- recursion follows the DATASET tree,
|
|
not the directory tree -- so there is no snapshot of it to stage.
|
|
|
|
It used to be scoped out by the dataset-NAME filter and then vanish: not staged,
|
|
not in `skipped`, no error. The backup reported SUCCESS with the data missing,
|
|
which is the cardinal-rule failure.
|
|
"""
|
|
|
|
def test_it_is_refused_loudly_not_omitted_silently(self):
|
|
foreign = DATASETS + [ds("Tank/photos", "/mnt/Tap/apps/photos")]
|
|
with pytest.raises(StagingError, match="Tank/photos"):
|
|
plan(datasets=foreign)
|
|
|
|
def test_the_error_explains_why_it_cannot_be_captured(self):
|
|
foreign = DATASETS + [ds("Tank/photos", "/mnt/Tap/apps/photos")]
|
|
with pytest.raises(StagingError, match="recursive snapshot"):
|
|
plan(datasets=foreign)
|
|
|
|
def test_a_foreign_dataset_OUTSIDE_the_path_is_still_ignored(self):
|
|
# Only datasets inside the backed-up path matter. Everything else on the box
|
|
# is none of our business, and reporting it would bury the ones that are.
|
|
elsewhere = DATASETS + [ds("Tank/photos", "/mnt/Tank/photos")]
|
|
mounts, skipped = plan(datasets=elsewhere)
|
|
assert len(mounts) == 6
|
|
assert skipped == []
|
|
|
|
|
|
class TestTheSeamsStayLateBound:
|
|
"""A default argument is frozen into `__defaults__` at def time.
|
|
|
|
`runner=_run`, `mounts_file="/proc/self/mounts"` and `sleep=time.sleep` were all
|
|
written that way, so `never_touch_the_real_system` could not intercept them: 19
|
|
tests read the REAL mount table -- one matching name away from running a real
|
|
`umount` on the NAS -- and the retry loop really slept for 20 seconds.
|
|
|
|
Re-freezing any of them silently re-arms that. The fixture cannot notice, because
|
|
it is the thing being bypassed. So assert it directly.
|
|
"""
|
|
|
|
def test_no_system_seam_is_frozen_into_a_default(self):
|
|
import inspect
|
|
|
|
offenders = []
|
|
for name in dir(tn):
|
|
fn = getattr(tn, name)
|
|
if not inspect.isfunction(fn):
|
|
continue
|
|
for default in fn.__defaults__ or ():
|
|
if default is tn._run or default is _REAL_SLEEP:
|
|
offenders.append(f"{name}() freezes {getattr(default, '__name__', default)!r}")
|
|
if default == "/proc/self/mounts":
|
|
offenders.append(f"{name}() freezes the real mount table")
|
|
assert not offenders, (
|
|
"these seams are frozen into __defaults__, so no test can intercept them "
|
|
"and they will reach the real system:\n " + "\n ".join(offenders)
|
|
+ "\nUse the late-bound idiom: `runner=None` + `runner = runner or _run`."
|
|
)
|
|
|
|
|
|
class TestOwnSnapshotCleansUpBeforeItBuilds:
|
|
def test_it_tears_down_a_crashed_run_before_recording_its_own(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
# A previous run may have died mid-flight. Its bind mounts still pin its
|
|
# snapshots (so they refuse to delete with EBUSY), and stacking a new staging
|
|
# tree on top of a stale one is how mounts leak.
|
|
monkeypatch.setattr(tn, "STAGING_BASE", str(tmp_path))
|
|
order = []
|
|
monkeypatch.setattr(tn, "teardown", lambda *a, **kw: order.append("teardown") or [])
|
|
real_write = tn._write_sidecar
|
|
monkeypatch.setattr(
|
|
tn, "_write_sidecar",
|
|
lambda *a, **kw: order.append("write") or real_write(*a, **kw),
|
|
)
|
|
mw = FakeMiddleware(["Tap@new"])
|
|
tn.own_snapshot(mw, "cloud_backup-5", "Tap@new",
|
|
list_snapshots=mw.list_snapshots)
|
|
assert order == ["teardown", "write"], (
|
|
"own_snapshot must tear down a crashed run's tree BEFORE recording its own"
|
|
)
|
|
|
|
|
|
class TestStagingRefusesUpFrontIfItCouldNotSweep:
|
|
def test_a_middleware_with_no_usable_delete_refuses_before_restic_runs(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
# We are about to pin a recursive snapshot with bind mounts. If we could never
|
|
# sweep it, the honest move is to fail NOW -- not after restic has uploaded and
|
|
# the snapshot is already stuck.
|
|
monkeypatch.setattr(tn, "STAGING_BASE", str(tmp_path))
|
|
|
|
class Hopeless(FakeMiddleware):
|
|
def get_service(self, name):
|
|
raise KeyError(name)
|
|
|
|
with pytest.raises(StagingError, match="no usable snapshot delete"):
|
|
tn.stage_nested(
|
|
Hopeless(), "/mnt/Tap", "Tap@snap", "Tap", "/mnt/Tap",
|
|
"cloud_backup-5", DATASETS, list_snapshots=lambda _d: [],
|
|
)
|
|
|
|
|
|
class TestQueryFilesystemsIgnoresMiddlewareEntirely:
|
|
"""`middleware` is accepted and IGNORED. That is the whole point of the function.
|
|
|
|
Reintroducing `if middleware: return middleware.call_sync("pool.dataset.query")` is
|
|
a two-line change that looks like an optimisation and reinstates the bug that
|
|
shipped once: middleware's query hides ix-apps/*, .system/*, .ix-virt/* -- 84 of
|
|
270 datasets on the real pool, including live app data -- and they would be omitted
|
|
from the staging plan without even reaching `skipped`.
|
|
"""
|
|
|
|
def test_passing_a_middleware_does_not_make_it_ask_middleware(self):
|
|
class Loud(FakeMiddleware):
|
|
def call_sync(self, method, *args):
|
|
raise AssertionError(
|
|
f"query_filesystems asked middleware ({method}). Its dataset query "
|
|
f"is FILTERED; enumeration must come from ZFS."
|
|
)
|
|
|
|
class R:
|
|
returncode = 0
|
|
stdout = "Tap\t/mnt/Tap\tyes\nTap/ix-apps\t/mnt/Tap/ix-apps\tyes\n"
|
|
stderr = ""
|
|
|
|
rows = tn.query_filesystems(Loud(), runner=lambda cmd: R())
|
|
assert [r["name"] for r in rows] == ["Tap", "Tap/ix-apps"]
|
|
|
|
|
|
class TestAnUnreadableZfsDuringTheByNameSweep:
|
|
def test_the_tree_is_still_owned_when_we_cannot_check(self):
|
|
# Recursive delete fails AND `zfs list` fails. We cannot know what is gone, so
|
|
# we must keep owning all of it: cleanup_task then KEEPS the sidecar and the
|
|
# next run reclaims. Reporting a clean sweep here drops the only record.
|
|
class NoRecursive(FakeMiddleware):
|
|
def call_sync(self, method, *args):
|
|
self.calls.append((method, args))
|
|
if method.endswith(".delete") and len(args) > 1:
|
|
raise RuntimeError("recursive delete unavailable")
|
|
return None # individual deletes "succeed" silently
|
|
|
|
calls = {"n": 0}
|
|
|
|
def flaky(dataset):
|
|
calls["n"] += 1
|
|
if calls["n"] == 1:
|
|
return ["Tap@snap", "Tap/apps@snap"] # the tree, for planning
|
|
raise tn.ZfsError("pool I/O is currently suspended") # ...then ZFS dies
|
|
|
|
mw = NoRecursive(["Tap@snap", "Tap/apps@snap"])
|
|
survivors = tn.delete_snapshot_tree(
|
|
mw, "Tap@snap", list_snapshots=flaky, sleep=lambda _s: None,
|
|
)
|
|
assert sorted(survivors) == ["Tap/apps@snap", "Tap@snap"], (
|
|
"with ZFS unreadable we cannot confirm anything was deleted. Returning [] "
|
|
"makes cleanup_task drop the sidecar and orphans the tree forever."
|
|
)
|
|
|
|
|
|
class TestTheSnapshotRecordIsNeverLostToAnUnreadableFile:
|
|
""""There is no sidecar" and "I could not READ the sidecar" are different facts.
|
|
|
|
Conflating them let `cleanup_task` take its `if not pinned` branch and UNLINK the
|
|
only record of a tree it had just failed to read.
|
|
"""
|
|
|
|
def test_an_unreadable_sidecar_raises_rather_than_reading_as_empty(self, tmp_path):
|
|
sc = tmp_path / "cloud_backup-5.snapshot"
|
|
sc.write_text("Tap@snap\n")
|
|
sc.chmod(0)
|
|
try:
|
|
with pytest.raises(OSError):
|
|
tn._read_sidecar(str(tmp_path / "cloud_backup-5"))
|
|
finally:
|
|
sc.chmod(0o600)
|
|
|
|
def test_a_missing_sidecar_is_simply_empty(self, tmp_path):
|
|
assert tn._read_sidecar(str(tmp_path / "nope")) == []
|
|
|
|
def test_cleanup_all_still_UNMOUNTS_when_a_sidecar_cannot_be_read(self, tmp_path):
|
|
# cleanup_all is what recover.sh and uninstall.sh call, i.e. it runs precisely
|
|
# when the box is already stuck. Its job is to get the mounts off. Aborting on
|
|
# one unreadable sidecar leaves the staging tree mounted -- which pins the
|
|
# snapshots, which is the state the caller is trying to escape.
|
|
sc = tmp_path / "cloud_backup-5.snapshot"
|
|
sc.write_text("Tap@snap\n")
|
|
sc.chmod(0)
|
|
|
|
unmounted = []
|
|
|
|
class R:
|
|
returncode = 0
|
|
stdout = ""
|
|
stderr = ""
|
|
|
|
def runner(cmd):
|
|
unmounted.append(cmd)
|
|
return R()
|
|
|
|
mounts = tmp_path / "mounts"
|
|
mounts.write_text(f"Tap@s {tmp_path}/cloud_backup-5 zfs ro 0 0\n")
|
|
out, _errors = tn.cleanup_all(
|
|
base=str(tmp_path), runner=runner, mounts_file=str(mounts),
|
|
glob_fn=lambda pat: [str(sc)],
|
|
)
|
|
|
|
assert sc.exists(), (
|
|
"cleanup_all DELETED the sidecar it had just failed to read. It has no idea "
|
|
"what that file names, and it may be the only record those snapshots exist."
|
|
)
|
|
sc.chmod(0o600)
|
|
|
|
assert any("umount" in " ".join(c) for c in unmounted), (
|
|
"cleanup_all stopped at the unreadable sidecar and never unmounted. The "
|
|
"staging tree stays mounted, pinning the snapshots -- which is exactly the "
|
|
"state recover.sh exists to escape."
|
|
)
|
|
assert "could not read the snapshot record" in "\n".join(out).lower()
|
|
|
|
|
|
class TestTheMountTableIsNeverGuessedAt:
|
|
def test_an_unreadable_mount_table_raises_rather_than_saying_nothing_is_mounted(self):
|
|
# Returning an empty set means "no snapshot is in use", which silently switches
|
|
# OFF the GC's only protection for a CONCURRENTLY RUNNING backup's snapshots.
|
|
# A first upload can easily run past the 1h age floor.
|
|
with pytest.raises(OSError):
|
|
tn.mounted_snapshots(mounts_file="/nonexistent/mounts")
|
|
|
|
def test_the_GC_actually_passes_in_use_through(self, tmp_path, monkeypatch):
|
|
# The `in_use` guard is well tested as a pure function -- but nothing checked
|
|
# that gc_stale_snapshots WIRES it in. Deleting the argument passed the suite.
|
|
monkeypatch.setattr(tn, "STAGING_BASE", str(tmp_path))
|
|
import datetime as dt
|
|
|
|
old = "Tap/apps/x@cloud_backup-5-20260713030000"
|
|
mounts = tmp_path / "mounts"
|
|
mounts.write_text(f"{old} /some/where zfs ro 0 0\n") # ...it is MOUNTED
|
|
|
|
mw = FakeMiddleware(["Tap@cloud_backup-5-20260714115900", old])
|
|
remaining = tn.gc_stale_snapshots(
|
|
mw, "cloud_backup-5", "Tap@cloud_backup-5-20260714115900",
|
|
now=dt.datetime(2026, 7, 14, 12, 0, 0, tzinfo=dt.UTC),
|
|
mounts_file=str(mounts), list_snapshots=mw.list_snapshots,
|
|
)
|
|
assert remaining == []
|
|
assert old in mw.snapshots, (
|
|
"the GC destroyed a snapshot that is still MOUNTED -- i.e. one a "
|
|
"concurrently running backup is reading from"
|
|
)
|
|
|
|
|
|
class TestPrefixCollisionsBetweenPools:
|
|
"""`Tap` and `Tap2` are different pools. String prefixes do not know that."""
|
|
|
|
def test_the_sweep_does_not_touch_a_similarly_named_pool(self):
|
|
mw = FakeMiddleware(["Tap@snap", "Tap/apps@snap", "Tap2/data@snap"])
|
|
tn.delete_snapshot_tree(mw, "Tap@snap", list_snapshots=lambda _d: list(mw.snapshots))
|
|
assert mw.snapshots == ["Tap2/data@snap"], (
|
|
"the sweep destroyed a snapshot in Tap2, a DIFFERENT POOL, because 'Tap2' "
|
|
"starts with 'Tap'"
|
|
)
|
|
|
|
def test_the_planner_does_not_stage_a_similarly_named_dataset(self):
|
|
noisy = DATASETS + [ds("Tap2/apps", "/mnt/Tap2/apps")]
|
|
mounts, skipped = plan(datasets=noisy)
|
|
assert len(mounts) == 6
|
|
assert skipped == []
|
|
|
|
def test_a_similarly_named_MOUNTPOINT_does_not_trip_the_foreign_check(self):
|
|
# /mnt/Tap2/x is not inside /mnt/Tap.
|
|
noisy = DATASETS + [ds("Tank/x", "/mnt/Tap2/x")]
|
|
mounts, skipped = plan(datasets=noisy)
|
|
assert len(mounts) == 6
|
|
|
|
def test_Tap2_is_not_a_child_of_Tap_even_when_mounted_inside_it(self):
|
|
# The one that actually bites: `Tap2/x` starts with the STRING "Tap", so a
|
|
# scope test that forgets the trailing "/" treats it as an in-tree descendant
|
|
# and STAGES it -- from a snapshot `zfs snapshot -r Tap@...` never took, since
|
|
# Tap2 is a different pool. It must be seen as FOREIGN and refused.
|
|
noisy = DATASETS + [ds("Tap2/x", "/mnt/Tap/apps/x")]
|
|
with pytest.raises(StagingError, match="Tap2/x"):
|
|
plan(datasets=noisy)
|
|
|
|
|
|
class TestTheForeignCheckDoesNotBreakWorkingConfigs:
|
|
def test_an_UNMOUNTED_foreign_dataset_is_skipped_not_refused(self):
|
|
# A locked/encrypted dataset contributes nothing to the live tree, so its
|
|
# absence is not a hole -- exactly as for an in-tree one. Raising here would
|
|
# turn a working nightly backup into a permanent failure the first time
|
|
# somebody locked a dataset.
|
|
datasets = DATASETS + [
|
|
ds("Tank/photos", "/mnt/Tap/apps/photos", mounted="no"),
|
|
]
|
|
mounts, skipped = plan(datasets=datasets)
|
|
assert len(mounts) == 6
|
|
assert ("Tank/photos", "dataset is not mounted (locked/encrypted?)") in skipped
|
|
|
|
def test_a_foreign_dataset_mounted_EXACTLY_AT_the_path_is_refused(self):
|
|
# One character wide: `mp.startswith(path + "/")` misses `mp == path`. And this
|
|
# case is the worse one -- it SHADOWS the base dataset's own directory, so we
|
|
# would stage what is hidden underneath instead of the data actually there.
|
|
datasets = DATASETS + [ds("Tank/x", "/mnt/Tap")]
|
|
with pytest.raises(StagingError, match="Tank/x"):
|
|
plan(datasets=datasets)
|