Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions containers/api-proxy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ RUN npm ci --omit=dev
# Copy application files
COPY server.js logging.js metrics.js rate-limiter.js \
token-tracker.js token-persistence.js token-parsers.js \
token-tracker-http.js token-tracker-ws.js \
token-tracker-http.js token-tracker-ws.js token-tracker-shared.js \
model-resolver.js model-utils.js model-body-rewriter.js proxy-utils.js adapter-factory.js anthropic-transforms.js \
model-config.js key-validation.js server-factory.js startup.js \
proxy-request.js http-client.js body-handler.js model-discovery.js management.js oidc-token-provider.js \
Expand All @@ -27,7 +27,8 @@ COPY server.js logging.js metrics.js rate-limiter.js \
ai-credits-pricing.js models-dev-catalog.js models.dev.catalog.json \
oidc-refresh-utils.js body-transform.js body-utils.js rate-limit.js websocket-proxy.js \
deprecated-header-tracker.js billing-headers.js upstream-response.js \
anthropic-cache.js otel.js token-budget-log.js blocked-request-diagnostics.js \
anthropic-cache.js otel.js otel-exporters.js otel-serialization.js \
token-budget-log.js blocked-request-diagnostics.js \
provider-env-constants.js ./
COPY guards/ ./guards/
COPY providers/ ./providers/
Expand Down
119 changes: 119 additions & 0 deletions containers/api-proxy/dockerfile-copy-coverage.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Guard test: every local module reachable from the runtime entrypoint
* (server.js) MUST be present in the Dockerfile COPY list.
*
* Background: the api-proxy image copies source files individually by name
* (no bundler). When a refactor adds a new module but forgets to update the
* Dockerfile, `require()` throws MODULE_NOT_FOUND inside the container. The
* proxy's graceful-degradation guards then silently stub the affected
* subsystem (e.g. token tracking, OTEL), so the container still boots but
* produces no token-usage.jsonl — causing AI-credit accounting to report 0.
*
* This regression has happened at least twice (OIDC modules, then
* token-tracker-shared.js). This test fails fast in CI instead.
*/

const fs = require('fs');
const path = require('path');

const ROOT = __dirname;
const ENTRYPOINT = path.join(ROOT, 'server.js');

/** Resolve a relative require spec to an existing file path, or null. */
function resolveLocal(fromFile, spec) {
const base = path.resolve(path.dirname(fromFile), spec);
const candidates = [base, `${base}.js`, `${base}.json`, path.join(base, 'index.js')];
for (const c of candidates) {
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
}
return null;
}

/** Compute the transitive closure of local (./ and ../) requires from an entry file. */
function computeRequireClosure(entry) {
const seen = new Set();
const stack = [entry];
const requireRe = /require\(\s*(["'])(\.{1,2}\/[^"']+)\1\s*\)/g;

while (stack.length > 0) {
const file = stack.pop();
if (seen.has(file)) continue;
seen.add(file);

let src;
try {
src = fs.readFileSync(file, 'utf8');
} catch {
continue;
}

let m;
while ((m = requireRe.exec(src)) !== null) {
const resolved = resolveLocal(file, m[2]);
if (resolved && !resolved.includes(`${path.sep}node_modules${path.sep}`)) {
stack.push(resolved);
}
}
}
return seen;
}

/** Parse the Dockerfile into a set of copied files and copied directory prefixes. */
function parseDockerfileCopies(dockerfilePath) {
const lines = fs.readFileSync(dockerfilePath, 'utf8').split('\n');
const files = new Set();
const dirs = new Set();

let inCopy = false;
for (const rawLine of lines) {
const line = rawLine.trim();
if (line.startsWith('#')) continue;

let body = line;
if (line.startsWith('COPY ')) {
inCopy = true;
body = line.slice('COPY '.length);
} else if (!inCopy) {
continue;
}

const continues = body.endsWith('\\');
body = body.replace(/\\$/, '').trim();

for (const tok of body.split(/\s+/)) {
if (!tok || tok === '.' || tok === './') continue;
const clean = tok.replace(/^\.\//, '');
if (clean.endsWith('/')) {
dirs.add(clean);
} else if (/\.(js|json)$/.test(clean)) {
files.add(clean);
}
}

if (!continues) inCopy = false;
}
return { files, dirs };
}

describe('Dockerfile COPY coverage', () => {
test('every module reachable from server.js is copied into the image', () => {
const closure = computeRequireClosure(ENTRYPOINT);
const { files, dirs } = parseDockerfileCopies(path.join(ROOT, 'Dockerfile'));

const isCopied = (relPath) => {
if (files.has(relPath)) return true;
for (const dir of dirs) {
if (relPath.startsWith(dir)) return true;
}
return false;
};

const missing = [...closure]
.map((abs) => path.relative(ROOT, abs).split(path.sep).join('/'))
.filter((rel) => !rel.startsWith('node_modules'))
.filter((rel) => !isCopied(rel))
.sort();

expect(missing).toEqual([]);
});
});
Loading