fix: third audit — a cross-tree dataset was omitted silently, and the block tests passed on comments
D1, the only cardinal-rule violation left. plan_staging scopes by dataset NAME, which
is right (a dataset with no mountpoint cannot be scoped by path). But ZFS lets any
dataset mount anywhere, so one from a DIFFERENT tree can sit inside the backup path:
Tank/photos mountpoint=/mnt/Tap/apps/photos
It holds data inside the path, and `zfs snapshot -r Tap@...` does NOT cover it —
recursion follows the dataset tree, not the directory tree. It fell out of the name
filter and vanished: not staged, not in `skipped`, no error. The backup reported
SUCCESS with that data missing. Stock has the same blind spot but refuses the nested
config outright; we are the ones relaxing that guard, so the hole is ours. It now
raises.
The test suite was the real weakness. apply.sh's injected blocks carry the
highest-consequence logic in the project — the run_in_thread hop, the flavour
selection, the finally-teardown, the re-raise — and were guarded only by substring
greps. Two of them passed on COMMENTS: `assert "raise" in block` was satisfied by a
comment reading "a cleanup that raises...", and `assert "cleanup_task" in block` by
"cleanup_task gets logger=None". Deleting the actual re-raise (restic then backs up the
UN-STAGED path — the silently-empty backup this module exists to prevent) and deleting
the actual cleanup call from the finally (~250 orphans per run) both left the suite
green. They are asserted structurally now, against the parsed block.
Eleven regressions the audit found surviving now fail the suite, including: a swallowed
staging failure, a missing teardown, an inverted flavour mapping, blocking work back on
the asyncio event loop, the host's deleted get_dataset_recursive, query_filesystems
quietly preferring the filtered middleware query, and a re-frozen `runner`/`sleep`/
`mounts_file` default (which would silently re-arm 19 tests reading the real mount
table on the NAS).
Also: _read_sidecar conflated "no sidecar" with "cannot read the sidecar", so
cleanup_task took the empty branch and UNLINKED the only record of a tree it could not
read. mounted_snapshots returned an empty set on error, silently switching off the GC's
protection for snapshots a concurrent run is using. Both raise now.
Verified on TrueNAS 26.0.0-BETA.1: zvol-orphan case 0 orphans, 292-dataset backup
0 orphans / 0 leaked mounts, byte-identical restore of a 4-deep child dataset.
This commit is contained in:
@@ -452,16 +452,30 @@ def _write_sidecar(staging_root: str, snapshots, logger=None) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _read_sidecar(staging_root: str):
|
||||
def _read_sidecar(staging_root: str, logger=None):
|
||||
"""Every snapshot tree a previous run recorded here. [] if none.
|
||||
|
||||
Tolerates the old single-line format, which is just a one-element list.
|
||||
|
||||
"There is no sidecar" and "I could not READ the sidecar" are different facts, and
|
||||
conflating them is dangerous: `cleanup_task` reads an empty list as "nothing was
|
||||
ever staged" and then REMOVES the sidecar -- destroying the only record of a tree
|
||||
it could not read. FileNotFoundError is the ordinary case and stays quiet; any
|
||||
other OSError is reported, and re-raised so no caller mistakes it for "empty".
|
||||
"""
|
||||
try:
|
||||
with open(sidecar_for(staging_root), encoding="utf-8") as fh:
|
||||
return [ln.strip() for ln in fh if ln.strip()]
|
||||
except OSError:
|
||||
return []
|
||||
except FileNotFoundError:
|
||||
return [] # genuinely nothing recorded
|
||||
except OSError as e:
|
||||
if logger:
|
||||
logger.error(
|
||||
"truecloud-patch: could not READ the snapshot record %s (%r). Not "
|
||||
"touching it -- it may name snapshots nothing else can find.",
|
||||
sidecar_for(staging_root), e,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def _remove_sidecar(staging_root: str) -> None:
|
||||
@@ -656,6 +670,43 @@ def plan_staging(base_dataset, base_mountpoint, path, snapshot_name, datasets,
|
||||
|
||||
mounts.append((src, os.path.join(staging_root, os.path.relpath(mp, path))))
|
||||
|
||||
# ── datasets INSIDE the path but OUTSIDE the snapshot's tree ─────────────
|
||||
#
|
||||
# The loop above scopes by dataset NAME, which is right: a dataset with no
|
||||
# mountpoint cannot be scoped by path at all. But ZFS lets any dataset mount
|
||||
# anywhere, so a dataset from a DIFFERENT tree -- even a different pool -- can
|
||||
# sit inside the backup path:
|
||||
#
|
||||
# Tank/photos mountpoint=/mnt/Tap/apps/photos
|
||||
#
|
||||
# It holds data inside the path, so its absence is a hole in the backup. And
|
||||
# `zfs snapshot -r Tap@...` does NOT cover it, because recursion follows the
|
||||
# DATASET tree, not the directory tree -- so there is no snapshot of it to
|
||||
# stage, and no way to capture it consistently with the rest.
|
||||
#
|
||||
# Before this check it was neither staged, nor reported in `skipped`, nor raised:
|
||||
# it simply fell out of the name filter and vanished. The backup reported
|
||||
# SUCCESS with that data missing, which is the precise failure this module
|
||||
# exists to prevent. Stock has the same blind spot, but stock also REFUSES the
|
||||
# nested config outright -- we are the ones relaxing that guard, so the hole is
|
||||
# ours to close.
|
||||
foreign = sorted(
|
||||
ds.get("name", "")
|
||||
for ds in datasets
|
||||
if (mp := ds.get("properties", {}).get("mountpoint", {}).get("value", ""))
|
||||
and mp.startswith(path_prefix)
|
||||
and not ds.get("name", "").startswith(ds_prefix)
|
||||
and ds.get("name", "") != base_dataset
|
||||
)
|
||||
if foreign:
|
||||
raise StagingError(
|
||||
"dataset(s) outside " + repr(base_dataset) + " are mounted inside the "
|
||||
"backup path and cannot be captured by its recursive snapshot: "
|
||||
+ ", ".join(repr(f) for f in foreign)
|
||||
+ ". Refusing to back up an incomplete tree -- move them, or back up "
|
||||
"their own dataset separately."
|
||||
)
|
||||
|
||||
# Parents before children, so each mountpoint exists before we mount onto it.
|
||||
mounts.sort(key=lambda m: _depth(m[1]))
|
||||
return mounts, skipped
|
||||
|
||||
Reference in New Issue
Block a user