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
+5
View File
@@ -5,8 +5,13 @@ dist
coverage coverage
playwright-report playwright-report
test-results test-results
__pycache__
*.py[cod]
data/geofabrik/*.osm.pbf data/geofabrik/*.osm.pbf
data/geofabrik/*.osm.pbf.md5 data/geofabrik/*.osm.pbf.md5
data/geofabrik/*.part
data/geofabrik/*.expected-md5
data/local/*.json
.env .env
.env.* .env.*
.DS_Store .DS_Store
+2
View File
@@ -3,6 +3,8 @@ HOST=0.0.0.0
DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass
REDIS_URL=redis://localhost:6379 REDIS_URL=redis://localhost:6379
WATERMAPS_DEMO_DATA=true WATERMAPS_DEMO_DATA=true
WATERMAPS_LOCAL_FAIRWAYS_PATH=data/local/germany-netherlands-fairways.json
WATERMAPS_LIVE_FAIRWAYS=false
# Serverseitiger EuRIS-Schleusenabgleich (`npm run sync:euris-locks`) # Serverseitiger EuRIS-Schleusenabgleich (`npm run sync:euris-locks`)
EURIS_COUNTRIES=DE EURIS_COUNTRIES=DE
+7
View File
@@ -4,7 +4,11 @@ dist/
coverage/ coverage/
playwright-report/ playwright-report/
test-results/ test-results/
.playwright-cli/
output/playwright/
*.tsbuildinfo *.tsbuildinfo
__pycache__/
*.py[cod]
.env .env
.env.* .env.*
!.env.example !.env.example
@@ -12,4 +16,7 @@ test-results/
.tools/ .tools/
data/geofabrik/*.osm.pbf data/geofabrik/*.osm.pbf
data/geofabrik/*.osm.pbf.md5 data/geofabrik/*.osm.pbf.md5
data/geofabrik/*.part
data/geofabrik/*.expected-md5
data/local/*.json
/gitlogin /gitlogin
+45
View File
@@ -0,0 +1,45 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json tsconfig.base.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN npm ci
COPY apps ./apps
COPY packages ./packages
RUN npm run build \
&& npm prune --omit=dev
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=5174 \
WATERMAPS_WEB_DIST_PATH=/app/apps/web/dist \
WATERMAPS_LOCAL_FAIRWAYS_PATH=/data/germany-netherlands-fairways.json \
WATERMAPS_LIVE_FAIRWAYS=false \
WATERMAPS_DEMO_DATA=false
WORKDIR /app
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
COPY --from=build /app/apps/api/dist ./apps/api/dist
COPY --from=build /app/apps/web/dist ./apps/web/dist
COPY --from=build /app/packages/shared/package.json ./packages/shared/package.json
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
EXPOSE 5174
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:5174/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
USER node
CMD ["node", "apps/api/dist/server.js"]
+116 -9
View File
@@ -22,18 +22,37 @@ iPhone-taugliche Browser-PWA für Bootsfahrer: Karte, GPS, Kompass, Wetter/Welle
## Start ## Start
Dieses Projekt benötigt Node `>=20.19`. In dieser Arbeitskopie liegt eine lokale Node-Version unter `.tools/`; nutze sie so: Der reguläre lokale Betrieb läuft als einzelner Produktionscontainer. Der
gemeinsame Fahrwasserindex für Deutschland und die Niederlande muss einmalig
vorhanden sein:
```bash
npm run setup:local-routing
npm run docker:up
```
Web und API: `http://localhost:5173`<br>
Healthcheck: `http://localhost:5173/health`
Die sichtbaren Kartenkacheln kommen weiterhin von den konfigurierten externen
Kartendiensten. Lokal gespeichert werden nur die OSM-Rohdaten und der daraus
erzeugte Routingindex.
Stoppen:
```bash
npm run docker:down
```
Für die Entwicklung ohne Container benötigt das Projekt Node `>=20.19`:
```bash ```bash
export PATH="$PWD/.tools/bin:$PATH"
npm install npm install
npm run build
npm run test
npm run dev npm run dev
``` ```
Web: `http://localhost:5173`<br> Dev-Web: `http://localhost:5173`<br>
API: `http://localhost:5174` Dev-API: `http://localhost:5174`
Für GPS-Tests auf dem iPhone muss die App über HTTPS laufen. Starte dafür: Für GPS-Tests auf dem iPhone muss die App über HTTPS laufen. Starte dafür:
@@ -43,13 +62,75 @@ npm run dev:https
Web HTTPS: `https://localhost:5173` bzw. die von Vite ausgegebene `https://192.168...:5173`-Adresse im gleichen WLAN. Beim lokalen Dev-Zertifikat muss Safari die Zertifikatswarnung einmal akzeptieren; falls iOS Geolocation danach weiterhin blockiert, nutze ein vertrauenswürdiges lokales Zertifikat oder einen HTTPS-Tunnel. Web HTTPS: `https://localhost:5173` bzw. die von Vite ausgegebene `https://192.168...:5173`-Adresse im gleichen WLAN. Beim lokalen Dev-Zertifikat muss Safari die Zertifikatswarnung einmal akzeptieren; falls iOS Geolocation danach weiterhin blockiert, nutze ein vertrauenswürdiges lokales Zertifikat oder einen HTTPS-Tunnel.
## Hetzner-Deployment mit OpenTofu
Die produktive Infrastruktur besteht aus einem Hetzner-Server, einer festen
IPv4, Firewall und einem persistenten Volume. Der Docker-Stack enthält nur die
App, Nginx/Certbot und den Wartungscontainer für Deutschland- und
Niederlande-Routendaten; Kartenkacheln werden nicht selbst gehostet.
Die beiden lokalen, von Git ignorierten Konfigurationsdateien sind bereits
angelegt:
- `infra/opentofu/terraform.tfvars`: hier den Hetzner-Cloud-Read/Write-Token
eintragen und `admin_cidrs` bei Bedarf auf die aktuelle öffentliche IP
aktualisieren.
- `deploy/.env.production`: hier eine echte E-Mail-Adresse für Let's Encrypt
als `WATERMAPS_ACME_EMAIL` eintragen.
Danach wird die Infrastruktur erzeugt:
```bash
cd infra/opentofu
tofu init
tofu plan -out=watermaps.tfplan
tofu apply watermaps.tfplan
tofu output server_ipv4
cd ../..
```
Sobald `server_ipv4` ausgegeben wurde, kann der manuelle DNS-A-Record
`watermaps.incoso.eu` auf diese IPv4 gesetzt werden. Das erste Deployment darf
bereits vor der DNS-Propagation laufen:
```bash
./deploy/scripts/upload-and-deploy.sh \
--identity ~/.ssh/watermaps_hetzner_ed25519
```
Dabei werden die vollständigen Geofabrik-Extrakte für Deutschland und die
Niederlande auf dem persistenten Server-Volume geladen und der lokale
Routingindex erstellt. Vor dem SSL-Livegang liefert Port 80 außer
ACME-Challenges nur 404.
Erst wenn der DNS-A-Record propagiert ist, wird HTTPS mit dem finalen manuellen
Befehl aktiviert:
```bash
./deploy/scripts/remote-go-live.sh \
--identity ~/.ssh/watermaps_hetzner_ed25519
```
Das Skript prüft DNS, beide Länder im Routingindex sowie Testrouten, fordert
das Zertifikat an und schaltet anschließend dauerhaft auf HTTPS um.
Ausführliche Hinweise stehen in
[`infra/opentofu/README.md`](infra/opentofu/README.md) und
[`deploy/README.md`](deploy/README.md).
## Datenstatus ## Datenstatus
Die freie Datenstrategie ist bewusst als Fahr- und Planungshilfe umgesetzt. Die App zeigt Attribution und den Status `Nicht amtlich`, weil freie Karten-/Modelldaten keine amtlich zugelassene Seekarte ersetzen. Die freie Datenstrategie ist bewusst als Fahr- und Planungshilfe umgesetzt. Die App zeigt Attribution und den Status `Nicht amtlich`, weil freie Karten-/Modelldaten keine amtlich zugelassene Seekarte ersetzen.
## Routingstatus ## Routingstatus
`POST /api/routes` nutzt zuerst lokale PostGIS-Fahrwasserdaten aus `marine_fairway_edges`. Wenn in der Datenbank kein passender Graph liegt, versucht die API live extrahierte OSM/OpenSeaMap-Fahrwasserdaten für die Bounding Box zwischen Start, Wegpunkten und Ziel. Lokale und Live-Fragmente werden topologisch zusammengeführt. Berücksichtigt werden unter anderem `navigation_line`, `recommended_track`, `fairway`, navigierbare Kanäle und explizit für Boote oder Schiffe freigegebene Flüsse. Gesperrte, private, stillgelegte oder im Bau befindliche Wege werden ausgeschlossen. `POST /api/routes` kann Fahrwasserdaten aus PostGIS, dem lokalen
Geofabrik-Dateiindex und wenn explizit aktiviert live aus Overpass
zusammenführen. Die Produktionskonfiguration verwendet ausschließlich den
lokalen Deutschland-/Niederlande-Index; sie benötigt weder PostGIS noch eine
laufende Overpass-Verbindung. Berücksichtigt werden unter anderem
`navigation_line`, `recommended_track`, `fairway`, navigierbare Kanäle und
explizit für Boote oder Schiffe freigegebene Flüsse. Gesperrte, private,
stillgelegte oder im Bau befindliche Wege werden ausgeschlossen.
Wenn freie Laufzeitdaten fehlen, stehen zwei klar als nicht amtlich markierte Fallback-Korridore bereit: Wenn freie Laufzeitdaten fehlen, stehen zwei klar als nicht amtlich markierte Fallback-Korridore bereit:
@@ -112,7 +193,7 @@ Die Küsten-PBFs lassen sich reproduzierbar von Geofabrik laden und ohne lokal i
```bash ```bash
./scripts/download-geofabrik.sh ./scripts/download-geofabrik.sh
docker compose up -d postgres redis martin docker compose --profile postgis up -d postgres redis martin
DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass \ DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass \
./scripts/import-geofabrik-docker.sh \ ./scripts/import-geofabrik-docker.sh \
data/geofabrik/germany-latest.osm.pbf \ data/geofabrik/germany-latest.osm.pbf \
@@ -206,11 +287,37 @@ Der lokale PostGIS-Container nutzt standardmäßig Host-Port `55432`, damit er n
## Lokale Infrastruktur ## Lokale Infrastruktur
```bash ```bash
docker compose up -d postgres redis martin docker compose --profile postgis up -d postgres redis martin
``` ```
Danach `.env` aus `.env.example` ableiten und für echte PostGIS-Features `WATERMAPS_DEMO_DATA=false` setzen. Die frühere Variable `SEA_COMPASS_DEMO_DATA` wird übergangsweise weiterhin akzeptiert. Danach `.env` aus `.env.example` ableiten und für echte PostGIS-Features `WATERMAPS_DEMO_DATA=false` setzen. Die frühere Variable `SEA_COMPASS_DEMO_DATA` wird übergangsweise weiterhin akzeptiert.
### Dateibasiertes Deutschland-/Niederlande-Routing
Wenn Docker/PostGIS nicht verfügbar ist, kann die API den vollständigen
Geofabrik-Extrakt beider Länder als kompakte lokale Fahrwasserdatei verwenden:
```bash
npm run setup:local-routing
```
Der Aufbau liest beide PBFs in Streaming-Durchläufen, führt überlappende
OSM-Wege zusammen und erzeugt
`data/local/germany-netherlands-fairways.json`. Für einen ausschließlich
lokalen Betrieb:
```dotenv
DATABASE_URL=
REDIS_URL=
WATERMAPS_LOCAL_FAIRWAYS_PATH=data/local/germany-netherlands-fairways.json
WATERMAPS_LIVE_FAIRWAYS=false
```
Damit benötigen Routen innerhalb des heruntergeladenen Datenstands weder
PostGIS noch eine laufende Overpass-Verbindung. Nach einem neuen
Geofabrik-Snapshot prüft `npm run setup:local-routing` die Prüfsummen und baut
den Index bei Bedarf atomar neu.
## APIs ## APIs
- `GET /api/config` - `GET /api/config`
+2 -1
View File
@@ -13,8 +13,9 @@
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.0.1", "@fastify/cors": "^11.0.1",
"@fastify/static": "^10.1.2",
"@watermaps/shared": "0.1.0", "@watermaps/shared": "0.1.0",
"fastify": "^5.4.0", "fastify": "^5.10.0",
"ioredis": "^5.6.1", "ioredis": "^5.6.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"zod": "^3.25.76" "zod": "^3.25.76"
+13 -2
View File
@@ -3,6 +3,7 @@ import Fastify, { type FastifyInstance } from "fastify";
import { z } from "zod"; import { z } from "zod";
import { import {
buildFairwayRoutes, buildFairwayRoutes,
EMDEN_EAST_EMS_GRAPH,
EMDEN_HAMM_GRAPH, EMDEN_HAMM_GRAPH,
type FairwayGraph, type FairwayGraph,
type RouteOption, type RouteOption,
@@ -110,7 +111,8 @@ export async function buildServer(deps: AppDeps = {}): Promise<FastifyInstance>
cache, cache,
fetcher, fetcher,
liveEnabled: env.liveOsmFairways, liveEnabled: env.liveOsmFairways,
databaseUrl: env.databaseUrl databaseUrl: env.databaseUrl,
localDataPath: env.localFairwaysPath
}); });
const app = Fastify({ const app = Fastify({
logger: { logger: {
@@ -183,12 +185,21 @@ export async function buildServer(deps: AppDeps = {}): Promise<FastifyInstance>
return reply.code(400).send({ error: "invalid_route", details: parsed.error.flatten() }); return reply.code(400).send({ error: "invalid_route", details: parsed.error.flatten() });
} }
let sourceError: unknown;
const dynamicGraphs = await fairwayService.getGraphsForRoute(parsed.data).catch((error) => { const dynamicGraphs = await fairwayService.getGraphsForRoute(parsed.data).catch((error) => {
sourceError = error;
app.log.warn({ error }, "fairway extraction failed"); app.log.warn({ error }, "fairway extraction failed");
return []; return [];
}); });
const route = buildRouteFromGraphs(parsed.data, dynamicGraphs); const route = buildRouteFromGraphs(parsed.data, dynamicGraphs);
if (!route) { if (!route) {
if (sourceError) {
return reply.code(503).send({
error: "fairway_sources_unavailable",
message:
"Fahrwasserdaten sind momentan nicht verfügbar. Prüfe den lokalen Deutschland-/Niederlande-Index oder versuche es später erneut."
});
}
return reply.code(422).send({ return reply.code(422).send({
error: "no_fairway_route", error: "no_fairway_route",
message: message:
@@ -224,7 +235,7 @@ function buildRouteFromGraphs(request: RouteRequest, graphs: FairwayGraph[]) {
} }
} }
for (const graph of [undefined, EMDEN_HAMM_GRAPH] as const) { for (const graph of [undefined, EMDEN_EAST_EMS_GRAPH, EMDEN_HAMM_GRAPH] as const) {
const routes = graph ? buildFairwayRoutes(request, graph) : buildFairwayRoutes(request); const routes = graph ? buildFairwayRoutes(request, graph) : buildFairwayRoutes(request);
if (routes.length > 0) { if (routes.length > 0) {
return routeResultWithAlternatives(routes); return routeResultWithAlternatives(routes);
+7
View File
@@ -3,6 +3,7 @@ export type ApiEnv = {
host: string; host: string;
databaseUrl?: string; databaseUrl?: string;
redisUrl?: string; redisUrl?: string;
localFairwaysPath?: string;
demoData: boolean; demoData: boolean;
liveOsmFairways: boolean; liveOsmFairways: boolean;
}; };
@@ -13,6 +14,12 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
host: env.HOST ?? "0.0.0.0", host: env.HOST ?? "0.0.0.0",
databaseUrl: env.DATABASE_URL, databaseUrl: env.DATABASE_URL,
redisUrl: env.REDIS_URL, redisUrl: env.REDIS_URL,
localFairwaysPath:
env.WATERMAPS_LOCAL_FAIRWAYS_PATH ??
env.SEA_COMPASS_LOCAL_FAIRWAYS_PATH ??
(env.NODE_ENV === "test"
? undefined
: "data/local/germany-netherlands-fairways.json"),
demoData: (env.WATERMAPS_DEMO_DATA ?? env.SEA_COMPASS_DEMO_DATA) !== "false", demoData: (env.WATERMAPS_DEMO_DATA ?? env.SEA_COMPASS_DEMO_DATA) !== "false",
liveOsmFairways: liveOsmFairways:
(env.WATERMAPS_LIVE_FAIRWAYS ?? env.SEA_COMPASS_LIVE_FAIRWAYS) !== "false" && (env.WATERMAPS_LIVE_FAIRWAYS ?? env.SEA_COMPASS_LIVE_FAIRWAYS) !== "false" &&
+24
View File
@@ -1,3 +1,4 @@
import fastifyStatic from "@fastify/static";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { buildServer } from "./app.js"; import { buildServer } from "./app.js";
@@ -12,6 +13,29 @@ for (const envFile of [resolve(process.cwd(), ".env"), resolve(process.cwd(), ".
const env = loadEnv(); const env = loadEnv();
const app = await buildServer({ env }); const app = await buildServer({ env });
const configuredWebDist = process.env.WATERMAPS_WEB_DIST_PATH;
const webDistCandidates = configuredWebDist
? [resolve(configuredWebDist)]
: [
resolve(process.cwd(), "apps/web/dist"),
resolve(process.cwd(), "../web/dist")
];
const webDistPath = webDistCandidates.find((candidate) =>
existsSync(resolve(candidate, "index.html"))
);
if (webDistPath) {
await app.register(fastifyStatic, {
root: webDistPath,
prefix: "/"
});
app.setNotFoundHandler((request, reply) => {
if (request.method === "GET" && !request.url.startsWith("/api/")) {
return reply.sendFile("index.html");
}
return reply.code(404).send({ error: "not_found" });
});
}
try { try {
await app.listen({ port: env.port, host: env.host }); await app.listen({ port: env.port, host: env.host });
+132
View File
@@ -1,4 +1,6 @@
import pg from "pg"; import pg from "pg";
import { readFile } from "node:fs/promises";
import { isAbsolute, resolve } from "node:path";
import type { Coordinate, FairwayEdge, FairwayGraph, FairwayNode, RouteRequest } from "@watermaps/shared"; import type { Coordinate, FairwayEdge, FairwayGraph, FairwayNode, RouteRequest } from "@watermaps/shared";
import type { Cache } from "./cache.js"; import type { Cache } from "./cache.js";
import type { FetchLike } from "./http.js"; import type { FetchLike } from "./http.js";
@@ -14,11 +16,25 @@ type OverpassResponse = {
elements?: OverpassElement[]; elements?: OverpassElement[];
}; };
type LocalFairwayWay = {
id: string;
bbox: [number, number, number, number];
tags?: Record<string, string>;
coordinates: [number, number][];
};
type LocalFairwayDocument = {
version: number;
source: string;
ways: LocalFairwayWay[];
};
type FairwayDeps = { type FairwayDeps = {
cache: Cache; cache: Cache;
fetcher: FetchLike; fetcher: FetchLike;
liveEnabled: boolean; liveEnabled: boolean;
databaseUrl?: string; databaseUrl?: string;
localDataPath?: string;
}; };
export type FairwayRow = { export type FairwayRow = {
@@ -38,6 +54,7 @@ const OVERPASS_URL = "https://overpass-api.de/api/interpreter";
const CACHE_TTL_MS = 1000 * 60 * 60 * 12; const CACHE_TTL_MS = 1000 * 60 * 60 * 12;
const MAX_BBOX_SPAN_DEG = 3; const MAX_BBOX_SPAN_DEG = 3;
const MAX_POSTGIS_BBOX_SPAN_DEG = 6; const MAX_POSTGIS_BBOX_SPAN_DEG = 6;
const MAX_LOCAL_BBOX_SPAN_DEG = 15;
const BBOX_MARGIN_DEG = 0.15; const BBOX_MARGIN_DEG = 0.15;
const ENDPOINT_SNAP_DEG = 0.0001; const ENDPOINT_SNAP_DEG = 0.0001;
const CONNECTOR_DISTANCE_NM = 0.08; const CONNECTOR_DISTANCE_NM = 0.08;
@@ -48,17 +65,21 @@ export class FairwayService {
private readonly fetcher: FetchLike; private readonly fetcher: FetchLike;
private readonly liveEnabled: boolean; private readonly liveEnabled: boolean;
private readonly pool: pg.Pool | null; private readonly pool: pg.Pool | null;
private readonly localDataPath?: string;
private localDocument: Promise<LocalFairwayDocument | null> | null = null;
constructor(deps: FairwayDeps) { constructor(deps: FairwayDeps) {
this.cache = deps.cache; this.cache = deps.cache;
this.fetcher = deps.fetcher; this.fetcher = deps.fetcher;
this.liveEnabled = deps.liveEnabled; this.liveEnabled = deps.liveEnabled;
this.localDataPath = deps.localDataPath;
this.pool = deps.databaseUrl ? new pg.Pool({ connectionString: deps.databaseUrl }) : null; this.pool = deps.databaseUrl ? new pg.Pool({ connectionString: deps.databaseUrl }) : null;
} }
async getGraphsForRoute(request: RouteRequest): Promise<FairwayGraph[]> { async getGraphsForRoute(request: RouteRequest): Promise<FairwayGraph[]> {
const results = await Promise.allSettled([ const results = await Promise.allSettled([
this.getPostgisGraphForRoute(request), this.getPostgisGraphForRoute(request),
this.getLocalGraphForRoute(request),
this.getLiveGraphForRoute(request) this.getLiveGraphForRoute(request)
]); ]);
const graphs = results.flatMap((result) => const graphs = results.flatMap((result) =>
@@ -118,6 +139,51 @@ export class FairwayService {
}); });
} }
private async getLocalGraphForRoute(request: RouteRequest): Promise<FairwayGraph | null> {
const bbox = routeBbox(
[request.start, ...(request.waypoints ?? []), request.destination],
MAX_LOCAL_BBOX_SPAN_DEG
);
if (!bbox || !this.localDataPath) {
return null;
}
const document = await this.loadLocalDocument();
if (!document) {
return null;
}
return localFairwaysToGraph(
document.ways.filter((way) => bboxesIntersect(way.bbox, bbox)),
bbox,
document.source
);
}
private async loadLocalDocument(): Promise<LocalFairwayDocument | null> {
if (!this.localDocument) {
this.localDocument = readFirstExistingFile(localDataPathCandidates(this.localDataPath!))
.then((raw) => JSON.parse(raw) as LocalFairwayDocument)
.then((document) => {
if (document.version !== 1 || !Array.isArray(document.ways)) {
throw new Error(`Unsupported local fairway data format: ${this.localDataPath}`);
}
return document;
})
.catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") {
return null;
}
throw error;
});
}
const document = await this.localDocument;
if (!document) {
this.localDocument = null;
}
return document;
}
private async getLiveGraphForRoute(request: RouteRequest): Promise<FairwayGraph | null> { private async getLiveGraphForRoute(request: RouteRequest): Promise<FairwayGraph | null> {
if (!this.liveEnabled) { if (!this.liveEnabled) {
return null; return null;
@@ -176,6 +242,29 @@ export class FairwayService {
} }
} }
async function readFirstExistingFile(paths: string[]): Promise<string> {
let lastError: NodeJS.ErrnoException | undefined;
for (const path of paths) {
try {
return await readFile(path, "utf8");
} catch (error) {
const fileError = error as NodeJS.ErrnoException;
if (fileError.code !== "ENOENT") {
throw error;
}
lastError = fileError;
}
}
throw lastError ?? new Error("No local fairway data path configured");
}
function localDataPathCandidates(path: string): string[] {
if (isAbsolute(path)) {
return [path];
}
return [...new Set([resolve(process.cwd(), path), resolve(process.cwd(), "../..", path)])];
}
function routeBbox(points: Coordinate[], maxSpanDeg = MAX_BBOX_SPAN_DEG): [number, number, number, number] | null { function routeBbox(points: Coordinate[], maxSpanDeg = MAX_BBOX_SPAN_DEG): [number, number, number, number] | null {
const lons = points.map((point) => point.lon); const lons = points.map((point) => point.lon);
const lats = points.map((point) => point.lat); const lats = points.map((point) => point.lat);
@@ -238,6 +327,37 @@ export function overpassToGraph(response: OverpassResponse, bbox: [number, numbe
); );
} }
export function localFairwaysToGraph(
ways: LocalFairwayWay[],
bbox: [number, number, number, number],
source: string
): FairwayGraph | null {
return waysToGraph(
ways
.map((way) => {
const tags = way.tags ?? {};
const coordinates = way.coordinates
.map(([lat, lon]) => ({ lat, lon }))
.filter(isValidCoordinate);
if (coordinates.length < 2 || !isRoutableWay(tags, coordinates)) {
return null;
}
return {
id: `local-osm-way-${way.id}`,
name: tags.name ?? tags.ref ?? `OSM ${way.id}`,
coordinates,
minDepthM: parseDepth(tags),
source: `local-geofabrik-${source}`,
...edgeRestrictions(tags)
};
})
.filter((way): way is NonNullable<typeof way> => way !== null),
`local-geofabrik-${bbox.map((value) => value.toFixed(3)).join("-")}`,
`Lokale Geofabrik-Fahrwasser (${source})`
);
}
export function mergeConnectedFairwayGraphs(graphs: FairwayGraph[]): FairwayGraph | null { export function mergeConnectedFairwayGraphs(graphs: FairwayGraph[]): FairwayGraph | null {
const nodes = new Map<string, FairwayNode>(); const nodes = new Map<string, FairwayNode>();
const edges = new Map<string, FairwayEdge>(); const edges = new Map<string, FairwayEdge>();
@@ -351,6 +471,18 @@ function isValidCoordinate(coordinate: Coordinate) {
); );
} }
function bboxesIntersect(
first: [number, number, number, number],
second: [number, number, number, number]
) {
return !(
first[2] < second[0] ||
first[0] > second[2] ||
first[3] < second[1] ||
first[1] > second[3]
);
}
function isRoutableWay(tags: Record<string, string>, coordinates: Coordinate[]) { function isRoutableWay(tags: Record<string, string>, coordinates: Coordinate[]) {
if ( if (
["no", "private"].includes(tags.access ?? "") || ["no", "private"].includes(tags.access ?? "") ||
+13 -1
View File
@@ -20,7 +20,7 @@ type FeatureCollection = {
properties: Record<string, unknown>; properties: Record<string, unknown>;
}>; }>;
metadata: { metadata: {
source: "postgis" | "demo"; source: "postgis" | "demo" | "unavailable";
warning?: string; warning?: string;
deduplication?: { deduplication?: {
inputPoiCount: number; inputPoiCount: number;
@@ -78,6 +78,18 @@ export class FeatureService {
return this.getPostgisFeatures(query); return this.getPostgisFeatures(query);
} }
if (!this.demoData) {
return {
type: "FeatureCollection",
features: [],
metadata: {
source: "unavailable",
warning:
"Keine lokale Feature-Datenbank konfiguriert. Kartenkacheln und Live-Dienste bleiben davon unberührt."
}
};
}
const [minLon, minLat, maxLon, maxLat] = query.bbox; const [minLon, minLat, maxLon, maxLat] = query.bbox;
return { return {
type: "FeatureCollection", type: "FeatureCollection",
+218
View File
@@ -184,6 +184,31 @@ describe("Watermaps API", () => {
await app.close(); await app.close();
}); });
it("reports unavailable fairway sources instead of claiming that no route exists", async () => {
const app = await buildServer({
cache: createCache(),
fairwayService: {
async getGraphsForRoute() {
throw new AggregateError([new Error("local data missing"), new Error("Overpass timeout")]);
},
async close() {}
}
});
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 54.1749, lon: 12.0731 },
destination: { lat: 54.1833, lon: 12.0928 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
}
});
expect(response.statusCode).toBe(503);
expect(response.json().error).toBe("fairway_sources_unavailable");
await app.close();
});
it("returns a fairway route from Emden Außenhafen to Borkum Reede", async () => { it("returns a fairway route from Emden Außenhafen to Borkum Reede", async () => {
const app = await buildServer({ cache: createCache() }); const app = await buildServer({ cache: createCache() });
const response = await app.inject({ const response = await app.inject({
@@ -205,6 +230,103 @@ describe("Watermaps API", () => {
await app.close(); await app.close();
}); });
it("plans the NorddeichNorderney route for the reported coordinates", async () => {
const app = await buildServer({
cache: createCache(),
fairwayService: {
async getGraphsForRoute() {
return [
{
id: "norddeich-norderney-test",
name: "NorddeichNorderney",
maxSnapDistanceNm: 0.5,
nodes: [
{ id: "norddeich", coordinate: { lat: 53.6234, lon: 7.1559 } },
{ id: "fairway", coordinate: { lat: 53.66, lon: 7.16 } },
{ id: "norderney", coordinate: { lat: 53.7023, lon: 7.1658 } }
],
edges: [
{
id: "norddeich-approach",
name: "Norddeich Fahrwasser",
from: "norddeich",
to: "fairway",
minDepthM: null,
source: "local-geofabrik-test",
coordinates: [
{ lat: 53.6234, lon: 7.1559 },
{ lat: 53.66, lon: 7.16 }
]
},
{
id: "norderney-approach",
name: "Norderney Fahrwasser",
from: "fairway",
to: "norderney",
minDepthM: null,
source: "local-geofabrik-test",
coordinates: [
{ lat: 53.66, lon: 7.16 },
{ lat: 53.7023, lon: 7.1658 }
]
}
]
}
];
},
async close() {}
}
});
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 53.6234, lon: 7.1559 },
destination: { lat: 53.7023, lon: 7.1658 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
}
});
const body = response.json();
expect(response.statusCode).toBe(200);
expect(body.routingMode).toBe("fairway");
expect(body.geometry.coordinates[0]).toEqual([7.1559, 53.6234]);
expect(body.geometry.coordinates.at(-1)).toEqual([7.1658, 53.7023]);
expect(body.distanceNm).toBeGreaterThan(4);
expect(body.distanceNm).toBeLessThan(6);
await app.close();
});
it("routes from Emden into the eastern lower Ems when all dynamic sources fail", async () => {
const app = await buildServer({
cache: createCache(),
fairwayService: {
async getGraphsForRoute() {
throw new AggregateError([new Error("PostGIS unavailable"), new Error("Overpass timeout")]);
},
async close() {}
}
});
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 53.3422, lon: 7.1871 },
destination: { lat: 53.465, lon: 7.4734 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
}
});
const body = response.json();
expect(response.statusCode).toBe(200);
expect(body.routingMode).toBe("fairway");
expect(body.dataSources).toContain("fairway-graph:emden-east-ems-seed");
expect(body.geometry.coordinates[0]).toEqual([7.1871, 53.3422]);
expect(body.geometry.coordinates.at(-1)?.[0]).toBeCloseTo(7.4734, 3);
expect(body.geometry.coordinates.at(-1)?.[1]).toBeCloseTo(53.465, 3);
await app.close();
});
it("returns the inland fallback route from Emden to Hamm", async () => { it("returns the inland fallback route from Emden to Hamm", async () => {
const app = await buildServer({ cache: createCache() }); const app = await buildServer({ cache: createCache() });
const response = await app.inject({ const response = await app.inject({
@@ -290,6 +412,102 @@ describe("Watermaps API", () => {
await app.close(); await app.close();
}); });
it("uses a shared local component for the reported Emden-Delfzijl coordinates", async () => {
const coordinate = (lat: number, lon: number) => ({ lat, lon });
const app = await buildServer({
cache: createCache(),
fairwayService: {
async getGraphsForRoute() {
return [
{
id: "local-geofabrik-component-snap",
name: "Lokaler Geofabrik-Komponententest",
maxSnapDistanceNm: 0.3,
nodes: [
{ id: "start-decoy-a", coordinate: coordinate(53.3416, 7.186) },
{ id: "start-decoy-b", coordinate: coordinate(53.342, 7.187) },
{ id: "destination-decoy-a", coordinate: coordinate(53.3282, 6.9304) },
{ id: "destination-decoy-b", coordinate: coordinate(53.3286, 6.9294) },
{ id: "shared-start", coordinate: coordinate(53.3395697, 7.1848883) },
{ id: "shared-east", coordinate: coordinate(53.3321722, 7.1329034) },
{ id: "shared-south", coordinate: coordinate(53.313849, 7.0011017) },
{ id: "shared-destination", coordinate: coordinate(53.3303531, 6.9334715) }
],
edges: [
{
id: "start-decoy",
name: "Nähere getrennte Startkante",
from: "start-decoy-a",
to: "start-decoy-b",
coordinates: [coordinate(53.3416, 7.186), coordinate(53.342, 7.187)],
minDepthM: null,
source: "closer-but-disconnected-start"
},
{
id: "destination-decoy",
name: "Nähere getrennte Zielkante",
from: "destination-decoy-a",
to: "destination-decoy-b",
coordinates: [coordinate(53.3282, 6.9304), coordinate(53.3286, 6.9294)],
minDepthM: null,
source: "closer-but-disconnected-destination"
},
{
id: "shared-east",
name: "Gemeinsamer lokaler Korridor Ost",
from: "shared-start",
to: "shared-east",
coordinates: [coordinate(53.3395697, 7.1848883), coordinate(53.3321722, 7.1329034)],
minDepthM: null,
source: "local-geofabrik-germany+netherlands"
},
{
id: "shared-south",
name: "Gemeinsamer lokaler Korridor Süd",
from: "shared-east",
to: "shared-south",
coordinates: [coordinate(53.3321722, 7.1329034), coordinate(53.313849, 7.0011017)],
minDepthM: null,
source: "local-geofabrik-germany+netherlands"
},
{
id: "shared-west",
name: "Gemeinsamer lokaler Korridor West",
from: "shared-south",
to: "shared-destination",
coordinates: [coordinate(53.313849, 7.0011017), coordinate(53.3303531, 6.9334715)],
minDepthM: null,
source: "local-geofabrik-germany+netherlands"
}
]
}
];
},
async close() {}
}
});
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 53.3416, lon: 7.186 },
destination: { lat: 53.3282, lon: 6.9304 },
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
}
});
const body = response.json();
expect(response.statusCode).toBe(200);
expect(body.routingMode).toBe("fairway");
expect(body.geometry.coordinates[0]).toEqual([7.186, 53.3416]);
expect(body.geometry.coordinates.at(-1)).toEqual([6.9304, 53.3282]);
expect(body.dataSources).toContain("local-geofabrik-germany+netherlands");
expect(body.dataSources).not.toContain("fairway-graph:ems-borkum-seed");
expect(body.dataSources).not.toContain("closer-but-disconnected-start");
expect(body.dataSources).not.toContain("closer-but-disconnected-destination");
await app.close();
});
it("returns distinct route alternatives when the waterway graph contains them", async () => { it("returns distinct route alternatives when the waterway graph contains them", async () => {
const coordinate = (lat: number, lon: number) => ({ lat, lon }); const coordinate = (lat: number, lon: number) => ({ lat, lon });
const edge = (id: string, from: string, to: string, coordinates: Array<{ lat: number; lon: number }>) => ({ const edge = (id: string, from: string, to: string, coordinates: Array<{ lat: number; lon: number }>) => ({
+85
View File
@@ -1,12 +1,97 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildRoute } from "@watermaps/shared"; import { buildRoute } from "@watermaps/shared";
import { createCache } from "../src/services/cache.js";
import { import {
FairwayService,
fairwayRowsToGraph, fairwayRowsToGraph,
localFairwaysToGraph,
mergeConnectedFairwayGraphs, mergeConnectedFairwayGraphs,
overpassToGraph overpassToGraph
} from "../src/services/fairways.js"; } from "../src/services/fairways.js";
describe("fairway graph extraction", () => { describe("fairway graph extraction", () => {
it("builds a routable graph from the persistent local Geofabrik format", () => {
const graph = localFairwaysToGraph(
[
{
id: "local-1",
bbox: [7.15, 53.62, 7.17, 53.71],
tags: { route: "ferry", name: "NorddeichNorderney" },
coordinates: [
[53.6234, 7.1559],
[53.66, 7.16],
[53.7023, 7.1658]
]
}
],
[7.0, 53.47, 7.32, 53.85],
"germany-test.osm.pbf"
);
const route = buildRoute(
{
start: { lat: 53.6234, lon: 7.1559 },
destination: { lat: 53.7023, lon: 7.1658 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
},
graph ?? undefined
);
expect(route?.routingMode).toBe("fairway");
expect(route?.dataSources).toContain("local-geofabrik-germany-test.osm.pbf");
});
it("allows country-wide requests against the persistent local index", async () => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "watermaps-local-span-"));
const localDataPath = join(temporaryDirectory, "germany-netherlands-fairways.json");
const request = {
start: { lat: 52, lon: 4 },
destination: { lat: 52, lon: 12 },
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
};
try {
await writeFile(
localDataPath,
JSON.stringify({
version: 1,
source: "germany+netherlands",
ways: [
{
id: "country-wide-test",
bbox: [4, 52, 12, 52],
tags: { waterway: "canal", name: "Country-wide test fairway" },
coordinates: [
[52, 4],
[52, 12]
]
}
]
})
);
const service = new FairwayService({
cache: createCache(),
fetcher: fetch,
liveEnabled: false,
localDataPath
});
const graphs = await service.getGraphsForRoute(request);
const route = buildRoute(request, graphs[0]);
expect(graphs).toHaveLength(1);
expect(route?.routingMode).toBe("fairway");
expect(route?.dataSources).toContain(
"local-geofabrik-germany+netherlands"
);
await service.close();
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
});
it("builds a routable graph from PostGIS fairway rows", () => { it("builds a routable graph from PostGIS fairway rows", () => {
const graph = fairwayRowsToGraph( const graph = fairwayRowsToGraph(
[ [
+19
View File
@@ -1,10 +1,29 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
deduplicateMarineContactFeatures, deduplicateMarineContactFeatures,
FeatureService,
normalizeDepthFeatureProperties, normalizeDepthFeatureProperties,
normalizeMarineFeatureProperties normalizeMarineFeatureProperties
} from "../src/services/features.js"; } from "../src/services/features.js";
describe("marine feature data modes", () => {
it("does not expose demo markers when production disables demo data without PostGIS", async () => {
const service = new FeatureService({
databaseUrl: undefined,
demoData: false
});
const result = await service.getFeatures({
bbox: [5, 50, 15, 56],
layers: ["seamarks", "locks", "harbours"]
});
expect(result.features).toEqual([]);
expect(result.metadata.source).toBe("unavailable");
await service.close();
});
});
describe("marine feature normalization", () => { describe("marine feature normalization", () => {
it("formats bridge clearance labels from known OSM height tags", () => { it("formats bridge clearance labels from known OSM height tags", () => {
const properties = normalizeMarineFeatureProperties({ const properties = normalizeMarineFeatureProperties({
+31
View File
@@ -0,0 +1,31 @@
# Diese Datei nach deploy/.env.production kopieren.
# Sie ist zugleich eine POSIX-Shell- und Docker-Compose-kompatible Env-Datei.
WATERMAPS_DOMAIN=watermaps.incoso.eu
WATERMAPS_ACME_EMAIL=REPLACE_WITH_REAL_EMAIL
# Wird von go-live.sh geprüft. upload-and-deploy.sh übergibt die von OpenTofu
# ausgegebene IPv4 automatisch; bei direkter Serverausführung hier eintragen.
WATERMAPS_EXPECTED_IPV4=
# Dauerhafte Routing-Rohdaten und der erzeugte Routingindex.
WATERMAPS_DATA_DIR=/srv/watermaps-data
# Generierte Nginx-Konfiguration und lokale Sperrdateien.
WATERMAPS_RUNTIME_DIR=/srv/watermaps-runtime
# Verhindert Speicherabbrüche beim vollständigen DE/NL-Indexaufbau auf 4-GB-Servern.
WATERMAPS_SWAP_SIZE_GB=4
WATERMAPS_APP_IMAGE=watermaps:production
WATERMAPS_NGINX_IMAGE=nginx:1.30.4-alpine
WATERMAPS_CERTBOT_IMAGE=certbot/certbot:v5.7.0
WATERMAPS_ROUTE_DATA_IMAGE=watermaps-route-data:production
# Nur auf true setzen, wenn bei jedem Deployment Deutschland und die
# Niederlande erneut geprüft und der Routingindex neu gebaut werden sollen.
WATERMAPS_REBUILD_ROUTE_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
+2
View File
@@ -0,0 +1,2 @@
.env.production
!.env.production.example
+107
View File
@@ -0,0 +1,107 @@
# 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.
## Konfiguration
```bash
cp deploy/.env.production.example deploy/.env.production
editor deploy/.env.production
```
Mindestens `WATERMAPS_ACME_EMAIL` muss angepasst werden. 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). Der Upload schließt Terraform-Variablen, Pläne,
State, lokale Env-Dateien und `.terraform` in jeder Verzeichnistiefe aus.
Die private SSH-Keydatei wird ebenfalls nicht gespeichert. Sie kann beim
Deployment mit `--identity` oder über `WATERMAPS_SSH_KEY` angegeben werden.
Falls OpenTofu keinen Output `ssh_private_key_path` bereitstellt und der Key
nicht bereits über den SSH-Agenten verfügbar ist, ist eine dieser beiden
Angaben erforderlich. Der vorbereitete SSH-Benutzer heißt standardmäßig
`deploy`; die privilegierten Installationsschritte laufen über dessen
passwortloses `sudo`.
## Deployment
Nach `tofu apply` liest dieses Skript standardmäßig den Output `server_ipv4`,
überträgt das Projekt ohne State, lokale Daten oder Node-Module und startet den
Serverbootstrap sowie den Docker-Stack:
```bash
./deploy/scripts/upload-and-deploy.sh --identity ~/.ssh/watermaps_hetzner_ed25519
```
Alternativ:
```bash
WATERMAPS_SERVER_IPV4=203.0.113.10 \
WATERMAPS_SSH_KEY=~/.ssh/watermaps_hetzner_ed25519 \
./deploy/scripts/upload-and-deploy.sh
```
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:
```text
/srv/watermaps-data/local/germany-netherlands-fairways.json
```
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
die großen PBF-Dateien nicht versehentlich auf dem Root-Dateisystem landen.
Für den kurzzeitigen Speicherpeak beim kombinierten Indexaufbau richtet der
Bootstrap zusätzlich die über `WATERMAPS_SWAP_SIZE_GB` konfigurierte,
persistente Swap-Reserve ein.
Vor dem Livegang antwortet Nginx nur für ACME-Challenges. Alle anderen
HTTP-Anfragen erhalten 404.
## DNS und SSL-Livegang
Sobald der OpenTofu-Output bekannt ist, kann folgender DNS-Eintrag gesetzt
werden:
```text
A watermaps.incoso.eu <server_ipv4>
```
Nach der DNS-Propagation wird der Livegang lokal ausgelöst:
```bash
./deploy/scripts/remote-go-live.sh --identity ~/.ssh/watermaps_hetzner_ed25519
```
Das Serverskript prüft, dass sämtliche A-Records ausschließlich auf die
erwartete IPv4 zeigen, testet den Routingindex mit je einer Route in
Deutschland und den Niederlanden, prüft den ACME-Webroot, fordert das
Zertifikat an und aktiviert erst anschließend HTTPS. HTTP leitet danach auf
HTTPS um.
## Automatik
`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.
- `watermaps-certbot-renew.timer`: zweimal täglich Certbot-Prüfung mit
anschließendem Nginx-Reload.
Status und Logs:
```bash
systemctl list-timers 'watermaps-*'
journalctl -u watermaps-route-update.service
journalctl -u watermaps-certbot-renew.service
docker compose \
--project-directory /opt/watermaps \
--env-file /opt/watermaps/deploy/.env.production \
-f /opt/watermaps/deploy/compose.production.yml ps
```
+100
View File
@@ -0,0 +1,100 @@
name: watermaps-production
services:
watermaps:
image: ${WATERMAPS_APP_IMAGE:-watermaps:production}
build:
context: .
dockerfile: Dockerfile
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 5174
DATABASE_URL: ""
REDIS_URL: ""
WATERMAPS_WEB_DIST_PATH: /app/apps/web/dist
WATERMAPS_LOCAL_FAIRWAYS_PATH: /data/germany-netherlands-fairways.json
WATERMAPS_LIVE_FAIRWAYS: "false"
WATERMAPS_DEMO_DATA: "false"
expose:
- "5174"
volumes:
- type: bind
source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}/local
target: /data
read_only: true
restart: unless-stopped
init: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- node
- -e
- >-
fetch('http://127.0.0.1:5174/health')
.then(response => { if (!response.ok) process.exit(1) })
.catch(() => process.exit(1))
interval: 15s
timeout: 5s
start_period: 15s
retries: 5
nginx:
image: ${WATERMAPS_NGINX_IMAGE:-nginx:1.30.4-alpine}
depends_on:
watermaps:
condition: service_healthy
ports:
- "80:80"
- "443:443"
volumes:
- type: bind
source: ${WATERMAPS_RUNTIME_DIR:-/srv/watermaps-runtime}/nginx/conf.d
target: /etc/nginx/conf.d
read_only: true
- type: bind
source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}/certbot/www
target: /var/www/certbot
read_only: true
- type: bind
source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}/certbot/letsencrypt
target: /etc/letsencrypt
read_only: true
restart: unless-stopped
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "nginx", "-t"]
interval: 30s
timeout: 5s
retries: 3
certbot:
image: ${WATERMAPS_CERTBOT_IMAGE:-certbot/certbot:v5.7.0}
profiles: ["maintenance"]
volumes:
- type: bind
source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}/certbot/www
target: /var/www/certbot
- type: bind
source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}/certbot/letsencrypt
target: /etc/letsencrypt
route-data:
image: ${WATERMAPS_ROUTE_DATA_IMAGE:-watermaps-route-data:production}
build:
context: .
dockerfile: deploy/route-data.Dockerfile
profiles: ["maintenance"]
environment:
WATERMAPS_GEOFABRIK_DIR: /workspace/data/geofabrik
WATERMAPS_LOCAL_FAIRWAYS_PATH: /workspace/data/local/germany-netherlands-fairways.json
volumes:
- type: bind
source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}
target: /workspace/data
restart: "no"
+15
View File
@@ -0,0 +1,15 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type text/plain;
try_files $uri =404;
}
location / {
return 404;
}
}
+67
View File
@@ -0,0 +1,67 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type text/plain;
try_files $uri =404;
}
location / {
return 404;
}
}
server {
listen 80;
listen [::]:80;
server_name __WATERMAPS_DOMAIN__;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type text/plain;
try_files $uri =404;
}
location / {
return 308 https://__WATERMAPS_DOMAIN__$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name __WATERMAPS_DOMAIN__;
ssl_certificate /etc/letsencrypt/live/__WATERMAPS_CERT_NAME__/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/__WATERMAPS_CERT_NAME__/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
client_max_body_size 2m;
location / {
if ($host != "__WATERMAPS_DOMAIN__") {
return 404;
}
proxy_pass http://watermaps:5174;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
}
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.12-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"
WORKDIR /workspace
COPY scripts ./scripts
COPY deploy/scripts/prepare-route-data.sh ./deploy/scripts/prepare-route-data.sh
ENTRYPOINT ["/workspace/deploy/scripts/prepare-route-data.sh"]
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
set -Eeuo pipefail
export DEBIAN_FRONTEND=noninteractive
if [[ "$(id -u)" -ne 0 ]]; then
printf 'Dieses Skript muss als root ausgeführt werden.\n' >&2
exit 1
fi
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}"
install_docker() {
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
return
fi
. /etc/os-release
case "${ID:-}" in
ubuntu|debian) ;;
*)
printf 'Nicht unterstützte Distribution für die automatische Docker-Installation: %s\n' "${ID:-unbekannt}" >&2
exit 1
;;
esac
apt-get update
apt-get install --yes ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl --fail --silent --show-error --location \
"https://download.docker.com/linux/$ID/gpg" \
--output /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
architecture="$(dpkg --print-architecture)"
codename="${VERSION_CODENAME:?VERSION_CODENAME fehlt in /etc/os-release}"
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/%s %s stable\n' \
"$architecture" "$ID" "$codename" \
>/etc/apt/sources.list.d/docker.list
apt-get update
apt-get install --yes docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
}
install_docker
apt-get update
apt-get install --yes bind9-dnsutils curl jq rsync
systemctl enable --now docker
if [[ ! -f "$WM_ENV_FILE" ]]; then
install -m 0600 "$WM_DEPLOY_DIR/.env.production.example" "$WM_ENV_FILE"
printf 'Konfiguration aus Vorlage angelegt: %s\n' "$WM_ENV_FILE"
fi
# shellcheck source=common.sh
source "$WM_DEPLOY_DIR/scripts/common.sh"
wm_load_env
wm_assert_data_mount
ensure_swap_reserve() {
local swap_file="/swapfile"
local swap_size_gb="${WATERMAPS_SWAP_SIZE_GB:-4}"
[[ "$swap_size_gb" =~ ^[1-9][0-9]*$ ]] &&
((swap_size_gb <= 16)) ||
wm_die "WATERMAPS_SWAP_SIZE_GB muss eine ganze Zahl zwischen 1 und 16 sein."
if swapon --show=NAME --noheadings | awk '{$1=$1; print}' | grep -Fxq "$swap_file"; then
return
fi
wm_log "Persistente ${swap_size_gb}-GB-Swap-Reserve wird vorbereitet."
fallocate --length "${swap_size_gb}G" "$swap_file"
chmod 0600 "$swap_file"
mkswap --force "$swap_file" >/dev/null
if ! grep -Fq "$swap_file none swap sw 0 0" /etc/fstab; then
printf '%s\n' "$swap_file none swap sw 0 0" >>/etc/fstab
fi
swapon "$swap_file"
}
ensure_swap_reserve
install -d -m 0755 \
"$WATERMAPS_DATA_DIR" \
"$WATERMAPS_DATA_DIR/geofabrik" \
"$WATERMAPS_DATA_DIR/local" \
"$WATERMAPS_DATA_DIR/certbot" \
"$WATERMAPS_DATA_DIR/certbot/www" \
"$WATERMAPS_DATA_DIR/certbot/letsencrypt" \
"$WATERMAPS_RUNTIME_DIR" \
"$WATERMAPS_RUNTIME_DIR/nginx" \
"$WATERMAPS_RUNTIME_DIR/nginx/conf.d" \
"$WATERMAPS_RUNTIME_DIR/locks"
if [[ ! -f "$WATERMAPS_RUNTIME_DIR/nginx/conf.d/default.conf" ]]; then
install -m 0644 \
"$WM_DEPLOY_DIR/nginx/bootstrap.conf" \
"$WATERMAPS_RUNTIME_DIR/nginx/conf.d/default.conf"
fi
install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-route-update.service" /etc/systemd/system/
install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-route-update.timer" /etc/systemd/system/
install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-certbot-renew.service" /etc/systemd/system/
install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-certbot-renew.timer" /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now watermaps-route-update.timer watermaps-certbot-renew.timer
wm_log "Server-Bootstrap abgeschlossen."
+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);
}
}
'
}
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -Eeuo pipefail
WM_DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=common.sh
source "$WM_DEPLOY_DIR/scripts/common.sh"
wm_load_env
wm_assert_data_mount
if [[ "$(id -u)" -ne 0 ]]; then
wm_die "Dieses Skript muss als root ausgeführt werden."
fi
install -d -m 0755 \
"$WATERMAPS_DATA_DIR/geofabrik" \
"$WATERMAPS_DATA_DIR/local" \
"$WATERMAPS_DATA_DIR/certbot/www" \
"$WATERMAPS_DATA_DIR/certbot/letsencrypt" \
"$WATERMAPS_RUNTIME_DIR/nginx/conf.d" \
"$WATERMAPS_RUNTIME_DIR/locks"
wm_acquire_route_lock ||
wm_die "Deployment abgebrochen, weil gerade Routingdaten aktualisiert werden."
if [[ ! -f "$WATERMAPS_RUNTIME_DIR/nginx/conf.d/default.conf" ]]; then
install -m 0644 \
"$WM_DEPLOY_DIR/nginx/bootstrap.conf" \
"$WATERMAPS_RUNTIME_DIR/nginx/conf.d/default.conf"
fi
wm_log "Anwendungsimage wird gebaut."
wm_compose build watermaps
wm_compose up --detach --remove-orphans watermaps nginx
wm_wait_for_health watermaps 240
wm_wait_for_health nginx 120
route_rebuild_required=false
update_args=()
if [[ "${WATERMAPS_REBUILD_ROUTE_DATA:-false}" == "true" ]]; then
route_rebuild_required=true
update_args+=(--force)
elif ! wm_route_data_ready; then
route_rebuild_required=true
fi
if [[ "$route_rebuild_required" == "true" ]]; then
wm_log "Deutschland- und Niederlande-Routingdaten werden sicher vorbereitet."
WATERMAPS_ROUTE_LOCK_HELD=true \
"$WM_DEPLOY_DIR/scripts/update-route-data.sh" "${update_args[@]}"
fi
wm_assert_route_data
wm_wait_for_health watermaps 240
wm_smoke_test_route
wm_log "Deployment ist bereit. Vor dem Livegang liefert Port 80 nur ACME-Challenges und ansonsten HTTP 404."
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
set -Eeuo pipefail
WM_DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=common.sh
source "$WM_DEPLOY_DIR/scripts/common.sh"
wm_load_env
wm_assert_data_mount
if [[ "$(id -u)" -ne 0 ]]; then
wm_die "Dieses Skript muss als root ausgeführt werden."
fi
domain="${WATERMAPS_DOMAIN:-}"
email="${WATERMAPS_ACME_EMAIL:-}"
expected_ipv4="${1:-${WATERMAPS_EXPECTED_IPV4:-}}"
[[ "$domain" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]] ||
wm_die "Ungültiger WATERMAPS_DOMAIN: $domain"
[[ "$email" == *@*.* && "$email" != "admin@example.com" ]] ||
wm_die "Bitte WATERMAPS_ACME_EMAIL in deploy/.env.production konfigurieren."
[[ "$expected_ipv4" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] ||
wm_die "Erwartete Server-IPv4 fehlt. Als Argument übergeben oder WATERMAPS_EXPECTED_IPV4 setzen."
wm_assert_route_data
wm_wait_for_health watermaps 120
wm_wait_for_health nginx 60
wm_smoke_test_route
mapfile -t resolved_ipv4s < <(dig +short A "$domain" | awk '/^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/')
[[ "${#resolved_ipv4s[@]}" -gt 0 ]] || wm_die "Für $domain ist noch kein A-Record auflösbar."
for resolved_ipv4 in "${resolved_ipv4s[@]}"; do
[[ "$resolved_ipv4" == "$expected_ipv4" ]] ||
wm_die "$domain zeigt zusätzlich/abweichend auf $resolved_ipv4 statt ausschließlich auf $expected_ipv4."
done
challenge_name="watermaps-preflight-$$"
challenge_dir="$WATERMAPS_DATA_DIR/certbot/www/.well-known/acme-challenge"
challenge_file="$challenge_dir/$challenge_name"
install -d -m 0755 "$challenge_dir"
printf 'watermaps-acme-preflight\n' >"$challenge_file"
trap 'rm -f "$challenge_file"' EXIT
curl --fail --silent --show-error \
--header "Host: $domain" \
"http://127.0.0.1/.well-known/acme-challenge/$challenge_name" \
| grep -qx 'watermaps-acme-preflight' ||
wm_die "Nginx stellt den ACME-Webroot nicht korrekt bereit."
certbot_args=(
certonly
--webroot
--webroot-path /var/www/certbot
--domain "$domain"
--email "$email"
--agree-tos
--non-interactive
--keep-until-expiring
)
cert_name="$domain"
if [[ "${WATERMAPS_CERTBOT_STAGING:-false}" == "true" ]]; then
cert_name="${domain}-staging"
certbot_args+=(--staging)
fi
certbot_args+=(--cert-name "$cert_name")
wm_log "Let's-Encrypt-Zertifikat wird angefordert."
wm_compose --profile maintenance run --rm certbot "${certbot_args[@]}"
certificate_path="$WATERMAPS_DATA_DIR/certbot/letsencrypt/live/$cert_name/fullchain.pem"
private_key_path="$WATERMAPS_DATA_DIR/certbot/letsencrypt/live/$cert_name/privkey.pem"
[[ -s "$certificate_path" && -s "$private_key_path" ]] ||
wm_die "Certbot war beendet, aber Zertifikat oder privater Schlüssel fehlen."
candidate="$(mktemp "$WATERMAPS_RUNTIME_DIR/nginx/conf.d/.https.XXXXXX")"
trap 'rm -f "$challenge_file" "$candidate"' EXIT
sed \
-e "s/__WATERMAPS_DOMAIN__/$domain/g" \
-e "s/__WATERMAPS_CERT_NAME__/$cert_name/g" \
"$WM_DEPLOY_DIR/nginx/https.conf.template" \
>"$candidate"
wm_compose run --rm --no-deps \
--volume "$candidate:/etc/nginx/conf.d/default.conf:ro" \
nginx nginx -t
active_config="$WATERMAPS_RUNTIME_DIR/nginx/conf.d/default.conf"
previous_config="$WATERMAPS_RUNTIME_DIR/nginx/conf.d/.default.conf.previous"
cp --preserve=mode,timestamps "$active_config" "$previous_config"
install -m 0644 "$candidate" "$active_config"
if ! wm_compose exec --no-TTY nginx nginx -t; then
install -m 0644 "$previous_config" "$active_config"
wm_compose exec --no-TTY nginx nginx -s reload || true
wm_die "HTTPS-Konfiguration war ungültig; Bootstrap-Konfiguration wurde wiederhergestellt."
fi
wm_compose exec --no-TTY nginx nginx -s reload
https_curl_args=(--fail --silent --show-error)
if [[ "${WATERMAPS_CERTBOT_STAGING:-false}" == "true" ]]; then
https_curl_args+=(--insecure)
fi
if ! curl "${https_curl_args[@]}" \
--resolve "$domain:443:$expected_ipv4" \
"https://$domain/health" \
>/dev/null; then
install -m 0644 "$previous_config" "$active_config"
wm_compose exec --no-TTY nginx nginx -t
wm_compose exec --no-TTY nginx nginx -s reload
wm_die "HTTPS-Smoke-Test fehlgeschlagen; vorherige Nginx-Konfiguration wurde wiederhergestellt."
fi
wm_log "Livegang erfolgreich: https://$domain"
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -Eeuo pipefail
cd /workspace
route_file="${WATERMAPS_LOCAL_FAIRWAYS_PATH:-/workspace/data/local/germany-netherlands-fairways.json}"
marker_file="${WATERMAPS_ROUTE_MARKER_PATH:-$(dirname "$route_file")/.germany-netherlands-fairways.ready}"
mkdir -p "$(dirname "$route_file")" /workspace/data/geofabrik
if [[ ! -x /workspace/scripts/setup-local-routing.sh ]]; then
printf 'Fehler: scripts/setup-local-routing.sh fehlt oder ist nicht ausführbar.\n' >&2
exit 1
fi
/workspace/scripts/setup-local-routing.sh
if [[ ! -s "$route_file" ]]; then
printf 'Fehler: Der Routingindex wurde nicht erzeugt: %s\n' "$route_file" >&2
exit 1
fi
chmod 0644 "$route_file"
temporary_marker="$(mktemp "$(dirname "$marker_file")/.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_file"
printf 'Routingdaten sind bereit: %s\n' "$route_file"
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -Eeuo pipefail
WM_LOCAL_DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WM_LOCAL_ROOT_DIR="$(cd "$WM_LOCAL_DEPLOY_DIR/.." && pwd)"
WM_LOCAL_INFRA_DIR="${WATERMAPS_INFRA_DIR:-$WM_LOCAL_ROOT_DIR/infra/opentofu}"
wm_local_die() {
printf 'Fehler: %s\n' "$*" >&2
exit 1
}
wm_local_resolve_ssh() {
local requested_server="${1:-}"
local requested_identity="${2:-}"
WM_SERVER_IPV4="${requested_server:-${WATERMAPS_SERVER_IPV4:-}}"
if [[ -z "$WM_SERVER_IPV4" ]]; then
command -v tofu >/dev/null 2>&1 ||
wm_local_die "OpenTofu fehlt und WATERMAPS_SERVER_IPV4 wurde nicht gesetzt."
[[ -d "$WM_LOCAL_INFRA_DIR" ]] ||
wm_local_die "OpenTofu-Verzeichnis fehlt: $WM_LOCAL_INFRA_DIR"
WM_SERVER_IPV4="$(tofu -chdir="$WM_LOCAL_INFRA_DIR" output -raw server_ipv4)"
fi
[[ "$WM_SERVER_IPV4" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] ||
wm_local_die "Ungültige Server-IPv4: $WM_SERVER_IPV4"
WM_SSH_IDENTITY="${requested_identity:-${WATERMAPS_SSH_KEY:-}}"
if [[ -z "$WM_SSH_IDENTITY" ]] && command -v tofu >/dev/null 2>&1 && [[ -d "$WM_LOCAL_INFRA_DIR" ]]; then
candidate_identity="$(tofu -chdir="$WM_LOCAL_INFRA_DIR" output -raw ssh_private_key_path 2>/dev/null || true)"
if [[ -n "$candidate_identity" && -f "$candidate_identity" ]]; then
WM_SSH_IDENTITY="$candidate_identity"
fi
fi
if [[ -n "$WM_SSH_IDENTITY" && ! -f "$WM_SSH_IDENTITY" ]]; then
wm_local_die "SSH-Keydatei nicht gefunden: $WM_SSH_IDENTITY"
fi
WM_SSH_USER="${WATERMAPS_SSH_USER:-deploy}"
[[ "$WM_SSH_USER" =~ ^[a-z_][a-z0-9_-]{0,30}$ ]] ||
wm_local_die "Ungültiger SSH-Benutzer: $WM_SSH_USER"
if [[ -n "${WATERMAPS_REMOTE_SUDO+x}" ]]; then
WM_REMOTE_SUDO="$WATERMAPS_REMOTE_SUDO"
elif [[ "$WM_SSH_USER" == "root" ]]; then
WM_REMOTE_SUDO=""
else
WM_REMOTE_SUDO="sudo"
fi
case "$WM_REMOTE_SUDO" in
""|sudo) ;;
*) wm_local_die "WATERMAPS_REMOTE_SUDO darf nur leer oder 'sudo' sein." ;;
esac
WM_SSH_TARGET="$WM_SSH_USER@$WM_SERVER_IPV4"
WM_SSH_OPTIONS=(-o BatchMode=yes -o StrictHostKeyChecking=accept-new)
if [[ -n "$WM_SSH_IDENTITY" ]]; then
WM_SSH_OPTIONS+=(-i "$WM_SSH_IDENTITY" -o IdentitiesOnly=yes)
fi
}
wm_local_wait_for_ssh() {
local timeout_seconds="${WATERMAPS_SSH_WAIT_SECONDS:-600}"
local deadline=$((SECONDS + timeout_seconds))
while ((SECONDS < deadline)); do
if ssh "${WM_SSH_OPTIONS[@]}" \
-o ConnectTimeout=5 \
"$WM_SSH_TARGET" true >/dev/null 2>&1; then
return 0
fi
printf '[watermaps] Warte auf SSH und Cloud-init-Benutzer %s …\n' "$WM_SSH_TARGET"
sleep 5
done
wm_local_die "SSH war nach ${timeout_seconds}s nicht erreichbar: $WM_SSH_TARGET"
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -Eeuo pipefail
WM_DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=remote-common.sh
source "$WM_DEPLOY_DIR/scripts/remote-common.sh"
server_ipv4=""
identity_file=""
while [[ "$#" -gt 0 ]]; do
case "$1" in
--host)
[[ "$#" -ge 2 ]] || wm_local_die "Wert für --host fehlt."
server_ipv4="$2"
shift 2
;;
--identity)
[[ "$#" -ge 2 ]] || wm_local_die "Wert für --identity fehlt."
identity_file="$2"
shift 2
;;
--user)
[[ "$#" -ge 2 ]] || wm_local_die "Wert für --user fehlt."
WATERMAPS_SSH_USER="$2"
export WATERMAPS_SSH_USER
shift 2
;;
-h|--help)
printf 'Verwendung: %s [--host IPV4] [--identity DATEI] [--user BENUTZER]\n' "$0"
exit 0
;;
*)
wm_local_die "Unbekannte Option: $1"
;;
esac
done
wm_local_resolve_ssh "$server_ipv4" "$identity_file"
wm_local_wait_for_ssh
remote_prefix=""
if [[ -n "$WM_REMOTE_SUDO" ]]; then
remote_prefix="$WM_REMOTE_SUDO "
fi
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
"${remote_prefix}/opt/watermaps/deploy/scripts/go-live.sh '$WM_SERVER_IPV4'"
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -Eeuo pipefail
WM_DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=common.sh
source "$WM_DEPLOY_DIR/scripts/common.sh"
wm_load_env
wm_assert_data_mount
if [[ "$(id -u)" -ne 0 ]]; then
wm_die "Dieses Skript muss als root ausgeführt werden."
fi
domain="${WATERMAPS_DOMAIN:-}"
cert_name="$domain"
if [[ "${WATERMAPS_CERTBOT_STAGING:-false}" == "true" ]]; then
cert_name="${domain}-staging"
fi
[[ -s "$WATERMAPS_DATA_DIR/certbot/letsencrypt/live/$cert_name/fullchain.pem" ]] || {
wm_log "Noch kein Zertifikat vorhanden; Erneuerung wird übersprungen."
exit 0
}
wm_compose --profile maintenance run --rm certbot \
renew \
--webroot \
--webroot-path /var/www/certbot \
--quiet
wm_compose exec --no-TTY nginx nginx -t
wm_compose exec --no-TTY nginx nginx -s reload
wm_log "Zertifikatserneuerung geprüft und Nginx neu geladen."
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
TEST_DIR="$(mktemp -d)"
trap 'rm -rf "$TEST_DIR"' EXIT
export WATERMAPS_DATA_DIR="$TEST_DIR/data"
export WATERMAPS_RUNTIME_DIR="$TEST_DIR/runtime"
mkdir -p "$WATERMAPS_DATA_DIR/local" "$WATERMAPS_RUNTIME_DIR"
# shellcheck source=../common.sh
source "$ROOT_DIR/deploy/scripts/common.sh"
route_file="$(wm_route_file)"
route_marker="$(wm_route_marker)"
write_valid_route() {
cat >"$route_file" <<'JSON'
{
"version": 1,
"generatorVersion": 2,
"source": "germany+netherlands",
"sources": [
{
"region": "germany",
"file": "germany-latest.osm.pbf",
"checksumMd5": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"sizeBytes": 1
},
{
"region": "netherlands",
"file": "netherlands-latest.osm.pbf",
"checksumMd5": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"sizeBytes": 1
}
],
"ways": [{"id": "1"}]
}
JSON
chmod 0644 "$route_file"
}
write_valid_route
wm_write_route_marker "$route_file" "$route_marker"
wm_route_data_ready
sed -i 's/^size_bytes=.*/size_bytes=1/' "$route_marker"
if wm_route_data_ready; then
printf 'Ungültige Markergröße wurde akzeptiert.\n' >&2
exit 1
fi
wm_write_route_marker "$route_file" "$route_marker"
jq '.sources = [.sources[0]] | .source = "germany"' \
"$route_file" >"$route_file.tmp"
mv "$route_file.tmp" "$route_file"
chmod 0644 "$route_file"
wm_write_route_marker "$route_file" "$route_marker"
if wm_route_data_ready; then
printf 'Deutschland-only-Index wurde akzeptiert.\n' >&2
exit 1
fi
write_valid_route
wm_write_route_marker "$route_file" "$route_marker"
chmod 0600 "$route_file"
if wm_route_data_ready; then
printf 'Für den App-Container unlesbarer Index wurde akzeptiert.\n' >&2
exit 1
fi
printf 'Routingdaten- und Markerprüfungen: OK\n'
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
TEST_DIR="$(mktemp -d)"
trap 'rm -rf "$TEST_DIR"' EXIT
FAKE_BIN="$TEST_DIR/bin"
DATA_DIR="$TEST_DIR/data"
RUNTIME_DIR="$TEST_DIR/runtime"
ENV_FILE="$TEST_DIR/production.env"
mkdir -p "$FAKE_BIN" "$DATA_DIR/local" "$RUNTIME_DIR"
cat >"$ENV_FILE" <<EOF
WATERMAPS_DATA_DIR=$DATA_DIR
WATERMAPS_RUNTIME_DIR=$RUNTIME_DIR
EOF
cat >"$FAKE_BIN/id" <<'SH'
#!/usr/bin/env bash
if [[ "${1:-}" == "-u" ]]; then
printf '0\n'
else
exec /usr/bin/id "$@"
fi
SH
cat >"$FAKE_BIN/mountpoint" <<'SH'
#!/usr/bin/env bash
exit 0
SH
cat >"$FAKE_BIN/docker" <<'SH'
#!/usr/bin/env bash
set -Eeuo pipefail
if [[ "${1:-}" == "inspect" ]]; then
printf 'healthy\n'
exit 0
fi
arguments=" $* "
if [[ "$arguments" == *" ps --quiet watermaps "* ]]; then
printf 'watermaps-test-container\n'
exit 0
fi
if [[ "$arguments" == *" run "*" route-data "* ||
"$arguments" == *" run "*" route-data" ]]; then
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
{
"version": 1,
"generatorVersion": 2,
"source": "germany+netherlands",
"revision": ${FAKE_ROUTE_REVISION:-1},
"sources": [
{
"region": "germany",
"file": "germany-latest.osm.pbf",
"checksumMd5": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"sizeBytes": 1
},
{
"region": "netherlands",
"file": "netherlands-latest.osm.pbf",
"checksumMd5": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"sizeBytes": 1
}
],
"ways": [{"id": "1"}]
}
JSON
# shellcheck source=../common.sh
source "$WM_TEST_ROOT/deploy/scripts/common.sh"
wm_write_route_marker "$route_file" "$marker_file"
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)"
if [[ -n "${FAKE_FAIL_REVISION:-}" && "$active_revision" == "$FAKE_FAIL_REVISION" ]]; then
exit 1
fi
exit 0
fi
exit 0
SH
chmod +x "$FAKE_BIN/id" "$FAKE_BIN/mountpoint" "$FAKE_BIN/docker"
export PATH="$FAKE_BIN:$PATH"
export WATERMAPS_ENV_FILE="$ENV_FILE"
export WM_TEST_ROOT="$ROOT_DIR"
# A first successful activation creates an active index and a validated marker.
FAKE_ROUTE_REVISION=1 "$ROOT_DIR/deploy/scripts/update-route-data.sh"
active_file="$DATA_DIR/local/germany-netherlands-fairways.json"
active_marker="$DATA_DIR/local/.germany-netherlands-fairways.ready"
[[ "$(jq --raw-output '.revision' "$active_file")" == "1" ]]
export WATERMAPS_DATA_DIR="$DATA_DIR"
export WATERMAPS_RUNTIME_DIR="$RUNTIME_DIR"
# shellcheck source=../common.sh
source "$ROOT_DIR/deploy/scripts/common.sh"
wm_route_data_files_ready "$active_file" "$active_marker"
# A candidate that fails its route smoke test must restore and retest revision 1.
set +e
FAKE_ROUTE_REVISION=2 FAKE_FAIL_REVISION=2 \
"$ROOT_DIR/deploy/scripts/update-route-data.sh"
update_status=$?
set -e
[[ "$update_status" -ne 0 ]]
[[ "$(jq --raw-output '.revision' "$active_file")" == "1" ]]
wm_route_data_files_ready "$active_file" "$active_marker"
[[ ! -e "$RUNTIME_DIR/locks/route-update.transaction" ]]
# A hard-crash marker restores the durable previous index before any new build.
backup_file="$DATA_DIR/local/.germany-netherlands-fairways.previous.json"
backup_marker="$DATA_DIR/local/.germany-netherlands-fairways.previous.ready"
cp "$active_file" "$backup_file"
wm_write_route_marker "$backup_file" "$backup_marker"
jq '.revision = 9' "$active_file" >"$active_file.tmp"
mv "$active_file.tmp" "$active_file"
chmod 0644 "$active_file"
wm_write_route_marker "$active_file" "$active_marker"
printf 'had_previous=true\n' >"$RUNTIME_DIR/locks/route-update.transaction"
FAKE_ROUTE_REVISION=1 "$ROOT_DIR/deploy/scripts/update-route-data.sh"
[[ "$(jq --raw-output '.revision' "$active_file")" == "1" ]]
wm_route_data_files_ready "$active_file" "$active_marker"
[[ ! -e "$RUNTIME_DIR/locks/route-update.transaction" ]]
# Without a previous valid index, a failed first candidate must not stay ready.
rm -f "$active_file" "$active_marker" \
"$DATA_DIR/local/.germany-netherlands-fairways.previous.json" \
"$DATA_DIR/local/.germany-netherlands-fairways.previous.ready"
set +e
FAKE_ROUTE_REVISION=3 FAKE_FAIL_REVISION=3 \
"$ROOT_DIR/deploy/scripts/update-route-data.sh"
first_update_status=$?
set -e
[[ "$first_update_status" -ne 0 ]]
[[ ! -e "$active_file" ]]
[[ ! -e "$active_marker" ]]
[[ ! -e "$RUNTIME_DIR/locks/route-update.transaction" ]]
printf 'Staging-, Rollback- und First-Update-Prüfungen: OK\n'
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env bash
set -Eeuo pipefail
WM_DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=common.sh
source "$WM_DEPLOY_DIR/scripts/common.sh"
wm_load_env
wm_assert_data_mount
if [[ "$(id -u)" -ne 0 ]]; then
wm_die "Dieses Skript muss als root ausgeführt werden."
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."
install -d -m 0755 "$WATERMAPS_RUNTIME_DIR/locks" "$WATERMAPS_DATA_DIR/local"
if [[ "${WATERMAPS_ROUTE_LOCK_HELD:-false}" != "true" ]]; then
if ! wm_acquire_route_lock; then
exit 0
fi
fi
route_file="$(wm_route_file)"
route_marker="$(wm_route_marker)"
route_dir="$(dirname "$route_file")"
candidate_file="$route_dir/.germany-netherlands-fairways.candidate.json"
candidate_marker="$route_dir/.germany-netherlands-fairways.candidate.ready"
backup_file="$route_dir/.germany-netherlands-fairways.previous.json"
backup_marker="$route_dir/.germany-netherlands-fairways.previous.ready"
transaction_file="$WATERMAPS_RUNTIME_DIR/locks/route-update.transaction"
container_candidate="/workspace/data/local/$(basename "$candidate_file")"
container_candidate_marker="/workspace/data/local/$(basename "$candidate_marker")"
transaction_active=false
rollback_restored_previous=false
wm_copy_route_atomically() {
local source_file="$1"
local destination_file="$2"
local temporary_file
temporary_file="$(mktemp "$(dirname "$destination_file")/.route-copy.XXXXXX")"
if ! cp --reflink=auto "$source_file" "$temporary_file"; then
rm -f "$temporary_file"
return 1
fi
chmod 0644 "$temporary_file"
mv -f "$temporary_file" "$destination_file"
}
wm_watermaps_is_running() {
[[ -n "$(wm_compose ps --quiet watermaps)" ]]
}
wm_restore_previous_route() {
rollback_restored_previous=false
if [[ -s "$backup_file" && -s "$backup_marker" ]] &&
wm_route_data_files_ready "$backup_file" "$backup_marker"; then
if ! wm_copy_route_atomically "$backup_file" "$route_file" ||
! wm_write_route_marker "$route_file" "$route_marker" ||
! wm_route_data_ready; then
rm -f "$route_file" "$route_marker"
return 1
fi
rollback_restored_previous=true
return 0
fi
rm -f "$route_file" "$route_marker"
return 1
}
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
fi
wm_compose restart watermaps
wm_wait_for_health watermaps 240 || return 1
if [[ "$rollback_restored_previous" == "true" ]]; then
wm_smoke_test_route
fi
}
wm_rollback_transaction() {
local restore_status=0
if ! wm_restore_previous_route; then
restore_status=1
fi
rm -f "$transaction_file"
transaction_active=false
if ! wm_restart_after_restore; then
return 1
fi
return "$restore_status"
}
wm_cleanup_on_exit() {
local status=$?
trap - EXIT HUP INT TERM
set +e
if [[ "$transaction_active" == "true" ]]; then
wm_log "Unvollständige Routingaktivierung wird zurückgerollt."
wm_rollback_transaction
fi
rm -f "$candidate_file" "$candidate_marker"
exit "$status"
}
trap wm_cleanup_on_exit EXIT
trap 'exit 130' HUP INT TERM
# A hard interruption cannot run shell traps. The durable transaction marker
# makes the next invocation restore the last validated index before proceeding.
if [[ -e "$transaction_file" ]]; then
wm_log "Unterbrochene Routingaktualisierung erkannt; letzter gültiger Stand wird wiederhergestellt."
if wm_restore_previous_route; then
wm_restart_after_restore ||
wm_die "Der wiederhergestellte Routingindex bestand den Routentest nicht."
else
wm_restart_after_restore ||
wm_log "Es existierte noch kein vorheriger Routingindex; aktiver Marker wurde entfernt."
fi
rm -f "$transaction_file"
fi
rm -f "$candidate_file" "$candidate_marker"
previous_ready=false
if wm_route_data_ready; then
previous_ready=true
if [[ "$force_rebuild" != "true" ]]; then
cp --reflink=auto "$route_file" "$candidate_file"
chmod 0644 "$candidate_file"
fi
fi
wm_log "Deutschland-/Niederlande-Routingdaten werden in einer Stagingdatei gebaut."
if ! wm_compose --profile maintenance run --rm --build \
--env "WATERMAPS_LOCAL_FAIRWAYS_PATH=$container_candidate" \
--env "WATERMAPS_ROUTE_MARKER_PATH=$container_candidate_marker" \
route-data; then
wm_die "Der Datenaufbau ist fehlgeschlagen; der aktive Index wurde nicht verändert."
fi
if ! wm_route_data_files_ready "$candidate_file" "$candidate_marker"; then
wm_die "Der neu gebaute Routingindex oder sein Bereitschaftsmarker ist ungültig."
fi
if [[ "$previous_ready" == "true" ]] && cmp --silent "$candidate_file" "$route_file"; then
wm_log "Geofabrik-Snapshots und Routingindex sind unverändert."
rm -f "$candidate_file" "$candidate_marker"
exit 0
fi
if [[ "$previous_ready" == "true" ]]; then
wm_copy_route_atomically "$route_file" "$backup_file"
wm_write_route_marker "$backup_file" "$backup_marker"
wm_route_data_files_ready "$backup_file" "$backup_marker" ||
wm_die "Der bisherige Routingindex konnte nicht sicher gesichert werden."
else
rm -f "$backup_file" "$backup_marker"
fi
temporary_transaction="$(mktemp "$WATERMAPS_RUNTIME_DIR/locks/.route-update-transaction.XXXXXX")"
{
printf 'started_at=%s\n' "$(date --utc +%Y-%m-%dT%H:%M:%SZ)"
printf 'had_previous=%s\n' "$previous_ready"
} >"$temporary_transaction"
chmod 0644 "$temporary_transaction"
mv -f "$temporary_transaction" "$transaction_file"
transaction_active=true
mv -f "$candidate_file" "$route_file"
rm -f "$candidate_marker"
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
rm -f "$transaction_file"
transaction_active=false
rm -f "$backup_file" "$backup_marker"
wm_log "Routingdaten-Update wurde erfolgreich aktiviert."
exit 0
fi
wm_log "Der neue Routingindex bestand die Routentests nicht; Rollback wird ausgeführt."
if wm_rollback_transaction; then
rm -f "$backup_file" "$backup_marker"
wm_die "Neuer Routingindex verworfen; der vorherige, erneut getestete Index ist aktiv."
fi
if [[ "$rollback_restored_previous" == "true" ]]; then
wm_die "Rollback-Dateien wurden wiederhergestellt, bestanden aber den Routentest nicht."
fi
wm_die "Neuer Routingindex verworfen; es existierte noch kein vorheriger gültiger Index."
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
set -Eeuo pipefail
WM_DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WM_ROOT_DIR="$(cd "$WM_DEPLOY_DIR/.." && pwd)"
# shellcheck source=remote-common.sh
source "$WM_DEPLOY_DIR/scripts/remote-common.sh"
server_ipv4=""
identity_file=""
run_go_live=false
usage() {
cat <<'USAGE'
Verwendung:
./deploy/scripts/upload-and-deploy.sh [Optionen]
Optionen:
--host IPV4 Server-IP statt `tofu output -raw server_ipv4`
--identity DATEI Privater SSH-Key (alternativ WATERMAPS_SSH_KEY)
--user BENUTZER SSH-Benutzer; Standard: deploy
--go-live Nach dem Deployment sofort den SSL-Livegang versuchen
-h, --help Hilfe anzeigen
Die Datei deploy/.env.production muss lokal vorhanden sein. Sie wird separat
mit Dateimodus 0600 übertragen. Terraform-State, .terraform, data,
node_modules, lokale Env-Dateien und Git-Metadaten werden ausgeschlossen.
USAGE
}
while [[ "$#" -gt 0 ]]; do
case "$1" in
--host)
[[ "$#" -ge 2 ]] || wm_local_die "Wert für --host fehlt."
server_ipv4="$2"
shift 2
;;
--identity)
[[ "$#" -ge 2 ]] || wm_local_die "Wert für --identity fehlt."
identity_file="$2"
shift 2
;;
--user)
[[ "$#" -ge 2 ]] || wm_local_die "Wert für --user fehlt."
WATERMAPS_SSH_USER="$2"
export WATERMAPS_SSH_USER
shift 2
;;
--go-live)
run_go_live=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
wm_local_die "Unbekannte Option: $1"
;;
esac
done
command -v rsync >/dev/null 2>&1 || wm_local_die "rsync ist lokal nicht installiert."
local_env="$WM_DEPLOY_DIR/.env.production"
[[ -f "$local_env" ]] ||
wm_local_die "Bitte zuerst deploy/.env.production aus der Vorlage erstellen und konfigurieren."
wm_local_resolve_ssh "$server_ipv4" "$identity_file"
wm_local_wait_for_ssh
remote_prefix=""
rsync_path="rsync"
if [[ -n "$WM_REMOTE_SUDO" ]]; then
remote_prefix="$WM_REMOTE_SUDO "
rsync_path="$WM_REMOTE_SUDO rsync"
fi
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 Projekt 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"
rsync \
--archive \
--compress \
--delete-delay \
--human-readable \
--rsync-path="$rsync_path" \
--exclude='.git/' \
--exclude='.terraform/' \
--exclude='.terraform.tfstate.lock.info' \
--exclude='*.tfvars' \
--exclude='*.tfvars.json' \
--exclude='*.tfplan' \
--exclude='*.tfstate*' \
--exclude='crash.log' \
--exclude='crash.*.log' \
--exclude='/data/' \
--exclude='/.tools/' \
--exclude='node_modules/' \
--exclude='**/.env' \
--exclude='**/.env.*' \
--exclude='/deploy/.env.production' \
-e "ssh ${WM_SSH_OPTIONS[*]@Q}" \
"$WM_ROOT_DIR/" \
"$WM_SSH_TARGET:/opt/watermaps/"
rsync \
--archive \
--chmod=F600 \
--rsync-path="$rsync_path" \
-e "ssh ${WM_SSH_OPTIONS[*]@Q}" \
"$local_env" \
"$WM_SSH_TARGET:/opt/watermaps/deploy/.env.production"
remote_command="${remote_prefix}chmod +x /opt/watermaps/deploy/scripts/*.sh"
remote_command+=" && ${remote_prefix}/opt/watermaps/deploy/scripts/bootstrap-server.sh"
remote_command+=" && ${remote_prefix}/opt/watermaps/deploy/scripts/deploy.sh"
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" "$remote_command"
if [[ "$run_go_live" == "true" ]]; then
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
"${remote_prefix}/opt/watermaps/deploy/scripts/go-live.sh '$WM_SERVER_IPV4'"
else
printf '[watermaps] Deployment abgeschlossen. Nach dem DNS-Eintrag:\n'
printf ' ./deploy/scripts/remote-go-live.sh --host %s\n' "$WM_SERVER_IPV4"
fi
@@ -0,0 +1,11 @@
[Unit]
Description=Watermaps Let's-Encrypt-Zertifikat erneuern
Wants=network-online.target
After=network-online.target docker.service watermaps-volume-setup.service
Requires=docker.service
RequiresMountsFor=/srv/watermaps-data
[Service]
Type=oneshot
WorkingDirectory=/opt/watermaps
ExecStart=/opt/watermaps/deploy/scripts/renew-certificate.sh
@@ -0,0 +1,11 @@
[Unit]
Description=Watermaps-Zertifikat zweimal täglich prüfen
[Timer]
OnCalendar=*-*-* 03,15:17:00
RandomizedDelaySec=45m
Persistent=true
Unit=watermaps-certbot-renew.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,14 @@
[Unit]
Description=Watermaps Deutschland-/Niederlande-Routingdaten aktualisieren
Wants=network-online.target
After=network-online.target docker.service watermaps-volume-setup.service
Requires=docker.service
RequiresMountsFor=/srv/watermaps-data
[Service]
Type=oneshot
WorkingDirectory=/opt/watermaps
ExecStart=/opt/watermaps/deploy/scripts/update-route-data.sh
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
@@ -0,0 +1,11 @@
[Unit]
Description=Täglich Watermaps-Routingdaten aktualisieren
[Timer]
OnCalendar=*-*-* 06:15:00
RandomizedDelaySec=30m
Persistent=true
Unit=watermaps-route-update.service
[Install]
WantedBy=timers.target
+41 -2
View File
@@ -1,7 +1,44 @@
version: "2.4"
services: services:
watermaps:
build:
context: .
dockerfile: Dockerfile
image: watermaps:local
ports:
- "${WATERMAPS_HTTP_PORT:-5173}:5174"
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 5174
DATABASE_URL: ""
REDIS_URL: ""
WATERMAPS_WEB_DIST_PATH: /app/apps/web/dist
WATERMAPS_LOCAL_FAIRWAYS_PATH: /data/germany-netherlands-fairways.json
WATERMAPS_LIVE_FAIRWAYS: "false"
WATERMAPS_DEMO_DATA: "true"
volumes:
- type: bind
source: ./data/local
target: /data
read_only: true
bind:
create_host_path: false
restart: unless-stopped
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:5174/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
postgres: postgres:
profiles: ["postgis"]
image: postgis/postgis:16-3.4 image: postgis/postgis:16-3.4
environment: environment:
POSTGRES_DB: seacompass POSTGRES_DB: seacompass
@@ -19,6 +56,7 @@ services:
retries: 5 retries: 5
redis: redis:
profiles: ["postgis"]
image: redis:7-alpine image: redis:7-alpine
ports: ports:
- "6379:6379" - "6379:6379"
@@ -29,6 +67,7 @@ services:
retries: 5 retries: 5
martin: martin:
profiles: ["postgis"]
image: ghcr.io/maplibre/martin:latest image: ghcr.io/maplibre/martin:latest
command: ["--config", "/config/martin.yaml"] command: ["--config", "/config/martin.yaml"]
ports: ports:
+18
View File
@@ -0,0 +1,18 @@
.terraform/
.terraform.tfstate.lock.info
*.tfstate
*.tfstate.*
*.tfplan
crash.log
crash.*.log
# Lokale Konfiguration mit Zugangsdaten
terraform.tfvars
*.auto.tfvars
!terraform.tfvars.example
# Lokale Overrides
override.tf
override.tf.json
*_override.tf
*_override.tf.json
+35
View File
@@ -0,0 +1,35 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/hetznercloud/hcloud" {
version = "1.66.1"
constraints = "1.66.1"
hashes = [
"h1:2xnL6r7iH/7sheeBlcSC8KpT9kUSYiboo0Ujc7B0RLw=",
"h1:4umCzzfMlXTswvnn46dX0tPNl267O72eIXRkO/+Nfng=",
"h1:7CSl5SdPjP2VK96O/4rz4zem2WSljipRr2r2DQWDbRw=",
"h1:JO2NGqSohoL+EfmcuUC6LdvoZh8Qd4fl0+Fqfyz+3Oc=",
"h1:QHgbvrPciaSf3B9q4tdfPq4yK5sJpA0vq7Z5Mjfj/sg=",
"h1:R+S77AiRWQQnVROR+HNqMXnhX8fDxZVBG1PwlpxGyss=",
"h1:WU+1RcBhDQNRY5CcurLApBjj8o2SOXKhJCjObIX4Ic0=",
"h1:WztH73MMX1CpUuglqMmnugXLS20nMHsntteg1hcM2xQ=",
"h1:Z/ipcn9gDF25Sjbp0v80Alt/6v4XwiBBmd9V6mYo0I0=",
"h1:bKv1qR7TB/x6a1u1OmacaGgFXmkAukokAPizicGvHAo=",
"h1:cc0E0i8geVFX5z8Kt5qrRew3eyB1OaWxTBzBbkoU7tA=",
"h1:lTWPM6oRkJ7988uDQ2bR8SUeezk7ux/rwg3WIuN2oRU=",
"h1:rxfnUOt0pPl2XXqIzMaEJimpWjBquLOCHv5Z/4/AD6g=",
"zh:113070176eb4fb26a3758b3d1031bb904e34a74f7c4f99f90df2301ca4468a51",
"zh:1bfc988bdcd7c9422e09c262c736ad265a205684f0402fa4a83e63c0b08e09ea",
"zh:37d92b4cf0f344295b0d780aabbc1408f02db31141cd4408276455a458071e54",
"zh:386bd1207b3ed284b513294ddad9b9f59047a693c3aa375605f858f9d9758b58",
"zh:43d26f0a4f5a64bf0ade1c5a278b40153d4ae8c77508933b9c8bb7f7860dae6d",
"zh:6a1fe681a1706be87f0d03b749f1dc69c838d663c6ab08a00ae8d87be2e4425e",
"zh:7032556ae2a74b2e1b7d68add9b96158e4f7202ebb8fbd75e0e8a1581719df7b",
"zh:893296927373b4afa7ec3639abb1ebab3c1b6cb9cfe427877cfa7bf69a33480d",
"zh:8d58b340e21428a59cc27dc4f5a7fcd2035ee1aa9372c3932f7962647532fe0d",
"zh:93509170347bf097ca38e288a6e921811bb769bb2dfac3c339eb2032ae8454c1",
"zh:9c00443cb5ae2401089a62e223d7d741eedb04f6a73c357b6a7443b6ae71b0be",
"zh:9c356be5ce8cb7b1d83d5cf2276823242f106dbd0c43fe992055f0fa11290f95",
"zh:f619116583c7e47751e0baefffca216c83f9f42edf99325121b85ee075db78cc",
]
}
+73
View File
@@ -0,0 +1,73 @@
# Watermaps auf Hetzner Cloud
Diese OpenTofu-Konfiguration erstellt einen einzelnen Ubuntu-24.04-Server mit
fester IPv4-Adresse, vorgeschalteter Hetzner-Firewall und einem persistenten
ext4-Volume für die Routingdaten von Deutschland und den Niederlanden.
Kartenkacheln, DNS-Einträge und Anwendungsdeployment sind bewusst nicht Teil
dieses Infrastrukturmoduls.
## Konfiguration
Die lokale Datei `terraform.tfvars` ist bereits angelegt und wird durch die
lokale `.gitignore` ausgeschlossen. Vor dem ersten Plan müssen dort mindestens
folgende Werte ersetzt werden:
- `hcloud_token`: Read/Write-API-Token des richtigen Hetzner-Cloud-Projekts
- `ssh_public_key`: vollständiger Inhalt des öffentlichen SSH-Schlüssels
- `admin_cidrs`: öffentliche Administrator-IP mit `/32` beziehungsweise ein
bewusst gewähltes Netz
`terraform.tfvars.example` bleibt als geheimnisfreie Vorlage versioniert.
Da `terraform.tfvars` den Token im Klartext enthält, sollte sie nur für den
lokalen Benutzer lesbar sein:
```sh
chmod 600 terraform.tfvars
```
Auch `terraform.tfstate` bleibt lokal und wird nicht nach Git oder auf den
Anwendungsserver übertragen. Nach dem ersten Apply sollte die State-Datei
verschlüsselt gesichert werden, weil OpenTofu sie für spätere Änderungen an
denselben Ressourcen benötigt.
## Infrastruktur erzeugen
```sh
cd infra/opentofu
tofu init
tofu fmt -check
tofu validate
tofu plan -out=watermaps.tfplan
tofu apply watermaps.tfplan
```
Die feste IPv4-Adresse und den erforderlichen manuellen DNS-Eintrag zeigt
OpenTofu anschließend an:
```sh
tofu output server_ipv4
tofu output dns_a_record
tofu output ssh_command
```
Der A-Record `watermaps.incoso.eu` kann direkt nach dem Apply auf
`server_ipv4` gesetzt werden. Das eigentliche SSL-Livegehen erfolgt erst durch
das separate Deployment-Skript, nachdem DNS propagiert ist und die Routingdaten
bereitstehen.
## Auf dem Server
Cloud-init installiert Docker einschließlich Compose v2, `curl`, `jq` und
`rsync`. Der Benutzer `deploy` erhält Zugriff per SSH und auf Docker. Das
persistente Volume wird nach dem Anhängen unter `/srv/watermaps-data` gemountet
und enthält die vom Produktions-Stack verwendeten Verzeichnisse `geofabrik`,
`local` und `certbot`. Ein systemd-Drop-in lässt Docker bei jedem Start auf den
erfolgreichen Volume-Mount warten, bevor bestehende Container neu gestartet
werden.
Der Status des ersten Starts lässt sich so prüfen:
```sh
ssh deploy@"$(tofu output -raw server_ipv4)" \
"cloud-init status --wait && systemctl status watermaps-volume-setup --no-pager"
```
+138
View File
@@ -0,0 +1,138 @@
#cloud-config
package_update: true
package_upgrade: false
groups:
- docker
users:
- default
- name: ${deploy_user}
gecos: Watermaps deployment user
shell: /bin/bash
lock_passwd: true
sudo: "ALL=(ALL) NOPASSWD:ALL"
groups:
- docker
- sudo
ssh_authorized_keys:
- ${ssh_public_key}
ssh_pwauth: false
packages:
- ca-certificates
- curl
- docker.io
- docker-compose-v2
- jq
- rsync
write_files:
- path: /etc/docker/daemon.json
owner: root:root
permissions: "0644"
content: |
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
- path: /usr/local/sbin/watermaps-prepare-volume
owner: root:root
permissions: "0755"
content: |
#!/usr/bin/env bash
set -euo pipefail
mount_path="/srv/watermaps-data"
install -d -m 0755 "$mount_path"
if ! mountpoint -q "$mount_path"; then
device=""
for attempt in $(seq 1 120); do
device="$(lsblk -dnpo NAME,MODEL | awk '$2 == "Volume" { print $1; exit }')"
if [ -n "$device" ] && [ -b "$device" ]; then
break
fi
device=""
sleep 5
done
if [ -z "$device" ]; then
echo "Kein angehängtes Hetzner Cloud Volume gefunden." >&2
exit 1
fi
filesystem="$(blkid -o value -s TYPE "$device" || true)"
if [ -z "$filesystem" ]; then
mkfs.ext4 -F "$device"
filesystem="ext4"
fi
if [ "$filesystem" != "ext4" ]; then
echo "Unerwartetes Dateisystem auf $device: $filesystem" >&2
exit 1
fi
uuid="$(blkid -o value -s UUID "$device")"
if ! grep -q "^UUID=$uuid " /etc/fstab; then
printf '%s\n' \
"UUID=$uuid $mount_path ext4 defaults,nofail,x-systemd.device-timeout=30 0 2" \
>> /etc/fstab
fi
mount "$mount_path"
fi
install -d -m 0755 -o ${deploy_user} -g ${deploy_user} \
"$mount_path/geofabrik" \
"$mount_path/local" \
"$mount_path/certbot" \
"$mount_path/certbot/www" \
"$mount_path/certbot/letsencrypt"
chown ${deploy_user}:${deploy_user} "$mount_path"
- path: /etc/systemd/system/watermaps-volume-setup.service
owner: root:root
permissions: "0644"
content: |
[Unit]
Description=Prepare persistent Watermaps routing data volume
Wants=network-online.target
After=network-online.target local-fs.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/watermaps-prepare-volume
RemainAfterExit=yes
Restart=on-failure
RestartSec=15
TimeoutStartSec=0
[Install]
WantedBy=multi-user.target
- path: /etc/systemd/system/docker.service.d/watermaps-volume.conf
owner: root:root
permissions: "0644"
content: |
[Unit]
Requires=watermaps-volume-setup.service
After=watermaps-volume-setup.service
runcmd:
- [install, -d, -m, "0755", -o, ${deploy_user}, -g, ${deploy_user}, /opt/watermaps]
- [install, -d, -m, "0755", -o, ${deploy_user}, -g, ${deploy_user}, /opt/watermaps/releases]
- [install, -d, -m, "0755", -o, ${deploy_user}, -g, ${deploy_user}, /opt/watermaps/shared]
- [install, -d, -m, "0755", -o, ${deploy_user}, -g, ${deploy_user}, /var/www/certbot]
- [systemctl, daemon-reload]
- [systemctl, enable, --now, watermaps-volume-setup.service]
- [systemctl, enable, docker.service]
- [systemctl, restart, docker.service]
final_message: "Watermaps host bootstrap completed after $UPTIME seconds."
+108
View File
@@ -0,0 +1,108 @@
locals {
common_labels = {
application = "watermaps"
managed_by = "opentofu"
}
}
resource "hcloud_ssh_key" "deploy" {
name = "${var.server_name}-deploy"
public_key = trimspace(var.ssh_public_key)
labels = local.common_labels
}
resource "hcloud_primary_ip" "main" {
name = "${var.server_name}-ipv4"
location = var.location
type = "ipv4"
auto_delete = false
delete_protection = var.enable_resource_protection
labels = local.common_labels
}
resource "hcloud_firewall" "main" {
name = "${var.server_name}-firewall"
labels = local.common_labels
rule {
description = "SSH from configured administrator networks"
direction = "in"
protocol = "tcp"
port = "22"
source_ips = var.admin_cidrs
}
rule {
description = "HTTP for ACME challenge and HTTPS redirect"
direction = "in"
protocol = "tcp"
port = "80"
source_ips = [
"0.0.0.0/0",
"::/0",
]
}
rule {
description = "HTTPS"
direction = "in"
protocol = "tcp"
port = "443"
source_ips = [
"0.0.0.0/0",
"::/0",
]
}
rule {
description = "ICMP diagnostics"
direction = "in"
protocol = "icmp"
source_ips = [
"0.0.0.0/0",
"::/0",
]
}
}
resource "hcloud_server" "main" {
name = var.server_name
image = "ubuntu-24.04"
server_type = var.server_type
location = var.location
ssh_keys = [hcloud_ssh_key.deploy.id]
firewall_ids = [hcloud_firewall.main.id]
user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
deploy_user = var.deploy_user
ssh_public_key = jsonencode(trimspace(var.ssh_public_key))
})
public_net {
ipv4_enabled = true
ipv4 = hcloud_primary_ip.main.id
ipv6_enabled = false
}
delete_protection = var.enable_resource_protection
rebuild_protection = var.enable_resource_protection
shutdown_before_deletion = true
labels = local.common_labels
}
resource "hcloud_volume" "routing_data" {
name = "${var.server_name}-routing-data"
location = var.location
size = var.routing_volume_size_gb
format = "ext4"
delete_protection = var.enable_resource_protection
labels = local.common_labels
}
resource "hcloud_volume_attachment" "routing_data" {
volume_id = hcloud_volume.routing_data.id
server_id = hcloud_server.main.id
automount = false
}
+33
View File
@@ -0,0 +1,33 @@
output "server_ipv4" {
description = "Persistente öffentliche IPv4-Adresse des Watermaps-Servers."
value = hcloud_primary_ip.main.ip_address
}
output "server_id" {
description = "Hetzner-ID des Servers."
value = hcloud_server.main.id
}
output "ssh_command" {
description = "SSH-Befehl für den vorbereiteten Deploy-Benutzer."
value = "ssh ${var.deploy_user}@${hcloud_primary_ip.main.ip_address}"
}
output "dns_a_record" {
description = "A-Record, der nach dem Apply manuell beim DNS-Provider angelegt werden soll."
value = {
type = "A"
name = var.dns_name
value = hcloud_primary_ip.main.ip_address
}
}
output "routing_data_mount_path" {
description = "Persistenter Pfad für heruntergeladene PBFs und erzeugte Fahrrouten."
value = "/srv/watermaps-data"
}
output "routing_volume_id" {
description = "Hetzner-ID des persistenten Routingdaten-Volumes."
value = hcloud_volume.routing_data.id
}
+3
View File
@@ -0,0 +1,3 @@
provider "hcloud" {
token = var.hcloud_token
}
+23
View File
@@ -0,0 +1,23 @@
# Diese Datei enthält ausschließlich Beispiele. Kopiere sie nach
# terraform.tfvars und ersetze alle REPLACE_*-Werte.
hcloud_token = "REPLACE_WITH_HETZNER_CLOUD_READ_WRITE_TOKEN"
# Inhalt einer öffentlichen .pub-Datei, niemals den privaten Schlüssel eintragen.
ssh_public_key = "ssh-ed25519 REPLACE_WITH_PUBLIC_KEY user@example"
# Auf deine aktuelle öffentliche IP begrenzen. Mehrere Netze sind möglich.
admin_cidrs = [
"203.0.113.10/32",
]
server_name = "watermaps"
dns_name = "watermaps.incoso.eu"
location = "fsn1"
server_type = "cx33"
deploy_user = "deploy"
routing_volume_size_gb = 40
# Schützt Ressourcen vor versehentlicher Löschung über Console/API.
enable_resource_protection = true
+122
View File
@@ -0,0 +1,122 @@
variable "hcloud_token" {
description = "Read/Write API-Token des Hetzner-Cloud-Projekts."
type = string
sensitive = true
nullable = false
validation {
condition = length(trimspace(var.hcloud_token)) == 64 && !startswith(var.hcloud_token, "REPLACE_")
error_message = "hcloud_token muss durch einen gültigen, 64 Zeichen langen Hetzner-Cloud-API-Token ersetzt werden."
}
}
variable "server_name" {
description = "Name des Hetzner-Servers und Präfix der zugehörigen Ressourcen."
type = string
default = "watermaps"
validation {
condition = can(regex("^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$", var.server_name))
error_message = "server_name muss ein gültiger Hostname aus Kleinbuchstaben, Ziffern und Bindestrichen sein."
}
}
variable "dns_name" {
description = "Öffentlicher DNS-Name. Der DNS-Eintrag selbst wird bewusst nicht von OpenTofu verwaltet."
type = string
default = "watermaps.incoso.eu"
validation {
condition = can(regex("^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$", var.dns_name))
error_message = "dns_name muss ein gültiger, vollständig qualifizierter DNS-Name sein."
}
}
variable "location" {
description = "Hetzner-Cloud-Location für Server, Primary IP und Volume."
type = string
default = "fsn1"
validation {
condition = length(trimspace(var.location)) > 0
error_message = "location darf nicht leer sein."
}
}
variable "server_type" {
description = "Hetzner-Cloud-Servertyp. cx33 ist für den initialen Routingdaten-Import konservativ gewählt."
type = string
default = "cx33"
validation {
condition = length(trimspace(var.server_type)) > 0
error_message = "server_type darf nicht leer sein."
}
}
variable "ssh_public_key" {
description = "Öffentlicher SSH-Schlüssel für root und den Deploy-Benutzer."
type = string
nullable = false
validation {
condition = (
!strcontains(var.ssh_public_key, "REPLACE_") &&
can(regex(
"^(ssh-(ed25519|rsa)|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh\\.com|sk-ecdsa-sha2-nistp256@openssh\\.com) ",
trimspace(var.ssh_public_key)
))
)
error_message = "ssh_public_key muss ein gültiger öffentlicher OpenSSH-Schlüssel sein."
}
}
variable "admin_cidrs" {
description = "IPv4- oder IPv6-CIDRs, aus denen SSH auf Port 22 erlaubt ist, zum Beispiel [\"203.0.113.10/32\"]."
type = list(string)
nullable = false
validation {
condition = (
length(var.admin_cidrs) > 0 &&
alltrue([for cidr in var.admin_cidrs : can(cidrhost(cidr, 0))]) &&
!contains(var.admin_cidrs, "203.0.113.10/32")
)
error_message = "admin_cidrs muss mindestens ein gültiges, echtes IPv4- oder IPv6-CIDR enthalten; die Beispieladresse muss ersetzt werden."
}
}
variable "deploy_user" {
description = "Unprivilegierter Benutzer für Upload und Betrieb der Anwendung."
type = string
default = "deploy"
validation {
condition = (
var.deploy_user != "root" &&
can(regex("^[a-z_][a-z0-9_-]{0,30}$", var.deploy_user))
)
error_message = "deploy_user muss ein gültiger Linux-Benutzername sein und darf nicht root heißen."
}
}
variable "routing_volume_size_gb" {
description = "Größe des persistenten ext4-Volumes für Deutschland-/Niederlande-PBFs und den Routingindex."
type = number
default = 40
validation {
condition = (
var.routing_volume_size_gb >= 10 &&
var.routing_volume_size_gb <= 10000 &&
floor(var.routing_volume_size_gb) == var.routing_volume_size_gb
)
error_message = "routing_volume_size_gb muss eine ganze Zahl zwischen 10 und 10000 sein."
}
}
variable "enable_resource_protection" {
description = "Aktiviert Hetzner-Löschschutz für Server, Primary IP und Volume."
type = bool
default = true
}
+10
View File
@@ -0,0 +1,10 @@
terraform {
required_version = ">= 1.8.0, < 2.0.0"
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "= 1.66.1"
}
}
}
+196 -47
View File
@@ -12,7 +12,7 @@
"packages/*" "packages/*"
], ],
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2", "concurrently": "^9.2.4",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vitest": "^3.2.4" "vitest": "^3.2.4"
}, },
@@ -25,8 +25,9 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fastify/cors": "^11.0.1", "@fastify/cors": "^11.0.1",
"@fastify/static": "^10.1.2",
"@watermaps/shared": "0.1.0", "@watermaps/shared": "0.1.0",
"fastify": "^5.4.0", "fastify": "^5.10.0",
"ioredis": "^5.6.1", "ioredis": "^5.6.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"zod": "^3.25.76" "zod": "^3.25.76"
@@ -2246,6 +2247,22 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@fastify/accept-negotiator": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
"integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/ajv-compiler": { "node_modules/@fastify/ajv-compiler": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
@@ -2377,6 +2394,71 @@
"ipaddr.js": "^2.1.0" "ipaddr.js": "^2.1.0"
} }
}, },
"node_modules/@fastify/send": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
"integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"escape-html": "~1.0.3",
"fast-decode-uri-component": "^1.0.1",
"http-errors": "^2.0.0",
"mime": "^3"
}
},
"node_modules/@fastify/static": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.2.tgz",
"integrity": "sha512-G/g18cG9tLutT/OVyN1AIsHIl9L1UwmJ+S3dkyhVpplIx0nEMicd7RGQ+uJLyhKKF4a3tTcQydccn3Mop1fX+Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/accept-negotiator": "^2.0.0",
"@fastify/error": "^4.0.0",
"@fastify/send": "^4.0.0",
"content-disposition": "^2.0.1",
"fastify-plugin": "^6.0.0",
"fastq": "^1.17.1",
"glob": "^13.0.0"
}
},
"node_modules/@fastify/static/node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@ioredis/commands": { "node_modules/@ioredis/commands": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz",
@@ -2454,6 +2536,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": { "node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
@@ -3731,7 +3822,6 @@
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
@@ -3754,7 +3844,6 @@
"version": "5.0.7", "version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
@@ -4004,15 +4093,15 @@
} }
}, },
"node_modules/concurrently": { "node_modules/concurrently": {
"version": "9.2.3", "version": "9.2.4",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
"integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==", "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chalk": "4.1.2", "chalk": "4.1.2",
"rxjs": "7.8.2", "rxjs": "7.8.2",
"shell-quote": "1.8.4", "shell-quote": "1.9.0",
"supports-color": "8.1.1", "supports-color": "8.1.1",
"tree-kill": "1.2.2", "tree-kill": "1.2.2",
"yargs": "17.7.2" "yargs": "17.7.2"
@@ -4028,6 +4117,19 @@
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1" "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
} }
}, },
"node_modules/content-disposition": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -4272,6 +4374,15 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dequal": { "node_modules/dequal": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -4570,6 +4681,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/estree-walker": { "node_modules/estree-walker": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -4666,9 +4783,9 @@
} }
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.3", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -4810,9 +4927,9 @@
} }
}, },
"node_modules/find-my-way": { "node_modules/find-my-way": {
"version": "9.6.0", "version": "9.7.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz",
"integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
@@ -5200,6 +5317,26 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/http-proxy-agent": { "node_modules/http-proxy-agent": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -5258,6 +5395,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internal-slot": { "node_modules/internal-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -6069,6 +6212,18 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/min-indent": { "node_modules/min-indent": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -6083,7 +6238,6 @@
"version": "10.2.5", "version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"brace-expansion": "^5.0.5" "brace-expansion": "^5.0.5"
@@ -6108,7 +6262,6 @@
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
@@ -6274,7 +6427,6 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"lru-cache": "^11.0.0", "lru-cache": "^11.0.0",
@@ -6291,7 +6443,6 @@
"version": "11.5.2", "version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": "20 || >=22" "node": "20 || >=22"
@@ -7215,6 +7366,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -7239,9 +7396,9 @@
} }
}, },
"node_modules/shell-quote": { "node_modules/shell-quote": {
"version": "1.8.4", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -7462,6 +7619,15 @@
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/std-env": { "node_modules/std-env": {
"version": "3.10.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
@@ -7848,6 +8014,15 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/tough-cookie": { "node_modules/tough-cookie": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
@@ -7923,7 +8098,6 @@
"os": [ "os": [
"aix" "aix"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -7941,7 +8115,6 @@
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -7959,7 +8132,6 @@
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -7977,7 +8149,6 @@
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -7995,7 +8166,6 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8013,7 +8183,6 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8031,7 +8200,6 @@
"os": [ "os": [
"freebsd" "freebsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8049,7 +8217,6 @@
"os": [ "os": [
"freebsd" "freebsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8067,7 +8234,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8085,7 +8251,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8103,7 +8268,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8121,7 +8285,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8139,7 +8302,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8157,7 +8319,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8175,7 +8336,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8193,7 +8353,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8211,7 +8370,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8229,7 +8387,6 @@
"os": [ "os": [
"netbsd" "netbsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8247,7 +8404,6 @@
"os": [ "os": [
"netbsd" "netbsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8265,7 +8421,6 @@
"os": [ "os": [
"openbsd" "openbsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8283,7 +8438,6 @@
"os": [ "os": [
"openbsd" "openbsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8301,7 +8455,6 @@
"os": [ "os": [
"openharmony" "openharmony"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8319,7 +8472,6 @@
"os": [ "os": [
"sunos" "sunos"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8337,7 +8489,6 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8355,7 +8506,6 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8373,7 +8523,6 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
+8 -2
View File
@@ -14,15 +14,21 @@
"build": "npm run build --workspace @watermaps/shared && npm run build --workspace @watermaps/api && npm run build --workspace @watermaps/web", "build": "npm run build --workspace @watermaps/shared && npm run build --workspace @watermaps/api && npm run build --workspace @watermaps/web",
"dev": "npm run build --workspace @watermaps/shared && concurrently -n api,web -c cyan,green \"npm run dev --workspace @watermaps/api\" \"npm run dev --workspace @watermaps/web\"", "dev": "npm run build --workspace @watermaps/shared && concurrently -n api,web -c cyan,green \"npm run dev --workspace @watermaps/api\" \"npm run dev --workspace @watermaps/web\"",
"dev:https": "npm run build --workspace @watermaps/shared && concurrently -n api,web -c cyan,green \"npm run dev --workspace @watermaps/api\" \"npm run dev:https --workspace @watermaps/web\"", "dev:https": "npm run build --workspace @watermaps/shared && concurrently -n api,web -c cyan,green \"npm run dev --workspace @watermaps/api\" \"npm run dev:https --workspace @watermaps/web\"",
"docker:down": "docker compose down",
"docker:up": "docker compose up -d --build watermaps",
"enrich:marine-search": "node scripts/enrich-marine-search.mjs", "enrich:marine-search": "node scripts/enrich-marine-search.mjs",
"enrich:marine-websites": "node scripts/enrich-marine-websites.mjs", "enrich:marine-websites": "node scripts/enrich-marine-websites.mjs",
"setup:local-germany": "./scripts/setup-local-germany.sh",
"setup:local-routing": "./scripts/setup-local-routing.sh",
"sync:euris-locks": "node scripts/sync-euris-locks.mjs", "sync:euris-locks": "node scripts/sync-euris-locks.mjs",
"test": "npm run build --workspace @watermaps/shared && npm run test --workspace @watermaps/shared && npm run test --workspace @watermaps/api && npm run test --workspace @watermaps/web", "test": "npm run build --workspace @watermaps/shared && npm run test --workspace @watermaps/shared && npm run test --workspace @watermaps/api && npm run test --workspace @watermaps/web && npm run test:local-routing && npm run test:deployment",
"test:deployment": "bash deploy/scripts/tests/route-data-helpers.test.sh && bash deploy/scripts/tests/update-route-data.test.sh",
"test:e2e": "npm run test:e2e --workspace @watermaps/web", "test:e2e": "npm run test:e2e --workspace @watermaps/web",
"test:local-routing": "PYTHONPATH=.tools/python python3 -m unittest discover -s scripts/tests -p 'test_*.py'",
"typecheck": "npm run build --workspace @watermaps/shared && npm run typecheck --workspace @watermaps/shared && npm run typecheck --workspace @watermaps/api && npm run typecheck --workspace @watermaps/web" "typecheck": "npm run build --workspace @watermaps/shared && npm run typecheck --workspace @watermaps/shared && npm run typecheck --workspace @watermaps/api && npm run typecheck --workspace @watermaps/web"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2", "concurrently": "^9.2.4",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }
+143 -23
View File
@@ -36,6 +36,12 @@ type EdgeSnap = {
distanceNm: number; distanceNm: number;
}; };
type EdgeSnapPair = {
componentId: string;
start: EdgeSnap;
destination: EdgeSnap;
};
export type FairwayGraph = { export type FairwayGraph = {
id: string; id: string;
name: string; name: string;
@@ -71,6 +77,7 @@ const ALTERNATIVE_EDGE_PENALTY = 8;
const MAX_ALTERNATIVE_DISTANCE_FACTOR = 1.75; const MAX_ALTERNATIVE_DISTANCE_FACTOR = 1.75;
const MIN_DIFFERENT_DISTANCE_NM = 0.25; const MIN_DIFFERENT_DISTANCE_NM = 0.25;
const MIN_DIFFERENT_DISTANCE_RATIO = 0.05; const MIN_DIFFERENT_DISTANCE_RATIO = 0.05;
const MAX_EDGE_SNAPS_PER_COMPONENT = 4;
const EMS_BORKUM_GRAPH: FairwayGraph = { const EMS_BORKUM_GRAPH: FairwayGraph = {
id: "ems-borkum-seed", id: "ems-borkum-seed",
@@ -211,12 +218,26 @@ export function buildFairwayRoutes(
return []; return [];
} }
const requestedPoints = [request.start, ...(request.waypoints ?? []), request.destination];
const componentByNode = weaklyConnectedComponents(routableGraph);
const legSnapPairs = requestedPoints.slice(0, -1).map((start, index) =>
findCompatibleEdgeSnapPairs(
routableGraph,
start,
requestedPoints[index + 1]!,
componentByNode
)
);
if (legSnapPairs.some((pairs) => pairs.length === 0)) {
return [];
}
const accepted: FairwayRouteCandidate[] = []; const accepted: FairwayRouteCandidate[] = [];
const penaltyCounts = new Map<string, number>(); const penaltyCounts = new Map<string, number>();
const maxAttempts = Math.max(8, routeLimit * 6); const maxAttempts = Math.max(8, routeLimit * 6);
for (let attempt = 0; attempt < maxAttempts && accepted.length < routeLimit; attempt += 1) { for (let attempt = 0; attempt < maxAttempts && accepted.length < routeLimit; attempt += 1) {
const candidate = buildFairwayRouteCandidate(request, routableGraph, penaltyCounts); const candidate = buildFairwayRouteCandidate(request, routableGraph, penaltyCounts, legSnapPairs);
if (!candidate) { if (!candidate) {
break; break;
} }
@@ -247,7 +268,8 @@ export function buildFairwayRoutes(
function buildFairwayRouteCandidate( function buildFairwayRouteCandidate(
request: RouteRequest, request: RouteRequest,
routableGraph: FairwayGraph, routableGraph: FairwayGraph,
penaltyCounts: ReadonlyMap<string, number> penaltyCounts: ReadonlyMap<string, number>,
legSnapPairs: EdgeSnapPair[][]
): FairwayRouteCandidate | null { ): FairwayRouteCandidate | null {
const requestedPoints = [request.start, ...(request.waypoints ?? []), request.destination]; const requestedPoints = [request.start, ...(request.waypoints ?? []), request.destination];
const routeCoordinates: Coordinate[] = []; const routeCoordinates: Coordinate[] = [];
@@ -257,7 +279,14 @@ function buildFairwayRouteCandidate(
for (let index = 0; index < requestedPoints.length - 1; index += 1) { for (let index = 0; index < requestedPoints.length - 1; index += 1) {
const legStart = requestedPoints[index]!; const legStart = requestedPoints[index]!;
const legDestination = requestedPoints[index + 1]!; const legDestination = requestedPoints[index + 1]!;
const leg = buildFairwayLeg(routableGraph, legStart, legDestination, penaltyCounts, adjacency); const leg = buildFairwayLeg(
routableGraph,
legStart,
legDestination,
penaltyCounts,
adjacency,
legSnapPairs[index]!
);
if (!leg) { if (!leg) {
return null; return null;
@@ -390,21 +419,17 @@ function buildFairwayLeg(
legStart: Coordinate, legStart: Coordinate,
legDestination: Coordinate, legDestination: Coordinate,
penaltyCounts: ReadonlyMap<string, number>, penaltyCounts: ReadonlyMap<string, number>,
adjacency: Adjacency adjacency: Adjacency,
snapPairs: EdgeSnapPair[]
): FairwayLeg | null { ): FairwayLeg | null {
const startSnap = findNearestEdgeSnap(graph, legStart);
const destinationSnap = findNearestEdgeSnap(graph, legDestination);
if (
!startSnap ||
!destinationSnap ||
startSnap.distanceNm > graph.maxSnapDistanceNm ||
destinationSnap.distanceNm > graph.maxSnapDistanceNm
) {
return null;
}
const candidates: FairwayLeg[] = []; const candidates: FairwayLeg[] = [];
const routedComponents = new Set<string>();
for (const { componentId, start: startSnap, destination: destinationSnap } of snapPairs) {
if (routedComponents.has(componentId)) {
continue;
}
const previousCandidateCount = candidates.length;
if (startSnap.edge.id === destinationSnap.edge.id && canTraverseBetweenSnaps(startSnap, destinationSnap)) { if (startSnap.edge.id === destinationSnap.edge.id && canTraverseBetweenSnaps(startSnap, destinationSnap)) {
const directOnEdge = edgePathBetweenSnaps(startSnap, destinationSnap); const directOnEdge = edgePathBetweenSnaps(startSnap, destinationSnap);
@@ -418,7 +443,10 @@ function buildFairwayLeg(
coordinates, coordinates,
usedEdges: [startSnap.edge], usedEdges: [startSnap.edge],
distanceNm: sumRouteDistanceNm(coordinates), distanceNm: sumRouteDistanceNm(coordinates),
costNm: sumRouteDistanceNm(coordinates) * edgePenaltyMultiplier(startSnap.edge, penaltyCounts) costNm:
startSnap.distanceNm +
sumRouteDistanceNm(directOnEdge) * edgePenaltyMultiplier(startSnap.edge, penaltyCounts) +
destinationSnap.distanceNm
}); });
} }
@@ -461,9 +489,11 @@ function buildFairwayLeg(
const distanceNm = sumRouteDistanceNm(coordinates); const distanceNm = sumRouteDistanceNm(coordinates);
const costNm = const costNm =
startSnap.distanceNm +
sumRouteDistanceNm(startEdgeCoordinates) * edgePenaltyMultiplier(startSnap.edge, penaltyCounts) + sumRouteDistanceNm(startEdgeCoordinates) * edgePenaltyMultiplier(startSnap.edge, penaltyCounts) +
path.reduce((total, step) => total + step.weightNm, 0) + path.reduce((total, step) => total + step.weightNm, 0) +
sumRouteDistanceNm(destinationEdgeCoordinates) * edgePenaltyMultiplier(destinationSnap.edge, penaltyCounts); sumRouteDistanceNm(destinationEdgeCoordinates) * edgePenaltyMultiplier(destinationSnap.edge, penaltyCounts) +
destinationSnap.distanceNm;
candidates.push({ candidates.push({
coordinates, coordinates,
usedEdges: [...usedEdges.values()], usedEdges: [...usedEdges.values()],
@@ -473,21 +503,69 @@ function buildFairwayLeg(
} }
} }
if (candidates.length > previousCandidateCount) {
routedComponents.add(componentId);
}
}
return candidates.sort((a, b) => a.costNm - b.costNm || a.distanceNm - b.distanceNm)[0] ?? null; return candidates.sort((a, b) => a.costNm - b.costNm || a.distanceNm - b.distanceNm)[0] ?? null;
} }
function findNearestEdgeSnap(graph: FairwayGraph, coordinate: Coordinate): EdgeSnap | null { function findCompatibleEdgeSnapPairs(
let nearest: EdgeSnap | null = null; graph: FairwayGraph,
start: Coordinate,
destination: Coordinate,
componentByNode: ReadonlyMap<string, string>
): EdgeSnapPair[] {
const startSnaps = findNearestEdgeSnapsByComponent(graph, start, componentByNode);
const destinationSnaps = findNearestEdgeSnapsByComponent(graph, destination, componentByNode);
const pairs: EdgeSnapPair[] = [];
for (const [componentId, componentStartSnaps] of startSnaps) {
const componentDestinationSnaps = destinationSnaps.get(componentId);
if (!componentDestinationSnaps) {
continue;
}
for (const startSnap of componentStartSnaps) {
for (const destinationSnap of componentDestinationSnaps) {
pairs.push({ componentId, start: startSnap, destination: destinationSnap });
}
}
}
return pairs.sort(
(a, b) =>
a.start.distanceNm + a.destination.distanceNm -
(b.start.distanceNm + b.destination.distanceNm)
);
}
function findNearestEdgeSnapsByComponent(
graph: FairwayGraph,
coordinate: Coordinate,
componentByNode: ReadonlyMap<string, string>
): Map<string, EdgeSnap[]> {
const nearestByComponent = new Map<string, EdgeSnap[]>();
for (const edge of graph.edges) { for (const edge of graph.edges) {
const componentId = componentByNode.get(edge.from);
if (!componentId) {
continue;
}
let nearestOnEdge: EdgeSnap | null = null;
for (let index = 0; index < edge.coordinates.length - 1; index += 1) { for (let index = 0; index < edge.coordinates.length - 1; index += 1) {
const start = edge.coordinates[index]!; const start = edge.coordinates[index]!;
const end = edge.coordinates[index + 1]!; const end = edge.coordinates[index + 1]!;
const snap = closestPointOnSegment(coordinate, start, end); const snap = closestPointOnSegment(coordinate, start, end);
const distanceNm = haversineDistanceNm(coordinate, snap.coordinate); const distanceNm = haversineDistanceNm(coordinate, snap.coordinate);
if (!nearest || distanceNm < nearest.distanceNm) { if (
nearest = { distanceNm <= graph.maxSnapDistanceNm &&
(!nearestOnEdge || distanceNm < nearestOnEdge.distanceNm)
) {
nearestOnEdge = {
edge, edge,
coordinate: snap.coordinate, coordinate: snap.coordinate,
segmentIndex: index, segmentIndex: index,
@@ -496,9 +574,51 @@ function findNearestEdgeSnap(graph: FairwayGraph, coordinate: Coordinate): EdgeS
}; };
} }
} }
if (nearestOnEdge) {
const componentSnaps = nearestByComponent.get(componentId) ?? [];
componentSnaps.push(nearestOnEdge);
componentSnaps.sort((a, b) => a.distanceNm - b.distanceNm);
if (componentSnaps.length > MAX_EDGE_SNAPS_PER_COMPONENT) {
componentSnaps.length = MAX_EDGE_SNAPS_PER_COMPONENT;
}
nearestByComponent.set(componentId, componentSnaps);
}
} }
return nearest; return nearestByComponent;
}
function weaklyConnectedComponents(graph: FairwayGraph): Map<string, string> {
const neighbours = new Map<string, string[]>();
for (const edge of graph.edges) {
neighbours.set(edge.from, [...(neighbours.get(edge.from) ?? []), edge.to]);
neighbours.set(edge.to, [...(neighbours.get(edge.to) ?? []), edge.from]);
}
const componentByNode = new Map<string, string>();
for (const startNodeId of neighbours.keys()) {
if (componentByNode.has(startNodeId)) {
continue;
}
const componentId = startNodeId;
const pending = [startNodeId];
componentByNode.set(startNodeId, componentId);
while (pending.length > 0) {
const nodeId = pending.pop()!;
for (const neighbourId of neighbours.get(nodeId) ?? []) {
if (!componentByNode.has(neighbourId)) {
componentByNode.set(neighbourId, componentId);
pending.push(neighbourId);
}
}
}
}
return componentByNode;
} }
function shortestPath( function shortestPath(
+55
View File
@@ -517,3 +517,58 @@ export const EMDEN_HAMM_GRAPH: FairwayGraph = {
} }
] ]
}; };
/**
* Offline fallback for the eastern Ems corridor from Emden Außenhafen towards
* the lower Ems. The geometry is a simplified extract of OSM/Geofabrik data
* (ODbL). Runtime PostGIS/OSM graphs take precedence whenever available.
*/
export const EMDEN_EAST_EMS_GRAPH: FairwayGraph = {
id: "emden-east-ems-seed",
name: "Emden Außenhafen Unterems",
maxSnapDistanceNm: 0.6,
nodes: [
{ id: "emden-east-start", coordinate: { lat: 53.3422, lon: 7.1871 } },
{ id: "emden-east-destination", coordinate: { lat: 53.4650304, lon: 7.4733641 } }
],
edges: [
{
id: "emden-east-ems",
name: "Unterems östlich von Emden",
from: "emden-east-start",
to: "emden-east-destination",
minDepthM: null,
source: "openstreetmap-geofabrik-curated-seed",
coordinates: [
{ lat: 53.3422, lon: 7.1871 },
{ lat: 53.3473059, lon: 7.1911316 },
{ lat: 53.3606114, lon: 7.2037359 },
{ lat: 53.3644551, lon: 7.2082274 },
{ lat: 53.3661998, lon: 7.21042 },
{ lat: 53.3666164, lon: 7.2167288 },
{ lat: 53.3678346, lon: 7.2249094 },
{ lat: 53.3691393, lon: 7.2329392 },
{ lat: 53.3707491, lon: 7.2378392 },
{ lat: 53.373502, lon: 7.2424648 },
{ lat: 53.3759113, lon: 7.2504687 },
{ lat: 53.3764243, lon: 7.2562576 },
{ lat: 53.3799364, lon: 7.2603821 },
{ lat: 53.3834496, lon: 7.2598983 },
{ lat: 53.3874288, lon: 7.2662906 },
{ lat: 53.3920681, lon: 7.2704136 },
{ lat: 53.395175, lon: 7.2798374 },
{ lat: 53.3987926, lon: 7.2914857 },
{ lat: 53.4001184, lon: 7.3043088 },
{ lat: 53.4023614, lon: 7.3192701 },
{ lat: 53.4079972, lon: 7.3347423 },
{ lat: 53.414927, lon: 7.3450734 },
{ lat: 53.420342, lon: 7.3654351 },
{ lat: 53.4267192, lon: 7.3910633 },
{ lat: 53.4359646, lon: 7.420754 },
{ lat: 53.4504899, lon: 7.4518959 },
{ lat: 53.4630916, lon: 7.4715145 },
{ lat: 53.4650304, lon: 7.4733641 }
]
}
]
};
+6 -2
View File
@@ -1,6 +1,6 @@
import { coordinateToGeoJson, sumRouteDistanceNm } from "./geo.js"; import { coordinateToGeoJson, sumRouteDistanceNm } from "./geo.js";
import { buildFairwayRoute, type FairwayGraph } from "./fairway-routing.js"; import { buildFairwayRoute, type FairwayGraph } from "./fairway-routing.js";
import { EMDEN_HAMM_GRAPH } from "./inland-seed.js"; import { EMDEN_EAST_EMS_GRAPH, EMDEN_HAMM_GRAPH } from "./inland-seed.js";
import type { import type {
DepthSample, DepthSample,
RouteRequest, RouteRequest,
@@ -16,7 +16,11 @@ export function buildRoute(request: RouteRequest, graph?: FairwayGraph): RouteRe
return buildFairwayRoute(request, graph); return buildFairwayRoute(request, graph);
} }
return buildFairwayRoute(request) ?? buildFairwayRoute(request, EMDEN_HAMM_GRAPH); return (
buildFairwayRoute(request) ??
buildFairwayRoute(request, EMDEN_EAST_EMS_GRAPH) ??
buildFairwayRoute(request, EMDEN_HAMM_GRAPH)
);
} }
export function requiredDepthM(profile: VesselProfile): number { export function requiredDepthM(profile: VesselProfile): number {
+120
View File
@@ -40,6 +40,44 @@ const ALTERNATIVE_GRAPH: FairwayGraph = {
] ]
}; };
const COMPONENT_AWARE_SNAP_GRAPH: FairwayGraph = {
id: "component-aware-snap-test",
name: "Komponentenbewusster Snap-Test",
maxSnapDistanceNm: 0.3,
nodes: [
{ id: "start-decoy-a", coordinate: { lat: 53.3416, lon: 7.186 } },
{ id: "start-decoy-b", coordinate: { lat: 53.342, lon: 7.187 } },
{ id: "destination-decoy-a", coordinate: { lat: 53.3282, lon: 6.9304 } },
{ id: "destination-decoy-b", coordinate: { lat: 53.3286, lon: 6.9294 } },
{ id: "shared-start", coordinate: { lat: 53.3395697, lon: 7.1848883 } },
{ id: "shared-east", coordinate: { lat: 53.3321722, lon: 7.1329034 } },
{ id: "shared-south", coordinate: { lat: 53.313849, lon: 7.0011017 } },
{ id: "shared-destination", coordinate: { lat: 53.3303531, lon: 6.9334715 } }
],
edges: [
edge("start-decoy", "start-decoy-a", "start-decoy-b", [
{ lat: 53.3416, lon: 7.186 },
{ lat: 53.342, lon: 7.187 }
], { source: "closer-but-disconnected-start" }),
edge("destination-decoy", "destination-decoy-a", "destination-decoy-b", [
{ lat: 53.3282, lon: 6.9304 },
{ lat: 53.3286, lon: 6.9294 }
], { source: "closer-but-disconnected-destination" }),
edge("shared-east", "shared-start", "shared-east", [
{ lat: 53.3395697, lon: 7.1848883 },
{ lat: 53.3321722, lon: 7.1329034 }
], { source: "shared-local-component" }),
edge("shared-south", "shared-east", "shared-south", [
{ lat: 53.3321722, lon: 7.1329034 },
{ lat: 53.313849, lon: 7.0011017 }
], { source: "shared-local-component" }),
edge("shared-west", "shared-south", "shared-destination", [
{ lat: 53.313849, lon: 7.0011017 },
{ lat: 53.3303531, lon: 6.9334715 }
], { source: "shared-local-component" })
]
};
function edge( function edge(
id: string, id: string,
from: string, from: string,
@@ -162,6 +200,88 @@ describe("route assessment", () => {
expect(result.dataSources).toContain("fairway-graph:ems-borkum-seed"); expect(result.dataSources).toContain("fairway-graph:ems-borkum-seed");
}); });
it("uses a shared reachable component when the individually nearest edges are disconnected", () => {
const start = { lat: 53.3416, lon: 7.186 };
const destination = { lat: 53.3282, lon: 6.9304 };
const routes = buildFairwayRoutes(
{
start,
destination,
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
},
COMPONENT_AWARE_SNAP_GRAPH
);
expect(routes).toHaveLength(1);
expect(routes[0]?.geometry.coordinates[0]).toEqual([start.lon, start.lat]);
expect(routes[0]?.geometry.coordinates.at(-1)).toEqual([destination.lon, destination.lat]);
expect(routes[0]?.dataSources).toContain("shared-local-component");
expect(routes[0]?.dataSources).not.toContain("closer-but-disconnected-start");
expect(routes[0]?.dataSources).not.toContain("closer-but-disconnected-destination");
expect(routes[0]?.distanceNm).toBeGreaterThan(9.5);
expect(routes[0]?.distanceNm).toBeLessThan(10.5);
});
it("returns no route when start and destination have no shared component inside the snap radius", () => {
const disconnectedGraph: FairwayGraph = {
...COMPONENT_AWARE_SNAP_GRAPH,
nodes: COMPONENT_AWARE_SNAP_GRAPH.nodes.slice(0, 4),
edges: COMPONENT_AWARE_SNAP_GRAPH.edges.slice(0, 2)
};
expect(
buildFairwayRoute(
{
start: { lat: 53.3416, lon: 7.186 },
destination: { lat: 53.3282, lon: 6.9304 },
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
},
disconnectedGraph
)
).toBeNull();
});
it("tries another snap in the same component when the nearest one-way branch cannot be exited", () => {
const graph: FairwayGraph = {
id: "oneway-snap-fallback",
name: "Einbahnstraßen-Snap-Fallback",
maxSnapDistanceNm: 0.2,
nodes: [
{ id: "junction", coordinate: { lat: 52, lon: 7 } },
{ id: "destination", coordinate: { lat: 52, lon: 7.04 } },
{ id: "oneway-dead-end", coordinate: { lat: 52.001, lon: 7 } }
],
edges: [
edge(
"oneway-trap",
"junction",
"oneway-dead-end",
[{ lat: 52, lon: 7 }, { lat: 52.001, lon: 7 }],
{ oneway: true, source: "oneway-trap" }
),
edge(
"main-route",
"junction",
"destination",
[{ lat: 52, lon: 7 }, { lat: 52, lon: 7.04 }],
{ source: "routable-main-edge" }
)
]
};
const route = buildFairwayRoute(
{
start: { lat: 52.001, lon: 7 },
destination: { lat: 52, lon: 7.04 },
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
},
graph
);
expect(route).not.toBeNull();
expect(route?.dataSources).toContain("routable-main-edge");
expect(route?.dataSources).not.toContain("oneway-trap");
});
it("does not fall back to a misleading straight line when no fairway graph matches", () => { it("does not fall back to a misleading straight line when no fairway graph matches", () => {
const result = buildRoute({ const result = buildRoute({
start: { lat: 54.1749, lon: 12.0731 }, start: { lat: 54.1749, lon: 12.0731 },
+392
View File
@@ -0,0 +1,392 @@
#!/usr/bin/env python3
"""Build a compact, file-backed fairway dataset from Geofabrik OSM PBFs.
The importer intentionally uses two streaming passes over every source. The
first pass retains only routable marine/inland ways and their node IDs; the
second retains only coordinates referenced by those ways. Coordinates and
ways shared by neighbouring extracts are merged by their globally unique OSM
IDs. This avoids loading complete OSM node tables into memory and does not
require PostGIS or Docker.
"""
from __future__ import annotations
import argparse
from collections import defaultdict
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import re
import sys
import tempfile
from typing import Any
try:
import osmium
except ImportError as error:
raise SystemExit(
"pyosmium fehlt. Installiere es mit "
"`python3 -m pip install --target .tools/python 'osmium>=4,<5'` "
"und starte mit `PYTHONPATH=.tools/python`."
) from error
KEPT_TAGS = {
"access",
"boat",
"construction",
"depth",
"disused",
"maxdraft",
"maxdraught",
"maxheight",
"maxheight:physical",
"maxwidth",
"maxwidth:physical",
"min_depth",
"motor_vehicle",
"motorboat",
"name",
"oneway",
"proposed",
"ref",
"route",
"seamark:bridge:clearance_height",
"seamark:bridge:clearance_height_safe",
"seamark:fairway:minimum_depth",
"seamark:lock:chamber_width",
"seamark:navigation_line:minimum_depth",
"seamark:recommended_track:minimum_depth",
"seamark:restriction:max_draught",
"seamark:type",
"ship",
"waterway",
}
GENERATOR_VERSION = 2
def is_routable(tags: dict[str, str]) -> bool:
if (
tags.get("access") in {"no", "private"}
or tags.get("boat") == "no"
or tags.get("ship") == "no"
or tags.get("motorboat") == "no"
or tags.get("disused") == "yes"
or "construction" in tags
or "proposed" in tags
):
return False
seamark_type = tags.get("seamark:type")
if seamark_type in {"navigation_line", "recommended_track"}:
return True
if tags.get("waterway") in {"fairway", "canal"}:
return True
if tags.get("waterway") == "river" and any(
tags.get(key) in {"yes", "designated", "permissive"}
for key in ("boat", "ship", "motorboat")
):
return True
if (
tags.get("route") == "ferry"
and tags.get("ship") != "no"
and tags.get("motor_vehicle") != "no"
):
return True
return seamark_type == "fairway"
class WayCollector(osmium.SimpleHandler):
def __init__(self) -> None:
super().__init__()
self.ways: list[dict[str, Any]] = []
self.node_ids: set[int] = set()
def way(self, way: Any) -> None:
tags = {tag.k: tag.v for tag in way.tags}
if not is_routable(tags):
return
node_ids = [node.ref for node in way.nodes]
if len(node_ids) < 2:
return
self.node_ids.update(node_ids)
self.ways.append(
{
"id": str(way.id),
"version": int(getattr(way, "version", 0) or 0),
"nodes": node_ids,
"tags": {key: value for key, value in tags.items() if key in KEPT_TAGS},
}
)
class NodeCollector(osmium.SimpleHandler):
def __init__(self, wanted: set[int]) -> None:
super().__init__()
self.wanted = wanted
self.coordinates: dict[int, tuple[int, float, float]] = {}
def node(self, node: Any) -> None:
if node.id not in self.wanted or not node.location.valid():
return
candidate = (
int(getattr(node, "version", 0) or 0),
node.location.lat,
node.location.lon,
)
current = self.coordinates.get(node.id)
if current is None or candidate > current:
self.coordinates[node.id] = candidate
def way_bbox(coordinates: list[list[float]]) -> list[float]:
lats = [coordinate[0] for coordinate in coordinates]
lons = [coordinate[1] for coordinate in coordinates]
return [min(lons), min(lats), max(lons), max(lats)]
def source_region(path: Path) -> str:
name = path.name.lower()
for suffix in ("-latest.osm.pbf", ".osm.pbf", ".pbf"):
if name.endswith(suffix):
name = name[: -len(suffix)]
break
region = re.sub(r"[^a-z0-9]+", "-", name).strip("-")
return region or "unknown"
def adjacent_md5(path: Path) -> str | None:
checksum_path = Path(f"{path}.md5")
if not checksum_path.is_file():
return None
fields = checksum_path.read_text(encoding="utf-8").split(maxsplit=1)
if not fields:
return None
checksum = fields[0].lower()
return checksum if re.fullmatch(r"[0-9a-f]{32}", checksum) else None
def candidate_rank(candidate: dict[str, Any]) -> tuple[int, int, str]:
"""Return a deterministic preference for duplicate versions of an OSM way."""
fingerprint = hashlib.sha256(
json.dumps(
{
"nodes": candidate["nodes"],
"tags": candidate["tags"],
"coordinates": candidate["coordinates"],
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
return candidate["version"], len(candidate["nodes"]), fingerprint
def build_document(pbf_paths: list[Path]) -> dict[str, Any]:
sources: list[dict[str, Any]] = []
wanted_node_ids: set[int] = set()
for index, path in enumerate(pbf_paths, start=1):
print(
f"Pass 1/2 [{index}/{len(pbf_paths)}]: routbare Wege aus {path} lesen",
flush=True,
)
collector = WayCollector()
processor = osmium.FileProcessor(
str(path), osmium.osm.WAY
).with_filter(osmium.filter.KeyFilter("seamark:type", "waterway", "route"))
for way in processor:
collector.way(way)
wanted_node_ids.update(collector.node_ids)
stat = path.stat()
sources.append(
{
"path": path,
"region": source_region(path),
"collector": collector,
"metadata": {
"region": source_region(path),
"file": path.name,
"sizeBytes": stat.st_size,
"modifiedAt": datetime.fromtimestamp(
stat.st_mtime, timezone.utc
).isoformat(),
"checksumMd5": adjacent_md5(path),
"routableWaysFound": len(collector.ways),
"referencedNodes": len(collector.node_ids),
"nodeCoordinatesFound": 0,
"exportedCandidateWays": 0,
"incompleteWaysSkipped": 0,
"closedFairwaysSkipped": 0,
},
}
)
print(
f"{len(collector.ways)} Wege mit "
f"{len(collector.node_ids)} referenzierten Knoten gefunden",
flush=True,
)
global_coordinates: dict[int, tuple[int, float, float]] = {}
coordinate_conflicts = 0
for index, source in enumerate(sources, start=1):
print(
f"Pass 2/2 [{index}/{len(sources)}]: benötigte Knotenkoordinaten "
f"aus {source['path']} lesen",
flush=True,
)
nodes = NodeCollector(wanted_node_ids)
if wanted_node_ids:
processor = osmium.FileProcessor(
str(source["path"]), osmium.osm.NODE
).with_filter(osmium.filter.IdFilter(wanted_node_ids))
for node in processor:
nodes.node(node)
source["metadata"]["nodeCoordinatesFound"] = len(nodes.coordinates)
for node_id, candidate in nodes.coordinates.items():
current = global_coordinates.get(node_id)
if current is not None and current[1:] != candidate[1:]:
coordinate_conflicts += 1
if current is None or candidate > current:
global_coordinates[node_id] = candidate
candidates_by_id: dict[str, list[dict[str, Any]]] = defaultdict(list)
for source in sources:
for way in source["collector"].ways:
if any(node_id not in global_coordinates for node_id in way["nodes"]):
source["metadata"]["incompleteWaysSkipped"] += 1
continue
coordinates = [
[global_coordinates[node_id][1], global_coordinates[node_id][2]]
for node_id in way["nodes"]
]
if (
way["tags"].get("seamark:type") == "fairway"
and coordinates[0] == coordinates[-1]
):
source["metadata"]["closedFairwaysSkipped"] += 1
continue
source["metadata"]["exportedCandidateWays"] += 1
candidates_by_id[way["id"]].append(
{
**way,
"coordinates": coordinates,
"region": source["region"],
"sourceFile": source["path"].name,
}
)
exported: list[dict[str, Any]] = []
duplicate_ways_merged = 0
for way_id, candidates in candidates_by_id.items():
winner = max(candidates, key=candidate_rank)
duplicate_ways_merged += len(candidates) - 1
exported.append(
{
"id": way_id,
"osmVersion": winner["version"],
"regions": sorted({candidate["region"] for candidate in candidates}),
"sourceFiles": sorted(
{candidate["sourceFile"] for candidate in candidates}
),
"bbox": way_bbox(winner["coordinates"]),
"tags": winner["tags"],
"coordinates": winner["coordinates"],
}
)
exported.sort(key=lambda way: int(way["id"]))
source_metadata = [source["metadata"] for source in sources]
regions = list(dict.fromkeys(source["region"] for source in sources))
source_label = (
pbf_paths[0].name if len(pbf_paths) == 1 else "+".join(regions)
)
modified_at = max(path.stat().st_mtime for path in pbf_paths)
return {
"version": 1,
"generatorVersion": GENERATOR_VERSION,
"source": source_label,
"sources": source_metadata,
"sourceSizeBytes": sum(path.stat().st_size for path in pbf_paths),
"sourceModifiedAt": datetime.fromtimestamp(
modified_at, timezone.utc
).isoformat(),
"generatedAt": datetime.now(timezone.utc).isoformat(),
"incompleteWaysSkipped": sum(
source["metadata"]["incompleteWaysSkipped"] for source in sources
),
"closedFairwaysSkipped": sum(
source["metadata"]["closedFairwaysSkipped"] for source in sources
),
"duplicateWaysMerged": duplicate_ways_merged,
"nodeCoordinateConflicts": coordinate_conflicts,
"ways": exported,
}
def write_document(document: dict[str, Any], output: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
dir=output.parent,
prefix=f".{output.name}.",
suffix=".tmp",
delete=False,
) as temporary:
json.dump(document, temporary, ensure_ascii=False, separators=(",", ":"))
temporary.write("\n")
temporary_path = Path(temporary.name)
temporary_path.chmod(0o644)
os.replace(temporary_path, output)
except BaseException:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
raise
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("pbf", type=Path, nargs="+")
parser.add_argument(
"--output",
type=Path,
default=Path("data/local/germany-netherlands-fairways.json"),
)
args = parser.parse_args()
pbf_paths: list[Path] = []
seen_paths: set[Path] = set()
for path in args.pbf:
if not path.is_file():
parser.error(f"PBF nicht gefunden: {path}")
resolved = path.resolve()
if resolved in seen_paths:
parser.error(f"PBF doppelt angegeben: {path}")
seen_paths.add(resolved)
pbf_paths.append(path)
document = build_document(pbf_paths)
write_document(document, args.output)
print(
f"{len(document['ways'])} Wege nach {args.output} geschrieben "
f"({document['duplicateWaysMerged']} Duplikate zusammengeführt; "
f"{document['incompleteWaysSkipped']} unvollständige Wege verworfen; "
f"{args.output.stat().st_size / 1024 / 1024:.1f} MiB)",
flush=True,
)
return 0
if __name__ == "__main__":
sys.exit(main())
+104 -8
View File
@@ -29,23 +29,119 @@ for region in "${regions[@]}"; do
esac esac
target="${OUT_DIR}/${file_name}" target="${OUT_DIR}/${file_name}"
checksum_target="${target}.md5" checksum_target="${target}.md5"
checksum_download="${checksum_target}.part"
download_part="${target}.part"
download_part_checksum="${download_part}.expected-md5"
echo "Downloading $url.md5" # Resolve the `latest` PBF redirect first and fetch the checksum belonging to
curl --fail --location --retry 5 --retry-delay 5 --output "$checksum_target" "${url}.md5" # that exact dated snapshot. This prevents transparent download proxies from
# combining a fresh PBF redirect with a stale `latest` checksum.
resolved_url="$(
curl \
--fail \
--head \
--location \
--silent \
--show-error \
--retry 5 \
--retry-all-errors \
--retry-delay 5 \
--output /dev/null \
--write-out '%{url_effective}' \
"$url"
)"
resolved_url="${resolved_url%%\?*}"
resolved_file_name="${resolved_url##*/}"
if [[ "$resolved_file_name" =~ ^[a-z0-9-]+-[0-9]{6}\.osm\.pbf$ ]]; then
download_url="$resolved_url"
else
download_url="$url"
fi
checksum_url="${download_url}.md5"
echo "Downloading $checksum_url"
curl \
--fail \
--location \
--silent \
--show-error \
--retry 5 \
--retry-all-errors \
--retry-delay 5 \
--output "$checksum_download" \
"$checksum_url"
expected_checksum="$(awk 'NR == 1 { print tolower($1) }' "$checksum_download")"
if [[ ! "$expected_checksum" =~ ^[0-9a-f]{32}$ ]]; then
echo "Invalid checksum response for $url" >&2
exit 1
fi
checksum_file_name="$(awk 'NR == 1 { name = $2; sub(/^\*/, "", name); print name }' "$checksum_download")"
if [[ "$checksum_file_name" =~ ^[a-z0-9-]+-[0-9]{6}\.osm\.pbf$ ]] &&
[[ "$checksum_file_name" != "${download_url##*/}" ]]; then
echo "Checksum file does not match resolved snapshot: $checksum_file_name" >&2
exit 1
fi
expected_checksum="$(awk 'NR == 1 { print $1 }' "$checksum_target")"
if [[ -f "$target" ]] && [[ "$(md5sum "$target" | awk '{ print $1 }')" == "$expected_checksum" ]]; then if [[ -f "$target" ]] && [[ "$(md5sum "$target" | awk '{ print $1 }')" == "$expected_checksum" ]]; then
mv -f "$checksum_download" "$checksum_target"
rm -f "$download_part" "$download_part_checksum"
echo "$(basename "$target"): already current" echo "$(basename "$target"): already current"
continue continue
fi fi
echo "Downloading $url" previous_part_checksum=""
curl --fail --location --continue-at - --retry 5 --retry-delay 5 --output "$target" "$url" if [[ -f "$download_part_checksum" ]]; then
previous_part_checksum="$(awk 'NR == 1 { print tolower($1) }' "$download_part_checksum")"
fi
if [[ "$previous_part_checksum" != "$expected_checksum" ]]; then
rm -f "$download_part"
fi
printf '%s\n' "$expected_checksum" >"$download_part_checksum"
actual_checksum="$(md5sum "$target" | awk '{ print $1 }')" part_is_complete=false
if [[ -z "$expected_checksum" || "$actual_checksum" != "$expected_checksum" ]]; then if [[ -f "$download_part" ]] && [[ "$(md5sum "$download_part" | awk '{ print $1 }')" == "$expected_checksum" ]]; then
echo "Checksum verification failed for $target" >&2 part_is_complete=true
fi
if [[ "$part_is_complete" == false ]]; then
echo "Downloading $download_url"
if ! curl \
--fail \
--location \
--silent \
--show-error \
--continue-at - \
--retry 5 \
--retry-all-errors \
--retry-delay 5 \
--output "$download_part" \
"$download_url"; then
echo "Resuming failed; retrying $download_url from the beginning" >&2
rm -f "$download_part"
curl \
--fail \
--location \
--silent \
--show-error \
--retry 5 \
--retry-all-errors \
--retry-delay 5 \
--output "$download_part" \
"$download_url"
fi
fi
actual_checksum="$(md5sum "$download_part" | awk '{ print $1 }')"
if [[ "$actual_checksum" != "$expected_checksum" ]]; then
rm -f "$download_part" "$download_part_checksum"
echo "Checksum verification failed for $url; previous snapshot retained" >&2
exit 1 exit 1
fi fi
mv -f "$download_part" "$target"
mv -f "$checksum_download" "$checksum_target"
rm -f "$download_part_checksum"
echo "$(basename "$target"): OK" echo "$(basename "$target"): OK"
done done
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
echo "Hinweis: setup-local-germany.sh ist veraltet; richte Deutschland und Niederlande gemeinsam ein." >&2
exec "$ROOT_DIR/scripts/setup-local-routing.sh" "$@"
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GEOFABRIK_DIR="${WATERMAPS_GEOFABRIK_DIR:-${SEA_COMPASS_GEOFABRIK_DIR:-data/geofabrik}}"
PYTHON_TARGET="$ROOT_DIR/.tools/python"
OUTPUT_PATH="${WATERMAPS_LOCAL_FAIRWAYS_PATH:-$ROOT_DIR/data/local/germany-netherlands-fairways.json}"
if [[ "$GEOFABRIK_DIR" != /* ]]; then
GEOFABRIK_DIR="$ROOT_DIR/$GEOFABRIK_DIR"
fi
if [[ "$OUTPUT_PATH" != /* ]]; then
OUTPUT_PATH="$ROOT_DIR/$OUTPUT_PATH"
fi
PBF_PATHS=(
"$GEOFABRIK_DIR/germany-latest.osm.pbf"
"$GEOFABRIK_DIR/netherlands-latest.osm.pbf"
)
cd "$ROOT_DIR"
# This validates existing snapshots and only replaces them after a complete,
# checksum-verified download.
WATERMAPS_GEOFABRIK_DIR="$GEOFABRIK_DIR" \
./scripts/download-geofabrik.sh germany netherlands
# A successful previous build can be reused when both downloaded snapshots
# still match the checksums recorded in its per-source metadata.
if python3 - "$OUTPUT_PATH" "${PBF_PATHS[@]}" <<'PY'
import json
from pathlib import Path
import re
import sys
output = Path(sys.argv[1])
pbf_paths = [Path(value) for value in sys.argv[2:]]
try:
document = json.loads(output.read_text(encoding="utf-8"))
sources = {
source["file"]: source
for source in document["sources"]
if isinstance(source, dict) and isinstance(source.get("file"), str)
}
if (
document.get("version") != 1
or document.get("generatorVersion") != 2
or not isinstance(document.get("ways"), list)
):
raise ValueError("unsupported index format")
for pbf_path in pbf_paths:
checksum_text = Path(f"{pbf_path}.md5").read_text(encoding="utf-8")
checksum = checksum_text.split(maxsplit=1)[0].lower()
metadata = sources[pbf_path.name]
if not re.fullmatch(r"[0-9a-f]{32}", checksum):
raise ValueError("invalid checksum")
if metadata.get("checksumMd5") != checksum:
raise ValueError("snapshot changed")
if metadata.get("sizeBytes") != pbf_path.stat().st_size:
raise ValueError("snapshot size changed")
except (
FileNotFoundError,
IndexError,
KeyError,
TypeError,
ValueError,
json.JSONDecodeError,
):
raise SystemExit(1)
PY
then
echo "Lokaler Deutschland-/Niederlande-Routingindex ist bereits aktuell: $OUTPUT_PATH"
exit 0
fi
mkdir -p "$PYTHON_TARGET"
if ! PYTHONPATH="$PYTHON_TARGET" python3 - <<'PY'
from importlib.metadata import version
import osmium
raise SystemExit(0 if version("osmium") == "4.3.1" else 1)
PY
then
python3 -m pip install \
--disable-pip-version-check \
--target "$PYTHON_TARGET" \
--upgrade \
"osmium==4.3.1"
fi
PYTHONPATH="$PYTHON_TARGET" python3 scripts/build-local-fairways.py \
"${PBF_PATHS[@]}" \
--output "$OUTPUT_PATH"
echo "Lokale Deutschland-/Niederlande-Fahrrouten sind bereit: $OUTPUT_PATH"
+194
View File
@@ -0,0 +1,194 @@
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import stat
import subprocess
import sys
import tempfile
import unittest
try:
import osmium
except ImportError:
osmium = None
ROOT_DIR = Path(__file__).resolve().parents[2]
BUILDER = ROOT_DIR / "scripts" / "build-local-fairways.py"
def write_pbf(
path: Path,
nodes: list[tuple[int, float, float]],
ways: list[tuple[int, int, list[int], dict[str, str]]],
) -> None:
assert osmium is not None
with osmium.SimpleWriter(str(path)) as writer:
for node_id, lon, lat in nodes:
writer.add_node(
osmium.osm.mutable.Node(
id=node_id,
version=1,
location=(lon, lat),
)
)
for way_id, version, node_ids, tags in ways:
writer.add_way(
osmium.osm.mutable.Way(
id=way_id,
version=version,
nodes=node_ids,
tags=tags,
)
)
checksum = hashlib.md5(path.read_bytes()).hexdigest()
Path(f"{path}.md5").write_text(
f"{checksum} {path.name}\n",
encoding="utf-8",
)
@unittest.skipIf(osmium is None, "pyosmium/osmium is not installed")
class BuildLocalFairwaysTest(unittest.TestCase):
def test_merges_two_extracts_and_deduplicates_shared_osm_ways(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
work_dir = Path(temporary_directory)
germany = work_dir / "germany-latest.osm.pbf"
netherlands = work_dir / "netherlands-latest.osm.pbf"
output = work_dir / "germany-netherlands-fairways.json"
write_pbf(
germany,
[
(1, 7.0, 53.0),
(2, 7.1, 53.1),
(3, 7.2, 53.2),
],
[
(100, 1, [1, 2], {"waterway": "canal", "name": "DE Kanal"}),
(
200,
1,
[2, 3],
{"seamark:type": "navigation_line", "name": "Alte Linie"},
),
(300, 1, [1, 2, 1], {"seamark:type": "fairway"}),
(500, 1, [1, 9], {"waterway": "canal", "name": "Grenzkanal"}),
(600, 1, [1, 9999], {"waterway": "canal"}),
],
)
write_pbf(
netherlands,
[
(2, 7.1, 53.1),
(3, 7.2, 53.2),
(4, 7.3, 53.3),
(9, 7.05, 53.05),
],
[
(
200,
2,
[2, 3, 4],
{"seamark:type": "navigation_line", "name": "Nieuwe lijn"},
),
(400, 1, [3, 4], {"route": "ferry", "name": "Veerboot"}),
],
)
result = subprocess.run(
[
sys.executable,
str(BUILDER),
str(germany),
str(netherlands),
"--output",
str(output),
],
cwd=ROOT_DIR,
env={
**os.environ,
"PYTHONPATH": str(ROOT_DIR / ".tools" / "python"),
},
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
document = json.loads(output.read_text(encoding="utf-8"))
self.assertEqual(
stat.S_IMODE(output.stat().st_mode),
0o644,
"the unprivileged application container must be able to read the index",
)
self.assertEqual(document["version"], 1)
self.assertEqual(document["generatorVersion"], 2)
self.assertEqual(document["source"], "germany+netherlands")
self.assertEqual(document["duplicateWaysMerged"], 1)
self.assertEqual(document["incompleteWaysSkipped"], 1)
self.assertEqual(document["closedFairwaysSkipped"], 1)
self.assertEqual([way["id"] for way in document["ways"]], ["100", "200", "400", "500"])
shared_way = next(way for way in document["ways"] if way["id"] == "200")
self.assertEqual(shared_way["osmVersion"], 2)
self.assertEqual(shared_way["regions"], ["germany", "netherlands"])
self.assertEqual(
shared_way["sourceFiles"],
["germany-latest.osm.pbf", "netherlands-latest.osm.pbf"],
)
self.assertEqual(shared_way["tags"]["name"], "Nieuwe lijn")
self.assertEqual(len(shared_way["coordinates"]), 3)
cross_border_way = next(
way for way in document["ways"] if way["id"] == "500"
)
self.assertEqual(
cross_border_way["coordinates"],
[[53.0, 7.0], [53.05, 7.05]],
)
self.assertEqual(
[source["region"] for source in document["sources"]],
["germany", "netherlands"],
)
self.assertTrue(
all(source["checksumMd5"] for source in document["sources"])
)
def test_failed_source_read_keeps_existing_output(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
work_dir = Path(temporary_directory)
broken_pbf = work_dir / "germany-latest.osm.pbf"
output = work_dir / "germany-netherlands-fairways.json"
broken_pbf.write_bytes(b"not an OSM PBF")
previous_content = '{"version":1,"source":"previous","ways":[]}\n'
output.write_text(previous_content, encoding="utf-8")
result = subprocess.run(
[
sys.executable,
str(BUILDER),
str(broken_pbf),
"--output",
str(output),
],
cwd=ROOT_DIR,
env={
**os.environ,
"PYTHONPATH": str(ROOT_DIR / ".tools" / "python"),
},
check=False,
capture_output=True,
text=True,
)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(output.read_text(encoding="utf-8"), previous_content)
if __name__ == "__main__":
unittest.main()