1878 lines
92 KiB
JavaScript
1878 lines
92 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 legacyDefaultRepo = "/home/ubuntu/hwlab_cd";
|
|
const rejectedRepo = "/home/ubuntu/hwlab";
|
|
const defaultRemoteCandidates = ["git@github.com:pikasTech/HWLAB.git"];
|
|
const dedicatedCacheRoot = path.join(os.homedir(), ".cache", "unidesk", "hwlab-cd");
|
|
const dedicatedCacheRepo = path.join(os.homedir(), ".cache", "unidesk", "hwlab-cd", "git-cache", "HWLAB.git");
|
|
const defaultEgressProxy = "http://127.0.0.1:18789";
|
|
const defaultNoProxy = "localhost,127.0.0.1,::1,host.docker.internal,d601-provider-egress-proxy,d601-provider-egress-proxy.unidesk,d601-provider-egress-proxy.unidesk.svc,d601-provider-egress-proxy.unidesk.svc.cluster.local";
|
|
const seedCacheRepos = ["/home/ubuntu/hwlab", "/home/ubuntu/workspace/hwlab", "/tmp/hwlab-dev-cd-35bbbee-host"];
|
|
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 repoRecord(rawPath, source, extra = {}) {
|
|
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,
|
|
path: absolutePath,
|
|
defaultPath: legacyDefaultRepo,
|
|
defaultMode: "ephemeral-clone",
|
|
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),
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function remoteCandidates() {
|
|
const fromEnv = process.env.UNIDESK_HWLAB_REMOTE_URL;
|
|
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return [fromEnv.trim()];
|
|
return defaultRemoteCandidates;
|
|
}
|
|
|
|
function seedRepoCandidates() {
|
|
const fromEnv = process.env.UNIDESK_HWLAB_CACHE_SEED_REPOS;
|
|
const configured = typeof fromEnv === "string" && fromEnv.trim().length > 0
|
|
? fromEnv.split(":").map((item) => item.trim()).filter(Boolean)
|
|
: seedCacheRepos;
|
|
return [...new Set(configured.map((item) => path.resolve(item)))];
|
|
}
|
|
|
|
function egressEnv() {
|
|
const proxy = process.env.UNIDESK_HWLAB_EGRESS_PROXY || defaultEgressProxy;
|
|
const noProxy = process.env.NO_PROXY || process.env.no_proxy || defaultNoProxy;
|
|
const proxyHostPort = proxy.replace(/^https?:\/\//u, "");
|
|
const gitSshCommand = process.env.GIT_SSH_COMMAND || [
|
|
"ssh",
|
|
"-o", "BatchMode=yes",
|
|
"-o", "StrictHostKeyChecking=accept-new",
|
|
"-o", `"ProxyCommand=nc -X connect -x ${proxyHostPort} %h %p"`,
|
|
].join(" ");
|
|
return {
|
|
...process.env,
|
|
HTTP_PROXY: proxy,
|
|
HTTPS_PROXY: proxy,
|
|
ALL_PROXY: proxy,
|
|
http_proxy: proxy,
|
|
https_proxy: proxy,
|
|
all_proxy: proxy,
|
|
NO_PROXY: noProxy,
|
|
no_proxy: noProxy,
|
|
GIT_SSH_COMMAND: gitSshCommand,
|
|
};
|
|
}
|
|
|
|
async function egressProxyHealth(dumpDir, timeoutMs) {
|
|
const proxy = process.env.UNIDESK_HWLAB_EGRESS_PROXY || defaultEgressProxy;
|
|
const healthUrl = `${proxy.replace(/\/$/u, "")}/__unidesk/egress-proxy/health`;
|
|
const command = await runCaptured(["curl", "-fsS", "--max-time", "3", healthUrl], os.homedir(), dumpDir, "egress-proxy-health", { env: egressEnv(), timeoutMs: Math.min(timeoutMs, 5000) });
|
|
const body = parseJson(command.stdoutText);
|
|
return {
|
|
status: command.ok && asRecord(body)?.connected === true ? "pass" : "blocked",
|
|
proxy,
|
|
healthUrl,
|
|
connected: asRecord(body)?.connected ?? null,
|
|
command: commandView(command),
|
|
};
|
|
}
|
|
|
|
function pathOwner(targetPath) {
|
|
try {
|
|
const stat = fs.statSync(targetPath);
|
|
return { uid: stat.uid, gid: stat.gid, mode: (stat.mode & 0o777).toString(8), isDirectory: stat.isDirectory() };
|
|
} catch (error) {
|
|
return { uid: null, gid: null, mode: null, isDirectory: false, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|
|
|
|
function cacheRepoRecord(cachePath) {
|
|
const absolutePath = path.resolve(cachePath);
|
|
const gitPath = path.join(absolutePath, ".git");
|
|
const bareHead = path.join(absolutePath, "HEAD");
|
|
const bareObjects = path.join(absolutePath, "objects");
|
|
const shallowPath = path.join(absolutePath, "shallow");
|
|
const exists = fs.existsSync(gitPath) || (fs.existsSync(bareHead) && fs.existsSync(bareObjects));
|
|
const readable = accessCheck(absolutePath, fs.constants.R_OK | fs.constants.X_OK);
|
|
const writable = accessCheck(absolutePath, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);
|
|
const owner = pathOwner(absolutePath);
|
|
return { path: absolutePath, exists, readable: readable.ok, writable: writable.ok, owner, shallow: fs.existsSync(shallowPath), error: readable.error || writable.error || null };
|
|
}
|
|
|
|
async function deployJsonCommitFromCache(dumpDir, timeoutMs) {
|
|
const command = await runCaptured(["git", "--git-dir", dedicatedCacheRepo, "show", "refs/heads/main:deploy/deploy.json"], os.homedir(), dumpDir, "git-dedicated-cache-deploy-json", { timeoutMs: Math.min(timeoutMs, 5000) });
|
|
if (!command.ok) return { commitId: null, command: commandView(command) };
|
|
const parsed = parseJson(command.stdoutText);
|
|
return { commitId: stringValue(asRecord(parsed)?.commitId), command: commandView(command) };
|
|
}
|
|
|
|
async function cacheCommitExists(commitId, dumpDir, timeoutMs, idPrefix = "git-dedicated-cache-commit") {
|
|
if (!commitId) return { exists: false, command: null };
|
|
const command = await runCaptured(["git", "--git-dir", dedicatedCacheRepo, "rev-parse", "--verify", `${commitId}^{commit}`], os.homedir(), dumpDir, idPrefix, { timeoutMs: Math.min(timeoutMs, 5000) });
|
|
return { exists: command.ok, resolvedCommit: command.stdoutText.trim() || null, command: commandView(command) };
|
|
}
|
|
|
|
async function refreshDedicatedCache(remoteUrl, dumpDir, timeoutMs) {
|
|
const env = egressEnv();
|
|
const parent = path.dirname(dedicatedCacheRepo);
|
|
await fsp.mkdir(parent, { recursive: true, mode: 0o700 });
|
|
await fsp.chmod(dedicatedCacheRoot, 0o700).catch(() => undefined);
|
|
await fsp.chmod(path.dirname(dedicatedCacheRoot), 0o700).catch(() => undefined);
|
|
const attempts = [];
|
|
const existing = cacheRepoRecord(dedicatedCacheRepo);
|
|
if (existing.exists && (!existing.writable || existing.owner.uid !== process.getuid?.())) {
|
|
attempts.push({ stage: "dedicated-cache-owner", ok: false, cache: existing });
|
|
return { ok: false, cache: existing, attempts, stage: "dedicated-cache-owner" };
|
|
}
|
|
if (existing.exists) {
|
|
const permissions = await runCaptured(["chmod", "-R", "go-rwx", dedicatedCacheRoot], os.homedir(), dumpDir, "git-dedicated-cache-permissions", { timeoutMs: Math.min(timeoutMs, 5000) });
|
|
attempts.push({ stage: "cache-permissions", ok: permissions.ok, command: commandView(permissions) });
|
|
if (!permissions.ok) return { ok: false, cache: cacheRepoRecord(dedicatedCacheRepo), attempts, stage: "cache-permissions" };
|
|
}
|
|
|
|
if (!existing.exists) {
|
|
for (const seed of seedRepoCandidates().map(cacheRepoRecord)) {
|
|
if (!seed.exists || !seed.readable) {
|
|
attempts.push({ stage: "seed-unavailable", ok: false, seed });
|
|
continue;
|
|
}
|
|
await fsp.rm(dedicatedCacheRepo, { recursive: true, force: true });
|
|
const cloneSeed = await runCaptured(["git", "clone", "--bare", seed.path, dedicatedCacheRepo], parent, dumpDir, "git-dedicated-cache-seed-clone", { timeoutMs: Math.min(timeoutMs, 15000) });
|
|
attempts.push({ stage: "seed-clone", ok: cloneSeed.ok, seed, command: commandView(cloneSeed) });
|
|
if (cloneSeed.ok) break;
|
|
}
|
|
if (!fs.existsSync(dedicatedCacheRepo)) {
|
|
const init = await runCaptured(["git", "init", "--bare", dedicatedCacheRepo], parent, dumpDir, "git-dedicated-cache-init", { timeoutMs: Math.min(timeoutMs, 5000) });
|
|
attempts.push({ stage: "bare-init", ok: init.ok, command: commandView(init) });
|
|
if (!init.ok) return { ok: false, cache: cacheRepoRecord(dedicatedCacheRepo), attempts, stage: "bare-init" };
|
|
}
|
|
const permissions = await runCaptured(["chmod", "-R", "go-rwx", dedicatedCacheRoot], os.homedir(), dumpDir, "git-dedicated-cache-permissions", { timeoutMs: Math.min(timeoutMs, 5000) });
|
|
attempts.push({ stage: "cache-permissions", ok: permissions.ok, command: commandView(permissions) });
|
|
if (!permissions.ok) return { ok: false, cache: cacheRepoRecord(dedicatedCacheRepo), attempts, stage: "cache-permissions" };
|
|
}
|
|
|
|
const remoteSet = await runCaptured(["git", "--git-dir", dedicatedCacheRepo, "remote", "set-url", "origin", remoteUrl], os.homedir(), dumpDir, "git-dedicated-cache-remote-set-url", { timeoutMs: Math.min(timeoutMs, 5000) });
|
|
const remoteAdd = remoteSet.ok ? null : await runCaptured(["git", "--git-dir", dedicatedCacheRepo, "remote", "add", "origin", remoteUrl], os.homedir(), dumpDir, "git-dedicated-cache-remote-add", { timeoutMs: Math.min(timeoutMs, 5000) });
|
|
attempts.push({ stage: "remote-set-url", ok: remoteSet.ok || remoteAdd?.ok === true, command: commandView(remoteSet), fallback: remoteAdd ? commandView(remoteAdd) : null });
|
|
if (!remoteSet.ok && remoteAdd?.ok !== true) return { ok: false, cache: cacheRepoRecord(dedicatedCacheRepo), attempts, stage: "remote-set-url" };
|
|
|
|
const remoteHead = await runCaptured(["git", "--git-dir", dedicatedCacheRepo, "ls-remote", "--heads", "origin", "main"], os.homedir(), dumpDir, "git-dedicated-cache-ls-remote-main", { env, timeoutMs: Math.min(timeoutMs, 15000) });
|
|
const remoteMainCommit = remoteHead.stdoutText.trim().split(/\s+/u)[0] || null;
|
|
attempts.push({ stage: "ls-remote-main", ok: remoteHead.ok && Boolean(remoteMainCommit), remoteMainCommit, command: commandView(remoteHead) });
|
|
const localHead = await runCaptured(["git", "--git-dir", dedicatedCacheRepo, "rev-parse", "refs/heads/main"], os.homedir(), dumpDir, "git-dedicated-cache-local-main", { timeoutMs: Math.min(timeoutMs, 5000) });
|
|
const localMainCommit = localHead.stdoutText.trim() || null;
|
|
attempts.push({ stage: "local-main", ok: localHead.ok && Boolean(localMainCommit), localMainCommit, command: commandView(localHead) });
|
|
const deployJson = localHead.ok ? await deployJsonCommitFromCache(dumpDir, timeoutMs) : { commitId: null, command: null };
|
|
attempts.push({ stage: "deploy-json-commit", ok: Boolean(deployJson.commitId), commitId: deployJson.commitId, command: deployJson.command });
|
|
const deployCommit = await cacheCommitExists(deployJson.commitId, dumpDir, timeoutMs, "git-dedicated-cache-deploy-commit");
|
|
attempts.push({ stage: "deploy-commit-present", ok: deployCommit.exists, commitId: deployJson.commitId, resolvedCommit: deployCommit.resolvedCommit, command: deployCommit.command });
|
|
const cacheRecordBeforeFetch = cacheRepoRecord(dedicatedCacheRepo);
|
|
if (remoteHead.ok && remoteMainCommit && localHead.ok && localMainCommit === remoteMainCommit && deployCommit.exists && cacheRecordBeforeFetch.shallow !== true) {
|
|
return { ok: true, cache: cacheRecordBeforeFetch, attempts, stage: "ready", refreshStatus: "current-full", remoteMainCommit, localMainCommit, deployCommitId: deployJson.commitId };
|
|
}
|
|
|
|
const fetchArgs = ["git", "--git-dir", dedicatedCacheRepo, "fetch", "--prune", "--tags", "origin", "+refs/heads/*:refs/heads/*"];
|
|
if (cacheRecordBeforeFetch.shallow === true) fetchArgs.splice(4, 0, "--unshallow");
|
|
const fetch = await runCaptured(fetchArgs, os.homedir(), dumpDir, "git-dedicated-cache-fetch-full", { env, timeoutMs: Math.min(timeoutMs, 55000) });
|
|
attempts.push({ stage: "fetch-main", ok: fetch.ok, command: commandView(fetch) });
|
|
if (!fetch.ok) {
|
|
if (localHead.ok && localMainCommit && deployCommit.exists) {
|
|
return { ok: true, cache: cacheRepoRecord(dedicatedCacheRepo), attempts, stage: "stale-cache", refreshStatus: "blocked", remoteMainCommit, localMainCommit, deployCommitId: deployJson.commitId };
|
|
}
|
|
return { ok: false, cache: cacheRepoRecord(dedicatedCacheRepo), attempts, stage: "fetch-full", refreshStatus: "blocked", remoteMainCommit, localMainCommit, deployCommitId: deployJson.commitId };
|
|
}
|
|
const fetchedLocalHead = await runCaptured(["git", "--git-dir", dedicatedCacheRepo, "rev-parse", "refs/heads/main"], os.homedir(), dumpDir, "git-dedicated-cache-local-main-after-fetch", { timeoutMs: Math.min(timeoutMs, 5000) });
|
|
const fetchedLocalMainCommit = fetchedLocalHead.stdoutText.trim() || localMainCommit;
|
|
attempts.push({ stage: "local-main-after-fetch", ok: fetchedLocalHead.ok && Boolean(fetchedLocalMainCommit), localMainCommit: fetchedLocalMainCommit, command: commandView(fetchedLocalHead) });
|
|
const fetchedDeployJson = await deployJsonCommitFromCache(dumpDir, timeoutMs);
|
|
attempts.push({ stage: "deploy-json-commit-after-fetch", ok: Boolean(fetchedDeployJson.commitId), commitId: fetchedDeployJson.commitId, command: fetchedDeployJson.command });
|
|
const fetchedDeployCommit = await cacheCommitExists(fetchedDeployJson.commitId, dumpDir, timeoutMs, "git-dedicated-cache-deploy-commit-after-fetch");
|
|
attempts.push({ stage: "deploy-commit-present-after-fetch", ok: fetchedDeployCommit.exists, commitId: fetchedDeployJson.commitId, resolvedCommit: fetchedDeployCommit.resolvedCommit, command: fetchedDeployCommit.command });
|
|
const permissions = await runCaptured(["chmod", "-R", "go-rwx", dedicatedCacheRoot], os.homedir(), dumpDir, "git-dedicated-cache-permissions-after-fetch", { timeoutMs: Math.min(timeoutMs, 5000) });
|
|
attempts.push({ stage: "cache-permissions-after-fetch", ok: permissions.ok, command: commandView(permissions) });
|
|
if (!permissions.ok) return { ok: false, cache: cacheRepoRecord(dedicatedCacheRepo), attempts, stage: "cache-permissions-after-fetch", refreshStatus: "blocked", remoteMainCommit, localMainCommit: fetchedLocalMainCommit, deployCommitId: fetchedDeployJson.commitId };
|
|
if (!fetchedDeployCommit.exists) return { ok: false, cache: cacheRepoRecord(dedicatedCacheRepo), attempts, stage: "deploy-commit-unresolved", refreshStatus: "blocked", remoteMainCommit, localMainCommit: fetchedLocalMainCommit, deployCommitId: fetchedDeployJson.commitId };
|
|
return { ok: true, cache: cacheRepoRecord(dedicatedCacheRepo), attempts, stage: "ready", refreshStatus: "refreshed-full", remoteMainCommit, localMainCommit: fetchedLocalMainCommit, deployCommitId: fetchedDeployJson.commitId };
|
|
}
|
|
|
|
async function materializeFromCache(cachePath, repoPath, remoteUrl, dumpDir, timeoutMs) {
|
|
await fsp.rm(repoPath, { recursive: true, force: true });
|
|
const clone = await runCaptured(["git", "clone", "--no-hardlinks", "--no-checkout", cachePath, repoPath], path.dirname(repoPath), dumpDir, "git-cache-clone", { timeoutMs: Math.min(timeoutMs, 15000) });
|
|
if (!clone.ok) return { ok: false, stage: "cache-clone", commands: [commandView(clone)] };
|
|
const remote = await runCaptured(["git", "remote", "set-url", "origin", remoteUrl], repoPath, dumpDir, "git-cache-remote-set-url", { timeoutMs: Math.min(timeoutMs, 5000) });
|
|
if (!remote.ok) return { ok: false, stage: "remote-set-url", commands: [commandView(clone), commandView(remote)] };
|
|
const checkout = await runCaptured(["git", "checkout", "-B", "main", "origin/main"], repoPath, dumpDir, "git-cache-checkout-main", { timeoutMs: Math.min(timeoutMs, 5000) });
|
|
const reset = checkout.ok
|
|
? await runCaptured(["git", "reset", "--hard", "origin/main"], repoPath, dumpDir, "git-cache-reset-hard", { timeoutMs: Math.min(timeoutMs, 5000) })
|
|
: null;
|
|
const clean = checkout.ok && reset?.ok
|
|
? await runCaptured(["git", "clean", "-ffdqx"], repoPath, dumpDir, "git-cache-clean", { timeoutMs: Math.min(timeoutMs, 5000) })
|
|
: null;
|
|
const upstream = checkout.ok && reset?.ok
|
|
? await runCaptured(["git", "branch", "--set-upstream-to=origin/main", "main"], repoPath, dumpDir, "git-cache-upstream", { timeoutMs: Math.min(timeoutMs, 5000) })
|
|
: null;
|
|
const fetchHead = checkout.ok && reset?.ok && clean?.ok
|
|
? await runCaptured(["bash", "-lc", "p=\"$(git rev-parse --path-format=absolute --git-common-dir)/FETCH_HEAD\"; : > \"$p\"; chmod 600 \"$p\""], repoPath, dumpDir, "git-cache-fetch-head", { timeoutMs: Math.min(timeoutMs, 5000) })
|
|
: null;
|
|
const commands = [clone, remote, checkout, reset, clean, upstream, fetchHead].filter(Boolean).map(commandView);
|
|
return { ok: checkout.ok && reset?.ok === true && clean?.ok === true && fetchHead?.ok === true, stage: "ready", commands };
|
|
}
|
|
|
|
async function cloneEphemeralRepo(dumpDir, timeoutMs) {
|
|
const root = path.join(dumpDir, "ephemeral-repo");
|
|
const attempts = [];
|
|
await fsp.mkdir(root, { recursive: true, mode: 0o700 });
|
|
const egress = await egressProxyHealth(dumpDir, Math.min(timeoutMs, 5000));
|
|
if (egress.status !== "pass") {
|
|
return repoRecord(path.join(root, "HWLAB"), "ephemeral:git-cache", {
|
|
ephemeral: true,
|
|
remoteUrl: null,
|
|
egressProxy: egress,
|
|
dedicatedCache: cacheRepoRecord(dedicatedCacheRepo),
|
|
seedRepos: seedRepoCandidates().map(cacheRepoRecord),
|
|
cloneAttempts: [],
|
|
cloneFailed: true,
|
|
cloneFailureStage: "egress-proxy",
|
|
retainedForDiagnostics: true,
|
|
});
|
|
}
|
|
for (const remoteUrl of remoteCandidates()) {
|
|
const repoPath = path.join(root, "HWLAB");
|
|
const refreshed = await refreshDedicatedCache(remoteUrl, dumpDir, Math.min(timeoutMs, 45000));
|
|
attempts.push({ remoteUrl, dedicatedCache: refreshed.cache, ok: refreshed.ok, stage: refreshed.stage, refreshAttempts: refreshed.attempts });
|
|
if (!refreshed.ok) continue;
|
|
const materialized = await materializeFromCache(dedicatedCacheRepo, repoPath, remoteUrl, dumpDir, Math.min(timeoutMs, 9000));
|
|
attempts.push({ remoteUrl, dedicatedCache: refreshed.cache, ok: materialized.ok, stage: materialized.stage, commands: materialized.commands });
|
|
if (materialized.ok) {
|
|
return repoRecord(repoPath, "ephemeral:dedicated-git-cache", {
|
|
ephemeral: true,
|
|
remoteUrl,
|
|
egressProxy: egress,
|
|
dedicatedCache: refreshed.cache,
|
|
cacheRefreshStatus: refreshed.refreshStatus || "current",
|
|
remoteMainCommit: refreshed.remoteMainCommit || null,
|
|
cacheMainCommit: refreshed.localMainCommit || null,
|
|
deployCommitId: refreshed.deployCommitId || null,
|
|
seedRepos: seedRepoCandidates().map(cacheRepoRecord),
|
|
cloneAttempts: attempts,
|
|
retainedForDiagnostics: true,
|
|
});
|
|
}
|
|
}
|
|
return repoRecord(path.join(root, "HWLAB"), "ephemeral:dedicated-git-cache", {
|
|
ephemeral: true,
|
|
remoteUrl: null,
|
|
egressProxy: egress,
|
|
dedicatedCache: cacheRepoRecord(dedicatedCacheRepo),
|
|
seedRepos: seedRepoCandidates().map(cacheRepoRecord),
|
|
cloneAttempts: attempts,
|
|
cloneFailed: true,
|
|
cloneFailureStage: attempts.findLast((attempt) => attempt.stage)?.stage || "cache-unavailable",
|
|
retainedForDiagnostics: true,
|
|
});
|
|
}
|
|
|
|
async function resolveRepo(provided, dumpDir, timeoutMs) {
|
|
if (provided) return repoRecord(provided, "option", { ephemeral: false });
|
|
if (process.env.UNIDESK_HWLAB_REPO) return repoRecord(process.env.UNIDESK_HWLAB_REPO, "env:UNIDESK_HWLAB_REPO", { ephemeral: false });
|
|
return cloneEphemeralRepo(dumpDir, Math.min(timeoutMs, 55000));
|
|
}
|
|
|
|
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 readJsonFile(repoPath, relativePath) {
|
|
const absolutePath = path.join(repoPath, relativePath);
|
|
try {
|
|
const raw = fs.readFileSync(absolutePath, "utf8");
|
|
return { ok: true, path: relativePath, exists: true, hash: sha256(raw), json: JSON.parse(raw), error: null };
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
path: relativePath,
|
|
exists: fs.existsSync(absolutePath),
|
|
hash: null,
|
|
json: null,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
function commitMatches(actual, expected) {
|
|
if (typeof actual !== "string" || typeof expected !== "string" || actual.length === 0 || expected.length === 0) return false;
|
|
return actual === expected || actual.startsWith(expected) || expected.startsWith(actual);
|
|
}
|
|
|
|
function imageTag(image) {
|
|
const value = String(image || "");
|
|
const slash = value.lastIndexOf("/");
|
|
const colon = value.lastIndexOf(":");
|
|
return colon > slash ? value.slice(colon + 1) : null;
|
|
}
|
|
|
|
function servicesFromManifest(manifest) {
|
|
const services = Array.isArray(manifest?.services)
|
|
? manifest.services
|
|
: manifest?.services && typeof manifest.services === "object"
|
|
? Object.values(manifest.services)
|
|
: [];
|
|
return services
|
|
.filter((service) => service && typeof service === "object")
|
|
.map((service) => ({
|
|
serviceId: stringValue(service.serviceId) || stringValue(service.id) || stringValue(service.name),
|
|
name: stringValue(service.name) || stringValue(service.serviceId) || stringValue(service.id),
|
|
image: stringValue(service.image),
|
|
imageTag: stringValue(service.imageTag) || imageTag(service.image),
|
|
commitId: stringValue(service.commitId) || stringValue(service.imageTag) || imageTag(service.image),
|
|
namespace: stringValue(service.namespace) || namespace,
|
|
healthPath: stringValue(service.healthPath) || "/health/live",
|
|
replicas: Number.isInteger(service.replicas) ? service.replicas : null,
|
|
env: asRecord(service.env) || {},
|
|
digest: stringValue(service.digest),
|
|
publishState: stringValue(service.publishState) || stringValue(service.status),
|
|
artifactRequired: service.artifactRequired !== false,
|
|
}))
|
|
.filter((service) => service.serviceId);
|
|
}
|
|
|
|
function summarizeDeployFile(file) {
|
|
const manifest = asRecord(file.json);
|
|
const endpoints = asRecord(manifest?.publicEndpoints) || {};
|
|
return {
|
|
path: file.path,
|
|
exists: file.exists,
|
|
hash: file.hash,
|
|
commitId: stringValue(manifest?.commitId),
|
|
environment: stringValue(manifest?.environment),
|
|
namespace: stringValue(manifest?.namespace) || namespace,
|
|
endpoint: stringValue(manifest?.endpoint),
|
|
serviceCount: servicesFromManifest(manifest).length,
|
|
publicEndpoints: {
|
|
frontend: stringValue(asRecord(endpoints.frontend)?.url),
|
|
api: stringValue(asRecord(endpoints.api)?.url),
|
|
},
|
|
unavailableReason: file.ok ? null : file.error,
|
|
};
|
|
}
|
|
|
|
function summarizeArtifactFile(file) {
|
|
const manifest = asRecord(file.json);
|
|
const services = servicesFromManifest(manifest);
|
|
const digestCounts = { sha256: 0, notPublished: 0, invalid: 0 };
|
|
for (const service of services) {
|
|
if (/^sha256:[a-f0-9]{64}$/u.test(service.digest || "")) digestCounts.sha256 += 1;
|
|
else if (service.digest === "not_published") digestCounts.notPublished += 1;
|
|
else digestCounts.invalid += 1;
|
|
}
|
|
return {
|
|
path: file.path,
|
|
exists: file.exists,
|
|
hash: file.hash,
|
|
commitId: stringValue(manifest?.commitId),
|
|
artifactState: stringValue(manifest?.artifactState),
|
|
ciPublished: manifest?.publish?.ciPublished ?? null,
|
|
registryVerified: manifest?.publish?.registryVerified ?? null,
|
|
serviceCount: services.length,
|
|
digestCounts,
|
|
unavailableReason: file.ok ? null : file.error,
|
|
};
|
|
}
|
|
|
|
function buildDesiredStateAudit(repoPath, target) {
|
|
const deployFile = readJsonFile(repoPath, "deploy/deploy.json");
|
|
const catalogFile = readJsonFile(repoPath, "deploy/artifact-catalog.dev.json");
|
|
const reportFile = readJsonFile(repoPath, "reports/dev-gate/dev-artifacts.json");
|
|
const deployServices = servicesFromManifest(deployFile.json);
|
|
const catalogServices = servicesFromManifest(catalogFile.json);
|
|
const catalogById = new Map(catalogServices.map((service) => [service.serviceId, service]));
|
|
const mismatches = [];
|
|
const missing = [];
|
|
for (const service of deployServices) {
|
|
if (!service.image) continue;
|
|
const catalog = catalogById.get(service.serviceId);
|
|
if (!catalog && service.artifactRequired) {
|
|
missing.push({ serviceId: service.serviceId, reason: "missing-from-artifact-catalog" });
|
|
continue;
|
|
}
|
|
if (catalog?.image && catalog.image !== service.image) {
|
|
mismatches.push({ serviceId: service.serviceId, deployImage: service.image, catalogImage: catalog.image });
|
|
}
|
|
if (catalog && catalog.artifactRequired && catalog.publishState && catalog.publishState !== "published") {
|
|
missing.push({ serviceId: service.serviceId, reason: `artifact-${catalog.publishState}` });
|
|
}
|
|
}
|
|
const deploySummary = summarizeDeployFile(deployFile);
|
|
const catalogSummary = summarizeArtifactFile(catalogFile);
|
|
const reportSummary = summarizeArtifactFile(reportFile);
|
|
const targetCommit = stringValue(target?.shortCommitId) || stringValue(target?.promotionCommit) || deploySummary.commitId;
|
|
const commitConverged = Boolean(
|
|
targetCommit &&
|
|
commitMatches(deploySummary.commitId, targetCommit) &&
|
|
(!catalogSummary.commitId || commitMatches(catalogSummary.commitId, targetCommit))
|
|
);
|
|
const status = !deployFile.ok || !catalogFile.ok || missing.length > 0
|
|
? "missing"
|
|
: mismatches.length > 0 || !commitConverged
|
|
? "mismatch"
|
|
: "pass";
|
|
return {
|
|
status,
|
|
targetCommit,
|
|
commitConverged,
|
|
deployJson: deploySummary,
|
|
artifactCatalog: catalogSummary,
|
|
artifactReport: reportSummary,
|
|
imageConvergence: {
|
|
status: missing.length === 0 && mismatches.length === 0 ? "pass" : missing.length > 0 ? "missing" : "mismatch",
|
|
serviceCount: deployServices.length,
|
|
catalogServiceCount: catalogServices.length,
|
|
missing: missing.slice(0, 12),
|
|
mismatches: mismatches.slice(0, 12),
|
|
},
|
|
services: deployServices.map((service) => ({
|
|
serviceId: service.serviceId,
|
|
image: service.image,
|
|
imageTag: service.imageTag,
|
|
catalogImage: catalogById.get(service.serviceId)?.image || null,
|
|
catalogDigest: catalogById.get(service.serviceId)?.digest || null,
|
|
replicas: service.replicas,
|
|
})).slice(0, 30),
|
|
serviceEnv: {
|
|
cloudApi: asRecord(deployServices.find((service) => service.serviceId === "hwlab-cloud-api")?.env) || {},
|
|
},
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
async function registryAudit(kubeconfig, guard, dumpDir, timeoutMs) {
|
|
const result = await runCaptured(["curl", "-fsS", "--max-time", "5", "http://127.0.0.1:5000/v2/"], process.cwd(), dumpDir, "registry-v2", { timeoutMs: Math.min(timeoutMs, 8000) });
|
|
const reachable = result.ok || /401|unauthorized/iu.test(`${result.stdoutText}\n${result.stderrText}`);
|
|
let k3sPullAccess = { status: "skipped", reason: "d601-native-k3s-guard-not-pass", pullFailureCount: null, pulledRegistryImageCount: null };
|
|
if (guard.status === "pass") {
|
|
const env = { ...process.env, KUBECONFIG: kubeconfig };
|
|
const pods = await runCaptured(["kubectl", "-n", namespace, "get", "pods", "-o", "json"], process.cwd(), dumpDir, "registry-k3s-pods", { env, timeoutMs: Math.min(timeoutMs, 10000) });
|
|
const parsed = asRecord(parseJson(pods.stdoutText));
|
|
const items = Array.isArray(parsed?.items) ? parsed.items : [];
|
|
let pullFailureCount = 0;
|
|
let pulledRegistryImageCount = 0;
|
|
for (const item of items) {
|
|
const statuses = Array.isArray(item?.status?.containerStatuses) ? item.status.containerStatuses : [];
|
|
for (const status of statuses) {
|
|
const image = String(status?.image || "");
|
|
const imageID = String(status?.imageID || "");
|
|
const waiting = String(status?.state?.waiting?.reason || "");
|
|
if (/ErrImagePull|ImagePullBackOff|InvalidImageName/u.test(waiting)) pullFailureCount += 1;
|
|
if (image.includes("127.0.0.1:5000/") && imageID.includes("sha256:")) pulledRegistryImageCount += 1;
|
|
}
|
|
}
|
|
k3sPullAccess = {
|
|
status: !pods.ok ? "unavailable" : pullFailureCount > 0 ? "blocked" : pulledRegistryImageCount > 0 ? "pass" : "degraded",
|
|
pullFailureCount,
|
|
pulledRegistryImageCount,
|
|
command: commandView(pods),
|
|
};
|
|
}
|
|
return {
|
|
status: reachable && !["blocked", "unavailable"].includes(k3sPullAccess.status) ? "pass" : reachable ? "degraded" : "blocked",
|
|
endpoint: "http://127.0.0.1:5000/v2/",
|
|
processHttpAccess: {
|
|
status: reachable ? "pass" : "blocked",
|
|
reachable,
|
|
exitCode: result.exitCode,
|
|
command: commandView(result),
|
|
},
|
|
k3sPullAccess,
|
|
};
|
|
}
|
|
|
|
function deploymentCondition(conditions, type) {
|
|
return (Array.isArray(conditions) ? conditions : []).find((condition) => condition?.type === type) || null;
|
|
}
|
|
|
|
function envValue(container, name) {
|
|
const item = (Array.isArray(container?.env) ? container.env : []).find((entry) => entry?.name === name);
|
|
return item?.value ?? null;
|
|
}
|
|
|
|
async function workloadAudit(kubeconfig, guard, desired, dumpDir, timeoutMs) {
|
|
if (guard.status !== "pass") {
|
|
return { status: "skipped", reason: "d601-native-k3s-guard-not-pass", namespace, deployments: [], currentImageConvergence: { status: "unavailable", unavailableReason: "d601-native-k3s-guard-not-pass" } };
|
|
}
|
|
const env = { ...process.env, KUBECONFIG: kubeconfig };
|
|
const [deployments, pods, jobs] = await Promise.all([
|
|
runCaptured(["kubectl", "-n", namespace, "get", "deployments", "-o", "json"], process.cwd(), dumpDir, "workload-deployments", { env, timeoutMs: Math.min(timeoutMs, 10000) }),
|
|
runCaptured(["kubectl", "-n", namespace, "get", "pods", "-o", "json"], process.cwd(), dumpDir, "workload-pods", { env, timeoutMs: Math.min(timeoutMs, 10000) }),
|
|
runCaptured(["kubectl", "-n", namespace, "get", "jobs", "-o", "json"], process.cwd(), dumpDir, "runtime-jobs", { env, timeoutMs: Math.min(timeoutMs, 10000) }),
|
|
]);
|
|
const desiredById = new Map((desired.services || []).map((service) => [service.serviceId, service]));
|
|
const deploymentItems = Array.isArray(parseJson(deployments.stdoutText)?.items) ? parseJson(deployments.stdoutText).items : [];
|
|
const summaries = deploymentItems.map((deployment) => {
|
|
const metadata = asRecord(deployment.metadata) || {};
|
|
const spec = asRecord(deployment.spec) || {};
|
|
const status = asRecord(deployment.status) || {};
|
|
const template = asRecord(spec.template) || {};
|
|
const podSpec = asRecord(template.spec) || {};
|
|
const containers = Array.isArray(podSpec.containers) ? podSpec.containers : [];
|
|
const container = containers[0] || {};
|
|
const labels = asRecord(metadata.labels) || {};
|
|
const serviceId = stringValue(labels["hwlab.pikastech.local/service-id"]) || stringValue(labels["app.kubernetes.io/name"]) || stringValue(metadata.name);
|
|
const desiredService = desiredById.get(serviceId);
|
|
const image = stringValue(container.image);
|
|
const desiredImage = desiredService?.image || null;
|
|
const available = Number(status.availableReplicas || 0);
|
|
const desiredReplicas = Number(spec.replicas || 0);
|
|
const updated = Number(status.updatedReplicas || 0);
|
|
const unavailable = Number(status.unavailableReplicas || 0);
|
|
const progressing = deploymentCondition(status.conditions, "Progressing");
|
|
const availableCondition = deploymentCondition(status.conditions, "Available");
|
|
return {
|
|
name: stringValue(metadata.name),
|
|
serviceId,
|
|
image,
|
|
imageTag: imageTag(image),
|
|
desiredImage,
|
|
imageMatchesDesired: desiredImage ? image === desiredImage : null,
|
|
revision: stringValue(asRecord(metadata.annotations)?.["deployment.kubernetes.io/revision"]),
|
|
replicas: desiredReplicas,
|
|
availableReplicas: available,
|
|
updatedReplicas: updated,
|
|
unavailableReplicas: unavailable,
|
|
rolloutStatus: desiredReplicas === 0 || (available >= desiredReplicas && updated >= desiredReplicas && unavailable === 0) ? "healthy" : "unhealthy",
|
|
conditions: {
|
|
available: availableCondition ? { status: availableCondition.status ?? null, reason: availableCondition.reason ?? null } : null,
|
|
progressing: progressing ? { status: progressing.status ?? null, reason: progressing.reason ?? null } : null,
|
|
},
|
|
commitEnv: envValue(container, "HWLAB_COMMIT_ID"),
|
|
};
|
|
});
|
|
const podItems = Array.isArray(parseJson(pods.stdoutText)?.items) ? parseJson(pods.stdoutText).items : [];
|
|
const waiting = [];
|
|
for (const pod of podItems) {
|
|
const podName = stringValue(pod?.metadata?.name);
|
|
for (const status of Array.isArray(pod?.status?.containerStatuses) ? pod.status.containerStatuses : []) {
|
|
const reason = stringValue(status?.state?.waiting?.reason);
|
|
if (reason) waiting.push({ pod: podName, container: status.name ?? null, reason, image: status.image ?? null });
|
|
}
|
|
}
|
|
const jobItems = Array.isArray(parseJson(jobs.stdoutText)?.items) ? parseJson(jobs.stdoutText).items : [];
|
|
const runtimeJobs = jobItems
|
|
.filter((job) => /hwlab-runtime|runtime-db|runtime/i.test(String(job?.metadata?.name || "")))
|
|
.map((job) => ({
|
|
name: stringValue(job?.metadata?.name),
|
|
succeeded: Number(job?.status?.succeeded || 0),
|
|
failed: Number(job?.status?.failed || 0),
|
|
active: Number(job?.status?.active || 0),
|
|
completionTime: stringValue(job?.status?.completionTime),
|
|
status: Number(job?.status?.failed || 0) > 0 ? "blocked" : Number(job?.status?.active || 0) > 0 ? "running" : Number(job?.status?.succeeded || 0) > 0 ? "pass" : "unknown",
|
|
}))
|
|
.slice(0, 12);
|
|
const imageMismatches = summaries.filter((deployment) => deployment.imageMatchesDesired === false);
|
|
const unhealthy = summaries.filter((deployment) => deployment.rolloutStatus === "unhealthy");
|
|
return {
|
|
status: !deployments.ok ? "unavailable" : unhealthy.length > 0 ? "unhealthy" : imageMismatches.length > 0 ? "mismatch" : "healthy",
|
|
namespace,
|
|
deploymentCount: summaries.length,
|
|
deployments: summaries.slice(0, 30),
|
|
currentImageConvergence: {
|
|
status: imageMismatches.length === 0 ? "pass" : "mismatch",
|
|
mismatches: imageMismatches.map((deployment) => ({ serviceId: deployment.serviceId, image: deployment.image, desiredImage: deployment.desiredImage })).slice(0, 12),
|
|
},
|
|
podWaiting: waiting.slice(0, 12),
|
|
runtimeJobs,
|
|
commands: [deployments, pods, jobs].map(commandView),
|
|
};
|
|
}
|
|
|
|
function compactHealth(body, expectedServiceId, expectedCommit) {
|
|
const json = asRecord(body);
|
|
const commit = stringValue(asRecord(json?.commit)?.id) || stringValue(json?.commitId) || stringValue(json?.revision) || stringValue(asRecord(json?.image)?.tag);
|
|
const runtime = asRecord(json?.runtime);
|
|
const db = asRecord(json?.db);
|
|
const blockerCodes = Array.isArray(json?.blockerCodes) ? json.blockerCodes.map(String).slice(0, 12) : [];
|
|
return {
|
|
serviceId: stringValue(json?.serviceId) || stringValue(asRecord(json?.service)?.id),
|
|
environment: stringValue(json?.environment),
|
|
status: stringValue(json?.status),
|
|
ready: typeof json?.ready === "boolean" ? json.ready : null,
|
|
observedCommit: commit,
|
|
commitMatches: expectedCommit ? commitMatches(commit, expectedCommit) : null,
|
|
serviceMatches: expectedServiceId ? (stringValue(json?.serviceId) || stringValue(asRecord(json?.service)?.id)) === expectedServiceId : null,
|
|
image: typeof json?.image === "string" ? json.image : stringValue(asRecord(json?.image)?.reference),
|
|
blockerCodes,
|
|
db: db === null ? null : {
|
|
ready: db.ready ?? null,
|
|
connected: db.connected ?? null,
|
|
liveDbEvidence: db.liveDbEvidence ?? null,
|
|
runtimeReadiness: asRecord(db.runtimeReadiness) ? {
|
|
status: stringValue(db.runtimeReadiness.status),
|
|
ready: db.runtimeReadiness.ready ?? null,
|
|
blocker: stringValue(db.runtimeReadiness.blocker),
|
|
} : null,
|
|
},
|
|
runtime: runtime === null ? null : {
|
|
status: stringValue(runtime.status),
|
|
ready: runtime.ready ?? null,
|
|
durable: runtime.durable ?? null,
|
|
durableRequested: runtime.durableRequested ?? null,
|
|
liveRuntimeEvidence: runtime.liveRuntimeEvidence ?? null,
|
|
blocker: stringValue(runtime.blocker),
|
|
adapter: stringValue(runtime.adapter),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function httpHealthAudit(desired, dumpDir, timeoutMs) {
|
|
const endpoints = [
|
|
{ id: "cloud-web-16666", url: desired.deployJson.publicEndpoints.frontend || "http://74.48.78.17:16666", expectedServiceId: "hwlab-cloud-web" },
|
|
{ id: "cloud-api-16667", url: desired.deployJson.publicEndpoints.api || desired.deployJson.endpoint || "http://74.48.78.17:16667", expectedServiceId: "hwlab-cloud-api" },
|
|
];
|
|
const expectedCommit = desired.targetCommit || desired.deployJson.commitId || null;
|
|
const observations = [];
|
|
for (const endpoint of endpoints) {
|
|
const result = await runCaptured(["curl", "-fsS", "--max-time", "8", `${endpoint.url.replace(/\/+$/u, "")}/health/live`], process.cwd(), dumpDir, `public-health-${endpoint.id}`, { timeoutMs: Math.min(timeoutMs, 10000) });
|
|
const parsed = parseJson(result.stdoutText);
|
|
const health = compactHealth(parsed, endpoint.expectedServiceId, expectedCommit);
|
|
const reachable = result.ok && parsed !== null;
|
|
observations.push({
|
|
id: endpoint.id,
|
|
url: endpoint.url,
|
|
status: reachable && health.serviceMatches !== false && health.commitMatches !== false ? "pass" : "blocked",
|
|
reachable,
|
|
expectedServiceId: endpoint.expectedServiceId,
|
|
expectedCommit,
|
|
health,
|
|
command: commandView(result),
|
|
});
|
|
}
|
|
return {
|
|
status: observations.every((item) => item.status === "pass") ? "pass" : "blocked",
|
|
expectedCommit,
|
|
summary: {
|
|
checked: observations.length,
|
|
reachable: observations.filter((item) => item.reachable).length,
|
|
commitMatches: observations.filter((item) => item.health.commitMatches === true).length,
|
|
readinessPass: observations.filter((item) => item.health.ready !== false && !["blocked", "failed"].includes(String(item.health.status || ""))).length,
|
|
ports: [16666, 16667],
|
|
},
|
|
endpoints: observations,
|
|
};
|
|
}
|
|
|
|
function runtimeDurabilityAudit(desired, publicHealth, secretRefs, workload) {
|
|
const api = publicHealth?.endpoints?.find((endpoint) => endpoint.id === "cloud-api-16667")?.health || null;
|
|
const cloudApiEnv = asRecord(desired.serviceEnv?.cloudApi) || {};
|
|
const configuredDurable = cloudApiEnv.HWLAB_CLOUD_RUNTIME_DURABLE === "true";
|
|
const configuredAdapter = stringValue(cloudApiEnv.HWLAB_CLOUD_RUNTIME_ADAPTER);
|
|
const dbSecret = (secretRefs?.secretRefs || []).find((ref) => ref.secretName === "hwlab-cloud-api-dev-db" && ref.secretKey === "database-url");
|
|
const adminSecret = (secretRefs?.secretRefs || []).find((ref) => ref.secretName === "hwlab-cloud-api-dev-db-admin" && ref.secretKey === "admin-url");
|
|
const runtime = asRecord(api?.runtime);
|
|
const dbRuntimeReadiness = asRecord(api?.db?.runtimeReadiness);
|
|
const liveReady = runtime?.durable === true && runtime?.ready !== false && runtime?.liveRuntimeEvidence === true && !["blocked", "degraded", "failed"].includes(String(runtime?.status || ""));
|
|
const status = liveReady
|
|
? "pass"
|
|
: api
|
|
? "risk"
|
|
: configuredDurable && configuredAdapter === "postgres" && dbSecret?.keyPresent === true && adminSecret?.keyPresent === true
|
|
? "unavailable"
|
|
: "risk";
|
|
return {
|
|
status,
|
|
configured: {
|
|
adapter: configuredAdapter,
|
|
durable: configuredDurable,
|
|
dbSecretPresent: dbSecret?.keyPresent === true,
|
|
adminSecretPresent: adminSecret?.keyPresent === true,
|
|
},
|
|
live: api ? {
|
|
db: api.db,
|
|
runtime: api.runtime,
|
|
dbRuntimeReadiness,
|
|
} : null,
|
|
workloadEvidence: {
|
|
cloudApiDeployment: (workload?.deployments || []).find((deployment) => deployment.serviceId === "hwlab-cloud-api") || null,
|
|
},
|
|
unavailableReason: api ? null : "public-health-cloud-api-unavailable",
|
|
risk: status === "pass" ? null : "db-runtime-durability-risk",
|
|
};
|
|
}
|
|
|
|
function blocker(scope, summary, details = {}) {
|
|
return { scope, summary, ...details };
|
|
}
|
|
|
|
function classifyAuditBlockers({ repo, git, guard, secretRefs, registry, lock, desired, workload, publicHealth, durability, controlled }) {
|
|
const blockers = [];
|
|
if (guard?.status !== "pass") blockers.push(blocker("control-plane-unavailable", guard?.summary || "D601 native k3s guard did not pass."));
|
|
if (guard?.refusalSignals?.length > 0) blockers.push(blocker("docker-desktop-context-risk", "Explicit D601 kubeconfig resolved to forbidden Docker Desktop control-plane signal.", { refusalSignals: guard.refusalSignals }));
|
|
if (guard?.secondControlPlaneRisk) blockers.push(blocker("second-control-plane-risk", "Bare kubectl can observe hwlab-dev through a different control plane; audit is read-only and will not release or mutate locks."));
|
|
if (repo.status !== "selected") blockers.push(blocker("workspace-unavailable", "No eligible HWLAB CD repo was selected."));
|
|
for (const item of git?.blockers || []) {
|
|
const scope = item.scope === "hwlab-git-clean" ? "dirty-worktree" : item.scope?.startsWith("hwlab-git") || item.scope?.includes("permission") ? "workspace-unavailable" : item.scope;
|
|
blockers.push(blocker(scope, item.summary));
|
|
}
|
|
for (const item of secretRefs?.blockers || []) blockers.push(blocker("secret-missing", item.summary, { secretRef: item.scope }));
|
|
if (registry?.status === "blocked" || registry?.status === "degraded") blockers.push(blocker("registry-unavailable", "Registry reachability or k3s pull evidence is not healthy.", { status: registry.status }));
|
|
if (lock?.held === true) blockers.push(blocker("lease-held", `HWLAB DEV CD Lease is held by ${lock.ownerTaskId || lock.holderIdentity || "unknown"}.`, { phase: lock.phase, retryAfterSeconds: lock.retryAfterSeconds }));
|
|
if (lock?.stale === true) blockers.push(blocker("lease-stale-candidate", "HWLAB DEV CD Lease appears stale; audit is read-only and did not release or break it.", { phase: lock.phase }));
|
|
if (desired?.status === "missing") blockers.push(blocker("artifact-missing", "deploy/deploy.json or artifact catalog/report is missing or incomplete.", { imageConvergence: desired.imageConvergence }));
|
|
if (desired?.status === "mismatch" || workload?.currentImageConvergence?.status === "mismatch") blockers.push(blocker("artifact-mismatch", "deploy/deploy.json, artifact catalog, or current workload images are not converged.", { desired: desired?.status, currentImageConvergence: workload?.currentImageConvergence }));
|
|
if ((workload?.runtimeJobs || []).some((job) => job.status === "blocked" || job.status === "running")) blockers.push(blocker("runtime-job-blocked", "Runtime DB maintenance Job is blocked or still running.", { runtimeJobs: workload.runtimeJobs }));
|
|
if (workload?.status === "unhealthy") blockers.push(blocker("rollout-unhealthy", "One or more hwlab-dev Deployments are not fully available.", { deployments: (workload.deployments || []).filter((item) => item.rolloutStatus === "unhealthy").slice(0, 8) }));
|
|
if (publicHealth?.status === "blocked") blockers.push(blocker("public-tunnel-unhealthy", "16666/16667 public health did not pass read-only commit/readiness checks.", { summary: publicHealth.summary }));
|
|
if (durability?.status === "risk") blockers.push(blocker("db-runtime-durability-risk", "DB/runtime durability is not proven by live cloud-api health.", { live: durability.live, configured: durability.configured }));
|
|
if (controlled?.commandOk === false || controlled?.status === "blocked") blockers.push(blocker("runtime-job-blocked", "HWLAB repo-owned dev-cd-apply status reported blocked or failed.", { status: controlled?.status }));
|
|
return blockers;
|
|
}
|
|
|
|
function auditSummary({ repo, git, guard, secretRefs, registry, lock, desired, workload, publicHealth, durability, controlled, dumpDir }) {
|
|
const blockers = classifyAuditBlockers({ repo, git, guard, secretRefs, registry, lock, desired, workload, publicHealth, durability, controlled });
|
|
const blockerTypes = [...new Set(blockers.map((item) => item.scope))];
|
|
return {
|
|
ok: blockers.length === 0,
|
|
status: blockers.length === 0 ? "pass" : guard?.refusal ? "refused" : "blocked",
|
|
env: "dev",
|
|
namespace,
|
|
nodeGuard: {
|
|
status: guard?.status ?? "unavailable",
|
|
nodeName: guard?.requiredNodeName ?? requiredNodeName,
|
|
nodeNames: guard?.nodeNames ?? [],
|
|
requiredNodePresent: guard?.requiredNodePresent ?? false,
|
|
currentContext: guard?.currentContext ?? null,
|
|
apiServer: guard?.apiServer ?? null,
|
|
kubeconfig: guard?.kubeconfig ?? nativeKubeconfig,
|
|
refusalSignals: guard?.refusalSignals ?? [],
|
|
secondControlPlaneRisk: guard?.secondControlPlaneRisk ?? false,
|
|
defaultKubectlDiagnostic: guard?.defaultKubectlDiagnostic ?? null,
|
|
},
|
|
secrets: {
|
|
status: secretRefs?.status ?? "unavailable",
|
|
valuesRead: false,
|
|
valuesPrinted: false,
|
|
secretRefs: (secretRefs?.secretRefs || []).map((ref) => ({
|
|
secretName: ref.secretName,
|
|
secretKey: ref.secretKey,
|
|
exists: ref.exists === true,
|
|
keyPresent: ref.keyPresent === true,
|
|
status: ref.status,
|
|
})),
|
|
unavailableReason: secretRefs?.reason ?? null,
|
|
},
|
|
registry: {
|
|
status: registry?.status ?? "unavailable",
|
|
endpoint: registry?.endpoint ?? "http://127.0.0.1:5000/v2/",
|
|
processHttpAccess: registry?.processHttpAccess ? { status: registry.processHttpAccess.status, reachable: registry.processHttpAccess.reachable } : null,
|
|
k3sPullAccess: registry?.k3sPullAccess ? {
|
|
status: registry.k3sPullAccess.status,
|
|
pullFailureCount: registry.k3sPullAccess.pullFailureCount,
|
|
pulledRegistryImageCount: registry.k3sPullAccess.pulledRegistryImageCount,
|
|
} : null,
|
|
},
|
|
lease: {
|
|
status: lock?.status ?? "unavailable",
|
|
phase: lock?.phase ?? null,
|
|
holder: lock?.ownerTaskId || lock?.holderIdentity || null,
|
|
ageSeconds: lock?.updatedAt && Number.isFinite(Date.parse(lock.updatedAt)) ? Math.max(0, Math.floor((Date.now() - Date.parse(lock.updatedAt)) / 1000)) : null,
|
|
retryAfterSeconds: lock?.retryAfterSeconds ?? null,
|
|
staleClassification: lock?.stale === true ? "lease-stale-candidate" : lock?.held === true ? "lease-held" : "not-held",
|
|
lockName: lock?.lockName ?? lockName,
|
|
},
|
|
desiredState: {
|
|
status: desired?.status ?? "unavailable",
|
|
targetCommit: desired?.targetCommit ?? null,
|
|
deployJson: desired?.deployJson ?? null,
|
|
artifactCatalog: desired?.artifactCatalog ?? null,
|
|
artifactReport: desired?.artifactReport ?? null,
|
|
imageConvergence: desired?.imageConvergence ?? null,
|
|
},
|
|
workload: {
|
|
status: workload?.status ?? "unavailable",
|
|
currentImageConvergence: workload?.currentImageConvergence ?? null,
|
|
deploymentCount: workload?.deploymentCount ?? 0,
|
|
deployments: (workload?.deployments || []).map((deployment) => ({
|
|
name: deployment.name,
|
|
serviceId: deployment.serviceId,
|
|
image: deployment.image,
|
|
imageTag: deployment.imageTag,
|
|
desiredImage: deployment.desiredImage,
|
|
revision: deployment.revision,
|
|
replicas: deployment.replicas,
|
|
availableReplicas: deployment.availableReplicas,
|
|
rolloutStatus: deployment.rolloutStatus,
|
|
commitEnv: deployment.commitEnv,
|
|
})),
|
|
podWaiting: workload?.podWaiting ?? [],
|
|
runtimeJobs: workload?.runtimeJobs ?? [],
|
|
},
|
|
publicHealth: {
|
|
status: publicHealth?.status ?? "unavailable",
|
|
expectedCommit: publicHealth?.expectedCommit ?? null,
|
|
summary: publicHealth?.summary ?? { checked: 0 },
|
|
endpoints: (publicHealth?.endpoints || []).map((endpoint) => ({
|
|
id: endpoint.id,
|
|
url: endpoint.url,
|
|
status: endpoint.status,
|
|
reachable: endpoint.reachable,
|
|
expectedServiceId: endpoint.expectedServiceId,
|
|
expectedCommit: endpoint.expectedCommit,
|
|
health: endpoint.health,
|
|
})),
|
|
},
|
|
durability: {
|
|
status: durability?.status ?? "unavailable",
|
|
configured: durability?.configured ?? null,
|
|
live: durability?.live ?? null,
|
|
unavailableReason: durability?.unavailableReason ?? null,
|
|
},
|
|
controlledDevCd: {
|
|
status: controlled?.status ?? "unavailable",
|
|
commandOk: controlled?.commandOk ?? null,
|
|
controlledEntrypoint: "scripts/dev-cd-apply.mjs",
|
|
target: controlled?.target ?? null,
|
|
blockers: controlled?.blockers ?? [],
|
|
},
|
|
blockers,
|
|
blockerTypes,
|
|
dumpPath: dumpDir,
|
|
safety: {
|
|
mutation: false,
|
|
kubectlApplyExecuted: false,
|
|
rolloutExecuted: false,
|
|
liveVerifyExecuted: false,
|
|
cdLockMutated: false,
|
|
secretValuesRead: false,
|
|
secretValuesPrinted: false,
|
|
reportsWritten: false,
|
|
repoReportsWritten: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
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 (repo.cacheRefreshStatus === "blocked") {
|
|
blockers.push({
|
|
scope: "hwlab-cache-refresh",
|
|
summary: "Dedicated HWLAB git cache could not refresh from GitHub through the provider egress proxy; using the last local cache state is not a release-truth PASS.",
|
|
remoteMainCommit: repo.remoteMainCommit || null,
|
|
cacheMainCommit: repo.cacheMainCommit || null,
|
|
});
|
|
}
|
|
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 === "audit" || 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 action === "audit" ? "resolve the structured blockers, then rerun bun scripts/cli.ts hwlab cd audit --env dev" : "resolve the structured blockers, then rerun bun scripts/cli.ts hwlab cd status --env dev";
|
|
if (action === "audit") return "audit is read-only; host commander may compare blockerTypes before deciding whether the unique CD runner should continue";
|
|
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";
|
|
}
|
|
|
|
function compactBlockers(blockers, limit = 12) {
|
|
return (Array.isArray(blockers) ? blockers : []).slice(0, limit).map((item) => ({
|
|
scope: item?.scope ?? item?.blockerScope ?? null,
|
|
type: item?.type ?? null,
|
|
status: item?.status ?? null,
|
|
summary: item?.summary ?? item?.reason ?? null,
|
|
}));
|
|
}
|
|
|
|
function compactGit(git) {
|
|
if (!git) return null;
|
|
return {
|
|
status: git.status,
|
|
clean: git.clean,
|
|
branch: git.branch,
|
|
onMain: git.onMain,
|
|
remoteMatches: git.remoteMatches,
|
|
headMatchesOriginMain: git.headMatchesOriginMain,
|
|
dirtyCount: git.dirtyCount,
|
|
blockers: compactBlockers(git.blockers, 8),
|
|
};
|
|
}
|
|
|
|
function compactNodeGuard(guard) {
|
|
if (!guard) return null;
|
|
return {
|
|
status: guard.status,
|
|
refusal: guard.refusal,
|
|
kubeconfig: guard.kubeconfig,
|
|
currentContext: guard.currentContext,
|
|
apiServer: guard.apiServer,
|
|
nodeNames: guard.nodeNames,
|
|
requiredNodePresent: guard.requiredNodePresent,
|
|
refusalSignals: guard.refusalSignals,
|
|
secondControlPlaneRisk: guard.secondControlPlaneRisk,
|
|
defaultKubectlStatus: guard.defaultKubectlDiagnostic?.status ?? null,
|
|
summary: guard.summary,
|
|
};
|
|
}
|
|
|
|
function compactSecretPreflight(secretRefs) {
|
|
if (!secretRefs) return null;
|
|
return {
|
|
status: secretRefs.status,
|
|
valuesRead: secretRefs.safety?.secretValuesRead === true,
|
|
valuesPrinted: secretRefs.safety?.secretValuesPrinted === true,
|
|
secretRefs: (secretRefs.secretRefs || []).map((ref) => ({
|
|
secretName: ref.secretName,
|
|
secretKey: ref.secretKey,
|
|
exists: ref.exists === true,
|
|
keyPresent: ref.keyPresent === true,
|
|
status: ref.status,
|
|
})).slice(0, 8),
|
|
blockers: compactBlockers(secretRefs.blockers, 8),
|
|
};
|
|
}
|
|
|
|
function compactDesiredState(desired) {
|
|
if (!desired) return null;
|
|
return {
|
|
status: desired.status,
|
|
targetCommit: desired.targetCommit,
|
|
deployJson: desired.deployJson ? {
|
|
hash: desired.deployJson.hash,
|
|
commitId: desired.deployJson.commitId,
|
|
environment: desired.deployJson.environment,
|
|
namespace: desired.deployJson.namespace,
|
|
serviceCount: desired.deployJson.serviceCount,
|
|
} : null,
|
|
artifactCatalog: desired.artifactCatalog ? {
|
|
hash: desired.artifactCatalog.hash,
|
|
commitId: desired.artifactCatalog.commitId,
|
|
artifactState: desired.artifactCatalog.artifactState,
|
|
ciPublished: desired.artifactCatalog.ciPublished,
|
|
registryVerified: desired.artifactCatalog.registryVerified,
|
|
digestCounts: desired.artifactCatalog.digestCounts,
|
|
} : null,
|
|
imageConvergence: desired.imageConvergence ? {
|
|
status: desired.imageConvergence.status,
|
|
missing: (desired.imageConvergence.missing || []).slice(0, 5),
|
|
mismatches: (desired.imageConvergence.mismatches || []).slice(0, 5),
|
|
} : null,
|
|
};
|
|
}
|
|
|
|
function compactControlled(controlled) {
|
|
if (!controlled) return null;
|
|
return {
|
|
status: controlled.status,
|
|
commandOk: controlled.commandOk,
|
|
mode: controlled.mode,
|
|
mutationAttempted: controlled.mutationAttempted,
|
|
prodTouched: controlled.prodTouched,
|
|
target: controlled.target ? {
|
|
ref: controlled.target.ref,
|
|
promotionCommit: controlled.target.promotionCommit,
|
|
shortCommitId: controlled.target.shortCommitId,
|
|
promotionSource: controlled.target.promotionSource,
|
|
publishRequired: controlled.target.publishRequired,
|
|
headMatchesTarget: controlled.target.headMatchesTarget,
|
|
desiredStateStatus: controlled.target.desiredStateCheck?.status ?? null,
|
|
artifactBoundaryStatus: controlled.target.artifactBoundary?.status ?? null,
|
|
namespace: controlled.target.namespace,
|
|
} : null,
|
|
blockers: compactBlockers(controlled.blockers, 8),
|
|
};
|
|
}
|
|
|
|
function compactAudit(audit) {
|
|
if (!audit) return null;
|
|
return {
|
|
ok: audit.ok,
|
|
status: audit.status,
|
|
env: audit.env,
|
|
namespace: audit.namespace,
|
|
nodeGuard: audit.nodeGuard,
|
|
secrets: audit.secrets ? {
|
|
status: audit.secrets.status,
|
|
valuesRead: audit.secrets.valuesRead,
|
|
valuesPrinted: audit.secrets.valuesPrinted,
|
|
} : null,
|
|
registry: audit.registry ? {
|
|
status: audit.registry.status,
|
|
processHttpAccess: audit.registry.processHttpAccess,
|
|
k3sPullAccess: audit.registry.k3sPullAccess,
|
|
} : null,
|
|
lease: audit.lease,
|
|
desiredState: audit.desiredState ? {
|
|
status: audit.desiredState.status,
|
|
targetCommit: audit.desiredState.targetCommit,
|
|
imageConvergence: audit.desiredState.imageConvergence,
|
|
} : null,
|
|
workload: audit.workload ? {
|
|
status: audit.workload.status,
|
|
deploymentCount: audit.workload.deploymentCount,
|
|
currentImageConvergence: audit.workload.currentImageConvergence,
|
|
podWaiting: (audit.workload.podWaiting || []).slice(0, 5),
|
|
runtimeJobs: (audit.workload.runtimeJobs || []).slice(0, 5),
|
|
} : null,
|
|
publicHealth: audit.publicHealth ? {
|
|
status: audit.publicHealth.status,
|
|
expectedCommit: audit.publicHealth.expectedCommit,
|
|
summary: audit.publicHealth.summary,
|
|
} : null,
|
|
durability: audit.durability ? {
|
|
status: audit.durability.status,
|
|
unavailableReason: audit.durability.unavailableReason,
|
|
} : null,
|
|
controlledDevCd: compactControlled(audit.controlledDevCd),
|
|
blockerTypes: audit.blockerTypes || [],
|
|
blockers: compactBlockers(audit.blockers, 12),
|
|
safety: audit.safety,
|
|
};
|
|
}
|
|
|
|
function compactForTransport(payload, fullResultPath) {
|
|
return {
|
|
ok: payload.ok,
|
|
env: payload.env,
|
|
environment: payload.environment,
|
|
status: payload.status,
|
|
error: payload.error ?? null,
|
|
action: payload.action ?? null,
|
|
dryRun: payload.dryRun,
|
|
mutation: payload.mutation,
|
|
remoteHost: payload.remoteHost,
|
|
workspace: payload.workspace,
|
|
kubeconfig: payload.kubeconfig,
|
|
dumpPath: payload.dumpPath,
|
|
remoteFullResultPath: fullResultPath,
|
|
repo: payload.repo ? {
|
|
status: payload.repo.status,
|
|
path: payload.repo.path,
|
|
source: payload.repo.source,
|
|
rejected: payload.repo.rejected,
|
|
ephemeral: payload.repo.ephemeral === true,
|
|
cacheRefreshStatus: payload.repo.cacheRefreshStatus || null,
|
|
} : null,
|
|
worktreeGuard: compactGit(payload.worktreeGuard),
|
|
nodeGuard: compactNodeGuard(payload.nodeGuard),
|
|
secretPreflight: compactSecretPreflight(payload.secretPreflight),
|
|
lockState: payload.lockState ? {
|
|
status: payload.lockState.status,
|
|
phase: payload.lockState.phase,
|
|
holder: payload.lockState.ownerTaskId || payload.lockState.holderIdentity || null,
|
|
held: payload.lockState.held,
|
|
stale: payload.lockState.stale,
|
|
retryAfterSeconds: payload.lockState.retryAfterSeconds,
|
|
lockName: payload.lockState.lockName,
|
|
} : null,
|
|
desiredState: payload.desiredState ? {
|
|
deployJson: payload.desiredState.deployJson,
|
|
artifactCatalog: payload.desiredState.artifactCatalog,
|
|
artifactReport: payload.desiredState.artifactReport,
|
|
} : null,
|
|
target: payload.target,
|
|
promotion: payload.promotion ? {
|
|
source: payload.promotion.source,
|
|
deployJson: payload.promotion.deployJson,
|
|
artifactCatalog: payload.promotion.artifactCatalog,
|
|
artifactReport: payload.promotion.artifactReport,
|
|
} : null,
|
|
controlledDevCd: compactControlled(payload.controlledDevCd),
|
|
audit: compactAudit(payload.audit),
|
|
blockers: compactBlockers(payload.blockers, 12),
|
|
blockerTypes: payload.blockerTypes || [...new Set(compactBlockers(payload.blockers, 12).map((item) => item.scope).filter(Boolean))],
|
|
nextSafeCommand: payload.nextSafeCommand,
|
|
safety: payload.safety,
|
|
};
|
|
}
|
|
|
|
function emitResult(payload, dumpDir, compactStdout = false) {
|
|
const fullResultPath = path.join(dumpDir, "result.full.json");
|
|
const compactResultPath = path.join(dumpDir, "result.compact.json");
|
|
fs.writeFileSync(fullResultPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
|
if (!compactStdout) {
|
|
fs.writeFileSync(compactResultPath, `${JSON.stringify(compactForTransport(payload, fullResultPath), null, 2)}\n`, { mode: 0o600 });
|
|
console.log(JSON.stringify(payload, null, 2));
|
|
return;
|
|
}
|
|
let compact = compactForTransport(payload, fullResultPath);
|
|
let text = JSON.stringify(compact, null, 2);
|
|
if (Buffer.byteLength(text, "utf8") > 3600) {
|
|
compact = {
|
|
ok: compact.ok,
|
|
env: compact.env,
|
|
status: compact.status,
|
|
error: compact.error,
|
|
action: compact.action,
|
|
dryRun: compact.dryRun,
|
|
mutation: compact.mutation,
|
|
workspace: compact.workspace,
|
|
dumpPath: compact.dumpPath,
|
|
remoteFullResultPath: compact.remoteFullResultPath,
|
|
repo: compact.repo,
|
|
worktreeGuard: compact.worktreeGuard,
|
|
nodeGuard: compact.nodeGuard,
|
|
lockState: compact.lockState,
|
|
target: compact.target ? {
|
|
ref: compact.target.ref,
|
|
promotionCommit: compact.target.promotionCommit,
|
|
shortCommitId: compact.target.shortCommitId,
|
|
promotionSource: compact.target.promotionSource,
|
|
publishRequired: compact.target.publishRequired,
|
|
} : null,
|
|
controlledDevCd: compact.controlledDevCd,
|
|
audit: compact.audit ? {
|
|
status: compact.audit.status,
|
|
blockerTypes: compact.audit.blockerTypes,
|
|
blockers: compact.audit.blockers,
|
|
publicHealth: compact.audit.publicHealth,
|
|
workload: compact.audit.workload,
|
|
durability: compact.audit.durability,
|
|
} : null,
|
|
blockers: compact.blockers,
|
|
blockerTypes: compact.blockerTypes,
|
|
nextSafeCommand: compact.nextSafeCommand,
|
|
safety: compact.safety,
|
|
outputCompacted: "provider-host-ssh-stdout-limit",
|
|
};
|
|
text = JSON.stringify(compact, null, 2);
|
|
}
|
|
if (Buffer.byteLength(text, "utf8") > 2400) {
|
|
compact = {
|
|
ok: payload.ok,
|
|
env: payload.env,
|
|
status: payload.status,
|
|
error: payload.error ?? null,
|
|
action: payload.action ?? null,
|
|
dryRun: payload.dryRun,
|
|
mutation: payload.mutation,
|
|
workspace: payload.workspace,
|
|
dumpPath: payload.dumpPath,
|
|
remoteFullResultPath: fullResultPath,
|
|
repo: payload.repo ? {
|
|
status: payload.repo.status,
|
|
path: payload.repo.path,
|
|
source: payload.repo.source,
|
|
rejected: payload.repo.rejected,
|
|
ephemeral: payload.repo.ephemeral === true,
|
|
cacheRefreshStatus: payload.repo.cacheRefreshStatus || null,
|
|
} : null,
|
|
worktreeStatus: payload.worktreeGuard?.status ?? null,
|
|
nodeGuardStatus: payload.nodeGuard?.status ?? payload.audit?.nodeGuard?.status ?? null,
|
|
lockState: payload.lockState ? {
|
|
status: payload.lockState.status,
|
|
phase: payload.lockState.phase,
|
|
held: payload.lockState.held,
|
|
holder: payload.lockState.ownerTaskId || payload.lockState.holderIdentity || null,
|
|
stale: payload.lockState.stale,
|
|
} : null,
|
|
target: payload.target ? {
|
|
ref: payload.target.ref,
|
|
promotionCommit: payload.target.promotionCommit,
|
|
shortCommitId: payload.target.shortCommitId,
|
|
promotionSource: payload.target.promotionSource,
|
|
} : payload.audit?.desiredState ? {
|
|
targetCommit: payload.audit.desiredState.targetCommit,
|
|
} : null,
|
|
controlledDevCdStatus: payload.controlledDevCd?.status ?? payload.audit?.controlledDevCd?.status ?? null,
|
|
auditStatus: payload.audit?.status ?? null,
|
|
blockerTypes: payload.blockerTypes || payload.audit?.blockerTypes || [...new Set(compactBlockers(payload.blockers, 8).map((item) => item.scope).filter(Boolean))],
|
|
blockers: compactBlockers(payload.blockers || payload.audit?.blockers, 6),
|
|
nextSafeCommand: payload.nextSafeCommand,
|
|
outputCompacted: "provider-host-ssh-stdout-limit",
|
|
};
|
|
text = JSON.stringify(compact);
|
|
}
|
|
if (Buffer.byteLength(text, "utf8") > 520) {
|
|
const blockerTypes = payload.blockerTypes
|
|
|| payload.audit?.blockerTypes
|
|
|| [...new Set(compactBlockers(payload.blockers || payload.audit?.blockers, 8).map((item) => item.scope).filter(Boolean))];
|
|
compact = {
|
|
ok: payload.ok,
|
|
env: payload.env,
|
|
status: payload.status,
|
|
error: payload.error ?? null,
|
|
action: payload.action ?? null,
|
|
remoteFullResultPath: fullResultPath,
|
|
repoPath: payload.repo?.path ?? payload.workspace?.path ?? null,
|
|
repoEphemeral: payload.repo?.ephemeral === true,
|
|
cacheRefreshStatus: payload.repo?.cacheRefreshStatus ?? null,
|
|
worktreeStatus: payload.worktreeGuard?.status ?? null,
|
|
nodeGuardStatus: payload.nodeGuard?.status ?? payload.audit?.nodeGuard?.status ?? null,
|
|
lockStatus: payload.lockState ? `${payload.lockState.status || "unknown"}/${payload.lockState.phase || "unknown"}/${payload.lockState.held === true ? "held" : "free"}` : null,
|
|
targetCommit: payload.target?.promotionCommit ?? payload.audit?.desiredState?.targetCommit ?? null,
|
|
blockerTypes: Array.isArray(blockerTypes) ? blockerTypes.slice(0, 4) : [],
|
|
outputCompacted: true,
|
|
};
|
|
text = JSON.stringify(compact);
|
|
}
|
|
fs.writeFileSync(compactResultPath, `${text}\n`, { mode: 0o600 });
|
|
console.log(text);
|
|
}
|
|
|
|
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 action = options.action;
|
|
const repo = await resolveRepo(options.repoPath || null, dumpDir, Math.min(timeoutMs, 55000));
|
|
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,
|
|
reportsWritten: false,
|
|
repoReportsWritten: false,
|
|
},
|
|
};
|
|
|
|
if (repo.status !== "selected") {
|
|
const blockers = collectBlockers({ repo, action });
|
|
emitResult({ ...base, status: "blocked", error: "hwlab-repo-not-found", repo, blockers, nextSafeCommand: nextSafeCommand(action, blockers) }, dumpDir, options.compactStdout === true);
|
|
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 === "audit" || 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" || action === "audit";
|
|
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 target = controlled.target || {
|
|
ref: "deploy/deploy.json",
|
|
promotionCommit: desiredState.deployJson.commitId || git.headCommit,
|
|
shortCommitId: desiredState.deployJson.commitId || null,
|
|
promotionSource: "deploy/deploy.json",
|
|
namespace,
|
|
};
|
|
if (action === "audit") {
|
|
const auditDesiredState = buildDesiredStateAudit(repoPath, target);
|
|
const [registry, workload, publicHealth] = await Promise.all([
|
|
registryAudit(options.kubeconfig || nativeKubeconfig, guard, dumpDir, Math.min(timeoutMs, 15000)),
|
|
workloadAudit(options.kubeconfig || nativeKubeconfig, guard, auditDesiredState, dumpDir, Math.min(timeoutMs, 15000)),
|
|
httpHealthAudit(auditDesiredState, dumpDir, Math.min(timeoutMs, 15000)),
|
|
]);
|
|
const durability = runtimeDurabilityAudit(auditDesiredState, publicHealth, secretRefs, workload);
|
|
const summary = auditSummary({ repo, git, guard, secretRefs, registry, lock, desired: auditDesiredState, workload, publicHealth, durability, controlled, dumpDir });
|
|
emitResult({
|
|
...base,
|
|
ok: summary.ok,
|
|
status: summary.status,
|
|
audit: summary,
|
|
repo: {
|
|
status: repo.status,
|
|
path: repo.path,
|
|
source: repo.source,
|
|
rejected: repo.rejected,
|
|
},
|
|
workspace: { path: repoPath, source: repo.source },
|
|
remoteDumpPath: dumpDir,
|
|
reportDumpPath: controlled.command?.dump?.stdout || dumpDir,
|
|
blockers: summary.blockers,
|
|
blockerTypes: summary.blockerTypes,
|
|
nextSafeCommand: nextSafeCommand(action, summary.blockers),
|
|
}, dumpDir, options.compactStdout === true);
|
|
process.exitCode = summary.ok ? 0 : 1;
|
|
return;
|
|
}
|
|
const blockers = collectBlockers({ repo, git, guard, lock, secretRefs, controlled, action });
|
|
const ok = blockers.length === 0;
|
|
emitResult({
|
|
...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),
|
|
}, dumpDir, options.compactStdout === true);
|
|
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;
|
|
});
|