256 lines
12 KiB
Bash
Executable File
256 lines
12 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-hocuspocus:smoke}"
|
|
MIGRATOR_IMAGE="${DOCKER_SMOKE_MIGRATOR_IMAGE:-hivegrid-web:smoke}"
|
|
CONTAINER_NAME="${DOCKER_HOCUSPOCUS_SMOKE_CONTAINER:-hivegrid-hocuspocus-runtime-smoke-${DOCKER_SMOKE_RUN_ID}}"
|
|
DB_CONTAINER_NAME="${DOCKER_HOCUSPOCUS_SMOKE_DB_CONTAINER:-hivegrid-hocuspocus-runtime-smoke-db-${DOCKER_SMOKE_RUN_ID}}"
|
|
NETWORK_NAME="${DOCKER_HOCUSPOCUS_SMOKE_NETWORK:-hivegrid-hocuspocus-runtime-smoke-${DOCKER_SMOKE_RUN_ID}}"
|
|
MIGRATION_CONTAINER_NAME="${DOCKER_HOCUSPOCUS_SMOKE_MIGRATION_CONTAINER:-hivegrid-hocuspocus-runtime-smoke-migrate-${DOCKER_SMOKE_RUN_ID}}"
|
|
ASSERT_CONTAINER_NAME="${DOCKER_HOCUSPOCUS_SMOKE_ASSERT_CONTAINER:-hivegrid-hocuspocus-runtime-smoke-assert-${DOCKER_SMOKE_RUN_ID}}"
|
|
SMOKE_REVISION="${DOCKER_HOCUSPOCUS_SMOKE_REVISION:-}"
|
|
STARTUP_ATTEMPTS="${DOCKER_HOCUSPOCUS_SMOKE_STARTUP_ATTEMPTS:-60}"
|
|
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 "hocuspocus:cleanup-containers" \
|
|
"$CONTAINER_NAME" "$MIGRATION_CONTAINER_NAME" "$ASSERT_CONTAINER_NAME" "$DB_CONTAINER_NAME"
|
|
docker_smoke_cleanup_network "hocuspocus: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 "hocuspocus: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 "hocuspocus: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:-<empty>} does not match Hocuspocus image revision ${SMOKE_REVISION}" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
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_HOCUSPOCUS_SMOKE_REVISION explicitly" >&2
|
|
exit 1
|
|
fi
|
|
verify_migrator_revision
|
|
|
|
IMAGE_USER=""
|
|
docker_smoke_capture IMAGE_USER "hocuspocus:inspect-user" "$DOCKER_SMOKE_PROBE_TIMEOUT_SECONDS" \
|
|
docker image inspect -f '{{ .Config.User }}' "$IMAGE"
|
|
if [[ "$IMAGE_USER" != "node" && "$IMAGE_USER" != "1000" && "$IMAGE_USER" != "1000:1000" ]]; then
|
|
echo "Image ${IMAGE} must declare the non-root node user, got ${IMAGE_USER:-<empty>}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Inspect the exact runtime filesystem before boot. This catches development
|
|
# artifacts and package-manager executables even when the server itself starts.
|
|
docker_smoke_run "hocuspocus:assert-runtime" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" docker run --rm --name "$ASSERT_CONTAINER_NAME" --entrypoint node "$IMAGE" -e '
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
if (process.getuid?.() !== 1000) {
|
|
console.error(`Expected runtime uid 1000, got ${process.getuid?.()}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const forbiddenExecutables = [
|
|
"/usr/local/bin/corepack",
|
|
"/usr/local/bin/npm",
|
|
"/usr/local/bin/npx",
|
|
"/usr/local/bin/pnpm",
|
|
"/usr/local/bin/pnpx",
|
|
"/usr/local/bin/yarn",
|
|
"/usr/local/bin/yarnpkg",
|
|
];
|
|
const retainedExecutables = forbiddenExecutables.filter((file) => fs.existsSync(file));
|
|
if (retainedExecutables.length > 0) {
|
|
console.error(`Runtime retained package-manager executables: ${retainedExecutables.join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const roots = ["/myapp/apps/hocuspocus/dist", "/myapp/node_modules"];
|
|
const retainedSourceArtifacts = [];
|
|
for (const root of roots) {
|
|
const pending = [root];
|
|
while (pending.length > 0) {
|
|
const current = pending.pop();
|
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
const absolute = path.join(current, entry.name);
|
|
if (entry.isDirectory() && !entry.isSymbolicLink()) pending.push(absolute);
|
|
else if (entry.name.endsWith(".map") || entry.name.endsWith(".d.ts")) retainedSourceArtifacts.push(absolute);
|
|
if (retainedSourceArtifacts.length >= 10) break;
|
|
}
|
|
if (retainedSourceArtifacts.length >= 10) break;
|
|
}
|
|
}
|
|
if (retainedSourceArtifacts.length > 0) {
|
|
console.error(`Runtime retained source artifacts: ${retainedSourceArtifacts.join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const sbom = JSON.parse(fs.readFileSync("/myapp/sbom-pnpm.json", "utf8"));
|
|
if (sbom.schema !== "hivegrid.sbom-pnpm/v2" || sbom.target !== "hocuspocus") {
|
|
console.error(`Expected target-specific Hocuspocus SBOM, got schema=${sbom.schema} target=${sbom.target}`);
|
|
process.exit(1);
|
|
}
|
|
if (sbom.source !== "installed pruned pnpm runtime tree" || !Array.isArray(sbom.packages)) {
|
|
console.error("Hocuspocus SBOM must describe the installed pruned runtime tree");
|
|
process.exit(1);
|
|
}
|
|
if (sbom.packageCount !== sbom.packages.length || sbom.packageCount === 0 || "workspaces" in sbom) {
|
|
console.error("Hocuspocus SBOM package inventory is inconsistent or contains workspace development data");
|
|
process.exit(1);
|
|
}
|
|
const packageNames = new Set(sbom.packages.map((pkg) => pkg.name));
|
|
if (!packageNames.has("@hocuspocus/server")) {
|
|
console.error("Hocuspocus SBOM is missing @hocuspocus/server");
|
|
process.exit(1);
|
|
}
|
|
const forbiddenPackages = ["esbuild", "tsx", "typescript", "vitest"].filter((name) => packageNames.has(name));
|
|
if (forbiddenPackages.length > 0) {
|
|
console.error(`Hocuspocus SBOM retained development packages: ${forbiddenPackages.join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const expectedPrismaCompilers = [
|
|
"@prisma/client/runtime/query_compiler_fast_bg.postgresql.mjs",
|
|
"@prisma/client/runtime/query_compiler_fast_bg.postgresql.wasm-base64.mjs",
|
|
];
|
|
const runtimeSource = fs.readFileSync("/myapp/apps/hocuspocus/dist/index.js", "utf8");
|
|
const importedPrismaCompilers = [...new Set(runtimeSource.match(/@prisma\/client\/runtime\/query_compiler_[^"\s)]+/gu) ?? [])].sort();
|
|
if (JSON.stringify(importedPrismaCompilers) !== JSON.stringify(expectedPrismaCompilers)) {
|
|
console.error(`Unexpected generated Prisma compiler imports: ${importedPrismaCompilers.join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
const prismaPackageEntry = fs.readdirSync("/myapp/node_modules/.pnpm").find((entry) => entry.startsWith("@prisma+client@"));
|
|
if (!prismaPackageEntry) {
|
|
console.error("Hocuspocus runtime is missing @prisma/client");
|
|
process.exit(1);
|
|
}
|
|
const prismaClientRoot = path.join("/myapp/node_modules/.pnpm", prismaPackageEntry, "node_modules/@prisma/client");
|
|
const retainedPrismaCompilers = fs.readdirSync(path.join(prismaClientRoot, "runtime")).filter((file) => file.startsWith("query_compiler_"));
|
|
if (JSON.stringify(retainedPrismaCompilers.sort()) !== JSON.stringify(expectedPrismaCompilers.map((specifier) => specifier.split("/").at(-1)).sort())) {
|
|
console.error(`Runtime retained unexpected Prisma compiler payloads: ${retainedPrismaCompilers.join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
if (fs.existsSync(path.join(prismaClientRoot, "generator-build"))) {
|
|
console.error("Runtime retained the build-only Prisma client generator");
|
|
process.exit(1);
|
|
}
|
|
Promise.all(expectedPrismaCompilers.map((specifier) => import(specifier))).catch((error) => {
|
|
console.error(`Retained Prisma compiler failed to load: ${error.message}`);
|
|
process.exit(1);
|
|
});
|
|
' >/dev/null
|
|
|
|
docker_smoke_run "hocuspocus:create-network" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \
|
|
docker network create "$NETWORK_NAME" >/dev/null
|
|
docker_smoke_run "hocuspocus: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
|
|
|
|
# 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 "hocuspocus: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 "hocuspocus:logs-db" "$DB_CONTAINER_NAME" >&2 || true
|
|
exit 1
|
|
fi
|
|
|
|
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"
|
|
provision_docker_smoke_rls_roles \
|
|
"hocuspocus:provision-rls-roles" \
|
|
"$DB_CONTAINER_NAME" "$DB_USER" "$DB_NAME" \
|
|
"$MIGRATOR_DB_PASSWORD" "$APP_DB_PASSWORD" "$WORKER_DB_PASSWORD" "$AUTHENTIK_JIT_DB_PASSWORD"
|
|
docker_smoke_run "hocuspocus: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
|
|
docker_smoke_run "hocuspocus:start-runtime" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" docker run -d --name "$CONTAINER_NAME" --network "$NETWORK_NAME" \
|
|
-e NODE_ENV=production \
|
|
-e HOCUSPOCUS_AUTH_SECRET="smoke-only-hocuspocus-auth-secret-not-real" \
|
|
-e DATABASE_URL="$APP_DATABASE_URL" \
|
|
-e DATABASE_RUNTIME_ROLE=hivegrid_app \
|
|
-e RLS_RUNTIME_MODE=required \
|
|
"$IMAGE" >/dev/null
|
|
|
|
for attempt in $(seq 1 "$STARTUP_ATTEMPTS"); do
|
|
if docker_smoke_probe "hocuspocus:probe-listener" docker exec "$CONTAINER_NAME" node -e '
|
|
const socket = require("node:net").connect(1234, "127.0.0.1");
|
|
socket.setTimeout(1000);
|
|
socket.once("connect", () => { socket.destroy(); process.exit(0); });
|
|
socket.once("timeout", () => { socket.destroy(); process.exit(1); });
|
|
socket.once("error", () => process.exit(1));
|
|
' >/dev/null 2>&1; then
|
|
break
|
|
fi
|
|
|
|
running=""
|
|
docker_smoke_capture running "hocuspocus:inspect-running" "$DOCKER_SMOKE_PROBE_TIMEOUT_SECONDS" \
|
|
docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true
|
|
if [[ "$running" != "true" ]]; then
|
|
echo "Hocuspocus container exited before opening its TCP listener" >&2
|
|
docker_smoke_logs "hocuspocus:logs-exit" "$CONTAINER_NAME" >&2 || true
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$attempt" == "$STARTUP_ATTEMPTS" ]]; then
|
|
echo "Hocuspocus container did not open port 1234 after ${STARTUP_ATTEMPTS}s" >&2
|
|
docker_smoke_logs "hocuspocus:logs-startup" "$CONTAINER_NAME" >&2 || true
|
|
exit 1
|
|
fi
|
|
|
|
sleep 1
|
|
done
|
|
|
|
RUNTIME_REVISION=""
|
|
docker_smoke_capture RUNTIME_REVISION "hocuspocus:runtime-revision" "$DOCKER_SMOKE_PROBE_TIMEOUT_SECONDS" \
|
|
docker exec "$CONTAINER_NAME" node -e 'process.stdout.write(process.env.APP_REVISION || "")'
|
|
if [[ "$RUNTIME_REVISION" != "$SMOKE_REVISION" ]]; then
|
|
echo "Expected runtime revision ${SMOKE_REVISION}, got ${RUNTIME_REVISION:-<empty>}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "Docker Hocuspocus runtime smoke passed for ${IMAGE}"
|