Compose validate - a preflight linter & validator for compose files

This commit is contained in:
jarek committed 2026-08-18 10:05:36 +02:00
1 parent 6fab8ec70a
commit 1ad16a7c2a
13 files changed
+2297 -15

No files matched your search

@@ -0,0 +1,49 @@
/**
* Pure context assembly - NO docker.ts import (which pulls better-sqlite3 and can't
* load under the bun unit runner). buildValidateContext (context.ts) fetches the live
* lists and calls this; unit tests import only this file.
*/
import type { ValidateContext } from './types';
/** Minimal shapes the pure builder needs (subset of docker.ts types). */
export interface CtxContainer {
name: string;
ports?: Array<{ PublicPort?: number }>;
}
/**
* Given the env's containers/networks/volumes, produce the ValidateContext. Excludes
* THIS stack's own containers so a re-deploy isn't flagged as a self-collision (compose
* names its containers `<project>-<svc>-<n>`).
*/
export function deriveContextFromLists(
containers: CtxContainer[],
networkNames: string[],
volumeNames: string[],
selfStackName?: string
): ValidateContext {
const usedHostPorts = new Set<number>();
const hostPortOwners = new Map<number, string>();
const existingContainerNames = new Set<string>();
for (const c of containers) {
const isSelf = !!selfStackName && c.name.startsWith(`${selfStackName}-`);
if (isSelf) continue;
existingContainerNames.add(c.name);
for (const p of c.ports || []) {
if (typeof p.PublicPort === 'number' && p.PublicPort > 0) {
usedHostPorts.add(p.PublicPort);
// First writer wins; a port maps to one publishing container.
if (!hostPortOwners.has(p.PublicPort)) hostPortOwners.set(p.PublicPort, c.name);
}
}
}
return {
selfStackName,
usedHostPorts,
hostPortOwners,
existingContainerNames,
existingNetworks: new Set(networkNames.filter(Boolean)),
existingVolumes: new Set(volumeNames.filter(Boolean))
};
}
@@ -0,0 +1,34 @@
/**
* Build the ValidateContext for a target environment: the live facts the
* context-aware rules need (host ports already in use, existing networks/volumes,
* container names). Thin async wrapper over the docker.ts list functions; the pure
* assembly lives in context-core.ts so it stays unit-testable without docker.ts.
*/
import { listContainers, listNetworks, listVolumes } from '../docker';
import { deriveContextFromLists } from './context-core';
import type { ValidateContext } from './types';
export { deriveContextFromLists } from './context-core';
/**
* Fetch the live context for `envId`. Degrades to an empty-ish context if the env
* can't be reached, so a validate never fails just because the daemon is momentarily
* down (context-aware rules simply produce nothing).
*/
export async function buildValidateContext(
envId: number | null | undefined,
selfStackName?: string
): Promise<ValidateContext> {
const [containers, networks, volumes] = await Promise.all([
listContainers(true, envId).catch(() => []),
listNetworks(envId).catch(() => []),
listVolumes(envId).catch(() => [])
]);
return deriveContextFromLists(
containers,
networks.map((n) => n.name),
volumes.map((v) => v.name),
selfStackName
);
}
@@ -0,0 +1,198 @@
/**
* Effective-compose provider via `docker compose config`.
*
* `docker compose config` is Docker's OWN validator: it renders the fully effective
* compose (interpolation + env_file + profiles + include + extends + overrides) and
* errors on any schema/syntax violation. We use it for two things:
* 1. authoritative schema/syntax findings (its stderr), and
* 2. the effective compose object the context-aware rules reason about.
*
* It is NOT a replacement for the rule catalog: `config` only says valid/invalid per
* schema - it can't know a host port is taken by another stack, that a key is a typo
* (it drops unknown keys just like `up`), or the transport/bind reality. Those stay in
* rules.ts. And `config`'s output is re-serialized, so its lines do NOT map to the
* user's original file - editor markers use the local position map (parse.ts), not this.
*
* Runs the daemon, so it's the deploy-time / env-online path. Edit-time falls back to
* the local parser. Best-effort: any failure returns { ok: false } and the caller uses
* the local parse instead of blocking.
*/
import { spawn } from 'node:child_process';
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { resolveComposeDockerHost, buildComposeBaseArgs } from '../compose-docker-args';
import type { Finding } from './types';
const CONFIG_TIMEOUT_MS = 20_000;
const OUTPUT_LIMIT = 8 * 1024 * 1024;
const STDERR_LIMIT = 256 * 1024;
const KILL_GRACE_MS = 2_000;
export interface EffectiveComposeResult {
/** true when `docker compose config` ran and validated the file. */
ok: boolean;
/** The rendered effective compose object (for the rules), when ok. */
effective?: Record<string, unknown>;
/** Schema/syntax findings extracted from `config` stderr (severity: error). */
findings: Finding[];
}
/**
* Run `docker compose config` for a compose string on the given daemon. `dockerHost`
* is the resolved TCP/socket host for the target env (null/undefined = local socket).
* Never throws; failure yields { ok: false, findings: [] } so the caller degrades to
* the local parser.
*/
export async function renderEffectiveCompose(
stackName: string,
composeContent: string,
dockerHost?: string | null
): Promise<EffectiveComposeResult> {
let dir: string | undefined;
try {
dir = await mkdtemp(join(tmpdir(), 'dockhand-validate-'));
await chmod(dir, 0o700);
const file = join(dir, 'docker-compose.yml');
await writeFile(file, composeContent, 'utf8');
const composeDockerHost = resolveComposeDockerHost(dockerHost, undefined);
const base = buildComposeBaseArgs(stackName, composeDockerHost); // ['docker', ('-H' host)?, 'compose', '-p', name]
// `--format json` renders the FULLY effective compose as JSON: interpolation,
// env_file, profiles, include, extends, overrides all resolved, ports normalized
// to long form. This is the authoritative object our context-aware rules reason
// about - far better than the raw ${VAR} source.
const args = [...base.slice(1), '-f', file, 'config', '--format', 'json'];
const { code, stdout, stderr } = await execDocker(base[0], args, dir);
if (code === 0) {
let effective: Record<string, unknown> | undefined;
try {
const parsed = JSON.parse(stdout);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
effective = parsed as Record<string, unknown>;
}
} catch {
// config succeeded but output unparseable - treat as no effective object
}
return { ok: true, effective, findings: [] };
}
// Non-zero: config found a schema/syntax problem. Surface it as an error finding.
return { ok: false, findings: parseConfigErrors(stderr) };
} catch {
// docker missing / daemon unreachable / spawn error: degrade silently.
return { ok: false, findings: [] };
} finally {
if (dir) await rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
}
/** Turn `docker compose config` stderr into schema findings. Each non-empty, non-noise
* line becomes an error finding; a `line: N` hint in the message is lifted when present. */
export function parseConfigErrors(stderr: string): Finding[] {
const findings: Finding[] = [];
const seen = new Set<string>();
for (const rawLine of stderr.split('\n')) {
const line = rawLine.trim();
if (!line) continue;
// Drop docker's generic framing so only the actual validation message shows.
// `validating <path>: services.x ...` -> strip the leading verb AND the temp
// compose-file path we wrote it to (users never see that scratch path).
const msg = line
.replace(/^validating\s+/i, '')
.replace(/^error:\s*/i, '')
.replace(/^time=".*?"\s+level=\w+\s+msg="?/i, '')
.replace(/^\S*\.ya?ml:\s*/i, '')
.replace(/"$/, '')
.trim();
if (!msg || seen.has(msg)) continue;
seen.add(msg);
const lineMatch = /line\s+(\d+)/i.exec(msg);
findings.push({
ruleId: 'COMPOSE_SCHEMA_ERROR',
severity: 'error',
message: msg.slice(0, 400),
hint: 'Reported by `docker compose config` - the file will not deploy as-is.',
line: lineMatch ? Number(lineMatch[1]) : undefined
});
}
// If config failed but printed nothing useful, still record one error.
if (findings.length === 0) {
findings.push({
ruleId: 'COMPOSE_SCHEMA_ERROR',
severity: 'error',
message: 'docker compose config rejected the file (no detail provided)',
hint: 'Run `docker compose config` locally to see the full error.'
});
}
return findings;
}
/** Spawn helper: shell:false, bounded output + lifetime, returns code/stdout/stderr. */
function execDocker(
exe: string,
args: string[],
cwd: string
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const outChunks: Buffer[] = [];
const errChunks: Buffer[] = [];
let outBytes = 0;
let errBytes = 0;
let settled = false;
let killTimer: ReturnType<typeof setTimeout> | undefined;
const child = spawn(exe, args, {
cwd,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
env: { PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin' }
});
const timeout = setTimeout(() => {
try {
child.kill('SIGTERM');
} catch {
/* close handler settles */
}
killTimer = setTimeout(() => {
try {
child.kill('SIGKILL');
} catch {
/* ignore */
}
}, KILL_GRACE_MS);
}, CONFIG_TIMEOUT_MS);
child.stdout?.on('data', (c: Buffer) => {
outBytes += c.length;
if (outBytes <= OUTPUT_LIMIT) outChunks.push(c);
});
child.stderr?.on('data', (c: Buffer) => {
errBytes += c.length;
if (errBytes <= STDERR_LIMIT) errChunks.push(c);
});
child.once('error', (e) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (killTimer) clearTimeout(killTimer);
reject(e);
});
child.once('close', (code) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (killTimer) clearTimeout(killTimer);
resolve({
code: code ?? 1,
stdout: Buffer.concat(outChunks).toString('utf8'),
stderr: Buffer.concat(errChunks).toString('utf8')
});
});
});
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Compose Validate - preflight linter entry point.
*
* runValidate(composeSource, ctx) parses the compose once, runs every rule, and
* returns findings (with editor line positions) sorted most-severe first. Pure and
* synchronous: the caller pre-fetches any environment context into `ctx`.
*/
import { parseCompose } from './parse';
import { RULES } from './rules';
import { renderEffectiveCompose } from './effective-compose';
import { findingKey } from '../../utils/compose-quick-fix';
import type { ValidateConfig, ValidateContext, ValidateReport, Finding, Severity } from './types';
const SEVERITY_ORDER: Record<Severity, number> = { error: 0, warn: 1, info: 2 };
/** The rule catalog as UI/config metadata (id, description, group, default severity). */
export function ruleCatalog(): { id: string; description: string; group: string; defaultSeverity: Severity }[] {
return RULES.map((r) => ({
id: r.id,
description: r.description,
group: r.group,
defaultSeverity: r.defaultSeverity
}));
}
function assemble(findings: Finding[]): ValidateReport {
// Dedupe by ruleId+line+message so config's schema error and our YAML_PARSE_ERROR
// don't double up on the same underlying problem.
const seen = new Set<string>();
const deduped = findings.filter((f) => {
const key = findingKey(f);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
deduped.sort(
(a, b) =>
SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
(a.line ?? 1e9) - (b.line ?? 1e9)
);
const counts = { error: 0, warn: 0, info: 0 };
for (const f of deduped) counts[f.severity]++;
return { findings: deduped, counts };
}
/**
* Synchronous, offline lint. Parses the compose locally and runs the rule catalog.
* This is the fast every-keystroke / edit-time path (no daemon).
*/
export function runValidate(
composeSource: string,
ctx: ValidateContext = {},
config: ValidateConfig = {}
): ValidateReport {
const parsed = parseCompose(composeSource);
const findings: Finding[] = [];
const disabled = new Set(config.disabled ?? []);
const overrides = config.severity ?? {};
// A hard YAML/parse error is itself a finding; rules that need `doc` will no-op.
// (YAML_PARSE_ERROR is not a configurable rule - a file that won't parse is always
// an error.)
if (parsed.parseError) {
findings.push({
ruleId: 'YAML_PARSE_ERROR',
severity: 'error',
message: parsed.parseError.message,
hint: 'Fix the YAML syntax; nothing else can be checked until it parses.',
line: parsed.parseError.line
});
}
for (const rule of RULES) {
if (disabled.has(rule.id)) continue;
try {
for (const f of rule.check(parsed, ctx)) {
// Effective severity: explicit (graded rule) > user override > rule default.
const severity = f.severity ?? overrides[rule.id] ?? rule.defaultSeverity;
findings.push({ ...f, severity });
}
} catch {
// A rule must never break the whole report; skip its output on error.
}
}
return assemble(findings);
}
/**
* Full validate: run `docker compose config` FIRST for authoritative schema/syntax
* validation (its errors get added), then always run the local rule catalog for the
* checks config can't do (port collisions, exposure, typos, transport). The rules run
* against the ORIGINAL source so editor line markers stay accurate (config's output is
* re-serialized and its lines wouldn't map back). Falls back to a pure local lint when
* no daemon is reachable. This is the deploy-time / env-online path.
*/
export async function runValidateWithConfig(
stackName: string,
composeSource: string,
ctx: ValidateContext = {},
dockerHost?: string | null,
config: ValidateConfig = {}
): Promise<ValidateReport> {
const local = runValidate(composeSource, ctx, config);
const cfg = await renderEffectiveCompose(stackName, composeSource, dockerHost);
// If config produced schema errors, merge them in. If the file already failed to
// parse locally, prefer config's precise schema message but keep both.
if (cfg.findings.length > 0) {
const parsed = parseCompose(composeSource);
const located = cfg.findings.map((f) => (f.line ? f : locateSchemaError(f, parsed)));
return assemble([...located, ...local.findings]);
}
return local;
}
/**
* `docker compose config` reports schema errors by dotted key path
* (`services.api additional properties 'restar' not allowed`) but no line. Recover a
* line so the finding is clickable / gets an editor marker: map the dotted path to a
* key path and ask the local position map. Best-effort - returns the finding unchanged
* when nothing matches.
*/
function locateSchemaError(f: Finding, parsed: ReturnType<typeof parseCompose>): Finding {
// Pull the leading dotted path (`services.api.ports`) out of the message.
const pathMatch = /^([a-z_][\w.]*)/i.exec(f.message);
if (!pathMatch) return f;
const segs = pathMatch[1].split('.').filter(Boolean);
// `additional properties 'foo'` -> the offending key sits under that path.
const badKey = /additional propert(?:y|ies)\s+'([^']+)'/i.exec(f.message)?.[1];
const keyPath = badKey ? [...segs, badKey] : segs;
const line = parsed.lineOf(keyPath) ?? parsed.lineOf(segs);
return line ? { ...f, line } : f;
}
export type { ValidateReport, Finding, ValidateContext, Severity } from './types';
+109
View File
@@ -0,0 +1,109 @@
/**
* Parse a compose file into a plain object plus a key-path -> line resolver.
*
* Two parsers on purpose: js-yaml gives the ergonomic plain object the rules read,
* and eemeli's `yaml` (already in the tree) gives node ranges + a LineCounter for
* precise editor markers. A duplicate-key or invalid-YAML error surfaces as a
* parseError with its line (js-yaml is strict about duplicate keys by default).
*/
import jsyaml from 'js-yaml';
import { parseDocument, LineCounter, isNode, isMap, isScalar } from 'yaml';
import type { ParsedCompose } from './types';
export function parseCompose(source: string): ParsedCompose {
// Plain object for the rules. js-yaml throws on duplicate keys and bad syntax.
let doc: Record<string, unknown> | null = null;
let parseError: ParsedCompose['parseError'] | undefined;
try {
const loaded = jsyaml.load(source);
doc = loaded && typeof loaded === 'object' && !Array.isArray(loaded)
? (loaded as Record<string, unknown>)
: null;
if (loaded !== null && loaded !== undefined && doc === null) {
parseError = { message: 'Compose file must be a YAML mapping at the top level' };
}
} catch (e: unknown) {
const mark = (e as { mark?: { line?: number } })?.mark;
parseError = {
message: e instanceof Error ? e.message.split('\n')[0] : 'Invalid YAML',
line: typeof mark?.line === 'number' ? mark.line + 1 : undefined
};
}
// Position map via eemeli's yaml. Independent of js-yaml so a lint-worthy but
// js-yaml-rejected file (duplicate key) can still report the error's line above.
const lineCounter = new LineCounter();
let positionDoc: ReturnType<typeof parseDocument> | null = null;
try {
positionDoc = parseDocument(source, { lineCounter, keepSourceTokens: false });
} catch {
positionDoc = null;
}
const lineOf = (path: (string | number)[]): number | undefined => {
if (!positionDoc) return undefined;
try {
// For a mapping key (string last segment) return the KEY's line, not its
// value node: a value that is a nested map/seq starts on the NEXT line, which
// would push the marker one line down (off-by-one on any parent key).
const last = path[path.length - 1];
if (typeof last === 'string') {
const parent =
path.length === 1 ? positionDoc.contents : positionDoc.getIn(path.slice(0, -1), true);
if (isMap(parent)) {
const pair = parent.items.find((it) => isScalar(it.key) && it.key.value === last);
if (pair && isNode(pair.key) && pair.key.range) {
return lineCounter.linePos(pair.key.range[0]).line;
}
}
}
const node = positionDoc.getIn(path, true);
if (isNode(node) && node.range) {
return lineCounter.linePos(node.range[0]).line;
}
} catch {
// bad path -> no line
}
return undefined;
};
// The 0-based indentation (column - 1) of the KEY at a mapping path, so an
// insert-fix can match the file's own indentation width. undefined if unresolved.
const indentOf = (path: (string | number)[]): number | undefined => {
if (!positionDoc) return undefined;
try {
const last = path[path.length - 1];
if (typeof last !== 'string') return undefined;
const parent =
path.length === 1 ? positionDoc.contents : positionDoc.getIn(path.slice(0, -1), true);
if (isMap(parent)) {
const pair = parent.items.find((it) => isScalar(it.key) && it.key.value === last);
if (pair && isNode(pair.key) && pair.key.range) {
return lineCounter.linePos(pair.key.range[0]).col - 1;
}
}
} catch {
// bad path -> no indent
}
return undefined;
};
// The 0-based column where the VALUE node at a path begins (for a scalar in a
// sequence, the start of that item), so a replace-fix can anchor to the exact token
// instead of the first substring match on the line. undefined if unresolved.
const columnOf = (path: (string | number)[]): number | undefined => {
if (!positionDoc) return undefined;
try {
const node = positionDoc.getIn(path, true);
if (isNode(node) && node.range) {
return lineCounter.linePos(node.range[0]).col - 1;
}
} catch {
// bad path -> no column
}
return undefined;
};
return { doc, parseError, lineOf, indentOf, columnOf };
}
+732
View File
@@ -0,0 +1,732 @@
/**
* Compose Validate rule catalog (plugin model).
*
* Each rule is a self-contained RuleDefinition: { id, description, group,
* defaultSeverity, check }. The engine (index.ts) runs each rule, stamps the effective
* severity (default OR user override), and isolates a crashing rule. A rule's `check`
* returns findings WITHOUT severity (the engine fills it) unless the rule is
* intrinsically graded, in which case it sets its own and the engine respects it.
*
* ADDING A RULE: write one RuleDefinition and push it into RULES below. That's it -
* the id auto-appears in the settings UI and the config accepts disable/severity for it.
*/
import type { RuleDefinition, RuleFinding, ParsedCompose } from './types';
import { SERVICE_KEYS, TOP_LEVEL_KEYS, isLikelyTypo } from './vocab';
// --- small readers over the plain-object compose --------------------------------
function services(p: ParsedCompose): Record<string, Record<string, unknown>> {
const s = p.doc?.services;
return s && typeof s === 'object' && !Array.isArray(s)
? (s as Record<string, Record<string, unknown>>)
: {};
}
function asRecord(v: unknown): Record<string, unknown> | null {
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}
function ports(svc: Record<string, unknown>): unknown[] {
return Array.isArray(svc.ports) ? svc.ports : [];
}
/**
* Parse a compose port entry into { hostIp, hostPort }. Handles the short string
* form ("8080:80", "127.0.0.1:8080:80", "8080", "8080-8090:80"), and the long
* object form ({ published, target, host_ip }). Returns null when there's no host
* publish (e.g. a bare container port with no mapping).
*/
export function parsePortEntry(
entry: unknown
): { hostIp: string | null; hostPort: number | null; raw: string } | null {
if (typeof entry === 'number') return { hostIp: null, hostPort: null, raw: String(entry) };
if (typeof entry === 'string') {
const raw = entry;
// Bracketed IPv6 host ip: "[::1]:8080:80" / "[::]:8080:80". Split off the bracket
// first so the ':' inside the address doesn't break the field split.
const v6 = /^\[([0-9a-fA-F:]+)\]:(.+)$/.exec(entry);
if (v6) {
const rest = v6[2].split(':'); // host[:container]
return { hostIp: v6[1], hostPort: toPort(rest[0]), raw };
}
const parts = entry.split(':');
if (parts.length === 1) return { hostIp: null, hostPort: null, raw }; // "80" - not published
if (parts.length === 2) return { hostIp: null, hostPort: toPort(parts[0]), raw };
return { hostIp: parts[0] || null, hostPort: toPort(parts[1]), raw }; // ip:host:container
}
const obj = asRecord(entry);
if (obj) {
const raw = JSON.stringify(obj);
const published = obj.published;
const hostPort =
typeof published === 'number'
? published
: typeof published === 'string'
? toPort(published)
: null;
return { hostIp: typeof obj.host_ip === 'string' ? obj.host_ip : null, hostPort, raw };
}
return null;
}
function toPort(s: string): number | null {
const first = s.split('-')[0].trim();
const n = Number(first);
return Number.isInteger(n) && n > 0 ? n : null;
}
const DB_CACHE_IMAGE =
/(?:^|\/)(postgres|mysql|mariadb|mongo|redis|memcached|rabbitmq|elasticsearch|clickhouse|cassandra|couchdb|influxdb)(?::|$)/i;
// Env-var NAMES that look like a secret (matched case-insensitively as a whole word part).
// Secret-looking env NAMES. Anchored on word/underscore boundaries so a secret word
// must be a whole segment (matches DB_PASSWORD, API_KEY; not TOKENIZED, PASSWORD_POLICY-
// style substrings). Plural credentials allowed.
const SECRET_NAME =
/(^|[_-])(password|passwd|secret|api[_-]?key|access[_-]?key|private[_-]?key|token|credentials?)([_-]|$)/i;
// `*_FILE` / `*_PATH` name a file to READ the secret from - that's the SAFE pattern
// (Docker/compose secrets), so don't flag those even if the name contains "password".
const SECRET_FILE_REF = /(_file|_path)$/i;
// Host paths that must never be bind-mounted writable - a rw mount here = own the host.
const SENSITIVE_HOST_ROOTS = ['/', '/etc', '/usr', '/bin', '/sbin', '/boot', '/lib', '/var', '/root'];
// Linux capabilities that approach privileged.
const DANGEROUS_CAPS = new Set(['ALL', 'SYS_ADMIN', 'NET_ADMIN', 'SYS_PTRACE', 'SYS_MODULE', 'DAC_READ_SEARCH']);
/**
* Iterate a service `environment:` as [key, value, index] regardless of the two forms:
* a mapping ({KEY: val}) or a list of "KEY=val" strings. `value` is null when absent
* (list entry with no '=', or mapping value null). `index` is the list position for
* lineOf, or the key for the mapping form.
*/
function envEntries(svc: Record<string, unknown>): { key: string; value: string | null; at: string | number }[] {
const env = svc.environment;
const out: { key: string; value: string | null; at: string | number }[] = [];
if (Array.isArray(env)) {
env.forEach((e, i) => {
if (typeof e !== 'string') return;
const eq = e.indexOf('=');
out.push(eq === -1 ? { key: e, value: null, at: i } : { key: e.slice(0, eq), value: e.slice(eq + 1), at: i });
});
} else {
const rec = asRecord(env);
if (rec) for (const [k, v] of Object.entries(rec)) out.push({ key: k, value: v == null ? null : String(v), at: k });
}
return out;
}
// A short-form volume's option field is the last ':'-segment, a comma list
// ("ro", "ro,z", "z,ro", "rw"). Read-only if it contains the `ro` option.
function optsAreReadonly(optField: string): boolean {
return optField.split(',').some((o) => o.trim() === 'ro');
}
/** The bind SOURCE (host side) of a volume entry, short or long form; null if not a bind. */
function bindSource(v: unknown): { source: string; readonly: boolean } | null {
if (typeof v === 'string') {
const parts = v.split(':');
if (parts.length < 2) return null; // anonymous volume, not a bind
const source = parts[0];
if (!source.startsWith('/') && !source.startsWith('.') && !source.startsWith('~')) return null; // named volume
// The option field only exists when there are 3+ segments (src:dst:opts).
return { source, readonly: parts.length >= 3 && optsAreReadonly(parts[parts.length - 1]) };
}
const o = asRecord(v);
if (o?.type === 'bind' && typeof o.source === 'string') {
return { source: o.source, readonly: o.read_only === true };
}
return null;
}
// --- rule catalog ---------------------------------------------------------------
export const RULES: RuleDefinition[] = [
{
id: 'NO_SERVICES',
description: 'The compose file defines no services',
group: 'correctness',
defaultSeverity: 'error',
check(p) {
if (!p.doc) return [];
if (Object.keys(services(p)).length === 0) {
return [
{
ruleId: 'NO_SERVICES',
message: 'No services defined - the compose file will deploy nothing',
hint: 'Add a `services:` block, or check for a typo in the key.',
line: p.lineOf(['services'])
}
];
}
return [];
}
},
{
id: 'DUPLICATE_HOST_PORT',
description: 'Two services in this stack publish the same host port',
group: 'correctness',
defaultSeverity: 'error',
check(p) {
const out: RuleFinding[] = [];
const seen = new Map<number, string>();
for (const [name, svc] of Object.entries(services(p))) {
for (let i = 0; i < ports(svc).length; i++) {
const parsed = parsePortEntry(ports(svc)[i]);
if (!parsed?.hostPort) continue;
const prev = seen.get(parsed.hostPort);
if (prev && prev !== name) {
out.push({
ruleId: 'DUPLICATE_HOST_PORT',
service: name,
message: `Host port ${parsed.hostPort} is already published by service "${prev}"`,
hint: 'Two services cannot bind the same host port; change one.',
line: p.lineOf(['services', name, 'ports', i]) ?? p.lineOf(['services', name])
});
} else if (!prev) {
seen.set(parsed.hostPort, name);
}
}
}
return out;
}
},
{
id: 'CROSS_STACK_PORT_COLLISION',
description: 'A host port is already used by another container on the environment',
group: 'correctness',
defaultSeverity: 'error',
check(p, ctx) {
if (!ctx.usedHostPorts || ctx.usedHostPorts.size === 0) return [];
const out: RuleFinding[] = [];
for (const [name, svc] of Object.entries(services(p))) {
for (let i = 0; i < ports(svc).length; i++) {
const parsed = parsePortEntry(ports(svc)[i]);
if (parsed?.hostPort && ctx.usedHostPorts.has(parsed.hostPort)) {
const owner = ctx.hostPortOwners?.get(parsed.hostPort);
out.push({
ruleId: 'CROSS_STACK_PORT_COLLISION',
service: name,
message: owner
? `Host port ${parsed.hostPort} is already in use on this environment by container "${owner}"`
: `Host port ${parsed.hostPort} is already in use on this environment by another container`,
hint: owner
? `Pick a free host port, or stop "${owner}".`
: 'Pick a free host port, or stop the container using it.',
line: p.lineOf(['services', name, 'ports', i]) ?? p.lineOf(['services', name])
});
}
}
}
return out;
}
},
{
id: 'DB_PORT_ON_ALL_INTERFACES',
description: 'A database/cache service publishes a port on all interfaces',
group: 'security',
defaultSeverity: 'warn',
check(p) {
const out: RuleFinding[] = [];
for (const [name, svc] of Object.entries(services(p))) {
const image = typeof svc.image === 'string' ? svc.image : '';
if (!DB_CACHE_IMAGE.test(image)) continue;
for (let i = 0; i < ports(svc).length; i++) {
const entry = ports(svc)[i];
const parsed = parsePortEntry(entry);
if (parsed?.hostPort && (!parsed.hostIp || parsed.hostIp === '0.0.0.0')) {
const line = p.lineOf(['services', name, 'ports', i]);
// A fix is only unambiguous for the short string form ("5432:5432" ->
// "127.0.0.1:5432:5432"). "0.0.0.0:..." is replaced in place; the long
// object form has no single-token edit, so it gets no fix.
let fix: RuleFinding['fix'];
if (line && typeof entry === 'string') {
const replaceTok =
parsed.hostIp === '0.0.0.0'
? entry.replace(/^0\.0\.0\.0:/, '127.0.0.1:')
: `127.0.0.1:${entry}`;
// Anchor to this entry's column so a short port that is a substring
// of an earlier port token on the same (flow) line isn't corrupted.
const at = p.columnOf(['services', name, 'ports', i]);
fix = { kind: 'replace-in-line', line, find: entry, replace: replaceTok, at };
}
out.push({
ruleId: 'DB_PORT_ON_ALL_INTERFACES',
service: name,
message: `Database/cache service "${name}" publishes port ${parsed.hostPort} on all interfaces`,
hint: 'Bind to 127.0.0.1 (e.g. "127.0.0.1:5432:5432") or drop the host mapping and reach it over the compose network.',
line: line ?? p.lineOf(['services', name]),
fix,
fixDescription: 'Bind this port to 127.0.0.1 (localhost only)'
});
}
}
}
return out;
}
},
{
id: 'DEPENDS_ON_UNDEFINED',
description: 'depends_on references a service not defined in the file',
group: 'correctness',
defaultSeverity: 'error',
check(p) {
const out: RuleFinding[] = [];
const svcNames = new Set(Object.keys(services(p)));
for (const [name, svc] of Object.entries(services(p))) {
const dep = svc.depends_on;
const deps = Array.isArray(dep)
? dep
: dep && typeof dep === 'object'
? Object.keys(dep as Record<string, unknown>)
: [];
for (const d of deps) {
if (typeof d === 'string' && !svcNames.has(d)) {
out.push({
ruleId: 'DEPENDS_ON_UNDEFINED',
service: name,
message: `"${name}" depends_on "${d}", which is not a service in this file`,
hint: 'Fix the name or define the service.',
line: p.lineOf(['services', name, 'depends_on']) ?? p.lineOf(['services', name])
});
}
}
}
return out;
}
},
{
id: 'UNDEFINED_NETWORK_REF',
description: 'A service uses a network that is not defined at the top level',
group: 'correctness',
defaultSeverity: 'error',
check(p) {
const defined = new Set(Object.keys(asRecord(p.doc?.networks) ?? {}));
// The implicit `default` network always exists.
defined.add('default');
const out: RuleFinding[] = [];
for (const [name, svc] of Object.entries(services(p))) {
const nets = svc.networks;
const refs = Array.isArray(nets)
? nets
: nets && typeof nets === 'object'
? Object.keys(nets as Record<string, unknown>)
: [];
for (const ref of refs) {
if (typeof ref === 'string' && !defined.has(ref)) {
out.push({
ruleId: 'UNDEFINED_NETWORK_REF',
service: name,
message: `"${name}" uses network "${ref}", which is not defined under the top-level networks:`,
hint: `Add "${ref}" to top-level networks: (mark it external: true if it already exists on the host).`,
line: p.lineOf(['services', name, 'networks']) ?? p.lineOf(['services', name])
});
}
}
}
return out;
}
},
{
id: 'UNDEFINED_VOLUME_REF',
description: 'A service mounts a named volume that is not defined at the top level',
group: 'correctness',
defaultSeverity: 'error',
check(p) {
const defined = new Set(Object.keys(asRecord(p.doc?.volumes) ?? {}));
const out: RuleFinding[] = [];
for (const [name, svc] of Object.entries(services(p))) {
const vols = Array.isArray(svc.volumes) ? svc.volumes : [];
for (let i = 0; i < vols.length; i++) {
const v = vols[i];
// A NAMED volume source is a bare token (no '/' or '.') on the left of ':'
// in short form, or a { type: volume, source } in long form. Bind mounts
// (paths) are not named volumes and are exempt.
let src: string | null = null;
if (typeof v === 'string') {
const left = v.split(':')[0];
if (left && !left.startsWith('/') && !left.startsWith('.') && !left.startsWith('~')) {
src = left;
}
} else {
const o = asRecord(v);
if (o?.type === 'volume' && typeof o.source === 'string') src = o.source;
}
if (src && !defined.has(src)) {
out.push({
ruleId: 'UNDEFINED_VOLUME_REF',
service: name,
message: `"${name}" mounts named volume "${src}", which is not defined under the top-level volumes:`,
hint: `Add "${src}" to top-level volumes: (mark it external: true if it already exists on the host).`,
line: p.lineOf(['services', name, 'volumes', i]) ?? p.lineOf(['services', name])
});
}
}
}
return out;
}
},
{
id: 'CONTAINER_NAME_COLLISION',
description: 'container_name is already used by a container on the environment',
group: 'correctness',
defaultSeverity: 'error',
check(p, ctx) {
if (!ctx.existingContainerNames || ctx.existingContainerNames.size === 0) return [];
const out: RuleFinding[] = [];
for (const [name, svc] of Object.entries(services(p))) {
const cn = svc.container_name;
if (typeof cn === 'string' && ctx.existingContainerNames.has(cn)) {
out.push({
ruleId: 'CONTAINER_NAME_COLLISION',
service: name,
message: `container_name "${cn}" is already used by a container on this environment`,
hint: 'A fixed container_name prevents clean recreation; remove it or make it unique.',
line: p.lineOf(['services', name, 'container_name']) ?? p.lineOf(['services', name])
});
}
}
return out;
}
},
{
id: 'MISSING_EXTERNAL_RESOURCE',
description: 'An external network/volume does not exist on the environment',
group: 'correctness',
defaultSeverity: 'error',
check(p, ctx) {
const out: RuleFinding[] = [];
const check = (kind: 'networks' | 'volumes', existing: Set<string> | undefined) => {
if (!existing) return;
const block = asRecord(p.doc?.[kind]);
if (!block) return;
for (const [resName, def] of Object.entries(block)) {
const d = asRecord(def);
if (d?.external === true || asRecord(d?.external)) {
const actual =
(asRecord(d?.external)?.name as string) ||
(typeof d?.name === 'string' ? (d.name as string) : resName);
if (!existing.has(actual)) {
out.push({
ruleId: 'MISSING_EXTERNAL_RESOURCE',
message: `External ${kind === 'networks' ? 'network' : 'volume'} "${actual}" does not exist on this environment`,
hint: 'Create it first, or drop external: true to let compose create it.',
line: p.lineOf([kind, resName])
});
}
}
}
};
check('networks', ctx.existingNetworks);
check('volumes', ctx.existingVolumes);
return out;
}
},
{
id: 'LATEST_TAG',
description: 'An image uses :latest or is untagged',
group: 'reliability',
defaultSeverity: 'warn',
check(p) {
const out: RuleFinding[] = [];
for (const [name, svc] of Object.entries(services(p))) {
const image = typeof svc.image === 'string' ? svc.image : '';
if (!image) continue;
const afterSlash = image.substring(image.lastIndexOf('/') + 1);
const tag = afterSlash.includes(':') ? afterSlash.split(':').pop() : '';
if (!tag || tag === 'latest') {
out.push({
ruleId: 'LATEST_TAG',
service: name,
message: `"${name}" uses ${tag ? '`:latest`' : 'an untagged image'} (${image})`,
hint: 'Pin a version tag for reproducible deploys and to enable newer-version detection.',
line: p.lineOf(['services', name, 'image']) ?? p.lineOf(['services', name])
});
}
}
return out;
}
},
{
id: 'PRIVILEGED_CONTAINER',
description: 'A service runs privileged (full host access)',
group: 'security',
defaultSeverity: 'warn',
check(p) {
const out: RuleFinding[] = [];
for (const [name, svc] of Object.entries(services(p))) {
if (svc.privileged === true) {
out.push({
ruleId: 'PRIVILEGED_CONTAINER',
service: name,
message: `"${name}" runs privileged (full host access)`,
hint: 'Grant specific cap_add instead of privileged where possible.',
line: p.lineOf(['services', name, 'privileged']) ?? p.lineOf(['services', name])
});
}
}
return out;
}
},
{
id: 'DOCKER_SOCKET_MOUNT',
description: 'A service mounts the Docker socket',
group: 'security',
defaultSeverity: 'warn', // graded: the check upgrades rw to error itself
check(p) {
const out: RuleFinding[] = [];
for (const [name, svc] of Object.entries(services(p))) {
const vols = Array.isArray(svc.volumes) ? svc.volumes : [];
for (let i = 0; i < vols.length; i++) {
const v = vols[i];
const src = typeof v === 'string' ? v : asRecord(v)?.source;
const raw = typeof v === 'string' ? v : '';
if (typeof src === 'string' && src.includes('/var/run/docker.sock')) {
// Read-only if the short-form option field contains `ro` (ro / ro,z /
// z,ro), or the long form sets read_only. Only 3+ segments have opts.
const parts = raw.split(':');
const readonly =
(parts.length >= 3 && optsAreReadonly(parts[parts.length - 1])) ||
asRecord(v)?.read_only === true;
out.push({
ruleId: 'DOCKER_SOCKET_MOUNT',
severity: readonly ? 'warn' : 'error',
service: name,
message: `"${name}" mounts the Docker socket${readonly ? ' (read-only)' : ' read-write (full daemon control)'}`,
hint: readonly
? 'Read-only still exposes the daemon; consider a socket proxy.'
: 'Read-write docker.sock = root on the host. Use a scoped socket proxy.',
line: p.lineOf(['services', name, 'volumes', i]) ?? p.lineOf(['services', name])
});
}
}
}
return out;
}
},
{
id: 'OBSOLETE_VERSION_KEY',
description: 'The top-level version: key is obsolete',
group: 'schema',
defaultSeverity: 'info',
check(p) {
if (p.doc && 'version' in p.doc) {
const line = p.lineOf(['version']);
return [
{
ruleId: 'OBSOLETE_VERSION_KEY',
message: 'The top-level `version:` key is obsolete in the Compose Spec and is ignored',
hint: 'Safe to remove.',
line,
fix: line ? { kind: 'delete-line', line } : undefined,
fixDescription: 'Remove the obsolete version: line'
}
];
}
return [];
}
},
{
id: 'UNKNOWN_SERVICE_KEY',
description: 'A service key looks like a typo of a real Compose key',
group: 'schema',
defaultSeverity: 'warn',
check(p) {
const out: RuleFinding[] = [];
for (const [name, svc] of Object.entries(services(p))) {
for (const key of Object.keys(svc)) {
const suggestion = isLikelyTypo(key, SERVICE_KEYS);
if (suggestion) {
const line = p.lineOf(['services', name, key]);
out.push({
ruleId: 'UNKNOWN_SERVICE_KEY',
service: name,
message: `"${key}" is not a known service key - did you mean "${suggestion}"?`,
hint: 'Docker silently ignores unknown keys, so this setting never applies.',
line: line ?? p.lineOf(['services', name]),
fix: line ? { kind: 'replace-in-line', line, find: key, replace: suggestion } : undefined,
fixDescription: `Rename "${key}" to "${suggestion}"`
});
}
}
}
return out;
}
},
{
id: 'UNKNOWN_TOP_LEVEL_KEY',
description: 'A top-level key looks like a typo',
group: 'schema',
defaultSeverity: 'warn',
check(p) {
if (!p.doc) return [];
const out: RuleFinding[] = [];
for (const key of Object.keys(p.doc)) {
if (key.startsWith('x-')) continue;
const suggestion = isLikelyTypo(key, TOP_LEVEL_KEYS);
if (suggestion) {
const line = p.lineOf([key]);
out.push({
ruleId: 'UNKNOWN_TOP_LEVEL_KEY',
message: `Top-level "${key}" is not a known key - did you mean "${suggestion}"?`,
line,
fix: line ? { kind: 'replace-in-line', line, find: key, replace: suggestion } : undefined,
fixDescription: `Rename "${key}" to "${suggestion}"`
});
}
}
return out;
}
},
{
id: 'SECRET_IN_ENVIRONMENT',
description: 'A secret-looking value is hard-coded in environment: instead of a secret/${VAR}',
group: 'security',
defaultSeverity: 'warn',
check(p) {
const out: RuleFinding[] = [];
for (const [name, svc] of Object.entries(services(p))) {
for (const { key, value, at } of envEntries(svc)) {
if (value == null || value === '') continue;
// A ${VAR} interpolation is exactly the safe pattern - never flag it.
if (value.includes('${')) continue;
if (!SECRET_NAME.test(key)) continue;
// *_FILE / *_PATH point at a file to read the secret from - safe.
if (SECRET_FILE_REF.test(key)) continue;
// A numeric/boolean value isn't a credential - it's a policy/flag
// (PASSWORD_POLICY_DAYS=30, TOKEN_ENABLED=true), so don't flag it.
if (/^(true|false|\d+)$/i.test(value.trim())) continue;
out.push({
ruleId: 'SECRET_IN_ENVIRONMENT',
service: name,
message: `"${name}" hard-codes a secret value for ${key} in environment:`,
hint: 'Store it as a Dockhand secret and reference ${' + key + '}, so it never lands in the compose/.env on disk.',
line: p.lineOf(['services', name, 'environment', at]) ?? p.lineOf(['services', name])
});
}
}
return out;
}
},
{
id: 'HOST_NETWORK_MODE',
description: 'A service uses network_mode: host (no network isolation)',
group: 'security',
defaultSeverity: 'warn',
check(p) {
const out: RuleFinding[] = [];
for (const [name, svc] of Object.entries(services(p))) {
if (svc.network_mode === 'host') {
out.push({
ruleId: 'HOST_NETWORK_MODE',
service: name,
message: `"${name}" uses network_mode: host - it shares the host network with no isolation`,
hint: 'Publish only the ports you need instead, so the container stays on its own network.',
line: p.lineOf(['services', name, 'network_mode']) ?? p.lineOf(['services', name])
});
}
}
return out;
}
},
{
id: 'WRITABLE_ROOT_MOUNT',
description: 'A sensitive host path is bind-mounted (writable = full host compromise)',
group: 'security',
defaultSeverity: 'error', // graded: ro is downgraded to warn by the check
check(p) {
const out: RuleFinding[] = [];
for (const [name, svc] of Object.entries(services(p))) {
const vols = Array.isArray(svc.volumes) ? svc.volumes : [];
for (let i = 0; i < vols.length; i++) {
const bind = bindSource(vols[i]);
if (!bind) continue;
// docker.sock has its own dedicated rule; don't double-report it here.
if (bind.source.includes('/var/run/docker.sock')) continue;
const normalized = bind.source.replace(/\/+$/, '') || '/';
if (!SENSITIVE_HOST_ROOTS.includes(normalized)) continue;
out.push({
ruleId: 'WRITABLE_ROOT_MOUNT',
severity: bind.readonly ? 'warn' : 'error',
service: name,
message: `"${name}" bind-mounts the host path "${bind.source}"${bind.readonly ? ' (read-only)' : ' read-write'}`,
hint: bind.readonly
? 'Even read-only, this exposes host system files; mount only the subdirectory you need.'
: 'A writable mount of a system path lets the container modify the host. Mount a specific subdirectory, read-only.',
line: p.lineOf(['services', name, 'volumes', i]) ?? p.lineOf(['services', name])
});
}
}
return out;
}
},
{
id: 'CAP_ADD_DANGEROUS',
description: 'A service adds a near-privileged Linux capability',
group: 'security',
defaultSeverity: 'warn',
check(p) {
const out: RuleFinding[] = [];
for (const [name, svc] of Object.entries(services(p))) {
const caps = Array.isArray(svc.cap_add) ? svc.cap_add : [];
for (let i = 0; i < caps.length; i++) {
const cap = typeof caps[i] === 'string' ? (caps[i] as string).toUpperCase() : '';
if (DANGEROUS_CAPS.has(cap)) {
out.push({
ruleId: 'CAP_ADD_DANGEROUS',
service: name,
message: `"${name}" grants the ${cap} capability (near-privileged)`,
hint: 'Grant only the narrow capability the workload actually needs.',
line: p.lineOf(['services', name, 'cap_add', i]) ?? p.lineOf(['services', name])
});
}
}
}
return out;
}
},
{
id: 'MISSING_RESTART_POLICY',
description: 'A service has no restart policy (will not come back after a host reboot)',
group: 'reliability',
defaultSeverity: 'info',
check(p) {
const out: RuleFinding[] = [];
for (const [name, svc] of Object.entries(services(p))) {
if ('restart' in svc) continue;
// Compose Spec: deploy.restart_policy is the swarm-mode equivalent.
const deploy = asRecord(svc.deploy);
if (deploy && 'restart_policy' in deploy) continue;
const svcLine = p.lineOf(['services', name]);
const firstKey = Object.keys(svc)[0];
// Only offer an insert-after fix for a BLOCK-style service (children on
// their own lines). A flow map (`web: {}` or `web: {image: x}`) has no
// child line to insert under - splicing `restart:` there produces invalid
// YAML - so those get the finding but no fix.
const firstKeyLine = firstKey ? p.lineOf(['services', name, firstKey]) : undefined;
const isBlockService = firstKeyLine != null && svcLine != null && firstKeyLine > svcLine;
const childIndent = (firstKey ? p.indentOf(['services', name, firstKey]) : undefined) ?? 0;
const fix =
isBlockService && childIndent > 0
? ({
kind: 'insert-after',
line: svcLine!,
text: `${' '.repeat(childIndent)}restart: unless-stopped`
} as const)
: undefined;
out.push({
ruleId: 'MISSING_RESTART_POLICY',
service: name,
message: `"${name}" has no restart: policy - it will not restart after a crash or host reboot`,
hint: 'Add restart: unless-stopped (or on-failure) unless this is a one-shot task.',
line: svcLine,
fix,
fixDescription: 'Add restart: unless-stopped'
});
}
return out;
}
}
];
+127
View File
@@ -0,0 +1,127 @@
/**
* Compose Validate - shared types.
*
* A preflight linter for compose files: it parses the compose, runs a catalog of
* pure rules, and returns findings that carry a 1-based `line` so the editor can
* place inline markers (underline + gutter icon + tooltip), IDE-style.
*/
export type Severity = 'error' | 'warn' | 'info';
// The fix shape + applier live in ../../utils so both the server (emit) and the stack
// editor (apply) share one definition. Use a relative path, not the $lib alias: the bun
// unit runner has no $lib alias, so a value import from here would break CI.
export type { QuickFix } from '../../utils/compose-quick-fix';
import type { QuickFix } from '../../utils/compose-quick-fix';
export interface Finding {
/** Stable rule id, e.g. 'DUPLICATE_HOST_PORT'. */
ruleId: string;
severity: Severity;
/** Human message shown in the tooltip / panel. */
message: string;
/** Optional actionable hint ("bind to 127.0.0.1 or use a reverse proxy"). */
hint?: string;
/** Service the finding belongs to, when service-scoped. */
service?: string;
/** 1-based line in the ORIGINAL source for the editor marker. Absent => file-level. */
line?: number;
/** A one-click deterministic fix, when the rule can offer one unambiguously. */
fix?: QuickFix;
/** Plain-language description of what the fix does (button tooltip). */
fixDescription?: string;
}
/**
* Parsed compose plus a key-path -> line resolver. Built once by parseCompose and
* passed to every rule so a rule can attach a precise line without re-parsing.
*/
export interface ParsedCompose {
/** The plain-object compose (js-yaml style), or null if the YAML failed to parse. */
doc: Record<string, unknown> | null;
/** A hard parse error (invalid YAML / duplicate key), if any. */
parseError?: { message: string; line?: number };
/**
* Resolve the 1-based source line for a key path, e.g.
* lineOf(['services','web','image']). Returns undefined if the path has no node.
*/
lineOf(path: (string | number)[]): number | undefined;
/**
* The 0-based indentation of the KEY at a mapping path (column - 1), so an
* insert-fix can match the file's own indent width. undefined if unresolved.
*/
indentOf(path: (string | number)[]): number | undefined;
/**
* The 0-based column where the VALUE node at a path begins, so a replace-fix can
* anchor to the exact token rather than the first substring on the line. undefined
* if unresolved.
*/
columnOf(path: (string | number)[]): number | undefined;
}
/**
* Context a rule may consult beyond the compose itself. All optional: pure/offline
* rules ignore it; context-aware rules (cross-stack port collisions, missing external
* networks/volumes, secret-provider awareness) read it. Built by context.ts from the
* target environment; undefined when running offline (e.g. edit-time before a bind).
*/
export interface ValidateContext {
/** Host ports already published by OTHER stacks/containers on the target env. */
usedHostPorts?: Set<number>;
/** For each used host port, the container name that publishes it (for the message). */
hostPortOwners?: Map<number, string>;
/** Names of networks that already exist on the target env (for external: true). */
existingNetworks?: Set<string>;
/** Names of volumes that already exist on the target env (for external: true). */
existingVolumes?: Set<string>;
/** Container names already in use on the target env (for container_name collisions). */
existingContainerNames?: Set<string>;
/** Env-var names the bound secret provider can supply (for SENSITIVE_ENV). */
providerKeys?: Set<string>;
/** This stack's own name, so cross-stack checks can exclude self. */
selfStackName?: string;
}
/**
* What a rule's check returns: a finding whose `severity` is OPTIONAL. Normally a rule
* omits it and the engine stamps the effective severity (default or user override). A
* rule MAY set severity itself when it is intrinsically graded (e.g. docker.sock
* rw=error vs ro=warn); the engine respects an explicitly-set severity.
*/
export type RuleFinding = Omit<Finding, 'severity'> & { severity?: Severity };
/** A rule's check: a pure function (parsed compose, context) -> findings. */
export type RuleCheck = (parsed: ParsedCompose, ctx: ValidateContext) => RuleFinding[];
/**
* A rule is a self-contained plugin. Adding a new rule = write one RuleDefinition and
* register it in the catalog - nothing else. `id` is stable (used in config + the UI),
* `defaultSeverity` is the out-of-the-box level, `check` is the pure logic.
*/
export interface RuleDefinition {
/** Stable, uppercase-snake id, e.g. 'DUPLICATE_HOST_PORT'. Used in config + UI. */
id: string;
/** One-line human description shown in the rules settings UI. */
description: string;
/** Severity applied unless the user overrides it (or the check sets its own). */
defaultSeverity: Severity;
/** Group for the settings UI (security / correctness / reliability / schema). */
group: 'security' | 'correctness' | 'reliability' | 'schema';
/** The pure check. */
check: RuleCheck;
}
/**
* User configuration for the rule set. `disabled` turns a rule off entirely;
* `severity` overrides a rule's level. Keyed by rule id. Stored per-stack (and/or a
* global default) and passed into the engine. Absent = every rule at its default.
*/
export interface ValidateConfig {
disabled?: string[];
severity?: Record<string, Severity>;
}
export interface ValidateReport {
findings: Finding[];
counts: { error: number; warn: number; info: number };
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Compose Spec property vocabularies, per level, for the typo detector.
*
* Docker silently IGNORES unknown keys, so a typo'd `restart:` (`resart:`) never
* applies and there is no error - the classic invisible footgun. We flag a key that
* is NOT in the vocabulary but is edit-distance-close to one that is. Vocab seeded
* from the Compose Spec and the dcvalidator prior art (~/projects/dcvalidator
* typoMistake.py), trimmed to current spec keys.
*/
export const SERVICE_KEYS = new Set([
'image', 'build', 'command', 'entrypoint', 'environment', 'env_file', 'ports',
'expose', 'volumes', 'volumes_from', 'networks', 'network_mode', 'depends_on',
'restart', 'healthcheck', 'labels', 'container_name', 'hostname', 'domainname',
'user', 'working_dir', 'dns', 'dns_search', 'extra_hosts', 'external_links',
'links', 'cap_add', 'cap_drop', 'devices', 'security_opt', 'sysctls', 'ulimits',
'privileged', 'read_only', 'tty', 'stdin_open', 'stop_signal', 'stop_grace_period',
'logging', 'deploy', 'configs', 'secrets', 'profiles', 'pull_policy', 'platform',
'init', 'ipc', 'pid', 'mac_address', 'shm_size', 'tmpfs', 'mem_limit', 'cpus',
'cpu_shares', 'mem_reservation', 'runtime', 'group_add', 'isolation', 'scale',
'cgroup_parent', 'credential_spec', 'device_cgroup_rules', 'extends', 'annotations'
]);
export const TOP_LEVEL_KEYS = new Set([
'version', 'services', 'networks', 'volumes', 'configs', 'secrets', 'name', 'include'
]);
export const BUILD_KEYS = new Set([
'context', 'dockerfile', 'args', 'cache_from', 'cache_to', 'labels', 'shm_size',
'target', 'network', 'extra_hosts', 'isolation', 'no_cache', 'pull', 'platforms',
'secrets', 'ssh', 'tags', 'additional_contexts', 'dockerfile_inline'
]);
/** Case-insensitive Levenshtein <= 2 and the key is a non-trivial length. */
export function isLikelyTypo(key: string, vocab: Set<string>): string | null {
const k = key.toLowerCase();
if (vocab.has(k)) return null;
if (k.length < 3) return null;
let best: string | null = null;
let bestDist = 3; // only care about distance <= 2
for (const known of vocab) {
const d = levenshtein(k, known);
if (d < bestDist) {
bestDist = d;
best = known;
}
}
return bestDist <= 2 ? best : null;
}
/** Small iterative Levenshtein (two-row). Adequate for short compose keys. */
function levenshtein(a: string, b: string): number {
if (a === b) return 0;
const m = a.length;
const n = b.length;
if (m === 0) return n;
if (n === 0) return m;
let prev = new Array(n + 1);
let curr = new Array(n + 1);
for (let j = 0; j <= n; j++) prev[j] = j;
for (let i = 1; i <= m; i++) {
curr[0] = i;
for (let j = 1; j <= n; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
}
[prev, curr] = [curr, prev];
}
return prev[n];
}