compose validate no longer flags a valid env_file

This commit is contained in:
Jarek Krochmalski committed 2026-09-02 15:52:44 +02:00
1 parent 2d12085ae4
commit 9a3c52374c
3 files changed
+129 -2

No files matched your search

@@ -19,10 +19,12 @@
*/
import { spawn } from 'node:child_process';
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { resolveComposeDockerHost, buildComposeBaseArgs } from '../compose-docker-args';
import { parseCompose } from './parse';
import { extractMaterializableEnvFiles } from './env-file-paths';
import type { Finding } from './types';
const CONFIG_TIMEOUT_MS = 20_000;
@@ -58,6 +60,22 @@ export async function renderEffectiveCompose(
const file = join(dir, 'docker-compose.yml');
await writeFile(file, composeContent, 'utf8');
// Pre-create referenced env_file(s) as empty files so `docker compose config` resolves
// them. The user's real env file lives in their stack dir, not this scratch dir, so
// without this a valid `env_file: - .env` is falsely reported "env file ... not found".
// Only temp-dir-safe relative paths are materialized; interpolation values still come
// from envVars (passed as process env), so empty files don't skew other findings.
try {
const parsed = parseCompose(composeContent);
for (const rel of extractMaterializableEnvFiles(parsed.doc)) {
const target = join(dir, rel);
await mkdir(dirname(target), { recursive: true });
await writeFile(target, '', { flag: 'wx' }).catch(() => undefined); // don't overwrite
}
} catch {
// A compose that won't parse is handled by the local parser; config will report it too.
}
const composeDockerHost = resolveComposeDockerHost(dockerHost, undefined);
const base = buildComposeBaseArgs(stackName, composeDockerHost); // ['docker', ('-H' host)?, 'compose', '-p', name]
// `--format json` renders the FULLY effective compose as JSON: interpolation,
@@ -0,0 +1,55 @@
/**
* Extract the `env_file` paths a compose references, keeping only those safe to MATERIALIZE
* inside a scratch validation dir. `docker compose config` fails "env file <path> not found"
* when a referenced env_file is absent from the cwd it runs in - but Compose Validate runs in a
* throwaway temp dir that has only the compose file, so a perfectly valid `env_file: - .env` is
* falsely rejected. We pre-create these files (empty) so config resolves.
*
* We only return paths that stay INSIDE the temp dir: a relative path with no `..` segment and
* not absolute. Absolute paths and `../` escapes are left alone (we can't/shouldn't write them;
* config's error for those is arguably legitimate).
*/
/** Normalize the compose `env_file` value (string | string[] | {path,required?}[]) to paths. */
function envFileEntriesToPaths(value: unknown): string[] {
const out: string[] = [];
const push = (v: unknown) => {
if (typeof v === 'string') {
if (v.trim()) out.push(v.trim());
} else if (v && typeof v === 'object' && typeof (v as { path?: unknown }).path === 'string') {
const p = (v as { path: string }).path.trim();
if (p) out.push(p);
}
};
if (typeof value === 'string') push(value);
else if (Array.isArray(value)) value.forEach(push);
return out;
}
/** True when `p` is a relative path that stays inside its base dir (no absolute, no `..`). */
function isInsideRelative(p: string): boolean {
if (!p || p.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(p)) return false; // absolute (posix / windows)
const segs = p.split(/[\\/]/);
return !segs.includes('..');
}
/**
* Every distinct, temp-dir-safe env_file path referenced by any service in the parsed compose.
* `doc` is the plain object from parseCompose. Returns relative paths (deduped) to create.
*/
export function extractMaterializableEnvFiles(doc: unknown): string[] {
const services = (doc as { services?: unknown })?.services;
if (!services || typeof services !== 'object') return [];
const paths = new Set<string>();
for (const svc of Object.values(services as Record<string, unknown>)) {
if (!svc || typeof svc !== 'object') continue;
const ef = (svc as { env_file?: unknown }).env_file;
if (ef === undefined) continue;
for (const p of envFileEntriesToPaths(ef)) {
if (isInsideRelative(p)) paths.add(p);
}
}
// Cap materialization: a validate request should never create an unbounded number of
// scratch files. Well past any real compose; the pre-parse already bounds payload size.
return [...paths].slice(0, 100);
}