Compose validate - a preflight linter & validator for compose files
This commit is contained in:
6 files changed
+62
-19
No files matched your search
@@ -10,24 +10,30 @@ import type { ValidateContext } from './types';
|
||||
export interface CtxContainer {
|
||||
name: string;
|
||||
ports?: Array<{ PublicPort?: number }>;
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>`).
|
||||
* 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
|
||||
* `<project>-<svc>-<n>` 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<number>();
|
||||
const hostPortOwners = new Map<number, string>();
|
||||
const existingContainerNames = new Set<string>();
|
||||
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,
|
||||
|
||||
@@ -18,7 +18,7 @@ export { deriveContextFromLists } from './context-core';
|
||||
*/
|
||||
export async function buildValidateContext(
|
||||
envId: number | null | undefined,
|
||||
selfStackName?: string
|
||||
selfStackName?: string | null
|
||||
): Promise<ValidateContext> {
|
||||
const [containers, networks, volumes] = await Promise.all([
|
||||
listContainers(true, envId).catch(() => []),
|
||||
|
||||
@@ -48,7 +48,8 @@ export interface EffectiveComposeResult {
|
||||
export async function renderEffectiveCompose(
|
||||
stackName: string,
|
||||
composeContent: string,
|
||||
dockerHost?: string | null
|
||||
dockerHost?: string | null,
|
||||
envVars?: Record<string, string>
|
||||
): Promise<EffectiveComposeResult> {
|
||||
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<string, unknown> | undefined;
|
||||
@@ -135,7 +136,8 @@ export function parseConfigErrors(stderr: string): Finding[] {
|
||||
function execDocker(
|
||||
exe: string,
|
||||
args: string[],
|
||||
cwd: string
|
||||
cwd: string,
|
||||
envVars?: Record<string, string>
|
||||
): 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(() => {
|
||||
|
||||
@@ -100,11 +100,12 @@ export async function runValidateWithConfig(
|
||||
composeSource: string,
|
||||
ctx: ValidateContext = {},
|
||||
dockerHost?: string | null,
|
||||
config: ValidateConfig = {}
|
||||
config: ValidateConfig = {},
|
||||
envVars?: Record<string, string>
|
||||
): Promise<ValidateReport> {
|
||||
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.
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user