Files
pikasTech-HWLAB/internal/cloud/server-caserun-http.ts
T
2026-07-16 08:14:15 +02:00

434 lines
19 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { buildCaseRun, collectCaseRun, prepareCaseRun } from "../../tools/src/hwlab-caserun-lib.ts";
const CONTRACT_VERSION = "hwlab-caserun-web-v1";
const CASES_CONTRACT_VERSION = "hwlab-caserun-cases-v1";
const AGGREGATE_CONTRACT_VERSION = "hwlab-caserun-aggregate-v1";
const SOFTWARE_SMOKE_CONTRACT_VERSION = "hwlab-caserun-software-smoke-v1";
const SOFTWARE_SMOKE_MODE = "software-smoke";
const DEFAULT_BODY_LIMIT_BYTES = 64 * 1024;
const RUN_ID_PATTERN = /^[A-Za-z0-9_.:-]{3,180}$/u;
const memoryRuns = new Map<string, any>();
export async function handleCaseRunHttp(request: IncomingMessage, response: ServerResponse, url: URL, options: any = {}) {
const env = options.env ?? process.env;
const cwd = resolveCwd(env);
const stateRoot = resolveStateRoot(cwd, env);
const caseRepo = resolveCaseRepo(cwd, env);
if (request.method === "GET" && url.pathname === "/v1/caserun") {
sendJson(response, 200, {
ok: true,
contractVersion: CONTRACT_VERSION,
casesRoute: "/v1/caserun/cases",
runsRoute: "/v1/caserun/runs",
caseAuthority: describeCaseAuthority(caseRepo, cwd),
compileOnly: true
});
return;
}
if (request.method === "GET" && url.pathname === "/v1/caserun/cases") {
const cases = await listCases(caseRepo);
sendJson(response, 200, {
ok: true,
contractVersion: CASES_CONTRACT_VERSION,
caseAuthority: describeCaseAuthority(caseRepo, cwd),
count: cases.length,
cases
});
return;
}
if (url.pathname === "/v1/caserun/runs") {
if (request.method !== "POST") return sendError(response, 405, "method_not_allowed", "POST required.");
const parsed = await readJsonBody(request, DEFAULT_BODY_LIMIT_BYTES);
if (!parsed.ok) return sendJson(response, 400, parsed.error);
const body = parsed.body && typeof parsed.body === "object" && !Array.isArray(parsed.body) ? parsed.body as Record<string, unknown> : {};
const caseId = safeOpaqueId(body.caseId);
if (!caseId) return sendError(response, 400, "invalid_case_id", "caseId is required.");
const caseDefinition = await loadCase(caseRepo, caseId).catch(() => null);
if (!caseDefinition) return sendError(response, 404, "case_not_found", "CaseRun case was not found.", { caseId });
const requestedRunId = safeOpaqueId(body.runId);
const runId = requestedRunId || nextRunId(caseId);
const runDir = path.join(stateRoot, "runs", runId);
const record = {
ok: true,
contractVersion: CONTRACT_VERSION,
runId,
caseId,
status: "queued",
stage: "queued",
runDir,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
caseAuthority: describeCaseAuthority(caseRepo, cwd),
runtimeApiUrl: runtimeApiUrlFromRequest(request, env),
events: [] as any[],
result: null as any
};
await saveRecord(stateRoot, record);
remember(record);
appendEvent(record, "queued", "queued", { caseId });
startBackgroundCaseRun(record, { ...options, env, cwd, stateRoot, caseRepo, caseDefinition });
sendJson(response, 202, publicRecord(record));
return;
}
const runMatch = url.pathname.match(/^\/v1\/caserun\/runs\/([^/]+)(?:\/(events|aggregate|cancel))?$/u);
if (runMatch) {
const runId = decodeURIComponent(runMatch[1] ?? "");
if (!RUN_ID_PATTERN.test(runId)) return sendError(response, 400, "invalid_run_id", "runId is invalid.");
const suffix = runMatch[2] ?? "";
const record = await findRecord(stateRoot, runId);
if (!record) return sendError(response, 404, "caserun_run_not_found", "CaseRun run was not found.", { runId });
if (!suffix && request.method === "GET") return sendJson(response, 200, publicRecord(record));
if (suffix === "events" && request.method === "GET") return sendJson(response, 200, { ok: true, contractVersion: CONTRACT_VERSION, runId, events: record.events ?? [] });
if (suffix === "aggregate" && request.method === "GET") return sendJson(response, 200, await aggregateForRecord(record));
if (suffix === "cancel" && request.method === "POST") return sendError(response, 409, "caserun_cancel_not_supported", "This compile-only CaseRun worker cannot be cancelled after dispatch.", { runId, status: record.status });
return sendError(response, 405, "method_not_allowed", suffix === "cancel" ? "POST required." : "GET required.");
}
sendError(response, 404, "caserun_route_not_found", "CaseRun REST route is not implemented.", { route: url.pathname });
}
function startBackgroundCaseRun(record: any, input: any) {
void runBackgroundCaseRun(record, input).catch(async (error) => {
appendEvent(record, "failed", "failed", { code: error?.code ?? error?.name ?? "caserun_failed", message: String(error?.message ?? error).slice(0, 500), details: error?.details ?? null });
record.status = "failed";
record.stage = "failed";
record.error = { code: error?.code ?? error?.name ?? "caserun_failed", message: String(error?.message ?? error), details: error?.details ?? null };
record.updatedAt = new Date().toISOString();
await saveRecord(input.stateRoot, record).catch(() => undefined);
});
}
async function runBackgroundCaseRun(record: any, input: any) {
appendEvent(record, "running", "prepare", { caseId: record.caseId });
record.status = "running";
record.stage = "prepare";
record.updatedAt = new Date().toISOString();
await saveRecord(input.stateRoot, record);
const context = caseContextForRecord(record, input);
const emit = async (stage: string, payload: Record<string, unknown> = {}) => {
appendEvent(record, "running", stage, payload);
record.stage = stage;
record.updatedAt = new Date().toISOString();
await saveRecord(input.stateRoot, record);
};
const executor = typeof input.caseRunExecutor === "function" ? input.caseRunExecutor : defaultCaseRunExecutor;
const result = await executor(context, emit, { record, caseDefinition: input.caseDefinition });
record.status = "completed";
record.stage = "completed";
record.result = compactResult(result);
record.updatedAt = new Date().toISOString();
appendEvent(record, "completed", "completed", { jobId: record.result?.summary?.jobId ?? record.result?.evidence?.keilJob?.jobId ?? null });
await saveRecord(input.stateRoot, record);
}
async function defaultCaseRunExecutor(context: any, emit: (stage: string, payload?: Record<string, unknown>) => Promise<void>, input: any = {}) {
if (input.caseDefinition?.mode === SOFTWARE_SMOKE_MODE) return runSoftwareSmokeCase(context, emit, input.caseDefinition);
const prepared = await prepareCaseRun(context, "web");
await emit("prepared", { runDir: prepared.runDir, specPath: prepared.specPath });
const run = prepared.run;
const build = await buildCaseRun(context, run);
await emit("build-completed", { jobId: build.summary?.jobId ?? build.evidence?.keilJob?.jobId ?? null, hwpodExitCode: build.summary?.hwpodExitCode ?? null });
const collected = await collectCaseRun(context, run, build.evidence);
await emit("collect-completed", { status: collected.status, artifactCount: Array.isArray(build.evidence?.artifacts) ? build.evidence.artifacts.length : 0 });
return { prepared, build, collected, evidence: build.evidence, summary: build.summary, run: collected.run ?? build.run ?? run };
}
async function runSoftwareSmokeCase(context: any, emit: (stage: string, payload?: Record<string, unknown>) => Promise<void>, caseDefinition: any) {
const runDir = path.resolve(context.parsed.runDir);
await mkdir(runDir, { recursive: true });
await emit("software-smoke-running", { mode: SOFTWARE_SMOKE_MODE, hardwareRequired: false });
const artifact = {
contractVersion: SOFTWARE_SMOKE_CONTRACT_VERSION,
caseId: context.parsed.caseId,
runId: context.parsed.runId,
mode: SOFTWARE_SMOKE_MODE,
status: "completed",
hardwareRequired: false,
checks: {
caseDefinitionLoaded: caseDefinition.available === true,
runDirectoryWritable: true,
backgroundWorkerRunning: true
}
};
const artifactPath = path.join(runDir, "software-smoke.json");
const artifactText = `${JSON.stringify(artifact, null, 2)}\n`;
await writeFile(artifactPath, artifactText, "utf8");
const artifacts = [{ path: "software-smoke.json", bytes: Buffer.byteLength(artifactText), sha256: createHash("sha256").update(artifactText).digest("hex") }];
const evidence = { status: "recorded", mode: SOFTWARE_SMOKE_MODE, hardwareRequired: false, checks: artifact.checks, artifacts };
const summary = { status: "completed", mode: SOFTWARE_SMOKE_MODE, hardwareRequired: false, artifactCount: artifacts.length, artifacts };
const run = { runId: context.parsed.runId, caseId: context.parsed.caseId, mode: SOFTWARE_SMOKE_MODE, hardwareRequired: false };
await emit("software-smoke-completed", { artifactCount: artifacts.length, artifactSha256: artifacts[0].sha256 });
return { summary, evidence, run };
}
function caseContextForRecord(record: any, input: any) {
const internalApiUrl = internalRuntimeApiUrlForCaseRun(record, input.env ?? {});
const parsed = {
_: [],
caseId: record.caseId,
runId: record.runId,
runDir: record.runDir,
caseRepo: input.caseRepo,
apiUrl: internalApiUrl,
noCaseRepoRecord: true,
stateDir: path.join(input.stateRoot, "cli")
};
return {
parsed,
env: {
...process.env,
...input.env,
HWLAB_CASE_REPO: input.caseRepo,
HWLAB_RUNTIME_API_URL: internalApiUrl,
HWLAB_CASERUN_PUBLIC_RUNTIME_API_URL: record.runtimeApiUrl,
HWLAB_RUNTIME_ENDPOINT_LOCKED: "1"
},
fetchImpl: input.fetchImpl ?? globalThis.fetch,
cwd: input.cwd,
now: () => new Date().toISOString(),
sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
runProcess: input.caseRunProcess,
startProcess: input.caseRunStartProcess,
argv: [],
rest: ["web", record.caseId]
};
}
function resolveCwd(env: Record<string, string | undefined>) {
return path.resolve(String(env.HWLAB_CASERUN_REPO_ROOT ?? process.cwd()));
}
function resolveStateRoot(cwd: string, env: Record<string, string | undefined>) {
return path.resolve(cwd, String(env.HWLAB_CASERUN_STATE_DIR ?? path.join(".state", "hwlab-cloud-api", "caserun")));
}
function resolveCaseRepo(cwd: string, env: Record<string, string | undefined>) {
return path.resolve(cwd, String(env.HWLAB_CASERUN_CASE_REPO ?? env.HWLAB_CASE_REPO ?? "."));
}
function describeCaseAuthority(caseRepo: string, cwd: string) {
const rel = relativeOrAbsolute(cwd, caseRepo);
return {
kind: rel === "." ? "builtin-hwlab-v03" : "configured-case-repo",
repoPath: rel,
casesPath: path.join(rel, "cases")
};
}
async function listCases(caseRepo: string) {
const casesRoot = path.join(caseRepo, "cases");
const entries = await readdir(casesRoot, { withFileTypes: true }).catch(() => []);
const cases = [] as any[];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const loaded = await loadCase(caseRepo, entry.name).catch(() => null);
if (loaded) cases.push(loaded);
}
cases.sort((left, right) => String(left.caseId).localeCompare(String(right.caseId)));
return cases;
}
async function loadCase(caseRepo: string, caseId: string) {
if (!safeOpaqueId(caseId)) throw new Error("invalid case id");
const caseDir = path.join(caseRepo, "cases", caseId);
const caseFile = path.join(caseDir, "case.json");
const definition = JSON.parse(await readFile(caseFile, "utf8"));
const mode = String(definition.mode ?? "compile-only").trim() || "compile-only";
const hardwareRequired = mode !== SOFTWARE_SMOKE_MODE;
const hwpodSpec = hardwareRequired ? String(definition.hwpodSpec ?? definition.specPath ?? "hwpod-spec.yaml").trim() || "hwpod-spec.yaml" : null;
const specPath = hwpodSpec ? path.resolve(caseDir, hwpodSpec) : null;
const specInfo = specPath ? await stat(specPath).catch(() => null) : null;
return {
caseId: String(definition.caseId ?? caseId).trim() || caseId,
title: String(definition.title ?? caseId).trim() || caseId,
mode,
hwpodSpec,
hardwareRequired,
available: hardwareRequired ? Boolean(specInfo?.isFile()) : true,
subject: definition.subject ?? null,
expected: definition.expected ?? null,
runtime: definition.runtime ?? null
};
}
async function findRecord(stateRoot: string, runId: string) {
const memory = memoryRuns.get(runId);
if (memory) return memory;
const text = await readFile(recordPath(stateRoot, runId), "utf8").catch(() => "");
if (!text) return null;
const record = JSON.parse(text);
remember(record);
return record;
}
async function saveRecord(stateRoot: string, record: any) {
await mkdir(path.dirname(recordPath(stateRoot, record.runId)), { recursive: true });
await mkdir(record.runDir, { recursive: true });
await writeFile(recordPath(stateRoot, record.runId), `${JSON.stringify(record, null, 2)}\n`, "utf8");
}
function recordPath(stateRoot: string, runId: string) {
return path.join(stateRoot, "records", `${runId}.json`);
}
function remember(record: any) {
memoryRuns.set(record.runId, record);
}
function appendEvent(record: any, status: string, stage: string, payload: Record<string, unknown> = {}) {
const event = { at: new Date().toISOString(), status, stage, payload };
record.events = Array.isArray(record.events) ? [...record.events, event] : [event];
record.updatedAt = event.at;
return event;
}
async function aggregateForRecord(record: any) {
const evidence = record.result?.evidence ?? await readJson(path.join(record.runDir, "evidence.json")).catch(() => null);
const run = record.result?.run ?? await readJson(path.join(record.runDir, "run.json")).catch(() => null);
const summary = record.result?.summary ?? summarizeEvidence(evidence);
const source = { runId: record.runId, caseId: record.caseId, status: record.status, summary, evidence, run };
return {
ok: true,
contractVersion: AGGREGATE_CONTRACT_VERSION,
runId: record.runId,
caseId: record.caseId,
status: record.status,
sha256: sha256Json(source),
summary,
evidence,
run
};
}
function compactResult(result: any) {
return {
summary: result?.summary ?? null,
evidence: result?.evidence ?? null,
run: result?.run ?? result?.collected?.run ?? result?.build?.run ?? result?.prepared?.run ?? null,
collected: result?.collected ? { status: result.collected.status, summary: result.collected.summary ?? null } : null
};
}
function summarizeEvidence(evidence: any) {
if (!evidence || typeof evidence !== "object") return null;
return {
status: evidence.status ?? null,
jobId: evidence.keilJob?.jobId ?? null,
keilStatus: evidence.keilJob?.status ?? null,
hwpodExitCode: evidence.hwpod?.exitCode ?? null,
artifactCount: Array.isArray(evidence.artifacts) ? evidence.artifacts.length : 0,
artifacts: evidence.artifacts ?? []
};
}
function publicRecord(record: any) {
return {
ok: true,
contractVersion: CONTRACT_VERSION,
runId: record.runId,
caseId: record.caseId,
status: record.status,
stage: record.stage,
runDir: record.runDir,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
runtimeApiUrl: record.runtimeApiUrl,
caseAuthority: record.caseAuthority,
eventCount: Array.isArray(record.events) ? record.events.length : 0,
lastEvent: Array.isArray(record.events) ? record.events.at(-1) ?? null : null,
summary: record.result?.summary ?? summarizeEvidence(record.result?.evidence),
error: record.error ?? null
};
}
function runtimeApiUrlFromRequest(request: IncomingMessage, env: Record<string, string | undefined>) {
const explicit = String(env.HWLAB_CASERUN_RUNTIME_API_URL ?? "").trim();
if (explicit) return explicit.replace(/\/+$/u, "");
const host = String(request.headers["x-forwarded-host"] ?? request.headers.host ?? "127.0.0.1:8080").split(",")[0]?.trim() || "127.0.0.1:8080";
const proto = String(request.headers["x-forwarded-proto"] ?? "http").split(",")[0]?.trim() || "http";
return `${proto}://${host}`.replace(/\/+$/u, "");
}
function internalRuntimeApiUrlForCaseRun(record: any, env: Record<string, string | undefined>) {
const explicit = String(env.HWLAB_CASERUN_INTERNAL_API_URL ?? env.HWLAB_RUNTIME_INTERNAL_API_URL ?? "").trim();
if (explicit) return explicit.replace(/\/+$/u, "");
const namespace = String(env.HWLAB_NAMESPACE ?? env.POD_NAMESPACE ?? env.KUBERNETES_NAMESPACE ?? namespaceFromEnvironment(env.HWLAB_ENVIRONMENT) ?? "").trim();
const port = String(env.HWLAB_CLOUD_API_PORT ?? env.HWLAB_PORT ?? env.PORT ?? "6667").trim() || "6667";
if (namespace) return `http://hwlab-cloud-api.${namespace}.svc.cluster.local:${port}`;
return String(record.runtimeApiUrl ?? "").replace(/\/+$/u, "");
}
function namespaceFromEnvironment(value: string | undefined) {
const text = String(value ?? "").trim();
if (!text) return "";
if (/^hwlab-[a-z0-9-]+$/u.test(text)) return text;
if (/^[a-z0-9-]+$/u.test(text)) return `hwlab-${text}`;
return "";
}
async function readJsonBody(request: IncomingMessage, limitBytes: number) {
let size = 0;
const chunks = [] as Buffer[];
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.length;
if (size > limitBytes) return { ok: false, error: { ok: false, error: { code: "body_too_large", message: `body exceeds ${limitBytes} bytes` } } };
chunks.push(buffer);
}
const body = Buffer.concat(chunks).toString("utf8").trim();
if (!body) return { ok: true, body: {} };
try {
return { ok: true, body: JSON.parse(body) };
} catch (error) {
return { ok: false, error: { ok: false, error: { code: "invalid_json", message: error instanceof Error ? error.message : String(error) } } };
}
}
async function readJson(file: string) {
return JSON.parse(await readFile(file, "utf8"));
}
function sendError(response: ServerResponse, statusCode: number, code: string, message: string, details: Record<string, unknown> = {}) {
return sendJson(response, statusCode, { ok: false, error: { code, message, details } });
}
function sendJson(response: ServerResponse, statusCode: number, body: unknown) {
if (response.headersSent || response.writableEnded) return false;
response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
response.end(`${JSON.stringify(body)}\n`);
return true;
}
function safeOpaqueId(value: unknown) {
const text = String(value ?? "").trim();
return RUN_ID_PATTERN.test(text) ? text : "";
}
function nextRunId(caseId: string) {
const stamp = new Date().toISOString().replace(/[^0-9]/gu, "").slice(0, 14);
return `web-${slug(caseId)}-${stamp}-${randomUUID().slice(0, 8)}`;
}
function slug(value: string) {
return String(value).trim().toLowerCase().replace(/[^a-z0-9_.:-]+/gu, "-").replace(/^-+|-+$/gu, "") || "caserun";
}
function sha256Json(value: unknown) {
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
}
function relativeOrAbsolute(cwd: string, target: string) {
const relative = path.relative(cwd, target) || ".";
return relative.startsWith("..") ? target : relative;
}