TrueNAS 26 support: one sync implementation, two wrappers
CI / python 3.13 (push) Successful in 15s
CI / shell (shellcheck + syntax) (push) Successful in 8s
CI / python 3.11 (push) Successful in 14s
CI / python 3.12 (push) Successful in 16s
TrueNAS compatibility / compat (push) Successful in 9s

26 rewrites cloud_backup from async to synchronous AND deletes
get_dataset_recursive(), which SNAPSHOT_BLOCK called out of the host module's
namespace. Either is a broken backup found at restore time.

The nested module is now one synchronous implementation talking to middlewared via
call_sync, behind two thin wrappers. apply.sh reads which flavour the installed
middleware declares and injects the matching one: <= 25.10 reaches it through
'await middleware.run_in_thread(...)', 26 is already in a worker thread and calls
it directly. The snapshot/bind-mount/failure logic exists once -- an async twin
would mean every future fix had to land twice.

A middleware whose three wrapped functions disagree about asyncness is refused,
not guessed at. get_dataset_recursive is vendored, removing the dependency on both
versions rather than asserting it.

master stays BROKEN on purpose: iX are still renaming middleware->context,
cloud_backup->entry and adding a required credentials param there. Chasing a
branch that moves daily is how you ship a patch nobody tested.
This commit is contained in:
2026-07-13 18:18:28 +00:00
parent cf2c6a8a02
commit 498b2690e1
8 changed files with 475 additions and 179 deletions
+56 -10
View File
@@ -105,19 +105,22 @@ ASSUMPTIONS = [
),
# ── nested snapshots. Every block here is an async wrapper. ───────────────
# is_async is deliberately NOT asserted on these three. The patch now injects an
# async OR a sync wrapper to match whichever the installed middleware declares
# (TrueNAS <= 25.10 is async; 26 rewrote them synchronous), so asyncness is a
# thing to DETECT, not a thing to require -- see async_flavour(). What must still
# hold is the shape: same name, same leading positional parameters.
Assumption(
"create-snapshot", NESTED, "plugins/cloud/snapshot.py", "create_snapshot",
is_async=True, params=["middleware", "path", "name"],
why="SNAPSHOT_BLOCK replaces it with `async def` that AWAITS the original "
"and returns (snapshot, staging_root). TrueNAS 26 made it synchronous: "
"the wrapper would return a coroutine that sync.py unpacks as a tuple",
params=["middleware", "path", "name"],
why="SNAPSHOT_BLOCK wraps it and returns (snapshot, staging_root) instead of "
"(snapshot, snap_path)",
),
Assumption(
"crud-mixin-validate", NESTED, "plugins/cloud/crud.py",
"CloudTaskServiceMixin._validate",
kind="method", is_async=True, params=["self", "app", "verrors", "name", "data"],
why="CRUD_BLOCK replaces it with `async def` that AWAITS the original, to "
"drop the no-further-nesting error",
kind="method", params=["self", "app", "verrors", "name", "data"],
why="CRUD_BLOCK wraps it to drop the no-further-nesting error",
),
Assumption(
# SYNC_BLOCK's wrapper is (middleware, job, cloud_backup, *args, **kwargs) and
@@ -125,10 +128,9 @@ ASSUMPTIONS = [
# 25.04 have `(…, dry_run)`, 25.10 added `rate_limit`. Only the leading three
# are named by the patch, so only they have to hold.
"restic-backup", NESTED, "plugins/cloud_backup/sync.py", "restic_backup",
is_async=True, forwards=True,
forwards=True,
params=["middleware", "job", "cloud_backup"],
why="SYNC_BLOCK replaces it with `async def` that AWAITS the original, to "
"tear down bind mounts in a finally",
why="SYNC_BLOCK wraps it to tear down bind mounts in a finally",
),
]
@@ -462,6 +464,50 @@ def check(loader, modules=None) -> dict:
return out
#: The three stock symbols the nested module wraps. TrueNAS <= 25.10 declares them
#: `async def`; TrueNAS 26 rewrote them synchronous. apply.sh injects the wrapper
#: that matches, so this is the question it has to answer at every boot.
NESTED_WRAPPED = [
("plugins/cloud/snapshot.py", "create_snapshot"),
("plugins/cloud/crud.py", "CloudTaskServiceMixin._validate"),
("plugins/cloud_backup/sync.py", "restic_backup"),
]
def async_flavour(loader) -> bool | None:
"""Is the installed cloud_backup path async? True, False, or None if unclear.
None means "do not patch": either a symbol is missing, or -- the case worth
naming -- the three DISAGREE. A middleware caught half-converted is one this
patch has never seen, and guessing a flavour there means injecting an `async def`
that a synchronous caller unpacks as a tuple. Declining costs a feature; guessing
costs a backup.
"""
flavours = set()
for path, symbol in NESTED_WRAPPED:
try:
src = loader(path)
except Unreadable:
return None
if src is None:
return None
try:
tree = ast.parse(_stock(src))
except SyntaxError:
return None
node = _find(tree, symbol)
if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
return None
flavours.add(isinstance(node, ast.AsyncFunctionDef))
return flavours.pop() if len(flavours) == 1 else None
def async_flavour_tree(root: str) -> bool | None:
return async_flavour(lambda p: _read(root, p))
def check_ref(ref: str, modules=None) -> dict:
return check(lambda p: _fetch(ref, p), modules)