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
28 changes: 18 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,11 @@ jobs:
env:
CI: "true"
RADAR_API_KEY: ${{ secrets.STAGING_RADAR_API_KEY }}
# Passage through Access for the admin surface. Playwright follows the
# edge's 302, so without these the admin tests would parse Cloudflare's
# login page as the app. Empty secrets render empty and send nothing.
CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }}
CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }}

e2e-prod:
name: Playwright E2E (production)
Expand Down Expand Up @@ -760,6 +765,11 @@ jobs:
done
- name: Run smoke tests against staging
timeout-minutes: 15
# Same service token as production: one token, admitted by a Service Auth
# policy on each admin application. Inert while the secrets are unset.
env:
CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }}
CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }}
run: bash deploy/staging-smoke-test.sh

deploy-production:
Expand Down Expand Up @@ -1194,6 +1204,14 @@ jobs:
- name: Run production smoke tests
id: smoke
timeout-minutes: 15
# Passage through Cloudflare Access for the admin vhost. An unset secret
# renders empty, and tower-contract.sh sends no headers at all in that
# case, so this is inert until the Access application and the service
# token both exist. Failing this step rolls production back, so it must
# not become the reason a deploy reverts.
env:
CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }}
CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }}
run: |
CURL="curl -s --connect-timeout 10 --max-time 60"
# PASS/FAIL/WARN and smoke_summary, shared with deploy/staging-smoke-test.sh.
Expand Down Expand Up @@ -1277,16 +1295,6 @@ jobs:
fi
done

# Wait for storage scan (background du task, 202 = still scanning)
printf " %-40s " "GET /api/admin/storage"
for i in $(seq 1 24); do
SCODE=$(curl -sk -o /dev/null -w "%{http_code}" "${BASE_URL}/api/admin/storage")
if [ "$SCODE" = "200" ]; then echo "OK (200)"; PASS=$((PASS+1)); break; fi
if [ "$SCODE" = "202" ] && [ "$i" = "24" ]; then echo "WARN (202 still scanning after 120s)"; WARN=$((WARN+1)); break; fi
if [ "$i" = "24" ]; then echo "FAIL ($SCODE != 200 after 120s)"; FAIL=$((FAIL+1)); break; fi
sleep 5
done

# Check aircraft data is flowing
AIRCRAFT=$(curl -sk "${BASE_URL}/api/test/dashboard" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['pipeline']['aircraft_on_map'])" 2>/dev/null) || AIRCRAFT=0
Expand Down
31 changes: 28 additions & 3 deletions deploy/tower-contract.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,28 @@ TOWER_CONTRACT_ECHO='"user_frequencies_mhz":[1234.5]'
# and measures ~3s; this is the outage threshold, not the expected time.
TOWER_CONTRACT_MAX_TIME=45

# Cloudflare Access service-token credentials, for the admin vhosts that sit
# behind an Access application. Without them the edge answers a browserless
# request with a 302 to its login page, which arrives here as `got HTTP 302`
# and reads like a routing fault rather than a missing credential.
#
# Empty unless the environment supplies both, so an ungated hostname and a
# developer running this by hand behave exactly as before. The token only buys
# passage through the edge: it carries no email claim, so the origin treats it
# as nobody and it cannot reach an admin route.
TOWER_CONTRACT_CF_HEADERS=()
if [ -n "${CF_ACCESS_CLIENT_ID:-}" ] && [ -n "${CF_ACCESS_CLIENT_SECRET:-}" ]; then
TOWER_CONTRACT_CF_HEADERS=(
-H "CF-Access-Client-Id: ${CF_ACCESS_CLIENT_ID}"
-H "CF-Access-Client-Secret: ${CF_ACCESS_CLIENT_SECRET}"
)
fi

# Every call site expands it as ${TOWER_CONTRACT_CF_HEADERS[@]+"${...[@]}"}:
# expanding an empty array is an unbound-variable error under `set -u` before
# bash 4.4, and staging-smoke-test.sh sources this with `set -euo pipefail`.
# The `+` guard expands to nothing at all when the array is empty.

# assert_tower_contract <endpoint-url>
# Endpoint, not host: the api vhost publishes this as /towers, everyone else as
# /api/towers. Prints why it failed on stdout; returns non-zero.
Expand All @@ -27,7 +49,8 @@ assert_tower_contract() {
# Two attempts: the endpoint depends on third-party APIs, and a blip there
# must not read as a routing fault and block a release.
for attempt in 1 2; do
resp=$(curl -s --connect-timeout 10 --max-time "$TOWER_CONTRACT_MAX_TIME" \
resp=$(curl -s ${TOWER_CONTRACT_CF_HEADERS[@]+"${TOWER_CONTRACT_CF_HEADERS[@]}"} \
--connect-timeout 10 --max-time "$TOWER_CONTRACT_MAX_TIME" \
-w '\n%{http_code}' "${endpoint}?${TOWER_CONTRACT_QUERY}" 2>/dev/null) || {
[ "$attempt" = 1 ] && { sleep 5; continue; }
echo "unreachable after 2 attempts: ${endpoint}"
Expand Down Expand Up @@ -87,7 +110,8 @@ _assert_json_keys() {
# Two attempts, same reasoning as assert_tower_contract: elevation fans out
# to a third party, and a blip there must not read as a routing fault.
for attempt in 1 2; do
resp=$(curl -s --connect-timeout 10 --max-time "$TOWER_CONTRACT_MAX_TIME" \
resp=$(curl -s ${TOWER_CONTRACT_CF_HEADERS[@]+"${TOWER_CONTRACT_CF_HEADERS[@]}"} \
--connect-timeout 10 --max-time "$TOWER_CONTRACT_MAX_TIME" \
-w '\n%{http_code}' "$url" 2>/dev/null) || {
[ "$attempt" = 1 ] && { sleep 5; continue; }
echo "${label}: unreachable after 2 attempts: ${url}"
Expand Down Expand Up @@ -136,7 +160,8 @@ assert_elevation_contract() {
local url="${1}?${TOWER_CONTRACT_ELEVATION_QUERY}" body code resp attempt
# Two attempts, as the siblings above: a blip must not read as a routing fault.
for attempt in 1 2; do
resp=$(curl -s --connect-timeout 10 --max-time "$TOWER_CONTRACT_MAX_TIME" \
resp=$(curl -s ${TOWER_CONTRACT_CF_HEADERS[@]+"${TOWER_CONTRACT_CF_HEADERS[@]}"} \
--connect-timeout 10 --max-time "$TOWER_CONTRACT_MAX_TIME" \
-w '\n%{http_code}' "$url" 2>/dev/null) || {
[ "$attempt" = 1 ] && { sleep 5; continue; }
echo "elevation: unreachable after 2 attempts: ${url}"
Expand Down
26 changes: 26 additions & 0 deletions frontend/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,31 @@ const HOSTS = {
export const env = ENV;
export const hosts = HOSTS[ENV];

/**
* Cloudflare Access service-token headers, empty unless CI supplies both.
*
* The admin vhost sits behind an Access application, which answers a request
* carrying no session with a 302 to its login page. Playwright follows that
* redirect and lands on Cloudflare's HTML, so without these a test against the
* admin surface fails somewhere unhelpful — parsing a login page as the app —
* rather than saying it was never let in.
*
* Empty on an ungated hostname and for anyone running the suite locally, so
* this changes nothing until the Access applications and the token both exist.
* Both halves or neither: half a credential is refused at the edge exactly like
* none, and sending one would only make the failure harder to read.
*
* Exported because `request.newContext()` does not inherit `use`, so a spec
* building its own context against a gated host has to pass these itself.
*/
export const accessHeaders: Record<string, string> =
process.env.CF_ACCESS_CLIENT_ID && process.env.CF_ACCESS_CLIENT_SECRET
? {
"CF-Access-Client-Id": process.env.CF_ACCESS_CLIENT_ID,
"CF-Access-Client-Secret": process.env.CF_ACCESS_CLIENT_SECRET,
}
: {};

export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
Expand All @@ -74,6 +99,7 @@ export default defineConfig({
reporter: process.env.CI ? "github" : "list",
use: {
baseURL: hosts.frontend,
extraHTTPHeaders: accessHeaders,
trace: "on-first-retry",
screenshot: "only-on-failure",
headless: true,
Expand Down
Loading