chore(docker-dev): readiness-gated deploy.sh + Traefik access log for the first-POST 404

This commit is contained in:
Joseph Doherty
2026-08-17 05:32:44 -04:00
parent 1edfc263ec
commit a61b3c98a2
3 changed files with 123 additions and 2 deletions
+25 -2
View File
@@ -86,10 +86,33 @@ Central nodes authenticate against the shared GLAuth at `10.100.0.35:3893` (base
## Headless deploy
```bash
POST http://localhost:9200/api/deployments
X-Api-Key: docker-dev-deploy-key
./docker-dev/deploy.sh # trigger a deploy (no request body)
./docker-dev/deploy.sh artifact.json # …sending that JSON file as the request body
```
`deploy.sh` polls `http://localhost:9200/health/ready` (every 2 s, up to 120 s) and only then
issues the single, un-retried `POST http://localhost:9200/api/deployments` with
`X-Api-Key: docker-dev-deploy-key`. Override the URL / key / timeout via `OTOPCUA_ADMIN_URL`,
`OTOPCUA_DEPLOY_API_KEY`, `OTOPCUA_READY_TIMEOUT_SECONDS`.
### First-POST 404
The first headless POST after `up -d` was seen answering **404** ~40 s into boot (2026-08-17 live
gate), clearing on retry. Neither end can produce it: the AdminUI maps every route synchronously
before `RunAsync` (`Program.cs:740` maps the deploy API, `:803` runs) and that endpoint's only
failure modes are 503 / 401 / 422 / 409 / 500 — it has no 404 path; and Traefik answers a *matched*
router whose servers are all down with **503 "no available server"** (the checked-in
`traefik-dynamic.yml` router is `PathPrefix(/)` → `otopcua-admin` with a `/health/active`
healthCheck). Traefik's `api@internal` router (`PathPrefix(/api)`) is bound to the `:8080`
entrypoint (host 8089), not `web`/`:9200`, so it does not shadow `/api/deployments` either. So a
404 from `:9200` is Go's default "404 page not found" — **no router matched at that instant**;
the mechanism is unconfirmed. The `--accesslog=true --accesslog.fields.defaultmode=keep` flags on
the `traefik` service exist to diagnose the next occurrence: `docker compose -f
docker-dev/docker-compose.yml logs traefik` and look for the request with an empty `RouterName`.
The readiness gate in `deploy.sh` avoids the race (a 200 through `:9200` proves both a matched
router and a live backend), so a 404 *after* the gate is new evidence and the script fails loudly
saying so.
## Tear down
```bash
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
#
# Readiness-gated headless deploy trigger for the docker-dev rig.
#
# Why the gate exists: the 2026-08-17 live gate saw the first
# `POST http://localhost:9200/api/deployments` after `up -d` answer a transient 404
# (~40 s into boot), which then cleared on a retry. That 404 can come from neither
# end of the request: the AdminUI maps every route synchronously before RunAsync and
# the deploy endpoint has no 404 path (503/401/422/409/500 only), and Traefik answers
# a matched-router-with-all-servers-down as 503 "no available server". So the 404 means
# no Traefik router matched at that instant. Polling /health/ready through :9200 until
# it answers 200 proves both halves are in place — the router table carries the
# otopcua-admin router AND a backend is up — before the single, un-retried POST.
#
# A 404 AFTER the gate is therefore new evidence, not the known boot race: it should be
# investigated against the Traefik access log (`--accesslog=true` on the traefik service;
# `docker compose -f docker-dev/docker-compose.yml logs traefik`), which records the
# RouterName (empty on an unmatched request) for the failing call.
#
# Usage:
# ./deploy.sh # no body (matches the bare README snippet)
# ./deploy.sh artifact.json # send that file as the JSON request body
# cat artifact.json | ./deploy.sh
#
set -euo pipefail
BASE_URL="${OTOPCUA_ADMIN_URL:-http://localhost:9200}"
DEPLOY_URL="$BASE_URL/api/deployments"
READY_URL="$BASE_URL/health/ready"
API_KEY="${OTOPCUA_DEPLOY_API_KEY:-docker-dev-deploy-key}"
READY_TIMEOUT_SECONDS="${OTOPCUA_READY_TIMEOUT_SECONDS:-120}"
READY_POLL_SECONDS=2
# --- 1. readiness gate -------------------------------------------------------
echo "Waiting for $READY_URL (up to ${READY_TIMEOUT_SECONDS}s)..."
deadline=$(( $(date +%s) + READY_TIMEOUT_SECONDS ))
ready=0
while [ "$(date +%s)" -lt "$deadline" ]; do
code="$(curl -s -o /dev/null -w "%{http_code}" "$READY_URL" || true)"
if [ "$code" = "200" ]; then
ready=1
break
fi
echo " not ready yet (HTTP ${code:-000}); retrying in ${READY_POLL_SECONDS}s"
sleep "$READY_POLL_SECONDS"
done
if [ "$ready" -ne 1 ]; then
echo "ERROR: $READY_URL did not return 200 within ${READY_TIMEOUT_SECONDS}s — not posting." >&2
echo " Check the stack is up: docker compose -f docker-dev/docker-compose.yml ps" >&2
exit 1
fi
echo "Ready. Posting deployment..."
# --- 2. the single, un-retried POST ------------------------------------------
curl_args=(-s -w '\n%{http_code}' -X POST "$DEPLOY_URL"
-H "X-Api-Key: $API_KEY"
-H "Content-Type: application/json")
if [ "$#" -ge 1 ]; then
[ -f "$1" ] || { echo "ERROR: body file not found: $1" >&2; exit 2; }
curl_args+=(--data-binary "@$1")
elif [ ! -t 0 ]; then
curl_args+=(--data-binary @-)
fi
response="$(curl "${curl_args[@]}")"
status="$(printf '%s' "$response" | tail -n1)"
body="$(printf '%s' "$response" | sed '$d')"
echo "HTTP $status"
if [ -n "$body" ]; then printf '%s\n' "$body"; fi
case "$status" in
2*)
exit 0
;;
404)
echo "ERROR: POST $DEPLOY_URL returned 404 AFTER the readiness gate passed." >&2
echo " This is NEW evidence — the AdminUI deploy endpoint has no 404 path and a" >&2
echo " down backend answers 503, so no Traefik router matched this request." >&2
echo " Inspect the Traefik access log (RouterName is empty on an unmatched request):" >&2
echo " docker compose -f docker-dev/docker-compose.yml logs traefik" >&2
exit 1
;;
*)
echo "ERROR: POST $DEPLOY_URL failed with HTTP $status." >&2
exit 1
;;
esac
+8
View File
@@ -914,6 +914,14 @@ services:
- --providers.file.filename=/etc/traefik/dynamic.yml
- --providers.file.watch=true
- --api.insecure=true
# Access log, on deliberately: the 2026-08-17 live gate saw a transient 404 on the
# first POST /api/deployments (~40 s after boot) that can only mean "no router
# matched" — the AdminUI has no 404 path on that endpoint and a matched router with
# every server down answers 503. `defaultmode=keep` keeps RouterName/ServiceName in
# each line, which is empty on an unmatched request — that is how the next
# occurrence gets diagnosed. See docker-dev/README.md §"First-POST 404".
- --accesslog=true
- --accesslog.fields.defaultmode=keep
ports:
- "9200:80" # host port 9200 → traefik :80 entrypoint (80 conflicts with scadabridge-traefik)
- "8089:8080" # 8080 conflicts with the sister scadalink dev stack