Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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))
.filter((rel) => !rel.startsWith('node_modules'))
.filter((rel) => !isCopied(rel))
.sort();
Comment on lines +111 to +115

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