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:
Anso
2026-06-20 11:58:42 -04:00
committed by GitHub
parent d26ab58189
commit 57a0856ffc
34 changed files with 2117 additions and 127 deletions
+16
View File
@@ -87,6 +87,21 @@ const envUnset: PreflightRule = {
},
};
const envFileMissing: PreflightRule = {
id: 'env-file-missing',
run(ctx) {
return ctx.missingEnvFiles.map(f => ({
ruleId: 'env-file-missing',
severity: 'high' as const,
title: `Missing env file ${f.rawPath}`,
message: `The Compose file declares env_file "${f.rawPath}"${f.services.length ? ` for service ${f.services.join(', ')}` : ''}, but no such file exists in the stack directory. Compose fails to start the stack when a required env_file is absent.`,
sourcePath: f.rawPath,
remediation: `Create ${f.rawPath} in the stack directory, fix the path, or mark the entry optional with "required: false".`,
service: f.services[0],
}));
},
};
const portConflictNode: PreflightRule = {
id: 'port-conflict-node',
run(ctx) {
@@ -671,6 +686,7 @@ const sensitiveServiceBroadExposure: PreflightRule = {
export const PREFLIGHT_RULES: PreflightRule[] = [
renderFailed,
envUnset,
envFileMissing,
portConflictNode,
portConflictInternal,
portExposedAllInterfaces,
+8
View File
@@ -43,6 +43,12 @@ export interface PreflightReport {
findings: PreflightFinding[];
}
/** A declared `env_file:` that is required and absent on disk (names only). */
export interface MissingEnvFile {
rawPath: string;
services: string[];
}
/** A host port bound by a running container on the target node. */
export interface NodePortBinding {
publishedPort: number;
@@ -83,6 +89,8 @@ export interface PreflightContext {
renderError: string | null;
/** Variable names Compose reported as unset (defaulted to empty string). */
unsetEnvVars: string[];
/** Declared `env_file:` paths that are required but absent on disk (names only). */
missingEnvFiles: MissingEnvFile[];
/** Service names parsed from the literal source file (pre-render). */
sourceServiceNames: string[];
/** Whether the source file could be read; gates source-derived checks so an