3f04a4d4e5
Live gate on the local OrbStack docker-dev rig (branch tip images, fresh down -v + up -d --build). 404: did NOT reproduce through deploy.sh on a fresh boot — the readiness gate passed on its first poll, and compose's depends_on: service_healthy serialization means up -d returns ~30 s after Traefik starts, so the window is already closed. Reproduced it directly instead, by rapid-polling :9200 across a traefik container restart: connection-refused -> 404 for ~65-160 ms -> 200. Body is exactly Go's "404 page not found" (19 bytes) and the access log records it with an EMPTY RouterName/ServiceName at 0ms, so no router matched and no backend was dialled. Mechanism: the entrypoint binds before the dynamic file-provider config is loaded. The competing candidate (health- checked service with no healthy server) is excluded — that is a matched router answering 503, and there are zero 503s in the log. Caveat recorded: the earliest 404s predate the accesslog handler itself and are not logged, so an empty access log does not prove absence. No-body POST: verified, not 400. The minimal-API DeployRequest? binds both a missing body and a Content-Length: 0 body as null; the handler falls back to createdBy = "rest-api". A bare ./docker-dev/deploy.sh answered 202 Accepted and the Deployment row carried CreatedBy = rest-api. deploy.sh's header NOTE goes from "unverified" to the observed truth.
114 lines
5.2 KiB
Bash
Executable File
114 lines
5.2 KiB
Bash
Executable File
#!/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.
|
|
#
|
|
# CONFIRMED 2026-08-18: the window is between Traefik binding the `web` entrypoint and
|
|
# loading its dynamic file-provider config — the port accepts connections before the
|
|
# router table exists, so requests get Go's default `404 page not found` (19 bytes),
|
|
# access-logged with an EMPTY RouterName/ServiceName and 0ms. Measured at ~65-160 ms
|
|
# across a `docker restart` of the traefik container. See docker-dev/README.md
|
|
# §"First-POST 404". 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. Caveat: the earliest
|
|
# 404s of the window predate the accesslog handler itself and are NOT logged at all, so
|
|
# an empty access log does not prove the 404 did not happen.
|
|
#
|
|
# 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
|
|
#
|
|
# NOTE (verified on the docker-dev rig 2026-08-18): the no-body invocation sends
|
|
# `Content-Type: application/json` with no body (or `Content-Length: 0` when stdin is a
|
|
# redirected-but-empty non-tty, which takes the `--data-binary @-` branch below). The
|
|
# minimal-API `DeployRequest?` parameter binds BOTH shapes as null — it does NOT 400 —
|
|
# and the handler falls back to `createdBy = "rest-api"` (DeployApiEndpoints.cs). Confirmed
|
|
# end to end: a bare `./deploy.sh` returned `HTTP 202 {"outcome":"Accepted",...}` and the
|
|
# resulting Deployment row carried `CreatedBy = rest-api`.
|
|
#
|
|
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
|
|
# --max-time bounds a connected-but-hung backend, so one poll can never eat the budget.
|
|
code="$(curl -s --max-time 10 -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 --max-time 60 -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
|
|
|
|
# Guarded: a transport-level curl failure (connection refused/reset, timeout) must not trip
|
|
# `set -e` before the status/body reporting below — that reporting is the whole point here.
|
|
if ! response="$(curl "${curl_args[@]}")"; then
|
|
echo "ERROR: POST $DEPLOY_URL failed (curl could not complete the request)." >&2
|
|
exit 1
|
|
fi
|
|
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
|