Reorganised deployment scripts and added rollback functionality. Updated documentation and workflow for container image builds.
Test and publish container images / test (push) Successful in 2m50s
Test and publish container images / publish (push) Failing after 54s

This commit is contained in:
BuTzZ
2026-07-25 12:36:35 +02:00
parent 12eee8d211
commit c9a20aacd7
17 changed files with 642 additions and 73 deletions
+89
View File
@@ -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
+23 -8
View File
@@ -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
WATERMAPS_REGISTRY_USERNAME=kevin_janssen \
WATERMAPS_REGISTRY_TOKEN='<read-only-package-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:
+5 -2
View File
@@ -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.
+2
View File
@@ -1,2 +1,4 @@
.env.production
.env.images
.env.images.candidate
!.env.production.example
+78 -13
View File
@@ -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:<commit-sha>
gitea.incoso.eu/kevin_janssen/watermaps-route-data:<commit-sha>
```
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='<package-read-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
```
+2 -8
View File
@@ -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
+100
View File
@@ -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"
}
+103 -9
View File
@@ -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."
+61
View File
@@ -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'"
+28
View File
@@ -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"
+56
View File
@@ -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" <<EOF
WATERMAPS_DEPLOY_REVISION=$revision
WATERMAPS_APP_IMAGE=registry.example:5000/team/watermaps:$revision
WATERMAPS_ROUTE_DATA_IMAGE=registry.example:5000/team/watermaps-route-data:$revision
EOF
wm_validate_images_env "$valid_env"
invalid_env="$TEST_DIR/invalid.env"
cat >"$invalid_env" <<EOF
WATERMAPS_DEPLOY_REVISION=$revision
WATERMAPS_APP_IMAGE=registry.example/team/watermaps:latest
WATERMAPS_ROUTE_DATA_IMAGE=registry.example/team/watermaps-route-data:$revision
EOF
if (wm_validate_images_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'
@@ -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" <<EOF
@@ -17,6 +18,12 @@ WATERMAPS_DATA_DIR=$DATA_DIR
WATERMAPS_RUNTIME_DIR=$RUNTIME_DIR
EOF
cat >"$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.
+1 -1
View File
@@ -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
+70 -22
View File
@@ -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
+13 -6
View File
@@ -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
+1 -2
View File
@@ -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]
+1 -1
View File
@@ -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"