test: add container test suites and run them in CI

The repository had no tests: CI only built the image, and the old
container-structure-test setup was parked as config-broken.yml /
docker-compose.test.broken.yml. This adds suites that actually run the image.

- test/compat.sh: backup, restore, forget, check, hooks, failure handling and
  timezone behaviour against the default configuration. These describe what
  users depend on today, so a change that turns them red is a breaking change.
- test/features.sh: optional features, each guarded by a detection check
  against the built image. Absent features are reported as SKIP, so open pull
  requests do not turn master red, and their feature tests start running by
  themselves once the pull request is rebased. Covers #109 (RESTIC_STREAM),
  #99 (docker cli, bash) and #84 (msmtp).
- test/required-features.txt: once a feature is merged its gate is listed here
  and a missing feature becomes a failure instead of a skip.
- test/structure.yml: revived image layout checks, falls back to the official
  container-structure-test image when the tool is not installed.
- test/run.sh --sabotage: breaks the backup call on a throwaway copy of the
  tree and expects the suite to fail, so the tests cannot be watered down into
  always passing. CI runs this as its own step.
- .github/workflows/test.yml: shellcheck plus all suites on every pull request.

Also adds .gitattributes pinning shell scripts, the Dockerfile and YAML to LF.
With a Windows checkout (core.autocrlf=true) the scripts were checked out with
CRLF, and the resulting image failed to start with
"exec /entry.sh: no such file or directory".

No runtime file is touched, the image itself is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Agent-Id: agent-7034f75b-1ddc-4aa1-a4ea-3a54b340e8d9
This commit is contained in:
Tobias KaupatandClaude Opus 5 committed 2026-10-17 22:47:26 +02:00
1 parent 47d29529e9
commit 10b197f1f5
12 files changed
+890 -30

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
#!/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
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: 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: 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"
# Point the run at a repository that does not exist. Deleting files inside the
# repository is not enough, restic happily recreates its directory structure.
fail_log="$(in_container_env RESTIC_REPOSITORY=/mnt/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"