Files
watermaps/deploy/scripts/common.sh
T
BuTzZ 593dbd5f85
Test and publish container images / test (push) Successful in 2m26s
Test and publish container images / publish (push) Failing after 3s
optimized events
2026-07-28 14:36:47 +02:00

638 lines
21 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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_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() {
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_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"
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_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}"
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: "EmdenDitzum",
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: "NorddeichNorderney",
start: { lat: 53.6234, lon: 7.1559 },
destination: { lat: 53.7023, lon: 7.1658 },
minimumAlternatives: 2
},
{
name: "EmdenDelfzijl",
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: "WeespUtrecht (Werkspoorhaven)",
start: { lat: 52.309, lon: 5.0423 },
destination: { lat: 52.1058659, lon: 5.0788596 },
minimumAlternatives: 2
},
{
name: "LemmerSneek",
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);
}
}
'
}
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);
}
'
}