890 lines
39 KiB
JavaScript
890 lines
39 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/worker/package.json",
|
|
];
|
|
|
|
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 SCAN_WORKER_SERVICE = "worker-general";
|
|
const NON_SCAN_WORKER_SERVICES = REQUIRED_WORKER_GROUPS
|
|
.map((worker) => worker.service)
|
|
.filter((service) => service !== SCAN_WORKER_SERVICE);
|
|
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 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 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+@toolctrl\/web\s+build\b|\bpnpm\s+--filter\s+@toolctrl\/api\s+build\b|\bpnpm\s+--filter\s+@toolctrl\/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+pnpm\s+--filter\s+@toolctrl\/web\s+build\s*$/im;
|
|
const API_BUILD_COMMAND_PATTERN = /^RUN\s+pnpm\s+--filter\s+@toolctrl\/api\s+build\s*$/im;
|
|
const WORKER_BUILD_COMMAND_PATTERN = /^RUN\s+pnpm\s+--filter\s+@toolctrl\/worker\s+build\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+-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 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[\s\S]*?127\.0\.0\.1:\$\{PORT\}\/healthz/;
|
|
const API_HEALTHCHECK_PATTERN = /\bHEALTHCHECK[\s\S]*?\bcurl\b[\s\S]*?127\.0\.0\.1:\$\{API_PORT:-\$\{PORT\}\}\/healthz/;
|
|
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 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 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",
|
|
".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 = [
|
|
["apps/api/dist", /^COPY\s+--from=api-runtime-artifacts\s+\/myapp\/apps\/api\/dist\s+\/myapp\/apps\/api\/dist\s*$/im],
|
|
["apps/api/package.json", /^COPY\s+--from=build\s+\/myapp\/apps\/api\/package\.json\s+\/myapp\/apps\/api\/package\.json\s*$/im],
|
|
["packages/db", /^COPY\s+--from=build\s+\/myapp\/packages\/db\s+\/myapp\/packages\/db\s*$/im],
|
|
["packages/db/generated/prisma/client", /^COPY\s+--from=build\s+\/myapp\/packages\/db\/generated\/prisma\/client\s+\/myapp\/packages\/db\/generated\/prisma\/client\s*$/im],
|
|
["prisma", /^COPY\s+--from=build\s+\/myapp\/prisma\s+\/myapp\/prisma\s*$/im],
|
|
["prisma.config.ts", /^COPY\s+--from=build\s+\/myapp\/prisma\.config\.ts\s+\/myapp\/prisma\.config\.ts\s*$/im],
|
|
["package.json", /^COPY\s+--from=build\s+\/myapp\/package\.json\s+\/myapp\/package\.json\s*$/im],
|
|
["pnpm-lock.yaml", /^COPY\s+--from=build\s+\/myapp\/pnpm-lock\.yaml\s+\/myapp\/pnpm-lock\.yaml\s*$/im],
|
|
["pnpm-workspace.yaml", /^COPY\s+--from=build\s+\/myapp\/pnpm-workspace\.yaml\s+\/myapp\/pnpm-workspace\.yaml\s*$/im],
|
|
];
|
|
const WORKER_REQUIRED_COPY_PATTERNS = [
|
|
["apps/worker/dist", /^COPY\s+--from=worker-runtime-artifacts\s+\/myapp\/apps\/worker\/dist\s+\/myapp\/apps\/worker\/dist\s*$/im],
|
|
["apps/worker/package.json", /^COPY\s+--from=build\s+\/myapp\/apps\/worker\/package\.json\s+\/myapp\/apps\/worker\/package\.json\s*$/im],
|
|
["packages/config", /^COPY\s+--from=build\s+\/myapp\/packages\/config\s+\/myapp\/packages\/config\s*$/im],
|
|
["packages/db", /^COPY\s+--from=build\s+\/myapp\/packages\/db\s+\/myapp\/packages\/db\s*$/im],
|
|
["packages/db/generated/prisma/client", /^COPY\s+--from=build\s+\/myapp\/packages\/db\/generated\/prisma\/client\s+\/myapp\/packages\/db\/generated\/prisma\/client\s*$/im],
|
|
["packages/mail", /^COPY\s+--from=build\s+\/myapp\/packages\/mail\s+\/myapp\/packages\/mail\s*$/im],
|
|
["packages/queue", /^COPY\s+--from=build\s+\/myapp\/packages\/queue\s+\/myapp\/packages\/queue\s*$/im],
|
|
["prisma", /^COPY\s+--from=build\s+\/myapp\/prisma\s+\/myapp\/prisma\s*$/im],
|
|
["prisma.config.ts", /^COPY\s+--from=build\s+\/myapp\/prisma\.config\.ts\s+\/myapp\/prisma\.config\.ts\s*$/im],
|
|
["package.json", /^COPY\s+--from=build\s+\/myapp\/package\.json\s+\/myapp\/package\.json\s*$/im],
|
|
["pnpm-lock.yaml", /^COPY\s+--from=build\s+\/myapp\/pnpm-lock\.yaml\s+\/myapp\/pnpm-lock\.yaml\s*$/im],
|
|
["pnpm-workspace.yaml", /^COPY\s+--from=build\s+\/myapp\/pnpm-workspace\.yaml\s+\/myapp\/pnpm-workspace\.yaml\s*$/im],
|
|
];
|
|
const WEB_REQUIRED_COPY_PATTERNS = [
|
|
["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\s+\/myapp\/packages\/db\s*$/im],
|
|
["packages/storage", /^COPY\s+--from=build\s+\/myapp\/packages\/storage\s+\/myapp\/packages\/storage\s*$/im],
|
|
["packages/storage/node_modules", /^COPY\s+--from=deps\s+\/myapp\/packages\/storage\/node_modules\s+\/myapp\/packages\/storage\/node_modules\s*$/im],
|
|
["packages/url", /^COPY\s+--from=build\s+\/myapp\/packages\/url\s+\/myapp\/packages\/url\s*$/im],
|
|
];
|
|
const START_SCRIPT_EXEC_PATTERN = /^exec\s+node\s+build\/server-runtime\/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 PRODUCTION_PRUNE_REUSE_DEPS_PATTERN = /^COPY\s+--from=deps\s+\/myapp\s+\/myapp\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"]) {
|
|
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`);
|
|
}
|
|
|
|
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, 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}`) && block.includes(`127.0.0.1:${healthPort}/healthz`)) {
|
|
passes.push(`${composePath}: ${service} healthcheck targets ${healthPort}/healthz`);
|
|
} else {
|
|
failures.push(`${composePath}: ${service} must use WORKER_HEALTH_PORT=${healthPort} and a matching healthcheck`);
|
|
}
|
|
|
|
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("pgbouncer:6432") && stagingBlock.includes("DIRECT_URL=") && stagingBlock.includes("condition: service_healthy")) {
|
|
passes.push(`${stagingPath}: ${service} routes staging database traffic through PgBouncer with DIRECT_URL fallback`);
|
|
} else {
|
|
failures.push(`${stagingPath}: ${service} must set staging DATABASE_URL, DIRECT_URL, and pgbouncer dependency`);
|
|
}
|
|
}
|
|
|
|
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`);
|
|
}
|
|
}
|
|
|
|
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`);
|
|
}
|
|
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 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 @toolctrl/web start") {
|
|
passes.push(`${relativePath}: production start script delegates to @toolctrl/web`);
|
|
} else {
|
|
failures.push(`${relativePath}: script "start" must delegate to @toolctrl/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 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`);
|
|
}
|
|
}
|
|
|
|
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 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");
|
|
|
|
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 (WEB_BUILD_TARGET_PATTERN.test(dockerfile) && webBuildBody && WEB_BUILD_COMMAND_PATTERN.test(webBuildBody)) {
|
|
passes.push(`${relativePath}: web-build target runs only @toolctrl/web build`);
|
|
} else {
|
|
failures.push(`${relativePath}: must define web-build target from build and run pnpm --filter @toolctrl/web build`);
|
|
}
|
|
|
|
if (API_BUILD_TARGET_PATTERN.test(dockerfile) && apiBuildBody && API_BUILD_COMMAND_PATTERN.test(apiBuildBody)) {
|
|
passes.push(`${relativePath}: api-build target runs only @toolctrl/api build`);
|
|
} else {
|
|
failures.push(`${relativePath}: must define api-build target from build and run pnpm --filter @toolctrl/api build`);
|
|
}
|
|
|
|
if (WORKER_BUILD_TARGET_PATTERN.test(dockerfile) && workerBuildBody && WORKER_BUILD_COMMAND_PATTERN.test(workerBuildBody)) {
|
|
passes.push(`${relativePath}: worker-build target runs only @toolctrl/worker build`);
|
|
} else {
|
|
failures.push(`${relativePath}: must define worker-build target from build and run pnpm --filter @toolctrl/worker build`);
|
|
}
|
|
|
|
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 && 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}`);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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 productionDepsBody = getDockerfileTargetBody(dockerfile, "production-deps");
|
|
const workspaceManifests = listWorkspacePackageManifests(rootDir);
|
|
|
|
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`);
|
|
}
|
|
}
|
|
|
|
if (!productionDepsBody) {
|
|
failures.push(`${relativePath}: must define production-deps target with "FROM base AS production-deps"`);
|
|
} else {
|
|
if (PRODUCTION_PRUNE_REUSE_DEPS_PATTERN.test(productionDepsBody)) {
|
|
passes.push(`${relativePath}: production-deps reuses deps filesystem for prune`);
|
|
} else {
|
|
failures.push(`${relativePath}: production-deps target must copy --from=deps /myapp /myapp before pnpm prune --prod`);
|
|
}
|
|
|
|
if (FULL_WORKSPACE_SOURCE_COPY_PATTERN.test(productionDepsBody)) {
|
|
failures.push(`${relativePath}: production-deps target must not copy full apps or packages source before pnpm prune`);
|
|
} else {
|
|
passes.push(`${relativePath}: production-deps target does not recopy full workspace source before prune`);
|
|
}
|
|
}
|
|
|
|
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 @toolctrl/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`);
|
|
}
|
|
|
|
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 && /\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 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 @toolctrl/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`);
|
|
}
|
|
|
|
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 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));
|
|
}
|
|
|
|
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],
|
|
["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);
|
|
}
|
|
}
|