diff --git a/.github/workflows/compose-smoke.yml b/.github/workflows/compose-smoke.yml new file mode 100644 index 0000000..3dae560 --- /dev/null +++ b/.github/workflows/compose-smoke.yml @@ -0,0 +1,104 @@ +name: Compose smoke + +# Brings up the one-command demo stack (deploy/docker-compose.demo.yml) on a +# clean runner and checks it works end to end: the dashboard answers and the +# pre-enrolled worker is online. Guards the demo against rot. + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + demo-stack: + name: Demo compose stack starts and enrols + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + COMPOSE: docker compose -f deploy/docker-compose.demo.yml + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Start the stack + run: | + echo "STARTED_AT=$(date +%s)" >> "$GITHUB_ENV" + # Not `up --wait`: the stack has one-shot services (certs, seed) + # that exit by design, which --wait can treat as a failure. + # `up -d` still waits on depends_on conditions (server healthy, seed + # completed) and exits non-zero if one is not met in time, e.g. while + # SQL Server warms up or a service restarts. The polling steps below + # are the real gate, so record the exit code and state, then go on. + rc=0 + $COMPOSE up -d || rc=$? + if [ "$rc" -ne 0 ]; then + echo "::warning::compose up exited $rc; continuing to the readiness checks" + $COMPOSE ps -a + $COMPOSE logs --no-color --tail=50 worker server seed certs || true + fi + + - name: Wait for the dashboard + run: | + for i in $(seq 1 120); do + if curl -fsS -o /dev/null http://localhost:8080/api/auth/config; then + echo "Dashboard answered after ~$((i * 5))s of polling" + exit 0 + fi + sleep 5 + done + echo "Dashboard did not answer on http://localhost:8080 in 10 minutes" >&2 + exit 1 + + - name: Dashboard serves HTML + run: | + curl -fsS http://localhost:8080/ | grep -qi ' /dev/null + api -X POST http://localhost:8080/api/auth/login \ + -d '{"username":"admin","password":"rsagent-demo"}' > /dev/null + for i in $(seq 1 90); do + if api http://localhost:8080/api/workers \ + | jq -e '.workers | any(.hostName == "DEMO-SQLHOST01" and .online == true)' > /dev/null; then + echo "Worker DEMO-SQLHOST01 is online" + exit 0 + fi + sleep 5 + done + echo "Worker DEMO-SQLHOST01 never came online" >&2 + api http://localhost:8080/api/workers | jq . >&2 || true + exit 1 + + - name: Record startup duration + if: success() + run: | + secs=$(( $(date +%s) - STARTED_AT )) + { + echo "### Demo stack startup" + echo + echo "Dashboard reachable and worker enrolled in **${secs}s** from \`compose up\`." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Logs and container state + if: failure() + run: | + $COMPOSE ps -a + echo "::group::worker logs" + $COMPOSE logs --no-color --tail=200 worker || true + echo "::endgroup::" + $COMPOSE logs --no-color --tail=200 + + - name: Tear down + if: always() + run: $COMPOSE down -v diff --git a/deploy/demo/mint-token.mjs b/deploy/demo/mint-token.mjs index 5050f27..9a74c9f 100644 --- a/deploy/demo/mint-token.mjs +++ b/deploy/demo/mint-token.mjs @@ -36,18 +36,33 @@ async function call(path, init = {}) { }); remember(response); if (!response.ok) { - throw new Error(`${init.method ?? 'GET'} ${path} -> ${response.status} ${await response.text()}`); + const error = new Error(`${init.method ?? 'GET'} ${path} -> ${response.status} ${await response.text()}`); + error.status = response.status; + throw error; } return response.json(); } -await call('/api/auth/config'); -await call('/api/auth/login', { - method: 'POST', - body: JSON.stringify({ username, password: adminPassword }), -}); -const minted = await call('/api/enrolment-tokens', { - method: 'POST', - body: JSON.stringify({ hostName, credentialMode: 'token', capabilities: ['readOnly'] }), -}); -process.stdout.write(minted.token); +async function mint() { + await call('/api/auth/config'); + await call('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password: adminPassword }), + }); + const minted = await call('/api/enrolment-tokens', { + method: 'POST', + body: JSON.stringify({ hostName, credentialMode: 'token', capabilities: ['observe'] }), + }); + return minted.token; +} + +// Exit 78 (EX_CONFIG) means the server rejected the request (4xx: bad +// credentials, bad capability, rate limited). Retrying cannot fix that, so +// worker-entry.sh stops instead of hammering /api/auth/login. Anything else +// (server still starting, network) exits 1 and is retried by compose. +try { + process.stdout.write(await mint()); +} catch (error) { + console.error(`[demo] ${error.message}`); + process.exit(error.status >= 400 && error.status < 500 ? 78 : 1); +} diff --git a/deploy/demo/worker-entry.sh b/deploy/demo/worker-entry.sh index c6296f2..d291e15 100644 --- a/deploy/demo/worker-entry.sh +++ b/deploy/demo/worker-entry.sh @@ -12,7 +12,16 @@ PKG=/app/worker-packages/rsagent-worker-linux.tar.gz tar -xzf "$PKG" -C "$RUN" ./rsagent-worker.mjs echo "[demo] minting an enrolment token as the bootstrap admin..." -TOKEN="$(node /demo/mint-token.mjs)" +rc=0 +TOKEN="$(node /demo/mint-token.mjs)" || rc=$? +if [ "$rc" -eq 78 ]; then + # The server answered with a 4xx. Retrying cannot help and would only burn + # the login rate limit, so exit 0 (compose's on-failure policy won't restart). + echo "[demo] the control plane rejected the enrolment request (see above); not retrying." >&2 + exit 0 +elif [ "$rc" -ne 0 ]; then + exit "$rc" +fi echo "[demo] enrolling ${RSAGENT_WORKER_HOST}..." node "$RUN/rsagent-worker.mjs" enrol --token "$TOKEN" /demo/worker.demo.yaml diff --git a/deploy/docker-compose.demo.yml b/deploy/docker-compose.demo.yml index 957699d..9f12479 100644 --- a/deploy/docker-compose.demo.yml +++ b/deploy/docker-compose.demo.yml @@ -195,7 +195,7 @@ services: cap_drop: - ALL read_only: true - restart: on-failure + restart: on-failure:5 mem_limit: 256m # --- Drift scenario --------------------------------------------------------