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:
+52
-28
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user