mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +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:
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ export const CAPABILITIES = [
|
||||
'compose-doctor',
|
||||
'update-guard',
|
||||
'compose-networking',
|
||||
'env-inventory',
|
||||
] as const;
|
||||
|
||||
export type Capability = (typeof CAPABILITIES)[number];
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user