From d9e728fd7b16835722b400a1060242c265600c8e Mon Sep 17 00:00:00 2001 From: Babissimo Date: Thu, 20 Aug 2026 17:25:07 +0100 Subject: [PATCH] rename: compose service tower-finder becomes server (86c990cfh) Task 7, the last functional part of the rename. The service key doubles as a hostname on the compose network, so the fleet's FLEET_HOST and FLEET_VALIDATION_URL move in the same change or the fleet cannot reach the app. Images become retina-server:latest and retina-server-fleet:latest. server rather than retina-server: inside a project already called retina-server the pair reads redundantly, and `docker compose logs server` is what people already type. The parity gate's allowlist is keyed on services.tower-finder.*, so it would have gone on passing while covering nothing. Its regex literals and the one dict-key access are both updated; a regex-only substitution misses the latter and the gate raises KeyError on the next run. The comment naming tower-finder-service is left alone, as are the four HOST_LEGACY_REDIRECT values: those are vhost hostnames that resolve to the separate service stack, not references to this service. Both deploy paths named the service on the compose command line, so ci.yml and deploy-test.yml move too or the next deploy fails outright. Rollback images were pre-tagged on every droplet before this lands, since pre-deploy.sh writes retina-server:rollback from now on and the previous deploy's rollback point exists only under the old name. staging has none to tag: pre-deploy.sh runs solely in deploy-production, so staging has no rollback point by design rather than by loss. The ONBOARDING note warning that `docker compose logs server` returns nothing is deleted rather than reversed. Measured on compose v2: an unknown service name errors with "no such service", so after this change the old name fails loudly instead of reading as a false all-clear. The trap it described no longer exists. --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++--------- .github/workflows/deploy-test.yml | 11 +++++++++-- ONBOARDING.md | 3 --- backend/tests/test_golden_path.py | 2 +- deploy/check-env-parity.py | 16 ++++++++-------- deploy/classify-migration-gap.py | 2 +- deploy/pre-deploy.sh | 8 ++++---- deploy/rollback.sh | 10 +++++----- docker-compose.local.yml | 4 ++-- docker-compose.prod.yml | 4 ++-- docker-compose.staging.yml | 4 ++-- docker-compose.test.yml | 4 ++-- docker-compose.yml | 18 ++++++++--------- docs/alerting.md | 2 +- docs/runbook.md | 10 +++++----- justfile | 6 +++--- 16 files changed, 77 insertions(+), 59 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03016070..1e50d5bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -205,7 +205,7 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive - - run: docker build -t tower-finder:ci . + - run: docker build -t retina-server:ci . env-parity: name: Staging/production parity @@ -500,13 +500,20 @@ jobs: # cache reused); the disk pre-flight now catches that root cause, so a # full wipe is redundant and only forced slow from-scratch rebuilds. docker builder prune -f --keep-storage 10GB - # Deploy the app FIRST, on its own — building only `tower-finder` + # Deploy the app FIRST, on its own — building only `server` # means a broken Dockerfile.fleet can't abort the staging app deploy # (which would block the whole pipeline). Fleet is a separate step below. - docker compose up -d --build tower-finder + # --remove-orphans because this deploy renamed the compose + # service. container_name is pinned, so the container kept by the + # old service key is an orphan holding the exact name the new one + # needs, and compose leaves orphans alone by default: the create + # then fails with a name conflict and the stack does not come up. + # Verified on retina-test before this landed. It stays afterwards + # as the general guard for a removed or renamed service. + docker compose up -d --build --remove-orphans server # Wait for server to become healthy (up to 90s) for i in $(seq 1 18); do - if docker compose exec -T tower-finder \ + if docker compose exec -T server \ python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" 2>/dev/null; then echo "Staging server healthy after ~$((i*5))s" break @@ -787,17 +794,24 @@ jobs: # cause, so a full wipe is redundant and only forced slow from-scratch # rebuilds. 10GB leaves ample headroom on both droplets (>60GB free). docker builder prune -f --keep-storage 10GB - # Deploy the app FIRST, on its own. Building only `tower-finder` + # Deploy the app FIRST, on its own. Building only `server` # here means a broken Dockerfile.fleet can't abort the production # app deploy. Production runs no fleet simulator (see the `sim` - # profile in docker-compose.prod.yml), so `tower-finder` is the + # profile in docker-compose.prod.yml), so `server` is the # whole of the production deploy; staging still builds both. - docker compose up -d --build tower-finder + # --remove-orphans because this deploy renamed the compose + # service. container_name is pinned, so the container kept by the + # old service key is an orphan holding the exact name the new one + # needs, and compose leaves orphans alone by default: the create + # then fails with a name conflict and the stack does not come up. + # Verified on retina-test before this landed. It stays afterwards + # as the general guard for a removed or renamed service. + docker compose up -d --build --remove-orphans server # Wait for backend to become healthy (up to 90s — matches staging # and the healthcheck start_period; a cold boot can need >60s). for i in $(seq 1 18); do - if docker compose exec -T tower-finder python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" 2>/dev/null; then + if docker compose exec -T server python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" 2>/dev/null; then echo "Deploy successful (healthy after ~$((i*5))s)" break fi @@ -874,7 +888,7 @@ jobs: # pre-flights with the running stack untouched. # # Rolling back in that case would be actively harmful rather than - # merely pointless: tower-finder:rollback and the newest deploy-* + # merely pointless: retina-server:rollback and the newest deploy-* # tag still describe the PREVIOUS deploy's snapshot, so rollback.sh # would drag production back past a deploy that succeeded in order # to recover from one that never started. diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml index 7f312632..52e38da6 100644 --- a/.github/workflows/deploy-test.yml +++ b/.github/workflows/deploy-test.yml @@ -174,7 +174,14 @@ jobs: pip3 install -e libs/retina-custody/ --break-system-packages --ignore-installed -q pip3 install -e libs/retina-simulation/ --break-system-packages -q docker builder prune -f --keep-storage 10GB - docker compose up -d --build tower-finder + # --remove-orphans because this deploy renamed the compose + # service. container_name is pinned, so the container kept by the + # old service key is an orphan holding the exact name the new one + # needs, and compose leaves orphans alone by default: the create + # then fails with a name conflict and the stack does not come up. + # Verified on retina-test before this landed. It stays afterwards + # as the general guard for a removed or renamed service. + docker compose up -d --build --remove-orphans server # ── Injection point 3 ───────────────────────────────────────────── # The containers have been replaced. This is the case auto-rollback @@ -192,7 +199,7 @@ jobs: fi for i in $(seq 1 18); do - if docker compose exec -T tower-finder python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" 2>/dev/null; then + if docker compose exec -T server python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" 2>/dev/null; then echo "Deploy successful (healthy after ~$((i*5))s)" break fi diff --git a/ONBOARDING.md b/ONBOARDING.md index 1c719dd7..161a49d9 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -237,9 +237,6 @@ branch, open a PR, get it green, then merge. branch got no tests, no lint and no build while the lone green automated-review tick made the page read as passing. Stacked PRs opened since run the full matrix, but an older branch keeps the old workflow until it is rebased. -- **The compose service is `tower-finder`, not `server`.** `docker compose logs - server` returns nothing and reads as an all-clear when the app is down. Check - `docker compose ps --services` first. - **A new per-environment key needs an `env-parity` entry** or CI fails. ## Where to go next diff --git a/backend/tests/test_golden_path.py b/backend/tests/test_golden_path.py index 136ed03f..778a55c9 100644 --- a/backend/tests/test_golden_path.py +++ b/backend/tests/test_golden_path.py @@ -21,7 +21,7 @@ Rollback note ───────────── -`deploy/rollback.sh` — rolls back to the saved Docker image (tower-finder:rollback) +`deploy/rollback.sh` — rolls back to the saved Docker image (retina-server:rollback) or to a specific git ref: `deploy/rollback.sh ` `deploy/pre-deploy.sh` — saves the current image + creates a git tag. Called by CI before every deploy; also safe to run manually. diff --git a/deploy/check-env-parity.py b/deploy/check-env-parity.py index 9e9aa719..09bb592a 100755 --- a/deploy/check-env-parity.py +++ b/deploy/check-env-parity.py @@ -70,16 +70,16 @@ # Different droplet sizes. r"^services\.[^.]+\.deploy\.resources\.limits\.(cpus|memory)$", # Which environment this is, and the hostnames that follow from it. - r"^services\.tower-finder\.environment\.RETINA_ENV$", + r"^services\.server\.environment\.RETINA_ENV$", # The name a droplet gives itself in an alert, which is a different question # from RETINA_ENV above and so a different variable. RETINA_ENV picks which # backend guards apply, and staging and test both answer `test` to that # during the build-out (ClickUp 86cb1emcx), so it cannot tell an alert's # origin apart. See services/alerting.py. - r"^services\.tower-finder\.environment\.ALERT_ENVIRONMENT$", - r"^services\.tower-finder\.environment\.CORS_ORIGINS$", - r"^services\.tower-finder\.environment\.CSP_CONNECT_SRC$", - r"^services\.tower-finder\.environment\.HOST_[A-Z_]+$", + r"^services\.server\.environment\.ALERT_ENVIRONMENT$", + r"^services\.server\.environment\.CORS_ORIGINS$", + r"^services\.server\.environment\.CSP_CONNECT_SRC$", + r"^services\.server\.environment\.HOST_[A-Z_]+$", # AUTH_ALLOW_ANONYMOUS_ADMIN and SYNTHETIC_FLEET_ENABLED are deliberately # absent from this list: each is set to the same value in every environment, # so a difference is drift rather than a decision, and CI should fail if one @@ -89,7 +89,7 @@ # has none and closes it, so the two legitimately differ here. Recorded rather # than silently allowed: if staging ever needs node ingest, it should be # opened deliberately and this entry revisited. - r"^services\.tower-finder\.ports(\..*)?$", + r"^services\.server\.ports(\..*)?$", # The whole fleet service, not just its FLEET_* scale knobs. Production runs # no simulator at all (docker-compose.prod.yml puts it behind an unenabled # `sim` profile), so it drops out of the merged prod config entirely and @@ -101,7 +101,7 @@ r"^services\.fleet(\..*)?$", # Production alone joins the external edge network that fronts # tower-finder-service; staging has no such stack. - r"^services\.tower-finder\.networks(\..*)?$", + r"^services\.server\.networks(\..*)?$", r"^networks(\..*)?$", # Compose records the file list it was assembled from. r"^name$", @@ -215,7 +215,7 @@ def render(env_values: dict[str, str], out_path: Path) -> str: def check_nginx(tmp: Path) -> list[str]: rendered = {} for env, overlay in OVERLAYS.items(): - service_env = compose_config(overlay)["services"]["tower-finder"]["environment"] + service_env = compose_config(overlay)["services"]["server"]["environment"] values = {k: service_env[k] for k in HOST_VARS} # Pass TLS_ENABLED through when the overlay sets it, so the render below # reflects what the environment would actually serve. Without this the diff --git a/deploy/classify-migration-gap.py b/deploy/classify-migration-gap.py index cddd9bb3..a00103e7 100755 --- a/deploy/classify-migration-gap.py +++ b/deploy/classify-migration-gap.py @@ -144,7 +144,7 @@ def classify(target_ref: str) -> tuple[str, bool]: verdict = ( " The restored code will query a schema that no longer matches. Downgrade\n" " before trusting this rollback:\n" - " docker compose exec tower-finder \\\n" + " docker compose exec server \\\n" f' sh -c "cd /app/backend && python3 -m alembic downgrade {_revision_of(restored[-1])}"' ) diff --git a/deploy/pre-deploy.sh b/deploy/pre-deploy.sh index b206dc86..1a1ec607 100755 --- a/deploy/pre-deploy.sh +++ b/deploy/pre-deploy.sh @@ -2,7 +2,7 @@ # ── Pre-Deploy Snapshot ────────────────────────────────────────────────────── # Run BEFORE each deploy to save rollback points. # -# 1. Tags the current running Docker image as `tower-finder:rollback` +# 1. Tags the current running Docker image as `retina-server:rollback` # 2. Creates a git tag `deploy-` on the current commit # # Usage: deploy/pre-deploy.sh @@ -10,9 +10,9 @@ set -euo pipefail APP_DIR="${APP_DIR:-/opt/retina-server}" -IMAGE_NAME="tower-finder" -COMPOSE_SERVICE="tower-finder" -FLEET_IMAGE_NAME="tower-finder-fleet" +IMAGE_NAME="retina-server" +COMPOSE_SERVICE="server" +FLEET_IMAGE_NAME="retina-server-fleet" FLEET_SERVICE="fleet" cd "$APP_DIR" diff --git a/deploy/rollback.sh b/deploy/rollback.sh index c494107f..60228583 100755 --- a/deploy/rollback.sh +++ b/deploy/rollback.sh @@ -9,15 +9,15 @@ # How it works: # Before each deploy, the CI pipeline (or manual deploy) should call # `deploy/pre-deploy.sh` which tags the current image as -# `tower-finder:rollback` and creates a git tag `deploy-`. +# `retina-server:rollback` and creates a git tag `deploy-`. # # This script restores service from that saved image or a given git ref. # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail APP_DIR="${APP_DIR:-/opt/retina-server}" -IMAGE_NAME="tower-finder" -FLEET_IMAGE_NAME="tower-finder-fleet" +IMAGE_NAME="retina-server" +FLEET_IMAGE_NAME="retina-server-fleet" # No `-f` flags below: the host's ./.env sets COMPOSE_FILE to the shared base # plus that host's overlay (deploy/env.*.example), so `docker compose` here # resolves exactly what the deploy resolved. Passing docker-compose.yml alone @@ -172,7 +172,7 @@ else # the retag is a harmless no-op — as is the "no rollback image" branch, # since pre-deploy.sh finds no fleet image to save there either. # Guarded because a box predating this change (or one where pre-deploy.sh - # found no running fleet) never captured tower-finder-fleet:rollback — in + # found no running fleet) never captured retina-server-fleet:rollback — in # that case roll the app back cleanly and leave the fleet image as-is. if docker image inspect "${FLEET_IMAGE_NAME}:rollback" >/dev/null 2>&1; then docker tag "${FLEET_IMAGE_NAME}:rollback" "${FLEET_IMAGE_NAME}:latest" @@ -196,7 +196,7 @@ fi # ── Wait for health ────────────────────────────────────────────────────────── echo "Waiting for server to become healthy..." for i in $(seq 1 12); do - if docker compose exec -T tower-finder \ + if docker compose exec -T server \ python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" 2>/dev/null; then if [ "$DB_NEEDS_DOWNGRADE" = 1 ]; then echo "Service back after ~$((i*5))s, but a database downgrade is outstanding." diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 572272f4..c24a0229 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -24,9 +24,9 @@ # Requires Docker Compose >= 2.24 for the !reset / !override merge tags. services: - tower-finder: + server: container_name: retina-local-server - # The base pins tower-finder:latest for the rollback tooling on the droplets. + # The base pins retina-server:latest for the rollback tooling on the droplets. # Reset it so a laptop build cannot collide with a tag the deploy scripts key # on, and gets Compose's implicit per-project name instead. image: !reset null diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d4ff2eba..19642540 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -15,7 +15,7 @@ # the same commit, so the divergence is a recorded decision rather than drift. services: - tower-finder: + server: container_name: retina-prod-server # Consumed by services/alerting.py, which otherwise sees the container id or # the service name (varies by Compose version), neither of which says which @@ -101,7 +101,7 @@ services: # The sizing below is kept, not deleted, so the fleet can be brought back for # an afternoon without a PR: # docker compose --profile sim up -d --build --no-deps fleet - # docker compose restart tower-finder # re-registers the synthetic nodes + # docker compose restart server # re-registers the synthetic nodes # Staging and test are unaffected and still run theirs by default. profiles: ["sim"] environment: diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index d1c62137..1b56912e 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -16,14 +16,14 @@ # the same commit, so the divergence is a recorded decision rather than drift. services: - tower-finder: + server: container_name: retina-staging-server # Consumed by services/alerting.py, which otherwise sees the container id or # the service name (varies by Compose version), neither of which says which # droplet raised an alert. hostname: retina-staging # No TCP ingest on staging. The base publishes 3012 for real receiver nodes; - # staging has none, and its own fleet reaches the server as tower-finder:3012 + # staging has none, and its own fleet reaches the server as server:3012 # over the compose network, never via the host port. Verified with `ss`: zero # established connections on it. Publishing it was therefore pure exposure — # and worse than it looks, because RETINA_ENV=test silences the missing diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 723eb351..f0bb1db5 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -19,14 +19,14 @@ # that matters. services: - tower-finder: + server: container_name: retina-test-server # Consumed by services/alerting.py, which otherwise sees the container id or # the service name (varies by Compose version), neither of which says which # droplet raised an alert. hostname: retina-test # No TCP ingest, for the same reason staging closes it: there are no real - # receiver nodes here, and the fleet reaches the server as tower-finder:3012 + # receiver nodes here, and the fleet reaches the server as server:3012 # over the compose network rather than via the host port. Publishing it would # be pure exposure — and RETINA_ENV=test silences the missing-RADAR_NODE_TOKEN # warning, so anything on the internet could register itself as a node. diff --git a/docker-compose.yml b/docker-compose.yml index 3a3a3bef..8f58bc1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,15 +17,15 @@ # overlay. deploy/check-env-parity.sh enforces that in CI. services: - tower-finder: + server: build: . # Pin an explicit image name so it is stable and independent of the Compose # project (directory) name. deploy/pre-deploy.sh + deploy/rollback.sh key the - # rollback on `tower-finder:latest`; without this, Compose builds an - # auto-named `-tower-finder` image and the rollback tag is never the one + # rollback on `retina-server:latest`; without this, Compose builds an + # auto-named `-server` image and the rollback tag is never the one # Compose actually runs, so `rollback.sh` silently restarts the bad build. # In the base so staging is rollback-capable on the same terms as prod. - image: tower-finder:latest + image: retina-server:latest ports: - "80:80" - "443:443" @@ -109,13 +109,13 @@ services: build: context: . dockerfile: Dockerfile.fleet - # Pin an explicit image name (mirrors the app's `image: tower-finder:latest`) + # Pin an explicit image name (mirrors the app's `image: retina-server:latest`) # so pre-deploy/rollback can save + retag a stable fleet image. Without this # Compose auto-names it `-fleet` and a rollback would leave the fleet on # the bad deploy's build while only the app reverted. - image: tower-finder-fleet:latest + image: retina-server-fleet:latest depends_on: - tower-finder: + server: condition: service_healthy # RADAR_API_KEY comes from backend/.env (the same file the app loads). The # backend enforces X-API-Key on its ingest whenever it has the key set @@ -128,13 +128,13 @@ services: env_file: - backend/.env environment: - - FLEET_HOST=tower-finder + - FLEET_HOST=server - FLEET_PORT=3012 - FLEET_VALIDATE=false # HTTP base URL for live ADS-B / ground-truth pushes (the moving "truth" # tracks). Must point at the server over the compose network, not the # fleet's own localhost. Reaches uvicorn directly (UVICORN_HOST=0.0.0.0). - - FLEET_VALIDATION_URL=http://tower-finder:8000 + - FLEET_VALIDATION_URL=http://server:8000 - FLEET_SEED=42 restart: unless-stopped logging: diff --git a/docs/alerting.md b/docs/alerting.md index 3ea793fd..8af7d349 100644 --- a/docs/alerting.md +++ b/docs/alerting.md @@ -25,7 +25,7 @@ Three layers, in order of what they catch: `host: ` line, then one `key: value` line per `meta` entry. Both shapes carry `environment` (from `ALERT_ENVIRONMENT`, or the literal `unknown` when unset or empty) and `host` (from `socket.gethostname()`, set - by the `hostname:` each droplet overlay gives its `tower-finder` service, or + by the `hostname:` each droplet overlay gives its `server` service, or `unknown` if that call fails or returns empty), because each droplet's `ALERT_WEBHOOK_URL` points at its own channel: channel routing is configuration, and a misrouted URL would otherwise put an alert in the diff --git a/docs/runbook.md b/docs/runbook.md index e55ce310..f9120d5c 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -107,7 +107,7 @@ it; the container will not serve against a half-applied schema. To see where a droplet stands: ```bash -ssh retina-prod 'cd /opt/retina-server && docker compose exec tower-finder \ +ssh retina-prod 'cd /opt/retina-server && docker compose exec server \ sh -c "cd /app/backend && python3 -m alembic current"' ``` @@ -148,7 +148,7 @@ about the database. To roll back past a destructive revision, downgrade and then redeploy: ```bash -ssh retina-prod 'cd /opt/retina-server && docker compose exec tower-finder \ +ssh retina-prod 'cd /opt/retina-server && docker compose exec server \ sh -c "cd /app/backend && python3 -m alembic downgrade "' ``` @@ -267,7 +267,7 @@ A stale `blah2_bridge:` means that node's `/api/detection` is unreachab The node list is `blah2_nodes.json` — url, rx, tx, fc and friends per node — read through the runtime-config overlay, so this is a config change with no rebuild: ```bash -docker compose exec tower-finder vi /app/backend/data/runtime/blah2_nodes.json +docker compose exec server vi /app/backend/data/runtime/blah2_nodes.json ``` Restart the container afterwards; the list is read once, at startup. @@ -548,9 +548,9 @@ at 107% CPU reporting `solver_latency_high` at rest. Naming `fleet` on the comma line *auto-enables its profile*, so the bounce command above is not inert on the prod droplet — it would silently put all 25 back. Do not run it there unless you intend exactly that, and if you do, undo it with `docker compose stop fleet` -followed by `docker compose restart tower-finder` (stopping the container does not +followed by `docker compose restart server` (stopping the container does not deregister the geometry it already registered). -`--no-deps` is the important flag: `fleet` declares `depends_on: tower-finder`, so +`--no-deps` is the important flag: `fleet` declares `depends_on: server`, so without it Compose would also rebuild and recreate the running app, turning a fleet bounce into a full redeploy and an outage. The host's `./.env` sets `COMPOSE_FILE`, so the bare `docker compose` above resolves to base + the production overlay. diff --git a/justfile b/justfile index f10cd7dd..0d784bda 100644 --- a/justfile +++ b/justfile @@ -374,13 +374,13 @@ deploy-test: echo "→ waiting for health..." healthy=no for _ in $(seq 1 24); do - if ssh "{{host_test}}" "cd {{app_test}} && docker compose exec -T tower-finder python3 -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/api/health\")'" >/dev/null 2>&1; then + if ssh "{{host_test}}" "cd {{app_test}} && docker compose exec -T server python3 -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/api/health\")'" >/dev/null 2>&1; then healthy=yes; break fi sleep 5 done if [ "$healthy" != yes ]; then - echo " ✗ not healthy after 120s — inspect: just deploy-test-logs tower-finder" + echo " ✗ not healthy after 120s — inspect: just deploy-test-logs server" exit 1 fi echo " ✓ healthy" @@ -401,7 +401,7 @@ deploy-test-status: # inside a double-quoted remote command — one level of escaping, no heredoc # (just indents every recipe line, so a heredoc terminator never closes). echo "── fleet ──" - ssh "{{host_test}}" "cd {{app_test}} && docker compose exec -T tower-finder python3 -c 'import json,urllib.request; d=json.load(urllib.request.urlopen(\"http://localhost:8000/api/radar/nodes\")); n=d[\"nodes\"]; print(\" nodes:\", len(n), \"total,\", sum(1 for v in n.values() if v.get(\"is_synthetic\")), \"synthetic,\", d.get(\"connected\"), \"connected\")'" 2>/dev/null || echo " (nodes endpoint unreachable)" + ssh "{{host_test}}" "cd {{app_test}} && docker compose exec -T server python3 -c 'import json,urllib.request; d=json.load(urllib.request.urlopen(\"http://localhost:8000/api/radar/nodes\")); n=d[\"nodes\"]; print(\" nodes:\", len(n), \"total,\", sum(1 for v in n.values() if v.get(\"is_synthetic\")), \"synthetic,\", d.get(\"connected\"), \"connected\")'" 2>/dev/null || echo " (nodes endpoint unreachable)" # Tail retina-test's container logs (Ctrl-C to stop; the stack keeps running) deploy-test-logs service="":