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,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[];
}