Files
dockhand-upstream/src/routes/api/stacks/[name]/validate/+server.ts
T
Björn StrausmannandJarek Krochmalski 33c0c47855 fix(openapi): recognize bare object type + document existing field on validate (#1532)
* fix(openapi): recognize bare `object` type in annotation parser

The @openapi annotation parser's parseMiniSchema() only recognized the
bare words integer/number/boolean/string as scalar types. Any other
bare word - including `object`, used in 16 route annotations across
the codebase (e.g. severity:object, envVars:object on
POST /api/stacks/{name}/validate) - silently fell through to the
generic string fallback, so these fields were documented in
static/openapi.json as {"type": "string"} instead of
{"type": "object"}.

Add a dedicated branch for bare `object`, producing an opaque object
schema (no declared properties, since the bare word carries no shape
information of its own - a documented shape should use the existing
{...} form).

Verified no other bare non-standard word is used in any body:/resp-*:
annotation line across the 257 route files, so this closes the gap
completely for the current codebase.

Fixes #1531

* docs(openapi): document the existing field on POST /api/stacks/{name}/validate

The handler reads body.existing (an optional boolean, undocumented)
to control self-exclusion of the stack's own running containers/ports
from the cross-stack collision checks - the exact case the endpoint's
own summary advertises as handled. Without it, a client built strictly
from the OpenAPI document has no way to learn the field exists, and
will validate an existing stack the same way as a new one, silently
reintroducing false-positive collision findings against the stack's
own containers.

Add existing:boolean to the body: annotation, plus a one-line
description explaining the true/omitted semantics.

Fixes #1530

---------

Co-authored-by: Jarek Krochmalski <jotka@users.noreply.github.com>
2026-09-06 15:51:12 +05:30

96 lines
5.4 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)
* description: Set existing:true when validating an already-deployed stack, so the stack's own running containers/ports are excluded from the cross-stack collision checks; omit it (or false) for a brand-new stack, where a name/port clash with a running stack of the same name must still be reported.
* 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!, existing:boolean, 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}}
* 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 envAccessDenied = await auth.requireEnvAccess(envIdNum ?? null);
if (envAccessDenied) return envAccessDenied;
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, 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 ?? {}, envVars);
return json(report);
};