936 lines
41 KiB
Bash
Executable File
936 lines
41 KiB
Bash
Executable File
#!/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:-<empty>}" >&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("<script", markerIndex); const scriptTagEnd = html.indexOf(">", 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:-<empty>}" >&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(/<meta[^>]+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" == "<no value>" || "$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}"
|