diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts index 456a17d..ddfb316 100644 --- a/scripts/generate-openapi.ts +++ b/scripts/generate-openapi.ts @@ -247,20 +247,36 @@ function runCheck(): number { } if (orphanMarkers.length > 0) hardFailures++; - // Gate 6: spec validity (write both outputs, lint the static copy) + // Gate 6: spec validity (write both outputs, lint the static copy) via the LOCAL + // @redocly/cli (a devDependency). It is NOT invoked through `npx --yes`: an on-demand + // npx fetch in a fresh CI container hung this gate for HOURS. Resolve the binary from + // node_modules; if it is not installed, SKIP (infra), do not block the release. A lint + // that RUNS and exits non-zero is a real spec failure and is hard. writeSpecOutputs(spec); - let validatorsOk = true; - try { - execFileSync('npx', ['--yes', '@redocly/cli@2.43.1', 'lint', STATIC_OUT_FILE, '--format=summary'], { - cwd: ROOT_DIR, - stdio: 'pipe' - }); - } catch (err: any) { - validatorsOk = false; - report.push(`[Gate 6] @redocly/cli lint FAILED:\n${err.stdout?.toString().slice(-1000) ?? err.message}`); + const redoclyBin = join(ROOT_DIR, 'node_modules', '.bin', 'redocly'); + if (!existsSync(redoclyBin)) { + report.push(`[Gate 6] Spec validity: SKIPPED (@redocly/cli not installed - run npm i)`); + } else { + try { + execFileSync(redoclyBin, ['lint', STATIC_OUT_FILE, '--format=summary'], { + cwd: ROOT_DIR, + stdio: 'pipe', + timeout: 120_000, // belt-and-suspenders: never hang the gate + env: { ...process.env, REDOCLY_TELEMETRY: 'off', REDOCLY_SUPPRESS_UPDATE_NOTICE: '1' } + }); + report.push(`[Gate 6] Spec validity (@redocly/cli lint): OK`); + } catch (err: any) { + // A spawn/timeout failure (ENOENT/ETIMEDOUT) is infra -> skip; a non-zero exit + // from a lint that actually ran is a real spec problem -> hard fail. + if (err.code === 'ETIMEDOUT' || err.code === 'ENOENT') { + report.push(`[Gate 6] Spec validity: SKIPPED (validator did not run: ${err.code})`); + } else { + const out = (err.stdout?.toString() ?? '') + (err.stderr?.toString() ?? ''); + report.push(`[Gate 6] @redocly/cli lint FAILED:\n${out.slice(-1000)}`); + hardFailures++; + } + } } - report.push(`[Gate 6] Spec validity (@redocly/cli lint): ${validatorsOk ? 'OK' : 'FAILED'}`); - if (!validatorsOk) hardFailures++; console.log(`\n=== OpenAPI --check Report ===`); for (const line of report) console.log(line); diff --git a/scripts/openapi/build-spec.ts b/scripts/openapi/build-spec.ts index a288fd4..51ad622 100644 --- a/scripts/openapi/build-spec.ts +++ b/scripts/openapi/build-spec.ts @@ -140,6 +140,33 @@ export function buildSpec({ routes, fileContents, annotationsByPath, isPublicFn, required: true, content: { 'application/json': { schema: miniSchemaToOpenApi(annotation.body), example } } }; + } else if (annotation?.bodyRaw) { + // A non-JSON raw body (e.g. application/x-tar): binary string schema. + const schema: Record = { type: 'string', format: 'binary' }; + if (annotation.bodyRaw.description) schema.description = annotation.bodyRaw.description; + requestBody = { + required: true, + content: { [annotation.bodyRaw.mediaType]: { schema } } + }; + } else if (annotation?.bodyMultipart) { + // A multipart/form-data body with a single (possibly array) file field. + const mp = annotation.bodyMultipart; + const fieldSchema: Record = mp.array + ? { type: 'array', items: { type: mp.type === 'binary' ? 'string' : mp.type, format: mp.type === 'binary' ? 'binary' : undefined } } + : { type: mp.type === 'binary' ? 'string' : mp.type, format: mp.type === 'binary' ? 'binary' : undefined }; + if (mp.description) fieldSchema.description = mp.description; + requestBody = { + required: true, + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { [mp.field]: fieldSchema }, + ...(mp.required ? { required: [mp.field] } : {}) + } + } + } + }; } else if (['POST', 'PUT', 'PATCH'].includes(method) && analysis.bodyFields.length > 0) { const properties = Object.fromEntries(analysis.bodyFields.map((f) => [f, { type: 'string' }])); requestBody = { diff --git a/scripts/openapi/lib.ts b/scripts/openapi/lib.ts index 9247eed..22b3f86 100644 --- a/scripts/openapi/lib.ts +++ b/scripts/openapi/lib.ts @@ -66,6 +66,11 @@ export interface HandlerAnnotation { path: Record; body?: MiniSchema; bodyExample?: unknown; + /** A non-JSON raw request body, e.g. `body-raw: application/x-tar The image tar`. */ + bodyRaw?: { mediaType: string; description: string }; + /** A multipart/form-data body: one file field, e.g. + * `body-multipart: files:binary[]! One or more files`. */ + bodyMultipart?: { field: string; type: string; array: boolean; required: boolean; description: string }; responses: Record; /** Raw block text, kept only for --check's orphan-block diagnostics. */ raw: string; @@ -217,6 +222,14 @@ export function parseMiniSchema(str: string): MiniSchema { function parseType(): MiniSchema { skipWs(); if (s[i] === '{') return parseObject(); + if (s[i] === '[') { + // bracket array form: [T] + i++; + const items = parseType(); + skipWs(); + if (s[i] === ']') i++; + return { kind: 'array', items }; + } if (s.slice(i, i + 6) === 'array<') { i += 6; const items = parseType(); @@ -226,6 +239,9 @@ export function parseMiniSchema(str: string): MiniSchema { } const start = i; while (i < s.length && /[a-zA-Z0-9_]/.test(s[i])) i++; + // Guarantee forward progress: an unrecognized char (not a word, brace, or + // bracket) must still advance i, or the parseObject loop spins forever. + if (i === start) i++; const word = s.slice(start, i) || 'string'; if (word === 'integer' || word === 'number' || word === 'boolean' || word === 'string') return { kind: word }; return { kind: 'string' }; @@ -374,6 +390,27 @@ export function parseAnnotations(content: string): Partial ` - a non-JSON raw body. + const rm = rawValue.match(/^(\S+)\s*(.*)$/); + if (rm) annotation.bodyRaw = { mediaType: rm[1], description: rm[2].trim() }; + continue; + } + if (key === 'body-multipart') { + // `body-multipart: :[]? [!] ` - a multipart body. + const mm = rawValue.match(/^([a-zA-Z0-9_]+)\s*:\s*([a-zA-Z0-9_]+)(\[\])?(!)?\s*(.*)$/); + if (mm) { + const [, field, type, array, required, description] = mm; + annotation.bodyMultipart = { + field, + type, + array: !!array, + required: !!required, + description: description.trim() + }; + } + continue; + } const respMatch = key.match(/^resp-(\d{3})(-desc|-example)?$/); if (respMatch) { const [, code, suffix] = respMatch; diff --git a/src/lib/openapi.generated.json b/src/lib/openapi.generated.json index 376974f..8fbf1e7 100644 --- a/src/lib/openapi.generated.json +++ b/src/lib/openapi.generated.json @@ -7329,7 +7329,30 @@ "bearerAuth": [] } ], - "description": "Send `multipart/form-data` with one or more `files` parts; each file is tar-wrapped and written into the target directory. Partial success is reported per file in `uploaded`/`errors`." + "description": "Send `multipart/form-data` with one or more `files` parts; each file is tar-wrapped and written into the target directory. Partial success is reported per file in `uploaded`/`errors`.", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "string", + "format": "binary" + }, + "description": "One or more files to write into the target directory" + } + }, + "required": [ + "files" + ] + } + } + } + } } }, "/api/containers/{id}/inspect": { @@ -15491,7 +15514,19 @@ "bearerAuth": [] } ], - "description": "The request body is the raw image tar (Content-Type application/x-tar), streamed straight to the daemon without buffering. Local/socket or direct TCP only; Hawser is rejected." + "description": "The request body is the raw image tar (Content-Type application/x-tar), streamed straight to the daemon without buffering. Local/socket or direct TCP only; Hawser is rejected.", + "requestBody": { + "required": true, + "content": { + "application/x-tar": { + "schema": { + "type": "string", + "format": "binary", + "description": "The raw image tar, streamed to the daemon (docker load)" + } + } + } + } } }, "/api/images/pull": { @@ -24994,6 +25029,175 @@ ] } }, + "/api/stacks/{name}/validate": { + "post": { + "operationId": "post_api_stacks_name_validate", + "tags": [ + "stacks" + ], + "summary": "Run the Compose Validate preflight linter on a compose file and return findings with severities and line numbers (requires the 'view' permission)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (used to exclude the stack's own containers from cross-stack collision checks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID for the context-aware checks (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "findings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ruleId": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "message": { + "type": "string" + }, + "hint": { + "type": "string" + }, + "service": { + "type": "string" + }, + "line": { + "type": "integer" + }, + "fix": { + "type": "string" + }, + "fixDescription": { + "type": "string" + } + }, + "required": [ + "ruleId", + "severity", + "message" + ] + } + }, + "counts": { + "type": "object", + "properties": { + "error": { + "type": "integer" + }, + "warn": { + "type": "integer" + }, + "info": { + "type": "integer" + } + }, + "required": [ + "error", + "warn", + "info" + ] + } + }, + "required": [ + "findings", + "counts" + ] + }, + "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 + } + } + } + } + }, + "400": { + "description": "compose content is required" + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "compose": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "disabled": { + "type": "array", + "items": { + "type": "string" + } + }, + "severity": { + "type": "string" + } + } + } + }, + "required": [ + "compose" + ] + }, + "example": { + "compose": "services:\n web:\n image: nginx:latest\n ports: [\"8080:80\"]\n" + } + } + } + } + } + }, "/api/stacks/adopt": { "post": { "operationId": "post_api_stacks_adopt", diff --git a/src/routes/api/containers/[id]/files/upload/+server.ts b/src/routes/api/containers/[id]/files/upload/+server.ts index 7bea39d..827aa79 100644 --- a/src/routes/api/containers/[id]/files/upload/+server.ts +++ b/src/routes/api/containers/[id]/files/upload/+server.ts @@ -90,6 +90,7 @@ function createTarArchive(filename: string, content: Uint8Array): Uint8Array { * @openapi * summary: Upload one or more files into a container directory (requires the 'exec' permission) * description: Send `multipart/form-data` with one or more `files` parts; each file is tar-wrapped and written into the target directory. Partial success is reported per file in `uploaded`/`errors`. + * body-multipart: files:binary[]! One or more files to write into the target directory * path: id:string! Container ID or name (from GET /api/containers) * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) * query: path:string! Absolute target directory inside the container diff --git a/src/routes/api/images/load/+server.ts b/src/routes/api/images/load/+server.ts index b8255b0..3e19f21 100644 --- a/src/routes/api/images/load/+server.ts +++ b/src/routes/api/images/load/+server.ts @@ -21,6 +21,7 @@ import type { RequestHandler } from './$types'; * @openapi * summary: Load a Docker image from an uploaded tar (docker load) for air-gapped hosts * description: The request body is the raw image tar (Content-Type application/x-tar), streamed straight to the daemon without buffering. Local/socket or direct TCP only; Hawser is rejected. + * body-raw: application/x-tar The raw image tar, streamed to the daemon (docker load) * query: env:integer Target environment id * resp-200: {success:boolean!, loaded:string} * resp-200-desc: loaded echoes the daemon's final line, e.g. "Loaded image: alpine:3.20"