mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +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:
@@ -17,8 +17,6 @@ const SECRET = 'pw-7Q2x-never-store';
|
||||
let tmpDir: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let ComposeDoctorService: typeof import('../services/ComposeDoctorService').ComposeDoctorService;
|
||||
let parseUnsetEnvVars: typeof import('../services/ComposeDoctorService').parseUnsetEnvVars;
|
||||
let parseMissingRequiredVars: typeof import('../services/ComposeDoctorService').parseMissingRequiredVars;
|
||||
let nodeId: number;
|
||||
|
||||
function db() { return DatabaseService.getInstance(); }
|
||||
@@ -50,36 +48,15 @@ beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
await import('../index');
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ ComposeDoctorService, parseUnsetEnvVars, parseMissingRequiredVars } = await import('../services/ComposeDoctorService'));
|
||||
({ ComposeDoctorService } = await import('../services/ComposeDoctorService'));
|
||||
nodeId = (db().getDb().prepare('SELECT id FROM nodes WHERE is_default = 1').get() as { id: number }).id;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('parseUnsetEnvVars', () => {
|
||||
it('extracts variable names from Compose stderr (real escaped, quoted, and bare forms)', () => {
|
||||
// The escaped form is exactly what `docker compose config` emits in logfmt.
|
||||
const stderr =
|
||||
'time="2026-06-10T00:36:15-04:00" level=warning msg="The \\"DB_HOST\\" variable is not set. Defaulting to a blank string."\n'
|
||||
+ 'The "TOKEN" variable is not set.\n'
|
||||
+ 'The PLAIN variable is not set.';
|
||||
expect(parseUnsetEnvVars(stderr).sort()).toEqual(['DB_HOST', 'PLAIN', 'TOKEN']);
|
||||
});
|
||||
it('returns nothing for clean stderr', () => {
|
||||
expect(parseUnsetEnvVars('')).toEqual([]);
|
||||
});
|
||||
it('ignores lines that do not match the unset-variable phrase', () => {
|
||||
expect(parseUnsetEnvVars('the DB connection variable is configured\nNODE_ENV is not set elsewhere')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMissingRequiredVars', () => {
|
||||
it('extracts the name from the real required-variable error (unquoted)', () => {
|
||||
const stderr = 'error while interpolating services.web.environment.TOKEN: required variable REQ_TOKEN is missing a value: must be provided';
|
||||
expect(parseMissingRequiredVars(stderr)).toEqual(['REQ_TOKEN']);
|
||||
});
|
||||
});
|
||||
// parseUnsetEnvVars / parseMissingRequiredVars now live in helpers/envVarParse and
|
||||
// are covered by env-var-parse.test.ts.
|
||||
|
||||
describe('ComposeService.renderConfig path guard', () => {
|
||||
it('rejects an invalid stack name without spawning docker', async () => {
|
||||
@@ -114,6 +91,26 @@ describe('runPreflight', () => {
|
||||
expect(latest.ranBy).toBe('tester');
|
||||
});
|
||||
|
||||
it('surfaces a missing required env_file as a finding and ignores an optional one', async () => {
|
||||
const stack = 'envfilemissing';
|
||||
const dir = path.join(process.env.COMPOSE_DIR as string, stack);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'compose.yaml'),
|
||||
'services:\n web:\n image: nginx:1.27\n env_file:\n - ./gone.env\n - path: ./optional.env\n required: false\n',
|
||||
);
|
||||
try {
|
||||
stubDocker({ name: stack, services: { web: { image: 'nginx:1.27' } }, networks: {}, volumes: {} }, '');
|
||||
const report = await doctor().runPreflight(nodeId, stack, 'tester');
|
||||
const envFile = report.findings.filter(f => f.ruleId === 'env-file-missing');
|
||||
expect(envFile).toHaveLength(1);
|
||||
expect(envFile[0].sourcePath).toBe('./gone.env');
|
||||
expect(envFile[0].severity).toBe('high');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('never stores an environment value', async () => {
|
||||
stubDocker({ name: STACK, services: { web: { image: 'nginx:1.27', environment: { APP_SECRET: SECRET } } }, networks: {}, volumes: {} });
|
||||
const report = await doctor().runPreflight(nodeId, STACK, null);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* resolveStackEnvSources: env_file existence metadata, the project .env
|
||||
* interpolation source, inline environment keys, interpolation refs, and the
|
||||
* multi-file Git deploy-spec path. Real filesystem + DB; no Docker.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import { resolveStackEnvSources } from '../helpers/envFileResolution';
|
||||
|
||||
let tmpDir: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let nodeId: number;
|
||||
|
||||
function composeDir(): string { return process.env.COMPOSE_DIR as string; }
|
||||
|
||||
function writeStack(stack: string, files: Record<string, string>): void {
|
||||
const dir = path.join(composeDir(), stack);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
const target = path.join(dir, name);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, content);
|
||||
}
|
||||
}
|
||||
|
||||
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('resolveStackEnvSources', () => {
|
||||
it('models the project .env as the interpolation source and reads inline keys + refs', async () => {
|
||||
writeStack('s1', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx:${TAG:-latest}\n environment:\n APP_PORT: "8080"\n FROM_SHELL: ${FROM_SHELL}\n',
|
||||
'.env': 'TAG=1.0\n',
|
||||
});
|
||||
const r = await resolveStackEnvSources(nodeId, 's1');
|
||||
const dotenv = r.envFiles.find(f => f.isInterpolationSource);
|
||||
expect(dotenv?.existence).toBe('present');
|
||||
expect(dotenv?.isInjectionSource).toBe(false);
|
||||
expect(r.inlineEnvKeysByService.web).toContain('APP_PORT');
|
||||
expect(r.interpolationRefs.map(x => x.name).sort()).toEqual(['FROM_SHELL', 'TAG']);
|
||||
});
|
||||
|
||||
it('flags a missing required env_file but not an optional one', async () => {
|
||||
writeStack('s2', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n env_file:\n - ./present.env\n - ./gone.env\n - path: ./optional.env\n required: false\n',
|
||||
'present.env': 'A=1\n',
|
||||
});
|
||||
const r = await resolveStackEnvSources(nodeId, 's2');
|
||||
const byRaw = (raw: string) => r.envFiles.find(f => f.rawPaths.includes(raw));
|
||||
expect(byRaw('./present.env')?.existence).toBe('present');
|
||||
expect(byRaw('./gone.env')).toMatchObject({ existence: 'missing', required: true, isInjectionSource: true });
|
||||
expect(byRaw('./optional.env')).toMatchObject({ existence: 'missing', required: false });
|
||||
});
|
||||
|
||||
it('marks interpolated and escaping env_file paths as unverifiable', async () => {
|
||||
writeStack('s3', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n env_file:\n - ${ENV_DIR}/x.env\n - ../escape.env\n',
|
||||
});
|
||||
const r = await resolveStackEnvSources(nodeId, 's3');
|
||||
for (const f of r.envFiles.filter(x => x.isInjectionSource)) {
|
||||
expect(f.existence).toBe('unverifiable');
|
||||
expect(f.resolvedPath).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats .env doubling as env_file: .env as one physical file with both roles', async () => {
|
||||
writeStack('s4', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n env_file:\n - .env\n',
|
||||
'.env': 'SHARED=1\n',
|
||||
});
|
||||
const r = await resolveStackEnvSources(nodeId, 's4');
|
||||
const dotenvFiles = r.envFiles.filter(f => f.isInterpolationSource);
|
||||
expect(dotenvFiles).toHaveLength(1);
|
||||
expect(dotenvFiles[0]).toMatchObject({ isInterpolationSource: true, isInjectionSource: true, existence: 'present' });
|
||||
});
|
||||
|
||||
it('reads env_file declared only in a Git multi-file override', async () => {
|
||||
writeStack('s5', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n',
|
||||
'override.yaml': 'services:\n web:\n env_file:\n - ./override.env\n',
|
||||
'override.env': 'O=1\n',
|
||||
});
|
||||
vi.spyOn(DatabaseService.getInstance(), 'getGitSource').mockReturnValue({
|
||||
applied_deploy_spec: { files: ['compose.yaml', 'override.yaml'], contextDir: null },
|
||||
} as unknown as ReturnType<typeof DatabaseService.prototype.getGitSource>);
|
||||
const r = await resolveStackEnvSources(nodeId, 's5');
|
||||
expect(r.composeFiles).toHaveLength(2);
|
||||
expect(r.envFiles.some(f => f.rawPaths.includes('./override.env') && f.existence === 'present')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves a nested override env_file relative to its compose file, not the stack root', async () => {
|
||||
writeStack('s6', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n',
|
||||
'infra/prod.yml': 'services:\n web:\n env_file:\n - ./prod.env\n',
|
||||
'infra/prod.env': 'NESTED=1\n',
|
||||
});
|
||||
vi.spyOn(DatabaseService.getInstance(), 'getGitSource').mockReturnValue({
|
||||
applied_deploy_spec: { files: ['compose.yaml', 'infra/prod.yml'], contextDir: null },
|
||||
} as unknown as ReturnType<typeof DatabaseService.prototype.getGitSource>);
|
||||
const r = await resolveStackEnvSources(nodeId, 's6');
|
||||
const f = r.envFiles.find(x => x.rawPaths.includes('./prod.env'));
|
||||
expect(f).toMatchObject({ existence: 'present', isInjectionSource: true });
|
||||
expect(f?.resolvedPath?.endsWith(path.join('infra', 'prod.env'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* buildEnvInventory status derivation, the hard no-value guarantee, and the
|
||||
* GET /api/stacks/:stackName/env-inventory route. The effective model render is
|
||||
* mocked; the filesystem and DB are real.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import request from 'supertest';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
import { ComposeService } from '../services/ComposeService';
|
||||
import { buildEnvInventory, type EnvInventory } from '../services/EnvInventoryService';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authCookie: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let nodeId: number;
|
||||
|
||||
function composeDir(): string { return process.env.COMPOSE_DIR as string; }
|
||||
|
||||
function writeStack(stack: string, files: Record<string, string>): void {
|
||||
const dir = path.join(composeDir(), stack);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), content);
|
||||
}
|
||||
|
||||
/** Mock the effective model render with the given injected env keys per service. */
|
||||
function stubRender(serviceEnv: Record<string, Record<string, string>> | null, stderr = ''): void {
|
||||
const rendered = serviceEnv === null
|
||||
? null
|
||||
: JSON.stringify({ name: 'proj', services: Object.fromEntries(Object.entries(serviceEnv).map(([s, env]) => [s, { environment: env }])) });
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue({ rendered, stderr, code: rendered === null ? 1 : 0, timedOut: false }),
|
||||
} as unknown as ComposeService);
|
||||
}
|
||||
|
||||
const itemFor = (inv: EnvInventory, key: string) => inv.items.find(i => i.key === key);
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
authCookie = await loginAsTestAdmin(app);
|
||||
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('buildEnvInventory status derivation', () => {
|
||||
it('classifies present, unused, interpolation, and injection sources', async () => {
|
||||
writeStack('inv1', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx:${USED:-x}\n environment:\n INLINE_KEY: "1"\n env_file:\n - ./svc.env\n',
|
||||
'.env': 'USED=1\nUNUSED_VAR=2\n',
|
||||
'svc.env': 'FILE_KEY=3\n',
|
||||
});
|
||||
stubRender({ web: { INLINE_KEY: '1', FILE_KEY: '3' } });
|
||||
const inv = await buildEnvInventory(nodeId, 'inv1');
|
||||
expect(inv.renderable).toBe(true);
|
||||
expect(itemFor(inv, 'USED')).toMatchObject({ status: 'present', usedForInterpolation: true, sources: expect.arrayContaining(['dotenv']) });
|
||||
expect(itemFor(inv, 'UNUSED_VAR')).toMatchObject({ status: 'unused' });
|
||||
expect(itemFor(inv, 'INLINE_KEY')).toMatchObject({ injectedIntoService: true, sources: expect.arrayContaining(['compose-inline']) });
|
||||
const fileKey = itemFor(inv, 'FILE_KEY');
|
||||
expect(fileKey?.sources).toContain('env-file');
|
||||
expect(fileKey?.sources).not.toContain('compose-inline');
|
||||
});
|
||||
|
||||
it('marks a referenced-but-unset variable as missing', async () => {
|
||||
writeStack('inv2', { 'compose.yaml': 'services:\n web:\n image: nginx:${MISSING}\n' });
|
||||
stubRender({ web: {} }, 'The "MISSING" variable is not set. Defaulting to a blank string.');
|
||||
const inv = await buildEnvInventory(nodeId, 'inv2');
|
||||
expect(itemFor(inv, 'MISSING')).toMatchObject({ status: 'missing', usedForInterpolation: true });
|
||||
});
|
||||
|
||||
it('picks up inline array and bare key forms', async () => {
|
||||
writeStack('inv3', { 'compose.yaml': 'services:\n web:\n image: nginx\n environment:\n - ARR_KEY=v\n - BARE_KEY\n' });
|
||||
stubRender({ web: { ARR_KEY: 'v', BARE_KEY: '' } });
|
||||
const inv = await buildEnvInventory(nodeId, 'inv3');
|
||||
expect(itemFor(inv, 'ARR_KEY')?.sources).toContain('compose-inline');
|
||||
expect(itemFor(inv, 'BARE_KEY')?.sources).toContain('compose-inline');
|
||||
});
|
||||
|
||||
it('drops an inline key that an override removed from the effective model', async () => {
|
||||
writeStack('inv4', { 'compose.yaml': 'services:\n web:\n image: nginx\n environment:\n KEPT: "1"\n GONE: "1"\n' });
|
||||
stubRender({ web: { KEPT: '1' } }); // GONE not in effective model
|
||||
const inv = await buildEnvInventory(nodeId, 'inv4');
|
||||
expect(itemFor(inv, 'KEPT')).toBeTruthy();
|
||||
expect(itemFor(inv, 'GONE')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not flag .env doubling as env_file: .env as a duplicate', async () => {
|
||||
writeStack('inv5', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx:${SHARED}\n env_file:\n - .env\n',
|
||||
'.env': 'SHARED=1\n',
|
||||
});
|
||||
stubRender({ web: { SHARED: '1' } });
|
||||
const inv = await buildEnvInventory(nodeId, 'inv5');
|
||||
const shared = itemFor(inv, 'SHARED');
|
||||
expect(shared?.status).not.toBe('duplicate');
|
||||
expect(shared).toMatchObject({ usedForInterpolation: true, injectedIntoService: true });
|
||||
});
|
||||
|
||||
it('reconciles inline provenance per service: an override-dropped inline key in one service is not inline because another service injects the same name', async () => {
|
||||
writeStack('invx', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n environment:\n FOO: "1"\n db:\n image: postgres\n env_file:\n - ./db.env\n',
|
||||
'db.env': 'FOO=2\n',
|
||||
});
|
||||
// web's effective env dropped FOO (e.g. an override); db injects FOO via env_file.
|
||||
stubRender({ web: {}, db: { FOO: '2' } });
|
||||
const inv = await buildEnvInventory(nodeId, 'invx');
|
||||
const foo = itemFor(inv, 'FOO');
|
||||
expect(foo?.sources).toContain('env-file');
|
||||
expect(foo?.sources).not.toContain('compose-inline');
|
||||
expect(foo?.status).not.toBe('duplicate');
|
||||
expect(foo?.injectedIntoService).toBe(true);
|
||||
});
|
||||
|
||||
it('reports duplicate when a key is defined inline and in a separate file', async () => {
|
||||
writeStack('inv6', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n environment:\n DUP: "1"\n env_file:\n - ./other.env\n',
|
||||
'other.env': 'DUP=2\n',
|
||||
});
|
||||
stubRender({ web: { DUP: '1' } });
|
||||
const inv = await buildEnvInventory(nodeId, 'inv6');
|
||||
expect(itemFor(inv, 'DUP')?.status).toBe('duplicate');
|
||||
});
|
||||
|
||||
it('degrades to renderable:false but still lists authored refs when render fails', async () => {
|
||||
writeStack('inv7', { 'compose.yaml': 'services:\n web:\n image: nginx:${REF}\n' });
|
||||
stubRender(null, 'yaml: line 2: mapping values are not allowed');
|
||||
const inv = await buildEnvInventory(nodeId, 'inv7');
|
||||
expect(inv.renderable).toBe(false);
|
||||
expect(itemFor(inv, 'REF')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('still marks inline and env_file keys as injected when the model cannot render', async () => {
|
||||
writeStack('inv8', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n environment:\n INLINE_X: "1"\n env_file:\n - ./svc.env\n',
|
||||
'svc.env': 'FILE_X=1\n',
|
||||
});
|
||||
stubRender(null, 'yaml: line 5: bad mapping');
|
||||
const inv = await buildEnvInventory(nodeId, 'inv8');
|
||||
expect(inv.renderable).toBe(false);
|
||||
expect(itemFor(inv, 'INLINE_X')).toMatchObject({ injectedIntoService: true, sources: expect.arrayContaining(['compose-inline']) });
|
||||
expect(itemFor(inv, 'FILE_X')).toMatchObject({ injectedIntoService: true, sources: expect.arrayContaining(['env-file']) });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEnvInventory secret safety', () => {
|
||||
it('never includes an inline environment value, only the key', async () => {
|
||||
const value = 'actual-secret-value-zzz';
|
||||
writeStack('sec1', { 'compose.yaml': `services:\n web:\n image: nginx\n environment:\n SECRET: ${value}\n` });
|
||||
stubRender({ web: { SECRET: value } });
|
||||
const inv = await buildEnvInventory(nodeId, 'sec1');
|
||||
expect(itemFor(inv, 'SECRET')).toMatchObject({ likelySecret: true });
|
||||
expect(JSON.stringify(inv)).not.toContain(value);
|
||||
});
|
||||
|
||||
it('never emits an inventory row for an unreferenced process.env key', async () => {
|
||||
process.env.UNRELATED_HOST_SECRET_XYZ = 'leak-me';
|
||||
try {
|
||||
writeStack('sec2', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
|
||||
stubRender({ web: {} });
|
||||
const inv = await buildEnvInventory(nodeId, 'sec2');
|
||||
expect(itemFor(inv, 'UNRELATED_HOST_SECRET_XYZ')).toBeUndefined();
|
||||
expect(JSON.stringify(inv)).not.toContain('UNRELATED_HOST_SECRET_XYZ');
|
||||
} finally {
|
||||
delete process.env.UNRELATED_HOST_SECRET_XYZ;
|
||||
}
|
||||
});
|
||||
|
||||
it('marks a shell-resolved, unpersisted referenced var as unpersisted', async () => {
|
||||
process.env.SHELL_ONLY_VAR_ABC = 'present-in-shell';
|
||||
try {
|
||||
writeStack('sec3', { 'compose.yaml': 'services:\n web:\n image: nginx:${SHELL_ONLY_VAR_ABC}\n' });
|
||||
stubRender({ web: {} }); // resolved (no unset warning) because shell has it
|
||||
const inv = await buildEnvInventory(nodeId, 'sec3');
|
||||
expect(itemFor(inv, 'SHELL_ONLY_VAR_ABC')).toMatchObject({ status: 'unpersisted', sources: expect.arrayContaining(['process-env']) });
|
||||
} finally {
|
||||
delete process.env.SHELL_ONLY_VAR_ABC;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/stacks/:stackName/env-inventory', () => {
|
||||
it('requires authentication', async () => {
|
||||
const res = await request(app).get('/api/stacks/inv1/env-inventory');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown stack', async () => {
|
||||
const res = await request(app).get('/api/stacks/does-not-exist/env-inventory').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns the inventory without leaking a value', async () => {
|
||||
const value = 'route-secret-value-qqq';
|
||||
writeStack('route1', { 'compose.yaml': `services:\n web:\n image: nginx\n environment:\n SECRET: ${value}\n` });
|
||||
stubRender({ web: { SECRET: value } });
|
||||
const res = await request(app).get('/api/stacks/route1/env-inventory').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.stackName).toBe('route1');
|
||||
// The key must appear (so the test fails if the row is dropped, not just if the
|
||||
// value happens to be absent), and the value must never appear.
|
||||
const secret = (res.body.items as { key: string; likelySecret: boolean }[]).find(i => i.key === 'SECRET');
|
||||
expect(secret).toMatchObject({ likelySecret: true });
|
||||
expect(JSON.stringify(res.body)).not.toContain(value);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
parseInterpolationRefs,
|
||||
extractEnvKeyFromLine,
|
||||
readEnvFileKeys,
|
||||
parseUnsetEnvVars,
|
||||
parseMissingRequiredVars,
|
||||
} from '../helpers/envVarParse';
|
||||
|
||||
describe('parseInterpolationRefs', () => {
|
||||
it('classifies all seven operator forms, including the no-colon variants', () => {
|
||||
const src = [
|
||||
'a: ${BARE}',
|
||||
'b: ${DEF:-fallback}',
|
||||
'c: ${DEF2-fallback}',
|
||||
'd: ${REQ:?must be set}',
|
||||
'e: ${REQ2?must be set}',
|
||||
'f: ${ALT:+present}',
|
||||
'g: ${ALT2+present}',
|
||||
].join('\n');
|
||||
const refs = new Map(parseInterpolationRefs(src).map(r => [r.name, r]));
|
||||
expect(refs.get('BARE')).toMatchObject({ required: false, hasDefault: false, alternate: false });
|
||||
expect(refs.get('DEF')).toMatchObject({ hasDefault: true, required: false });
|
||||
expect(refs.get('DEF2')).toMatchObject({ hasDefault: true, required: false });
|
||||
expect(refs.get('REQ')).toMatchObject({ required: true });
|
||||
expect(refs.get('REQ2')).toMatchObject({ required: true });
|
||||
expect(refs.get('ALT')).toMatchObject({ alternate: true, required: false, hasDefault: false });
|
||||
expect(refs.get('ALT2')).toMatchObject({ alternate: true });
|
||||
});
|
||||
|
||||
it('skips the $${ESCAPED} literal', () => {
|
||||
expect(parseInterpolationRefs('x: $${ESCAPED}').map(r => r.name)).not.toContain('ESCAPED');
|
||||
});
|
||||
|
||||
it('merges flags across repeated references of one name', () => {
|
||||
const refs = parseInterpolationRefs('${X} then ${X:?e}');
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0]).toMatchObject({ name: 'X', required: true });
|
||||
});
|
||||
|
||||
it('ORs default and alternate flags across occurrences too', () => {
|
||||
const refs = parseInterpolationRefs('${Y:-d} then ${Y:+a}');
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0]).toMatchObject({ name: 'Y', hasDefault: true, alternate: true, required: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractEnvKeyFromLine', () => {
|
||||
it('returns the key name only for each line form', () => {
|
||||
expect(extractEnvKeyFromLine('FOO=bar')).toBe('FOO');
|
||||
expect(extractEnvKeyFromLine('export BAZ=qux')).toBe('BAZ');
|
||||
expect(extractEnvKeyFromLine('BARE')).toBe('BARE');
|
||||
expect(extractEnvKeyFromLine('# comment')).toBeNull();
|
||||
expect(extractEnvKeyFromLine(' ')).toBeNull();
|
||||
expect(extractEnvKeyFromLine('1BAD=x')).toBeNull();
|
||||
});
|
||||
|
||||
it('never returns the value', () => {
|
||||
expect(extractEnvKeyFromLine('SECRET=supersecretvalue')).toBe('SECRET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readEnvFileKeys', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'envkeys-'));
|
||||
|
||||
it('reads key names only and never the value', async () => {
|
||||
const p = path.join(base, '.env');
|
||||
fs.writeFileSync(p, 'FOO=secretvalue\n# c\nexport BAR=2\nBARE\n');
|
||||
const res = await readEnvFileKeys(p, base);
|
||||
expect(res.keys.sort()).toEqual(['BAR', 'BARE', 'FOO']);
|
||||
expect(res.unverifiable).toBe(false);
|
||||
expect(JSON.stringify(res)).not.toContain('secretvalue');
|
||||
});
|
||||
|
||||
it('caps bytes/lines on a large fixture, flags truncated, and leaks no value', async () => {
|
||||
const p = path.join(base, 'big.env');
|
||||
let content = '';
|
||||
for (let i = 0; i < 20000; i++) content += `K${i}=verylongsecret${'x'.repeat(40)}\n`;
|
||||
fs.writeFileSync(p, content);
|
||||
const res = await readEnvFileKeys(p, base, { maxBytes: 4096, maxLines: 100, maxLineLen: 8192 });
|
||||
expect(res.truncated).toBe(true);
|
||||
expect(res.keys.length).toBeLessThanOrEqual(100);
|
||||
expect(JSON.stringify(res)).not.toContain('verylongsecret');
|
||||
});
|
||||
|
||||
it('marks a path escaping the base directory as unverifiable', async () => {
|
||||
// A secure temp dir that is a sibling of `base`, so the file is outside it.
|
||||
const otherBase = fs.mkdtempSync(path.join(os.tmpdir(), 'envkeys-out-'));
|
||||
const outside = path.join(otherBase, 'x.env');
|
||||
fs.writeFileSync(outside, 'X=1');
|
||||
const res = await readEnvFileKeys(outside, base);
|
||||
expect(res.unverifiable).toBe(true);
|
||||
expect(res.keys).toEqual([]);
|
||||
fs.rmSync(otherBase, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('marks a missing file as unverifiable', async () => {
|
||||
const res = await readEnvFileKeys(path.join(base, 'nope.env'), base);
|
||||
expect(res.unverifiable).toBe(true);
|
||||
expect(res.keys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseUnsetEnvVars / parseMissingRequiredVars', () => {
|
||||
it('extracts unset variable names (escaped, quoted, and bare forms)', () => {
|
||||
const stderr =
|
||||
'time="t" level=warning msg="The \\"DB_HOST\\" variable is not set. Defaulting to a blank string."\n'
|
||||
+ 'The "TOKEN" variable is not set.\n'
|
||||
+ 'The PLAIN variable is not set.';
|
||||
expect(parseUnsetEnvVars(stderr).sort()).toEqual(['DB_HOST', 'PLAIN', 'TOKEN']);
|
||||
});
|
||||
|
||||
it('extracts the name from a required-variable error', () => {
|
||||
const stderr = 'error while interpolating services.web.environment.TOKEN: required variable REQ_TOKEN is missing a value: must be provided';
|
||||
expect(parseMissingRequiredVars(stderr)).toEqual(['REQ_TOKEN']);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ function ctx(over: Partial<PreflightContext> = {}): PreflightContext {
|
||||
const m = over.model !== undefined ? over.model : model([]);
|
||||
return {
|
||||
stackName: 'proj', platform: 'linux', model: m, renderable: true, renderError: null, unsetEnvVars: [],
|
||||
missingEnvFiles: [],
|
||||
sourceServiceNames: m ? m.services.map(s => s.name) : [], sourceReadable: true,
|
||||
nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(),
|
||||
existingContainers: [], bindChecks: [],
|
||||
@@ -56,6 +57,19 @@ describe('env-unset', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('env-file-missing', () => {
|
||||
it('emits one high finding per missing required env file', () => {
|
||||
const f = ids(runRules(ctx({ missingEnvFiles: [{ rawPath: './db.env', services: ['db'] }] })), 'env-file-missing');
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].severity).toBe('high');
|
||||
expect(f[0].sourcePath).toBe('./db.env');
|
||||
expect(f[0].service).toBe('db');
|
||||
});
|
||||
it('stays silent when there are no missing env files (optional/unverifiable are pre-filtered)', () => {
|
||||
expect(ids(runRules(ctx({ missingEnvFiles: [] })), 'env-file-missing')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('port-conflict-node', () => {
|
||||
const withPort = (proto = 'tcp', hostIp = '') => model([svc({ ports: [{ startPort: 8080, endPort: 8080, hostIp, protocol: proto }] })]);
|
||||
|
||||
@@ -324,7 +338,7 @@ describe('rule registry completeness', () => {
|
||||
// The canonical rule set. Adding or removing a rule must update this list,
|
||||
// which forces a deliberate pass over the docs and the frontend severity map.
|
||||
const EXPECTED_RULE_IDS = [
|
||||
'render-failed', 'env-unset', 'port-conflict-node', 'port-conflict-internal', 'port-exposed-all-interfaces',
|
||||
'render-failed', 'env-unset', 'env-file-missing', 'port-conflict-node', 'port-conflict-internal', 'port-exposed-all-interfaces',
|
||||
'bind-path-missing', 'bind-path-permission', 'docker-socket-mount', 'privileged', 'network-mode-host',
|
||||
'uid-gid-risk', 'image-latest', 'no-restart-policy', 'no-healthcheck', 'deploy-swarm-only',
|
||||
'external-network-missing', 'external-volume-missing', 'new-network', 'new-volume',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isLikelySecretKey } from '../helpers/secretClassification';
|
||||
|
||||
describe('isLikelySecretKey', () => {
|
||||
it('flags keys whose segments are known secret words', () => {
|
||||
for (const k of [
|
||||
'DB_PASSWORD', 'API_KEY', 'PRIVATE_KEY', 'CLIENT_SECRET', 'WEBHOOK_SECRET',
|
||||
'GITHUB_TOKEN', 'APP_PASS', 'JWT_SECRET', 'AUTH_TOKEN', 'REDIS_PASSWORD',
|
||||
'SECRET_KEY_BASE', 'MAIL_PASSPHRASE',
|
||||
]) {
|
||||
expect(isLikelySecretKey(k), k).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('flags connection-string keys whose segments are innocuous', () => {
|
||||
for (const k of ['DATABASE_URL', 'REDIS_URL', 'MONGO_URI', 'MONGODB_URI', 'AMQP_URL', 'DSN']) {
|
||||
expect(isLikelySecretKey(k), k).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not flag innocuous keys that merely contain a secret word as a substring', () => {
|
||||
for (const k of [
|
||||
'KEYCLOAK_URL', 'APP_PORT', 'NODE_ENV', 'LOG_LEVEL', 'PUBLIC_URL',
|
||||
'COMPASS_HOST', 'BYPASS_CACHE', 'TZ', 'SERVER_NAME', 'AUTHORS_FILE',
|
||||
]) {
|
||||
expect(isLikelySecretKey(k), k).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('is case-insensitive and trims, and rejects empty', () => {
|
||||
expect(isLikelySecretKey(' db_password ')).toBe(true);
|
||||
expect(isLikelySecretKey('Api_Key')).toBe(true);
|
||||
expect(isLikelySecretKey('')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -298,6 +298,45 @@ describe('health gate settings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('env_block_deploy_on_missing_required setting', () => {
|
||||
it('seeds to "0" (opt-in) in a fresh database', () => {
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().env_block_deploy_on_missing_required).toBe('0');
|
||||
});
|
||||
|
||||
it('is exposed through the settings GET projection', async () => {
|
||||
const res = await request(app).get('/api/settings').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.env_block_deploy_on_missing_required).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects a non-admin write with 403', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/settings')
|
||||
.set('Cookie', viewerCookie)
|
||||
.send({ key: 'env_block_deploy_on_missing_required', value: '1' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('accepts a well-formed write and rejects a non-enum value', async () => {
|
||||
const ok = await request(app)
|
||||
.post('/api/settings')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ key: 'env_block_deploy_on_missing_required', value: '1' });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().env_block_deploy_on_missing_required).toBe('1');
|
||||
|
||||
const bad = await request(app)
|
||||
.post('/api/settings')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ key: 'env_block_deploy_on_missing_required', value: 'banana' });
|
||||
expect(bad.status).toBe(400);
|
||||
expect(bad.body.error).toBe('Validation failed');
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().env_block_deploy_on_missing_required).toBe('1');
|
||||
|
||||
DatabaseService.getInstance().updateGlobalSetting('env_block_deploy_on_missing_required', '0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/settings (bulk update)', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app).patch('/api/settings').send({ host_cpu_limit: 50 });
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Authored-compose env analysis for a stack: the env_file declarations (with
|
||||
* existence metadata), the project `.env` interpolation source, the inline
|
||||
* `environment:` KEY names per service, and the `${}` interpolation references.
|
||||
*
|
||||
* This is the single reader of the authored compose file set, so the multi-file
|
||||
* Git case is handled once and every consumer (the route's env-file wrapper that
|
||||
* Fleet Secrets calls, the Compose Doctor preflight, and the env inventory) sees
|
||||
* the same env_file set. It surfaces NAMES and structural
|
||||
* facts only: an env-file value is never read here, and inline environment values
|
||||
* are dropped immediately after their key names are taken.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import YAML from 'yaml';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { isPathWithinBase, isValidRelativeStackPath } from '../utils/validation';
|
||||
import { parseInterpolationRefs, type InterpolationRef } from './envVarParse';
|
||||
|
||||
const MAX_COMPOSE_PARSE_BYTES = 1_048_576; // 1 MiB, matches the routes/stacks.ts bound
|
||||
const ROOT_COMPOSE_CANDIDATES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
||||
|
||||
export type EnvFileExistence = 'present' | 'missing' | 'unverifiable';
|
||||
|
||||
/**
|
||||
* One physical env file, deduped by resolved absolute path. The project `.env`
|
||||
* doubling as an `env_file: .env` entry is ONE physical file carrying both roles,
|
||||
* so it is never double-counted as a duplicate definition.
|
||||
*/
|
||||
export interface PhysicalEnvFile {
|
||||
/** Absolute path, or null when the raw path is interpolated or escapes the stack dir. */
|
||||
resolvedPath: string | null;
|
||||
/** Raw paths as written (or '.env' for the implicit project source). */
|
||||
rawPaths: string[];
|
||||
existence: EnvFileExistence;
|
||||
/** A missing file matters only when at least one declaration required it. */
|
||||
required: boolean;
|
||||
/** True when this is the project `.env` Compose reads for `${}` interpolation. */
|
||||
isInterpolationSource: boolean;
|
||||
/** True when a service `env_file:` injects this file into a container. */
|
||||
isInjectionSource: boolean;
|
||||
/** Services that declared this file via `env_file:`. */
|
||||
declaringServices: string[];
|
||||
}
|
||||
|
||||
export interface StackEnvSources {
|
||||
stackDir: string;
|
||||
baseDir: string;
|
||||
/** Absolute authored compose files actually read (multi-file Git aware). */
|
||||
composeFiles: string[];
|
||||
/** Project `.env` + every declared env_file, deduped by resolved path. */
|
||||
envFiles: PhysicalEnvFile[];
|
||||
/** Authored `environment:` KEY names per service (union across authored files). */
|
||||
inlineEnvKeysByService: Record<string, string[]>;
|
||||
/** `${}` references found across the authored compose source. */
|
||||
interpolationRefs: InterpolationRef[];
|
||||
}
|
||||
|
||||
interface EnvFileEntry {
|
||||
rawPath: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
/** Normalize a service `env_file:` field (string, array of strings, or long-form objects). */
|
||||
function normalizeEnvFileField(envFile: unknown): EnvFileEntry[] {
|
||||
if (typeof envFile === 'string') return [{ rawPath: envFile, required: true }];
|
||||
if (!Array.isArray(envFile)) return [];
|
||||
const out: EnvFileEntry[] = [];
|
||||
for (const entry of envFile) {
|
||||
if (typeof entry === 'string') {
|
||||
out.push({ rawPath: entry, required: true });
|
||||
} else if (entry && typeof entry === 'object') {
|
||||
const p = (entry as Record<string, unknown>).path;
|
||||
if (typeof p === 'string') {
|
||||
out.push({ rawPath: p, required: (entry as Record<string, unknown>).required !== false });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Inline `environment:` KEY names (object / `KEY=value` array / bare `KEY`), never values. */
|
||||
function inlineEnvKeysOf(environment: unknown): string[] {
|
||||
if (Array.isArray(environment)) {
|
||||
return environment
|
||||
.filter((e): e is string => typeof e === 'string')
|
||||
.map(e => e.split('=')[0].trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (environment && typeof environment === 'object') {
|
||||
return Object.keys(environment as Record<string, unknown>);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function existenceOf(fsService: FileSystemService, abs: string, baseDir: string): Promise<EnvFileExistence> {
|
||||
if (!isPathWithinBase(abs, baseDir)) return 'unverifiable';
|
||||
try {
|
||||
await fsService.access(abs);
|
||||
return 'present';
|
||||
} catch (err) {
|
||||
return (err as NodeJS.ErrnoException).code === 'ENOENT' ? 'missing' : 'unverifiable';
|
||||
}
|
||||
}
|
||||
|
||||
/** The authored compose files Compose would read: the applied Git deploy spec, else the root file. */
|
||||
async function discoverAuthoredComposeFiles(
|
||||
fsService: FileSystemService,
|
||||
stackName: string,
|
||||
stackDir: string,
|
||||
): Promise<string[]> {
|
||||
const spec = DatabaseService.getInstance().getGitSource(stackName)?.applied_deploy_spec;
|
||||
if (spec && Array.isArray(spec.files) && spec.files.length > 0) {
|
||||
const files: string[] = [];
|
||||
for (const f of spec.files) {
|
||||
if (typeof f !== 'string' || !isValidRelativeStackPath(f)) continue;
|
||||
const abs = path.resolve(stackDir, f);
|
||||
if (isPathWithinBase(abs, stackDir)) files.push(abs);
|
||||
}
|
||||
if (files.length > 0) return files;
|
||||
}
|
||||
for (const name of ROOT_COMPOSE_CANDIDATES) {
|
||||
const abs = path.resolve(stackDir, name);
|
||||
try {
|
||||
await fsService.access(abs);
|
||||
return [abs];
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function parseComposeServices(fsService: FileSystemService, absPath: string): Promise<{
|
||||
services: Record<string, unknown>;
|
||||
text: string;
|
||||
} | null> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fsService.readFile(absPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (content.length > MAX_COMPOSE_PARSE_BYTES) return null;
|
||||
try {
|
||||
const parsed = YAML.parse(content) as Record<string, unknown> | null;
|
||||
const services = (parsed?.services && typeof parsed.services === 'object')
|
||||
? parsed.services as Record<string, unknown>
|
||||
: {};
|
||||
return { services, text: content };
|
||||
} catch {
|
||||
return { services: {}, text: content };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every authored env source for a stack. Reads each authored compose file
|
||||
* once and returns env_file existence, inline key names, and interpolation refs.
|
||||
*/
|
||||
export async function resolveStackEnvSources(nodeId: number, stackName: string): Promise<StackEnvSources> {
|
||||
const fsService = FileSystemService.getInstance(nodeId);
|
||||
const baseDir = fsService.getBaseDir();
|
||||
const stackDir = path.join(baseDir, stackName);
|
||||
|
||||
const composeFiles = await discoverAuthoredComposeFiles(fsService, stackName, stackDir);
|
||||
|
||||
// Physical env files, deduped by resolved absolute path. Seed with the project
|
||||
// `.env`: always the interpolation source, regardless of any env_file entry.
|
||||
const byPath = new Map<string, PhysicalEnvFile>();
|
||||
const dotenvPath = path.resolve(stackDir, '.env');
|
||||
const dotenv: PhysicalEnvFile = {
|
||||
resolvedPath: dotenvPath,
|
||||
rawPaths: ['.env'],
|
||||
existence: await existenceOf(fsService, dotenvPath, baseDir),
|
||||
required: false,
|
||||
isInterpolationSource: true,
|
||||
isInjectionSource: false,
|
||||
declaringServices: [],
|
||||
};
|
||||
byPath.set(dotenvPath, dotenv);
|
||||
|
||||
const unresolved: PhysicalEnvFile[] = [];
|
||||
const inlineEnvKeysByService: Record<string, string[]> = {};
|
||||
let authoredText = '';
|
||||
|
||||
for (const file of composeFiles) {
|
||||
const parsed = await parseComposeServices(fsService, file);
|
||||
if (!parsed) continue;
|
||||
authoredText += parsed.text + '\n';
|
||||
|
||||
for (const [serviceName, svcRaw] of Object.entries(parsed.services)) {
|
||||
const svc = (svcRaw ?? {}) as Record<string, unknown>;
|
||||
|
||||
const inlineKeys = inlineEnvKeysOf(svc.environment);
|
||||
if (inlineKeys.length > 0) {
|
||||
const existing = inlineEnvKeysByService[serviceName] ?? [];
|
||||
inlineEnvKeysByService[serviceName] = [...new Set([...existing, ...inlineKeys])];
|
||||
}
|
||||
|
||||
for (const entry of normalizeEnvFileField(svc.env_file)) {
|
||||
const interpolated = entry.rawPath.includes('${');
|
||||
// Resolve relative to the directory of the compose file that declared it,
|
||||
// so an env_file in a nested multi-file override (e.g. infra/prod.yml ->
|
||||
// ./prod.env) lands next to that file, not at the stack root. For the root
|
||||
// compose file this dir is the stack dir, so the common case is unchanged.
|
||||
const abs = interpolated ? null : path.resolve(path.dirname(file), entry.rawPath);
|
||||
const within = abs !== null && isPathWithinBase(abs, stackDir);
|
||||
const resolvedPath = within ? abs : null;
|
||||
|
||||
if (resolvedPath) {
|
||||
const existing = byPath.get(resolvedPath);
|
||||
if (existing) {
|
||||
existing.isInjectionSource = true;
|
||||
existing.required ||= entry.required;
|
||||
if (!existing.rawPaths.includes(entry.rawPath)) existing.rawPaths.push(entry.rawPath);
|
||||
if (!existing.declaringServices.includes(serviceName)) existing.declaringServices.push(serviceName);
|
||||
} else {
|
||||
byPath.set(resolvedPath, {
|
||||
resolvedPath,
|
||||
rawPaths: [entry.rawPath],
|
||||
existence: await existenceOf(fsService, resolvedPath, baseDir),
|
||||
required: entry.required,
|
||||
isInterpolationSource: false,
|
||||
isInjectionSource: true,
|
||||
declaringServices: [serviceName],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Interpolated or escaping path: unverifiable, kept so the inventory can show it.
|
||||
unresolved.push({
|
||||
resolvedPath: null,
|
||||
rawPaths: [entry.rawPath],
|
||||
existence: 'unverifiable',
|
||||
required: entry.required,
|
||||
isInterpolationSource: false,
|
||||
isInjectionSource: true,
|
||||
declaringServices: [serviceName],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stackDir,
|
||||
baseDir,
|
||||
composeFiles,
|
||||
envFiles: [...byPath.values(), ...unresolved],
|
||||
inlineEnvKeysByService,
|
||||
interpolationRefs: parseInterpolationRefs(authoredText),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Name-only parsing of Compose interpolation and env-file keys, plus the shared
|
||||
* stderr parsers Compose Doctor and the deploy guard both use. Every function
|
||||
* here surfaces variable NAMES only; an env-file value is never returned or
|
||||
* retained, and the bounded reader never loads a whole large file into memory.
|
||||
*/
|
||||
|
||||
import { promises as fsp } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/** A `${...}` reference found in authored compose source. */
|
||||
export interface InterpolationRef {
|
||||
name: string;
|
||||
/** `${VAR:?e}` / `${VAR?e}`: Compose errors if unset (`:?` also if empty). */
|
||||
required: boolean;
|
||||
/** `${VAR:-d}` / `${VAR-d}`: a default makes the value optional. */
|
||||
hasDefault: boolean;
|
||||
/** `${VAR:+x}` / `${VAR+x}`: alternate value; an unset VAR is intentional. */
|
||||
alternate: boolean;
|
||||
}
|
||||
|
||||
// ${VAR}, ${VAR:-d}, ${VAR-d}, ${VAR:?e}, ${VAR?e}, ${VAR:+x}, ${VAR+x}.
|
||||
// The leading (?<!\$) skips Compose's `$${VAR}` escape (a literal, not a ref).
|
||||
// Group 2 is the operator (':-','-',':?','?',':+','+') or undefined for a bare ref.
|
||||
const INTERPOLATION_RE = /(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-?+])[^}]*)?\}/g;
|
||||
|
||||
/**
|
||||
* Extract every distinct `${...}` reference from authored compose text, with its
|
||||
* operator semantics. Operates on raw text (no YAML/value construction), so it
|
||||
* never materializes an env value.
|
||||
*/
|
||||
export function parseInterpolationRefs(source: string): InterpolationRef[] {
|
||||
const byName = new Map<string, InterpolationRef>();
|
||||
for (const m of source.matchAll(INTERPOLATION_RE)) {
|
||||
const name = m[1];
|
||||
const op = m[2];
|
||||
const required = op === ':?' || op === '?';
|
||||
const hasDefault = op === ':-' || op === '-';
|
||||
const alternate = op === ':+' || op === '+';
|
||||
const existing = byName.get(name);
|
||||
if (existing) {
|
||||
existing.required ||= required;
|
||||
existing.hasDefault ||= hasDefault;
|
||||
existing.alternate ||= alternate;
|
||||
} else {
|
||||
byName.set(name, { name, required, hasDefault, alternate });
|
||||
}
|
||||
}
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the KEY name from a single env-file line, or null for a blank/comment line.
|
||||
* Handles `KEY=value`, `export KEY=value`, and a bare `KEY` (value sourced from the
|
||||
* shell). The value after `=` is never read or returned.
|
||||
*/
|
||||
export function extractEnvKeyFromLine(line: string): string | null {
|
||||
let s = line.trim();
|
||||
if (!s || s.startsWith('#')) return null;
|
||||
if (s.startsWith('export ')) s = s.slice('export '.length).trim();
|
||||
const eq = s.indexOf('=');
|
||||
const key = (eq === -1 ? s : s.slice(0, eq)).trim();
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) ? key : null;
|
||||
}
|
||||
|
||||
export interface EnvKeyReadLimits {
|
||||
maxBytes: number;
|
||||
maxLines: number;
|
||||
maxLineLen: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_ENV_KEY_LIMITS: EnvKeyReadLimits = {
|
||||
maxBytes: 256 * 1024,
|
||||
maxLines: 5000,
|
||||
maxLineLen: 8192,
|
||||
};
|
||||
|
||||
export interface EnvKeyReadResult {
|
||||
/** Distinct key names, in first-seen order. Never includes a value. */
|
||||
keys: string[];
|
||||
/** True when the file exceeded a limit and was only partially read. */
|
||||
truncated: boolean;
|
||||
/** True when the path escaped the base or the file could not be read/statted. */
|
||||
unverifiable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read env-file KEY names from a file under `baseDir`, bounded by `limits`. The
|
||||
* path containment barrier is inlined at the read sink (CodeQL does not credit a
|
||||
* wrapped helper), and the read is capped so a large or adversarial file cannot
|
||||
* exhaust heap. Values are never materialized: only the slice before the first
|
||||
* `=` of each line is kept.
|
||||
*/
|
||||
export async function readEnvFileKeys(
|
||||
filePath: string,
|
||||
baseDir: string,
|
||||
limits: EnvKeyReadLimits = DEFAULT_ENV_KEY_LIMITS,
|
||||
): Promise<EnvKeyReadResult> {
|
||||
const resolved = path.resolve(filePath);
|
||||
const baseResolved = path.resolve(baseDir);
|
||||
if (resolved !== baseResolved && !resolved.startsWith(baseResolved + path.sep)) {
|
||||
return { keys: [], truncated: false, unverifiable: true };
|
||||
}
|
||||
|
||||
let handle: fsp.FileHandle | undefined;
|
||||
try {
|
||||
// Open first, then fstat the open handle (not the path), so there is no
|
||||
// check-then-use window between a path stat and the open.
|
||||
handle = await fsp.open(resolved, 'r');
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) return { keys: [], truncated: false, unverifiable: true };
|
||||
const truncated = stat.size > limits.maxBytes;
|
||||
const len = Math.min(stat.size, limits.maxBytes);
|
||||
const buf = Buffer.alloc(len);
|
||||
if (len > 0) await handle.read(buf, 0, len, 0);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const keys: string[] = [];
|
||||
const lines = buf.toString('utf-8').split(/\r?\n/);
|
||||
const lineBudget = Math.min(lines.length, limits.maxLines);
|
||||
for (let i = 0; i < lineBudget; i++) {
|
||||
const line = lines[i];
|
||||
if (line.length > limits.maxLineLen) continue;
|
||||
const key = extractEnvKeyFromLine(line);
|
||||
if (key && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
return { keys, truncated: truncated || lines.length > limits.maxLines, unverifiable: false };
|
||||
} catch {
|
||||
return { keys: [], truncated: false, unverifiable: true };
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect the deduplicated capture-group-1 matches of a global regex over stderr. */
|
||||
function collectNames(stderr: string, re: RegExp): string[] {
|
||||
const names = new Set<string>();
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(stderr)) !== null) names.add(m[1]);
|
||||
return [...names];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the names of variables Compose reported as unset from its stderr.
|
||||
* Compose prints this in logfmt (`msg="The \"VAR\" variable is not set..."`),
|
||||
* so the name is wrapped in an escaped quote; the pattern tolerates the
|
||||
* escaped, plain-quoted, and unquoted forms across Compose versions.
|
||||
*/
|
||||
export function parseUnsetEnvVars(stderr: string): string[] {
|
||||
return collectNames(stderr, /([A-Za-z_][A-Za-z0-9_]*)\\?"?\s+variable is not set/gi);
|
||||
}
|
||||
|
||||
/** Names of required (${VAR:?...}) variables Compose reported as missing. Names only, never values. */
|
||||
export function parseMissingRequiredVars(stderr: string): string[] {
|
||||
return collectNames(stderr, /required variable\s+\\?"?([A-Za-z_][A-Za-z0-9_]*)\\?"?\s+is missing/gi);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Deterministic "is this env key likely a secret" classification by key NAME only.
|
||||
*
|
||||
* Used to decide whether an env-inventory row is redacted (presence shown, value
|
||||
* never read or rendered). The heuristic is segment-aware: a key is split on
|
||||
* non-alphanumeric boundaries and each segment is matched against a known set, so
|
||||
* `API_KEY` / `DB_PASSWORD` / `CLIENT_SECRET` match while a key that merely
|
||||
* contains a secret word as part of a larger token (e.g. `KEYCLOAK_URL`, where the
|
||||
* segment is `KEYCLOAK`, not `KEY`) does not. Over-flagging is safe: classification
|
||||
* only hides a value the inventory already never reads.
|
||||
*/
|
||||
|
||||
/** Whole-segment matches. Split on `_`/non-alnum, so `KEYCLOAK` never matches `KEY`. */
|
||||
const SECRET_SEGMENTS = new Set([
|
||||
'PASSWORD', 'PASSWD', 'PASS', 'PASSPHRASE',
|
||||
'SECRET', 'SECRETS',
|
||||
'TOKEN', 'KEY', 'APIKEY',
|
||||
'CREDENTIAL', 'CREDENTIALS', 'AUTH',
|
||||
]);
|
||||
|
||||
/** Connection strings whose value is sensitive but whose segments are innocuous. */
|
||||
const SECRET_FULL_KEYS = new Set([
|
||||
'DATABASE_URL', 'DATABASE_DSN', 'REDIS_URL', 'MONGO_URI', 'MONGODB_URI', 'AMQP_URL', 'DSN',
|
||||
]);
|
||||
|
||||
/** True when the key name suggests its value is a secret. Names only, never values. */
|
||||
export function isLikelySecretKey(rawKey: string): boolean {
|
||||
const key = rawKey.trim().toUpperCase();
|
||||
if (!key) return false;
|
||||
if (SECRET_FULL_KEYS.has(key)) return true;
|
||||
const segments = key.split(/[^A-Z0-9]+/).filter(Boolean);
|
||||
return segments.some(seg => SECRET_SEGMENTS.has(seg));
|
||||
}
|
||||
@@ -29,6 +29,7 @@ const ALLOWED_SETTING_KEYS = new Set([
|
||||
'snapshot_documentation',
|
||||
'health_gate_enabled',
|
||||
'health_gate_window_seconds',
|
||||
'env_block_deploy_on_missing_required',
|
||||
]);
|
||||
|
||||
// Keys whose write requires a paid license, not just an admin role.
|
||||
@@ -56,6 +57,7 @@ const SettingsPatchSchema = z.object({
|
||||
snapshot_documentation: z.enum(['0', '1']),
|
||||
health_gate_enabled: z.enum(['0', '1']),
|
||||
health_gate_window_seconds: z.coerce.number().int().min(15).max(600).transform(String),
|
||||
env_block_deploy_on_missing_required: z.enum(['0', '1']),
|
||||
}).partial();
|
||||
|
||||
export const settingsRouter = Router();
|
||||
|
||||
@@ -18,6 +18,7 @@ import { DriftLedgerService, type DriftTemporal } from '../services/DriftLedgerS
|
||||
import { ComposeDoctorService } from '../services/ComposeDoctorService';
|
||||
import { buildStackNetworkFacts } from '../services/network/composeNetworkInspector';
|
||||
import { buildEffectiveAnatomy } from '../services/effectiveAnatomy';
|
||||
import { buildEnvInventory } from '../services/EnvInventoryService';
|
||||
import { EXPOSURE_INTENTS, type ExposureIntent } from '../services/network/types';
|
||||
import { UpdateGuardService } from '../services/UpdateGuardService';
|
||||
import { HealthGateService } from '../services/HealthGateService';
|
||||
@@ -27,7 +28,7 @@ import { NotificationService, type NotificationCategory } from '../services/Noti
|
||||
import { StackOpLockService, type StackOpAction } from '../services/StackOpLockService';
|
||||
import { StackOpMetricsService, type StackOpAction as StackMetricAction } from '../services/StackOpMetricsService';
|
||||
import { FileExplorerMetricsService, type FileExplorerOp } from '../services/FileExplorerMetricsService';
|
||||
import { isValidGitSourcePath, isValidStackName, isValidServiceName, isPathWithinBase, isValidRelativeStackPath } from '../utils/validation';
|
||||
import { isValidGitSourcePath, isValidStackName, isValidServiceName, isValidRelativeStackPath } from '../utils/validation';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
@@ -36,6 +37,7 @@ import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan } from '..
|
||||
import { parseComposePreview, type ComposePreview } from '../helpers/composePreview';
|
||||
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
||||
import { parseComposeSelection, defaultEnvPath } from '../helpers/gitSourceSelection';
|
||||
import { resolveStackEnvSources } from '../helpers/envFileResolution';
|
||||
import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants';
|
||||
import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic';
|
||||
|
||||
@@ -135,80 +137,21 @@ async function requireStackExists(nodeId: number, stackName: string, res: Respon
|
||||
return true;
|
||||
}
|
||||
|
||||
// Thin wrapper over the shared env-source resolver. Returns the absolute paths of
|
||||
// the env files Compose would consult for this stack: the existing declared
|
||||
// `env_file:` paths when any are declared (no project `.env` fallback in that
|
||||
// case), otherwise the project `.env` when it exists. The multi-file Git case and
|
||||
// path validation live in resolveStackEnvSources so every consumer agrees.
|
||||
export async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise<string[]> {
|
||||
const fsService = FileSystemService.getInstance(nodeId);
|
||||
const stackDir = path.join(fsService.getBaseDir(), stackName);
|
||||
const defaultEnvPath = path.join(stackDir, '.env');
|
||||
|
||||
try {
|
||||
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
||||
let composeContent: string | null = null;
|
||||
|
||||
for (const file of composeFiles) {
|
||||
try {
|
||||
composeContent = await fsService.readFile(path.join(stackDir, file), 'utf-8');
|
||||
break;
|
||||
} catch {
|
||||
// Try next file
|
||||
}
|
||||
}
|
||||
|
||||
if (!composeContent) return [defaultEnvPath];
|
||||
|
||||
if (composeContent.length > MAX_COMPOSE_PARSE_BYTES) {
|
||||
console.warn(`[Stacks] Compose for ${sanitizeForLog(stackName)} exceeds ${MAX_COMPOSE_PARSE_BYTES} bytes; skipping env_file resolution`);
|
||||
return [defaultEnvPath];
|
||||
}
|
||||
|
||||
const parsed = YAML.parse(composeContent);
|
||||
if (!parsed?.services) return [defaultEnvPath];
|
||||
|
||||
const envFiles = new Set<string>();
|
||||
|
||||
for (const serviceName of Object.keys(parsed.services)) {
|
||||
const service = parsed.services[serviceName];
|
||||
if (!service?.env_file) continue;
|
||||
|
||||
const addEnvPath = (rawPath: string) => {
|
||||
const resolved = path.resolve(stackDir, rawPath);
|
||||
if (!isPathWithinBase(resolved, stackDir)) return;
|
||||
envFiles.add(resolved);
|
||||
};
|
||||
|
||||
if (typeof service.env_file === 'string') {
|
||||
addEnvPath(service.env_file);
|
||||
} else if (Array.isArray(service.env_file)) {
|
||||
for (const entry of service.env_file) {
|
||||
const entryPath = typeof entry === 'string' ? entry : (entry?.path || '');
|
||||
if (entryPath) addEnvPath(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (envFiles.size === 0) {
|
||||
envFiles.add(defaultEnvPath);
|
||||
}
|
||||
|
||||
const existing: string[] = [];
|
||||
for (const f of envFiles) {
|
||||
try {
|
||||
await fsService.access(f);
|
||||
existing.push(f);
|
||||
} catch {
|
||||
// File does not exist, skip
|
||||
}
|
||||
}
|
||||
return existing;
|
||||
} catch (error) {
|
||||
console.warn('Could not parse compose.yaml for env_file resolution in stack "%s":', sanitizeForLog(stackName), error);
|
||||
}
|
||||
|
||||
try {
|
||||
await fsService.access(defaultEnvPath);
|
||||
return [defaultEnvPath];
|
||||
} catch {
|
||||
return [];
|
||||
const sources = await resolveStackEnvSources(nodeId, stackName);
|
||||
const injection = sources.envFiles.filter(f => f.isInjectionSource);
|
||||
if (injection.length > 0) {
|
||||
return injection
|
||||
.filter(f => f.existence === 'present' && f.resolvedPath)
|
||||
.map(f => f.resolvedPath as string);
|
||||
}
|
||||
const dotenv = sources.envFiles.find(f => f.isInterpolationSource && f.existence === 'present' && f.resolvedPath);
|
||||
return dotenv ? [dotenv.resolvedPath as string] : [];
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
@@ -1170,6 +1113,24 @@ stacksRouter.get('/:stackName/effective-anatomy', async (req: Request, res: Resp
|
||||
}
|
||||
});
|
||||
|
||||
// Environment inventory: per-stack env vars with their source, scope (Compose
|
||||
// interpolation vs container injection), and status (present/missing/unused/
|
||||
// duplicate/unpersisted), plus likely-secret classification. Read-only and
|
||||
// advisory; auto-proxies to the active node. Names only: an env value is never
|
||||
// read into the payload, so stack:read is the correct gate.
|
||||
stacksRouter.get('/:stackName/env-inventory', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
||||
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
||||
try {
|
||||
res.json(await buildEnvInventory(req.nodeId, stackName));
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to build env inventory for %s:', sanitizeForLog(stackName),
|
||||
sanitizeForLog(inspect(error, { depth: 4 })));
|
||||
res.status(500).json({ error: 'Failed to build env inventory' });
|
||||
}
|
||||
});
|
||||
|
||||
// Exposure intent: the user's per-stack (service '') and per-service exposure
|
||||
// classification, stored separately from generated facts so mismatches stay
|
||||
// detectable. Rows are stored independently; precedence (a service row taking
|
||||
|
||||
@@ -38,6 +38,7 @@ export const CAPABILITIES = [
|
||||
'compose-doctor',
|
||||
'update-guard',
|
||||
'compose-networking',
|
||||
'env-inventory',
|
||||
] as const;
|
||||
|
||||
export type Capability = (typeof CAPABILITIES)[number];
|
||||
|
||||
@@ -13,38 +13,17 @@ import { parseAccessUrlPorts } from './network/normalize';
|
||||
import type { ExposureIntent } from './network/types';
|
||||
import { runRules, SEVERITY_RANK, RULE_IDS, RENDER_FAILED_RULE_ID } from './preflight/rules';
|
||||
import type {
|
||||
BindCheck, NodePortBinding, PreflightContext, PreflightFinding, PreflightReport, PreflightSeverity, PreflightStatus,
|
||||
BindCheck, NodePortBinding, PreflightContext, PreflightFinding, PreflightReport, PreflightSeverity, PreflightStatus, MissingEnvFile,
|
||||
} from './preflight/types';
|
||||
|
||||
import { isPathWithinBase } from '../utils/validation';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
|
||||
import { parseUnsetEnvVars, parseMissingRequiredVars } from '../helpers/envVarParse';
|
||||
import { resolveStackEnvSources } from '../helpers/envFileResolution';
|
||||
|
||||
const MAX_RENDER_ERROR = 600; // chars kept from a (redacted) render error
|
||||
|
||||
/** Collect the deduplicated capture-group-1 matches of a global regex over stderr. */
|
||||
function collectNames(stderr: string, re: RegExp): string[] {
|
||||
const names = new Set<string>();
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(stderr)) !== null) names.add(m[1]);
|
||||
return [...names];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the names of variables Compose reported as unset from its stderr.
|
||||
* Compose prints this in logfmt (`msg="The \"VAR\" variable is not set..."`),
|
||||
* so the name is wrapped in an escaped quote; the pattern tolerates the
|
||||
* escaped, plain-quoted, and unquoted forms across Compose versions.
|
||||
*/
|
||||
export function parseUnsetEnvVars(stderr: string): string[] {
|
||||
return collectNames(stderr, /([A-Za-z_][A-Za-z0-9_]*)\\?"?\s+variable is not set/gi);
|
||||
}
|
||||
|
||||
/** Names of required (${VAR:?...}) variables Compose reported as missing. Names only, never values. */
|
||||
export function parseMissingRequiredVars(stderr: string): string[] {
|
||||
return collectNames(stderr, /required variable\s+\\?"?([A-Za-z_][A-Za-z0-9_]*)\\?"?\s+is missing/gi);
|
||||
}
|
||||
|
||||
const ruleOrder = new Map(RULE_IDS.map((id, i) => [id, i]));
|
||||
/** Severity descending, then registry order, so output is deterministic. */
|
||||
function sortFindings(findings: PreflightFinding[]): PreflightFinding[] {
|
||||
@@ -200,6 +179,20 @@ export class ComposeDoctorService {
|
||||
const bindChecks = model ? await this.resolveBindChecks(model, baseDir) : [];
|
||||
const { stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls } = this.exposureState(nodeId, stackName);
|
||||
|
||||
// Required `env_file:` declarations whose file is absent. Optional
|
||||
// (required: false) and interpolated/escaping paths are excluded. Fail-soft:
|
||||
// a resolution error simply yields no env-file findings.
|
||||
let missingEnvFiles: MissingEnvFile[] = [];
|
||||
try {
|
||||
const envSources = await resolveStackEnvSources(nodeId, stackName);
|
||||
missingEnvFiles = envSources.envFiles
|
||||
.filter(f => f.isInjectionSource && f.required && f.existence === 'missing')
|
||||
.map(f => ({ rawPath: f.rawPaths[0], services: f.declaringServices }));
|
||||
} catch (err) {
|
||||
console.warn('[ComposeDoctor] env-file resolution failed for %s:',
|
||||
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown')));
|
||||
}
|
||||
|
||||
return {
|
||||
stackName,
|
||||
platform: process.platform,
|
||||
@@ -207,6 +200,7 @@ export class ComposeDoctorService {
|
||||
renderable,
|
||||
renderError,
|
||||
unsetEnvVars,
|
||||
missingEnvFiles,
|
||||
sourceServiceNames,
|
||||
sourceReadable,
|
||||
nodePorts,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getErrorMessage } from '../utils/errors';
|
||||
import { describeSpawnError } from '../utils/spawnErrors';
|
||||
import { isPathWithinBase, isValidStackName } from '../utils/validation';
|
||||
import { authoredComposeFileArgs, authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs';
|
||||
import { parseMissingRequiredVars } from '../helpers/envVarParse';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
export class ComposeRollbackError extends Error {
|
||||
@@ -364,7 +365,34 @@ export class ComposeService {
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, [action]), stackDir, ws);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in guard: when `env_block_deploy_on_missing_required` is enabled, refuse a
|
||||
* deploy whose required `${VAR:?err}` variables are unset OR empty, before any
|
||||
* backup, cleanup, pull, or `up` runs. Compose's own resolution is authoritative
|
||||
* (it passes process.env), and on the failing path it emits no rendered model, so
|
||||
* no env value is materialized. Default off and any settings-read failure both
|
||||
* fall through without blocking.
|
||||
*/
|
||||
private async assertRequiredEnvPresent(stackName: string): Promise<void> {
|
||||
let enabled = false;
|
||||
try {
|
||||
enabled = DatabaseService.getInstance().getGlobalSettings()['env_block_deploy_on_missing_required'] === '1';
|
||||
} catch {
|
||||
return; // safe default: a settings-read failure never blocks a deploy
|
||||
}
|
||||
if (!enabled) return;
|
||||
const result = await this.renderConfig(stackName);
|
||||
const missing = parseMissingRequiredVars(result.stderr);
|
||||
if (missing.length === 0) return;
|
||||
const plural = missing.length > 1;
|
||||
throw new Error(
|
||||
`Deploy blocked: required environment variable${plural ? 's' : ''} ${missing.join(', ')} ` +
|
||||
`${plural ? 'are' : 'is'} missing. Define ${plural ? 'them' : 'it'} in a .env or env_file, then deploy again.`,
|
||||
);
|
||||
}
|
||||
|
||||
async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
await this.assertRequiredEnvPresent(stackName);
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const debug = isDebugEnabled();
|
||||
const t0 = Date.now();
|
||||
@@ -541,6 +569,7 @@ export class ComposeService {
|
||||
}
|
||||
|
||||
async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
await this.assertRequiredEnvPresent(stackName);
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const debug = isDebugEnabled();
|
||||
const t0 = Date.now();
|
||||
|
||||
@@ -1493,6 +1493,7 @@ export class DatabaseService {
|
||||
stmt.run('health_gate_enabled', '1');
|
||||
stmt.run('health_gate_window_seconds', '90');
|
||||
stmt.run('image_update_check_interval_minutes', '120');
|
||||
stmt.run('env_block_deploy_on_missing_required', '0');
|
||||
|
||||
// Seed the default local node if none exists
|
||||
const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
* resolved secret VALUE in the rendered model can never reach this payload.
|
||||
*/
|
||||
import { ComposeService } from './ComposeService';
|
||||
import { parseMissingRequiredVars } from './ComposeDoctorService';
|
||||
import { parseMissingRequiredVars } from '../helpers/envVarParse';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import DockerController, { type DependencySnapshot } from '../DockerController';
|
||||
import { ComposeService } from '../ComposeService';
|
||||
import { FileSystemService } from '../FileSystemService';
|
||||
import { parseEffectiveModel, type EffectiveModel } from '../preflight/effectiveModel';
|
||||
import { parseMissingRequiredVars } from '../ComposeDoctorService';
|
||||
import { parseMissingRequiredVars } from '../../helpers/envVarParse';
|
||||
import {
|
||||
compareStackNetworks, fromEffectiveModel, isAllInterfaces, isLoopback,
|
||||
} from './normalize';
|
||||
|
||||
@@ -87,6 +87,21 @@ const envUnset: PreflightRule = {
|
||||
},
|
||||
};
|
||||
|
||||
const envFileMissing: PreflightRule = {
|
||||
id: 'env-file-missing',
|
||||
run(ctx) {
|
||||
return ctx.missingEnvFiles.map(f => ({
|
||||
ruleId: 'env-file-missing',
|
||||
severity: 'high' as const,
|
||||
title: `Missing env file ${f.rawPath}`,
|
||||
message: `The Compose file declares env_file "${f.rawPath}"${f.services.length ? ` for service ${f.services.join(', ')}` : ''}, but no such file exists in the stack directory. Compose fails to start the stack when a required env_file is absent.`,
|
||||
sourcePath: f.rawPath,
|
||||
remediation: `Create ${f.rawPath} in the stack directory, fix the path, or mark the entry optional with "required: false".`,
|
||||
service: f.services[0],
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const portConflictNode: PreflightRule = {
|
||||
id: 'port-conflict-node',
|
||||
run(ctx) {
|
||||
@@ -671,6 +686,7 @@ const sensitiveServiceBroadExposure: PreflightRule = {
|
||||
export const PREFLIGHT_RULES: PreflightRule[] = [
|
||||
renderFailed,
|
||||
envUnset,
|
||||
envFileMissing,
|
||||
portConflictNode,
|
||||
portConflictInternal,
|
||||
portExposedAllInterfaces,
|
||||
|
||||
@@ -43,6 +43,12 @@ export interface PreflightReport {
|
||||
findings: PreflightFinding[];
|
||||
}
|
||||
|
||||
/** A declared `env_file:` that is required and absent on disk (names only). */
|
||||
export interface MissingEnvFile {
|
||||
rawPath: string;
|
||||
services: string[];
|
||||
}
|
||||
|
||||
/** A host port bound by a running container on the target node. */
|
||||
export interface NodePortBinding {
|
||||
publishedPort: number;
|
||||
@@ -83,6 +89,8 @@ export interface PreflightContext {
|
||||
renderError: string | null;
|
||||
/** Variable names Compose reported as unset (defaulted to empty string). */
|
||||
unsetEnvVars: string[];
|
||||
/** Declared `env_file:` paths that are required but absent on disk (names only). */
|
||||
missingEnvFiles: MissingEnvFile[];
|
||||
/** Service names parsed from the literal source file (pre-render). */
|
||||
sourceServiceNames: string[];
|
||||
/** Whether the source file could be read; gates source-derived checks so an
|
||||
|
||||
Reference in New Issue
Block a user