Files
sencho/backend/src/__tests__/env-deploy-guard.test.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

102 lines
5.1 KiB
TypeScript

/**
* The opt-in deploy guard: blocks a deploy/update when required env vars are
* missing, only when the setting is on. Compose's own stderr is the authoritative
* signal (so an empty `REQ=` with `${REQ:?err}` is caught, which a key-only check
* could not), and the guard runs before any backup/cleanup/pull/up side effect.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import { ComposeService } from '../services/ComposeService';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let nodeId: number;
// The stderr `docker compose config` emits for an unset OR empty `${REQ:?err}`.
const REQUIRED_MISSING_STDERR = 'required variable REQ is missing a value: must be provided';
function setBlocking(on: boolean): void {
vi.spyOn(DatabaseService.getInstance(), 'getGlobalSettings')
.mockReturnValue({ env_block_deploy_on_missing_required: on ? '1' : '0' } as Record<string, string>);
}
function stubStderr(stderr: string, rendered: string | null = null) {
const compose = ComposeService.getInstance(nodeId);
const spy = vi.spyOn(compose, 'renderConfig').mockResolvedValue({ rendered, stderr, code: rendered === null ? 1 : 0, timedOut: false });
return { compose, spy };
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
nodeId = (DatabaseService.getInstance().getDb().prepare('SELECT id FROM nodes WHERE is_default = 1').get() as { id: number }).id;
});
afterAll(() => cleanupTestDb(tmpDir));
afterEach(() => vi.restoreAllMocks());
describe('assertRequiredEnvPresent', () => {
it('does not render when the setting is off', async () => {
setBlocking(false);
const { compose, spy } = stubStderr(REQUIRED_MISSING_STDERR);
await (compose as unknown as { assertRequiredEnvPresent(s: string): Promise<void> }).assertRequiredEnvPresent('s');
expect(spy).not.toHaveBeenCalled();
});
it('blocks when a required variable is unset or empty', async () => {
setBlocking(true);
const { compose } = stubStderr(REQUIRED_MISSING_STDERR);
await expect((compose as unknown as { assertRequiredEnvPresent(s: string): Promise<void> }).assertRequiredEnvPresent('s'))
.rejects.toThrow(/REQ/);
});
it('names every missing variable with plural grammar', async () => {
setBlocking(true);
const { compose } = stubStderr('required variable A is missing a value\nrequired variable B is missing a value');
await expect((compose as unknown as { assertRequiredEnvPresent(s: string): Promise<void> }).assertRequiredEnvPresent('s'))
.rejects.toThrow(/variables A, B are missing/);
});
it('allows when all required variables are present', async () => {
setBlocking(true);
const { compose } = stubStderr('', '{"services":{}}');
await expect((compose as unknown as { assertRequiredEnvPresent(s: string): Promise<void> }).assertRequiredEnvPresent('s'))
.resolves.toBeUndefined();
});
it('does not block on a render failure unrelated to required vars', async () => {
setBlocking(true);
const { compose } = stubStderr('yaml: line 2: mapping values are not allowed');
await expect((compose as unknown as { assertRequiredEnvPresent(s: string): Promise<void> }).assertRequiredEnvPresent('s'))
.resolves.toBeUndefined();
});
it('falls through without blocking when the settings read fails', async () => {
vi.spyOn(DatabaseService.getInstance(), 'getGlobalSettings').mockImplementation(() => { throw new Error('db down'); });
const { compose, spy } = stubStderr(REQUIRED_MISSING_STDERR);
await expect((compose as unknown as { assertRequiredEnvPresent(s: string): Promise<void> }).assertRequiredEnvPresent('s'))
.resolves.toBeUndefined();
expect(spy).not.toHaveBeenCalled();
});
});
describe('deployStack/updateStack guard ordering', () => {
it('deployStack throws before taking an atomic backup when a required var is missing', async () => {
setBlocking(true);
const compose = ComposeService.getInstance(nodeId);
vi.spyOn(compose, 'renderConfig').mockResolvedValue({ rendered: null, stderr: REQUIRED_MISSING_STDERR, code: 1, timedOut: false });
const backup = vi.spyOn(compose as unknown as { createAtomicBackup(...a: unknown[]): Promise<void> }, 'createAtomicBackup').mockResolvedValue(undefined);
await expect(compose.deployStack('s', undefined, true)).rejects.toThrow(/REQ/);
expect(backup).not.toHaveBeenCalled();
});
it('updateStack throws before taking an atomic backup when a required var is missing', async () => {
setBlocking(true);
const compose = ComposeService.getInstance(nodeId);
vi.spyOn(compose, 'renderConfig').mockResolvedValue({ rendered: null, stderr: REQUIRED_MISSING_STDERR, code: 1, timedOut: false });
const backup = vi.spyOn(compose as unknown as { createAtomicBackup(...a: unknown[]): Promise<void> }, 'createAtomicBackup').mockResolvedValue(undefined);
await expect(compose.updateStack('s', undefined, true)).rejects.toThrow(/REQ/);
expect(backup).not.toHaveBeenCalled();
});
});