diff --git a/.env.example b/.env.example index 1b1f1cc..1ccd428 100644 --- a/.env.example +++ b/.env.example @@ -112,6 +112,9 @@ BUILD_ELEVATION=True # MAP_MATCH_MAX_POINTS=10000 # MAP_MATCH_CONCURRENCY=4 # VALHALLA_MATCH_TIMEOUT=60000 +# Comma-separated coverage labels for a remote Valhalla whose source files are +# not mounted into Atlas. This is operator-declared metadata, not a health check. +# VALHALLA_COVERAGE_REGIONS=Germany # Transit API default; Settings persists the selected engine. TRANSIT_BACKEND=motis diff --git a/CHANGELOG.md b/CHANGELOG.md index c2444ec..dcaa26f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to Dawarich Atlas are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- External Valhalla deployments can declare their routing and map-matching coverage with `VALHALLA_COVERAGE_REGIONS`, so API clients do not lose regional metadata when Valhalla's source files live outside Atlas. + ## [0.6.1] - 2026-09-22 ### Added diff --git a/app-phoenix/lib/atlas/control/service_coverage.ex b/app-phoenix/lib/atlas/control/service_coverage.ex index 2b5ea1f..38391eb 100644 --- a/app-phoenix/lib/atlas/control/service_coverage.ex +++ b/app-phoenix/lib/atlas/control/service_coverage.ex @@ -13,13 +13,16 @@ defmodule Atlas.Control.ServiceCoverage do read_opts = opts - |> Keyword.drop([:health, :transit_backend]) + |> Keyword.drop([:health, :transit_backend, :valhalla_regions]) |> Keyword.put_new(:catalog, []) |> Keyword.put(:probe, &skip_header_probe/1) statuses = Map.get(health, :capabilities, %{}) - routing = capability("valhalla", Map.get(statuses, "routing", "down"), read_opts) + routing = + "valhalla" + |> capability(Map.get(statuses, "routing", "down"), read_opts) + |> use_declared_regions(valhalla_regions(opts), "VALHALLA_COVERAGE_REGIONS") %{ capabilities: %{ @@ -89,6 +92,54 @@ defmodule Atlas.Control.ServiceCoverage do } end + defp valhalla_regions(opts) do + opts + |> Keyword.get_lazy(:valhalla_regions, fn -> System.get_env("VALHALLA_COVERAGE_REGIONS") end) + |> normalize_regions() + end + + defp normalize_regions(regions) when is_binary(regions) do + regions + |> String.split(",") + |> normalize_regions() + end + + defp normalize_regions(regions) when is_list(regions) do + regions + |> Enum.map(&present_string/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp normalize_regions(_regions), do: [] + + defp use_declared_regions(%{regions: [_ | _]} = capability, _regions, _source), + do: capability + + defp use_declared_regions(capability, [], _source), do: capability + + defp use_declared_regions(capability, regions, source) do + datasets = + Enum.map(regions, fn region -> + %{ + label: region, + kind: "Declared service coverage", + evidence: "Declared by the Atlas operator; not inspected from the remote service", + source: source + } + end) + + %{ + capability + | coverage_status: "known", + datasets: capability.datasets ++ datasets, + note: + capability.note <> + " Region coverage is declared by the operator via #{source}.", + regions: regions + } + end + defp inspect_service("libpostal", _dir, _catalog, _probe) do %{ entries: [], diff --git a/app-phoenix/lib/atlas_web/controllers/api/v1/coverage_controller.ex b/app-phoenix/lib/atlas_web/controllers/api/v1/coverage_controller.ex index 0f9a102..8d09753 100644 --- a/app-phoenix/lib/atlas_web/controllers/api/v1/coverage_controller.ex +++ b/app-phoenix/lib/atlas_web/controllers/api/v1/coverage_controller.ex @@ -19,7 +19,9 @@ defmodule AtlasWeb.Api.V1.CoverageController do an empty list with `coverage_status=unknown` does not prove that the service has no coverage. Map matching inherits routing's Valhalla coverage. Transit timetable coverage is reported separately in `transit_feeds` because it can - differ from the walking-network regions. + differ from the walking-network regions. An external Valhalla whose files + are not mounted into Atlas can declare its regions with + `VALHALLA_COVERAGE_REGIONS`. """, responses: %{ 200 => response("Capability coverage", "application/json", Schemas.CoverageResponse) diff --git a/app-phoenix/test/atlas/control/service_coverage_test.exs b/app-phoenix/test/atlas/control/service_coverage_test.exs index 4140e01..3cd40ae 100644 --- a/app-phoenix/test/atlas/control/service_coverage_test.exs +++ b/app-phoenix/test/atlas/control/service_coverage_test.exs @@ -174,6 +174,7 @@ defmodule Atlas.Control.ServiceCoverageTest do ServiceCoverage.summary( data_dir: dir, catalog: [@berlin], + valhalla_regions: "Germany", transit_backend: "otp", health: %{ capabilities: %{ @@ -202,6 +203,33 @@ defmodule Atlas.Control.ServiceCoverageTest do ] end + test "uses declared Valhalla coverage when remote dataset provenance is unavailable", %{ + dir: dir + } do + result = + ServiceCoverage.summary( + data_dir: dir, + valhalla_regions: " Germany, Europe, Germany, ", + transit_backend: "motis", + health: %{capabilities: %{"routing" => "up"}} + ) + + assert result.capabilities.routing.regions == ["Germany", "Europe"] + assert result.capabilities.routing.coverage_status == "known" + assert result.capabilities.routing.available + assert result.capabilities.map_matching.regions == ["Germany", "Europe"] + assert result.capabilities.map_matching.inherits == "routing" + + assert Enum.map(result.capabilities.routing.datasets, & &1.label) == ["Germany", "Europe"] + + assert Enum.all?( + result.capabilities.routing.datasets, + &(&1.source == "VALHALLA_COVERAGE_REGIONS") + ) + + assert result.capabilities.routing.note =~ "declared by the operator" + end + test "public summary does not launch header probes for missing manifests", %{dir: dir} do for path <- ~w(valhalla/region.osm.pbf otp/region.osm.pbf osm/current.osm.pbf), do: put(dir, path, "not a real pbf") diff --git a/compose.compare.yml b/compose.compare.yml index 9ed72b9..a455539 100644 --- a/compose.compare.yml +++ b/compose.compare.yml @@ -101,6 +101,7 @@ services: PLACEHOLDER_URL: http://placeholder:3000 LIBPOSTAL_URL: http://libpostal:4400 VALHALLA_URL: http://valhalla:8002 + VALHALLA_COVERAGE_REGIONS: ${VALHALLA_COVERAGE_REGIONS:-} OVERPASS_URL: http://overpass:80 OTP_URL: http://otp:8080 TILES_URL: ${TILES_URL:-} diff --git a/compose.dev.yml b/compose.dev.yml index 12f7bb5..c48bda6 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -62,6 +62,7 @@ services: PLACEHOLDER_URL: http://placeholder:3000 LIBPOSTAL_URL: http://libpostal:4400 VALHALLA_URL: http://valhalla:8002 + VALHALLA_COVERAGE_REGIONS: ${VALHALLA_COVERAGE_REGIONS:-} # Map matching knobs. Declared here so a value set in .env actually # reaches the app — this service passes an explicit allowlist, not # env_file, so an undeclared variable is silently dropped. diff --git a/compose.dokploy.yml b/compose.dokploy.yml index e18633e..cf8ec53 100644 --- a/compose.dokploy.yml +++ b/compose.dokploy.yml @@ -118,6 +118,7 @@ services: PHOTON_URL: ${PHOTON_URL:-http://photon:2322} # Internal names — resolve once you uncomment the services at the bottom. VALHALLA_URL: http://valhalla:8002 + VALHALLA_COVERAGE_REGIONS: ${VALHALLA_COVERAGE_REGIONS:-} # Map matching knobs. Declared here so a value set in .env actually # reaches the app — this service passes an explicit allowlist, not # env_file, so an undeclared variable is silently dropped. diff --git a/compose.yml b/compose.yml index ed8d9e4..4f07fd5 100644 --- a/compose.yml +++ b/compose.yml @@ -46,6 +46,7 @@ services: PLACEHOLDER_URL: http://placeholder:3000 LIBPOSTAL_URL: http://libpostal:4400 VALHALLA_URL: http://valhalla:8002 + VALHALLA_COVERAGE_REGIONS: ${VALHALLA_COVERAGE_REGIONS:-} # Map matching knobs. Declared here so a value set in .env actually # reaches the app — this service passes an explicit allowlist, not # env_file, so an undeclared variable is silently dropped. diff --git a/test/deployment_config.test.mjs b/test/deployment_config.test.mjs index f0fdccb..4f4fd2d 100644 --- a/test/deployment_config.test.mjs +++ b/test/deployment_config.test.mjs @@ -17,6 +17,13 @@ test('Valhalla has a safe worker cap and nofile limit by default', () => { assert.match(compose, /ulimits:\n\s+nofile:\n\s+soft:\s+1048576\n\s+hard:\s+1048576/); }); +test('Valhalla coverage metadata reaches the app in standard and Dokploy Compose', () => { + for (const file of ['compose.yml', 'compose.dokploy.yml']) { + const compose = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8'); + assert.match(compose, /VALHALLA_COVERAGE_REGIONS:\s+\$\{VALHALLA_COVERAGE_REGIONS:-\}/); + } +}); + test('Overpass diff updates are opt-in by default', () => { const compose = readFileSync(new URL('../compose.yml', import.meta.url), 'utf8');