mirror of
https://github.com/lobaro/restic-backup-docker.git
synced 2026-11-13 15:45:22 +00:00
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:
1 parent
31f06b5d9b
commit
be7d426f78
21 files changed
+1692
-958
No files matched your search
+11
-12
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
@@ -0,0 +1 @@
|
||||
3.13
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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.
@@ -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
|
||||
@@ -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
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in new issue
Block a user