diff --git a/backend/src/__tests__/compose-images.test.ts b/backend/src/__tests__/compose-images.test.ts index d9653fe5..36d51faa 100644 --- a/backend/src/__tests__/compose-images.test.ts +++ b/backend/src/__tests__/compose-images.test.ts @@ -38,7 +38,7 @@ vi.mock('../services/DockerController', () => ({ })); vi.mock('../services/DatabaseService', () => ({ - DatabaseService: { getInstance: () => ({ getRegistries: () => [], getGitSource: () => undefined }) }, + DatabaseService: { getInstance: () => ({ getRegistries: () => [], getGitSource: () => undefined, getStackProjectEnvFiles: () => [] }) }, })); vi.mock('../services/RegistryService', () => ({ diff --git a/backend/src/__tests__/compose-service.test.ts b/backend/src/__tests__/compose-service.test.ts index 6f641371..638dc3fb 100644 --- a/backend/src/__tests__/compose-service.test.ts +++ b/backend/src/__tests__/compose-service.test.ts @@ -87,6 +87,7 @@ vi.mock('../services/DatabaseService', () => ({ getRegistries: mockGetRegistries, getGlobalSettings: mockGetGlobalSettings, getGitSource: () => undefined, + getStackProjectEnvFiles: () => [], }), }, })); diff --git a/backend/src/__tests__/image-update-service.test.ts b/backend/src/__tests__/image-update-service.test.ts index 0e4cabe2..a674f3b5 100644 --- a/backend/src/__tests__/image-update-service.test.ts +++ b/backend/src/__tests__/image-update-service.test.ts @@ -44,6 +44,7 @@ vi.mock('../services/DatabaseService', () => ({ getGlobalSettings: mockGetGlobalSettings, getNodes: () => [], getGitSource: () => undefined, + getStackProjectEnvFiles: () => [], upsertStackUpdateStatus: mockUpsertStackUpdateStatus, getStackUpdateStatus: mockGetStackUpdateStatus, clearStackUpdateStatus: mockClearStackUpdateStatus, @@ -553,6 +554,7 @@ describe('ImageUpdateService - check() concurrency guard', () => { getGlobalSettings: () => ({ developer_mode: developerMode }), getNodes: () => [{ type: 'local', id: 1, name: 'local', mode: 'proxy', compose_dir: '/tmp/compose', is_default: true, status: 'online', created_at: 1 }], getGitSource: () => undefined, + getStackProjectEnvFiles: () => [], upsertStackUpdateStatus: mockUpsertStackUpdateStatus, getStackUpdateStatus: mockGetStackUpdateStatus, clearStackUpdateStatus: mockClearStackUpdateStatus, diff --git a/backend/src/helpers/envFileResolution.ts b/backend/src/helpers/envFileResolution.ts index 825ab65d..5293681f 100644 --- a/backend/src/helpers/envFileResolution.ts +++ b/backend/src/helpers/envFileResolution.ts @@ -165,20 +165,52 @@ export async function resolveStackEnvSources(nodeId: number, stackName: string): const composeFiles = await discoverAuthoredComposeFiles(fsService, stackName, stackDir); - // Physical env files, deduped by resolved absolute path. Seed with the project - // `.env`: always the interpolation source, regardless of any env_file entry. + // 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(); - 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); + + 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 = {}; @@ -251,3 +283,40 @@ export async function resolveStackEnvSources(nodeId: number, stackName: string): interpolationRefs: parseInterpolationRefs(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; +} diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index ff3cfc7d..26e7be43 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -46,7 +46,7 @@ import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan, describeP import { parseComposePreview, type ComposePreview } from '../helpers/composePreview'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; import { parseComposeSelection, defaultEnvPath } from '../helpers/gitSourceSelection'; -import { resolveStackEnvSources } from '../helpers/envFileResolution'; +import { resolveStackEnvSources, discoverStackLocalEnvFiles } from '../helpers/envFileResolution'; import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants'; import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic'; @@ -147,20 +147,24 @@ async function requireStackExists(nodeId: number, stackName: string, res: Respon } // Thin wrapper over the shared env-source resolver. Returns the absolute paths of -// the env files Compose would consult for this stack: the existing declared -// `env_file:` paths when any are declared (no project `.env` fallback in that -// case), otherwise the project `.env` when it exists. The multi-file Git case and -// path validation live in resolveStackEnvSources so every consumer agrees. +// the env files Compose would consult for this stack: existing declared `env_file:` +// paths (injection sources) plus the project interpolation source(s). Configured +// project env files are included as interpolation sources. The multi-file Git case +// and path validation live in resolveStackEnvSources so every consumer agrees. export async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise { const sources = await resolveStackEnvSources(nodeId, stackName); - const injection = sources.envFiles.filter(f => f.isInjectionSource); - if (injection.length > 0) { - return injection - .filter(f => f.existence === 'present' && f.resolvedPath) - .map(f => f.resolvedPath as string); + const present = sources.envFiles.filter(f => f.existence === 'present' && f.resolvedPath); + const injection = present.filter(f => f.isInjectionSource).map(f => f.resolvedPath as string); + const interpolation = present.filter(f => f.isInterpolationSource && !f.isInjectionSource).map(f => f.resolvedPath as string); + // Dedup: interpolation sources may also appear as injection sources. + const seen = new Set(injection); + for (const p of interpolation) { + if (!seen.has(p)) { + seen.add(p); + injection.push(p); + } } - const dotenv = sources.envFiles.find(f => f.isInterpolationSource && f.existence === 'present' && f.resolvedPath); - return dotenv ? [dotenv.resolvedPath as string] : []; + return injection; } // Uploads spool to disk (not memory) so a 25 MB upload is never held in RAM. @@ -665,6 +669,101 @@ stacksRouter.put('/:stackName/env', async (req: Request, res: Response) => { } }); +// Project env files: per-stack ordered list of env files that serve as the Docker +// Compose project environment file(s) for ${VAR} interpolation. An empty array means +// "use the default .env behavior". +stacksRouter.get('/:stackName/project-env-files', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; + try { + const files = DatabaseService.getInstance().getStackProjectEnvFiles(req.nodeId, stackName); + res.json({ envFiles: files }); + } catch (error) { + console.error('[Stacks] Failed to read project env files:', error); + res.status(500).json({ error: 'Failed to read project env files' }); + } +}); + +stacksRouter.put('/:stackName/project-env-files', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + if (!(await requireStackExists(req.nodeId, stackName, res))) return; + try { + const { envFiles } = req.body as { envFiles?: unknown }; + if (!Array.isArray(envFiles)) { + return res.status(400).json({ error: 'envFiles must be an array' }); + } + const files = envFiles as string[]; + const fsService = FileSystemService.getInstance(req.nodeId); + + // Dedup while preserving order. Skip validation for duplicates: already + // confirmed on first occurrence and the file cannot change mid-handler. + const seen = new Set(); + const deduped: string[] = []; + for (const file of files) { + if (typeof file !== 'string' || file.length === 0) { + return res.status(400).json({ error: 'Each env file must be a non-empty string' }); + } + if (seen.has(file)) continue; + if (path.isAbsolute(file) || file.includes('..') || file.startsWith('.git')) { + return res.status(400).json({ error: `Invalid env file path: "${file}"` }); + } + if (file.includes('/') || file.includes('\\')) { + return res.status(400).json({ error: `Project env files must be at the stack root: "${file}"` }); + } + if (!isValidRelativeStackPath(file)) { + return res.status(400).json({ error: `Invalid env file path: "${file}"` }); + } + // Canonical js/path-injection barrier inline with the fs sink: resolve both + // the compose root and the stack directory from a single canonical root so + // containment is unambiguous even when the compose dir is a symlink. The + // pattern mirrors authoredComposeArgs.ts and every FileSystemService sink. + const baseResolved = path.resolve(fsService.getBaseDir()); + const stackDir = path.resolve(baseResolved, stackName); + if (!stackDir.startsWith(baseResolved + path.sep)) { + return res.status(400).json({ error: `Stack directory escapes the compose directory: "${stackName}"` }); + } + const safePath = path.resolve(stackDir, file); + if (!safePath.startsWith(baseResolved + path.sep)) { + return res.status(400).json({ error: `Env file path escapes the compose directory: "${file}"` }); + } + try { + await fsService.access(safePath); + const stat = await fsp.stat(safePath); + if (!stat.isFile()) { + return res.status(400).json({ error: `Not a regular file: "${file}"` }); + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return res.status(400).json({ error: `Env file not found: "${file}"` }); + } + return res.status(400).json({ error: `Cannot access env file "${file}"` }); + } + seen.add(file); + deduped.push(file); + } + + DatabaseService.getInstance().setStackProjectEnvFiles(req.nodeId, stackName, deduped); + res.json({ envFiles: deduped }); + } catch (error) { + console.error('[Stacks] Failed to save project env files:', error); + res.status(500).json({ error: 'Failed to save project env files' }); + } +}); + +// Candidates for project env file selection: stack-local env-like files. +stacksRouter.get('/:stackName/project-env-files/candidates', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; + try { + const files = await discoverStackLocalEnvFiles(req.nodeId, stackName); + res.json({ envFiles: files }); + } catch (error) { + console.error('[Stacks] Failed to discover project env file candidates:', error); + res.status(500).json({ error: 'Failed to discover project env file candidates' }); + } +}); + // Stack Dossier: operator-authored documentation persisted per (node, stack). // All fields default to '' so a PUT is a full-document save (an omitted field // clears it) and a GET for a stack with no dossier yet returns a clean blank. @@ -947,6 +1046,7 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => { DatabaseService.getInstance().deleteStackDriftFindings(req.nodeId, stackName); DatabaseService.getInstance().deleteStackExposureIntents(req.nodeId, stackName); DatabaseService.getInstance().deleteStackExposure(req.nodeId, stackName); + DatabaseService.getInstance().deleteStackProjectEnvFiles(req.nodeId, stackName); if (debug) console.debug(`[Stacks:debug] Delete: db OK`, { stackName: sanitizedName }); } catch (dbErr) { console.error('[Stacks] Database cleanup failed for %s; files already removed:', sanitizeForLog(stackName), dbErr); diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 00c5bea5..393b9db3 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -39,6 +39,7 @@ export const CAPABILITIES = [ 'update-guard', 'compose-networking', 'env-inventory', + 'project-env-files', 'compose-storage', ] as const; diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index c10c211e..22fb474c 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -1875,6 +1875,22 @@ export class DatabaseService { } catch (e) { console.warn('[DatabaseService] mesh_stacks migration:', (e as Error).message); } + try { + this.db.prepare(` + CREATE TABLE IF NOT EXISTS stack_project_env_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER NOT NULL, + stack_name TEXT NOT NULL, + env_file TEXT NOT NULL, + position INTEGER NOT NULL, + UNIQUE(node_id, stack_name, env_file), + FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE + ) + `).run(); + this.db.prepare('CREATE INDEX IF NOT EXISTS idx_stack_project_env_files_lookup ON stack_project_env_files(node_id, stack_name)').run(); + } catch (e) { + console.warn('[DatabaseService] stack_project_env_files migration:', (e as Error).message); + } // mesh_centrals was the peer-side cache of the reverse-callback JWT // (central → peer bootstrap material). Peer→central traffic now // multiplexes over the existing forward WS via `tcp_open_reverse`, @@ -2025,6 +2041,33 @@ export class DatabaseService { this.db.prepare('DELETE FROM mesh_stacks WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName); } + // --- Project env files --- + + public getStackProjectEnvFiles(nodeId: number, stackName: string): string[] { + const rows = this.db.prepare( + 'SELECT env_file FROM stack_project_env_files WHERE node_id = ? AND stack_name = ? ORDER BY position ASC' + ).all(nodeId, stackName) as Array<{ env_file: string }>; + return rows.map(r => r.env_file); + } + + public setStackProjectEnvFiles(nodeId: number, stackName: string, files: string[]): void { + const deleteStmt = this.db.prepare('DELETE FROM stack_project_env_files WHERE node_id = ? AND stack_name = ?'); + const insertStmt = this.db.prepare( + 'INSERT INTO stack_project_env_files (node_id, stack_name, env_file, position) VALUES (?, ?, ?, ?)' + ); + const tx = this.db.transaction((ordered: string[]) => { + deleteStmt.run(nodeId, stackName); + ordered.forEach((file, idx) => { + insertStmt.run(nodeId, stackName, file, idx); + }); + }); + tx(files); + } + + public deleteStackProjectEnvFiles(nodeId: number, stackName: string): void { + this.db.prepare('DELETE FROM stack_project_env_files WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName); + } + public setNodeMeshEnabled(nodeId: number, enabled: boolean): void { this.db.prepare('UPDATE nodes SET mesh_enabled = ? WHERE id = ?').run(enabled ? 1 : 0, nodeId); } diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index fc5ef71d..640039eb 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -6,6 +6,7 @@ import type { Dirent } from 'fs'; import { Readable } from 'stream'; import { pipeline } from 'stream/promises'; import { NodeRegistry } from './NodeRegistry'; +import { DatabaseService } from './DatabaseService'; import { isPathWithinBase, isValidStackName } from '../utils/validation'; import { isBinaryBuffer } from '../utils/binaryDetect'; import { sanitizeForLog } from '../utils/safeLog'; @@ -953,24 +954,32 @@ export class FileSystemService { } await fsPromises.mkdir(backupDir, { recursive: true }); - // Clear stale managed files from the backup slot before writing the current - // ones. The slot is reused across runs, so a managed file removed from the - // stack since the last backup (e.g. a deleted .env or a switched compose - // variant) would otherwise linger here and a later restore would resurrect - // it, breaking the faithful-revert guarantee. Scope is the protected set - // Sencho writes; .timestamp is rewritten below. A clear failure is logged but - // not fatal: it only risks a stale future rollback, so it should not block an - // otherwise valid deploy. - for (const file of PROTECTED_STACK_FILES) { - const stale = path.resolve(backupRoot, path.join(backupDir, file)); - if (!stale.startsWith(backupRoot + path.sep)) continue; - try { - await fsPromises.unlink(stale); - } catch (e: unknown) { - if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') { - console.warn(`[FileSystemService] Could not clear stale backup ${file}:`, (e as Error).message); + // Clear ALL non-marker files from the backup slot before writing the current + // set. The slot is reused across runs, so a file removed from the stack since + // the last backup (e.g. a deleted .env, a switched compose variant, or a + // removed project env file like old.env) would otherwise linger here and a + // later restore would resurrect it, breaking the faithful-revert guarantee. + // Marker files (.timestamp, .checksums) are preserved until rewritten below. + // A clear failure is logged but not fatal: it only risks a stale future + // rollback, so it should not block an otherwise valid deploy. + try { + const existing = await fsPromises.readdir(backupDir); + for (const item of existing) { + if (BACKUP_MARKER_FILES.has(item)) continue; + const stale = path.resolve(backupRoot, path.join(backupDir, item)); + if (!stale.startsWith(backupRoot + path.sep)) continue; + try { + await fsPromises.unlink(stale); + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') { + console.warn(`[FileSystemService] Could not clear stale backup ${item}:`, (e as Error).message); + } } } + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') { + console.warn('[FileSystemService] Could not read backup directory for stale cleanup:', (e as Error).message); + } } // Copy each managed file by reading it into memory, writing it to the backup @@ -1028,6 +1037,23 @@ export class FileSystemService { } await writeManagedBackupFile('.env', envSrc); + // Backup configured project env files (e.g. stack.env, .env.production). + // Dedup against .env (already in PROTECTED_STACK_FILES) so it is not + // duplicated. Stale dynamic files from a prior backup are cleared below so a + // previously-backed-up old.env does not linger. + let projectEnvFiles: string[] = []; + try { + projectEnvFiles = DatabaseService.getInstance().getStackProjectEnvFiles(this.nodeId, stackName); + } catch { + // DB read failure is not fatal to the deploy; skip project env file backup. + } + for (const file of projectEnvFiles) { + if (file === '.env') continue; // already handled above + const src = path.resolve(baseResolved, path.join(stackDir, file)); + if (!src.startsWith(baseResolved + path.sep)) continue; + await writeManagedBackupFile(file, src); + } + // Write the integrity manifest before the timestamp marker, so a crash // between the two leaves the checksums present (a backup that restore can // verify) rather than a timestamp with no integrity data. Only files that @@ -1142,9 +1168,10 @@ export class FileSystemService { } /** - * Capture the current managed stack files (PROTECTED_STACK_FILES) in memory and - * return a function that puts them back, faithfully (writing the captured - * contents and removing any managed file that did not exist when captured). + * Capture the current managed stack files (PROTECTED_STACK_FILES plus configured + * project env files) in memory and return a function that puts them back, + * faithfully (writing the captured contents and removing any managed file that + * did not exist when captured). * * Used by the rollback route to undo a restored backup when the policy gate * blocks before the deploy commits: restoreStackFiles has already overwritten @@ -1154,12 +1181,21 @@ export class FileSystemService { async snapshotStackFiles(stackName: string): Promise<() => Promise> { const stackDir = this.resolveStackDir(stackName); await this.assertRealWithinBase(stackDir); - // Canonical js/path-injection barrier inline with the read/write sinks, the - // same pattern restoreStackFiles uses: resolve against the base and confirm - // containment so static analysis credits the barrier. const baseResolved = path.resolve(this.baseDir); const snapshot = new Map(); - for (const file of PROTECTED_STACK_FILES) { + + // Collect the unified set: PROTECTED_STACK_FILES + configured project env files. + const files = new Set(PROTECTED_STACK_FILES); + try { + const projectEnvFiles = DatabaseService.getInstance().getStackProjectEnvFiles(this.nodeId, stackName); + for (const f of projectEnvFiles) { + if (f !== '.env') files.add(f); // .env already in PROTECTED_STACK_FILES + } + } catch { + // DB read failure: snapshot without project env files (safe fallback). + } + + for (const file of files) { const target = path.resolve(baseResolved, path.join(stackDir, file)); if (!target.startsWith(baseResolved + path.sep)) { throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' }); @@ -1171,7 +1207,7 @@ export class FileSystemService { } } return async () => { - for (const file of PROTECTED_STACK_FILES) { + for (const file of files) { const target = path.resolve(baseResolved, path.join(stackDir, file)); if (!target.startsWith(baseResolved + path.sep)) continue; const saved = snapshot.get(file); diff --git a/backend/src/utils/authoredComposeArgs.ts b/backend/src/utils/authoredComposeArgs.ts index 3476e147..1a6b2803 100644 --- a/backend/src/utils/authoredComposeArgs.ts +++ b/backend/src/utils/authoredComposeArgs.ts @@ -58,32 +58,86 @@ export function authoredComposeFileArgs(stackName: string, nodeId?: number): str } /** - * Build the `--env-file /.env` flag a multi-file Git deploy needs when - * the applied spec sets a context dir, or `[]` otherwise. + * Build `--env-file` arguments for the stack's configured project env files. * - * When `authoredComposeFileArgs` emits `--project-directory `, Docker - * Compose treats the context dir as the project directory and looks for `.env` - * there, not at the stack root where Sencho writes it. `validateCompose` passes - * the root `.env` explicitly with `--env-file` whenever the stack has env content, - * so without the same flag at deploy/render/scan time a Git source could validate - * with one effective config and deploy or render another. This flag makes every - * compose invocation resolve env from the same root `.env` the validator used. + * When the stack has one or more project env files configured (via the + * project-env-files API), each file is resolved against the stack directory, + * validated for containment and file type, and emitted as a repeated + * `--env-file ` flag. Configured files apply to ALL stack types + * (single-file, multi-file Git, non-Git). * - * Scoped to the context-dir case on purpose: with no `--project-directory`, the - * project directory stays the stack dir (the compose command's cwd) and Compose - * auto-discovers the root `.env`, so single-file / no-context stacks need no flag - * and keep their existing behavior. An explicit `--env-file` to a missing file - * errors, so the flag is only added when a root `.env` actually exists. + * When no project env files are configured, fall back to the legacy behavior: + * pass `--env-file /.env` only for multi-file Git stacks whose + * deploy spec sets a contextDir and whose root `.env` actually exists. This + * preserves byte-identical behavior for existing stacks. */ export async function authoredComposeEnvFileArgs(stackName: string, nodeId?: number): Promise { const resolvedNodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId(); + const db = DatabaseService.getInstance(); + const configuredFiles = db.getStackProjectEnvFiles(resolvedNodeId, stackName); + + if (configuredFiles.length > 0) { + const baseResolved = path.resolve(NodeRegistry.getInstance().getComposeDir(resolvedNodeId)); + const stackDir = path.resolve(baseResolved, stackName); + if (!stackDir.startsWith(baseResolved + path.sep)) return []; + + const args: string[] = []; + for (const file of configuredFiles) { + if (!file || !isValidRelativeStackPath(file)) { + throw new Error(`Invalid project env file path for stack "${stackName}": "${file}"`); + } + // Reject paths with directory separators: project env files live at the + // stack root, matching Compose's auto-discovery behavior. + if (file.includes('/') || file.includes('\\')) { + throw new Error( + `Project env file "${file}" for stack "${stackName}" must be at the stack root. ` + + `Update the project env file selection in the Environment tab.` + ); + } + const envPath = path.resolve(stackDir, file); + if (!isPathWithinBase(envPath, stackDir)) { + throw new Error(`Project env file path escapes stack directory for stack "${stackName}": "${file}"`); + } + // Verify the real path stays within the stack directory, defending against + // symlinks that were created or swapped after configuration. + let realEnvPath: string; + try { + realEnvPath = await fsPromises.realpath(envPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error( + `Project env file "${file}" configured for stack "${stackName}" is missing. ` + + `Restore the file or update the project env file selection in the Environment tab.` + ); + } + throw err; + } + if (!isPathWithinBase(realEnvPath, stackDir)) { + throw new Error( + `Project env file "${file}" for stack "${stackName}" resolves outside the stack directory. ` + + `Update the project env file selection in the Environment tab.` + ); + } + const stat = await fsPromises.stat(realEnvPath); + if (!stat.isFile()) { + throw new Error( + `Project env file "${file}" configured for stack "${stackName}" is not a regular file. ` + + `Update the project env file selection in the Environment tab.` + ); + } + args.push('--env-file', realEnvPath); + } + return args; + } + + // Legacy fallback: --env-file .env only for multi-file Git stacks with contextDir. const spec = DatabaseService.getInstance().getGitSource(stackName)?.applied_deploy_spec; if (!spec || spec.files.length === 0 || !spec.contextDir) return []; // Inline js/path-injection barrier at the fs sink: resolve against a known-safe // base and assert containment with startsWith right here. CodeQL does not credit // the wrapped isPathWithinBase helper or a check separated from the sink, matching - // the inline guards in renderConfig and validateCompose. `.env` is a fixed name. + // the inline guards in renderConfig and validateCompose. const baseResolved = path.resolve(NodeRegistry.getInstance().getComposeDir(resolvedNodeId)); const stackDir = path.resolve(baseResolved, stackName); if (!stackDir.startsWith(baseResolved + path.sep)) return []; @@ -91,10 +145,6 @@ export async function authoredComposeEnvFileArgs(stackName: string, nodeId?: num try { await fsPromises.access(envPath); } catch (err) { - // A missing `.env` is the normal "nothing to pass" case. Any other error - // (e.g. EACCES on an existing but unreadable `.env`) is a real fault: surface - // it rather than silently dropping the flag and deploying a different effective - // config than the one validated. if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; throw err; } diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 3113463f..45e08ea8 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -515,7 +515,7 @@ export function EditorView(props: EditorViewProps) { {activeTab === 'env' && (
- Variables defined here are automatically available for substitution in your compose.yaml (e.g., ${'{}'}VAR). To pass them directly into your container, you must add env_file: - .env to your service definition. + Variables defined in the project environment file are available for substitution in your compose.yaml (e.g., ${'{}'}VAR). To pass them directly into your container, add env_file: - .env to your service definition.
)} diff --git a/frontend/src/components/stack/EnvironmentPanel.tsx b/frontend/src/components/stack/EnvironmentPanel.tsx index d3eb9858..a30f0c09 100644 --- a/frontend/src/components/stack/EnvironmentPanel.tsx +++ b/frontend/src/components/stack/EnvironmentPanel.tsx @@ -1,10 +1,11 @@ -import { useEffect, useState } from 'react'; -import { Lock, Copy, Info, TriangleAlert, ShieldAlert } from 'lucide-react'; +import { useEffect, useState, useCallback } from 'react'; +import { Lock, Copy, Info, TriangleAlert, ShieldAlert, X, ChevronUp, ChevronDown } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { cn } from '@/lib/utils'; import { toast } from '@/components/ui/toast-store'; import { copyToClipboard } from '@/lib/clipboard'; import { useNodes } from '@/context/NodeContext'; +import { useAuth } from '@/context/AuthContext'; import { buildEnvChecklistMarkdown, SOURCE_LABELS, @@ -14,6 +15,7 @@ import { type EnvItemStatus, type EnvFileExistence, } from '@/lib/envChecklist'; +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle'; const ACTION_CLASS = @@ -91,39 +93,114 @@ const STATUS_GROUPS: { status: EnvItemStatus; label: string }[] = [ ]; export default function EnvironmentPanel({ stackName }: { stackName: string }) { - const { activeNode } = useNodes(); + const { activeNode, hasCapability } = useNodes(); + const { can } = useAuth(); const nodeId = activeNode?.id; const [inventory, setInventory] = useState(null); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(false); const [copying, setCopying] = useState(false); + // Project env file selection + const projectEnvCapable = hasCapability('project-env-files'); + const canEdit = can('stack:edit', 'stack', stackName); + const [projectEnvFiles, setProjectEnvFiles] = useState([]); + const [candidates, setCandidates] = useState([]); + const [savingProjectEnv, setSavingProjectEnv] = useState(false); + + const fetchJsonEnvFiles = useCallback(async (suffix: string, setter: (v: string[]) => void) => { + if (!projectEnvCapable) return; + try { + const res = await apiFetch(`/stacks/${stackName}/project-env-files${suffix}`); + if (res.ok) { + const data = await res.json() as { envFiles: string[] }; + setter(data.envFiles); + } + } catch { /* silently skip on older nodes */ } + }, [stackName, projectEnvCapable]); + + const fetchProjectEnvFiles = useCallback( + () => fetchJsonEnvFiles('', setProjectEnvFiles), + [fetchJsonEnvFiles], + ); + + const fetchCandidates = useCallback( + () => fetchJsonEnvFiles('/candidates', setCandidates), + [fetchJsonEnvFiles], + ); + + const fetchInventory = useCallback(async () => { + setLoading(true); + setLoadError(false); + try { + const res = await apiFetch(`/stacks/${stackName}/env-inventory`); + if (!res.ok) { + setLoadError(true); + toast.error('Failed to load the environment inventory.'); + return; + } + setInventory((await res.json()) as EnvInventory); + } catch { + setLoadError(true); + toast.error('Failed to load the environment inventory.'); + } finally { + setLoading(false); + } + }, [stackName]); + useEffect(() => { let cancelled = false; const run = async () => { - setLoading(true); - setLoadError(false); - try { - const res = await apiFetch(`/stacks/${stackName}/env-inventory`); - if (cancelled) return; - if (!res.ok) { - setLoadError(true); - toast.error('Failed to load the environment inventory.'); - return; - } - setInventory((await res.json()) as EnvInventory); - } catch { - if (!cancelled) { - setLoadError(true); - toast.error('Failed to load the environment inventory.'); - } - } finally { - if (!cancelled) setLoading(false); - } + await fetchInventory(); + if (cancelled) return; + await fetchProjectEnvFiles(); + if (cancelled) return; + await fetchCandidates(); }; void run(); return () => { cancelled = true; }; - }, [stackName, nodeId]); + }, [stackName, nodeId]); // eslint-disable-line react-hooks/exhaustive-deps + + const saveProjectEnvFiles = async (files: string[]) => { + setSavingProjectEnv(true); + try { + const res = await apiFetch(`/stacks/${stackName}/project-env-files`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ envFiles: files }), + }); + if (!res.ok) { + const data = await res.json() as { error?: string }; + toast.error(data.error ?? 'Failed to save project env file selection.'); + return; + } + setProjectEnvFiles(files); + toast.success('Project env file selection saved.'); + // Refresh inventory to reflect new interpolation source. + await fetchInventory(); + } catch { + toast.error('Failed to save project env file selection.'); + } finally { + setSavingProjectEnv(false); + } + }; + + const addProjectEnvFile = (file: string) => { + if (!file || projectEnvFiles.includes(file)) return; + saveProjectEnvFiles([...projectEnvFiles, file]); + }; + + const removeProjectEnvFile = (idx: number) => { + saveProjectEnvFiles(projectEnvFiles.filter((_, i) => i !== idx)); + }; + + const moveProjectEnvFile = (idx: number, dir: -1 | 1) => { + const newFiles = [...projectEnvFiles]; + const target = idx + dir; + if (target < 0 || target >= newFiles.length) return; + [newFiles[idx], newFiles[target]] = [newFiles[target], newFiles[idx]]; + saveProjectEnvFiles(newFiles); + }; const copyChecklist = async () => { if (!inventory) return; @@ -154,11 +231,85 @@ export default function EnvironmentPanel({ stackName }: { stackName: string }) {

- Compose reads .env and the shell for {'${VAR}'} interpolation, + Compose reads the project environment file and the shell for {'${VAR}'} interpolation, while env_file and inline environment are injected into the container. Values are never read or shown: likely secrets show presence only.

+ {projectEnvCapable && ( +
+
project environment file
+
+ {projectEnvFiles.length === 0 ? ( +

+ Using .env (default). Compose auto-discovers .env in the project directory. +

+ ) : ( +
+ {projectEnvFiles.map((file, idx) => ( +
+ {file} + {canEdit && ( + <> + + + + + )} +
+ ))} +
+ )} + {canEdit && candidates.length > 0 && ( +
+ +
+ )} +
+
+ )} + {loadError ? (
diff --git a/frontend/src/components/stack/__tests__/EnvironmentPanel.test.tsx b/frontend/src/components/stack/__tests__/EnvironmentPanel.test.tsx index e8badf17..93835bc1 100644 --- a/frontend/src/components/stack/__tests__/EnvironmentPanel.test.tsx +++ b/frontend/src/components/stack/__tests__/EnvironmentPanel.test.tsx @@ -6,7 +6,8 @@ vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }, })); vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) })); -vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' } }) })); +vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' }, hasCapability: () => false }) })); +vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ can: () => false }) })); import { apiFetch } from '@/lib/api'; import { copyToClipboard } from '@/lib/clipboard'; diff --git a/frontend/src/lib/capabilities.ts b/frontend/src/lib/capabilities.ts index 1c6d58af..8f1a0a44 100644 --- a/frontend/src/lib/capabilities.ts +++ b/frontend/src/lib/capabilities.ts @@ -27,6 +27,7 @@ export const CAPABILITIES = [ 'update-guard', 'compose-networking', 'env-inventory', + 'project-env-files', 'compose-storage', ] as const;