feat: add Docker/OpenTofu deployment and DE/NL routing

This commit is contained in:
BuTzZ
2026-07-24 23:10:17 +02:00
parent 57f7b4dedb
commit 12eee8d211
59 changed files with 4452 additions and 148 deletions
+363
View File
@@ -0,0 +1,363 @@
#!/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_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() {
docker compose \
--project-directory "$WM_ROOT_DIR" \
--env-file "$WM_ENV_FILE" \
--file "$WM_COMPOSE_FILE" \
"$@"
}
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: "NorddeichNorderney",
start: { lat: 53.6234, lon: 7.1559 },
destination: { lat: 53.7023, lon: 7.1658 },
minimumAlternatives: 2
},
{
name: "EmdenDelfzijl",
start: { lat: 53.3416, lon: 7.186 },
destination: { lat: 53.3282, lon: 6.9304 },
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",
start: { lat: 52.309, lon: 5.0423 },
destination: { lat: 52.105, lon: 5.085 },
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) <= 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);
}
}
'
}