feat: harden HWLAB dev CD wrapper

This commit is contained in:
Codex
2026-05-23 19:21:40 +00:00
parent 7b6f2e55ff
commit 44d8e7e0e7
5 changed files with 475 additions and 95 deletions
+5
View File
@@ -22,6 +22,7 @@ const syntaxFiles = [
"scripts/src/code-queue.ts",
"scripts/src/command.ts",
"scripts/src/d601-k3s-guard.ts",
"scripts/src/hwlab-cd.ts",
"scripts/src/decision-center.ts",
"scripts/src/dev-env.ts",
"scripts/src/deploy.ts",
@@ -48,6 +49,7 @@ const syntaxFiles = [
"scripts/code-queue-cli-read-terminal-contract-test.ts",
"scripts/code-queue-gh-auth-redaction-contract-test.ts",
"scripts/d601-recovery-guardrails-contract-test.ts",
"scripts/hwlab-cd-wrapper-contract-test.ts",
"scripts/code-queue-queues-shape-contract-test.ts",
"scripts/microservice-health-output-contract-test.ts",
"scripts/code-queue-supervisor-disclosure-contract-test.ts",
@@ -386,6 +388,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
fileItem("scripts/src/auth-broker.ts"),
fileItem("scripts/auth-broker-contract-test.ts"),
fileItem("scripts/d601-recovery-guardrails-contract-test.ts"),
fileItem("scripts/hwlab-cd-wrapper-contract-test.ts"),
fileItem("src/components/microservices/auth-broker/Cargo.toml"),
fileItem("src/components/microservices/auth-broker/Dockerfile"),
fileItem("src/components/microservices/auth-broker/src/main.rs"),
@@ -439,6 +442,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
items.push(commandItem("playwright:cli-wrapper-contract", ["bun", "scripts/playwright-cli-contract-test.ts"], 30_000));
items.push(commandItem("auth-broker:p0-contract", ["bun", "scripts/auth-broker-contract-test.ts"], 30_000));
items.push(commandItem("d601:recovery-guardrails-contract", ["bun", "scripts/d601-recovery-guardrails-contract-test.ts"], 30_000));
items.push(commandItem("hwlab:cd-wrapper-contract", ["bun", "scripts/hwlab-cd-wrapper-contract-test.ts"], 30_000));
} else {
items.push(skippedItem("typescript:scripts", "scripts TypeScript typecheck is opt-in", "--scripts-typecheck or --full"));
items.push(skippedItem("code-queue:prompt-observation-contract", "prompt observation contract is opt-in with script checks", "--scripts-typecheck or --full"));
@@ -479,6 +483,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
items.push(skippedItem("playwright:cli-wrapper-contract", "Playwright wrapper/headless/session contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("auth-broker:p0-contract", "Auth Broker P0 skeleton and CLI adapter contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("d601:recovery-guardrails-contract", "D601 recovery guardrails fixture contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("hwlab:cd-wrapper-contract", "HWLAB DEV CD wrapper contract is opt-in with script checks", "--scripts-typecheck or --full"));
}
if (options.logs) {
items.push(unifiedLogRotationItem());
+356 -88
View File
@@ -1,6 +1,6 @@
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { createWriteStream, existsSync, mkdirSync, openSync, readSync, statSync, writeFileSync, closeSync } from "node:fs";
import { accessSync, constants as fsConstants, 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";
@@ -10,7 +10,7 @@ import {
d601NativeKubeconfig,
} from "./d601-k3s-guard";
type HwlabCdAction = "status" | "apply";
type HwlabCdAction = "status" | "preflight" | "apply";
type HwlabCdEnvironment = "dev";
interface HwlabCdOptions {
@@ -65,10 +65,16 @@ interface CommandView {
const namespace = "hwlab-dev";
const lockName = "hwlab-dev-cd-lock";
const nativeKubeconfig = d601NativeKubeconfig;
const defaultHwlabCdRepoPath = "/home/ubuntu/hwlab_cd";
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;
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 isHelpArg(value: string | undefined): boolean {
return value === "help" || value === "--help" || value === "-h";
@@ -88,8 +94,8 @@ function parsePositiveInteger(value: string, option: string): number {
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");
if (scope !== "cd") throw new Error("hwlab usage: bun scripts/cli.ts hwlab cd status|preflight|apply --env dev");
if (actionArg !== "status" && actionArg !== "preflight" && actionArg !== "apply") throw new Error("hwlab cd usage: status|preflight|apply");
const options: HwlabCdOptions = {
action: actionArg,
@@ -289,14 +295,21 @@ function stringValue(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function accessCheck(path: string, mode: number): Record<string, unknown> {
try {
accessSync(path, mode);
return { ok: true, path };
} catch (error) {
return { ok: false, path, error: error instanceof Error ? error.message : String(error) };
}
}
function defaultRepoCandidates(provided: string | null): { source: string; path: string }[] {
if (provided !== null) return [{ source: "option", path: provided }];
if (process.env.UNIDESK_HWLAB_REPO !== undefined) return [{ source: "env:UNIDESK_HWLAB_REPO", path: process.env.UNIDESK_HWLAB_REPO }];
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" },
{ source: "default:d601-clean-mirror", path: defaultHwlabCdRepoPath },
{ source: "rejected:runner-history", path: "/home/ubuntu/hwlab" },
];
}
@@ -304,49 +317,72 @@ 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");
const desiredStatePlan = join(absolutePath, "scripts/deploy-desired-state-plan.mjs");
const rejected = candidate.source.startsWith("rejected:") || absolutePath === "/home/ubuntu/hwlab";
return {
...candidate,
path: absolutePath,
exists: existsSync(absolutePath),
eligible: !rejected,
rejected,
rejectionReason: rejected ? "runner-history-directory-is-not-hwlab-cd-release-truth" : null,
hasDevCdApply: existsSync(devCdApply),
hasDevDeployApply: existsSync(devDeployApply),
hasDesiredStatePlan: existsSync(desiredStatePlan),
};
});
const selected = candidates.find((candidate) => candidate.exists && candidate.hasDevCdApply && candidate.hasDevDeployApply) ?? null;
const selected = candidates.find((candidate) => candidate.eligible && candidate.exists && candidate.hasDevCdApply && candidate.hasDesiredStatePlan) ?? null;
return {
ok: selected !== null,
defaultPath: defaultHwlabCdRepoPath,
selected,
candidates,
};
}
async function gitSummary(repoPath: string, dumpDir: string, timeoutMs: number): Promise<Record<string, unknown>> {
const [branch, head, originMain, statusShort, statusPorcelain] = await Promise.all([
const [branch, head, originMain, remote, gitDir, 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", "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 fetchHeadPath = gitDirPath.length > 0 ? join(gitDirPath, "FETCH_HEAD") : join(repoPath, ".git", "FETCH_HEAD");
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);
const worktreeAccess = accessCheck(repoPath, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK);
const fetchHeadAccess = accessCheck(fetchHeadPath, fsConstants.R_OK);
const remoteMatches = /github\.com[:/]pikasTech\/HWLAB(?:\.git)?$/u.test(remoteUrl);
return {
ok: branch.ok && head.ok && statusShort.ok && statusPorcelain.ok,
ok: branch.ok && head.ok && remote.ok && gitDir.ok && statusShort.ok && statusPorcelain.ok && worktreeAccess.ok && fetchHeadAccess.ok,
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: existsSync(fetchHeadPath),
readable: fetchHeadAccess.ok,
error: fetchHeadAccess.error,
},
statusShort: statusLines.slice(0, 40),
dirtyCount: dirtyLines.length,
dirtyPreview: dirtyLines.slice(0, 30),
commands: [branch, head, originMain, statusShort, statusPorcelain].map(commandView),
commands: [branch, head, originMain, remote, gitDir, statusShort, statusPorcelain].map(commandView),
};
}
@@ -385,6 +421,48 @@ async function nativeK3sGuard(kubeconfig: string, dumpDir: string, timeoutMs: nu
};
}
async function liveWorkloadStatus(kubeconfig: string, guard: Record<string, unknown>, dumpDir: string, timeoutMs: number): Promise<Record<string, unknown>> {
if (guard.refusal === true || guard.status !== "pass") {
return {
status: "skipped",
reason: "d601-native-k3s-guard-not-pass",
mutation: false,
namespace,
};
}
const env = { ...process.env, KUBECONFIG: kubeconfig };
const jsonpath = "{range .items[*]}{.metadata.name}{\"\\t\"}{range .spec.template.spec.containers[*]}{.name}{\"=\"}{.image}{\",\"}{end}{\"\\n\"}{end}";
const result = await runCaptured(["kubectl", "-n", namespace, "get", "deploy", "-o", `jsonpath=${jsonpath}`], repoRoot, dumpDir, "live-workload-images", { env, timeoutMs });
const workloads = result.stdoutText
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.slice(0, 80)
.map((line) => {
const [deployment, rawContainers = ""] = line.split("\t");
const containers = rawContainers
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0)
.slice(0, 20)
.map((item) => {
const separator = item.indexOf("=");
const name = separator === -1 ? item : item.slice(0, separator);
const image = separator === -1 ? null : item.slice(separator + 1);
return { name, image, tag: image === null ? null : image.split(":").at(-1) ?? null };
});
return { deployment, containers };
});
return {
status: result.ok ? "observed" : "blocked",
namespace,
mutation: false,
workloadCount: workloads.length,
workloads,
command: commandView(result),
};
}
function annotation(annotations: Record<string, unknown>, name: string): string | null {
return stringValue(annotations[`hwlab.pikastech.local/${name}`]);
}
@@ -460,9 +538,24 @@ async function cdLockStatus(kubeconfig: string, guard: Record<string, unknown>,
function summarizeDesiredState(parsed: unknown, command: CapturedCommand): Record<string, unknown> {
const record = asRecord(parsed);
const summary = asRecord(record?.summary);
const source = asRecord(record?.source);
const promotionBoundary = asRecord(record?.promotionBoundary);
return {
status: stringValue(record?.status) ?? (command.ok ? "unknown" : "blocked"),
kind: stringValue(record?.kind),
mode: stringValue(record?.mode),
source: {
deployJson: stringValue(source?.deploy) ?? "deploy/deploy.json",
artifactCatalog: stringValue(source?.artifactCatalog) ?? "deploy/artifact-catalog.dev.json",
workloads: stringValue(source?.workloads) ?? "deploy/k8s/base/workloads.yaml",
optionalReport: stringValue(source?.optionalReport),
},
authoritativeDesiredState: Array.isArray(promotionBoundary?.authoritativeDesiredState)
? promotionBoundary.authoritativeDesiredState.slice(0, 10)
: ["deploy/deploy.json", "deploy/artifact-catalog.dev.json", "deploy/k8s/base/workloads.yaml"],
nonAuthoritativeEvidence: Array.isArray(promotionBoundary?.nonAuthoritativeEvidence)
? promotionBoundary.nonAuthoritativeEvidence.slice(0, 10)
: [],
desiredCommitId: stringValue(summary?.desiredCommitId),
desiredImageTag: stringValue(summary?.desiredImageTag),
artifactState: stringValue(summary?.artifactState),
@@ -511,6 +604,117 @@ async function controlledObservability(repoPath: string, dumpDir: string, timeou
};
}
async function controlledDevCdDryRun(repoPath: string, kubeconfig: string, dumpDir: string, timeoutMs: number): Promise<Record<string, unknown>> {
const result = await runCaptured(["node", "scripts/dev-cd-apply.mjs", "--dry-run", "--kubeconfig", kubeconfig, "--skip-live-verify"], repoPath, dumpDir, "controlled-dev-cd-apply-dry-run", {
env: { ...process.env, KUBECONFIG: kubeconfig },
timeoutMs,
});
const parsed = asRecord(parseJson(result.stdoutText));
const target = asRecord(parsed?.target);
const desiredStateCheck = asRecord(target?.desiredStateCheck);
const artifactBoundary = asRecord(target?.artifactBoundary);
return {
status: stringValue(parsed?.status) ?? (result.ok ? "unknown" : "blocked"),
commandOk: result.ok,
mode: stringValue(parsed?.mode),
mutationAttempted: parsed?.mutationAttempted ?? false,
prodTouched: parsed?.prodTouched ?? false,
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,
artifactBoundary: artifactBoundary === null ? null : {
status: stringValue(artifactBoundary.status),
desiredState: artifactBoundary.desiredState ?? null,
},
namespace: stringValue(target.namespace) ?? namespace,
},
deployJson: asRecord(parsed?.deployJson),
artifactCatalog: asRecord(parsed?.artifactCatalog),
artifactReport: asRecord(parsed?.artifactReport),
lock: asRecord(parsed?.lock),
liveDelta: asRecord(parsed?.liveDelta),
nextActions: Array.isArray(parsed?.nextActions) ? parsed.nextActions.slice(0, 20) : [],
controlledEntrypoint: "scripts/dev-cd-apply.mjs",
command: commandView(result),
parsed: parsed !== null,
};
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
async function requiredSecretRefPreflight(kubeconfig: string, guard: Record<string, unknown>, dumpDir: string, timeoutMs: number): Promise<Record<string, unknown>> {
if (guard.refusal === true || 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 = await Promise.all(requiredSecretRefs.map(async (ref) => {
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"], repoRoot, dumpDir, `${idBase}-exists`, { env, timeoutMs });
if (!exists.ok) {
return {
...ref,
status: "missing-secret",
exists: false,
keyPresent: false,
command: commandView(exists),
};
}
const describe = await runCaptured(["kubectl", "-n", namespace, "describe", "secret", ref.secretName], repoRoot, dumpDir, `${idBase}-describe`, { env, timeoutMs });
const keyPattern = new RegExp(`(?:^|\\s)${escapeRegExp(ref.secretKey)}(?:\\s|:|$)`, "mu");
const keyPresent = describe.ok && keyPattern.test(describe.stdoutText);
return {
...ref,
status: keyPresent ? "present" : describe.ok ? "missing-key" : "key-observation-blocked",
exists: true,
keyPresent,
command: commandView(describe),
};
}));
const blockers = observations
.filter((observation) => observation.status !== "present")
.map((observation) => ({
scope: `secretref:${observation.secretName}/${observation.secretKey}`,
summary: observation.status === "missing-secret"
? `Required Secret ${observation.secretName} is missing in ${namespace}.`
: `Required SecretRef ${observation.secretName}/${observation.secretKey} is not present as key metadata in ${namespace}.`,
impact: `${observation.consumers.join(", ")} would fail after a DEV CD apply or runtime preflight Job.`,
runbook: `Create or repair Secret ${observation.secretName} with key ${observation.secretKey} in namespace ${namespace}; verify key presence only and do not print the Secret value.`,
}));
return {
status: blockers.length === 0 ? "pass" : "blocked",
namespace,
mutation: false,
safety: {
secretValuesRead: false,
secretValuesPrinted: false,
secretKeyNamesOnly: true,
},
requiredSecretRefs: requiredSecretRefs.map((ref) => `${ref.secretName}/${ref.secretKey}`),
secretRefs: observations,
blockers,
};
}
function revisionFromHealth(json: Record<string, unknown> | null): string | null {
const commit = asRecord(json?.commit);
const image = asRecord(json?.image);
@@ -600,13 +804,20 @@ function collectBlockers(parts: {
guard?: Record<string, unknown>;
lock?: Record<string, unknown>;
live?: Record<string, unknown>;
secretRefs?: Record<string, unknown>;
controlled?: 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.remoteMatches === false) blockers.push({ scope: "hwlab-git-remote", summary: "HWLAB repo origin remote is not pikasTech/HWLAB." });
if (parts.git.headMatchesOriginMain === false) blockers.push({ scope: "hwlab-git-origin-main", summary: "HWLAB HEAD does not match local origin/main." });
const worktreeAccess = asRecord(parts.git.worktreeAccess);
if (worktreeAccess?.ok === false) blockers.push({ scope: "hwlab-worktree-permission", summary: "HWLAB CD worktree is not readable/writable by the wrapper." });
const fetchHead = asRecord(parts.git.fetchHead);
if (fetchHead?.readable === false) blockers.push({ scope: "hwlab-fetch-head-permission", summary: "HWLAB CD FETCH_HEAD is missing or unreadable." });
}
if (parts.desired !== undefined && parts.desired.status !== "pass") {
blockers.push({ scope: "desired-state", summary: `HWLAB desired-state status is ${String(parts.desired.status)}` });
@@ -620,6 +831,22 @@ function collectBlockers(parts: {
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.secretRefs !== undefined && parts.secretRefs.status === "blocked") {
const secretBlockers = Array.isArray(parts.secretRefs.blockers) ? parts.secretRefs.blockers : [];
blockers.push(...secretBlockers.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null && !Array.isArray(item)));
}
if (parts.controlled !== undefined) {
if (parts.controlled.commandOk === false) blockers.push({ scope: "controlled-dev-cd-dry-run", summary: "HWLAB scripts/dev-cd-apply.mjs --dry-run did not complete successfully." });
const target = asRecord(parts.controlled.target);
const desiredStateCheck = asRecord(target?.desiredStateCheck);
if (desiredStateCheck !== null && desiredStateCheck.status !== "pass") {
blockers.push({ scope: "controlled-dev-cd-desired-state", summary: `HWLAB dev-cd-apply desired-state check is ${String(desiredStateCheck.status)}.` });
}
const artifactBoundary = asRecord(target?.artifactBoundary);
if (artifactBoundary !== null && artifactBoundary.status !== "pass") {
blockers.push({ scope: "controlled-dev-cd-artifact-boundary", summary: `HWLAB dev-cd-apply artifact boundary is ${String(artifactBoundary.status)}.` });
}
}
if (parts.dryRun !== undefined && parts.dryRun.commandOk === false) {
blockers.push({ scope: "apply-dry-run-command", summary: "HWLAB controlled dry-run command failed." });
}
@@ -645,15 +872,19 @@ async function status(options: HwlabCdOptions): Promise<Record<string, unknown>>
};
}
const repoPath = String(selected.path);
const [git, desired, guard, controlled, live] = await Promise.all([
const [git, desired, guard, controlled, controlledCd, 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)),
controlledDevCdDryRun(repoPath, options.kubeconfig, dumpDir, Math.min(options.timeoutMs, 30_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 });
const [lock, liveWorkloads] = await Promise.all([
cdLockStatus(options.kubeconfig, guard, dumpDir, Math.min(options.timeoutMs, 15_000)),
liveWorkloadStatus(options.kubeconfig, guard, dumpDir, Math.min(options.timeoutMs, 15_000)),
]);
const blockers = collectBlockers({ git, desired, guard, lock, live, controlled: controlledCd });
return {
ok: guard.refusal !== true,
status: blockers.length === 0 ? "ready" : "blocked",
@@ -666,72 +897,26 @@ async function status(options: HwlabCdOptions): Promise<Record<string, unknown>>
source: selected.source,
controlledEntrypoints: {
devCdApply: join(repoPath, "scripts/dev-cd-apply.mjs"),
devDeployApply: join(repoPath, "scripts/dev-deploy-apply.mjs"),
desiredStatePlan: join(repoPath, "scripts/deploy-desired-state-plan.mjs"),
},
},
git,
desiredState: desired,
controlledDevCdDryRun: controlledCd,
d601NativeK3sGuard: guard,
controlledObservability: controlled,
cdLock: lock,
liveRevisions: live,
liveWorkloads,
blockers,
nextCommands: {
preflight: "bun scripts/cli.ts hwlab cd preflight --env dev",
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",
@@ -786,7 +971,11 @@ async function apply(options: HwlabCdOptions): Promise<Record<string, unknown>>
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));
const [git, desired, guard] = 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)),
]);
if (guard.refusal === true) {
return {
ok: false,
@@ -802,21 +991,14 @@ async function apply(options: HwlabCdOptions): Promise<Record<string, unknown>>
};
}
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 });
const secretRefs = await requiredSecretRefPreflight(options.kubeconfig, guard, dumpDir, Math.min(options.timeoutMs, 15_000));
const preCommandBlockers = collectBlockers({ git, desired, guard, secretRefs });
const dryRun = preCommandBlockers.length === 0
? await controlledDevCdDryRun(repoPath, options.kubeconfig, dumpDir, options.timeoutMs)
: { status: "skipped", commandOk: true, reason: "preflight-blockers-before-controlled-dev-cd-dry-run" };
const blockers = collectBlockers({ git, desired, guard, secretRefs, controlled: dryRun });
return {
ok: command.ok,
ok: blockers.length === 0,
status: blockers.length === 0 ? "prepared" : "blocked",
environment: options.environment,
dryRun: true,
@@ -825,10 +1007,13 @@ async function apply(options: HwlabCdOptions): Promise<Record<string, unknown>>
hwlabRepo: {
path: repoPath,
source: selected.source,
controlledEntrypoint: join(repoPath, "scripts/dev-deploy-apply.mjs"),
liveApplyEntrypointShape: join(repoPath, "scripts/dev-cd-apply.mjs"),
controlledEntrypoint: join(repoPath, "scripts/dev-cd-apply.mjs"),
desiredStatePlan: join(repoPath, "scripts/deploy-desired-state-plan.mjs"),
},
git,
desiredState: desired,
d601NativeK3sGuard: guard,
secretRefPreflight: secretRefs,
controlledDryRun: dryRun,
blockers,
hostCommanderOnlyLiveApply: {
@@ -848,20 +1033,102 @@ async function apply(options: HwlabCdOptions): Promise<Record<string, unknown>>
};
}
async function preflight(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: true,
mutation: false,
dumpDir,
repo,
error: "hwlab-repo-not-found",
};
}
const repoPath = String(selected.path);
const [git, desired, guard, 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)),
liveRevisionStatus(options, dumpDir),
]);
if (guard.refusal === true) {
return {
ok: false,
status: "refused",
environment: options.environment,
dryRun: true,
mutation: false,
dumpDir,
hwlabRepo: { path: repoPath, source: selected.source },
git,
desiredState: desired,
d601NativeK3sGuard: guard,
error: "native-k3s-guard-refused",
summary: "Refusing HWLAB DEV CD preflight because kubectl resolved to a forbidden Docker Desktop control plane signal.",
};
}
const [lock, liveWorkloads, secretRefs] = await Promise.all([
cdLockStatus(options.kubeconfig, guard, dumpDir, Math.min(options.timeoutMs, 15_000)),
liveWorkloadStatus(options.kubeconfig, guard, dumpDir, Math.min(options.timeoutMs, 15_000)),
requiredSecretRefPreflight(options.kubeconfig, guard, dumpDir, Math.min(options.timeoutMs, 15_000)),
]);
const preCommandBlockers = collectBlockers({ git, desired, guard, lock, live, secretRefs });
const controlled = preCommandBlockers.length === 0
? await controlledDevCdDryRun(repoPath, options.kubeconfig, dumpDir, Math.min(options.timeoutMs, 30_000))
: { status: "skipped", commandOk: true, reason: "preflight-blockers-before-controlled-dev-cd-dry-run" };
const blockers = collectBlockers({ git, desired, guard, lock, live, secretRefs, controlled });
return {
ok: blockers.length === 0,
status: blockers.length === 0 ? "pass" : "blocked",
environment: options.environment,
dryRun: true,
mutation: false,
dumpDir,
hwlabRepo: {
path: repoPath,
source: selected.source,
controlledEntrypoint: join(repoPath, "scripts/dev-cd-apply.mjs"),
desiredStatePlan: join(repoPath, "scripts/deploy-desired-state-plan.mjs"),
},
git,
desiredState: desired,
d601NativeK3sGuard: guard,
cdLock: lock,
liveRevisions: live,
liveWorkloads,
secretRefPreflight: secretRefs,
controlledDevCdDryRun: controlled,
blockers,
nextCommands: {
dryRunApply: "bun scripts/cli.ts hwlab cd apply --env dev --dry-run",
fullDump: `find ${JSON.stringify(dumpDir)} -type f -maxdepth 1 -print`,
},
};
}
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 preflight --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",
`default HWLAB CD repo is ${defaultHwlabCdRepoPath}; /home/ubuntu/hwlab is rejected as runner history`,
"status and preflight are read-only and bounded; live apply is host-commander-only and not executed by the dry-run path",
"preflight checks required SecretRef object/key presence without reading or printing Secret values",
"dry-run apply calls HWLAB scripts/dev-cd-apply.mjs --dry-run; live apply shape points at scripts/dev-cd-apply.mjs --apply",
],
};
}
@@ -870,5 +1137,6 @@ export async function runHwlabCdCommand(args: string[]): Promise<Record<string,
if (args.length === 0 || args.some(isHelpArg)) return { ok: true, ...hwlabHelp() };
const options = parseOptions(args);
if (options.action === "status") return status(options);
if (options.action === "preflight") return preflight(options);
return apply(options);
}