mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +00:00
feat(stacks): per-stack environment inventory and secret-safe guardrails (#1397)
* feat(stacks): per-stack environment inventory and secret-safe guardrails
Add an Environment tab to Stack Anatomy that derives a per-stack inventory
of environment variables from the compose files and env files. Each variable
shows its source, whether Compose interpolates it or injects it into a
container, and a status (present, missing, unused, duplicate, or shell-only),
plus likely-secret classification. The inventory works from variable names
only: a value is never read, returned, or logged, and a likely secret shows
presence only. A copy env checklist action exports names and status without
values.
Surface a missing required env_file as a Compose Doctor preflight finding,
and add an opt-in node setting that refuses a deploy or update when a
required ${VAR:?...} variable is unset or empty, before any backup, pull, or
up runs. Default off.
The Environment tab is capability-gated so it hides on older remote nodes.
* fix(stacks): harden env-file reader against a stat-then-open race
Open the env-file handle first and fstat the open handle instead of
stat-ing the path before opening, removing the check-then-use window in
readEnvFileKeys. Use a secure mkdtemp directory for the out-of-base test
path instead of a predictable name in the temp root.
* fix(stacks): resolve nested env_file paths per compose file, reconcile inline keys per service
Resolve each env_file relative to the directory of the compose file that
declared it, so a nested multi-file Git override (infra/prod.yml referencing
./prod.env) lands next to that file instead of the stack root. The root
compose file is unaffected, since its directory is the stack directory.
Reconcile inline environment provenance per service, so a key an override
removed from one service's effective env is not labeled compose-inline just
because another service injects the same name from a different source.
This commit is contained in:
@@ -17,6 +17,7 @@ import { getErrorMessage } from '../utils/errors';
|
||||
import { describeSpawnError } from '../utils/spawnErrors';
|
||||
import { isPathWithinBase, isValidStackName } from '../utils/validation';
|
||||
import { authoredComposeFileArgs, authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs';
|
||||
import { parseMissingRequiredVars } from '../helpers/envVarParse';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
export class ComposeRollbackError extends Error {
|
||||
@@ -364,7 +365,34 @@ export class ComposeService {
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, [action]), stackDir, ws);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in guard: when `env_block_deploy_on_missing_required` is enabled, refuse a
|
||||
* deploy whose required `${VAR:?err}` variables are unset OR empty, before any
|
||||
* backup, cleanup, pull, or `up` runs. Compose's own resolution is authoritative
|
||||
* (it passes process.env), and on the failing path it emits no rendered model, so
|
||||
* no env value is materialized. Default off and any settings-read failure both
|
||||
* fall through without blocking.
|
||||
*/
|
||||
private async assertRequiredEnvPresent(stackName: string): Promise<void> {
|
||||
let enabled = false;
|
||||
try {
|
||||
enabled = DatabaseService.getInstance().getGlobalSettings()['env_block_deploy_on_missing_required'] === '1';
|
||||
} catch {
|
||||
return; // safe default: a settings-read failure never blocks a deploy
|
||||
}
|
||||
if (!enabled) return;
|
||||
const result = await this.renderConfig(stackName);
|
||||
const missing = parseMissingRequiredVars(result.stderr);
|
||||
if (missing.length === 0) return;
|
||||
const plural = missing.length > 1;
|
||||
throw new Error(
|
||||
`Deploy blocked: required environment variable${plural ? 's' : ''} ${missing.join(', ')} ` +
|
||||
`${plural ? 'are' : 'is'} missing. Define ${plural ? 'them' : 'it'} in a .env or env_file, then deploy again.`,
|
||||
);
|
||||
}
|
||||
|
||||
async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
await this.assertRequiredEnvPresent(stackName);
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const debug = isDebugEnabled();
|
||||
const t0 = Date.now();
|
||||
@@ -541,6 +569,7 @@ export class ComposeService {
|
||||
}
|
||||
|
||||
async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
await this.assertRequiredEnvPresent(stackName);
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const debug = isDebugEnabled();
|
||||
const t0 = Date.now();
|
||||
|
||||
Reference in New Issue
Block a user