mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +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:
@@ -30,7 +30,7 @@ function SectionSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
type HostAlertFields = Pick<PatchableSettings, 'host_cpu_limit' | 'host_ram_limit' | 'host_disk_limit' | 'host_alert_suppression_mins' | 'global_crash' | 'health_gate_enabled' | 'health_gate_window_seconds'>;
|
||||
type HostAlertFields = Pick<PatchableSettings, 'host_cpu_limit' | 'host_ram_limit' | 'host_disk_limit' | 'host_alert_suppression_mins' | 'global_crash' | 'health_gate_enabled' | 'health_gate_window_seconds' | 'env_block_deploy_on_missing_required'>;
|
||||
|
||||
const DEFAULT_HOST_ALERTS: HostAlertFields = {
|
||||
host_cpu_limit: DEFAULT_SETTINGS.host_cpu_limit,
|
||||
@@ -40,6 +40,7 @@ const DEFAULT_HOST_ALERTS: HostAlertFields = {
|
||||
global_crash: DEFAULT_SETTINGS.global_crash,
|
||||
health_gate_enabled: DEFAULT_SETTINGS.health_gate_enabled,
|
||||
health_gate_window_seconds: DEFAULT_SETTINGS.health_gate_window_seconds,
|
||||
env_block_deploy_on_missing_required: DEFAULT_SETTINGS.env_block_deploy_on_missing_required,
|
||||
};
|
||||
|
||||
export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
|
||||
@@ -80,6 +81,7 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
|
||||
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
|
||||
health_gate_enabled: (nodeData.health_gate_enabled as '0' | '1') ?? DEFAULT_SETTINGS.health_gate_enabled,
|
||||
health_gate_window_seconds: nodeData.health_gate_window_seconds ?? DEFAULT_SETTINGS.health_gate_window_seconds,
|
||||
env_block_deploy_on_missing_required: (nodeData.env_block_deploy_on_missing_required as '0' | '1') ?? DEFAULT_SETTINGS.env_block_deploy_on_missing_required,
|
||||
};
|
||||
reset(safe);
|
||||
} catch (e) {
|
||||
@@ -212,6 +214,18 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Deploy guardrails">
|
||||
<SettingsField
|
||||
label="Block deploy on missing required env vars"
|
||||
helper="When on, a deploy or update is refused before it starts if a required ${VAR:?message} variable is unset or empty, so the stack fails fast with a clear message instead of mid-deploy. Off by default."
|
||||
>
|
||||
<TogglePill
|
||||
checked={settings.env_block_deploy_on_missing_required === '1'}
|
||||
onChange={(next) => onSettingChange('env_block_deploy_on_missing_required', next ? '1' : '0')}
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
|
||||
{!readOnly && (
|
||||
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
|
||||
|
||||
@@ -67,6 +67,7 @@ describe('split section save payloads', () => {
|
||||
fireEvent.click(save);
|
||||
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
|
||||
expect(patchedKeys()).toEqual([
|
||||
'env_block_deploy_on_missing_required',
|
||||
'global_crash',
|
||||
'health_gate_enabled',
|
||||
'health_gate_window_seconds',
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface PatchableSettings {
|
||||
snapshot_documentation?: '0' | '1';
|
||||
health_gate_enabled?: '0' | '1';
|
||||
health_gate_window_seconds?: string;
|
||||
env_block_deploy_on_missing_required?: '0' | '1';
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: PatchableSettings = {
|
||||
@@ -38,6 +39,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
|
||||
snapshot_documentation: '0',
|
||||
health_gate_enabled: '1',
|
||||
health_gate_window_seconds: '90',
|
||||
env_block_deploy_on_missing_required: '0',
|
||||
};
|
||||
|
||||
export type SectionId =
|
||||
|
||||
Reference in New Issue
Block a user