From c9a20aacd7f6c8622634271fec726d307afcc2fc Mon Sep 17 00:00:00 2001 From: BuTzZ Date: Sat, 25 Jul 2026 12:36:35 +0200 Subject: [PATCH] Reorganised deployment scripts and added rollback functionality. Updated documentation and workflow for container image builds. --- .gitea/workflows/container-images.yml | 89 ++++++++++++++ README.md | 33 ++++-- deploy/.env.production.example | 7 +- deploy/.gitignore | 2 + deploy/README.md | 91 ++++++++++++-- deploy/compose.production.yml | 10 +- deploy/scripts/common.sh | 100 ++++++++++++++++ deploy/scripts/deploy.sh | 112 ++++++++++++++++-- deploy/scripts/remote-rollback.sh | 61 ++++++++++ deploy/scripts/rollback.sh | 28 +++++ deploy/scripts/tests/image-references.test.sh | 56 +++++++++ .../scripts/tests/update-route-data.test.sh | 8 ++ deploy/scripts/update-route-data.sh | 2 +- deploy/scripts/upload-and-deploy.sh | 92 ++++++++++---- infra/opentofu/README.md | 19 ++- infra/opentofu/cloud-init.yaml.tftpl | 3 +- package.json | 2 +- 17 files changed, 642 insertions(+), 73 deletions(-) create mode 100644 .gitea/workflows/container-images.yml create mode 100755 deploy/scripts/remote-rollback.sh create mode 100755 deploy/scripts/rollback.sh create mode 100755 deploy/scripts/tests/image-references.test.sh diff --git a/.gitea/workflows/container-images.yml b/.gitea/workflows/container-images.yml new file mode 100644 index 0000000..55c1316 --- /dev/null +++ b/.gitea/workflows/container-images.yml @@ -0,0 +1,89 @@ +name: Test and publish container images + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + packages: none + +env: + REGISTRY: gitea.incoso.eu + REGISTRY_OWNER: kevin_janssen + +jobs: + test: + runs-on: ubuntu-latest + container: + image: node:22-bookworm + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Install test dependencies + run: | + apt-get update + apt-get install --yes --no-install-recommends jq python3 python3-pip util-linux + rm -rf /var/lib/apt/lists/* + python3 -m pip install \ + --disable-pip-version-check \ + --target .tools/python \ + "osmium==4.3.1" + + - name: Install Node dependencies + run: npm ci + + - name: Run type checks and tests + run: | + npm run typecheck + npm test + + publish: + if: ${{ gitea.event_name == 'push' }} + needs: + - test + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Log in to the Gitea Container Registry + env: + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + test -n "$REGISTRY_USERNAME" + test -n "$REGISTRY_TOKEN" + printf '%s\n' "$REGISTRY_TOKEN" | + docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin + + - name: Build images for the triggering commit + env: + REVISION: ${{ gitea.sha }} + run: | + test "$(printf '%s' "$REVISION" | wc -c)" -eq 40 + docker build \ + --label "org.opencontainers.image.revision=$REVISION" \ + --label "org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}" \ + --tag "$REGISTRY/$REGISTRY_OWNER/watermaps:$REVISION" \ + . + docker build \ + --file deploy/route-data.Dockerfile \ + --label "org.opencontainers.image.revision=$REVISION" \ + --label "org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}" \ + --tag "$REGISTRY/$REGISTRY_OWNER/watermaps-route-data:$REVISION" \ + . + + - name: Push immutable commit images + env: + REVISION: ${{ gitea.sha }} + run: | + docker push "$REGISTRY/$REGISTRY_OWNER/watermaps:$REVISION" + docker push "$REGISTRY/$REGISTRY_OWNER/watermaps-route-data:$REVISION" + + - name: Log out from the registry + if: always() + run: docker logout "$REGISTRY" || true diff --git a/README.md b/README.md index 3fa0fe4..0426087 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,17 @@ Die produktive Infrastruktur besteht aus einem Hetzner-Server, einer festen IPv4, Firewall und einem persistenten Volume. Der Docker-Stack enthält nur die App, Nginx/Certbot und den Wartungscontainer für Deutschland- und Niederlande-Routendaten; Kartenkacheln werden nicht selbst gehostet. +Gitea Actions testet jeden Stand und veröffentlicht App und +Routingdaten-Builder für Commits auf `main` als Container-Images. Der +Produktionsserver klont keinen Quellcode und baut keine Anwendung. -Die beiden lokalen, von Git ignorierten Konfigurationsdateien sind bereits -angelegt: +Die beiden lokalen, von Git ignorierten Konfigurationsdateien werden aus ihren +Vorlagen angelegt: + +```bash +cp infra/opentofu/terraform.tfvars.example infra/opentofu/terraform.tfvars +cp deploy/.env.production.example deploy/.env.production +``` - `infra/opentofu/terraform.tfvars`: hier den Hetzner-Cloud-Read/Write-Token eintragen und `admin_cidrs` bei Bedarf auf die aktuelle öffentliche IP @@ -89,19 +97,26 @@ tofu output server_ipv4 cd ../.. ``` -Sobald `server_ipv4` ausgegeben wurde, kann der manuelle DNS-A-Record +Vor dem ersten Deployment müssen Gitea Actions, ein Docker-fähiger Runner sowie +die Action-Secrets `REGISTRY_USERNAME` und `REGISTRY_TOKEN` eingerichtet sein. +Der Token benötigt Schreibzugriff auf Packages. Sobald `server_ipv4` ausgegeben +wurde, kann der manuelle DNS-A-Record `watermaps.incoso.eu` auf diese IPv4 gesetzt werden. Das erste Deployment darf -bereits vor der DNS-Propagation laufen: +bereits vor der DNS-Propagation laufen, sobald die Action die Images des +aktuellen Commits veröffentlicht hat: ```bash -./deploy/scripts/upload-and-deploy.sh \ +WATERMAPS_REGISTRY_USERNAME=kevin_janssen \ +WATERMAPS_REGISTRY_TOKEN='' \ + ./deploy/scripts/upload-and-deploy.sh \ --identity ~/.ssh/watermaps_hetzner_ed25519 ``` -Dabei werden die vollständigen Geofabrik-Extrakte für Deutschland und die -Niederlande auf dem persistenten Server-Volume geladen und der lokale -Routingindex erstellt. Vor dem SSL-Livegang liefert Port 80 außer -ACME-Challenges nur 404. +Der Registry-Token wird nur an `docker login --password-stdin` übertragen und +nicht Teil der Projektkonfiguration. Das Skript überträgt ausschließlich die +Deployment-Dateien. Der Server zieht die commitgenauen Images, fixiert deren +Digests und lädt beim ersten Start die Geofabrik-Extrakte auf das persistente +Volume. Vor dem SSL-Livegang liefert Port 80 außer ACME-Challenges nur 404. Erst wenn der DNS-A-Record propagiert ist, wird HTTPS mit dem finalen manuellen Befehl aktiviert: diff --git a/deploy/.env.production.example b/deploy/.env.production.example index 5eab590..1cbc5aa 100644 --- a/deploy/.env.production.example +++ b/deploy/.env.production.example @@ -17,10 +17,13 @@ WATERMAPS_RUNTIME_DIR=/srv/watermaps-runtime # Verhindert Speicherabbrüche beim vollständigen DE/NL-Indexaufbau auf 4-GB-Servern. WATERMAPS_SWAP_SIZE_GB=4 -WATERMAPS_APP_IMAGE=watermaps:production +# Gitea Container Registry. Die commitgenauen App-Referenzen werden beim +# Deployment separat erzeugt und niemals hier von Hand auf `latest` gesetzt. +WATERMAPS_REGISTRY=gitea.incoso.eu +WATERMAPS_REGISTRY_OWNER=kevin_janssen + WATERMAPS_NGINX_IMAGE=nginx:1.30.4-alpine WATERMAPS_CERTBOT_IMAGE=certbot/certbot:v5.7.0 -WATERMAPS_ROUTE_DATA_IMAGE=watermaps-route-data:production # Nur auf true setzen, wenn bei jedem Deployment Deutschland und die # Niederlande erneut geprüft und der Routingindex neu gebaut werden sollen. diff --git a/deploy/.gitignore b/deploy/.gitignore index 13e0a5d..f1a9738 100644 --- a/deploy/.gitignore +++ b/deploy/.gitignore @@ -1,2 +1,4 @@ .env.production +.env.images +.env.images.candidate !.env.production.example diff --git a/deploy/README.md b/deploy/README.md index 1ad0e3f..fc0ef81 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -12,11 +12,11 @@ cp deploy/.env.production.example deploy/.env.production editor deploy/.env.production ``` -Mindestens `WATERMAPS_ACME_EMAIL` muss angepasst werden. Der Hetzner-API-Token -gehört **nicht** in diese Datei. Er bleibt lokal in der ignorierten Datei -`infra/opentofu/terraform.tfvars` (alternativ kann der Provider -`TF_VAR_hcloud_token` lesen). Der Upload schließt Terraform-Variablen, Pläne, -State, lokale Env-Dateien und `.terraform` in jeder Verzeichnistiefe aus. +Mindestens `WATERMAPS_ACME_EMAIL` muss angepasst werden. Außerdem müssen +`WATERMAPS_REGISTRY` und `WATERMAPS_REGISTRY_OWNER` auf die Gitea Container +Registry zeigen. Der Hetzner-API-Token gehört **nicht** in diese Datei. Er +bleibt lokal in der ignorierten Datei `infra/opentofu/terraform.tfvars` +(alternativ kann der Provider `TF_VAR_hcloud_token` lesen). Die private SSH-Keydatei wird ebenfalls nicht gespeichert. Sie kann beim Deployment mit `--identity` oder über `WATERMAPS_SSH_KEY` angegeben werden. @@ -26,24 +26,64 @@ Angaben erforderlich. Der vorbereitete SSH-Benutzer heißt standardmäßig `deploy`; die privilegierten Installationsschritte laufen über dessen passwortloses `sudo`. +## Gitea Actions und Container Registry + +Der Workflow `.gitea/workflows/container-images.yml` führt Typechecks und Tests +aus. Nach einem erfolgreichen Push auf `main` baut und veröffentlicht er zwei +OCI-Images unter der vollständigen Git-Commit-SHA: + +```text +gitea.incoso.eu/kevin_janssen/watermaps: +gitea.incoso.eu/kevin_janssen/watermaps-route-data: +``` + +In Gitea müssen Repository Actions aktiviert und ein Docker-fähiger +`ubuntu-latest`-Runner registriert sein. Unter +`Repository → Settings → Actions → Secrets` werden benötigt: + +- `REGISTRY_USERNAME`: Gitea-Benutzer, dem die Packages gehören +- `REGISTRY_TOKEN`: Personal Access Token mit `package: Read and Write` + +Der separate PAT ist derzeit nötig, weil Giteas eingebauter Job-Token +OCI-Pakete noch nicht zuverlässig veröffentlichen kann. Für Pull Requests +werden nur Tests ausgeführt; Registry-Secrets werden dabei nicht verwendet. + ## Deployment -Nach `tofu apply` liest dieses Skript standardmäßig den Output `server_ipv4`, -überträgt das Projekt ohne State, lokale Daten oder Node-Module und startet den -Serverbootstrap sowie den Docker-Stack: +Nach einem erfolgreichen Image-Build liest das Skript standardmäßig den +OpenTofu-Output `server_ipv4`. Es erlaubt ausschließlich einen sauberen, +vollständig committeten Git-Stand und überträgt nur den kleinen Ordner +`deploy/`, nicht den Anwendungsquellcode. Die Image-Tags entsprechen exakt +`git rev-parse HEAD`. + +Ist die Registry privat, werden einmalig beziehungsweise nach Tokenwechsel +lokale Pull-Zugangsdaten mitgegeben: ```bash -./deploy/scripts/upload-and-deploy.sh --identity ~/.ssh/watermaps_hetzner_ed25519 +WATERMAPS_REGISTRY_USERNAME=kevin_janssen \ +WATERMAPS_REGISTRY_TOKEN='' \ + ./deploy/scripts/upload-and-deploy.sh \ + --identity ~/.ssh/watermaps_hetzner_ed25519 ``` -Alternativ: +Der Token benötigt auf dem Produktionsserver nur `package: Read`. Er wird per +SSH an `docker login --password-stdin` übergeben, nicht in +`.env.production`, einem Container oder dem Image gespeichert. Docker legt die +Anmeldung root-lesbar in seiner lokalen Client-Konfiguration ab. Bei späteren +Deployments muss der Token nicht erneut angegeben werden, solange die +Anmeldung gültig ist: ```bash -WATERMAPS_SERVER_IPV4=203.0.113.10 \ -WATERMAPS_SSH_KEY=~/.ssh/watermaps_hetzner_ed25519 \ -./deploy/scripts/upload-and-deploy.sh +./deploy/scripts/upload-and-deploy.sh \ + --identity ~/.ssh/watermaps_hetzner_ed25519 ``` +Das Serverskript lädt App, Routingdaten-Builder, Nginx und Certbot mit +`docker compose pull`. Die beiden commitgenauen Gitea-Tags werden anschließend +in ihre unveränderlichen Registry-Digests aufgelöst. Erst danach werden die +Container mit `--no-build` gestartet. Der Produktionsserver benötigt deshalb +weder Git noch Node/npm oder den Quellcode. + Der erste Datenaufbau lädt die Geofabrik-Extrakte für Deutschland und die Niederlande herunter und kann entsprechend der Serverleistung längere Zeit dauern. Der produktive Index liegt auf dem Server unter: @@ -63,6 +103,30 @@ persistente Swap-Reserve ein. Vor dem Livegang antwortet Nginx nur für ACME-Challenges. Alle anderen HTTP-Anfragen erhalten 404. +## Rollback + +Jedes erfolgreiche Release wird mit Commit-SHA und den aufgelösten +Image-Digests unter `/srv/watermaps-runtime/deployments/` gespeichert. Scheitert +ein Deployment nach dem Containerwechsel, startet `deploy.sh` automatisch das +vorherige Release und führt die Health- und Routentests erneut aus. + +Das unmittelbar vorherige Release lässt sich auch manuell aktivieren: + +```bash +./deploy/scripts/remote-rollback.sh \ + --identity ~/.ssh/watermaps_hetzner_ed25519 +``` + +Oder ein bestimmter, bereits erfolgreich deployter Commit: + +```bash +./deploy/scripts/remote-rollback.sh \ + --revision 0123456789abcdef0123456789abcdef01234567 \ + --identity ~/.ssh/watermaps_hetzner_ed25519 +``` + +Der Rollback verwendet gespeicherte Digests, nicht einen beweglichen Tag. + ## DNS und SSL-Livegang Sobald der OpenTofu-Output bekannt ist, kann folgender DNS-Eintrag gesetzt @@ -103,5 +167,6 @@ journalctl -u watermaps-certbot-renew.service docker compose \ --project-directory /opt/watermaps \ --env-file /opt/watermaps/deploy/.env.production \ + --env-file /opt/watermaps/deploy/.env.images \ -f /opt/watermaps/deploy/compose.production.yml ps ``` diff --git a/deploy/compose.production.yml b/deploy/compose.production.yml index 68f429c..fc06006 100644 --- a/deploy/compose.production.yml +++ b/deploy/compose.production.yml @@ -2,10 +2,7 @@ name: watermaps-production services: watermaps: - image: ${WATERMAPS_APP_IMAGE:-watermaps:production} - build: - context: . - dockerfile: Dockerfile + image: ${WATERMAPS_APP_IMAGE:?WATERMAPS_APP_IMAGE fehlt} environment: NODE_ENV: production HOST: 0.0.0.0 @@ -85,10 +82,7 @@ services: target: /etc/letsencrypt route-data: - image: ${WATERMAPS_ROUTE_DATA_IMAGE:-watermaps-route-data:production} - build: - context: . - dockerfile: deploy/route-data.Dockerfile + image: ${WATERMAPS_ROUTE_DATA_IMAGE:?WATERMAPS_ROUTE_DATA_IMAGE fehlt} profiles: ["maintenance"] environment: WATERMAPS_GEOFABRIK_DIR: /workspace/data/geofabrik diff --git a/deploy/scripts/common.sh b/deploy/scripts/common.sh index 62c96ab..438a215 100755 --- a/deploy/scripts/common.sh +++ b/deploy/scripts/common.sh @@ -5,6 +5,7 @@ set -Eeuo pipefail WM_DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WM_ROOT_DIR="$(cd "$WM_DEPLOY_DIR/.." && pwd)" WM_ENV_FILE="${WATERMAPS_ENV_FILE:-$WM_DEPLOY_DIR/.env.production}" +WM_IMAGES_ENV_FILE="${WATERMAPS_IMAGES_ENV_FILE:-$WM_DEPLOY_DIR/.env.images}" WM_COMPOSE_FILE="$WM_DEPLOY_DIR/compose.production.yml" wm_die() { @@ -47,13 +48,112 @@ wm_assert_data_mount() { } wm_compose() { + wm_validate_images_env "$WM_IMAGES_ENV_FILE" docker compose \ --project-directory "$WM_ROOT_DIR" \ --env-file "$WM_ENV_FILE" \ + --env-file "$WM_IMAGES_ENV_FILE" \ --file "$WM_COMPOSE_FILE" \ "$@" } +wm_use_images_env() { + local images_env_file="$1" + wm_validate_images_env "$images_env_file" + WM_IMAGES_ENV_FILE="$images_env_file" + export WATERMAPS_IMAGES_ENV_FILE="$images_env_file" +} + +wm_validate_images_env() { + local images_env_file="$1" + local revision app_image route_data_image + + [[ -f "$images_env_file" ]] || + wm_die "Image-Konfiguration fehlt: $images_env_file" + + revision="$(wm_env_value "$images_env_file" WATERMAPS_DEPLOY_REVISION)" + app_image="$(wm_env_value "$images_env_file" WATERMAPS_APP_IMAGE)" + route_data_image="$(wm_env_value "$images_env_file" WATERMAPS_ROUTE_DATA_IMAGE)" + + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] || + wm_die "WATERMAPS_DEPLOY_REVISION muss eine vollständige Git-Commit-SHA sein." + wm_validate_image_reference "$app_image" WATERMAPS_APP_IMAGE + wm_validate_image_reference "$route_data_image" WATERMAPS_ROUTE_DATA_IMAGE + wm_validate_release_image "$app_image" "$revision" WATERMAPS_APP_IMAGE + wm_validate_release_image \ + "$route_data_image" \ + "$revision" \ + WATERMAPS_ROUTE_DATA_IMAGE +} + +wm_env_value() { + local env_file="$1" + local requested_key="$2" + local value + + value="$( + awk -F= -v requested_key="$requested_key" ' + $1 == requested_key { + print substr($0, length($1) + 2) + matches += 1 + } + END { + if (matches != 1) { + exit 1 + } + } + ' "$env_file" + )" || wm_die "$requested_key fehlt oder ist mehrfach in $env_file vorhanden." + printf '%s\n' "$value" +} + +wm_validate_image_reference() { + local image_reference="$1" + local variable_name="$2" + + [[ -n "$image_reference" && + "$image_reference" != *[[:space:]]* && + "$image_reference" == */* && + "$image_reference" != *REPLACE* ]] || + wm_die "$variable_name enthält keine gültige Registry-Image-Referenz." +} + +wm_validate_release_image() { + local image_reference="$1" + local revision="$2" + local variable_name="$3" + + [[ "$image_reference" == *":$revision" || + "$image_reference" =~ @sha256:[0-9a-f]{64}$ ]] || + wm_die "$variable_name muss auf den Commit-Tag $revision oder einen SHA256-Digest zeigen." +} + +wm_resolve_image_digest() { + local image_reference="$1" + local requested_repository last_component resolved_reference + + requested_repository="${image_reference%%@*}" + last_component="${requested_repository##*/}" + if [[ "$last_component" == *:* ]]; then + requested_repository="${requested_repository%:*}" + fi + + resolved_reference="$( + docker image inspect \ + --format '{{range .RepoDigests}}{{println .}}{{end}}' \ + "$image_reference" | + awk -v requested_repository="$requested_repository" ' + index($0, requested_repository "@sha256:") == 1 { + print + exit + } + ' + )" + [[ "$resolved_reference" =~ @sha256:[0-9a-f]{64}$ ]] || + wm_die "Für $image_reference konnte kein lokaler Registry-Digest ermittelt werden." + printf '%s\n' "$resolved_reference" +} + wm_route_file() { printf '%s/local/germany-netherlands-fairways.json\n' "$WATERMAPS_DATA_DIR" } diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh index e0197dd..833ea0e 100755 --- a/deploy/scripts/deploy.sh +++ b/deploy/scripts/deploy.sh @@ -5,12 +5,35 @@ set -Eeuo pipefail WM_DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=common.sh source "$WM_DEPLOY_DIR/scripts/common.sh" + +images_env_file="$WM_IMAGES_ENV_FILE" + +usage() { + printf 'Verwendung: %s [--images-file DATEI]\n' "$0" +} + +while [[ "$#" -gt 0 ]]; do + case "$1" in + --images-file) + [[ "$#" -ge 2 ]] || wm_die "Wert für --images-file fehlt." + images_env_file="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + wm_die "Unbekannte Option: $1" + ;; + esac +done + +[[ "$(id -u)" -eq 0 ]] || + wm_die "Dieses Skript muss als root ausgeführt werden." wm_load_env wm_assert_data_mount - -if [[ "$(id -u)" -ne 0 ]]; then - wm_die "Dieses Skript muss als root ausgeführt werden." -fi +wm_use_images_env "$images_env_file" install -d -m 0755 \ "$WATERMAPS_DATA_DIR/geofabrik" \ @@ -18,20 +41,81 @@ install -d -m 0755 \ "$WATERMAPS_DATA_DIR/certbot/www" \ "$WATERMAPS_DATA_DIR/certbot/letsencrypt" \ "$WATERMAPS_RUNTIME_DIR/nginx/conf.d" \ - "$WATERMAPS_RUNTIME_DIR/locks" + "$WATERMAPS_RUNTIME_DIR/locks" \ + "$WATERMAPS_RUNTIME_DIR/deployments" wm_acquire_route_lock || wm_die "Deployment abgebrochen, weil gerade Routingdaten aktualisiert werden." +active_images_file="$WM_DEPLOY_DIR/.env.images" +rollback_images_file="" +resolved_images_file="" +deployment_complete=false + +if [[ -f "$active_images_file" ]]; then + wm_validate_images_env "$active_images_file" + rollback_images_file="$( + mktemp "$WATERMAPS_RUNTIME_DIR/deployments/.rollback-images.XXXXXX" + )" + install -m 0644 "$active_images_file" "$rollback_images_file" +fi + +wm_finish_deployment() { + local status=$? + trap - EXIT HUP INT TERM + set +e + + if [[ "$status" -ne 0 && "$deployment_complete" != "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" + wm_compose up --detach --no-build --remove-orphans watermaps nginx + wm_wait_for_health watermaps 240 + wm_wait_for_health nginx 120 + if wm_route_data_ready; then + wm_smoke_test_route + fi + else + wm_log "Deployment fehlgeschlagen; für das erste Release existiert noch kein Rollback." + fi + fi + + [[ -z "$resolved_images_file" ]] || rm -f "$resolved_images_file" + [[ -z "$rollback_images_file" ]] || rm -f "$rollback_images_file" + exit "$status" +} + +trap wm_finish_deployment EXIT +trap 'exit 130' HUP INT TERM + if [[ ! -f "$WATERMAPS_RUNTIME_DIR/nginx/conf.d/default.conf" ]]; then install -m 0644 \ "$WM_DEPLOY_DIR/nginx/bootstrap.conf" \ "$WATERMAPS_RUNTIME_DIR/nginx/conf.d/default.conf" fi -wm_log "Anwendungsimage wird gebaut." -wm_compose build watermaps -wm_compose up --detach --remove-orphans watermaps nginx +revision="$(wm_env_value "$images_env_file" WATERMAPS_DEPLOY_REVISION)" +requested_app_image="$(wm_env_value "$images_env_file" WATERMAPS_APP_IMAGE)" +requested_route_data_image="$(wm_env_value "$images_env_file" WATERMAPS_ROUTE_DATA_IMAGE)" + +wm_log "Container-Images für Commit $revision werden aus den Registries geladen." +wm_compose --profile maintenance pull watermaps nginx certbot route-data + +resolved_app_image="$(wm_resolve_image_digest "$requested_app_image")" +resolved_route_data_image="$(wm_resolve_image_digest "$requested_route_data_image")" +resolved_images_file="$( + mktemp "$WATERMAPS_RUNTIME_DIR/deployments/.resolved-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" +} >"$resolved_images_file" +chmod 0644 "$resolved_images_file" +wm_use_images_env "$resolved_images_file" + +wm_log "Commit $revision wird mit unveränderlichen Image-Digests gestartet." +wm_compose up --detach --no-build --remove-orphans watermaps nginx wm_wait_for_health watermaps 240 wm_wait_for_health nginx 120 @@ -55,4 +139,14 @@ wm_assert_route_data wm_wait_for_health watermaps 240 wm_smoke_test_route -wm_log "Deployment ist bereit. Vor dem Livegang liefert Port 80 nur ACME-Challenges und ansonsten HTTP 404." +release_images_file="$WATERMAPS_RUNTIME_DIR/deployments/$revision.env" +install -m 0644 "$resolved_images_file" "$release_images_file" +if [[ -n "$rollback_images_file" ]]; then + install -m 0644 \ + "$rollback_images_file" \ + "$WATERMAPS_RUNTIME_DIR/deployments/previous.env" +fi +install -m 0644 "$resolved_images_file" "$active_images_file" + +deployment_complete=true +wm_log "Deployment von Commit $revision ist bereit. Vor dem Livegang liefert Port 80 nur ACME-Challenges und ansonsten HTTP 404." diff --git a/deploy/scripts/remote-rollback.sh b/deploy/scripts/remote-rollback.sh new file mode 100755 index 0000000..c552cc2 --- /dev/null +++ b/deploy/scripts/remote-rollback.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +WM_DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=remote-common.sh +source "$WM_DEPLOY_DIR/scripts/remote-common.sh" + +server_ipv4="" +identity_file="" +revision="previous" + +usage() { + printf 'Verwendung: %s [--revision SHA|previous] [--host IPV4] [--identity DATEI] [--user BENUTZER]\n' "$0" +} + +while [[ "$#" -gt 0 ]]; do + case "$1" in + --revision) + [[ "$#" -ge 2 ]] || wm_local_die "Wert für --revision fehlt." + revision="$2" + shift 2 + ;; + --host) + [[ "$#" -ge 2 ]] || wm_local_die "Wert für --host fehlt." + server_ipv4="$2" + shift 2 + ;; + --identity) + [[ "$#" -ge 2 ]] || wm_local_die "Wert für --identity fehlt." + identity_file="$2" + shift 2 + ;; + --user) + [[ "$#" -ge 2 ]] || wm_local_die "Wert für --user fehlt." + WATERMAPS_SSH_USER="$2" + export WATERMAPS_SSH_USER + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + wm_local_die "Unbekannte Option: $1" + ;; + esac +done + +[[ "$revision" == "previous" || "$revision" =~ ^[0-9a-f]{40}$ ]] || + wm_local_die "--revision muss 'previous' oder eine vollständige Git-Commit-SHA sein." + +wm_local_resolve_ssh "$server_ipv4" "$identity_file" +wm_local_wait_for_ssh +remote_prefix="" +if [[ -n "$WM_REMOTE_SUDO" ]]; then + remote_prefix="$WM_REMOTE_SUDO " +fi + +ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \ + "${remote_prefix}/opt/watermaps/deploy/scripts/rollback.sh '$revision'" diff --git a/deploy/scripts/rollback.sh b/deploy/scripts/rollback.sh new file mode 100755 index 0000000..4902e45 --- /dev/null +++ b/deploy/scripts/rollback.sh @@ -0,0 +1,28 @@ +#!/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" + +if [[ "$(id -u)" -ne 0 ]]; then + wm_die "Dieses Skript muss als root ausgeführt werden." +fi + +wm_load_env +revision="${1:-previous}" +deployments_dir="$WATERMAPS_RUNTIME_DIR/deployments" + +if [[ "$revision" == "previous" ]]; then + images_file="$deployments_dir/previous.env" +else + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] || + wm_die "Rollback-Ziel muss 'previous' oder eine vollständige Git-Commit-SHA sein." + images_file="$deployments_dir/$revision.env" +fi + +[[ -f "$images_file" ]] || + wm_die "Gespeichertes Release nicht gefunden: $images_file" + +exec "$WM_DEPLOY_DIR/scripts/deploy.sh" --images-file "$images_file" diff --git a/deploy/scripts/tests/image-references.test.sh b/deploy/scripts/tests/image-references.test.sh new file mode 100755 index 0000000..25acfb6 --- /dev/null +++ b/deploy/scripts/tests/image-references.test.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +TEST_DIR="$(mktemp -d)" +trap 'rm -rf "$TEST_DIR"' EXIT + +# shellcheck source=../common.sh +source "$ROOT_DIR/deploy/scripts/common.sh" + +revision="0123456789abcdef0123456789abcdef01234567" +valid_env="$TEST_DIR/valid.env" +cat >"$valid_env" <"$invalid_env" </dev/null 2>&1); then + printf 'Beweglicher latest-Tag wurde als Release akzeptiert.\n' >&2 + exit 1 +fi + +fake_bin="$TEST_DIR/bin" +mkdir -p "$fake_bin" +cat >"$fake_bin/docker" <<'SH' +#!/usr/bin/env bash +set -Eeuo pipefail + +if [[ "${1:-}" == "image" && "${2:-}" == "inspect" ]]; then + printf '%s\n' \ + 'mirror.example/team/watermaps@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \ + 'registry.example:5000/team/watermaps@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + exit 0 +fi +exit 1 +SH +chmod +x "$fake_bin/docker" +PATH="$fake_bin:$PATH" + +resolved="$( + wm_resolve_image_digest \ + "registry.example:5000/team/watermaps:$revision" +)" +[[ "$resolved" == \ + "registry.example:5000/team/watermaps@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" ]] + +printf 'Commit-Tag- und Digest-Prüfungen: OK\n' diff --git a/deploy/scripts/tests/update-route-data.test.sh b/deploy/scripts/tests/update-route-data.test.sh index 50b5fed..dd74b2e 100755 --- a/deploy/scripts/tests/update-route-data.test.sh +++ b/deploy/scripts/tests/update-route-data.test.sh @@ -10,6 +10,7 @@ FAKE_BIN="$TEST_DIR/bin" DATA_DIR="$TEST_DIR/data" RUNTIME_DIR="$TEST_DIR/runtime" ENV_FILE="$TEST_DIR/production.env" +IMAGES_ENV_FILE="$TEST_DIR/images.env" mkdir -p "$FAKE_BIN" "$DATA_DIR/local" "$RUNTIME_DIR" cat >"$ENV_FILE" <"$IMAGES_ENV_FILE" <<'EOF' +WATERMAPS_DEPLOY_REVISION=0123456789abcdef0123456789abcdef01234567 +WATERMAPS_APP_IMAGE=registry.example/watermaps:0123456789abcdef0123456789abcdef01234567 +WATERMAPS_ROUTE_DATA_IMAGE=registry.example/watermaps-route-data:0123456789abcdef0123456789abcdef01234567 +EOF + cat >"$FAKE_BIN/id" <<'SH' #!/usr/bin/env bash if [[ "${1:-}" == "-u" ]]; then @@ -95,6 +102,7 @@ chmod +x "$FAKE_BIN/id" "$FAKE_BIN/mountpoint" "$FAKE_BIN/docker" export PATH="$FAKE_BIN:$PATH" export WATERMAPS_ENV_FILE="$ENV_FILE" +export WATERMAPS_IMAGES_ENV_FILE="$IMAGES_ENV_FILE" export WM_TEST_ROOT="$ROOT_DIR" # A first successful activation creates an active index and a validated marker. diff --git a/deploy/scripts/update-route-data.sh b/deploy/scripts/update-route-data.sh index cee6afd..74dda20 100755 --- a/deploy/scripts/update-route-data.sh +++ b/deploy/scripts/update-route-data.sh @@ -145,7 +145,7 @@ if wm_route_data_ready; then fi wm_log "Deutschland-/Niederlande-Routingdaten werden in einer Stagingdatei gebaut." -if ! wm_compose --profile maintenance run --rm --build \ +if ! wm_compose --profile maintenance run --rm \ --env "WATERMAPS_LOCAL_FAIRWAYS_PATH=$container_candidate" \ --env "WATERMAPS_ROUTE_MARKER_PATH=$container_candidate_marker" \ route-data; then diff --git a/deploy/scripts/upload-and-deploy.sh b/deploy/scripts/upload-and-deploy.sh index 2daf29c..5d4c9dc 100755 --- a/deploy/scripts/upload-and-deploy.sh +++ b/deploy/scripts/upload-and-deploy.sh @@ -24,8 +24,13 @@ Optionen: -h, --help Hilfe anzeigen Die Datei deploy/.env.production muss lokal vorhanden sein. Sie wird separat -mit Dateimodus 0600 übertragen. Terraform-State, .terraform, data, -node_modules, lokale Env-Dateien und Git-Metadaten werden ausgeschlossen. +mit Dateimodus 0600 übertragen. Es wird ausschließlich der Deployment-Ordner +übertragen; App und Routingdaten-Builder werden als commitgenaue Images aus der +in .env.production konfigurierten Gitea Registry geladen. + +Für eine private Registry können WATERMAPS_REGISTRY_USERNAME und +WATERMAPS_REGISTRY_TOKEN lokal gesetzt werden. Der Token wird nur über SSH an +`docker login --password-stdin` übergeben und nicht auf den Server hochgeladen. USAGE } @@ -62,10 +67,49 @@ while [[ "$#" -gt 0 ]]; do done command -v rsync >/dev/null 2>&1 || wm_local_die "rsync ist lokal nicht installiert." +command -v git >/dev/null 2>&1 || wm_local_die "git ist lokal nicht installiert." local_env="$WM_DEPLOY_DIR/.env.production" [[ -f "$local_env" ]] || wm_local_die "Bitte zuerst deploy/.env.production aus der Vorlage erstellen und konfigurieren." +if [[ -n "$(git -C "$WM_ROOT_DIR" status --porcelain --untracked-files=normal)" ]]; then + wm_local_die "Deployment nur aus einem vollständig committeten, sauberen Git-Stand erlaubt." +fi +revision="$(git -C "$WM_ROOT_DIR" rev-parse HEAD)" +[[ "$revision" =~ ^[0-9a-f]{40}$ ]] || + wm_local_die "Aktueller Git-Commit konnte nicht als vollständige SHA ermittelt werden." + +set -a +# shellcheck disable=SC1090 +source "$local_env" +set +a +registry="${WATERMAPS_REGISTRY:-}" +registry_owner="${WATERMAPS_REGISTRY_OWNER:-}" +[[ "$registry" =~ ^[a-z0-9][a-z0-9.-]*(:[0-9]{1,5})?$ ]] || + wm_local_die "WATERMAPS_REGISTRY muss ein Registry-Hostname ohne URL-Schema sein." +[[ "$registry_owner" =~ ^[A-Za-z0-9_.-]+$ ]] || + wm_local_die "WATERMAPS_REGISTRY_OWNER enthält keinen gültigen Gitea-Besitzer." + +registry_username="${WATERMAPS_REGISTRY_USERNAME:-}" +registry_token="${WATERMAPS_REGISTRY_TOKEN:-}" +if [[ -n "$registry_username" || -n "$registry_token" ]]; then + [[ -n "$registry_username" && -n "$registry_token" ]] || + wm_local_die "WATERMAPS_REGISTRY_USERNAME und WATERMAPS_REGISTRY_TOKEN müssen gemeinsam gesetzt werden." + [[ "$registry_username" =~ ^[A-Za-z0-9_.-]+$ ]] || + 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 @@ -80,7 +124,7 @@ 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 Projekt nach %s:/opt/watermaps\n' "$WM_SSH_TARGET" +printf '[watermaps] Übertrage Deployment-Dateien nach %s:/opt/watermaps/deploy\n' "$WM_SSH_TARGET" ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \ "${remote_prefix}install -d -m 0755 /opt/watermaps /opt/watermaps/deploy" @@ -90,24 +134,11 @@ rsync \ --delete-delay \ --human-readable \ --rsync-path="$rsync_path" \ - --exclude='.git/' \ - --exclude='.terraform/' \ - --exclude='.terraform.tfstate.lock.info' \ - --exclude='*.tfvars' \ - --exclude='*.tfvars.json' \ - --exclude='*.tfplan' \ - --exclude='*.tfstate*' \ - --exclude='crash.log' \ - --exclude='crash.*.log' \ - --exclude='/data/' \ - --exclude='/.tools/' \ - --exclude='node_modules/' \ - --exclude='**/.env' \ - --exclude='**/.env.*' \ - --exclude='/deploy/.env.production' \ + --exclude='.env.production' \ + --exclude='.env.images*' \ -e "ssh ${WM_SSH_OPTIONS[*]@Q}" \ - "$WM_ROOT_DIR/" \ - "$WM_SSH_TARGET:/opt/watermaps/" + "$WM_DEPLOY_DIR/" \ + "$WM_SSH_TARGET:/opt/watermaps/deploy/" rsync \ --archive \ @@ -117,15 +148,32 @@ 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" | + ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \ + "${remote_prefix}docker login '$registry' --username '$registry_username' --password-stdin" +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" +remote_command+=" && ${remote_prefix}/opt/watermaps/deploy/scripts/deploy.sh --images-file /opt/watermaps/deploy/.env.images.candidate" 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'" else - printf '[watermaps] Deployment abgeschlossen. Nach dem DNS-Eintrag:\n' + 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" fi diff --git a/infra/opentofu/README.md b/infra/opentofu/README.md index af36b6c..ba9e2a5 100644 --- a/infra/opentofu/README.md +++ b/infra/opentofu/README.md @@ -3,14 +3,20 @@ Diese OpenTofu-Konfiguration erstellt einen einzelnen Ubuntu-24.04-Server mit fester IPv4-Adresse, vorgeschalteter Hetzner-Firewall und einem persistenten ext4-Volume für die Routingdaten von Deutschland und den Niederlanden. -Kartenkacheln, DNS-Einträge und Anwendungsdeployment sind bewusst nicht Teil -dieses Infrastrukturmoduls. +Kartenkacheln, DNS-Einträge und konkrete Anwendungsversionen sind bewusst nicht +Teil dieses Infrastrukturmoduls. Die Anwendung wird anschließend als +commitgenaues Container-Image aus der Gitea Registry deployt. ## Konfiguration -Die lokale Datei `terraform.tfvars` ist bereits angelegt und wird durch die -lokale `.gitignore` ausgeschlossen. Vor dem ersten Plan müssen dort mindestens -folgende Werte ersetzt werden: +Die lokale Datei `terraform.tfvars` wird aus der versionierten Vorlage angelegt +und durch die lokale `.gitignore` ausgeschlossen: + +```sh +cp terraform.tfvars.example terraform.tfvars +``` + +Vor dem ersten Plan müssen dort mindestens folgende Werte ersetzt werden: - `hcloud_token`: Read/Write-API-Token des richtigen Hetzner-Cloud-Projekts - `ssh_public_key`: vollständiger Inhalt des öffentlichen SSH-Schlüssels @@ -58,7 +64,8 @@ bereitstehen. ## Auf dem Server Cloud-init installiert Docker einschließlich Compose v2, `curl`, `jq` und -`rsync`. Der Benutzer `deploy` erhält Zugriff per SSH und auf Docker. Das +`rsync`. Der Benutzer `deploy` erhält Zugriff per SSH und auf Docker. Es wird +kein Git-Repository auf den Server geklont. Das persistente Volume wird nach dem Anhängen unter `/srv/watermaps-data` gemountet und enthält die vom Produktions-Stack verwendeten Verzeichnisse `geofabrik`, `local` und `certbot`. Ein systemd-Drop-in lässt Docker bei jedem Start auf den diff --git a/infra/opentofu/cloud-init.yaml.tftpl b/infra/opentofu/cloud-init.yaml.tftpl index 8a02606..f1822a7 100644 --- a/infra/opentofu/cloud-init.yaml.tftpl +++ b/infra/opentofu/cloud-init.yaml.tftpl @@ -127,8 +127,7 @@ write_files: runcmd: - [install, -d, -m, "0755", -o, ${deploy_user}, -g, ${deploy_user}, /opt/watermaps] - - [install, -d, -m, "0755", -o, ${deploy_user}, -g, ${deploy_user}, /opt/watermaps/releases] - - [install, -d, -m, "0755", -o, ${deploy_user}, -g, ${deploy_user}, /opt/watermaps/shared] + - [install, -d, -m, "0755", -o, ${deploy_user}, -g, ${deploy_user}, /opt/watermaps/deploy] - [install, -d, -m, "0755", -o, ${deploy_user}, -g, ${deploy_user}, /var/www/certbot] - [systemctl, daemon-reload] - [systemctl, enable, --now, watermaps-volume-setup.service] diff --git a/package.json b/package.json index 5c0c347..d146381 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "setup:local-routing": "./scripts/setup-local-routing.sh", "sync:euris-locks": "node scripts/sync-euris-locks.mjs", "test": "npm run build --workspace @watermaps/shared && npm run test --workspace @watermaps/shared && npm run test --workspace @watermaps/api && npm run test --workspace @watermaps/web && npm run test:local-routing && npm run test:deployment", - "test:deployment": "bash deploy/scripts/tests/route-data-helpers.test.sh && bash deploy/scripts/tests/update-route-data.test.sh", + "test:deployment": "bash deploy/scripts/tests/image-references.test.sh && bash deploy/scripts/tests/route-data-helpers.test.sh && bash deploy/scripts/tests/update-route-data.test.sh", "test:e2e": "npm run test:e2e --workspace @watermaps/web", "test:local-routing": "PYTHONPATH=.tools/python python3 -m unittest discover -s scripts/tests -p 'test_*.py'", "typecheck": "npm run build --workspace @watermaps/shared && npm run typecheck --workspace @watermaps/shared && npm run typecheck --workspace @watermaps/api && npm run typecheck --workspace @watermaps/web"