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