884 lines
37 KiB
JavaScript
884 lines
37 KiB
JavaScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import assert from "node:assert/strict";
|
|
import { afterEach, describe, it } from "vitest";
|
|
|
|
import {
|
|
checkDockerfileDepsManifestContract,
|
|
checkDockerignoreContract,
|
|
checkDockerfileApiContract,
|
|
checkDockerfileWebContract,
|
|
checkDockerfileWorkerContract,
|
|
checkDockerComposeWorkerTopology,
|
|
checkRequiredFiles,
|
|
checkStartScript,
|
|
formatVerificationResult,
|
|
verifyDockerBaseline,
|
|
} from "./verify-docker-baseline.mjs";
|
|
|
|
const VALID_DOCKERFILE = `FROM node:20-bookworm-slim AS base
|
|
FROM base AS deps
|
|
WORKDIR /myapp
|
|
ADD package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
|
ADD prisma prisma
|
|
COPY apps/api/package.json apps/api/package.json
|
|
COPY apps/web/package.json apps/web/package.json
|
|
COPY apps/worker/package.json apps/worker/package.json
|
|
RUN --mount=type=cache,id=toolctrl-pnpm-store,target=/pnpm/store \\
|
|
pnpm fetch --frozen-lockfile
|
|
RUN --mount=type=cache,id=toolctrl-pnpm-store,target=/pnpm/store \\
|
|
pnpm install --frozen-lockfile --prod=false --offline
|
|
FROM base AS production-deps
|
|
WORKDIR /myapp
|
|
COPY --from=deps /myapp /myapp
|
|
RUN pnpm prune --prod
|
|
FROM base AS build
|
|
RUN pnpm --filter @toolctrl/db prisma:generate
|
|
RUN pnpm --filter @toolctrl/db build
|
|
FROM build AS web-build
|
|
ARG VITE_SENTRY_DSN_WEB=""
|
|
ENV VITE_SENTRY_DSN_WEB="\${VITE_SENTRY_DSN_WEB}"
|
|
RUN pnpm --filter @toolctrl/web build
|
|
FROM build AS api-build
|
|
RUN pnpm --filter @toolctrl/api build
|
|
FROM build AS worker-build
|
|
RUN pnpm --filter @toolctrl/worker build
|
|
FROM web-build AS web-runtime-artifacts
|
|
RUN find /myapp/build /myapp/apps/web/build -type f -name '*.map' -delete
|
|
FROM api-build AS api-runtime-artifacts
|
|
RUN find /myapp/apps/api/dist -type f -name '*.map' -delete
|
|
FROM worker-build AS worker-runtime-artifacts
|
|
RUN find /myapp/apps/worker/dist -type f -name '*.map' -delete
|
|
FROM base AS worker
|
|
ARG APP_REVISION=unknown
|
|
ENV APP_REVISION="\${APP_REVISION}"
|
|
LABEL org.opencontainers.image.revision="\${APP_REVISION}"
|
|
COPY --from=build /myapp/package.json /myapp/package.json
|
|
COPY --from=build /myapp/pnpm-lock.yaml /myapp/pnpm-lock.yaml
|
|
COPY --from=build /myapp/pnpm-workspace.yaml /myapp/pnpm-workspace.yaml
|
|
COPY --from=build /myapp/apps/worker/package.json /myapp/apps/worker/package.json
|
|
COPY --from=worker-runtime-artifacts /myapp/apps/worker/dist /myapp/apps/worker/dist
|
|
COPY --from=build /myapp/packages/config /myapp/packages/config
|
|
COPY --from=build /myapp/packages/db /myapp/packages/db
|
|
COPY --from=build /myapp/packages/db/generated/prisma/client /myapp/packages/db/generated/prisma/client
|
|
COPY --from=build /myapp/packages/mail /myapp/packages/mail
|
|
COPY --from=build /myapp/packages/queue /myapp/packages/queue
|
|
COPY --from=build /myapp/prisma /myapp/prisma
|
|
COPY --from=build /myapp/prisma.config.ts /myapp/prisma.config.ts
|
|
HEALTHCHECK CMD node apps/worker/dist/health.js
|
|
CMD [ "node", "apps/worker/dist/index.js" ]
|
|
FROM base AS api
|
|
ARG APP_REVISION=unknown
|
|
ENV APP_REVISION="\${APP_REVISION}"
|
|
LABEL org.opencontainers.image.revision="\${APP_REVISION}"
|
|
EXPOSE 8080
|
|
COPY --from=api-runtime-artifacts /myapp/apps/api/dist /myapp/apps/api/dist
|
|
COPY --from=build /myapp/apps/api/package.json /myapp/apps/api/package.json
|
|
COPY --from=build /myapp/packages/db /myapp/packages/db
|
|
COPY --from=build /myapp/packages/db/generated/prisma/client /myapp/packages/db/generated/prisma/client
|
|
COPY --from=build /myapp/prisma /myapp/prisma
|
|
COPY --from=build /myapp/prisma.config.ts /myapp/prisma.config.ts
|
|
COPY --from=build /myapp/package.json /myapp/package.json
|
|
COPY --from=build /myapp/pnpm-lock.yaml /myapp/pnpm-lock.yaml
|
|
COPY --from=build /myapp/pnpm-workspace.yaml /myapp/pnpm-workspace.yaml
|
|
HEALTHCHECK CMD curl -fsS "http://127.0.0.1:\${API_PORT:-\${PORT}}/healthz"
|
|
CMD [ "node", "apps/api/dist/server.js" ]
|
|
FROM base AS web
|
|
ARG APP_REVISION=unknown
|
|
ENV APP_REVISION="\${APP_REVISION}"
|
|
LABEL org.opencontainers.image.revision="\${APP_REVISION}"
|
|
EXPOSE 8080
|
|
COPY --from=web-runtime-artifacts /myapp/build /myapp/build
|
|
COPY --from=web-runtime-artifacts /myapp/build/server-runtime /myapp/build/server-runtime
|
|
COPY --from=build /myapp/prisma.config.ts /myapp/prisma.config.ts
|
|
COPY --from=build /myapp/packages/db /myapp/packages/db
|
|
COPY --from=build /myapp/packages/storage /myapp/packages/storage
|
|
COPY --from=deps /myapp/packages/storage/node_modules /myapp/packages/storage/node_modules
|
|
COPY --from=build /myapp/packages/url /myapp/packages/url
|
|
HEALTHCHECK CMD curl -fsS "http://127.0.0.1:\${PORT}/healthz"
|
|
ENTRYPOINT [ "./start.sh" ]
|
|
`;
|
|
|
|
const VALID_DOCKER_COMPOSE = `services:
|
|
redis:
|
|
image: redis:7-alpine
|
|
clamav:
|
|
image: clamav/clamav:stable
|
|
worker-general: &worker-base
|
|
environment:
|
|
- WORKER_QUEUE_GROUP=general
|
|
- WORKER_CONCURRENCY_GENERAL=\${WORKER_CONCURRENCY_GENERAL:-10}
|
|
- WORKER_HEALTH_PORT=9101
|
|
- WORKER_ADMIN_PORT=9201
|
|
- APP_URL=\${APP_URL:-}
|
|
- OUTBOX_DISPATCHER_ENABLED=\${OUTBOX_DISPATCHER_ENABLED:-false}
|
|
- OUTBOX_DISPATCHER_REQUIRED=\${OUTBOX_DISPATCHER_REQUIRED:-false}
|
|
- WORKFLOW_TRIGGER_EXECUTOR_MODE=\${WORKFLOW_TRIGGER_EXECUTOR_MODE:-http}
|
|
- WORKFLOW_TRIGGER_EXECUTOR_URL=\${WORKFLOW_TRIGGER_EXECUTOR_URL:-}
|
|
- API_ACCESS_TOKEN=\${API_ACCESS_TOKEN:-}
|
|
- WORKFLOW_TRIGGER_EXECUTOR_TIMEOUT_MS=\${WORKFLOW_TRIGGER_EXECUTOR_TIMEOUT_MS:-30000}
|
|
- WORKFLOW_TRIGGER_OUTBOX_ENABLED=\${WORKFLOW_TRIGGER_OUTBOX_ENABLED:-true}
|
|
- WORKFLOW_TRIGGER_OUTBOX_CONCURRENCY=\${WORKFLOW_TRIGGER_OUTBOX_CONCURRENCY:-5}
|
|
- WORKFLOW_TRIGGER_OUTBOX_REQUIRED=\${WORKFLOW_TRIGGER_OUTBOX_REQUIRED:-false}
|
|
- SCAN_QUEUE_ENABLED=\${SCAN_QUEUE_ENABLED:-false}
|
|
- CLAMAV_HOST=\${CLAMAV_HOST:-clamav}
|
|
- CLAMAV_PORT=\${CLAMAV_PORT:-3310}
|
|
depends_on:
|
|
redis:
|
|
condition: service_healthy
|
|
clamav:
|
|
condition: service_healthy
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9101/healthz || exit 1"]
|
|
worker-mail:
|
|
<<: *worker-base
|
|
environment:
|
|
- WORKER_QUEUE_GROUP=mail
|
|
- WORKER_CONCURRENCY_MAIL=\${WORKER_CONCURRENCY_MAIL:-5}
|
|
- WORKER_HEALTH_PORT=9102
|
|
- WORKER_ADMIN_PORT=9202
|
|
depends_on:
|
|
redis:
|
|
condition: service_healthy
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9102/healthz || exit 1"]
|
|
worker-import:
|
|
<<: *worker-base
|
|
environment:
|
|
- WORKER_QUEUE_GROUP=import
|
|
- WORKER_CONCURRENCY_IMPORT=\${WORKER_CONCURRENCY_IMPORT:-1}
|
|
- WORKER_HEALTH_PORT=9103
|
|
- WORKER_ADMIN_PORT=9203
|
|
depends_on:
|
|
redis:
|
|
condition: service_healthy
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9103/healthz || exit 1"]
|
|
worker-webhook:
|
|
<<: *worker-base
|
|
environment:
|
|
- WORKER_QUEUE_GROUP=webhook
|
|
- WORKER_CONCURRENCY_WEBHOOK=\${WORKER_CONCURRENCY_WEBHOOK:-20}
|
|
- WORKER_HEALTH_PORT=9104
|
|
- WORKER_ADMIN_PORT=9204
|
|
depends_on:
|
|
redis:
|
|
condition: service_healthy
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9104/healthz || exit 1"]
|
|
worker-plugin:
|
|
<<: *worker-base
|
|
environment:
|
|
- WORKER_QUEUE_GROUP=plugin
|
|
- WORKER_CONCURRENCY_PLUGIN=\${WORKER_CONCURRENCY_PLUGIN:-5}
|
|
- WORKER_HEALTH_PORT=9105
|
|
- WORKER_ADMIN_PORT=9205
|
|
depends_on:
|
|
redis:
|
|
condition: service_healthy
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9105/healthz || exit 1"]
|
|
volumes:
|
|
redis-data:
|
|
clamav-db:
|
|
`;
|
|
|
|
const VALID_DOCKER_COMPOSE_STAGING = `# docker compose -f docker-compose.yml -f docker-compose.staging.yml up postgres pgbouncer api web worker-general worker-mail worker-import worker-webhook worker-plugin
|
|
services:
|
|
worker-general:
|
|
environment:
|
|
- DATABASE_URL=postgres://boilerplate:boilerplate@pgbouncer:6432/boilerplate_validation?pgbouncer=true
|
|
- DIRECT_URL=postgres://boilerplate:boilerplate@postgres:5432/boilerplate_validation
|
|
- OUTBOX_DISPATCHER_ENABLED=true
|
|
- OUTBOX_DISPATCHER_REQUIRED=true
|
|
- WORKFLOW_TRIGGER_OUTBOX_ENABLED=true
|
|
- WORKFLOW_TRIGGER_OUTBOX_REQUIRED=true
|
|
depends_on:
|
|
pgbouncer:
|
|
condition: service_healthy
|
|
worker-mail:
|
|
environment:
|
|
- DATABASE_URL=postgres://boilerplate:boilerplate@pgbouncer:6432/boilerplate_validation?pgbouncer=true
|
|
- DIRECT_URL=postgres://boilerplate:boilerplate@postgres:5432/boilerplate_validation
|
|
depends_on:
|
|
pgbouncer:
|
|
condition: service_healthy
|
|
worker-import:
|
|
environment:
|
|
- DATABASE_URL=postgres://boilerplate:boilerplate@pgbouncer:6432/boilerplate_validation?pgbouncer=true
|
|
- DIRECT_URL=postgres://boilerplate:boilerplate@postgres:5432/boilerplate_validation
|
|
depends_on:
|
|
pgbouncer:
|
|
condition: service_healthy
|
|
worker-webhook:
|
|
environment:
|
|
- DATABASE_URL=postgres://boilerplate:boilerplate@pgbouncer:6432/boilerplate_validation?pgbouncer=true
|
|
- DIRECT_URL=postgres://boilerplate:boilerplate@postgres:5432/boilerplate_validation
|
|
depends_on:
|
|
pgbouncer:
|
|
condition: service_healthy
|
|
worker-plugin:
|
|
environment:
|
|
- DATABASE_URL=postgres://boilerplate:boilerplate@pgbouncer:6432/boilerplate_validation?pgbouncer=true
|
|
- DIRECT_URL=postgres://boilerplate:boilerplate@postgres:5432/boilerplate_validation
|
|
depends_on:
|
|
pgbouncer:
|
|
condition: service_healthy
|
|
volumes:
|
|
postgres-data:
|
|
`;
|
|
|
|
const fixtures = [];
|
|
|
|
function writeFile(rootDir, relativePath, content) {
|
|
const absolutePath = path.join(rootDir, relativePath);
|
|
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
fs.writeFileSync(absolutePath, content);
|
|
}
|
|
|
|
function writeJson(rootDir, relativePath, value) {
|
|
writeFile(rootDir, relativePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
function createFixture(overrides = {}) {
|
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "docker-baseline-"));
|
|
fixtures.push(rootDir);
|
|
|
|
writeFile(rootDir, "Dockerfile", overrides.dockerfile ?? VALID_DOCKERFILE);
|
|
writeFile(rootDir, "docker-compose.yml", overrides.dockerCompose ?? VALID_DOCKER_COMPOSE);
|
|
writeFile(rootDir, "docker-compose.staging.yml", overrides.dockerComposeStaging ?? VALID_DOCKER_COMPOSE_STAGING);
|
|
writeFile(
|
|
rootDir,
|
|
".dockerignore",
|
|
overrides.dockerignore
|
|
?? [
|
|
"node_modules",
|
|
"/docs",
|
|
".cache",
|
|
"**/.cache",
|
|
".eslintcache",
|
|
"**/.eslintcache",
|
|
"coverage",
|
|
"**/coverage",
|
|
".nyc_output",
|
|
"**/.nyc_output",
|
|
"reports",
|
|
"**/reports",
|
|
"public/build",
|
|
"/build",
|
|
"/apps/*/build",
|
|
"apps/*/dist",
|
|
"packages/*/dist",
|
|
"playwright-report",
|
|
"**/playwright-report",
|
|
"test-results",
|
|
"**/test-results",
|
|
"blob-report",
|
|
"**/blob-report",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
writeFile(rootDir, "start.sh", overrides.startScript ?? "#!/bin/sh\nexec node build/server-runtime/server.js\n");
|
|
writeFile(rootDir, "pnpm-workspace.yaml", overrides.workspace ?? "packages:\n - apps/*\n");
|
|
writeJson(
|
|
rootDir,
|
|
"package.json",
|
|
overrides.rootPackage ?? {
|
|
private: true,
|
|
scripts: {
|
|
build: "vite build && node scripts/build-web-server.mjs",
|
|
lint: "eslint .",
|
|
test: "pnpm --filter @toolctrl/web test",
|
|
typecheck: "tsc -b",
|
|
start: "pnpm --filter @toolctrl/web start",
|
|
},
|
|
},
|
|
);
|
|
writeJson(
|
|
rootDir,
|
|
"apps/web/package.json",
|
|
overrides.webPackage ?? {
|
|
name: "@toolctrl/web",
|
|
scripts: {
|
|
start: "node ../../build/server-runtime/server.js",
|
|
},
|
|
},
|
|
);
|
|
writeJson(
|
|
rootDir,
|
|
"apps/api/package.json",
|
|
overrides.apiPackage ?? {
|
|
name: "@toolctrl/api",
|
|
scripts: {
|
|
build: "tsc -p tsconfig.json",
|
|
start: "node dist/server.js",
|
|
},
|
|
},
|
|
);
|
|
writeJson(
|
|
rootDir,
|
|
"apps/worker/package.json",
|
|
overrides.workerPackage ?? {
|
|
name: "@toolctrl/worker",
|
|
scripts: {
|
|
start: "node dist/index.js",
|
|
health: "node dist/health.js",
|
|
},
|
|
},
|
|
);
|
|
|
|
if (overrides.remove) {
|
|
for (const relativePath of overrides.remove) {
|
|
fs.rmSync(path.join(rootDir, relativePath), { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
return rootDir;
|
|
}
|
|
|
|
afterEach(() => {
|
|
while (fixtures.length > 0) {
|
|
fs.rmSync(fixtures.pop(), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("verify-docker-baseline", () => {
|
|
it("passes for a repository with required Docker files and runtime scripts", () => {
|
|
const rootDir = createFixture();
|
|
|
|
const result = verifyDockerBaseline(rootDir);
|
|
|
|
assert.deepEqual(result.failures, []);
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: exists"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: deps stage copies all workspace package manifests before install"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: deps target fetches lockfile dependencies before offline install"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: deps target uses frozen offline dev dependency install"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: deps target uses BuildKit pnpm store cache"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: production-deps reuses deps filesystem for prune"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: defines named web target from base"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: copies build/server-runtime into web image"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: build target leaves web, api, and worker app builds to split stages"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: build target keeps VITE build args out of api/worker cache paths"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: web-build target runs only @toolctrl/web build"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: api-build target runs only @toolctrl/api build"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: worker-build target runs only @toolctrl/worker build"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: target-specific runtime artifacts remove web, api, and worker sourcemaps before runtime COPY"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: web target exposes port 8080"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: web target avoids recursive runtime chown"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: web target copies prisma.config.ts"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: web target copies packages/db production seed artifact"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: web target copies packages/storage"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: web target copies packages/url"));
|
|
assert.ok(result.passes.some((pass) => pass === "start.sh: execs the production start process for container signal handling"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: defines named worker target from base"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: worker target copies apps/worker/dist"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: worker target copies prisma.config.ts"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: worker target runtime does not use tsx"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: worker target avoids recursive runtime chown"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: defines named api target from base"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: api target copies packages/db/generated/prisma/client"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: api target copies prisma.config.ts"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: api target exposes port 8080"));
|
|
assert.ok(result.passes.some((pass) => pass === "Dockerfile: api target avoids recursive runtime chown"));
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: script \"typecheck\" is defined"));
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: production start script delegates to @toolctrl/web"));
|
|
assert.ok(result.passes.some((pass) => pass === "apps/web/package.json: owns production web server start command"));
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: production start script does not use tsx"));
|
|
assert.ok(result.passes.some((pass) => pass === "apps/api/package.json: production start script uses node"));
|
|
assert.ok(result.passes.some((pass) => pass === "apps/worker/package.json: script \"start\" targets dist/index.js"));
|
|
assert.ok(result.passes.some((pass) => pass === "apps/worker/package.json: script \"health\" targets dist/health.js"));
|
|
assert.ok(result.passes.some((pass) => pass === "docker-compose.yml: worker-general passes through OUTBOX_DISPATCHER_ENABLED"));
|
|
assert.ok(result.passes.some((pass) => pass === "docker-compose.yml: worker-general passes through OUTBOX_DISPATCHER_REQUIRED"));
|
|
assert.ok(result.passes.some((pass) => pass === "docker-compose.yml: worker-general passes through WORKFLOW_TRIGGER_OUTBOX_REQUIRED"));
|
|
assert.ok(result.passes.some((pass) => pass === "docker-compose.yml: worker-general uses distinct WORKER_ADMIN_PORT=9201"));
|
|
assert.ok(result.passes.some((pass) => pass === "docker-compose.yml: worker-mail uses distinct WORKER_ADMIN_PORT=9202"));
|
|
assert.ok(result.passes.some((pass) => pass === "docker-compose.yml: worker-plugin uses distinct WORKER_ADMIN_PORT=9205"));
|
|
assert.ok(result.passes.some((pass) => pass === "docker-compose.staging.yml: worker-general forces OUTBOX_DISPATCHER_ENABLED=true"));
|
|
assert.ok(result.passes.some((pass) => pass === "docker-compose.staging.yml: worker-general forces OUTBOX_DISPATCHER_REQUIRED=true"));
|
|
assert.ok(result.passes.some((pass) => pass === "docker-compose.staging.yml: worker-general forces WORKFLOW_TRIGGER_OUTBOX_ENABLED=true"));
|
|
assert.ok(result.passes.some((pass) => pass === "docker-compose.staging.yml: worker-general forces WORKFLOW_TRIGGER_OUTBOX_REQUIRED=true"));
|
|
assert.ok(result.passes.some((pass) => pass === "docker-compose.yml: worker-general owns ClamAV dependency for file scanning"));
|
|
assert.ok(result.passes.some((pass) => pass === "docker-compose.yml: worker-mail depends on redis only and does not wait for ClamAV"));
|
|
assert.ok(result.passes.some((pass) => pass === "docker-compose.yml: worker-plugin depends on redis only and does not wait for ClamAV"));
|
|
});
|
|
|
|
it("fails with a path-specific message when Dockerfile is missing", () => {
|
|
const rootDir = createFixture({ remove: ["Dockerfile"] });
|
|
|
|
const result = checkRequiredFiles(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Dockerfile: missing required Docker baseline file/);
|
|
});
|
|
|
|
it("fails when the deps target omits a workspace package manifest", () => {
|
|
const rootDir = createFixture({
|
|
dockerfile: VALID_DOCKERFILE.replace("COPY apps/api/package.json apps/api/package.json\n", ""),
|
|
});
|
|
|
|
const result = checkDockerfileDepsManifestContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Dockerfile: deps target must copy apps\/api\/package\.json before pnpm install/);
|
|
});
|
|
|
|
it("fails when the compose worker topology omits the plugin worker group", () => {
|
|
const rootDir = createFixture({
|
|
dockerCompose: VALID_DOCKER_COMPOSE.replace(/\n worker-plugin:[\s\S]*?(?=\nvolumes:)/, ""),
|
|
dockerComposeStaging: VALID_DOCKER_COMPOSE_STAGING.replace(/\n worker-plugin:[\s\S]*?(?=\nvolumes:)/, ""),
|
|
});
|
|
|
|
const result = checkDockerComposeWorkerTopology(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /docker-compose\.yml: missing worker-plugin service for WORKER_QUEUE_GROUP=plugin/);
|
|
assert.match(result.failures.join("\n"), /docker-compose\.staging\.yml: missing worker-plugin staging overlay/);
|
|
});
|
|
|
|
it("fails when non-scanning workers inherit the ClamAV dependency", () => {
|
|
const rootDir = createFixture({
|
|
dockerCompose: VALID_DOCKER_COMPOSE.replace(
|
|
/ worker-mail:\n <<: \*worker-base\n environment:[\s\S]*? healthcheck:/,
|
|
` worker-mail:
|
|
<<: *worker-base
|
|
environment:
|
|
- WORKER_QUEUE_GROUP=mail
|
|
- WORKER_CONCURRENCY_MAIL=\${WORKER_CONCURRENCY_MAIL:-5}
|
|
- WORKER_HEALTH_PORT=9102
|
|
- WORKER_ADMIN_PORT=9202
|
|
healthcheck:`,
|
|
),
|
|
});
|
|
|
|
const result = checkDockerComposeWorkerTopology(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /docker-compose\.yml: worker-mail must override worker-base depends_on so it does not inherit clamav/);
|
|
});
|
|
|
|
it("fails when a worker group omits its distinct admin port", () => {
|
|
const rootDir = createFixture({
|
|
dockerCompose: VALID_DOCKER_COMPOSE.replace(" - WORKER_ADMIN_PORT=9202\n", ""),
|
|
});
|
|
|
|
const result = checkDockerComposeWorkerTopology(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /docker-compose\.yml: worker-mail must set WORKER_ADMIN_PORT=9202 distinct from WORKER_HEALTH_PORT=9102/);
|
|
});
|
|
|
|
it("fails when dependency targets copy full workspace source before install or prune", () => {
|
|
const rootDir = createFixture({
|
|
dockerfile: VALID_DOCKERFILE
|
|
.replace("COPY apps/api/package.json apps/api/package.json\n", "ADD apps apps\n")
|
|
.replace("COPY --from=deps /myapp /myapp\n", "ADD packages packages\n"),
|
|
});
|
|
|
|
const result = checkDockerfileDepsManifestContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Dockerfile: deps target must copy workspace package manifests, not full apps or packages source, before pnpm install/);
|
|
assert.match(result.failures.join("\n"), /Dockerfile: production-deps target must copy --from=deps \/myapp \/myapp before pnpm prune --prod/);
|
|
assert.match(result.failures.join("\n"), /Dockerfile: production-deps target must not copy full apps or packages source before pnpm prune/);
|
|
});
|
|
|
|
it("fails when the deps target installs without a BuildKit pnpm store cache", () => {
|
|
const rootDir = createFixture({
|
|
dockerfile: VALID_DOCKERFILE.replace(
|
|
"RUN --mount=type=cache,id=toolctrl-pnpm-store,target=/pnpm/store \\\n pnpm install --frozen-lockfile --prod=false --offline",
|
|
"RUN pnpm install --frozen-lockfile --prod=false"
|
|
),
|
|
});
|
|
|
|
const result = checkDockerfileDepsManifestContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Dockerfile: deps target must use BuildKit cache mount for \/pnpm\/store during pnpm install/);
|
|
});
|
|
|
|
it("fails when the deps target does not fetch before offline install", () => {
|
|
const rootDir = createFixture({
|
|
dockerfile: VALID_DOCKERFILE.replace(
|
|
"RUN --mount=type=cache,id=toolctrl-pnpm-store,target=/pnpm/store \\\n pnpm fetch --frozen-lockfile\n",
|
|
""
|
|
),
|
|
});
|
|
|
|
const result = checkDockerfileDepsManifestContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Dockerfile: deps target must run pnpm fetch --frozen-lockfile with BuildKit cache before install/);
|
|
});
|
|
|
|
it("fails when .dockerignore broadly excludes source build directories", () => {
|
|
const rootDir = createFixture({
|
|
dockerignore: [
|
|
"node_modules",
|
|
"/docs",
|
|
".cache",
|
|
"**/.cache",
|
|
".eslintcache",
|
|
"**/.eslintcache",
|
|
"coverage",
|
|
"**/coverage",
|
|
".nyc_output",
|
|
"**/.nyc_output",
|
|
"reports",
|
|
"**/reports",
|
|
"public/build",
|
|
"/build",
|
|
"**/build",
|
|
"apps/*/dist",
|
|
"packages/*/dist",
|
|
"playwright-report",
|
|
"**/playwright-report",
|
|
"test-results",
|
|
"**/test-results",
|
|
"blob-report",
|
|
"**/blob-report",
|
|
"",
|
|
].join("\n"),
|
|
});
|
|
|
|
const result = checkDockerignoreContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.dockerignore: must not use broad \*\*\/build/);
|
|
});
|
|
|
|
it("fails when .dockerignore omits generated build ignores", () => {
|
|
const rootDir = createFixture({
|
|
dockerignore: [
|
|
"node_modules",
|
|
"apps/*/dist",
|
|
"packages/*/dist",
|
|
"",
|
|
].join("\n"),
|
|
});
|
|
|
|
const result = checkDockerignoreContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.dockerignore: must ignore generated public\/build/);
|
|
assert.match(result.failures.join("\n"), /\.dockerignore: must ignore generated \/build/);
|
|
assert.match(result.failures.join("\n"), /\.dockerignore: must ignore generated \/apps\/\*\/build/);
|
|
});
|
|
|
|
it("fails when .dockerignore omits Docker context artifact ignores", () => {
|
|
const rootDir = createFixture({
|
|
dockerignore: [
|
|
"node_modules",
|
|
"public/build",
|
|
"/build",
|
|
"/apps/*/build",
|
|
"apps/*/dist",
|
|
"packages/*/dist",
|
|
"",
|
|
].join("\n"),
|
|
});
|
|
|
|
const result = checkDockerignoreContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.dockerignore: must ignore Docker context artifact \/docs/);
|
|
assert.match(result.failures.join("\n"), /\.dockerignore: must ignore Docker context artifact \*\*\/coverage/);
|
|
assert.match(result.failures.join("\n"), /\.dockerignore: must ignore Docker context artifact \*\*\/reports/);
|
|
assert.match(result.failures.join("\n"), /\.dockerignore: must ignore Docker context artifact \*\*\/blob-report/);
|
|
assert.match(result.failures.join("\n"), /\.dockerignore: must ignore Docker context artifact \*\*\/\.nyc_output/);
|
|
});
|
|
|
|
it("fails when .dockerignore broadly excludes cache or report source directories", () => {
|
|
const rootDir = createFixture({
|
|
dockerignore: [
|
|
"node_modules",
|
|
"/docs",
|
|
".cache",
|
|
"**/.cache",
|
|
"public/build",
|
|
"/build",
|
|
"/apps/*/build",
|
|
"apps/*/dist",
|
|
"packages/*/dist",
|
|
"**/cache",
|
|
"**/report",
|
|
"coverage",
|
|
"**/coverage",
|
|
".nyc_output",
|
|
"**/.nyc_output",
|
|
"reports",
|
|
"**/reports",
|
|
"playwright-report",
|
|
"**/playwright-report",
|
|
"test-results",
|
|
"**/test-results",
|
|
"blob-report",
|
|
"**/blob-report",
|
|
"",
|
|
].join("\n"),
|
|
});
|
|
|
|
const result = checkDockerignoreContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.dockerignore: must not use broad \*\*\/cache/);
|
|
assert.match(result.failures.join("\n"), /\.dockerignore: must not use broad \*\*\/report/);
|
|
});
|
|
|
|
it("fails when start.sh does not exec the production start process", () => {
|
|
const rootDir = createFixture({ startScript: "#!/bin/sh\npnpm start\n" });
|
|
|
|
const result = checkStartScript(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /start\.sh: must use "exec node build\/server-runtime\/server\.js" so container signals reach the web process without pnpm indirection/);
|
|
});
|
|
|
|
it("fails when the root production start script uses tsx", () => {
|
|
const rootDir = createFixture({
|
|
rootPackage: {
|
|
private: true,
|
|
scripts: {
|
|
build: "vite build && node scripts/build-web-server.mjs",
|
|
lint: "eslint .",
|
|
test: "pnpm --filter @toolctrl/web test",
|
|
typecheck: "tsc -b",
|
|
start: "tsx server/server.ts",
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = verifyDockerBaseline(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /package\.json: script "start" must delegate to @toolctrl\/web/);
|
|
assert.match(result.failures.join("\n"), /package\.json: script "start" must not use tsx for web production runtime/);
|
|
});
|
|
|
|
it("fails when Dockerfile does not define the named web target", () => {
|
|
const rootDir = createFixture({
|
|
dockerfile: VALID_DOCKERFILE.replace("FROM base AS web", "FROM base"),
|
|
});
|
|
|
|
const result = checkDockerfileWebContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Dockerfile: must define web target with "FROM base AS web"/);
|
|
});
|
|
|
|
it("fails when Dockerfile does not define the named api target", () => {
|
|
const rootDir = createFixture({
|
|
dockerfile: VALID_DOCKERFILE.replace("FROM base AS api", "FROM base"),
|
|
});
|
|
|
|
const result = checkDockerfileApiContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Dockerfile: must define api target with "FROM base AS api"/);
|
|
});
|
|
|
|
it("fails when Dockerfile does not define the named worker target", () => {
|
|
const rootDir = createFixture({
|
|
dockerfile: VALID_DOCKERFILE.replace("FROM base AS worker", "FROM base"),
|
|
});
|
|
|
|
const result = checkDockerfileWorkerContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Dockerfile: must define worker target with "FROM base AS worker"/);
|
|
});
|
|
|
|
it("fails when runtime images use recursive chown", () => {
|
|
const rootDir = createFixture({
|
|
dockerfile: VALID_DOCKERFILE
|
|
.replace("HEALTHCHECK CMD curl", "RUN chown -R node:node /myapp\nHEALTHCHECK CMD curl")
|
|
.replace("HEALTHCHECK CMD node", "RUN chown -R node:node /myapp\nHEALTHCHECK CMD node")
|
|
.replace("ENTRYPOINT [ \"./start.sh\" ]", "RUN chown -R node:node /myapp\nENTRYPOINT [ \"./start.sh\" ]"),
|
|
});
|
|
|
|
assert.match(checkDockerfileWebContract(rootDir).failures.join("\n"), /web target must not use recursive chown/);
|
|
assert.match(checkDockerfileApiContract(rootDir).failures.join("\n"), /api target must not use recursive chown/);
|
|
assert.match(checkDockerfileWorkerContract(rootDir).failures.join("\n"), /worker target must not use recursive chown/);
|
|
});
|
|
|
|
it("fails when Dockerfile misses the worker dist copy", () => {
|
|
const rootDir = createFixture({
|
|
dockerfile: VALID_DOCKERFILE.replace(
|
|
"COPY --from=worker-runtime-artifacts /myapp/apps/worker/dist /myapp/apps/worker/dist\n",
|
|
"",
|
|
),
|
|
});
|
|
|
|
const result = checkDockerfileWorkerContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Dockerfile: worker target must copy \/myapp\/apps\/worker\/dist/);
|
|
});
|
|
|
|
it("fails when Dockerfile worker target uses tsx at runtime", () => {
|
|
const rootDir = createFixture({
|
|
dockerfile: VALID_DOCKERFILE.replace(
|
|
'CMD [ "node", "apps/worker/dist/index.js" ]',
|
|
'CMD [ "tsx", "apps/worker/src/index.ts" ]',
|
|
),
|
|
});
|
|
|
|
const result = checkDockerfileWorkerContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Dockerfile: worker target must invoke node apps\/worker\/dist\/index\.js directly/);
|
|
assert.match(result.failures.join("\n"), /Dockerfile: worker target must not use tsx at runtime/);
|
|
});
|
|
|
|
it("fails when Dockerfile misses the api generated Prisma client copy", () => {
|
|
const rootDir = createFixture({
|
|
dockerfile: VALID_DOCKERFILE.replaceAll(
|
|
"COPY --from=build /myapp/packages/db/generated/prisma/client /myapp/packages/db/generated/prisma/client\n",
|
|
"",
|
|
),
|
|
});
|
|
|
|
const result = checkDockerfileApiContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Dockerfile: api target must copy \/myapp\/packages\/db\/generated\/prisma\/client/);
|
|
});
|
|
|
|
it("fails when Dockerfile api target uses tsx at runtime", () => {
|
|
const rootDir = createFixture({
|
|
dockerfile: VALID_DOCKERFILE.replace(
|
|
'CMD [ "node", "apps/api/dist/server.js" ]',
|
|
'CMD [ "tsx", "apps/api/src/server.ts" ]',
|
|
),
|
|
});
|
|
|
|
const result = checkDockerfileApiContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Dockerfile: api target must invoke node apps\/api\/dist\/server\.js directly/);
|
|
assert.match(result.failures.join("\n"), /Dockerfile: api target must not use tsx at runtime/);
|
|
});
|
|
|
|
it("fails when Dockerfile misses the server-runtime copy", () => {
|
|
const rootDir = createFixture({
|
|
dockerfile: VALID_DOCKERFILE.replace(
|
|
"COPY --from=web-runtime-artifacts /myapp/build/server-runtime /myapp/build/server-runtime\n",
|
|
"",
|
|
),
|
|
});
|
|
|
|
const result = checkDockerfileWebContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Dockerfile: web target must copy \/myapp\/build\/server-runtime from web-runtime-artifacts stage/);
|
|
});
|
|
|
|
it("fails when Dockerfile does not remove sourcemaps before runtime artifact copies", () => {
|
|
const rootDir = createFixture({
|
|
dockerfile: VALID_DOCKERFILE.replace(
|
|
"RUN find /myapp/build /myapp/apps/web/build -type f -name '*.map' -delete\n",
|
|
"",
|
|
),
|
|
});
|
|
|
|
const result = checkDockerfileWebContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Dockerfile: web\/api\/worker runtime artifact targets must delete their own \*\.map files before runtime COPY/);
|
|
});
|
|
|
|
it("fails when the shared build stage runs app builds instead of split build targets", () => {
|
|
const rootDir = createFixture({
|
|
dockerfile: VALID_DOCKERFILE.replace("RUN pnpm --filter @toolctrl/db build\n", "RUN pnpm --filter @toolctrl/db build\nRUN pnpm build\n"),
|
|
});
|
|
|
|
const result = checkDockerfileWebContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Dockerfile: build target must not run root\/web\/api\/worker app builds/);
|
|
});
|
|
|
|
it("fails when the shared build stage owns Vite build args", () => {
|
|
const rootDir = createFixture({
|
|
dockerfile: VALID_DOCKERFILE.replace("RUN pnpm --filter @toolctrl/db prisma:generate\n", 'ARG VITE_SENTRY_DSN_WEB=""\nRUN pnpm --filter @toolctrl/db prisma:generate\n'),
|
|
});
|
|
|
|
const result = checkDockerfileWebContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Dockerfile: build target must not declare VITE_\* ARG\/ENV values/);
|
|
});
|
|
|
|
it("fails when Dockerfile misses required web workspace package copies", () => {
|
|
const rootDir = createFixture({
|
|
dockerfile: VALID_DOCKERFILE.replace(
|
|
"COPY --from=build /myapp/packages/storage /myapp/packages/storage\nCOPY --from=deps /myapp/packages/storage/node_modules /myapp/packages/storage/node_modules\nCOPY --from=build /myapp/packages/url /myapp/packages/url\n",
|
|
"",
|
|
),
|
|
});
|
|
|
|
const result = checkDockerfileWebContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Dockerfile: web target must copy \/myapp\/packages\/storage/);
|
|
assert.match(result.failures.join("\n"), /Dockerfile: web target must copy \/myapp\/packages\/storage\/node_modules/);
|
|
assert.match(result.failures.join("\n"), /Dockerfile: web target must copy \/myapp\/packages\/url/);
|
|
});
|
|
|
|
it("checks web runtime copy and entrypoint only inside the web target", () => {
|
|
const rootDir = createFixture({
|
|
dockerfile: `FROM node:20-bookworm-slim AS base
|
|
FROM base AS build
|
|
COPY --from=build /myapp/build/server-runtime /myapp/build/server-runtime
|
|
ENTRYPOINT [ "./start.sh" ]
|
|
FROM base AS web
|
|
COPY --from=build /myapp/build /myapp/build
|
|
`,
|
|
});
|
|
|
|
const result = checkDockerfileWebContract(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Dockerfile: web target must copy \/myapp\/build\/server-runtime from web-runtime-artifacts stage/);
|
|
assert.match(result.failures.join("\n"), /Dockerfile: web target must keep ENTRYPOINT/);
|
|
});
|
|
|
|
it("fails with the script name and manifest path when root typecheck is missing", () => {
|
|
const rootDir = createFixture({
|
|
rootPackage: {
|
|
private: true,
|
|
scripts: {
|
|
build: "vite build",
|
|
lint: "eslint .",
|
|
test: "pnpm --filter @toolctrl/web test",
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = verifyDockerBaseline(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /package\.json: missing required script "typecheck"/);
|
|
});
|
|
|
|
it("fails with a path-specific message when a workspace package manifest is missing", () => {
|
|
const rootDir = createFixture({ remove: ["apps/api/package.json"] });
|
|
|
|
const result = verifyDockerBaseline(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /apps\/api\/package\.json: missing required Docker baseline file/);
|
|
});
|
|
|
|
it("fails with a path-specific message when the web package manifest is missing", () => {
|
|
const rootDir = createFixture({ remove: ["apps/web/package.json"] });
|
|
|
|
const result = verifyDockerBaseline(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /apps\/web\/package\.json: missing required Docker baseline file/);
|
|
});
|
|
|
|
it("fails when worker production scripts use tsx", () => {
|
|
const rootDir = createFixture({
|
|
workerPackage: {
|
|
name: "@toolctrl/worker",
|
|
scripts: {
|
|
start: "tsx src/index.ts",
|
|
health: "tsx src/health.ts",
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = verifyDockerBaseline(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /apps\/worker\/package\.json: script "start" must contain node for worker production runtime/);
|
|
assert.match(result.failures.join("\n"), /apps\/worker\/package\.json: script "start" must target dist\/index\.js/);
|
|
assert.match(result.failures.join("\n"), /apps\/worker\/package\.json: script "start" must not use tsx for worker production runtime/);
|
|
assert.match(result.failures.join("\n"), /apps\/worker\/package\.json: script "health" must target dist\/health\.js/);
|
|
});
|
|
|
|
it("formats concise PASS, OBSERVE, and FAIL lines", () => {
|
|
const output = formatVerificationResult({
|
|
passes: ["Dockerfile: exists"],
|
|
observations: ["apps/worker/package.json: dev script uses tsx"],
|
|
failures: ["package.json: missing required script \"typecheck\""],
|
|
});
|
|
|
|
assert.match(output, /^FAIL Docker baseline verification/m);
|
|
assert.match(output, /^PASS Dockerfile: exists/m);
|
|
assert.match(output, /^OBSERVE apps\/worker\/package\.json: dev script uses tsx/m);
|
|
assert.match(output, /^FAIL package\.json: missing required script "typecheck"/m);
|
|
});
|
|
});
|