test: move container test suites to pytest (#112)

* test: scaffold pytest suite with mise, uv and Task

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

* ci: run the pytest suite via mise and task

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

* fix: make test cleanup survive repeated SIGINT

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

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

* fix: pin Python 3.13 for uv without mise

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

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

* fix: clean up test containers on SIGTERM

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

* test: port compat tests to pytest

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

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

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

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

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

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

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

* test: port feature-gated tests to pytest

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

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

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

* fix: create parent directories in put_file

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

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

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

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

* test: remove the Bash suite

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

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

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

* docs: describe the Python test suite

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

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

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

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

* test: sabotage check rejects errors and malformed reports

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

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

* test: structure wrapper removes only its own containers

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

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

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

task test no longer stops at the first failing suite.

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

* fix: sabotage check treats failed scenario steps as INVALID

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

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

* fix: hard timeout for container-structure-test

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

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

* fix: shorten sabotage message for lint

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

* fix: handle JUnit failures without message in sabotage check

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Tobias KaupatandClaude Sonnet 5.5 authored and GitHub committed 2026-10-20 20:47:03 +02:00
1 parent 31f06b5d9b
commit be7d426f78
21 files changed
+1692 -958

No files matched your search

+11 -12
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/
+1
View File
@@ -0,0 +1 @@
3.13
+2 -2
View File
@@ -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
+27 -25
View File
@@ -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("<gate>")` 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
+55
View File
@@ -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
+8
View File
@@ -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"
+32
View File
@@ -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"]
-275
View File
@@ -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"
-172
View File
@@ -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"
-287
View File
@@ -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 <id> /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 <suite name> -> 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 <name suffix> [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 <name suffix> <path in container> <content> [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 <cmd...> - run a command inside the running test container
in_container() {
docker exec "$CONTAINER" "$@"
}
# in_container_env <KEY=VALUE> <cmd...> - run with one overridden variable
in_container_env() {
local env_pair="$1"
shift
docker exec -e "$env_pair" "$CONTAINER" "$@"
}
# write_file <path in container> <content>
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 <name> - 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 <name> <pull request reference>
# 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
}
-183
View File
@@ -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"
+171
View File
@@ -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
+253
View File
@@ -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)
@@ -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.
+146
View File
@@ -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())
+77
View File
@@ -0,0 +1,77 @@
"""Run container-structure-test without leaking Docker volumes.
container-structure-test starts one container per test and never removes it. The image
declares `VOLUME /data`, so every container drags an anonymous volume along that outlives
`docker rm`. With `--save` the containers are kept, and this wrapper removes the ones of
the image under test that the run created, together with their volumes (`remove(v=True)`);
containers of other images or of the compat suite (a parallel run, a compose stack) are left
alone.
Run with `task test:structure`.
A run longer than CST_TIMEOUT seconds (default 300) is killed: container-structure-test has
no timeout of its own and can block forever waiting for a container.
Exit code: the one of container-structure-test; 124 on timeout.
"""
from __future__ import annotations
import contextlib
import os
import signal
import subprocess
import sys
from pathlib import Path
import docker
from helpers import raise_interrupt
CONFIG = Path(__file__).with_name("structure.yml")
TIMEOUT_EXIT = 124
def _containers_of(client: docker.DockerClient, image_id: str) -> list:
"""Containers of the image that container-structure-test could have created.
Containers of the compat suite carry the `rbd-test-run` label; their owner removes them.
"""
found = client.containers.list(all=True, filters={"ancestor": image_id})
return [c for c in found if "rbd-test-run" not in c.labels]
def main() -> int:
image = os.environ.get("IMAGE", "restic-backup-docker:test")
signal.signal(signal.SIGTERM, raise_interrupt)
client = docker.from_env()
image_id = client.images.get(image).id
before = {c.id for c in _containers_of(client, image_id)}
try:
timeout = float(os.environ.get("CST_TIMEOUT", "300"))
try:
return subprocess.run(
[
"container-structure-test",
"test",
"--save",
"--image",
image,
"--config",
os.environ.get("CST_CONFIG", str(CONFIG)),
],
check=False,
timeout=timeout,
).returncode
except subprocess.TimeoutExpired:
print(f"container-structure-test timed out after {timeout:g} s", file=sys.stderr)
return TIMEOUT_EXIT
finally:
signal.signal(signal.SIGINT, signal.SIG_IGN)
for container in _containers_of(client, image_id):
if container.id not in before:
with contextlib.suppress(docker.errors.NotFound):
container.remove(v=True, force=True)
signal.signal(signal.SIGINT, signal.default_int_handler)
if __name__ == "__main__":
sys.exit(main())
File renamed without changes.
+469
View File
@@ -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
+213
View File
@@ -0,0 +1,213 @@
"""Feature-gated tests.
Each class is guarded by `@pytest.mark.feature(<gate>)`: it runs as soon as the built
image ships the feature (detected in conftest.py) and is skipped before that. A gate
listed in tests/required-features.txt turns the skip into a failure. 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
Generated
+222
View File
@@ -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" },
]