mirror of
https://github.com/lobaro/restic-backup-docker.git
synced 2026-11-13 15:45:22 +00:00
test: add container test suites and run them in CI (#111)
Adds structure, compat and feature-gated container test suites (test/run.sh), runs them in CI, and gates image publishing on a green test run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
47d29529e9
commit
31f06b5d9b
13 files changed
+1129
-31
No files matched your search
+183
@@ -0,0 +1,183 @@
|
||||
#!/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"
|
||||
Reference in new issue
Block a user