mirror of
https://github.com/lobaro/restic-backup-docker.git
synced 2026-11-13 15:45:22 +00:00
test: port feature-gated tests to pytest
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8ff93f4709
commit
9c49d6bd01
3 files changed
+258
-21
No files matched your search
+19
-7
@@ -125,12 +125,10 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
|
||||
raise pytest.UsageError(f"unknown feature gate: {marker.args[0]}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _feature_gate(request: pytest.FixtureRequest, detected_features: set[str]) -> None:
|
||||
"""Skip a feature test when the image lacks it; fail when it is required."""
|
||||
for marker in request.node.iter_markers("feature"):
|
||||
def _apply_feature_gate(node: pytest.Item | pytest.Class, detected: set[str]) -> None:
|
||||
for marker in node.iter_markers("feature"):
|
||||
name = marker.args[0]
|
||||
if name in detected_features:
|
||||
if name in detected:
|
||||
continue
|
||||
if name in required_features():
|
||||
pytest.fail(
|
||||
@@ -141,6 +139,18 @@ def _feature_gate(request: pytest.FixtureRequest, detected_features: set[str]) -
|
||||
pytest.skip(f"feature '{name}' not in image yet")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _feature_gate(request: pytest.FixtureRequest, detected_features: set[str]) -> None:
|
||||
"""Skip a feature test when the image lacks it; fail when it is required."""
|
||||
_apply_feature_gate(request.node, detected_features)
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def feature_gate_class(request: pytest.FixtureRequest, detected_features: set[str]) -> None:
|
||||
"""Same gate for class-scoped fixtures: request it first so no container starts when gated."""
|
||||
_apply_feature_gate(request.node, detected_features)
|
||||
|
||||
|
||||
LABELS = {"rbd-test-run": RUN_ID}
|
||||
|
||||
|
||||
@@ -151,9 +161,11 @@ def run_container(
|
||||
"""Factory: `with run_container("name", env={...}) as c:` starts, waits, always removes."""
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _run(suffix: str, env: dict[str, str] | None = None) -> Iterator[Container]:
|
||||
def _run(
|
||||
suffix: str, env: dict[str, str] | None = None, files: dict[str, str] | None = None
|
||||
) -> Iterator[Container]:
|
||||
container = start_container(
|
||||
docker_client, image, f"rbd-test-{suffix}-{RUN_ID}", LABELS, env
|
||||
docker_client, image, f"rbd-test-{suffix}-{RUN_ID}", LABELS, env, files
|
||||
)
|
||||
try:
|
||||
yield container
|
||||
|
||||
+30
-14
@@ -56,16 +56,7 @@ class Container:
|
||||
def write_file(self, path: str, content: str, *, executable: bool = False) -> None:
|
||||
"""Create a file (and its directory) without a bind mount."""
|
||||
self.run("mkdir", "-p", str(PurePosixPath(path).parent))
|
||||
data = content.encode()
|
||||
info = tarfile.TarInfo(PurePosixPath(path).name)
|
||||
info.size = len(data)
|
||||
info.mode = 0o755 if executable else 0o644
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w") as tar:
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
buf.seek(0)
|
||||
if not self.raw.put_archive(str(PurePosixPath(path).parent), buf):
|
||||
raise CommandError(f"could not write {path}")
|
||||
put_file(self.raw, path, content, executable=executable)
|
||||
|
||||
def read_file(self, path: str) -> str:
|
||||
return self.run("cat", path)
|
||||
@@ -103,23 +94,48 @@ class Container:
|
||||
return self.exec("restic", "restore", "latest", "--target", target)
|
||||
|
||||
|
||||
def put_file(raw: DockerContainer, path: str, content: str, *, executable: bool = False) -> None:
|
||||
"""Copy a file into an existing (also not yet started) container via a tar archive."""
|
||||
data = content.encode()
|
||||
info = tarfile.TarInfo(PurePosixPath(path).name)
|
||||
info.size = len(data)
|
||||
info.mode = 0o755 if executable else 0o644
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w") as tar:
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
buf.seek(0)
|
||||
if not raw.put_archive(str(PurePosixPath(path).parent), buf):
|
||||
raise CommandError(f"could not write {path}")
|
||||
|
||||
|
||||
def start_container(
|
||||
client: docker.DockerClient,
|
||||
image: str,
|
||||
name: str,
|
||||
labels: dict[str, str],
|
||||
env: dict[str, str] | None = None,
|
||||
files: dict[str, str] | None = None,
|
||||
) -> Container:
|
||||
"""Start the image with a cron far in the future, so only explicit backups run."""
|
||||
"""Start the image with a cron far in the future, so only explicit backups run.
|
||||
|
||||
`files` (path in container -> content) are placed before entry.sh runs, without a
|
||||
bind mount, so tests can cover what the entrypoint does with them at startup.
|
||||
"""
|
||||
environment = {
|
||||
"RESTIC_PASSWORD": "testpassword",
|
||||
"RESTIC_TAG": "testtag",
|
||||
"BACKUP_CRON": "0 0 31 2 *",
|
||||
**(env or {}),
|
||||
}
|
||||
raw = client.containers.run(
|
||||
image, detach=True, name=name, environment=environment, labels=labels
|
||||
)
|
||||
if files:
|
||||
raw = client.containers.create(image, name=name, environment=environment, labels=labels)
|
||||
for path, content in files.items():
|
||||
put_file(raw, path, content)
|
||||
raw.start()
|
||||
else:
|
||||
raw = client.containers.run(
|
||||
image, detach=True, name=name, environment=environment, labels=labels
|
||||
)
|
||||
deadline = time.monotonic() + START_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
log = raw.logs().decode(errors="replace")
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Feature-gated tests, ported from test/features.sh.
|
||||
|
||||
Each class is guarded by `@pytest.mark.feature(<gate>)`: it runs as soon as the built
|
||||
image ships the feature (detected in conftest.py) and is skipped before that. A gate
|
||||
listed in tests/required-features.txt turns the skip into a failure. Each docstring is
|
||||
the name of the matching Bash test.
|
||||
|
||||
Scenario fixtures request `feature_gate_class` first, so a gated class never starts a
|
||||
container on an image without the feature.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from conftest import LABELS
|
||||
from helpers import Container, run_once
|
||||
|
||||
STREAM_ENV = {
|
||||
"RESTIC_STREAM": "1",
|
||||
"RESTIC_STREAM_CMD": "echo hello-stream",
|
||||
"RESTIC_STREAM_FILENAME": "stream.txt",
|
||||
}
|
||||
BASH_HOOK = """#!/bin/bash
|
||||
arr=(one two)
|
||||
if [[ "${arr[1]}" == "two" ]]; then echo "BASH-HOOK-RAN"; fi
|
||||
"""
|
||||
MSMTP_CONFIG = "defaults\naccount default\nhost localhost\n"
|
||||
|
||||
|
||||
def _running(container: Container) -> Container:
|
||||
if not container.started:
|
||||
pytest.fail(f"container did not start:\n{container.start_log}", pytrace=False)
|
||||
return container
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stream backup (PR #109)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def stream_run(feature_gate_class, run_container) -> Iterator[SimpleNamespace]:
|
||||
with run_container("stream", env=STREAM_ENV) as c:
|
||||
_running(c)
|
||||
run = SimpleNamespace(container=c)
|
||||
run.backup = c.exec("/bin/backup")
|
||||
run.snapshots = c.snapshots()
|
||||
c.exec("restic", "restore", "latest", "--target", "/tmp/restore")
|
||||
run.restored = c.exec("cat", "/tmp/restore/stream.txt")
|
||||
yield run
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def stream_jobargs_run(feature_gate_class, run_container) -> Iterator[SimpleNamespace]:
|
||||
# --host is used because it is visible in the snapshot metadata of a stdin backup.
|
||||
with run_container(
|
||||
"stream-jobargs", env={**STREAM_ENV, "RESTIC_JOB_ARGS": "--host=jobargshost"}
|
||||
) as c:
|
||||
_running(c)
|
||||
c.exec("/bin/backup")
|
||||
# restic's own --host filter only returns snapshots whose hostname equals the
|
||||
# value; both directions are checked so the filter is known to discriminate.
|
||||
yield SimpleNamespace(
|
||||
matching=c.restic_json("snapshots", "--host", "jobargshost") or [],
|
||||
unrelated=c.restic_json("snapshots", "--host", "some-other-host") or [],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def stream_misconfigured_run(feature_gate_class, run_container) -> Iterator[SimpleNamespace]:
|
||||
with run_container("stream-misconfigured", env={"RESTIC_STREAM": "1"}) as c:
|
||||
_running(c)
|
||||
backup = c.exec("/bin/backup")
|
||||
yield SimpleNamespace(backup=backup, snapshots=c.snapshots())
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def stream_disabled_run(feature_gate_class, run_container) -> Iterator[SimpleNamespace]:
|
||||
with run_container("stream-disabled") as c:
|
||||
_running(c)
|
||||
c.write_file("/data/hello.txt", "backup me")
|
||||
c.exec("/bin/backup")
|
||||
yield SimpleNamespace(snapshots=c.snapshots())
|
||||
|
||||
|
||||
@pytest.mark.feature("stream")
|
||||
class TestStream:
|
||||
def test_stream_backup_succeeds(self, stream_run):
|
||||
"""stream backup succeeds"""
|
||||
assert "Backup Successful" in stream_run.backup.out
|
||||
|
||||
def test_stream_backup_creates_one_snapshot(self, stream_run):
|
||||
"""stream backup creates exactly one snapshot"""
|
||||
assert len(stream_run.snapshots) == 1
|
||||
|
||||
def test_stream_content_stored_under_filename(self, stream_run):
|
||||
"""stream content is stored under RESTIC_STREAM_FILENAME"""
|
||||
assert stream_run.restored.rc == 0, stream_run.restored.out
|
||||
assert stream_run.restored.out == "hello-stream\n"
|
||||
|
||||
def test_job_args_set_host_of_stream_snapshot(self, stream_jobargs_run):
|
||||
"""RESTIC_JOB_ARGS sets the host of the stream backup snapshot"""
|
||||
assert len(stream_jobargs_run.matching) == 1
|
||||
|
||||
def test_host_filter_does_not_match_unrelated_host(self, stream_jobargs_run):
|
||||
"""the host filter does not match an unrelated hostname"""
|
||||
assert stream_jobargs_run.unrelated == []
|
||||
|
||||
def test_incomplete_stream_config_does_not_report_success(self, stream_misconfigured_run):
|
||||
"""incomplete stream configuration does not report success"""
|
||||
assert "Backup Successful" not in stream_misconfigured_run.backup.out
|
||||
|
||||
def test_incomplete_stream_config_creates_no_snapshot(self, stream_misconfigured_run):
|
||||
"""incomplete stream configuration creates no snapshot"""
|
||||
assert stream_misconfigured_run.snapshots == []
|
||||
|
||||
def test_file_backup_still_works_with_stream_feature(self, stream_disabled_run):
|
||||
"""file backup still works while the stream feature is present"""
|
||||
assert len(stream_disabled_run.snapshots) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# docker cli (PR #99)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.feature("docker-cli")
|
||||
def test_docker_cli_usable(docker_client, image):
|
||||
"""docker cli is usable inside the image"""
|
||||
out = run_once(
|
||||
docker_client, image, entrypoint=["docker"], command=["--version"], labels=LABELS
|
||||
)
|
||||
assert "Docker version" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bash in hooks (PR #99)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def bash_hook_run(feature_gate_class, run_container) -> Iterator[SimpleNamespace]:
|
||||
with run_container("bash-hooks") as c:
|
||||
_running(c)
|
||||
c.write_file("/hooks/pre-backup.sh", BASH_HOOK, executable=True)
|
||||
c.write_file("/data/hello.txt", "backup me")
|
||||
yield SimpleNamespace(backup=c.exec("/bin/backup"))
|
||||
|
||||
|
||||
@pytest.mark.feature("bash")
|
||||
class TestBashHooks:
|
||||
def test_bash_hook_runs(self, bash_hook_run):
|
||||
"""a hook with a bash shebang and bash syntax runs"""
|
||||
assert "BASH-HOOK-RAN" in bash_hook_run.backup.out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# msmtp mail relay (PR #84)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def msmtp_config_run(feature_gate_class, run_container) -> Iterator[Container]:
|
||||
# entry.sh copies the config at startup, so it must exist before the container starts.
|
||||
with run_container("msmtp-config", files={"/config/msmtprc": MSMTP_CONFIG}) as c:
|
||||
yield _running(c)
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def msmtp_missing_config_run(feature_gate_class, run_container) -> Iterator[SimpleNamespace]:
|
||||
# Users who configured mail before msmtp existed must not lose their backups.
|
||||
env = {"MAILX_ARGS": "-r from@example.com -s subject to@example.com"}
|
||||
with run_container("msmtp-missing-config", env=env) as c:
|
||||
_running(c)
|
||||
c.write_file("/data/hello.txt", "backup me")
|
||||
backup = c.exec("/bin/backup")
|
||||
yield SimpleNamespace(backup=backup, snapshots=c.snapshots())
|
||||
|
||||
|
||||
@pytest.mark.feature("msmtp")
|
||||
class TestMsmtp:
|
||||
def test_sendmail_is_msmtp(self, docker_client, image):
|
||||
"""sendmail is provided by msmtp"""
|
||||
target = run_once(
|
||||
docker_client,
|
||||
image,
|
||||
entrypoint=["sh", "-c", "readlink -f /usr/sbin/sendmail"],
|
||||
labels=LABELS,
|
||||
)
|
||||
assert "msmtp" in target
|
||||
|
||||
def test_config_copied_to_etc(self, msmtp_config_run):
|
||||
"""msmtp config from /config is copied to /etc/msmtprc"""
|
||||
assert msmtp_config_run.file_exists("/etc/msmtprc")
|
||||
|
||||
def test_copied_config_not_world_readable(self, msmtp_config_run):
|
||||
"""copied msmtp config is not world readable"""
|
||||
assert msmtp_config_run.run("stat", "-c", "%a", "/etc/msmtprc").strip() == "600"
|
||||
|
||||
def test_backup_succeeds_without_msmtp_config(self, msmtp_missing_config_run):
|
||||
"""backup succeeds with MAILX_ARGS set but no msmtp config"""
|
||||
assert "Backup Successful" in msmtp_missing_config_run.backup.out
|
||||
|
||||
def test_snapshot_created_without_msmtp_config(self, msmtp_missing_config_run):
|
||||
"""snapshot is created even when the mail relay is unconfigured"""
|
||||
assert len(msmtp_missing_config_run.snapshots) == 1
|
||||
Reference in new issue
Block a user