const { spawn } = require("node:child_process"); const crypto = require("node:crypto"); const fs = require("node:fs"); const fsp = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path"); const namespace = "hwlab-dev"; const lockName = "hwlab-dev-cd-lock"; const nativeKubeconfig = "/etc/rancher/k3s/k3s.yaml"; const defaultRepo = "/home/ubuntu/hwlab_cd"; const rejectedRepo = "/home/ubuntu/hwlab"; const requiredNodeName = "d601"; const tailChars = 1400; const parseCaptureLimitBytes = 1024 * 1024; const requiredSecretRefs = [ { secretName: "hwlab-cloud-api-dev-db", secretKey: "database-url", consumers: ["hwlab-cloud-api"] }, { secretName: "hwlab-cloud-api-dev-db-admin", secretKey: "admin-url", consumers: ["runtime provisioning", "runtime migration"] }, { secretName: "hwlab-code-agent-provider", secretKey: "openai-api-key", consumers: ["hwlab-cloud-api", "code agent provider"] }, ]; function decodeOptions() { const raw = process.env.UNIDESK_HWLAB_CD_OPTIONS_B64 || ""; if (!raw) throw new Error("UNIDESK_HWLAB_CD_OPTIONS_B64 is required"); return JSON.parse(Buffer.from(raw, "base64").toString("utf8")); } function redact(value) { return String(value || "") .replace(/\b(Bearer\s+)[A-Za-z0-9._~+/=-]+/giu, "$1") .replace(/\b(token|password|api[_-]?key|secret|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) { const text = redact(value); return text.slice(Math.max(0, text.length - tailChars)); } function readFileTail(filePath) { try { return boundedTail(fs.readFileSync(filePath, "utf8")); } catch { return ""; } } function commandView(result) { 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, }, }; } async function runCaptured(command, cwd, dumpDir, id, options = {}) { await fsp.mkdir(dumpDir, { recursive: true, mode: 0o700 }); const stdoutPath = path.join(dumpDir, `${id}.stdout.txt`); const stderrPath = path.join(dumpDir, `${id}.stderr.txt`); fs.writeFileSync(stdoutPath, "", { mode: 0o600 }); fs.writeFileSync(stderrPath, "", { mode: 0o600 }); const startedAt = Date.now(); let timedOut = false; let spawnErrorMessage = ""; let stdoutBytes = 0; let stderrBytes = 0; const stdoutChunks = []; const stderrChunks = []; const stdout = fs.createWriteStream(stdoutPath, { flags: "a" }); const stderr = fs.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 ? setTimeout(() => { timedOut = true; child.kill("SIGTERM"); setTimeout(() => child.kill("SIGKILL"), 1000).unref(); }, options.timeoutMs) : null; child.stdout.on("data", (chunk) => { stdoutBytes += chunk.byteLength; stdout.write(chunk); if (Buffer.concat(stdoutChunks).byteLength < parseCaptureLimitBytes) stdoutChunks.push(Buffer.from(chunk)); }); child.stderr.on("data", (chunk) => { stderrBytes += chunk.byteLength; stderr.write(chunk); if (Buffer.concat(stderrChunks).byteLength < parseCaptureLimitBytes) stderrChunks.push(Buffer.from(chunk)); }); const closed = await new Promise((resolve) => { child.on("error", (error) => { spawnErrorMessage = error.message; }); child.on("close", (exitCode, signal) => resolve({ exitCode, signal })); }); if (timeout !== null) clearTimeout(timeout); await new Promise((resolve) => stdout.end(resolve)); await new Promise((resolve) => stderr.end(resolve)); const stdoutText = stdoutBytes <= parseCaptureLimitBytes ? Buffer.concat(stdoutChunks).toString("utf8") : ""; const stderrText = stderrBytes <= parseCaptureLimitBytes ? Buffer.concat(stderrChunks).toString("utf8") : spawnErrorMessage; const exitCode = spawnErrorMessage && 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 parseJson(text) { if (!String(text || "").trim()) return null; try { return JSON.parse(text); } catch { return null; } } function asRecord(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : null; } function stringValue(value) { return typeof value === "string" && value.length > 0 ? value : null; } function sha256(text) { return `sha256:${crypto.createHash("sha256").update(text).digest("hex")}`; } function accessCheck(targetPath, mode) { try { fs.accessSync(targetPath, mode); return { ok: true, path: targetPath }; } catch (error) { return { ok: false, path: targetPath, error: error instanceof Error ? error.message : String(error) }; } } function resolveRepo(provided) { const rawPath = provided || process.env.UNIDESK_HWLAB_REPO || defaultRepo; const absolutePath = path.resolve(rawPath); const rejected = absolutePath === rejectedRepo; const devCdApply = path.join(absolutePath, "scripts/dev-cd-apply.mjs"); const deployJson = path.join(absolutePath, "deploy/deploy.json"); return { status: !rejected && fs.existsSync(absolutePath) && fs.existsSync(devCdApply) && fs.existsSync(deployJson) ? "selected" : "blocked", source: provided ? "option" : process.env.UNIDESK_HWLAB_REPO ? "env:UNIDESK_HWLAB_REPO" : "default:d601-clean-mirror", path: absolutePath, defaultPath: defaultRepo, rejected, rejectionReason: rejected ? "runner-history-directory-is-not-hwlab-cd-release-truth" : null, exists: fs.existsSync(absolutePath), hasDevCdApply: fs.existsSync(devCdApply), hasDeployJson: fs.existsSync(deployJson), }; } async function gitSummary(repoPath, dumpDir, timeoutMs) { const [branch, head, originMain, remote, gitDir, gitCommonDir, 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", "remote", "get-url", "origin"], repoPath, dumpDir, "git-remote-origin", { timeoutMs }), runCaptured(["git", "rev-parse", "--path-format=absolute", "--git-dir"], repoPath, dumpDir, "git-dir", { timeoutMs }), runCaptured(["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], repoPath, dumpDir, "git-common-dir", { 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 remoteUrl = remote.stdoutText.trim(); const gitDirPath = gitDir.stdoutText.trim(); const gitCommonDirPath = gitCommonDir.stdoutText.trim() || gitDirPath; const fetchHeadPath = gitCommonDirPath ? path.join(gitCommonDirPath, "FETCH_HEAD") : path.join(repoPath, ".git", "FETCH_HEAD"); const worktreesPath = gitCommonDirPath ? path.join(gitCommonDirPath, "worktrees") : path.join(repoPath, ".git", "worktrees"); const porcelain = statusPorcelain.stdoutText.trim(); const dirtyLines = porcelain ? porcelain.split("\n").filter(Boolean) : []; const worktreeAccess = accessCheck(repoPath, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK); const fetchHeadAccess = accessCheck(fetchHeadPath, fs.constants.R_OK | fs.constants.W_OK); const worktreesAccess = fs.existsSync(worktreesPath) ? accessCheck(worktreesPath, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK) : { ok: true, path: worktreesPath, exists: false }; const remoteMatches = /github\.com[:/]pikasTech\/HWLAB(?:\.git)?$/u.test(remoteUrl); const blockers = []; if (!branch.ok || !head.ok || !remote.ok || !gitDir.ok || !gitCommonDir.ok || !statusShort.ok || !statusPorcelain.ok) blockers.push({ scope: "hwlab-git-command", summary: "One or more HWLAB git guard commands failed." }); if (dirtyLines.length > 0) blockers.push({ scope: "hwlab-git-clean", summary: "HWLAB CD worktree has local modifications; deploy/deploy.json cannot be treated as release truth." }); if (branchName !== "main") blockers.push({ scope: "hwlab-git-main", summary: `HWLAB CD worktree branch is ${branchName || ""}, expected main.` }); if (!remoteMatches) blockers.push({ scope: "hwlab-git-remote", summary: "HWLAB CD worktree origin is not pikasTech/HWLAB." }); if (originMain.ok && headCommit && originMainCommit && headCommit !== originMainCommit) blockers.push({ scope: "hwlab-git-origin-main", summary: "HWLAB CD worktree HEAD does not match local origin/main." }); if (!worktreeAccess.ok) blockers.push({ scope: "hwlab-worktree-permission", summary: "HWLAB CD worktree is not readable/writable/executable by the wrapper." }); if (!fetchHeadAccess.ok) blockers.push({ scope: "hwlab-fetch-head-permission", summary: "HWLAB CD FETCH_HEAD is missing or not writable by the wrapper." }); if (!worktreesAccess.ok) blockers.push({ scope: "hwlab-worktrees-permission", summary: "HWLAB .git/worktrees permission guard failed." }); return { status: blockers.length === 0 ? "pass" : "blocked", clean: dirtyLines.length === 0, branch: branchName || null, onMain: branchName === "main", remote: remoteUrl || null, remoteMatches, expectedRemote: "git@github.com:pikasTech/HWLAB.git or https://github.com/pikasTech/HWLAB.git", headCommit: headCommit || null, originMainCommit: originMain.ok ? originMainCommit || null : null, headMatchesOriginMain: originMain.ok && headCommit.length > 0 && headCommit === originMainCommit, worktreeAccess, fetchHead: { path: fetchHeadPath, exists: fs.existsSync(fetchHeadPath), writable: fetchHeadAccess.ok, error: fetchHeadAccess.error || null }, worktrees: worktreesAccess, statusShort: statusShort.stdoutText.trim().split("\n").filter(Boolean).slice(0, 30), dirtyCount: dirtyLines.length, dirtyPreview: dirtyLines.slice(0, 20), blockers, commands: [branch, head, originMain, remote, gitDir, gitCommonDir, statusShort, statusPorcelain].map(commandView), }; } function readJsonSummary(repoPath, relativePath, fallback = {}) { const absolutePath = path.join(repoPath, relativePath); try { const raw = fs.readFileSync(absolutePath, "utf8"); const parsed = JSON.parse(raw); return { path: relativePath, exists: true, hash: sha256(raw), commitId: parsed.commitId || parsed.sourceCommitId || null, environment: parsed.environment || null, namespace: parsed.namespace || namespace, endpoint: parsed.endpoint || null, artifactState: parsed.artifactState || parsed.publish?.artifactState || null, ciPublished: parsed.publish?.ciPublished ?? parsed.artifactPublish?.ciPublished ?? null, registryVerified: parsed.publish?.registryVerified ?? parsed.artifactPublish?.registryVerified ?? null, serviceCount: Array.isArray(parsed.services) ? parsed.services.length : parsed.artifactPublish?.serviceCount ?? null, ...fallback, }; } catch (error) { return { path: relativePath, exists: false, hash: null, error: error instanceof Error ? error.message : String(error), ...fallback, }; } } function forbiddenKubeSignals(text) { const signals = []; if (/docker-desktop/iu.test(text)) signals.push("docker-desktop"); if (/desktop-control-plane/iu.test(text)) signals.push("desktop-control-plane"); if (/127\.0\.0\.1:11700/u.test(text)) signals.push("127.0.0.1:11700"); return [...new Set(signals)]; } async function observeKube(kubeconfig, dumpDir, timeoutMs, withExplicitEnv) { const env = withExplicitEnv ? { ...process.env, KUBECONFIG: kubeconfig } : { ...process.env }; if (!withExplicitEnv) { delete env.KUBECONFIG; delete env.HWLAB_DEV_KUBECONFIG; } const [context, server, nodes, namespaceCheck] = await Promise.all([ runCaptured(["kubectl", "config", "current-context"], process.cwd(), dumpDir, withExplicitEnv ? "k3s-current-context" : "default-current-context", { env, timeoutMs }), runCaptured(["kubectl", "config", "view", "--minify", "-o", "jsonpath={.clusters[0].cluster.server}"], process.cwd(), dumpDir, withExplicitEnv ? "k3s-server" : "default-server", { env, timeoutMs }), runCaptured(["kubectl", "get", "nodes", "-o", "jsonpath={range .items[*]}{.metadata.name}{\"\\n\"}{end}"], process.cwd(), dumpDir, withExplicitEnv ? "k3s-nodes" : "default-nodes", { env, timeoutMs }), runCaptured(["kubectl", "get", "namespace", namespace, "-o", "name"], process.cwd(), dumpDir, withExplicitEnv ? "k3s-namespace" : "default-namespace", { env, timeoutMs }), ]); const combinedText = [context.stdoutText, context.stderrText, server.stdoutText, server.stderrText, nodes.stdoutText, nodes.stderrText, namespaceCheck.stdoutText, namespaceCheck.stderrText].join("\n"); return { checked: true, explicit: withExplicitEnv, currentContext: context.stdoutText.trim() || null, apiServer: server.stdoutText.trim() || null, nodeNames: nodes.stdoutText.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean), namespaceObserved: namespaceCheck.ok, commandsOk: context.ok && server.ok && nodes.ok, refusalSignals: forbiddenKubeSignals(combinedText), commands: [context, server, nodes, namespaceCheck].map(commandView), }; } async function nodeGuard(kubeconfig, dumpDir, timeoutMs) { const [explicit, bare] = await Promise.all([ observeKube(kubeconfig, dumpDir, timeoutMs, true), observeKube(kubeconfig, dumpDir, timeoutMs, false), ]); const wrongKubeconfig = kubeconfig !== nativeKubeconfig; const requiredNodePresent = explicit.nodeNames.includes(requiredNodeName); const explicitRefusal = explicit.refusalSignals.length > 0; const secondControlPlaneRisk = Boolean( bare.namespaceObserved && explicit.apiServer && bare.apiServer && bare.apiServer !== explicit.apiServer ); const status = explicitRefusal ? "refused" : wrongKubeconfig || !explicit.commandsOk || !requiredNodePresent ? "blocked" : "pass"; return { status, refusal: explicitRefusal, refusalSignals: explicit.refusalSignals, kubeconfig, expectedKubeconfig: nativeKubeconfig, currentContext: explicit.currentContext, apiServer: explicit.apiServer, nodeNames: explicit.nodeNames, nodeCount: explicit.nodeNames.length, requiredNodeName, requiredNodePresent, commandsOk: explicit.commandsOk, secondControlPlaneRisk, defaultKubectlDiagnostic: { checked: true, currentContext: bare.currentContext, apiServer: bare.apiServer, nodeNames: bare.nodeNames, namespaceObserved: bare.namespaceObserved, refusalSignals: bare.refusalSignals, status: bare.refusalSignals.length > 0 ? "stale-forbidden-default" : bare.commandsOk ? "clean" : "unavailable", secondControlPlaneRisk, summary: secondControlPlaneRisk ? "Bare kubectl can observe hwlab-dev through a different control plane; write paths must stop." : bare.refusalSignals.length > 0 ? "Bare kubectl shows a forbidden local control-plane signal; explicit D601 KUBECONFIG remains the target." : "Bare kubectl did not prove a second HWLAB DEV control plane.", }, summary: explicitRefusal ? "Refusing because explicit D601 kubeconfig resolved to a forbidden Docker Desktop control-plane signal." : wrongKubeconfig ? `Expected KUBECONFIG=${nativeKubeconfig}.` : !explicit.commandsOk ? "Explicit D601 kubeconfig could not read context, server, and nodes." : !requiredNodePresent ? "Explicit D601 kubeconfig did not report node d601." : "D601 native k3s guard passed with explicit KUBECONFIG.", commands: [...explicit.commands, ...bare.commands], }; } async function lockStatus(kubeconfig, guard, dumpDir, timeoutMs) { if (guard.status !== "pass") { return { status: "skipped", reason: "d601-native-k3s-guard-not-pass", namespace, lockName, present: null }; } const env = { ...process.env, KUBECONFIG: kubeconfig }; const result = await runCaptured(["kubectl", "-n", namespace, "get", "lease", lockName, "-o", "json"], process.cwd(), dumpDir, "cd-lock", { env, timeoutMs }); const text = `${result.stdoutText}\n${result.stderrText}`; if (!result.ok && /not\s*found|notfound/iu.test(text)) { return { status: "absent", namespace, lockName, present: false, held: false, stale: false, command: commandView(result) }; } const parsed = asRecord(parseJson(result.stdoutText)); const metadata = asRecord(parsed?.metadata) || {}; const spec = asRecord(parsed?.spec) || {}; const annotations = asRecord(metadata.annotations) || {}; const annotation = (name) => stringValue(annotations[`hwlab.pikastech.local/${name}`]); const lock = parsed === null ? null : { lockBackend: "Lease", lockName: stringValue(metadata.name) || lockName, namespace: stringValue(metadata.namespace) || namespace, holderIdentity: stringValue(spec.holderIdentity), resourceVersion: stringValue(metadata.resourceVersion), ownerTaskId: annotation("ownerTaskId"), transactionId: annotation("transactionId"), phase: annotation("phase") || "unknown", promotionCommit: annotation("promotionCommit"), deployJsonHash: annotation("deployJsonHash"), targetRef: annotation("targetRef"), targetNamespace: annotation("targetNamespace"), updatedAt: annotation("updatedAt") || stringValue(spec.renewTime), ttlSeconds: Number(annotation("ttlSeconds") || spec.leaseDurationSeconds || 3600), releaseStatus: annotation("releaseStatus"), releasedAt: annotation("releasedAt"), }; const updatedAt = stringValue(lock?.updatedAt) || ""; const ttlSeconds = Number(lock?.ttlSeconds || 3600); const expiresAtMs = Number.isFinite(Date.parse(updatedAt)) ? Date.parse(updatedAt) + ttlSeconds * 1000 : 0; const retryAfterSeconds = Math.max(0, Math.ceil((expiresAtMs - Date.now()) / 1000)); return { status: result.ok && lock !== null ? "observed" : "blocked", present: result.ok && lock !== null, ...(lock || {}), held: lock !== null && lock.phase !== "released" && retryAfterSeconds > 0, stale: lock !== null && lock.phase !== "released" && retryAfterSeconds <= 0, retryAfterSeconds, expiresAt: expiresAtMs > 0 ? new Date(expiresAtMs).toISOString() : null, command: commandView(result), }; } function parseSecretDescribeKeys(stdout) { const keys = new Set(); let inData = false; for (const rawLine of String(stdout || "").split(/\r?\n/u)) { const line = rawLine.trim(); if (/^Data\b/iu.test(line)) { inData = true; continue; } if (!inData || line === "" || /^=+$/u.test(line)) continue; const match = line.match(/^([A-Za-z0-9_.-]+):\s+\d+\s+bytes\b/iu); if (match) keys.add(match[1]); } return [...keys].sort(); } async function secretPreflight(kubeconfig, guard, dumpDir, timeoutMs) { if (guard.status !== "pass") { return { status: "skipped", reason: "d601-native-k3s-guard-not-pass", namespace, mutation: false, safety: { secretValuesRead: false, secretValuesPrinted: false, secretKeyNamesOnly: true }, secretRefs: requiredSecretRefs.map((ref) => ({ ...ref, status: "not_checked" })), blockers: [], }; } const env = { ...process.env, KUBECONFIG: kubeconfig }; const observations = []; for (const ref of requiredSecretRefs) { const idBase = `secretref-${ref.secretName}-${ref.secretKey}`.replace(/[^A-Za-z0-9_.-]/gu, "-"); const exists = await runCaptured(["kubectl", "-n", namespace, "get", "secret", ref.secretName, "-o", "name"], process.cwd(), dumpDir, `${idBase}-exists`, { env, timeoutMs }); if (!exists.ok) { observations.push({ ...ref, status: "missing-secret", exists: false, keyPresent: false, command: commandView(exists) }); continue; } const describe = await runCaptured(["kubectl", "-n", namespace, "describe", "secret", ref.secretName], process.cwd(), dumpDir, `${idBase}-describe`, { env, timeoutMs }); const keysObserved = parseSecretDescribeKeys(describe.stdoutText); const keyPresent = keysObserved.includes(ref.secretKey); observations.push({ ...ref, status: keyPresent ? "present" : describe.ok ? "missing-key" : "key-observation-blocked", exists: true, keyPresent, keysObserved, command: commandView(describe), }); } const blockers = observations .filter((observation) => observation.status !== "present") .map((observation) => ({ scope: `secretref:${observation.secretName}/${observation.secretKey}`, summary: observation.status === "missing-secret" ? `Required Secret ${observation.secretName} is missing in ${namespace}.` : `Required SecretRef ${observation.secretName}/${observation.secretKey} is not present as key metadata in ${namespace}.`, impact: `${observation.consumers.join(", ")} would fail after a DEV CD apply or runtime preflight Job.`, runbook: `Create or repair Secret ${observation.secretName} with key ${observation.secretKey} in namespace ${namespace}; verify key presence only and do not print the Secret value.`, })); return { status: blockers.length === 0 ? "pass" : "blocked", namespace, mutation: false, safety: { secretValuesRead: false, secretValuesPrinted: false, secretKeyNamesOnly: true }, requiredSecretRefs: requiredSecretRefs.map((ref) => `${ref.secretName}/${ref.secretKey}`), secretRefs: observations, blockers, }; } function summarizeControlled(parsed, command) { const record = asRecord(parsed); const target = asRecord(record?.target); const desiredStateCheck = asRecord(target?.desiredStateCheck); const artifactBoundary = asRecord(target?.artifactBoundary); return { status: stringValue(record?.status) || (command.ok ? "unknown" : "blocked"), commandOk: command.ok, mode: stringValue(record?.mode), mutationAttempted: record?.mutationAttempted ?? false, prodTouched: record?.prodTouched ?? false, controlledEntrypoint: "scripts/dev-cd-apply.mjs", target: target === null ? null : { ref: stringValue(target.ref), promotionCommit: stringValue(target.promotionCommit), shortCommitId: stringValue(target.shortCommitId), promotionSource: stringValue(target.promotionSource), publishRequired: target.publishRequired ?? null, headCommitId: stringValue(target.headCommitId), headMatchesTarget: target.headMatchesTarget ?? null, desiredStateCheck: desiredStateCheck === null ? null : { status: stringValue(desiredStateCheck.status), summary: desiredStateCheck.summary ?? null, diagnostics: Array.isArray(desiredStateCheck.diagnostics) ? desiredStateCheck.diagnostics.slice(0, 5) : [], }, artifactBoundary: artifactBoundary === null ? null : { status: stringValue(artifactBoundary.status), desiredState: artifactBoundary.desiredState ?? null, }, namespace: stringValue(target.namespace) || namespace, }, deployJson: asRecord(record?.deployJson), artifactCatalog: asRecord(record?.artifactCatalog), artifactReport: asRecord(record?.artifactReport), lock: asRecord(record?.lock), liveDelta: asRecord(record?.liveDelta), blockers: Array.isArray(record?.blockers) ? record.blockers.slice(0, 12) : [], nextActions: Array.isArray(record?.nextActions) ? record.nextActions.slice(0, 12) : [], parsed: record !== null, command: commandView(command), }; } async function runDevCdApply(repoPath, kubeconfig, action, dumpDir, timeoutMs) { const modeArg = action === "status" ? "--status" : "--dry-run"; const command = ["node", "scripts/dev-cd-apply.mjs", modeArg, "--kubeconfig", kubeconfig, "--skip-live-verify"]; const result = await runCaptured(command, repoPath, dumpDir, `controlled-dev-cd-apply-${action === "status" ? "status" : "dry-run"}`, { env: { ...process.env, KUBECONFIG: kubeconfig }, timeoutMs, }); return summarizeControlled(parseJson(result.stdoutText), result); } function collectBlockers({ repo, git, guard, lock, secretRefs, controlled, action }) { const blockers = []; if (repo.status !== "selected") { blockers.push({ scope: "hwlab-repo", summary: repo.rejected ? "Rejected runner history HWLAB directory." : "No eligible HWLAB CD repo with scripts/dev-cd-apply.mjs and deploy/deploy.json was found." }); } if (git?.blockers) blockers.push(...git.blockers); if (guard) { if (guard.refusal) blockers.push({ scope: "d601-native-k3s-guard", summary: guard.summary, refusal: true }); else if (guard.status !== "pass") blockers.push({ scope: "d601-native-k3s-guard", summary: guard.summary }); if ((action === "preflight" || action === "apply") && guard.secondControlPlaneRisk) { blockers.push({ scope: "second-hwlab-dev-control-plane", summary: "Bare kubectl can observe hwlab-dev through a different control plane; refusing write-path planning." }); } } if ((action === "preflight" || action === "apply") && lock?.held === true) { blockers.push({ scope: "cd-lock", summary: `HWLAB DEV CD lock is held by ${lock.ownerTaskId || lock.holderIdentity || "unknown"}.` }); } if (secretRefs?.blockers) blockers.push(...secretRefs.blockers); if (controlled) { if (controlled.commandOk === false) blockers.push({ scope: "controlled-dev-cd", summary: "HWLAB scripts/dev-cd-apply.mjs did not complete successfully." }); if (controlled.status === "blocked") blockers.push({ scope: "controlled-dev-cd-status", summary: "HWLAB scripts/dev-cd-apply.mjs reported blocked status." }); } return blockers; } function nextSafeCommand(action, blockers) { if (blockers.length > 0) return "resolve the structured blockers, then rerun bun scripts/cli.ts hwlab cd status --env dev"; if (action === "status") return "bun scripts/cli.ts hwlab cd apply --env dev --dry-run"; return "host commander or the unique CD runner may decide whether to run node scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --write-report on D601; this wrapper did not apply or rollout"; } async function main() { const options = decodeOptions(); const timeoutMs = Math.min(Number(options.timeoutMs || 45000), 60000); const runId = options.runId || `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`; const dumpDir = path.join(os.homedir(), ".state", "unidesk-hwlab-cd", runId); await fsp.mkdir(dumpDir, { recursive: true, mode: 0o700 }); const repo = resolveRepo(options.repoPath || null); const action = options.action; const base = { ok: false, env: "dev", environment: "dev", action, dryRun: action === "apply" ? Boolean(options.dryRun) : action !== "status", mutation: false, remoteHost: "D601", workspace: repo.status === "selected" ? { path: repo.path, source: repo.source } : null, kubeconfig: { source: "explicit", path: options.kubeconfig || nativeKubeconfig, required: nativeKubeconfig }, dumpPath: dumpDir, safety: { wrapperCallsOnlyRepoOwnedDevCdApply: true, deployJsonAuthoritative: "deploy/deploy.json", kubectlApplyExecuted: false, rolloutExecuted: false, liveVerifyExecuted: false, secretValuesRead: false, secretValuesPrinted: false, cdLockMutated: false, prodTouched: false, }, }; if (repo.status !== "selected") { const blockers = collectBlockers({ repo, action }); console.log(JSON.stringify({ ...base, status: "blocked", error: "hwlab-repo-not-found", repo, blockers, nextSafeCommand: nextSafeCommand(action, blockers) }, null, 2)); process.exitCode = 1; return; } const repoPath = repo.path; process.chdir(repoPath); const [git, guard] = await Promise.all([ gitSummary(repoPath, dumpDir, Math.min(timeoutMs, 15000)), nodeGuard(options.kubeconfig || nativeKubeconfig, dumpDir, Math.min(timeoutMs, 15000)), ]); const desiredState = { deployJson: readJsonSummary(repoPath, "deploy/deploy.json"), artifactCatalog: readJsonSummary(repoPath, "deploy/artifact-catalog.dev.json"), artifactReport: readJsonSummary(repoPath, "reports/dev-gate/dev-artifacts.json"), }; const lock = await lockStatus(options.kubeconfig || nativeKubeconfig, guard, dumpDir, Math.min(timeoutMs, 15000)); const needsSecretPreflight = action === "preflight" || action === "apply"; const secretRefs = needsSecretPreflight ? await secretPreflight(options.kubeconfig || nativeKubeconfig, guard, dumpDir, Math.min(timeoutMs, 15000)) : { status: "skipped", reason: "status-does-not-run-secret-preflight", namespace, mutation: false, blockers: [] }; const preCommandBlockers = collectBlockers({ repo, git, guard, lock, secretRefs, action }); const canRunControlled = preCommandBlockers.length === 0 || action === "status"; const controlled = canRunControlled && git.status === "pass" && guard.status === "pass" ? await runDevCdApply(repoPath, options.kubeconfig || nativeKubeconfig, action === "status" ? "status" : "apply", dumpDir, Math.max(1000, timeoutMs - 5000)) : { status: "skipped", commandOk: true, reason: "preflight-blockers-before-controlled-dev-cd-apply" }; const blockers = collectBlockers({ repo, git, guard, lock, secretRefs, controlled, action }); const ok = blockers.length === 0; const target = controlled.target || { ref: "deploy/deploy.json", promotionCommit: desiredState.deployJson.commitId || git.headCommit, shortCommitId: desiredState.deployJson.commitId || null, promotionSource: "deploy/deploy.json", namespace, }; console.log(JSON.stringify({ ...base, ok, status: ok ? action === "status" ? "ready" : "prepared" : guard.refusal ? "refused" : "blocked", repo, workspace: { path: repoPath, source: repo.source }, worktreeGuard: git, nodeGuard: guard, secretPreflight: secretRefs, lockState: lock, desiredState, target, promotion: { source: "deploy/deploy.json", deployJson: desiredState.deployJson, artifactCatalog: desiredState.artifactCatalog, artifactReport: desiredState.artifactReport, }, controlledDevCd: controlled, reportDumpPath: controlled.command?.dump?.stdout || dumpDir, blockers, nextSafeCommand: nextSafeCommand(action, blockers), }, null, 2)); process.exitCode = ok ? 0 : 1; } main().catch((error) => { const message = error instanceof Error ? error.message : String(error); console.log(JSON.stringify({ ok: false, env: "dev", status: "blocked", error: "remote-wrapper-exception", message: redact(message) }, null, 2)); process.exitCode = 1; });