chore(docker-dev): readiness-gated deploy.sh + Traefik access log for the first-POST 404
This commit is contained in:
+25
-2
@@ -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
|
||||
|
||||
Executable
+90
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user