mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 09:24:09 +00:00
57a0856ffc
* 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.
229 lines
9.0 KiB
TypeScript
229 lines
9.0 KiB
TypeScript
/**
|
|
* Per-stack environment inventory: which env vars a stack references, where they
|
|
* come from, whether Compose interpolates them or injects them into a container,
|
|
* and whether each is likely a secret. Names, sources, and status ONLY: an env
|
|
* value is never read, returned, retained, or logged here.
|
|
*
|
|
* Compose env semantics this encodes:
|
|
* - `${VAR}` interpolation resolves from the project `.env` + the shell only.
|
|
* - `env_file:` and inline `environment:` inject values into a container.
|
|
* - `${VAR:?err}` fails when unset OR empty, so the unset/missing signal comes
|
|
* from Compose's own stderr (authoritative), not a key-only guess.
|
|
*
|
|
* Injected keys come from the merge-correct effective model; interpolation refs
|
|
* and inline-vs-env-file provenance come from the authored source. `process.env`
|
|
* is consulted ONLY to resolve interpolation refs the stack already references, so
|
|
* unrelated host/system env names never appear as inventory rows.
|
|
*/
|
|
|
|
import { ComposeService } from './ComposeService';
|
|
import { parseEffectiveModel } from './preflight/effectiveModel';
|
|
import { resolveStackEnvSources, type EnvFileExistence } from '../helpers/envFileResolution';
|
|
import { parseUnsetEnvVars, parseMissingRequiredVars, readEnvFileKeys } from '../helpers/envVarParse';
|
|
import { isLikelySecretKey } from '../helpers/secretClassification';
|
|
|
|
export type EnvSource = 'compose-inline' | 'env-file' | 'dotenv' | 'process-env' | 'compose-ref';
|
|
export type EnvItemStatus = 'present' | 'missing' | 'unused' | 'duplicate' | 'unpersisted';
|
|
|
|
export interface EnvInventoryItem {
|
|
key: string;
|
|
sources: EnvSource[];
|
|
/** Consumed by Compose `${}` interpolation. */
|
|
usedForInterpolation: boolean;
|
|
/** Injected into a container (effective model is authoritative). */
|
|
injectedIntoService: boolean;
|
|
required: boolean;
|
|
hasDefault: boolean;
|
|
likelySecret: boolean;
|
|
status: EnvItemStatus;
|
|
}
|
|
|
|
export interface EnvFileInfo {
|
|
/** Raw paths as written in compose (or '.env' for the project source). No absolute path. */
|
|
rawPaths: string[];
|
|
existence: EnvFileExistence;
|
|
required: boolean;
|
|
isInterpolationSource: boolean;
|
|
isInjectionSource: boolean;
|
|
declaringServices: string[];
|
|
}
|
|
|
|
export interface EnvInventory {
|
|
stackName: string;
|
|
/** False when the effective model could not be rendered; the inventory is then partial. */
|
|
renderable: boolean;
|
|
items: EnvInventoryItem[];
|
|
envFiles: EnvFileInfo[];
|
|
summary: {
|
|
total: number;
|
|
missing: number;
|
|
unused: number;
|
|
duplicate: number;
|
|
unpersisted: number;
|
|
likelySecret: number;
|
|
};
|
|
}
|
|
|
|
/** Build the env inventory for a stack on a node. Key names only; never any value. */
|
|
export async function buildEnvInventory(nodeId: number, stackName: string): Promise<EnvInventory> {
|
|
const sources = await resolveStackEnvSources(nodeId, stackName);
|
|
const refByName = new Map(sources.interpolationRefs.map(r => [r.name, r]));
|
|
|
|
// Render the effective model: the authoritative injected-key set and the
|
|
// unset/missing-required signal. Failure path materializes no values.
|
|
const result = await ComposeService.getInstance(nodeId).renderConfig(stackName);
|
|
const missingRequired = new Set(parseMissingRequiredVars(result.stderr));
|
|
let renderable = false;
|
|
let unsetVars = new Set<string>();
|
|
const effectiveKeys = new Set<string>();
|
|
const effectiveKeysByService = new Map<string, Set<string>>();
|
|
if (result.rendered !== null) {
|
|
unsetVars = new Set(parseUnsetEnvVars(result.stderr));
|
|
try {
|
|
const model = parseEffectiveModel(JSON.parse(result.rendered), stackName);
|
|
for (const svc of model.services) {
|
|
const svcKeys = new Set(svc.envKeys);
|
|
effectiveKeysByService.set(svc.name, svcKeys);
|
|
for (const k of svc.envKeys) effectiveKeys.add(k);
|
|
}
|
|
renderable = true;
|
|
} catch {
|
|
renderable = false;
|
|
}
|
|
}
|
|
|
|
const names = new Set<string>();
|
|
const locations = new Map<string, Set<string>>(); // distinct physical definition locations
|
|
const itemSources = new Map<string, Set<EnvSource>>();
|
|
const dotenvKeys = new Set<string>();
|
|
const injectionFileKeys = new Set<string>();
|
|
|
|
const addLocation = (key: string, location: string) => {
|
|
const set = locations.get(key) ?? new Set<string>();
|
|
set.add(location);
|
|
locations.set(key, set);
|
|
};
|
|
const addSource = (key: string, source: EnvSource) => {
|
|
const set = itemSources.get(key) ?? new Set<EnvSource>();
|
|
set.add(source);
|
|
itemSources.set(key, set);
|
|
};
|
|
|
|
for (const ref of sources.interpolationRefs) names.add(ref.name);
|
|
|
|
// Env-file provenance: each physical file contributes ONE source label and ONE
|
|
// location, so the project `.env` doubling as `env_file: .env` is not a duplicate.
|
|
for (const file of sources.envFiles) {
|
|
if (!file.resolvedPath || file.existence !== 'present') continue;
|
|
const { keys, unverifiable } = await readEnvFileKeys(file.resolvedPath, sources.baseDir);
|
|
if (unverifiable) {
|
|
// The existence probe said present, but the key read failed (a permission
|
|
// change, a race, or transient I/O). Surface that rather than silently
|
|
// reporting zero keys for a file the inventory claims is present.
|
|
file.existence = 'unverifiable';
|
|
continue;
|
|
}
|
|
const label: EnvSource = file.isInterpolationSource ? 'dotenv' : 'env-file';
|
|
for (const key of keys) {
|
|
names.add(key);
|
|
addLocation(key, file.resolvedPath);
|
|
addSource(key, label);
|
|
if (file.isInterpolationSource) dotenvKeys.add(key);
|
|
if (file.isInjectionSource) injectionFileKeys.add(key);
|
|
}
|
|
}
|
|
|
|
// Inline `environment:` keys, reconciled against the effective model PER SERVICE
|
|
// so a key an override removed from one service is not reported as inline just
|
|
// because another service defines the same name elsewhere.
|
|
const inlineAll = new Set<string>();
|
|
for (const keys of Object.values(sources.inlineEnvKeysByService)) for (const k of keys) inlineAll.add(k);
|
|
for (const [service, keys] of Object.entries(sources.inlineEnvKeysByService)) {
|
|
const svcEffective = effectiveKeysByService.get(service);
|
|
for (const key of keys) {
|
|
if (renderable && !svcEffective?.has(key)) continue;
|
|
names.add(key);
|
|
addLocation(key, 'inline');
|
|
addSource(key, 'compose-inline');
|
|
}
|
|
}
|
|
|
|
for (const key of effectiveKeys) names.add(key);
|
|
|
|
const injectedKeys = renderable
|
|
? effectiveKeys
|
|
: new Set<string>([...inlineAll, ...injectionFileKeys]);
|
|
|
|
const shellHas = (name: string): boolean => Object.prototype.hasOwnProperty.call(process.env, name);
|
|
|
|
const items: EnvInventoryItem[] = [];
|
|
for (const key of [...names].sort()) {
|
|
const ref = refByName.get(key);
|
|
const usedForInterpolation = !!ref;
|
|
const required = ref?.required ?? false;
|
|
const hasDefault = ref?.hasDefault ?? false;
|
|
const alternate = ref?.alternate ?? false;
|
|
const injected = injectedKeys.has(key);
|
|
const locationCount = locations.get(key)?.size ?? 0;
|
|
const sourceSet = new Set<EnvSource>(itemSources.get(key) ?? []);
|
|
|
|
// A referenced var defined in no stack-local source resolves from the shell or
|
|
// is missing. Surface that provenance without adding unreferenced shell keys.
|
|
if (usedForInterpolation && locationCount === 0 && !injected) {
|
|
if (shellHas(key)) sourceSet.add('process-env');
|
|
else sourceSet.add('compose-ref');
|
|
}
|
|
|
|
// Compose's own resolution is authoritative for unset/empty required vars; the
|
|
// heuristic only fills in when the model could not be rendered for other reasons.
|
|
const refUndefinedUnshelled = usedForInterpolation && !hasDefault && !alternate && locationCount === 0 && !shellHas(key);
|
|
const missing = missingRequired.has(key)
|
|
|| (renderable && unsetVars.has(key))
|
|
|| (!renderable && refUndefinedUnshelled);
|
|
const unpersisted = usedForInterpolation && locationCount === 0 && !missing && shellHas(key);
|
|
const unused = dotenvKeys.has(key) && !usedForInterpolation && !injected;
|
|
|
|
let status: EnvItemStatus;
|
|
if (missing) status = 'missing';
|
|
else if (locationCount >= 2) status = 'duplicate';
|
|
else if (unpersisted) status = 'unpersisted';
|
|
else if (unused) status = 'unused';
|
|
else status = 'present';
|
|
|
|
items.push({
|
|
key,
|
|
sources: [...sourceSet],
|
|
usedForInterpolation,
|
|
injectedIntoService: injected,
|
|
required,
|
|
hasDefault,
|
|
likelySecret: isLikelySecretKey(key),
|
|
status,
|
|
});
|
|
}
|
|
|
|
const envFiles: EnvFileInfo[] = sources.envFiles.map(f => ({
|
|
rawPaths: f.rawPaths,
|
|
existence: f.existence,
|
|
required: f.required,
|
|
isInterpolationSource: f.isInterpolationSource,
|
|
isInjectionSource: f.isInjectionSource,
|
|
declaringServices: f.declaringServices,
|
|
}));
|
|
|
|
return {
|
|
stackName,
|
|
renderable,
|
|
items,
|
|
envFiles,
|
|
summary: {
|
|
total: items.length,
|
|
missing: items.filter(i => i.status === 'missing').length,
|
|
unused: items.filter(i => i.status === 'unused').length,
|
|
duplicate: items.filter(i => i.status === 'duplicate').length,
|
|
unpersisted: items.filter(i => i.status === 'unpersisted').length,
|
|
likelySecret: items.filter(i => i.likelySecret).length,
|
|
},
|
|
};
|
|
}
|