mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
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:
parent
d5428baec0
commit
c9db917422
7 changed files with 338 additions and 37 deletions
|
|
@ -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
50
.github/workflows/docker-image.yml
vendored
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
66
Dockerfile
66
Dockerfile
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
214
scripts/check-docker-image.mjs
Normal file
214
scripts/check-docker-image.mjs
Normal 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' });
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue