Files
pikasTech-unidesk/scripts/src/hwlab-cd-remote-runner.cjs
T
2026-05-23 20:48:48 +00:00

677 lines
31 KiB
JavaScript

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<redacted>")
.replace(/\b(token|password|api[_-]?key|secret|client-key-data|client-certificate-data|certificate-authority-data)\b(\s*[:=]\s*)(["']?)([^"'\s,}]+)/giu, "$1$2$3<redacted>")
.replace(/\b(postgres(?:ql)?:\/\/[^:\s/@]+:)([^@\s]+)(@)/giu, "$1<redacted>$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 || "<unknown>"}, 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;
});