571 lines
21 KiB
Python
571 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""What this patch assumes about middlewared -- written down, and checkable.
|
|
|
|
WHY THIS EXISTS
|
|
---------------
|
|
This patch appends code to middlewared's own modules. middlewared has no stability
|
|
contract: it is internal API, and iX may reshape it in any release. When they do,
|
|
the patch does not politely decline -- it breaks a backup, possibly silently, which
|
|
is the worst thing a backup tool can do.
|
|
|
|
It has already happened. TrueNAS 26 rewrites the whole cloud_backup path from
|
|
async to synchronous:
|
|
|
|
25.10: async def create_snapshot(...) / await create_snapshot(...)
|
|
26.0: def create_snapshot(...) / create_snapshot(...)
|
|
|
|
Every block the nested module injects is an `async def` wrapping an `await`ed
|
|
original. On 26 that unpacks a coroutine object instead of a tuple. Nobody would
|
|
have found out until a restore failed.
|
|
|
|
So the assumptions are written down here, once, and checked in two places:
|
|
|
|
* .github/workflows/compat.yml runs `--ref` against TrueNAS's *unreleased*
|
|
branches (master, the newest BETA/RC) on a schedule, and opens a bug report
|
|
the day iX breaks us -- while it is still a beta, not after it ships.
|
|
|
|
* patch/apply.sh runs `--tree` against the middlewared *actually installed*, at
|
|
every boot, and REFUSES to patch a module whose assumptions no longer hold.
|
|
That is the guarantee: an unpatched module means stock TrueNAS (Storj only,
|
|
but working). A patched-anyway module means broken backups. Declining is
|
|
always the better failure.
|
|
|
|
The two modules are checked independently, because they fail independently: on 26
|
|
the providers module (B2/S3) only touches synchronous symbols and survives, while
|
|
the nested module does not.
|
|
|
|
python3 tools/compat.py --tree /usr/lib/python3/dist-packages/middlewared
|
|
python3 tools/compat.py --ref release/26.0.0-BETA.3
|
|
python3 tools/compat.py --ref master --json
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.request
|
|
|
|
PROVIDERS = "providers"
|
|
NESTED = "nested"
|
|
|
|
RAW = "https://raw.githubusercontent.com/truenas/middleware/{ref}/src/middlewared/middlewared/{path}"
|
|
|
|
_TIMEOUT = 30
|
|
|
|
|
|
class Assumption:
|
|
"""One thing that must be true of middlewared, or a module cannot be applied.
|
|
|
|
`is_async=None` means "do not care". Everywhere else it is stated explicitly,
|
|
because asyncness is exactly the axis TrueNAS 26 changed and a checker that
|
|
ignored it would have passed a build that breaks every backup.
|
|
"""
|
|
|
|
def __init__(self, ident, module, path, symbol, *, kind="function",
|
|
is_async=None, params=None, why=""):
|
|
self.id = ident
|
|
self.module = module
|
|
self.path = path
|
|
self.symbol = symbol
|
|
self.kind = kind
|
|
self.is_async = is_async
|
|
self.params = params or []
|
|
self.why = why
|
|
|
|
|
|
#: Everything patch/apply.sh's injected blocks depend on. Derived from the blocks
|
|
#: themselves -- if you add a block, add its assumptions here or the checker is
|
|
#: decoration.
|
|
ASSUMPTIONS = [
|
|
# ── providers (B2/S3). Touches only synchronous symbols. ──────────────────
|
|
Assumption(
|
|
"b2-remote-class", PROVIDERS, "rclone/remote/b2.py", "B2RcloneRemote",
|
|
kind="class",
|
|
why="B2_BLOCK sets .get_restic_config and .restic on this class",
|
|
),
|
|
Assumption(
|
|
"restic-config-fn", PROVIDERS, "plugins/cloud_backup/restic.py",
|
|
"get_restic_config", is_async=False, params=["cloud_backup"],
|
|
why="RESTIC_BLOCK wraps it to rewrite the repo URL; it calls the original "
|
|
"WITHOUT await, so it must stay synchronous",
|
|
),
|
|
Assumption(
|
|
"restic-config-class", PROVIDERS, "plugins/cloud_backup/restic.py",
|
|
"ResticConfig", kind="class",
|
|
why="RESTIC_BLOCK does dataclasses.replace(result, cmd=...) on what "
|
|
"get_restic_config returns",
|
|
),
|
|
|
|
# ── nested snapshots. Every block here is an async wrapper. ───────────────
|
|
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",
|
|
),
|
|
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",
|
|
),
|
|
Assumption(
|
|
"restic-backup", NESTED, "plugins/cloud_backup/sync.py", "restic_backup",
|
|
is_async=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",
|
|
),
|
|
]
|
|
|
|
|
|
#: Things that mean iX has done the job themselves and the module should RETIRE,
|
|
#: not break. Absence of the nesting guard = nested snapshots went native.
|
|
#: `restic = True` already on B2RcloneRemote = B2 restic support went native.
|
|
NATIVE_PROBES = {
|
|
NESTED: (
|
|
"plugins/cloud/crud.py",
|
|
"no further nesting",
|
|
False, # native when the phrase is ABSENT
|
|
),
|
|
PROVIDERS: (
|
|
"rclone/remote/b2.py",
|
|
"restic = True",
|
|
True, # native when the phrase is PRESENT
|
|
),
|
|
}
|
|
|
|
|
|
def _squash(text: str) -> str:
|
|
"""Drop whitespace and quotes, so a phrase split across string literals matches.
|
|
|
|
Stock middleware writes the guard as an implicitly-concatenated literal:
|
|
|
|
verrors.add(f"{name}.snapshot", "This option is only available for "
|
|
"datasets that have no further nesting")
|
|
|
|
A naive `"no further nesting" in source` is therefore FALSE on a version that
|
|
very much has the guard -- and this probe's False means "TrueNAS supports it
|
|
natively, retire the module". That is a silent, catastrophic misread: it would
|
|
disable nested snapshots on every box that currently depends on them.
|
|
|
|
apply.sh already learned this the hard way and normalises the same way. Both
|
|
now call this one function, which is the only reason they cannot drift apart
|
|
again.
|
|
"""
|
|
return text.translate(str.maketrans("", "", " \t\n\r\"'"))
|
|
|
|
|
|
# ── AST lookups ──────────────────────────────────────────────────────────────
|
|
|
|
def _find(tree, symbol):
|
|
"""The node for `name` or `Class.method`, or None."""
|
|
if "." in symbol:
|
|
cls_name, meth = symbol.split(".", 1)
|
|
for node in tree.body:
|
|
if isinstance(node, ast.ClassDef) and node.name == cls_name:
|
|
for sub in node.body:
|
|
if isinstance(sub, ast.FunctionDef | ast.AsyncFunctionDef) \
|
|
and sub.name == meth:
|
|
return sub
|
|
return None
|
|
|
|
for node in tree.body:
|
|
if isinstance(node, ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef) \
|
|
and node.name == symbol:
|
|
return node
|
|
return None
|
|
|
|
|
|
def _params(node):
|
|
a = node.args
|
|
return [p.arg for p in (*a.posonlyargs, *a.args, *a.kwonlyargs)]
|
|
|
|
|
|
def check_source(a: Assumption, src: str | None) -> str | None:
|
|
"""The reason assumption `a` no longer holds, or None if it does."""
|
|
if src is None:
|
|
return f"{a.path} does not exist"
|
|
|
|
try:
|
|
tree = ast.parse(src)
|
|
except SyntaxError as e:
|
|
return f"{a.path} does not parse: {e}"
|
|
|
|
node = _find(tree, a.symbol)
|
|
if node is None:
|
|
return f"{a.path} no longer defines {a.symbol}"
|
|
|
|
if a.kind == "class":
|
|
if not isinstance(node, ast.ClassDef):
|
|
return f"{a.symbol} is no longer a class"
|
|
return None
|
|
|
|
if isinstance(node, ast.ClassDef):
|
|
return f"{a.symbol} is a class, expected a function"
|
|
|
|
got_async = isinstance(node, ast.AsyncFunctionDef)
|
|
if a.is_async is not None and got_async != a.is_async:
|
|
want = "async def" if a.is_async else "def"
|
|
got = "async def" if got_async else "def"
|
|
return (
|
|
f"{a.symbol} is now `{got}`, the patch requires `{want}` "
|
|
f"({a.path})"
|
|
)
|
|
|
|
have = _params(node)
|
|
missing = [p for p in a.params if p not in have]
|
|
if missing:
|
|
return (
|
|
f"{a.symbol}{tuple(have)} no longer takes {', '.join(missing)}"
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
# ── sources ──────────────────────────────────────────────────────────────────
|
|
|
|
def _fetch(ref: str, path: str) -> str | None:
|
|
url = RAW.format(ref=ref, path=path)
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=_TIMEOUT) as r: # noqa: S310
|
|
if r.status != 200:
|
|
return None
|
|
return r.read().decode("utf-8", "replace")
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _read(root: str, path: str) -> str | None:
|
|
try:
|
|
with open(os.path.join(root, *path.split("/")), encoding="utf-8") as fh:
|
|
return fh.read()
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def check(loader, modules=None) -> dict:
|
|
"""Check every assumption. `loader(path) -> source|None`.
|
|
|
|
Returns {module: {"ok": bool, "native": bool, "problems": [...]}}.
|
|
"""
|
|
modules = modules or [PROVIDERS, NESTED]
|
|
cache = {}
|
|
|
|
def src(path):
|
|
if path not in cache:
|
|
cache[path] = loader(path)
|
|
return cache[path]
|
|
|
|
out = {m: {"ok": True, "native": False, "problems": []} for m in modules}
|
|
|
|
for a in ASSUMPTIONS:
|
|
if a.module not in out:
|
|
continue
|
|
problem = check_source(a, src(a.path))
|
|
if problem:
|
|
out[a.module]["ok"] = False
|
|
out[a.module]["problems"].append({
|
|
"id": a.id, "detail": problem, "why": a.why,
|
|
})
|
|
|
|
for module, (path, phrase, native_when_present) in NATIVE_PROBES.items():
|
|
if module not in out:
|
|
continue
|
|
text = src(path)
|
|
if text is None:
|
|
continue
|
|
present = _squash(phrase) in _squash(text)
|
|
out[module]["native"] = (present == native_when_present)
|
|
|
|
return out
|
|
|
|
|
|
def check_ref(ref: str, modules=None) -> dict:
|
|
return check(lambda p: _fetch(ref, p), modules)
|
|
|
|
|
|
def check_tree(root: str, modules=None) -> dict:
|
|
return check(lambda p: _read(root, p), modules)
|
|
|
|
|
|
# ── which TrueNAS versions to check ──────────────────────────────────────────
|
|
|
|
REPO = "https://github.com/truenas/middleware"
|
|
|
|
#: TrueCloud Backup -- the restic-based cloud_backup this patch extends -- was
|
|
#: introduced in 24.10. In 24.04 the modules simply do not exist (404), which the
|
|
#: checker would otherwise report as three separate "broken assumptions" for a
|
|
#: feature that was never there.
|
|
OLDEST = (24, 10)
|
|
|
|
#: BETA < RC < shipped. Without this, "26.0.0-BETA.1" and "26.0.0-BETA.3" both
|
|
#: reduce to (26,0,0) and the matrix silently reports whichever was seen first --
|
|
#: which is how it first showed BETA.1 while BETA.3 was the one to worry about.
|
|
_STAGE = {"BETA": 0, "RC": 1}
|
|
_SHIPPED = 2
|
|
|
|
|
|
def _version_of(name: str):
|
|
"""Sortable version of 'release/26.0.0-BETA.3' or 'TS-25.10.4'. None if junk.
|
|
|
|
Returns ((major, minor, ...), stage_rank, stage_number).
|
|
"""
|
|
tail = name.split("/", 1)[1] if "/" in name else name
|
|
tail = tail.removeprefix("TS-")
|
|
|
|
core, _, suffix = tail.partition("-")
|
|
try:
|
|
version = tuple(int(p) for p in core.split("."))
|
|
except ValueError:
|
|
return None
|
|
if len(version) < 2:
|
|
return None
|
|
|
|
if not suffix:
|
|
return version, _SHIPPED, 0
|
|
|
|
stage, _, num = suffix.partition(".")
|
|
rank = _STAGE.get(stage.upper())
|
|
if rank is None:
|
|
return None # not a release line we understand
|
|
return version, rank, int(num) if num.isdigit() else 0
|
|
|
|
|
|
def _newest_per_line(names):
|
|
"""Newest name on each (major, minor) line."""
|
|
best = {}
|
|
for name in names:
|
|
v = _version_of(name)
|
|
if not v or v[0][:2] < OLDEST:
|
|
continue
|
|
key = v[0][:2]
|
|
if key not in best or v > best[key][0]:
|
|
best[key] = (v, name)
|
|
return [n for _, n in sorted(best.values())]
|
|
|
|
|
|
def _ls_remote(remote, what):
|
|
import subprocess
|
|
|
|
out = subprocess.run(
|
|
["git", "ls-remote", what, "--refs", remote],
|
|
capture_output=True, text=True, check=True, timeout=60,
|
|
).stdout
|
|
prefix = "refs/tags/" if what == "--tags" else "refs/heads/"
|
|
return [
|
|
line.split(prefix, 1)[1].strip()
|
|
for line in out.splitlines() if prefix in line
|
|
]
|
|
|
|
|
|
def discover_refs(remote: str = REPO) -> list[str]:
|
|
"""What to check: every shipped TrueNAS line, everything unreleased, and master.
|
|
|
|
Two sources, because they are authoritative for different things:
|
|
|
|
* SHIPPED comes from the `TS-*` TAGS. Those are what iX actually released.
|
|
The `release/*` branches include mistakes -- `release/25.20.2.2` exists and
|
|
25.20 is not a TrueNAS version -- and a typo branch in the matrix reads as
|
|
a real supported release that we are silently broken on.
|
|
|
|
* UNRELEASED comes from the BRANCHES, because that is where a beta appears
|
|
first: `release/26.0.0-BETA.3` had no tag yet while it was the newest beta.
|
|
Catching breakage here, before it ships, is the whole point of this file.
|
|
"""
|
|
tags = _ls_remote(remote, "--tags")
|
|
heads = _ls_remote(remote, "--heads")
|
|
|
|
shipped = _newest_per_line([
|
|
t for t in tags if t.startswith("TS-") and "-BETA" not in t and "-RC" not in t
|
|
])
|
|
|
|
# A prerelease of a line that has ALREADY shipped is history, not a warning:
|
|
# release/24.10-RC.2 still exists, and the nested module does not apply to it,
|
|
# but 24.10 shipped long ago and TS-24.10.2.4 is fine. Reporting it would be a
|
|
# standing red row in the matrix for a version nobody can install.
|
|
shipped_lines = {_version_of(t)[0][:2] for t in shipped}
|
|
upcoming = [
|
|
h for h in _newest_per_line([
|
|
h for h in heads
|
|
if h.startswith("release/") and ("-BETA" in h or "-RC" in h)
|
|
])
|
|
if _version_of(h)[0][:2] not in shipped_lines
|
|
]
|
|
|
|
return [*shipped, *upcoming, "master"]
|
|
|
|
|
|
def is_unreleased(ref: str) -> bool:
|
|
"""master and any BETA/RC. Breakage here is early warning, not an outage."""
|
|
return ref == "master" or "-BETA" in ref or "-RC" in ref
|
|
|
|
|
|
def matrix(refs=None, remote: str = REPO) -> list[dict]:
|
|
"""Check every release line. Returns one row per ref."""
|
|
rows = []
|
|
for ref in (refs or discover_refs(remote)):
|
|
result = check(lambda p, r=ref: _fetch(r, p))
|
|
rows.append({
|
|
"ref": ref,
|
|
"unreleased": is_unreleased(ref),
|
|
"modules": result,
|
|
})
|
|
return rows
|
|
|
|
|
|
def _verdict(r: dict) -> str:
|
|
if r["native"]:
|
|
return "native"
|
|
return "ok" if r["ok"] else "BROKEN"
|
|
|
|
|
|
#: Versions a human has actually run a backup on, with real data, on real hardware.
|
|
#: This is NOT automatable and must never be inferred: everything else in this file
|
|
#: is static analysis of iX's source, which proves the patch's assumptions hold --
|
|
#: a strictly weaker claim than "a restore worked". Add a row only after doing it.
|
|
HARDWARE_VERIFIED = {
|
|
"25.10.4": "nested + providers; 252-snapshot recursive backup of /mnt/Tap, 18m",
|
|
}
|
|
|
|
_LEGEND = """
|
|
| verdict | meaning |
|
|
| --- | --- |
|
|
| **ok** | Every assumption the patch makes about middleware still holds. |
|
|
| **BROKEN** | middleware changed underneath the patch. `apply.sh` **refuses to apply that module** on this version and leaves TrueNAS stock, so backups keep working — without the module's feature. |
|
|
| **native** | TrueNAS does this itself now. The module retires; it is not a failure. |
|
|
|
|
"ok" means *the patch's assumptions hold*, checked automatically against iX's
|
|
source. It does not mean a human ran a backup on it — that is the
|
|
**Hardware-verified** column, which is filled in by hand and only by doing it.
|
|
"""
|
|
|
|
|
|
def render_markdown(rows: list[dict]) -> str:
|
|
"""The matrix, for COMPATIBILITY.md and the README."""
|
|
out = [
|
|
"| TrueNAS | B2/S3 providers | Nested snapshots | Hardware-verified |",
|
|
"| --- | --- | --- | --- |",
|
|
]
|
|
for row in rows:
|
|
m = row["modules"]
|
|
ref = row["ref"]
|
|
label = ref.removeprefix("TS-").removeprefix("release/")
|
|
if row["unreleased"]:
|
|
label = f"{label} _(unreleased)_"
|
|
|
|
cells = []
|
|
for mod in (PROVIDERS, NESTED):
|
|
v = _verdict(m[mod])
|
|
cells.append({
|
|
"ok": "ok",
|
|
"BROKEN": "**BROKEN**",
|
|
"native": "native",
|
|
}[v])
|
|
|
|
version = ref.removeprefix("TS-")
|
|
hw = HARDWARE_VERIFIED.get(version)
|
|
out.append(f"| {label} | {cells[0]} | {cells[1]} | {hw or '—'} |")
|
|
|
|
return "\n".join(out) + "\n" + _LEGEND
|
|
|
|
|
|
def render_matrix(rows: list[dict]) -> str:
|
|
"""A support table.
|
|
|
|
Says "assumptions hold", not "works" -- this is static analysis of iX's source,
|
|
which is a strictly weaker claim than having run a backup on the hardware. The
|
|
hardware-verified column lives in COMPATIBILITY.md and is maintained by hand,
|
|
because nothing else can honestly fill it in.
|
|
"""
|
|
w = max((len(r["ref"]) for r in rows), default=10)
|
|
lines = [
|
|
f"{'TrueNAS'.ljust(w)} {'providers':<10} {'nested':<10}",
|
|
f"{'-' * w} {'-' * 10} {'-' * 10}",
|
|
]
|
|
for row in rows:
|
|
m = row["modules"]
|
|
lines.append(
|
|
f"{row['ref'].ljust(w)} "
|
|
f"{_verdict(m[PROVIDERS]):<10} {_verdict(m[NESTED]):<10}"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ── reporting ────────────────────────────────────────────────────────────────
|
|
|
|
def render(label: str, result: dict) -> str:
|
|
lines = [f"TrueNAS middleware @ {label}", ""]
|
|
for module, r in sorted(result.items()):
|
|
if r["native"]:
|
|
lines.append(
|
|
f" [NATIVE] {module}: TrueNAS appears to support this natively "
|
|
f"now — the module should be retired, not fixed."
|
|
)
|
|
elif r["ok"]:
|
|
lines.append(f" [ok] {module}: all assumptions hold")
|
|
else:
|
|
lines.append(f" [BROKEN] {module}:")
|
|
for p in r["problems"]:
|
|
lines.append(f" - {p['detail']}")
|
|
lines.append(f" why it matters: {p['why']}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main(argv):
|
|
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
g = ap.add_mutually_exclusive_group(required=True)
|
|
g.add_argument("--ref", help="a truenas/middleware git ref, e.g. master")
|
|
g.add_argument("--tree", help="path to an installed middlewared package")
|
|
g.add_argument("--matrix", action="store_true",
|
|
help="check every TrueNAS release line, newest of each")
|
|
ap.add_argument("--module", action="append", choices=[PROVIDERS, NESTED],
|
|
help="check only this module (repeatable)")
|
|
ap.add_argument("--json", action="store_true")
|
|
ap.add_argument("--markdown", action="store_true",
|
|
help="with --matrix: emit the table for COMPATIBILITY.md")
|
|
args = ap.parse_args(argv[1:])
|
|
|
|
if args.matrix:
|
|
rows = matrix()
|
|
if args.json:
|
|
print(json.dumps(rows, indent=2))
|
|
elif args.markdown:
|
|
print(render_markdown(rows))
|
|
else:
|
|
print(render_matrix(rows))
|
|
# A broken UNRELEASED line (master, -BETA, -RC) is a warning, not a build
|
|
# failure -- it is exactly what we want to know early, and it is iX's tree
|
|
# to change. compat.yml turns it into a bug report. A broken SHIPPED line
|
|
# is a genuine failure: users are on it right now.
|
|
shipped_broken = [
|
|
r["ref"] for r in rows
|
|
if not r["unreleased"]
|
|
and any(not m["ok"] and not m["native"] for m in r["modules"].values())
|
|
]
|
|
if shipped_broken:
|
|
print(f"\nBROKEN on shipped releases: {', '.join(shipped_broken)}",
|
|
file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
label = args.ref or args.tree
|
|
result = (check_ref(args.ref, args.module) if args.ref
|
|
else check_tree(args.tree, args.module))
|
|
|
|
if args.json:
|
|
print(json.dumps({"ref": label, "modules": result}, indent=2))
|
|
else:
|
|
print(render(label, result))
|
|
|
|
# Exit 1 if any module is broken. "Native" is not broken -- it is good news.
|
|
return 1 if any(not r["ok"] and not r["native"] for r in result.values()) else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|