/** * Authored-compose env analysis for a stack: the env_file declarations (with * existence metadata), the project `.env` interpolation source, the inline * `environment:` KEY names per service, and the `${}` interpolation references. * * This is the single reader of the authored compose file set, so the multi-file * Git case is handled once and every consumer (the route's env-file wrapper that * Fleet Secrets calls, the Compose Doctor preflight, and the env inventory) sees * the same env_file set. It surfaces NAMES and structural * facts only: an env-file value is never read here, and inline environment values * are dropped immediately after their key names are taken. */ import path from 'path'; import YAML from 'yaml'; import { FileSystemService } from '../services/FileSystemService'; import { DatabaseService } from '../services/DatabaseService'; import { isPathWithinBase, isValidRelativeStackPath } from '../utils/validation'; import { parseInterpolationRefs, type InterpolationRef } from './envVarParse'; const MAX_COMPOSE_PARSE_BYTES = 1_048_576; // 1 MiB, matches the routes/stacks.ts bound const ROOT_COMPOSE_CANDIDATES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']; export type EnvFileExistence = 'present' | 'missing' | 'unverifiable'; /** * One physical env file, deduped by resolved absolute path. The project `.env` * doubling as an `env_file: .env` entry is ONE physical file carrying both roles, * so it is never double-counted as a duplicate definition. */ export interface PhysicalEnvFile { /** Absolute path, or null when the raw path is interpolated or escapes the stack dir. */ resolvedPath: string | null; /** Raw paths as written (or '.env' for the implicit project source). */ rawPaths: string[]; existence: EnvFileExistence; /** A missing file matters only when at least one declaration required it. */ required: boolean; /** True when this is the project `.env` Compose reads for `${}` interpolation. */ isInterpolationSource: boolean; /** True when a service `env_file:` injects this file into a container. */ isInjectionSource: boolean; /** Services that declared this file via `env_file:`. */ declaringServices: string[]; } export interface StackEnvSources { stackDir: string; baseDir: string; /** Absolute authored compose files actually read (multi-file Git aware). */ composeFiles: string[]; /** Project `.env` + every declared env_file, deduped by resolved path. */ envFiles: PhysicalEnvFile[]; /** Authored `environment:` KEY names per service (union across authored files). */ inlineEnvKeysByService: Record; /** `${}` references found across the authored compose source. */ interpolationRefs: InterpolationRef[]; /** Concatenated authored compose file text (in-memory classification only). */ authoredComposeText: string; } export interface EnvFileEntry { rawPath: string; required: boolean; } /** Normalize a service `env_file:` field (string, array of strings, or long-form objects). */ export function normalizeEnvFileField(envFile: unknown): EnvFileEntry[] { if (typeof envFile === 'string') return [{ rawPath: envFile, required: true }]; if (!Array.isArray(envFile)) return []; const out: EnvFileEntry[] = []; for (const entry of envFile) { if (typeof entry === 'string') { out.push({ rawPath: entry, required: true }); } else if (entry && typeof entry === 'object') { const p = (entry as Record).path; if (typeof p === 'string') { out.push({ rawPath: p, required: (entry as Record).required !== false }); } } } return out; } /** Inline `environment:` KEY names (object / `KEY=value` array / bare `KEY`), never values. */ function inlineEnvKeysOf(environment: unknown): string[] { if (Array.isArray(environment)) { return environment .filter((e): e is string => typeof e === 'string') .map(e => e.split('=')[0].trim()) .filter(Boolean); } if (environment && typeof environment === 'object') { return Object.keys(environment as Record); } return []; } async function existenceOf(fsService: FileSystemService, abs: string, baseDir: string): Promise { if (!isPathWithinBase(abs, baseDir)) return 'unverifiable'; try { await fsService.access(abs); return 'present'; } catch (err) { return (err as NodeJS.ErrnoException).code === 'ENOENT' ? 'missing' : 'unverifiable'; } } /** The authored compose files Compose would read: the applied Git deploy spec, else the root file. */ async function discoverAuthoredComposeFiles( fsService: FileSystemService, stackName: string, stackDir: string, ): Promise { const spec = DatabaseService.getInstance().getGitSource(stackName)?.applied_deploy_spec; if (spec && Array.isArray(spec.files) && spec.files.length > 0) { const files: string[] = []; for (const f of spec.files) { if (typeof f !== 'string' || !isValidRelativeStackPath(f)) continue; const abs = path.resolve(stackDir, f); if (isPathWithinBase(abs, stackDir)) files.push(abs); } if (files.length > 0) return files; } for (const name of ROOT_COMPOSE_CANDIDATES) { const abs = path.resolve(stackDir, name); try { await fsService.access(abs); return [abs]; } catch { // try next candidate } } return []; } async function parseComposeServices(fsService: FileSystemService, absPath: string): Promise<{ services: Record; text: string; } | null> { let content: string; try { content = await fsService.readFile(absPath, 'utf-8'); } catch { return null; } if (content.length > MAX_COMPOSE_PARSE_BYTES) return null; try { const parsed = YAML.parse(content) as Record | null; const services = (parsed?.services && typeof parsed.services === 'object') ? parsed.services as Record : {}; return { services, text: content }; } catch { return { services: {}, text: content }; } } /** * Resolve every authored env source for a stack. Reads each authored compose file * once and returns env_file existence, inline key names, and interpolation refs. */ export async function resolveStackEnvSources(nodeId: number, stackName: string): Promise { const fsService = FileSystemService.getInstance(nodeId); const baseDir = fsService.getBaseDir(); const stackDir = path.join(baseDir, stackName); const composeFiles = await discoverAuthoredComposeFiles(fsService, stackName, stackDir); // Physical env files, deduped by resolved absolute path. // Seed the project `.env` as the fallback interpolation source. When the stack // has configured project env files, they REPLACE `.env` as the interpolation // source (matching Docker Compose behavior: explicit --env-file suppresses // default .env auto-discovery). If the user wants `.env` in addition to custom // files, they must include it in the configured list. const configuredProjectFiles = DatabaseService.getInstance().getStackProjectEnvFiles(nodeId, stackName); const hasConfiguredProjectFiles = configuredProjectFiles.length > 0; const byPath = new Map(); if (hasConfiguredProjectFiles) { for (const file of configuredProjectFiles) { const resolvedPath = path.resolve(stackDir, file); const exists = await existenceOf(fsService, resolvedPath, baseDir); const existing = byPath.get(resolvedPath); if (existing) { existing.isInterpolationSource = true; } else { byPath.set(resolvedPath, { resolvedPath, rawPaths: [file], existence: exists, required: false, isInterpolationSource: true, isInjectionSource: false, declaringServices: [], }); } } } // Seed the project `.env` as the fallback interpolation source when nothing is configured. if (!hasConfiguredProjectFiles) { const dotenvPath = path.resolve(stackDir, '.env'); const dotenv: PhysicalEnvFile = { resolvedPath: dotenvPath, rawPaths: ['.env'], existence: await existenceOf(fsService, dotenvPath, baseDir), required: false, isInterpolationSource: true, isInjectionSource: false, declaringServices: [], }; byPath.set(dotenvPath, dotenv); } const unresolved: PhysicalEnvFile[] = []; const inlineEnvKeysByService: Record = {}; let authoredText = ''; for (const file of composeFiles) { const parsed = await parseComposeServices(fsService, file); if (!parsed) continue; authoredText += parsed.text + '\n'; for (const [serviceName, svcRaw] of Object.entries(parsed.services)) { const svc = (svcRaw ?? {}) as Record; const inlineKeys = inlineEnvKeysOf(svc.environment); if (inlineKeys.length > 0) { const existing = inlineEnvKeysByService[serviceName] ?? []; inlineEnvKeysByService[serviceName] = [...new Set([...existing, ...inlineKeys])]; } for (const entry of normalizeEnvFileField(svc.env_file)) { const interpolated = entry.rawPath.includes('${'); // Resolve relative to the directory of the compose file that declared it, // so an env_file in a nested multi-file override (e.g. infra/prod.yml -> // ./prod.env) lands next to that file, not at the stack root. For the root // compose file this dir is the stack dir, so the common case is unchanged. const abs = interpolated ? null : path.resolve(path.dirname(file), entry.rawPath); const within = abs !== null && isPathWithinBase(abs, stackDir); const resolvedPath = within ? abs : null; if (resolvedPath) { const existing = byPath.get(resolvedPath); if (existing) { existing.isInjectionSource = true; existing.required ||= entry.required; if (!existing.rawPaths.includes(entry.rawPath)) existing.rawPaths.push(entry.rawPath); if (!existing.declaringServices.includes(serviceName)) existing.declaringServices.push(serviceName); } else { byPath.set(resolvedPath, { resolvedPath, rawPaths: [entry.rawPath], existence: await existenceOf(fsService, resolvedPath, baseDir), required: entry.required, isInterpolationSource: false, isInjectionSource: true, declaringServices: [serviceName], }); } } else { // Interpolated or escaping path: unverifiable, kept so the inventory can show it. unresolved.push({ resolvedPath: null, rawPaths: [entry.rawPath], existence: 'unverifiable', required: entry.required, isInterpolationSource: false, isInjectionSource: true, declaringServices: [serviceName], }); } } } } return { stackDir, baseDir, composeFiles, envFiles: [...byPath.values(), ...unresolved], inlineEnvKeysByService, interpolationRefs: parseInterpolationRefs(authoredText), authoredComposeText: authoredText, }; } /** * Scan the stack directory (non-recursive) for env-like files that could serve as * project env files. Matches three patterns: `.env`, `*.env` (e.g. `stack.env`), * and `.env.*` (e.g. `.env.production`, `.env.local`). Returns only regular files * (directories named `*.env` are excluded). Results are stack-relative paths sorted * alphabetically. */ export async function discoverStackLocalEnvFiles(nodeId: number, stackName: string): Promise { const fsService = FileSystemService.getInstance(nodeId); const baseDir = fsService.getBaseDir(); let entries: { name: string; type: string }[]; try { entries = await fsService.listStackDirectory(stackName, ''); } catch (err) { console.warn('[EnvFileResolution] Failed to list stack directory for env file discovery:', (err as Error).message); return []; } const stackDir = path.join(baseDir, stackName); const candidates: string[] = []; for (const entry of entries) { const name = entry.name; if (name === '.env' || name.endsWith('.env') || name.startsWith('.env.')) { // Must be a regular file, not a directory. if (entry.type !== 'file') continue; // Validate containment (defense in depth). const absPath = path.resolve(stackDir, name); if (!isPathWithinBase(absPath, stackDir)) continue; candidates.push(name); } } candidates.sort(); return candidates; }