mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 02:41:14 +00:00
feat: add per-stack project env file selection for Docker Compose (#1457)
* feat: add per-stack project env file selection for Docker Compose
Allow users to configure an ordered list of env files per stack that serve
as the project environment file(s) for Docker Compose ${VAR} interpolation.
The selected files are passed via repeated --env-file flags during all
compose commands.
Backend:
- Add stack_project_env_files table (node-scoped, ordered)
- Extend authoredComposeEnvFileArgs to emit --env-file for configured files
- Add GET/PUT /stacks/:name/project-env-files and /candidates endpoints
- Update resolveStackEnvSources to use configured files as interpolation source
- Update resolveAllEnvFilePaths to merge injection + interpolation sources
- Add discoverStackLocalEnvFiles for candidate discovery
- Extend backupStackFiles and snapshotStackFiles for project env files
- Add project-env-files capability to CapabilityRegistry
Frontend:
- Add project env file selector to EnvironmentPanel (capability-gated)
- Update EditorView banner to generic "project environment file" language
- Add project-env-files capability to capabilities.ts
Issue: #1454
* fix: add realpath validation, clear all stale backup files, reject nested paths
- authoredComposeEnvFileArgs: use fsPromises.realpath + isPathWithinBase
for symlink escape defense at use time
- backupStackFiles: clear ALL non-marker files from backup slot before
writing, not just PROTECTED_STACK_FILES (handles stale old.env)
- PUT project-env-files: reject paths containing / or \ (root-level
only, matching Compose auto-discovery behavior)
* fix: add getStackProjectEnvFiles to compose-service mock
The new authoredComposeEnvFileArgs calls getStackProjectEnvFiles
on the DatabaseService singleton. The compose-service mesh-override
tests mock that singleton without the new method, causing 6 failures.
Add getStackProjectEnvFiles: () => [] (empty = fall back to legacy
behavior, which is what these tests exercise).
* fix: add getStackProjectEnvFiles to remaining service mocks
The new authoredComposeEnvFileArgs calls getStackProjectEnvFiles,
which is missing from the mock in compose-images.test.ts (6 failures)
and image-update-service.test.ts (proactive fix).
* fix: apply inline path-injection barrier at fs sink for CodeQL
The PUT project-env-files route resolved paths via isPathWithinBase
before calling fsp.stat, but CodeQL does not credit a containment check
separated from the sink. Apply the canonical inline barrier pattern
(path.resolve + startsWith at the sink) used throughout the codebase.
* fix: resolve stackDir from the same canonical root as safePath
Prevents a containment bypass when the compose base directory is
a symlink: stackDir was previously joined from the unresolved
baseDir while the inline barrier used path.resolve(baseDir),
which could differ for symlinked paths. Now both stackDir and
safePath are resolved from a single canonical root, then each is
containment-checked against it.
* fix: remove unused isPathWithinBase import
The inline path-injection barrier refactor replaced isPathWithinBase
with an inline startsWith check at the fs sink, so the import is now
unused and fails ESLint no-unused-vars.
This commit is contained in:
@@ -58,32 +58,86 @@ export function authoredComposeFileArgs(stackName: string, nodeId?: number): str
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `--env-file <stackDir>/.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 <contextDir>`, 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 <absPath>` 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 <stackDir>/.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<string[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user