Audit fixes: a compat verdict must never be able to brick a working box
The audit found the new machinery could do more harm than the bugs it prevents. - apply.sh reused the 'nothing left to do' exit -- which touches the PERMANENT kill switch, cleared only by install.sh, never by update.sh -- for the incompatible case. On TrueNAS 26 (providers ok, nested opt-out) both modules go quiet, so the switch would fire and the release that fixed 26 could never re-enable itself. Retirement and incompatibility now take different exits. - A network blip, a re-export, or a conditional def all read as BROKEN. Each is now 'unknown', which changes nothing, rather than evidence strong enough to disable a module. - 'native' outranked BROKEN everywhere but apply.sh, so a TrueNAS that reworded the guard AND reshaped the functions rendered as good news. - compat.py --tree read B2_BLOCK's own 'restic = True' as native support, so the documented way to check a live box lied on every patched machine. - The signature check was a name-subset test. It passed reorders, kw-only conversions, and added required params -- and it had already passed a real bug: restic_backup takes 4 args on 24.10/25.04, and the wrapper forwarded 5. Nested backups have been raising TypeError on those releases the whole time. The wrapper now forwards *args/**kwargs. - release.sh --promote was unreachable: it died if the tag existed, the gate died if it did not. The tests hid it by always tagging first.
This commit is contained in:
@@ -307,3 +307,33 @@ def test_guard_is_relaxed_only_after_traversal_is_installed():
|
||||
src.index("patch_file(crud_py, CRUD_BLOCK)"),
|
||||
]
|
||||
assert order == sorted(order), "crud.py must be patched last"
|
||||
|
||||
|
||||
class TestWrappersDoNotHardcodeStockArity:
|
||||
"""iX changes the tail of these signatures between releases.
|
||||
|
||||
SYNC_BLOCK used to spell out `(middleware, job, cloud_backup, dry_run, rate_limit)`
|
||||
and forward all five. But 24.10 and 25.04 declare only four -- `rate_limit` arrived
|
||||
in 25.10 -- so every nested backup on those two releases raised
|
||||
`TypeError: restic_backup() takes 4 positional arguments but 5 were given`.
|
||||
It shipped broken and nothing noticed, because the compat check at the time only
|
||||
asked whether the parameter NAMES still appeared somewhere in the signature.
|
||||
|
||||
Forwarding *args/**kwargs makes the wrapper indifferent to a trailing parameter
|
||||
being added or dropped, which is the only part iX actually churns.
|
||||
"""
|
||||
|
||||
def test_restic_backup_forwards_rather_than_naming_stock_params(self):
|
||||
block = extract_blocks()["SYNC_BLOCK"]
|
||||
assert "async def restic_backup(middleware, job, cloud_backup, *args, **kwargs)" in block
|
||||
assert "_tc_orig_restic_backup(middleware, job, cloud_backup, *args, **kwargs)" in block
|
||||
|
||||
# Comments stripped: the block's own commentary explains the rate_limit
|
||||
# history, and that must not be mistaken for the code re-declaring it.
|
||||
code = "\n".join(
|
||||
line for line in block.splitlines()
|
||||
if not line.lstrip().startswith("#")
|
||||
)
|
||||
assert "rate_limit" not in code, (
|
||||
"naming a trailing stock parameter re-introduces the arity bug"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Tests for the middlewared compatibility manifest.
|
||||
|
||||
Two failure directions, and they are NOT symmetric:
|
||||
|
||||
* a false **BROKEN** makes a module decline to apply on a box where it works.
|
||||
Worse, if both modules go quiet, apply.sh used to set a PERMANENT kill switch
|
||||
that only install.sh clears -- so a network blip or an innocent refactor could
|
||||
take a working box's B2 backups down until someone noticed by hand.
|
||||
|
||||
* a false **OK** lets the patch inject into middleware it does not fit, which is a
|
||||
broken backup discovered at restore time.
|
||||
|
||||
Both are tested. The `native` verdict gets its own scrutiny because it is the most
|
||||
dangerous thing this file can say -- it means "TrueNAS does this now, retire the
|
||||
module" -- and it rests on nothing more than a substring match.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "tools"))
|
||||
|
||||
import compat # noqa: E402
|
||||
from compat import ( # noqa: E402
|
||||
NESTED,
|
||||
PROVIDERS,
|
||||
Unreadable,
|
||||
check,
|
||||
is_broken,
|
||||
)
|
||||
|
||||
# A middlewared that the patch fits: TrueNAS 25.10 in miniature.
|
||||
GOOD = {
|
||||
"rclone/remote/b2.py": "class B2RcloneRemote(BaseRcloneRemote):\n pass\n",
|
||||
"plugins/cloud_backup/restic.py": (
|
||||
"class ResticConfig:\n cmd: list\n\n"
|
||||
"def get_restic_config(cloud_backup):\n return ResticConfig([], {})\n"
|
||||
),
|
||||
"plugins/cloud/snapshot.py": (
|
||||
'async def create_snapshot(middleware, path, name="x"):\n return "s", "p"\n'
|
||||
),
|
||||
"plugins/cloud/crud.py": (
|
||||
"class CloudTaskServiceMixin:\n"
|
||||
" async def _validate(self, app, verrors, name, data):\n"
|
||||
" verrors.add('x', 'datasets that have no further '\n"
|
||||
" 'nesting')\n"
|
||||
),
|
||||
"plugins/cloud_backup/sync.py": (
|
||||
"async def restic_backup(middleware, job, cloud_backup, dry_run=False, "
|
||||
"rate_limit=None):\n pass\n"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def loader(files):
|
||||
def load(path):
|
||||
if path not in files:
|
||||
return None
|
||||
v = files[path]
|
||||
if isinstance(v, Exception):
|
||||
raise v
|
||||
return v
|
||||
return load
|
||||
|
||||
|
||||
def check_files(files, modules=None):
|
||||
return check(loader(files), modules)
|
||||
|
||||
|
||||
def with_(**overrides):
|
||||
files = dict(GOOD)
|
||||
files.update(overrides)
|
||||
return files
|
||||
|
||||
|
||||
class TestTheBaseline:
|
||||
def test_a_good_tree_is_ok_and_not_native(self):
|
||||
r = check_files(GOOD)
|
||||
for mod in (PROVIDERS, NESTED):
|
||||
assert r[mod]["ok"], r[mod]["problems"]
|
||||
assert not r[mod]["native"]
|
||||
assert not r[mod]["unknown"]
|
||||
|
||||
|
||||
class TestFalseOkWouldBreakBackups:
|
||||
"""The patch calls the originals POSITIONALLY. A name-subset check passed all of
|
||||
these, and each is a TypeError or -- worse -- silently swapped arguments."""
|
||||
|
||||
def test_reordered_parameters_are_broken(self):
|
||||
r = check_files(with_(**{
|
||||
"plugins/cloud/snapshot.py":
|
||||
'async def create_snapshot(name, path, middleware):\n return 1, 2\n',
|
||||
}))
|
||||
assert is_broken(r[NESTED])
|
||||
|
||||
def test_a_keyword_only_conversion_is_broken(self):
|
||||
r = check_files(with_(**{
|
||||
"plugins/cloud/snapshot.py":
|
||||
'async def create_snapshot(middleware, *, path, name="x"):\n return 1, 2\n',
|
||||
}))
|
||||
assert is_broken(r[NESTED])
|
||||
|
||||
def test_a_new_required_parameter_is_broken(self):
|
||||
r = check_files(with_(**{
|
||||
"plugins/cloud/snapshot.py":
|
||||
'async def create_snapshot(middleware, path, name, dataset):\n return 1, 2\n',
|
||||
}))
|
||||
assert is_broken(r[NESTED])
|
||||
|
||||
def test_a_new_optional_parameter_is_fine(self):
|
||||
# The patch simply will not pass it. Refusing here would be false BROKEN.
|
||||
r = check_files(with_(**{
|
||||
"plugins/cloud/snapshot.py":
|
||||
'async def create_snapshot(middleware, path, name="x", quiet=False):\n'
|
||||
" return 1, 2\n",
|
||||
}))
|
||||
assert r[NESTED]["ok"], r[NESTED]["problems"]
|
||||
|
||||
def test_the_master_signature_change_is_caught(self):
|
||||
# iX really did rename this on master: get_restic_config(entry, credentials).
|
||||
# RESTIC_BLOCK rebinds the module-level name to a 1-arg wrapper, so getting
|
||||
# this wrong kills EVERY TrueCloud task -- Storj included.
|
||||
r = check_files(with_(**{
|
||||
"plugins/cloud_backup/restic.py":
|
||||
"class ResticConfig:\n cmd: list\n\n"
|
||||
"def get_restic_config(entry, credentials):\n pass\n",
|
||||
}))
|
||||
assert is_broken(r[PROVIDERS])
|
||||
|
||||
def test_async_to_sync_is_caught(self):
|
||||
# THE TrueNAS 26 change.
|
||||
r = check_files(with_(**{
|
||||
"plugins/cloud/snapshot.py":
|
||||
'def create_snapshot(middleware, path, name="x"):\n return 1, 2\n',
|
||||
}))
|
||||
assert is_broken(r[NESTED])
|
||||
assert "async def" in r[NESTED]["problems"][0]["detail"]
|
||||
|
||||
|
||||
class TestFalseBrokenWouldDisableWorkingBoxes:
|
||||
def test_a_conditionally_defined_symbol_is_not_broken(self):
|
||||
r = check_files(with_(**{
|
||||
"plugins/cloud/snapshot.py":
|
||||
"try:\n"
|
||||
" from .fast import create_snapshot\n"
|
||||
"except ImportError:\n"
|
||||
' async def create_snapshot(middleware, path, name="x"):\n'
|
||||
" return 1, 2\n",
|
||||
}))
|
||||
assert not is_broken(r[NESTED]), r[NESTED]["problems"]
|
||||
|
||||
def test_a_re_exported_symbol_is_unknown_not_broken(self):
|
||||
r = check_files(with_(**{
|
||||
"plugins/cloud_backup/restic.py":
|
||||
"from ._impl import ResticConfig, get_restic_config\n",
|
||||
}))
|
||||
assert not is_broken(r[PROVIDERS])
|
||||
assert r[PROVIDERS]["unknown"]
|
||||
|
||||
def test_an_unreadable_source_is_unknown_not_broken(self):
|
||||
# A rate limit (the matrix makes ~30 unauthenticated requests) must not be
|
||||
# able to say "iX deleted six files, both modules are broken".
|
||||
r = check_files(with_(**{
|
||||
"plugins/cloud/snapshot.py": Unreadable("HTTP 429"),
|
||||
}))
|
||||
assert not is_broken(r[NESTED])
|
||||
assert r[NESTED]["unknown"]
|
||||
|
||||
def test_a_definite_break_still_wins_over_an_unknown(self):
|
||||
r = check_files(with_(**{
|
||||
"plugins/cloud/snapshot.py": Unreadable("HTTP 429"),
|
||||
"plugins/cloud_backup/sync.py":
|
||||
"def restic_backup(middleware, job, cloud_backup, dry_run=False, "
|
||||
"rate_limit=None):\n pass\n",
|
||||
}))
|
||||
assert is_broken(r[NESTED]), "unknown must not launder away a proven break"
|
||||
|
||||
|
||||
class TestTheNativeVerdict:
|
||||
""""native" means "retire the module". It is the most destructive thing this file
|
||||
can say, and it is only a substring match — so it must never outrank BROKEN."""
|
||||
|
||||
def test_broken_outranks_native(self):
|
||||
# Guard reworded (reads as native) AND the signatures changed (really broken).
|
||||
# This used to render as good news: green CI, no bug report, and a README row
|
||||
# telling users the feature went native while it was in fact broken.
|
||||
r = check_files(with_(**{
|
||||
"plugins/cloud/crud.py":
|
||||
"class CloudTaskServiceMixin:\n"
|
||||
" def _validate(self, app, verrors, name, data):\n"
|
||||
" verrors.add('x', 'no children allowed')\n",
|
||||
}))
|
||||
assert r[NESTED]["native"]
|
||||
assert is_broken(r[NESTED])
|
||||
assert compat._verdict(r[NESTED]) == "BROKEN"
|
||||
|
||||
def test_an_already_patched_tree_does_not_read_as_native(self):
|
||||
# B2_BLOCK writes `B2RcloneRemote.restic = True` into b2.py. Scanning the whole
|
||||
# file finds OUR OWN line and concludes TrueNAS went native — so the command
|
||||
# compat.py's docstring recommends for a live box (`--tree /usr/lib/...`)
|
||||
# reported providers as native on every patched machine.
|
||||
r = check_files(with_(**{
|
||||
"rclone/remote/b2.py":
|
||||
"class B2RcloneRemote(BaseRcloneRemote):\n pass\n"
|
||||
"\n# TRUECLOUD_PATCH — added by truenas-truecloud-patch/patch/apply.sh\n"
|
||||
"B2RcloneRemote.restic = True\n",
|
||||
}))
|
||||
assert not r[PROVIDERS]["native"], "read its own patch as native support"
|
||||
assert r[PROVIDERS]["ok"]
|
||||
|
||||
def test_a_genuinely_native_b2_is_native(self):
|
||||
r = check_files(with_(**{
|
||||
"rclone/remote/b2.py":
|
||||
"class B2RcloneRemote(BaseRcloneRemote):\n restic = True\n",
|
||||
}))
|
||||
assert r[PROVIDERS]["native"]
|
||||
|
||||
|
||||
class TestUpdateReadmeCannotPublishAGuess:
|
||||
def test_it_refuses_when_anything_is_unknown(self, tmp_path):
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text(f"x\n{compat.BEGIN}\nold\n{compat.END}\ny\n")
|
||||
rows = [{
|
||||
"ref": "TS-25.10.4", "unreleased": False,
|
||||
"modules": check_files(with_(**{
|
||||
"plugins/cloud/snapshot.py": Unreadable("HTTP 429"),
|
||||
})),
|
||||
}]
|
||||
with pytest.raises(Unreadable):
|
||||
compat.update_readme(rows, path=str(readme))
|
||||
assert "old" in readme.read_text(), "a blip must not repaint the matrix"
|
||||
@@ -65,6 +65,33 @@ def repo(tmp_path):
|
||||
return r
|
||||
|
||||
|
||||
class TestTheBarrierBeforeTheTagExists:
|
||||
"""release.sh calls the gate BEFORE creating the stable tag.
|
||||
|
||||
Every other test here tags first, and that is what let a fatal bug ship green:
|
||||
`check_promotable` began with `commit_for(vX.Y.Z)` and returned "does not exist",
|
||||
while release.sh's own guard refuses to run at all IF the tag exists. The two
|
||||
conditions were mutually exclusive, so `--promote` could never succeed -- the
|
||||
only way to cut a stable release was to hand-tag, bypassing every gate.
|
||||
|
||||
A gate that can only be satisfied after the thing it gates is not a gate.
|
||||
"""
|
||||
|
||||
def test_promote_is_allowed_when_head_was_a_candidate_and_the_tag_is_absent(self, repo):
|
||||
repo.git("tag", "v1.0.0-rc1") # rc on HEAD, no stable tag yet
|
||||
assert check_promotable("v1.0.0", cwd=str(repo)) == []
|
||||
|
||||
def test_promote_is_refused_when_head_was_never_a_candidate(self, repo):
|
||||
assert check_promotable("v1.0.0", cwd=str(repo))
|
||||
|
||||
def test_promote_is_refused_when_head_moved_past_the_candidate(self, repo):
|
||||
repo.git("tag", "v1.0.0-rc1")
|
||||
repo.commit("one more little fix") # HEAD is no longer the candidate
|
||||
problems = check_promotable("v1.0.0", cwd=str(repo))
|
||||
assert problems
|
||||
assert "no release candidate does" in problems[0]
|
||||
|
||||
|
||||
class TestTheBarrier:
|
||||
def test_a_tag_with_no_candidate_is_refused(self, repo):
|
||||
repo.git("tag", "v1.0.0")
|
||||
@@ -109,9 +136,15 @@ class TestTheBarrier:
|
||||
repo.git("tag", "v1.0.0")
|
||||
assert check_promotable("v1.0.0", cwd=str(repo)) == []
|
||||
|
||||
def test_missing_tag_is_reported_not_crashed(self, repo):
|
||||
problems = check_promotable("v9.9.9", cwd=str(repo))
|
||||
assert problems and "does not exist" in problems[0]
|
||||
def test_a_tag_the_numbering_does_not_understand_is_not_a_candidate(self, repo):
|
||||
# `v1.0.0-rc*` also globs `v1.0.0-rc1-hotfix`, which _rc_number reads as 0.
|
||||
# The barrier must be satisfied only by something that really was a candidate.
|
||||
repo.git("tag", "v1.0.0-rc1-hotfix")
|
||||
repo.git("tag", "v1.0.0")
|
||||
problems = check_promotable("v1.0.0", cwd=str(repo))
|
||||
assert problems
|
||||
assert "never a release candidate" in problems[0]
|
||||
assert rc_tags("1.0.0", cwd=str(repo)) == []
|
||||
|
||||
|
||||
class TestRcNumbering:
|
||||
|
||||
Reference in New Issue
Block a user