optimized events
Test and publish container images / test (push) Successful in 2m26s
Test and publish container images / publish (push) Failing after 3s

This commit is contained in:
BuTzZ
2026-07-28 14:36:47 +02:00
parent b98ec8bc6f
commit 593dbd5f85
42 changed files with 2405 additions and 283 deletions
+10
View File
@@ -17,6 +17,12 @@ WATERMAPS_RUNTIME_DIR=/srv/watermaps-runtime
# Verhindert Speicherabbrüche beim vollständigen DE/NL-Indexaufbau auf 4-GB-Servern.
WATERMAPS_SWAP_SIZE_GB=4
# Persistente PostGIS-Datenbank für Häfen, Schleusen, Brücken und Kontaktdaten.
# Vor dem ersten Deployment beispielsweise mit `openssl rand -hex 32`
# erzeugen. Das Secret muss mindestens 24 Zeichen lang sein.
WATERMAPS_POSTGRES_PASSWORD=REPLACE_WITH_RANDOM_32_BYTE_HEX_SECRET
WATERMAPS_POSTGRES_IMAGE=postgis/postgis:16-3.4
# 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
@@ -29,6 +35,10 @@ WATERMAPS_CERTBOT_IMAGE=certbot/certbot:v5.7.0
# Niederlande erneut geprüft und der Routingindex neu gebaut werden sollen.
WATERMAPS_REBUILD_ROUTE_DATA=false
# Erzwingt unabhängig vom Bereitschaftsmarker einen vollständigen Neuimport
# der Marine-Features aus den vorhandenen Deutschland-/Niederlande-PBFs.
WATERMAPS_REBUILD_MARINE_DATA=false
# Für einen Test gegen Let's Encrypt Staging auf true setzen.
# Das damit ausgestellte Zertifikat ist im Browser nicht vertrauenswürdig.
WATERMAPS_CERTBOT_STAGING=false
+64 -21
View File
@@ -1,9 +1,10 @@
# Watermaps-Produktion
Dieser Stack hostet die Watermaps-App und die lokalen Fahrrouten für
Deutschland und die Niederlande. Die sichtbaren Kartenkacheln bleiben externe
Dienste. Öffentlich gebunden werden ausschließlich TCP 80 und 443; die
Watermaps-App ist nur im internen Docker-Netz erreichbar.
Dieser Stack hostet die Watermaps-App, die lokalen Fahrrouten sowie eine
persistente PostGIS-Datenbank für Häfen, Schleusen, Brücken und Kontaktdaten in
Deutschland und den Niederlanden. Die sichtbaren Kartenkacheln bleiben externe
Dienste. Öffentlich gebunden werden ausschließlich TCP 80 und 443; App und
PostGIS sind nur im internen Docker-Netz erreichbar.
## Konfiguration
@@ -12,11 +13,19 @@ cp deploy/.env.production.example deploy/.env.production
editor deploy/.env.production
```
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).
Mindestens `WATERMAPS_ACME_EMAIL` und `WATERMAPS_POSTGRES_PASSWORD` müssen
angepasst werden. Für das Datenbankpasswort eignet sich ein URL-unabhängiges
Hex-Secret:
```bash
openssl rand -hex 32
```
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.
@@ -53,8 +62,8 @@ werden nur Tests ausgeführt; Registry-Secrets werden dabei nicht verwendet.
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`.
`deploy/` sowie das kanonische `database/schema.sql`, 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:
@@ -78,20 +87,36 @@ Anmeldung gültig ist:
--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.
Das Serverskript lädt App, den kombinierten Routing-/Feature-Daten-Builder,
PostGIS, 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:
Niederlande, baut den Fahrroutenindex und importiert daraus die
`marine_features`. Je nach Serverleistung kann dies längere Zeit dauern.
App und Nginx werden beim ersten Release erst gestartet, wenn sowohl der
Routingindex als auch ein nicht leerer, zur PBF-Prüfsumme passender Bestand an
Häfen, Schleusen und Brücken geprüft wurde. Ein HTTP-Healthcheck allein kann
damit keine leere Ereignisdatenbank freigeben.
Die produktiven Daten liegen auf dem persistenten Servervolume:
```text
/srv/watermaps-data/local/germany-netherlands-fairways.json
/srv/watermaps-data/local/.marine-features.ready
/srv/watermaps-data/postgres/
/srv/watermaps-data/tmp/
```
PostGIS veröffentlicht keinen Host-Port. Die App verbindet sich im privaten
Compose-Netz; das Passwort wird separat über `PGPASSWORD` übergeben und muss
nicht URL-kodiert in `DATABASE_URL` dupliziert werden. Auch die temporären,
potenziell mehrere Gigabyte großen Filter- und GeoJSON-Dateien des Imports
liegen unter `tmp/` auf diesem Volume und nicht im Docker-Overlay der
Rootdisk; nach jedem Importlauf werden sie entfernt.
Der Upload wartet zuerst auf SSH und den Abschluss von Cloud-init. Anschließend
startet und prüft er `watermaps-volume-setup.service`. Ohne tatsächlich unter
`/srv/watermaps-data` eingehängtes Volume wird kein Download gestartet, damit
@@ -108,7 +133,10 @@ HTTP-Anfragen erhalten 404.
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.
vorherige Release und führt die Health-, Routen- und Featuretests erneut aus.
Ein Rollback wechselt nur die unveränderlichen App-/Builder-Images. Die
persistente PostGIS-Datenbank bleibt erhalten; das Schema und der Import sind
aufwärtskompatibel und idempotent.
Das unmittelbar vorherige Release lässt sich auch manuell aktivieren:
@@ -153,11 +181,26 @@ HTTPS um.
`bootstrap-server.sh` installiert zwei systemd-Timer:
- `watermaps-route-update.timer`: täglich neue Deutschland- und
Niederlande-Daten; bei Build- oder Routentestfehler bleibt der vorherige
Index aktiv.
Niederlande-Daten. Der Fahrroutenindex wird atomar aktiviert. Der
Feature-Importer aktualisiert beide Länder per Upsert und entfernt
verschwundene Datensätze erst, nachdem beide Importe vollständig waren;
dabei werden ausschließlich OSM-Zeilen bereinigt. EuRIS- und
Website-Anreicherungen bleiben erhalten.
- `watermaps-certbot-renew.timer`: zweimal täglich Certbot-Prüfung mit
anschließendem Nginx-Reload.
Ein vollständiger manueller Feature-Neuimport lässt sich über die
Option `--force-marine` erzwingen:
```bash
/opt/watermaps/deploy/scripts/update-route-data.sh --force-marine
```
`WATERMAPS_REBUILD_MARINE_DATA=true` in `.env.production` erzwingt den
Neuimport stattdessen beim nächsten regulären Deployment. Der systemd-Dienst
hat bewusst kein Startzeitlimit, da Download, Indexbau und Import auf kleineren
Servern deutlich länger als 90 Sekunden dauern können.
Status und Logs:
```bash
+57 -2
View File
@@ -7,7 +7,8 @@ services:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 5174
DATABASE_URL: ""
DATABASE_URL: postgresql://seacompass@postgres:5432/seacompass
PGPASSWORD: ${WATERMAPS_POSTGRES_PASSWORD:?WATERMAPS_POSTGRES_PASSWORD fehlt}
REDIS_URL: ""
WATERMAPS_WEB_DIST_PATH: /app/apps/web/dist
WATERMAPS_LOCAL_FAIRWAYS_PATH: /data/germany-netherlands-fairways.json
@@ -20,6 +21,9 @@ services:
source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}/local
target: /data
read_only: true
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
init: true
security_opt:
@@ -32,7 +36,7 @@ services:
- node
- -e
- >-
fetch('http://127.0.0.1:5174/health')
fetch('http://127.0.0.1:5174/ready')
.then(response => { if (!response.ok) process.exit(1) })
.catch(() => process.exit(1))
interval: 15s
@@ -40,6 +44,38 @@ services:
start_period: 15s
retries: 5
postgres:
image: ${WATERMAPS_POSTGRES_IMAGE:-postgis/postgis:16-3.4}
environment:
POSTGRES_DB: seacompass
POSTGRES_USER: seacompass
POSTGRES_PASSWORD: ${WATERMAPS_POSTGRES_PASSWORD:?WATERMAPS_POSTGRES_PASSWORD fehlt}
expose:
- "5432"
volumes:
- type: bind
source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}/postgres
target: /var/lib/postgresql/data
bind:
create_host_path: false
- type: bind
source: ./database/schema.sql
target: /docker-entrypoint-initdb.d/01-schema.sql
read_only: true
restart: unless-stopped
init: true
security_opt:
- no-new-privileges:true
healthcheck:
test:
- CMD-SHELL
- pg_isready --username "$$POSTGRES_USER" --dbname "$$POSTGRES_DB"
interval: 10s
timeout: 5s
start_period: 30s
retries: 10
stop_grace_period: 60s
nginx:
image: ${WATERMAPS_NGINX_IMAGE:-nginx:1.30.4-alpine}
depends_on:
@@ -92,3 +128,22 @@ services:
source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}
target: /workspace/data
restart: "no"
marine-data:
image: ${WATERMAPS_ROUTE_DATA_IMAGE:?WATERMAPS_ROUTE_DATA_IMAGE fehlt}
profiles: ["maintenance"]
entrypoint: ["/workspace/deploy/scripts/prepare-marine-features.sh"]
environment:
DATABASE_URL: postgresql://seacompass@postgres:5432/seacompass
PGPASSWORD: ${WATERMAPS_POSTGRES_PASSWORD:?WATERMAPS_POSTGRES_PASSWORD fehlt}
TMPDIR: /workspace/data/tmp
WATERMAPS_GEOFABRIK_DIR: /workspace/data/geofabrik
WATERMAPS_MARINE_MARKER_PATH: /workspace/data/local/.marine-features.ready
volumes:
- type: bind
source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}
target: /workspace/data
depends_on:
postgres:
condition: service_healthy
restart: "no"
+21 -4
View File
@@ -1,13 +1,30 @@
FROM python:3.12-slim
FROM node:22-bookworm-slim
RUN apt-get update \
&& apt-get install --yes --no-install-recommends ca-certificates curl coreutils libexpat1 \
&& rm -rf /var/lib/apt/lists/* \
&& python3 -m pip install --no-cache-dir "osmium==4.3.1"
&& apt-get install --yes --no-install-recommends \
ca-certificates \
coreutils \
curl \
libexpat1 \
osmium-tool \
postgresql-client \
python3 \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace
RUN python3 -m pip install \
--break-system-packages \
--disable-pip-version-check \
--no-cache-dir \
--target /workspace/.tools/python \
"osmium==4.3.1" \
&& npm install --no-package-lock --no-save "pg@8.16.3"
COPY scripts ./scripts
COPY database ./database
COPY deploy/scripts/prepare-route-data.sh ./deploy/scripts/prepare-route-data.sh
COPY deploy/scripts/prepare-marine-features.sh ./deploy/scripts/prepare-marine-features.sh
ENTRYPOINT ["/workspace/deploy/scripts/prepare-route-data.sh"]
+8
View File
@@ -87,6 +87,8 @@ install -d -m 0755 \
"$WATERMAPS_DATA_DIR" \
"$WATERMAPS_DATA_DIR/geofabrik" \
"$WATERMAPS_DATA_DIR/local" \
"$WATERMAPS_DATA_DIR/postgres" \
"$WATERMAPS_DATA_DIR/tmp" \
"$WATERMAPS_DATA_DIR/certbot" \
"$WATERMAPS_DATA_DIR/certbot/www" \
"$WATERMAPS_DATA_DIR/certbot/letsencrypt" \
@@ -95,6 +97,12 @@ install -d -m 0755 \
"$WATERMAPS_RUNTIME_DIR/nginx/conf.d" \
"$WATERMAPS_RUNTIME_DIR/locks"
# postgis/postgis uses the Debian postgres UID/GID 999. Keeping PGDATA on the
# mounted Hetzner volume makes event data survive image deployments and host
# reboots; the official entrypoint can still repair ownership inside PGDATA.
chown 999:999 "$WATERMAPS_DATA_DIR/postgres"
chmod 0700 "$WATERMAPS_DATA_DIR/postgres"
if [[ ! -f "$WATERMAPS_RUNTIME_DIR/nginx/conf.d/default.conf" ]]; then
install -m 0644 \
"$WM_DEPLOY_DIR/nginx/bootstrap.conf" \
+156
View File
@@ -33,6 +33,16 @@ wm_load_env() {
wm_require_safe_absolute_dir "$WATERMAPS_DATA_DIR"
wm_require_safe_absolute_dir "$WATERMAPS_RUNTIME_DIR"
wm_validate_postgres_password
}
wm_validate_postgres_password() {
local password="${WATERMAPS_POSTGRES_PASSWORD:-}"
[[ "${#password}" -ge 24 &&
"$password" != *[[:space:]]* &&
"$password" != *REPLACE* ]] ||
wm_die "WATERMAPS_POSTGRES_PASSWORD muss ein gesetztes Secret mit mindestens 24 Zeichen ohne Leerzeichen sein."
}
wm_require_safe_absolute_dir() {
@@ -162,6 +172,10 @@ wm_route_marker() {
printf '%s/local/.germany-netherlands-fairways.ready\n' "$WATERMAPS_DATA_DIR"
}
wm_marine_marker() {
printf '%s/local/.marine-features.ready\n' "$WATERMAPS_DATA_DIR"
}
wm_acquire_route_lock() {
install -d -m 0755 "$WATERMAPS_RUNTIME_DIR/locks"
exec 9>"$WATERMAPS_RUNTIME_DIR/locks/route-update.lock"
@@ -288,6 +302,107 @@ wm_route_data_files_ready() {
return 0
}
wm_geofabrik_checksum() {
local region="$1"
local checksum_file="$WATERMAPS_DATA_DIR/geofabrik/${region}-latest.osm.pbf.md5"
local checksum
[[ -s "$checksum_file" ]] || return 1
checksum="$(awk 'NR == 1 { print tolower($1) }' "$checksum_file")"
[[ "$checksum" =~ ^[0-9a-f]{32}$ ]] || return 1
printf '%s\n' "$checksum"
}
wm_postgres_query() {
local query="$1"
wm_compose exec --no-TTY postgres \
psql \
--no-psqlrc \
--username seacompass \
--dbname seacompass \
--tuples-only \
--no-align \
--field-separator '|' \
--set ON_ERROR_STOP=1 \
--command "$query"
}
wm_marine_features_ready() {
local marker germany_checksum netherlands_checksum database_counts
local total_count harbour_count lock_count bridge_count
local marker_format marker_source marker_germany marker_netherlands
local marker_total marker_harbours marker_locks marker_bridges
marker="$(wm_marine_marker)"
if [[ ! -s "$marker" ]]; then
wm_log "Bereitschaftsmarker für Marine-Features fehlt: $marker"
return 1
fi
germany_checksum="$(wm_geofabrik_checksum germany 2>/dev/null || true)"
netherlands_checksum="$(wm_geofabrik_checksum netherlands 2>/dev/null || true)"
if [[ -z "$germany_checksum" || -z "$netherlands_checksum" ]]; then
wm_log "Geofabrik-Prüfsummen für den Marine-Feature-Stand fehlen oder sind ungültig."
return 1
fi
marker_format="$(wm_marker_value "$marker" format_version 2>/dev/null || true)"
marker_source="$(wm_marker_value "$marker" source 2>/dev/null || true)"
marker_germany="$(wm_marker_value "$marker" germany_md5 2>/dev/null || true)"
marker_netherlands="$(wm_marker_value "$marker" netherlands_md5 2>/dev/null || true)"
marker_total="$(wm_marker_value "$marker" total_osm_features 2>/dev/null || true)"
marker_harbours="$(wm_marker_value "$marker" harbour_count 2>/dev/null || true)"
marker_locks="$(wm_marker_value "$marker" lock_count 2>/dev/null || true)"
marker_bridges="$(wm_marker_value "$marker" bridge_count 2>/dev/null || true)"
if [[ "$marker_format" != "1" ||
"$marker_source" != "germany+netherlands" ||
"$marker_germany" != "$germany_checksum" ||
"$marker_netherlands" != "$netherlands_checksum" ]]; then
wm_log "Marine-Feature-Marker passt nicht zu den aktuellen Deutschland-/Niederlande-Snapshots."
return 1
fi
if ! database_counts="$(
wm_postgres_query "
SELECT
count(*)::bigint,
count(*) FILTER (WHERE layer = 'harbours')::bigint,
count(*) FILTER (WHERE layer = 'locks')::bigint,
count(*) FILTER (WHERE layer = 'bridges')::bigint
FROM marine_features
WHERE source = 'osm';
"
)"; then
wm_log "Marine-Feature-Tabellen sind in PostGIS nicht abfragbar."
return 1
fi
database_counts="${database_counts//[[:space:]]/}"
IFS='|' read -r total_count harbour_count lock_count bridge_count <<<"$database_counts"
for count in "$total_count" "$harbour_count" "$lock_count" "$bridge_count"; do
if [[ ! "$count" =~ ^[1-9][0-9]*$ ]]; then
wm_log "PostGIS enthält keine vollständigen OSM-Hafen-/Schleusen-/Brückendaten."
return 1
fi
done
if [[ "$marker_total" != "$total_count" ||
"$marker_harbours" != "$harbour_count" ||
"$marker_locks" != "$lock_count" ||
"$marker_bridges" != "$bridge_count" ]]; then
wm_log "Marine-Feature-Marker stimmt nicht mit den OSM-Zeilen in PostGIS überein."
return 1
fi
return 0
}
wm_assert_marine_features() {
wm_marine_features_ready ||
wm_die "Marine-Features sind nicht vollständig in PostGIS bereit."
}
wm_wait_for_health() {
local service="$1"
local timeout_seconds="${2:-180}"
@@ -479,3 +594,44 @@ wm_smoke_test_route() {
}
'
}
wm_smoke_test_features() {
wm_compose exec --no-TTY watermaps node --input-type=module --eval '
const params = new URLSearchParams({
bbox: "7.118483884871649,53.302596677614666,7.543960668999219,53.50669706666667",
layers: "harbours,locks,bridges"
});
const response = await fetch(`http://127.0.0.1:5174/api/features?${params}`);
if (!response.ok) {
console.error("Feature-Smoke-Test", response.status, await response.text());
process.exit(1);
}
const collection = await response.json();
const features = Array.isArray(collection.features) ? collection.features : [];
const layers = new Set(features.map((feature) => feature?.properties?.layer));
const contactFeature = features.find((feature) =>
["harbours", "locks"].includes(feature?.properties?.layer)
&& typeof feature?.properties?.phone === "string"
&& feature.properties.phone.trim().length > 0
);
if (
collection?.metadata?.source !== "postgis"
|| !layers.has("harbours")
|| !layers.has("locks")
|| !layers.has("bridges")
|| !contactFeature
) {
console.error(
"PostGIS-Feature-Prüfung für die EmdenAurich-Teststrecke fehlgeschlagen.",
JSON.stringify({
source: collection?.metadata?.source,
featureCount: features.length,
layers: [...layers],
hasCallableHarbourOrLock: Boolean(contactFeature)
})
);
process.exit(1);
}
'
}
+28 -12
View File
@@ -69,12 +69,16 @@ wm_finish_deployment() {
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_compose up --detach --no-build --remove-orphans postgres watermaps nginx
wm_wait_for_health postgres 240
wm_wait_for_health watermaps 240
wm_wait_for_health nginx 120
if wm_route_data_ready; then
wm_smoke_test_route
fi
if wm_marine_features_ready; then
wm_smoke_test_features
fi
else
wm_log "Deployment fehlgeschlagen; für das erste Release existiert noch kein Rollback."
fi
@@ -99,7 +103,7 @@ 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
wm_compose --profile maintenance pull watermaps postgres 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")"
@@ -114,30 +118,42 @@ 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_log "Persistentes PostGIS wird vor App und Datenimport gestartet."
wm_compose up --detach --no-build postgres
wm_wait_for_health postgres 240
wm_wait_for_health watermaps 240
wm_wait_for_health nginx 120
route_rebuild_required=false
data_update_required=false
update_args=()
if [[ "${WATERMAPS_REBUILD_ROUTE_DATA:-false}" == "true" ]]; then
route_rebuild_required=true
data_update_required=true
update_args+=(--force)
elif ! wm_route_data_ready; then
route_rebuild_required=true
data_update_required=true
fi
if [[ "${WATERMAPS_REBUILD_MARINE_DATA:-false}" == "true" ]]; then
data_update_required=true
update_args+=(--force-marine)
elif ! wm_marine_features_ready; then
data_update_required=true
fi
if [[ "$route_rebuild_required" == "true" ]]; then
wm_log "Deutschland- und Niederlande-Routingdaten werden sicher vorbereitet."
if [[ "$data_update_required" == "true" ]]; then
wm_log "Deutschland-/Niederlande-Routingdaten und Marine-Features werden sicher vorbereitet."
WATERMAPS_ROUTE_LOCK_HELD=true \
"$WM_DEPLOY_DIR/scripts/update-route-data.sh" "${update_args[@]}"
fi
wm_assert_route_data
wm_assert_marine_features
wm_log "Commit $revision wird erst mit validierten Routing- und Ereignisdaten gestartet."
wm_compose up --detach --no-build --remove-orphans postgres watermaps nginx
wm_wait_for_health postgres 240
wm_wait_for_health watermaps 240
wm_wait_for_health nginx 120
wm_smoke_test_route
wm_smoke_test_features
release_images_file="$WATERMAPS_RUNTIME_DIR/deployments/$revision.env"
install -m 0644 "$resolved_images_file" "$release_images_file"
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
set -Eeuo pipefail
cd /workspace
database_url="${DATABASE_URL:-}"
geofabrik_dir="${WATERMAPS_GEOFABRIK_DIR:-/workspace/data/geofabrik}"
marker_file="${WATERMAPS_MARINE_MARKER_PATH:-/workspace/data/local/.marine-features.ready}"
temporary_root="${TMPDIR:-/tmp}"
pbf_paths=(
"$geofabrik_dir/germany-latest.osm.pbf"
"$geofabrik_dir/netherlands-latest.osm.pbf"
)
if [[ -z "$database_url" ]]; then
printf 'Fehler: DATABASE_URL ist für den Marine-Feature-Import erforderlich.\n' >&2
exit 1
fi
if [[ ! -x /workspace/scripts/import-geofabrik.sh ]]; then
printf 'Fehler: scripts/import-geofabrik.sh fehlt oder ist nicht ausführbar.\n' >&2
exit 1
fi
mkdir -p "$(dirname "$marker_file")" "$temporary_root"
export TMPDIR="$temporary_root"
declare -A source_checksums
for pbf_path in "${pbf_paths[@]}"; do
if [[ ! -s "$pbf_path" ]]; then
printf 'Fehler: Geofabrik-Snapshot fehlt oder ist leer: %s\n' "$pbf_path" >&2
exit 1
fi
checksum_file="${pbf_path}.md5"
if [[ ! -s "$checksum_file" ]]; then
printf 'Fehler: Geofabrik-Prüfsumme fehlt: %s\n' "$checksum_file" >&2
exit 1
fi
expected_checksum="$(awk 'NR == 1 { print tolower($1) }' "$checksum_file")"
if [[ ! "$expected_checksum" =~ ^[0-9a-f]{32}$ ]]; then
printf 'Fehler: Ungültige Geofabrik-Prüfsumme in %s\n' "$checksum_file" >&2
exit 1
fi
actual_checksum="$(md5sum "$pbf_path" | awk '{ print $1 }')"
if [[ "$actual_checksum" != "$expected_checksum" ]]; then
printf 'Fehler: Geofabrik-Snapshot stimmt nicht mit seiner Prüfsumme überein: %s\n' "$pbf_path" >&2
exit 1
fi
source_checksums["$(basename "$pbf_path" -latest.osm.pbf)"]="$actual_checksum"
done
# Every imported OSM row receives updated_at=now(). The shared database
# timestamp lets us remove disappeared OSM objects only after both regional
# imports completed and passed the sanity checks. Other sources such as EuRIS
# and facility-website enrichments are deliberately preserved.
import_started_epoch="$(
psql "$database_url" \
--no-psqlrc \
--tuples-only \
--no-align \
--set ON_ERROR_STOP=1 \
--command "SELECT extract(epoch FROM clock_timestamp());"
)"
import_started_epoch="${import_started_epoch//[[:space:]]/}"
if [[ ! "$import_started_epoch" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
printf 'Fehler: Datenbank lieferte keinen gültigen Importzeitpunkt.\n' >&2
exit 1
fi
for pbf_path in "${pbf_paths[@]}"; do
printf 'Importiere Marine-Features aus %s\n' "$(basename "$pbf_path")"
DATABASE_URL="$database_url" /workspace/scripts/import-geofabrik.sh "$pbf_path"
done
fresh_counts="$(
psql "$database_url" \
--no-psqlrc \
--tuples-only \
--no-align \
--field-separator '|' \
--set ON_ERROR_STOP=1 \
--command "
SELECT
count(*)::bigint,
count(*) FILTER (WHERE layer = 'harbours')::bigint,
count(*) FILTER (WHERE layer = 'locks')::bigint,
count(*) FILTER (WHERE layer = 'bridges')::bigint
FROM marine_features
WHERE source = 'osm'
AND updated_at >= to_timestamp($import_started_epoch);
"
)"
fresh_counts="${fresh_counts//[[:space:]]/}"
IFS='|' read -r fresh_total fresh_harbours fresh_locks fresh_bridges <<<"$fresh_counts"
for count in "$fresh_total" "$fresh_harbours" "$fresh_locks" "$fresh_bridges"; do
if [[ ! "$count" =~ ^[1-9][0-9]*$ ]]; then
printf 'Fehler: Der neue OSM-Import enthält keine vollständigen Hafen-/Schleusen-/Brückendaten.\n' >&2
exit 1
fi
done
psql "$database_url" \
--no-psqlrc \
--set ON_ERROR_STOP=1 \
--command "
BEGIN;
DELETE FROM marine_features
WHERE source = 'osm'
AND updated_at < to_timestamp($import_started_epoch);
DELETE FROM marine_fairway_edges
WHERE source = 'osm'
AND updated_at < to_timestamp($import_started_epoch);
COMMIT;
ANALYZE marine_features;
ANALYZE marine_fairway_edges;
"
final_counts="$(
psql "$database_url" \
--no-psqlrc \
--tuples-only \
--no-align \
--field-separator '|' \
--set ON_ERROR_STOP=1 \
--command "
SELECT
count(*)::bigint,
count(*) FILTER (WHERE layer = 'harbours')::bigint,
count(*) FILTER (WHERE layer = 'locks')::bigint,
count(*) FILTER (WHERE layer = 'bridges')::bigint
FROM marine_features
WHERE source = 'osm';
"
)"
final_counts="${final_counts//[[:space:]]/}"
IFS='|' read -r total_osm_features harbour_count lock_count bridge_count <<<"$final_counts"
temporary_marker="$(mktemp "$(dirname "$marker_file")/.marine-features-ready.XXXXXX")"
{
printf 'format_version=1\n'
printf 'generated_at=%s\n' "$(date --utc +%Y-%m-%dT%H:%M:%SZ)"
printf 'source=germany+netherlands\n'
printf 'germany_md5=%s\n' "${source_checksums[germany]}"
printf 'netherlands_md5=%s\n' "${source_checksums[netherlands]}"
printf 'total_osm_features=%s\n' "$total_osm_features"
printf 'harbour_count=%s\n' "$harbour_count"
printf 'lock_count=%s\n' "$lock_count"
printf 'bridge_count=%s\n' "$bridge_count"
} >"$temporary_marker"
chmod 0644 "$temporary_marker"
mv -f "$temporary_marker" "$marker_file"
printf 'Marine-Features sind bereit: %s OSM-Objekte (%s Häfen, %s Schleusen, %s Brücken)\n' \
"$total_osm_features" "$harbour_count" "$lock_count" "$bridge_count"
@@ -71,4 +71,56 @@ if wm_route_data_ready; then
exit 1
fi
printf 'Routingdaten- und Markerprüfungen: OK\n'
mkdir -p "$WATERMAPS_DATA_DIR/geofabrik"
printf '%s germany-latest.osm.pbf\n' \
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \
>"$WATERMAPS_DATA_DIR/geofabrik/germany-latest.osm.pbf.md5"
printf '%s netherlands-latest.osm.pbf\n' \
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' \
>"$WATERMAPS_DATA_DIR/geofabrik/netherlands-latest.osm.pbf.md5"
cat >"$(wm_marine_marker)" <<'MARKER'
format_version=1
generated_at=2026-07-28T00:00:00Z
source=germany+netherlands
germany_md5=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
netherlands_md5=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
total_osm_features=100
harbour_count=10
lock_count=5
bridge_count=20
MARKER
wm_postgres_query() {
printf '100|10|5|20\n'
}
wm_marine_features_ready
wm_postgres_query() {
printf '100|10|0|20\n'
}
if wm_marine_features_ready; then
printf 'Marine-Feature-Marker wurde trotz fehlender Schleusen akzeptiert.\n' >&2
exit 1
fi
wm_postgres_query() {
printf '99|10|5|20\n'
}
if wm_marine_features_ready; then
printf 'Marine-Feature-Marker wurde trotz abweichendem Datenbankbestand akzeptiert.\n' >&2
exit 1
fi
if grep -Eq 'DATABASE_URL:[[:space:]]*""' \
"$ROOT_DIR/docker-compose.yml" \
"$ROOT_DIR/deploy/compose.production.yml"; then
printf 'Compose trennt die App weiterhin explizit von PostGIS.\n' >&2
exit 1
fi
grep -Fq 'condition: service_healthy' "$ROOT_DIR/deploy/compose.production.yml"
grep -Fq '/ready' "$ROOT_DIR/deploy/compose.production.yml"
grep -Fq 'source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}/postgres' \
"$ROOT_DIR/deploy/compose.production.yml"
printf 'Routingdaten-, Marine-Feature- und Compose-Prüfungen: OK\n'
@@ -16,6 +16,7 @@ mkdir -p "$FAKE_BIN" "$DATA_DIR/local" "$RUNTIME_DIR"
cat >"$ENV_FILE" <<EOF
WATERMAPS_DATA_DIR=$DATA_DIR
WATERMAPS_RUNTIME_DIR=$RUNTIME_DIR
WATERMAPS_POSTGRES_PASSWORD=0123456789abcdef0123456789abcdef
EOF
cat >"$IMAGES_ENV_FILE" <<'EOF'
@@ -48,6 +49,11 @@ if [[ "${1:-}" == "inspect" ]]; then
fi
arguments=" $* "
if [[ "$arguments" == *" ps --quiet postgres "* ]]; then
printf 'postgres-test-container\n'
exit 0
fi
if [[ "$arguments" == *" ps --quiet watermaps "* ]]; then
printf 'watermaps-test-container\n'
exit 0
@@ -55,6 +61,13 @@ fi
if [[ "$arguments" == *" run "*" route-data "* ||
"$arguments" == *" run "*" route-data" ]]; then
mkdir -p "$WATERMAPS_DATA_DIR/geofabrik"
printf '%s germany-latest.osm.pbf\n' \
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \
>"$WATERMAPS_DATA_DIR/geofabrik/germany-latest.osm.pbf.md5"
printf '%s netherlands-latest.osm.pbf\n' \
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' \
>"$WATERMAPS_DATA_DIR/geofabrik/netherlands-latest.osm.pbf.md5"
route_file="$WATERMAPS_DATA_DIR/local/.germany-netherlands-fairways.candidate.json"
marker_file="$WATERMAPS_DATA_DIR/local/.germany-netherlands-fairways.candidate.ready"
cat >"$route_file" <<JSON
@@ -86,6 +99,27 @@ JSON
exit 0
fi
if [[ "$arguments" == *" run "*" marine-data "* ||
"$arguments" == *" run "*" marine-data" ]]; then
cat >"$WATERMAPS_DATA_DIR/local/.marine-features.ready" <<'MARKER'
format_version=1
generated_at=2026-07-28T00:00:00Z
source=germany+netherlands
germany_md5=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
netherlands_md5=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
total_osm_features=100
harbour_count=10
lock_count=5
bridge_count=20
MARKER
exit 0
fi
if [[ "$arguments" == *" exec --no-TTY postgres "* ]]; then
printf '100|10|5|20\n'
exit 0
fi
if [[ "$arguments" == *" exec --no-TTY watermaps "* ]]; then
active_file="$WATERMAPS_DATA_DIR/local/germany-netherlands-fairways.json"
active_revision="$(jq --raw-output '.revision // 0' "$active_file" 2>/dev/null || true)"
+46 -10
View File
@@ -13,11 +13,22 @@ if [[ "$(id -u)" -ne 0 ]]; then
fi
force_rebuild=false
if [[ "${1:-}" == "--force" ]]; then
force_rebuild=true
shift
fi
[[ "$#" -eq 0 ]] || wm_die "Unbekannte Argumente für update-route-data.sh."
force_marine_import=false
while [[ "$#" -gt 0 ]]; do
case "$1" in
--force)
force_rebuild=true
shift
;;
--force-marine)
force_marine_import=true
shift
;;
*)
wm_die "Unbekanntes Argument für update-route-data.sh: $1"
;;
esac
done
install -d -m 0755 "$WATERMAPS_RUNTIME_DIR/locks" "$WATERMAPS_DATA_DIR/local"
if [[ "${WATERMAPS_ROUTE_LOCK_HELD:-false}" != "true" ]]; then
@@ -26,6 +37,9 @@ if [[ "${WATERMAPS_ROUTE_LOCK_HELD:-false}" != "true" ]]; then
fi
fi
wm_compose up --detach --no-build postgres
wm_wait_for_health postgres 240
route_file="$(wm_route_file)"
route_marker="$(wm_route_marker)"
route_dir="$(dirname "$route_file")"
@@ -58,6 +72,16 @@ wm_watermaps_is_running() {
[[ -n "$(wm_compose ps --quiet watermaps)" ]]
}
wm_update_marine_features() {
if [[ "$force_marine_import" == "true" ]] || ! wm_marine_features_ready; then
wm_log "Häfen, Schleusen und Brücken werden aus den verifizierten Geofabrik-Snapshots importiert."
wm_compose --profile maintenance run --rm marine-data
else
wm_log "Marine-Features in PostGIS sind bereits aktuell."
fi
wm_assert_marine_features
}
wm_restore_previous_route() {
rollback_restored_previous=false
if [[ -s "$backup_file" && -s "$backup_marker" ]] &&
@@ -78,8 +102,8 @@ wm_restore_previous_route() {
wm_restart_after_restore() {
if ! wm_watermaps_is_running; then
wm_log "Watermaps läuft nicht; wiederhergestellte Routingdaten können noch nicht getestet werden."
return 1
wm_log "Watermaps läuft noch nicht; wiederhergestellte Routingdaten werden beim App-Start getestet."
return 0
fi
wm_compose restart watermaps
@@ -156,8 +180,10 @@ if ! wm_route_data_files_ready "$candidate_file" "$candidate_marker"; then
wm_die "Der neu gebaute Routingindex oder sein Bereitschaftsmarker ist ungültig."
fi
wm_update_marine_features
if [[ "$previous_ready" == "true" ]] && cmp --silent "$candidate_file" "$route_file"; then
wm_log "Geofabrik-Snapshots und Routingindex sind unverändert."
wm_log "Geofabrik-Snapshots, Routingindex und Marine-Features sind aktuell."
rm -f "$candidate_file" "$candidate_marker"
exit 0
fi
@@ -186,8 +212,18 @@ wm_write_route_marker "$route_file" "$route_marker"
wm_route_data_ready ||
wm_die "Der aktivierte Routingindex besitzt keinen gültigen Bereitschaftsmarker."
wm_compose restart watermaps
if wm_wait_for_health watermaps 240 && wm_smoke_test_route; then
activation_valid=false
if wm_watermaps_is_running; then
wm_compose restart watermaps
if wm_wait_for_health watermaps 240 && wm_smoke_test_route; then
activation_valid=true
fi
else
wm_log "Watermaps läuft noch nicht; Routing- und Feature-API werden beim App-Start getestet."
activation_valid=true
fi
if [[ "$activation_valid" == "true" ]]; then
rm -f "$transaction_file"
transaction_active=false
rm -f "$backup_file" "$backup_marker"
+10 -2
View File
@@ -124,9 +124,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 nach %s:/opt/watermaps/deploy\n' "$WM_SSH_TARGET"
printf '[watermaps] Übertrage Deployment-Dateien und Datenbankschema 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"
"${remote_prefix}install -d -m 0755 /opt/watermaps /opt/watermaps/deploy /opt/watermaps/database"
rsync \
--archive \
@@ -140,6 +140,14 @@ rsync \
"$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 \
@@ -1,5 +1,5 @@
[Unit]
Description=Watermaps Deutschland-/Niederlande-Routingdaten aktualisieren
Description=Watermaps Deutschland-/Niederlande-Routing- und Ereignisdaten aktualisieren
Wants=network-online.target
After=network-online.target docker.service watermaps-volume-setup.service
Requires=docker.service
@@ -9,6 +9,7 @@ RequiresMountsFor=/srv/watermaps-data
Type=oneshot
WorkingDirectory=/opt/watermaps
ExecStart=/opt/watermaps/deploy/scripts/update-route-data.sh
TimeoutStartSec=0
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=Täglich Watermaps-Routingdaten aktualisieren
Description=Täglich Watermaps-Routing- und Ereignisdaten aktualisieren
[Timer]
OnCalendar=*-*-* 06:15:00