The test suite is not bloated -- 3,701 lines of test code against 3,760 lines of product code, one duplicate pair in 356 tests, 8% single-assertion tests. The waste was not volume, it was four tests that looked like coverage and provided none: - test_parents_are_mounted_before_children: the fixture was already in depth order, so the sort it exists for was never exercised. Deleting `mounts.sort(key=_depth)` passed the whole suite. It now uses datasets whose NAME order differs from their MOUNTPOINT depth, which is the only case the sort is for. - test_it_NEVER_touches_the_current_run: the "current" snapshot was a minute old, so the age floor excluded it regardless and the same-snapname guard never ran. That guard only matters for a run that OUTLIVES the floor -- which a first full upload easily does, and where collecting it would yank the snapshot out from under a backup that is still reading from it. Now tested with a 12-hour-old current run. - the fingerprint/unknown test put the unreadable file in `providers`, which is not broken -- so fingerprint() skipped the whole module via is_broken() and the filter under test never executed. The blip has to land in the module that IS broken. - test_the_real_truenas_versions was a byte-identical copy of test_async_middleware_is_detected under a name promising more. Replaced with the fact actually worth pinning: the flavour probe must read STOCK source, because apply.sh re-runs on an already-patched overlay and our own SNAPSHOT_SYNC block is a plain `def create_snapshot` -- reading it would report a 25.10 box as synchronous and inject the wrong wrapper. All four now fail when the code they name is broken.
577 lines
24 KiB
Python
577 lines
24 KiB
Python
"""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"
|
|
),
|
|
# 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.
|
|
# Not a plugin: a method on the middleware OBJECT. `snapshot_service()` resolves
|
|
# the snapshot namespace through it, so if it vanishes the module cannot sweep the
|
|
# snapshot it just took.
|
|
"utils/plugins.py": (
|
|
"class LoadPluginsMixin:\n"
|
|
" def get_service(self, name):\n pass\n"
|
|
),
|
|
"plugins/pool_/dataset.py": (
|
|
"class PoolDatasetService(CRUDService):\n"
|
|
" class Config:\n"
|
|
" namespace = 'pool.dataset'\n"
|
|
" def query(self, filters, options):\n pass\n"
|
|
),
|
|
"plugins/pool_/snapshot.py": (
|
|
"class PoolSnapshotService(CRUDService):\n"
|
|
" class Config:\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):
|
|
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_a_vanished_symbol_is_broken(self):
|
|
r = check_files(with_(**{
|
|
"plugins/cloud/snapshot.py": "def something_else():\n pass\n",
|
|
}))
|
|
assert is_broken(r[NESTED])
|
|
|
|
|
|
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":
|
|
"async def restic_backup(job, middleware, cloud_backup):\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"
|
|
" async def _validate(self, verrors, name):\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"
|
|
|
|
|
|
class TestAsyncFlavour:
|
|
"""TrueNAS <= 25.10 is async; 26 is synchronous. Both are supported -- apply.sh
|
|
injects the wrapper that matches. So asyncness is DETECTED, never assumed."""
|
|
|
|
def test_async_middleware_is_detected(self):
|
|
assert compat.async_flavour(loader(GOOD)) is True
|
|
|
|
def test_sync_middleware_is_detected(self):
|
|
sync = dict(GOOD)
|
|
sync["plugins/cloud/snapshot.py"] = (
|
|
'def create_snapshot(middleware, path, name="x"):\n return "s", "p"\n'
|
|
)
|
|
sync["plugins/cloud/crud.py"] = (
|
|
"class CloudTaskServiceMixin:\n"
|
|
" def _validate(self, app, verrors, name, data):\n"
|
|
" verrors.add('x', 'no further nesting')\n"
|
|
)
|
|
sync["plugins/cloud_backup/sync.py"] = (
|
|
"def restic_backup(middleware, job, cloud_backup, dry_run=False, "
|
|
"rate_limit=None):\n pass\n"
|
|
)
|
|
assert compat.async_flavour(loader(sync)) is False
|
|
|
|
def test_a_HALF_converted_middleware_is_refused(self):
|
|
# The dangerous middle. If iX converts create_snapshot but not restic_backup,
|
|
# there is no single wrapper flavour that works -- and guessing means either
|
|
# a coroutine unpacked as a tuple, or the event loop blocked. None means
|
|
# "do not patch"; apply.sh turns that into a skip, not a guess.
|
|
half = dict(GOOD)
|
|
half["plugins/cloud/snapshot.py"] = (
|
|
'def create_snapshot(middleware, path, name="x"):\n return "s", "p"\n'
|
|
)
|
|
assert compat.async_flavour(loader(half)) is None
|
|
|
|
def test_an_unreadable_source_refuses_rather_than_guesses(self):
|
|
broken = dict(GOOD)
|
|
broken["plugins/cloud_backup/sync.py"] = Unreadable("HTTP 429")
|
|
assert compat.async_flavour(loader(broken)) is None
|
|
|
|
def test_it_reads_STOCK_source_not_our_own_injected_block(self):
|
|
# This was a byte-identical copy of test_async_middleware_is_detected under a
|
|
# name that promised more. The fact worth pinning: apply.sh re-runs on an
|
|
# ALREADY-PATCHED overlay, so the probe must cut our block off first -- our own
|
|
# SNAPSHOT_SYNC wrapper is a plain `def create_snapshot`, and reading it would
|
|
# report a 25.10 box as synchronous and inject the wrong flavour.
|
|
patched = dict(GOOD)
|
|
patched["plugins/cloud/snapshot.py"] = (
|
|
GOOD["plugins/cloud/snapshot.py"]
|
|
+ "\n# TRUECLOUD_PATCH\n"
|
|
+ 'def create_snapshot(middleware, path, name="x"):\n return "s", "p"\n'
|
|
)
|
|
assert compat.async_flavour(loader(patched)) is True, (
|
|
"the flavour probe read our own injected block and concluded the box is "
|
|
"synchronous -- it would then inject a sync wrapper into an async "
|
|
"middleware, and every nested backup would break"
|
|
)
|
|
|
|
|
|
class TestMiddlewareMethodsWeCall:
|
|
"""The assumption class that was MISSING, and that hid a catastrophic break.
|
|
|
|
The manifest recorded the symbols the patch WRAPS. It said nothing about the
|
|
middlewared methods the patch CALLS -- and TrueNAS 26 deleted
|
|
plugins/zfs_/dataset.py and plugins/zfs_/snapshot.py outright, taking
|
|
`zfs.dataset.query`, `zfs.snapshot.query` and `zfs.snapshot.delete` with them.
|
|
|
|
Nothing about the five cloud_backup files reveals that. The patch applied
|
|
perfectly and every other check went green. The first backup would have failed --
|
|
or, far worse, snapshotted fine and then failed to DELETE, orphaning one snapshot
|
|
per descendant dataset (250 on a real pool) on every run, forever.
|
|
"""
|
|
|
|
POOL_SNAPSHOT = GOOD["plugins/pool_/snapshot.py"]
|
|
|
|
def _tree(self, **over):
|
|
files = dict(GOOD)
|
|
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._modern())
|
|
assert r[NESTED]["ok"], r[NESTED]["problems"]
|
|
|
|
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 "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/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):
|
|
# 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"]
|
|
|
|
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/pool_/snapshot.py": None,
|
|
"plugins/zfs_/snapshot.py": None,
|
|
}))
|
|
whys = " ".join(p["why"] for p in r[NESTED]["problems"])
|
|
assert "orphan" in whys
|
|
|
|
|
|
class TestTheMethodCheckIsNotJustANamespaceCheck:
|
|
"""compat must verify the METHOD, not merely that the namespace still exists.
|
|
|
|
Deleting the method check entirely used to leave all 304 tests green -- so the
|
|
"namespace AND method" claim was unenforced and silently revertible. It is the
|
|
half of the predicate that catches iX gutting a method while keeping its service,
|
|
which they have already done to `pool.snapshot.do_update` on master.
|
|
"""
|
|
|
|
def test_a_namespace_that_no_longer_defines_delete_is_broken(self):
|
|
gutted = (
|
|
"class PoolSnapshotService(CRUDService):\n"
|
|
" class Config:\n"
|
|
" namespace = 'pool.snapshot'\n"
|
|
" def query(self, filters, options):\n pass\n"
|
|
# do_delete is GONE -- the service is still registered and still a
|
|
# CRUDService, so it still INHERITS a callable `delete`.
|
|
)
|
|
r = check_files(with_(**{
|
|
"plugins/pool_/snapshot.py": gutted,
|
|
"plugins/zfs_/snapshot.py": None, # no fallback either
|
|
}))
|
|
assert is_broken(r[NESTED]), (
|
|
"a namespace with no delete must be BROKEN. Checking only that the "
|
|
"namespace exists would apply the patch to a box that cannot sweep its "
|
|
"own snapshots."
|
|
)
|
|
|
|
def test_the_alternative_still_saves_it_when_only_the_primary_is_gutted(self):
|
|
gutted = (
|
|
"class PoolSnapshotService(CRUDService):\n"
|
|
" class Config:\n"
|
|
" namespace = 'pool.snapshot'\n"
|
|
" def query(self, filters, options):\n pass\n"
|
|
)
|
|
r = check_files(with_(**{
|
|
"plugins/pool_/snapshot.py": gutted,
|
|
"plugins/zfs_/snapshot.py": ZFS_ERA_SNAPSHOT,
|
|
}))
|
|
assert r[NESTED]["ok"], r[NESTED]["problems"]
|
|
|
|
|
|
class TestUnreadableIsNeverOkAndNeverBroken:
|
|
"""A rate limit is not a regression, and it is not a clean bill of health either.
|
|
|
|
compat runs ~30 unauthenticated GitHub requests per matrix; 429 is a real outcome.
|
|
It also runs at BOOT against the installed tree, where a read can fail with EACCES.
|
|
|
|
* treating unreadable as BROKEN repaints the README, files a bug report, and
|
|
makes apply.sh refuse the module on a box where it works.
|
|
* treating it as OK injects a module whose delete may be gone.
|
|
|
|
Both mutations used to pass the whole suite.
|
|
"""
|
|
|
|
def test_both_spellings_unreadable_is_unknown_not_broken(self):
|
|
r = check_files(with_(**{
|
|
"plugins/pool_/snapshot.py": Unreadable("HTTP 429"),
|
|
"plugins/zfs_/snapshot.py": Unreadable("HTTP 429"),
|
|
}))
|
|
assert not is_broken(r[NESTED]), "a 429 is not iX deleting the snapshot service"
|
|
assert r[NESTED]["unknown"]
|
|
|
|
def test_an_unreadable_primary_with_a_healthy_alternative_is_ok(self):
|
|
r = check_files(with_(**{
|
|
"plugins/pool_/snapshot.py": Unreadable("HTTP 429"),
|
|
"plugins/zfs_/snapshot.py": ZFS_ERA_SNAPSHOT,
|
|
}))
|
|
assert r[NESTED]["ok"], r[NESTED]["problems"]
|
|
assert not r[NESTED]["unknown"], (
|
|
"one spelling answered the question; the other's 429 is irrelevant"
|
|
)
|
|
|
|
def test_a_missing_primary_with_an_unreadable_alternative_is_unknown(self):
|
|
# We cannot tell whether the box is broken. Saying either would be a guess.
|
|
r = check_files(with_(**{
|
|
"plugins/pool_/snapshot.py": None,
|
|
"plugins/zfs_/snapshot.py": Unreadable("HTTP 429"),
|
|
}))
|
|
assert not is_broken(r[NESTED])
|
|
assert r[NESTED]["unknown"]
|
|
|
|
|
|
class TestGetServiceIsChecked:
|
|
"""The runtime resolves the snapshot namespace through `middleware.get_service`.
|
|
|
|
It is not a plugin method, so the manifest had no way to express it and never
|
|
checked it. If it vanishes, `_can_delete` reports BOTH namespaces unusable and
|
|
every nested backup fails -- on a box the preflight had declared healthy.
|
|
"""
|
|
|
|
def test_a_middleware_without_get_service_is_broken(self):
|
|
r = check_files(with_(**{"utils/plugins.py": None}))
|
|
assert is_broken(r[NESTED])
|
|
details = " ".join(p["detail"] for p in r[NESTED]["problems"])
|
|
assert "get_service" in details
|
|
|
|
|
|
class TestATransientNetworkBlipDoesNotWakeAnybody:
|
|
"""The fingerprint must digest what iX BROKE, not what GitHub failed to serve.
|
|
|
|
`unknown` problems (a 429 on one of ~30 unauthenticated fetches, an EACCES at boot)
|
|
used to be folded into an already-broken module's problem list, so one blip flipped
|
|
the fingerprint, `compat_publish` rewrote the issue body, and the next clean run
|
|
rewrote it back. Daily churn is what teaches people to ignore the bot -- which is
|
|
the whole thing this fingerprint exists to prevent.
|
|
"""
|
|
|
|
def _rows(self, files):
|
|
return [{"ref": "master", "modules": check_files(files)}]
|
|
|
|
def test_an_unreadable_file_does_not_change_the_fingerprint_of_a_broken_ref(self):
|
|
# The blip must land in the SAME module that is broken. Put it in `providers`
|
|
# (which is healthy) and `fingerprint()` skips the whole module via
|
|
# `is_broken(m)` -- so the `state` filter under test never runs and the test
|
|
# passes no matter what the code does. `nested` is the broken one here, so the
|
|
# unreadable file goes in `nested` too.
|
|
broken = with_(**{
|
|
"plugins/cloud/snapshot.py":
|
|
"async def create_snapshot(name, path, middleware):\n return 1, 2\n",
|
|
})
|
|
clean = compat.fingerprint(self._rows(broken))
|
|
|
|
blipped = dict(broken)
|
|
blipped["plugins/cloud_backup/sync.py"] = Unreadable("HTTP 429") # nested
|
|
assert compat.fingerprint(self._rows(blipped)) == clean, (
|
|
"a rate-limited fetch changed the fingerprint, so the bot rewrites the "
|
|
"issue body and then rewrites it back tomorrow"
|
|
)
|
|
|
|
def test_a_REAL_new_finding_still_changes_it(self):
|
|
# ...and the anti-noise measure must not have made it deaf.
|
|
broken = with_(**{
|
|
"plugins/cloud/snapshot.py":
|
|
"async def create_snapshot(name, path, middleware):\n return 1, 2\n",
|
|
})
|
|
worse = dict(broken)
|
|
worse["plugins/cloud_backup/restic.py"] = (
|
|
"class ResticConfig:\n cmd: list\n\n"
|
|
"def get_restic_config(entry, credentials):\n pass\n"
|
|
)
|
|
assert compat.fingerprint(self._rows(worse)) != compat.fingerprint(self._rows(broken))
|