diff --git a/docker-dev/README.md b/docker-dev/README.md index fa55c9b8..c728f431 100644 --- a/docker-dev/README.md +++ b/docker-dev/README.md @@ -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 diff --git a/docker-dev/deploy.sh b/docker-dev/deploy.sh new file mode 100755 index 00000000..4309f35b --- /dev/null +++ b/docker-dev/deploy.sh @@ -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 diff --git a/docker-dev/docker-compose.yml b/docker-dev/docker-compose.yml index 3ab758c0..97abc07c 100644 --- a/docker-dev/docker-compose.yml +++ b/docker-dev/docker-compose.yml @@ -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