fix: fourth audit — two regressions from the last fix, and the boot preflight had no test

Two of these were mine, from the previous round.

- mounted_snapshots still swallowed OSError. I said I had fixed it and had not: the
  edit never matched, and I did not read it back. With the mount table unreadable the
  GC loses its in-use protection entirely and can destroy the snapshots of a backup
  that is still uploading (a first upload easily outlives the 1h age floor). It raises
  now, and both behaviours are tested.

- The foreign-dataset check added last round had two bugs of its own. It ignored
  `mounted`, so a locked/encrypted dataset from a sibling tree turned a working nightly
  backup into a permanent failure — it belongs in `skipped`, exactly as an in-tree one
  does. And it tested `mp.startswith(path + "/")`, so a foreign dataset mounted EXACTLY
  at the backup path slipped through — the very hole the check was added to close, one
  character wide, and the worse case of the two because it SHADOWS the base dataset's
  own directory.

- _read_sidecar's new raise broke cleanup_all, which is what recover.sh and
  uninstall.sh call — i.e. the code that must work when the box is ALREADY stuck. One
  unreadable sidecar aborted it before it unmounted anything, leaving the staging tree
  mounted, which pins the snapshots, which is the state recover.sh exists to escape. It
  now reports and carries on — and does not delete a record it could not read.

- compat could report a FALSE OK: `defined` was collected by walking the whole file, so
  any function named `delete` anywhere in it — on an unrelated class, or nested inside
  another method — satisfied "this namespace defines delete". The runtime is stricter
  (a plugin class on the service's MRO), so the two could disagree in the ok direction.
  compat now looks in the class that declares the namespace. Same question on both
  sides, which is what pick_snapshot_service's docstring has been claiming all along.

- apply.sh's compat preflight — the guard that refuses to patch a middleware whose
  assumptions no longer hold, on every boot, on a live NAS — had no test at all. It
  could be turned into a no-op eight different ways with the suite still green. The
  SHIPPED heredoc is now extracted and driven directly against fake verdicts.

Also pinned: the Tap/Tap2 prefix collisions (a sweep that treats "Tap2/data@snap" as
part of Tap's tree DESTROYS another pool's snapshot), and the GC's in_use wiring.

355 tests. Verified on TrueNAS 26.0.0-BETA.1: 292-dataset backup, 0 orphans, 0 leaked
mounts, byte-identical restore of a 4-deep child dataset.
This commit is contained in:
2026-07-14 01:34:13 +00:00
parent 677c90481c
commit 086b20ed23
4 changed files with 360 additions and 39 deletions
+52 -28
View File
@@ -301,37 +301,61 @@ def check_call(c: MiddlewareCall, src: str | None,
except SyntaxError as e:
return "unknown", f"{path} does not parse: {e}"
# namespace = 'zfs.snapshot' on some Service class in this file...
namespaces = {
n.value.value
for n in ast.walk(tree)
if isinstance(n, ast.Assign)
and isinstance(n.value, ast.Constant)
and isinstance(n.value.value, str)
and any(isinstance(t, ast.Name) and t.id == "namespace" for t in n.targets)
}
if namespace not in namespaces:
return "broken", (
f"{path} no longer declares namespace {namespace!r} "
f"(found: {sorted(namespaces) or 'none'}), so `{method}` is gone"
)
# ...and it defines the method.
# Find the CLASS that declares this namespace, and look for the method THERE.
#
# Not anywhere in the file. `ast.walk` over the whole module made *any* function
# called `delete` satisfy the check -- one on an unrelated class, or even a nested
# local function inside `do_query`. That is a FALSE OK, and it breaks the one
# invariant this checker and the runtime share: `_defines_delete()` looks in
# `vars(klass)` for a PLUGIN class on the service's MRO. If iX gutted
# `PoolSnapshotService.do_delete` while some other class in the same file still had
# a `delete`, compat would say ok, apply.sh would patch, and the runtime would then
# correctly refuse `pool.snapshot`, fall through to a `zfs.snapshot` that does not
# exist on 26, and fail every nested backup on a box the preflight called healthy.
#
# Same question on both sides: does the class that OWNS this namespace define the
# method?
#
# A CRUDService exposes `create`/`update`/`delete` from methods NAMED
# `do_create`/`do_update`/`do_delete`. Both spellings are live right now:
# 24.10 and 25.04 declare `do_delete`, 25.10 renamed it to `delete`, and all
# three answer to `zfs.snapshot.delete`. Accepting only the literal name reported
# the two older releases as broken -- a false BROKEN that would have switched off
# nested snapshots on boxes where they work.
defined = {
n.name for n in ast.walk(tree)
if isinstance(n, ast.FunctionDef | ast.AsyncFunctionDef)
}
if not any(sp in defined for sp in accepted_spellings(name)):
return "broken", f"{path} no longer defines `{method}`"
# `do_create`/`do_update`/`do_delete`. Both spellings are live: 24.10 and 25.04
# declare `do_delete`, 25.10 renamed it to `delete`, and all answer to
# `<ns>.delete`. Accepting only the literal name reported working releases as
# broken.
owners = []
all_namespaces = set()
for cls in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)):
declared = {
n.value.value
for n in ast.walk(cls)
if isinstance(n, ast.Assign)
and isinstance(n.value, ast.Constant)
and isinstance(n.value.value, str)
and any(isinstance(t, ast.Name) and t.id == "namespace" for t in n.targets)
}
all_namespaces |= declared
if namespace in declared:
owners.append(cls)
return "ok", None
if not owners:
return "broken", (
f"{path} no longer declares namespace {namespace!r} "
f"(found: {sorted(all_namespaces) or 'none'}), so `{method}` is gone"
)
wanted = accepted_spellings(name)
for cls in owners:
# Direct members of the class, not its nested scopes: a `def delete` inside
# another method is a local function, not a service method.
if any(
isinstance(n, ast.FunctionDef | ast.AsyncFunctionDef) and n.name in wanted
for n in cls.body
):
return "ok", None
return "broken", (
f"{path} still declares namespace {namespace!r}, but its class no longer "
f"defines `{'` or `'.join(wanted)}` -- so `{method}` is gone"
)
#: Things that mean iX has done the job themselves and the module should RETIRE,