Files
dockhand-upstream/src/routes/api/stacks/[name]/validate/+server.ts
T

78 lines
4.2 KiB
TypeScript

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);
};