#!/usr/bin/env bash 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() { printf 'Fehler: %s\n' "$*" >&2 exit 1 } wm_log() { printf '[watermaps] %s\n' "$*" } wm_load_env() { if [[ ! -f "$WM_ENV_FILE" ]]; then wm_die "Konfiguration fehlt: $WM_ENV_FILE (Vorlage: deploy/.env.production.example)" fi set -a # shellcheck disable=SC1090 source "$WM_ENV_FILE" set +a WATERMAPS_DATA_DIR="${WATERMAPS_DATA_DIR:-/srv/watermaps-data}" WATERMAPS_RUNTIME_DIR="${WATERMAPS_RUNTIME_DIR:-/srv/watermaps-runtime}" export WATERMAPS_DATA_DIR WATERMAPS_RUNTIME_DIR wm_require_safe_absolute_dir "$WATERMAPS_DATA_DIR" wm_require_safe_absolute_dir "$WATERMAPS_RUNTIME_DIR" } wm_require_safe_absolute_dir() { local directory="$1" [[ "$directory" == /* ]] || wm_die "Pfad muss absolut sein: $directory" [[ "$directory" != "/" ]] || wm_die "Das Wurzelverzeichnis darf nicht als Datenpfad verwendet werden." [[ "$directory" != "/srv" ]] || wm_die "Bitte ein Unterverzeichnis von /srv verwenden." } wm_assert_data_mount() { mountpoint --quiet "$WATERMAPS_DATA_DIR" || wm_die "Persistentes Datenvolume ist nicht unter $WATERMAPS_DATA_DIR eingehängt." } 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" } wm_route_marker() { printf '%s/local/.germany-netherlands-fairways.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" if ! flock --nonblock 9; then wm_log "Ein Routingdaten-Update oder Deployment läuft bereits." return 1 fi } wm_assert_route_data() { wm_route_data_ready || wm_die "Routingdaten sind nicht vollständig bereit." } wm_route_data_ready() { wm_route_data_files_ready "$(wm_route_file)" "$(wm_route_marker)" } wm_marker_value() { local marker="$1" local requested_key="$2" awk -F= -v requested_key="$requested_key" ' $1 == requested_key { value = substr($0, length($1) + 2) matches += 1 } END { if (matches != 1) { exit 1 } print value } ' "$marker" } wm_write_route_marker() { local route_file="$1" local marker="$2" local temporary_marker [[ -s "$route_file" ]] || return 1 chmod 0644 "$route_file" temporary_marker="$(mktemp "$(dirname "$marker")/.routing-ready.XXXXXX")" { printf 'format_version=1\n' printf 'generated_at=%s\n' "$(date --utc +%Y-%m-%dT%H:%M:%SZ)" printf 'route_file_name=%s\n' "$(basename "$route_file")" printf 'size_bytes=%s\n' "$(stat --format=%s "$route_file")" printf 'sha256=%s\n' "$(sha256sum "$route_file" | awk '{ print $1 }')" printf 'generator_version=2\n' printf 'source=germany+netherlands\n' } >"$temporary_marker" chmod 0644 "$temporary_marker" mv -f "$temporary_marker" "$marker" } wm_route_data_files_ready() { local route_file="$1" local marker="$2" local file_mode marker_format marker_file_name marker_size marker_checksum local marker_generator marker_source actual_size actual_checksum if [[ ! -s "$route_file" ]]; then wm_log "Routingindex fehlt oder ist leer: $route_file" return 1 fi if [[ ! -s "$marker" ]]; then wm_log "Bereitschaftsmarker fehlt: $marker" return 1 fi if [[ ! "$marker" -nt "$route_file" ]]; then wm_log "Bereitschaftsmarker ist älter als der Routingindex." return 1 fi if [[ ! -r "$route_file" ]]; then wm_log "Routingindex ist für den prüfenden Benutzer nicht lesbar: $route_file" return 1 fi file_mode="$(stat --format=%a "$route_file")" if (( (8#$file_mode & 4) == 0 )); then wm_log "Routingindex ist für den unprivilegierten App-Container nicht lesbar (Modus $file_mode)." return 1 fi if ! jq --exit-status ' (.version == 1) and (.generatorVersion == 2) and (.source == "germany+netherlands") and (.sources | type == "array" and length == 2) and (([.sources[].region] | sort) == ["germany", "netherlands"]) and (([.sources[].file] | sort) == [ "germany-latest.osm.pbf", "netherlands-latest.osm.pbf" ]) and (all(.sources[]; (.checksumMd5 | type == "string" and test("^[0-9a-f]{32}$")) and (.sizeBytes | type == "number" and . > 0) )) and (.ways | type == "array" and length > 0) ' "$route_file" >/dev/null; then wm_log "Routingindex enthält nicht exakt die erwarteten Deutschland-/Niederlande-Quellen." return 1 fi marker_format="$(wm_marker_value "$marker" format_version 2>/dev/null || true)" marker_file_name="$(wm_marker_value "$marker" route_file_name 2>/dev/null || true)" marker_size="$(wm_marker_value "$marker" size_bytes 2>/dev/null || true)" marker_checksum="$(wm_marker_value "$marker" sha256 2>/dev/null || true)" marker_generator="$(wm_marker_value "$marker" generator_version 2>/dev/null || true)" marker_source="$(wm_marker_value "$marker" source 2>/dev/null || true)" actual_size="$(stat --format=%s "$route_file")" actual_checksum="$(sha256sum "$route_file" | awk '{ print $1 }')" if [[ "$marker_format" != "1" || "$marker_file_name" != "$(basename "$route_file")" || "$marker_size" != "$actual_size" || ! "$marker_checksum" =~ ^[0-9a-f]{64}$ || "$marker_checksum" != "$actual_checksum" || "$marker_generator" != "2" || "$marker_source" != "germany+netherlands" ]]; then wm_log "Bereitschaftsmarker stimmt nicht mit dem Routingindex überein: $marker" return 1 fi return 0 } wm_wait_for_health() { local service="$1" local timeout_seconds="${2:-180}" local container_id status local deadline=$((SECONDS + timeout_seconds)) container_id="$(wm_compose ps --quiet "$service")" [[ -n "$container_id" ]] || wm_die "Container für $service läuft nicht." while ((SECONDS < deadline)); do status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")" case "$status" in healthy|running) wm_log "$service ist bereit." return 0 ;; unhealthy|exited|dead) docker inspect --format '{{json .State}}' "$container_id" >&2 || true return 1 ;; esac sleep 3 done wm_log "Timeout beim Warten auf $service." return 1 } wm_smoke_test_route() { wm_compose exec --no-TTY watermaps node --input-type=module --eval ' const expectedSource = "local-geofabrik-germany+netherlands"; const routeChecks = [ { name: "Emden–Ditzum", start: { lat: 53.3422, lon: 7.1871 }, destination: { lat: 53.465, lon: 7.4734 }, minimumAlternatives: 2 }, { name: "Ems-Jade-Kanal bei Rahe", start: { lat: 53.4498, lon: 7.4509 }, destination: { lat: 53.4646, lon: 7.4742 }, minimumAlternatives: 0, minimumCoordinates: 25, minimumDistanceNm: 1.2, maximumDistanceNm: 1.35, maximumSegmentNm: 0.55, corridorToleranceNm: 0.01, corridorCoordinates: [ [7.4648841, 53.4595046], [7.46945, 53.4616984], [7.4724784, 53.4646149] ] }, { name: "Norddeich–Norderney", start: { lat: 53.6234, lon: 7.1559 }, destination: { lat: 53.7023, lon: 7.1658 }, minimumAlternatives: 2 }, { name: "Emden–Delfzijl", start: { lat: 53.3395697, lon: 7.1848883 }, destination: { lat: 53.330353, lon: 6.9334717 }, minimumAlternatives: 0, minimumCoordinates: 30, minimumDistanceNm: 9.5, maximumDistanceNm: 10.5, maximumSegmentNm: 2, maximumLongitude: 7.19, corridorCoordinates: [ [7.1848883, 53.3395697], [7.0011017, 53.313849], [6.9427276, 53.3256427] ] }, { name: "Weesp–Utrecht (Werkspoorhaven)", start: { lat: 52.309, lon: 5.0423 }, destination: { lat: 52.1058659, lon: 5.0788596 }, minimumAlternatives: 2 }, { name: "Lemmer–Sneek", start: { lat: 52.844, lon: 5.71 }, destination: { lat: 53.033, lon: 5.66 }, minimumAlternatives: 2 }, { name: "Smal Weesp (Niederlande)", start: { lat: 52.3043984, lon: 5.0210794 }, destination: { lat: 52.307897, lon: 5.0330976 }, minimumAlternatives: 0 } ]; for (const routeCheck of routeChecks) { const response = await fetch("http://127.0.0.1:5174/api/routes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ start: routeCheck.start, destination: routeCheck.destination, vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } }) }); if (!response.ok) { console.error(routeCheck.name, response.status, await response.text()); process.exit(1); } const route = await response.json(); const alternatives = Array.isArray(route.alternatives) ? route.alternatives : []; const routeCoordinates = route.geometry?.coordinates; const distanceNm = (first, second) => { const toRadians = (value) => value * Math.PI / 180; const deltaLat = toRadians(second[1] - first[1]); const deltaLon = toRadians(second[0] - first[0]); const firstLat = toRadians(first[1]); const secondLat = toRadians(second[1]); const haversine = Math.sin(deltaLat / 2) ** 2 + Math.cos(firstLat) * Math.cos(secondLat) * Math.sin(deltaLon / 2) ** 2; return 3440.065 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)); }; const largestSegmentNm = Array.isArray(routeCoordinates) ? routeCoordinates.slice(1).reduce( (largest, coordinate, index) => Math.max(largest, distanceNm(routeCoordinates[index], coordinate)), 0 ) : Number.POSITIVE_INFINITY; const expectedCorridorCoordinates = routeCheck.corridorCoordinates ?? []; const corridorCoordinatesAreValid = expectedCorridorCoordinates.length === 0 || ( Array.isArray(routeCoordinates) && expectedCorridorCoordinates.every((expectedCoordinate) => routeCoordinates.some( (coordinate) => distanceNm(coordinate, expectedCoordinate) <= (routeCheck.corridorToleranceNm ?? 0.15) ) ) ); const alternativeRoutesAreValid = alternatives.every((alternative) => alternative.routingMode === "fairway" && Array.isArray(alternative.geometry?.coordinates) && alternative.geometry.coordinates.length >= 2 && Array.isArray(alternative.dataSources) && alternative.dataSources.includes(expectedSource) ); const routeSignatures = [ route.geometry?.coordinates, ...alternatives.map((alternative) => alternative.geometry?.coordinates) ].map((coordinates) => JSON.stringify(coordinates)); if ( route.routingMode !== "fairway" || !Array.isArray(routeCoordinates) || routeCoordinates.length < (routeCheck.minimumCoordinates ?? 2) || !Array.isArray(route.dataSources) || !route.dataSources.includes(expectedSource) || alternatives.length < routeCheck.minimumAlternatives || route.distanceNm < (routeCheck.minimumDistanceNm ?? 0) || route.distanceNm > (routeCheck.maximumDistanceNm ?? Number.POSITIVE_INFINITY) || largestSegmentNm > (routeCheck.maximumSegmentNm ?? Number.POSITIVE_INFINITY) || routeCoordinates.some( ([lon]) => lon > (routeCheck.maximumLongitude ?? Number.POSITIVE_INFINITY) ) || !corridorCoordinatesAreValid || !alternativeRoutesAreValid || new Set(routeSignatures).size !== routeSignatures.length ) { console.error( `${routeCheck.name}: Routen- oder Alternativenprüfung des lokalen Deutschland-/Niederlande-Index fehlgeschlagen.`, JSON.stringify({ routingMode: route.routingMode, dataSources: route.dataSources, distanceNm: route.distanceNm, coordinateCount: routeCoordinates?.length, largestSegmentNm, alternativeCount: alternatives.length, minimumAlternatives: routeCheck.minimumAlternatives, corridorCoordinatesAreValid }) ); process.exit(1); } } ' }