feat: add Compose Doctor preflight checks for stacks (#1348)

* feat: add Compose Doctor preflight checks for stacks

Add an on-demand, advisory preflight that renders a stack's effective
Compose model with `docker compose config` and runs a registry of
deterministic checks before deploy, surfacing findings grouped by
severity (blocker, high, warning, info) with a remediation for each.
Findings cover unset env vars, host-port conflicts on the node, broad
0.0.0.0 exposure, missing bind-mount paths, a mounted Docker socket,
privileged and host networking, moving image tags, missing restart
policy and healthcheck, Swarm-only deploy fields, missing external
networks or volumes, and container_name collisions.

The report is node-scoped and stored as the last run per stack, and the
route auto-proxies to the active node so a remote stack is checked on
the node that owns it. A new Doctor tab on the stack detail panel runs
preflight and shows the grouped findings, with a severity dot on the tab
when the last run has blocker or high findings. The tab is gated on a
compose-doctor capability so older nodes hide it.

No environment value is ever stored, returned, or logged: only env key
names and structural facts are read, and render failures surface a
generic message or the missing required-variable names, never raw
stderr.

* fix: scroll the stack tab strip when its tabs overflow

Adding the Doctor tab can push the per-stack Anatomy tab strip past the
panel width on narrower layouts. Make the tab row scroll horizontally
with subtle edge fades that appear only while there is more to scroll in
that direction, so a panel wide enough to show every tab is unchanged.

* fix: add clickable arrows and wheel scroll to the stack tab strip

Hiding the scrollbar left mouse users with no way to scroll the
overflowing tab row: a vertical wheel does not move a horizontal overflow
and native rows do not drag-scroll. Replace the passive edge fades with
clickable chevron arrows shown only when the row overflows that edge, and
translate a vertical wheel over the row into horizontal scroll.

* fix: inline the path-injection barrier in renderConfig

CodeQL's path-injection check does not credit the wrapped isPathWithinBase
helper as a sanitizer, so move the containment check inline at the spawn
cwd sink, matching the canonical barrier used elsewhere in the codebase.
Behavior is unchanged: the resolved stack directory must be contained in
the compose base and may not be the base itself.

* fix: hoist the compose-config spawn into the path-barrier scope

The earlier inline barrier sat in a different scope than the spawn cwd
sink (separated by the Promise-executor closure) and used a compound
guard, so CodeQL did not credit it. Use the exact canonical startsWith
barrier and hoist the spawn into the same scope as the check. Behavior
is unchanged: the executor runs synchronously in the same tick as the
spawn, so handlers still attach before any event can fire.
This commit is contained in:
Anso
2026-06-10 11:35:39 -04:00
committed by GitHub
parent d369b03a38
commit 52ff0725f4
20 changed files with 2620 additions and 9 deletions
@@ -0,0 +1,223 @@
/**
* ComposeDoctorService: orchestration over the renderer, the rule registry, and
* persistence. Docker (render + snapshot) is mocked; the filesystem and database
* are real. Covers status derivation, replace-on-run persistence, getLatest, the
* unrenderable path, node-deletion cleanup, the renderConfig path guard, and the
* hard guarantee that an environment value never reaches a stored row.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import DockerController from '../services/DockerController';
import { ComposeService } from '../services/ComposeService';
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(); }
function doctor() { return ComposeDoctorService.getInstance(); }
/** Mock the two Docker calls; render returns the given effective model JSON. */
function stubDocker(rendered: object | null, stderr = '', snapshot = { containers: [], networks: [], volumes: [] }) {
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockResolvedValue({
rendered: rendered === null ? null : JSON.stringify(rendered),
stderr,
code: rendered === null ? 1 : 0,
timedOut: false,
}),
} as unknown as ComposeService);
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockResolvedValue(snapshot),
} as unknown as DockerController);
}
function writeStack(stack: string, content = 'services:\n web:\n image: nginx:latest\n ports:\n - "8080:80"\n') {
const dir = path.join(process.env.COMPOSE_DIR as string, stack);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'compose.yaml'), content);
return dir;
}
beforeAll(async () => {
tmpDir = await setupTestDb();
await import('../index');
({ DatabaseService } = await import('../services/DatabaseService'));
({ ComposeDoctorService, parseUnsetEnvVars, parseMissingRequiredVars } = 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']);
});
});
describe('ComposeService.renderConfig path guard', () => {
it('rejects an invalid stack name without spawning docker', async () => {
await expect(ComposeService.getInstance(nodeId).renderConfig('../escape')).rejects.toThrow('Invalid stack path');
});
});
describe('runPreflight', () => {
const STACK = 'doctorrun';
beforeEach(() => { writeStack(STACK); });
afterEach(() => { fs.rmSync(path.join(process.env.COMPOSE_DIR as string, STACK), { recursive: true, force: true }); });
it('derives status from the highest finding and persists the run', async () => {
stubDocker(
{ name: STACK, services: { web: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }], environment: { APP_SECRET: SECRET } } }, networks: {}, volumes: {} },
'WARN The "MISSING" variable is not set. Defaulting to a blank string.',
);
const report = await doctor().runPreflight(nodeId, STACK, 'tester');
expect(report.renderable).toBe(true);
expect(report.status).toBe('high'); // env-unset + 0.0.0.0 exposure are high
expect(report.highestSeverity).toBe('high');
expect(report.findings.map(f => f.ruleId)).toEqual(expect.arrayContaining(['env-unset', 'port-exposed-all-interfaces', 'image-latest', 'no-healthcheck']));
expect(report.ranBy).toBe('tester');
expect(report.sourceHash).toBeTruthy();
// Persisted and retrievable.
const stored = db().getLatestPreflightRun(nodeId, STACK);
expect(stored?.status).toBe('high');
expect(db().getPreflightFindings(stored!.id).length).toBe(report.findings.length);
const latest = doctor().getLatest(nodeId, STACK);
expect(latest.findings.length).toBe(report.findings.length);
expect(latest.ranBy).toBe('tester');
});
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);
const runs = JSON.stringify(db().getDb().prepare('SELECT * FROM preflight_runs').all());
const findings = JSON.stringify(db().getDb().prepare('SELECT * FROM preflight_findings').all());
expect(runs).not.toContain(SECRET);
expect(findings).not.toContain(SECRET);
expect(JSON.stringify(report)).not.toContain(SECRET);
});
it('replaces the prior run rather than accumulating', async () => {
stubDocker({ name: STACK, services: { web: { image: 'nginx:latest' } }, networks: {}, volumes: {} });
await doctor().runPreflight(nodeId, STACK, null);
vi.restoreAllMocks();
stubDocker({ name: STACK, services: { web: { image: 'nginx:1.27', restart: 'always', healthcheck: { test: ['CMD', 'true'] } } }, networks: {}, volumes: {} });
await doctor().runPreflight(nodeId, STACK, null);
const allRuns = db().getDb().prepare('SELECT * FROM preflight_runs WHERE node_id = ? AND stack_name = ?').all(nodeId, STACK);
expect(allRuns).toHaveLength(1);
expect(doctor().getLatest(nodeId, STACK).status).toBe('pass');
});
it('returns an unrenderable report and never stores raw stderr', async () => {
stubDocker(null, `bad yaml near ${SECRET}`); // stderr can echo arbitrary file content
const report = await doctor().runPreflight(nodeId, STACK, null);
expect(report.renderable).toBe(false);
expect(report.status).toBe('unrenderable');
expect(report.findings.map(f => f.ruleId)).toEqual(['render-failed']);
// Raw stderr is never surfaced, so an arbitrary secret in it cannot leak.
expect(report.renderError).not.toContain(SECRET);
expect(report.renderError).not.toContain('bad yaml');
const findings = JSON.stringify(db().getDb().prepare('SELECT * FROM preflight_findings').all());
expect(findings).not.toContain(SECRET);
});
it('names a missing required variable in the render error without echoing the value', async () => {
stubDocker(null, `required variable "DB_PASS" is missing a value: ${SECRET}`);
const report = await doctor().runPreflight(nodeId, STACK, null);
expect(report.renderError).toContain('DB_PASS');
expect(report.renderError).not.toContain(SECRET);
});
it('getLatest round-trips an unrenderable run from the database', async () => {
stubDocker(null, 'boom');
await doctor().runPreflight(nodeId, STACK, null);
vi.restoreAllMocks(); // getLatest is a pure DB read; no docker needed
const latest = doctor().getLatest(nodeId, STACK);
expect(latest.renderable).toBe(false);
expect(latest.status).toBe('unrenderable');
expect(latest.renderError).toBeTruthy();
expect(latest.findings.map(f => f.ruleId)).toEqual(['render-failed']);
});
it('degrades to model-only findings when the node snapshot fails', async () => {
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockResolvedValue({
rendered: JSON.stringify({ name: STACK, services: { web: { image: 'nginx:latest' } }, networks: {}, volumes: {} }),
stderr: '', code: 0, timedOut: false,
}),
} as unknown as ComposeService);
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockRejectedValue(new Error('docker down')),
} as unknown as DockerController);
const report = await doctor().runPreflight(nodeId, STACK, null);
expect(report.renderable).toBe(true);
expect(report.findings.map(f => f.ruleId)).toContain('image-latest'); // model rule still ran
expect(report.findings.map(f => f.ruleId)).not.toContain('port-conflict-node'); // node-state skipped
});
it('orders findings by severity, highest first', async () => {
stubDocker({
name: STACK,
services: {
a: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }], container_name: 'dup' },
b: { image: 'nginx:1.27', container_name: 'dup', restart: 'always', healthcheck: { test: ['CMD', 'x'] } },
},
networks: {}, volumes: {},
});
const r = await doctor().runPreflight(nodeId, STACK, null);
const rank = { blocker: 3, high: 2, warning: 1, info: 0 } as const;
const ranks = r.findings.map(f => rank[f.severity]);
expect(ranks).toEqual([...ranks].sort((x, y) => y - x)); // non-increasing
expect(r.findings[0].severity).toBe('blocker'); // duplicate container_name
});
});
describe('getLatest', () => {
it('returns a never-run sentinel before any run', () => {
const r = doctor().getLatest(nodeId, 'nostackyet');
expect(r.status).toBe('never-run');
expect(r.ranAt).toBeNull();
expect(r.findings).toEqual([]);
});
});
describe('node deletion cleanup', () => {
it('removes preflight runs and findings for a deleted node', () => {
const ghostNode = 987654;
db().replacePreflightRun(
{ id: 'run-x', node_id: ghostNode, stack_name: 's', source_hash: null, rendered_hash: null, status: 'pass', highest_severity: null, created_at: 1, created_by: null },
[{ id: 'find-x', run_id: 'run-x', rule_id: 'privileged', severity: 'high', title: 't', message: 'm', source_path: null, remediation: null, service: 's', created_at: 1 }],
);
expect(db().getLatestPreflightRun(ghostNode, 's')).toBeDefined();
db().deleteNode(ghostNode);
expect(db().getLatestPreflightRun(ghostNode, 's')).toBeUndefined();
expect(db().getPreflightFindings('run-x')).toEqual([]);
});
});
@@ -0,0 +1,119 @@
/**
* parseEffectiveModel: turns `docker compose config --format json` output into
* the structural facts the preflight rules need. The critical property is that
* it never retains an environment VALUE (only key names).
*/
import { describe, it, expect } from 'vitest';
import { parseEffectiveModel } from '../services/preflight/effectiveModel';
const SECRET = 'topsecret-9f3a-value';
function render() {
return {
name: 'myapp',
services: {
web: {
image: 'nginx:latest',
ports: [
{ mode: 'ingress', target: 80, published: '8080', protocol: 'tcp' },
{ target: 53, published: '5300', protocol: 'udp', host_ip: '127.0.0.1' },
{ target: 90, published: '9000-9002', protocol: 'tcp' },
],
volumes: [
{ type: 'bind', source: '/srv/data', target: '/data' },
{ type: 'volume', source: 'cache', target: '/var/cache' },
],
privileged: true,
network_mode: 'host',
restart: 'unless-stopped',
healthcheck: { test: ['CMD', 'true'] },
deploy: { placement: { constraints: [] } },
container_name: 'web1',
user: '1000:1000',
environment: { DB_PASSWORD: SECRET, PUID: '1000' },
},
},
networks: {
default: { name: 'myapp_default' },
shared: { name: 'shared_net', external: true },
},
volumes: {
cache: { name: 'myapp_cache' },
ext: { name: 'shared_vol', external: true },
},
};
}
describe('parseEffectiveModel', () => {
it('extracts structural facts from the rendered model', () => {
const m = parseEffectiveModel(render(), 'fallback');
expect(m.projectName).toBe('myapp');
const web = m.services[0];
expect(web.name).toBe('web');
expect(web.image).toBe('nginx:latest');
expect(web.privileged).toBe(true);
expect(web.networkMode).toBe('host');
expect(web.restart).toBe('unless-stopped');
expect(web.hasHealthcheck).toBe(true);
expect(web.deploy).toBeDefined();
expect(web.containerName).toBe('web1');
expect(web.user).toBe('1000:1000');
expect(web.binds).toEqual([{ source: '/srv/data', target: '/data' }]);
expect(web.namedVolumes).toEqual(['cache']);
});
it('parses ports with host IP, protocol, and ranges', () => {
const web = parseEffectiveModel(render(), 'fallback').services[0];
expect(web.ports).toEqual([
{ startPort: 8080, endPort: 8080, hostIp: '', protocol: 'tcp' },
{ startPort: 5300, endPort: 5300, hostIp: '127.0.0.1', protocol: 'udp' },
{ startPort: 9000, endPort: 9002, hostIp: '', protocol: 'tcp' },
]);
});
it('resolves top-level networks and volumes with external flags', () => {
const m = parseEffectiveModel(render(), 'fallback');
expect(m.networks.shared).toEqual({ name: 'shared_net', external: true });
expect(m.networks.default).toEqual({ name: 'myapp_default', external: false });
expect(m.volumes.ext).toEqual({ name: 'shared_vol', external: true });
expect(m.volumes.cache).toEqual({ name: 'myapp_cache', external: false });
});
it('reads environment KEY names only, never values', () => {
const m = parseEffectiveModel(render(), 'fallback');
expect(m.services[0].envKeys).toEqual(['DB_PASSWORD', 'PUID']);
// The secret value must not survive anywhere in the parsed model.
expect(JSON.stringify(m)).not.toContain(SECRET);
});
it('reads env key names from the array form without keeping the value', () => {
const m = parseEffectiveModel(
{ services: { api: { environment: [`TOKEN=${SECRET}`, 'MODE=prod'] } } },
'fallback',
);
expect(m.services[0].envKeys).toEqual(['TOKEN', 'MODE']);
expect(JSON.stringify(m)).not.toContain(SECRET);
});
it('parses the short-string port form and drops container-only EXPOSE', () => {
const m = parseEffectiveModel({ services: { s: { ports: ['127.0.0.1:8080:80/udp', '8443:443', '90'] } } }, 'p');
expect(m.services[0].ports).toEqual([
{ startPort: 8080, endPort: 8080, hostIp: '127.0.0.1', protocol: 'udp' },
{ startPort: 8443, endPort: 8443, hostIp: '', protocol: 'tcp' },
]);
});
it('treats a disabled healthcheck as none', () => {
const m = parseEffectiveModel({ services: { web: { healthcheck: { disable: true } } } }, 'fallback');
expect(m.services[0].hasHealthcheck).toBe(false);
});
it('falls back to the provided project name and yields an empty model for garbage', () => {
const m = parseEffectiveModel({ services: {} }, 'mystack');
expect(m.projectName).toBe('mystack');
expect(m.services).toEqual([]);
const empty = parseEffectiveModel(null, 'mystack');
expect(empty.services).toEqual([]);
expect(empty.networks).toEqual({});
});
});
@@ -0,0 +1,82 @@
/**
* Compose Doctor routes: GET returns the stored run (never-run before any run),
* POST runs and persists. Both require stack:read and reject unauthenticated and
* missing-stack requests. Docker render + snapshot are mocked.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import DockerController from '../services/DockerController';
import { ComposeService } from '../services/ComposeService';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
const STACK = 'preflightroute';
function stub() {
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockResolvedValue({
rendered: JSON.stringify({ name: STACK, services: { web: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }] } }, networks: {}, volumes: {} }),
stderr: '', code: 0, timedOut: false,
}),
} as unknown as ComposeService);
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockResolvedValue({ containers: [], networks: [], volumes: [] }),
} as unknown as DockerController);
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' })}`;
});
afterAll(() => cleanupTestDb(tmpDir));
describe('preflight routes', () => {
let stackDir: string;
beforeEach(() => {
stackDir = path.join(process.env.COMPOSE_DIR as string, STACK);
fs.mkdirSync(stackDir, { recursive: true });
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n ports:\n - "8080:80"\n');
stub();
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(stackDir, { recursive: true, force: true });
});
it('GET returns a never-run report before any run', async () => {
const res = await request(app).get(`/api/stacks/${STACK}/preflight`).set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.status).toBe('never-run');
expect(res.body.findings).toEqual([]);
});
it('POST runs preflight, persists, and GET then returns the stored run', async () => {
const run = await request(app).post(`/api/stacks/${STACK}/preflight/run`).set('Authorization', authHeader);
expect(run.status).toBe(200);
expect(run.body.renderable).toBe(true);
expect(run.body.findings.length).toBeGreaterThan(0);
expect(run.body.findings.map((f: { ruleId: string }) => f.ruleId)).toContain('port-exposed-all-interfaces');
const get = await request(app).get(`/api/stacks/${STACK}/preflight`).set('Authorization', authHeader);
expect(get.body.status).toBe(run.body.status);
expect(get.body.findings.length).toBe(run.body.findings.length);
});
it('rejects an unauthenticated request', async () => {
const res = await request(app).get(`/api/stacks/${STACK}/preflight`);
expect(res.status).toBe(401);
});
it('returns 404 for a stack that does not exist', async () => {
const res = await request(app).post('/api/stacks/nope-not-here/preflight/run').set('Authorization', authHeader);
expect(res.status).toBe(404);
});
});
@@ -0,0 +1,247 @@
/**
* The preflight rule registry. Each rule is a pure function over a
* PreflightContext; these tests assert each fires on its positive case, stays
* silent otherwise, and carries the right severity. They also pin the port
* conflict semantics (protocol, interface overlap, same-stack, ranges) and keep
* the registry aligned with the documented rule set.
*/
import { describe, it, expect } from 'vitest';
import { runRules, RULE_IDS } from '../services/preflight/rules';
import type { EffService, EffectiveModel } from '../services/preflight/effectiveModel';
import type { PreflightContext, PreflightFinding } from '../services/preflight/types';
function svc(over: Partial<EffService> = {}): EffService {
return {
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [],
privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [], ...over,
};
}
function model(services: EffService[], over: Partial<EffectiveModel> = {}): EffectiveModel {
return { projectName: 'proj', services, networks: {}, volumes: {}, ...over };
}
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: [],
sourceServiceNames: m ? m.services.map(s => s.name) : [], sourceReadable: true,
nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(),
existingContainers: [], bindChecks: [], ...over,
};
}
const ids = (findings: PreflightFinding[], ruleId: string) => findings.filter(f => f.ruleId === ruleId);
describe('render-failed', () => {
it('fires only when the model is unrenderable', () => {
const f = runRules(ctx({ renderable: false, model: null, renderError: 'boom' }));
expect(ids(f, 'render-failed')).toHaveLength(1);
expect(ids(f, 'render-failed')[0].severity).toBe('blocker');
expect(ids(f, 'render-failed')[0].message).toContain('boom');
});
it('stays silent and runs model rules when renderable', () => {
expect(ids(runRules(ctx({ model: model([svc()]) })), 'render-failed')).toHaveLength(0);
});
});
describe('env-unset', () => {
it('emits one high finding per unset variable name', () => {
const f = ids(runRules(ctx({ unsetEnvVars: ['FOO', 'BAR'] })), 'env-unset');
expect(f).toHaveLength(2);
expect(f[0].severity).toBe('high');
expect(f.map(x => x.sourcePath)).toEqual(['FOO', 'BAR']);
});
});
describe('port-conflict-node', () => {
const withPort = (proto = 'tcp', hostIp = '') => model([svc({ ports: [{ startPort: 8080, endPort: 8080, hostIp, protocol: proto }] })]);
it('blocks a port held by a different stack', () => {
const f = runRules(ctx({ model: withPort(), nodePorts: [{ publishedPort: 8080, protocol: 'tcp', ip: '', stack: 'other' }] }));
expect(ids(f, 'port-conflict-node')).toHaveLength(1);
expect(ids(f, 'port-conflict-node')[0].severity).toBe('blocker');
});
it('ignores the same stack reusing its own port', () => {
const f = runRules(ctx({ stackName: 'proj', model: withPort(), nodePorts: [{ publishedPort: 8080, protocol: 'tcp', ip: '', stack: 'proj' }] }));
expect(ids(f, 'port-conflict-node')).toHaveLength(0);
});
it('does not conflict TCP with UDP on the same number', () => {
const f = runRules(ctx({ model: withPort('tcp'), nodePorts: [{ publishedPort: 8080, protocol: 'udp', ip: '', stack: 'other' }] }));
expect(ids(f, 'port-conflict-node')).toHaveLength(0);
});
it('treats a loopback bind as overlapping an all-interfaces bind', () => {
const f = runRules(ctx({ model: withPort('tcp', '127.0.0.1'), nodePorts: [{ publishedPort: 8080, protocol: 'tcp', ip: '', stack: 'other' }] }));
expect(ids(f, 'port-conflict-node')).toHaveLength(1);
});
it('catches a port inside a published range', () => {
const m = model([svc({ ports: [{ startPort: 9000, endPort: 9002, hostIp: '', protocol: 'tcp' }] })]);
const f = runRules(ctx({ model: m, nodePorts: [{ publishedPort: 9001, protocol: 'tcp', ip: '', stack: 'other' }] }));
expect(ids(f, 'port-conflict-node')).toHaveLength(1);
});
});
describe('port-conflict-internal', () => {
it('blocks two services publishing the same host port', () => {
const m = model([
svc({ name: 'a', ports: [{ startPort: 80, endPort: 80, hostIp: '', protocol: 'tcp' }] }),
svc({ name: 'b', ports: [{ startPort: 80, endPort: 80, hostIp: '', protocol: 'tcp' }] }),
]);
expect(ids(runRules(ctx({ model: m })), 'port-conflict-internal')).toHaveLength(1);
});
it('allows the same number on different interfaces', () => {
const m = model([
svc({ name: 'a', ports: [{ startPort: 80, endPort: 80, hostIp: '127.0.0.1', protocol: 'tcp' }] }),
svc({ name: 'b', ports: [{ startPort: 80, endPort: 80, hostIp: '192.168.1.5', protocol: 'tcp' }] }),
]);
expect(ids(runRules(ctx({ model: m })), 'port-conflict-internal')).toHaveLength(0);
});
});
describe('port-exposed-all-interfaces', () => {
it('flags an all-interfaces bind but not a loopback bind', () => {
const open = model([svc({ ports: [{ startPort: 80, endPort: 80, hostIp: '', protocol: 'tcp' }] })]);
const local = model([svc({ ports: [{ startPort: 80, endPort: 80, hostIp: '127.0.0.1', protocol: 'tcp' }] })]);
expect(ids(runRules(ctx({ model: open })), 'port-exposed-all-interfaces')).toHaveLength(1);
expect(ids(runRules(ctx({ model: local })), 'port-exposed-all-interfaces')).toHaveLength(0);
});
it('treats :: (IPv6 all-interfaces) as exposed and overlapping', () => {
const v6 = model([svc({ ports: [{ startPort: 80, endPort: 80, hostIp: '::', protocol: 'tcp' }] })]);
expect(ids(runRules(ctx({ model: v6 })), 'port-exposed-all-interfaces')).toHaveLength(1);
const f = runRules(ctx({ model: v6, nodePorts: [{ publishedPort: 80, protocol: 'tcp', ip: '127.0.0.1', stack: 'other' }] }));
expect(ids(f, 'port-conflict-node')).toHaveLength(1);
});
});
describe('bind-path-missing / bind-path-permission', () => {
it('flags a missing within-base bind as high', () => {
const f = runRules(ctx({ bindChecks: [{ service: 'web', source: '/base/proj/data', target: '/data', withinBase: true, exists: false, ownerUid: null }] }));
expect(ids(f, 'bind-path-missing')).toHaveLength(1);
expect(ids(f, 'bind-path-missing')[0].severity).toBe('high');
});
it('does not assert an absolute (outside-base) bind as missing', () => {
const f = runRules(ctx({ bindChecks: [{ service: 'web', source: '/mnt/media', target: '/media', withinBase: false, exists: false, ownerUid: null }] }));
expect(ids(f, 'bind-path-missing')).toHaveLength(0);
});
it('warns on a root-owned within-base bind when the service drops privileges', () => {
const m = model([svc({ envKeys: ['PUID'] })]);
const bind = { service: 'web', source: '/base/proj/data', target: '/data', withinBase: true, exists: true, ownerUid: 0 };
expect(ids(runRules(ctx({ model: m, bindChecks: [bind] })), 'bind-path-permission')).toHaveLength(1);
});
it('skips the ownership heuristic on Windows', () => {
const m = model([svc({ envKeys: ['PUID'] })]);
const bind = { service: 'web', source: 'C:/base/proj/data', target: '/data', withinBase: true, exists: true, ownerUid: 0 };
expect(ids(runRules(ctx({ platform: 'win32', model: m, bindChecks: [bind] })), 'bind-path-permission')).toHaveLength(0);
});
});
describe('security rules', () => {
it('flags a docker socket mount', () => {
const m = model([svc({ binds: [{ source: '/var/run/docker.sock', target: '/var/run/docker.sock' }] })]);
expect(ids(runRules(ctx({ model: m })), 'docker-socket-mount')[0].severity).toBe('high');
});
it('flags privileged and host networking', () => {
expect(ids(runRules(ctx({ model: model([svc({ privileged: true })]) })), 'privileged')).toHaveLength(1);
expect(ids(runRules(ctx({ model: model([svc({ networkMode: 'host' })]) })), 'network-mode-host')).toHaveLength(1);
});
});
describe('uid-gid-risk', () => {
it('fires only for unverifiable (outside-base) binds', () => {
const m = model([svc({ name: 'web', envKeys: ['PUID'] })]);
const outside = [{ service: 'web', source: '/mnt/x', target: '/x', withinBase: false, exists: false, ownerUid: null }];
const inside = [{ service: 'web', source: '/base/proj/x', target: '/x', withinBase: true, exists: true, ownerUid: 1000 }];
expect(ids(runRules(ctx({ model: m, bindChecks: outside })), 'uid-gid-risk')).toHaveLength(1);
expect(ids(runRules(ctx({ model: m, bindChecks: inside })), 'uid-gid-risk')).toHaveLength(0);
});
});
describe('hygiene rules', () => {
it('flags a moving image tag but not a pinned one', () => {
expect(ids(runRules(ctx({ model: model([svc({ image: 'nginx:latest' })]) })), 'image-latest')).toHaveLength(1);
expect(ids(runRules(ctx({ model: model([svc({ image: 'nginx' })]) })), 'image-latest')).toHaveLength(1);
expect(ids(runRules(ctx({ model: model([svc({ image: 'nginx:1.27' })]) })), 'image-latest')).toHaveLength(0);
expect(ids(runRules(ctx({ model: model([svc({ image: 'nginx@sha256:abc' })]) })), 'image-latest')).toHaveLength(0);
});
it('flags a missing restart policy and healthcheck', () => {
const bare = model([svc({ restart: undefined, hasHealthcheck: false })]);
expect(ids(runRules(ctx({ model: bare })), 'no-restart-policy')).toHaveLength(1);
expect(ids(runRules(ctx({ model: bare })), 'no-healthcheck')).toHaveLength(1);
const withDeployRestart = model([svc({ restart: undefined, deploy: { restart_policy: { condition: 'any' } }})]);
expect(ids(runRules(ctx({ model: withDeployRestart })), 'no-restart-policy')).toHaveLength(0);
});
it('flags swarm-only deploy fields but not honored ones', () => {
expect(ids(runRules(ctx({ model: model([svc({ deploy: { placement: {} }})]) })), 'deploy-swarm-only')).toHaveLength(1);
expect(ids(runRules(ctx({ model: model([svc({ deploy: { replicas: 3 }})]) })), 'deploy-swarm-only')).toHaveLength(0);
});
});
describe('network / volume rules', () => {
it('blocks a missing external network and volume', () => {
const m = model([svc()], { networks: { ext: { name: 'shared', external: true } }, volumes: { v: { name: 'data', external: true } } });
const f = runRules(ctx({ model: m }));
expect(ids(f, 'external-network-missing')).toHaveLength(1);
expect(ids(f, 'external-volume-missing')).toHaveLength(1);
});
it('does not block an external resource that exists', () => {
const m = model([svc()], { networks: { ext: { name: 'shared', external: true } } });
const f = runRules(ctx({ model: m, existingNetworkNames: new Set(['shared']) }));
expect(ids(f, 'external-network-missing')).toHaveLength(0);
});
it('reports a new network/volume as info when absent on the node', () => {
const m = model([svc()], { networks: { backend: { name: 'backend', external: false } }, volumes: { data: { name: 'data', external: false } } });
const f = runRules(ctx({ model: m }));
expect(ids(f, 'new-network')[0].severity).toBe('info');
expect(ids(f, 'new-volume')[0].message).toContain('proj_data');
});
});
describe('container_name rules', () => {
it('blocks a duplicate container_name within the stack', () => {
const m = model([svc({ name: 'a', containerName: 'dup' }), svc({ name: 'b', containerName: 'dup' })]);
expect(ids(runRules(ctx({ model: m })), 'container-name-internal-dup')[0].severity).toBe('blocker');
});
it('blocks a container_name owned by a different stack', () => {
const m = model([svc({ containerName: 'taken' })]);
const f = runRules(ctx({ model: m, existingContainers: [{ name: 'taken', stack: 'other' }] }));
expect(ids(f, 'container-name-collision')[0].severity).toBe('blocker');
});
it('does not flag a container_name owned by the same stack', () => {
const m = model([svc({ containerName: 'mine' })]);
const f = runRules(ctx({ stackName: 'proj', model: m, existingContainers: [{ name: 'mine', stack: 'proj' }] }));
expect(ids(f, 'container-name-collision')).toHaveLength(0);
});
});
describe('effective-model-expanded', () => {
it('flags services present in the rendered model but not the source', () => {
const m = model([svc({ name: 'web' }), svc({ name: 'sidecar' })]);
const f = runRules(ctx({ model: m, sourceServiceNames: ['web'] }));
expect(ids(f, 'effective-model-expanded')).toHaveLength(1);
expect(ids(f, 'effective-model-expanded')[0].message).toContain('sidecar');
});
it('stays silent when source and effective services match', () => {
const m = model([svc({ name: 'web' })]);
expect(ids(runRules(ctx({ model: m, sourceServiceNames: ['web'] })), 'effective-model-expanded')).toHaveLength(0);
});
it('stays silent when the source could not be read (empty != zero services)', () => {
const m = model([svc({ name: 'web' }), svc({ name: 'sidecar' })]);
const f = runRules(ctx({ model: m, sourceServiceNames: [], sourceReadable: false }));
expect(ids(f, 'effective-model-expanded')).toHaveLength(0);
});
});
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',
'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',
'container-name-internal-dup', 'container-name-collision', 'effective-model-expanded',
];
it('the registry contains exactly the expected rules', () => {
expect([...RULE_IDS].sort()).toEqual([...EXPECTED_RULE_IDS].sort());
});
});
+34
View File
@@ -15,6 +15,7 @@ import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../se
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { buildStackDriftReport, type DriftFindingKind, type StackDriftReport } from '../services/DriftDetectionService';
import { DriftLedgerService, type DriftTemporal } from '../services/DriftLedgerService';
import { ComposeDoctorService } from '../services/ComposeDoctorService';
import { requirePermission, checkPermission } from '../middleware/permissions';
import { NotificationService, type NotificationCategory } from '../services/NotificationService';
import { StackOpLockService, type StackOpAction } from '../services/StackOpLockService';
@@ -1086,6 +1087,39 @@ stacksRouter.post('/:stackName/drift/recheck', async (req: Request, res: Respons
}
});
// Compose Doctor: GET returns the last stored preflight run (or a never-run
// sentinel); it is a side-effect-free read. Both routes auto-proxy to the active
// node, so a remote stack is preflighted on the node that actually owns it.
stacksRouter.get('/:stackName/preflight', 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(ComposeDoctorService.getInstance().getLatest(req.nodeId, stackName));
} catch (error) {
console.error('[Stacks] Failed to load preflight for %s:', sanitizeForLog(stackName),
sanitizeForLog(inspect(error, { depth: 4 })));
res.status(500).json({ error: 'Failed to load preflight report' });
}
});
// Running preflight renders the effective model and stores the result, replacing
// any prior run. It is advisory and never blocks a deploy; stack:read is the
// correct gate since it mutates only the preflight tables, never the stack.
stacksRouter.post('/:stackName/preflight/run', 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 {
const report = await ComposeDoctorService.getInstance().runPreflight(req.nodeId, stackName, req.user?.username ?? null);
res.json(report);
} catch (error) {
console.error('[Stacks] Failed to run preflight for %s:', sanitizeForLog(stackName),
sanitizeForLog(inspect(error, { depth: 4 })));
res.status(500).json({ error: 'Failed to run preflight' });
}
});
stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
@@ -35,6 +35,7 @@ export const CAPABILITIES = [
'registries',
'self-update',
'vulnerability-scanning',
'compose-doctor',
] as const;
export type Capability = (typeof CAPABILITIES)[number];
@@ -0,0 +1,305 @@
import fs from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';
import DockerController from './DockerController';
import { ComposeService } from './ComposeService';
import { FileSystemService } from './FileSystemService';
import { DatabaseService } from './DatabaseService';
import { computeStackHashes } from './DriftLedgerService';
import { parseComposeDependencies } from '../helpers/composeDependencyParse';
import { parseEffectiveModel, type EffectiveModel } from './preflight/effectiveModel';
import { runRules, SEVERITY_RANK, RULE_IDS, RENDER_FAILED_RULE_ID } from './preflight/rules';
import type {
BindCheck, NodePortBinding, PreflightContext, PreflightFinding, PreflightReport, PreflightSeverity, PreflightStatus,
} from './preflight/types';
import { isPathWithinBase } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
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[] {
return [...findings].sort((a, b) =>
(SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]) ||
((ruleOrder.get(a.ruleId) ?? 0) - (ruleOrder.get(b.ruleId) ?? 0)));
}
function highestOf(findings: PreflightFinding[]): PreflightSeverity | null {
let best: PreflightSeverity | null = null;
for (const f of findings) {
if (best === null || SEVERITY_RANK[f.severity] > SEVERITY_RANK[best]) best = f.severity;
}
return best;
}
/**
* Compose Doctor: renders the effective model and runs the deterministic
* preflight rule registry against the active node. Advisory only (it never
* blocks a deploy), read-only with respect to the stack, and node-scoped. It
* never stores, returns, or logs an environment value.
*/
export class ComposeDoctorService {
private static instance: ComposeDoctorService | null = null;
static getInstance(): ComposeDoctorService {
if (!ComposeDoctorService.instance) ComposeDoctorService.instance = new ComposeDoctorService();
return ComposeDoctorService.instance;
}
private constructor() { /* singleton */ }
/** Run all checks, persist the result (replacing any prior run), return the report. */
async runPreflight(nodeId: number, stackName: string, ranBy: string | null): Promise<PreflightReport> {
const fsSvc = FileSystemService.getInstance(nodeId);
let source: string | null = null;
try {
source = await fsSvc.getStackContent(stackName);
} catch (err) {
// An unreadable source is logged here (not silently swallowed) so a later
// skipped hash or source comparison is traceable.
console.warn('[ComposeDoctor] Source unreadable for %s; source-derived checks skipped:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown')));
}
const sourceReadable = source !== null;
// renderedHash is the parsed-SOURCE-model hash (the same `rendered_hash`
// meaning the drift ledger uses), deliberately not a hash of docker's
// rendered output, which inlines resolved env values and must never be hashed.
const hashes = source !== null
? computeStackHashes(source)
: { sourceHash: null as string | null, renderedHash: null as string | null };
const sourceServiceNames = source !== null ? parseComposeDependencies(source).services.map(s => s.name) : [];
const ctx = await this.buildContext(nodeId, stackName, sourceServiceNames, sourceReadable);
const findings = sortFindings(runRules(ctx));
const highestSeverity = highestOf(findings);
const status: PreflightStatus = !ctx.renderable ? 'unrenderable' : (highestSeverity ?? 'pass');
const report: PreflightReport = {
stack: stackName,
ranAt: Date.now(),
ranBy,
renderable: ctx.renderable,
renderError: ctx.renderError,
status,
highestSeverity,
sourceHash: hashes.sourceHash,
renderedHash: hashes.renderedHash,
findings,
};
this.persist(nodeId, report);
return report;
}
/** Read the last stored run for a stack, mapped to the report shape. */
getLatest(nodeId: number, stackName: string): PreflightReport {
const db = DatabaseService.getInstance();
const run = db.getLatestPreflightRun(nodeId, stackName);
if (!run) {
return {
stack: stackName, ranAt: null, ranBy: null, renderable: true, renderError: null,
status: 'never-run', highestSeverity: null, sourceHash: null, renderedHash: null, findings: [],
};
}
const findings = sortFindings(db.getPreflightFindings(run.id).map(r => ({
ruleId: r.rule_id,
severity: r.severity as PreflightSeverity,
title: r.title,
message: r.message,
sourcePath: r.source_path ?? undefined,
remediation: r.remediation ?? undefined,
service: r.service ?? undefined,
})));
const renderable = run.status !== 'unrenderable';
// The render error is carried by the render-failed finding, not a column.
const renderError = renderable ? null : (findings.find(f => f.ruleId === RENDER_FAILED_RULE_ID)?.message ?? null);
return {
stack: stackName,
ranAt: run.created_at,
ranBy: run.created_by,
renderable,
renderError,
status: run.status as PreflightStatus,
highestSeverity: (run.highest_severity as PreflightSeverity | null) ?? null,
sourceHash: run.source_hash,
renderedHash: run.rendered_hash,
findings,
};
}
private async buildContext(nodeId: number, stackName: string, sourceServiceNames: string[], sourceReadable: boolean): Promise<PreflightContext> {
const fsSvc = FileSystemService.getInstance(nodeId);
const baseDir = fsSvc.getBaseDir();
let renderable = false;
let renderError: string | null = null;
let model: EffectiveModel | null = null;
let unsetEnvVars: string[] = [];
try {
const result = await ComposeService.getInstance(nodeId).renderConfig(stackName);
if (result.rendered !== null) {
// Unset-variable warnings come from stderr and do not depend on the
// model parsing, so capture them before attempting the parse, so a parse
// failure does not also suppress the env-unset findings.
unsetEnvVars = parseUnsetEnvVars(result.stderr);
try {
model = parseEffectiveModel(JSON.parse(result.rendered), stackName);
renderable = true;
} catch (parseErr) {
// JSON.parse errors carry no file content, so the message is safe to log.
console.warn('[ComposeDoctor] Effective model parse failed for %s:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(parseErr, 'unknown')));
renderError = 'Sencho could not parse the rendered Compose model.';
}
} else {
// The raw stderr from `docker compose config` can echo file content
// (and therefore secrets), so it is never stored. We surface only safe,
// structural signals: the names of any required variables Compose
// reported as missing, otherwise a generic nudge.
const missing = parseMissingRequiredVars(result.stderr);
renderError = missing.length
? `Required variable${missing.length > 1 ? 's' : ''} ${missing.join(', ')} ${missing.length > 1 ? 'have' : 'has'} no value, so the effective model cannot be rendered.`
: 'Sencho could not render the effective Compose model. Check the compose and env files for a YAML syntax error, an unresolved include or merge, or a required variable with no value, then re-run.';
}
} catch (err) {
// Spawn failure (docker unavailable). Spawn errors carry no file content;
// redact defensively anyway.
renderError = redactSensitiveText(getErrorMessage(err, 'docker compose could not be started.')).slice(0, MAX_RENDER_ERROR).trim()
|| 'Sencho could not run docker compose on this node.';
}
const { nodePorts, existingNetworkNames, existingVolumeNames, existingContainers } = await this.nodeState(nodeId, fsSvc, stackName);
const bindChecks = model ? await this.resolveBindChecks(model, baseDir) : [];
return {
stackName,
platform: process.platform,
model,
renderable,
renderError,
unsetEnvVars,
sourceServiceNames,
sourceReadable,
nodePorts,
existingNetworkNames,
existingVolumeNames,
existingContainers,
bindChecks,
};
}
/** Snapshot the node's ports/networks/volumes/containers. Degrades to empty if Docker is unreachable. */
private async nodeState(nodeId: number, fsSvc: FileSystemService, stackName: string): Promise<{
nodePorts: NodePortBinding[];
existingNetworkNames: Set<string>;
existingVolumeNames: Set<string>;
existingContainers: { name: string; stack: string | null }[];
}> {
try {
const knownStacks = await fsSvc.getStacks();
const snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(knownStacks);
const nodePorts = snapshot.containers.flatMap(c =>
c.ports.map(p => ({ publishedPort: p.publishedPort, protocol: p.protocol, ip: p.ip, stack: c.stack })));
return {
nodePorts,
existingNetworkNames: new Set(snapshot.networks.map(n => n.name)),
existingVolumeNames: new Set(snapshot.volumes.map(v => v.name)),
existingContainers: snapshot.containers.map(c => ({ name: c.name, stack: c.stack })),
};
} catch (error) {
console.warn('[ComposeDoctor] Node snapshot unavailable for %s; node-state checks skipped:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
return { nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(), existingContainers: [] };
}
}
/**
* Stat each bind-mount source. Existence/ownership is probed ONLY for sources
* that resolve inside the node's compose base dir (relative binds); absolute
* host paths are outside Sencho's filesystem view and are left unverified.
*/
private async resolveBindChecks(model: EffectiveModel, baseDir: string): Promise<BindCheck[]> {
const resolvedBase = path.resolve(baseDir);
const checks: BindCheck[] = [];
for (const svc of model.services) {
for (const bind of svc.binds) {
const withinBase = isPathWithinBase(path.resolve(bind.source), resolvedBase);
let exists = false;
let ownerUid: number | null = null;
if (withinBase) {
try {
const st = await fs.promises.stat(bind.source);
exists = true;
ownerUid = typeof st.uid === 'number' ? st.uid : null;
} catch {
exists = false;
}
}
checks.push({ service: svc.name, source: bind.source, target: bind.target, withinBase, exists, ownerUid });
}
}
return checks;
}
/** Persist the run, replacing any prior run for this stack. Best-effort. */
private persist(nodeId: number, report: PreflightReport): void {
if (report.ranAt === null) return;
try {
const runId = randomUUID();
DatabaseService.getInstance().replacePreflightRun(
{
id: runId,
node_id: nodeId,
stack_name: report.stack,
source_hash: report.sourceHash,
rendered_hash: report.renderedHash,
status: report.status,
highest_severity: report.highestSeverity,
created_at: report.ranAt,
created_by: report.ranBy,
},
report.findings.map(f => ({
id: randomUUID(),
run_id: runId,
rule_id: f.ruleId,
severity: f.severity,
title: f.title,
message: f.message,
source_path: f.sourcePath ?? null,
remediation: f.remediation ?? null,
service: f.service ?? null,
created_at: report.ranAt!,
})),
);
} catch (error) {
console.error('[ComposeDoctor] Failed to persist preflight run for %s:',
sanitizeForLog(report.stack), sanitizeForLog(getErrorMessage(error, 'unknown')));
}
}
}
+74
View File
@@ -674,4 +674,78 @@ export class ComposeService {
});
});
}
/**
* Render the fully-resolved effective Compose model via `docker compose
* config --format json`. This is the AUTHORED model: it does NOT splice in
* the Sencho Mesh override, so it stays read-only (the override is
* write-generated) and reflects what the user actually edits. The override
* would also add the managed `sencho_mesh` external network and per-service
* mesh attachments, which would make preflight emit a false "external network
* not found" finding, so rendering the authored model is both safer and more
* accurate here.
* Captures stderr (where Compose reports unset variables) and never rejects
* on a non-zero exit, so the Compose Doctor can turn a failed render into a
* finding rather than an exception. Bounded by a timeout and an output cap.
* Rejects only when the docker binary cannot be spawned.
*/
public renderConfig(
stackName: string,
): Promise<{ rendered: string | null; stderr: string; code: number | null; timedOut: boolean }> {
if (!isValidStackName(stackName)) {
return Promise.reject(new Error('Invalid stack path'));
}
// Canonical inline js/path-injection barrier, kept in the same scope as the
// spawn cwd sink below. CodeQL credits neither the wrapped isPathWithinBase
// helper nor a barrier separated from the sink by the Promise-executor
// closure, so the spawn is hoisted out of the executor. startsWith already
// rejects the base dir itself, since base does not start with base + sep.
const baseResolved = path.resolve(this.baseDir);
const stackDir = path.resolve(baseResolved, stackName);
if (!stackDir.startsWith(baseResolved + path.sep)) {
return Promise.reject(new Error('Invalid stack path'));
}
const child = spawn('docker', ['compose', 'config', '--format', 'json'], {
cwd: stackDir,
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
},
});
return new Promise((resolve, reject) => {
const MAX_OUTPUT = 5 * 1024 * 1024; // 5 MiB cap on each stream
const TIMEOUT_MS = 20_000;
let stdout = '';
let stderr = '';
let timedOut = false;
let capped = false;
let settled = false;
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, TIMEOUT_MS);
const finish = (result: { rendered: string | null; stderr: string; code: number | null; timedOut: boolean }) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(result);
};
child.stdout.on('data', (data: Buffer) => {
stdout += data.toString();
if (stdout.length > MAX_OUTPUT && !capped) { capped = true; child.kill('SIGKILL'); }
});
child.stderr.on('data', (data: Buffer) => {
if (stderr.length < MAX_OUTPUT) stderr += data.toString();
});
child.on('error', (err: NodeJS.ErrnoException) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(new Error(describeSpawnError(err, { command: 'docker compose' }).message));
});
child.on('close', (code) => {
if (timedOut) finish({ rendered: null, stderr: stderr.trim() || 'docker compose config timed out', code, timedOut: true });
else if (capped) finish({ rendered: null, stderr: 'Rendered model exceeded the size limit', code, timedOut: false });
else if (code === 0) finish({ rendered: stdout, stderr, code, timedOut: false });
else finish({ rendered: null, stderr: stderr.trim() || `docker compose config failed with code ${code}`, code, timedOut: false });
});
});
}
}
+97
View File
@@ -93,6 +93,33 @@ export interface StackDossier extends StackDossierFields {
updated_at: number;
}
/** A stored Compose Doctor run. Replace-on-run keeps one row per (node, stack). */
export interface PreflightRunRow {
id: string;
node_id: number;
stack_name: string;
source_hash: string | null;
rendered_hash: string | null;
status: string;
highest_severity: string | null;
created_at: number;
created_by: string | null;
}
/** One finding within a stored preflight run. Never carries an environment value. */
export interface PreflightFindingRow {
id: string;
run_id: string;
rule_id: string;
severity: string;
title: string;
message: string;
source_path: string | null;
remediation: string | null;
service: string | null;
created_at: number;
}
/** A persisted drift finding: one service-scoped divergence, open until resolved. */
export interface StackDriftFindingRow {
id: number;
@@ -1216,6 +1243,35 @@ export class DatabaseService {
CREATE INDEX IF NOT EXISTS idx_stack_drift_findings_open
ON stack_drift_findings(node_id, stack_name, resolved_at);
CREATE TABLE IF NOT EXISTS preflight_runs (
id TEXT PRIMARY KEY,
node_id INTEGER NOT NULL,
stack_name TEXT NOT NULL,
source_hash TEXT,
rendered_hash TEXT,
status TEXT NOT NULL CHECK (status IN ('pass','unrenderable','blocker','high','warning','info')),
highest_severity TEXT CHECK (highest_severity IN ('blocker','high','warning','info')),
created_at INTEGER NOT NULL,
created_by TEXT
);
CREATE INDEX IF NOT EXISTS idx_preflight_runs_node_stack
ON preflight_runs(node_id, stack_name);
CREATE TABLE IF NOT EXISTS preflight_findings (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
rule_id TEXT NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('blocker','high','warning','info')),
title TEXT NOT NULL,
message TEXT NOT NULL,
source_path TEXT,
remediation TEXT,
service TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_preflight_findings_run
ON preflight_findings(run_id);
CREATE TABLE IF NOT EXISTS secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
@@ -2206,6 +2262,45 @@ export class DatabaseService {
this.db.prepare('DELETE FROM stack_drift_findings WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
}
// --- Compose Doctor / Preflight ---
/** Store a run and its findings, replacing any prior run for this (node, stack). */
public replacePreflightRun(run: PreflightRunRow, findings: PreflightFindingRow[]): void {
this.transaction(() => {
this.db.prepare(
'DELETE FROM preflight_findings WHERE run_id IN (SELECT id FROM preflight_runs WHERE node_id = ? AND stack_name = ?)'
).run(run.node_id, run.stack_name);
this.db.prepare('DELETE FROM preflight_runs WHERE node_id = ? AND stack_name = ?').run(run.node_id, run.stack_name);
this.db.prepare(
`INSERT INTO preflight_runs
(id, node_id, stack_name, source_hash, rendered_hash, status, highest_severity, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(run.id, run.node_id, run.stack_name, run.source_hash, run.rendered_hash, run.status, run.highest_severity, run.created_at, run.created_by);
const insert = this.db.prepare(
`INSERT INTO preflight_findings
(id, run_id, rule_id, severity, title, message, source_path, remediation, service, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const f of findings) {
insert.run(f.id, f.run_id, f.rule_id, f.severity, f.title, f.message, f.source_path, f.remediation, f.service, f.created_at);
}
});
}
/** The most recent stored run for a stack, or undefined when none exists. */
public getLatestPreflightRun(nodeId: number, stackName: string): PreflightRunRow | undefined {
return this.db.prepare(
'SELECT * FROM preflight_runs WHERE node_id = ? AND stack_name = ? ORDER BY created_at DESC, id DESC LIMIT 1'
).get(nodeId, stackName) as PreflightRunRow | undefined;
}
/** Findings for a run, in insertion order (the caller re-sorts by severity). */
public getPreflightFindings(runId: string): PreflightFindingRow[] {
return this.db.prepare(
'SELECT * FROM preflight_findings WHERE run_id = ? ORDER BY rowid ASC'
).all(runId) as PreflightFindingRow[];
}
// --- Notification History ---
private mapNotificationRow(row: any): NotificationHistory {
@@ -2544,6 +2639,8 @@ export class DatabaseService {
this.db.prepare('DELETE FROM stack_labels WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM stack_dossiers WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM stack_drift_findings WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM preflight_findings WHERE run_id IN (SELECT id FROM preflight_runs WHERE node_id = ?)').run(id);
this.db.prepare('DELETE FROM preflight_runs WHERE node_id = ?').run(id);
this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id);
this.deleteRoleAssignmentsByResource('node', String(id));
this.db.prepare('DELETE FROM fleet_sync_status WHERE node_id = ?').run(id);
@@ -0,0 +1,189 @@
/**
* Parser for the output of `docker compose config` (the fully-resolved
* effective model). It extracts only the STRUCTURAL facts the preflight rules
* need; it never retains an environment VALUE. Service environment is read for
* its key NAMES only (to detect PUID/PGID style directives), and render errors
* are handled by the caller, not here.
*/
/** A host-published port range declared by a service (start==end for one port). */
export interface EffPortSpec {
startPort: number;
endPort: number;
/** '' / '0.0.0.0' / '::' means all interfaces. */
hostIp: string;
protocol: string;
}
export interface EffBind {
/** Absolute source path (compose config resolves relative binds to absolute). */
source: string;
target: string;
}
export interface EffService {
name: string;
image?: string;
ports: EffPortSpec[];
binds: EffBind[];
namedVolumes: string[];
privileged: boolean;
networkMode?: string;
restart?: string;
hasHealthcheck: boolean;
/** Raw deploy block (read for key presence only, never values; undefined = none). */
deploy?: Record<string, unknown>;
containerName?: string;
user?: string;
/** Environment KEY names only. Values are never extracted. */
envKeys: string[];
}
export interface EffResource {
/** Resolved docker name (compose config fills this in). */
name: string;
external: boolean;
}
export interface EffectiveModel {
projectName: string;
services: EffService[];
networks: Record<string, EffResource>;
volumes: Record<string, EffResource>;
}
function str(v: unknown): string | undefined {
if (typeof v === 'string') return v;
if (typeof v === 'number') return String(v);
return undefined;
}
/** Parse a `start[-end]` published-port string into a clamped range, or null if invalid. */
function parsePortRange(raw: string): { startPort: number; endPort: number } | null {
const [a, b] = raw.split('-');
const start = parseInt(a, 10);
if (!Number.isFinite(start) || start <= 0) return null;
const end = b !== undefined ? parseInt(b, 10) : start;
return { startPort: start, endPort: Number.isFinite(end) && end >= start ? end : start };
}
/** Parse one rendered `ports:` entry (long object form, with a short-string fallback). */
function parsePortSpec(entry: unknown): EffPortSpec | null {
if (entry && typeof entry === 'object') {
const o = entry as Record<string, unknown>;
const publishedRaw = str(o.published);
if (publishedRaw === undefined || publishedRaw === '') return null; // container-only
const range = parsePortRange(publishedRaw);
if (!range) return null;
return { ...range, hostIp: str(o.host_ip) ?? '', protocol: str(o.protocol) ?? 'tcp' };
}
const short = str(entry);
if (short === undefined) return null;
const [spec, proto] = short.split('/');
const parts = spec.split(':');
let hostIp = '';
let hostPart: string | undefined;
if (parts.length >= 3) { hostIp = parts[0]; hostPart = parts[1]; }
else if (parts.length === 2) { hostPart = parts[0]; }
else return null; // container-only EXPOSE
const range = parsePortRange(hostPart ?? '');
if (!range) return null;
return { ...range, hostIp, protocol: proto || 'tcp' };
}
/** Split a service `volumes:` list into bind mounts and named-volume sources. */
function parseVolumes(volumes: unknown): { binds: EffBind[]; named: string[] } {
const binds: EffBind[] = [];
const named: string[] = [];
if (!Array.isArray(volumes)) return { binds, named };
for (const v of volumes) {
if (v && typeof v === 'object') {
const o = v as Record<string, unknown>;
const type = str(o.type);
const source = str(o.source);
const target = str(o.target) ?? '';
if (type === 'bind' && source) binds.push({ source, target });
else if (type === 'volume' && source) named.push(source);
continue;
}
const s = str(v);
if (!s) continue;
const parts = s.split(':');
if (parts.length < 2) continue; // anonymous volume, nothing to check
const source = parts[0];
const target = parts[1];
const isPath = source.startsWith('/') || source.startsWith('.') || source.startsWith('~') || /^[a-zA-Z]:[\\/]/.test(source);
if (isPath) binds.push({ source, target });
else named.push(source);
}
return { binds, named };
}
/** Environment KEY names only. Never returns a value. */
function envKeysOf(env: unknown): string[] {
if (Array.isArray(env)) {
return env
.map(e => str(e))
.filter((s): s is string => s !== undefined)
.map(s => s.split('=')[0])
.filter(Boolean);
}
if (env && typeof env === 'object') return Object.keys(env as Record<string, unknown>);
return [];
}
function parseResources(value: unknown): Record<string, EffResource> {
const out: Record<string, EffResource> = {};
if (value && typeof value === 'object' && !Array.isArray(value)) {
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
const o = (entry ?? {}) as Record<string, unknown>;
out[key] = { name: str(o.name) ?? key, external: o.external === true };
}
}
return out;
}
/**
* Build an EffectiveModel from the parsed JSON of `docker compose config
* --format json`. Tolerant of missing fields; an empty/garbage input yields an
* empty model rather than throwing.
*/
export function parseEffectiveModel(parsed: unknown, fallbackProjectName: string): EffectiveModel {
const root = (parsed ?? {}) as Record<string, unknown>;
const rawServices = (root.services && typeof root.services === 'object') ? root.services as Record<string, unknown> : {};
const services: EffService[] = [];
for (const [name, raw] of Object.entries(rawServices)) {
const svc = (raw ?? {}) as Record<string, unknown>;
const ports = Array.isArray(svc.ports)
? svc.ports.map(parsePortSpec).filter((p): p is EffPortSpec => p !== null)
: [];
const { binds, named } = parseVolumes(svc.volumes);
const healthcheck = svc.healthcheck;
const hasHealthcheck = !!healthcheck
&& typeof healthcheck === 'object'
&& (healthcheck as Record<string, unknown>).disable !== true;
services.push({
name,
image: str(svc.image),
ports,
binds,
namedVolumes: named,
privileged: svc.privileged === true,
networkMode: str(svc.network_mode),
restart: str(svc.restart),
hasHealthcheck,
deploy: (svc.deploy && typeof svc.deploy === 'object') ? svc.deploy as Record<string, unknown> : undefined,
containerName: str(svc.container_name),
user: str(svc.user),
envKeys: envKeysOf(svc.environment),
});
}
return {
projectName: str(root.name) ?? fallbackProjectName,
services,
networks: parseResources(root.networks),
volumes: parseResources(root.volumes),
};
}
+555
View File
@@ -0,0 +1,555 @@
import type { PreflightContext, PreflightFinding, PreflightSeverity, NodePortBinding } from './types';
import type { EffService, EffPortSpec } from './effectiveModel';
/** Higher number = more severe. Used to derive a run's overall status. */
export const SEVERITY_RANK: Record<PreflightSeverity, number> = { info: 0, warning: 1, high: 2, blocker: 3 };
/** The one rule whose message doubles as the report's render error. Shared so the
* service that reconstructs renderError from it cannot drift from the rule id. */
export const RENDER_FAILED_RULE_ID = 'render-failed';
export interface PreflightRule {
id: string;
run(ctx: PreflightContext): PreflightFinding[];
}
// ----- shared helpers -------------------------------------------------------
const MAX_RANGE = 256; // cap range expansion so an adversarial 1-65535 spec can't blow up
function isAllInterfaces(ip: string): boolean {
return ip === '' || ip === '0.0.0.0' || ip === '::' || ip === '[::]';
}
function interfaceOverlap(a: string, b: string): boolean {
return isAllInterfaces(a) || isAllInterfaces(b) || a === b;
}
function portsOf(spec: EffPortSpec): number[] {
const end = Math.min(spec.endPort, spec.startPort + MAX_RANGE - 1);
const out: number[] = [];
for (let p = spec.startPort; p <= end; p++) out.push(p);
return out;
}
function specLabel(spec: EffPortSpec): string {
return spec.startPort === spec.endPort ? `${spec.startPort}` : `${spec.startPort}-${spec.endPort}`;
}
/** True when the image reference resolves to a moving `latest` tag. */
function usesLatestTag(image: string): boolean {
if (image.includes('@sha256:')) return false; // digest-pinned
const lastSlash = image.lastIndexOf('/');
const lastColon = image.lastIndexOf(':');
if (lastColon > lastSlash) return image.slice(lastColon + 1) === 'latest';
return true; // no tag → implicit latest
}
const UID_GID_KEYS = new Set(['PUID', 'PGID', 'UID', 'GID']);
function hasUidGidSignal(svc: EffService): boolean {
return svc.user !== undefined || svc.envKeys.some(k => UID_GID_KEYS.has(k));
}
/** Resolved runtime name of a top-level network/volume (compose prefixes the project). */
function runtimeResourceName(projectName: string, key: string, declaredName: string): string {
return declaredName !== key ? declaredName : `${projectName}_${key}`;
}
// ----- rules ----------------------------------------------------------------
const renderFailed: PreflightRule = {
id: RENDER_FAILED_RULE_ID,
run(ctx) {
if (ctx.renderable) return [];
return [{
ruleId: RENDER_FAILED_RULE_ID,
severity: 'blocker',
title: 'Compose model could not be rendered',
message: ctx.renderError ?? 'docker compose config failed to produce an effective model.',
remediation: 'Fix the reported error. Sencho cannot validate a stack it cannot render.',
}];
},
};
const envUnset: PreflightRule = {
id: 'env-unset',
run(ctx) {
return ctx.unsetEnvVars.map(name => ({
ruleId: 'env-unset',
severity: 'high' as const,
title: `Unset variable ${name}`,
message: `"${name}" is referenced by the Compose model but is not set in the environment or any consulted env file. Compose substitutes an empty string, which often breaks the container silently.`,
sourcePath: name,
remediation: `Define ${name} in a .env or env_file, or give it a default with \${${name}:-value}.`,
}));
},
};
const portConflictNode: PreflightRule = {
id: 'port-conflict-node',
run(ctx) {
if (!ctx.model) return [];
const byPort = new Map<number, NodePortBinding[]>();
for (const b of ctx.nodePorts) {
const list = byPort.get(b.publishedPort);
if (list) list.push(b); else byPort.set(b.publishedPort, [b]);
}
const findings: PreflightFinding[] = [];
for (const svc of ctx.model.services) {
for (const spec of svc.ports) {
for (const port of portsOf(spec)) {
const clash = (byPort.get(port) ?? []).find(b =>
b.protocol === spec.protocol && interfaceOverlap(spec.hostIp, b.ip) && b.stack !== ctx.stackName);
if (!clash) continue;
const owner = clash.stack ? `stack "${clash.stack}"` : 'another container';
findings.push({
ruleId: 'port-conflict-node',
severity: 'blocker',
title: `Host port ${port} is already in use`,
message: `Service "${svc.name}" publishes ${port}/${spec.protocol}, but ${owner} already binds that port on this node. The deploy will fail.`,
sourcePath: svc.name,
service: svc.name,
remediation: 'Stop the conflicting workload or publish a different host port.',
});
break; // one finding per service+spec is enough
}
}
}
return findings;
},
};
const portConflictInternal: PreflightRule = {
id: 'port-conflict-internal',
run(ctx) {
if (!ctx.model) return [];
const claims = new Map<string, { service: string; hostIp: string }[]>();
for (const svc of ctx.model.services) {
for (const spec of svc.ports) {
for (const port of portsOf(spec)) {
const key = `${port}/${spec.protocol}`;
const list = claims.get(key);
if (list) list.push({ service: svc.name, hostIp: spec.hostIp });
else claims.set(key, [{ service: svc.name, hostIp: spec.hostIp }]);
}
}
}
const findings: PreflightFinding[] = [];
for (const [key, list] of claims) {
const services = [...new Set(list.map(c => c.service))];
if (services.length < 2) continue;
const overlapping = list.some((a, i) => list.slice(i + 1).some(b => b.service !== a.service && interfaceOverlap(a.hostIp, b.hostIp)));
if (!overlapping) continue;
findings.push({
ruleId: 'port-conflict-internal',
severity: 'blocker',
title: `Two services publish ${key}`,
message: `Services ${services.map(s => `"${s}"`).join(' and ')} both publish host port ${key}. Only one can bind it, so the deploy will fail.`,
remediation: 'Give each service a distinct host port.',
});
}
return findings;
},
};
const portExposedAllInterfaces: PreflightRule = {
id: 'port-exposed-all-interfaces',
run(ctx) {
if (!ctx.model) return [];
const findings: PreflightFinding[] = [];
for (const svc of ctx.model.services) {
for (const spec of svc.ports) {
if (!isAllInterfaces(spec.hostIp)) continue;
findings.push({
ruleId: 'port-exposed-all-interfaces',
severity: 'high',
title: `Port ${specLabel(spec)} exposed on all interfaces`,
message: `Service "${svc.name}" publishes ${specLabel(spec)}/${spec.protocol} on all interfaces (0.0.0.0), so it is reachable from every network the host is attached to.`,
sourcePath: svc.name,
service: svc.name,
remediation: `Bind to a specific interface, e.g. 127.0.0.1:${spec.startPort}, if this should not be public.`,
});
}
}
return findings;
},
};
const bindPathMissing: PreflightRule = {
id: 'bind-path-missing',
run(ctx) {
return ctx.bindChecks
.filter(b => b.withinBase && !b.exists)
.map(b => ({
ruleId: 'bind-path-missing',
severity: 'high' as const,
title: 'Bind mount path is missing',
message: `The host path "${b.source}" for service "${b.service}" does not exist. Docker will create it as a root-owned directory on deploy, which often leaves the container unable to write to it.`,
sourcePath: b.source,
service: b.service,
remediation: 'Create the directory with the ownership the container expects before deploying.',
}));
},
};
const bindPathPermission: PreflightRule = {
id: 'bind-path-permission',
run(ctx) {
if (!ctx.model || ctx.platform === 'win32') return [];
const svcByName = new Map(ctx.model.services.map(s => [s.name, s]));
const findings: PreflightFinding[] = [];
for (const b of ctx.bindChecks) {
if (!b.withinBase || !b.exists || b.ownerUid !== 0) continue;
const svc = svcByName.get(b.service);
if (!svc || !hasUidGidSignal(svc)) continue;
findings.push({
ruleId: 'bind-path-permission',
severity: 'warning',
title: 'Bind mount may have wrong ownership',
message: `The host path "${b.source}" is owned by root, but service "${b.service}" runs as a non-root user. It may not be able to write there.`,
sourcePath: b.source,
service: b.service,
remediation: 'chown the path to the UID/GID the container runs as.',
});
}
return findings;
},
};
const dockerSocketMount: PreflightRule = {
id: 'docker-socket-mount',
run(ctx) {
if (!ctx.model) return [];
const findings: PreflightFinding[] = [];
for (const svc of ctx.model.services) {
const hit = svc.binds.some(b => b.source.includes('docker.sock') || b.target.includes('docker.sock'));
if (!hit) continue;
findings.push({
ruleId: 'docker-socket-mount',
severity: 'high',
title: 'Docker socket mounted',
message: `Service "${svc.name}" mounts the Docker socket, which grants it root-equivalent control over the host.`,
sourcePath: svc.name,
service: svc.name,
remediation: 'Avoid mounting docker.sock unless required; consider a scoped socket proxy.',
});
}
return findings;
},
};
const privileged: PreflightRule = {
id: 'privileged',
run(ctx) {
if (!ctx.model) return [];
return ctx.model.services.filter(s => s.privileged).map(s => ({
ruleId: 'privileged',
severity: 'high' as const,
title: 'Privileged container',
message: `Service "${s.name}" runs with privileged: true, which disables most container isolation.`,
sourcePath: s.name,
service: s.name,
remediation: 'Drop privileged and grant only the specific capabilities the service needs.',
}));
},
};
const networkModeHost: PreflightRule = {
id: 'network-mode-host',
run(ctx) {
if (!ctx.model) return [];
return ctx.model.services.filter(s => s.networkMode === 'host').map(s => ({
ruleId: 'network-mode-host',
severity: 'high' as const,
title: 'Host network mode',
message: `Service "${s.name}" uses network_mode: host. Its ports bypass Docker's network isolation and ignore published-port mappings.`,
sourcePath: s.name,
service: s.name,
remediation: 'Use bridge networking with explicit published ports unless host mode is required.',
}));
},
};
const uidGidRisk: PreflightRule = {
id: 'uid-gid-risk',
run(ctx) {
if (!ctx.model) return [];
// Only for binds whose ownership Sencho cannot verify (outside the compose
// base); within-base root-owned binds are covered by bind-path-permission.
const unverifiableByService = new Set(ctx.bindChecks.filter(b => !b.withinBase).map(b => b.service));
return ctx.model.services
.filter(s => hasUidGidSignal(s) && unverifiableByService.has(s.name))
.map(s => ({
ruleId: 'uid-gid-risk',
severity: 'warning' as const,
title: 'Check UID/GID alignment',
message: `Service "${s.name}" sets a user/UID and mounts host paths Sencho cannot inspect. Mismatched ownership between the host path and the container user is a common cause of permission errors.`,
sourcePath: s.name,
service: s.name,
remediation: 'Ensure the bind-mount paths are owned by the UID/GID the container runs as.',
}));
},
};
const imageLatest: PreflightRule = {
id: 'image-latest',
run(ctx) {
if (!ctx.model) return [];
return ctx.model.services
.filter(s => s.image !== undefined && usesLatestTag(s.image))
.map(s => ({
ruleId: 'image-latest',
severity: 'warning' as const,
title: 'Image uses a moving tag',
message: `Service "${s.name}" uses "${s.image}", which resolves to a moving latest tag. Deploys are not reproducible and can change under you.`,
sourcePath: s.name,
service: s.name,
remediation: 'Pin a specific version tag.',
}));
},
};
const noRestartPolicy: PreflightRule = {
id: 'no-restart-policy',
run(ctx) {
if (!ctx.model) return [];
// `restart: "no"` is Compose's default and means "do not restart", which
// `docker compose config` may render explicitly, so treat it as no policy.
return ctx.model.services
.filter(s => (!s.restart || s.restart === 'no') && !(s.deploy && s.deploy['restart_policy'] !== undefined))
.map(s => ({
ruleId: 'no-restart-policy',
severity: 'warning' as const,
title: 'No restart policy',
message: `Service "${s.name}" has no restart policy, so it will not come back after a crash or host reboot.`,
sourcePath: s.name,
service: s.name,
remediation: 'Add restart: unless-stopped.',
}));
},
};
const noHealthcheck: PreflightRule = {
id: 'no-healthcheck',
run(ctx) {
if (!ctx.model) return [];
return ctx.model.services
.filter(s => !s.hasHealthcheck)
.map(s => ({
ruleId: 'no-healthcheck',
severity: 'warning' as const,
title: 'No healthcheck',
message: `Service "${s.name}" declares no healthcheck, so Docker and Sencho cannot tell when it is actually ready (the image may still define one).`,
sourcePath: s.name,
service: s.name,
remediation: 'Add a healthcheck, or confirm the image provides one.',
}));
},
};
const SWARM_ONLY_DEPLOY_KEYS = ['placement', 'update_config', 'rollback_config', 'endpoint_mode'];
const deploySwarmOnly: PreflightRule = {
id: 'deploy-swarm-only',
run(ctx) {
if (!ctx.model) return [];
const findings: PreflightFinding[] = [];
for (const s of ctx.model.services) {
if (!s.deploy) continue;
const present = SWARM_ONLY_DEPLOY_KEYS.filter(k => s.deploy?.[k] !== undefined);
if (present.length === 0) continue;
findings.push({
ruleId: 'deploy-swarm-only',
severity: 'warning',
title: 'Swarm-only deploy fields',
message: `Service "${s.name}" sets deploy.${present.join(', deploy.')}, which standalone Compose ignores (these apply to Swarm).`,
sourcePath: s.name,
service: s.name,
remediation: 'Remove the Swarm-only deploy fields or move equivalent settings to their standalone keys.',
});
}
return findings;
},
};
const externalNetworkMissing: PreflightRule = {
id: 'external-network-missing',
run(ctx) {
if (!ctx.model) return [];
const findings: PreflightFinding[] = [];
for (const [key, net] of Object.entries(ctx.model.networks)) {
if (!net.external || ctx.existingNetworkNames.has(net.name)) continue;
findings.push({
ruleId: 'external-network-missing',
severity: 'blocker',
title: 'External network not found',
message: `The model requires the external network "${net.name}", which does not exist on this node. The deploy will fail.`,
sourcePath: `networks.${key}`,
remediation: `Create it with: docker network create ${net.name}`,
});
}
return findings;
},
};
const externalVolumeMissing: PreflightRule = {
id: 'external-volume-missing',
run(ctx) {
if (!ctx.model) return [];
const findings: PreflightFinding[] = [];
for (const [key, vol] of Object.entries(ctx.model.volumes)) {
if (!vol.external || ctx.existingVolumeNames.has(vol.name)) continue;
findings.push({
ruleId: 'external-volume-missing',
severity: 'blocker',
title: 'External volume not found',
message: `The model requires the external volume "${vol.name}", which does not exist on this node. The deploy will fail.`,
sourcePath: `volumes.${key}`,
remediation: `Create it with: docker volume create ${vol.name}`,
});
}
return findings;
},
};
const newNetwork: PreflightRule = {
id: 'new-network',
run(ctx) {
if (!ctx.model) return [];
const findings: PreflightFinding[] = [];
for (const [key, net] of Object.entries(ctx.model.networks)) {
if (net.external || key === 'default') continue;
const expected = runtimeResourceName(ctx.model.projectName, key, net.name);
if (ctx.existingNetworkNames.has(expected)) continue;
findings.push({
ruleId: 'new-network',
severity: 'info',
title: 'New network will be created',
message: `Deploying will create the network "${expected}".`,
sourcePath: `networks.${key}`,
});
}
return findings;
},
};
const newVolume: PreflightRule = {
id: 'new-volume',
run(ctx) {
if (!ctx.model) return [];
const findings: PreflightFinding[] = [];
for (const [key, vol] of Object.entries(ctx.model.volumes)) {
if (vol.external) continue;
const expected = runtimeResourceName(ctx.model.projectName, key, vol.name);
if (ctx.existingVolumeNames.has(expected)) continue;
findings.push({
ruleId: 'new-volume',
severity: 'info',
title: 'New volume will be created',
message: `Deploying will create the named volume "${expected}".`,
sourcePath: `volumes.${key}`,
});
}
return findings;
},
};
const containerNameInternalDup: PreflightRule = {
id: 'container-name-internal-dup',
run(ctx) {
if (!ctx.model) return [];
const byName = new Map<string, string[]>();
for (const s of ctx.model.services) {
if (!s.containerName) continue;
const list = byName.get(s.containerName);
if (list) list.push(s.name); else byName.set(s.containerName, [s.name]);
}
const findings: PreflightFinding[] = [];
for (const [name, services] of byName) {
if (services.length < 2) continue;
findings.push({
ruleId: 'container-name-internal-dup',
severity: 'blocker',
title: 'Duplicate container_name',
message: `Services ${services.map(s => `"${s}"`).join(' and ')} both set container_name "${name}". Docker requires unique names, so the deploy will fail.`,
remediation: 'Give each service a unique container_name, or remove it and let Compose name them.',
});
}
return findings;
},
};
const containerNameCollision: PreflightRule = {
id: 'container-name-collision',
run(ctx) {
if (!ctx.model) return [];
const findings: PreflightFinding[] = [];
for (const s of ctx.model.services) {
if (!s.containerName) continue;
const clash = ctx.existingContainers.find(c => c.name === s.containerName && c.stack !== ctx.stackName);
if (!clash) continue;
const owner = clash.stack ? `stack "${clash.stack}"` : 'an unmanaged container';
findings.push({
ruleId: 'container-name-collision',
severity: 'blocker',
title: 'container_name already in use',
message: `container_name "${s.containerName}" for service "${s.name}" is already used by ${owner} on this node. The deploy will fail with a name conflict.`,
sourcePath: s.name,
service: s.name,
remediation: 'Choose a different container_name or remove the conflicting container.',
});
}
return findings;
},
};
const effectiveModelExpanded: PreflightRule = {
id: 'effective-model-expanded',
run(ctx) {
// Skip when the source could not be read: an empty source-service set then
// means "unknown", not "zero services", and would flag every service.
if (!ctx.model || !ctx.sourceReadable) return [];
const source = new Set(ctx.sourceServiceNames);
const extra = ctx.model.services.map(s => s.name).filter(n => !source.has(n));
if (extra.length === 0) return [];
return [{
ruleId: 'effective-model-expanded',
severity: 'info',
title: 'Effective model adds services',
message: `The effective model includes ${extra.map(s => `"${s}"`).join(', ')}, which are not in this file (pulled in via include, extends, or profiles). What deploys differs from what you see here.`,
remediation: 'Review the included files to confirm this is intended.',
}];
},
};
/** The ordered registry. Order is the display order within a severity group. */
export const PREFLIGHT_RULES: PreflightRule[] = [
renderFailed,
envUnset,
portConflictNode,
portConflictInternal,
portExposedAllInterfaces,
bindPathMissing,
bindPathPermission,
dockerSocketMount,
privileged,
networkModeHost,
uidGidRisk,
imageLatest,
noRestartPolicy,
noHealthcheck,
deploySwarmOnly,
externalNetworkMissing,
externalVolumeMissing,
newNetwork,
newVolume,
containerNameInternalDup,
containerNameCollision,
effectiveModelExpanded,
];
export const RULE_IDS: readonly string[] = PREFLIGHT_RULES.map(r => r.id);
/** Run every rule and concatenate findings. */
export function runRules(ctx: PreflightContext): PreflightFinding[] {
return PREFLIGHT_RULES.flatMap(rule => rule.run(ctx));
}
+95
View File
@@ -0,0 +1,95 @@
import type { EffectiveModel } from './effectiveModel';
/** Graded severity of a single preflight finding. */
export type PreflightSeverity = 'blocker' | 'high' | 'warning' | 'info';
/**
* Overall outcome of a run. `pass` = renderable with no findings;
* `unrenderable` = the effective model could not be produced; `never-run` =
* no run is stored yet. Otherwise the value is the highest finding severity.
*/
export type PreflightStatus = 'never-run' | 'pass' | 'unrenderable' | PreflightSeverity;
/** A single deterministic finding. Never carries an environment value. */
export interface PreflightFinding {
ruleId: string;
severity: PreflightSeverity;
/** Short headline: what Sencho detected. */
title: string;
/** Why it matters. */
message: string;
/** Where it came from (service name, top-level key, or host path). */
sourcePath?: string;
/** Suggested fix. */
remediation?: string;
/** Service the finding is scoped to, when applicable. */
service?: string;
}
/** The full report returned by both the GET (latest) and POST (run) routes. */
export interface PreflightReport {
stack: string;
/** Epoch ms of the run, or null when never run. */
ranAt: number | null;
ranBy: string | null;
renderable: boolean;
/** Redacted, truncated render error when `renderable` is false. */
renderError: string | null;
status: PreflightStatus;
highestSeverity: PreflightSeverity | null;
sourceHash: string | null;
renderedHash: string | null;
findings: PreflightFinding[];
}
/** A host port bound by a running container on the target node. */
export interface NodePortBinding {
publishedPort: number;
protocol: string;
/** '' / '0.0.0.0' / '::' means all interfaces. */
ip: string;
/** Resolved Sencho stack owning the binding, or null when unmanaged. */
stack: string | null;
}
/** Pre-resolved existence/ownership of a single bind-mount source. */
export interface BindCheck {
service: string;
/** Absolute source path as rendered by `docker compose config`. */
source: string;
target: string;
/** True when the source resolves inside the node's compose base dir. */
withinBase: boolean;
/** Existence is only probed for `withinBase` sources (others are unverifiable). */
exists: boolean;
/** File owner uid when statted on a POSIX host, else null. */
ownerUid: number | null;
}
/**
* Everything the pure rule functions need, computed once by the service so the
* rules stay synchronous and individually testable. No field ever holds an
* environment value.
*/
export interface PreflightContext {
stackName: string;
/** The node's platform, so POSIX-only rules can skip themselves on Windows. */
platform: NodeJS.Platform;
/** The rendered effective model, or null when it could not be produced. */
model: EffectiveModel | null;
renderable: boolean;
/** Redacted + truncated render error, or null. */
renderError: string | null;
/** Variable names Compose reported as unset (defaulted to empty string). */
unsetEnvVars: string[];
/** 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
* unreadable source cannot be mistaken for an empty one. */
sourceReadable: boolean;
nodePorts: NodePortBinding[];
existingNetworkNames: Set<string>;
existingVolumeNames: Set<string>;
existingContainers: { name: string; stack: string | null }[];
bindChecks: BindCheck[];
}
+1
View File
@@ -105,6 +105,7 @@
"features/stack-activity",
"features/stack-dossier",
"features/stack-drift",
"features/compose-doctor",
"features/stack-labels",
"features/sidebar"
]
+66
View File
@@ -0,0 +1,66 @@
---
title: Compose Doctor
description: Run a preflight check on a stack before you deploy. Compose Doctor renders the effective Compose model and reports common failure modes, grouped by severity, with a clear fix for each.
---
The **Doctor** tab in the right-hand **Anatomy** panel answers one question before you apply a change: *what will Docker actually run, and is it safe on this node?* Compose Doctor renders the effective Compose model, the fully resolved result after interpolation, includes, profiles, `.env`, and `env_file` are applied, and then runs a set of deterministic checks against it and the node it would deploy to.
The check is advisory. It never blocks a deploy and never changes a stack; it tells you what it found so you can decide. It runs on demand: press **run preflight** and Sencho renders the model, runs the checks, and stores the result so the tab still shows it the next time you open the stack.
Compose Doctor never shows a secret value. It reads the structure of the effective model, service names, images, ports, volumes, and the *names* of environment variables, but never their values, so nothing sensitive appears in the report, the stored run, or the logs.
## Severity
Every finding is graded so you can triage at a glance. The tab groups findings under four headings and the summary line reflects the most severe one:
| Severity | Meaning |
|----------|---------|
| **Blocker** | The deploy will fail or the model cannot be rendered. Fix these before applying. |
| **High risk** | The deploy will likely succeed but does something dangerous or surprising, such as exposing a port to every network or mounting the Docker socket. |
| **Warning** | A reliability or reproducibility concern, such as a moving `latest` tag or a missing restart policy. |
| **Info** | Something to be aware of, such as a network or volume that will be created on first deploy. |
A stack with unresolved blocker or high-risk findings also shows a small coloured dot on the **Doctor** tab, so you can see there is something to look at without opening it.
## What it checks
Compose Doctor renders the model first. If `docker compose config` cannot produce a model, the report says so and stops, since nothing else can be trusted. When the model renders, the checks cover:
- **Environment.** Variables referenced by the model but not set in any consulted env file, which Compose silently turns into empty strings.
- **Ports.** Host ports already in use by another stack on the node, two services in the same stack claiming the same port, and ports published on all interfaces (`0.0.0.0`).
- **Host paths.** Bind-mount directories that do not exist yet, and paths that look likely to have ownership problems for a container running as a non-root user.
- **Privilege and exposure.** A mounted Docker socket, `privileged: true`, and `network_mode: host`.
- **Reliability.** Images pinned to a moving `latest` tag, services with no restart policy, and services with no healthcheck.
- **Compose semantics.** `deploy` fields that standalone Compose ignores, and required external networks or volumes that do not exist on the node.
- **Surprises.** Duplicate or already-taken `container_name`s, and services that the effective model pulls in via `include` or `extends` that are not in the file you are looking at.
Each finding states what was detected, why it matters, where in the model it came from, and how to fix it.
## Running preflight
1. Click any stack in the left sidebar to open it.
2. Switch to the **Doctor** tab in the Anatomy panel header.
3. Press **run preflight**. The summary updates with the result and findings grouped by severity.
4. Fix anything that matters, then run it again to confirm it clears.
Preflight runs against the **active node**, so selecting a remote node checks the stack on the machine that actually owns it. On a phone, the same report appears under the **Compose** section of the stack detail.
## Troubleshooting
<AccordionGroup>
<Accordion title="The report says it cannot render the model">
Compose Doctor runs `docker compose config` to resolve the effective model, and that command failed. The usual causes are a YAML syntax error, an unresolved `include` or `merge`, or a required variable with no value. Fix the Compose or env file and run preflight again. Sencho deliberately does not echo the raw error, since it can contain values from your files.
</Accordion>
<Accordion title="A bind mount I use every day is flagged as missing">
Compose Doctor can only check paths that live inside the node's Compose directory, such as a relative `./data` mount. An absolute host path like `/mnt/media` is outside what Sencho can see from inside its container, so it is never reported as missing. A missing relative path is real: Docker would create it as a root-owned directory on deploy.
</Accordion>
<Accordion title="Every service is flagged for no healthcheck">
Compose Doctor reports a healthcheck only when the Compose file does not declare one. Many images define their own healthcheck internally, which Sencho cannot see from the model alone, so treat these as a prompt to confirm rather than a hard problem.
</Accordion>
<Accordion title="A port conflict is flagged for a port my own stack uses">
Preflight ignores ports already held by the stack you are checking, so redeploying a running stack does not flag its own bindings. A conflict means a *different* stack, or an unmanaged container, holds that host port on this node.
</Accordion>
<Accordion title="The Doctor tab is not there on a remote node">
The tab appears when the active node reports that it supports Compose Doctor. A node running an older version of Sencho does not advertise it, so the tab is hidden for that node until it is updated.
</Accordion>
</AccordionGroup>
@@ -0,0 +1,73 @@
/**
* Covers the capability-gated Doctor tab and its severity dot in
* StackAnatomyPanel when the active node advertises compose-doctor. The
* capability-off case (tab hidden, no badge fetch) is covered in
* StackAnatomyPanel.test.tsx.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('./stack/StackActivityTimeline', () => ({ StackActivityTimeline: () => <div /> }));
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 }, hasCapability: () => true }) }));
import { apiFetch } from '@/lib/api';
import StackAnatomyPanel from './StackAnatomyPanel';
let badgeSeverity: string | null = 'blocker';
function jsonRes(body: unknown, ok = true) {
return { ok, status: ok ? 200 : 404, json: async () => body, text: async () => '' } as unknown as Response;
}
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/preflight')) {
return jsonRes({ stack: 'web', ranAt: 1, ranBy: 'x', renderable: true, renderError: null, status: 'high', highestSeverity: badgeSeverity, findings: [] });
}
return jsonRes(null, false); // git-source, update-preview, scan-status
});
});
function panel() {
return (
<StackAnatomyPanel
stackName="web"
content={'services:\n web:\n image: nginx:1.25\n'}
envContent=""
selectedEnvFile=".env"
gitSourcePending={false}
onEditCompose={vi.fn()}
onOpenGitSource={vi.fn()}
onApplyUpdate={vi.fn()}
canEdit
/>
);
}
describe('StackAnatomyPanel Doctor tab (capability on)', () => {
it('renders the Doctor tab with a destructive dot for a blocker result', async () => {
badgeSeverity = 'blocker';
render(panel());
expect(await screen.findByTestId('doctor-tab')).toBeInTheDocument();
const dot = await screen.findByTestId('doctor-tab-dot');
expect(dot.className).toContain('bg-destructive');
});
it('uses the warning color for a high-risk result', async () => {
badgeSeverity = 'high';
render(panel());
const dot = await screen.findByTestId('doctor-tab-dot');
expect(dot.className).toContain('bg-warning');
});
it('shows no dot for a warning-only result', async () => {
badgeSeverity = 'warning';
render(panel());
await screen.findByTestId('doctor-tab');
await waitFor(() => expect(vi.mocked(apiFetch).mock.calls.some(([u]) => String(u).includes('/preflight'))).toBe(true));
expect(screen.queryByTestId('doctor-tab-dot')).not.toBeInTheDocument();
});
});
@@ -11,6 +11,9 @@ vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('./stack/StackActivityTimeline', () => ({
StackActivityTimeline: () => <div data-testid="activity-timeline" />,
}));
// This suite covers the update banner, not the Doctor tab; keep the capability
// off so the panel surface stays exactly what these tests assert against.
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 }, hasCapability: () => false }) }));
import { apiFetch } from '@/lib/api';
import StackAnatomyPanel from './StackAnatomyPanel';
+117 -9
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen, ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from './ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { apiFetch } from '@/lib/api';
@@ -9,6 +9,8 @@ import { parseAnatomy, parseEnvKeys, formatGitSource, type GitSourceInfo } from
import { StackActivityTimeline } from './stack/StackActivityTimeline';
import StackDossierPanel from './stack/StackDossierPanel';
import DriftPanel from './stack/DriftPanel';
import PreflightPanel from './stack/PreflightPanel';
import { useNodes } from '@/context/NodeContext';
import type { NotificationItem } from '@/components/dashboard/types';
interface StackAnatomyPanelProps {
@@ -75,14 +77,71 @@ export default function StackAnatomyPanel({
const envVarCount = envKeys.size;
const { hasCapability, activeNode } = useNodes();
const doctorEnabled = hasCapability('compose-doctor');
const [gitSource, setGitSource] = useState<{ stack: string; info: GitSourceInfo } | null>(null);
const [updatePreview, setUpdatePreview] = useState<UpdatePreview | null>(null);
// Last preflight severity, used only to dot the Doctor tab. Radix mounts the
// active tab content lazily, so the badge cannot come from PreflightPanel; the
// parent reads the stored run once per stack/node change.
const [preflightSeverity, setPreflightSeverity] = useState<string | null>(null);
const [scanStatus, setScanStatus] = useState<{
status: 'ok' | 'partial' | 'failed' | 'skipped' | null;
attemptedAt?: number;
errorMessage?: string | null;
} | null>(null);
// Best-effort badge: read the last stored preflight severity to dot the tab.
// Skipped when the active node does not advertise the capability.
useEffect(() => {
// The dot and tab are gated on doctorEnabled, so a stale severity is never
// shown; no synchronous reset needed when the capability is absent.
if (!doctorEnabled) return;
let cancelled = false;
void (async () => {
try {
const res = await apiFetch(`/stacks/${stackName}/preflight`);
if (cancelled || !res.ok) return;
const data = await res.json();
if (!cancelled) setPreflightSeverity(typeof data?.highestSeverity === 'string' ? data.highestSeverity : null);
} catch {
if (!cancelled) setPreflightSeverity(null);
}
})();
return () => { cancelled = true; };
}, [stackName, activeNode?.id, doctorEnabled]);
// The tab row scrolls horizontally when its tabs overflow the panel width.
// Clickable arrows appear only while there is more to scroll in that direction
// (a wide panel that fits every tab looks unchanged), and a vertical mouse
// wheel over the row is translated into horizontal scroll.
const tabScrollRef = useRef<HTMLDivElement>(null);
const [tabEdges, setTabEdges] = useState({ left: false, right: false });
const measureTabEdges = useCallback((el: HTMLElement) => {
setTabEdges({ left: el.scrollLeft > 1, right: Math.ceil(el.scrollLeft + el.clientWidth) < el.scrollWidth });
}, []);
const scrollTabs = useCallback((direction: -1 | 1) => {
const el = tabScrollRef.current;
if (el) el.scrollBy({ left: direction * Math.max(96, el.clientWidth * 0.7), behavior: 'smooth' });
}, []);
useEffect(() => {
const el = tabScrollRef.current;
if (!el) return;
measureTabEdges(el);
// Non-passive so preventDefault works: turn a vertical wheel into horizontal
// scroll only when the row overflows (trackpads already scroll horizontally).
const onWheel = (e: WheelEvent) => {
if (el.scrollWidth <= el.clientWidth || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
el.scrollLeft += e.deltaY;
e.preventDefault();
};
el.addEventListener('wheel', onWheel, { passive: false });
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => measureTabEdges(el)) : null;
ro?.observe(el);
return () => { el.removeEventListener('wheel', onWheel); ro?.disconnect(); };
}, [measureTabEdges, doctorEnabled, preflightSeverity]);
useEffect(() => {
let cancelled = false;
const run = async () => {
@@ -248,13 +307,57 @@ export default function StackAnatomyPanel({
<div className="flex h-full min-h-0 flex-col rounded-xl border border-muted bg-card/40">
<Tabs defaultValue="anatomy" className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between border-b border-muted px-3 py-1.5 gap-2">
<TabsList className="h-7 gap-0.5 bg-transparent border-none p-0">
<TabsTrigger value="anatomy" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Anatomy</TabsTrigger>
<TabsTrigger value="activity" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Activity</TabsTrigger>
<TabsTrigger value="dossier" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Dossier</TabsTrigger>
<TabsTrigger value="drift" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Drift</TabsTrigger>
</TabsList>
<div className="flex items-center gap-3">
<div className="relative min-w-0 flex-1">
<div
ref={tabScrollRef}
onScroll={e => measureTabEdges(e.currentTarget)}
className="overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<TabsList className="h-7 w-max gap-0.5 bg-transparent border-none p-0">
<TabsTrigger value="anatomy" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Anatomy</TabsTrigger>
<TabsTrigger value="activity" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Activity</TabsTrigger>
<TabsTrigger value="dossier" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Dossier</TabsTrigger>
<TabsTrigger value="drift" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Drift</TabsTrigger>
{doctorEnabled && (
<TabsTrigger value="doctor" data-testid="doctor-tab" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">
<span className="inline-flex items-center gap-1">
Doctor
{(preflightSeverity === 'blocker' || preflightSeverity === 'high') && (
<span
data-testid="doctor-tab-dot"
className={cn('h-1.5 w-1.5 rounded-full', preflightSeverity === 'blocker' ? 'bg-destructive' : 'bg-warning')}
/>
)}
</span>
</TabsTrigger>
)}
</TabsList>
</div>
{/* Clickable arrows over a fade: shown only when the row overflows that edge. */}
{tabEdges.left && (
<button
type="button"
aria-label="Scroll tabs left"
data-testid="tab-scroll-left"
onClick={() => scrollTabs(-1)}
className="absolute inset-y-0 left-0 flex w-7 items-center justify-start bg-gradient-to-r from-card via-card/90 to-transparent text-stat-subtitle hover:text-brand transition-colors"
>
<ChevronLeft className="h-4 w-4" strokeWidth={1.5} />
</button>
)}
{tabEdges.right && (
<button
type="button"
aria-label="Scroll tabs right"
data-testid="tab-scroll-right"
onClick={() => scrollTabs(1)}
className="absolute inset-y-0 right-0 flex w-7 items-center justify-end bg-gradient-to-l from-card via-card/90 to-transparent text-stat-subtitle hover:text-brand transition-colors"
>
<ChevronRight className="h-4 w-4" strokeWidth={1.5} />
</button>
)}
</div>
<div className="flex items-center gap-3 shrink-0">
{onOpenFiles && (
<button
type="button"
@@ -463,6 +566,11 @@ export default function StackAnatomyPanel({
<TabsContent value="drift" className="flex flex-col flex-1 min-h-0 mt-0">
<DriftPanel stackName={stackName} />
</TabsContent>
{doctorEnabled && (
<TabsContent value="doctor" className="flex flex-col flex-1 min-h-0 mt-0">
<PreflightPanel stackName={stackName} />
</TabsContent>
)}
</Tabs>
</div>
);
@@ -0,0 +1,112 @@
/**
* Covers the Compose Doctor panel: the never-run empty state, the all-clear and
* graded-findings summaries, the unrenderable banner, a load-failure retry
* state, and running preflight on demand.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 } }) }));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import PreflightPanel from './PreflightPanel';
interface Finding {
ruleId: string;
severity: 'blocker' | 'high' | 'warning' | 'info';
title: string;
message: string;
sourcePath?: string;
remediation?: string;
service?: string;
}
interface Report {
stack: string;
ranAt: number | null;
ranBy: string | null;
renderable: boolean;
renderError: string | null;
status: string;
highestSeverity: string | null;
findings: Finding[];
}
function report(partial: Partial<Report>): Report {
return { stack: 'web', ranAt: 1000, ranBy: 'admin', renderable: true, renderError: null, status: 'pass', highestSeverity: null, findings: [], ...partial };
}
function jsonRes(body: unknown, ok = true) {
return { ok, status: ok ? 200 : 500, json: async () => body, text: async () => '' } as unknown as Response;
}
beforeEach(() => { vi.clearAllMocks(); });
describe('PreflightPanel', () => {
it('shows the never-run empty state', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ status: 'never-run', ranAt: null })));
render(<PreflightPanel stackName="web" />);
expect(await screen.findByText(/Run preflight to render the effective model/i)).toBeInTheDocument();
});
it('renders the all-clear summary when there are no findings', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ status: 'pass' })));
render(<PreflightPanel stackName="web" />);
const status = await screen.findByTestId('preflight-status');
expect(status).toHaveAttribute('data-status', 'pass');
expect(status).toHaveTextContent(/all clear/i);
});
it('groups findings and reflects the highest severity', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
status: 'high',
highestSeverity: 'high',
findings: [
{ ruleId: 'privileged', severity: 'high', title: 'Privileged container', message: 'runs privileged', service: 'web' },
{ ruleId: 'image-latest', severity: 'warning', title: 'Image uses a moving tag', message: 'latest tag', service: 'web' },
],
})));
render(<PreflightPanel stackName="web" />);
const status = await screen.findByTestId('preflight-status');
expect(status).toHaveAttribute('data-status', 'high');
expect(screen.getByText('Privileged container')).toBeInTheDocument();
expect(screen.getByText('Image uses a moving tag')).toBeInTheDocument();
});
it('surfaces the unrenderable state with the render error', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
renderable: false, status: 'unrenderable', highestSeverity: 'blocker',
renderError: 'Sencho could not render the effective Compose model.',
findings: [{ ruleId: 'render-failed', severity: 'blocker', title: 'Compose model could not be rendered', message: 'Sencho could not render the effective Compose model.' }],
})));
render(<PreflightPanel stackName="web" />);
const status = await screen.findByTestId('preflight-status');
expect(status).toHaveAttribute('data-status', 'unrenderable');
expect(status).toHaveTextContent(/cannot render/i);
});
it('shows a retry state and toasts when the load fails', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(null, false));
render(<PreflightPanel stackName="web" />);
expect(await screen.findByText(/Could not load the preflight report/i)).toBeInTheDocument();
expect(toast.error).toHaveBeenCalled();
});
it('runs preflight on demand and shows the new findings', async () => {
vi.mocked(apiFetch)
.mockResolvedValueOnce(jsonRes(report({ status: 'never-run', ranAt: null })))
.mockResolvedValueOnce(jsonRes(report({
status: 'blocker', highestSeverity: 'blocker',
findings: [{ ruleId: 'port-conflict-node', severity: 'blocker', title: 'Host port 8080 is already in use', message: 'taken', service: 'web' }],
})));
render(<PreflightPanel stackName="web" />);
fireEvent.click(await screen.findByTestId('preflight-run-btn'));
expect(await screen.findByText('Host port 8080 is already in use')).toBeInTheDocument();
await waitFor(() => {
const calls = vi.mocked(apiFetch).mock.calls;
expect(calls.some(([url, opts]) => String(url).includes('/preflight/run') && (opts as RequestInit | undefined)?.method === 'POST')).toBe(true);
});
});
});
@@ -0,0 +1,226 @@
import { useEffect, useState } from 'react';
import {
Check, TriangleAlert, ShieldAlert, Info, RefreshCw, Stethoscope, type LucideIcon,
} from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui/toast-store';
import { formatTimeAgo } from '@/lib/relativeTime';
import { useNodes } from '@/context/NodeContext';
// Mirrors the backend payload shape (the frontend never imports backend).
type PreflightSeverity = 'blocker' | 'high' | 'warning' | 'info';
type PreflightStatus = 'never-run' | 'pass' | 'unrenderable' | PreflightSeverity;
interface PreflightFinding {
ruleId: string;
severity: PreflightSeverity;
title: string;
message: string;
sourcePath?: string;
remediation?: string;
service?: string;
}
interface PreflightReport {
stack: string;
ranAt: number | null;
ranBy: string | null;
renderable: boolean;
renderError: string | null;
status: PreflightStatus;
highestSeverity: PreflightSeverity | null;
findings: PreflightFinding[];
}
const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle';
const ACTION_CLASS =
'inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors disabled:opacity-40';
const CARD_CLASS = 'rounded-lg border px-3 py-2.5';
const SEVERITY_META: Record<PreflightSeverity, { label: string; icon: LucideIcon; tone: string }> = {
blocker: { label: 'blocker', icon: ShieldAlert, tone: 'border-destructive/40 bg-destructive/[0.06] text-destructive' },
high: { label: 'high risk', icon: TriangleAlert, tone: 'border-warning/40 bg-warning/[0.06] text-warning' },
warning: { label: 'warning', icon: Info, tone: 'border-info/40 bg-info/[0.06] text-info' },
info: { label: 'info', icon: Info, tone: 'border-muted bg-card/40 text-stat-subtitle' },
};
const GROUP_ORDER: PreflightSeverity[] = ['blocker', 'high', 'warning', 'info'];
/** The header summary card: a single read on the overall result. */
function summaryMeta(report: PreflightReport): { label: string; icon: LucideIcon; tone: string; line: string } {
if (!report.renderable) {
return {
label: 'cannot render',
icon: ShieldAlert,
tone: 'border-destructive/40 bg-destructive/[0.06] text-destructive',
line: report.renderError ?? 'Sencho could not render the effective Compose model.',
};
}
if (report.findings.length === 0) {
return { label: 'all clear', icon: Check, tone: 'border-success/40 bg-success/[0.06] text-success', line: 'No issues found in the effective model.' };
}
const meta = SEVERITY_META[report.highestSeverity ?? 'info'];
const counts = GROUP_ORDER
.map(sev => ({ sev, n: report.findings.filter(f => f.severity === sev).length }))
.filter(c => c.n > 0)
.map(c => `${c.n} ${SEVERITY_META[c.sev].label}`)
.join(' · ');
return { label: meta.label, icon: meta.icon, tone: meta.tone, line: counts };
}
function FindingRow({ finding }: { finding: PreflightFinding }) {
return (
<div className="border-t border-muted py-2 first:border-t-0">
<div className="flex flex-wrap items-center gap-2">
{finding.service && (
<span className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{finding.service}</span>
)}
<span className="text-[12px] font-medium text-foreground/90">{finding.title}</span>
</div>
<div className="mt-1 text-[12px] leading-relaxed text-foreground/80">{finding.message}</div>
{finding.remediation && (
<div className="mt-1 text-[11px] text-stat-subtitle">
<span className="font-mono text-[10px] uppercase tracking-wide">fix</span> · {finding.remediation}
</div>
)}
{finding.sourcePath && (
<div className="mt-0.5 font-mono text-[10px] text-stat-subtitle">{finding.sourcePath}</div>
)}
</div>
);
}
export default function PreflightPanel({ stackName }: { stackName: string }) {
const { activeNode } = useNodes();
const nodeId = activeNode?.id;
const [report, setReport] = useState<PreflightReport | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [running, setRunning] = useState(false);
// Passive load of the last stored run when the stack or active node changes.
// Read-only: opening the tab never renders or stores anything.
useEffect(() => {
let cancelled = false;
const run = async () => {
setLoading(true);
setLoadError(false);
try {
const res = await apiFetch(`/stacks/${stackName}/preflight`);
if (cancelled) return;
if (!res.ok) {
setLoadError(true);
toast.error('Failed to load the preflight report.');
return;
}
setReport((await res.json()) as PreflightReport);
setLoadError(false);
} catch {
if (!cancelled) {
setLoadError(true);
toast.error('Failed to load the preflight report.');
}
} finally {
if (!cancelled) setLoading(false);
}
};
void run();
return () => { cancelled = true; };
}, [stackName, nodeId, reloadKey]);
// Running preflight renders the effective model and stores the result.
const runPreflight = async () => {
setRunning(true);
try {
const res = await apiFetch(`/stacks/${stackName}/preflight/run`, { method: 'POST' });
if (!res.ok) {
toast.error('Failed to run preflight.');
return;
}
setReport((await res.json()) as PreflightReport);
setLoadError(false);
} catch {
toast.error('Failed to run preflight.');
} finally {
setRunning(false);
}
};
const summary = report && report.status !== 'never-run' ? summaryMeta(report) : null;
const SummaryIcon = summary?.icon;
const busy = loading || running;
return (
<div data-testid="preflight-panel" className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<span className={LABEL_CLASS}>compose doctor</span>
<button
type="button"
data-testid="preflight-run-btn"
onClick={runPreflight}
disabled={busy}
className={ACTION_CLASS}
>
<RefreshCw className={cn('h-3 w-3', running && 'animate-spin')} strokeWidth={1.5} /> run preflight
</button>
</div>
{loadError ? (
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/40 bg-destructive/[0.06] px-3 py-3">
<span className="font-mono text-[11px] text-destructive">Could not load the preflight report.</span>
<button
type="button"
onClick={() => setReloadKey(k => k + 1)}
className="font-mono text-[10px] uppercase tracking-wide text-destructive hover:underline"
>
retry
</button>
</div>
) : !report ? (
<div className="py-3 font-mono text-[11px] text-stat-subtitle">Loading preflight</div>
) : report.status === 'never-run' ? (
<div className={cn(CARD_CLASS, 'border-muted bg-card/40 flex flex-col items-start gap-2')}>
<div className="flex items-center gap-2 text-stat-subtitle">
<Stethoscope className="h-4 w-4" strokeWidth={1.5} />
<span className="font-mono text-[11px] uppercase tracking-wide">no preflight yet</span>
</div>
<p className="text-[12px] leading-relaxed text-foreground/80">
Run preflight to render the effective model and check this stack for common deploy problems before you apply it.
</p>
</div>
) : (
<>
{summary && SummaryIcon && (
<div data-testid="preflight-status" data-status={report.status} className={cn(CARD_CLASS, summary.tone)}>
<div className="flex items-center gap-2">
<SummaryIcon className="h-4 w-4 shrink-0" strokeWidth={1.5} />
<span className="font-mono text-[11px] uppercase tracking-wide">{summary.label}</span>
{report.ranAt && (
<span className="font-mono text-[10px] text-stat-subtitle">
· ran {formatTimeAgo(report.ranAt)}{report.ranBy ? ` by ${report.ranBy}` : ''}
</span>
)}
</div>
<div className="mt-1 font-mono text-[11px] leading-relaxed text-foreground/80">{summary.line}</div>
</div>
)}
{GROUP_ORDER.map(sev => {
const items = report.findings.filter(f => f.severity === sev);
if (items.length === 0) return null;
return (
<section key={sev}>
<div className={cn(LABEL_CLASS, 'mb-1.5')}>{SEVERITY_META[sev].label} · {items.length}</div>
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
{items.map((f, i) => <FindingRow key={`${f.ruleId}-${f.service ?? ''}-${i}`} finding={f} />)}
</div>
</section>
);
})}
</>
)}
</div>
);
}
+1
View File
@@ -23,6 +23,7 @@ export const CAPABILITIES = [
'registries',
'self-update',
'vulnerability-scanning',
'compose-doctor',
] as const;
export type Capability = (typeof CAPABILITIES)[number];