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
34 changes: 33 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions backend/tests/test_towers_vhost_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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))
Expand Down
16 changes: 15 additions & 1 deletion deploy/nginx/snippets/towers-proxy.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down Expand Up @@ -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
}
6 changes: 3 additions & 3 deletions deploy/render-nginx-config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}


Expand Down
12 changes: 11 additions & 1 deletion deploy/staging-smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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) ──"
Expand Down
49 changes: 41 additions & 8 deletions deploy/tower-contract.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -254,6 +255,38 @@ assert_config_contract() {
_assert_json_keys "config" "$1" $TOWER_CONTRACT_CONFIG_KEYS
}

# assert_geocode_contract <endpoint-url> 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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading