Automate immutable production deployments
Test and publish container images / test (push) Successful in 2m43s
Test and publish container images / publish (push) Successful in 2m56s

This commit is contained in:
BuTzZ
2026-07-29 13:00:50 +02:00
parent 2e84f4eae4
commit 2064570913
34 changed files with 1779 additions and 215 deletions
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -Eeuo pipefail
install_root="${WATERMAPS_INSTALL_DIR:-/opt/watermaps}"
[[ "$install_root" == /* && "$install_root" != "/" ]] || {
printf 'Ungültiges Watermaps-Installationsverzeichnis: %s\n' "$install_root" >&2
exit 1
}
current_script="$install_root/current/deploy/scripts/auto-deploy.sh"
bootstrap_script="$install_root/deploy/scripts/auto-deploy.sh"
if [[ -x "$current_script" ]]; then
exec "$current_script"
fi
if [[ -x "$bootstrap_script" ]]; then
exec "$bootstrap_script"
fi
printf 'Kein Watermaps-Auto-Deploy-Skript gefunden.\n' >&2
exit 1
+414
View File
@@ -0,0 +1,414 @@
#!/usr/bin/env bash
set -Eeuo pipefail
WM_DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=common.sh
source "$WM_DEPLOY_DIR/scripts/common.sh"
[[ "$(id -u)" -eq 0 ]] ||
wm_die "Dieses Skript muss als root ausgeführt werden."
wm_load_env
wm_assert_data_mount
registry="${WATERMAPS_REGISTRY:-}"
registry_owner="${WATERMAPS_REGISTRY_OWNER:-}"
repository="${WATERMAPS_GITEA_REPOSITORY:-$registry_owner/watermaps}"
[[ "$registry" =~ ^[a-z0-9][a-z0-9.-]*(:[0-9]{1,5})?$ ]] ||
wm_die "WATERMAPS_REGISTRY muss ein Registry-Hostname ohne URL-Schema sein."
[[ "$registry_owner" =~ ^[A-Za-z0-9_.-]+$ ]] ||
wm_die "WATERMAPS_REGISTRY_OWNER enthält keinen gültigen Gitea-Besitzer."
[[ "$repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] ||
wm_die "WATERMAPS_GITEA_REPOSITORY muss Besitzer und Repository enthalten."
release_repository="$registry/$registry_owner/watermaps-release"
release_pointer="$release_repository:main"
app_repository="$registry/$registry_owner/watermaps"
route_data_repository="$registry/$registry_owner/watermaps-route-data"
release_store="$WATERMAPS_DATA_DIR/releases"
install_root="${WATERMAPS_INSTALL_DIR:-/opt/watermaps}"
wm_require_safe_absolute_dir "$install_root"
shared_deploy_dir="$install_root/deploy"
active_images_file="$shared_deploy_dir/.env.images"
current_release_link="$install_root/current"
release_retention="${WATERMAPS_RELEASE_RETENTION:-5}"
[[ "$release_retention" =~ ^[2-9]$|^1[0-9]$|^20$ ]] ||
wm_die "WATERMAPS_RELEASE_RETENTION muss zwischen 2 und 20 liegen."
install -d -m 0755 \
"$WATERMAPS_RUNTIME_DIR/locks" \
"$release_store" \
"$shared_deploy_dir"
exec 8>"$WATERMAPS_RUNTIME_DIR/locks/auto-deploy.lock"
if ! flock --nonblock 8; then
wm_log "Eine automatische Release-Prüfung läuft bereits."
exit 0
fi
temporary_release_dir=""
temporary_images_file=""
temporary_current_link=""
release_container_id=""
release_directory=""
cleanup() {
local status=$?
trap - EXIT HUP INT TERM
set +e
if [[ -n "$release_container_id" ]]; then
docker container rm --force "$release_container_id" >/dev/null 2>&1
fi
if [[ -n "$temporary_release_dir" && -d "$temporary_release_dir" ]]; then
rm -rf -- "$temporary_release_dir"
fi
if [[ -n "$temporary_images_file" && -f "$temporary_images_file" ]]; then
rm -f -- "$temporary_images_file"
fi
if [[ -n "$temporary_current_link" && -L "$temporary_current_link" ]]; then
rm -f -- "$temporary_current_link"
fi
exit "$status"
}
trap cleanup EXIT HUP INT TERM
image_revision() {
local image_reference="$1"
local revision
revision="$(
docker image inspect \
--format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' \
"$image_reference"
)"
[[ "$revision" =~ ^[0-9a-f]{40}$ ]] ||
wm_die "Image $image_reference enthält kein gültiges OCI-Revision-Label."
printf '%s\n' "$revision"
}
image_id() {
docker image inspect --format '{{.Id}}' "$1"
}
gitea_main_revision() {
local api_url
api_url="https://$registry/api/v1/repos/$repository/branches/main"
curl \
--fail \
--silent \
--show-error \
--location \
--max-time 30 \
"$api_url" |
jq --exit-status --raw-output '
.commit.id
| select(type == "string" and test("^[0-9a-f]{40}$"))
'
}
validate_release_bundle() {
local directory="$1"
local expected_revision="$2"
local required_file
local manifest_revision manifest_source manifest_app manifest_route
for required_file in \
SHA256SUMS \
release.env \
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/deploy.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
[[ -f "$directory/$required_file" ]] ||
wm_die "Release-Bundle ist unvollständig: $required_file fehlt."
done
(
cd "$directory"
sha256sum --check --strict SHA256SUMS >/dev/null
) || wm_die "Release-Bundle hat die Integritätsprüfung nicht bestanden."
[[ "$(wm_env_value "$directory/release.env" format_version)" == "1" ]] ||
wm_die "Release-Manifest hat eine unbekannte Formatversion."
manifest_revision="$(wm_env_value "$directory/release.env" revision)"
manifest_source="$(wm_env_value "$directory/release.env" source)"
manifest_app="$(wm_env_value "$directory/release.env" app_image)"
manifest_route="$(wm_env_value "$directory/release.env" route_data_image)"
[[ "$manifest_revision" == "$expected_revision" ]] ||
wm_die "Release-Manifest und erwartete Revision stimmen nicht überein."
[[ "$manifest_source" == "https://$registry/$repository" ]] ||
wm_die "Release-Manifest verweist auf eine unerwartete Quelle."
[[ "$manifest_app" =~ ^${app_repository//./\\.}@sha256:[0-9a-f]{64}$ ]] ||
wm_die "Release-Manifest enthält kein gültiges App-Image."
[[ "$manifest_route" =~ ^${route_data_repository//./\\.}@sha256:[0-9a-f]{64}$ ]] ||
wm_die "Release-Manifest enthält kein gültiges Routingdaten-Image."
}
ensure_release_bundle() {
local revision="$1"
local image_reference="$release_repository:$revision"
local destination="$release_store/$revision"
if [[ -d "$destination" ]]; then
validate_release_bundle "$destination" "$revision"
release_directory="$destination"
return
fi
docker pull "$image_reference" >/dev/null
[[ "$(image_revision "$image_reference")" == "$revision" ]] ||
wm_die "Release-Image und erwartete Revision stimmen nicht überein."
temporary_release_dir="$(mktemp -d "$release_store/.candidate-$revision.XXXXXX")"
release_container_id="$(docker create "$image_reference")"
docker cp "$release_container_id:/release/." "$temporary_release_dir/"
docker container rm "$release_container_id" >/dev/null
release_container_id=""
validate_release_bundle "$temporary_release_dir" "$revision"
chmod 0755 "$temporary_release_dir/deploy/scripts/"*.sh
mv "$temporary_release_dir" "$destination"
temporary_release_dir=""
release_directory="$destination"
}
install_release_units() {
local source_directory="$1"
install -m 0755 \
"$source_directory/deploy/scripts/auto-deploy-entrypoint.sh" \
/usr/local/sbin/watermaps-auto-deploy
install -m 0644 \
"$source_directory/deploy/systemd/watermaps-auto-deploy.service" \
/etc/systemd/system/
install -m 0644 \
"$source_directory/deploy/systemd/watermaps-auto-deploy.timer" \
/etc/systemd/system/
install -m 0644 \
"$source_directory/deploy/systemd/watermaps-route-update.service" \
/etc/systemd/system/
install -m 0644 \
"$source_directory/deploy/systemd/watermaps-route-update.timer" \
/etc/systemd/system/
install -m 0644 \
"$source_directory/deploy/systemd/watermaps-certbot-renew.service" \
/etc/systemd/system/
install -m 0644 \
"$source_directory/deploy/systemd/watermaps-certbot-renew.timer" \
/etc/systemd/system/
systemctl daemon-reload
systemctl enable --now \
watermaps-auto-deploy.timer \
watermaps-route-update.timer \
watermaps-certbot-renew.timer \
>/dev/null
}
activate_release() {
local source_directory="$1"
temporary_current_link="$install_root/.current.$$.new"
rm -f -- "$temporary_current_link"
ln --symbolic "$source_directory" "$temporary_current_link"
mv --no-target-directory "$temporary_current_link" "$current_release_link"
temporary_current_link=""
}
prune_old_releases() {
local candidate old_revision old_directory old_app old_route
local retained_revision retained_manifest image_is_retained
local -a revisions=()
local -A keep=()
mapfile -t revisions < <(
find "$release_store" \
-mindepth 1 \
-maxdepth 1 \
-type d \
-printf '%T@ %f\n' |
sort --numeric-sort --reverse |
awk '$2 ~ /^[0-9a-f]{40}$/ { print $2 }'
)
keep["$revision"]=1
if [[ -n "$active_revision" ]]; then
keep["$active_revision"]=1
fi
for candidate in "${revisions[@]:0:release_retention}"; do
keep["$candidate"]=1
done
for old_revision in "${revisions[@]}"; do
[[ -z "${keep[$old_revision]:-}" ]] || continue
[[ "$old_revision" =~ ^[0-9a-f]{40}$ ]] || continue
old_directory="$release_store/$old_revision"
[[ -d "$old_directory" ]] || continue
old_app="$(wm_env_value "$old_directory/release.env" app_image)"
old_route="$(wm_env_value "$old_directory/release.env" route_data_image)"
for candidate in "$old_app" "$old_route"; do
image_is_retained=false
for retained_revision in "${!keep[@]}"; do
retained_manifest="$release_store/$retained_revision/release.env"
if [[ -f "$retained_manifest" ]] &&
grep --fixed-strings --line-regexp --quiet \
"app_image=$candidate" "$retained_manifest" ||
[[ -f "$retained_manifest" ]] &&
grep --fixed-strings --line-regexp --quiet \
"route_data_image=$candidate" "$retained_manifest"; then
image_is_retained=true
break
fi
done
if [[ "$image_is_retained" == "false" ]]; then
docker image rm "$candidate" >/dev/null 2>&1 || true
fi
done
docker image rm \
"$app_repository:$old_revision" \
"$route_data_repository:$old_revision" \
"$release_repository:$old_revision" \
>/dev/null 2>&1 ||
true
rm -rf -- "$old_directory"
wm_log "Altes lokales Release $old_revision wurde gezielt bereinigt."
done
}
wm_log "Prüfe den atomaren Gitea-Release-Pointer."
docker pull "$release_pointer" >/dev/null
revision="$(image_revision "$release_pointer")"
release_pointer_id="$(image_id "$release_pointer")"
main_revision="$(gitea_main_revision)"
if [[ "$revision" != "$main_revision" ]]; then
wm_log "Release $revision ist nicht der aktuelle main-Commit $main_revision; warte auf den laufenden Build."
exit 0
fi
release_image="$release_repository:$revision"
docker pull "$release_image" >/dev/null
[[ "$(image_id "$release_image")" == "$release_pointer_id" ]] ||
wm_die "Der bewegliche Release-Pointer stimmt nicht mit dem unveränderlichen Commit-Image überein."
ensure_release_bundle "$revision"
active_revision=""
if [[ -f "$active_images_file" ]]; then
wm_validate_images_env "$active_images_file"
active_revision="$(wm_env_value "$active_images_file" WATERMAPS_DEPLOY_REVISION)"
fi
if [[ "$active_revision" == "$revision" ]]; then
current_release_target="$(
readlink --canonicalize "$current_release_link" 2>/dev/null || true
)"
expected_release_target="$(readlink --canonicalize "$release_directory")"
if [[ ! -L "$current_release_link" ||
"$current_release_target" != "$expected_release_target" ]]; then
activate_release "$release_directory"
install_release_units "$release_directory"
wm_log "Der atomare Current-Link für Commit $revision wurde repariert."
fi
wm_log "Commit $revision ist bereits aktiv."
exit 0
fi
app_image="$(wm_env_value "$release_directory/release.env" app_image)"
route_data_image="$(wm_env_value "$release_directory/release.env" route_data_image)"
docker pull "$app_image" >/dev/null
docker pull "$route_data_image" >/dev/null
[[ "$(image_revision "$app_image")" == "$revision" ]] ||
wm_die "App-Image gehört nicht zu Release $revision."
[[ "$(image_revision "$route_data_image")" == "$revision" ]] ||
wm_die "Routingdaten-Image gehört nicht zu Release $revision."
resolved_app_image="$(wm_resolve_image_digest "$app_image")"
resolved_route_data_image="$(wm_resolve_image_digest "$route_data_image")"
temporary_images_file="$(
mktemp "$WATERMAPS_RUNTIME_DIR/.auto-deploy-images.XXXXXX"
)"
{
printf 'WATERMAPS_DEPLOY_REVISION=%s\n' "$revision"
printf 'WATERMAPS_APP_IMAGE=%s\n' "$resolved_app_image"
printf 'WATERMAPS_ROUTE_DATA_IMAGE=%s\n' "$resolved_route_data_image"
} >"$temporary_images_file"
chmod 0600 "$temporary_images_file"
wm_validate_images_env "$temporary_images_file"
# A stale workflow must never activate after a newer push. Pull and compare the
# single release pointer immediately before changing deployment files.
docker pull "$release_pointer" >/dev/null
[[ "$(image_revision "$release_pointer")" == "$revision" ]] ||
wm_die "Während der Vorbereitung wurde ein neueres Release veröffentlicht."
[[ "$(image_id "$release_pointer")" == "$release_pointer_id" ]] ||
wm_die "Der Release-Pointer wurde während der Vorbereitung ausgetauscht."
[[ "$(gitea_main_revision)" == "$revision" ]] ||
wm_die "main wurde während der Vorbereitung auf einen neueren Commit verschoben."
previous_release_directory=""
previous_images_file=""
if [[ -n "$active_revision" ]]; then
previous_release_directory="$release_store/$active_revision"
previous_images_file="$WATERMAPS_RUNTIME_DIR/deployments/$active_revision.env"
if [[ ! -d "$previous_release_directory" ||
! -f "$previous_images_file" ]]; then
previous_release_directory=""
previous_images_file=""
fi
fi
set +e
WATERMAPS_ENV_FILE="$WM_ENV_FILE" \
WATERMAPS_ACTIVE_IMAGES_FILE="$active_images_file" \
WATERMAPS_DISABLE_INTERNAL_ROLLBACK=true \
"$release_directory/deploy/scripts/deploy.sh" \
--images-file "$temporary_images_file"
deploy_status=$?
set -e
if [[ "$deploy_status" -ne 0 ]]; then
if [[ "$deploy_status" -eq 75 ]]; then
wm_log "Ein Datenupdate oder manuelles Deployment läuft; das Release wird später erneut versucht."
exit 0
fi
if [[ -n "$previous_release_directory" &&
-n "$previous_images_file" ]]; then
wm_log "Das vorherige Release $active_revision wird aus seinem unveränderlichen Bundle wiederhergestellt."
set +e
WATERMAPS_ENV_FILE="$WM_ENV_FILE" \
WATERMAPS_ACTIVE_IMAGES_FILE="$active_images_file" \
WATERMAPS_DISABLE_INTERNAL_ROLLBACK=true \
"$previous_release_directory/deploy/scripts/deploy.sh" \
--images-file "$previous_images_file"
rollback_status=$?
set -e
if [[ "$rollback_status" -ne 0 ]]; then
wm_die "Release $revision und die Wiederherstellung von $active_revision sind fehlgeschlagen."
fi
activate_release "$previous_release_directory"
wm_log "Vorheriges Release $active_revision wurde erneut geprüft und ist aktiv."
fi
exit "$deploy_status"
fi
activate_release "$release_directory"
install_release_units "$release_directory"
prune_old_releases
wm_log "Automatisches Deployment von Commit $revision abgeschlossen."
+9 -1
View File
@@ -113,7 +113,15 @@ install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-route-update.service" /etc/sys
install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-route-update.timer" /etc/systemd/system/
install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-certbot-renew.service" /etc/systemd/system/
install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-certbot-renew.timer" /etc/systemd/system/
install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-auto-deploy.service" /etc/systemd/system/
install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-auto-deploy.timer" /etc/systemd/system/
install -m 0755 \
"$WM_DEPLOY_DIR/scripts/auto-deploy-entrypoint.sh" \
/usr/local/sbin/watermaps-auto-deploy
systemctl daemon-reload
systemctl enable --now watermaps-route-update.timer watermaps-certbot-renew.timer
systemctl enable \
watermaps-route-update.timer \
watermaps-certbot-renew.timer
systemctl enable --now watermaps-auto-deploy.timer
wm_log "Server-Bootstrap abgeschlossen."
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
set +x
set -Eeuo pipefail
WM_DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WM_ROOT_DIR="$(cd "$WM_DEPLOY_DIR/.." && pwd)"
config_token_file="${WATERMAPS_GITEA_CONFIG_TOKEN_FILE:-$WM_DEPLOY_DIR/.gitea-actions-config-token}"
registry_token_file="${WATERMAPS_GITEA_REGISTRY_TOKEN_FILE:-$WM_DEPLOY_DIR/.gitea-registry-publish-token}"
gitea_url="${WATERMAPS_GITEA_URL:-https://gitea.incoso.eu}"
repository="${WATERMAPS_GITEA_REPOSITORY:-kevin_janssen/watermaps}"
jq_command="$(command -v jq 2>/dev/null || true)"
if [[ -z "$jq_command" && -x "$WM_ROOT_DIR/.tools/bin/jq" ]]; then
jq_command="$WM_ROOT_DIR/.tools/bin/jq"
fi
[[ -n "$jq_command" ]] || {
printf 'jq fehlt; bitte jq installieren oder unter .tools/bin/jq bereitstellen.\n' >&2
exit 1
}
cleanup_secrets() {
unset config_token registry_token token_payload
}
trap cleanup_secrets EXIT
read_secret_file() {
local label="$1"
local path="$2"
local mode
local value
[[ -f "$path" && ! -L "$path" ]] || {
printf '%s fehlt oder ist keine reguläre Datei: %s\n' "$label" "$path" >&2
return 1
}
mode="$(stat --format=%a -- "$path")"
[[ "$mode" == "600" ]] || {
printf '%s muss Dateimodus 600 haben (chmod 600 %s; aktuell: %s).\n' \
"$label" "$path" "$mode" >&2
return 1
}
value="$(<"$path")"
[[ -n "$value" && "$value" != *[[:space:]]* ]] || {
printf '%s ist leer oder enthält Leerzeichen beziehungsweise Zeilenumbrüche.\n' \
"$label" >&2
return 1
}
printf '%s' "$value"
}
config_token="$(
read_secret_file \
"Lokaler Gitea-Konfigurations-PAT" \
"$config_token_file"
)"
registry_token="$(
read_secret_file \
"Gitea-Registry-Publish-PAT" \
"$registry_token_file"
)"
[[ ! "$config_token_file" -ef "$registry_token_file" ]] || {
printf 'Konfigurations- und Registry-PAT müssen in zwei getrennten Dateien liegen.\n' >&2
exit 1
}
[[ "$config_token" != "$registry_token" ]] || {
printf 'Konfigurations- und Registry-PAT müssen zwei unterschiedliche Tokens sein.\n' >&2
exit 1
}
[[ "$repository" =~ ^([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)$ ]] || {
printf 'Ungültiges Gitea-Repository: %s\n' "$repository" >&2
exit 1
}
registry_username="${BASH_REMATCH[1]}"
[[ "$gitea_url" =~ ^https://[^/?#]+(:[0-9]+)?$ ]] || {
printf 'Die Gitea-URL muss eine HTTPS-Origin ohne Pfad sein: %s\n' "$gitea_url" >&2
exit 1
}
api_request() {
local method="$1"
local endpoint="$2"
local payload="${3:-}"
local auth_header="Authorization: token $config_token"
local args=(
--proto '=https'
--tlsv1.2
--fail
--silent
--show-error
--connect-timeout 10
--max-time 30
--request "$method"
--header @/dev/fd/3
)
if [[ -n "$payload" ]]; then
args+=(
--header "Content-Type: application/json"
--data-binary @/dev/fd/4
)
curl "${args[@]}" "$gitea_url/api/v1/$endpoint" \
3<<<"$auth_header" \
4<<<"$payload"
else
curl "${args[@]}" "$gitea_url/api/v1/$endpoint" \
3<<<"$auth_header"
fi
}
username_payload="$(
printf '%s' "$registry_username" |
"$jq_command" --raw-input --slurp '{data: .}'
)"
token_payload="$(
printf '%s' "$registry_token" |
"$jq_command" --raw-input --slurp '{data: .}'
)"
api_request \
PUT \
"repos/$repository/actions/secrets/REGISTRY_USERNAME" \
"$username_payload" \
>/dev/null
api_request \
PUT \
"repos/$repository/actions/secrets/REGISTRY_TOKEN" \
"$token_payload" \
>/dev/null
unset config_token registry_token token_payload
printf 'Gitea Actions Registry-Secrets wurden für %s aktualisiert.\n' "$repository"
+64 -4
View File
@@ -44,10 +44,14 @@ install -d -m 0755 \
"$WATERMAPS_RUNTIME_DIR/locks" \
"$WATERMAPS_RUNTIME_DIR/deployments"
wm_acquire_route_lock ||
wm_die "Deployment abgebrochen, weil gerade Routingdaten aktualisiert werden."
if ! wm_acquire_route_lock; then
wm_log "Deployment wird später erneut versucht, weil gerade Routingdaten aktualisiert werden."
exit 75
fi
active_images_file="$WM_DEPLOY_DIR/.env.images"
active_images_file="${WATERMAPS_ACTIVE_IMAGES_FILE:-${WATERMAPS_IMAGES_ENV_FILE:-$WM_DEPLOY_DIR/.env.images}}"
[[ "$active_images_file" == /* ]] ||
wm_die "WATERMAPS_ACTIVE_IMAGES_FILE muss ein absoluter Pfad sein."
rollback_images_file=""
resolved_images_file=""
deployment_complete=false
@@ -65,7 +69,9 @@ wm_finish_deployment() {
trap - EXIT HUP INT TERM
set +e
if [[ "$status" -ne 0 && "$deployment_complete" != "true" ]]; then
if [[ "$status" -ne 0 &&
"$deployment_complete" != "true" &&
"${WATERMAPS_DISABLE_INTERNAL_ROLLBACK:-false}" != "true" ]]; then
if [[ -n "$rollback_images_file" && -f "$rollback_images_file" ]]; then
wm_log "Deployment fehlgeschlagen; vorheriges Container-Release wird wiederhergestellt."
wm_use_images_env "$rollback_images_file"
@@ -122,6 +128,60 @@ wm_log "Persistentes PostGIS wird vor App und Datenimport gestartet."
wm_compose up --detach --no-build postgres
wm_wait_for_health postgres 240
wm_log "Versionierte Datenbankmigrationen für Commit $revision werden angewendet."
wm_compose exec --no-TTY postgres \
psql \
--no-psqlrc \
--username seacompass \
--dbname seacompass \
--set ON_ERROR_STOP=1 \
--command '
CREATE TABLE IF NOT EXISTS watermaps_schema_migrations (
version text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
);
'
shopt -s nullglob
migration_files=("$WM_ROOT_DIR"/database/migrations/*.sql)
shopt -u nullglob
[[ "${#migration_files[@]}" -gt 0 ]] ||
wm_die "Keine Datenbankmigrationen im Release gefunden."
for migration_file in "${migration_files[@]}"; do
migration_name="$(basename "$migration_file")"
[[ "$migration_name" =~ ^[0-9]{4}_[a-z0-9_]+\.sql$ ]] ||
wm_die "Ungültiger Migrationsdateiname: $migration_name"
migration_version="${migration_name%.sql}"
migration_applied="$(
wm_postgres_query "
SELECT EXISTS (
SELECT 1
FROM watermaps_schema_migrations
WHERE version = '$migration_version'
);
"
)"
migration_applied="${migration_applied//[[:space:]]/}"
if [[ "$migration_applied" == "t" ]]; then
continue
fi
wm_log "Wende Datenbankmigration $migration_version atomar an."
wm_compose exec --no-TTY postgres \
psql \
--no-psqlrc \
--username seacompass \
--dbname seacompass \
--set ON_ERROR_STOP=1 \
--single-transaction \
--file "/docker-entrypoint-initdb.d/migrations/$migration_name" \
--command "
INSERT INTO watermaps_schema_migrations (version)
VALUES ('$migration_version');
"
done
data_update_required=false
update_args=()
if [[ "${WATERMAPS_REBUILD_ROUTE_DATA:-false}" == "true" ]]; then
+17 -6
View File
@@ -11,25 +11,36 @@ wm_local_die() {
exit 1
}
wm_local_iac_command() {
if command -v tofu >/dev/null 2>&1; then
command -v tofu
elif command -v terraform >/dev/null 2>&1; then
command -v terraform
else
return 1
fi
}
wm_local_resolve_ssh() {
local requested_server="${1:-}"
local requested_identity="${2:-}"
local iac_command
WM_SERVER_IPV4="${requested_server:-${WATERMAPS_SERVER_IPV4:-}}"
if [[ -z "$WM_SERVER_IPV4" ]]; then
command -v tofu >/dev/null 2>&1 ||
wm_local_die "OpenTofu fehlt und WATERMAPS_SERVER_IPV4 wurde nicht gesetzt."
iac_command="$(wm_local_iac_command)" ||
wm_local_die "OpenTofu/Terraform fehlt und WATERMAPS_SERVER_IPV4 wurde nicht gesetzt."
[[ -d "$WM_LOCAL_INFRA_DIR" ]] ||
wm_local_die "OpenTofu-Verzeichnis fehlt: $WM_LOCAL_INFRA_DIR"
WM_SERVER_IPV4="$(tofu -chdir="$WM_LOCAL_INFRA_DIR" output -raw server_ipv4)"
WM_SERVER_IPV4="$("$iac_command" -chdir="$WM_LOCAL_INFRA_DIR" output -raw server_ipv4)"
fi
[[ "$WM_SERVER_IPV4" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] ||
wm_local_die "Ungültige Server-IPv4: $WM_SERVER_IPV4"
WM_SSH_IDENTITY="${requested_identity:-${WATERMAPS_SSH_KEY:-}}"
if [[ -z "$WM_SSH_IDENTITY" ]] && command -v tofu >/dev/null 2>&1 && [[ -d "$WM_LOCAL_INFRA_DIR" ]]; then
candidate_identity="$(tofu -chdir="$WM_LOCAL_INFRA_DIR" output -raw ssh_private_key_path 2>/dev/null || true)"
if [[ -n "$candidate_identity" && -f "$candidate_identity" ]]; then
if [[ -z "$WM_SSH_IDENTITY" ]]; then
candidate_identity="$HOME/.ssh/watermaps_deploy_ed25519"
if [[ -f "$candidate_identity" ]]; then
WM_SSH_IDENTITY="$candidate_identity"
fi
fi
+1 -1
View File
@@ -45,4 +45,4 @@ if [[ -n "$WM_REMOTE_SUDO" ]]; then
fi
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
"${remote_prefix}/opt/watermaps/deploy/scripts/go-live.sh '$WM_SERVER_IPV4'"
"${remote_prefix}WATERMAPS_ENV_FILE=/opt/watermaps/deploy/.env.production WATERMAPS_IMAGES_ENV_FILE=/opt/watermaps/deploy/.env.images /opt/watermaps/current/deploy/scripts/go-live.sh '$WM_SERVER_IPV4'"
+1 -1
View File
@@ -58,4 +58,4 @@ if [[ -n "$WM_REMOTE_SUDO" ]]; then
fi
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
"${remote_prefix}/opt/watermaps/deploy/scripts/rollback.sh '$revision'"
"${remote_prefix}WATERMAPS_ENV_FILE=/opt/watermaps/deploy/.env.production WATERMAPS_IMAGES_ENV_FILE=/opt/watermaps/deploy/.env.images WATERMAPS_ACTIVE_IMAGES_FILE=/opt/watermaps/deploy/.env.images /opt/watermaps/current/deploy/scripts/rollback.sh '$revision'"
+35 -1
View File
@@ -25,4 +25,38 @@ fi
[[ -f "$images_file" ]] ||
wm_die "Gespeichertes Release nicht gefunden: $images_file"
exec "$WM_DEPLOY_DIR/scripts/deploy.sh" --images-file "$images_file"
target_revision="$(wm_env_value "$images_file" WATERMAPS_DEPLOY_REVISION)"
release_directory="$WATERMAPS_DATA_DIR/releases/$target_revision"
[[ -d "$release_directory" &&
-x "$release_directory/deploy/scripts/deploy.sh" ]] ||
wm_die "Unveränderliches Release-Bundle fehlt: $release_directory"
install_root="${WATERMAPS_INSTALL_DIR:-/opt/watermaps}"
wm_require_safe_absolute_dir "$install_root"
active_images_file="${WATERMAPS_ACTIVE_IMAGES_FILE:-$install_root/deploy/.env.images}"
WATERMAPS_ENV_FILE="$WM_ENV_FILE" \
WATERMAPS_ACTIVE_IMAGES_FILE="$active_images_file" \
"$release_directory/deploy/scripts/deploy.sh" \
--images-file "$images_file"
temporary_link="$install_root/.current.rollback.$$.new"
trap 'rm -f -- "$temporary_link"' EXIT HUP INT TERM
ln --symbolic "$release_directory" "$temporary_link"
mv --no-target-directory "$temporary_link" "$install_root/current"
temporary_link=""
for unit in \
watermaps-auto-deploy.service \
watermaps-auto-deploy.timer \
watermaps-route-update.service \
watermaps-route-update.timer \
watermaps-certbot-renew.service \
watermaps-certbot-renew.timer; do
install -m 0644 \
"$release_directory/deploy/systemd/$unit" \
"/etc/systemd/system/$unit"
done
systemctl daemon-reload
wm_log "Rollback auf Commit $target_revision ist geprüft und atomar aktiviert."
+605
View File
@@ -0,0 +1,605 @@
#!/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" <<EOF
WATERMAPS_DATA_DIR=$CASE_DATA
WATERMAPS_RUNTIME_DIR=$CASE_RUNTIME
WATERMAPS_POSTGRES_PASSWORD=0123456789abcdef0123456789abcdef
WATERMAPS_REGISTRY=registry.example
WATERMAPS_REGISTRY_OWNER=team
WATERMAPS_GITEA_REPOSITORY=team/watermaps
EOF
export CASE_DIR CASE_DATA CASE_RUNTIME CASE_INSTALL
export FAKE_DOCKER_LOG FAKE_DEPLOY_LOG FAKE_SYSTEMCTL_LOG
export FAKE_INSTALL_LOG FAKE_CURL_LOG
export FAKE_REVISION="$REVISION"
export FAKE_MAIN_REVISION="$REVISION"
export FAKE_POINTER_REVISION="$REVISION"
export FAKE_POINTER_ID="sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
export FAKE_IMMUTABLE_ID="$FAKE_POINTER_ID"
export FAKE_RELEASE_POINTER="$RELEASE_POINTER"
export FAKE_APP_IMAGE="$APP_IMAGE"
export FAKE_ROUTE_IMAGE="$ROUTE_IMAGE"
export FAKE_DEPLOY_STATUS=0
export FAKE_CORRUPT_BUNDLE=0
export WATERMAPS_ENV_FILE="$CASE_ENV"
export WATERMAPS_INSTALL_DIR="$CASE_INSTALL"
}
populate_release() {
local revision="$1"
local destination="$CASE_DATA/releases/$revision"
mkdir -p "$destination"
fake-populate-release "$destination" "$revision"
}
run_auto_deploy() {
set +e
RUN_OUTPUT="$("$AUTO_DEPLOY" 2>&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'
+8 -33
View File
@@ -99,17 +99,6 @@ if [[ -n "$registry_username" || -n "$registry_token" ]]; then
wm_local_die "WATERMAPS_REGISTRY_USERNAME enthält ungültige Zeichen."
fi
images_env="$(mktemp)"
trap 'rm -f "$images_env"' EXIT
chmod 0600 "$images_env"
{
printf 'WATERMAPS_DEPLOY_REVISION=%s\n' "$revision"
printf 'WATERMAPS_APP_IMAGE=%s/%s/watermaps:%s\n' \
"$registry" "$registry_owner" "$revision"
printf 'WATERMAPS_ROUTE_DATA_IMAGE=%s/%s/watermaps-route-data:%s\n' \
"$registry" "$registry_owner" "$revision"
} >"$images_env"
wm_local_resolve_ssh "$server_ipv4" "$identity_file"
wm_local_wait_for_ssh
@@ -124,9 +113,9 @@ printf '[watermaps] Warte auf Cloud-init und das persistente Hetzner-Volume.\n'
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
"cloud-init status --wait && ${remote_prefix}systemctl start watermaps-volume-setup.service && mountpoint --quiet /srv/watermaps-data"
printf '[watermaps] Übertrage Deployment-Dateien und Datenbankschema nach %s:/opt/watermaps\n' "$WM_SSH_TARGET"
printf '[watermaps] Übertrage Bootstrap-Dateien nach %s:/opt/watermaps\n' "$WM_SSH_TARGET"
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
"${remote_prefix}install -d -m 0755 /opt/watermaps /opt/watermaps/deploy /opt/watermaps/database"
"${remote_prefix}install -d -m 0755 /opt/watermaps /opt/watermaps/deploy"
rsync \
--archive \
@@ -136,18 +125,12 @@ rsync \
--rsync-path="$rsync_path" \
--exclude='.env.production' \
--exclude='.env.images*' \
--exclude='.gitea-token' \
--exclude='.gitea-*-token' \
-e "ssh ${WM_SSH_OPTIONS[*]@Q}" \
"$WM_DEPLOY_DIR/" \
"$WM_SSH_TARGET:/opt/watermaps/deploy/"
rsync \
--archive \
--chmod=F644 \
--rsync-path="$rsync_path" \
-e "ssh ${WM_SSH_OPTIONS[*]@Q}" \
"$WM_LOCAL_ROOT_DIR/database/schema.sql" \
"$WM_SSH_TARGET:/opt/watermaps/database/schema.sql"
rsync \
--archive \
--chmod=F600 \
@@ -156,14 +139,6 @@ rsync \
"$local_env" \
"$WM_SSH_TARGET:/opt/watermaps/deploy/.env.production"
rsync \
--archive \
--chmod=F600 \
--rsync-path="$rsync_path" \
-e "ssh ${WM_SSH_OPTIONS[*]@Q}" \
"$images_env" \
"$WM_SSH_TARGET:/opt/watermaps/deploy/.env.images.candidate"
if [[ -n "$registry_token" ]]; then
printf '[watermaps] Aktualisiere den privaten Registry-Login auf dem Server.\n'
printf '%s\n' "$registry_token" |
@@ -173,14 +148,14 @@ fi
remote_command="${remote_prefix}chmod +x /opt/watermaps/deploy/scripts/*.sh"
remote_command+=" && ${remote_prefix}/opt/watermaps/deploy/scripts/bootstrap-server.sh"
remote_command+=" && ${remote_prefix}/opt/watermaps/deploy/scripts/deploy.sh --images-file /opt/watermaps/deploy/.env.images.candidate"
remote_command+=" && ${remote_prefix}WATERMAPS_ENV_FILE=/opt/watermaps/deploy/.env.production /opt/watermaps/deploy/scripts/auto-deploy.sh"
remote_command+=" && ${remote_prefix}test -L /opt/watermaps/current"
remote_command+=" && ${remote_prefix}grep -Fxq 'WATERMAPS_DEPLOY_REVISION=$revision' /opt/watermaps/deploy/.env.images"
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" "$remote_command"
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
"${remote_prefix}rm -f /opt/watermaps/deploy/.env.images.candidate"
if [[ "$run_go_live" == "true" ]]; then
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
"${remote_prefix}/opt/watermaps/deploy/scripts/go-live.sh '$WM_SERVER_IPV4'"
"${remote_prefix}WATERMAPS_ENV_FILE=/opt/watermaps/deploy/.env.production /opt/watermaps/current/deploy/scripts/go-live.sh '$WM_SERVER_IPV4'"
else
printf '[watermaps] Commit %s wurde aus der Registry deployt. Nach dem DNS-Eintrag:\n' "$revision"
printf ' ./deploy/scripts/remote-go-live.sh --host %s\n' "$WM_SERVER_IPV4"