415 lines
15 KiB
Bash
Executable File
415 lines
15 KiB
Bash
Executable File
#!/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."
|