diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7642e180..ef02e098 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -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 '' --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//`,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//`,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 可能混入 PR,CLI 会从 `.data.issues` 中过滤 pull request。 diff --git a/docs/reference/hwlab.md b/docs/reference/hwlab.md index e4d68c37..1872a179 100644 --- a/docs/reference/hwlab.md +++ b/docs/reference/hwlab.md @@ -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//` 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 可以展示受控命令形状: diff --git a/scripts/hwlab-cd-wrapper-contract-test.ts b/scripts/hwlab-cd-wrapper-contract-test.ts index 39840e33..1ad816b1 100644 --- a/scripts/hwlab-cd-wrapper-contract-test.ts +++ b/scripts/hwlab-cd-wrapper-contract-test.ts @@ -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" })); diff --git a/scripts/src/check.ts b/scripts/src/check.ts index 3b67cb74..5f8c8935 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -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()); diff --git a/scripts/src/hwlab-cd.ts b/scripts/src/hwlab-cd.ts index ec865fba..5dce42bd 100644 --- a/scripts/src/hwlab-cd.ts +++ b/scripts/src/hwlab-cd.ts @@ -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 { + 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 { 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> { - 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, dumpDir: string, timeoutMs: number): Promise> { + 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, name: string): string | null { return stringValue(annotations[`hwlab.pikastech.local/${name}`]); } @@ -460,9 +538,24 @@ async function cdLockStatus(kubeconfig: string, guard: Record, function summarizeDesiredState(parsed: unknown, command: CapturedCommand): Record { 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> { + 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, dumpDir: string, timeoutMs: number): Promise> { + 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 | null): string | null { const commit = asRecord(json?.commit); const image = asRecord(json?.image); @@ -600,13 +804,20 @@ function collectBlockers(parts: { guard?: Record; lock?: Record; live?: Record; + secretRefs?: Record; + controlled?: Record; dryRun?: Record; }): Record[] { const blockers: Record[] = []; 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 => 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> }; } 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> 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 { - 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 { const controlledCommand = [ "node", @@ -786,7 +971,11 @@ async function apply(options: HwlabCdOptions): Promise> 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> }; } - 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> 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> }; } +async function preflight(options: HwlabCdOptions): Promise> { + 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 { 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