Files
sencho/frontend/src/lib/envChecklist.ts
T
Anso 57a0856ffc 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.
2026-06-20 11:58:42 -04:00

113 lines
3.5 KiB
TypeScript

/**
* 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');
}