import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { createWriteStream, existsSync, mkdirSync, openSync, readSync, statSync, writeFileSync, closeSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { repoRoot, rootPath } from "./config"; import { classifyD601DefaultKubectlDiagnostic, classifyD601K3sTarget, d601NativeKubeconfig, } from "./d601-k3s-guard"; type HwlabCdAction = "status" | "apply"; type HwlabCdEnvironment = "dev"; interface HwlabCdOptions { action: HwlabCdAction; environment: HwlabCdEnvironment; dryRun: boolean; repoPath: string | null; kubeconfig: string; timeoutMs: number; frontendLiveUrl: string; apiLiveUrl: string; } interface CapturedCommand { id: string; command: string[]; cwd: string; ok: boolean; exitCode: number | null; signal: NodeJS.Signals | null; timedOut: boolean; durationMs: number; stdoutText: string; stderrText: string; dump: { stdout: string; stderr: string; stdoutBytes: number; stderrBytes: number; stdoutTail: string; stderrTail: string; }; } interface CommandView { id: string; command: string[]; cwd: string; ok: boolean; exitCode: number | null; signal: NodeJS.Signals | null; timedOut: boolean; durationMs: number; dump: { stdout: string; stderr: string; stdoutBytes: number; stderrBytes: number; }; } const namespace = "hwlab-dev"; const lockName = "hwlab-dev-cd-lock"; const nativeKubeconfig = d601NativeKubeconfig; const defaultFrontendLiveUrl = "http://74.48.78.17:16666/health/live"; const defaultApiLiveUrl = "http://74.48.78.17:16667/health/live"; const parseCaptureLimitBytes = 4 * 1024 * 1024; const tailChars = 1000; function isHelpArg(value: string | undefined): boolean { return value === "help" || value === "--help" || value === "-h"; } function readOption(argv: string[], index: number, option: string): string { const value = argv[index]; if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${option} requires a value`); return value; } function parsePositiveInteger(value: string, option: string): number { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${option} must be a positive integer`); return parsed; } function parseOptions(args: string[]): HwlabCdOptions { const [scope, actionArg] = args; if (scope !== "cd") throw new Error("hwlab usage: bun scripts/cli.ts hwlab cd status|apply --env dev"); if (actionArg !== "status" && actionArg !== "apply") throw new Error("hwlab cd usage: status|apply"); const options: HwlabCdOptions = { action: actionArg, environment: "dev", dryRun: false, repoPath: null, kubeconfig: nativeKubeconfig, timeoutMs: 60_000, frontendLiveUrl: process.env.UNIDESK_HWLAB_CD_TEST_FRONTEND_LIVE_URL ?? defaultFrontendLiveUrl, apiLiveUrl: process.env.UNIDESK_HWLAB_CD_TEST_API_LIVE_URL ?? defaultApiLiveUrl, }; let envSeen = false; for (let index = 2; index < args.length; index += 1) { const arg = args[index] ?? ""; if (arg === "--env") { const value = readOption(args, index + 1, arg); if (value !== "dev") throw new Error("hwlab cd only supports --env dev"); options.environment = value; envSeen = true; index += 1; } else if (arg === "--dry-run") { options.dryRun = true; } else if (arg === "--hwlab-repo" || arg === "--repo") { options.repoPath = readOption(args, index + 1, arg); index += 1; } else if (arg === "--kubeconfig") { const value = readOption(args, index + 1, arg); if (value !== nativeKubeconfig) { throw new Error(`hwlab cd requires the D601 native k3s kubeconfig: ${nativeKubeconfig}`); } options.kubeconfig = value; index += 1; } else if (arg === "--timeout-ms") { options.timeoutMs = parsePositiveInteger(readOption(args, index + 1, arg), arg); index += 1; } else if (arg === "--frontend-live-url") { options.frontendLiveUrl = readOption(args, index + 1, arg); index += 1; } else if (arg === "--api-live-url") { options.apiLiveUrl = readOption(args, index + 1, arg); index += 1; } else if (!isHelpArg(arg)) { throw new Error(`unknown hwlab cd option: ${arg}`); } } if (!envSeen) throw new Error("hwlab cd requires --env dev"); return options; } function makeRunId(): string { const timestamp = new Date().toISOString().replace(/[:.]/gu, "-"); return `${timestamp}-${process.pid}-${randomBytes(4).toString("hex")}`; } function redact(value: string): string { return value .replace(/\b(Bearer\s+)[A-Za-z0-9._~+/=-]+/giu, "$1") .replace(/\b(token|password|client-key-data|client-certificate-data|certificate-authority-data)\b(\s*[:=]\s*)(["']?)([^"'\s,}]+)/giu, "$1$2$3") .replace(/\b(postgres(?:ql)?:\/\/[^:\s/@]+:)([^@\s]+)(@)/giu, "$1$3"); } function boundedTail(value: string): string { const redacted = redact(value); return redacted.slice(Math.max(0, redacted.length - tailChars)); } async function runCaptured(command: string[], cwd: string, dumpDir: string, id: string, options: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {}): Promise { await mkdir(dumpDir, { recursive: true }); const stdoutPath = join(dumpDir, `${id}.stdout.txt`); const stderrPath = join(dumpDir, `${id}.stderr.txt`); writeFileSync(stdoutPath, "", { mode: 0o600 }); writeFileSync(stderrPath, "", { mode: 0o600 }); const startedAt = Date.now(); let timedOut = false; let stdoutBytes = 0; let stderrBytes = 0; let spawnErrorMessage = ""; const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; const stdout = createWriteStream(stdoutPath, { flags: "a" }); const stderr = createWriteStream(stderrPath, { flags: "a" }); const child = spawn(command[0] ?? "", command.slice(1), { cwd, env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], }); const timeout = options.timeoutMs === undefined ? null : setTimeout(() => { timedOut = true; child.kill("SIGTERM"); setTimeout(() => child.kill("SIGKILL"), 1000).unref(); }, options.timeoutMs); child.stdout.on("data", (chunk: Buffer) => { stdoutBytes += chunk.byteLength; stdout.write(chunk); if (stdoutBytes <= parseCaptureLimitBytes) stdoutChunks.push(Buffer.from(chunk)); }); child.stderr.on("data", (chunk: Buffer) => { stderrBytes += chunk.byteLength; stderr.write(chunk); if (stderrBytes <= parseCaptureLimitBytes) stderrChunks.push(Buffer.from(chunk)); }); const closed = await new Promise<{ exitCode: number | null; signal: NodeJS.Signals | null }>((resolveClose) => { child.on("error", (error) => { spawnErrorMessage = error.message; }); child.on("close", (exitCode, signal) => { resolveClose({ exitCode, signal }); }); }); if (timeout !== null) clearTimeout(timeout); await new Promise((resolveEnd) => stdout.end(resolveEnd)); await new Promise((resolveEnd) => stderr.end(resolveEnd)); const stdoutText = stdoutBytes <= parseCaptureLimitBytes ? Buffer.concat(stdoutChunks).toString("utf8") : ""; const stderrText = stderrBytes <= parseCaptureLimitBytes ? Buffer.concat(stderrChunks).toString("utf8") : spawnErrorMessage; const exitCode = spawnErrorMessage.length > 0 && closed.exitCode === null ? 127 : closed.exitCode; return { id, command, cwd, ok: exitCode === 0 && !timedOut, exitCode, signal: closed.signal, timedOut, durationMs: Date.now() - startedAt, stdoutText, stderrText, dump: { stdout: stdoutPath, stderr: stderrPath, stdoutBytes, stderrBytes, stdoutTail: boundedTail(stdoutText || readFileTail(stdoutPath)), stderrTail: boundedTail(stderrText || readFileTail(stderrPath)), }, }; } function readFileTail(path: string): string { try { const size = statSync(path).size; const bytesToRead = Math.min(size, tailChars * 4); const buffer = Buffer.alloc(bytesToRead); const fd = openSync(path, "r"); try { readSync(fd, buffer, 0, bytesToRead, size - bytesToRead); } finally { closeSync(fd); } return buffer.toString("utf8").slice(-tailChars); } catch { return ""; } } function commandView(result: CapturedCommand): CommandView { return { id: result.id, command: result.command, cwd: result.cwd, ok: result.ok, exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, durationMs: result.durationMs, dump: { stdout: result.dump.stdout, stderr: result.dump.stderr, stdoutBytes: result.dump.stdoutBytes, stderrBytes: result.dump.stderrBytes, }, }; } function parseJson(text: string): unknown | null { if (text.trim().length === 0) return null; try { return JSON.parse(text); } catch { return null; } } function asRecord(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; } function stringValue(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; } function defaultRepoCandidates(provided: string | null): { source: string; path: string }[] { return [ ...(provided === null ? [] : [{ source: "option", path: provided }]), ...(process.env.UNIDESK_HWLAB_REPO === undefined ? [] : [{ source: "env:UNIDESK_HWLAB_REPO", path: process.env.UNIDESK_HWLAB_REPO }]), { source: "default", path: "/workspace/hwlab" }, { source: "default", path: "/home/ubuntu/workspace/hwlab" }, { source: "default", path: "/home/ubuntu/hwlab-cd-master-cli" }, { source: "default", path: "/home/ubuntu/hwlab" }, ]; } function resolveHwlabRepo(provided: string | null): Record { const candidates = defaultRepoCandidates(provided).map((candidate) => { const absolutePath = resolve(candidate.path); const devCdApply = join(absolutePath, "scripts/dev-cd-apply.mjs"); const devDeployApply = join(absolutePath, "scripts/dev-deploy-apply.mjs"); return { ...candidate, path: absolutePath, exists: existsSync(absolutePath), hasDevCdApply: existsSync(devCdApply), hasDevDeployApply: existsSync(devDeployApply), }; }); const selected = candidates.find((candidate) => candidate.exists && candidate.hasDevCdApply && candidate.hasDevDeployApply) ?? null; return { ok: selected !== null, selected, candidates, }; } async function gitSummary(repoPath: string, dumpDir: string, timeoutMs: number): Promise> { const [branch, head, originMain, statusShort, statusPorcelain] = await Promise.all([ runCaptured(["git", "rev-parse", "--abbrev-ref", "HEAD"], repoPath, dumpDir, "git-branch", { timeoutMs }), runCaptured(["git", "rev-parse", "HEAD"], repoPath, dumpDir, "git-head", { timeoutMs }), runCaptured(["git", "rev-parse", "--verify", "origin/main^{commit}"], repoPath, dumpDir, "git-origin-main", { timeoutMs }), runCaptured(["git", "status", "--short", "--branch"], repoPath, dumpDir, "git-status-short", { timeoutMs }), runCaptured(["git", "status", "--porcelain=v1"], repoPath, dumpDir, "git-status-porcelain", { timeoutMs }), ]); const branchName = branch.stdoutText.trim(); const headCommit = head.stdoutText.trim(); const originMainCommit = originMain.stdoutText.trim(); const porcelain = statusPorcelain.stdoutText.trim(); const statusLines = statusShort.stdoutText.trim().split("\n").filter((line) => line.length > 0); const dirtyLines = porcelain.length === 0 ? [] : porcelain.split("\n").filter((line) => line.length > 0); return { ok: branch.ok && head.ok && statusShort.ok && statusPorcelain.ok, clean: dirtyLines.length === 0, branch: branchName || null, onMain: branchName === "main", headCommit: headCommit || null, originMainCommit: originMain.ok ? originMainCommit || null : null, headMatchesOriginMain: originMain.ok && headCommit.length > 0 && headCommit === originMainCommit, statusShort: statusLines.slice(0, 40), dirtyCount: dirtyLines.length, dirtyPreview: dirtyLines.slice(0, 30), commands: [branch, head, originMain, statusShort, statusPorcelain].map(commandView), }; } async function nativeK3sGuard(kubeconfig: string, dumpDir: string, timeoutMs: number): Promise> { const env = { ...process.env, KUBECONFIG: kubeconfig }; const [context, server, nodes, defaultContext, defaultServer, defaultNodes] = await Promise.all([ runCaptured(["kubectl", "config", "current-context"], repoRoot, dumpDir, "k3s-current-context", { env, timeoutMs }), runCaptured(["kubectl", "config", "view", "--minify", "-o", "jsonpath={.clusters[0].cluster.server}"], repoRoot, dumpDir, "k3s-server", { env, timeoutMs }), runCaptured(["kubectl", "get", "nodes", "-o", "jsonpath={range .items[*]}{.metadata.name}{\"\\n\"}{end}"], repoRoot, dumpDir, "k3s-nodes", { env, timeoutMs }), runCaptured(["kubectl", "config", "current-context"], repoRoot, dumpDir, "default-kubectl-current-context", { timeoutMs }), runCaptured(["kubectl", "config", "view", "--minify", "-o", "jsonpath={.clusters[0].cluster.server}"], repoRoot, dumpDir, "default-kubectl-server", { timeoutMs }), runCaptured(["kubectl", "get", "nodes", "-o", "jsonpath={range .items[*]}{.metadata.name}{\"\\n\"}{end}"], repoRoot, dumpDir, "default-kubectl-nodes", { timeoutMs }), ]); const contextText = context.stdoutText.trim(); const serverText = server.stdoutText.trim(); const nodeNames = nodes.stdoutText.split("\n").map((line) => line.trim()).filter((line) => line.length > 0); const defaultDiagnostic = classifyD601DefaultKubectlDiagnostic({ currentContext: defaultContext.stdoutText.trim() || null, apiServer: defaultServer.stdoutText.trim() || null, combinedText: `${defaultContext.stdoutText}\n${defaultContext.stderrText}\n${defaultServer.stdoutText}\n${defaultServer.stderrText}\n${defaultNodes.stdoutText}\n${defaultNodes.stderrText}`, commandsOk: defaultContext.ok && defaultServer.ok && defaultNodes.ok, }); const guard = classifyD601K3sTarget({ kubeconfig, expectedKubeconfig: nativeKubeconfig, currentContext: contextText || null, apiServer: serverText || null, nodeNames, commandsOk: context.ok && server.ok && nodes.ok, combinedText: `${context.stdoutText}\n${context.stderrText}\n${server.stdoutText}\n${server.stderrText}\n${nodes.stdoutText}\n${nodes.stderrText}`, }, defaultDiagnostic); return { ...guard, injectedEnv: { KUBECONFIG: kubeconfig }, commands: [context, server, nodes, defaultContext, defaultServer, defaultNodes].map(commandView), }; } function annotation(annotations: Record, name: string): string | null { return stringValue(annotations[`hwlab.pikastech.local/${name}`]); } function classifyLock(lock: Record | null): Record { if (lock === null || lock.phase === "released") return { held: false, stale: false, retryAfterSeconds: 0, expiresAt: null }; const updatedAt = stringValue(lock.updatedAt) ?? ""; const ttlSeconds = Number(lock.ttlSeconds ?? 3600); const updatedMs = Number.isFinite(Date.parse(updatedAt)) ? Date.parse(updatedAt) : 0; const expiresAtMs = updatedMs + (Number.isFinite(ttlSeconds) ? ttlSeconds : 3600) * 1000; const retryAfterSeconds = Math.max(0, Math.ceil((expiresAtMs - Date.now()) / 1000)); return { held: retryAfterSeconds > 0, stale: retryAfterSeconds <= 0, retryAfterSeconds, expiresAt: Number.isFinite(expiresAtMs) ? new Date(expiresAtMs).toISOString() : null, }; } async function cdLockStatus(kubeconfig: string, guard: Record, dumpDir: string, timeoutMs: number): Promise> { if (guard.refusal === true) { return { status: "skipped", reason: "native-k3s-guard-refused", present: null, lockName, namespace, }; } const env = { ...process.env, KUBECONFIG: kubeconfig }; const result = await runCaptured(["kubectl", "-n", namespace, "get", "lease", lockName, "-o", "json"], repoRoot, dumpDir, "cd-lock", { env, timeoutMs }); const text = `${result.stderrText}\n${result.stdoutText}`; if (!result.ok && /not\s*found|notfound/iu.test(text)) { return { status: "absent", present: false, held: false, stale: false, lockName, namespace, command: commandView(result), }; } const parsed = asRecord(parseJson(result.stdoutText)); const annotations = asRecord(asRecord(parsed?.metadata)?.annotations) ?? {}; const lock = parsed === null ? null : { lockBackend: "Lease", lockName: stringValue(asRecord(parsed.metadata)?.name) ?? lockName, namespace: stringValue(asRecord(parsed.metadata)?.namespace) ?? namespace, holderIdentity: stringValue(asRecord(parsed.spec)?.holderIdentity), resourceVersion: stringValue(asRecord(parsed.metadata)?.resourceVersion), ownerTaskId: annotation(annotations, "ownerTaskId"), transactionId: annotation(annotations, "transactionId"), phase: annotation(annotations, "phase") ?? "unknown", promotionCommit: annotation(annotations, "promotionCommit"), deployJsonHash: annotation(annotations, "deployJsonHash"), targetRef: annotation(annotations, "targetRef"), targetNamespace: annotation(annotations, "targetNamespace"), updatedAt: annotation(annotations, "updatedAt") ?? stringValue(asRecord(parsed.spec)?.renewTime), ttlSeconds: Number(annotation(annotations, "ttlSeconds") ?? asRecord(parsed.spec)?.leaseDurationSeconds ?? 3600), releaseStatus: annotation(annotations, "releaseStatus"), releasedAt: annotation(annotations, "releasedAt"), }; return { status: result.ok && lock !== null ? "observed" : "blocked", present: result.ok && lock !== null, ...(lock ?? {}), ...classifyLock(lock), command: commandView(result), }; } function summarizeDesiredState(parsed: unknown, command: CapturedCommand): Record { const record = asRecord(parsed); const summary = asRecord(record?.summary); return { status: stringValue(record?.status) ?? (command.ok ? "unknown" : "blocked"), kind: stringValue(record?.kind), desiredCommitId: stringValue(summary?.desiredCommitId), desiredImageTag: stringValue(summary?.desiredImageTag), artifactState: stringValue(summary?.artifactState), ciPublished: summary?.ciPublished ?? null, registryVerified: summary?.registryVerified ?? null, services: summary?.services ?? null, workloadContainers: summary?.workloadContainers ?? null, diagnostics: summary?.diagnostics ?? null, blockers: summary?.blockers ?? null, targetConvergence: summary?.targetConvergence ?? null, command: commandView(command), parsed: record !== null, }; } async function desiredStateStatus(repoPath: string, dumpDir: string, timeoutMs: number): Promise> { const script = join(repoPath, "scripts/deploy-desired-state-plan.mjs"); if (!existsSync(script)) { return { status: "skipped", reason: "scripts/deploy-desired-state-plan.mjs not found" }; } const result = await runCaptured(["node", "scripts/deploy-desired-state-plan.mjs", "--pretty"], repoPath, dumpDir, "desired-state-plan", { timeoutMs }); return summarizeDesiredState(parseJson(result.stdoutText), result); } async function controlledObservability(repoPath: string, dumpDir: string, timeoutMs: number): Promise> { const script = join(repoPath, "scripts/d601-k3s-readonly-observability.mjs"); if (!existsSync(script)) { return { status: "skipped", reason: "scripts/d601-k3s-readonly-observability.mjs not found" }; } const result = await runCaptured(["node", "scripts/d601-k3s-readonly-observability.mjs", "--no-write", "--timeout-ms", String(Math.min(timeoutMs, 10_000))], repoPath, dumpDir, "d601-readonly-observability", { env: { ...process.env, KUBECONFIG: nativeKubeconfig }, timeoutMs: Math.max(timeoutMs, 15_000), }); const parsed = asRecord(parseJson(result.stdoutText)); return { status: stringValue(parsed?.conclusion) ?? (result.ok ? "unknown" : "blocked"), reportKind: stringValue(parsed?.reportKind), readable: parsed?.readable ?? null, runnerKubeconfigReadable: parsed?.runnerKubeconfigReadable ?? null, d601PublicEndpointsReachable: parsed?.d601PublicEndpointsReachable ?? null, d601K3sUnavailable: parsed?.d601K3sUnavailable ?? null, attemptedExecutors: parsed?.attemptedExecutors ?? [], blockers: parsed?.blockers ?? [], command: commandView(result), parsed: parsed !== null, }; } function revisionFromHealth(json: Record | null): string | null { const commit = asRecord(json?.commit); const image = asRecord(json?.image); return stringValue(json?.revision) ?? stringValue(json?.commitId) ?? stringValue(commit?.id) ?? stringValue(image?.tag) ?? null; } function imageFromHealth(json: Record | null): string | null { const image = json?.image; if (typeof image === "string") return image; return stringValue(asRecord(image)?.reference); } async function fetchLive(url: string, dumpDir: string, id: string, timeoutMs: number): Promise> { const startedAt = Date.now(); await mkdir(dumpDir, { recursive: true }); const bodyPath = join(dumpDir, `${id}.body.txt`); const errorPath = join(dumpDir, `${id}.error.txt`); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { method: "GET", signal: controller.signal }); const body = await response.text(); await writeFile(bodyPath, body, { mode: 0o600 }); await writeFile(errorPath, "", { mode: 0o600 }); const json = asRecord(parseJson(body)); return { id, url, ok: response.ok && json !== null, httpStatus: response.status, durationMs: Date.now() - startedAt, serviceId: stringValue(json?.serviceId) ?? stringValue(asRecord(json?.service)?.id), environment: stringValue(json?.environment), applicationStatus: stringValue(json?.status), ready: json?.ready ?? null, revision: revisionFromHealth(json), image: imageFromHealth(json), blockerCodes: Array.isArray(json?.blockerCodes) ? json.blockerCodes.slice(0, 20) : [], dump: { body: bodyPath, error: errorPath, bodyBytes: Buffer.byteLength(body, "utf8"), bodyTail: boundedTail(body), }, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); await writeFile(bodyPath, "", { mode: 0o600 }); await writeFile(errorPath, message, { mode: 0o600 }); return { id, url, ok: false, httpStatus: null, durationMs: Date.now() - startedAt, error: message, dump: { body: bodyPath, error: errorPath, bodyBytes: 0, bodyTail: "", }, }; } finally { clearTimeout(timeout); } } async function liveRevisionStatus(options: HwlabCdOptions, dumpDir: string): Promise> { const [frontend, api] = await Promise.all([ fetchLive(options.frontendLiveUrl, dumpDir, "live-16666", Math.min(options.timeoutMs, 10_000)), fetchLive(options.apiLiveUrl, dumpDir, "live-16667", Math.min(options.timeoutMs, 10_000)), ]); return { status: frontend.ok === true && api.ok === true ? "observed" : "blocked", endpoints: [frontend, api], }; } function collectBlockers(parts: { git?: Record; desired?: Record; guard?: Record; lock?: Record; live?: Record; dryRun?: Record; }): Record[] { const blockers: Record[] = []; if (parts.git !== undefined) { if (parts.git.clean === false) blockers.push({ scope: "hwlab-git-clean", summary: "HWLAB repo has local modifications." }); if (parts.git.onMain === false) blockers.push({ scope: "hwlab-git-main", summary: "HWLAB repo is not on main." }); if (parts.git.headMatchesOriginMain === false) blockers.push({ scope: "hwlab-git-origin-main", summary: "HWLAB HEAD does not match local origin/main." }); } if (parts.desired !== undefined && parts.desired.status !== "pass") { blockers.push({ scope: "desired-state", summary: `HWLAB desired-state status is ${String(parts.desired.status)}` }); } if (parts.guard !== undefined && parts.guard.status !== "pass") { blockers.push({ scope: "d601-native-k3s-guard", summary: String(parts.guard.summary ?? "D601 native k3s guard is not pass."), refusal: parts.guard.refusal === true }); } if (parts.lock !== undefined && parts.lock.held === true) { blockers.push({ scope: "cd-lock", summary: `HWLAB DEV CD lock is held by ${String(parts.lock.ownerTaskId ?? parts.lock.holderIdentity ?? "unknown")}.` }); } if (parts.live !== undefined && parts.live.status !== "observed") { blockers.push({ scope: "live-revision", summary: "16666/16667 live revision summary is not fully observable." }); } if (parts.dryRun !== undefined && parts.dryRun.commandOk === false) { blockers.push({ scope: "apply-dry-run-command", summary: "HWLAB controlled dry-run command failed." }); } if (parts.dryRun !== undefined && parts.dryRun.status === "blocked") { blockers.push({ scope: "apply-dry-run-blockers", summary: "HWLAB controlled dry-run reported open blockers." }); } return blockers; } async function status(options: HwlabCdOptions): Promise> { const dumpDir = rootPath(".state", "hwlab-cd", makeRunId()); mkdirSync(dumpDir, { recursive: true, mode: 0o700 }); const repo = resolveHwlabRepo(options.repoPath); const selected = asRecord(repo.selected); if (selected === null) { return { ok: false, status: "blocked", environment: options.environment, dumpDir, repo, error: "hwlab-repo-not-found", }; } const repoPath = String(selected.path); const [git, desired, guard, controlled, live] = await Promise.all([ gitSummary(repoPath, dumpDir, Math.min(options.timeoutMs, 15_000)), desiredStateStatus(repoPath, dumpDir, options.timeoutMs), nativeK3sGuard(options.kubeconfig, dumpDir, Math.min(options.timeoutMs, 15_000)), controlledObservability(repoPath, dumpDir, Math.min(options.timeoutMs, 15_000)), liveRevisionStatus(options, dumpDir), ]); const lock = await cdLockStatus(options.kubeconfig, guard, dumpDir, Math.min(options.timeoutMs, 15_000)); const blockers = collectBlockers({ git, desired, guard, lock, live }); return { ok: guard.refusal !== true, status: blockers.length === 0 ? "ready" : "blocked", environment: options.environment, dryRun: false, mutation: false, dumpDir, hwlabRepo: { path: repoPath, source: selected.source, controlledEntrypoints: { devCdApply: join(repoPath, "scripts/dev-cd-apply.mjs"), devDeployApply: join(repoPath, "scripts/dev-deploy-apply.mjs"), }, }, git, desiredState: desired, d601NativeK3sGuard: guard, controlledObservability: controlled, cdLock: lock, liveRevisions: live, blockers, nextCommands: { dryRunApply: "bun scripts/cli.ts hwlab cd apply --env dev --dry-run", fullDump: `find ${JSON.stringify(dumpDir)} -type f -maxdepth 1 -print`, }, }; } function summarizeApplyDryRun(parsed: unknown, command: CapturedCommand): Record { const report = asRecord(parsed); const apply = asRecord(report?.devDeployApply); const conclusion = asRecord(apply?.conclusion); const boundary = asRecord(apply?.applyBoundary); const artifactPlan = asRecord(apply?.artifactPlan); const applyStep = asRecord(apply?.applyStep); return { status: stringValue(report?.status) ?? (command.ok ? "unknown" : "blocked"), commandOk: command.ok, reportVersion: stringValue(report?.reportVersion), commitId: stringValue(report?.commitId), namespace: stringValue(report?.namespace) ?? namespace, endpoint: stringValue(report?.endpoint) ?? "http://74.48.78.17:16667", blockers: Array.isArray(report?.blockers) ? report.blockers.slice(0, 30) : [], blockerCount: Array.isArray(report?.blockers) ? report.blockers.length : null, conclusion, artifactPlan: artifactPlan === null ? null : { expectedArtifactCommit: artifactPlan.expectedArtifactCommit, deployCommitId: artifactPlan.deployCommitId, catalogCommitId: artifactPlan.catalogCommitId, published: artifactPlan.published, registryVerified: artifactPlan.registryVerified, imageCount: artifactPlan.imageCount, requiredServiceCount: artifactPlan.requiredServiceCount, unpublishedServices: artifactPlan.unpublishedServices, }, applyBoundary: boundary === null ? null : { currentMode: boundary.currentMode, defaultNoWrite: boundary.defaultNoWrite, mutationAttempted: boundary.mutationAttempted, mutationAllowed: boundary.mutationAllowed, kubeconfigSource: boundary.kubeconfigSource, writeScope: boundary.writeScope, noWriteScope: boundary.noWriteScope, forbiddenActions: boundary.forbiddenActions, }, applyStep: applyStep === null ? null : { status: applyStep.status, command: applyStep.command, mutationAttempted: applyStep.mutationAttempted, expectedImmutableTemplateJobDryRun: applyStep.expectedImmutableTemplateJobDryRun, }, manualCommands: asRecord(apply?.manualCommands), command: commandView(command), parsed: report !== null, }; } function realApplyRefusal(options: HwlabCdOptions, dumpDir: string, repoPath: string): Record { const controlledCommand = [ "node", "scripts/dev-cd-apply.mjs", "--apply", "--confirm-dev", "--confirmed-non-production", "--write-report", "--kubeconfig", options.kubeconfig, ]; return { ok: false, status: "refused", environment: options.environment, dryRun: false, mutation: false, dumpDir, hwlabRepo: { path: repoPath }, error: "host-commander-only-real-apply", summary: "UniDesk HWLAB CD wrapper exposes the real DEV apply shape but does not execute it from this runner path. Use dry-run here; host commander must run live apply only after explicit DEV CD authorization.", hostCommanderOnly: true, requiredDefaultCommand: "bun scripts/cli.ts hwlab cd apply --env dev --dry-run", controlledEntrypoint: "scripts/dev-cd-apply.mjs", controlledCommandShape: controlledCommand, safety: { prodTouched: false, kubectlApplyExecuted: false, rolloutExecuted: false, secretValuesRead: false, }, }; } async function apply(options: HwlabCdOptions): Promise> { const dumpDir = rootPath(".state", "hwlab-cd", makeRunId()); mkdirSync(dumpDir, { recursive: true, mode: 0o700 }); const repo = resolveHwlabRepo(options.repoPath); const selected = asRecord(repo.selected); if (selected === null) { return { ok: false, status: "blocked", environment: options.environment, dryRun: options.dryRun, mutation: false, dumpDir, repo, error: "hwlab-repo-not-found", }; } const repoPath = String(selected.path); if (!options.dryRun) return realApplyRefusal(options, dumpDir, repoPath); const guard = await nativeK3sGuard(options.kubeconfig, dumpDir, Math.min(options.timeoutMs, 15_000)); if (guard.refusal === true) { return { ok: false, status: "refused", environment: options.environment, dryRun: true, mutation: false, dumpDir, hwlabRepo: { path: repoPath, source: selected.source }, d601NativeK3sGuard: guard, error: "native-k3s-guard-refused", summary: "Refusing HWLAB DEV CD dry-run because kubectl resolved to a forbidden Docker Desktop control plane signal.", }; } const command = await runCaptured([ "node", "scripts/dev-deploy-apply.mjs", "--dry-run", "--expect-blocked", "--kubeconfig", options.kubeconfig, ], repoPath, dumpDir, "controlled-dev-deploy-apply-dry-run", { env: { ...process.env, KUBECONFIG: options.kubeconfig }, timeoutMs: options.timeoutMs, }); const dryRun = summarizeApplyDryRun(parseJson(command.stdoutText), command); const blockers = collectBlockers({ guard, dryRun }); return { ok: command.ok, status: blockers.length === 0 ? "prepared" : "blocked", environment: options.environment, dryRun: true, mutation: false, dumpDir, hwlabRepo: { path: repoPath, source: selected.source, controlledEntrypoint: join(repoPath, "scripts/dev-deploy-apply.mjs"), liveApplyEntrypointShape: join(repoPath, "scripts/dev-cd-apply.mjs"), }, d601NativeK3sGuard: guard, controlledDryRun: dryRun, blockers, hostCommanderOnlyLiveApply: { supportedByShapeOnly: true, notExecuted: true, commandShape: [ "node", "scripts/dev-cd-apply.mjs", "--apply", "--confirm-dev", "--confirmed-non-production", "--write-report", "--kubeconfig", options.kubeconfig, ], }, }; } export function hwlabHelp(): Record { return { command: "hwlab cd", output: "json", usage: [ "bun scripts/cli.ts hwlab cd status --env dev", "bun scripts/cli.ts hwlab cd apply --env dev --dry-run", ], description: "Inspect and prepare HWLAB DEV CD from UniDesk without embedding release kubectl logic. The wrapper calls HWLAB repo-owned scripts and writes full stdout/stderr dumps under .state/hwlab-cd/.", boundary: [ `KUBECONFIG is forced to ${nativeKubeconfig}`, "docker-desktop, desktop-control-plane, and 127.0.0.1:11700 are refusal signals", "status is read-only and bounded; live apply is host-commander-only and not executed by the dry-run path", "dry-run apply calls HWLAB scripts/dev-deploy-apply.mjs; live apply shape points at scripts/dev-cd-apply.mjs", ], }; } export async function runHwlabCdCommand(args: string[]): Promise> { if (args.length === 0 || args.some(isHelpArg)) return { ok: true, ...hwlabHelp() }; const options = parseOptions(args); if (options.action === "status") return status(options); return apply(options); }