build: enforce Docker image contract (#1222)

* build: enforce Docker image contract

* fix: run Docker contract on durable storage

* build: restore 200 MB image contract

* build: complete container runtime contract

* chore: refresh reviewed secret fingerprint

* fix: unwrap Docker backup response

* build: exclude generated Docker context

* build: enforce platform image budgets

* chore: align Docker docs leak baseline
This commit is contained in:
Brad Groux 2026-08-23 23:49:13 -05:00 committed by GitHub
parent d5428baec0
commit c9db917422
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 338 additions and 37 deletions

View file

@ -7,6 +7,11 @@ node_modules
server/dist
web/dist
shared/dist
desktop/.desktop-release
desktop/release
.veritas-desktop-dev
playwright-report
test-results
# Git
.git

50
.github/workflows/docker-image.yml vendored Normal file
View file

@ -0,0 +1,50 @@
name: Docker Image Contract
on:
push:
branches: [main]
paths:
- Dockerfile
- .dockerignore
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- server/**
- shared/**
- web/**
- scripts/check-docker-image.mjs
- .github/workflows/docker-image.yml
pull_request:
branches: [main]
paths:
- Dockerfile
- .dockerignore
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- server/**
- shared/**
- web/**
- scripts/check-docker-image.mjs
- .github/workflows/docker-image.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: docker-image-${{ github.ref }}
cancel-in-progress: true
jobs:
contract:
name: Build, Size, and Runtime Contract
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build production image
run: docker build --target production --tag veritas-kanban:contract .
- name: Enforce image and runtime contract
run: node scripts/check-docker-image.mjs veritas-kanban:contract

View file

@ -6,7 +6,7 @@ docs/API-REFERENCE.md:generic-api-key:991
docs/API-WORKFLOWS.md:generic-api-key:1460
# Operator documentation uses placeholders in curl authentication examples.
docs/DEPLOYMENT.md:curl-auth-header:904
docs/DEPLOYMENT.md:curl-auth-header:923
docs/TROUBLESHOOTING.md:curl-auth-header:229
docs/TROUBLESHOOTING.md:curl-auth-header:257
docs/TROUBLESHOOTING.md:curl-auth-header:260

View file

@ -5,16 +5,17 @@
# 1. deps — Install all workspace dependencies (shared cache layer)
# 2. build-shared — Build the shared package
# 3. build-web — Build React frontend with Vite
# 4. build-server — Compile Express server TypeScript
# 5. production — Minimal runtime image
# 4. build-server — Compile the Express server TypeScript
# 5. production-deps — Install the server-only runtime closure
# 6. production — Minimal runtime image
#
# Target image size: < 200MB
# Target image size: < 200,000,000 bytes on arm64; < 600,000,000 bytes on amd64
# =============================================================================
# ---------------------------------------------------------------------------
# Stage 1: Install dependencies (shared across build stages)
# ---------------------------------------------------------------------------
FROM node:22-alpine AS deps
FROM node:22-alpine3.24 AS deps
RUN corepack enable && corepack prepare pnpm@11.1.1 --activate
@ -63,38 +64,49 @@ COPY server/ ./server/
RUN pnpm --filter @veritas-kanban/server build
# ---------------------------------------------------------------------------
# Stage 5: Production runtime
# Stage 5: Install the server-only production dependency closure
# ---------------------------------------------------------------------------
FROM node:22-alpine AS production
RUN corepack enable && corepack prepare pnpm@11.1.1 --activate
# Security: run as non-root
RUN addgroup -g 1001 -S nodejs && \
adduser -S veritas -u 1001 -G nodejs
FROM node:22-alpine3.24 AS production-deps
WORKDIR /app
# Copy workspace config for pnpm (include real web/package.json for lockfile integrity)
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
COPY web/package.json ./web/
COPY cli/package.json ./cli/
COPY mcp/package.json ./mcp/
COPY scripts/ ./scripts/
RUN corepack enable && \
corepack prepare pnpm@11.1.1 --activate && \
HUSKY=0 pnpm install --frozen-lockfile --prod --filter @veritas-kanban/server... && \
rm -rf /root/.cache/node/corepack /root/.local/share/pnpm/store /root/.local/share/pnpm/.tools
# Install production-only dependencies
# --ignore-scripts: skip husky prepare hook (not needed in container)
# Note: web deps get installed to satisfy the lockfile, but we remove them
# since the frontend is pre-built as static assets
RUN pnpm install --frozen-lockfile --prod --ignore-scripts && \
rm -rf web/node_modules && \
pnpm store prune
# ---------------------------------------------------------------------------
# Stage 6: Production runtime
# ---------------------------------------------------------------------------
# The matching Alpine base keeps Node's musl ABI while excluding npm,
# Corepack, headers, and package-manager tooling from the runtime image.
FROM alpine:3.24 AS production
# Copy built artifacts
COPY --from=build-shared /app/shared/dist ./shared/dist
COPY --from=build-server /app/server/dist ./server/dist
COPY --from=build-web /app/web/dist ./web/dist
RUN apk add --no-cache ca-certificates libstdc++ && \
addgroup -g 1001 -S nodejs && \
adduser -S veritas -u 1001 -G nodejs
COPY --from=production-deps /usr/local/bin/node /usr/local/bin/node
WORKDIR /app
# Copy only the resolved server runtime closure. The platform-specific Codex
# binary remains available, while npm, pnpm, workspace manifests, and build
# tooling never enter the production stage.
COPY --from=production-deps --chown=veritas:nodejs /app/node_modules ./node_modules
COPY --from=production-deps --chown=veritas:nodejs /app/server/node_modules ./server/node_modules
COPY --from=production-deps --chown=veritas:nodejs /app/shared/package.json ./shared/package.json
COPY --from=production-deps --chown=veritas:nodejs /app/server/package.json ./server/package.json
# Copy only built runtime artifacts. CLI, MCP, frontend dependencies, source,
# and build tooling never enter the production stage.
COPY --from=build-shared --chown=veritas:nodejs /app/shared/dist ./shared/dist
COPY --from=build-server --chown=veritas:nodejs /app/server/dist ./server/dist
COPY --from=build-web --chown=veritas:nodejs /app/web/dist ./web/dist
# Create the single volume-backed storage root. Runtime state is stored at
# /app/data/.veritas-kanban and task data at /app/data/tasks.

View file

@ -67,17 +67,36 @@ Data is persisted in a Docker named volume (`kanban-data`), so it survives conta
### Dockerfile Overview
The multi-stage Dockerfile produces a minimal production image (< 200 MB):
The multi-stage Dockerfile enforces architecture-specific production image budgets:
| Stage | Purpose |
| -------------- | --------------------------------------- |
| `deps` | Install all pnpm workspace dependencies |
| `build-shared` | Compile the shared TypeScript package |
| `build-web` | Build the React frontend with Vite |
| `build-server` | Compile the Express server TypeScript |
| `production` | Minimal Node.js 22 Alpine runtime |
| Architecture | Maximum compressed image size | Measured release candidate |
| ------------ | ----------------------------- | -------------------------- |
| `arm64` | 200,000,000 bytes | 195,910,880 bytes |
| `amd64` | 600,000,000 bytes | 571,590,173 bytes |
The production stage runs as a non-root user (`veritas`, UID 1001) for security.
| Stage | Purpose |
| -------------- | ------------------------------------------------------------------------ |
| `deps` | Install all pnpm workspace dependencies |
| `build-shared` | Compile the shared TypeScript package |
| `build-web` | Build the React frontend with Vite |
| `build-server` | Compile the server and deploy its production dependency closure |
| `production` | Copy only the server closure and built web assets into Node.js 22 Alpine |
The production stage does not contain npm, pnpm, the root workspace/lockfile,
CLI dependencies, or MCP dependencies. It retains only the server and shared
package identity manifests required for module resolution and version health.
It runs as the non-root `veritas` user (UID 1001).
The `amd64` image is larger because the Linux Codex runtime bundled by `@openai/codex-sdk`
occupies about 302 MB of its unpacked filesystem, including a roughly 245 MB executable.
Retaining it keeps the `codex-sdk` provider functional without an operator-supplied binary.
The budgets leave about 2% headroom on `arm64` and 5% on `amd64`, so material dependency growth
still fails the contract instead of being normalized by one loose cross-platform ceiling.
CI builds the production target and runs `pnpm check:docker-image`. The contract fails when the
image reaches its architecture budget or when the runtime smoke cannot prove non-root execution,
SQLite startup, API authentication, static web serving, health checks, and the native `bcrypt`
module. `VERITAS_DOCKER_MAX_BYTES` can set an explicit budget for another architecture.
**Path Resolution (v2.1.3):** All services use the shared `paths.ts` utility for consistent path resolution. The resolution priority is: `DATA_DIR` / `VERITAS_DATA_DIR` env var → auto-discovery of monorepo root (walks up from cwd looking for `pnpm-workspace.yaml`) → fallback to cwd. A filesystem root guard prevents silent `/` resolution, which previously caused `EACCES: permission denied` errors in Docker. The production image uses `WORKDIR /app/server` for backwards compatibility.

View file

@ -43,6 +43,7 @@
"check:security-artifacts": "node scripts/check-security-artifacts.mjs",
"check:tracked-ignore": "node --test scripts/check-tracked-ignore.test.mjs && node scripts/check-tracked-ignore.mjs",
"check:service-filesystem-boundary": "node --test scripts/check-service-filesystem-boundary.test.mjs && node scripts/check-service-filesystem-boundary.mjs",
"check:docker-image": "node scripts/check-docker-image.mjs",
"typecheck": "pnpm --filter @veritas-kanban/shared build && pnpm -r typecheck",
"test": "pnpm test:unit",
"test:unit": "node --test scripts/run-workspace-unit-tests.test.mjs && node scripts/run-workspace-unit-tests.mjs",

View file

@ -0,0 +1,214 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs';
const image = process.env.VERITAS_DOCKER_IMAGE || process.argv[2] || 'veritas-kanban:contract';
const configuredMaxBytes = process.env.VERITAS_DOCKER_MAX_BYTES;
const defaultMaxBytesByArchitecture = {
arm64: 200_000_000,
amd64: 600_000_000,
};
const containerName = `veritas-kanban-contract-${process.pid}`;
const volumeName = `${containerName}-data`;
const adminKey = randomBytes(24).toString('hex');
const expectedVersion = JSON.parse(
readFileSync(new URL('../package.json', import.meta.url), 'utf8')
).version;
function run(args) {
const result = spawnSync('docker', args, { encoding: 'utf8', stdio: 'pipe' });
if (result.status !== 0) {
const detail = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
throw new Error(detail || `Docker command failed with status ${result.status}`);
}
return result.stdout?.trim() ?? '';
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function containerLogs() {
const result = spawnSync('docker', ['logs', containerName], {
encoding: 'utf8',
stdio: 'pipe',
});
return [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
}
async function waitForHealthyContainer() {
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
const state = JSON.parse(
run(['inspect', '--format', '{{json .State}}', containerName])
);
if (state.Health?.Status === 'healthy') return;
if (state.Status === 'exited' || state.Status === 'dead') {
throw new Error(
`Container stopped before becoming healthy (${state.Status})\n${containerLogs()}`
);
}
await new Promise((resolve) => globalThis.setTimeout(resolve, 1_000));
}
const health = run(['inspect', '--format', '{{json .State.Health}}', containerName]);
throw new Error(
`Container did not become healthy within 90 seconds\nHealth: ${health}\n${containerLogs()}`
);
}
const runtimeProbe = String.raw`
import bcrypt from 'bcrypt';
import { access } from 'node:fs/promises';
const assert = (condition, message) => {
if (!condition) throw new Error(message);
};
const response = async (path, init) => fetch('http://127.0.0.1:3001' + path, init);
const health = await response('/health');
assert(health.status === 200, 'GET /health did not return 200');
const readiness = await response('/health/ready');
const readinessBody = await readiness.json();
assert(readiness.status === 200, 'GET /health/ready did not return 200');
assert(readinessBody.checks?.sqlite === 'ok', 'SQLite readiness was not healthy');
const index = await response('/');
const html = await index.text();
assert(index.status === 200 && html.includes('id="root"'), 'Built web app was not served');
const unauthenticated = await response('/api/tasks');
assert(unauthenticated.status === 401, 'Protected API did not reject an unauthenticated request');
const authenticated = await response('/api/tasks', {
headers: { 'X-API-Key': process.env.VERITAS_ADMIN_KEY },
});
assert(authenticated.status === 200, 'Admin API key did not authenticate');
const deepHealth = await response('/api/health/deep', {
headers: { 'X-API-Key': process.env.VERITAS_ADMIN_KEY },
});
const deepHealthBody = await deepHealth.json();
assert(deepHealth.status === 200, 'Deep health endpoint did not return 200');
assert(deepHealthBody.status === 'ok', 'Deep health reported a degraded runtime');
assert(
deepHealthBody.version === ${JSON.stringify(expectedVersion)},
'Deep health did not report release version ${expectedVersion}'
);
assert(deepHealthBody.checks?.storage === 'ok', 'Storage integrity check was not healthy');
assert(deepHealthBody.sqlite?.healthPosture === 'healthy', 'SQLite startup was not healthy');
assert(
deepHealthBody.dataDirectory?.path === '/app/data/.veritas-kanban',
'Runtime state did not resolve beneath the mounted DATA_DIR'
);
const backup = await response('/api/v1/sqlite/export', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.VERITAS_ADMIN_KEY,
},
body: JSON.stringify({
sqlitePath: '/app/data/.veritas-kanban/veritas.db',
outputDir: '/app/data/backups/docker-contract',
}),
});
const backupBody = await backup.json();
assert(backup.status === 200, 'SQLite backup export did not return 200');
assert(
backupBody.success === true &&
backupBody.data?.bundlePath === '/app/data/backups/docker-contract',
'SQLite backup export escaped the mounted DATA_DIR'
);
await access('/app/data/backups/docker-contract/manifest.json');
const hash = await bcrypt.hash('native-module-probe', 4);
assert(await bcrypt.compare('native-module-probe', hash), 'bcrypt native module failed');
`;
let started = false;
let volumeCreated = false;
try {
const architecture = run(['image', 'inspect', image, '--format', '{{.Architecture}}']);
const maxBytes = configuredMaxBytes
? Number(configuredMaxBytes)
: defaultMaxBytesByArchitecture[architecture];
assert(
maxBytes !== undefined,
`No Docker image size budget is defined for architecture ${architecture}; set VERITAS_DOCKER_MAX_BYTES explicitly`
);
assert(Number.isFinite(maxBytes) && maxBytes > 0, 'VERITAS_DOCKER_MAX_BYTES must be positive');
const imageBytes = Number(run(['image', 'inspect', image, '--format', '{{.Size}}']));
assert(Number.isFinite(imageBytes), `Could not read image size for ${image}`);
if (imageBytes >= maxBytes) {
const diagnostics = run([
'run',
'--rm',
'--entrypoint',
'sh',
image,
'-c',
'du -ak /app /usr/local 2>/dev/null | sort -nr | head -25',
]);
throw new Error(
`Docker image is ${imageBytes.toLocaleString()} bytes; budget is below ${maxBytes.toLocaleString()} bytes\nLargest runtime paths (KiB):\n${diagnostics}`
);
}
const configuredUser = run(['image', 'inspect', image, '--format', '{{.Config.User}}']);
assert(configuredUser === 'veritas', `Expected image user veritas, found ${configuredUser || 'root'}`);
run(['volume', 'create', volumeName]);
volumeCreated = true;
run([
'run',
'--detach',
'--name',
containerName,
'--mount',
`type=volume,source=${volumeName},target=/app/data`,
'--env',
`VERITAS_ADMIN_KEY=${adminKey}`,
'--env',
'VERITAS_STORAGE=sqlite',
image,
]);
started = true;
await waitForHealthyContainer();
run([
'exec',
containerName,
'sh',
'-c',
'test "$(id -u)" = 1001 && test ! -e /app/cli && test ! -e /app/mcp && test ! -e /app/pnpm-lock.yaml && test -f /app/data/.veritas-kanban/veritas.db',
]);
run(['exec', containerName, 'node', '--input-type=module', '--eval', runtimeProbe]);
run(['stop', '--time', '15', containerName]);
const stoppedState = JSON.parse(
run(['inspect', '--format', '{{json .State}}', containerName])
);
assert(stoppedState.Status === 'exited', 'Container did not stop cleanly');
assert(stoppedState.ExitCode === 0, `Container exited with code ${stoppedState.ExitCode}`);
run(['rm', containerName]);
started = false;
console.log(
`Docker image contract passed on ${architecture}: ${imageBytes.toLocaleString()} bytes (< ${maxBytes.toLocaleString()})`
);
console.log(
'Runtime smoke passed: non-root user, version, mounted paths, SQLite, backup, auth, web assets, health, bcrypt, and clean shutdown'
);
} finally {
if (started) {
spawnSync('docker', ['rm', '--force', containerName], { stdio: 'ignore' });
}
if (volumeCreated) {
spawnSync('docker', ['volume', 'rm', volumeName], { stdio: 'ignore' });
}
}