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
+1 -1
View File
@@ -31,7 +31,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI
- `dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]` 创建异步 job,通过 UniDesk SSH 维护桥在 D601 上把开发底座依赖镜像从 Docker 缓存导入原生 k3s containerd。默认镜像是 `postgres:16-alpine``rancher/mirrored-library-busybox:1.36.1`,用于避免 `postgres-dev` 与 local-path helper pod 卡在外部 registry 拉取。该命令固定验证 `/etc/rancher/k3s/k3s.yaml` 指向的 native k3s 上下文,并输出 `dev_env_containerd_image_ready=...` 作为成功判据;它不 apply manifest、不修改生产 `unidesk` namespace。
- `artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service` 管理 D601 host-managed CNCF Distribution registry 的声明、安装、只读检查和 pull-only artifact CD。该 registry 固定为 D601 loopback `127.0.0.1:5000`,由 systemd + Docker Compose 管理,位于 native k3s 故障域外;`deploy-service` 只拉取 CI 已发布的 commit-pinned 镜像、retag/recreate 或导入 native k3s,并做 live commit 验证,不构建 runtime source。`deploy-backend-core` 是 deprecated 兼容名,标准 backend-core prod CD 入口是 `deploy apply --env prod --service backend-core`。长期规则见 `docs/reference/artifact-registry.md`
- `commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run|prompt-lint --kind gpt55-pr` 是 host Codex 指挥官直管微服务 skeleton 入口。当前命令返回 `phase=source-contract`、service/API/state/bridge/prompt/trace/#20/#46/ClaudeQQ 审批边界、.state/commander/ 状态模型、dev 无 daemon smoke contract、dry-run 计划和 GPT-5.5 PR prompt 边界辅助 lint,不接 live bridge、不注入 prompt、不发送 ClaudeQQ。`approval request --dry-run` 会生成 200 字以内中文纯文本 ClaudeQQ 审批草案、`notification-path-unavailable` blocker 和授权后唯一可用的 `bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw` 命令;不得提示使用本机 ClaudeQQ skill、powershell 或本地 server。`prompt-lint` 支持 `--prompt-file``--stdin`,输出 `ok``missingClauses``riskLevel``suggestedPatchSnippet` 且不回显完整 prompt;它是 commander 辅助检查,不是业务 PR 门禁,也不改变 `codex submit` 默认行为。`plan``smoke``approval request` 必须带 `--dry-run`;缺少时返回 `error=dry-run-required`。长期规则见 `docs/reference/host-codex-commander.md`
- `hwlab cd status --env dev``hwlab cd apply --env dev --dry-run` 是 HWLAB DEV CD 指挥侧 wrapper。它只调用 HWLAB repo-owned 受控入口,不内嵌发布 kubectl 逻辑:`status` 汇总 HWLAB repo path、Git clean/main/origin-main、`deploy/deploy.json`/artifact catalog/workloads 一致性、D601 native k3s guard、CD Lease lock、16666/16667 live revision;完整 stdout/stderr 写入 `.state/hwlab-cd/<run-id>/`stdout 只返回有界摘要。wrapper 强制 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并只以这个显式目标作为 gate;显式目标出现 `docker-desktop``desktop-control-plane``127.0.0.1:11700` 信号会结构化拒绝,`kubectl` 默认 context 只作为诊断`apply --dry-run` 调用 HWLAB `scripts/dev-deploy-apply.mjs --dry-run --expect-blocked --kubeconfig /etc/rancher/k3s/k3s.yaml`;真实 apply 只暴露 `scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --write-report` 命令形状并标注 host-commander-only,本 runner 不执行 live apply。长期规则见 `docs/reference/hwlab.md`
- `hwlab cd status|preflight|apply --env dev [--dry-run]` 是 HWLAB DEV CD 指挥侧 wrapper。它只调用 HWLAB repo-owned 受控入口,不内嵌发布 kubectl 逻辑:`status` 汇总固定 CD mirror、Git clean/main/origin-main、`deploy/deploy.json`/artifact catalog/workloads 同源收敛、D601 native k3s guard、CD Lease lock、16666/16667 live revision 和当前 live workload image`preflight` 进一步检查必需 SecretRef 对象/键存在性并运行 HWLAB `scripts/dev-cd-apply.mjs --dry-run` 受控事务摘要;完整 stdout/stderr 写入 `.state/hwlab-cd/<run-id>/`,stdout 只返回有界摘要。默认 HWLAB CD repo 是 `/home/ubuntu/hwlab_cd``/home/ubuntu/hwlab` runner 历史目录不得作为发布真相。wrapper 强制 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并只以这个显式目标作为 gate;显式目标出现 `docker-desktop``desktop-control-plane``127.0.0.1:11700` 信号会结构化拒绝,写操作计划还必须观察到 node `d601``apply --dry-run` 调用 HWLAB `scripts/dev-cd-apply.mjs --dry-run --kubeconfig /etc/rancher/k3s/k3s.yaml`;真实 apply 只暴露 `scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --write-report` 命令形状并标注 host-commander-only,本 runner 不执行 live apply。长期规则见 `docs/reference/hwlab.md`
- `gh auth status [--repo owner/name]` 探测 GitHub 操作前置条件并输出脱敏 JSON:是否存在 `gh` binary、是否存在 `GH_TOKEN`/`GITHUB_TOKEN` 或可用 `gh auth token` fallback、REST API 是否可达、目标 repo 是否可见、issue 是否可读。degraded reason 必须归类为 `missing-binary``missing-token``auth-failed``github-transient``network-proxy-failed``permission-denied``repo-not-found``repo-forbidden``issue-not-found``pr-not-found``scope-insufficient``validation-failed``invalid-response``unsupported-command`,不得打印 token;失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`runner 应优先用该字段分流。`github-transient` 表示 GitHub DNS/API 连接在收到 HTTP 状态前失败,输出应带 `retryable=true` 或等价 commander action;这不是缺 token、认证失败、权限不足或 PR 语义失败。
- `codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]` 是派发/steer 前的本地 dry-run prompt lint。它只读取 prompt 文本,返回 `dryRun=true``mutation=false``declaredClass``effectiveClass``requiredClass``dispatchDisposition`、缺失或矛盾项和有界 evidence,不访问 live service、不提交任务、不打印完整 prompt。分级固定为 `read-only``live-read``live-mutating`;未声明时按 `read-only` 处理。`codex submit --dry-run``codex steer --dry-run` 会嵌入同一 `promptLint` 结果,帮助指挥官在 dispatch/steer 前发现缺失或矛盾的 live mutation 授权。长期规则见 `docs/reference/code-queue-supervision.md` 的 DEV 测试授权分级。
- `gh issue list [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]` 通过 GitHub REST 列出 issue,默认 `state=open``limit=30`,输出稳定 JSON 且不依赖系统 `gh` binary。`--limit` 会映射到 GitHub `per_page` 并限制返回数量,避免一次拉爆上下文;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。GitHub issues API 可能混入 PRCLI 会从 `.data.issues` 中过滤 pull request。
+7 -3
View File
@@ -46,15 +46,19 @@ UniDesk 指挥侧固定入口:
```sh
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
```
wrapper 的职责是把 host commander 常用的 HWLAB DEV rollout 查看/准备动作收敛到单一入口。它只调用 HWLAB repo-owned 受控脚本,不在 UniDesk 内重写发布流程或拼接 ad hoc `kubectl apply`
- `status` 只读汇总 HWLAB repo path、Git clean/main/origin-main、`deploy/deploy.json`/`deploy/artifact-catalog.dev.json`/`deploy/k8s/base/workloads.yaml` 一致性、D601 native k3s guard、`Lease/hwlab-dev/hwlab-dev-cd-lock`、公网 `16666/16667` live revision
- `apply --dry-run` 调用 HWLAB `scripts/dev-deploy-apply.mjs --dry-run --expect-blocked --kubeconfig /etc/rancher/k3s/k3s.yaml`,只生成准备/阻塞摘要,不做真实 apply、rollout 或 live verification
- 默认 HWLAB CD repo 是 D601 固定干净 mirror `/home/ubuntu/hwlab_cd`,也可用 `--hwlab-repo` 显式指定同等干净 clone。wrapper 必须检查 `git status --short --branch`、origin remote、当前 branch `main`、本地 `origin/main``FETCH_HEAD` 和 worktree 权限;任何 dirty worktree、错误 remote、非 main、HEAD 未跟上本地 `origin/main` 或权限异常都返回结构化 blocker。`/home/ubuntu/hwlab` 是 runner 历史目录,不得作为发布真相
- `deploy/deploy.json` 是唯一 desired-state。wrapper 只把 `deploy/artifact-catalog.dev.json``deploy/k8s/base/workloads.yaml``reports/dev-gate/dev-artifacts.json` 当作派生/证据读数;`status`/`preflight` 必须显示 target commit/ref、deploy.json、artifact catalog、workloads 和 live workload image 是否同源/收敛,不引入第二套 desired state
- `status` 只读汇总 HWLAB repo path、Git clean/main/origin-main、desired-state 收敛、D601 native k3s guard、`Lease/hwlab-dev/hwlab-dev-cd-lock`、公网 `16666/16667` live revision 和当前 live workload image。
- `preflight``status` 的基础上检查 apply 前 SecretRef`hwlab-cloud-api-dev-db/database-url``hwlab-cloud-api-dev-db-admin/admin-url``hwlab-code-agent-provider/openai-api-key`。只验证 Secret 对象和 key 元数据存在性,缺失时返回 blocker、影响范围和修复 runbook;禁止读取或打印 Secret value。
- `apply --dry-run` 调用 HWLAB `scripts/dev-cd-apply.mjs --dry-run --kubeconfig /etc/rancher/k3s/k3s.yaml`,只生成受控事务准备/阻塞摘要,不做真实 apply、rollout 或 live verification。历史 `scripts/dev-deploy-apply.mjs` 可作为 HWLAB 内部支持脚本出现,但 UniDesk wrapper 不能把它当成平行 CD 入口。
- 完整下游 stdout/stderr、HTTP body 和 kubectl 读命令输出写入 UniDesk `.state/hwlab-cd/<run-id>/` dump 目录;CLI stdout 只显示有界摘要和 dump path。
- wrapper 显式注入 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并以这个显式目标作为唯一 gate:目标 context/server/nodes 若出现 `docker-desktop``desktop-control-plane``127.0.0.1:11700` 必须拒绝继续,目标 nodes 包含 `d601` 必须阻断。裸 `kubectl` 默认 context 只作为诊断输出;即使默认 kubeconfig 仍残留 Docker Desktop,只要显式 D601 kubeconfig 通过,也不能把默认 context 当成 CD blocker。
- wrapper 显式注入 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并以这个显式目标作为唯一 gate:目标 context/server/nodes 若出现 `docker-desktop``desktop-control-plane``127.0.0.1:11700` 必须拒绝继续,写操作计划或 `apply --dry-run`目标 nodes 必须包含 `d601`。裸 `kubectl` 默认 context 只作为诊断输出;即使默认 kubeconfig 仍残留 Docker Desktop,只要显式 D601 kubeconfig 通过,也不能把默认 context 当成 CD blocker。
真实 DEV apply 只允许 host commander 在明确授权后执行。UniDesk wrapper 可以展示受控命令形状:
+106 -3
View File
@@ -25,7 +25,35 @@ function runCli(args: string[], env: NodeJS.ProcessEnv = {}): JsonRecord {
function makeFakeHwlabRepo(): string {
const root = join(tmpdir(), `unidesk-hwlab-cd-wrapper-${process.pid}-${Date.now()}`);
mkdirSync(join(root, "scripts"), { recursive: true });
writeFileSync(join(root, "scripts/dev-cd-apply.mjs"), "process.stdout.write(JSON.stringify({ok:true}))\n");
writeFileSync(join(root, "scripts/dev-cd-apply.mjs"), [
"const kubeconfigIndex = process.argv.indexOf('--kubeconfig');",
"process.stdout.write(JSON.stringify({",
" ok: true,",
" status: 'pass',",
" mode: process.argv.includes('--dry-run') ? 'dry-run' : 'status',",
" command: 'dev-cd-apply',",
" mutationAttempted: false,",
" prodTouched: false,",
" target: {",
" ref: 'origin/main',",
" promotionCommit: 'abc1234567890abcdef',",
" shortCommitId: 'abc1234',",
" promotionSource: 'deploy-json',",
" publishRequired: false,",
" headCommitId: 'abc1234567890abcdef',",
" headMatchesTarget: true,",
" desiredStateCheck: { status: 'pass', summary: { desiredCommitId: 'abc1234', targetConvergence: 'already_promoted' } },",
" artifactBoundary: { status: 'pass', desiredState: { deployCommitId: 'abc1234', catalogCommitId: 'abc1234', deployCommitMatches: true, catalogCommitMatches: true } },",
" namespace: 'hwlab-dev'",
" },",
" deployJson: { path: 'deploy/deploy.json', commitId: 'abc1234', matchesTarget: true },",
" artifactCatalog: { path: 'deploy/artifact-catalog.dev.json', commitId: 'abc1234', artifactState: 'published', ciPublished: true, registryVerified: true },",
" artifactReport: { path: 'reports/dev-gate/dev-artifacts.json', commitId: 'abc1234' },",
" lock: { status: 'absent' },",
" liveDelta: { status: 'unknown' },",
" kubeconfig: kubeconfigIndex >= 0 ? process.argv[kubeconfigIndex + 1] : null",
"}, null, 2));",
].join("\n"));
writeFileSync(join(root, "scripts/dev-deploy-apply.mjs"), [
"const dryRun = process.argv.includes('--dry-run');",
"const kubeconfigIndex = process.argv.indexOf('--kubeconfig');",
@@ -49,14 +77,25 @@ function makeFakeHwlabRepo(): string {
writeFileSync(join(root, "scripts/deploy-desired-state-plan.mjs"), [
"process.stdout.write(JSON.stringify({",
" kind: 'hwlab-deploy-desired-state-plan',",
" mode: 'read-only-plan',",
" status: 'pass',",
" source: { deploy: 'deploy/deploy.json', artifactCatalog: 'deploy/artifact-catalog.dev.json', workloads: 'deploy/k8s/base/workloads.yaml', optionalReport: 'reports/dev-gate/dev-artifacts.json' },",
" promotionBoundary: { authoritativeDesiredState: ['deploy/deploy.json', 'deploy/artifact-catalog.dev.json', 'deploy/k8s/base/workloads.yaml'], nonAuthoritativeEvidence: ['reports/dev-gate/dev-artifacts.json'] },",
" summary: { desiredCommitId: 'abc1234', desiredImageTag: 'abc1234', artifactState: 'published', ciPublished: true, registryVerified: true, services: 13, workloadContainers: 13, diagnostics: 0, blockers: 0, targetConvergence: 'not_requested' }",
"}, null, 2));",
].join("\n"));
spawnSync("git", ["init", "-b", "main"], { cwd: root, encoding: "utf8" });
spawnSync("git", ["config", "user.email", "test@example.invalid"], { cwd: root, encoding: "utf8" });
spawnSync("git", ["config", "user.name", "HWLAB CD Test"], { cwd: root, encoding: "utf8" });
spawnSync("git", ["remote", "add", "origin", "git@github.com:pikasTech/HWLAB.git"], { cwd: root, encoding: "utf8" });
spawnSync("git", ["add", "."], { cwd: root, encoding: "utf8" });
spawnSync("git", ["commit", "-m", "fixture"], { cwd: root, encoding: "utf8" });
spawnSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: root, encoding: "utf8" });
writeFileSync(join(root, ".git", "FETCH_HEAD"), "fixture\n");
return root;
}
function makeFakeBin(mode: "native" | "desktop" | "stale-default" | "wrong-node"): string {
function makeFakeBin(mode: "native" | "desktop" | "stale-default" | "wrong-node" | "missing-secret"): string {
const bin = join(tmpdir(), `unidesk-hwlab-cd-bin-${process.pid}-${Date.now()}-${mode}`);
mkdirSync(bin, { recursive: true });
const explicitContext = mode === "desktop" ? "docker-desktop" : "default";
@@ -81,6 +120,12 @@ function makeFakeBin(mode: "native" | "desktop" | "stale-default" | "wrong-node"
"if [[ \"$*\" == 'config view --minify -o jsonpath={.clusters[0].cluster.server}' ]]; then printf '%s' \"$server\"; exit 0; fi",
"if [[ \"$*\" == 'get nodes -o jsonpath={range .items[*]}{.metadata.name}{\"\\n\"}{end}' ]]; then printf '%s\\n' \"$nodes\"; exit 0; fi",
"if [[ \"$*\" == '-n hwlab-dev get lease hwlab-dev-cd-lock -o json' ]]; then printf 'Error from server (NotFound): leases.coordination.k8s.io \"hwlab-dev-cd-lock\" not found\\n' >&2; exit 1; fi",
"if [[ \"$*\" == '-n hwlab-dev get deploy -o jsonpath={range .items[*]}{.metadata.name}{\"\\t\"}{range .spec.template.spec.containers[*]}{.name}{\"=\"}{.image}{\",\"}{end}{\"\\n\"}{end}' ]]; then printf 'hwlab-cloud-api\\thwlab-cloud-api=127.0.0.1:5000/hwlab/hwlab-cloud-api:abc1234,\\n'; exit 0; fi",
"if [[ \"$*\" == '-n hwlab-dev get secret hwlab-code-agent-provider -o name' && " + JSON.stringify(mode) + " == 'missing-secret' ]]; then printf 'Error from server (NotFound): secrets \"hwlab-code-agent-provider\" not found\\n' >&2; exit 1; fi",
"if [[ \"$*\" =~ ^-n\\ hwlab-dev\\ get\\ secret\\ ([^[:space:]]+)\\ -o\\ name$ ]]; then printf 'secret/%s\\n' \"${BASH_REMATCH[1]}\"; exit 0; fi",
"if [[ \"$*\" == '-n hwlab-dev describe secret hwlab-cloud-api-dev-db' ]]; then printf 'Name: hwlab-cloud-api-dev-db\\nData\\n====\\ndatabase-url: 48 bytes\\n'; exit 0; fi",
"if [[ \"$*\" == '-n hwlab-dev describe secret hwlab-cloud-api-dev-db-admin' ]]; then printf 'Name: hwlab-cloud-api-dev-db-admin\\nData\\n====\\nadmin-url: 48 bytes\\n'; exit 0; fi",
"if [[ \"$*\" == '-n hwlab-dev describe secret hwlab-code-agent-provider' ]]; then printf 'Name: hwlab-code-agent-provider\\nData\\n====\\nopenai-api-key: 48 bytes\\n'; exit 0; fi",
"printf '{}\\n'",
].join("\n"));
spawnSync("chmod", ["+x", join(bin, "kubectl")]);
@@ -92,6 +137,7 @@ const nativeBin = makeFakeBin("native");
const desktopBin = makeFakeBin("desktop");
const staleDefaultBin = makeFakeBin("stale-default");
const wrongNodeBin = makeFakeBin("wrong-node");
const missingSecretBin = makeFakeBin("missing-secret");
const liveBody = "data:application/json,%7B%22serviceId%22%3A%22hwlab-cloud-web%22%2C%22environment%22%3A%22dev%22%2C%22status%22%3A%22ok%22%2C%22revision%22%3A%22abc1234%22%7D";
const apiBody = "data:application/json,%7B%22serviceId%22%3A%22hwlab-cloud-api%22%2C%22environment%22%3A%22dev%22%2C%22status%22%3A%22ok%22%2C%22revision%22%3A%22abc1234%22%7D";
@@ -99,6 +145,22 @@ const help = runCli(["hwlab", "help"]);
assert.equal(help.ok, true);
assert.equal((help.data as JsonRecord).command, "hwlab cd");
const runnerHistoryRepo = runCli([
"hwlab",
"cd",
"status",
"--env",
"dev",
"--hwlab-repo",
"/home/ubuntu/hwlab",
], {
PATH: `${nativeBin}:${process.env.PATH ?? ""}`,
});
assert.equal(runnerHistoryRepo.ok, false);
const runnerHistoryCandidates = ((runnerHistoryRepo.data as JsonRecord).repo as JsonRecord).candidates as JsonRecord[];
assert.equal(runnerHistoryCandidates[0]?.rejected, true);
assert.equal(runnerHistoryCandidates[0]?.rejectionReason, "runner-history-directory-is-not-hwlab-cd-release-truth");
const applyDryRun = runCli([
"hwlab",
"cd",
@@ -118,7 +180,29 @@ assert.equal(dryRunData.mutation, false);
assert.equal(((dryRunData.d601NativeK3sGuard as JsonRecord).injectedEnv as JsonRecord).KUBECONFIG, "/etc/rancher/k3s/k3s.yaml");
assert.equal((dryRunData.d601NativeK3sGuard as JsonRecord).requiredNodePresent, true);
assert.equal((dryRunData.controlledDryRun as JsonRecord).commandOk, true);
assert.equal((dryRunData.secretRefPreflight as JsonRecord).status, "pass");
assert.equal(((dryRunData.controlledDryRun as JsonRecord).controlledEntrypoint), "scripts/dev-cd-apply.mjs");
assert.equal(((dryRunData.hostCommanderOnlyLiveApply as JsonRecord).commandShape as unknown[]).includes("scripts/dev-cd-apply.mjs"), true);
assert.equal(JSON.stringify(dryRunData).includes("sk-secret"), false);
const preflight = runCli([
"hwlab",
"cd",
"preflight",
"--env",
"dev",
"--hwlab-repo",
fakeRepo,
], {
PATH: `${nativeBin}:${process.env.PATH ?? ""}`,
UNIDESK_HWLAB_CD_TEST_FRONTEND_LIVE_URL: liveBody,
UNIDESK_HWLAB_CD_TEST_API_LIVE_URL: apiBody,
});
assert.equal(preflight.ok, true);
const preflightData = preflight.data as JsonRecord;
assert.equal(preflightData.mutation, false);
assert.equal((preflightData.secretRefPreflight as JsonRecord).status, "pass");
assert.equal((preflightData.liveWorkloads as JsonRecord).status, "observed");
const realApply = runCli([
"hwlab",
@@ -201,10 +285,29 @@ const wrongNodeBlocked = runCli([
], {
PATH: `${wrongNodeBin}:${process.env.PATH ?? ""}`,
});
assert.equal(wrongNodeBlocked.ok, true);
assert.equal(wrongNodeBlocked.ok, false);
const wrongNodeGuard = (wrongNodeBlocked.data as JsonRecord).d601NativeK3sGuard as JsonRecord;
assert.equal(wrongNodeGuard.status, "blocked");
assert.equal(wrongNodeGuard.requiredNodePresent, false);
assert.equal(((wrongNodeBlocked.data as JsonRecord).blockers as JsonRecord[]).some((blocker) => blocker.scope === "d601-native-k3s-guard"), true);
const missingSecretBlocked = runCli([
"hwlab",
"cd",
"apply",
"--env",
"dev",
"--dry-run",
"--hwlab-repo",
fakeRepo,
], {
PATH: `${missingSecretBin}:${process.env.PATH ?? ""}`,
});
assert.equal(missingSecretBlocked.ok, false);
const missingSecretData = missingSecretBlocked.data as JsonRecord;
assert.equal((missingSecretData.secretRefPreflight as JsonRecord).status, "blocked");
assert.equal((missingSecretData.controlledDryRun as JsonRecord).status, "skipped");
assert.equal((missingSecretData.blockers as JsonRecord[]).some((blocker) => blocker.scope === "secretref:hwlab-code-agent-provider/openai-api-key"), true);
assert.equal(JSON.stringify(missingSecretData).includes("sk-secret"), false);
console.log(JSON.stringify({ ok: true, checked: "hwlab-cd-wrapper-contract" }));
+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);
}