#!/usr/bin/env bash set -euo pipefail DOCKER_SMOKE_RUN_ID="${DOCKER_SMOKE_RUN_ID:-$$}" if [[ -z "$DOCKER_SMOKE_RUN_ID" || ${#DOCKER_SMOKE_RUN_ID} -gt 80 || ! $DOCKER_SMOKE_RUN_ID =~ ^[A-Za-z0-9_.-]+$ ]]; then echo "DOCKER_SMOKE_RUN_ID must contain 1-80 Docker-safe characters" >&2 exit 2 fi # shellcheck source=scripts/lib/docker-rls-role-fixture.sh source "$(dirname "${BASH_SOURCE[0]}")/lib/docker-rls-role-fixture.sh" # shellcheck source=scripts/lib/docker-runtime-smoke.sh source "$(dirname "${BASH_SOURCE[0]}")/lib/docker-runtime-smoke.sh" IMAGE="${1:-hivegrid-api:smoke}" MIGRATOR_IMAGE="${DOCKER_SMOKE_MIGRATOR_IMAGE:-hivegrid-web:smoke}" PORT="${DOCKER_API_SMOKE_PORT:-}" CONTAINER_NAME="${DOCKER_API_SMOKE_CONTAINER:-hivegrid-api-runtime-smoke-${DOCKER_SMOKE_RUN_ID}}" DB_CONTAINER_NAME="${DOCKER_API_SMOKE_DB_CONTAINER:-hivegrid-api-runtime-smoke-db-${DOCKER_SMOKE_RUN_ID}}" REDIS_CONTAINER_NAME="${DOCKER_API_SMOKE_REDIS_CONTAINER:-hivegrid-api-runtime-smoke-redis-${DOCKER_SMOKE_RUN_ID}}" NETWORK_NAME="${DOCKER_API_SMOKE_NETWORK:-hivegrid-api-runtime-smoke-${DOCKER_SMOKE_RUN_ID}}" MIGRATION_CONTAINER_NAME="${DOCKER_API_SMOKE_MIGRATION_CONTAINER:-hivegrid-api-runtime-smoke-migrate-${DOCKER_SMOKE_RUN_ID}}" ASSERT_CONTAINER_NAME="${DOCKER_API_SMOKE_ASSERT_CONTAINER:-hivegrid-api-runtime-smoke-assert-${DOCKER_SMOKE_RUN_ID}}" TRANSPORT="${DOCKER_API_SMOKE_TRANSPORT:-host}" SMOKE_REVISION="${DOCKER_API_SMOKE_REVISION:-}" HEALTH_ATTEMPTS="${DOCKER_API_SMOKE_HEALTH_ATTEMPTS:-120}" DB_PASSWORD="hivegrid" DB_NAME="hivegrid" DB_USER="hivegrid" APP_DB_PASSWORD="hivegrid-app-smoke" WORKER_DB_PASSWORD="hivegrid-worker-smoke" MIGRATOR_DB_PASSWORD="hivegrid-migrator-smoke" AUTHENTIK_JIT_DB_PASSWORD="hivegrid-authentik-jit-smoke" cleanup() { docker_smoke_cleanup_container "api:cleanup-containers" \ "$CONTAINER_NAME" "$MIGRATION_CONTAINER_NAME" "$ASSERT_CONTAINER_NAME" \ "$DB_CONTAINER_NAME" "$REDIS_CONTAINER_NAME" docker_smoke_cleanup_network "api:cleanup-network" "$NETWORK_NAME" } resolve_smoke_revision() { local image_revision if [[ -n "$SMOKE_REVISION" ]]; then printf "%s" "$SMOKE_REVISION" return fi docker_smoke_capture image_revision "api:inspect-revision" "$DOCKER_SMOKE_PROBE_TIMEOUT_SECONDS" \ docker image inspect -f '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$IMAGE" printf "%s" "$image_revision" } verify_migrator_revision() { local migrator_revision docker_smoke_capture migrator_revision "api:inspect-migrator-revision" "$DOCKER_SMOKE_PROBE_TIMEOUT_SECONDS" \ docker image inspect -f '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$MIGRATOR_IMAGE" if [[ "$migrator_revision" != "$SMOKE_REVISION" ]]; then echo "Migrator image ${MIGRATOR_IMAGE} revision ${migrator_revision:-} does not match API image revision ${SMOKE_REVISION}" >&2 exit 1 fi } http_status() { local path="$1" local code if [[ "$TRANSPORT" == "exec" ]]; then if ! docker_smoke_capture code "api:http-status" "$DOCKER_SMOKE_PROBE_TIMEOUT_SECONDS" \ docker exec "$CONTAINER_NAME" node -e 'const http = require("node:http"); const [path] = process.argv.slice(1); const request = http.request({ hostname: "127.0.0.1", port: 8080, path, headers: { Host: "api.localhost" } }, (response) => { process.stdout.write(String(response.statusCode ?? "000")); response.resume(); }); request.on("error", () => process.stdout.write("000")); request.end();' "$path" 2>/dev/null; then code="000" fi else if ! docker_smoke_capture code "api:http-status" "$DOCKER_SMOKE_PROBE_TIMEOUT_SECONDS" \ curl -s -o /dev/null -w "%{http_code}" -H "Host: api.localhost" "http://127.0.0.1:${PORT}${path}" 2>/dev/null; then code="000" fi fi if [[ -z "$code" ]]; then printf "000" return fi printf "%s" "$code" } http_body() { local path="$1" local body if [[ "$TRANSPORT" == "exec" ]]; then docker_smoke_capture body "api:http-body" "$DOCKER_SMOKE_PROBE_TIMEOUT_SECONDS" \ docker exec "$CONTAINER_NAME" node -e 'const http = require("node:http"); const [path] = process.argv.slice(1); const request = http.request({ hostname: "127.0.0.1", port: 8080, path, headers: { Host: "api.localhost" } }, (response) => { let body = ""; response.setEncoding("utf8"); response.on("data", (chunk) => { body += chunk; }); response.on("end", () => process.stdout.write(body)); }); request.on("error", () => process.exit(1)); request.end();' "$path" else docker_smoke_capture body "api:http-body" "$DOCKER_SMOKE_PROBE_TIMEOUT_SECONDS" \ curl --fail --silent --show-error -H "Host: api.localhost" "http://127.0.0.1:${PORT}${path}" fi printf "%s" "$body" } expect_status() { local path="$1" local expected="$2" local actual actual="$(http_status "$path")" if [[ "$actual" != "$expected" ]]; then echo "Expected ${path} to return ${expected}, got ${actual}" >&2 docker_smoke_logs "api:logs-status" "$CONTAINER_NAME" >&2 || true exit 1 fi } expect_health_revision() { local path="$1" local body local actual body="$(http_body "$path")" actual="$(printf "%s" "$body" | node -e 'const fs = require("node:fs"); try { const payload = JSON.parse(fs.readFileSync(0, "utf8")); process.stdout.write(payload.revision || ""); } catch { process.exit(1); }')" if [[ "$actual" != "$SMOKE_REVISION" ]]; then echo "Expected ${path} revision ${SMOKE_REVISION}, got ${actual:-}" >&2 docker_smoke_logs "api:logs-revision" "$CONTAINER_NAME" >&2 || true exit 1 fi } expect_openapi_json() { local path="$1" local body body="$(http_body "$path")" printf "%s" "$body" | node -e ' const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(0, "utf8")); if (typeof payload.openapi !== "string") throw new Error("missing openapi version"); if (!payload.paths || !payload.paths["/v1/health"] || !payload.paths["/v1/session/current-user"]) { throw new Error("missing expected OpenAPI paths"); } ' } assert_api_container_running() { local running if ! docker_smoke_capture running "api:inspect-running" "$DOCKER_SMOKE_PROBE_TIMEOUT_SECONDS" \ docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null; then running="" fi if [[ "$running" == "true" ]]; then return fi echo "API container stopped before becoming healthy" >&2 docker_smoke_run "api:inspect-exit" "$DOCKER_SMOKE_LOG_TIMEOUT_SECONDS" docker inspect \ -f 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}} error={{json .State.Error}} finished={{.State.FinishedAt}}' \ "$CONTAINER_NAME" >&2 || true docker_smoke_logs "api:logs-exit" "$CONTAINER_NAME" >&2 || true return 1 } docker_smoke_install_cleanup_traps cleanup cleanup SMOKE_REVISION="$(resolve_smoke_revision)" if [[ -z "$SMOKE_REVISION" || "$SMOKE_REVISION" == "" || "$SMOKE_REVISION" == "unknown" ]]; then echo "Image ${IMAGE} does not expose a concrete org.opencontainers.image.revision; build with --build-arg APP_REVISION or set DOCKER_API_SMOKE_REVISION explicitly" >&2 exit 1 fi verify_migrator_revision docker_smoke_run "api:create-network" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \ docker network create "$NETWORK_NAME" >/dev/null docker_smoke_run "api:start-redis" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \ docker run -d --name "$REDIS_CONTAINER_NAME" --network "$NETWORK_NAME" --network-alias redis redis:7-alpine >/dev/null for attempt in $(seq 1 30); do if docker_smoke_probe "api:probe-redis" docker exec "$REDIS_CONTAINER_NAME" redis-cli ping 2>/dev/null | grep -q PONG; then break fi if [[ "$attempt" == "30" ]]; then echo "Redis smoke service did not become ready" >&2 docker_smoke_logs "api:logs-redis" "$REDIS_CONTAINER_NAME" >&2 || true exit 1 fi sleep 1 done docker_smoke_run "api:start-db" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" docker run -d --name "$DB_CONTAINER_NAME" --network "$NETWORK_NAME" --network-alias db \ -e POSTGRES_USER="$DB_USER" \ -e POSTGRES_PASSWORD="$DB_PASSWORD" \ -e POSTGRES_DB="$DB_NAME" \ postgres:16-alpine >/dev/null MIGRATOR_DATABASE_URL="postgresql://hivegrid_migrator:${MIGRATOR_DB_PASSWORD}@db:5432/${DB_NAME}?schema=public" APP_DATABASE_URL="postgresql://hivegrid_app:${APP_DB_PASSWORD}@db:5432/${DB_NAME}?schema=public" # Probe over TCP, not the unix socket. On first boot the postgres image starts a # temporary server that listens on the socket ONLY (initdb + init scripts), then # shuts it down before starting the real one. A socket-based pg_isready reports # ready during that window, so the very next psql can hit # "FATAL: the database system is shutting down". The temp server sets # listen_addresses='', so a TCP probe cannot succeed too early. if ! docker_smoke_wait_for_postgres "api:probe-db" \ "$DB_CONTAINER_NAME" "$DB_USER" "$DB_NAME"; then echo "Postgres smoke database did not become ready within the ${DOCKER_SMOKE_DATABASE_READY_TIMEOUT_SECONDS}-second budget" >&2 docker_smoke_logs "api:logs-db" "$DB_CONTAINER_NAME" >&2 || true exit 1 fi provision_docker_smoke_rls_roles \ "api:provision-rls-roles" \ "$DB_CONTAINER_NAME" "$DB_USER" "$DB_NAME" \ "$MIGRATOR_DB_PASSWORD" "$APP_DB_PASSWORD" "$WORKER_DB_PASSWORD" "$AUTHENTIK_JIT_DB_PASSWORD" # The web image is the deployment migration authority and already contains the # reviewed migrations plus Prisma CLI. Reusing it keeps this isolated smoke job # independent from host package managers without widening the API runtime. docker_smoke_run "api:migrate" "$DOCKER_SMOKE_MIGRATION_TIMEOUT_SECONDS" docker run --rm --name "$MIGRATION_CONTAINER_NAME" --network "$NETWORK_NAME" \ -e DIRECT_URL="$MIGRATOR_DATABASE_URL" \ "$MIGRATOR_IMAGE" migrate # API runtime instances never migrate schemas. Keeping the optional Prisma CLI # out of this image cuts the attack surface and leaves migration authority with # the dedicated deployment workflow. docker_smoke_run "api:assert-runtime" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" docker run --rm --name "$ASSERT_CONTAINER_NAME" --entrypoint node "$IMAGE" -e ' try { require.resolve("prisma"); process.exit(1); } catch (error) { if (error?.code !== "MODULE_NOT_FOUND") throw error; } ' >/dev/null publish_args=(-p 127.0.0.1::8080) if [[ -n "$PORT" ]]; then publish_args=(-p "${PORT}:8080") fi docker_smoke_run "api:start-runtime" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" docker run -d --name "$CONTAINER_NAME" --network "$NETWORK_NAME" "${publish_args[@]}" \ -e NODE_ENV=production \ -e PORT=8080 \ -e API_PORT=8080 \ -e APP_NAME=HiveGrid \ -e APP_BASE_DOMAIN=localhost \ -e WEB_APP_HOST=app.localhost \ -e ADMIN_APP_HOST=admin.localhost \ -e API_HOST=api.localhost \ -e APP_PROTOCOL=http \ -e DATABASE_URL="$APP_DATABASE_URL" \ -e DATABASE_RUNTIME_ROLE=hivegrid_app \ -e RLS_RUNTIME_MODE=required \ -e REDIS_URL='redis://redis:6379/0' \ -e SESSION_SECRET='deploy-smoke-session-secret-minimum-32' \ -e CRYPTO_SECRET='deploy-smoke-crypto-secret-minimum-32' \ -e ADMIN_CONNECTION_MASTER_KEY='deploy-smoke-admin-connection-key-minimum-32' \ -e JWT_SECRET='deploy-smoke-jwt-secret-minimum-32' \ -e API_ACCESS_TOKEN='deploy-smoke-api-access-token-minimum-32' \ -e ANALYTICS_TOKEN='deploy-smoke-analytics-token-minimum-32' \ -e AUDIT_CHAIN_SECRET='deploy-smoke-audit-chain-secret-minimum-32' \ "$IMAGE" >/dev/null if [[ -z "$PORT" ]]; then docker_smoke_capture PORT "api:resolve-port" "$DOCKER_SMOKE_PROBE_TIMEOUT_SECONDS" \ docker port "$CONTAINER_NAME" 8080/tcp PORT="$(printf "%s\n" "$PORT" | awk -F: 'NR == 1 { print $NF }')" fi for attempt in $(seq 1 "$HEALTH_ATTEMPTS"); do assert_api_container_running if [[ "$(http_status /healthz)" == "200" ]]; then break fi if [[ "$attempt" == "$HEALTH_ATTEMPTS" ]]; then echo "API container did not become healthy on port ${PORT} after ${HEALTH_ATTEMPTS}s" >&2 docker_smoke_logs "api:logs-health" "$CONTAINER_NAME" >&2 || true exit 1 fi sleep 1 done expect_status /healthz 200 expect_health_revision /healthz expect_status /v1/docs 200 expect_status /v1/openapi.json 200 expect_openapi_json /v1/openapi.json expect_status /readyz 200 echo "Docker API runtime smoke passed for ${IMAGE}"