Files
watermaps/deploy/scripts/common.sh
T
BuTzZ 9813a1fafb
Test and publish container images / test (push) Successful in 2m21s
Test and publish container images / publish (push) Failing after 4s
Optimized routing
2026-07-27 10:18:31 +02:00

482 lines
16 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_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: "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);
}
}
'
}