diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte index 3936c64..1b79da4 100644 --- a/src/lib/components/CodeEditor.svelte +++ b/src/lib/components/CodeEditor.svelte @@ -211,6 +211,14 @@ defaultValue?: string; // The default value from compose syntax (e.g., ${VAR:-default}) } + /** A Compose Validate finding placed on a source line (IDE-style lint marker). */ + export interface LintMarker { + line: number; // 1-based + severity: 'error' | 'warn' | 'info'; + ruleId: string; + message: string; + } + interface Props { value: string; language?: string; @@ -219,12 +227,17 @@ onchange?: (value: string) => void; class?: string; variableMarkers?: VariableMarker[]; + /** Compose Validate findings rendered as line underlines + gutter icons. */ + lintMarkers?: LintMarker[]; + /** Fired when a lint gutter icon / underlined line is clicked (for the mini-modal). */ + onLintClick?: (line: number) => void; } - let { value = '', language = 'yaml', readonly = false, theme = 'dark', onchange, class: className = '', variableMarkers: variableMarkersProp = [] }: Props = $props(); + let { value = '', language = 'yaml', readonly = false, theme = 'dark', onchange, class: className = '', variableMarkers: variableMarkersProp = [], lintMarkers: lintMarkersProp = [], onLintClick }: Props = $props(); // Keep markers reactive - destructured props with defaults lose reactivity const variableMarkers = $derived(variableMarkersProp); + const lintMarkers = $derived(lintMarkersProp); let container: HTMLDivElement; let view: EditorView | null = null; @@ -512,6 +525,126 @@ initialSpacer: () => new VariableGutterMarker('required') }); + // --- Compose Validate lint markers (line underline + clickable gutter icon) ------- + // Callback ref so the click handler updates without recreating the editor. + let onLintClickRef: ((line: number) => void) | undefined = onLintClick; + $effect(() => { onLintClickRef = onLintClick; }); + + // Inner SVG paths for the lucide icons used on the gutter (circle-x / triangle-alert + // / info). Rendered inline because a CodeMirror GutterMarker can't mount a Svelte + // component; currentColor follows the severity class colour. + const LINT_ICON_SVG = { + error: '', + warn: '', + info: '' + } as const; + + class LintGutterMarker extends GutterMarker { + severity: 'error' | 'warn' | 'info'; + line: number; + tooltip: string; + constructor(severity: 'error' | 'warn' | 'info', line: number, tooltip: string) { + super(); + this.severity = severity; + this.line = line; + this.tooltip = tooltip; + } + // Let CodeMirror reuse the DOM when the marker is unchanged, so an unrelated + // keystroke doesn't rebuild the gutter icons. + eq(other: LintGutterMarker) { + return ( + this.severity === other.severity && this.line === other.line && this.tooltip === other.tooltip + ); + } + toDOM() { + const el = document.createElement('span'); + el.className = `cm-lint-marker cm-lint-${this.severity}`; + // The native title shows the actual finding(s) on hover (click for full details). + el.title = this.tooltip; + el.setAttribute('role', 'button'); + el.innerHTML = `${LINT_ICON_SVG[this.severity]}`; + el.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + onLintClickRef?.(this.line); + }); + return el; + } + } + + const updateLintEffect = StateEffect.define(); + const currentLintField = StateField.define({ + create: () => lintMarkers, + update(v, tr) { + for (const e of tr.effects) if (e.is(updateLintEffect)) return e.value; + return v; + } + }); + + // Highest severity per line drives both the gutter icon and the underline colour. + function worstBySeverityPerLine(markers: LintMarker[]): Map { + const rank = { error: 0, warn: 1, info: 2 } as const; + const m = new Map(); + for (const f of markers) { + const prev = m.get(f.line); + if (!prev || rank[f.severity] < rank[prev]) m.set(f.line, f.severity); + } + return m; + } + + // All finding messages on a line, for the hover tooltip. + function messagesForLine(markers: LintMarker[], line: number): string { + const msgs = markers.filter((m) => m.line === line).map((m) => m.message); + return msgs.join('\n'); + } + + function buildLintGutter(doc: any, markers: LintMarker[]): RangeSet { + const worst = worstBySeverityPerLine(markers); + const builder: { from: number; marker: GutterMarker }[] = []; + for (const [line, sev] of worst) { + if (line < 1 || line > doc.lines) continue; + builder.push({ from: doc.line(line).from, marker: new LintGutterMarker(sev, line, messagesForLine(markers, line)) }); + } + builder.sort((a, b) => a.from - b.from); + return RangeSet.of(builder.map((b) => b.marker.range(b.from)), true); + } + + function buildLintDecorations(doc: any, markers: LintMarker[]): DecorationSet { + const worst = worstBySeverityPerLine(markers); + const decos: { from: number; deco: Decoration }[] = []; + for (const [line, sev] of worst) { + if (line < 1 || line > doc.lines) continue; + decos.push({ + from: doc.line(line).from, + deco: Decoration.line({ class: `cm-lint-line cm-lint-line-${sev}` }) + }); + } + decos.sort((a, b) => a.from - b.from); + return Decoration.set(decos.map((d) => d.deco.range(d.from)), true); + } + + const lintGutterField = StateField.define>({ + create: (state) => buildLintGutter(state.doc, lintMarkers), + update(v, tr) { + for (const e of tr.effects) if (e.is(updateLintEffect)) return buildLintGutter(tr.state.doc, e.value); + return tr.docChanged ? buildLintGutter(tr.state.doc, tr.state.field(currentLintField)) : v; + } + }); + + const lintDecorationsField = StateField.define({ + create: (state) => buildLintDecorations(state.doc, lintMarkers), + update(v, tr) { + for (const e of tr.effects) if (e.is(updateLintEffect)) return buildLintDecorations(tr.state.doc, e.value); + return tr.docChanged ? buildLintDecorations(tr.state.doc, tr.state.field(currentLintField)) : v; + }, + provide: (f) => EditorView.decorations.from(f) + }); + + const lintGutter = gutter({ + class: 'cm-lint-gutter', + markers: (view) => view.state.field(lintGutterField) + }); + // YAML Enter handler: after a key-only line ending with ":", indent one level // deeper than what the default indent service returns (it can't predict child // indent when no child content exists yet). @@ -732,6 +865,12 @@ // Always add variable markers gutter and value decorations (can be updated dynamically) extensions.push(currentMarkersField, variableMarkersField, variableGutter, valueDecorationsField); + // Lint gutter/decorations only when this editor is a lint consumer (Compose + // validate wires onLintClick, or passes markers up front). Otherwise every other + // CodeEditor would render an empty 18px lint gutter column. + if (onLintClickRef || lintMarkers.length > 0) { + extensions.push(currentLintField, lintGutterField, lintDecorationsField, lintGutter); + } const state = EditorState.create({ doc: value, @@ -764,7 +903,9 @@ dispatchTransactions }); - // Push initial markers if provided + // Push initial variable markers if provided. Lint markers need no initial + // dispatch: currentLintField/gutter are created from lintMarkers, and the reactive + // $effect below fires once on mount - dispatching here too would double it. if (variableMarkers.length > 0) { view.dispatch({ effects: updateMarkersEffect.of(variableMarkers) @@ -772,6 +913,12 @@ } } + // Re-dispatch lint markers whenever the prop changes (reactive). + $effect(() => { + const markers = lintMarkers; + if (view) view.dispatch({ effects: updateLintEffect.of(markers) }); + }); + function destroyEditor() { if (markerUpdateTimer) { clearTimeout(markerUpdateTimer); @@ -808,6 +955,19 @@ view?.focus(); } + /** Scroll a 1-based line into view and place the cursor on it (used by validate jump-to). */ + export function scrollToLine(line: number) { + if (!view) return; + const doc = view.state.doc; + if (line < 1 || line > doc.lines) return; + const pos = doc.line(line).from; + view.dispatch({ + selection: { anchor: pos }, + effects: EditorView.scrollIntoView(pos, { y: 'center' }) + }); + view.focus(); + } + // Update variable markers - this is the key method for parent to call // Debounced to prevent flicker during fast typing export function updateVariableMarkers(markers: VariableMarker[], immediate = false) { @@ -962,6 +1122,38 @@ box-shadow: 0 0 4px #10b981; } + /* --- Compose Validate lint markers ------------------------------------------- */ + div :global(.cm-lint-gutter) { + width: 18px; + } + div :global(.cm-lint-marker) { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + margin: 3px 2px; + cursor: pointer; + opacity: 0.9; + transition: opacity 0.12s ease, transform 0.08s ease; + } + div :global(.cm-lint-marker svg) { width: 13px; height: 13px; } + div :global(.cm-lint-marker:hover) { opacity: 1; transform: scale(1.15); } + div :global(.cm-lint-error) { color: #ef4444; } + div :global(.cm-lint-warn) { color: #f59e0b; } + div :global(.cm-lint-info) { color: #3b82f6; } + + /* Line highlight: a faint tint by severity (no gutter bar). */ + div :global(.cm-lint-line-error) { + background-color: rgba(239, 68, 68, 0.08); + } + div :global(.cm-lint-line-warn) { + background-color: rgba(245, 158, 11, 0.08); + } + div :global(.cm-lint-line-info) { + background-color: rgba(59, 130, 246, 0.06); + } + /* Variable value overlay widget - base styles */ div :global(.var-value-overlay) { display: inline-block; diff --git a/src/lib/server/compose-validate/context-core.ts b/src/lib/server/compose-validate/context-core.ts new file mode 100644 index 0000000..ecbf1fa --- /dev/null +++ b/src/lib/server/compose-validate/context-core.ts @@ -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 `--`). + */ +export function deriveContextFromLists( + containers: CtxContainer[], + networkNames: string[], + volumeNames: string[], + selfStackName?: string +): ValidateContext { + const usedHostPorts = new Set(); + const hostPortOwners = new Map(); + const existingContainerNames = new Set(); + 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)) + }; +} diff --git a/src/lib/server/compose-validate/context.ts b/src/lib/server/compose-validate/context.ts new file mode 100644 index 0000000..b5583af --- /dev/null +++ b/src/lib/server/compose-validate/context.ts @@ -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 { + 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 + ); +} diff --git a/src/lib/server/compose-validate/effective-compose.ts b/src/lib/server/compose-validate/effective-compose.ts new file mode 100644 index 0000000..5575dd3 --- /dev/null +++ b/src/lib/server/compose-validate/effective-compose.ts @@ -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; + /** 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 { + 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 | undefined; + try { + const parsed = JSON.parse(stdout); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + effective = parsed as Record; + } + } 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(); + 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 : 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 | 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') + }); + }); + }); +} diff --git a/src/lib/server/compose-validate/index.ts b/src/lib/server/compose-validate/index.ts new file mode 100644 index 0000000..a1b1b75 --- /dev/null +++ b/src/lib/server/compose-validate/index.ts @@ -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 = { 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(); + 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 { + 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): 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'; diff --git a/src/lib/server/compose-validate/parse.ts b/src/lib/server/compose-validate/parse.ts new file mode 100644 index 0000000..9a11a8d --- /dev/null +++ b/src/lib/server/compose-validate/parse.ts @@ -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 | null = null; + let parseError: ParsedCompose['parseError'] | undefined; + try { + const loaded = jsyaml.load(source); + doc = loaded && typeof loaded === 'object' && !Array.isArray(loaded) + ? (loaded as Record) + : 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 | 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 }; +} diff --git a/src/lib/server/compose-validate/rules.ts b/src/lib/server/compose-validate/rules.ts new file mode 100644 index 0000000..50ad273 --- /dev/null +++ b/src/lib/server/compose-validate/rules.ts @@ -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> { + const s = p.doc?.services; + return s && typeof s === 'object' && !Array.isArray(s) + ? (s as Record>) + : {}; +} + +function asRecord(v: unknown): Record | null { + return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null; +} + +function ports(svc: Record): 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): { 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(); + 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) + : []; + 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) + : []; + 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 | 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; + } + } +]; diff --git a/src/lib/server/compose-validate/types.ts b/src/lib/server/compose-validate/types.ts new file mode 100644 index 0000000..2e81141 --- /dev/null +++ b/src/lib/server/compose-validate/types.ts @@ -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 | 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; + /** For each used host port, the container name that publishes it (for the message). */ + hostPortOwners?: Map; + /** Names of networks that already exist on the target env (for external: true). */ + existingNetworks?: Set; + /** Names of volumes that already exist on the target env (for external: true). */ + existingVolumes?: Set; + /** Container names already in use on the target env (for container_name collisions). */ + existingContainerNames?: Set; + /** Env-var names the bound secret provider can supply (for SENSITIVE_ENV). */ + providerKeys?: Set; + /** 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 & { 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; +} + +export interface ValidateReport { + findings: Finding[]; + counts: { error: number; warn: number; info: number }; +} diff --git a/src/lib/server/compose-validate/vocab.ts b/src/lib/server/compose-validate/vocab.ts new file mode 100644 index 0000000..46bfece --- /dev/null +++ b/src/lib/server/compose-validate/vocab.ts @@ -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 | 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]; +} diff --git a/src/lib/utils/compose-quick-fix.ts b/src/lib/utils/compose-quick-fix.ts new file mode 100644 index 0000000..7d6638d --- /dev/null +++ b/src/lib/utils/compose-quick-fix.ts @@ -0,0 +1,72 @@ +/** + * Compose Validate quick-fix: the QuickFix shape + a pure line-based applier. + * + * Lives in $lib/utils (not $lib/server) because BOTH sides need it: rules emit a + * QuickFix server-side, and the stack editor applies it client-side. Pure and + * newline-preserving so it is trivially unit-tested and identical on both ends. + */ + +/** + * A deterministic, one-line edit that resolves a finding. Only rules whose fix is + * UNAMBIGUOUS carry one (a typo with a known suggestion, an obsolete key to drop, a + * db-port to bind to localhost) - never a fix that requires the user to choose. + * - delete-line: remove the whole 1-based line. + * - replace-in-line: replace the FIRST `find` on the line with `replace`. + * - insert-after: insert `text` as a new line right AFTER the 1-based line (text + * carries its own indentation; the rule computes it). + */ +export type QuickFix = + | { kind: 'delete-line'; line: number } + | { kind: 'replace-in-line'; line: number; find: string; replace: string; at?: number } + | { kind: 'insert-after'; line: number; text: string }; + +/** + * Stable identity of a finding, used for the {#each} key, optimistic drop, and set + * comparison on the client - and it MUST match the server's dedup key + * (compose-validate/index.ts) so the two never disagree. Delimited so distinct fields + * can't collide (ruleId 'A'+line 1 vs ruleId 'A1'+no line both -> 'A1' without it). + */ +export function findingKey(f: { ruleId: string; line?: number; message: string }): string { + return `${f.ruleId}|${f.line ?? ''}|${f.message}`; +} + +/** + * Apply a QuickFix to compose source text. Out-of-range / no-match fixes return the + * source unchanged (the caller re-validates afterwards, so a stale fix simply no-ops). + */ +export function applyQuickFix(source: string, fix: QuickFix): string { + // Split on \n only, so \r on CRLF lines is preserved: a per-line edit never + // rewrites the whole file's line endings. + const lines = source.split('\n'); + const idx = fix.line - 1; // fix.line is 1-based + if (idx < 0 || idx >= lines.length) return source; + + if (fix.kind === 'delete-line') { + lines.splice(idx, 1); + return lines.join('\n'); + } + + if (fix.kind === 'insert-after') { + lines.splice(idx + 1, 0, fix.text); + return lines.join('\n'); + } + + // replace-in-line: swap `find` with `replace`. When the rule provides `at` (the + // token's 0-based column, which for a quoted value points at the opening quote), + // look for `find` at or just after `at` (within a couple of chars, to skip a quote/ + // space). This anchors to the intended occurrence so a token that is a substring of + // an earlier token can't be corrupted; a shifted line (stale batch offset) finds no + // nearby match and is a no-op rather than a wrong edit. Without `at`, first match. + const line = lines[idx]; + let at: number; + if (typeof fix.at === 'number' && fix.at >= 0) { + const found = line.indexOf(fix.find, fix.at); + if (found === -1 || found - fix.at > 2) return source; // stale/shifted - re-validate re-anchors + at = found; + } else { + at = line.indexOf(fix.find); + if (at === -1) return source; + } + lines[idx] = line.slice(0, at) + fix.replace + line.slice(at + fix.find.length); + return lines.join('\n'); +} diff --git a/src/routes/api/stacks/[name]/validate/+server.ts b/src/routes/api/stacks/[name]/validate/+server.ts new file mode 100644 index 0000000..3ba13f9 --- /dev/null +++ b/src/routes/api/stacks/[name]/validate/+server.ts @@ -0,0 +1,77 @@ +import { json } from '@sveltejs/kit'; +import { authorize } from '$lib/server/authorize'; +import { runValidateWithConfig } from '$lib/server/compose-validate'; +import { buildValidateContext } from '$lib/server/compose-validate/context'; +import { getEnvironment } from '$lib/server/db'; +import type { ValidateConfig } from '$lib/server/compose-validate/types'; +import type { RequestHandler } from './$types'; + +/** + * The daemon `docker compose config` should validate against. `config` renders the + * effective compose locally (it does NOT dial the daemon), so this only matters for + * consistency: local/socket -> default socket; direct TCP -> that host. Hawser/edge + * agents are remote, so we skip config for them (null) and fall back to local rules. + */ +async function resolveDockerHostForConfig(envIdNum: number | undefined): Promise { + if (!envIdNum) return null; // no env -> local socket + const env = await getEnvironment(envIdNum).catch(() => undefined); + if (!env) return null; + const type = env.connectionType || 'socket'; + if (type === 'socket') return null; + if (type === 'direct' && env.host) { + const proto = env.protocol === 'https' ? 'https' : 'tcp'; + return `${proto}://${env.host}:${env.port || 2375}`; + } + return null; // hawser-standard / hawser-edge: no local TCP host, config renders locally anyway +} + +/** + * Validate a compose file (Compose Validate): a preflight linter that surfaces + * problems - port collisions, exposure, typos, undefined network/volume refs, latest + * tags, docker.sock mounts - before `docker compose up`, with the line of each finding + * for editor markers. Context-aware rules (cross-stack port collisions, missing + * external networks/volumes, container-name collisions) use the target environment's + * live containers/networks/volumes. + * + * @openapi + * summary: Run the Compose Validate preflight linter on a compose file and return findings with severities and line numbers (requires the 'view' permission) + * path: name:string! Stack name (used to exclude the stack's own containers from cross-stack collision checks) + * query: env:integer Environment ID for the context-aware checks (from GET /api/environments) + * body: {compose:string!, config:{disabled:[string], severity:object}} + * body-example: {"compose":"services:\n web:\n image: nginx:latest\n ports: [\"8080:80\"]\n"} + * resp-200: {findings:[{ruleId:string!, severity:string!, message:string!, hint:string, service:string, line:integer, fix:object, fixDescription:string}]!, counts:{error:integer!, warn:integer!, info:integer!}!} + * resp-200-example: {"findings":[{"ruleId":"LATEST_TAG","severity":"warn","message":"\"web\" uses `:latest` (nginx:latest)","hint":"Pin a version tag for reproducible deploys and to enable newer-version detection.","service":"web","line":3}],"counts":{"error":0,"warn":1,"info":0}} + * resp-400: compose content is required + * resp-403: Permission denied + */ +export const POST: RequestHandler = async ({ cookies, url, params, request }) => { + const auth = await authorize(cookies); + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId, 10) : undefined; + + if (auth.authEnabled && !(await auth.can('stacks', 'view', envIdNum))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + const body = (await request.json().catch(() => ({}))) as { + compose?: string; + config?: ValidateConfig; + }; + const compose = typeof body.compose === 'string' ? body.compose : ''; + if (!compose.trim()) { + return json({ error: 'compose content is required' }, { status: 400 }); + } + + const stackName = decodeURIComponent(params.name); + + // Best-effort live context; degrades to an empty context if the env is unreachable + // (context-aware rules then simply produce nothing - the linter still runs). + const ctx = await buildValidateContext(envIdNum ?? null, stackName).catch(() => ({})); + + // `docker compose config` gives the authoritative schema/syntax verdict; the local + // rule catalog adds the context-aware checks config can't do. Config degrades to a + // pure local lint if the daemon/CLI is unreachable, so this never blocks. + const dockerHost = await resolveDockerHostForConfig(envIdNum); + const report = await runValidateWithConfig(stackName, compose, ctx, dockerHost, body.config ?? {}); + return json(report); +}; diff --git a/src/routes/stacks/ComposeValidatePanel.svelte b/src/routes/stacks/ComposeValidatePanel.svelte new file mode 100644 index 0000000..4b12e32 --- /dev/null +++ b/src/routes/stacks/ComposeValidatePanel.svelte @@ -0,0 +1,280 @@ + + +
+ +
+
Compose Validate
+ {#if !loading && !error && report} +
+ {#each ['error', 'warn', 'info'] as const as sev} + {@const c = counts[sev]} + {@const SIcon = SEV[sev].Icon} +
+ + {c} +
+ {/each} +
+ {/if} +
+ {#if onRevalidate} + + {/if} + +
+
+ + +
+ {#if loading} +

Validating...

+ {:else if error} +

{error}

+ {:else if clean} +
+
+

No problems found

+

Passes every enabled check.

+
+ {:else} + {#each grouped as group (group.sev)} + {@const GIcon = SEV[group.sev].Icon} +
+ + {#if !collapsed[group.sev]} + {#each group.items as f (findingKey(f))} +
+
+
+
+ {f.ruleId} + {#if f.line}L{f.line}{/if} + {#if f.fix && onApplyFix} + + {/if} +
+ +
+
+ {/each} + {/if} +
+ {/each} + {/if} +
+
+ + diff --git a/src/routes/stacks/StackModal.svelte b/src/routes/stacks/StackModal.svelte index 709cb34..f1aec76 100644 --- a/src/routes/stacks/StackModal.svelte +++ b/src/routes/stacks/StackModal.svelte @@ -10,7 +10,9 @@ import SecretProviderPicker from '$lib/components/SecretProviderPicker.svelte'; import { SELECTOR_VARS } from '$lib/utils/bulk-selector'; import { classifyMarker, resolvedRefVarNames } from '$lib/utils/invault-markers'; - import { Layers, Save, Play, Code, GitGraph, GitBranch, GitCommitHorizontal, Github, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check, XCircle, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync, Archive } from 'lucide-svelte'; + import { applyQuickFix, findingKey } from '$lib/utils/compose-quick-fix'; + import { Layers, Save, Play, Code, GitGraph, GitBranch, GitCommitHorizontal, Github, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check, XCircle, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync, Archive, ListChecks } from 'lucide-svelte'; + import ComposeValidatePanel from './ComposeValidatePanel.svelte'; import BackupPanel from '../containers/BackupPanel.svelte'; import { volumesForStack, type VolumeInfo } from '$lib/utils/mounts'; import { fetchBackupExecutions } from '$lib/utils/backup'; @@ -145,6 +147,137 @@ let composePathCopied = $state<'ok' | 'error' | null>(null); let envPathCopied = $state<'ok' | 'error' | null>(null); let composeContentCopied = $state<'ok' | 'error' | null>(null); + + // --- Compose Validate (side panel) ------------------------------------------ + let validatePanelOpen = $state(false); + let validateLoading = $state(false); + let validateError = $state(null); + let validateActiveLine = $state(null); + let validateReport = $state(null); + // Monotonic token: only the newest validate response is allowed to write the report, + // so a slow silent re-validate can't overwrite a newer one (fix-spam race). + let validateSeq = 0; + // Findings mapped to editor lint markers (only those with a line). + const validateMarkers = $derived( + (validateReport?.findings ?? []) + .filter((f) => typeof f.line === 'number') + .map((f) => ({ line: f.line!, severity: f.severity, ruleId: f.ruleId, message: f.message })) + ); + + async function runComposeValidate(opts: { silent?: boolean } = {}) { + if (!composeContent.trim()) return; + // Silent re-validate (after a quick fix) keeps the current list visible so the + // panel doesn't collapse to a spinner and lose the scroll position. + if (!opts.silent) validateLoading = true; + validateError = null; + validatePanelOpen = true; + const seq = ++validateSeq; + try { + const envId = $currentEnvironment?.id ?? null; + const name = (mode === 'edit' ? stackName : newStackName) || 'stack'; + const res = await fetch( + appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/validate`, envId), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ compose: composeContent }) + } + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Validation failed (${res.status})`); + } + const fresh = await res.json(); + // Stale response (a newer validate started meanwhile): drop it entirely. + if (seq !== validateSeq) return; + // On a silent re-validate, only swap the report if the finding set actually + // changed. When a fix succeeded the optimistic list already matches the fresh + // one, so keeping the same object avoids re-rendering (and the flash) of the + // surviving boxes. + if (opts.silent && validateReport && sameFindingSet(validateReport.findings, fresh.findings)) { + // no-op: current (optimistic) report is already correct + } else { + validateReport = fresh; + } + } catch (e) { + if (seq !== validateSeq) return; // superseded - don't clobber a newer report + validateError = e instanceof Error ? e.message : 'Validation failed'; + validateReport = null; + } finally { + if (seq === validateSeq) validateLoading = false; + } + } + + // Two finding lists are "the same" set (order-independent) by their stable keys. + function sameFindingSet( + a: { ruleId: string; line?: number; message: string }[], + b: { ruleId: string; line?: number; message: string }[] + ): boolean { + if (a.length !== b.length) return false; + const bag = new Map(); + for (const f of a) bag.set(findingKey(f), (bag.get(findingKey(f)) ?? 0) + 1); + for (const f of b) { + const k = findingKey(f); + const n = bag.get(k); + if (!n) return false; + bag.set(k, n - 1); + } + return true; + } + + // Remove a finding from the current report immediately (optimistic), so its box + // animates out without waiting for the round-trip. + function dropFinding(target: { ruleId: string; line?: number; message: string }) { + if (!validateReport) return; + const targetKey = findingKey(target); + const remaining = validateReport.findings.filter((f) => findingKey(f) !== targetKey); + const counts = { error: 0, warn: 0, info: 0 }; + for (const f of remaining) counts[f.severity]++; + validateReport = { findings: remaining, counts }; + } + + // Closing the panel clears the findings so the editor markers disappear too + // (validateMarkers is derived from validateReport). + function closeValidatePanel() { + validatePanelOpen = false; + validateReport = null; + validateError = null; + validateActiveLine = null; + } + + // Clicking a gutter marker opens the panel, highlights that line's finding, and + // scrolls the panel to it (the editor->panel direction). + function openValidateAtLine(line: number) { + if (validateReport) validatePanelOpen = true; + validateActiveLine = line; + validatePanelRef?.scrollToFinding?.(line); + } + + // Clicking a finding in the panel jumps the editor to its line (panel stays open). + function jumpToComposeLine(line: number) { + codeEditorRef?.scrollToLine?.(line); + validateActiveLine = line; + } + + // Apply a quick fix from the panel: rewrite the compose in place, drop the fixed + // finding's box immediately (it animates out), then re-validate silently so the list + // stays put - no spinner, no scroll reset. + function applyValidateFix(finding: { + ruleId: string; + line?: number; + message: string; + fix?: import('$lib/utils/compose-quick-fix').QuickFix; + }) { + if (!finding.fix) return; + const next = applyQuickFix(composeContent, finding.fix); + if (next === composeContent) return; // stale fix (text moved) - re-validate re-anchors + composeContent = next; + // The reactive editor sync suppresses onchange, so mark dirty ourselves. + isDirty = true; + validateActiveLine = null; + dropFinding(finding); // optimistic: the box animates out now + runComposeValidate({ silent: true }); // reconcile against the daemon without a flash + } let needsFileLocation = $state(false); // Container info for untracked stacks @@ -597,6 +730,7 @@ // CodeEditor reference for explicit marker updates let codeEditorRef: CodeEditor | null = $state(null); + let validatePanelRef: ComposeValidatePanel | null = $state(null); // ComposeGraphViewer reference for resize on panel toggle let graphViewerRef: ComposeGraphViewer | null = $state(null); @@ -822,12 +956,32 @@ isDraggingSplit = true; } + // Validate side-panel width (px), drag-resizable, persisted. + const STORAGE_KEY_VALIDATE_W = 'dockhand-validate-panel-width'; + let validatePanelWidth = $state( + typeof localStorage !== 'undefined' + ? Number(localStorage.getItem(STORAGE_KEY_VALIDATE_W)) || 288 + : 288 + ); + let isDraggingValidate = $state(false); + let editorRowRef = $state(null); + function startValidateDrag(e: MouseEvent) { + e.preventDefault(); + isDraggingValidate = true; + } + function handleMouseMove(e: MouseEvent) { if (isDraggingSplit && containerRef) { const rect = containerRef.getBoundingClientRect(); const newRatio = ((e.clientX - rect.left) / rect.width) * 100; splitRatio = Math.max(30, Math.min(80, newRatio)); } + if (isDraggingValidate && editorRowRef) { + const rect = editorRowRef.getBoundingClientRect(); + // panel is on the right: width = distance from cursor to the row's right edge. + const w = rect.right - e.clientX; + validatePanelWidth = Math.max(220, Math.min(560, w)); + } } function handleMouseUp() { @@ -836,6 +990,10 @@ // Save split ratio localStorage.setItem(STORAGE_KEY_SPLIT, splitRatio.toString()); } + if (isDraggingValidate) { + isDraggingValidate = false; + localStorage.setItem(STORAGE_KEY_VALIDATE_W, String(validatePanelWidth)); + } } // Populate the backup picker's volume/bind list from this stack's containers' @@ -1791,8 +1949,23 @@ {:else}
- -
+ +
+
- +
+ + {#if validatePanelOpen} + + +
+ +
+ {/if} +
{/if}