#!/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-web:smoke}" PORT="${DOCKER_WEB_SMOKE_PORT:-}" ADMIN_PORT="${DOCKER_WEB_SMOKE_ADMIN_PORT:-}" PORTAL_PORT="${DOCKER_WEB_SMOKE_PORTAL_PORT:-}" CORE_CONTAINER_NAME="${DOCKER_WEB_SMOKE_CONTAINER:-hivegrid-web-runtime-smoke-${DOCKER_SMOKE_RUN_ID}}" ADMIN_CONTAINER_NAME="${DOCKER_WEB_SMOKE_ADMIN_CONTAINER:-hivegrid-admin-runtime-smoke-${DOCKER_SMOKE_RUN_ID}}" PORTAL_CONTAINER_NAME="${DOCKER_WEB_SMOKE_PORTAL_CONTAINER:-hivegrid-portal-runtime-smoke-${DOCKER_SMOKE_RUN_ID}}" CONTAINER_NAME="$CORE_CONTAINER_NAME" DB_CONTAINER_NAME="${DOCKER_WEB_SMOKE_DB_CONTAINER:-hivegrid-web-runtime-smoke-db-${DOCKER_SMOKE_RUN_ID}}" REDIS_CONTAINER_NAME="${DOCKER_WEB_SMOKE_REDIS_CONTAINER:-hivegrid-web-runtime-smoke-redis-${DOCKER_SMOKE_RUN_ID}}" NETWORK_NAME="${DOCKER_WEB_SMOKE_NETWORK:-hivegrid-web-runtime-smoke-${DOCKER_SMOKE_RUN_ID}}" SQL_ARTIFACT_CONTAINER_NAME="${DOCKER_WEB_SMOKE_SQL_ARTIFACT_CONTAINER:-hivegrid-web-runtime-smoke-sql-artifact-${DOCKER_SMOKE_RUN_ID}}" PASSWORD_HASH_CONTAINER_NAME="${DOCKER_WEB_SMOKE_PASSWORD_HASH_CONTAINER:-hivegrid-web-runtime-smoke-password-hash-${DOCKER_SMOKE_RUN_ID}}" MIGRATION_CONTAINER_NAME="${DOCKER_WEB_SMOKE_MIGRATION_CONTAINER:-hivegrid-web-runtime-smoke-migrate-${DOCKER_SMOKE_RUN_ID}}" DB_PUSH_CONTAINER_NAME="${DOCKER_WEB_SMOKE_DB_PUSH_CONTAINER:-hivegrid-web-runtime-smoke-db-push-${DOCKER_SMOKE_RUN_ID}}" TRANSPORT="${DOCKER_WEB_SMOKE_TRANSPORT:-host}" SMOKE_REVISION="${DOCKER_WEB_SMOKE_REVISION:-}" HEALTH_ATTEMPTS="${DOCKER_WEB_SMOKE_HEALTH_ATTEMPTS:-600}" 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" SMOKE_LOGIN_TENANT_ID="docker-runtime-login-tenant" SMOKE_LOGIN_TENANT_SLUG="docker-runtime-login" SMOKE_LOGIN_USER_ID="docker-runtime-login-user" SMOKE_LOGIN_EMAIL="docker.runtime.login.01@mitarbeiter.example.test" SMOKE_LOGIN_PASSWORD='Docker-Runtime-Smoke-Only-2026!' CATALOG_SENTINEL_TENANT_ID="docker-catalog-sentinel-tenant" CATALOG_SENTINEL_ROLE_ID="docker-catalog-sentinel-role" CATALOG_SENTINEL_GRANT_ID="docker-catalog-sentinel-grant" CATALOG_STALE_GRANT_ID="docker-catalog-stale-global-grant" SMOKE_WEB_PUSH_VAPID_PUBLIC_KEY='BHzyexiNA09-ilI4AwS1GsPAiWnid_IbNaYLSPxHZpl4B3dVENuO0EApPZrGn3Qw27p9reY86YIpngS3nSJ4c9E' SMOKE_GDPR_EXPORT_SSE_C_KEY_BASE64='BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=' cleanup() { docker_smoke_cleanup_container "web:cleanup:containers" \ "$CORE_CONTAINER_NAME" "$ADMIN_CONTAINER_NAME" "$PORTAL_CONTAINER_NAME" "$DB_CONTAINER_NAME" "$REDIS_CONTAINER_NAME" \ "$SQL_ARTIFACT_CONTAINER_NAME" "$PASSWORD_HASH_CONTAINER_NAME" "$MIGRATION_CONTAINER_NAME" "$DB_PUSH_CONTAINER_NAME" docker_smoke_cleanup_network "web:cleanup:network" "$NETWORK_NAME" } emit_runtime_logs() { local container_name="$1" docker_smoke_logs "web:runtime-logs" "$container_name" >&2 || true } resolve_smoke_revision() { if [[ -n "$SMOKE_REVISION" ]]; then printf "%s" "$SMOKE_REVISION" return fi docker_smoke_probe "web:image-revision" \ docker image inspect -f '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$IMAGE" } http_status() { local host="$1" local path="$2" local code if [[ "$TRANSPORT" == "exec" ]]; then code="$(docker_smoke_probe "web:http-status" docker exec "$CONTAINER_NAME" node -e 'const http = require("node:http"); const [host, path] = process.argv.slice(1); const request = http.request({ hostname: "127.0.0.1", port: 8080, path, headers: { Host: host } }, (response) => { process.stdout.write(String(response.statusCode ?? "000")); response.resume(); }); request.on("error", () => process.stdout.write("000")); request.end();' "$host" "$path" 2>/dev/null || true)" else code="$(docker_smoke_probe "web:http-status" curl -s -o /dev/null -w "%{http_code}" -H "Host: ${host}" "http://127.0.0.1:${PORT}${path}" 2>/dev/null || true)" fi if [[ -z "$code" ]]; then printf "000" return fi printf "%s" "$code" } http_body() { local host="$1" local path="$2" if [[ "$TRANSPORT" == "exec" ]]; then docker_smoke_probe "web:http-body" docker exec "$CONTAINER_NAME" node -e 'const http = require("node:http"); const [host, path] = process.argv.slice(1); const request = http.request({ hostname: "127.0.0.1", port: 8080, path, headers: { Host: host } }, (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();' "$host" "$path" else docker_smoke_probe "web:http-body" curl --fail --silent --show-error -H "Host: ${host}" "http://127.0.0.1:${PORT}${path}" fi } http_header() { local host="$1" local path="$2" local header_name="$3" if [[ "$TRANSPORT" == "exec" ]]; then docker_smoke_probe "web:http-header" docker exec "$CONTAINER_NAME" node -e 'const http = require("node:http"); const [host, path, headerName] = process.argv.slice(1); const request = http.request({ hostname: "127.0.0.1", port: 8080, path, method: "HEAD", headers: { Host: host } }, (response) => { const value = response.headers[headerName.toLowerCase()]; process.stdout.write(Array.isArray(value) ? value.join(", ") : value ?? ""); response.resume(); }); request.on("error", () => process.exit(1)); request.end();' "$host" "$path" "$header_name" else docker_smoke_probe "web:http-header" curl --fail --silent --show-error --head -H "Host: ${host}" "http://127.0.0.1:${PORT}${path}" \ | tr -d '\r' \ | awk -F': *' -v expected="$(printf '%s' "$header_name" | tr '[:upper:]' '[:lower:]')" 'tolower($1) == expected { sub(/^[^:]+:[[:space:]]*/, ""); print; exit }' fi } expect_service_worker_csp() { local host="$1" local policy policy="$(http_header "$host" /sw.js Content-Security-Policy)" if [[ "$policy" != *"default-src 'none'"* || "$policy" != *"connect-src 'self'"* || "$policy" == *"connect-src 'none'"* ]]; then echo "Expected ${host}/sw.js to allow only same-origin worker fetches, got ${policy:-}" >&2 emit_runtime_logs "$CONTAINER_NAME" exit 1 fi } expect_service_worker_recovery_bootstrap() { local host="$1" local body body="$(http_body "$host" /login)" if ! printf '%s' "$body" | node -e 'const fs = require("node:fs"); const html = fs.readFileSync(0, "utf8"); const markerIndex = html.indexOf("service-worker-csp-recovery:v3"); const stylesheetIndex = html.indexOf("rel=\"stylesheet\""); const scriptStart = html.lastIndexOf("", scriptStart); const scriptTag = scriptStart >= 0 && scriptTagEnd > scriptStart ? html.slice(scriptStart, scriptTagEnd + 1) : ""; if (markerIndex < 0 || stylesheetIndex <= markerIndex || !/\snonce="[^"]+"/.test(scriptTag)) process.exit(1);'; then echo "Expected ${host}/login to emit the nonce-protected worker recovery before stylesheet discovery" >&2 emit_runtime_logs "$CONTAINER_NAME" exit 1 fi } expect_status() { local host="$1" local path="$2" local expected="$3" local actual actual="$(http_status "$host" "$path")" if [[ "$actual" != "$expected" ]]; then echo "Expected ${host}${path} to return ${expected}, got ${actual}" >&2 emit_runtime_logs "$CONTAINER_NAME" exit 1 fi } expect_health_revision() { local host="$1" local path="$2" local body local actual body="$(http_body "$host" "$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 ${host}${path} revision ${SMOKE_REVISION}, got ${actual:-}" >&2 emit_runtime_logs "$CONTAINER_NAME" exit 1 fi } expect_runtime_payload() { local host="$1" local path="$2" local expected_status="$3" local expected_service="$4" local expected_surface="$5" local body body="$(http_body "$host" "$path")" if ! printf "%s" "$body" | node -e ' const fs = require("node:fs"); const [expectedStatus, expectedService, expectedSurface, expectedRevision] = process.argv.slice(1); try { const payload = JSON.parse(fs.readFileSync(0, "utf8")); if ( payload.status !== expectedStatus || payload.service !== expectedService || payload.surface !== expectedSurface || payload.revision !== expectedRevision ) process.exit(1); } catch { process.exit(1); } ' "$expected_status" "$expected_service" "$expected_surface" "$SMOKE_REVISION"; then echo "Expected ${host}${path} to report status=${expected_status}, service=${expected_service}, surface=${expected_surface}, revision=${SMOKE_REVISION}" >&2 emit_runtime_logs "$CONTAINER_NAME" exit 1 fi } wait_for_runtime_health() { local host="$1" local label="$2" for attempt in $(seq 1 "$HEALTH_ATTEMPTS"); do HTTP_STATUS="$(http_status "$host" /healthz)" CONTAINER_HEALTH="$(docker_smoke_probe "web:health-inspect" docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' "$CONTAINER_NAME" 2>/dev/null || true)" if [[ "$HTTP_STATUS" == "200" && "$CONTAINER_HEALTH" == "healthy" ]]; then return fi if [[ "$(docker_smoke_probe "web:running-inspect" docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" != "true" ]]; then echo "${label} runtime container exited during boot" >&2 emit_runtime_logs "$CONTAINER_NAME" exit 1 fi if [[ "$CONTAINER_HEALTH" == "unhealthy" ]]; then echo "${label} container Docker HEALTHCHECK became unhealthy with HTTP status ${HTTP_STATUS}" >&2 docker_smoke_run "web:health-diagnostic" "$DOCKER_SMOKE_LOG_TIMEOUT_SECONDS" \ docker inspect -f '{{json .State.Health}}' "$CONTAINER_NAME" >&2 || true emit_runtime_logs "$CONTAINER_NAME" exit 1 fi if [[ "$attempt" == "$HEALTH_ATTEMPTS" ]]; then echo "${label} container did not become HTTP- and Docker-healthy on port ${PORT} after ${HEALTH_ATTEMPTS}s" >&2 emit_runtime_logs "$CONTAINER_NAME" exit 1 fi sleep 1 done } assert_runtime_boot_is_mutation_free() { local label="$1" local boot_logs docker_smoke_capture boot_logs "web:runtime-logs:capture" "$DOCKER_SMOKE_LOG_TIMEOUT_SECONDS" \ docker logs --tail "$DOCKER_SMOKE_LOG_TAIL" "$CONTAINER_NAME" if grep -Eq '^\[(?:boot|migrator)\]' <<<"$boot_logs"; then echo "${label} runtime unexpectedly executed migration or seed work" >&2 exit 1 fi if grep -q '\[DEP0169\]' <<<"$boot_logs"; then echo "${label} container used Node's deprecated url.parse() runtime path" >&2 emit_runtime_logs "$CONTAINER_NAME" exit 1 fi } apply_image_sql_artifact() { local artifact_path="$1" local stage="$2" docker_smoke_run "$stage" "$DOCKER_SMOKE_MIGRATION_TIMEOUT_SECONDS" bash -o pipefail -c ' docker run --rm --name "$1" --entrypoint /bin/sh "$2" \ -c "test -f \"\$1\" && cat \"\$1\"" _ "$6" \ | docker exec -e "PGPASSWORD=$3" -e PGOPTIONS="--client-min-messages=warning" -i "$4" \ psql -v ON_ERROR_STOP=1 -h 127.0.0.1 -U hivegrid_migrator -d "$5" >/dev/null ' _ "$SQL_ARTIFACT_CONTAINER_NAME" "$IMAGE" "$MIGRATOR_DB_PASSWORD" "$DB_CONTAINER_NAME" "$DB_NAME" "$artifact_path" } seed_password_login_fixture() { local password_hash # Generate the fixture hash with argon2 from the exact image under test. The # command substitution deliberately captures the hash so neither credential # material nor the hash is written to smoke logs. docker_smoke_capture password_hash "web:password-hash" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \ docker run --rm --name "$PASSWORD_HASH_CONTAINER_NAME" --entrypoint node "$IMAGE" -e ' import("argon2") .then(({ default: argon2 }) => argon2.hash(process.argv[1], { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1, })) .then((hash) => process.stdout.write(hash)) .catch(() => process.exit(1)); ' "$SMOKE_LOGIN_PASSWORD" if [[ "$password_hash" != '$argon2id$'* ]]; then echo "Exact web image failed to generate the password-login fixture hash" >&2 exit 1 fi docker_smoke_run "web:seed-login-fixture" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \ docker exec -i "$DB_CONTAINER_NAME" psql -v ON_ERROR_STOP=1 -q \ -U "$DB_USER" -d "$DB_NAME" \ -v smoke_password_hash="$password_hash" \ -v smoke_tenant_id="$SMOKE_LOGIN_TENANT_ID" \ -v smoke_tenant_slug="$SMOKE_LOGIN_TENANT_SLUG" \ -v smoke_user_id="$SMOKE_LOGIN_USER_ID" \ -v smoke_email="$SMOKE_LOGIN_EMAIL" >/dev/null <<'SQL' INSERT INTO "Tenant" ("id", "updatedAt", "slug", "name", "active") VALUES (:'smoke_tenant_id', now(), :'smoke_tenant_slug', 'Docker runtime login', true); INSERT INTO "User" ("id", "updatedAt", "email", "passwordHash", "firstName", "lastName", "defaultTenantId", "active") VALUES (:'smoke_user_id', now(), :'smoke_email', :'smoke_password_hash', 'Docker', 'Runtime', :'smoke_tenant_id', true); INSERT INTO "TenantUser" ("id", "tenantId", "userId", "type", "joined", "status") VALUES ('docker-runtime-login-membership', :'smoke_tenant_id', :'smoke_user_id', 0, 0, 2); SQL } expect_password_login_session() { if ! docker_smoke_run "web:password-login" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" docker exec \ -e SMOKE_LOGIN_EMAIL="$SMOKE_LOGIN_EMAIL" \ -e SMOKE_LOGIN_PASSWORD="$SMOKE_LOGIN_PASSWORD" \ "$CONTAINER_NAME" node -e ' const http = require("node:http"); function request(options, body) { return new Promise((resolve, reject) => { const req = http.request( { hostname: "127.0.0.1", port: 8080, ...options }, (response) => { let responseBody = ""; response.setEncoding("utf8"); response.on("data", (chunk) => { responseBody += chunk; }); response.on("end", () => resolve({ status: response.statusCode ?? 0, headers: response.headers, body: responseBody, })); }, ); req.on("error", reject); if (body) req.write(body); req.end(); }); } (async () => { const loginPage = await request({ path: "/login", method: "GET", headers: { Host: "app.localhost", Accept: "text/html" }, }); const csrf = loginPage.body.match(/]+name=["\x27]csrf-token["\x27][^>]+content=["\x27]([^"\x27]+)["\x27]/i)?.[1]; const loginPageCookies = loginPage.headers["set-cookie"] ?? []; const cookieHeader = loginPageCookies.map((value) => value.split(";", 1)[0]).join("; "); if (loginPage.status !== 200 || !csrf || !cookieHeader) { throw new Error("login bootstrap did not provide status, CSRF, and cookies"); } const form = new URLSearchParams({ email: process.env.SMOKE_LOGIN_EMAIL, password: process.env.SMOKE_LOGIN_PASSWORD, redirectTo: "", csrf, }).toString(); const loginResponse = await request({ path: "/login", method: "POST", headers: { Host: "app.localhost", Origin: "http://app.localhost", Referer: "http://app.localhost/login", Accept: "text/html,application/xhtml+xml", Cookie: cookieHeader, "Content-Type": "application/x-www-form-urlencoded", "Content-Length": Buffer.byteLength(form), }, }, form); const responseCookies = loginResponse.headers["set-cookie"] ?? []; const hasAuthenticatedCookie = responseCookies.some((value) => value.startsWith("__Host-hivegrid_app_session=")); const location = loginResponse.headers.location ?? ""; if (![302, 303].includes(loginResponse.status) || !hasAuthenticatedCookie || !location || location.includes("/login")) { throw new Error(`login did not create an authenticated redirect (status ${loginResponse.status})`); } })().catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : "password login failed"}\n`); process.exit(1); }); ' >/dev/null; then echo "Expected exact web image to complete password login under required RLS" >&2 emit_runtime_logs "$CONTAINER_NAME" exit 1 fi local active_session_count docker_smoke_capture active_session_count "web:password-session" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \ docker exec -i "$DB_CONTAINER_NAME" psql -v ON_ERROR_STOP=1 -qAt \ -U "$DB_USER" -d "$DB_NAME" \ -v smoke_user_id="$SMOKE_LOGIN_USER_ID" <<'SQL' SELECT count(*) FROM "UserSession" WHERE "userId" = :'smoke_user_id' AND "revokedAt" IS NULL AND "expiresAt" > now(); SQL if [[ "$active_session_count" != "1" ]]; then echo "Expected password login to create exactly one active server session, got ${active_session_count:-0}" >&2 emit_runtime_logs "$CONTAINER_NAME" exit 1 fi } run_migrator() { local output_variable="$1" local stage="$2" docker_smoke_capture "$output_variable" "$stage" "$DOCKER_SMOKE_MIGRATION_TIMEOUT_SECONDS" \ docker run --rm --name "$MIGRATION_CONTAINER_NAME" --network "$NETWORK_NAME" \ -e DIRECT_URL="$MIGRATOR_DATABASE_URL" \ "$IMAGE" migrate } expect_migrator_completion() { local logs="$1" if ! grep -q '^\[migrator\] prisma migrate deploy' <<<"$logs"; then echo "One-shot migrator logs are missing the migration banner" >&2 exit 1 fi if ! grep -q '^\[migrator\] sync permission catalog' <<<"$logs"; then echo "One-shot migrator logs are missing the permission catalog sync banner" >&2 exit 1 fi if ! grep -q '"event":"permission-catalog-sync-complete"' <<<"$logs"; then echo "One-shot migrator logs are missing the completed permission catalog postcondition" >&2 exit 1 fi } expect_permission_catalog_contract() { local contract_status docker_smoke_capture contract_status "web:permission-catalog-contract" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \ docker exec -i "$DB_CONTAINER_NAME" psql -v ON_ERROR_STOP=1 -qAt \ -U "$DB_USER" -d "$DB_NAME" <<'SQL' WITH catalog_tables AS ( SELECT relation.relname, pg_get_userbyid(relation.relowner) AS owner_name, relation.relrowsecurity AS rls_enabled, relation.relforcerowsecurity AS force_rls, has_table_privilege('hivegrid_rls_definer', format('%I.%I', namespace.nspname, relation.relname), 'SELECT') AS can_select, has_table_privilege('hivegrid_rls_definer', format('%I.%I', namespace.nspname, relation.relname), 'INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER') AS has_table_dml, has_any_column_privilege('hivegrid_rls_definer', format('%I.%I', namespace.nspname, relation.relname), 'INSERT,UPDATE,REFERENCES') AS has_column_dml FROM pg_class AS relation JOIN pg_namespace AS namespace ON namespace.oid = relation.relnamespace WHERE namespace.nspname = 'public' AND relation.relname IN ('Permission', 'Role', 'RolePermission') ), canonical_roles AS ( SELECT * FROM "Role" WHERE "tenantId" IS NULL AND "name" IN ('SuperAdmin', 'SuperUser', 'Admin', 'Billing Admin', 'User') ) SELECT ( (SELECT count(*) = 3 AND bool_and(owner_name = 'hivegrid_migrator' AND rls_enabled AND force_rls AND can_select AND NOT has_table_dml AND NOT has_column_dml) FROM catalog_tables) AND (SELECT count(*) = 5 AND bool_and(NOT "isTemplateOrigin") FROM canonical_roles) AND EXISTS ( SELECT 1 FROM canonical_roles WHERE "name" = 'SuperUser' AND "description" = 'Has all app permissions' AND "type" = 'app' AND NOT "assignToNewUsers" AND "isDefault" AND "order" = 2 ) AND EXISTS ( SELECT 1 FROM "Permission" WHERE "name" = 'chat.write' AND "description" = 'Default permission chat.write' AND "type" = 'app' AND "isDefault" AND "order" > 0 ) AND EXISTS ( SELECT 1 FROM "RolePermission" AS grant_row JOIN "Role" AS role_row ON role_row."id" = grant_row."roleId" JOIN "Permission" AS permission_row ON permission_row."id" = grant_row."permissionId" WHERE role_row."tenantId" IS NULL AND role_row."name" = 'SuperUser' AND permission_row."name" = 'chat.write' ) AND NOT EXISTS ( SELECT 1 FROM "RolePermission" AS grant_row JOIN "Role" AS role_row ON role_row."id" = grant_row."roleId" JOIN "Permission" AS permission_row ON permission_row."id" = grant_row."permissionId" WHERE role_row."tenantId" IS NULL AND role_row."name" = 'User' AND permission_row."name" = 'admin.accounts.view' ) )::text; SQL if [[ "$contract_status" != "true" ]]; then echo "Permission catalog data, FORCE RLS, or definer ACL postcondition failed" >&2 exit 1 fi } seed_permission_catalog_sentinel() { docker_smoke_run "web:seed-permission-sentinel" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \ docker exec -i "$DB_CONTAINER_NAME" psql -v ON_ERROR_STOP=1 -q \ -U "$DB_USER" -d "$DB_NAME" \ -v sentinel_tenant_id="$CATALOG_SENTINEL_TENANT_ID" \ -v sentinel_role_id="$CATALOG_SENTINEL_ROLE_ID" \ -v sentinel_grant_id="$CATALOG_SENTINEL_GRANT_ID" >/dev/null <<'SQL' INSERT INTO "Tenant" ("id", "updatedAt", "slug", "name", "active") VALUES (:'sentinel_tenant_id', now(), 'docker-catalog-sentinel', 'Docker catalog sentinel', true); INSERT INTO "Role" ( "id", "updatedAt", "tenantId", "name", "description", "type", "assignToNewUsers", "isDefault", "order", "seatCategory", "isTemplateOrigin" ) VALUES ( :'sentinel_role_id', now(), :'sentinel_tenant_id', 'Admin', 'Tenant-owned byte sentinel', 'app', true, true, 777, 'office', true ); INSERT INTO "RolePermission" ("id", "roleId", "permissionId") SELECT :'sentinel_grant_id', :'sentinel_role_id', "id" FROM "Permission" WHERE "name" = 'app.dashboard.view'; SQL } permission_catalog_state_snapshot() { local snapshot docker_smoke_capture snapshot "web:permission-state-snapshot" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \ docker exec -i "$DB_CONTAINER_NAME" psql -v ON_ERROR_STOP=1 -qAt \ -U "$DB_USER" -d "$DB_NAME" <<'SQL' SELECT md5( COALESCE(( SELECT string_agg(concat_ws('|', "id", "name", "description", "type", "isDefault"::text, "order"::text, "updatedAt"::text), E'\n' ORDER BY "name") FROM "Permission" WHERE "isDefault" ), '') || E'\n--roles--\n' || COALESCE(( SELECT string_agg(concat_ws('|', "id", "name", "description", "type", "assignToNewUsers"::text, "isDefault"::text, "order"::text, "isTemplateOrigin"::text, "updatedAt"::text), E'\n' ORDER BY "name") FROM "Role" WHERE "tenantId" IS NULL AND "name" IN ('SuperAdmin', 'SuperUser', 'Admin', 'Billing Admin', 'User') ), '') || E'\n--grants--\n' || COALESCE(( SELECT string_agg(concat_ws('|', grant_row."id", grant_row."roleId", grant_row."permissionId"), E'\n' ORDER BY grant_row."roleId", grant_row."permissionId") FROM "RolePermission" AS grant_row JOIN "Role" AS role_row ON role_row."id" = grant_row."roleId" WHERE role_row."tenantId" IS NULL AND role_row."name" IN ('SuperAdmin', 'SuperUser', 'Admin', 'Billing Admin', 'User') ), '') ); SQL printf '%s' "$snapshot" } permission_catalog_value_snapshot() { local snapshot docker_smoke_capture snapshot "web:permission-value-snapshot" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \ docker exec -i "$DB_CONTAINER_NAME" psql -v ON_ERROR_STOP=1 -qAt \ -U "$DB_USER" -d "$DB_NAME" <<'SQL' SELECT md5( COALESCE(( SELECT string_agg(concat_ws('|', "id", "name", "description", "type", "isDefault"::text, "order"::text), E'\n' ORDER BY "name") FROM "Permission" WHERE "isDefault" ), '') || E'\n--roles--\n' || COALESCE(( SELECT string_agg(concat_ws('|', "id", "name", "description", "type", "assignToNewUsers"::text, "isDefault"::text, "order"::text, "isTemplateOrigin"::text), E'\n' ORDER BY "name") FROM "Role" WHERE "tenantId" IS NULL AND "name" IN ('SuperAdmin', 'SuperUser', 'Admin', 'Billing Admin', 'User') ), '') || E'\n--grants--\n' || COALESCE(( SELECT string_agg(concat_ws('|', grant_row."id", grant_row."roleId", grant_row."permissionId"), E'\n' ORDER BY grant_row."roleId", grant_row."permissionId") FROM "RolePermission" AS grant_row JOIN "Role" AS role_row ON role_row."id" = grant_row."roleId" WHERE role_row."tenantId" IS NULL AND role_row."name" IN ('SuperAdmin', 'SuperUser', 'Admin', 'Billing Admin', 'User') ), '') ); SQL printf '%s' "$snapshot" } tenant_catalog_sentinel_snapshot() { local snapshot docker_smoke_capture snapshot "web:tenant-sentinel-snapshot" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \ docker exec -i "$DB_CONTAINER_NAME" psql -v ON_ERROR_STOP=1 -qAt \ -U "$DB_USER" -d "$DB_NAME" \ -v sentinel_role_id="$CATALOG_SENTINEL_ROLE_ID" <<'SQL' SELECT md5(concat_ws('|', role_row."id", role_row."updatedAt"::text, role_row."tenantId", role_row."name", role_row."description", role_row."type", role_row."assignToNewUsers"::text, role_row."isDefault"::text, role_row."order"::text, role_row."seatCategory"::text, role_row."isTemplateOrigin"::text, grant_row."id", grant_row."permissionId" )) FROM "Role" AS role_row JOIN "RolePermission" AS grant_row ON grant_row."roleId" = role_row."id" WHERE role_row."id" = :'sentinel_role_id'; SQL printf '%s' "$snapshot" } inject_permission_catalog_drift() { docker_smoke_run "web:inject-permission-drift" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \ docker exec -i "$DB_CONTAINER_NAME" psql -v ON_ERROR_STOP=1 -q \ -U "$DB_USER" -d "$DB_NAME" \ -v stale_grant_id="$CATALOG_STALE_GRANT_ID" >/dev/null <<'SQL' UPDATE "Permission" SET "description" = 'drifted', "type" = 'drifted', "isDefault" = false, "order" = -1 WHERE "name" = 'chat.write'; UPDATE "Role" SET "description" = 'drifted', "type" = 'admin', "assignToNewUsers" = true, "isDefault" = false, "order" = -1 WHERE "tenantId" IS NULL AND "name" = 'SuperUser'; INSERT INTO "RolePermission" ("id", "roleId", "permissionId") SELECT :'stale_grant_id', role_row."id", permission_row."id" FROM "Role" AS role_row CROSS JOIN "Permission" AS permission_row WHERE role_row."tenantId" IS NULL AND role_row."name" = 'User' AND permission_row."name" = 'admin.accounts.view' ON CONFLICT ("roleId", "permissionId") DO NOTHING; SQL } 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_WEB_SMOKE_REVISION explicitly" >&2 exit 1 fi docker_smoke_run "web:network-create" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \ docker network create "$NETWORK_NAME" >/dev/null docker_smoke_run "web:redis-start" "$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 "web:redis-ready" 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 emit_runtime_logs "$REDIS_CONTAINER_NAME" exit 1 fi sleep 1 done docker_smoke_run "web:postgres-start" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \ docker run -d --name "$DB_CONTAINER_NAME" --network "$NETWORK_NAME" --network-alias db -p 127.0.0.1::5432 \ -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 "web:postgres-ready" \ "$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 emit_runtime_logs "$DB_CONTAINER_NAME" exit 1 fi provision_docker_smoke_rls_roles \ "web:provision-rls-roles" \ "$DB_CONTAINER_NAME" "$DB_USER" "$DB_NAME" \ "$MIGRATOR_DB_PASSWORD" "$APP_DB_PASSWORD" "$WORKER_DB_PASSWORD" "$AUTHENTIK_JIT_DB_PASSWORD" MIGRATOR_LOGS="" run_migrator MIGRATOR_LOGS "web:migrate:initial" expect_migrator_completion "$MIGRATOR_LOGS" expect_permission_catalog_contract seed_permission_catalog_sentinel TENANT_SENTINEL_SNAPSHOT="$(tenant_catalog_sentinel_snapshot)" CATALOG_STATE_SNAPSHOT="$(permission_catalog_state_snapshot)" CATALOG_VALUE_SNAPSHOT="$(permission_catalog_value_snapshot)" if [[ -z "$TENANT_SENTINEL_SNAPSHOT" || -z "$CATALOG_STATE_SNAPSHOT" || -z "$CATALOG_VALUE_SNAPSHOT" ]]; then echo "Permission catalog smoke snapshots are incomplete" >&2 exit 1 fi SECOND_MIGRATOR_LOGS="" run_migrator SECOND_MIGRATOR_LOGS "web:migrate:idempotency" expect_migrator_completion "$SECOND_MIGRATOR_LOGS" expect_permission_catalog_contract if [[ "$(tenant_catalog_sentinel_snapshot)" != "$TENANT_SENTINEL_SNAPSHOT" ]]; then echo "Idempotent permission catalog sync mutated a tenant-owned template role" >&2 exit 1 fi if [[ "$(permission_catalog_state_snapshot)" != "$CATALOG_STATE_SNAPSHOT" ]]; then echo "Idempotent permission catalog sync changed canonical catalog state" >&2 exit 1 fi inject_permission_catalog_drift THIRD_MIGRATOR_LOGS="" run_migrator THIRD_MIGRATOR_LOGS "web:migrate:drift-repair" expect_migrator_completion "$THIRD_MIGRATOR_LOGS" expect_permission_catalog_contract if [[ "$(tenant_catalog_sentinel_snapshot)" != "$TENANT_SENTINEL_SNAPSHOT" ]]; then echo "Permission catalog drift repair mutated a tenant-owned template role" >&2 exit 1 fi if [[ "$(permission_catalog_value_snapshot)" != "$CATALOG_VALUE_SNAPSHOT" ]]; then echo "Permission catalog drift repair did not restore canonical values and grants" >&2 exit 1 fi # The feature branch intentionally carries its Prisma and RLS artifacts before # the reviewed merge migration exists. Apply those artifacts only to this # disposable, already-migrated smoke database so both runtime surfaces exercise # the current built-image schema without weakening the production migration # contract. apply_image_sql_artifact "/myapp/prisma/rls/portal-legacy-commerce-recut-pre-db-push.sql" "web:sql-artifact:legacy-recut" docker_smoke_run "web:db-push" "$DOCKER_SMOKE_MIGRATION_TIMEOUT_SECONDS" \ docker run --rm --name "$DB_PUSH_CONTAINER_NAME" --network "$NETWORK_NAME" --entrypoint node \ -e DATABASE_URL="$MIGRATOR_DATABASE_URL" \ "$IMAGE" /myapp/node_modules/prisma/build/index.js db push \ --accept-data-loss \ --config prisma.config.ts apply_image_sql_artifact "/myapp/prisma/rls/tenant-policy-contract-isolation.sql" "web:sql-artifact:tenant-policy" apply_image_sql_artifact "/myapp/prisma/rls/portal-policy-contract-isolation.sql" "web:sql-artifact:portal-policy" apply_image_sql_artifact "/myapp/prisma/rls/portal-domain-capabilities.sql" "web:sql-artifact:portal-domain" seed_password_login_fixture publish_args=(-p 127.0.0.1::8080) if [[ -n "$PORT" ]]; then publish_args=(-p "${PORT}:8080") fi docker_smoke_run "web:core-start" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \ docker run -d --name "$CONTAINER_NAME" --network "$NETWORK_NAME" "${publish_args[@]}" \ -e NODE_ENV=production \ -e 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 PORTAL_APP_HOST=portal.localhost \ -e APP_PROTOCOL=http \ -e WEB_RUNTIME_SURFACE=core \ -e DATABASE_URL="$APP_DATABASE_URL" \ -e DATABASE_RUNTIME_ROLE=hivegrid_app \ -e RLS_RUNTIME_MODE=required \ -e SENTRY_ENVIRONMENT=staging \ -e STAGING_DEMO_LOGIN_ENABLED=true \ -e REDIS_URL='redis://redis:6379/0' \ -e IP_HASH_PEPPER='deploy-smoke-ip-hash-pepper-minimum-32' \ -e AUTH_PASSWORD_HIBP_CHECK=false \ -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 AUDIT_CHAIN_SECRET='deploy-smoke-audit-chain-secret-minimum-32' \ -e API_ACCESS_TOKEN='deploy-smoke-api-access-token-minimum-32' \ -e ANALYTICS_TOKEN='deploy-smoke-analytics-token-minimum-32' \ -e WEB_PUSH_VAPID_PUBLIC_KEY="$SMOKE_WEB_PUSH_VAPID_PUBLIC_KEY" \ -e STORAGE_ENDPOINT='https://storage.example.test' \ -e STORAGE_REGION='eu-central-1' \ -e STORAGE_BUCKET='docker-smoke-private-exports' \ -e STORAGE_ACCESS_KEY_ID='docker-smoke-storage-access-key' \ -e STORAGE_SECRET_ACCESS_KEY='docker-smoke-storage-secret-key' \ -e STORAGE_SESSION_TOKEN='docker-smoke-storage-session-token' \ -e STORAGE_FORCE_PATH_STYLE=false \ -e GDPR_EXPORT_SSE_C_KEY_BASE64="$SMOKE_GDPR_EXPORT_SSE_C_KEY_BASE64" \ -e MCP_RESOURCE_URL='http://mcp.localhost:8080' \ -e MCP_AUTHORIZATION_SERVER_URL='http://app.localhost' \ "$IMAGE" >/dev/null if [[ -z "$PORT" ]]; then CORE_PORT_BINDING="" docker_smoke_capture CORE_PORT_BINDING "web:core-port" "$DOCKER_SMOKE_PROBE_TIMEOUT_SECONDS" \ docker port "$CONTAINER_NAME" 8080/tcp PORT="$(printf '%s\n' "$CORE_PORT_BINDING" | awk -F: 'NR == 1 { print $NF }')" fi wait_for_runtime_health app.localhost Web assert_runtime_boot_is_mutation_free Web expect_status app.localhost /healthz 200 expect_health_revision app.localhost /healthz expect_status app.localhost /readyz 200 expect_password_login_session expect_status app.localhost /sw.js 200 expect_service_worker_csp app.localhost expect_service_worker_recovery_bootstrap app.localhost # The web image must never serve the separately deployed API surface. expect_status api.localhost /v1/docs 404 expect_status api.localhost /v1/openapi.json 404 expect_status api.localhost /v1/health 404 expect_status app.localhost /app/acme/settings 404 expect_status admin.localhost /settings 404 docker_smoke_run "web:core-cleanup" "$DOCKER_SMOKE_CLEANUP_TIMEOUT_SECONDS" \ docker rm -f "$CORE_CONTAINER_NAME" >/dev/null CONTAINER_NAME="$ADMIN_CONTAINER_NAME" PORT="$ADMIN_PORT" admin_publish_args=(-p 127.0.0.1::8080) if [[ -n "$PORT" ]]; then admin_publish_args=(-p "${PORT}:8080") fi docker_smoke_run "web:admin-start" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \ docker run -d --name "$ADMIN_CONTAINER_NAME" --network "$NETWORK_NAME" "${admin_publish_args[@]}" \ -e NODE_ENV=production \ -e 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 PORTAL_APP_HOST=portal.localhost \ -e APP_PROTOCOL=http \ -e WEB_RUNTIME_SURFACE=admin \ -e DATABASE_URL="$APP_DATABASE_URL" \ -e DATABASE_RUNTIME_ROLE=hivegrid_app \ -e RLS_RUNTIME_MODE=required \ -e SENTRY_ENVIRONMENT=staging \ -e REDIS_URL='redis://redis:6379/0' \ -e IP_HASH_PEPPER='deploy-smoke-ip-hash-pepper-minimum-32' \ -e AUTH_PASSWORD_HIBP_CHECK=false \ -e SESSION_SECRET='deploy-smoke-admin-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 ADMIN_FALLBACK_LOGIN_ENABLED=false \ -e JWT_SECRET='deploy-smoke-jwt-secret-minimum-32' \ -e AUDIT_CHAIN_SECRET='deploy-smoke-audit-chain-secret-minimum-32' \ -e API_ACCESS_TOKEN='deploy-smoke-api-access-token-minimum-32' \ -e ANALYTICS_TOKEN='deploy-smoke-analytics-token-minimum-32' \ -e WEB_PUSH_VAPID_PUBLIC_KEY="$SMOKE_WEB_PUSH_VAPID_PUBLIC_KEY" \ -e STORAGE_ENDPOINT='https://storage.example.test' \ -e STORAGE_REGION='eu-central-1' \ -e STORAGE_BUCKET='docker-smoke-private-exports' \ -e STORAGE_ACCESS_KEY_ID='docker-smoke-storage-access-key' \ -e STORAGE_SECRET_ACCESS_KEY='docker-smoke-storage-secret-key' \ -e STORAGE_SESSION_TOKEN='docker-smoke-storage-session-token' \ -e STORAGE_FORCE_PATH_STYLE=false \ -e GDPR_EXPORT_SSE_C_KEY_BASE64="$SMOKE_GDPR_EXPORT_SSE_C_KEY_BASE64" \ -e MCP_RESOURCE_URL='http://mcp.localhost:8080' \ -e MCP_AUTHORIZATION_SERVER_URL='http://admin.localhost' \ -e AUTHENTIK_ISSUER_URL='https://auth.example.test/application/o/hivegrid-admin/' \ -e AUTHENTIK_ADMIN_CLIENT_ID='docker-smoke-admin' \ -e AUTHENTIK_ADMIN_CLIENT_SECRET='docker-smoke-authentik-secret-minimum-32' \ -e AUTHENTIK_ADMIN_REDIRECT_URI='https://admin.localhost/auth/admin/callback' \ -e AUTHENTIK_ADMIN_JIT_DATABASE_URL="postgresql://hivegrid_authentik_jit:${AUTHENTIK_JIT_DB_PASSWORD}@db:5432/${DB_NAME}?schema=public" \ "$IMAGE" >/dev/null if [[ -z "$PORT" ]]; then ADMIN_PORT_BINDING="" docker_smoke_capture ADMIN_PORT_BINDING "web:admin-port" "$DOCKER_SMOKE_PROBE_TIMEOUT_SECONDS" \ docker port "$ADMIN_CONTAINER_NAME" 8080/tcp PORT="$(printf '%s\n' "$ADMIN_PORT_BINDING" | awk -F: 'NR == 1 { print $NF }')" fi wait_for_runtime_health admin.localhost Admin assert_runtime_boot_is_mutation_free Admin expect_status admin.localhost /healthz 200 expect_runtime_payload admin.localhost /healthz ok hivegrid-admin admin expect_status admin.localhost /readyz 200 expect_runtime_payload admin.localhost /readyz ready hivegrid-admin admin expect_status app.localhost /healthz 404 expect_status portal.localhost /healthz 404 expect_status admin.localhost /settings 302 docker_smoke_run "web:admin-cleanup" "$DOCKER_SMOKE_CLEANUP_TIMEOUT_SECONDS" \ docker rm -f "$ADMIN_CONTAINER_NAME" >/dev/null PORTAL_DATABASE_URL="postgresql://hivegrid_portal:${DOCKER_SMOKE_PORTAL_DB_PASSWORD}@db:5432/${DB_NAME}?schema=public" CONTAINER_NAME="$PORTAL_CONTAINER_NAME" PORT="$PORTAL_PORT" portal_publish_args=(-p 127.0.0.1::8080) if [[ -n "$PORT" ]]; then portal_publish_args=(-p "${PORT}:8080") fi docker_smoke_run "web:portal-start" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \ docker run -d --name "$PORTAL_CONTAINER_NAME" --network "$NETWORK_NAME" "${portal_publish_args[@]}" \ -e NODE_ENV=production \ -e PORT=8080 \ -e APP_NAME=HiveGrid \ -e APP_BASE_DOMAIN=localhost \ -e PORTAL_APP_HOST=portal.localhost \ -e APP_PROTOCOL=http \ -e WEB_RUNTIME_SURFACE=portal \ -e DATABASE_URL="$PORTAL_DATABASE_URL" \ -e DATABASE_RUNTIME_ROLE=hivegrid_portal \ -e RLS_RUNTIME_MODE=required \ -e SENTRY_ENVIRONMENT=staging \ -e PORTAL_SESSION_SECRET='portal-smoke-session-secret-minimum-32' \ -e AUTH_MAGIC_LINK_ENABLED=true \ -e AUTH_MAGIC_LINK_TOKEN_TTL_MINUTES=15 \ -e CAPTCHA_PROVIDER=mcaptcha \ -e MCAPTCHA_INSTANCE_URL='http://127.0.0.1:9090' \ -e MCAPTCHA_SITEKEY_LOGIN='portal-smoke-login-sitekey' \ -e MCAPTCHA_ACCOUNT_SECRET='portal-smoke-captcha-secret' \ -e REDIS_URL='redis://redis:6379/0' \ -e BULLMQ_REDIS_URL='redis://redis:6379/0' \ -e STORAGE_ENDPOINT='https://storage.example.test' \ -e STORAGE_REGION='eu-central-1' \ -e STORAGE_BUCKET='docker-smoke-private-exports' \ -e STORAGE_ACCESS_KEY_ID='docker-smoke-storage-access-key' \ -e STORAGE_SECRET_ACCESS_KEY='docker-smoke-storage-secret-key' \ -e STORAGE_SESSION_TOKEN='docker-smoke-storage-session-token' \ -e STORAGE_FORCE_PATH_STYLE=false \ -e GDPR_EXPORT_SSE_C_KEY_BASE64="$SMOKE_GDPR_EXPORT_SSE_C_KEY_BASE64" \ -e MCP_RESOURCE_URL='http://mcp.localhost:8080' \ -e MCP_AUTHORIZATION_SERVER_URL='http://portal.localhost' \ "$IMAGE" >/dev/null if [[ -z "$PORT" ]]; then PORTAL_PORT_BINDING="" docker_smoke_capture PORTAL_PORT_BINDING "web:portal-port" "$DOCKER_SMOKE_PROBE_TIMEOUT_SECONDS" \ docker port "$PORTAL_CONTAINER_NAME" 8080/tcp PORT="$(printf '%s\n' "$PORTAL_PORT_BINDING" | awk -F: 'NR == 1 { print $NF }')" fi wait_for_runtime_health portal.localhost Portal assert_runtime_boot_is_mutation_free Portal expect_status portal.localhost /healthz 200 expect_runtime_payload portal.localhost /healthz ok hivegrid-portal portal expect_status portal.localhost /readyz 200 expect_runtime_payload portal.localhost /readyz ready hivegrid-portal portal echo "Docker web, admin, and portal runtime smoke passed for ${IMAGE}"