diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 047509ae..6874398d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -405,6 +405,10 @@ jobs: # is not; elevation is left to the public-DNS step above, which has # the retries for it. echo "CFGKEYS_B64=$(printf '%s' "$TOWER_CONTRACT_CONFIG_KEYS" | base64 | tr -d '\n')" + # The geocode route, also forwarded by the include. Its probe is an + # empty query the service refuses at validation, so it is as + # deterministic here as the config read. + echo "GEOBODY_B64=$(printf '%s' "$TOWER_CONTRACT_GEOCODE_BODY" | base64 | tr -d '\n')" } >> "$GITHUB_ENV" - name: Assert staging's instance if: github.event_name == 'push' @@ -413,17 +417,19 @@ jobs: QUERY_B64: ${{ env.QUERY_B64 }} ECHO_B64: ${{ env.ECHO_B64 }} CFGKEYS_B64: ${{ env.CFGKEYS_B64 }} + GEOBODY_B64: ${{ env.GEOBODY_B64 }} with: host: ${{ secrets.STAGING_HOST }} username: root key: ${{ secrets.STAGING_SSH_KEY }} command_timeout: 5m - envs: QUERY_B64,ECHO_B64,CFGKEYS_B64 + envs: QUERY_B64,ECHO_B64,CFGKEYS_B64,GEOBODY_B64 script: | set -e QUERY=$(printf '%s' "$QUERY_B64" | base64 -d) WANT=$(printf '%s' "$ECHO_B64" | base64 -d) CFGKEYS=$(printf '%s' "$CFGKEYS_B64" | base64 -d) + GEOBODY=$(printf '%s' "$GEOBODY_B64" | base64 -d) if ! docker network inspect retina-edge >/dev/null 2>&1; then echo "::error::retina-edge does not exist on $(hostname), so nginx cannot reach tower-finder-service and every proxied /api/towers would 502. Bring that stack up before routing a vhost to it." exit 1 @@ -452,6 +458,23 @@ jobs: exit 1 fi done + + # /api/geocode, forwarded by the same include. An empty query is + # refused by the service's own request model with 422, before + # either geocoder upstream is asked; anything else means the + # instance behind this deploy does not carry the route yet, and + # every vhost would 404 the address lookup once it is pointed here. + GEO=$(docker run --rm --network retina-edge curlimages/curl:latest \ + -s -m 45 -o /dev/null -w '%{http_code}' -X POST \ + -H 'Content-Type: application/json' -d "$GEOBODY" \ + "http://tower-finder-service:8000/api/geocode") || { + echo "::error::tower-finder-service did not answer /api/geocode on retina-edge from $(hostname)." + exit 1 + } + if [ "$GEO" != "422" ]; then + echo "::error::staging's tower-finder-service answered /api/geocode with HTTP ${GEO}, not the 422 an empty query gets from the route. Either that instance predates the address lookup, or the request model changed. snippets/towers-proxy.conf forwards the route on every vhost, so pointing one at this instance would 404 the search form's address field." + exit 1 + fi echo "staging's tower-finder-service honours the contract." # Does this push touch anything the droplets serve? A docs-only merge would @@ -1141,6 +1164,15 @@ jobs: else echo "FAIL"; printf ' %s\n' "$REASON"; FAIL=$((FAIL+1)) fi + # Strict, like config: the probe stops at the service's own input + # validation and never reaches a geocoder, so there is no third + # party here whose outage could roll production back. + printf " %-40s " "${endpoint#https://}/api/geocode" + if REASON=$(assert_geocode_contract "${endpoint}/api/geocode"); then + echo "OK"; PASS=$((PASS+1)) + else + echo "FAIL"; printf ' %s\n' "$REASON"; FAIL=$((FAIL+1)) + fi done # Check aircraft data is flowing diff --git a/ONBOARDING.md b/ONBOARDING.md index 869e8227..ce81145e 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -25,7 +25,7 @@ One FastAPI backend serves several React front-ends, distinguished by subdomain: Illuminator search is deliberately absent from that table: **tower-finder-service** (separate repo and container) owns the API and the UI both, and serves `towers.retina.fm` from its own edge. Our vhosts only proxy `/api/towers`, -`/api/elevation` and `/api/config` to it. +`/api/elevation`, `/api/config` and `/api/geocode` to it. Receiver nodes connect over TCP and stream detection frames. The pipeline (tracker → geolocator) turns frames into aircraft positions, broadcast to the diff --git a/README.md b/README.md index de0fe2c1..6f2b50b6 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,14 @@ and the admin dashboard. Finding a suitable broadcast illuminator near a receiver is **tower-finder-service**, a separate repo and container. It owns both halves: the API (`/api/towers`, -`/api/elevation`, `/api/config`) and the UI, which it serves itself on -`towers.retina.fm` through its own edge. +`/api/elevation`, `/api/config`, `/api/geocode`) and the UI, which it serves itself +on `towers.retina.fm` through its own edge. What remains here is the proxy seam. `api.retina.fm/towers` forwards to the service for callers that want a clean public API name, and the other vhosts -still forward `/api/towers`, `/api/elevation` and `/api/config` so that a -request arriving at one of them reaches the single implementation rather than a -404 from this backend. +still forward `/api/towers`, `/api/elevation`, `/api/config` and `/api/geocode` +so that a request arriving at one of them reaches the single implementation +rather than a 404 from this backend. ## Project Structure @@ -79,7 +79,8 @@ cd backend && .venv/bin/uvicorn main:app --reload The API runs at `http://localhost:8000`. Interactive docs at `/docs`. The tower search is not part of this process: run tower-finder-service (its own repo and -container) if you need `/api/towers`, `/api/elevation` or `/api/config` locally. +container) if you need `/api/towers`, `/api/elevation`, `/api/config` or +`/api/geocode` locally. The live map and the dashboard do not need it. #### Database migrations @@ -138,10 +139,10 @@ the routes as `x-retry` and `x-terminal`, and the vocabulary is defined in the contract's own description. A breaking change raises `NODE_API_VERSION` in `backend/routes/nodes.py`. -### `GET /api/towers`, `GET /api/elevation`, `GET|PUT /api/config` +### `GET /api/towers`, `GET /api/elevation`, `GET|PUT /api/config`, `POST /api/geocode` Answered by **tower-finder-service**, not by this backend. nginx proxies all -three to that service on every vhost that answers `/api/` (see +four to that service on every vhost that answers `/api/` (see `deploy/nginx/snippets/towers-proxy.conf` and the `TOWER_FINDER` conditional in `deploy/nginx/nginx.conf.template`); this repo keeps the SPA that calls them and the routing, and no longer keeps a second implementation of the search, the diff --git a/backend/tests/test_towers_vhost_coverage.py b/backend/tests/test_towers_vhost_coverage.py index 51767803..f0d05949 100644 --- a/backend/tests/test_towers_vhost_coverage.py +++ b/backend/tests/test_towers_vhost_coverage.py @@ -38,11 +38,11 @@ } # The paths towers-proxy.conf hands to the service. Every vhost that includes it -# must forward all three. This used to guard against a split-brain (each route +# must forward all four. This used to guard against a split-brain (each route # answered by whichever implementation the vhost happened to reach); now that the # monolith's copy is deleted, a route missing from the snippet is a 404 on that # vhost instead — louder, but still only visible in production. -_PROXIED_PATHS = ("/api/towers", "/api/elevation", "/api/config") +_PROXIED_PATHS = ("/api/towers", "/api/elevation", "/api/config", "/api/geocode") def _server_blocks(text: str) -> list[str]: @@ -93,10 +93,10 @@ def test_the_smoke_test_defines_every_url_it_is_expected_to_probe(): def test_the_shared_snippet_proxies_every_deduplicated_path(): """One vhost include must carry the whole tower stack, not just the search. - /api/elevation and /api/config have no implementation left in this repo, so a - route dropped from this snippet is not served at all on that vhost: the - request falls through `location /` to an app that no longer has the handler - and answers 404. + /api/elevation and /api/config have no implementation left in this repo, and + /api/geocode never had one, so a route dropped from this snippet is not + served at all on that vhost: the request falls through `location /` to an + app without the handler and answers 404. """ snippet = (_TEMPLATE.parent / "snippets" / "towers-proxy.conf").read_text() locations = set(re.findall(r"^location\s+(\S+)\s*\{", snippet, re.M)) diff --git a/deploy/nginx/snippets/towers-proxy.conf b/deploy/nginx/snippets/towers-proxy.conf index da798ce4..58f23b77 100644 --- a/deploy/nginx/snippets/towers-proxy.conf +++ b/deploy/nginx/snippets/towers-proxy.conf @@ -12,7 +12,7 @@ # role token, so a hardcoded production name survives in the staging and test # renders only, and reads as a parity failure. Name the ROLE, not the host. # -# All three are LONGER prefixes than `location /api/`, so nginx matches these +# All four are LONGER prefixes than `location /api/`, so nginx matches these # first on every vhost that also defines that one. None of them is an exact # (`location =`) match anywhere in the template, so nothing outranks them. # @@ -41,3 +41,17 @@ location /api/elevation { location /api/config { # RETINA_INCLUDE snippets/tower-finder.conf } + +# Address lookup for the search form's address field (the service's api.ts +# geocodeAddress). POST only, and the one route here that this repo's own +# bundle does not call yet: the field shipped in the service's UI first. It +# is forwarded all the same, because the vhosts that serve either bundle must +# answer the same set of tower routes — a vhost missing this one 404s the +# lookup from the app's `location /` fallback, which never had the handler. +# +# The smoke probes send an empty query. The service's own validation refuses +# that with 422 before either geocoder upstream is asked, so the check is +# deterministic and cannot be mistaken for the fallback's 404. +location /api/geocode { + # RETINA_INCLUDE snippets/tower-finder.conf +} diff --git a/deploy/render-nginx-config.py b/deploy/render-nginx-config.py index c7e317f5..96336a99 100755 --- a/deploy/render-nginx-config.py +++ b/deploy/render-nginx-config.py @@ -73,9 +73,9 @@ # routes the tower stack to the service, and there is no longer a second # implementation to fall back to — the monolith's copy was deleted once the # proxy went live — so a missing variable must not quietly leave a vhost -# answering 404 on /api/towers, /api/elevation and /api/config. The laptop turns -# it off because it runs no service and no retina-edge network, and would -# otherwise 502; with it off those three routes simply do not exist there. +# answering 404 on /api/towers, /api/elevation, /api/config and /api/geocode. +# The laptop turns it off because it runs no service and no retina-edge network, +# and would otherwise 502; with it off those four routes simply do not exist there. FLAGS = {"TLS": ("TLS_ENABLED", True), "TOWER_FINDER": ("TOWER_FINDER_ENABLED", True)} diff --git a/deploy/staging-smoke-test.sh b/deploy/staging-smoke-test.sh index ad5bbb4f..6b6e38f0 100644 --- a/deploy/staging-smoke-test.sh +++ b/deploy/staging-smoke-test.sh @@ -372,7 +372,7 @@ for endpoint in "${BASE_URL}/api/towers" "${MAP_URL}/api/towers" \ done # The other half of the seam: a sibling /api/ path on the same vhost is still # served by the app. /api/radar/nodes has no counterpart on the service, so a -# 200 here can only have come from the monolith — the proxy must take the three +# 200 here can only have come from the monolith — the proxy must take the four # tower routes and nothing else. check_status "sibling /api/ path stays on the app" "${MAP_URL}/api/radar/nodes" "200" @@ -431,6 +431,16 @@ if [ "$PUT_CODE" = "401" ] || [ "$PUT_CODE" = "403" ]; then else echo "FAIL ($PUT_CODE — expected 401 or 403; an open config write is a takeover)"; FAIL=$((FAIL+1)) fi +# The fourth route the include forwards. Same vhost as the two above, for the +# same reason; the probe itself is in tower-contract.sh and never reaches a +# geocoder upstream, so unlike elevation there is no degraded state to tolerate. +printf " %-40s " "dash /api/geocode answers" +if REASON=$(assert_geocode_contract "${DASH_URL}/api/geocode"); then + echo "OK"; PASS=$((PASS+1)) +else + echo "FAIL"; printf ' %s +' "$REASON"; FAIL=$((FAIL+1)) +fi echo "" echo "── Detection archive (dash /data) ──" diff --git a/deploy/tower-contract.sh b/deploy/tower-contract.sh index 70368c33..06cbe007 100644 --- a/deploy/tower-contract.sh +++ b/deploy/tower-contract.sh @@ -108,13 +108,14 @@ assert_tower_contract() { return 0 } -# ── The other two deduplicated routes ──────────────────────────────────────── +# ── The other deduplicated routes ──────────────────────────────────────────── # /api/towers was never the whole tower stack. Every vhost that includes -# snippets/towers-proxy.conf forwards /api/elevation and /api/config as well, so -# all three are part of what the service must honour before a vhost points at -# it. They get a shape assertion rather than a parameter echo: neither takes a -# ranking parameter, and what a caller can be broken by is the response losing a -# key it reads. +# snippets/towers-proxy.conf forwards /api/elevation, /api/config and +# /api/geocode as well, so all four are part of what the service must honour +# before a vhost points at it. Elevation and config get a shape assertion +# rather than a parameter echo: neither takes a ranking parameter, and what a +# caller can be broken by is the response losing a key it reads. Geocode has +# its own probe further down. # # The keys below were the ones BOTH implementations returned while retina still # had its own. That copy is deleted, so they now pin the shape retina's callers @@ -254,6 +255,38 @@ assert_config_contract() { _assert_json_keys "config" "$1" $TOWER_CONTRACT_CONFIG_KEYS } +# assert_geocode_contract e.g. https://host/api/geocode +# The address lookup, forwarded by the same include. Probed with an EMPTY query +# on purpose: the service's request model refuses it with 422 before either of +# its geocoder upstreams (US Census, Nominatim) is asked, so this neither spends +# a third party's quota nor depends on one being up — the two things that made +# elevation's probe tolerant. It is also unambiguous: the app's `location /` +# fallback answers a route it never had with 404, never 422. +TOWER_CONTRACT_GEOCODE_BODY='{"query":""}' +assert_geocode_contract() { + local url="$1" code attempt + # Two attempts, as the siblings: a blip must not read as a routing fault. + for attempt in 1 2; do + code=$(curl -s --connect-timeout 10 --max-time "$TOWER_CONTRACT_MAX_TIME" \ + -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' \ + -d "$TOWER_CONTRACT_GEOCODE_BODY" "$url" 2>/dev/null) || { + [ "$attempt" = 1 ] && { sleep 5; continue; } + echo "geocode: unreachable after 2 attempts: ${url}" + return 1 + } + [ "$code" = "422" ] && return 0 + [ "$attempt" = 1 ] && { sleep 5; continue; } + if [ "$code" = "404" ]; then + echo "geocode: got HTTP 404 from ${url}. No \`location /api/geocode\` on that vhost:" + echo "the request fell through to the app, which has no such route. See" + echo "deploy/nginx/snippets/towers-proxy.conf." + else + echo "geocode: got HTTP ${code} from ${url}, expected 422 for an empty query" + fi + return 1 + done +} + # Run directly (not sourced) to gate a deploy on the contract. Takes the # /api/towers endpoint; the sibling routes are derived from it, so a caller # cannot check the search and forget the other two. @@ -290,11 +323,11 @@ if [ "${BASH_SOURCE[0]}" = "$0" ]; then # unchanged means it was not a /api/towers URL (the api vhost publishes the # search as /towers), and the siblings cannot be derived from it. if [ "$BASE" = "$TARGET" ]; then - echo "Skipping the /api/elevation and /api/config checks: ${TARGET} is not a /api/towers URL, so the sibling routes cannot be derived. Point this at the service's own /api/towers to cover them." + echo "Skipping the /api/elevation, /api/config and /api/geocode checks: ${TARGET} is not a /api/towers URL, so the sibling routes cannot be derived. Point this at the service's own /api/towers to cover them." exit "$RC" fi - for check in elevation config; do + for check in elevation config geocode; do printf 'Asserting %s/api/%s honours the shape our callers read... ' "$BASE" "$check" # 2 is tolerated for elevation only. For elevation it is a third party's # rate limiter, which has no bearing on whether a vhost may be pointed diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 0f931113..b0724ff0 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -57,7 +57,7 @@ services: # This flag controls the nginx proxy and nothing else. There is no local # fallback behind it any more: the monolith's own tower stack was deleted # once every deployed vhost was proxied to the service, so with the flag - # off, /api/towers, /api/elevation and /api/config are 404s and the tower + # off, /api/towers, /api/elevation, /api/config and /api/geocode are 404s and the tower # search SPA on towers.localhost cannot function. To work on the tower # search locally, run tower-finder-service (its own repo and container) # and point this stack at it — do not expect this one to answer.