Compose validate - a preflight linter & validator for compose files

This commit is contained in:
jarek committed 2026-08-19 14:26:45 +02:00
1 parent bc91e71075
commit 2584e52852
6 files changed
+62 -19

No files matched your search

@@ -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<string, string>;
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<string, string> = {};
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);
};
+20 -4
View File
@@ -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<string, string> = {};
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<HTMLDivElement | null>(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));
}
}