Files
restic-backup-docker/tests/sabotage.py
T
Tobias KaupatandClaude Sonnet 5.5 be7d426f78 test: move container test suites to pytest (#112)
* test: scaffold pytest suite with mise, uv and Task

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* ci: run the pytest suite via mise and task

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix: make test cleanup survive repeated SIGINT

A group Ctrl-C reaches pytest a second time via task and uv while the
session fixture tears down, aborting the volume cleanup. Ignore SIGINT
and SIGTERM during cleanup, remove volumes in finally, and tolerate
already-removed objects.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix: pin Python 3.13 for uv without mise

Add .python-version (read by mise and uv) and cap requires-python at
<3.14 so bare uv uses 3.13 or fails instead of picking 3.14.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix: clean up test containers on SIGTERM

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test: port compat tests to pytest

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test: assert the success message first in the corruption test

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test: assert the empty-tag snapshot carries no tags

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test: remove anonymous volumes with short-lived test containers

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test: port feature-gated tests to pytest

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test: port sabotage self-test to task test:sabotage

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix: create parent directories in put_file

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test: move structure.yml to tests and remove cst volume leak

container-structure-test left one anonymous volume per test container.
tests/structure.py runs it with --save and removes the new containers
with their volumes afterwards.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test: remove the Bash suite

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* ci: run only the Python suite in the test job

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs: describe the Python test suite

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test: isolate scenario steps, share helpers, use restore_latest, HEAD dir probe

Scenario fixtures record each step; a failing step fails only the tests
that assert on it. running() and raise_interrupt() live in helpers.py.
put_file probes parent directories with a HEAD request instead of a
get_archive stream. stream_run uses restore_latest and asserts its result.
Docstrings no longer refer to the removed Bash suite.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test: sabotage check rejects errors and malformed reports

Only <failure> of the expected tests counts as red; errors in them and a
malformed JUnit report exit 2 (invalid) instead of passing or exiting 1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test: structure wrapper removes only its own containers

Restrict cleanup to containers of the image under test without the
rbd-test-run label and ignore NotFound.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* build: run all test suites and aggregate the exit code

task test no longer stops at the first failing suite.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix: sabotage check treats failed scenario steps as INVALID

StepFailed is no AssertionError any more; sabotage.py counts a JUnit failure
named StepFailed as error, so an expected-red test only counts when its own
assertion fails.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix: hard timeout for container-structure-test

CST_TIMEOUT (default 300 s) kills a hanging run; existing cleanup removes the
containers; exit code 124 with a clear message.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix: shorten sabotage message for lint

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix: handle JUnit failures without message in sabotage check

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:47:03 +02:00

147 lines
5.2 KiB
Python

"""Self test of the suite: prove that it goes red when the backup is broken.
Run with `task test:sabotage`.
A throwaway copy of the tree gets a backup.sh whose `restic backup` call is a no-op, the
nastiest kind of regression (the script still reports success). The compat tests run
against an image built from that copy. The check passes only if the assertions about the
backup itself fail by name. Red for another reason (an image that cannot start, a
non-executable /bin/backup) proves nothing and is reported as INVALID.
Exit codes: 0 sabotage detected, 1 suite did not detect it, 2 check invalid or broken.
Tracked files are never modified.
"""
from __future__ import annotations
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import uuid
import xml.etree.ElementTree as ET
from pathlib import Path
from helpers import raise_interrupt
ROOT = Path(__file__).resolve().parent.parent
COPIED = ("Dockerfile", "backup.sh", "check.sh", "entry.sh")
# Tests about the backup itself; each must fail on the sabotaged image.
EXPECTED_RED = (
"test_one_snapshot_after_one_backup",
"test_second_backup_adds_snapshot",
)
STEP_FAILED = "StepFailed" # name of helpers.StepFailed in the JUnit report
class Invalid(Exception):
"""The check itself is broken; the suite result means nothing."""
def sabotage_tree(tmp: Path) -> None:
for name in COPIED:
shutil.copy2(ROOT / name, tmp / name)
script = tmp / "backup.sh"
lines = script.read_text().splitlines(keepends=True)
patched = [
("true " + ln[len("restic backup ") :]) if ln.startswith("restic backup ") else ln
for ln in lines
]
if patched == lines:
raise Invalid("sabotage patch did not apply, backup.sh changed shape")
script.write_text("".join(patched))
# Dockerfile COPY has no chmod: the image only works when the file is executable.
script.chmod(script.stat().st_mode | 0o111)
if not os.access(script, os.X_OK):
raise Invalid("sabotaged backup.sh is not executable")
def red_tests(report: Path) -> tuple[set[str], set[str]]:
"""Names of tests with an assertion failure, and of tests with an error.
Errors are setup errors and failed scenario steps (StepFailed): neither is an assertion
about the behaviour under test.
"""
try:
cases = list(ET.parse(report).getroot().iter("testcase"))
except ET.ParseError as exc:
raise Invalid(f"pytest report is malformed or truncated: {exc}") from exc
errors = {c.get("name", "") for c in cases if c.find("error") is not None}
failures = set()
for case in cases:
failure = case.find("failure")
if failure is None:
continue
if STEP_FAILED in (failure.get("message") or "") or STEP_FAILED in (failure.text or ""):
errors.add(case.get("name", ""))
else:
failures.add(case.get("name", ""))
return failures, errors
def main() -> int:
tag = f"rbd-sabotage:{uuid.uuid4().hex[:8]}"
tmp = Path(tempfile.mkdtemp(prefix="rbd-sabotage-"))
try:
sabotage_tree(tmp)
print(f">>> building {tag} from a sabotaged copy")
build = subprocess.run(["docker", "build", "--quiet", "-t", tag, str(tmp)])
if build.returncode != 0:
raise Invalid("image build failed")
print(">>> running the compat tests against the sabotaged image (failure expected)")
report = tmp / "report.xml"
proc = subprocess.run(
[
sys.executable,
"-m",
"pytest",
"tests/test_compat.py",
"-p",
"no:cacheprovider",
f"--junitxml={report}",
],
cwd=ROOT,
env={**os.environ, "IMAGE": tag},
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
print(proc.stdout)
if proc.returncode == 0:
print(
"SABOTAGE CHECK FAILED: the compat tests passed on a broken image", file=sys.stderr
)
return 1
if "permission denied" in proc.stdout.lower():
raise Invalid("log contains 'permission denied', suite failed for the wrong reason")
if not report.is_file():
raise Invalid(f"pytest produced no report (exit code {proc.returncode})")
failures, errors = red_tests(report)
broken = [name for name in EXPECTED_RED if name in errors]
if broken:
raise Invalid(f"expected tests hit an error or failed step, not an assertion: {broken}")
missing = [name for name in EXPECTED_RED if name not in failures]
for name in missing:
print(
f"SABOTAGE CHECK FAILED: expected assertion did not fail: {name}", file=sys.stderr
)
if missing:
return 1
print("\nsabotage check ok: the compat tests detect a broken backup")
return 0
except Invalid as exc:
print(f"SABOTAGE CHECK INVALID: {exc}", file=sys.stderr)
return 2
finally:
shutil.rmtree(tmp, ignore_errors=True)
subprocess.run(["docker", "rmi", "-f", tag], capture_output=True)
if __name__ == "__main__":
signal.signal(signal.SIGTERM, raise_interrupt)
sys.exit(main())