From d8c4371ebc0b19deaee6ef89f160511f1b287b4e Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 27 Sep 2026 20:19:16 +0200 Subject: [PATCH] chore(release): prepare Atlas 0.6.2 --- .env.example | 4 ++ CHANGELOG.md | 6 +- DEPLOY-DOKPLOY.md | 7 +++ app-phoenix/lib/atlas/control/health.ex | 55 ++++++++++++++++--- .../lib/atlas/control/service_coverage.ex | 13 ++++- .../controllers/api/v1/coverage_controller.ex | 5 +- app-phoenix/mix.exs | 2 +- .../atlas/control/health_summary_test.exs | 55 +++++++++++++++++++ .../atlas/control/service_coverage_test.exs | 45 +++++++++++++++ compose.dokploy.yml | 1 + compose.yml | 1 + test/deployment_config.test.mjs | 7 +++ 12 files changed, 186 insertions(+), 15 deletions(-) create mode 100644 app-phoenix/test/atlas/control/health_summary_test.exs diff --git a/.env.example b/.env.example index 1ccd4289..fff69bf2 100644 --- a/.env.example +++ b/.env.example @@ -115,6 +115,10 @@ BUILD_ELEVATION=True # 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 +# Comma-separated coverage labels for an external Photon whose import source +# cannot be inspected by Atlas. Confirm the actual dataset before declaring it; +# Photon's /status endpoint reports health but not geographic scope. +# PHOTON_COVERAGE_REGIONS=Worldwide # Transit API default; Settings persists the selected engine. TRANSIT_BACKEND=motis diff --git a/CHANGELOG.md b/CHANGELOG.md index dcaa26f6..f5f7a63c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,14 @@ 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] +## [0.6.2] - 2026-09-27 ### 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. +- External Photon deployments can declare their geocoding coverage with `PHOTON_COVERAGE_REGIONS` when Atlas cannot inspect the imported dataset. + +### Fixed +- Geocoding readiness now checks the configured Photon's live `/status` endpoint instead of relying on a stale Atlas container record. Coverage and API clients no longer mark a healthy external Photon as unavailable. ## [0.6.1] - 2026-09-22 diff --git a/DEPLOY-DOKPLOY.md b/DEPLOY-DOKPLOY.md index 227e6326..aca18b35 100644 --- a/DEPLOY-DOKPLOY.md +++ b/DEPLOY-DOKPLOY.md @@ -45,6 +45,7 @@ straight to the `photon` container instead, which has no auth: |---|---| | `ATLAS_HOST` | `atlas.example.com` (domain; Traefik + `PHX_HOST`) | | `PHOTON_URL` | `http://photon:2322` (the container, over `photon-net`) | + | `PHOTON_COVERAGE_REGIONS` | Optional comma-separated labels for a verified external Photon dataset, e.g. `Worldwide` | | `SECRET_KEY_BASE` | `openssl rand -hex 64` | | `ADMIN_USERNAME` / `ADMIN_PASSWORD` | for `/admin` | @@ -53,6 +54,12 @@ straight to the `photon` container instead, which has no auth: `PHOTON_URL` points at the Photon **root** — the app appends `/api`, `/reverse`, … itself. +Atlas checks the configured Photon's live `/status` endpoint for API readiness. +Photon does not report its geographic scope there. When Photon is managed outside +Atlas, set `PHOTON_COVERAGE_REGIONS` only after confirming its imported dataset; +Atlas-generated local import metadata takes precedence. This setting supplies +coverage labels, not a health check. + ### Or a keyless public URL (no shared network) If your Photon endpoint needs no key, skip the network: set diff --git a/app-phoenix/lib/atlas/control/health.ex b/app-phoenix/lib/atlas/control/health.ex index cc3f2629..634a49cb 100644 --- a/app-phoenix/lib/atlas/control/health.ex +++ b/app-phoenix/lib/atlas/control/health.ex @@ -1,8 +1,8 @@ defmodule Atlas.Control.Health do @moduledoc """ - Per-capability health for the maps API. Aggregates the `services.profile` - grouping into capability statuses (`up` | `down` | `starting`) and an - overall (`up` | `degraded` | `down`). + Per-capability health for the maps API. Container state supplies the default + statuses; a configured Photon upstream is probed directly so externally + managed Photon does not appear down when its Atlas control record is stale. """ alias Atlas.Control.Service alias Atlas.Repo @@ -27,12 +27,49 @@ defmodule Atlas.Control.Health do %{status: overall(Map.values(caps)), capabilities: caps} end - @spec summary() :: map() - def summary do - Service - |> Repo.all() - |> Map.new(fn s -> {s.name, s.status} end) - |> summarize(Atlas.Settings.transit_backend()) + @spec summary(keyword()) :: map() + def summary(opts \\ []) do + statuses = + Service + |> Repo.all() + |> Map.new(fn s -> {s.name, s.status} end) + |> with_live_photon_status(opts) + + summarize( + statuses, + Keyword.get_lazy(opts, :transit_backend, &Atlas.Settings.transit_backend/0) + ) + end + + # Container state is not authoritative when Photon is supplied through + # PHOTON_URL (including a separate Compose deployment). Probe the same + # upstream used by the public geocoding API instead. + defp with_live_photon_status(statuses, opts) do + case Keyword.get_lazy(opts, :photon_url, fn -> System.get_env("PHOTON_URL") end) do + url when is_binary(url) and url != "" -> + probe = Keyword.get(opts, :photon_probe, &photon_available?/1) + Map.put(statuses, "photon", if(probe.(url), do: :ready, else: :stopped)) + + _ -> + statuses + end + end + + defp photon_available?(url) do + req = + Req.new( + base_url: url, + connect_options: [timeout: 1_000, protocols: [:http1]], + receive_timeout: 2_000, + retry: false + ) + + case Req.get(req, url: "/status") do + {:ok, %Req.Response{status: 200, body: %{"status" => "Ok"}}} -> true + _ -> false + end + rescue + _ -> false end defp normalize(s) when s in ["ready", :ready], do: "up" diff --git a/app-phoenix/lib/atlas/control/service_coverage.ex b/app-phoenix/lib/atlas/control/service_coverage.ex index 38391eb6..2d5e8b6f 100644 --- a/app-phoenix/lib/atlas/control/service_coverage.ex +++ b/app-phoenix/lib/atlas/control/service_coverage.ex @@ -13,7 +13,7 @@ defmodule Atlas.Control.ServiceCoverage do read_opts = opts - |> Keyword.drop([:health, :transit_backend, :valhalla_regions]) + |> Keyword.drop([:health, :transit_backend, :photon_regions, :valhalla_regions]) |> Keyword.put_new(:catalog, []) |> Keyword.put(:probe, &skip_header_probe/1) @@ -26,7 +26,10 @@ defmodule Atlas.Control.ServiceCoverage do %{ capabilities: %{ - geocoding: capability("photon", Map.get(statuses, "geocoding", "down"), read_opts), + geocoding: + "photon" + |> capability(Map.get(statuses, "geocoding", "down"), read_opts) + |> use_declared_regions(photon_regions(opts), "PHOTON_COVERAGE_REGIONS"), routing: routing, map_matching: Map.put(routing, :inherits, "routing"), pois: capability("overpass", Map.get(statuses, "pois", "down"), read_opts), @@ -98,6 +101,12 @@ defmodule Atlas.Control.ServiceCoverage do |> normalize_regions() end + defp photon_regions(opts) do + opts + |> Keyword.get_lazy(:photon_regions, fn -> System.get_env("PHOTON_COVERAGE_REGIONS") end) + |> normalize_regions() + end + defp normalize_regions(regions) when is_binary(regions) do regions |> String.split(",") 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 8d097534..6419d41c 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 @@ -15,13 +15,14 @@ defmodule AtlasWeb.Api.V1.CoverageController do summary: "List available regions for each maps capability", description: """ Combines live service health with provenance from the datasets installed on - this Atlas instance. `regions` contains only region names Atlas can verify; + this Atlas instance. `regions` contains locally verified or operator-declared names; 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. An external Valhalla whose files are not mounted into Atlas can declare its regions with - `VALHALLA_COVERAGE_REGIONS`. + `VALHALLA_COVERAGE_REGIONS`. External Photon can be declared with + `PHOTON_COVERAGE_REGIONS`; its status API does not report geographic scope. """, responses: %{ 200 => response("Capability coverage", "application/json", Schemas.CoverageResponse) diff --git a/app-phoenix/mix.exs b/app-phoenix/mix.exs index 0c5b5ad0..a7340a51 100644 --- a/app-phoenix/mix.exs +++ b/app-phoenix/mix.exs @@ -4,7 +4,7 @@ defmodule Atlas.MixProject do def project do [ app: :atlas, - version: "0.6.1", + version: "0.6.2", elixir: "~> 1.15", elixirc_paths: elixirc_paths(Mix.env()), compilers: Mix.compilers() ++ [:boundary], diff --git a/app-phoenix/test/atlas/control/health_summary_test.exs b/app-phoenix/test/atlas/control/health_summary_test.exs new file mode 100644 index 00000000..36bf0c24 --- /dev/null +++ b/app-phoenix/test/atlas/control/health_summary_test.exs @@ -0,0 +1,55 @@ +defmodule Atlas.Control.HealthSummaryTest do + use Atlas.DataCase, async: true + + alias Atlas.Control.{Health, Service} + alias Atlas.Repo + + test "live Photon status overrides a stale stopped container state" do + Repo.insert!(%Service{name: "photon", profile: "geocoding", status: :stopped}) + bypass = Bypass.open() + + Bypass.expect_once(bypass, "GET", "/status", fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.resp(200, ~s({"status":"Ok"})) + end) + + health = Health.summary(photon_url: "http://localhost:#{bypass.port}") + + assert health.capabilities["geocoding"] == "up" + end + + test "a failing Photon status overrides a stale ready container state" do + Repo.insert!(%Service{name: "photon", profile: "geocoding", status: :ready}) + bypass = Bypass.open() + + Bypass.expect_once(bypass, "GET", "/status", fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.resp(200, ~s({"status":"Importing"})) + end) + + health = Health.summary(photon_url: "http://localhost:#{bypass.port}") + + assert health.capabilities["geocoding"] == "down" + end + + test "a non-200 Photon response is down even if the container is ready" do + Repo.insert!(%Service{name: "photon", profile: "geocoding", status: :ready}) + bypass = Bypass.open() + + Bypass.expect_once(bypass, "GET", "/status", fn conn -> + Plug.Conn.resp(conn, 503, "unavailable") + end) + + assert Health.summary(photon_url: "http://localhost:#{bypass.port}").capabilities[ + "geocoding" + ] == "down" + end + + test "without a configured Photon URL the container state remains authoritative" do + Repo.insert!(%Service{name: "photon", profile: "geocoding", status: :ready}) + + assert Health.summary(photon_url: nil).capabilities["geocoding"] == "up" + end +end diff --git a/app-phoenix/test/atlas/control/service_coverage_test.exs b/app-phoenix/test/atlas/control/service_coverage_test.exs index 3cd40aea..88bae893 100644 --- a/app-phoenix/test/atlas/control/service_coverage_test.exs +++ b/app-phoenix/test/atlas/control/service_coverage_test.exs @@ -230,6 +230,51 @@ defmodule Atlas.Control.ServiceCoverageTest do assert result.capabilities.routing.note =~ "declared by the operator" end + test "uses declared Photon coverage when an external index has no local provenance", %{ + dir: dir + } do + result = + ServiceCoverage.summary( + data_dir: dir, + photon_regions: " Worldwide, Germany, Worldwide, ", + transit_backend: "otp", + health: %{capabilities: %{"geocoding" => "up"}} + ) + + assert result.capabilities.geocoding.regions == ["Worldwide", "Germany"] + assert result.capabilities.geocoding.coverage_status == "known" + assert result.capabilities.geocoding.available + + assert Enum.all?( + result.capabilities.geocoding.datasets, + &(&1.source == "PHOTON_COVERAGE_REGIONS") + ) + end + + test "local Photon import provenance takes precedence over a declaration", %{dir: dir} do + source = "https://example.test/photon-db-germany-1.0-latest.tar.bz2" + + put( + dir, + "photon/logs/photon.log", + "Using constructed location for download: #{source}\n" <> + "Sequential download process completed successfully.\n" + ) + + File.mkdir_p!(Path.join(dir, "photon/photon_data")) + + result = + ServiceCoverage.summary( + data_dir: dir, + photon_regions: "Worldwide", + transit_backend: "otp", + health: %{capabilities: %{"geocoding" => "up"}} + ) + + assert result.capabilities.geocoding.regions == ["Germany"] + assert [%{source: ^source}] = result.capabilities.geocoding.datasets + 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.dokploy.yml b/compose.dokploy.yml index cf8ec531..81209c46 100644 --- a/compose.dokploy.yml +++ b/compose.dokploy.yml @@ -116,6 +116,7 @@ services: # photon-net network this is http://photon:2322 (bypasses the API-key # proxy). A keyless public endpoint would be https://photon.example.com. PHOTON_URL: ${PHOTON_URL:-http://photon:2322} + PHOTON_COVERAGE_REGIONS: ${PHOTON_COVERAGE_REGIONS:-} # Internal names — resolve once you uncomment the services at the bottom. VALHALLA_URL: http://valhalla:8002 VALHALLA_COVERAGE_REGIONS: ${VALHALLA_COVERAGE_REGIONS:-} diff --git a/compose.yml b/compose.yml index 4f07fd58..52f660a9 100644 --- a/compose.yml +++ b/compose.yml @@ -43,6 +43,7 @@ services: DOCKER_GID: ${DOCKER_GID:-999} DATABASE_URL: ${DATABASE_URL:-} PHOTON_URL: http://photon:2322 + PHOTON_COVERAGE_REGIONS: ${PHOTON_COVERAGE_REGIONS:-} PLACEHOLDER_URL: http://placeholder:3000 LIBPOSTAL_URL: http://libpostal:4400 VALHALLA_URL: http://valhalla:8002 diff --git a/test/deployment_config.test.mjs b/test/deployment_config.test.mjs index 4f4fd2d9..ee334b41 100644 --- a/test/deployment_config.test.mjs +++ b/test/deployment_config.test.mjs @@ -24,6 +24,13 @@ test('Valhalla coverage metadata reaches the app in standard and Dokploy Compose } }); +test('Photon 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, /PHOTON_COVERAGE_REGIONS:\s+\$\{PHOTON_COVERAGE_REGIONS:-\}/); + } +}); + test('Overpass diff updates are opt-in by default', () => { const compose = readFileSync(new URL('../compose.yml', import.meta.url), 'utf8');