Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions DEPLOY-DOKPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand All @@ -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
Expand Down
55 changes: 46 additions & 9 deletions app-phoenix/lib/atlas/control/health.ex
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand Down
13 changes: 11 additions & 2 deletions app-phoenix/lib/atlas/control/service_coverage.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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),
Expand Down Expand Up @@ -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(",")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion app-phoenix/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
55 changes: 55 additions & 0 deletions app-phoenix/test/atlas/control/health_summary_test.exs
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions app-phoenix/test/atlas/control/service_coverage_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions compose.dokploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}
Expand Down
1 change: 1 addition & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions test/deployment_config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
Loading