677 lines
31 KiB
JavaScript
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;
|
|
});
|