#!/usr/bin/env bash set -Eeuo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" AUTO_DEPLOY="$ROOT_DIR/deploy/scripts/auto-deploy.sh" TEST_ROOT="$(mktemp -d)" trap 'rm -rf "$TEST_ROOT"' EXIT REVISION="1111111111111111111111111111111111111111" PREVIOUS_REVISION="2222222222222222222222222222222222222222" APP_DIGEST="$(printf 'a%.0s' {1..64})" ROUTE_DIGEST="$(printf 'b%.0s' {1..64})" PREVIOUS_APP_DIGEST="$(printf 'c%.0s' {1..64})" PREVIOUS_ROUTE_DIGEST="$(printf 'd%.0s' {1..64})" APP_IMAGE="registry.example/team/watermaps@sha256:$APP_DIGEST" ROUTE_IMAGE="registry.example/team/watermaps-route-data@sha256:$ROUTE_DIGEST" PREVIOUS_APP_IMAGE="registry.example/team/watermaps@sha256:$PREVIOUS_APP_DIGEST" PREVIOUS_ROUTE_IMAGE="registry.example/team/watermaps-route-data@sha256:$PREVIOUS_ROUTE_DIGEST" RELEASE_POINTER="registry.example/team/watermaps-release:main" RELEASE_IMAGE="registry.example/team/watermaps-release:$REVISION" FAKE_BIN="$TEST_ROOT/bin" mkdir -p "$FAKE_BIN" fail() { printf 'Fehler: %s\n' "$*" >&2 exit 1 } assert_contains() { local expected="$1" local actual="$2" grep -Fq -- "$expected" <<<"$actual" || fail "Erwarteter Text fehlt: $expected" } assert_file_contains() { local expected="$1" local file="$2" grep -Fq -- "$expected" "$file" || fail "$file enthält nicht: $expected" } assert_file_not_contains() { local unexpected="$1" local file="$2" if grep -Fq -- "$unexpected" "$file"; then fail "$file enthält unerwartet: $unexpected" fi } assert_no_temporary_artifacts() { local artifacts=() shopt -s nullglob artifacts+=( "$CASE_DATA/releases"/.candidate-* "$CASE_RUNTIME"/.auto-deploy-images.* "$CASE_INSTALL"/.current.*.new ) shopt -u nullglob if (( ${#artifacts[@]} > 0 )); then printf 'Temporäre Artefakte wurden nicht aufgeräumt:\n' >&2 printf ' %s\n' "${artifacts[@]}" >&2 exit 1 fi } write_images_file() { local destination="$1" local revision="$2" local app_image="$3" local route_image="$4" install -d -m 0755 "$(dirname "$destination")" { printf 'WATERMAPS_DEPLOY_REVISION=%s\n' "$revision" printf 'WATERMAPS_APP_IMAGE=%s\n' "$app_image" printf 'WATERMAPS_ROUTE_DATA_IMAGE=%s\n' "$route_image" } >"$destination" chmod 0600 "$destination" } cat >"$FAKE_BIN/fake-populate-release" <<'SH' #!/usr/bin/env bash set -Eeuo pipefail destination="$1" revision="$2" mkdir -p \ "$destination/deploy/nginx" \ "$destination/deploy/scripts" \ "$destination/deploy/systemd" \ "$destination/database/migrations" for file in \ deploy/compose.production.yml \ deploy/nginx/bootstrap.conf \ deploy/nginx/https.conf.template \ deploy/scripts/common.sh \ deploy/scripts/auto-deploy.sh \ deploy/scripts/auto-deploy-entrypoint.sh \ deploy/scripts/bootstrap-server.sh \ deploy/scripts/renew-certificate.sh \ deploy/scripts/rollback.sh \ deploy/scripts/update-route-data.sh \ deploy/systemd/watermaps-auto-deploy.service \ deploy/systemd/watermaps-auto-deploy.timer \ deploy/systemd/watermaps-certbot-renew.service \ deploy/systemd/watermaps-certbot-renew.timer \ deploy/systemd/watermaps-route-update.service \ deploy/systemd/watermaps-route-update.timer \ database/schema.sql \ database/migrations/0001_initial.sql; do printf 'Testinhalt für %s\n' "$file" >"$destination/$file" done cat >"$destination/deploy/scripts/deploy.sh" <<'DEPLOY' #!/usr/bin/env bash set -Eeuo pipefail [[ "${1:-}" == "--images-file" && -n "${2:-}" ]] || exit 97 images_file="$2" revision="$( awk -F= '$1 == "WATERMAPS_DEPLOY_REVISION" { print substr($0, length($1) + 2) }' \ "$images_file" )" app_image="$( awk -F= '$1 == "WATERMAPS_APP_IMAGE" { print substr($0, length($1) + 2) }' \ "$images_file" )" route_image="$( awk -F= '$1 == "WATERMAPS_ROUTE_DATA_IMAGE" { print substr($0, length($1) + 2) }' \ "$images_file" )" printf '%s|%s|%s|%s|%s|%s\n' \ "$0" \ "$revision" \ "${WATERMAPS_DISABLE_INTERNAL_ROLLBACK:-}" \ "$images_file" \ "$app_image" \ "$route_image" \ >>"$FAKE_DEPLOY_LOG" if [[ "$revision" == "$FAKE_REVISION" ]]; then [[ "$app_image" == "$FAKE_APP_IMAGE" ]] || exit 98 [[ "$route_image" == "$FAKE_ROUTE_IMAGE" ]] || exit 99 if [[ "${FAKE_DEPLOY_STATUS:-0}" -ne 0 ]]; then exit "$FAKE_DEPLOY_STATUS" fi fi cp "$images_file" "$WATERMAPS_ACTIVE_IMAGES_FILE" DEPLOY chmod 0755 "$destination/deploy/scripts/"*.sh { printf 'format_version=1\n' printf 'revision=%s\n' "$revision" printf 'source=https://registry.example/team/watermaps\n' printf 'app_image=%s\n' "$FAKE_APP_IMAGE" printf 'route_data_image=%s\n' "$FAKE_ROUTE_IMAGE" } >"$destination/release.env" ( cd "$destination" { sha256sum release.env find deploy database -type f -print | LC_ALL=C sort | xargs sha256sum } >SHA256SUMS ) if [[ "${FAKE_CORRUPT_BUNDLE:-0}" == "1" ]]; then printf 'nachträgliche Manipulation\n' >>"$destination/database/schema.sql" fi SH cat >"$FAKE_BIN/id" <<'SH' #!/usr/bin/env bash if [[ "${1:-}" == "-u" ]]; then printf '0\n' else exec /usr/bin/id "$@" fi SH cat >"$FAKE_BIN/mountpoint" <<'SH' #!/usr/bin/env bash exit 0 SH cat >"$FAKE_BIN/curl" <<'SH' #!/usr/bin/env bash printf 'curl|%s\n' "$*" >>"$FAKE_CURL_LOG" printf '{"commit":{"id":"%s"}}\n' "$FAKE_MAIN_REVISION" SH cat >"$FAKE_BIN/jq" <<'SH' #!/usr/bin/env bash set -Eeuo pipefail input="$(tr -d '\r\n')" revision="${input#*\"id\":\"}" revision="${revision%%\"*}" [[ "$revision" =~ ^[0-9a-f]{40}$ ]] || exit 1 printf '%s\n' "$revision" SH cat >"$FAKE_BIN/docker" <<'SH' #!/usr/bin/env bash set -Eeuo pipefail printf 'docker|%s\n' "$*" >>"$FAKE_DOCKER_LOG" command_name="${1:-}" shift || true case "$command_name" in pull) [[ -n "${1:-}" ]] ;; image) [[ "${1:-}" == "inspect" ]] || exit 91 shift reference="${!#}" if [[ "$*" == *'org.opencontainers.image.revision'* ]]; then if [[ "$reference" == "$FAKE_RELEASE_POINTER" ]]; then printf '%s\n' "$FAKE_POINTER_REVISION" else printf '%s\n' "$FAKE_REVISION" fi elif [[ "$*" == *'{{.Id}}'* ]]; then if [[ "$reference" == "$FAKE_RELEASE_POINTER" ]]; then printf '%s\n' "$FAKE_POINTER_ID" else printf '%s\n' "$FAKE_IMMUTABLE_ID" fi elif [[ "$*" == *'.RepoDigests'* ]]; then [[ "$reference" == *@sha256:* ]] || exit 92 printf '%s\n' "$reference" else exit 93 fi ;; create) printf 'fake-release-container\n' ;; cp) [[ "${1:-}" == "fake-release-container:/release/." ]] || exit 94 destination="${2%/}" fake-populate-release "$destination" "$FAKE_REVISION" ;; container) [[ "${1:-}" == "rm" ]] || exit 95 ;; *) printf 'Unerwarteter Docker-Aufruf: %s %s\n' "$command_name" "$*" >&2 exit 96 ;; esac SH cat >"$FAKE_BIN/install" <<'SH' #!/usr/bin/env bash set -Eeuo pipefail if [[ "${1:-}" == "-d" ]]; then exec /usr/bin/install "$@" fi destination="${!#}" if [[ "$destination" == "/usr/local/sbin/watermaps-auto-deploy" ]]; then source_file="${@: -2:1}" [[ -x "$source_file" ]] printf '%s\n' "watermaps-auto-deploy-entrypoint" >>"$FAKE_INSTALL_LOG" exit 0 fi if [[ "$destination" == "/etc/systemd/system/" ]]; then source_file="${@: -2:1}" [[ -f "$source_file" ]] printf '%s\n' "$(basename "$source_file")" >>"$FAKE_INSTALL_LOG" exit 0 fi exec /usr/bin/install "$@" SH cat >"$FAKE_BIN/systemctl" <<'SH' #!/usr/bin/env bash printf '%s\n' "$*" >>"$FAKE_SYSTEMCTL_LOG" SH chmod 0755 "$FAKE_BIN/"* export PATH="$FAKE_BIN:/usr/bin:/bin" new_case() { local name="$1" CASE_DIR="$TEST_ROOT/$name" CASE_DATA="$CASE_DIR/data" CASE_RUNTIME="$CASE_DIR/runtime" CASE_INSTALL="$CASE_DIR/install" CASE_ENV="$CASE_DIR/production.env" mkdir -p \ "$CASE_DATA" \ "$CASE_RUNTIME" \ "$CASE_INSTALL/deploy" FAKE_DOCKER_LOG="$CASE_DIR/docker.log" FAKE_DEPLOY_LOG="$CASE_DIR/deploy.log" FAKE_SYSTEMCTL_LOG="$CASE_DIR/systemctl.log" FAKE_INSTALL_LOG="$CASE_DIR/install.log" FAKE_CURL_LOG="$CASE_DIR/curl.log" : >"$FAKE_DOCKER_LOG" : >"$FAKE_DEPLOY_LOG" : >"$FAKE_SYSTEMCTL_LOG" : >"$FAKE_INSTALL_LOG" : >"$FAKE_CURL_LOG" cat >"$CASE_ENV" <&1)" RUN_STATUS=$? set -e } assert_systemd_installed() { local expected_units=( watermaps-auto-deploy.service watermaps-auto-deploy.timer watermaps-route-update.service watermaps-route-update.timer watermaps-certbot-renew.service watermaps-certbot-renew.timer ) local unit [[ "$(wc -l <"$FAKE_INSTALL_LOG")" -eq "$((${#expected_units[@]} + 1))" ]] || fail "Es wurden nicht exakt sechs Systemd-Units und der stabile Einstiegspunkt installiert." grep -Fxq "watermaps-auto-deploy-entrypoint" "$FAKE_INSTALL_LOG" || fail "Stabiler Auto-Deploy-Einstiegspunkt wurde nicht installiert." for unit in "${expected_units[@]}"; do grep -Fxq "$unit" "$FAKE_INSTALL_LOG" || fail "Systemd-Unit wurde nicht installiert: $unit" done grep -Fxq 'daemon-reload' "$FAKE_SYSTEMCTL_LOG" || fail "systemctl daemon-reload fehlt." assert_file_contains \ 'enable --now watermaps-auto-deploy.timer watermaps-route-update.timer watermaps-certbot-renew.timer' \ "$FAKE_SYSTEMCTL_LOG" } test_already_active_repairs_current_link() { new_case already-active mkdir -p "$CASE_DATA/releases" populate_release "$REVISION" write_images_file \ "$CASE_INSTALL/deploy/.env.images" \ "$REVISION" \ "$APP_IMAGE" \ "$ROUTE_IMAGE" mkdir -p "$CASE_DIR/wrong-release" ln -s "$CASE_DIR/wrong-release" "$CASE_INSTALL/current" run_auto_deploy [[ "$RUN_STATUS" -eq 0 ]] || fail "Bereits aktives Release schlug fehl: $RUN_OUTPUT" assert_contains "Der atomare Current-Link für Commit $REVISION wurde repariert." "$RUN_OUTPUT" assert_contains "Commit $REVISION ist bereits aktiv." "$RUN_OUTPUT" [[ "$(readlink --canonicalize "$CASE_INSTALL/current")" == \ "$(readlink --canonicalize "$CASE_DATA/releases/$REVISION")" ]] || fail "Current-Link wurde für das aktive Release nicht repariert." [[ ! -s "$FAKE_DEPLOY_LOG" ]] || fail "Ein bereits aktives Release darf deploy.sh nicht erneut ausführen." assert_file_not_contains 'docker|create ' "$FAKE_DOCKER_LOG" assert_file_not_contains 'docker|cp ' "$FAKE_DOCKER_LOG" assert_systemd_installed assert_no_temporary_artifacts } test_successful_release_uses_manifest_digests() { new_case successful-release run_auto_deploy [[ "$RUN_STATUS" -eq 0 ]] || fail "Neues Release schlug fehl: $RUN_OUTPUT" assert_contains "Automatisches Deployment von Commit $REVISION abgeschlossen." "$RUN_OUTPUT" [[ -f "$CASE_DATA/releases/$REVISION/SHA256SUMS" ]] || fail "Validiertes Release-Bundle wurde nicht atomar installiert." ( cd "$CASE_DATA/releases/$REVISION" sha256sum --check --strict SHA256SUMS >/dev/null ) || fail "Installiertes Release-Bundle besteht seine SHA256-Prüfung nicht." [[ "$(readlink --canonicalize "$CASE_INSTALL/current")" == \ "$(readlink --canonicalize "$CASE_DATA/releases/$REVISION")" ]] || fail "Current-Link zeigt nicht auf das neue Release." [[ "$(wc -l <"$FAKE_DEPLOY_LOG")" -eq 1 ]] || fail "Das neue Release muss deploy.sh exakt einmal ausführen." IFS='|' read -r invoked_script deployed_revision rollback_disabled \ images_file deployed_app deployed_route <"$FAKE_DEPLOY_LOG" [[ "$invoked_script" == \ "$CASE_DATA/releases/$REVISION/deploy/scripts/deploy.sh" ]] || fail "deploy.sh wurde nicht aus dem unveränderlichen Release-Bundle ausgeführt." [[ "$deployed_revision" == "$REVISION" ]] || fail "deploy.sh erhielt die falsche Revision." [[ "$rollback_disabled" == "true" ]] || fail "Der interne Rollback muss beim orchestrierten Deployment deaktiviert sein." [[ "$images_file" == "$CASE_RUNTIME"/.auto-deploy-images.* ]] || fail "deploy.sh erhielt keine temporäre, validierte Image-Datei." [[ "$deployed_app" == "$APP_IMAGE" && "$deployed_route" == "$ROUTE_IMAGE" ]] || fail "deploy.sh erhielt nicht die im Release-Manifest gebundenen Digests." [[ ! -e "$images_file" ]] || fail "Temporäre Image-Datei blieb nach dem Deployment bestehen." assert_file_contains "docker|pull $APP_IMAGE" "$FAKE_DOCKER_LOG" assert_file_contains "docker|pull $ROUTE_IMAGE" "$FAKE_DOCKER_LOG" assert_file_not_contains "docker|pull registry.example/team/watermaps:$REVISION" \ "$FAKE_DOCKER_LOG" assert_file_contains "WATERMAPS_DEPLOY_REVISION=$REVISION" \ "$CASE_INSTALL/deploy/.env.images" assert_file_contains "WATERMAPS_APP_IMAGE=$APP_IMAGE" \ "$CASE_INSTALL/deploy/.env.images" assert_file_contains "WATERMAPS_ROUTE_DATA_IMAGE=$ROUTE_IMAGE" \ "$CASE_INSTALL/deploy/.env.images" assert_systemd_installed assert_no_temporary_artifacts } test_pointer_main_mismatch_does_not_deploy() { new_case pointer-main-mismatch export FAKE_MAIN_REVISION="$PREVIOUS_REVISION" run_auto_deploy [[ "$RUN_STATUS" -eq 0 ]] || fail "Pointer/main-Abweichung soll sauber warten: $RUN_OUTPUT" assert_contains \ "Release $REVISION ist nicht der aktuelle main-Commit $PREVIOUS_REVISION" \ "$RUN_OUTPUT" [[ ! -e "$CASE_DATA/releases/$REVISION" ]] || fail "Bei Pointer/main-Abweichung darf kein Bundle extrahiert werden." [[ ! -L "$CASE_INSTALL/current" ]] || fail "Bei Pointer/main-Abweichung darf Current nicht verändert werden." [[ ! -s "$FAKE_DEPLOY_LOG" ]] || fail "Bei Pointer/main-Abweichung darf deploy.sh nicht laufen." [[ ! -s "$FAKE_SYSTEMCTL_LOG" && ! -s "$FAKE_INSTALL_LOG" ]] || fail "Bei Pointer/main-Abweichung darf Systemd nicht verändert werden." assert_file_contains "docker|pull $RELEASE_POINTER" "$FAKE_DOCKER_LOG" assert_file_not_contains "docker|pull $RELEASE_IMAGE" "$FAKE_DOCKER_LOG" assert_file_not_contains 'docker|create ' "$FAKE_DOCKER_LOG" assert_no_temporary_artifacts } test_failed_release_restores_previous_immutable_release() { new_case failed-release mkdir -p "$CASE_DATA/releases" "$CASE_RUNTIME/deployments" populate_release "$PREVIOUS_REVISION" write_images_file \ "$CASE_INSTALL/deploy/.env.images" \ "$PREVIOUS_REVISION" \ "$PREVIOUS_APP_IMAGE" \ "$PREVIOUS_ROUTE_IMAGE" cp \ "$CASE_INSTALL/deploy/.env.images" \ "$CASE_RUNTIME/deployments/$PREVIOUS_REVISION.env" ln -s "$CASE_DATA/releases/$PREVIOUS_REVISION" "$CASE_INSTALL/current" export FAKE_DEPLOY_STATUS=42 run_auto_deploy [[ "$RUN_STATUS" -eq 42 ]] || fail "Fehlgeschlagenes Release muss seinen Status weitergeben: $RUN_OUTPUT" assert_contains \ "Das vorherige Release $PREVIOUS_REVISION wird aus seinem unveränderlichen Bundle wiederhergestellt." \ "$RUN_OUTPUT" assert_contains \ "Vorheriges Release $PREVIOUS_REVISION wurde erneut geprüft und ist aktiv." \ "$RUN_OUTPUT" [[ "$(wc -l <"$FAKE_DEPLOY_LOG")" -eq 2 ]] || fail "Nach einem Fehler müssen Kandidat und vorheriges Release genau einmal laufen." candidate_log="$(sed -n '1p' "$FAKE_DEPLOY_LOG")" previous_log="$(sed -n '2p' "$FAKE_DEPLOY_LOG")" assert_contains \ "$CASE_DATA/releases/$REVISION/deploy/scripts/deploy.sh|$REVISION|true|" \ "$candidate_log" assert_contains "|$APP_IMAGE|$ROUTE_IMAGE" "$candidate_log" assert_contains \ "$CASE_DATA/releases/$PREVIOUS_REVISION/deploy/scripts/deploy.sh|$PREVIOUS_REVISION|true|$CASE_RUNTIME/deployments/$PREVIOUS_REVISION.env|" \ "$previous_log" assert_contains "|$PREVIOUS_APP_IMAGE|$PREVIOUS_ROUTE_IMAGE" "$previous_log" [[ "$(readlink --canonicalize "$CASE_INSTALL/current")" == \ "$(readlink --canonicalize "$CASE_DATA/releases/$PREVIOUS_REVISION")" ]] || fail "Current-Link wurde nach dem Fehler nicht auf das vorherige Release zurückgesetzt." assert_file_contains "WATERMAPS_DEPLOY_REVISION=$PREVIOUS_REVISION" \ "$CASE_INSTALL/deploy/.env.images" [[ ! -s "$FAKE_SYSTEMCTL_LOG" && ! -s "$FAKE_INSTALL_LOG" ]] || fail "Ein fehlgeschlagener Kandidat darf keine neuen Systemd-Units aktivieren." assert_no_temporary_artifacts } test_invalid_bundle_is_rejected_and_cleaned() { new_case corrupt-release export FAKE_CORRUPT_BUNDLE=1 run_auto_deploy [[ "$RUN_STATUS" -ne 0 ]] || fail "Ein manipuliertes Release-Bundle wurde akzeptiert." assert_contains "Release-Bundle hat die Integritätsprüfung nicht bestanden." "$RUN_OUTPUT" [[ ! -e "$CASE_DATA/releases/$REVISION" ]] || fail "Manipuliertes Bundle wurde in den Release-Store verschoben." [[ ! -s "$FAKE_DEPLOY_LOG" ]] || fail "Manipuliertes Bundle darf deploy.sh nicht ausführen." [[ ! -s "$FAKE_SYSTEMCTL_LOG" && ! -s "$FAKE_INSTALL_LOG" ]] || fail "Manipuliertes Bundle darf Systemd nicht verändern." assert_file_contains 'docker|container rm fake-release-container' "$FAKE_DOCKER_LOG" assert_no_temporary_artifacts } test_stable_entrypoint_bootstraps_and_then_uses_current() { local entrypoint_root="$TEST_ROOT/stable-entrypoint" local output mkdir -p "$entrypoint_root/deploy/scripts" cat >"$entrypoint_root/deploy/scripts/auto-deploy.sh" <<'SH' #!/usr/bin/env bash printf 'bootstrap\n' SH chmod 0755 "$entrypoint_root/deploy/scripts/auto-deploy.sh" output="$( WATERMAPS_INSTALL_DIR="$entrypoint_root" \ "$ROOT_DIR/deploy/scripts/auto-deploy-entrypoint.sh" )" [[ "$output" == "bootstrap" ]] || fail "Stabiler Einstiegspunkt verwendet vor dem ersten Release nicht das Bootstrap-Skript." mkdir -p "$entrypoint_root/current/deploy/scripts" cat >"$entrypoint_root/current/deploy/scripts/auto-deploy.sh" <<'SH' #!/usr/bin/env bash printf 'current\n' SH chmod 0755 "$entrypoint_root/current/deploy/scripts/auto-deploy.sh" output="$( WATERMAPS_INSTALL_DIR="$entrypoint_root" \ "$ROOT_DIR/deploy/scripts/auto-deploy-entrypoint.sh" )" [[ "$output" == "current" ]] || fail "Stabiler Einstiegspunkt wechselt nach der Aktivierung nicht auf Current." } test_stable_entrypoint_bootstraps_and_then_uses_current test_already_active_repairs_current_link test_successful_release_uses_manifest_digests test_pointer_main_mismatch_does_not_deploy test_failed_release_restores_previous_immutable_release test_invalid_bundle_is_rejected_and_cleaned printf 'Auto-Deploy: Manifest-Digests, atomare Aktivierung und Rollback: OK\n'