Compose validate - a preflight linter & validator for compose files
This commit is contained in:
13 files changed
+2297
-15
No files matched your search
@@ -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: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
|
||||
warn: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>'
|
||||
} 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 = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round">${LINT_ICON_SVG[this.severity]}</svg>`;
|
||||
el.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onLintClickRef?.(this.line);
|
||||
});
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
const updateLintEffect = StateEffect.define<LintMarker[]>();
|
||||
const currentLintField = StateField.define<LintMarker[]>({
|
||||
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<number, 'error' | 'warn' | 'info'> {
|
||||
const rank = { error: 0, warn: 1, info: 2 } as const;
|
||||
const m = new Map<number, 'error' | 'warn' | 'info'>();
|
||||
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<GutterMarker> {
|
||||
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<RangeSet<GutterMarker>>({
|
||||
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<DecorationSet>({
|
||||
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;
|
||||
|
||||
@@ -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')
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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<string | null> {
|
||||
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);
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { CircleX, TriangleAlert, Info, CheckCircle2, ArrowRight, Loader2, RefreshCw, X, ChevronDown, Wand2 } from 'lucide-svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { findingKey, type QuickFix } from '$lib/utils/compose-quick-fix';
|
||||
|
||||
export interface ValidateFinding {
|
||||
ruleId: string;
|
||||
severity: 'error' | 'warn' | 'info';
|
||||
message: string;
|
||||
hint?: string;
|
||||
service?: string;
|
||||
line?: number;
|
||||
fix?: QuickFix;
|
||||
fixDescription?: string;
|
||||
}
|
||||
export interface ValidateReport {
|
||||
findings: ValidateFinding[];
|
||||
counts: { error: number; warn: number; info: number };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
report: ValidateReport | null;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
activeLine?: number | null;
|
||||
onClose: () => void;
|
||||
onJumpToLine?: (line: number) => void;
|
||||
onRevalidate?: () => void;
|
||||
onApplyFix?: (finding: ValidateFinding) => void;
|
||||
}
|
||||
let { report, loading = false, error = null, activeLine = null, onClose, onJumpToLine, onRevalidate, onApplyFix }: Props = $props();
|
||||
|
||||
const SEV = {
|
||||
error: { label: 'Errors', Icon: CircleX, color: 'var(--sev-error)', bg: 'rgba(239,68,68,0.12)' },
|
||||
warn: { label: 'Warnings', Icon: TriangleAlert, color: 'var(--sev-warn)', bg: 'rgba(245,158,11,0.12)' },
|
||||
info: { label: 'Suggestions', Icon: Info, color: 'var(--sev-info)', bg: 'rgba(59,130,246,0.12)' }
|
||||
} as const;
|
||||
|
||||
const findings = $derived(report?.findings ?? []);
|
||||
const counts = $derived(report?.counts ?? { error: 0, warn: 0, info: 0 });
|
||||
const total = $derived(counts.error + counts.warn + counts.info);
|
||||
const clean = $derived(!loading && !error && report !== null && total === 0);
|
||||
const grouped = $derived(
|
||||
(['error', 'warn', 'info'] as const)
|
||||
.map((sev) => ({ sev, items: findings.filter((f) => f.severity === sev) }))
|
||||
.filter((g) => g.items.length > 0)
|
||||
);
|
||||
|
||||
// Collapsed severity groups (all expanded by default).
|
||||
let collapsed = $state<Record<'error' | 'warn' | 'info', boolean>>({ error: false, warn: false, info: false });
|
||||
function toggleGroup(sev: 'error' | 'warn' | 'info') {
|
||||
collapsed = { ...collapsed, [sev]: !collapsed[sev] };
|
||||
}
|
||||
|
||||
// Explicitly scroll the finding for a line into view (called by the parent when a
|
||||
// gutter icon is clicked - NOT on every re-validate, which would make the list jump).
|
||||
let bodyEl = $state<HTMLDivElement | null>(null);
|
||||
export function scrollToFinding(line: number) {
|
||||
const target = findings.find((f) => f.line === line);
|
||||
if (target && collapsed[target.severity]) {
|
||||
collapsed = { ...collapsed, [target.severity]: false };
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
bodyEl?.querySelector(`[data-line="${line}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="cvp">
|
||||
<!-- Header: title + count chips inline -->
|
||||
<div class="cvp-header">
|
||||
<div class="cvp-title">Compose Validate</div>
|
||||
{#if !loading && !error && report}
|
||||
<div class="cvp-chips">
|
||||
{#each ['error', 'warn', 'info'] as const as sev}
|
||||
{@const c = counts[sev]}
|
||||
{@const SIcon = SEV[sev].Icon}
|
||||
<div class="cvp-chip" class:cvp-chip-muted={c === 0} style="--chip-color: {SEV[sev].color}; --chip-bg: {SEV[sev].bg}">
|
||||
<SIcon class="h-3 w-3" />
|
||||
<span class="cvp-chip-count">{c}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="cvp-header-actions">
|
||||
{#if onRevalidate}
|
||||
<button class="cvp-iconbtn" title="Re-check" onclick={onRevalidate} disabled={loading}>
|
||||
{#if loading}<Loader2 class="h-4 w-4 animate-spin" />{:else}<RefreshCw class="h-4 w-4" />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="cvp-iconbtn" title="Close panel" onclick={onClose}><X class="h-4 w-4" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="cvp-body" bind:this={bodyEl}>
|
||||
{#if loading}
|
||||
<div class="cvp-center"><Loader2 class="h-5 w-5 animate-spin text-muted-foreground" /><p>Validating...</p></div>
|
||||
{:else if error}
|
||||
<div class="cvp-center cvp-error-state"><CircleX class="h-5 w-5" /><p>{error}</p></div>
|
||||
{:else if clean}
|
||||
<div class="cvp-center cvp-clean">
|
||||
<div class="cvp-clean-badge"><CheckCircle2 class="h-7 w-7" /></div>
|
||||
<p class="cvp-clean-title">No problems found</p>
|
||||
<p class="cvp-clean-sub">Passes every enabled check.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each grouped as group (group.sev)}
|
||||
{@const GIcon = SEV[group.sev].Icon}
|
||||
<div class="cvp-group">
|
||||
<button type="button" class="cvp-group-head" style="--chip-color: {SEV[group.sev].color}" onclick={() => toggleGroup(group.sev)}>
|
||||
<ChevronDown class="cvp-chevron h-3.5 w-3.5 {collapsed[group.sev] ? '-rotate-90' : ''}" />
|
||||
<GIcon class="h-3.5 w-3.5" />
|
||||
<span>{SEV[group.sev].label}</span>
|
||||
<span class="cvp-group-count">{group.items.length}</span>
|
||||
</button>
|
||||
{#if !collapsed[group.sev]}
|
||||
{#each group.items as f (findingKey(f))}
|
||||
<div
|
||||
class="cvp-finding"
|
||||
class:cvp-finding-active={activeLine != null && f.line === activeLine}
|
||||
style="--chip-color: {SEV[f.severity].color}"
|
||||
data-line={f.line ?? undefined}
|
||||
transition:slide={{ duration: 200 }}
|
||||
>
|
||||
<div class="cvp-finding-bar"></div>
|
||||
<div class="cvp-finding-body">
|
||||
<div class="cvp-finding-top">
|
||||
<span class="cvp-rule">{f.ruleId}</span>
|
||||
{#if f.line}<span class="cvp-line">L{f.line}<ArrowRight class="h-3 w-3" /></span>{/if}
|
||||
{#if f.fix && onApplyFix}
|
||||
<button
|
||||
type="button"
|
||||
class="cvp-fix"
|
||||
title={f.fixDescription ?? 'Apply quick fix'}
|
||||
onclick={() => onApplyFix?.(f)}
|
||||
>
|
||||
<Wand2 class="h-3 w-3" />
|
||||
Fix
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="cvp-finding-jump"
|
||||
onclick={() => f.line && onJumpToLine?.(f.line)}
|
||||
disabled={!f.line}
|
||||
>
|
||||
<p class="cvp-message">{f.message}</p>
|
||||
{#if f.hint}<p class="cvp-hint">{f.hint}</p>{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cvp {
|
||||
--sev-error: #ef4444;
|
||||
--sev-warn: #f59e0b;
|
||||
--sev-info: #3b82f6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cvp-header {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
.cvp-title { font-size: 0.82rem; font-weight: 650; line-height: 1.1; white-space: nowrap; }
|
||||
.cvp-chips { display: flex; gap: 0.3rem; margin-left: 0.15rem; }
|
||||
.cvp-header-actions { display: flex; gap: 0.15rem; margin-left: auto; }
|
||||
.cvp-iconbtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 1.6rem; height: 1.6rem; border-radius: 0.35rem;
|
||||
color: hsl(var(--muted-foreground)); flex-shrink: 0;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.cvp-iconbtn:hover { background: hsl(var(--muted)); color: hsl(var(--foreground)); }
|
||||
.cvp-iconbtn:disabled { opacity: 0.5; }
|
||||
|
||||
.cvp-chip {
|
||||
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||
padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.72rem;
|
||||
color: var(--chip-color); background: var(--chip-bg);
|
||||
border: 1px solid color-mix(in srgb, var(--chip-color) 30%, transparent);
|
||||
}
|
||||
.cvp-chip-muted { opacity: 0.4; filter: grayscale(0.6); }
|
||||
.cvp-chip-count { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.cvp-body { flex: 1; overflow-y: auto; padding: 0.5rem 0.6rem 0.75rem; }
|
||||
.cvp-center {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 0.6rem; padding: 2.5rem 0.75rem; color: hsl(var(--muted-foreground));
|
||||
text-align: center; font-size: 0.8rem;
|
||||
}
|
||||
.cvp-error-state { color: var(--sev-error); }
|
||||
.cvp-clean-badge {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 3rem; height: 3rem; border-radius: 999px;
|
||||
background: rgba(34,197,94,0.14); color: #22c55e;
|
||||
}
|
||||
.cvp-clean-title { font-size: 0.9rem; font-weight: 600; color: hsl(var(--foreground)); }
|
||||
.cvp-clean-sub { font-size: 0.76rem; }
|
||||
|
||||
.cvp-group { margin-top: 0.7rem; }
|
||||
.cvp-group:first-child { margin-top: 0.15rem; }
|
||||
.cvp-group-head {
|
||||
display: flex; align-items: center; gap: 0.35rem; width: 100%;
|
||||
font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: var(--chip-color); margin-bottom: 0.35rem;
|
||||
padding: 0.15rem 0.2rem; border-radius: 0.3rem;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.cvp-group-head:hover { background: hsl(var(--muted) / 0.5); }
|
||||
:global(.cvp-chevron) { transition: transform 0.15s ease; flex-shrink: 0; opacity: 0.8; }
|
||||
.cvp-group-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: color-mix(in srgb, var(--chip-color) 15%, transparent);
|
||||
border-radius: 999px; padding: 0 0.35rem; font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.cvp-finding {
|
||||
display: flex; align-items: stretch; width: 100%; text-align: left;
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.45rem; margin-bottom: 0.35rem; overflow: hidden;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.cvp-finding:hover {
|
||||
border-color: color-mix(in srgb, var(--chip-color) 55%, hsl(var(--border)));
|
||||
}
|
||||
.cvp-finding-active {
|
||||
border-color: var(--chip-color) !important;
|
||||
background: color-mix(in srgb, var(--chip-color) 8%, hsl(var(--background)));
|
||||
}
|
||||
.cvp-finding-bar { width: 3px; background: var(--chip-color); flex-shrink: 0; }
|
||||
.cvp-finding-body { padding: 0.5rem 0.65rem; min-width: 0; flex: 1; display: flex; flex-direction: column; }
|
||||
.cvp-finding-jump {
|
||||
text-align: left; background: transparent; transition: transform 0.06s ease;
|
||||
}
|
||||
.cvp-finding-jump:not(:disabled):hover { cursor: pointer; }
|
||||
.cvp-finding-jump:not(:disabled):active { transform: translateY(1px); }
|
||||
.cvp-finding-jump:disabled { cursor: default; }
|
||||
.cvp-fix {
|
||||
display: inline-flex; align-items: center; gap: 0.2rem; flex-shrink: 0;
|
||||
margin-left: 0.4rem; padding: 0.12rem 0.4rem; border-radius: 0.3rem;
|
||||
font-size: 0.66rem; font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted));
|
||||
border: 1px solid hsl(var(--border));
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.cvp-fix:hover { background: hsl(var(--accent)); color: hsl(var(--foreground)); cursor: pointer; }
|
||||
.cvp-fix:active { transform: translateY(1px); }
|
||||
.cvp-finding-top { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.2rem; }
|
||||
.cvp-rule {
|
||||
font-family: ui-monospace, monospace; font-size: 0.64rem; font-weight: 600;
|
||||
color: var(--chip-color);
|
||||
background: color-mix(in srgb, var(--chip-color) 12%, transparent);
|
||||
padding: 0.03rem 0.35rem; border-radius: 0.28rem;
|
||||
}
|
||||
.cvp-line {
|
||||
display: inline-flex; align-items: center; gap: 0.1rem;
|
||||
font-size: 0.66rem; color: hsl(var(--muted-foreground)); margin-left: auto;
|
||||
}
|
||||
.cvp-message { font-size: 0.78rem; color: hsl(var(--foreground)); line-height: 1.3; }
|
||||
.cvp-hint { font-size: 0.72rem; color: hsl(var(--muted-foreground)); margin-top: 0.15rem; line-height: 1.3; }
|
||||
</style>
|
||||
@@ -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<string | null>(null);
|
||||
let validateActiveLine = $state<number | null>(null);
|
||||
let validateReport = $state<import('./ComposeValidatePanel.svelte').ValidateReport | null>(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<string, number>();
|
||||
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<HTMLDivElement | null>(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 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- Copy button row -->
|
||||
<div class="flex justify-end mb-1">
|
||||
<!-- Copy + Validate button row -->
|
||||
<div class="flex justify-end items-center gap-1 mb-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
|
||||
onclick={runComposeValidate}
|
||||
disabled={!composeContent}
|
||||
title="Check this compose for problems before deploy"
|
||||
>
|
||||
{#if validateLoading}
|
||||
<Loader2 class="w-3 h-3 animate-spin" />
|
||||
{:else}
|
||||
<ListChecks class="w-3 h-3" />
|
||||
{/if}
|
||||
Validate
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -1817,16 +1990,47 @@
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
<CodeEditor
|
||||
bind:this={codeEditorRef}
|
||||
value={composeContent}
|
||||
language="yaml"
|
||||
{readonly}
|
||||
theme={editorTheme}
|
||||
onchange={readonly ? undefined : handleComposeChange}
|
||||
variableMarkers={variableMarkers}
|
||||
class="flex-1 rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"
|
||||
/>
|
||||
<div bind:this={editorRowRef} class="flex-1 min-h-0 flex">
|
||||
<CodeEditor
|
||||
bind:this={codeEditorRef}
|
||||
value={composeContent}
|
||||
language="yaml"
|
||||
{readonly}
|
||||
theme={editorTheme}
|
||||
onchange={readonly ? undefined : handleComposeChange}
|
||||
variableMarkers={variableMarkers}
|
||||
lintMarkers={validateMarkers}
|
||||
onLintClick={openValidateAtLine}
|
||||
class="flex-1 min-w-0 rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"
|
||||
/>
|
||||
{#if validatePanelOpen}
|
||||
<!-- Resize handle -->
|
||||
<div
|
||||
class="w-1 mx-1 flex-shrink-0 rounded bg-zinc-200 dark:bg-zinc-700 hover:bg-blue-400 dark:hover:bg-blue-500 cursor-col-resize transition-colors flex items-center justify-center group {isDraggingValidate ? 'bg-blue-500 dark:bg-blue-400' : ''}"
|
||||
onmousedown={startValidateDrag}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="w-4 h-8 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity {isDraggingValidate ? 'opacity-100' : ''}">
|
||||
<GripVertical class="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 min-h-0" style="width: {validatePanelWidth}px">
|
||||
<ComposeValidatePanel
|
||||
bind:this={validatePanelRef}
|
||||
report={validateReport}
|
||||
loading={validateLoading}
|
||||
error={validateError}
|
||||
activeLine={validateActiveLine}
|
||||
onClose={closeValidatePanel}
|
||||
onJumpToLine={jumpToComposeLine}
|
||||
onRevalidate={runComposeValidate}
|
||||
onApplyFix={applyValidateFix}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user