#!/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