From 2584e528527d8582e27448a90f0bc19590e1c93f Mon Sep 17 00:00:00 2001 From: jarek Date: Wed, 19 Aug 2026 14:26:45 +0200 Subject: [PATCH] Compose validate - a preflight linter & validator for compose files --- .../server/compose-validate/context-core.ts | 16 +++++++++---- src/lib/server/compose-validate/context.ts | 2 +- .../compose-validate/effective-compose.ts | 13 ++++++---- src/lib/server/compose-validate/index.ts | 5 ++-- .../api/stacks/[name]/validate/+server.ts | 21 +++++++++++++--- src/routes/stacks/StackModal.svelte | 24 +++++++++++++++---- 6 files changed, 62 insertions(+), 19 deletions(-) diff --git a/src/lib/server/compose-validate/context-core.ts b/src/lib/server/compose-validate/context-core.ts index ecbf1fa..36520ca 100644 --- a/src/lib/server/compose-validate/context-core.ts +++ b/src/lib/server/compose-validate/context-core.ts @@ -10,24 +10,30 @@ import type { ValidateContext } from './types'; export interface CtxContainer { name: string; ports?: Array<{ PublicPort?: number }>; + labels?: Record; } /** * 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 `--`). + * THIS stack's own containers so a re-deploy isn't flagged as a self-collision. Match on + * the compose project LABEL first (authoritative - Docker stamps it on every compose + * container regardless of a custom container_name), then fall back to the default + * `--` name prefix for containers that lack the label. */ export function deriveContextFromLists( containers: CtxContainer[], networkNames: string[], volumeNames: string[], - selfStackName?: string + selfStackName?: string | null ): ValidateContext { const usedHostPorts = new Set(); const hostPortOwners = new Map(); const existingContainerNames = new Set(); for (const c of containers) { - const isSelf = !!selfStackName && c.name.startsWith(`${selfStackName}-`); + const isSelf = + !!selfStackName && + (c.labels?.['com.docker.compose.project'] === selfStackName || + c.name.startsWith(`${selfStackName}-`)); if (isSelf) continue; existingContainerNames.add(c.name); for (const p of c.ports || []) { @@ -39,7 +45,7 @@ export function deriveContextFromLists( } } return { - selfStackName, + selfStackName: selfStackName ?? undefined, usedHostPorts, hostPortOwners, existingContainerNames, diff --git a/src/lib/server/compose-validate/context.ts b/src/lib/server/compose-validate/context.ts index b5583af..127aaf1 100644 --- a/src/lib/server/compose-validate/context.ts +++ b/src/lib/server/compose-validate/context.ts @@ -18,7 +18,7 @@ export { deriveContextFromLists } from './context-core'; */ export async function buildValidateContext( envId: number | null | undefined, - selfStackName?: string + selfStackName?: string | null ): Promise { const [containers, networks, volumes] = await Promise.all([ listContainers(true, envId).catch(() => []), diff --git a/src/lib/server/compose-validate/effective-compose.ts b/src/lib/server/compose-validate/effective-compose.ts index 5575dd3..ed40145 100644 --- a/src/lib/server/compose-validate/effective-compose.ts +++ b/src/lib/server/compose-validate/effective-compose.ts @@ -48,7 +48,8 @@ export interface EffectiveComposeResult { export async function renderEffectiveCompose( stackName: string, composeContent: string, - dockerHost?: string | null + dockerHost?: string | null, + envVars?: Record ): Promise { let dir: string | undefined; try { @@ -65,7 +66,7 @@ export async function renderEffectiveCompose( // 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); + const { code, stdout, stderr } = await execDocker(base[0], args, dir, envVars); if (code === 0) { let effective: Record | undefined; @@ -135,7 +136,8 @@ export function parseConfigErrors(stderr: string): Finding[] { function execDocker( exe: string, args: string[], - cwd: string + cwd: string, + envVars?: Record ): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const outChunks: Buffer[] = []; @@ -149,7 +151,10 @@ function execDocker( cwd, shell: false, stdio: ['ignore', 'pipe', 'pipe'], - env: { PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin' } + // The stack's env vars (incl. secrets) are injected so `${VAR}` interpolation + // resolves the same way it will at deploy time - otherwise config reports a + // spurious "VAR is not set" for every variable Dockhand supplies via shell env. + env: { PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin', ...(envVars ?? {}) } }); const timeout = setTimeout(() => { diff --git a/src/lib/server/compose-validate/index.ts b/src/lib/server/compose-validate/index.ts index a1b1b75..428bdb6 100644 --- a/src/lib/server/compose-validate/index.ts +++ b/src/lib/server/compose-validate/index.ts @@ -100,11 +100,12 @@ export async function runValidateWithConfig( composeSource: string, ctx: ValidateContext = {}, dockerHost?: string | null, - config: ValidateConfig = {} + config: ValidateConfig = {}, + envVars?: Record ): Promise { const local = runValidate(composeSource, ctx, config); - const cfg = await renderEffectiveCompose(stackName, composeSource, dockerHost); + const cfg = await renderEffectiveCompose(stackName, composeSource, dockerHost, envVars); // 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. diff --git a/src/routes/api/stacks/[name]/validate/+server.ts b/src/routes/api/stacks/[name]/validate/+server.ts index 3ba13f9..933a696 100644 --- a/src/routes/api/stacks/[name]/validate/+server.ts +++ b/src/routes/api/stacks/[name]/validate/+server.ts @@ -37,7 +37,7 @@ async function resolveDockerHostForConfig(envIdNum: number | undefined): Promise * 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: {compose:string!, config:{disabled:[string], severity:object}, envVars: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}} @@ -56,22 +56,37 @@ export const POST: RequestHandler = async ({ cookies, url, params, request }) => const body = (await request.json().catch(() => ({}))) as { compose?: string; config?: ValidateConfig; + envVars?: Record; + existing?: boolean; }; const compose = typeof body.compose === 'string' ? body.compose : ''; if (!compose.trim()) { return json({ error: 'compose content is required' }, { status: 400 }); } + // The editor's current env vars (incl. secrets) so `docker compose config` resolves + // `${VAR}` the same way a deploy will, instead of reporting a spurious "VAR not set". + const envVars: Record = {}; + if (body.envVars && typeof body.envVars === 'object') { + for (const [k, v] of Object.entries(body.envVars)) { + if (typeof k === 'string' && typeof v === 'string') envVars[k] = v; + } + } + const stackName = decodeURIComponent(params.name); + // Self-exclusion (don't flag the stack's own running containers/ports as collisions) + // applies ONLY when editing an existing stack. A brand-new stack has no own containers, + // so a name/port clash with a running stack of the same name must still be reported. + const selfStackName = body.existing ? stackName : null; // 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(() => ({})); + const ctx = await buildValidateContext(envIdNum ?? null, selfStackName).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 ?? {}); + const report = await runValidateWithConfig(stackName, compose, ctx, dockerHost, body.config ?? {}, envVars); return json(report); }; diff --git a/src/routes/stacks/StackModal.svelte b/src/routes/stacks/StackModal.svelte index f1aec76..2417b38 100644 --- a/src/routes/stacks/StackModal.svelte +++ b/src/routes/stacks/StackModal.svelte @@ -175,12 +175,26 @@ try { const envId = $currentEnvironment?.id ?? null; const name = (mode === 'edit' ? stackName : newStackName) || 'stack'; + // Send the editor's current env vars (incl. secrets) so `docker compose config` + // resolves ${VAR} the same way a deploy will, instead of flagging "VAR not set". + const validateEnvVars: Record = {}; + for (const v of envVars) { + const k = v.key.trim(); + if (k) validateEnvVars[k] = v.value ?? ''; + } const res = await fetch( appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/validate`, envId), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ compose: composeContent }) + body: JSON.stringify({ + compose: composeContent, + envVars: validateEnvVars, + // Only an EDIT of an existing stack has "own" containers to exclude from + // collision checks. A NEW stack with a name that clashes with a running + // stack must still be flagged, so never self-exclude in create mode. + existing: mode === 'edit' + }) } ); if (!res.ok) { @@ -960,8 +974,8 @@ const STORAGE_KEY_VALIDATE_W = 'dockhand-validate-panel-width'; let validatePanelWidth = $state( typeof localStorage !== 'undefined' - ? Number(localStorage.getItem(STORAGE_KEY_VALIDATE_W)) || 288 - : 288 + ? Math.max(320, Number(localStorage.getItem(STORAGE_KEY_VALIDATE_W)) || 320) + : 320 ); let isDraggingValidate = $state(false); let editorRowRef = $state(null); @@ -980,7 +994,9 @@ 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)); + // Floor at 320px: below that the header's title + count chips + re-check button + // no longer fit on one line and start clipping. + validatePanelWidth = Math.max(320, Math.min(560, w)); } }