feat: TrueNAS 26 support; enumerate datasets and snapshots from ZFS, not middleware
TrueNAS 26 deletes plugins/zfs_/ outright, taking the private zfs.dataset.query, zfs.snapshot.query and zfs.snapshot.delete with it. All three were on the nested module's critical path, so nested snapshots were BROKEN on 26. Snapshot deletion now resolves its namespace at runtime: pool.snapshot on 25.10 and 26, zfs.snapshot on 24.10 and 25.04. No single namespace spans every supported release. tools/compat.py checks the same list the runtime uses, so what CI verifies and what runs cannot drift apart. Enumeration does NOT move to pool.dataset.query / pool.snapshot.query, and that is the point of this commit. Those methods exist, are documented, and are covered by iX's deprecation policy — and they are not like-for-like replacements. They apply a visibility policy that hides ix-apps/*, .system/* and .ix-virt/*: 84 of 270 datasets on a real pool, including live application data. Staging from that view omits them silently, and plan_staging never sees them, so they do not even reach the skipped list. The snapshot query hides the same datasets' snapshots, so the sweep orphans one per hidden dataset on every run. So: read the truth from ZFS, make changes through middleware. zfs list cannot be filtered by policy and behaves identically on every release. A failing zfs list raises rather than returning an empty list — "no datasets" and "the command broke" must never look the same. No shipped release is affected: v0.6.1 and earlier use the private zfs.dataset.query, which returns all 270 datasets. The bug existed only in this port. Verified on a real TrueNAS 26.0.0-BETA.1 install: 274-snapshot recursive backup of a 292-dataset pool, zero orphaned snapshots, zero leaked mounts, and a byte-identical restore of a four-level-deep child dataset that pool.dataset.query hides.
This commit is contained in:
@@ -438,8 +438,22 @@ class TestOnlyOurOwnTasksAreTouched:
|
||||
# The point is to add NO new failure mode to a CloudSync task. If any
|
||||
# middleware call happened before the bail-out, we would already have broken
|
||||
# the thing we are trying not to touch.
|
||||
#
|
||||
# Checked against whichever interactions the block ACTUALLY contains, not a
|
||||
# fixed list: the dataset query moved behind `_tc_nested.query_filesystems()`
|
||||
# when it switched to the public pool.* API, and a hardcoded
|
||||
# `middleware.call_sync(` simply stopped being found -- a test that silently
|
||||
# stops testing is worse than no test.
|
||||
block = extract_blocks()[name]
|
||||
gate = block.index('if not name.startswith("cloud_backup"):')
|
||||
for call in ("middleware.call_sync(", "_tc_nested.stage_nested(",
|
||||
"_tc_nested.delete_snapshot_tree("):
|
||||
|
||||
interactions = [
|
||||
"middleware.call_sync(",
|
||||
"_tc_nested.query_filesystems(",
|
||||
"_tc_nested.stage_nested(",
|
||||
"_tc_nested.delete_snapshot_tree(",
|
||||
]
|
||||
present = [c for c in interactions if c in block]
|
||||
assert present, "found no middleware interaction at all -- the test is vacuous"
|
||||
for call in present:
|
||||
assert gate < block.index(call), f"{call} runs before the cloud_backup gate"
|
||||
|
||||
+88
-38
@@ -51,24 +51,37 @@ GOOD = {
|
||||
"async def restic_backup(middleware, job, cloud_backup, dry_run=False, "
|
||||
"rate_limit=None):\n pass\n"
|
||||
),
|
||||
# The middlewared METHODS the injected code calls. TrueNAS 26 deleted both of
|
||||
# these files, taking zfs.dataset.query / zfs.snapshot.query / zfs.snapshot.delete
|
||||
# with them -- see TestMiddlewareMethodsWeCall.
|
||||
"plugins/zfs_/dataset.py": (
|
||||
"class ZFSDataset(CRUDService):\n"
|
||||
# The middlewared METHODS the injected code calls. These now go through the
|
||||
# PUBLIC pool.* API: TrueNAS 26 deleted plugins/zfs_/ outright, taking the whole
|
||||
# private zfs.* service with it -- see TestMiddlewareMethodsWeCall.
|
||||
#
|
||||
# This default tree is a MODERN box (25.10/26): it has pool.snapshot and no
|
||||
# zfs.snapshot. The older shape is built explicitly where it is tested.
|
||||
"plugins/pool_/dataset.py": (
|
||||
"class PoolDatasetService(CRUDService):\n"
|
||||
" class Config:\n"
|
||||
" namespace = 'zfs.dataset'\n"
|
||||
" namespace = 'pool.dataset'\n"
|
||||
" def query(self, filters, options):\n pass\n"
|
||||
),
|
||||
"plugins/zfs_/snapshot.py": (
|
||||
"class ZFSSnapshot(CRUDService):\n"
|
||||
"plugins/pool_/snapshot.py": (
|
||||
"class PoolSnapshotService(CRUDService):\n"
|
||||
" class Config:\n"
|
||||
" namespace = 'zfs.snapshot'\n"
|
||||
" namespace = 'pool.snapshot'\n"
|
||||
" def query(self, filters, options):\n pass\n"
|
||||
" def delete(self, id_, options={}):\n pass\n"
|
||||
),
|
||||
}
|
||||
|
||||
#: A 24.10/25.04 box: `pool.snapshot` does not exist yet and the snapshot CRUD
|
||||
#: service still answers to the (then-public) `zfs.snapshot`.
|
||||
ZFS_ERA_SNAPSHOT = (
|
||||
"class ZFSSnapshot(CRUDService):\n"
|
||||
" class Config:\n"
|
||||
" namespace = 'zfs.snapshot'\n"
|
||||
" def query(self, filters, options):\n pass\n"
|
||||
" def do_delete(self, id_, options={}):\n pass\n"
|
||||
)
|
||||
|
||||
|
||||
def loader(files):
|
||||
def load(path):
|
||||
@@ -303,52 +316,86 @@ class TestMiddlewareMethodsWeCall:
|
||||
per descendant dataset (250 on a real pool) on every run, forever.
|
||||
"""
|
||||
|
||||
ZFS_SNAPSHOT = (
|
||||
"class ZFSSnapshot(CRUDService):\n"
|
||||
" class Config:\n"
|
||||
" namespace = 'zfs.snapshot'\n"
|
||||
" def query(self, filters, options):\n pass\n"
|
||||
" def delete(self, id_, options={}):\n pass\n"
|
||||
)
|
||||
ZFS_DATASET = (
|
||||
"class ZFSDataset(CRUDService):\n"
|
||||
" class Config:\n"
|
||||
" namespace = 'zfs.dataset'\n"
|
||||
" def query(self, filters, options):\n pass\n"
|
||||
)
|
||||
POOL_SNAPSHOT = GOOD["plugins/pool_/snapshot.py"]
|
||||
|
||||
def _tree(self, **over):
|
||||
files = dict(GOOD)
|
||||
files["plugins/zfs_/snapshot.py"] = self.ZFS_SNAPSHOT
|
||||
files["plugins/zfs_/dataset.py"] = self.ZFS_DATASET
|
||||
files.update(over)
|
||||
return files
|
||||
|
||||
#: A 26 box: pool.snapshot only.
|
||||
def _modern(self, **over):
|
||||
return self._tree(**over)
|
||||
|
||||
#: A 24.10/25.04 box: zfs.snapshot only -- plugins/pool_/snapshot.py does not
|
||||
#: exist yet.
|
||||
def _zfs_era(self, **over):
|
||||
return self._tree(**{
|
||||
"plugins/pool_/snapshot.py": None,
|
||||
"plugins/zfs_/snapshot.py": ZFS_ERA_SNAPSHOT,
|
||||
**over,
|
||||
})
|
||||
|
||||
def test_present_methods_are_ok(self):
|
||||
r = check_files(self._tree())
|
||||
r = check_files(self._modern())
|
||||
assert r[NESTED]["ok"], r[NESTED]["problems"]
|
||||
|
||||
def test_a_deleted_plugin_file_is_broken(self):
|
||||
# Literally TrueNAS 26: plugins/zfs_/snapshot.py does not exist.
|
||||
r = check_files(self._tree(**{"plugins/zfs_/snapshot.py": None}))
|
||||
def test_the_OLD_zfs_era_snapshot_service_also_satisfies_the_call(self):
|
||||
# 24.10 and 25.04 have no `pool.snapshot` at all -- the CRUD service is the
|
||||
# then-public `zfs.snapshot`. Pinning only the modern spelling marked both of
|
||||
# those releases BROKEN and would have switched nested snapshots OFF on boxes
|
||||
# where they work perfectly. The runtime picks the same way; see
|
||||
# pick_snapshot_service().
|
||||
r = check_files(self._zfs_era())
|
||||
assert r[NESTED]["ok"], r[NESTED]["problems"]
|
||||
|
||||
def test_it_is_broken_only_when_NEITHER_namespace_exists(self):
|
||||
# The real failure: middleware drops the last spelling we know how to call.
|
||||
r = check_files(self._tree(**{
|
||||
"plugins/pool_/snapshot.py": None,
|
||||
"plugins/zfs_/snapshot.py": None,
|
||||
}))
|
||||
assert is_broken(r[NESTED])
|
||||
details = " ".join(p["detail"] for p in r[NESTED]["problems"])
|
||||
assert "zfs.snapshot.delete" in details
|
||||
assert "pool.snapshot.delete" in details
|
||||
assert "zfs.snapshot.delete" in details, (
|
||||
"the report must say BOTH spellings were tried, or whoever reads it will "
|
||||
"think we simply never looked for the one their box has"
|
||||
)
|
||||
|
||||
def test_we_do_NOT_depend_on_a_middleware_dataset_query_at_all(self):
|
||||
# iX could delete plugins/pool_/dataset.py tomorrow and the patch would not
|
||||
# care, because the staging plan is enumerated from ZFS, not from middleware.
|
||||
#
|
||||
# That is deliberate, and it was expensive to learn. `pool.dataset.query`
|
||||
# exists and is correctly shaped -- and it LIES: it applies a visibility
|
||||
# policy that hides ix-apps/*, .system/* and .ix-virt/* (84 of 270 datasets
|
||||
# on the real pool, including live app data). No source check could ever
|
||||
# have caught that; only running it could. So there is no assumption here
|
||||
# left to break.
|
||||
r = check_files(self._modern(**{"plugins/pool_/dataset.py": None}))
|
||||
assert r[NESTED]["ok"], r[NESTED]["problems"]
|
||||
|
||||
ids = {c.id for c in compat.MIDDLEWARE_CALLS}
|
||||
assert not any("dataset" in i or "query" in i for i in ids), (
|
||||
"a dataset/snapshot QUERY assumption crept back into the manifest -- "
|
||||
"middleware's queries are filtered; enumerate from ZFS"
|
||||
)
|
||||
|
||||
def test_a_renamed_namespace_is_broken(self):
|
||||
r = check_files(self._tree(**{
|
||||
"plugins/zfs_/snapshot.py": self.ZFS_SNAPSHOT.replace(
|
||||
"'zfs.snapshot'", "'zfs.resource.snapshot'"),
|
||||
"plugins/pool_/snapshot.py": self.POOL_SNAPSHOT.replace(
|
||||
"'pool.snapshot'", "'zfs.resource.snapshot'"),
|
||||
"plugins/zfs_/snapshot.py": None,
|
||||
}))
|
||||
assert is_broken(r[NESTED])
|
||||
|
||||
def test_the_CRUDService_do_prefix_is_accepted(self):
|
||||
# 24.10 and 25.04 declare `do_delete`; 25.10 renamed it to `delete`. BOTH
|
||||
# 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 perfectly.
|
||||
r = check_files(self._tree(**{
|
||||
"plugins/zfs_/snapshot.py": self.ZFS_SNAPSHOT.replace(
|
||||
# A CRUDService exposes `delete` from a method NAMED `do_delete`. Both
|
||||
# spellings are live across the matrix. Accepting only the literal name
|
||||
# reported working releases as broken.
|
||||
r = check_files(self._modern(**{
|
||||
"plugins/pool_/snapshot.py": self.POOL_SNAPSHOT.replace(
|
||||
"def delete(", "def do_delete("),
|
||||
}))
|
||||
assert r[NESTED]["ok"], r[NESTED]["problems"]
|
||||
@@ -356,6 +403,9 @@ class TestMiddlewareMethodsWeCall:
|
||||
def test_the_snapshot_delete_reason_names_the_orphan_risk(self):
|
||||
# If this ever regresses, whoever reads the bug report must understand that
|
||||
# it is not a cosmetic failure.
|
||||
r = check_files(self._tree(**{"plugins/zfs_/snapshot.py": None}))
|
||||
r = check_files(self._tree(**{
|
||||
"plugins/pool_/snapshot.py": None,
|
||||
"plugins/zfs_/snapshot.py": None,
|
||||
}))
|
||||
whys = " ".join(p["why"] for p in r[NESTED]["problems"])
|
||||
assert "orphan" in whys
|
||||
|
||||
+146
-17
@@ -204,18 +204,52 @@ class FakeMiddleware:
|
||||
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):
|
||||
def __init__(self, snapshots=None, snapshot_ns="pool.snapshot"):
|
||||
self.snapshots = list(snapshots or [])
|
||||
self.calls = []
|
||||
self.logger = None
|
||||
self.snapshot_ns = snapshot_ns
|
||||
|
||||
def get_service(self, name):
|
||||
if name != self.snapshot_ns:
|
||||
raise KeyError(name) # middleware raises KeyError for an unknown ns
|
||||
return object()
|
||||
|
||||
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))
|
||||
if method == "zfs.snapshot.query":
|
||||
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 method == "zfs.snapshot.delete":
|
||||
if op == "delete":
|
||||
name = args[0]
|
||||
opts = args[1] if len(args) > 1 else {}
|
||||
if name not in self.snapshots:
|
||||
@@ -265,44 +299,44 @@ class TestDeleteSnapshotTree:
|
||||
mw = FakeMiddleware([
|
||||
"Tap@snap", "Tap/apps@snap", "Tap/apps/lidarr@snap", "Tap@keepme",
|
||||
])
|
||||
delete_snapshot_tree(mw, "Tap@snap")
|
||||
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")
|
||||
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"])
|
||||
delete_snapshot_tree(mw, "Tap@snap")
|
||||
delete_snapshot_tree(mw, "Tap@snap", list_snapshots=mw.list_snapshots)
|
||||
assert mw.snapshots == []
|
||||
deletes = [a for m, a in mw.calls if m == "zfs.snapshot.delete"]
|
||||
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 not [m for m, _a in mw.calls if m == "zfs.snapshot.query"], (
|
||||
assert not [m for m, _a in mw.calls if m.endswith(".query")], (
|
||||
"no enumeration needed on the fast path"
|
||||
)
|
||||
|
||||
def test_survives_recursive_and_query_failure_by_deleting_the_parent(self):
|
||||
class Broken(FakeMiddleware):
|
||||
def call_sync(self, method, *args):
|
||||
if method == "zfs.snapshot.query":
|
||||
if method.endswith(".query"):
|
||||
raise RuntimeError("boom")
|
||||
if method == "zfs.snapshot.delete" and len(args) > 1:
|
||||
if method.endswith(".delete") and len(args) > 1:
|
||||
raise RuntimeError("recursive delete unavailable")
|
||||
return super().call_sync(method, *args)
|
||||
|
||||
mw = Broken(["Tap@snap"])
|
||||
delete_snapshot_tree(mw, "Tap@snap")
|
||||
delete_snapshot_tree(mw, "Tap@snap", list_snapshots=mw.list_snapshots)
|
||||
assert mw.snapshots == []
|
||||
|
||||
def test_leaves_unrelated_snapshots_alone_when_the_tree_is_gone(self):
|
||||
mw = FakeMiddleware(["Tap@unrelated"])
|
||||
delete_snapshot_tree(mw, "Tap@snap")
|
||||
delete_snapshot_tree(mw, "Tap@snap", list_snapshots=mw.list_snapshots)
|
||||
assert mw.snapshots == ["Tap@unrelated"]
|
||||
|
||||
|
||||
@@ -692,7 +726,7 @@ class BusyMiddleware(FakeMiddleware):
|
||||
self.attempts = 0
|
||||
|
||||
def call_sync(self, method, *args):
|
||||
if method == "zfs.snapshot.delete":
|
||||
if method.endswith(".delete"):
|
||||
name = args[0]
|
||||
opts = args[1] if len(args) > 1 else {}
|
||||
if opts.get("recursive"):
|
||||
@@ -713,7 +747,7 @@ class TestDeleteRetriesAndReportsSurvivors:
|
||||
["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)
|
||||
survivors = tn.delete_snapshot_tree(mw, "Tap@snap", sleep=lambda _s: None, list_snapshots=mw.list_snapshots)
|
||||
assert survivors == []
|
||||
assert mw.snapshots == []
|
||||
|
||||
@@ -725,7 +759,7 @@ class TestDeleteRetriesAndReportsSurvivors:
|
||||
["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)
|
||||
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"]
|
||||
|
||||
@@ -742,7 +776,7 @@ class TestDeleteRetriesAndReportsSurvivors:
|
||||
return real(method, *args)
|
||||
|
||||
mw.call_sync = spy
|
||||
tn.delete_snapshot_tree(mw, "Tap@snap", sleep=lambda _s: None)
|
||||
tn.delete_snapshot_tree(mw, "Tap@snap", sleep=lambda _s: None, list_snapshots=mw.list_snapshots)
|
||||
assert order[0] == ("release", "snap"), order
|
||||
|
||||
|
||||
@@ -1002,6 +1036,7 @@ class TestGarbageCollectorExecution:
|
||||
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 == [
|
||||
@@ -1026,6 +1061,7 @@ class TestGarbageCollectorExecution:
|
||||
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]
|
||||
|
||||
@@ -1039,7 +1075,7 @@ class TestGarbageCollectorExecution:
|
||||
|
||||
class Broken(FakeMiddleware):
|
||||
def call_sync(self, method, *args):
|
||||
if method == "zfs.snapshot.query":
|
||||
if method.endswith(".query"):
|
||||
raise RuntimeError("middleware is having a day")
|
||||
return super().call_sync(method, *args)
|
||||
|
||||
@@ -1049,3 +1085,96 @@ class TestGarbageCollectorExecution:
|
||||
now=dt.datetime(2026, 7, 14, 12, 0, 0, tzinfo=dt.UTC),
|
||||
mounts_file=str(mounts),
|
||||
) == []
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user