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
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { buildEnvChecklistMarkdown, type EnvInventory } from '../envChecklist';
const inv: EnvInventory = {
stackName: 'demo',
renderable: true,
items: [
{ key: 'DB_PASSWORD', sources: ['env-file'], usedForInterpolation: false, injectedIntoService: true, required: false, hasDefault: false, likelySecret: true, status: 'present' },
{ key: 'MISSING_VAR', sources: ['compose-ref'], usedForInterpolation: true, injectedIntoService: false, required: true, hasDefault: false, likelySecret: false, status: 'missing' },
],
envFiles: [],
summary: { total: 2, missing: 1, unused: 0, duplicate: 0, unpersisted: 0, likelySecret: 1 },
};
describe('buildEnvChecklistMarkdown', () => {
it('lists names, status, and source, and marks likely secrets without a value', () => {
const md = buildEnvChecklistMarkdown(inv);
expect(md).toContain('# Environment checklist · demo');
expect(md).toContain('No values are included');
expect(md).toContain('DB_PASSWORD');
expect(md).toContain('likely secret (value hidden)');
expect(md).toContain('status: missing');
expect(md).toMatch(/- \[x\] DB_PASSWORD/); // present → checked
expect(md).toMatch(/- \[ \] MISSING_VAR/); // actionable → unchecked
});
it('notes partial data when the model could not be rendered', () => {
expect(buildEnvChecklistMarkdown({ ...inv, renderable: false })).toContain('could not be rendered');
});
it('handles an empty inventory', () => {
const md = buildEnvChecklistMarkdown({ ...inv, items: [], summary: { total: 0, missing: 0, unused: 0, duplicate: 0, unpersisted: 0, likelySecret: 0 } });
expect(md).toContain('None referenced or defined.');
});
});
+1
View File
@@ -26,6 +26,7 @@ export const CAPABILITIES = [
'compose-doctor',
'update-guard',
'compose-networking',
'env-inventory',
] as const;
export type Capability = (typeof CAPABILITIES)[number];
+112
View File
@@ -0,0 +1,112 @@
/**
* Env inventory types (mirrors the backend payload; the frontend never imports
* backend) and the "copy env checklist" Markdown builder. The checklist carries
* variable NAMES, status, and source only. A value is never present in the
* inventory payload and never written here.
*/
export type EnvSource = 'compose-inline' | 'env-file' | 'dotenv' | 'process-env' | 'compose-ref';
export type EnvItemStatus = 'present' | 'missing' | 'unused' | 'duplicate' | 'unpersisted';
export type EnvFileExistence = 'present' | 'missing' | 'unverifiable';
export interface EnvInventoryItem {
key: string;
sources: EnvSource[];
usedForInterpolation: boolean;
injectedIntoService: boolean;
required: boolean;
hasDefault: boolean;
likelySecret: boolean;
status: EnvItemStatus;
}
export interface EnvFileInfo {
rawPaths: string[];
existence: EnvFileExistence;
required: boolean;
isInterpolationSource: boolean;
isInjectionSource: boolean;
declaringServices: string[];
}
export interface EnvInventory {
stackName: string;
renderable: boolean;
items: EnvInventoryItem[];
envFiles: EnvFileInfo[];
summary: {
total: number;
missing: number;
unused: number;
duplicate: number;
unpersisted: number;
likelySecret: number;
};
}
export const SOURCE_LABELS: Record<EnvSource, string> = {
'compose-inline': 'inline',
'env-file': 'env_file',
'dotenv': '.env',
'process-env': 'shell',
'compose-ref': 'referenced',
};
export const STATUS_LABELS: Record<EnvItemStatus, string> = {
present: 'present',
missing: 'missing',
unused: 'unused',
duplicate: 'duplicate',
unpersisted: 'shell-only',
};
function scopeLabel(item: EnvInventoryItem): string {
const parts: string[] = [];
if (item.usedForInterpolation) parts.push('interpolation');
if (item.injectedIntoService) parts.push('injected');
return parts.join('+') || 'unused';
}
/**
* Build a Markdown checklist of the inventory: names, status, source, and scope
* only. Never includes a value. Actionable statuses are unchecked boxes so the
* list reads as a to-do.
*/
export function buildEnvChecklistMarkdown(inv: EnvInventory): string {
const lines: string[] = [];
lines.push(`# Environment checklist · ${inv.stackName}`);
lines.push('');
lines.push('> Variable names, status, and source only. No values are included.');
if (!inv.renderable) {
lines.push('>');
lines.push('> The effective model could not be rendered, so injected-key data is partial.');
}
lines.push('');
lines.push('## Summary');
lines.push(`- Total: ${inv.summary.total}`);
lines.push(`- Missing: ${inv.summary.missing}`);
lines.push(`- Duplicate: ${inv.summary.duplicate}`);
lines.push(`- Unused: ${inv.summary.unused}`);
lines.push(`- Shell-only: ${inv.summary.unpersisted}`);
lines.push(`- Likely secrets: ${inv.summary.likelySecret}`);
lines.push('');
lines.push('## Variables');
if (inv.items.length === 0) {
lines.push('- None referenced or defined.');
} else {
for (const item of inv.items) {
const done = item.status === 'present';
const sources = item.sources.map(s => SOURCE_LABELS[s] ?? s).join(', ') || '-';
const flags = [
`status: ${STATUS_LABELS[item.status]}`,
`source: ${sources}`,
`scope: ${scopeLabel(item)}`,
];
if (item.required) flags.push('required');
if (item.likelySecret) flags.push('likely secret (value hidden)');
lines.push(`- [${done ? 'x' : ' '}] ${item.key} · ${flags.join(' · ')}`);
}
}
lines.push('');
return lines.join('\n');
}