From be7d426f788b9904887e2ed7382c944034de4f29 Mon Sep 17 00:00:00 2001 From: Tobias Kaupat Date: Tue, 20 Oct 2026 20:47:03 +0200 Subject: [PATCH] test: move container test suites to pytest (#112) * test: scaffold pytest suite with mise, uv and Task Co-Authored-By: Claude Sonnet 5.5 * ci: run the pytest suite via mise and task Co-Authored-By: Claude Sonnet 5.5 * 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 * 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 * fix: clean up test containers on SIGTERM Co-Authored-By: Claude Sonnet 5.5 * test: port compat tests to pytest Co-Authored-By: Claude Sonnet 5.5 * test: assert the success message first in the corruption test Co-Authored-By: Claude Sonnet 5.5 * test: assert the empty-tag snapshot carries no tags Co-Authored-By: Claude Sonnet 5.5 * test: remove anonymous volumes with short-lived test containers Co-Authored-By: Claude Sonnet 5.5 * test: port feature-gated tests to pytest Co-Authored-By: Claude Sonnet 5.5 * test: port sabotage self-test to task test:sabotage Co-Authored-By: Claude Sonnet 5.5 * fix: create parent directories in put_file Co-Authored-By: Claude Sonnet 5.5 * 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 * test: remove the Bash suite Co-Authored-By: Claude Sonnet 5.5 * ci: run only the Python suite in the test job Co-Authored-By: Claude Sonnet 5.5 * docs: describe the Python test suite Co-Authored-By: Claude Sonnet 5.5 * 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 * test: sabotage check rejects errors and malformed reports Only 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 * 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 * 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 * 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 * 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 * fix: shorten sabotage message for lint Co-Authored-By: Claude Sonnet 5.5 * fix: handle JUnit failures without message in sabotage check Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 --- .github/workflows/test.yml | 23 +- .gitignore | 4 + .python-version | 1 + CHANGELOG.md | 4 +- README.md | 52 +-- Taskfile.yml | 55 +++ mise.toml | 8 + pyproject.toml | 32 ++ test/compat.sh | 275 --------------- test/features.sh | 172 ---------- test/lib.sh | 287 ---------------- test/run.sh | 183 ---------- tests/conftest.py | 171 ++++++++++ tests/helpers.py | 253 ++++++++++++++ {test => tests}/required-features.txt | 3 +- tests/sabotage.py | 146 ++++++++ tests/structure.py | 77 +++++ {test => tests}/structure.yml | 0 tests/test_compat.py | 469 ++++++++++++++++++++++++++ tests/test_features.py | 213 ++++++++++++ uv.lock | 222 ++++++++++++ 21 files changed, 1692 insertions(+), 958 deletions(-) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 Taskfile.yml create mode 100644 mise.toml create mode 100644 pyproject.toml delete mode 100644 test/compat.sh delete mode 100644 test/features.sh delete mode 100644 test/lib.sh delete mode 100644 test/run.sh create mode 100644 tests/conftest.py create mode 100644 tests/helpers.py rename {test => tests}/required-features.txt (87%) create mode 100644 tests/sabotage.py create mode 100644 tests/structure.py rename {test => tests}/structure.yml (100%) create mode 100644 tests/test_compat.py create mode 100644 tests/test_features.py create mode 100644 uv.lock diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b528dff..0b757f7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,10 +28,6 @@ jobs: sudo apt-get update -qq sudo apt-get install -y -qq shellcheck - # The test scripts are new, so they are held to a clean bill of health. - - name: Run shellcheck on the test scripts - run: shellcheck --severity=warning test/*.sh - # The image scripts declare #!/bin/sh but use a few bash constructs that # busybox ash happens to accept. Cleaning that up is a separate change, # so this step reports but does not block. @@ -40,6 +36,8 @@ jobs: shellcheck --severity=warning backup.sh check.sh entry.sh build.sh run.sh \ || echo "::warning::shellcheck reported findings in the image scripts, see the log above" + # Python suite (pytest) plus container-structure-test. Tools are pinned in + # mise.toml, Python dependencies in uv.lock. test: runs-on: ubuntu-latest timeout-minutes: 20 @@ -47,15 +45,16 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Build test image - run: docker build -t restic-backup-docker:test . + - name: Install tools from mise.toml + uses: jdx/mise-action@v3 + + - name: Lint + run: task lint - # container-structure-test is not installed: test/run.sh falls back to - # the official tool image, which keeps CI and local runs identical. - name: Run test suites - run: bash test/run.sh --no-build + run: task test - # Guards the guard: if someone waters the suites down until a broken - # backup no longer fails them, this step turns red. + # Guards the guard: if someone waters the suite down until a broken + # backup no longer fails it, this step turns red. - name: Verify the suite detects a broken backup - run: bash test/run.sh --sabotage + run: task test:sabotage diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae4022a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/CHANGELOG.md b/CHANGELOG.md index d2b902f..fb022ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,11 @@ ## Unreleased ### Added -* Test suites in `test/` (compat, features, structure) that run the image as a real container, plus a `Test` GitHub Actions workflow running them on every pull request +* Test suites in `tests/` (compat, features, structure) that run the image as a real container, plus a `Test` GitHub Actions workflow running them on every pull request. The suites are written in Python (pytest) and run via `mise install` and `task test`; `task test:sabotage` proves they detect a broken backup * `.gitattributes` pinning shell scripts, the Dockerfile and YAML files to LF line endings. With a Windows checkout (`core.autocrlf=true`) the scripts were checked out with CRLF, which made the built image fail to start with `exec /entry.sh: no such file or directory` ### Removed -* `config-broken.yml` and `docker-compose.test.broken.yml`, replaced by `test/structure.yml` and the test workflow +* `config-broken.yml` and `docker-compose.test.broken.yml`, replaced by `tests/structure.yml` and the test workflow ## v1.4.2 ### Removed diff --git a/README.md b/README.md index 2ff88d0..f985d46 100644 --- a/README.md +++ b/README.md @@ -28,43 +28,45 @@ When behaviour of the image changes (Features, Bugfixes, Changes in the API) ple ## Tests -The test suites build the image and exercise it as a real container. They need -`bash` and `docker`, nothing else: +The tests build the image and exercise it as a real container. Tools are pinned +in `mise.toml` (Python, [uv](https://docs.astral.sh/uv/), +[Task](https://taskfile.dev/), container-structure-test), Python dependencies in +`uv.lock`. You need [mise](https://mise.jdx.dev/) and Docker, nothing else: ```sh -bash test/run.sh # build the image and run every suite -bash test/run.sh --no-build # reuse an already built image -bash test/run.sh --suite compat # run a single suite +mise install # install the pinned tools +task test # build the image and run every suite +task test:sabotage # self test, see below +task lint # ruff check and format check of tests/ ``` -They run unchanged on Linux, macOS and Windows/Git-Bash. On Git-Bash the -scripts set `MSYS_NO_PATHCONV=1` and `MSYS2_ARG_CONV_EXCL='*'` themselves, -because MSYS would otherwise rewrite container paths such as -`/usr/bin/backup` into `C:/Program Files/Git/usr/bin/backup` before `docker` -sees them. Nothing has to be exported by hand. +Single suites: `task test:structure`, `task test:compat`, `task test:features`. +Arguments after `--` go to pytest, e.g. `task test:compat -- -k hooks`. Set +`IMAGE` to test an already built image instead of `restic-backup-docker:test`. -There are three suites: +The suites live in `tests/`: -* **compat** (`test/compat.sh`) - backup, restore, forget, check, hooks and +* **compat** (`tests/test_compat.py`) - backup, restore, forget, check, hooks and failure handling against the default configuration. These tests describe the behaviour users rely on. A change that turns one of them red is a breaking change. -* **features** (`test/features.sh`) - tests for optional features. Each block - is guarded by a detection check against the built image, so a feature that is - not part of the image is reported as `SKIP` instead of failing. Pull requests - that add such a feature are therefore covered as soon as they are rebased, - without the author having to write the basic tests. After merging a feature, - add its gate name to `test/required-features.txt` so that a missing feature - turns into a failure. -* **structure** (`test/structure.yml`) - image layout checks via +* **features** (`tests/test_features.py`) - tests for optional features. Each + test is marked `@pytest.mark.feature("")` and guarded by a detection + check against the built image, so a feature that is not part of the image is + reported as skipped instead of failing. Pull requests that add such a feature + are therefore covered as soon as they are rebased, without the author having + to write the basic tests. After merging a feature, add its gate name to + `tests/required-features.txt` so that a missing feature turns into a failure. +* **structure** (`tests/structure.yml`) - image layout checks via [container-structure-test](https://github.com/GoogleContainerTools/container-structure-test). - When the tool is not installed, its official container image is used instead. + `tests/structure.py` runs it and removes the containers and anonymous volumes + it leaves behind. -`bash test/run.sh --sabotage` is a self test: it breaks the backup call on a -throwaway copy of the tree and expects the compat suite to fail. CI runs it so -that the suites cannot be watered down into always passing. +`task test:sabotage` is a self test: it breaks the backup call on a throwaway +copy of the tree and expects the compat tests to fail. CI runs it so that the +tests cannot be watered down into always passing. -When you add a feature, please add a block to `test/features.sh` for it. +When you add a feature, please add tests with a `feature` marker for it. ## Hooks diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..b05fc72 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,55 @@ +version: "3" + +vars: + IMAGE: '{{.IMAGE | default "restic-backup-docker:test"}}' + +env: + IMAGE: "{{.IMAGE}}" + +tasks: + default: + cmds: + - task --list + silent: true + + build: + desc: Build the image under test + cmds: + - docker build --quiet -t {{.IMAGE}} . + + lint: + desc: Lint the Python test suite with ruff + cmds: + - uv run ruff check tests + - uv run ruff format --check tests + + test: + desc: Build the image and run all suites; every suite runs, any failure fails the task + cmds: + - task: build + - cmd: | + rc=0 + {{.TASK_EXE}} test:structure || rc=1 + {{.TASK_EXE}} test:compat || rc=1 + {{.TASK_EXE}} test:features || rc=1 + exit $rc + + test:structure: + desc: Run container-structure-test against the image and remove its containers and volumes + cmds: + - uv run python tests/structure.py + + test:compat: + desc: Run the compat tests + cmds: + - uv run pytest -m "not feature" {{.CLI_ARGS}} + + test:features: + desc: Run the feature-gated tests + cmds: + - uv run pytest -m feature {{.CLI_ARGS}} + + test:sabotage: + desc: Prove the compat tests go red on an image with a broken backup + cmds: + - uv run python tests/sabotage.py diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..077f4c5 --- /dev/null +++ b/mise.toml @@ -0,0 +1,8 @@ +[tools] +python = "3.13" +uv = "0.12.15" +task = "3.54.0" +"aqua:GoogleContainerTools/container-structure-test" = "1.22.1" + +[env] +UV_PYTHON_PREFERENCE = "only-system" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3b150a2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "restic-backup-docker-tests" +version = "0.0.0" +description = "Container test suite for restic-backup-docker" +requires-python = ">=3.13,<3.14" +dependencies = [] + +[dependency-groups] +dev = [ + "docker>=7.1", + "pytest>=8.3", + "ruff>=0.8", +] + +[tool.uv] +package = false +default-groups = ["dev"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra --strict-markers" +markers = [ + "container(suffix, **env): container a test class runs against, with extra environment", + "feature(name): test needs an optional image feature; skipped when absent, failed when listed in tests/required-features.txt", +] + +[tool.ruff] +line-length = 100 +target-version = "py313" + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] diff --git a/test/compat.sh b/test/compat.sh deleted file mode 100644 index 5de7076..0000000 --- a/test/compat.sh +++ /dev/null @@ -1,275 +0,0 @@ -#!/usr/bin/env bash -# Backwards-compatibility suite. -# -# Everything in here describes behaviour the image has today and that users -# depend on. These tests always run, for every pull request. A change that -# turns one of them red is a breaking change, whether or not that was intended. - -set -u - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=test/lib.sh -. "${SCRIPT_DIR}/lib.sh" - -trap stop_container EXIT - -echo "=== compat: repository bootstrap and default backup ===" - -start_container default -wait_for_start || { fail "container starts with default configuration"; summary "compat"; exit 1; } -pass "container starts with default configuration" - -startup_log="$(docker logs "$CONTAINER" 2>&1)" -assert_contains "$startup_log" "Running restic init" \ - "empty repository is initialised on first start" -assert_contains "$startup_log" "Repo init status 0" \ - "repository initialisation succeeds" - -crontab="$(in_container cat /var/spool/cron/crontabs/root)" -assert_contains "$crontab" "/bin/backup" \ - "BACKUP_CRON is written to the crontab" -assert_contains "$crontab" "flock -n /var/run/backup.lock" \ - "backup cron job is guarded by flock" -assert_not_contains "$crontab" "/bin/check" \ - "no check cron job is installed while CHECK_CRON is unset" - -write_file /data/hello.txt "backup me" - -backup_log="$(in_container /bin/backup 2>&1)" -assert_contains "$backup_log" "Backup Successful" \ - "backup of /data succeeds" -assert_contains "$backup_log" "Pre-backup script not found" \ - "missing pre-backup hook is reported, not fatal" -assert_contains "$backup_log" "Post-backup script not found" \ - "missing post-backup hook is reported, not fatal" -assert_not_contains "$backup_log" "notification" \ - "no mail or Teams notification is attempted without configuration" -assert_eq "1" "$(snapshot_count)" \ - "exactly one snapshot exists after one backup" - -snapshots="$(in_container restic snapshots --json 2>/dev/null)" -assert_contains "$snapshots" '"testtag"' \ - "snapshot carries the configured RESTIC_TAG" - -in_container restic restore latest --target /tmp/restore >/dev/null 2>&1 -restore_rc=$? -assert_eq "0" "$restore_rc" \ - "restic restore of the latest snapshot exits 0" -restored="$(in_container cat /tmp/restore/data/hello.txt 2>&1)" -assert_eq "backup me" "$restored" \ - "restored file content matches the original" - -logfile="$(in_container cat /var/log/backup-last.log 2>&1)" -assert_contains "$logfile" "RESTIC_REPOSITORY: /mnt/restic" \ - "backup-last.log records the run parameters" - -in_container /bin/backup >/dev/null 2>&1 -assert_eq "2" "$(snapshot_count)" \ - "a second backup adds a second snapshot" - -echo -echo "=== compat: data integrity check ===" - -check_log="$(in_container /bin/check 2>&1)" -assert_contains "$check_log" "Check Successful" \ - "restic check passes on a healthy repository" -assert_contains "$check_log" "Pre-check script not found" \ - "missing pre-check hook is reported, not fatal" - -echo -echo "=== compat: pre- and post-backup hooks ===" - -write_file /hooks/pre-backup.sh '#!/bin/sh -echo "PRE-HOOK-RAN" -' -write_file /hooks/post-backup.sh '#!/bin/sh -echo "POST-HOOK-RAN with rc=$1" -' -in_container chmod +x /hooks/pre-backup.sh /hooks/post-backup.sh - -hook_log="$(in_container /bin/backup 2>&1)" -assert_contains "$hook_log" "PRE-HOOK-RAN" \ - "pre-backup hook is executed" -assert_contains "$hook_log" "POST-HOOK-RAN with rc=0" \ - "post-backup hook receives the backup return code" - -stop_container - -echo -echo "=== compat: CHECK_CRON and check hooks ===" - -start_container checkcron -e "CHECK_CRON=0 0 31 2 *" -wait_for_start || { fail "container starts with CHECK_CRON"; summary "compat"; exit 1; } - -check_crontab="$(in_container cat /var/spool/cron/crontabs/root)" -check_cron_line="$(echo "$check_crontab" | grep '/bin/check' || true)" -assert_contains "$check_cron_line" "flock -n /var/run/backup.lock" \ - "check cron job is guarded by the shared backup lock" -assert_contains "$check_cron_line" "/bin/check" \ - "CHECK_CRON adds a check cron job to the crontab" -assert_eq "2" "$(echo "$check_crontab" | grep -c .)" \ - "crontab has a backup line and a check line" -assert_contains "$(echo "$check_crontab" | head -n1)" "/bin/backup" \ - "CHECK_CRON does not replace the backup cron job" - -write_file /data/hello.txt "backup me" -in_container /bin/backup >/dev/null 2>&1 -write_file /hooks/pre-check.sh '#!/bin/sh -echo "PRE-CHECK-HOOK-RAN" -' -write_file /hooks/post-check.sh '#!/bin/sh -echo "POST-CHECK-HOOK-RAN with rc=$1" -' -in_container chmod +x /hooks/pre-check.sh /hooks/post-check.sh - -check_hook_log="$(in_container /bin/check 2>&1)" -assert_contains "$check_hook_log" "PRE-CHECK-HOOK-RAN" \ - "pre-check hook is executed" -assert_contains "$check_hook_log" "POST-CHECK-HOOK-RAN with rc=0" \ - "post-check hook receives the check return code" -stop_container - -echo -echo "=== compat: a corrupted repository is reported as failed ===" -# The success path above is not enough: check.sh prints "Check Successful" -# whenever restic exits 0, so a `restic check` that does nothing at all would -# keep this suite green. Damage the repository and require the failure path. -# Runs in its own throwaway container, the repository stays broken afterwards. - -start_container corrupt -wait_for_start || { fail "container starts for the corruption test"; summary "compat"; exit 1; } -write_file /data/hello.txt "backup me" -in_container /bin/backup >/dev/null 2>&1 -assert_eq "1" "$(snapshot_count)" \ - "the repository to be corrupted has a snapshot" - -in_container sh -c 'f=$(find /mnt/restic/data -type f | head -n1); chmod u+w "$f"; printf garbage > "$f"' -broken_check_log="$(in_container /bin/check 2>&1)" -assert_contains "$broken_check_log" "Check Failed with Status" \ - "restic check detects a corrupted repository" -assert_not_contains "$broken_check_log" "Check Successful" \ - "a corrupted repository is not reported as successful" -if in_container test -f /var/log/check-error-last.log; then - pass "failing check writes check-error-last.log" -else - fail "failing check writes check-error-last.log" "file is missing" -fi -stop_container - -echo -echo "=== compat: RESTIC_FORGET_ARGS ===" - -start_container forget -e "RESTIC_FORGET_ARGS=--keep-last 1" -wait_for_start || { fail "container starts with RESTIC_FORGET_ARGS"; summary "compat"; exit 1; } -write_file /data/hello.txt "backup me" -in_container /bin/backup >/dev/null 2>&1 -forget_log="$(in_container /bin/backup 2>&1)" -assert_contains "$forget_log" "Forget Successful" \ - "forget runs after a successful backup when configured" -assert_eq "1" "$(snapshot_count)" \ - "--keep-last 1 leaves exactly one snapshot" -stop_container - -echo -echo "=== compat: RESTIC_JOB_ARGS ===" -# backup-last.log only records the variable, it does not prove restic ever saw -# it. The arguments have to have a visible effect, otherwise a backup that -# silently drops RESTIC_JOB_ARGS still looks healthy. - -start_container jobargs -e "RESTIC_JOB_ARGS=--exclude=/data/skipme.txt" -wait_for_start || { fail "container starts with RESTIC_JOB_ARGS"; summary "compat"; exit 1; } -write_file /data/keep.txt "keep" -write_file /data/skipme.txt "skip" -jobargs_log="$(in_container /bin/backup 2>&1)" -assert_contains "$jobargs_log" "Backup Successful" \ - "backup with RESTIC_JOB_ARGS succeeds" -in_container restic restore latest --target /tmp/restore >/dev/null 2>&1 -assert_eq "keep" "$(in_container cat /tmp/restore/data/keep.txt 2>&1)" \ - "RESTIC_JOB_ARGS does not break a normal backup" -if in_container test -f /tmp/restore/data/skipme.txt; then - fail "RESTIC_JOB_ARGS is passed to restic backup" \ - "the excluded file was backed up anyway" -else - pass "RESTIC_JOB_ARGS is passed to restic backup" -fi -stop_container - -echo -echo "=== compat: BACKUP_SOURCES ===" - -start_container sources -e "BACKUP_SOURCES=/opt/custom" -wait_for_start || { fail "container starts with BACKUP_SOURCES"; summary "compat"; exit 1; } -write_file /opt/custom/custom.txt "custom source" -write_file /data/ignored.txt "should not be backed up" -in_container /bin/backup >/dev/null 2>&1 -in_container restic restore latest --target /tmp/restore >/dev/null 2>&1 -assert_eq "custom source" "$(in_container cat /tmp/restore/opt/custom/custom.txt 2>&1)" \ - "BACKUP_SOURCES overrides the default /data source" -if in_container test -f /tmp/restore/data/ignored.txt; then - fail "BACKUP_SOURCES excludes /data" "/data was backed up although BACKUP_SOURCES was set" -else - pass "BACKUP_SOURCES excludes /data" -fi -stop_container - -echo -echo "=== compat: failure handling ===" - -start_container failure -wait_for_start || { fail "container starts for failure test"; summary "compat"; exit 1; } -write_file /data/hello.txt "backup me" -# Back up a source that does not exist. The repository stays the initialised -# default one, so the snapshot count below looks at the repo the run really used. -fail_log="$(in_container_env BACKUP_SOURCES=/does-not-exist /bin/backup 2>&1)" -assert_contains "$fail_log" "Backup Failed with Status" \ - "a failing backup is reported as failed" -if in_container test -f /var/log/backup-error-last.log; then - pass "failing backup writes backup-error-last.log" -else - fail "failing backup writes backup-error-last.log" "file is missing" -fi -assert_eq "0" "$(snapshot_count)" \ - "a failed backup leaves no snapshot behind" -stop_container - -echo -echo "=== compat: timezone ===" -# The base image already ships tzdata, so TZ works today. Pinned here so that -# neither a base image change nor a slimming effort silently breaks cron times -# for users who already rely on it. - -tz_default="$(docker run --rm --entrypoint date "$IMAGE" +%Z 2>&1 | tr -d '\r')" -assert_eq "UTC" "$tz_default" \ - "the default timezone is UTC" - -tz_berlin="$(docker run --rm -e TZ=Europe/Berlin --entrypoint date "$IMAGE" +%Z 2>&1 | tr -d '\r')" -if [ "$tz_berlin" = "CET" ] || [ "$tz_berlin" = "CEST" ]; then - pass "TZ=Europe/Berlin changes the container timezone" -else - fail "TZ=Europe/Berlin changes the container timezone" "got: ${tz_berlin}" -fi - -echo -echo "=== compat: RESTIC_TAG handling ===" - -start_container tag -wait_for_start || { fail "container starts for RESTIC_TAG test"; summary "compat"; exit 1; } -write_file /data/hello.txt "backup me" - -# The Dockerfile declares RESTIC_TAG="", so in a normal container the variable -# is always set and an empty value means "no tag". Documented here because the -# `${RESTIC_TAG?...}` guard in backup.sh only triggers when it is truly unset. -emptytag_log="$(in_container_env RESTIC_TAG= /bin/backup 2>&1)" -assert_contains "$emptytag_log" "Backup Successful" \ - "an empty RESTIC_TAG creates an untagged backup" -assert_eq "1" "$(snapshot_count)" \ - "an empty RESTIC_TAG still produces a snapshot" - -notag_log="$(in_container env -u RESTIC_TAG /bin/backup 2>&1)" -assert_contains "$notag_log" "Missing environment variable RESTIC_TAG" \ - "backup refuses to run when RESTIC_TAG is unset entirely" -assert_eq "1" "$(snapshot_count)" \ - "no extra snapshot is created when RESTIC_TAG is unset" -stop_container - -summary "compat" diff --git a/test/features.sh b/test/features.sh deleted file mode 100644 index c3f77e4..0000000 --- a/test/features.sh +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env bash -# Feature suite. -# -# Each block is guarded by a detection check against the built image. On master -# every block reports SKIP. As soon as the corresponding pull request is rebased -# onto master, the feature is detected and its tests start running - the PR -# author does not have to write test code for the basics. -# -# After merging a feature, add its gate name to test/required-features.txt. -# From then on a missing feature is a failure instead of a skip. - -set -u - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=test/lib.sh -. "${SCRIPT_DIR}/lib.sh" - -trap stop_container EXIT - -echo "=== feature: stream backup (RESTIC_STREAM) ===" -# Pull request #109 - back up the output of a command via `restic backup --stdin`. - -if require_feature stream "PR #109"; then - start_container stream \ - -e RESTIC_STREAM=1 \ - -e "RESTIC_STREAM_CMD=echo hello-stream" \ - -e RESTIC_STREAM_FILENAME=stream.txt - if wait_for_start; then - stream_log="$(in_container /bin/backup 2>&1)" - assert_contains "$stream_log" "Backup Successful" \ - "stream backup succeeds" - assert_eq "1" "$(snapshot_count)" \ - "stream backup creates exactly one snapshot" - in_container restic restore latest --target /tmp/restore >/dev/null 2>&1 - restored="$(in_container sh -c 'cat /tmp/restore/stream.txt 2>/dev/null' 2>&1)" - assert_eq "hello-stream" "$(echo "$restored" | tr -d '\r')" \ - "stream content is stored under RESTIC_STREAM_FILENAME" - else - fail "stream backup container starts" - fi - stop_container - - # Blocker 1 of PR #109: the stream branch must honour RESTIC_JOB_ARGS, the - # same way the file backup does. --host is used because it is visible in - # the snapshot metadata of a stdin backup. - start_container stream-jobargs \ - -e RESTIC_STREAM=1 \ - -e "RESTIC_STREAM_CMD=echo hello-stream" \ - -e RESTIC_STREAM_FILENAME=stream.txt \ - -e "RESTIC_JOB_ARGS=--host=jobargshost" - if wait_for_start; then - in_container /bin/backup >/dev/null 2>&1 - # Match on the hostname field itself: restic's own --host filter only - # returns snapshots whose hostname equals the value, so the string - # cannot be satisfied by "jobargshost" appearing in some other field. - # Both directions are checked so the filter is known to discriminate. - assert_eq "1" "$(in_container sh -c \ - 'restic snapshots --host jobargshost --json 2>/dev/null | grep -o "\"short_id\"" | wc -l' | tr -d ' \r')" \ - "RESTIC_JOB_ARGS sets the host of the stream backup snapshot" - assert_eq "0" "$(in_container sh -c \ - 'restic snapshots --host some-other-host --json 2>/dev/null | grep -o "\"short_id\"" | wc -l' | tr -d ' \r')" \ - "the host filter does not match an unrelated hostname" - else - fail "stream RESTIC_JOB_ARGS container starts" - fi - stop_container - - # A half-configured stream must not look like a successful backup. - start_container stream-misconfigured -e RESTIC_STREAM=1 - if wait_for_start; then - misconfig_log="$(in_container /bin/backup 2>&1)" - assert_not_contains "$misconfig_log" "Backup Successful" \ - "incomplete stream configuration does not report success" - assert_eq "0" "$(snapshot_count)" \ - "incomplete stream configuration creates no snapshot" - else - fail "stream misconfiguration container starts" - fi - stop_container - - # The classic path must keep working while the feature is available. - start_container stream-disabled - if wait_for_start; then - write_file /data/hello.txt "backup me" - in_container /bin/backup >/dev/null 2>&1 - assert_eq "1" "$(snapshot_count)" \ - "file backup still works while the stream feature is present" - else - fail "stream-disabled container starts" - fi - stop_container -fi - -echo -echo "=== feature: docker cli ===" -# Pull request #99 - talk to the host docker daemon from hooks. - -if require_feature docker-cli "PR #99"; then - docker_version="$(docker run --rm --entrypoint docker "$IMAGE" --version 2>&1)" - assert_contains "$docker_version" "Docker version" \ - "docker cli is usable inside the image" -fi - -echo -echo "=== feature: bash ===" -# Pull request #99 - allow bash syntax in pre/post hooks. - -if require_feature bash "PR #99"; then - start_container bash-hooks - if wait_for_start; then - write_file /hooks/pre-backup.sh '#!/bin/bash -arr=(one two) -if [[ "${arr[1]}" == "two" ]]; then echo "BASH-HOOK-RAN"; fi -' - in_container chmod +x /hooks/pre-backup.sh - write_file /data/hello.txt "backup me" - bash_log="$(in_container /bin/backup 2>&1)" - assert_contains "$bash_log" "BASH-HOOK-RAN" \ - "a hook with a bash shebang and bash syntax runs" - else - fail "bash hook container starts" - fi - stop_container -fi - -echo -echo "=== feature: msmtp mail relay ===" -# Pull request #84 - send notification mails through msmtp. - -if require_feature msmtp "PR #84"; then - sendmail_target="$(docker run --rm --entrypoint sh "$IMAGE" -c \ - 'readlink -f /usr/sbin/sendmail' 2>&1 | tr -d '\r')" - assert_contains "$sendmail_target" "msmtp" \ - "sendmail is provided by msmtp" - - # The config has to be in place when the container starts, entry.sh copies - # it. Re-running entry.sh in a live container would start a second crond. - start_container_with_file msmtp-config /config/msmtprc 'defaults -account default -host localhost -' - if wait_for_start; then - if in_container test -f /etc/msmtprc; then - pass "msmtp config from /config is copied to /etc/msmtprc" - mode="$(in_container stat -c '%a' /etc/msmtprc 2>&1 | tr -d '\r')" - assert_eq "600" "$mode" \ - "copied msmtp config is not world readable" - else - fail "msmtp config from /config is copied to /etc/msmtprc" "file is missing" - fi - else - fail "msmtp config container starts" - fi - stop_container - - # Users who configured mail before msmtp existed must not lose their backups. - start_container msmtp-missing-config \ - -e "MAILX_ARGS=-r from@example.com -s subject to@example.com" - if wait_for_start; then - write_file /data/hello.txt "backup me" - mail_log="$(in_container /bin/backup 2>&1)" - assert_contains "$mail_log" "Backup Successful" \ - "backup succeeds with MAILX_ARGS set but no msmtp config" - assert_eq "1" "$(snapshot_count)" \ - "snapshot is created even when the mail relay is unconfigured" - else - fail "msmtp missing-config container starts" - fi - stop_container -fi - -summary "features" diff --git a/test/lib.sh b/test/lib.sh deleted file mode 100644 index 1dc496f..0000000 --- a/test/lib.sh +++ /dev/null @@ -1,287 +0,0 @@ -#!/usr/bin/env bash -# Shared helpers for the restic-backup-docker test suites. -# -# Deliberately dependency-free: bash + docker only. No test framework, no -# host-side bind mounts (files are moved with `docker cp`) so the suites behave -# identically on Linux CI runners, macOS and Windows/Git-Bash. -# -# Windows/Git-Bash needs one extra thing: MSYS rewrites every argument that -# looks like a POSIX path before the process sees it, so -# `docker exec /usr/bin/backup` reaches the daemon as -# `C:/Program Files/Git/usr/bin/backup` and the container command is not found. -# Both variables are required (one covers the command, one covers its -# arguments) and both are ignored on Linux and macOS. -export MSYS_NO_PATHCONV=1 -export MSYS2_ARG_CONV_EXCL='*' - -IMAGE="${IMAGE:-restic-backup-docker:test}" - -PASS_COUNT=0 -FAIL_COUNT=0 -SKIP_COUNT=0 -FAILED_TESTS=() -SKIPPED_TESTS=() - -_c_red='' -_c_green='' -_c_yellow='' -_c_reset='' -if [ -t 1 ]; then - _c_red=$'\033[31m' - _c_green=$'\033[32m' - _c_yellow=$'\033[33m' - _c_reset=$'\033[0m' -fi - -log() { echo " $*"; } - -pass() { - PASS_COUNT=$((PASS_COUNT + 1)) - echo "${_c_green}PASS${_c_reset} $1" -} - -fail() { - FAIL_COUNT=$((FAIL_COUNT + 1)) - FAILED_TESTS+=("$1") - echo "${_c_red}FAIL${_c_reset} $1" - if [ -n "${2:-}" ]; then - echo "$2" | sed 's/^/ /' - fi -} - -skip() { - SKIP_COUNT=$((SKIP_COUNT + 1)) - SKIPPED_TESTS+=("$1") - echo "${_c_yellow}SKIP${_c_reset} $1 (${2:-not applicable})" -} - -# summary -> exit code 0 when nothing failed -summary() { - echo - echo "--- $1: ${PASS_COUNT} passed, ${FAIL_COUNT} failed, ${SKIP_COUNT} skipped ---" - if [ ${#FAILED_TESTS[@]} -gt 0 ]; then - echo "Failed:" - printf ' - %s\n' "${FAILED_TESTS[@]}" - fi - if [ ${#SKIPPED_TESTS[@]} -gt 0 ]; then - echo "Skipped:" - printf ' - %s\n' "${SKIPPED_TESTS[@]}" - fi - [ "${FAIL_COUNT}" -eq 0 ] -} - -# --------------------------------------------------------------------------- -# assertions -# --------------------------------------------------------------------------- - -assert_eq() { - local expected="$1" actual="$2" name="$3" - if [ "$expected" = "$actual" ]; then - pass "$name" - else - fail "$name" "expected: ${expected} -actual: ${actual}" - fi -} - -assert_contains() { - local haystack="$1" needle="$2" name="$3" - case "$haystack" in - *"$needle"*) pass "$name" ;; - *) fail "$name" "expected to contain: ${needle} -actual: -$(echo "$haystack" | tail -n 25)" ;; - esac -} - -assert_not_contains() { - local haystack="$1" needle="$2" name="$3" - case "$haystack" in - *"$needle"*) fail "$name" "unexpectedly contains: ${needle}" ;; - *) pass "$name" ;; - esac -} - -# --------------------------------------------------------------------------- -# container helpers -# -# Every test gets its own throwaway container. The restic repository lives -# inside the container at the image default (/mnt/restic), so no host paths are -# involved and tests cannot pollute each other. -# --------------------------------------------------------------------------- - -CONTAINER="" - -# start_container [extra docker run args...] -# Starts the image with a cron expression far in the future so that the only -# backup that ever runs is the one a test triggers explicitly. -start_container() { - local suffix="$1" - shift - CONTAINER="rbd-test-${suffix}-$$" - docker rm -f "$CONTAINER" >/dev/null 2>&1 || true - docker run -d --name "$CONTAINER" \ - -e RESTIC_PASSWORD=testpassword \ - -e RESTIC_TAG=testtag \ - -e "BACKUP_CRON=0 0 31 2 *" \ - "$@" \ - "$IMAGE" >/dev/null -} - -# start_container_with_file [extra docker run args...] -# Like start_container, but the file exists before entry.sh runs, so tests can -# cover what the entrypoint does with it at real container startup. The file is -# placed with `docker create` + `docker cp`, no bind mount. -start_container_with_file() { - local suffix="$1" path="$2" content="$3" - shift 3 - local seed seed_src - seed="$(mktemp -d)" - printf '%s' "$content" > "${seed}/$(basename "$path")" - CONTAINER="rbd-test-${suffix}-$$" - docker rm -f "$CONTAINER" >/dev/null 2>&1 || true - docker create --name "$CONTAINER" \ - -e RESTIC_PASSWORD=testpassword \ - -e RESTIC_TAG=testtag \ - -e "BACKUP_CRON=0 0 31 2 *" \ - "$@" \ - "$IMAGE" >/dev/null - if command -v cygpath >/dev/null 2>&1; then - seed_src="$(cygpath -w "$seed")" - else - seed_src="$seed" - fi - docker cp "${seed_src}/." "${CONTAINER}:$(dirname "$path")" - rm -rf "$seed" - docker start "$CONTAINER" >/dev/null -} - -stop_container() { - [ -n "$CONTAINER" ] || return 0 - docker rm -f "$CONTAINER" >/dev/null 2>&1 || true - CONTAINER="" -} - -# wait_for_start -> fails if entry.sh did not finish within the timeout -wait_for_start() { - local waited=0 - while [ "$waited" -lt 60 ]; do - waited=$((waited + 1)) - if docker logs "$CONTAINER" 2>&1 | grep -q "Container started."; then - return 0 - fi - if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null)" != "true" ]; then - echo "container exited early:" - docker logs "$CONTAINER" 2>&1 | sed 's/^/ /' - return 1 - fi - sleep 1 - done - echo "timeout waiting for container start:" - docker logs "$CONTAINER" 2>&1 | sed 's/^/ /' - return 1 -} - -# in_container - run a command inside the running test container -in_container() { - docker exec "$CONTAINER" "$@" -} - -# in_container_env - run with one overridden variable -in_container_env() { - local env_pair="$1" - shift - docker exec -e "$env_pair" "$CONTAINER" "$@" -} - -# write_file -write_file() { - local path="$1" content="$2" - local dir - dir="$(dirname "$path")" - docker exec "$CONTAINER" mkdir -p "$dir" - printf '%s' "$content" | docker exec -i "$CONTAINER" sh -c "cat > '$path'" -} - -# snapshot_count -> number of snapshots in the repository -# A restic error (wrong password, missing repository) is not "zero snapshots": -# the function prints the error text and returns 1, so assert_eq on the result -# fails loudly instead of matching an expected "0". -snapshot_count() { - local out - if ! out="$(docker exec "$CONTAINER" restic snapshots --json 2>&1)"; then - echo "restic snapshots failed: $(echo "$out" | tail -n 3 | tr '\n\r' ' ')" - return 1 - fi - printf '%s' "$out" | grep -o '"short_id"' | wc -l | tr -d ' \r' -} - -# --------------------------------------------------------------------------- -# feature gating -# -# A feature test only runs when the image actually ships the feature. That way -# this suite already covers the currently open pull requests: once such a PR is -# rebased onto master its feature is detected and its tests start running, -# without the PR author having to write any test code. -# -# test/required-features.txt lists features that MUST be present. A feature -# listed there that is not detected turns the skip into a hard failure, so a -# merged feature cannot silently disappear from the image again. -# --------------------------------------------------------------------------- - -REQUIRED_FEATURES_FILE="${REQUIRED_FEATURES_FILE:-$(dirname "${BASH_SOURCE[0]}")/required-features.txt}" - -feature_is_required() { - local feature="$1" - [ -f "$REQUIRED_FEATURES_FILE" ] || return 1 - tr -d '\r' < "$REQUIRED_FEATURES_FILE" \ - | grep -v '^[[:space:]]*#' \ - | grep -qx "[[:space:]]*${feature}[[:space:]]*" -} - -# One throwaway container probes the image for all features at once; the -# result is cached so every has_feature call after the first is free. -FEATURE_PROBE="" -FEATURE_PROBED=0 - -probe_features() { - [ "$FEATURE_PROBED" -eq 0 ] || return 0 - FEATURE_PROBE="$(docker run --rm --entrypoint sh "$IMAGE" -c ' - grep -q RESTIC_STREAM /bin/backup && echo stream - command -v docker >/dev/null && echo docker-cli - command -v bash >/dev/null && echo bash - command -v msmtp >/dev/null && echo msmtp - true' 2>/dev/null | tr -d '\r')" - FEATURE_PROBED=1 -} - -# has_feature - detection runs against the built image, not the source -has_feature() { - case "$1" in - stream|docker-cli|bash|msmtp) - probe_features - printf '%s\n' "$FEATURE_PROBE" | grep -qx "$1" - ;; - *) - echo "unknown feature: $1" >&2 - return 1 - ;; - esac -} - -# require_feature -# Returns 0 when the feature tests should run. Otherwise records a skip, or a -# failure when the feature is listed in required-features.txt. -require_feature() { - local feature="$1" ref="$2" - if has_feature "$feature"; then - return 0 - fi - if feature_is_required "$feature"; then - fail "feature '${feature}'" \ - "listed in $(basename "$REQUIRED_FEATURES_FILE") but not present in the image" - else - skip "feature '${feature}'" "not in image yet, see ${ref}" - fi - return 1 -} diff --git a/test/run.sh b/test/run.sh deleted file mode 100644 index c3b69d9..0000000 --- a/test/run.sh +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env bash -# Entry point for the test suites. -# -# bash test/run.sh build the image and run every suite -# bash test/run.sh --no-build reuse an already built $IMAGE -# bash test/run.sh --suite compat run a single suite (compat|features|structure) -# bash test/run.sh --sabotage self test: break backup.sh on a throwaway -# copy of the tree and prove the suite fails -# -# Requires bash and docker. container-structure-test is optional: without a -# local install the structure suite runs through the official tool image. - -set -u - -# Windows/Git-Bash: MSYS mangles POSIX-looking arguments on their way to the -# docker daemon, which breaks `docker exec` and `--config /test/structure.yml`. -# lib.sh exports the same two variables for the suites that source it; run.sh -# calls docker itself and starts the suites as separate processes, so it needs -# them as well. Both are ignored on Linux and macOS. -export MSYS_NO_PATHCONV=1 -export MSYS2_ARG_CONV_EXCL='*' - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" -IMAGE="${IMAGE:-restic-backup-docker:test}" -export IMAGE -# Pinned by digest-free tag on purpose: the structure test tool is only used to -# read the image under test, a floating tag cannot affect the product. -STRUCTURE_TEST_IMAGE="${STRUCTURE_TEST_IMAGE:-gcr.io/gcp-runtimes/container-structure-test:latest}" - -BUILD=1 -SABOTAGE=0 -ONLY_SUITE="" - -while [ $# -gt 0 ]; do - case "$1" in - --no-build) BUILD=0 ;; - --sabotage) SABOTAGE=1 ;; - --suite) - # A missing value, or a following flag, must not be taken as one. - case "${2:-}" in - ""|-*) echo "--suite requires a value (compat|features|structure)" >&2; exit 2 ;; - esac - ONLY_SUITE="$2"; shift ;; - --suite=*) - ONLY_SUITE="${1#--suite=}" - [ -n "$ONLY_SUITE" ] || { echo "--suite requires a value (compat|features|structure)" >&2; exit 2; } ;; - -h|--help) sed -n '2,/^$/{/^#/p;}' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; - *) echo "unknown argument: $1" >&2; exit 2 ;; - esac - shift -done - -if ! docker info >/dev/null 2>&1; then - echo "docker is not available, cannot run the test suite" >&2 - exit 2 -fi - -# Docker on Windows does not understand the /c/... paths Git-Bash produces, -# so build contexts are translated when cygpath is around. No-op elsewhere. -host_path() { - if command -v cygpath >/dev/null 2>&1; then - cygpath -w "$1" - else - printf '%s' "$1" - fi -} - -build_image() { - local context tag - context="$(host_path "$1")" - tag="$2" - echo ">>> building ${tag}" - if ! docker build --quiet -t "$tag" "$context" >/dev/null; then - echo "image build failed" >&2 - return 1 - fi -} - -# --sabotage proves the suite has teeth: it breaks the backup call on a copy of -# the tree and expects the compat suite to go red. If this mode ever passes, -# the tests are not actually testing anything. -if [ "$SABOTAGE" -eq 1 ]; then - tmp="$(mktemp -d)" - trap 'rm -rf "$tmp"; docker rmi -f restic-backup-docker:sabotage >/dev/null 2>&1 || true' EXIT - tar -c -C "$ROOT_DIR" --exclude=.git --exclude=.intent . | tar -x -C "$tmp" - # Silently turn the backup into a no-op, the nastiest kind of regression. - # No `sed -i`: its argument differs between GNU and BSD (macOS) sed. - sed 's|^restic backup |true |' "$tmp/backup.sh" > "$tmp/backup.sh.new" \ - && mv "$tmp/backup.sh.new" "$tmp/backup.sh" - # mv keeps the mode of the new file (644); Dockerfile COPY has no chmod. - chmod +x "$tmp/backup.sh" - if ! grep -q '^true ' "$tmp/backup.sh"; then - echo "sabotage patch did not apply, backup.sh changed shape" >&2 - exit 2 - fi - if [ ! -x "$tmp/backup.sh" ]; then - echo "SABOTAGE CHECK INVALID: sabotaged backup.sh is not executable" >&2 - exit 2 - fi - build_image "$tmp" restic-backup-docker:sabotage || exit 2 - echo ">>> running compat suite against the sabotaged image (failure expected)" - sabotage_log="${tmp}/compat.log" - if IMAGE=restic-backup-docker:sabotage bash "${SCRIPT_DIR}/compat.sh" > "$sabotage_log" 2>&1; then - cat "$sabotage_log" - echo - echo "SABOTAGE CHECK FAILED: the compat suite passed on a broken image" >&2 - exit 1 - fi - cat "$sabotage_log" - # Red for the wrong reason (e.g. a non-executable /bin/backup) proves nothing. - if grep -qi 'permission denied' "$sabotage_log"; then - echo "SABOTAGE CHECK INVALID: log contains 'permission denied', suite failed for the wrong reason" >&2 - exit 2 - fi - # Any red test would do for "it failed"; a broken test environment is red too. - # Demand the assertions that are about the backup itself. - missing=0 - for name in \ - "exactly one snapshot exists after one backup" \ - "a second backup adds a second snapshot"; do - if ! grep -qF "FAIL ${name}" "$sabotage_log"; then - echo "SABOTAGE CHECK FAILED: expected assertion did not fail: ${name}" >&2 - missing=1 - fi - done - [ "$missing" -eq 0 ] || exit 1 - echo - echo "sabotage check ok: the compat suite detects a broken backup" - exit 0 -fi - -if [ "$BUILD" -eq 1 ]; then - build_image "$ROOT_DIR" "$IMAGE" || exit 2 -fi - -rc=0 - -run_structure() { - echo - echo ">>> structure suite" - if command -v container-structure-test >/dev/null 2>&1; then - container-structure-test test --image "$IMAGE" --config "${SCRIPT_DIR}/structure.yml" || rc=1 - else - # Not installed locally: fall back to the official image. It needs the - # docker socket because it inspects and runs the image under test. - echo "container-structure-test not installed, using the official image" - # The config file is copied in with `docker cp` instead of bind mounted, - # so this also works when DOCKER_HOST points at a remote daemon. - local tool_cid tool_rc - tool_cid="$(docker create \ - -v //var/run/docker.sock:/var/run/docker.sock \ - "$STRUCTURE_TEST_IMAGE" \ - test --image "$IMAGE" --config /structure.yml)" || { rc=1; return; } - docker cp "$(host_path "${SCRIPT_DIR}")/structure.yml" "${tool_cid}:/structure.yml" \ - && docker start -a "$tool_cid" - tool_rc=$? - docker rm -f "$tool_cid" >/dev/null 2>&1 || true - [ "$tool_rc" -eq 0 ] || rc=1 - fi -} - -run_suite() { - echo - echo ">>> $1 suite" - bash "${SCRIPT_DIR}/$1.sh" || rc=1 -} - -case "$ONLY_SUITE" in - "") run_structure; run_suite compat; run_suite features ;; - structure) run_structure ;; - compat) run_suite compat ;; - features) run_suite features ;; - *) echo "unknown suite: ${ONLY_SUITE}" >&2; exit 2 ;; -esac - -echo -if [ "$rc" -eq 0 ]; then - echo "all suites passed" -else - echo "at least one suite failed" -fi -exit "$rc" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9295a09 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,171 @@ +"""Shared fixtures and feature gating for the container test suite.""" + +from __future__ import annotations + +import contextlib +import os +import signal +import subprocess +import uuid +from collections.abc import Callable, Iterator +from pathlib import Path + +import docker +import pytest +from helpers import Container, raise_interrupt, remove_quietly, run_once, start_container + +ROOT = Path(__file__).resolve().parent.parent +IMAGE = os.environ.get("IMAGE", "restic-backup-docker:test") +REQUIRED_FEATURES_FILE = Path( + os.environ.get("REQUIRED_FEATURES_FILE", Path(__file__).with_name("required-features.txt")) +) +RUN_ID = uuid.uuid4().hex[:8] + +# Shell probe run once in a throwaway container; one line per detected feature. +FEATURE_PROBE = """ +grep -q RESTIC_STREAM /bin/backup && echo stream +command -v docker >/dev/null && echo docker-cli +command -v bash >/dev/null && echo bash +command -v msmtp >/dev/null && echo msmtp +true +""" +KNOWN_FEATURES = ("stream", "docker-cli", "bash", "msmtp") + + +def required_features() -> set[str]: + if not REQUIRED_FEATURES_FILE.is_file(): + return set() + lines = (line.strip() for line in REQUIRED_FEATURES_FILE.read_text().splitlines()) + return {line for line in lines if line and not line.startswith("#")} + + +def pytest_configure(config: pytest.Config) -> None: + """Turn SIGTERM into KeyboardInterrupt so session teardown (cleanup) runs.""" + signal.signal(signal.SIGTERM, raise_interrupt) + + +def pytest_addoption(parser: pytest.Parser) -> None: + parser.addoption("--build", action="store_true", help="build the image before testing") + + +@pytest.fixture(scope="session") +def docker_client() -> Iterator[docker.DockerClient]: + try: + client = docker.from_env() + client.ping() + except docker.errors.DockerException as exc: + pytest.exit(f"docker is not available: {exc}", returncode=2) + yield client + client.close() + + +@pytest.fixture(scope="session") +def image(request: pytest.FixtureRequest, docker_client: docker.DockerClient) -> str: + """Tag of the image under test; built on demand with --build.""" + if request.config.getoption("--build"): + subprocess.run(["docker", "build", "--quiet", "-t", IMAGE, str(ROOT)], check=True) + try: + docker_client.images.get(IMAGE) + except docker.errors.ImageNotFound: + pytest.exit(f"image {IMAGE} not found, run `task build` or pass --build", returncode=2) + return IMAGE + + +def _remove_leftovers(client: docker.DockerClient) -> None: + """Remove containers, then volumes, labelled with this run; safe to call repeatedly.""" + flt = {"label": f"rbd-test-run={RUN_ID}"} + try: + for container in client.containers.list(all=True, filters=flt): + with contextlib.suppress(docker.errors.NotFound): + container.remove(force=True, v=True) + finally: + for volume in client.volumes.list(filters=flt): + with contextlib.suppress(docker.errors.NotFound): + volume.remove(force=True) + + +@pytest.fixture(scope="session", autouse=True) +def _cleanup_leftovers(docker_client: docker.DockerClient) -> Iterator[None]: + """Remove every container/volume this run created, even after failures or Ctrl-C. + + A Ctrl-C in `task`/`uv` is forwarded as a second SIGINT while teardown runs, so + SIGINT/SIGTERM are ignored until cleanup is finished. + """ + try: + yield + finally: + old = {sig: signal.signal(sig, signal.SIG_IGN) for sig in (signal.SIGINT, signal.SIGTERM)} + try: + _remove_leftovers(docker_client) + finally: + for sig, handler in old.items(): + signal.signal(sig, handler) + + +@pytest.fixture(scope="session") +def detected_features(docker_client: docker.DockerClient, image: str) -> set[str]: + out = run_once( + docker_client, + image, + entrypoint=["sh", "-c", FEATURE_PROBE], + labels={"rbd-test-run": RUN_ID}, + ) + return set(out.split()) + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + for item in items: + for marker in item.iter_markers("feature"): + item.fixturenames.append("_feature_gate") + if marker.args[0] not in KNOWN_FEATURES: + raise pytest.UsageError(f"unknown feature gate: {marker.args[0]}") + + +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: + continue + if name in required_features(): + pytest.fail( + f"feature '{name}' listed in {REQUIRED_FEATURES_FILE.name} " + "but not present in the image", + pytrace=False, + ) + 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} + + +@pytest.fixture(scope="session") +def run_container( + docker_client: docker.DockerClient, image: str +) -> Callable[..., contextlib.AbstractContextManager[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, files: dict[str, str] | None = None + ) -> Iterator[Container]: + container = start_container( + docker_client, image, f"rbd-test-{suffix}-{RUN_ID}", LABELS, env, files + ) + try: + yield container + finally: + remove_quietly(container) + + return _run diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..1656261 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,253 @@ +"""Helpers around a running test container. + +Rule for everything in here: a failing command is never turned into an empty or +zero result. Helpers that return parsed data raise on a non-zero exit, so a broken +restic call fails the test instead of looking like "no snapshots". +""" + +from __future__ import annotations + +import contextlib +import io +import json +import signal +import tarfile +import time +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import PurePosixPath +from typing import Any + +import docker +import pytest +from docker.models.containers import Container as DockerContainer + +START_TIMEOUT = 60 +START_MARKER = "Container started." + + +@dataclass(frozen=True) +class ExecResult: + rc: int + out: str # stdout and stderr, interleaved like `2>&1` + + +class CommandError(AssertionError): + pass + + +def raise_interrupt(signum: int, _frame: object) -> None: + """Signal handler: turn SIGTERM into KeyboardInterrupt so cleanup code runs.""" + raise KeyboardInterrupt(f"received {signal.Signals(signum).name}") + + +class StepFailed(Exception): + """A scenario step could not run; not an assertion about the behaviour under test. + + Deliberately no AssertionError: sabotage.py tells it apart from a real assertion + failure by the exception name in the JUnit report. + """ + + +class Scenario: + """Results of the actions of one scenario fixture. + + `record(name, fn)` runs one step and stores its result or its error. Reading an + attribute of a failed step raises, so only the tests that assert on that step fail; + the other tests of the class still run against what the scenario did produce. + """ + + def __init__(self) -> None: + self._steps: dict[str, tuple[Any, BaseException | None]] = {} + + def record(self, name: str, step: Callable[[], Any]) -> None: + try: + self._steps[name] = (step(), None) + except Exception as exc: # noqa: BLE001 - the error is re-raised on access + self._steps[name] = (None, exc) + + def __getattr__(self, name: str) -> Any: + try: + value, error = self.__dict__["_steps"][name] + except KeyError: + raise AttributeError(name) from None + if error is not None: + raise StepFailed(f"scenario step '{name}' failed: {error}") from error + return value + + +def running(container: Container) -> Container: + """Scenarios that act on the container need it started; the start tests do not.""" + if not container.started: + pytest.fail(f"container did not start:\n{container.start_log}", pytrace=False) + return container + + +class Container: + """A started image container with exec/file helpers.""" + + def __init__(self, raw: DockerContainer, started: bool, start_log: str) -> None: + self.raw = raw + self.started = started + self.start_log = start_log + + def exec(self, *cmd: str, env: dict[str, str] | None = None) -> ExecResult: + """Run a command; the exit code is returned, not checked.""" + code, out = self.raw.exec_run(list(cmd), environment=env) + return ExecResult(code, out.decode(errors="replace").replace("\r", "")) + + def run(self, *cmd: str, env: dict[str, str] | None = None) -> str: + """Run a command that must succeed; return its output.""" + res = self.exec(*cmd, env=env) + if res.rc != 0: + raise CommandError(f"{' '.join(cmd)} exited {res.rc}:\n{res.out}") + return res.out + + 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)) + put_file(self.raw, path, content, executable=executable) + + def read_file(self, path: str) -> str: + return self.run("cat", path) + + def file_exists(self, path: str) -> bool: + res = self.exec("test", "-f", path) + if res.rc not in (0, 1): + raise CommandError(f"test -f {path} exited {res.rc}:\n{res.out}") + return res.rc == 0 + + def logs(self) -> str: + return self.raw.logs().decode(errors="replace").replace("\r", "") + + # restic ------------------------------------------------------------- + + def restic_json(self, *args: str) -> Any: + """Run restic with --json and parse stdout; any failure raises.""" + code, (out, err) = self.raw.exec_run(["restic", *args, "--json"], demux=True) + if code != 0: + raise CommandError(f"restic {' '.join(args)} exited {code}:\n{(err or b'').decode()}") + return json.loads((out or b"").decode()) + + def snapshots(self) -> list[dict[str, Any]]: + return self.restic_json("snapshots") or [] + + def backed_up_paths(self, snapshot: str = "latest") -> set[str]: + """Paths contained in a snapshot (`restic ls --json` emits one JSON object per line).""" + code, (out, err) = self.raw.exec_run(["restic", "ls", snapshot, "--json"], demux=True) + if code != 0: + raise CommandError(f"restic ls {snapshot} exited {code}:\n{(err or b'').decode()}") + lines = (out or b"").decode().splitlines() + return {n["path"] for n in map(json.loads, lines) if n.get("struct_type") == "node"} + + def restore_latest(self, target: str) -> ExecResult: + return self.exec("restic", "restore", "latest", "--target", target) + + +def _dir_exists(raw: DockerContainer, path: str) -> bool: + """Ask the daemon with a HEAD request on the archive endpoint: stat only, no tar stream. + + A container that is not started cannot exec `test -d`, and `get_archive` would leave + its streaming response open. + """ + api = raw.client.api + response = api.head(api._url("/containers/{0}/archive", raw.id), params={"path": path}) + try: + if response.status_code == 404: + return False + response.raise_for_status() + return True + finally: + response.close() + + +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. + + Missing parent directories are created (like `mkdir -p`); existing ones are left + untouched, so their mode and owner stay as the image defines them. + """ + target = PurePosixPath(path) + missing = [] + for parent in target.parents: + if _dir_exists(raw, str(parent)): + break + missing.append(parent) + data = content.encode() + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tar: + for directory in reversed(missing): + dir_info = tarfile.TarInfo(str(directory).lstrip("/")) + dir_info.type = tarfile.DIRTYPE + dir_info.mode = 0o755 + tar.addfile(dir_info) + info = tarfile.TarInfo(str(target).lstrip("/")) + info.size = len(data) + info.mode = 0o755 if executable else 0o644 + tar.addfile(info, io.BytesIO(data)) + buf.seek(0) + if not raw.put_archive("/", 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. + + `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 {}), + } + 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") + if START_MARKER in log: + return Container(raw, True, log) + raw.reload() + if raw.status != "running": + return Container(raw, False, f"container exited early:\n{log}") + time.sleep(0.5) + return Container(raw, False, f"timeout waiting for start:\n{raw.logs().decode()}") + + +def remove_quietly(container: Container) -> None: + with contextlib.suppress(docker.errors.NotFound): + container.raw.remove(force=True, v=True) + + +def run_once(client: docker.DockerClient, image: str, **kwargs: Any) -> str: + """Run a short-lived container and return its output. + + `containers.run(remove=True)` keeps the image's anonymous volumes (VOLUME /data), + so run detached and remove the container together with its volumes. + """ + container = client.containers.run(image, detach=True, **kwargs) + try: + result = container.wait() + output = container.logs(stdout=True, stderr=False).decode() + if result["StatusCode"] != 0: + raise CommandError(f"container exited {result['StatusCode']}:\n{output}") + return output + finally: + with contextlib.suppress(docker.errors.NotFound): + container.remove(force=True, v=True) diff --git a/test/required-features.txt b/tests/required-features.txt similarity index 87% rename from test/required-features.txt rename to tests/required-features.txt index 5b266d7..1827224 100644 --- a/test/required-features.txt +++ b/tests/required-features.txt @@ -1,7 +1,7 @@ # Optional features that MUST be present in the built image. # # One gate name per line. Known gates and the pull request that introduces -# them (see test/features.sh): +# them (see tests/test_features.py): # # stream stream backups via RESTIC_STREAM PR #109 # docker-cli docker cli inside the container PR #99 @@ -15,4 +15,3 @@ # tests are mandatory and a feature that silently disappears from the image # fails the build. # -# Empty on purpose: master currently ships none of the optional features. diff --git a/tests/sabotage.py b/tests/sabotage.py new file mode 100644 index 0000000..9d17637 --- /dev/null +++ b/tests/sabotage.py @@ -0,0 +1,146 @@ +"""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()) diff --git a/tests/structure.py b/tests/structure.py new file mode 100644 index 0000000..7f1f3a1 --- /dev/null +++ b/tests/structure.py @@ -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()) diff --git a/test/structure.yml b/tests/structure.yml similarity index 100% rename from test/structure.yml rename to tests/structure.yml diff --git a/tests/test_compat.py b/tests/test_compat.py new file mode 100644 index 0000000..5ea1749 --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,469 @@ +"""Backwards-compatibility suite. + +Everything here describes behaviour the image has today and that users depend on. +A change that turns one of these red is a breaking change, whether or not intended. + +Layout: every scenario fixture starts one throwaway container, performs the actions +once and records the results; the tests only assert on what was recorded, so they do +not depend on each other. A scenario step that fails fails only the tests that assert +on its result. Each docstring states the behaviour the test pins. +Tests run in a container with the image defaults: repository /mnt/restic, tag +`testtag`, cron far in the future. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass + +import docker +import pytest +from conftest import LABELS +from helpers import Container, Scenario, run_once, running + +HELLO = "backup me" +PRE_BACKUP_HOOK = '#!/bin/sh\necho "PRE-HOOK-RAN"\n' +POST_BACKUP_HOOK = '#!/bin/sh\necho "POST-HOOK-RAN with rc=$1"\n' +PRE_CHECK_HOOK = '#!/bin/sh\necho "PRE-CHECK-HOOK-RAN"\n' +POST_CHECK_HOOK = '#!/bin/sh\necho "POST-CHECK-HOOK-RAN with rc=$1"\n' + + +@pytest.fixture(scope="class") +def started(request: pytest.FixtureRequest, run_container) -> Iterator[Container]: + """Container for the class, configured by `@pytest.mark.container(suffix, **env)`.""" + marker = request.node.get_closest_marker("container") + with run_container(marker.args[0], env=marker.kwargs) as container: + yield container + + +def _crontab_lines(container: Container) -> list[str]: + return container.read_file("/var/spool/cron/crontabs/root").splitlines() + + +# --------------------------------------------------------------------------- +# repository bootstrap and default backup +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="class") +def default_run(started: Container) -> Scenario: + c = running(started) + c.write_file("/data/hello.txt", HELLO) + run = Scenario() + run.record("backup", lambda: c.exec("/bin/backup")) + run.record("snapshots_after_first", c.snapshots) + run.record("restore", lambda: c.restore_latest("/tmp/restore")) + run.record("restored", lambda: c.exec("cat", "/tmp/restore/data/hello.txt")) + run.record("backup_last_log", lambda: c.read_file("/var/log/backup-last.log")) + run.record("second_backup", lambda: c.run("/bin/backup")) + run.record("snapshots_after_second", c.snapshots) + run.record("check", lambda: c.exec("/bin/check")) + c.write_file("/hooks/pre-backup.sh", PRE_BACKUP_HOOK, executable=True) + c.write_file("/hooks/post-backup.sh", POST_BACKUP_HOOK, executable=True) + run.record("hook_backup", lambda: c.exec("/bin/backup")) + return run + + +@pytest.mark.container("default") +class TestDefaultBackup: + def test_container_starts(self, started): + """container starts with default configuration""" + assert started.started, started.start_log + + def test_repository_is_initialised(self, started): + """empty repository is initialised on first start""" + assert "Running restic init" in started.logs() + + def test_repository_init_succeeds(self, started): + """repository initialisation succeeds""" + assert "Repo init status 0" in started.logs() + + def test_backup_cron_in_crontab(self, started): + """BACKUP_CRON is written to the crontab""" + lines = _crontab_lines(started) + assert any(line.startswith("0 0 31 2 *") and "/bin/backup" in line for line in lines) + + def test_backup_cron_guarded_by_flock(self, started): + """backup cron job is guarded by flock""" + line = next(line for line in _crontab_lines(started) if "/bin/backup" in line) + assert "flock -n /var/run/backup.lock" in line + + def test_no_check_cron_by_default(self, started): + """no check cron job is installed while CHECK_CRON is unset""" + lines = _crontab_lines(started) + assert any("/bin/backup" in line for line in lines) # the crontab we looked at is real + assert not any("/bin/check" in line for line in lines) + + def test_backup_succeeds(self, default_run): + """backup of /data succeeds""" + assert default_run.backup.rc == 0 + assert "Backup Successful" in default_run.backup.out + + def test_missing_pre_backup_hook_reported(self, default_run): + """missing pre-backup hook is reported, not fatal""" + assert "Pre-backup script not found" in default_run.backup.out + assert default_run.backup.rc == 0 + + def test_missing_post_backup_hook_reported(self, default_run): + """missing post-backup hook is reported, not fatal""" + assert "Post-backup script not found" in default_run.backup.out + assert default_run.backup.rc == 0 + + def test_no_notification_without_config(self, default_run): + """no mail or Teams notification is attempted without configuration""" + assert "Backup Successful" in default_run.backup.out + assert "notification" not in default_run.backup.out + + def test_one_snapshot_after_one_backup(self, default_run): + """exactly one snapshot exists after one backup""" + assert len(default_run.snapshots_after_first) == 1 + + def test_snapshot_carries_restic_tag(self, default_run): + """snapshot carries the configured RESTIC_TAG""" + assert [s["tags"] for s in default_run.snapshots_after_first] == [["testtag"]] + + def test_restore_exits_zero(self, default_run): + """restic restore of the latest snapshot exits 0""" + assert default_run.restore.rc == 0, default_run.restore.out + + def test_restored_content_matches(self, default_run): + """restored file content matches the original""" + assert default_run.restored.rc == 0, default_run.restored.out + assert default_run.restored.out == HELLO + + def test_backup_last_log_records_parameters(self, default_run): + """backup-last.log records the run parameters""" + assert "RESTIC_REPOSITORY: /mnt/restic" in default_run.backup_last_log + + def test_second_backup_adds_snapshot(self, default_run): + """a second backup adds a second snapshot""" + ids = {s["id"] for s in default_run.snapshots_after_second} + assert len(default_run.snapshots_after_second) == 2 + assert {s["id"] for s in default_run.snapshots_after_first} < ids + + def test_check_passes_on_healthy_repository(self, default_run): + """restic check passes on a healthy repository""" + assert default_run.check.rc == 0 + assert "Check Successful" in default_run.check.out + + def test_missing_pre_check_hook_reported(self, default_run): + """missing pre-check hook is reported, not fatal""" + assert "Pre-check script not found" in default_run.check.out + assert "Check Successful" in default_run.check.out + + def test_pre_backup_hook_executed(self, default_run): + """pre-backup hook is executed""" + assert "PRE-HOOK-RAN" in default_run.hook_backup.out + + def test_post_backup_hook_gets_return_code(self, default_run): + """post-backup hook receives the backup return code""" + assert "POST-HOOK-RAN with rc=0" in default_run.hook_backup.out + + +# --------------------------------------------------------------------------- +# CHECK_CRON and check hooks +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="class") +def checkcron_run(started: Container) -> Scenario: + c = running(started) + run = Scenario() + run.record("crontab", lambda: _crontab_lines(c)) + c.write_file("/data/hello.txt", HELLO) + run.record("backup", lambda: c.run("/bin/backup")) + c.write_file("/hooks/pre-check.sh", PRE_CHECK_HOOK, executable=True) + c.write_file("/hooks/post-check.sh", POST_CHECK_HOOK, executable=True) + run.record("check", lambda: c.exec("/bin/check")) + return run + + +@pytest.mark.container("checkcron", CHECK_CRON="0 0 31 2 *") +class TestCheckCron: + def test_check_cron_guarded_by_shared_lock(self, checkcron_run): + """check cron job is guarded by the shared backup lock""" + check_lines = [line for line in checkcron_run.crontab if "/bin/check" in line] + assert len(check_lines) == 1 + assert "flock -n /var/run/backup.lock" in check_lines[0] + + def test_check_cron_adds_cron_job(self, checkcron_run): + """CHECK_CRON adds a check cron job to the crontab""" + assert any( + line.startswith("0 0 31 2 *") and "/bin/check" in line for line in checkcron_run.crontab + ) + + def test_crontab_has_backup_and_check_line(self, checkcron_run): + """crontab has a backup line and a check line""" + assert len([line for line in checkcron_run.crontab if line.strip()]) == 2 + + def test_check_cron_keeps_backup_cron(self, checkcron_run): + """CHECK_CRON does not replace the backup cron job""" + assert "/bin/backup" in checkcron_run.crontab[0] + + def test_pre_check_hook_executed(self, checkcron_run): + """pre-check hook is executed""" + assert "PRE-CHECK-HOOK-RAN" in checkcron_run.check.out + + def test_post_check_hook_gets_return_code(self, checkcron_run): + """post-check hook receives the check return code""" + assert "POST-CHECK-HOOK-RAN with rc=0" in checkcron_run.check.out + + +# --------------------------------------------------------------------------- +# a corrupted repository is reported as failed +# +# check.sh prints "Check Successful" whenever restic exits 0, so a `restic check` +# that does nothing would keep the success path green. Damage the repository and +# require the failure path. The container is throwaway; the repo stays broken. +# --------------------------------------------------------------------------- + +CORRUPT_CMD = 'f=$(find /mnt/restic/data -type f | head -n1); chmod u+w "$f"; printf garbage > "$f"' + + +@pytest.fixture(scope="class") +def corrupt_run(started: Container) -> Scenario: + c = running(started) + c.write_file("/data/hello.txt", HELLO) + run = Scenario() + run.record("backup", lambda: c.run("/bin/backup")) + run.record("snapshots_before", c.snapshots) + run.record("corrupt", lambda: c.run("sh", "-c", CORRUPT_CMD)) + run.record("check", lambda: c.exec("/bin/check")) + run.record("error_log_exists", lambda: c.file_exists("/var/log/check-error-last.log")) + return run + + +@pytest.mark.container("corrupt") +class TestCorruptedRepository: + def test_repository_to_corrupt_has_snapshot(self, corrupt_run): + """the repository to be corrupted has a snapshot""" + assert len(corrupt_run.snapshots_before) == 1 + + def test_check_detects_corruption(self, corrupt_run): + """restic check detects a corrupted repository""" + assert "Check Failed with Status" in corrupt_run.check.out + + def test_corruption_not_reported_successful(self, corrupt_run): + """a corrupted repository is not reported as successful""" + assert "Check Successful" not in corrupt_run.check.out + assert "Check Failed with Status" in corrupt_run.check.out # the check did run and report + + def test_failing_check_writes_error_log(self, corrupt_run): + """failing check writes check-error-last.log""" + assert corrupt_run.error_log_exists + + +# --------------------------------------------------------------------------- +# RESTIC_FORGET_ARGS +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="class") +def forget_run(started: Container) -> Scenario: + c = running(started) + c.write_file("/data/hello.txt", HELLO) + run = Scenario() + run.record("first_backup", lambda: c.run("/bin/backup")) + run.record("first", c.snapshots) + run.record("second_backup", lambda: c.exec("/bin/backup")) + run.record("snapshots", c.snapshots) + return run + + +@pytest.mark.container("forget", RESTIC_FORGET_ARGS="--keep-last 1") +class TestForgetArgs: + def test_forget_runs_after_backup(self, forget_run): + """forget runs after a successful backup when configured""" + assert "Forget Successful" in forget_run.second_backup.out + + def test_keep_last_leaves_one_snapshot(self, forget_run): + """--keep-last 1 leaves exactly one snapshot""" + # two backups ran: one snapshot left that is not the first one proves forget pruned + assert len(forget_run.first) == 1 + assert len(forget_run.snapshots) == 1 + assert forget_run.snapshots[0]["id"] != forget_run.first[0]["id"] + + +# --------------------------------------------------------------------------- +# RESTIC_JOB_ARGS +# +# backup-last.log only records the variable, it does not prove restic ever saw it. +# The arguments need a visible effect, otherwise a backup that silently drops +# RESTIC_JOB_ARGS still looks healthy. +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="class") +def jobargs_run(started: Container) -> Scenario: + c = running(started) + c.write_file("/data/keep.txt", "keep") + c.write_file("/data/skipme.txt", "skip") + run = Scenario() + run.record("backup", lambda: c.exec("/bin/backup")) + run.record("restore", lambda: c.restore_latest("/tmp/restore")) + run.record("restored_keep", lambda: c.exec("cat", "/tmp/restore/data/keep.txt")) + run.record("paths", c.backed_up_paths) + return run + + +@pytest.mark.container("jobargs", RESTIC_JOB_ARGS="--exclude=/data/skipme.txt") +class TestJobArgs: + def test_backup_with_job_args_succeeds(self, jobargs_run): + """backup with RESTIC_JOB_ARGS succeeds""" + assert "Backup Successful" in jobargs_run.backup.out + + def test_job_args_do_not_break_backup(self, jobargs_run): + """RESTIC_JOB_ARGS does not break a normal backup""" + assert jobargs_run.restore.rc == 0, jobargs_run.restore.out + assert jobargs_run.restored_keep.out == "keep" + + def test_job_args_passed_to_restic(self, jobargs_run): + """RESTIC_JOB_ARGS is passed to restic backup""" + # keep.txt shows the snapshot is the one of this scenario, skipme.txt must be missing + assert "/data/keep.txt" in jobargs_run.paths + assert "/data/skipme.txt" not in jobargs_run.paths + + +# --------------------------------------------------------------------------- +# BACKUP_SOURCES +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="class") +def sources_run(started: Container) -> Scenario: + c = running(started) + c.write_file("/opt/custom/custom.txt", "custom source") + c.write_file("/data/ignored.txt", "should not be backed up") + run = Scenario() + run.record("backup", lambda: c.run("/bin/backup")) + run.record("restore", lambda: c.restore_latest("/tmp/restore")) + run.record("restored", lambda: c.exec("cat", "/tmp/restore/opt/custom/custom.txt")) + run.record("paths", c.backed_up_paths) + return run + + +@pytest.mark.container("sources", BACKUP_SOURCES="/opt/custom") +class TestBackupSources: + def test_sources_override_default(self, sources_run): + """BACKUP_SOURCES overrides the default /data source""" + assert sources_run.restore.rc == 0, sources_run.restore.out + assert sources_run.restored.out == "custom source" + + def test_sources_exclude_data(self, sources_run): + """BACKUP_SOURCES excludes /data""" + assert "/opt/custom/custom.txt" in sources_run.paths + assert not [p for p in sources_run.paths if p.startswith("/data")] + + +# --------------------------------------------------------------------------- +# failure handling +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="class") +def failure_run(started: Container) -> Scenario: + c = running(started) + c.write_file("/data/hello.txt", HELLO) + # Back up a source that does not exist. The repository stays the initialised + # default one, so the snapshot list below is of the repo the run really used. + run = Scenario() + run.record("backup", lambda: c.exec("/bin/backup", env={"BACKUP_SOURCES": "/does-not-exist"})) + run.record("error_log_exists", lambda: c.file_exists("/var/log/backup-error-last.log")) + run.record("snapshots", c.snapshots) + return run + + +@pytest.mark.container("failure") +class TestFailureHandling: + def test_failing_backup_reported(self, failure_run): + """a failing backup is reported as failed""" + assert "Backup Failed with Status" in failure_run.backup.out + + def test_failing_backup_writes_error_log(self, failure_run): + """failing backup writes backup-error-last.log""" + assert failure_run.error_log_exists + + def test_failed_backup_leaves_no_snapshot(self, failure_run): + """a failed backup leaves no snapshot behind""" + # snapshots() raises on a restic error, so [] means a readable, empty repository + assert "Backup Failed with Status" in failure_run.backup.out + assert failure_run.snapshots == [] + + +# --------------------------------------------------------------------------- +# timezone +# +# The base image already ships tzdata, so TZ works today. Pinned here so that +# neither a base image change nor a slimming effort silently breaks cron times +# for users who already rely on it. +# --------------------------------------------------------------------------- + + +@dataclass +class TimezoneRunner: + client: docker.DockerClient + image: str + + def zone(self, env: dict[str, str] | None = None) -> str: + out = run_once( + self.client, self.image, entrypoint=["date", "+%Z"], environment=env, labels=LABELS + ) + return out.strip() + + +@pytest.fixture(scope="module") +def timezone(docker_client, image) -> TimezoneRunner: + return TimezoneRunner(docker_client, image) + + +class TestTimezone: + def test_default_timezone_is_utc(self, timezone): + """the default timezone is UTC""" + assert timezone.zone() == "UTC" + + def test_tz_variable_changes_timezone(self, timezone): + """TZ=Europe/Berlin changes the container timezone""" + assert timezone.zone({"TZ": "Europe/Berlin"}) in ("CET", "CEST") + + +# --------------------------------------------------------------------------- +# RESTIC_TAG handling +# +# The Dockerfile declares RESTIC_TAG="", so in a normal container the variable is +# always set and an empty value means "no tag". The `${RESTIC_TAG?...}` guard in +# backup.sh only triggers when the variable is truly unset. +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="class") +def tag_run(started: Container) -> Scenario: + c = running(started) + c.write_file("/data/hello.txt", HELLO) + run = Scenario() + run.record("empty", lambda: c.exec("/bin/backup", env={"RESTIC_TAG": ""})) + run.record("snapshots_after_empty", c.snapshots) + run.record("unset", lambda: c.exec("env", "-u", "RESTIC_TAG", "/bin/backup")) + run.record("snapshots_after_unset", c.snapshots) + return run + + +@pytest.mark.container("tag") +class TestResticTag: + def test_empty_tag_creates_untagged_backup(self, tag_run): + """an empty RESTIC_TAG creates an untagged backup""" + assert "Backup Successful" in tag_run.empty.out + [snapshot] = tag_run.snapshots_after_empty + assert not snapshot.get("tags") + + def test_empty_tag_still_produces_snapshot(self, tag_run): + """an empty RESTIC_TAG still produces a snapshot""" + assert len(tag_run.snapshots_after_empty) == 1 + + def test_unset_tag_refuses_backup(self, tag_run): + """backup refuses to run when RESTIC_TAG is unset entirely""" + assert "Missing environment variable RESTIC_TAG" in tag_run.unset.out + + def test_unset_tag_creates_no_snapshot(self, tag_run): + """no extra snapshot is created when RESTIC_TAG is unset""" + assert len(tag_run.snapshots_after_empty) == 1 # there was something to not add to + assert len(tag_run.snapshots_after_unset) == 1 diff --git a/tests/test_features.py b/tests/test_features.py new file mode 100644 index 0000000..79658ae --- /dev/null +++ b/tests/test_features.py @@ -0,0 +1,213 @@ +"""Feature-gated tests. + +Each class is guarded by `@pytest.mark.feature()`: 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. A scenario step +that fails fails only the tests that assert on its result. + +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 + +import pytest +from conftest import LABELS +from helpers import Container, Scenario, run_once, running + +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" + + +# --------------------------------------------------------------------------- +# stream backup (PR #109) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="class") +def stream_run(feature_gate_class, run_container) -> Iterator[Scenario]: + with run_container("stream", env=STREAM_ENV) as c: + running(c) + run = Scenario() + run.record("backup", lambda: c.exec("/bin/backup")) + run.record("snapshots", c.snapshots) + run.record("restore", lambda: c.restore_latest("/tmp/restore")) + run.record("restored", lambda: c.exec("cat", "/tmp/restore/stream.txt")) + yield run + + +@pytest.fixture(scope="class") +def stream_jobargs_run(feature_gate_class, run_container) -> Iterator[Scenario]: + # --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) + run = Scenario() + run.record("backup", lambda: c.run("/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. + run.record("matching", lambda: c.restic_json("snapshots", "--host", "jobargshost") or []) + run.record( + "unrelated", lambda: c.restic_json("snapshots", "--host", "some-other-host") or [] + ) + yield run + + +@pytest.fixture(scope="class") +def stream_misconfigured_run(feature_gate_class, run_container) -> Iterator[Scenario]: + with run_container("stream-misconfigured", env={"RESTIC_STREAM": "1"}) as c: + running(c) + run = Scenario() + run.record("backup", lambda: c.exec("/bin/backup")) + run.record("snapshots", c.snapshots) + yield run + + +@pytest.fixture(scope="class") +def stream_disabled_run(feature_gate_class, run_container) -> Iterator[Scenario]: + with run_container("stream-disabled") as c: + running(c) + c.write_file("/data/hello.txt", "backup me") + run = Scenario() + run.record("backup", lambda: c.run("/bin/backup")) + run.record("snapshots", c.snapshots) + yield run + + +@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.restore.rc == 0, stream_run.restore.out + 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[Scenario]: + 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") + run = Scenario() + run.record("backup", lambda: c.exec("/bin/backup")) + yield run + + +@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[Scenario]: + # 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") + run = Scenario() + run.record("backup", lambda: c.exec("/bin/backup")) + run.record("snapshots", c.snapshots) + yield run + + +@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 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..94a13bc --- /dev/null +++ b/uv.lock @@ -0,0 +1,222 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "certifi" +version = "2026.10.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/eb/a2aa1ac3ea30dd3f30ca57f40232138b6b0040719316ecb85daf61167771/certifi-2026.10.18.tar.gz", hash = "sha256:b9995d97b64d0431a5af837d8ca8cd6238104d1f319f04bd93ea43124d6c861e", size = 135227, upload-time = "2026-10-18T16:21:07.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/ba/95dd616042484f7c83a06974388b37b837732740ee70bc5dc715b5fcd55a/certifi-2026.10.18-py3-none-any.whl", hash = "sha256:73191c47aaa79da394e8fee2dbc2ec827432cab56311122c9e47abf79e390f62", size = 134093, upload-time = "2026-10-18T16:21:06.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "docker" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/7f/731ff914b0255d3d065f45fd4e626d4b8c95dbcbaada049f337a6ac16410/docker-7.2.0.tar.gz", hash = "sha256:cebb93773d334f778e023a7ee352a8d6e13ab1bd3b863a4d4a59dec897df43ac", size = 118731, upload-time = "2026-07-09T14:53:46.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "restic-backup-docker-tests" +version = "0.0.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "docker" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "docker", specifier = ">=7.1" }, + { name = "pytest", specifier = ">=8.3" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[package]] +name = "ruff" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e7/e6d5749983b020e6cb1055abec0a6f361a8e94f0184cdcf56d4760995f02/ruff-0.17.1.tar.gz", hash = "sha256:5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f", size = 5323349, upload-time = "2026-10-15T15:23:18.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/78/90c46ca6250709f7c37e397b60d06061adee8d0f7d13959ed6a8e49b14fd/ruff-0.17.1-py3-none-linux_armv6l.whl", hash = "sha256:1796c41eafec66f4635125dd1e074a7aa27b8d20c187d484360acf11d247fc4a", size = 10147528, upload-time = "2026-10-15T15:22:33.667Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d2/a2e28bc35b083ae51fa07dfba5280a667c8b01ecaee7802fc8849dcd5c46/ruff-0.17.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8eadb09a788010be29c895418bdfa513e4e1c65e64b94e5b19e4cd381f6cc7fb", size = 10225974, upload-time = "2026-10-15T15:22:36.328Z" }, + { url = "https://files.pythonhosted.org/packages/8e/71/7a9029a81c8206ee01a98f6cfeeb56e6c095af86857581a37045c46d7e94/ruff-0.17.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:29b454f18ca3eeaf37fbf140841dcf27d36109e29dee9aae8acecb68c26c6f74", size = 9270361, upload-time = "2026-10-15T15:22:38.651Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/e075e9c917cb0575297880c96768304e881240171c870cbd8d9b7b54bec2/ruff-0.17.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872e03dd231744d6c5ec5c8af6be52b71323fc6ee096e4c8b34d9f844ee3b978", size = 9938300, upload-time = "2026-10-15T15:22:41.284Z" }, + { url = "https://files.pythonhosted.org/packages/87/27/0b4bb926a49de2383f161272b4d863571a4c7ecdbf595a6bc58813a13367/ruff-0.17.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c0d6cb5f8fbac77eec9af49f4fc0eaf856b05545e372b0694d24b4e76b1c640", size = 9948464, upload-time = "2026-10-15T15:22:43.681Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/518d57e0e02aecb8ea4acac6b57b51a53173d25fa734952bfdd7d531e88e/ruff-0.17.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78beced4a3c94dfedbee4b999b95b6a27b35820fdc0afbacc6d37a8da26c1cd1", size = 10638639, upload-time = "2026-10-15T15:22:46.081Z" }, + { url = "https://files.pythonhosted.org/packages/01/82/d89a4e498ade59e6cee52d67f8da569019372212b87468c1fdeae884601c/ruff-0.17.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7feefc63921202894f0e158b0e32f45a5950d70a7fb693ccb9264816c25b1c05", size = 11337238, upload-time = "2026-10-15T15:22:48.894Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7c/3b0fdef9352656317c977086fe1d802902406e4f2a0d8578da90c0e6d1c5/ruff-0.17.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684154861a819bf1f433669d2a0ce9c3e54677c65b8ebb866cc2a7ec86345785", size = 10896327, upload-time = "2026-10-15T15:22:51.45Z" }, + { url = "https://files.pythonhosted.org/packages/62/86/5566290219708ce2928f3a29c4f843a1185b341ec6111c4631a33c5ceced/ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189", size = 10350811, upload-time = "2026-10-15T15:22:54.401Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d2/ca8462dabb3c413990d49703ba306c30c862195a75448d080fa8fcce9775/ruff-0.17.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8f9aa5b5bd5fa1937803ea609c8f153b8de39bf3e0e149897ae34b0c5b52f467", size = 10569994, upload-time = "2026-10-15T15:22:57.142Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ad/dd1864c1f1db17c8cfb297ce70b352388e6977620f66f7182c52855407df/ruff-0.17.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:80e53b0e5faa3c5667f035daaa032893ae42b7cfe708c886d39ed131e54688ab", size = 10059672, upload-time = "2026-10-15T15:23:00.084Z" }, + { url = "https://files.pythonhosted.org/packages/65/45/3f56cf1d2168c2165cdd5ac6f9668aee66a4cfdd4caf2b2cc5ccb2b1d568/ruff-0.17.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:056c5d5fa0ac790051ae165d7035c7bf956d02de2db557f2e9b31d877a505fca", size = 9947803, upload-time = "2026-10-15T15:23:03.132Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4b/5457cb2be4c1d1ea5e919558131a0f197a4a454a0530d9e30820e8206e4c/ruff-0.17.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd8f5a55b10f371642eacd0e8665a42a8b97da15c9a7187c79e697b77b5e0ce4", size = 10351807, upload-time = "2026-10-15T15:23:05.628Z" }, + { url = "https://files.pythonhosted.org/packages/49/fb/db15b4f8fa24a1e36b99e1401f42ef6df16ee299a48670d5d262b771e7d7/ruff-0.17.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ce05e6f3973611d9550a600e04bb531061e368baba31610eb57c1509a8f21a0a", size = 10765716, upload-time = "2026-10-15T15:23:08.092Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e8/1e186578076aff7a80735f625005fde678412ab609cd323ed182eb7d6b87/ruff-0.17.1-py3-none-win32.whl", hash = "sha256:1de0030a8aa78c65c5fecb84b2e5c390ac684c0452875a02afc7c083d49a496a", size = 9895093, upload-time = "2026-10-15T15:23:10.582Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2e/da3d9589bae8e8b58f3a48bd5bc0b980232fb14a83bf02c3be7660f87c1b/ruff-0.17.1-py3-none-win_amd64.whl", hash = "sha256:5df76f16580fab58d0f6c98a3ac254a4abceb7b2489b8b678335869cff77b3d7", size = 10097955, upload-time = "2026-10-15T15:23:13.309Z" }, + { url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +]