1974 lines
94 KiB
JavaScript
1974 lines
94 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const REQUIRED_FILES = [
|
|
"Dockerfile",
|
|
"docker-compose.yml",
|
|
"docker-compose.staging.yml",
|
|
".dockerignore",
|
|
"start.sh",
|
|
"package.json",
|
|
"pnpm-workspace.yaml",
|
|
"apps/web/package.json",
|
|
"apps/api/package.json",
|
|
"apps/hocuspocus/Dockerfile",
|
|
"apps/portal-domain-controller/package.json",
|
|
"apps/worker/package.json",
|
|
"packages/db/package.json",
|
|
"packages/db/scripts/build-runtime-artifacts.mjs",
|
|
"runtime/api/package.json",
|
|
"runtime/hocuspocus/package.json",
|
|
"runtime/mcp/package.json",
|
|
"runtime/portal-domain-controller/package.json",
|
|
"runtime/web/package.json",
|
|
"runtime/worker/package.json",
|
|
"scripts/ci/deploy-production-closures.sh",
|
|
];
|
|
|
|
const ROOT_REQUIRED_SCRIPTS = ["build", "lint", "test", "typecheck"];
|
|
const API_REQUIRED_SCRIPTS = ["build"];
|
|
const REQUIRED_WORKER_GROUPS = [
|
|
{ service: "worker-general", group: "general", healthPort: "9101", adminPort: "9201", concurrencyEnv: "WORKER_CONCURRENCY_GENERAL" },
|
|
{ service: "worker-mail", group: "mail", healthPort: "9102", adminPort: "9202", concurrencyEnv: "WORKER_CONCURRENCY_MAIL" },
|
|
{ service: "worker-import", group: "import", healthPort: "9103", adminPort: "9203", concurrencyEnv: "WORKER_CONCURRENCY_IMPORT" },
|
|
{ service: "worker-webhook", group: "webhook", healthPort: "9104", adminPort: "9204", concurrencyEnv: "WORKER_CONCURRENCY_WEBHOOK" },
|
|
{ service: "worker-plugin", group: "plugin", healthPort: "9105", adminPort: "9205", concurrencyEnv: "WORKER_CONCURRENCY_PLUGIN" },
|
|
];
|
|
const REQUIRED_WORKER_GENERAL_WORKFLOW_ENV = [
|
|
"APP_URL",
|
|
"OUTBOX_DISPATCHER_ENABLED",
|
|
"OUTBOX_DISPATCHER_REQUIRED",
|
|
"WORKFLOW_TRIGGER_EXECUTOR_MODE",
|
|
"WORKFLOW_TRIGGER_EXECUTOR_URL",
|
|
"API_ACCESS_TOKEN",
|
|
"WORKFLOW_TRIGGER_EXECUTOR_TIMEOUT_MS",
|
|
"WORKFLOW_TRIGGER_OUTBOX_ENABLED",
|
|
"WORKFLOW_TRIGGER_OUTBOX_CONCURRENCY",
|
|
"WORKFLOW_TRIGGER_OUTBOX_REQUIRED",
|
|
];
|
|
const REQUIRED_STAGING_WORKER_GENERAL_ENV = [
|
|
["OUTBOX_DISPATCHER_ENABLED", "true"],
|
|
["OUTBOX_DISPATCHER_REQUIRED", "true"],
|
|
["WORKFLOW_TRIGGER_OUTBOX_ENABLED", "true"],
|
|
["WORKFLOW_TRIGGER_OUTBOX_REQUIRED", "true"],
|
|
];
|
|
const REQUIRED_WORKER_GENERAL_BILLING_ENV = [
|
|
"LAGO_API_URL",
|
|
"LAGO_API_KEY",
|
|
"LAGO_TIMEOUT_MS",
|
|
"MOLLIE_API_KEY",
|
|
"MOLLIE_PROFILE_ID",
|
|
"MOLLIE_TIMEOUT_MS",
|
|
"BILLING_MOLLIE_WEBHOOK_URL",
|
|
];
|
|
const REQUIRED_WORKER_GENERAL_SEARCH_ENV = ["MEILI_URL", "MEILI_API_KEY"];
|
|
const REQUIRED_WORKER_SCAN_ENV = [
|
|
"STORAGE_ENDPOINT",
|
|
"STORAGE_REGION",
|
|
"STORAGE_BUCKET",
|
|
"STORAGE_ACCESS_KEY_ID",
|
|
"STORAGE_SECRET_ACCESS_KEY",
|
|
"STORAGE_SESSION_TOKEN",
|
|
"STORAGE_FORCE_PATH_STYLE",
|
|
"STORAGE_QUARANTINE_BUCKET",
|
|
];
|
|
const REQUIRED_WEBHOOK_VAPID_ENV = ["WEB_PUSH_VAPID_SUBJECT", "WEB_PUSH_VAPID_PUBLIC_KEY", "WEB_PUSH_VAPID_PRIVATE_KEY"];
|
|
const REQUIRED_STAGING_WEB_AIRCALL_ENV = ["AIRCALL_CLIENT_ID", "AIRCALL_CLIENT_SECRET", "AIRCALL_REDIRECT_URI", "AIRCALL_WEBHOOK_URL"];
|
|
const REQUIRED_STAGING_WEB_GDPR_EXPORT_STORAGE_ENV = [
|
|
"STORAGE_ENDPOINT",
|
|
"STORAGE_REGION",
|
|
"STORAGE_BUCKET",
|
|
"STORAGE_ACCESS_KEY_ID",
|
|
"STORAGE_SECRET_ACCESS_KEY",
|
|
"GDPR_EXPORT_SSE_C_KEY_BASE64",
|
|
];
|
|
const OPTIONAL_STAGING_WEB_GDPR_EXPORT_STORAGE_ENV = ["STORAGE_SESSION_TOKEN", "STORAGE_FORCE_PATH_STYLE"];
|
|
const STAGING_STORAGE_CREDENTIAL_SOURCES = Object.freeze({
|
|
web: Object.freeze({
|
|
STORAGE_ACCESS_KEY_ID: "WEB_STORAGE_ACCESS_KEY_ID",
|
|
STORAGE_SECRET_ACCESS_KEY: "WEB_STORAGE_SECRET_ACCESS_KEY",
|
|
STORAGE_SESSION_TOKEN: "WEB_STORAGE_SESSION_TOKEN",
|
|
}),
|
|
"worker-general": Object.freeze({
|
|
STORAGE_ACCESS_KEY_ID: "WORKER_GENERAL_STORAGE_ACCESS_KEY_ID",
|
|
STORAGE_SECRET_ACCESS_KEY: "WORKER_GENERAL_STORAGE_SECRET_ACCESS_KEY",
|
|
STORAGE_SESSION_TOKEN: "WORKER_GENERAL_STORAGE_SESSION_TOKEN",
|
|
}),
|
|
"worker-import": Object.freeze({
|
|
STORAGE_ACCESS_KEY_ID: "WORKER_IMPORT_STORAGE_ACCESS_KEY_ID",
|
|
STORAGE_SECRET_ACCESS_KEY: "WORKER_IMPORT_STORAGE_SECRET_ACCESS_KEY",
|
|
STORAGE_SESSION_TOKEN: "WORKER_IMPORT_STORAGE_SESSION_TOKEN",
|
|
}),
|
|
});
|
|
const REQUIRED_WORKER_IMPORT_DEDICATED_GATE_ENV = [
|
|
["IMPORT_QUEUE_ENABLED", "false"],
|
|
["IMPORT_QUEUE_RUNTIME_READY", "false"],
|
|
["PORTAL_GDPR_EXPORT_QUEUE_ENABLED", "true"],
|
|
];
|
|
const SCAN_WORKER_SERVICE = "worker-general";
|
|
const NON_SCAN_WORKER_SERVICES = REQUIRED_WORKER_GROUPS.map((worker) => worker.service).filter((service) => service !== SCAN_WORKER_SERVICE);
|
|
|
|
function stagingStorageSource(service, envName) {
|
|
return STAGING_STORAGE_CREDENTIAL_SOURCES[service]?.[envName] ?? envName;
|
|
}
|
|
const WEB_TARGET_PATTERN = /^FROM\s+base\s+AS\s+web\s*$/im;
|
|
const API_TARGET_PATTERN = /^FROM\s+base\s+AS\s+api\s*$/im;
|
|
const MCP_TARGET_PATTERN = /^FROM\s+base\s+AS\s+mcp\s*$/im;
|
|
const WORKER_TARGET_PATTERN = /^FROM\s+base\s+AS\s+worker\s*$/im;
|
|
const WEB_BUILD_TARGET_PATTERN = /^FROM\s+build\s+AS\s+web-build\s*$/im;
|
|
const API_BUILD_TARGET_PATTERN = /^FROM\s+build\s+AS\s+api-build\s*$/im;
|
|
const MCP_BUILD_TARGET_PATTERN = /^FROM\s+build\s+AS\s+mcp-build\s*$/im;
|
|
const PORTAL_CONTROLLER_BUILD_TARGET_PATTERN = /^FROM\s+build\s+AS\s+portal-domain-controller-build\s*$/im;
|
|
const WORKER_BUILD_TARGET_PATTERN = /^FROM\s+build\s+AS\s+worker-build\s*$/im;
|
|
const BUILD_STAGE_FORBIDDEN_APP_BUILD_PATTERN =
|
|
/\bpnpm\s+(?:run\s+)?build\b|\bpnpm\s+--filter\s+@hivegrid\/web\s+build\b|\bpnpm\s+--filter\s+@hivegrid\/api\s+build\b|\bpnpm\s+--filter\s+@hivegrid\/worker\s+build\b/;
|
|
const SHARED_BUILD_FORBIDDEN_VITE_PATTERN = /\b(?:ARG|ENV)\s+VITE_[A-Z0-9_]+/;
|
|
const WEB_BUILD_COMMAND_PATTERN = /^RUN\s+node\s+scripts\/build-web-surfaces\.mjs\s+&&\s+node\s+scripts\/build-web-server\.mjs\s*$/im;
|
|
const WEB_HOCUSPOCUS_BUILD_ARG_PATTERN = /^ARG\s+VITE_HOCUSPOCUS_URL=""\s*$/im;
|
|
const WEB_HOCUSPOCUS_BUILD_ENV_PATTERN = /^ENV\s+VITE_HOCUSPOCUS_URL="\$\{VITE_HOCUSPOCUS_URL\}"\s*$/im;
|
|
const API_BUILD_COMMAND_PATTERN = /^RUN\s+node\s+apps\/api\/scripts\/build\.mjs\s*$/im;
|
|
const MCP_BUILD_COMMAND_PATTERN = /^RUN\s+node\s+apps\/mcp\/scripts\/build\.mjs\s*$/im;
|
|
const PORTAL_CONTROLLER_BUILD_COMMAND_PATTERN = /^RUN\s+cd\s+apps\/portal-domain-controller\s+&&\s+node\s+scripts\/build\.mjs\s*$/im;
|
|
const WORKER_BUILD_COMMAND_PATTERN = /^RUN\s+cd\s+apps\/worker\s+&&\s+node\s+scripts\/build\.mjs\s*$/im;
|
|
const WEB_RUNTIME_ARTIFACTS_TARGET_PATTERN = /^FROM\s+web-build\s+AS\s+web-runtime-artifacts\s*$/im;
|
|
const API_RUNTIME_ARTIFACTS_TARGET_PATTERN = /^FROM\s+api-build\s+AS\s+api-runtime-artifacts\s*$/im;
|
|
const WORKER_RUNTIME_ARTIFACTS_TARGET_PATTERN = /^FROM\s+worker-build\s+AS\s+worker-runtime-artifacts\s*$/im;
|
|
const WEB_RUNTIME_ARTIFACTS_SOURCEMAP_DELETE_PATTERN =
|
|
/^RUN\s+find\s+\/myapp\/build\s+\/myapp\/apps\/web\/build\s+\/myapp\/apps\/web\/build-portal\s+-type\s+f\s+-name\s+['"]\*\.map['"]\s+-delete\s*$/im;
|
|
const API_RUNTIME_ARTIFACTS_SOURCEMAP_DELETE_PATTERN = /^RUN\s+find\s+\/myapp\/apps\/api\/dist\s+-type\s+f\s+-name\s+['"]\*\.map['"]\s+-delete\s*$/im;
|
|
const WORKER_RUNTIME_ARTIFACTS_SOURCEMAP_DELETE_PATTERN = /^RUN\s+find\s+\/myapp\/apps\/worker\/dist\s+-type\s+f\s+-name\s+['"]\*\.map['"]\s+-delete\s*$/im;
|
|
const RUNTIME_DEPLOY_TARGETS = [
|
|
{ target: "web", stage: "web-production-deps", packageName: "@hivegrid/runtime-web", helperFunction: "deploy_web", prunePrisma: true },
|
|
{
|
|
target: "api",
|
|
stage: "api-production-deps",
|
|
packageName: "@hivegrid/runtime-api",
|
|
helperFunction: "deploy_api",
|
|
omitOptional: true,
|
|
prunePrisma: true,
|
|
},
|
|
{
|
|
target: "mcp",
|
|
stage: "mcp-production-deps",
|
|
packageName: "@hivegrid/runtime-mcp",
|
|
helperFunction: "deploy_mcp",
|
|
omitOptional: true,
|
|
prunePrisma: true,
|
|
},
|
|
{
|
|
target: "portal-domain-controller",
|
|
stage: "portal-domain-controller-production-deps",
|
|
packageName: "@hivegrid/runtime-portal-domain-controller",
|
|
helperFunction: "deploy_portal_controller",
|
|
omitOptional: true,
|
|
prunePrisma: false,
|
|
},
|
|
{ target: "worker", stage: "worker-production-deps", packageName: "@hivegrid/runtime-worker", helperFunction: "deploy_worker", prunePrisma: true },
|
|
];
|
|
const PRODUCTION_DEPS_HELPER_PATH = "scripts/ci/deploy-production-closures.sh";
|
|
const PRODUCTION_DEPS_BUNDLE_TARGET_PATTERN = /^FROM\s+build\s+AS\s+production-deps-bundle\s*$/im;
|
|
const SERVER_RUNTIME_COPY_PATTERN = /^COPY\s+--from=web-runtime-artifacts\s+\/myapp\/build\/server-runtime\s+\/myapp\/build\/server-runtime\s*$/im;
|
|
const START_SH_ENTRYPOINT_PATTERN = /^ENTRYPOINT\s+\[\s*["']\.\/start\.sh["']\s*\]\s*$/im;
|
|
const APP_REVISION_ARG_PATTERN = /\bARG\s+APP_REVISION(?:=unknown)?\b/;
|
|
const APP_REVISION_ENV_PATTERN = /\bENV\s+APP_REVISION=["']?\$\{APP_REVISION\}["']?/;
|
|
const APP_REVISION_LABEL_PATTERN = /\bLABEL\s+org\.opencontainers\.image\.revision=["']?\$\{APP_REVISION\}["']?/;
|
|
const WEB_HEALTHCHECK_PATTERN =
|
|
/\bHEALTHCHECK[\s\S]*?(?:(?:\bcurl\b|\bnode\s+-e\b)[\s\S]*?127\.0\.0\.1:\$\{PORT\}\/healthz|hostname\s*:\s*["']127\.0\.0\.1["'][\s\S]*?port\s*:\s*process\.env\.PORT[\s\S]*?path\s*:\s*["']\/healthz["'])/;
|
|
const WEB_HEALTHCHECK_HOST_PATTERN = /headers\s*:\s*\{\s*host\s*:\s*process\.env\.WEB_APP_HOST/;
|
|
const API_HEALTHCHECK_PATTERN = /\bHEALTHCHECK[\s\S]*?(?:\bcurl\b|\bnode\s+-e\b)[\s\S]*?127\.0\.0\.1:\$\{API_PORT:-\$\{PORT\}\}\/healthz/;
|
|
const MCP_HEALTHCHECK_PATTERN = /\bHEALTHCHECK[\s\S]*?\bnode\s+-e\b[\s\S]*?127\.0\.0\.1:\$\{MCP_PORT:-\$\{PORT\}\}\/healthz/;
|
|
const IMAGE_REVISION_FILE_PATTERN = /^RUN\s+printf\s+'%s\\n'\s+"\$APP_REVISION"\s+>\s+\/myapp\/\.image-revision\s*$/im;
|
|
const RECURSIVE_CHOWN_PATTERN = /\bchown\s+-R\b/;
|
|
const EXPOSE_8080_PATTERN = /^EXPOSE\s+8080\s*$/im;
|
|
const API_RUNTIME_PATTERN = /\bnode\b[\s\S]*?(?:\/myapp\/)?apps\/api\/dist\/server\.js\b/;
|
|
const MCP_RUNTIME_PATTERN = /\bnode\b[\s\S]*?(?:\/myapp\/)?apps\/mcp\/dist\/server\.js\b/;
|
|
const WORKER_RUNTIME_PATTERN = /\bnode\b[\s\S]*?(?:\/myapp\/)?apps\/worker\/dist\/index\.js\b/;
|
|
const WORKER_HEALTH_PATTERN = /\bnode\b[\s\S]*?(?:\/myapp\/)?apps\/worker\/dist\/health\.js\b/;
|
|
const FAST_START_HEALTHCHECK_CADENCE_PATTERN = /\bHEALTHCHECK\s+--interval=30s\b[^\n]*--start-interval=2s\b[^\n]*--retries=3\b/;
|
|
const DIRECT_PRISMA_GENERATE_PATTERN = /^RUN\s+\.\/node_modules\/\.bin\/prisma\s+generate\s+--config\s+prisma\.config\.ts\s*$/im;
|
|
const DIRECT_DB_RUNTIME_BUILD_PATTERN = /^RUN\s+node\s+packages\/db\/scripts\/build-runtime-artifacts\.mjs\s*$/im;
|
|
const DB_RUNTIME_BUILD_SCRIPT_PATH = "packages/db/scripts/build-runtime-artifacts.mjs";
|
|
const DB_RUNTIME_ARTIFACTS = [
|
|
["packages/db/src/permissions-seed.server.ts", "packages/db/dist/permissions-seed.server.js"],
|
|
["packages/db/src/deploy-permission-catalog-sync.server.ts", "packages/db/dist/deploy-permission-catalog-sync.server.js"],
|
|
["prisma/seed.ts", "packages/db/dist/production-seed.server.js"],
|
|
["scripts/seed-e2e-readiness.ts", "packages/db/dist/e2e-readiness-seed.server.js"],
|
|
];
|
|
const FORBIDDEN_DOCKERIGNORE_PATTERNS = [
|
|
["build", "**/build"],
|
|
["dist", "**/dist"],
|
|
["cache", "**/cache"],
|
|
["report", "**/report"],
|
|
];
|
|
const REQUIRED_DOCKERIGNORE_PATTERNS = ["public/build", "/build", "/apps/*/build", "apps/*/dist", "packages/*/dist"];
|
|
const REQUIRED_DOCKERIGNORE_ARTIFACT_PATTERNS = [
|
|
"/docs",
|
|
"/secrets",
|
|
"/.gitea",
|
|
"/.github",
|
|
"/tests",
|
|
"/e2e",
|
|
"**/*.test.ts",
|
|
"**/*.test.tsx",
|
|
"**/*.test.mjs",
|
|
"**/*.test.js",
|
|
"**/*.spec.ts",
|
|
"/scripts/ci/deploy-buildkitd.toml",
|
|
"/scripts/ci/verify-deploy-buildkit-config.mjs",
|
|
"/scripts/verify-gitea-ci-workflow.mjs",
|
|
"/scripts/verify-gitea-deploy-workflow.mjs",
|
|
".cache",
|
|
"**/.cache",
|
|
".eslintcache",
|
|
"**/.eslintcache",
|
|
"coverage",
|
|
"**/coverage",
|
|
".nyc_output",
|
|
"**/.nyc_output",
|
|
"reports",
|
|
"**/reports",
|
|
"playwright-report",
|
|
"**/playwright-report",
|
|
"test-results",
|
|
"**/test-results",
|
|
"blob-report",
|
|
"**/blob-report",
|
|
];
|
|
const API_REQUIRED_COPY_PATTERNS = [
|
|
[
|
|
"target-specific node_modules",
|
|
/^COPY\s+--exclude=\*\*\/\*\.map\s+--exclude=\*\*\/\*\.d\.ts\s+--from=api-production-deps\s+\/prod\/api\/node_modules\s+\/myapp\/node_modules\s*$/im,
|
|
],
|
|
["apps/api/dist", /^COPY\s+--from=api-runtime-artifacts\s+\/myapp\/apps\/api\/dist\s+\/myapp\/apps\/api\/dist\s*$/im],
|
|
];
|
|
const WORKER_REQUIRED_COPY_PATTERNS = [
|
|
[
|
|
"target-specific node_modules",
|
|
/^COPY\s+--exclude=\*\*\/\*\.map\s+--exclude=\*\*\/\*\.d\.ts\s+--from=worker-production-deps\s+\/prod\/worker\/node_modules\s+\/myapp\/node_modules\s*$/im,
|
|
],
|
|
["apps/worker/dist", /^COPY\s+--from=worker-runtime-artifacts\s+\/myapp\/apps\/worker\/dist\s+\/myapp\/apps\/worker\/dist\s*$/im],
|
|
];
|
|
const WEB_REQUIRED_COPY_PATTERNS = [
|
|
[
|
|
"target-specific node_modules",
|
|
/^COPY\s+--exclude=\*\*\/\*\.map\s+--exclude=\*\*\/\*\.d\.ts\s+--from=web-production-deps\s+\/prod\/web\/node_modules\s+\/myapp\/node_modules\s*$/im,
|
|
],
|
|
["Core React Router build", /^COPY\s+--from=web-runtime-artifacts\s+\/myapp\/apps\/web\/build\s+\/myapp\/apps\/web\/build\s*$/im],
|
|
["Customer Portal React Router build", /^COPY\s+--from=web-runtime-artifacts\s+\/myapp\/apps\/web\/build-portal\s+\/myapp\/apps\/web\/build-portal\s*$/im],
|
|
["migrator role verifier", /^COPY\s+--from=build\s+\/myapp\/scripts\/verify-migrator-role\.mjs\s+\/myapp\/scripts\/verify-migrator-role\.mjs\s*$/im],
|
|
["Prisma baseline adopter", /^COPY\s+--from=build\s+\/myapp\/scripts\/adopt-prisma-baseline\.mjs\s+\/myapp\/scripts\/adopt-prisma-baseline\.mjs\s*$/im],
|
|
["prisma.config.ts", /^COPY\s+--from=build\s+\/myapp\/prisma\.config\.ts\s+\/myapp\/prisma\.config\.ts\s*$/im],
|
|
["packages/db production seed artifact", /^COPY\s+--from=build\s+\/myapp\/packages\/db\/dist\s+\/myapp\/packages\/db\/dist\s*$/im],
|
|
["prisma", /^COPY\s+--from=build\s+\/myapp\/prisma\s+\/myapp\/prisma\s*$/im],
|
|
];
|
|
const START_SCRIPT_EXEC_PATTERN = /^exec\s+node\s+build\/server-runtime\/server\.js\s*$/m;
|
|
const START_SCRIPT_MIGRATE_PATTERN = /^\s*node\s+\/myapp\/node_modules\/prisma\/build\/index\.js\s+migrate\s+deploy\s+--config\s+prisma\.config\.ts\s*$/m;
|
|
const START_SCRIPT_MIGRATOR_ROLE_VERIFY_PATTERN = /^\s*node\s+\/myapp\/scripts\/verify-migrator-role\.mjs\s*$/m;
|
|
const START_SCRIPT_BASELINE_ADOPTION_PATTERN = /^\s*node\s+\/myapp\/scripts\/adopt-prisma-baseline\.mjs\s+--adopt\s*$/m;
|
|
const START_SCRIPT_CURRENT_SCHEMA_VERIFY_PATTERN =
|
|
/^\s*node\s+\/myapp\/scripts\/adopt-prisma-baseline\.mjs\s+--verify-current-schema\s*$/m;
|
|
const START_SCRIPT_PERMISSION_CATALOG_SYNC_PATTERN = /^\s*node\s+\/myapp\/packages\/db\/dist\/deploy-permission-catalog-sync\.server\.js\s*$/m;
|
|
const FULL_WORKSPACE_SOURCE_COPY_PATTERN = /^(?:ADD|COPY)\s+(apps|packages)\s+\1\s*$/im;
|
|
const FROZEN_FETCH_PATTERN = /^RUN\s+--mount=type=cache,[^\n]*target=\/pnpm\/store[^\n]*\s+\\?\s*\n\s*pnpm\s+fetch\s+--frozen-lockfile\s*$/im;
|
|
const FROZEN_DEV_INSTALL_PATTERN =
|
|
/^RUN\s+(?:--mount=type=cache,[^\n]*\s+)?(?:\\\s*\n\s*)?pnpm\s+install\s+--frozen-lockfile\s+--prod=false(?:\s+--offline)?\s*$/im;
|
|
const PNPM_BUILDKIT_CACHE_PATTERN = /^RUN\s+--mount=type=cache,[^\n]*target=\/pnpm\/store[^\n]*\s+\\?\s*\n\s*pnpm\s+install\b/im;
|
|
const GENERIC_PRODUCTION_NODE_MODULES_COPY_PATTERN = /^COPY\s+--from=production-deps\s+\/myapp\/node_modules\s+\/myapp\/node_modules\s*$/im;
|
|
|
|
function manifestPath(relativePath) {
|
|
return relativePath.split(path.sep).join("/");
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function readJsonFile(rootDir, relativePath) {
|
|
const absolutePath = path.join(rootDir, relativePath);
|
|
return JSON.parse(fs.readFileSync(absolutePath, "utf8"));
|
|
}
|
|
|
|
function hasScript(manifest, scriptName) {
|
|
return typeof manifest.scripts?.[scriptName] === "string" && manifest.scripts[scriptName].trim().length > 0;
|
|
}
|
|
|
|
function getComposeServiceBlock(content, serviceName) {
|
|
const escaped = escapeRegExp(serviceName);
|
|
return (
|
|
content.match(new RegExp(`^ ${escaped}:[^\\n]*\\n([\\s\\S]*?)(?=^ [A-Za-z0-9_-]+:[^\\n]*\\n|^volumes:\\n|^networks:\\n|(?![\\s\\S]))`, "m"))?.[1] ?? ""
|
|
);
|
|
}
|
|
|
|
function listWorkspacePackageManifests(rootDir) {
|
|
const manifests = [];
|
|
|
|
for (const workspaceRoot of ["apps", "packages", "runtime"]) {
|
|
const absoluteWorkspaceRoot = path.join(rootDir, workspaceRoot);
|
|
if (!fs.existsSync(absoluteWorkspaceRoot)) {
|
|
continue;
|
|
}
|
|
|
|
for (const entry of fs.readdirSync(absoluteWorkspaceRoot, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) {
|
|
continue;
|
|
}
|
|
|
|
const relativePath = manifestPath(path.join(workspaceRoot, entry.name, "package.json"));
|
|
if (fs.existsSync(path.join(rootDir, relativePath))) {
|
|
manifests.push(relativePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
return manifests.sort();
|
|
}
|
|
|
|
export function checkRequiredFiles(rootDir) {
|
|
const passes = [];
|
|
const failures = [];
|
|
|
|
for (const relativePath of REQUIRED_FILES) {
|
|
if (fs.existsSync(path.join(rootDir, relativePath))) {
|
|
passes.push(`${relativePath}: exists`);
|
|
} else {
|
|
failures.push(`${relativePath}: missing required Docker baseline file`);
|
|
}
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkDockerignoreContract(rootDir) {
|
|
const relativePath = ".dockerignore";
|
|
const passes = [];
|
|
const failures = [];
|
|
const content = fs.readFileSync(path.join(rootDir, relativePath), "utf8");
|
|
const patterns = content
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0 && !line.startsWith("#"));
|
|
|
|
for (const [sourceName, pattern] of FORBIDDEN_DOCKERIGNORE_PATTERNS) {
|
|
if (patterns.includes(pattern)) {
|
|
failures.push(`${relativePath}: must not use broad ${pattern}; it can exclude source directories named ${sourceName}`);
|
|
} else {
|
|
passes.push(`${relativePath}: does not exclude all ${sourceName} directories recursively`);
|
|
}
|
|
}
|
|
|
|
for (const pattern of REQUIRED_DOCKERIGNORE_PATTERNS) {
|
|
if (patterns.includes(pattern)) {
|
|
passes.push(`${relativePath}: ignores generated ${pattern}`);
|
|
} else {
|
|
failures.push(`${relativePath}: must ignore generated ${pattern} without broad recursive source exclusions`);
|
|
}
|
|
}
|
|
|
|
for (const pattern of REQUIRED_DOCKERIGNORE_ARTIFACT_PATTERNS) {
|
|
if (patterns.includes(pattern)) {
|
|
passes.push(`${relativePath}: ignores Docker context artifact ${pattern}`);
|
|
} else {
|
|
failures.push(`${relativePath}: must ignore Docker context artifact ${pattern} without broad recursive source exclusions`);
|
|
}
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkStartScript(rootDir) {
|
|
const relativePath = "start.sh";
|
|
const passes = [];
|
|
const failures = [];
|
|
const content = fs.readFileSync(path.join(rootDir, relativePath), "utf8");
|
|
|
|
if (/^#!\/bin\/sh\s*$/m.test(content)) {
|
|
passes.push(`${relativePath}: uses POSIX shell shebang`);
|
|
} else {
|
|
failures.push(`${relativePath}: must use #!/bin/sh`);
|
|
}
|
|
|
|
if (START_SCRIPT_EXEC_PATTERN.test(content)) {
|
|
passes.push(`${relativePath}: execs the production start process for container signal handling`);
|
|
} else {
|
|
failures.push(`${relativePath}: must use "exec node build/server-runtime/server.js" so container signals reach the web process without pnpm indirection`);
|
|
}
|
|
|
|
if (/^set -eu\s*$/m.test(content)) {
|
|
passes.push(`${relativePath}: fails closed on command errors and unset variables (set -eu)`);
|
|
} else {
|
|
failures.push(`${relativePath}: must set -eu so migration and runtime credential mistakes fail closed`);
|
|
}
|
|
|
|
if (
|
|
/if \[ "\$\{1:-\}" = "migrate" \]; then/.test(content) &&
|
|
/DIRECT_URL is required for the one-shot migrator/.test(content) &&
|
|
/DATABASE_URL must not be forwarded to the one-shot migrator/.test(content) &&
|
|
/AUTHENTIK_ADMIN_JIT_DATABASE_URL belongs only to the web runtime/.test(content) &&
|
|
/MIGRATOR_\* aliases must not be forwarded to the one-shot migrator/.test(content) &&
|
|
START_SCRIPT_MIGRATOR_ROLE_VERIFY_PATTERN.test(content) &&
|
|
START_SCRIPT_BASELINE_ADOPTION_PATTERN.test(content) &&
|
|
content.indexOf("node /myapp/scripts/verify-migrator-role.mjs") < content.indexOf("node /myapp/scripts/adopt-prisma-baseline.mjs --adopt") &&
|
|
content.indexOf("node /myapp/scripts/adopt-prisma-baseline.mjs --adopt") < content.indexOf('export DATABASE_URL="$DIRECT_URL"') &&
|
|
/export DATABASE_URL="\$DIRECT_URL"/.test(content) &&
|
|
START_SCRIPT_MIGRATE_PATTERN.test(content) &&
|
|
START_SCRIPT_CURRENT_SCHEMA_VERIFY_PATTERN.test(content) &&
|
|
content.indexOf("node /myapp/node_modules/prisma/build/index.js migrate deploy --config prisma.config.ts") <
|
|
content.indexOf("node /myapp/scripts/adopt-prisma-baseline.mjs --verify-current-schema")
|
|
) {
|
|
passes.push(`${relativePath}: isolates migrate deploy behind the DIRECT_URL-only one-shot mode`);
|
|
} else {
|
|
failures.push(
|
|
`${relativePath}: must verify the exact non-superuser migrator role and safely adopt the reviewed baseline before running the installed Prisma CLI in a DIRECT_URL-only "migrate" mode without package-manager indirection`
|
|
);
|
|
}
|
|
|
|
const migrateModeStartMarker = 'if [ "${1:-}" = "migrate" ]; then';
|
|
const migrateModeEndMarker = " exit 0\nfi";
|
|
const permissionCatalogSyncCommand = "node /myapp/packages/db/dist/deploy-permission-catalog-sync.server.js";
|
|
const migrateModeStartIndex = content.indexOf(migrateModeStartMarker);
|
|
const migrateModeEndIndex = migrateModeStartIndex >= 0 ? content.indexOf(migrateModeEndMarker, migrateModeStartIndex) : -1;
|
|
const migrateModeBlock =
|
|
migrateModeStartIndex >= 0 && migrateModeEndIndex >= 0 ? content.slice(migrateModeStartIndex, migrateModeEndIndex + migrateModeEndMarker.length) : "";
|
|
const migratorRoleProbeIndex = migrateModeBlock.indexOf("node /myapp/scripts/verify-migrator-role.mjs");
|
|
const baselineAdoptionIndex = migrateModeBlock.indexOf("node /myapp/scripts/adopt-prisma-baseline.mjs --adopt");
|
|
const databaseAliasIndex = migrateModeBlock.indexOf('export DATABASE_URL="$DIRECT_URL"');
|
|
const migrateIndex = migrateModeBlock.indexOf("node /myapp/node_modules/prisma/build/index.js migrate deploy --config prisma.config.ts");
|
|
const currentSchemaVerifyIndex = migrateModeBlock.indexOf("node /myapp/scripts/adopt-prisma-baseline.mjs --verify-current-schema");
|
|
const permissionCatalogSyncIndex = migrateModeBlock.indexOf(permissionCatalogSyncCommand);
|
|
const migrateModeExitIndex = migrateModeBlock.indexOf(" exit 0");
|
|
const permissionCatalogSyncOccurrences = content.split(permissionCatalogSyncCommand).length - 1;
|
|
if (
|
|
permissionCatalogSyncOccurrences === 1 &&
|
|
START_SCRIPT_PERMISSION_CATALOG_SYNC_PATTERN.test(migrateModeBlock) &&
|
|
migratorRoleProbeIndex >= 0 &&
|
|
migratorRoleProbeIndex < baselineAdoptionIndex &&
|
|
baselineAdoptionIndex < databaseAliasIndex &&
|
|
databaseAliasIndex < migrateIndex &&
|
|
migrateIndex < currentSchemaVerifyIndex &&
|
|
currentSchemaVerifyIndex < permissionCatalogSyncIndex &&
|
|
permissionCatalogSyncIndex < migrateModeExitIndex
|
|
) {
|
|
passes.push(`${relativePath}: syncs the permission catalog inside one-shot migrate mode only after successful migrations`);
|
|
} else {
|
|
failures.push(
|
|
`${relativePath}: must run the bundled permission catalog sync exactly once inside one-shot migrate mode after baseline adoption, prisma migrate deploy, and current schema verification`
|
|
);
|
|
}
|
|
|
|
if (
|
|
/seed credentials and flags must not be forwarded to the one-shot migrator/.test(content) &&
|
|
!/\bnode packages\/db\/dist\/production-seed\.server\.js\b/.test(content)
|
|
) {
|
|
passes.push(`${relativePath}: keeps seeding outside the non-superuser migration authority`);
|
|
} else {
|
|
failures.push(`${relativePath}: one-shot migrator must reject SEED_* and must not execute the cross-tenant production seed`);
|
|
}
|
|
|
|
if (
|
|
/RLS_RUNTIME_MODE=required is mandatory for the web runtime/.test(content) &&
|
|
/DATABASE_RUNTIME_ROLE=hivegrid_app is mandatory for the web runtime/.test(content) &&
|
|
/DIRECT_URL must not be forwarded to the web runtime/.test(content) &&
|
|
/MIGRATOR_\* credentials must not be forwarded to the web runtime/.test(content) &&
|
|
/AUTHENTIK_ADMIN_JIT_DATABASE_URL must use the dedicated hivegrid_authentik_jit login/.test(content) &&
|
|
/MIGRATE_ON_BOOT is forbidden/.test(content) &&
|
|
/SEED_ON_BOOT is forbidden/.test(content) &&
|
|
!/RLS_RUNTIME_MODE=off/.test(content)
|
|
) {
|
|
passes.push(`${relativePath}: rejects privileged credentials and RLS bypasses in the long-lived web runtime`);
|
|
} else {
|
|
failures.push(`${relativePath}: web runtime must require hivegrid_app with RLS required and reject direct, migrate, and seed env`);
|
|
}
|
|
|
|
if (
|
|
/forbidden core session, OAuth, billing, ingress, or domain-controller credential in the portal web runtime/.test(content) &&
|
|
/AUTHENTIK_/.test(content) &&
|
|
/OAUTH/.test(content) &&
|
|
/BILLING/.test(content) &&
|
|
/COOLIFY/.test(content) &&
|
|
/SSH/.test(content) &&
|
|
/TRAEFIK/.test(content) &&
|
|
/CLOUDFLARE/.test(content) &&
|
|
/DOMAIN_CONTROLLER/.test(content)
|
|
) {
|
|
passes.push(`${relativePath}: rejects Core and operator credentials inside the portal process itself`);
|
|
} else {
|
|
failures.push(`${relativePath}: portal runtime must reject Core session, OAuth, billing, ingress, and domain-controller credentials before Node starts`);
|
|
}
|
|
|
|
if (
|
|
/WEB_RUNTIME_SURFACE must be explicitly set to core, admin, or portal/.test(content) &&
|
|
/HIVEGRID_RUNTIME_SURFACE must match WEB_RUNTIME_SURFACE/.test(content) &&
|
|
/export HIVEGRID_RUNTIME_SURFACE="\$WEB_RUNTIME_SURFACE"/.test(content)
|
|
) {
|
|
passes.push(`${relativePath}: binds the selected runtime to the matching isolated React Router build`);
|
|
} else {
|
|
failures.push(`${relativePath}: must validate WEB_RUNTIME_SURFACE and export the matching HIVEGRID_RUNTIME_SURFACE`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkDockerComposeWorkerTopology(rootDir) {
|
|
const passes = [];
|
|
const failures = [];
|
|
const composePath = "docker-compose.yml";
|
|
const stagingPath = "docker-compose.staging.yml";
|
|
const compose = fs.readFileSync(path.join(rootDir, composePath), "utf8");
|
|
const staging = fs.readFileSync(path.join(rootDir, stagingPath), "utf8");
|
|
|
|
for (const [service, port] of [
|
|
["redis", "6379"],
|
|
["clamav", "3310"],
|
|
]) {
|
|
const block = getComposeServiceBlock(compose, service);
|
|
if (block.includes(`127.0.0.1:${port}:${port}`)) {
|
|
passes.push(`${composePath}: ${service} host port ${port} is loopback-only`);
|
|
} else {
|
|
failures.push(`${composePath}: ${service} host port ${port} must bind to 127.0.0.1 or remain unpublished`);
|
|
}
|
|
}
|
|
|
|
for (const { service, group, healthPort, adminPort, concurrencyEnv } of REQUIRED_WORKER_GROUPS) {
|
|
const block = getComposeServiceBlock(compose, service);
|
|
if (!block) {
|
|
failures.push(`${composePath}: missing ${service} service for WORKER_QUEUE_GROUP=${group}`);
|
|
} else if (block.includes(`WORKER_QUEUE_GROUP=${group}`)) {
|
|
passes.push(`${composePath}: ${service} sets WORKER_QUEUE_GROUP=${group}`);
|
|
} else {
|
|
failures.push(`${composePath}: ${service} must set WORKER_QUEUE_GROUP=${group}`);
|
|
}
|
|
|
|
if (!block) {
|
|
// Already reported above.
|
|
} else if (block.includes(concurrencyEnv)) {
|
|
passes.push(`${composePath}: ${service} exposes ${concurrencyEnv}`);
|
|
} else {
|
|
failures.push(`${composePath}: ${service} must expose ${concurrencyEnv}`);
|
|
}
|
|
|
|
if (!block) {
|
|
// Already reported above.
|
|
} else if (block.includes(`WORKER_HEALTH_PORT=${healthPort}`) && /test:\s*\["CMD",\s*"node",\s*"apps\/worker\/dist\/health\.js"\]/.test(block)) {
|
|
passes.push(`${composePath}: ${service} uses the dependency-free health probe on port ${healthPort}`);
|
|
} else {
|
|
failures.push(`${composePath}: ${service} must use WORKER_HEALTH_PORT=${healthPort} and the node apps/worker/dist/health.js probe`);
|
|
}
|
|
|
|
if (!block) {
|
|
// Already reported above.
|
|
} else if (block.includes(`WORKER_ADMIN_PORT=${adminPort}`) && adminPort !== healthPort) {
|
|
passes.push(`${composePath}: ${service} uses distinct WORKER_ADMIN_PORT=${adminPort}`);
|
|
} else {
|
|
failures.push(`${composePath}: ${service} must set WORKER_ADMIN_PORT=${adminPort} distinct from WORKER_HEALTH_PORT=${healthPort}`);
|
|
}
|
|
|
|
const stagingBlock = getComposeServiceBlock(staging, service);
|
|
if (!stagingBlock) {
|
|
failures.push(`${stagingPath}: missing ${service} staging overlay`);
|
|
continue;
|
|
}
|
|
if (
|
|
stagingBlock.includes("WORKER_DATABASE_URL") &&
|
|
stagingBlock.includes("DATABASE_RUNTIME_ROLE=hivegrid_worker") &&
|
|
stagingBlock.includes("RLS_RUNTIME_MODE=required") &&
|
|
!stagingBlock.includes("DIRECT_URL=") &&
|
|
!stagingBlock.includes("AUTHENTIK_ADMIN_JIT_DATABASE_URL") &&
|
|
stagingBlock.includes("condition: service_healthy")
|
|
) {
|
|
passes.push(`${stagingPath}: ${service} receives only the RLS-enforced worker database credential`);
|
|
} else {
|
|
failures.push(`${stagingPath}: ${service} must use WORKER_DATABASE_URL with hivegrid_worker, RLS required, no DIRECT_URL, and a PgBouncer dependency`);
|
|
}
|
|
}
|
|
|
|
for (const service of ["api", "web", "hocuspocus"]) {
|
|
const block = getComposeServiceBlock(staging, service);
|
|
const hasExpectedJitCredential =
|
|
service === "web"
|
|
? block.includes("AUTHENTIK_ADMIN_JIT_DATABASE_URL=${AUTHENTIK_ADMIN_JIT_DATABASE_URL:-}")
|
|
: !block.includes("AUTHENTIK_ADMIN_JIT_DATABASE_URL");
|
|
if (
|
|
block.includes("APP_DATABASE_URL") &&
|
|
block.includes("DATABASE_RUNTIME_ROLE=hivegrid_app") &&
|
|
block.includes("RLS_RUNTIME_MODE=required") &&
|
|
!block.includes("DIRECT_URL=") &&
|
|
hasExpectedJitCredential
|
|
) {
|
|
passes.push(`${stagingPath}: ${service} receives only its RLS-enforced database capability`);
|
|
} else {
|
|
failures.push(`${stagingPath}: ${service} must use hivegrid_app with RLS required while the Authentik JIT credential remains web-only`);
|
|
}
|
|
}
|
|
|
|
const migratorBlock = getComposeServiceBlock(staging, "migrator");
|
|
if (
|
|
migratorBlock.includes('entrypoint: ["./start.sh", "migrate"]') &&
|
|
migratorBlock.includes("DIRECT_URL=${MIGRATOR_DIRECT_URL") &&
|
|
!migratorBlock.includes("DATABASE_URL=") &&
|
|
!migratorBlock.includes("AUTHENTIK_ADMIN_JIT_DATABASE_URL")
|
|
) {
|
|
passes.push(`${stagingPath}: migrator is a DIRECT_URL-only one-shot service`);
|
|
} else {
|
|
failures.push(`${stagingPath}: migrator must be a DIRECT_URL-only ./start.sh migrate service`);
|
|
}
|
|
|
|
if (!staging.includes("RLS_RUNTIME_MODE=off")) {
|
|
passes.push(`${stagingPath}: contains no RLS runtime bypass`);
|
|
} else {
|
|
failures.push(`${stagingPath}: must not set RLS_RUNTIME_MODE=off`);
|
|
}
|
|
|
|
const generalBlock = getComposeServiceBlock(compose, "worker-general");
|
|
const stagingGeneralBlock = getComposeServiceBlock(staging, "worker-general");
|
|
for (const envName of REQUIRED_WORKER_GENERAL_WORKFLOW_ENV) {
|
|
if (generalBlock.includes(`${envName}=`)) {
|
|
passes.push(`${composePath}: worker-general passes through ${envName}`);
|
|
} else {
|
|
failures.push(`${composePath}: worker-general must pass through ${envName} so outbox.workflow is explicitly configured`);
|
|
}
|
|
}
|
|
for (const [envName, expectedValue] of REQUIRED_STAGING_WORKER_GENERAL_ENV) {
|
|
if (stagingGeneralBlock.includes(`${envName}=${expectedValue}`)) {
|
|
passes.push(`${stagingPath}: worker-general forces ${envName}=${expectedValue}`);
|
|
} else {
|
|
failures.push(`${stagingPath}: worker-general must force ${envName}=${expectedValue} so staging/prod outbox.workflow cannot silently idle`);
|
|
}
|
|
}
|
|
for (const envName of REQUIRED_WORKER_GENERAL_BILLING_ENV) {
|
|
if (generalBlock.includes(`${envName}=`)) {
|
|
passes.push(`${composePath}: worker-general passes through ${envName} for billing reconciliation`);
|
|
} else {
|
|
failures.push(`${composePath}: worker-general must pass through ${envName} for billing reconciliation`);
|
|
}
|
|
if (stagingGeneralBlock.includes(`${envName}=`)) {
|
|
passes.push(`${stagingPath}: worker-general configures ${envName} for required billing reconciliation`);
|
|
} else {
|
|
failures.push(`${stagingPath}: worker-general must configure ${envName} for required billing reconciliation`);
|
|
}
|
|
}
|
|
for (const envName of REQUIRED_WORKER_GENERAL_SEARCH_ENV) {
|
|
if (generalBlock.includes(`${envName}=\${${envName}:-}`)) {
|
|
passes.push(`${composePath}: worker-general passes through ${envName} for tenant hard-delete search cleanup`);
|
|
} else {
|
|
failures.push(`${composePath}: worker-general must pass through ${envName} for tenant hard-delete search cleanup`);
|
|
}
|
|
if (stagingGeneralBlock.includes(`${envName}=\${${envName}:?`)) {
|
|
passes.push(`${stagingPath}: worker-general requires ${envName} for tenant hard-delete search cleanup`);
|
|
} else {
|
|
failures.push(`${stagingPath}: worker-general must require ${envName} for tenant hard-delete search cleanup`);
|
|
}
|
|
}
|
|
|
|
if (generalBlock.includes("CLAMAV_HOST=") && generalBlock.includes("CLAMAV_PORT=") && generalBlock.includes("clamav:")) {
|
|
passes.push(`${composePath}: worker-general owns ClamAV dependency for file scanning`);
|
|
} else {
|
|
failures.push(`${composePath}: worker-general must keep CLAMAV_* env and depend on clamav for file scanning`);
|
|
}
|
|
if (generalBlock.includes("SCAN_QUEUE_ENABLED=${SCAN_QUEUE_ENABLED:-true}")) {
|
|
passes.push(`${composePath}: worker-general enables the fail-closed scan queue by default`);
|
|
} else {
|
|
failures.push(`${composePath}: worker-general must default SCAN_QUEUE_ENABLED to true`);
|
|
}
|
|
for (const envName of REQUIRED_WORKER_SCAN_ENV) {
|
|
if (generalBlock.includes(`${envName}=`)) {
|
|
passes.push(`${composePath}: worker-general passes through ${envName} for scan and preview storage`);
|
|
} else {
|
|
failures.push(`${composePath}: worker-general must pass through ${envName} for scan and preview storage`);
|
|
}
|
|
}
|
|
if (stagingGeneralBlock.includes("SCAN_QUEUE_ENABLED=true")) {
|
|
passes.push(`${stagingPath}: worker-general forces SCAN_QUEUE_ENABLED=true`);
|
|
} else {
|
|
failures.push(`${stagingPath}: worker-general must force SCAN_QUEUE_ENABLED=true`);
|
|
}
|
|
if (generalBlock.includes("EXPORT_RETENTION_QUEUE_ENABLED=${EXPORT_RETENTION_QUEUE_ENABLED:-false}")) {
|
|
passes.push(`${composePath}: worker-general passes through EXPORT_RETENTION_QUEUE_ENABLED with a fail-closed default`);
|
|
} else {
|
|
failures.push(`${composePath}: worker-general must pass through EXPORT_RETENTION_QUEUE_ENABLED with a fail-closed false default`);
|
|
}
|
|
if (stagingGeneralBlock.includes("EXPORT_RETENTION_QUEUE_ENABLED=true")) {
|
|
passes.push(`${stagingPath}: worker-general forces EXPORT_RETENTION_QUEUE_ENABLED=true for GDPR export hard-delete`);
|
|
} else {
|
|
failures.push(`${stagingPath}: worker-general must force EXPORT_RETENTION_QUEUE_ENABLED=true for GDPR export hard-delete`);
|
|
}
|
|
for (const envName of REQUIRED_STAGING_WEB_GDPR_EXPORT_STORAGE_ENV.filter((name) => name !== "GDPR_EXPORT_SSE_C_KEY_BASE64")) {
|
|
const sourceName = stagingStorageSource("worker-general", envName);
|
|
if (stagingGeneralBlock.includes(`${envName}=\${${sourceName}:?`)) {
|
|
passes.push(`${stagingPath}: worker-general requires ${envName} for GDPR export retention`);
|
|
} else {
|
|
failures.push(`${stagingPath}: worker-general must require ${envName} for GDPR export retention`);
|
|
}
|
|
}
|
|
if (stagingGeneralBlock.includes(`STORAGE_SESSION_TOKEN=\${${stagingStorageSource("worker-general", "STORAGE_SESSION_TOKEN")}:-`)) {
|
|
passes.push(`${stagingPath}: worker-general uses its dedicated optional storage session-token source`);
|
|
} else {
|
|
failures.push(`${stagingPath}: worker-general must use its dedicated optional storage session-token source`);
|
|
}
|
|
for (const service of NON_SCAN_WORKER_SERVICES) {
|
|
const block = getComposeServiceBlock(compose, service);
|
|
if (!block) {
|
|
continue;
|
|
}
|
|
if (!block.includes("depends_on:")) {
|
|
failures.push(`${composePath}: ${service} must override worker-base depends_on so it does not inherit clamav`);
|
|
continue;
|
|
}
|
|
if (block.includes("clamav:") || block.includes("CLAMAV_HOST=") || block.includes("CLAMAV_PORT=")) {
|
|
failures.push(`${composePath}: ${service} must not depend on clamav or receive CLAMAV_* scan env`);
|
|
} else if (block.includes("redis:") && block.includes("condition: service_healthy")) {
|
|
passes.push(`${composePath}: ${service} depends on redis only and does not wait for ClamAV`);
|
|
} else {
|
|
failures.push(`${composePath}: ${service} must depend on redis with condition: service_healthy after overriding worker-base depends_on`);
|
|
}
|
|
}
|
|
|
|
const importBlock = getComposeServiceBlock(compose, "worker-import");
|
|
const stagingImportBlock = getComposeServiceBlock(staging, "worker-import");
|
|
for (const [envName] of REQUIRED_WORKER_IMPORT_DEDICATED_GATE_ENV) {
|
|
if (importBlock.includes(`${envName}=\${${envName}:-false}`)) {
|
|
passes.push(`${composePath}: worker-import passes through ${envName}`);
|
|
} else {
|
|
failures.push(`${composePath}: worker-import must pass through ${envName} with a fail-closed false default`);
|
|
}
|
|
}
|
|
for (const envName of [...REQUIRED_STAGING_WEB_GDPR_EXPORT_STORAGE_ENV, ...OPTIONAL_STAGING_WEB_GDPR_EXPORT_STORAGE_ENV]) {
|
|
if (importBlock.includes(`${envName}=\${${envName}:-`)) {
|
|
passes.push(`${composePath}: worker-import passes through ${envName} for encrypted GDPR exports`);
|
|
} else {
|
|
failures.push(`${composePath}: worker-import must pass through ${envName} for encrypted GDPR exports`);
|
|
}
|
|
}
|
|
for (const [envName, expectedValue] of REQUIRED_WORKER_IMPORT_DEDICATED_GATE_ENV) {
|
|
if (stagingImportBlock.includes(`${envName}=${expectedValue}`)) {
|
|
passes.push(`${stagingPath}: worker-import forces ${envName}=${expectedValue}`);
|
|
} else {
|
|
failures.push(`${stagingPath}: worker-import must force ${envName}=${expectedValue} for dedicated encrypted GDPR exports`);
|
|
}
|
|
}
|
|
for (const envName of REQUIRED_STAGING_WEB_GDPR_EXPORT_STORAGE_ENV) {
|
|
const sourceName = stagingStorageSource("worker-import", envName);
|
|
if (stagingImportBlock.includes(`${envName}=\${${sourceName}:?`)) {
|
|
passes.push(`${stagingPath}: worker-import requires ${envName} for encrypted GDPR exports`);
|
|
} else {
|
|
failures.push(`${stagingPath}: worker-import must require ${envName} for encrypted GDPR exports`);
|
|
}
|
|
}
|
|
for (const envName of OPTIONAL_STAGING_WEB_GDPR_EXPORT_STORAGE_ENV) {
|
|
const sourceName = stagingStorageSource("worker-import", envName);
|
|
if (stagingImportBlock.includes(`${envName}=\${${sourceName}:-`)) {
|
|
passes.push(`${stagingPath}: worker-import passes through optional ${envName} for encrypted GDPR exports`);
|
|
} else {
|
|
failures.push(`${stagingPath}: worker-import must pass through optional ${envName} for encrypted GDPR exports`);
|
|
}
|
|
}
|
|
|
|
const webhookBlock = getComposeServiceBlock(compose, "worker-webhook");
|
|
const stagingWebhookBlock = getComposeServiceBlock(staging, "worker-webhook");
|
|
const stagingWebBlock = getComposeServiceBlock(staging, "web");
|
|
if (stagingWebBlock.includes("REDIS_URL=${REDIS_URL:?")) {
|
|
passes.push(`${stagingPath}: web requires REDIS_URL for distributed GDPR export download gates`);
|
|
} else {
|
|
failures.push(`${stagingPath}: web must require REDIS_URL for distributed GDPR export download gates`);
|
|
}
|
|
for (const envName of REQUIRED_WEBHOOK_VAPID_ENV) {
|
|
const optionalSource = `${envName}=\${${envName}:-}`;
|
|
const requiredSource = `${envName}=\${${envName}:?`;
|
|
if (webhookBlock.includes(optionalSource) && stagingWebhookBlock.includes(requiredSource)) {
|
|
passes.push(`${composePath}: worker-webhook and staging overlay pass through ${envName}`);
|
|
} else {
|
|
failures.push(`${composePath}: worker-webhook and staging overlay must pass through ${envName}`);
|
|
}
|
|
}
|
|
const requiredPublicKeySource = "WEB_PUSH_VAPID_PUBLIC_KEY=${WEB_PUSH_VAPID_PUBLIC_KEY:?";
|
|
if (stagingWebBlock.includes(requiredPublicKeySource) && stagingWebhookBlock.includes(requiredPublicKeySource)) {
|
|
passes.push(`${stagingPath}: web and worker-webhook share the VAPID public key source`);
|
|
} else {
|
|
failures.push(`${stagingPath}: web must receive WEB_PUSH_VAPID_PUBLIC_KEY from the shared deployment source`);
|
|
}
|
|
for (const envName of REQUIRED_STAGING_WEB_AIRCALL_ENV) {
|
|
if (stagingWebBlock.includes(`${envName}=`)) {
|
|
passes.push(`${stagingPath}: web passes through ${envName}`);
|
|
} else {
|
|
failures.push(`${stagingPath}: web must pass through ${envName} for Aircall OAuth`);
|
|
}
|
|
}
|
|
for (const envName of REQUIRED_STAGING_WEB_GDPR_EXPORT_STORAGE_ENV) {
|
|
const sourceName = stagingStorageSource("web", envName);
|
|
if (stagingWebBlock.includes(`${envName}=\${${sourceName}:?`)) {
|
|
passes.push(`${stagingPath}: web requires ${envName} for encrypted GDPR export downloads`);
|
|
} else {
|
|
failures.push(`${stagingPath}: web must require ${envName} for encrypted GDPR export downloads`);
|
|
}
|
|
}
|
|
for (const envName of OPTIONAL_STAGING_WEB_GDPR_EXPORT_STORAGE_ENV) {
|
|
const sourceName = stagingStorageSource("web", envName);
|
|
if (stagingWebBlock.includes(`${envName}=\${${sourceName}:-`)) {
|
|
passes.push(`${stagingPath}: web passes through optional ${envName} for encrypted GDPR export downloads`);
|
|
} else {
|
|
failures.push(`${stagingPath}: web must pass through optional ${envName} for encrypted GDPR export downloads`);
|
|
}
|
|
}
|
|
|
|
const missingCommandServices = REQUIRED_WORKER_GROUPS.map((worker) => worker.service).filter((service) => !staging.includes(service));
|
|
if (missingCommandServices.length === 0) {
|
|
passes.push(`${stagingPath}: reference compose command includes all worker group services`);
|
|
} else {
|
|
failures.push(`${stagingPath}: reference compose command must include ${missingCommandServices.join(", ")}`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkRootManifest(rootDir) {
|
|
const relativePath = "package.json";
|
|
const passes = [];
|
|
const failures = [];
|
|
const manifest = readJsonFile(rootDir, relativePath);
|
|
|
|
for (const scriptName of ROOT_REQUIRED_SCRIPTS) {
|
|
if (hasScript(manifest, scriptName)) {
|
|
passes.push(`${relativePath}: script "${scriptName}" is defined`);
|
|
} else {
|
|
failures.push(`${relativePath}: missing required script "${scriptName}"`);
|
|
}
|
|
}
|
|
|
|
if (!hasScript(manifest, "start")) {
|
|
failures.push(`${relativePath}: missing required script "start"`);
|
|
} else {
|
|
const startCommand = manifest.scripts.start;
|
|
if (startCommand === "pnpm --filter @hivegrid/web start") {
|
|
passes.push(`${relativePath}: production start script delegates to @hivegrid/web`);
|
|
} else {
|
|
failures.push(`${relativePath}: script "start" must delegate to @hivegrid/web`);
|
|
}
|
|
|
|
if (/\btsx\b/.test(startCommand)) {
|
|
failures.push(`${relativePath}: script "start" must not use tsx for web production runtime`);
|
|
} else {
|
|
passes.push(`${relativePath}: production start script does not use tsx`);
|
|
}
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkDbManifest(rootDir) {
|
|
const relativePath = "packages/db/package.json";
|
|
const passes = [];
|
|
const failures = [];
|
|
const manifest = readJsonFile(rootDir, relativePath);
|
|
const buildCommand = manifest.scripts?.build;
|
|
const bundleCommand =
|
|
typeof buildCommand === "string"
|
|
? buildCommand
|
|
.split("&&")
|
|
.map((command) => command.trim())
|
|
.find((command) => command.includes("packages/db/src/deploy-permission-catalog-sync.server.ts"))
|
|
: undefined;
|
|
|
|
if (
|
|
bundleCommand?.includes("--bundle") &&
|
|
bundleCommand.includes("--platform=node") &&
|
|
bundleCommand.includes("--format=esm") &&
|
|
bundleCommand.includes("--alias:@hivegrid/logger=./packages/logger/src/index.ts") &&
|
|
bundleCommand.includes("--outfile=packages/db/dist/deploy-permission-catalog-sync.server.js")
|
|
) {
|
|
passes.push(`${relativePath}: builds the self-contained deploy permission catalog sync artifact`);
|
|
} else {
|
|
failures.push(
|
|
`${relativePath}: build must bundle deploy-permission-catalog-sync.server.ts for Node and inline the workspace logger into dist/deploy-permission-catalog-sync.server.js`
|
|
);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkWebManifest(rootDir) {
|
|
const relativePath = "apps/web/package.json";
|
|
const passes = [];
|
|
const failures = [];
|
|
const manifest = readJsonFile(rootDir, relativePath);
|
|
|
|
if (!hasScript(manifest, "start")) {
|
|
failures.push(`${relativePath}: missing required script "start"`);
|
|
} else {
|
|
const startCommand = manifest.scripts.start;
|
|
if (startCommand === "node ../../build/server-runtime/server.js") {
|
|
passes.push(`${relativePath}: owns production web server start command`);
|
|
} else {
|
|
failures.push(`${relativePath}: script "start" must run node ../../build/server-runtime/server.js`);
|
|
}
|
|
if (/\btsx\b/.test(startCommand)) {
|
|
failures.push(`${relativePath}: script "start" must not use tsx for web production runtime`);
|
|
} else {
|
|
passes.push(`${relativePath}: production start script does not use tsx`);
|
|
}
|
|
}
|
|
|
|
if (manifest.dependencies?.["@hivegrid/api"]) {
|
|
failures.push(`${relativePath}: web must not depend on the separately deployed @hivegrid/api app`);
|
|
} else {
|
|
passes.push(`${relativePath}: does not depend on the separate API application`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkApiManifest(rootDir) {
|
|
const relativePath = "apps/api/package.json";
|
|
const passes = [];
|
|
const failures = [];
|
|
const manifest = readJsonFile(rootDir, relativePath);
|
|
|
|
for (const scriptName of API_REQUIRED_SCRIPTS) {
|
|
if (hasScript(manifest, scriptName)) {
|
|
passes.push(`${relativePath}: script "${scriptName}" is defined`);
|
|
} else {
|
|
failures.push(`${relativePath}: missing required script "${scriptName}"`);
|
|
}
|
|
}
|
|
|
|
if (!hasScript(manifest, "start")) {
|
|
failures.push(`${relativePath}: missing required script "start"`);
|
|
} else if (/\bnode\b/.test(manifest.scripts.start)) {
|
|
passes.push(`${relativePath}: production start script uses node`);
|
|
} else {
|
|
failures.push(`${relativePath}: script "start" must contain node for production runtime`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkWorkerManifest(rootDir) {
|
|
const relativePath = "apps/worker/package.json";
|
|
const passes = [];
|
|
const failures = [];
|
|
const manifest = readJsonFile(rootDir, relativePath);
|
|
const requiredScripts = [
|
|
["start", /\bdist\/index\.js\b/],
|
|
["health", /\bdist\/health\.js\b/],
|
|
];
|
|
|
|
for (const [scriptName, distPattern] of requiredScripts) {
|
|
if (!hasScript(manifest, scriptName)) {
|
|
failures.push(`${relativePath}: missing required script "${scriptName}"`);
|
|
continue;
|
|
}
|
|
|
|
const command = manifest.scripts[scriptName];
|
|
if (/\bnode\b/.test(command)) {
|
|
passes.push(`${relativePath}: script "${scriptName}" uses node`);
|
|
} else {
|
|
failures.push(`${relativePath}: script "${scriptName}" must contain node for worker production runtime`);
|
|
}
|
|
|
|
if (distPattern.test(command)) {
|
|
passes.push(`${relativePath}: script "${scriptName}" targets ${scriptName === "start" ? "dist/index.js" : "dist/health.js"}`);
|
|
} else {
|
|
failures.push(`${relativePath}: script "${scriptName}" must target ${scriptName === "start" ? "dist/index.js" : "dist/health.js"}`);
|
|
}
|
|
|
|
if (/\btsx\b/.test(command)) {
|
|
failures.push(`${relativePath}: script "${scriptName}" must not use tsx for worker production runtime`);
|
|
} else {
|
|
passes.push(`${relativePath}: script "${scriptName}" does not use tsx`);
|
|
}
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkDockerfileWebContract(rootDir) {
|
|
const relativePath = "Dockerfile";
|
|
const dockerfile = fs.readFileSync(path.join(rootDir, relativePath), "utf8");
|
|
const passes = [];
|
|
const failures = [];
|
|
const buildBody = getDockerfileTargetBody(dockerfile, "build");
|
|
const webBuildBody = getDockerfileTargetBody(dockerfile, "web-build");
|
|
const apiBuildBody = getDockerfileTargetBody(dockerfile, "api-build");
|
|
const mcpBuildBody = getDockerfileTargetBody(dockerfile, "mcp-build");
|
|
const portalControllerBuildBody = getDockerfileTargetBody(dockerfile, "portal-domain-controller-build");
|
|
const workerBuildBody = getDockerfileTargetBody(dockerfile, "worker-build");
|
|
const webBody = getDockerfileTargetBody(dockerfile, "web");
|
|
const webRuntimeArtifactsBody = getDockerfileTargetBody(dockerfile, "web-runtime-artifacts");
|
|
const apiRuntimeArtifactsBody = getDockerfileTargetBody(dockerfile, "api-runtime-artifacts");
|
|
const workerRuntimeArtifactsBody = getDockerfileTargetBody(dockerfile, "worker-runtime-artifacts");
|
|
const dbRuntimeBuildScriptPath = path.join(rootDir, DB_RUNTIME_BUILD_SCRIPT_PATH);
|
|
const dbRuntimeBuildSource = fs.existsSync(dbRuntimeBuildScriptPath) ? fs.readFileSync(dbRuntimeBuildScriptPath, "utf8") : "";
|
|
|
|
if (WEB_TARGET_PATTERN.test(dockerfile)) {
|
|
passes.push(`${relativePath}: defines named web target from base`);
|
|
} else {
|
|
failures.push(`${relativePath}: must define web target with "FROM base AS web"`);
|
|
}
|
|
|
|
if (webBody && SERVER_RUNTIME_COPY_PATTERN.test(webBody)) {
|
|
passes.push(`${relativePath}: copies build/server-runtime into web image`);
|
|
} else {
|
|
failures.push(`${relativePath}: web target must copy /myapp/build/server-runtime from web-runtime-artifacts stage`);
|
|
}
|
|
|
|
if (buildBody && !BUILD_STAGE_FORBIDDEN_APP_BUILD_PATTERN.test(buildBody)) {
|
|
passes.push(`${relativePath}: build target leaves web, api, and worker app builds to split stages`);
|
|
} else {
|
|
failures.push(`${relativePath}: build target must not run root/web/api/worker app builds; use web-build, api-build, and worker-build stages`);
|
|
}
|
|
|
|
if (buildBody && !SHARED_BUILD_FORBIDDEN_VITE_PATTERN.test(buildBody)) {
|
|
passes.push(`${relativePath}: build target keeps VITE build args out of api/worker cache paths`);
|
|
} else {
|
|
failures.push(`${relativePath}: build target must not declare VITE_* ARG/ENV values; keep them in web-build only`);
|
|
}
|
|
|
|
if (
|
|
buildBody &&
|
|
DIRECT_PRISMA_GENERATE_PATTERN.test(buildBody) &&
|
|
!/pnpm\s+(?:--filter\s+@hivegrid\/db\s+)?(?:run\s+)?prisma(?::generate|\s+generate)/u.test(buildBody)
|
|
) {
|
|
passes.push(`${relativePath}: shared build invokes the pinned Prisma CLI directly`);
|
|
} else {
|
|
failures.push(`${relativePath}: shared build must invoke ./node_modules/.bin/prisma generate --config prisma.config.ts directly`);
|
|
}
|
|
|
|
if (buildBody && DIRECT_DB_RUNTIME_BUILD_PATTERN.test(buildBody) && !/pnpm\s+--filter\s+@hivegrid\/db\s+build/u.test(buildBody)) {
|
|
passes.push(`${relativePath}: shared build invokes the DB runtime artifact builder directly`);
|
|
} else {
|
|
failures.push(`${relativePath}: shared build must invoke node ${DB_RUNTIME_BUILD_SCRIPT_PATH} directly`);
|
|
}
|
|
|
|
for (const [entryPoint, outfile] of DB_RUNTIME_ARTIFACTS) {
|
|
const artifactPattern = new RegExp(`entryPoints:\\s*\\["${escapeRegExp(entryPoint)}"\\][\\s\\S]{0,500}?outfile:\\s*"${escapeRegExp(outfile)}"`, "u");
|
|
if (artifactPattern.test(dbRuntimeBuildSource)) {
|
|
passes.push(`${DB_RUNTIME_BUILD_SCRIPT_PATH}: builds ${outfile} from ${entryPoint}`);
|
|
} else {
|
|
failures.push(`${DB_RUNTIME_BUILD_SCRIPT_PATH}: must build ${outfile} from ${entryPoint}`);
|
|
}
|
|
}
|
|
|
|
if (
|
|
/import\s+\{\s*build\s*\}\s+from\s+"esbuild"/u.test(dbRuntimeBuildSource) &&
|
|
/await\s+Promise\.all/u.test(dbRuntimeBuildSource) &&
|
|
/packages:\s*"external"/u.test(dbRuntimeBuildSource) &&
|
|
/"@hivegrid\/logger":\s*"\.\/packages\/logger\/src\/index\.ts"/u.test(dbRuntimeBuildSource) &&
|
|
/"~":\s*"\.\/apps\/web\/app"/u.test(dbRuntimeBuildSource) &&
|
|
/createRequire\(import\.meta\.url\)/u.test(dbRuntimeBuildSource) &&
|
|
!/child_process|\bpnpm\b/u.test(dbRuntimeBuildSource)
|
|
) {
|
|
passes.push(`${DB_RUNTIME_BUILD_SCRIPT_PATH}: preserves the four bundled runtime contracts without package-manager subprocesses`);
|
|
} else {
|
|
failures.push(
|
|
`${DB_RUNTIME_BUILD_SCRIPT_PATH}: must use the esbuild API in parallel and preserve external packages, logger/web aliases, and the seed createRequire banner`
|
|
);
|
|
}
|
|
|
|
if (WEB_BUILD_TARGET_PATTERN.test(dockerfile) && webBuildBody && WEB_BUILD_COMMAND_PATTERN.test(webBuildBody)) {
|
|
passes.push(`${relativePath}: web-build target invokes both direct web build entry points`);
|
|
} else {
|
|
failures.push(`${relativePath}: web-build must run node scripts/build-web-surfaces.mjs && node scripts/build-web-server.mjs directly`);
|
|
}
|
|
|
|
if (webBuildBody && WEB_HOCUSPOCUS_BUILD_ARG_PATTERN.test(webBuildBody) && WEB_HOCUSPOCUS_BUILD_ENV_PATTERN.test(webBuildBody)) {
|
|
passes.push(`${relativePath}: web-build target forwards VITE_HOCUSPOCUS_URL into the Vite and CSP builds`);
|
|
} else {
|
|
failures.push(`${relativePath}: web-build target must declare ARG VITE_HOCUSPOCUS_URL and forward it with ENV before the web build`);
|
|
}
|
|
|
|
if (API_BUILD_TARGET_PATTERN.test(dockerfile) && apiBuildBody && API_BUILD_COMMAND_PATTERN.test(apiBuildBody)) {
|
|
passes.push(`${relativePath}: api-build target invokes its direct build entry point`);
|
|
} else {
|
|
failures.push(`${relativePath}: api-build must run node apps/api/scripts/build.mjs directly`);
|
|
}
|
|
|
|
if (MCP_BUILD_TARGET_PATTERN.test(dockerfile) && mcpBuildBody && MCP_BUILD_COMMAND_PATTERN.test(mcpBuildBody)) {
|
|
passes.push(`${relativePath}: mcp-build target invokes its direct build entry point`);
|
|
} else {
|
|
failures.push(`${relativePath}: mcp-build must run node apps/mcp/scripts/build.mjs directly`);
|
|
}
|
|
|
|
if (
|
|
PORTAL_CONTROLLER_BUILD_TARGET_PATTERN.test(dockerfile) &&
|
|
portalControllerBuildBody &&
|
|
PORTAL_CONTROLLER_BUILD_COMMAND_PATTERN.test(portalControllerBuildBody)
|
|
) {
|
|
passes.push(`${relativePath}: portal controller build target invokes its direct cwd-aware entry point`);
|
|
} else {
|
|
failures.push(`${relativePath}: portal controller build must cd into its app and run node scripts/build.mjs directly`);
|
|
}
|
|
|
|
if (WORKER_BUILD_TARGET_PATTERN.test(dockerfile) && workerBuildBody && WORKER_BUILD_COMMAND_PATTERN.test(workerBuildBody)) {
|
|
passes.push(`${relativePath}: worker-build target invokes its direct cwd-aware build entry point`);
|
|
} else {
|
|
failures.push(`${relativePath}: worker-build must cd into apps/worker and run node scripts/build.mjs directly`);
|
|
}
|
|
|
|
if (
|
|
WEB_RUNTIME_ARTIFACTS_TARGET_PATTERN.test(dockerfile) &&
|
|
API_RUNTIME_ARTIFACTS_TARGET_PATTERN.test(dockerfile) &&
|
|
WORKER_RUNTIME_ARTIFACTS_TARGET_PATTERN.test(dockerfile) &&
|
|
webRuntimeArtifactsBody &&
|
|
apiRuntimeArtifactsBody &&
|
|
workerRuntimeArtifactsBody &&
|
|
WEB_RUNTIME_ARTIFACTS_SOURCEMAP_DELETE_PATTERN.test(webRuntimeArtifactsBody) &&
|
|
API_RUNTIME_ARTIFACTS_SOURCEMAP_DELETE_PATTERN.test(apiRuntimeArtifactsBody) &&
|
|
WORKER_RUNTIME_ARTIFACTS_SOURCEMAP_DELETE_PATTERN.test(workerRuntimeArtifactsBody)
|
|
) {
|
|
passes.push(`${relativePath}: target-specific runtime artifacts remove web, api, and worker sourcemaps before runtime COPY`);
|
|
} else {
|
|
failures.push(`${relativePath}: web/api/worker runtime artifact targets must delete their own *.map files before runtime COPY`);
|
|
}
|
|
|
|
if (webBody && (START_SH_ENTRYPOINT_PATTERN.test(webBody) || /\bpnpm\s+start\b|\bnpm\s+start\b|\bnode\s+build\/server-runtime\/server\.js\b/.test(webBody))) {
|
|
passes.push(`${relativePath}: web runtime invokes production start path`);
|
|
} else {
|
|
failures.push(`${relativePath}: web target must keep ENTRYPOINT [ "./start.sh" ] or invoke the root production start path`);
|
|
}
|
|
|
|
if (webBody && RECURSIVE_CHOWN_PATTERN.test(webBody)) {
|
|
failures.push(`${relativePath}: web target must not use recursive chown in the runtime image`);
|
|
} else if (webBody) {
|
|
passes.push(`${relativePath}: web target avoids recursive runtime chown`);
|
|
}
|
|
|
|
if (webBody && WEB_HEALTHCHECK_PATTERN.test(webBody)) {
|
|
passes.push(`${relativePath}: web target contains HEALTHCHECK using /healthz`);
|
|
} else {
|
|
failures.push(`${relativePath}: web target HEALTHCHECK must probe http://127.0.0.1:${"${PORT}"}/healthz`);
|
|
}
|
|
|
|
if (webBody && WEB_HEALTHCHECK_HOST_PATTERN.test(webBody)) {
|
|
passes.push(`${relativePath}: web target HEALTHCHECK uses the canonical WEB_APP_HOST`);
|
|
} else {
|
|
failures.push(`${relativePath}: web target HEALTHCHECK must send WEB_APP_HOST for host-aware routing`);
|
|
}
|
|
checkFastHealthcheckCadence(webBody, relativePath, "web", passes, failures);
|
|
|
|
if (webBody && EXPOSE_8080_PATTERN.test(webBody)) {
|
|
passes.push(`${relativePath}: web target exposes port 8080`);
|
|
} else {
|
|
failures.push(`${relativePath}: web target must declare EXPOSE 8080 for Coolify port discovery`);
|
|
}
|
|
|
|
if (webBody && APP_REVISION_ARG_PATTERN.test(webBody) && APP_REVISION_ENV_PATTERN.test(webBody) && APP_REVISION_LABEL_PATTERN.test(webBody)) {
|
|
passes.push(`${relativePath}: web target persists APP_REVISION in env and OCI label`);
|
|
} else {
|
|
failures.push(`${relativePath}: web target must persist APP_REVISION in ENV and org.opencontainers.image.revision label`);
|
|
}
|
|
|
|
for (const [copyName, copyPattern] of WEB_REQUIRED_COPY_PATTERNS) {
|
|
if (webBody && copyPattern.test(webBody)) {
|
|
passes.push(`${relativePath}: web target copies ${copyName}`);
|
|
} else {
|
|
failures.push(`${relativePath}: web target must copy /myapp/${copyName}`);
|
|
}
|
|
}
|
|
|
|
if (webBody && /\/myapp\/apps\/api(?:\/|\s)/.test(webBody)) {
|
|
failures.push(`${relativePath}: web target must not copy API source, manifests, or artifacts`);
|
|
} else if (webBody) {
|
|
passes.push(`${relativePath}: web target excludes the separate API application`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
function getDockerfileTargetBody(dockerfile, targetName) {
|
|
const targetPattern = new RegExp(`^FROM\\s+\\S+(?:\\s+AS\\s+${targetName})\\s*$`, "im");
|
|
const targetMatch = targetPattern.exec(dockerfile);
|
|
|
|
if (!targetMatch) {
|
|
return null;
|
|
}
|
|
|
|
const bodyStart = targetMatch.index + targetMatch[0].length;
|
|
const nextTargetMatch = /^FROM\s+/gim.exec(dockerfile.slice(bodyStart));
|
|
const bodyEnd = nextTargetMatch ? bodyStart + nextTargetMatch.index : dockerfile.length;
|
|
return dockerfile.slice(bodyStart, bodyEnd);
|
|
}
|
|
|
|
function normalizeContinuedCommand(value) {
|
|
return value
|
|
.replace(/\\\s*\n\s*/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function getDockerfileInstructions(targetBody) {
|
|
if (!targetBody) {
|
|
return [];
|
|
}
|
|
|
|
const withoutComments = targetBody
|
|
.split(/\r?\n/)
|
|
.filter((line) => !line.trimStart().startsWith("#"))
|
|
.join("\n");
|
|
|
|
return withoutComments
|
|
.split(/\n(?=[A-Z][A-Z0-9_]*\s)/)
|
|
.map(normalizeContinuedCommand)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function getShellFunctionBody(source, functionName) {
|
|
const functionPattern = new RegExp(`^${escapeRegExp(functionName)}\\(\\)\\s*\\{\\s*\\n([\\s\\S]*?)^\\}`, "m");
|
|
return functionPattern.exec(source)?.[1] ?? null;
|
|
}
|
|
|
|
function getShellArrayItems(source, variableName) {
|
|
const arrayPattern = new RegExp(`^${escapeRegExp(variableName)}=\\(([^)]*)\\)\\s*$`, "m");
|
|
return arrayPattern.exec(source)?.[1]?.trim().split(/\s+/).filter(Boolean) ?? [];
|
|
}
|
|
|
|
function checkFastHealthcheckCadence(targetBody, relativePath, target, passes, failures) {
|
|
if (targetBody && FAST_START_HEALTHCHECK_CADENCE_PATTERN.test(targetBody)) {
|
|
passes.push(`${relativePath}: ${target} HEALTHCHECK starts at 2s and keeps the 30s/3 steady-state budget`);
|
|
} else {
|
|
failures.push(`${relativePath}: ${target} HEALTHCHECK must use --start-interval=2s with steady --interval=30s and --retries=3`);
|
|
}
|
|
}
|
|
|
|
function supportsCopyExcludeSyntax(dockerfile) {
|
|
const match = /^#\s*syntax=docker\/dockerfile:(\d+)\.(\d+)\s*$/mu.exec(dockerfile);
|
|
if (!match) return false;
|
|
const major = Number(match[1]);
|
|
const minor = Number(match[2]);
|
|
return major > 1 || (major === 1 && minor >= 19);
|
|
}
|
|
|
|
function expectedProductionDepsInstructions({ target, prunePrisma }) {
|
|
const instructions = [];
|
|
|
|
if (target === "worker") {
|
|
instructions.push("RUN node /myapp/scripts/prune-pnpm-runtime-closure.mjs /prod/worker/node_modules prisma typescript");
|
|
}
|
|
|
|
if (prunePrisma) {
|
|
instructions.push(
|
|
`RUN node /myapp/scripts/prune-prisma-client-runtime.mjs /prod/${target}/node_modules /myapp/packages/db/generated/prisma/client/internal/class.ts`
|
|
);
|
|
}
|
|
|
|
instructions.push(
|
|
`RUN SBOM_TARGET=${target} SBOM_ROOT_PATH=/prod/${target} SBOM_NODE_MODULES_PATH=/prod/${target}/node_modules SBOM_OUTPUT_PATH=/prod/${target}/sbom-pnpm.json node /myapp/scripts/generate-sbom.mjs`
|
|
);
|
|
return instructions;
|
|
}
|
|
|
|
export function checkDockerfileDepsManifestContract(rootDir) {
|
|
const relativePath = "Dockerfile";
|
|
const dockerfile = fs.readFileSync(path.join(rootDir, relativePath), "utf8");
|
|
const passes = [];
|
|
const failures = [];
|
|
const depsBody = getDockerfileTargetBody(dockerfile, "deps");
|
|
const workspaceManifests = listWorkspacePackageManifests(rootDir);
|
|
|
|
if (supportsCopyExcludeSyntax(dockerfile)) {
|
|
passes.push(`${relativePath}: Dockerfile syntax supports COPY --exclude`);
|
|
} else {
|
|
failures.push(`${relativePath}: must declare docker/dockerfile syntax 1.19 or newer for COPY --exclude`);
|
|
}
|
|
|
|
if (!depsBody) {
|
|
failures.push(`${relativePath}: must define deps target with "FROM base AS deps"`);
|
|
} else {
|
|
if (FULL_WORKSPACE_SOURCE_COPY_PATTERN.test(depsBody)) {
|
|
failures.push(`${relativePath}: deps target must copy workspace package manifests, not full apps or packages source, before pnpm install`);
|
|
} else {
|
|
passes.push(`${relativePath}: deps target does not copy full workspace source before pnpm install`);
|
|
}
|
|
|
|
for (const workspaceManifest of workspaceManifests) {
|
|
const copyPattern = new RegExp(`^COPY\\s+${escapeRegExp(workspaceManifest)}\\s+${escapeRegExp(workspaceManifest)}\\s*$`, "m");
|
|
if (copyPattern.test(depsBody)) {
|
|
passes.push(`${relativePath}: deps target copies ${workspaceManifest}`);
|
|
} else {
|
|
failures.push(`${relativePath}: deps target must copy ${workspaceManifest} before pnpm install`);
|
|
}
|
|
}
|
|
|
|
if (FROZEN_FETCH_PATTERN.test(depsBody)) {
|
|
passes.push(`${relativePath}: deps target fetches lockfile dependencies before offline install`);
|
|
} else {
|
|
failures.push(`${relativePath}: deps target must run pnpm fetch --frozen-lockfile with BuildKit cache before install`);
|
|
}
|
|
|
|
if (FROZEN_DEV_INSTALL_PATTERN.test(depsBody) && /\bpnpm\s+install\s+--frozen-lockfile\s+--prod=false\s+--offline\b/.test(depsBody)) {
|
|
passes.push(`${relativePath}: deps target uses frozen offline dev dependency install`);
|
|
} else {
|
|
failures.push(`${relativePath}: deps target must run pnpm install --frozen-lockfile --prod=false --offline`);
|
|
}
|
|
|
|
if (PNPM_BUILDKIT_CACHE_PATTERN.test(depsBody)) {
|
|
passes.push(`${relativePath}: deps target uses BuildKit pnpm store cache`);
|
|
} else {
|
|
failures.push(`${relativePath}: deps target must use BuildKit cache mount for /pnpm/store during pnpm install`);
|
|
}
|
|
|
|
if (
|
|
workspaceManifests.length > 0 &&
|
|
workspaceManifests.every((workspaceManifest) => {
|
|
const copyPattern = new RegExp(`^COPY\\s+${escapeRegExp(workspaceManifest)}\\s+${escapeRegExp(workspaceManifest)}\\s*$`, "m");
|
|
return copyPattern.test(depsBody);
|
|
})
|
|
) {
|
|
passes.push(`${relativePath}: deps stage copies all workspace package manifests before install`);
|
|
}
|
|
}
|
|
|
|
const productionDepsBundleBody = getDockerfileTargetBody(dockerfile, "production-deps-bundle");
|
|
const expectedBundleInstruction =
|
|
"RUN --mount=type=cache,id=hivegrid-pnpm-store-v2,target=/pnpm/store,sharing=locked bash scripts/ci/deploy-production-closures.sh";
|
|
const productionDepsBundleInstructions = getDockerfileInstructions(productionDepsBundleBody);
|
|
const productionDepsBundleStageCount = (dockerfile.match(/^FROM\s+\S+\s+AS\s+production-deps-bundle\s*$/gim) ?? []).length;
|
|
|
|
if (
|
|
productionDepsBundleStageCount === 1 &&
|
|
PRODUCTION_DEPS_BUNDLE_TARGET_PATTERN.test(dockerfile) &&
|
|
productionDepsBundleInstructions.length === 1 &&
|
|
productionDepsBundleInstructions[0] === expectedBundleInstruction
|
|
) {
|
|
passes.push(`${relativePath}: production-deps-bundle delegates one locked warm v2 store directly to the closure helper`);
|
|
} else {
|
|
failures.push(
|
|
`${relativePath}: production-deps-bundle must derive from build and contain only one locked hivegrid-pnpm-store-v2 invocation of scripts/ci/deploy-production-closures.sh`
|
|
);
|
|
}
|
|
|
|
const helperPath = path.join(rootDir, PRODUCTION_DEPS_HELPER_PATH);
|
|
const helperSource = fs.existsSync(helperPath) ? fs.readFileSync(helperPath, "utf8") : "";
|
|
const normalizedHelper = normalizeContinuedCommand(helperSource);
|
|
|
|
if (helperSource && /^set\s+-euo\s+pipefail\s*$/m.test(helperSource)) {
|
|
passes.push(`${PRODUCTION_DEPS_HELPER_PATH}: fails closed on command errors, unset variables, and pipeline failures`);
|
|
} else {
|
|
failures.push(`${PRODUCTION_DEPS_HELPER_PATH}: must enable set -euo pipefail`);
|
|
}
|
|
|
|
const expectedTasks = RUNTIME_DEPLOY_TARGETS.map(({ helperFunction }) => helperFunction).join(" ");
|
|
const expectedTargets = RUNTIME_DEPLOY_TARGETS.map(({ target }) => target).join(" ");
|
|
const helperTasks = getShellArrayItems(helperSource, "tasks");
|
|
const helperTargets = getShellArrayItems(helperSource, "targets");
|
|
const runWaveBody = getShellFunctionBody(helperSource, "run_wave") ?? "";
|
|
const normalizedRunWave = normalizeContinuedCommand(runWaveBody);
|
|
const hasParallelLaunch = new RegExp(
|
|
`local -a pids=\\(\\)[\\s\\S]*for task in "\\$\\{tasks\\[@\\]\\}"; do[\\s\\S]*"\\$task" &[\\s\\S]*pids\\+=\\("\\$!"\\)[\\s\\S]*done`
|
|
).test(normalizedRunWave);
|
|
const hasCompleteWaitAndFailurePropagation =
|
|
/local status=0[\s\S]*for index in "\$\{!pids\[@\]\}"; do[\s\S]*if wait "\$\{pids\[\$index\]\}"; then[\s\S]*else child_status=\$\?;? fi[\s\S]*if \[\[ "\$status" -eq 0 \]\]; then status="\$child_status";? fi[\s\S]*done[\s\S]*return "\$status"/.test(
|
|
normalizedRunWave
|
|
) && (normalizedRunWave.match(/\bwait\b/g) ?? []).length === 1;
|
|
|
|
if (helperTasks.join(" ") === expectedTasks && helperTargets.join(" ") === expectedTargets && hasParallelLaunch && hasCompleteWaitAndFailurePropagation) {
|
|
passes.push(`${PRODUCTION_DEPS_HELPER_PATH}: each wave starts all five closures in parallel, reaps every PID, and returns the first failure`);
|
|
} else {
|
|
failures.push(
|
|
`${PRODUCTION_DEPS_HELPER_PATH}: tasks and targets must list the exact five closures and run_wave must collect all five background PIDs, wait for every child, preserve the first non-zero status, and return after all children are reaped`
|
|
);
|
|
}
|
|
|
|
const initialWaveCall = "run_wave offline-first || initial_status=$?";
|
|
const cleanupLoop = 'for target in "${targets[@]}"; do rm -rf -- "/prod/${target}" done';
|
|
const fetchCommand = "pnpm fetch --frozen-lockfile";
|
|
const retryWaveCall = "run_wave after-fetch || retry_status=$?";
|
|
const initialWaveIndex = normalizedHelper.indexOf(initialWaveCall);
|
|
const cleanupIndex = normalizedHelper.indexOf(cleanupLoop);
|
|
const fetchIndex = normalizedHelper.indexOf(`if ${fetchCommand}; then`);
|
|
const retryWaveIndex = normalizedHelper.indexOf(retryWaveCall);
|
|
const hasExactFallbackSequence =
|
|
initialWaveIndex >= 0 &&
|
|
cleanupIndex > initialWaveIndex &&
|
|
fetchIndex > cleanupIndex &&
|
|
retryWaveIndex > fetchIndex &&
|
|
(normalizedHelper.match(/\brun_wave\s+(?:offline-first|after-fetch)\b/g) ?? []).length === 2 &&
|
|
(normalizedHelper.match(/\bpnpm\s+fetch\s+--frozen-lockfile\b/g) ?? []).length === 1 &&
|
|
(normalizedHelper.match(/\brm\s+-rf\b/g) ?? []).length === 1 &&
|
|
/initial_status=0 run_wave offline-first \|\| initial_status=\$\? if \[\[ "\$initial_status" -eq 0 \]\]; then[\s\S]*exit 0[\s\S]*fi/.test(
|
|
normalizedHelper
|
|
) &&
|
|
/if pnpm fetch --frozen-lockfile; then[\s\S]*else fetch_status=\$\?[\s\S]*exit "\$fetch_status"[\s\S]*fi/.test(normalizedHelper) &&
|
|
/retry_status=0 run_wave after-fetch \|\| retry_status=\$\? if \[\[ "\$retry_status" -ne 0 \]\]; then[\s\S]*exit "\$retry_status"[\s\S]*fi/.test(
|
|
normalizedHelper
|
|
);
|
|
const hasForbiddenFallbackShortcut =
|
|
/sentinel|sha256sum|lockfile[_-]?hash/i.test(helperSource) ||
|
|
/\|\|\s*(?:true|:|exit\s+0)\b/.test(helperSource) ||
|
|
/\brm\s+-rf\s+(?:--\s+)?["']?\/prod["']?(?:\s|;|$)/.test(helperSource) ||
|
|
/\brm\s+-rf\s+(?:--\s+)?["']?\/prod\/\*/.test(helperSource);
|
|
|
|
if (hasExactFallbackSequence && !hasForbiddenFallbackShortcut) {
|
|
passes.push(
|
|
`${PRODUCTION_DEPS_HELPER_PATH}: retries one complete wave only after exact-target cleanup and one frozen fetch, with no sentinel or fail-open bypass`
|
|
);
|
|
} else {
|
|
failures.push(
|
|
`${PRODUCTION_DEPS_HELPER_PATH}: must try one offline-first wave, exit on success, clean exactly the five /prod targets on failure, fetch once, retry one complete wave, and propagate fetch or retry failure without a sentinel`
|
|
);
|
|
}
|
|
|
|
if ((normalizedHelper.match(/\bpnpm\s+--offline\b/g) ?? []).length !== RUNTIME_DEPLOY_TARGETS.length) {
|
|
failures.push(`${PRODUCTION_DEPS_HELPER_PATH}: must contain exactly five offline pnpm deployment commands`);
|
|
} else {
|
|
passes.push(`${PRODUCTION_DEPS_HELPER_PATH}: contains exactly five offline pnpm deployment commands`);
|
|
}
|
|
|
|
const hasLockedMetadataWalk = /prune_runtime_metadata/u.test(helperSource) || /find\s+[^\n]*node_modules[^\n]*(?:\*\.map|\*\.d\.ts)/u.test(helperSource);
|
|
if (!hasLockedMetadataWalk) {
|
|
passes.push(`${PRODUCTION_DEPS_HELPER_PATH}: releases the pnpm store lock without recursive metadata walks`);
|
|
} else {
|
|
failures.push(`${PRODUCTION_DEPS_HELPER_PATH}: must not run metadata find/delete work while the locked production closure RUN is active`);
|
|
}
|
|
|
|
for (const { target, stage, packageName, helperFunction, omitOptional, prunePrisma } of RUNTIME_DEPLOY_TARGETS) {
|
|
const productionDepsBody = getDockerfileTargetBody(dockerfile, stage);
|
|
const helperFunctionBody = getShellFunctionBody(helperSource, helperFunction);
|
|
const expectedDeployCommand = `pnpm --offline --config.inject-workspace-packages=true --filter ${packageName} deploy --prod${
|
|
omitOptional ? " --no-optional" : ""
|
|
} /prod/${target}`;
|
|
const helperFunctionCount = (helperSource.match(new RegExp(`^${escapeRegExp(helperFunction)}\\(\\)\\s*\\{`, "gm")) ?? []).length;
|
|
const hasExactDeployCommand = helperFunctionCount === 1 && helperFunctionBody && normalizeContinuedCommand(helperFunctionBody) === expectedDeployCommand;
|
|
|
|
if (hasExactDeployCommand) {
|
|
passes.push(`${PRODUCTION_DEPS_HELPER_PATH}: ${helperFunction} deploys exactly the isolated ${packageName} production closure`);
|
|
} else {
|
|
failures.push(`${PRODUCTION_DEPS_HELPER_PATH}: ${helperFunction} must run exactly "${expectedDeployCommand}" with no extra or missing flags`);
|
|
}
|
|
|
|
const stagePattern = new RegExp(`^FROM\\s+production-deps-bundle\\s+AS\\s+${escapeRegExp(stage)}\\s*$`, "im");
|
|
const stageDeclarationCount = (dockerfile.match(new RegExp(`^FROM\\s+\\S+\\s+AS\\s+${escapeRegExp(stage)}\\s*$`, "gim")) ?? []).length;
|
|
const expectedStageInstructions = expectedProductionDepsInstructions({ target, prunePrisma });
|
|
const actualStageInstructions = getDockerfileInstructions(productionDepsBody);
|
|
if (
|
|
stageDeclarationCount === 1 &&
|
|
stagePattern.test(dockerfile) &&
|
|
actualStageInstructions.length === expectedStageInstructions.length &&
|
|
actualStageInstructions.every((instruction, index) => instruction === expectedStageInstructions[index])
|
|
) {
|
|
passes.push(`${relativePath}: ${stage} derives from production-deps-bundle and performs only required reachability pruning and SBOM generation`);
|
|
} else {
|
|
failures.push(`${relativePath}: ${stage} must derive from production-deps-bundle and contain only its required post-deploy prune and SBOM instructions`);
|
|
}
|
|
|
|
if (omitOptional && hasExactDeployCommand) {
|
|
passes.push(`${PRODUCTION_DEPS_HELPER_PATH}: ${helperFunction} omits optional peer toolchains from the runtime closure`);
|
|
}
|
|
|
|
const normalizedProductionDepsBody = normalizeContinuedCommand(productionDepsBody ?? "");
|
|
if (prunePrisma) {
|
|
const prismaRuntimePruneCommand = `node /myapp/scripts/prune-prisma-client-runtime.mjs /prod/${target}/node_modules /myapp/packages/db/generated/prisma/client/internal/class.ts`;
|
|
if (normalizedProductionDepsBody.includes(prismaRuntimePruneCommand)) {
|
|
passes.push(`${relativePath}: ${stage} keeps only the generated PostgreSQL Prisma runtime`);
|
|
} else {
|
|
failures.push(`${relativePath}: ${stage} must prune unused Prisma query compilers before SBOM generation`);
|
|
}
|
|
}
|
|
|
|
const targetSbomPattern = new RegExp(
|
|
`SBOM_TARGET=${target}[\\s\\S]*SBOM_ROOT_PATH=\\/prod\\/${target}[\\s\\S]*SBOM_NODE_MODULES_PATH=\\/prod\\/${target}\\/node_modules[\\s\\S]*SBOM_OUTPUT_PATH=\\/prod\\/${target}\\/sbom-pnpm\\.json[\\s\\S]*generate-sbom\\.mjs`
|
|
);
|
|
if (productionDepsBody && targetSbomPattern.test(productionDepsBody)) {
|
|
passes.push(`${relativePath}: ${stage} generates an SBOM from its pruned runtime tree`);
|
|
} else {
|
|
failures.push(`${relativePath}: ${stage} must generate its SBOM from /prod/${target}/node_modules after pruning`);
|
|
}
|
|
|
|
if (target === "worker") {
|
|
const removesOptionalToolchains = normalizedProductionDepsBody.includes(
|
|
"node /myapp/scripts/prune-pnpm-runtime-closure.mjs /prod/worker/node_modules prisma typescript"
|
|
);
|
|
if (removesOptionalToolchains) {
|
|
passes.push(`${relativePath}: worker-production-deps removes optional peer toolchains and their unreachable dependencies`);
|
|
} else {
|
|
failures.push(`${relativePath}: worker-production-deps must prune optional Prisma and TypeScript peer closures by reachability`);
|
|
}
|
|
}
|
|
|
|
const runtimeBody = getDockerfileTargetBody(dockerfile, target);
|
|
const expectedDependencyCopy = `COPY --exclude=**/*.map --exclude=**/*.d.ts --from=${stage} /prod/${target}/node_modules /myapp/node_modules`;
|
|
const dependencyCopyInstructions = getDockerfileInstructions(runtimeBody).filter((instruction) =>
|
|
instruction.endsWith(`/prod/${target}/node_modules /myapp/node_modules`)
|
|
);
|
|
if (dependencyCopyInstructions.length === 1 && dependencyCopyInstructions[0] === expectedDependencyCopy) {
|
|
passes.push(`${relativePath}: ${target} target copies only its deployed dependency closure without maps or declarations`);
|
|
} else {
|
|
failures.push(`${relativePath}: ${target} target dependency COPY must use both --exclude=**/*.map and --exclude=**/*.d.ts from ${stage}`);
|
|
}
|
|
|
|
const sbomCopyPattern = new RegExp(
|
|
`^COPY\\s+--from=${escapeRegExp(stage)}\\s+\\/prod\\/${target}\\/sbom-pnpm\\.json\\s+\\/myapp\\/sbom-pnpm\\.json\\s*$`,
|
|
"im"
|
|
);
|
|
if (runtimeBody && sbomCopyPattern.test(runtimeBody)) {
|
|
passes.push(`${relativePath}: ${target} target ships its target-specific runtime SBOM`);
|
|
} else {
|
|
failures.push(`${relativePath}: ${target} target must copy its SBOM from ${stage}`);
|
|
}
|
|
|
|
if (target === "web" || target === "api" || target === "mcp" || target === "portal-domain-controller" || target === "worker") {
|
|
const removesPackageManagers =
|
|
runtimeBody &&
|
|
/rm\s+-rf[\s\S]*\/usr\/local\/lib\/node_modules\/corepack/.test(runtimeBody) &&
|
|
/\/usr\/local\/bin\/pnpm/.test(runtimeBody) &&
|
|
/\/usr\/local\/bin\/yarn/.test(runtimeBody);
|
|
if (removesPackageManagers) {
|
|
passes.push(`${relativePath}: ${target} target removes inherited package-manager executables`);
|
|
} else {
|
|
failures.push(`${relativePath}: ${target} target must remove inherited Corepack, pnpm, and Yarn executables`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (GENERIC_PRODUCTION_NODE_MODULES_COPY_PATTERN.test(dockerfile) || /^FROM\s+base\s+AS\s+production-deps\s*$/im.test(dockerfile)) {
|
|
failures.push(`${relativePath}: must not build or copy a generic whole-workspace production node_modules closure`);
|
|
} else {
|
|
passes.push(`${relativePath}: excludes generic whole-workspace production node_modules from runtime images`);
|
|
}
|
|
|
|
if (/^COPY\s+--from=build\s+\/myapp\/sbom-pnpm\.json\s+\/myapp\/sbom-pnpm\.json\s*$/im.test(dockerfile)) {
|
|
failures.push(`${relativePath}: runtime targets must not copy the full-workspace build SBOM`);
|
|
} else {
|
|
passes.push(`${relativePath}: runtime targets do not ship a full-workspace build SBOM`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkDockerfileWorkerContract(rootDir) {
|
|
const relativePath = "Dockerfile";
|
|
const dockerfile = fs.readFileSync(path.join(rootDir, relativePath), "utf8");
|
|
const passes = [];
|
|
const failures = [];
|
|
const workerBody = getDockerfileTargetBody(dockerfile, "worker");
|
|
|
|
if (WORKER_TARGET_PATTERN.test(dockerfile)) {
|
|
passes.push(`${relativePath}: defines named worker target from base`);
|
|
} else {
|
|
failures.push(`${relativePath}: must define worker target with "FROM base AS worker"`);
|
|
}
|
|
|
|
if (workerBody && WORKER_RUNTIME_PATTERN.test(workerBody)) {
|
|
passes.push(`${relativePath}: worker runtime invokes @hivegrid/worker production start path`);
|
|
} else {
|
|
failures.push(`${relativePath}: worker target must invoke node apps/worker/dist/index.js directly`);
|
|
}
|
|
|
|
if (workerBody && WORKER_HEALTH_PATTERN.test(workerBody)) {
|
|
passes.push(`${relativePath}: worker target invokes worker health path directly`);
|
|
} else {
|
|
failures.push(`${relativePath}: worker target must invoke node apps/worker/dist/health.js directly`);
|
|
}
|
|
checkFastHealthcheckCadence(workerBody, relativePath, "worker", passes, failures);
|
|
|
|
if (workerBody && APP_REVISION_ARG_PATTERN.test(workerBody) && APP_REVISION_ENV_PATTERN.test(workerBody) && APP_REVISION_LABEL_PATTERN.test(workerBody)) {
|
|
passes.push(`${relativePath}: worker target persists APP_REVISION in env and OCI label`);
|
|
} else {
|
|
failures.push(`${relativePath}: worker target must persist APP_REVISION in ENV and org.opencontainers.image.revision label`);
|
|
}
|
|
|
|
if (workerBody && IMAGE_REVISION_FILE_PATTERN.test(workerBody)) {
|
|
passes.push(`${relativePath}: worker target writes the immutable image revision file before dropping privileges`);
|
|
} else {
|
|
failures.push(`${relativePath}: worker target must write root-owned /myapp/.image-revision from APP_REVISION`);
|
|
}
|
|
|
|
if (workerBody && /\btsx\b/.test(workerBody)) {
|
|
failures.push(`${relativePath}: worker target must not use tsx at runtime`);
|
|
} else if (workerBody) {
|
|
passes.push(`${relativePath}: worker target runtime does not use tsx`);
|
|
}
|
|
|
|
if (workerBody && RECURSIVE_CHOWN_PATTERN.test(workerBody)) {
|
|
failures.push(`${relativePath}: worker target must not use recursive chown in the runtime image`);
|
|
} else if (workerBody) {
|
|
passes.push(`${relativePath}: worker target avoids recursive runtime chown`);
|
|
}
|
|
|
|
for (const [copyName, copyPattern] of WORKER_REQUIRED_COPY_PATTERNS) {
|
|
if (workerBody && copyPattern.test(workerBody)) {
|
|
passes.push(`${relativePath}: worker target copies ${copyName}`);
|
|
} else {
|
|
failures.push(`${relativePath}: worker target must copy /myapp/${copyName}`);
|
|
}
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkDockerfileMcpContract(rootDir) {
|
|
const relativePath = "Dockerfile";
|
|
const dockerfile = fs.readFileSync(path.join(rootDir, relativePath), "utf8");
|
|
const passes = [];
|
|
const failures = [];
|
|
const mcpBody = getDockerfileTargetBody(dockerfile, "mcp");
|
|
|
|
if (MCP_TARGET_PATTERN.test(dockerfile)) {
|
|
passes.push(`${relativePath}: defines named MCP target from base`);
|
|
} else {
|
|
failures.push(`${relativePath}: must define MCP target with "FROM base AS mcp"`);
|
|
}
|
|
|
|
if (mcpBody && MCP_RUNTIME_PATTERN.test(mcpBody)) {
|
|
passes.push(`${relativePath}: MCP target invokes node apps/mcp/dist/server.js directly`);
|
|
} else {
|
|
failures.push(`${relativePath}: MCP target must invoke node apps/mcp/dist/server.js directly`);
|
|
}
|
|
|
|
if (mcpBody && MCP_HEALTHCHECK_PATTERN.test(mcpBody)) {
|
|
passes.push(`${relativePath}: MCP target contains dependency-free Node HEALTHCHECK using /healthz`);
|
|
} else {
|
|
failures.push(`${relativePath}: MCP target HEALTHCHECK must probe http://127.0.0.1:\${MCP_PORT:-\${PORT}}/healthz with Node`);
|
|
}
|
|
checkFastHealthcheckCadence(mcpBody, relativePath, "MCP", passes, failures);
|
|
|
|
if (mcpBody && APP_REVISION_ARG_PATTERN.test(mcpBody) && APP_REVISION_ENV_PATTERN.test(mcpBody) && APP_REVISION_LABEL_PATTERN.test(mcpBody)) {
|
|
passes.push(`${relativePath}: MCP target persists APP_REVISION in env and OCI label`);
|
|
} else {
|
|
failures.push(`${relativePath}: MCP target must persist APP_REVISION in ENV and org.opencontainers.image.revision label`);
|
|
}
|
|
|
|
if (mcpBody && IMAGE_REVISION_FILE_PATTERN.test(mcpBody)) {
|
|
passes.push(`${relativePath}: MCP target writes the immutable image revision file before dropping privileges`);
|
|
} else {
|
|
failures.push(`${relativePath}: MCP target must write root-owned /myapp/.image-revision from APP_REVISION`);
|
|
}
|
|
|
|
if (mcpBody && /^COPY\s+--from=mcp-runtime-artifacts\s+\/myapp\/apps\/mcp\/dist\s+\/myapp\/apps\/mcp\/dist\s*$/im.test(mcpBody)) {
|
|
passes.push(`${relativePath}: MCP target copies the compiled runtime including deploy health`);
|
|
} else {
|
|
failures.push(`${relativePath}: MCP target must copy /myapp/apps/mcp/dist from mcp-runtime-artifacts`);
|
|
}
|
|
|
|
if (mcpBody && /^USER\s+node\s*$/im.test(mcpBody)) {
|
|
passes.push(`${relativePath}: MCP target runs as the non-root node user`);
|
|
} else {
|
|
failures.push(`${relativePath}: MCP target must run as USER node`);
|
|
}
|
|
|
|
if (mcpBody && RECURSIVE_CHOWN_PATTERN.test(mcpBody)) {
|
|
failures.push(`${relativePath}: MCP target must not use recursive chown in the runtime image`);
|
|
} else if (mcpBody) {
|
|
passes.push(`${relativePath}: MCP target avoids recursive runtime chown`);
|
|
}
|
|
|
|
if (mcpBody && /\btsx\b/.test(mcpBody)) {
|
|
failures.push(`${relativePath}: MCP target must not use tsx at runtime`);
|
|
} else if (mcpBody) {
|
|
passes.push(`${relativePath}: MCP target runtime does not use tsx`);
|
|
}
|
|
|
|
if (mcpBody && EXPOSE_8080_PATTERN.test(mcpBody)) {
|
|
passes.push(`${relativePath}: MCP target exposes port 8080`);
|
|
} else {
|
|
failures.push(`${relativePath}: MCP target must declare EXPOSE 8080 for Coolify port discovery`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkDockerfileApiContract(rootDir) {
|
|
const relativePath = "Dockerfile";
|
|
const dockerfile = fs.readFileSync(path.join(rootDir, relativePath), "utf8");
|
|
const passes = [];
|
|
const failures = [];
|
|
const apiBody = getDockerfileTargetBody(dockerfile, "api");
|
|
|
|
if (API_TARGET_PATTERN.test(dockerfile)) {
|
|
passes.push(`${relativePath}: defines named api target from base`);
|
|
} else {
|
|
failures.push(`${relativePath}: must define api target with "FROM base AS api"`);
|
|
}
|
|
|
|
if (apiBody && API_RUNTIME_PATTERN.test(apiBody)) {
|
|
passes.push(`${relativePath}: api runtime invokes @hivegrid/api production start path`);
|
|
} else {
|
|
failures.push(`${relativePath}: api target must invoke node apps/api/dist/server.js directly`);
|
|
}
|
|
|
|
if (apiBody && /\btsx\b/.test(apiBody)) {
|
|
failures.push(`${relativePath}: api target must not use tsx at runtime`);
|
|
} else if (apiBody) {
|
|
passes.push(`${relativePath}: api target runtime does not use tsx`);
|
|
}
|
|
|
|
if (apiBody && RECURSIVE_CHOWN_PATTERN.test(apiBody)) {
|
|
failures.push(`${relativePath}: api target must not use recursive chown in the runtime image`);
|
|
} else if (apiBody) {
|
|
passes.push(`${relativePath}: api target avoids recursive runtime chown`);
|
|
}
|
|
|
|
if (apiBody && API_HEALTHCHECK_PATTERN.test(apiBody)) {
|
|
passes.push(`${relativePath}: api target contains HEALTHCHECK using /healthz`);
|
|
} else {
|
|
failures.push(`${relativePath}: api target HEALTHCHECK must probe http://127.0.0.1:\${API_PORT:-\${PORT}}/healthz`);
|
|
}
|
|
checkFastHealthcheckCadence(apiBody, relativePath, "api", passes, failures);
|
|
|
|
if (apiBody && EXPOSE_8080_PATTERN.test(apiBody)) {
|
|
passes.push(`${relativePath}: api target exposes port 8080`);
|
|
} else {
|
|
failures.push(`${relativePath}: api target must declare EXPOSE 8080 for Coolify port discovery`);
|
|
}
|
|
|
|
if (apiBody && APP_REVISION_ARG_PATTERN.test(apiBody) && APP_REVISION_ENV_PATTERN.test(apiBody) && APP_REVISION_LABEL_PATTERN.test(apiBody)) {
|
|
passes.push(`${relativePath}: api target persists APP_REVISION in env and OCI label`);
|
|
} else {
|
|
failures.push(`${relativePath}: api target must persist APP_REVISION in ENV and org.opencontainers.image.revision label`);
|
|
}
|
|
|
|
for (const [copyName, copyPattern] of API_REQUIRED_COPY_PATTERNS) {
|
|
if (apiBody && copyPattern.test(apiBody)) {
|
|
passes.push(`${relativePath}: api target copies ${copyName}`);
|
|
} else {
|
|
failures.push(`${relativePath}: api target must copy /myapp/${copyName}`);
|
|
}
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkHocuspocusDockerfileContract(rootDir) {
|
|
const relativePath = "apps/hocuspocus/Dockerfile";
|
|
const dockerfile = fs.readFileSync(path.join(rootDir, relativePath), "utf8");
|
|
const passes = [];
|
|
const failures = [];
|
|
const depsBody = getDockerfileTargetBody(dockerfile, "deps");
|
|
const buildBody = getDockerfileTargetBody(dockerfile, "build");
|
|
const runtimeBody = getDockerfileTargetBody(dockerfile, "hocuspocus");
|
|
const productionDepsBody = getDockerfileTargetBody(dockerfile, "hocuspocus-production-deps");
|
|
|
|
if (supportsCopyExcludeSyntax(dockerfile)) {
|
|
passes.push(`${relativePath}: Dockerfile syntax supports COPY --exclude`);
|
|
} else {
|
|
failures.push(`${relativePath}: must declare docker/dockerfile syntax 1.19 or newer for COPY --exclude`);
|
|
}
|
|
|
|
if (/^FROM\s+deps\s+AS\s+hocuspocus-production-deps\s*$/imu.test(dockerfile)) {
|
|
passes.push(`${relativePath}: production dependency closure is cached independently from application source`);
|
|
} else {
|
|
failures.push(`${relativePath}: hocuspocus-production-deps must derive from the manifest-only deps stage`);
|
|
}
|
|
|
|
if (!depsBody) {
|
|
failures.push(`${relativePath}: must define a manifest-only deps target`);
|
|
} else {
|
|
const allowedCopyPatterns = [
|
|
/^ADD\s+package\.json\s+pnpm-lock\.yaml\s+pnpm-workspace\.yaml\s+\.\/$/u,
|
|
/^COPY\s+patches\s+\.\/patches$/u,
|
|
/^COPY\s+(apps|packages|runtime)\/[^/]+\/package\.json\s+\1\/[^/]+\/package\.json$/u,
|
|
];
|
|
const invalidDependencyInputs = depsBody
|
|
.split(/\r?\n/u)
|
|
.map((line) => line.trim())
|
|
.filter((line) => /^(?:ADD|COPY)\s+/u.test(line))
|
|
.filter((line) => !allowedCopyPatterns.some((pattern) => pattern.test(line)));
|
|
|
|
if (invalidDependencyInputs.length === 0) {
|
|
passes.push(`${relativePath}: dependency cache is invalidated only by manifests, lockfiles, workspace config, and patches`);
|
|
} else {
|
|
failures.push(`${relativePath}: deps target must not copy source inputs before install (${invalidDependencyInputs.join(", ")})`);
|
|
}
|
|
|
|
const workspaceManifests = listWorkspacePackageManifests(rootDir);
|
|
const missingManifests = workspaceManifests.filter(
|
|
(workspaceManifest) => !new RegExp(`^COPY\\s+${escapeRegExp(workspaceManifest)}\\s+${escapeRegExp(workspaceManifest)}\\s*$`, "m").test(depsBody)
|
|
);
|
|
if (missingManifests.length === 0) {
|
|
passes.push(`${relativePath}: deps target copies every required workspace package manifest before install`);
|
|
} else {
|
|
failures.push(`${relativePath}: deps target must copy workspace manifests before install (${missingManifests.join(", ")})`);
|
|
}
|
|
|
|
if (
|
|
/\bpnpm\s+fetch\s+--frozen-lockfile\b/u.test(depsBody) &&
|
|
/\bpnpm\s+install\s+--frozen-lockfile\s+--prod=false\s+--offline\b/u.test(depsBody) &&
|
|
(depsBody.match(/target=\/pnpm\/store/gu)?.length ?? 0) >= 2
|
|
) {
|
|
passes.push(`${relativePath}: deps target uses a frozen fetched dependency set and offline install`);
|
|
} else {
|
|
failures.push(`${relativePath}: deps target must use pnpm fetch --frozen-lockfile followed by frozen offline install`);
|
|
}
|
|
}
|
|
|
|
if (
|
|
buildBody &&
|
|
/^RUN\s+\.\/node_modules\/\.bin\/prisma\s+generate\s+--config\s+prisma\.config\.ts\s*$/imu.test(buildBody) &&
|
|
/^RUN\s+node\s+apps\/hocuspocus\/scripts\/build\.mjs\s*$/imu.test(buildBody) &&
|
|
!/pnpm\s+--filter\s+@hivegrid\/(?:db|hocuspocus)\s+build/u.test(buildBody)
|
|
) {
|
|
passes.push(`${relativePath}: source-only rebuilds skip workspace resolution and unused database seed builds`);
|
|
} else {
|
|
failures.push(`${relativePath}: build target must invoke pinned Prisma and Hocuspocus build scripts directly and skip unused database seed artifacts`);
|
|
}
|
|
|
|
const hocusDeployCommand =
|
|
"pnpm --offline --config.inject-workspace-packages=true --filter @hivegrid/runtime-hocuspocus deploy --prod --no-optional /prod/hocuspocus";
|
|
const hocusClosureInstruction = getDockerfileInstructions(productionDepsBody).find((instruction) => instruction.includes(hocusDeployCommand));
|
|
const hasLockedMetadataWalk = /find\s+[^\n]*node_modules[^\n]*(?:\*\.map|\*\.d\.ts)/u.test(productionDepsBody ?? "");
|
|
if (hocusClosureInstruction?.endsWith(hocusDeployCommand) && !hasLockedMetadataWalk) {
|
|
passes.push(`${relativePath}: deploys the Hocuspocus closure without extending the store lock for metadata walks`);
|
|
} else {
|
|
failures.push(`${relativePath}: must deploy @hivegrid/runtime-hocuspocus without metadata find/delete work in the locked RUN`);
|
|
}
|
|
|
|
if (
|
|
productionDepsBody &&
|
|
/COPY\s+--from=build\s+\/myapp\/packages\/db\/generated\/prisma\/client\/internal\/class\.ts\s+\/myapp\/packages\/db\/generated\/prisma\/client\/internal\/class\.ts/u.test(
|
|
productionDepsBody.replace(/\\\r?\n\s*/gu, " ")
|
|
) &&
|
|
/COPY\s+scripts\/prune-prisma-client-runtime\.mjs\s+\/myapp\/scripts\/prune-prisma-client-runtime\.mjs/u.test(productionDepsBody) &&
|
|
/node\s+\/myapp\/scripts\/prune-prisma-client-runtime\.mjs\s+\/prod\/hocuspocus\/node_modules\s+\/myapp\/packages\/db\/generated\/prisma\/client\/internal\/class\.ts/u.test(
|
|
productionDepsBody.replace(/\\\r?\n\s*/gu, " ")
|
|
)
|
|
) {
|
|
passes.push(`${relativePath}: fail-closed Prisma runtime pruning follows the generated client contract`);
|
|
} else {
|
|
failures.push(`${relativePath}: must use the generated-client-aware Prisma runtime pruner`);
|
|
}
|
|
|
|
if (
|
|
productionDepsBody &&
|
|
/SBOM_TARGET=hocuspocus[\s\S]*SBOM_NODE_MODULES_PATH=\/prod\/hocuspocus\/node_modules[\s\S]*SBOM_OUTPUT_PATH=\/prod\/hocuspocus\/sbom-pnpm\.json[\s\S]*generate-sbom\.mjs/.test(
|
|
productionDepsBody
|
|
)
|
|
) {
|
|
passes.push(`${relativePath}: generates a target-specific SBOM from the pruned runtime tree`);
|
|
} else {
|
|
failures.push(`${relativePath}: must generate its SBOM from the pruned Hocuspocus runtime tree`);
|
|
}
|
|
|
|
const expectedHocusDependencyCopy =
|
|
"COPY --exclude=**/*.map --exclude=**/*.d.ts --from=hocuspocus-production-deps /prod/hocuspocus/node_modules /myapp/node_modules";
|
|
const hocusDependencyCopyInstructions = getDockerfileInstructions(runtimeBody).filter((instruction) =>
|
|
instruction.endsWith("/prod/hocuspocus/node_modules /myapp/node_modules")
|
|
);
|
|
if (hocusDependencyCopyInstructions.length === 1 && hocusDependencyCopyInstructions[0] === expectedHocusDependencyCopy) {
|
|
passes.push(`${relativePath}: final image copies production node_modules without maps or declarations`);
|
|
} else {
|
|
failures.push(`${relativePath}: final dependency COPY must use both --exclude=**/*.map and --exclude=**/*.d.ts from hocuspocus-production-deps`);
|
|
}
|
|
|
|
if (runtimeBody && /^COPY\s+--from=hocuspocus-production-deps\s+\/prod\/hocuspocus\/sbom-pnpm\.json\s+\/myapp\/sbom-pnpm\.json\s*$/im.test(runtimeBody)) {
|
|
passes.push(`${relativePath}: final image ships the target-specific runtime SBOM`);
|
|
} else {
|
|
failures.push(`${relativePath}: final image must copy its SBOM from hocuspocus-production-deps`);
|
|
}
|
|
|
|
if (
|
|
/^FROM\s+build\s+AS\s+hocuspocus-runtime-artifacts\s*$/im.test(dockerfile) &&
|
|
/find\s+\/myapp\/apps\/hocuspocus\/dist\s+-type\s+f\s+-name\s+['"]\*\.map['"]\s+-delete/.test(dockerfile) &&
|
|
runtimeBody &&
|
|
/^COPY\s+--from=hocuspocus-runtime-artifacts\s+\/myapp\/apps\/hocuspocus\/dist\s+\/myapp\/apps\/hocuspocus\/dist\s*$/im.test(runtimeBody)
|
|
) {
|
|
passes.push(`${relativePath}: strips source maps before the runtime copy`);
|
|
} else {
|
|
failures.push(`${relativePath}: must strip dist source maps in a runtime-artifacts stage`);
|
|
}
|
|
|
|
if (runtimeBody && /^USER\s+node\s*$/im.test(runtimeBody)) {
|
|
passes.push(`${relativePath}: final image runs as the non-root node user`);
|
|
} else {
|
|
failures.push(`${relativePath}: final image must run as USER node`);
|
|
}
|
|
checkFastHealthcheckCadence(runtimeBody, relativePath, "Hocuspocus", passes, failures);
|
|
|
|
if (
|
|
runtimeBody &&
|
|
/rm\s+-rf[\s\S]*\/usr\/local\/lib\/node_modules\/corepack/.test(runtimeBody) &&
|
|
/\/usr\/local\/bin\/pnpm/.test(runtimeBody) &&
|
|
/\/usr\/local\/bin\/yarn/.test(runtimeBody)
|
|
) {
|
|
passes.push(`${relativePath}: removes inherited package-manager executables`);
|
|
} else {
|
|
failures.push(`${relativePath}: must remove inherited Corepack, pnpm, and Yarn executables`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function verifyDockerBaseline(rootDir = process.cwd()) {
|
|
const requiredFilesResult = checkRequiredFiles(rootDir);
|
|
const results = [requiredFilesResult];
|
|
|
|
if (fs.existsSync(path.join(rootDir, "Dockerfile"))) {
|
|
results.push(checkDockerfileDepsManifestContract(rootDir));
|
|
results.push(checkDockerfileWebContract(rootDir));
|
|
results.push(checkDockerfileWorkerContract(rootDir));
|
|
results.push(checkDockerfileApiContract(rootDir));
|
|
results.push(checkDockerfileMcpContract(rootDir));
|
|
}
|
|
|
|
if (fs.existsSync(path.join(rootDir, "apps/hocuspocus/Dockerfile"))) {
|
|
results.push(checkHocuspocusDockerfileContract(rootDir));
|
|
}
|
|
|
|
if (fs.existsSync(path.join(rootDir, ".dockerignore"))) {
|
|
results.push(checkDockerignoreContract(rootDir));
|
|
}
|
|
|
|
if (fs.existsSync(path.join(rootDir, "start.sh"))) {
|
|
results.push(checkStartScript(rootDir));
|
|
}
|
|
|
|
if (fs.existsSync(path.join(rootDir, "docker-compose.yml")) && fs.existsSync(path.join(rootDir, "docker-compose.staging.yml"))) {
|
|
results.push(checkDockerComposeWorkerTopology(rootDir));
|
|
}
|
|
|
|
const manifestChecks = [
|
|
["package.json", checkRootManifest],
|
|
["packages/db/package.json", checkDbManifest],
|
|
["apps/web/package.json", checkWebManifest],
|
|
["apps/api/package.json", checkApiManifest],
|
|
["apps/worker/package.json", checkWorkerManifest],
|
|
];
|
|
|
|
for (const [relativePath, check] of manifestChecks) {
|
|
if (fs.existsSync(path.join(rootDir, relativePath))) {
|
|
try {
|
|
results.push(check(rootDir));
|
|
} catch (error) {
|
|
results.push({
|
|
passes: [],
|
|
failures: [`${manifestPath(relativePath)}: failed to read manifest JSON: ${error.message}`],
|
|
observations: [],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return results.reduce(
|
|
(result, check) => ({
|
|
passes: [...result.passes, ...check.passes],
|
|
failures: [...result.failures, ...check.failures],
|
|
observations: [...result.observations, ...check.observations],
|
|
}),
|
|
{ passes: [], failures: [], observations: [] }
|
|
);
|
|
}
|
|
|
|
export function formatVerificationResult(result) {
|
|
const status = result.failures.length > 0 ? "FAIL" : "PASS";
|
|
return [
|
|
`${status} Docker baseline verification`,
|
|
...result.passes.map((pass) => `PASS ${pass}`),
|
|
...result.observations.map((observation) => `OBSERVE ${observation}`),
|
|
...result.failures.map((failure) => `FAIL ${failure}`),
|
|
`Summary: ${result.passes.length} passed, ${result.observations.length} observed, ${result.failures.length} failed`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
|
|
if (isCli) {
|
|
const result = verifyDockerBaseline(process.cwd());
|
|
const output = formatVerificationResult(result);
|
|
|
|
if (result.failures.length > 0) {
|
|
process.stderr.write(output);
|
|
process.exitCode = 1;
|
|
} else {
|
|
process.stdout.write(output);
|
|
}
|
|
}
|