mirror of
https://github.com/lobaro/restic-backup-docker.git
synced 2026-11-14 07:52:44 +00:00
MSYS rewrites POSIX-looking arguments before docker sees them, so `docker exec <id> /usr/bin/backup` arrived as `C:/Program Files/Git/usr/bin/backup` and the structure suite could not read `--config /test/structure.yml`. 23 of 32 compat assertions and the whole structure suite failed on Git-Bash, while test/lib.sh claimed the suites behave identically on every platform. test/lib.sh and test/run.sh now export MSYS_NO_PATHCONV=1 and MSYS2_ARG_CONV_EXCL='*' themselves - lib.sh for the suites that source it, run.sh for its own docker calls and the suites it starts as separate processes. Both variables are ignored on Linux and macOS. README documents that nothing has to be exported by hand. Verified on Git-Bash without any variable set in the environment: structure 13/13, compat 32/32, features 4 SKIP, exit 0, and `bash test/run.sh --sabotage` still reports "sabotage check ok". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Agent-Id: agent-4966d0e6-7f4d-4b37-be3d-0648ed56cb8a
249 lines
7.5 KiB
Bash
249 lines
7.5 KiB
Bash
#!/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
|
|
}
|
|
|
|
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
|
|
snapshot_count() {
|
|
docker exec "$CONTAINER" sh -c \
|
|
'restic snapshots --json 2>/dev/null | 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:]]*"
|
|
}
|
|
|
|
# has_feature <name> - detection runs against the built image, not the source
|
|
has_feature() {
|
|
case "$1" in
|
|
stream)
|
|
docker run --rm --entrypoint sh "$IMAGE" -c \
|
|
'grep -q RESTIC_STREAM /bin/backup' >/dev/null 2>&1
|
|
;;
|
|
docker-cli)
|
|
docker run --rm --entrypoint sh "$IMAGE" -c \
|
|
'command -v docker' >/dev/null 2>&1
|
|
;;
|
|
bash)
|
|
docker run --rm --entrypoint sh "$IMAGE" -c \
|
|
'command -v bash' >/dev/null 2>&1
|
|
;;
|
|
msmtp)
|
|
docker run --rm --entrypoint sh "$IMAGE" -c \
|
|
'command -v msmtp' >/dev/null 2>&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
|
|
}
|