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>
This commit is contained in:
Tobias KaupatandClaude Sonnet 5.5 authored and GitHub committed 2026-10-20 20:47:03 +02:00
1 parent 31f06b5d9b
commit be7d426f78
21 files changed
+1692 -958

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
"""Run container-structure-test without leaking Docker volumes.
container-structure-test starts one container per test and never removes it. The image
declares `VOLUME /data`, so every container drags an anonymous volume along that outlives
`docker rm`. With `--save` the containers are kept, and this wrapper removes the ones of
the image under test that the run created, together with their volumes (`remove(v=True)`);
containers of other images or of the compat suite (a parallel run, a compose stack) are left
alone.
Run with `task test:structure`.
A run longer than CST_TIMEOUT seconds (default 300) is killed: container-structure-test has
no timeout of its own and can block forever waiting for a container.
Exit code: the one of container-structure-test; 124 on timeout.
"""
from __future__ import annotations
import contextlib
import os
import signal
import subprocess
import sys
from pathlib import Path
import docker
from helpers import raise_interrupt
CONFIG = Path(__file__).with_name("structure.yml")
TIMEOUT_EXIT = 124
def _containers_of(client: docker.DockerClient, image_id: str) -> list:
"""Containers of the image that container-structure-test could have created.
Containers of the compat suite carry the `rbd-test-run` label; their owner removes them.
"""
found = client.containers.list(all=True, filters={"ancestor": image_id})
return [c for c in found if "rbd-test-run" not in c.labels]
def main() -> int:
image = os.environ.get("IMAGE", "restic-backup-docker:test")
signal.signal(signal.SIGTERM, raise_interrupt)
client = docker.from_env()
image_id = client.images.get(image).id
before = {c.id for c in _containers_of(client, image_id)}
try:
timeout = float(os.environ.get("CST_TIMEOUT", "300"))
try:
return subprocess.run(
[
"container-structure-test",
"test",
"--save",
"--image",
image,
"--config",
os.environ.get("CST_CONFIG", str(CONFIG)),
],
check=False,
timeout=timeout,
).returncode
except subprocess.TimeoutExpired:
print(f"container-structure-test timed out after {timeout:g} s", file=sys.stderr)
return TIMEOUT_EXIT
finally:
signal.signal(signal.SIGINT, signal.SIG_IGN)
for container in _containers_of(client, image_id):
if container.id not in before:
with contextlib.suppress(docker.errors.NotFound):
container.remove(v=True, force=True)
signal.signal(signal.SIGINT, signal.default_int_handler)
if __name__ == "__main__":
sys.exit(main())