diff --git a/AGENTS.md b/AGENTS.md index 5c13f4ee..a87b55bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run`:查看 Auth Broker P0 Rust skeleton 与 CLI adapter contract,runner 无 `GH_TOKEN`/`GITHUB_TOKEN` 时返回结构化 `auth-missing`/`broker-needed`,不读取或打印 token 值,规则见 `docs/reference/auth-broker.md`。 - `bun scripts/cli.ts gh preflight|auth status|issue ...|pr list|files|diff --stat|read|view|preflight|closeout|create|edit|update|comment` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入、当日滚动简报时间线 ClaudeQQ 通知、escape 扫描、只读 cleanup-plan 和 #20 board-audit、PR changed-file/stat summary、PR 创建/评论 dry-run、REST-only 低噪声 PR title/body 编辑、PR 收口元数据观察(含 merged/closed 区分与 merge commit)、低噪声 PR 收口 preflight 与 runner PR preflight;`gh issue/pr read|view` 支持 `owner/repo#number` shorthand,`--raw|--full` 是显式完整披露别名,`gh pr diff` 仅支持 `--stat` 紧凑 JSON,`gh pr merge` 当前仍结构化拒绝但普通 PR 可按任务边界用 repo-owned GitHub 路径收口,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。 - `bun scripts/cli.ts commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run|prompt-lint --kind gpt55-pr`:查看 host Codex 指挥官直管微服务 skeleton 的 source/contract、无 daemon smoke 验证计划、.state/commander/ 状态模型、trace summary 聚合、ClaudeQQ 高风险请示草案和 GPT-5.5 PR prompt 边界辅助 lint;当前只返回 dry-run 计划和 backend-core `microservice proxy claudeqq` 授权后候选命令,不接 live bridge、不接管人工指挥官,不发送消息,`prompt-lint` 不作为业务 PR 门禁也不改变 `codex submit` 默认行为,规则见 `docs/reference/host-codex-commander.md`。 +- `bun scripts/cli.ts hwlab cd status --env dev` / `bun scripts/cli.ts hwlab cd apply --env dev --dry-run`:HWLAB DEV CD 指挥侧 wrapper,调用 HWLAB repo-owned 发布脚本并强制 D601 原生 k3s kubeconfig;真实 apply 只暴露 host-commander-only 命令形状,规则见 `docs/reference/hwlab.md`。 - `bun scripts/cli.ts ci install/status/run/publish-backend-core/publish-user-service/run-dev-e2e/logs`:在 D601 原生 k3s 上安装和运行 Tekton CI,支持每 commit 检查、Code Queue 只读性能门禁、`CI.json` catalog 驱动的 backend-core 与 user-service commit-pinned 镜像发布和手动触发的 `origin/master:deploy.json#environments.dev` 临时 namespace e2e;catalog/producer/consumer 分工见 `docs/reference/cicd-standardization.md`,`run-dev-e2e` 的 Git 控制 runner、短 launcher 和 no-CD 边界见 `docs/reference/dev-ci-runner.md`,Tekton 规则见 `docs/reference/ci.md`。 - `bun scripts/cli.ts codex deploy `:旧 Code Queue 兼容部署入口已禁用,原因是它会绕过受控部署边界直连 D601 部署 Code Queue;规则见 `docs/reference/codex-deploy.md`。 - `bun scripts/cli.ts codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]` / `codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue ]` / `codex pr-preflight [--remote]`:`prompt-lint` 在派发/steer 前 dry-run 检查 runner prompt 的 DEV 测试授权分级(`read-only`/`live-read`/`live-mutating`)且不回显 prompt;`submit --dry-run` 同时给出 MiniMax/GPT/人工路由建议、该 lint 结果和 requested/effective execution mode;真实提交成功只返回写入确认、task id、服务级 runnerPermissions 和后续查看命令,不回显 prompt;`pr-preflight` 只读检查 D601 scheduler/runner 的 GitHub token、egress 和 PR 能力,PR 型派单前必须使用,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。 @@ -98,7 +99,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `docs/reference/staff-reference.md`:幕僚长期参考、决策过程和用户偏好摘要;与 `strategy-governance`、`code-queue-supervision` 配套。 - `docs/reference/secretary-reference.md`:秘书日程管理、时间盒、短期待办捕获和 Todo Note / Decision Center 分流规则。 - `docs/reference/code-queue-supervision.md`:Code Queue 居中调度、并发队列拆分、运行中监控、基础设施缺陷分流和验收收口规则。 -- `docs/reference/hwlab.md`:HWLAB 指挥侧固定 workspace、D601 原生 k3s 口径、16666/16667 DEV 入口和已验证手动发布路径。 +- `docs/reference/hwlab.md`:HWLAB 指挥侧固定 workspace、D601 原生 k3s 口径、16666/16667 DEV 入口、DEV CD wrapper 和受控发布边界。 - `docs/reference/observability.md`:服务日志、任务活性、通用性能指标 API 和性能面板的可观测性规则。 - `docs/reference/microservices.md`:用户服务(兼容命名 `microservice`)的配置、代理、安全边界、unidesk-direct/k3sctl-managed 部署模式、Todo Note/Baidu Netdisk on main-server、k3s Control/Code Queue/MDTODO/Decision Center/FindJob/Pipeline/MET Nonlinear on D601 和验证规则。 - `docs/reference/windows-passthrough.md`:WSL provider 通过 SSH 透传调用 Windows cmd/PowerShell、Keil、COM 串口和 Windows 侧 skill 的长期规则。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 864e5c2d..fb58cca6 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -31,6 +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`,任何 `docker-desktop`、`desktop-control-plane` 或 `127.0.0.1:11700` 信号都会结构化拒绝。`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`。 - `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 6fa1b886..22c0ef94 100644 --- a/docs/reference/hwlab.md +++ b/docs/reference/hwlab.md @@ -40,6 +40,30 @@ KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n hwlab-dev get deploy,svc,pod -o `frpc` 配置不是 D601 host `/etc/frp/frpc.toml`,而是 `hwlab-dev` namespace 里的 `hwlab-frpc-config` ConfigMap 和 `hwlab-frpc` Deployment。master 侧 `frps` 配置由 `hwlab-frps-dev` 容器挂载 `/opt/hwlab-frp/frps.dev.toml`。 +## UniDesk HWLAB DEV CD Wrapper + +UniDesk 指挥侧固定入口: + +```sh +bun scripts/cli.ts hwlab cd status --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。 +- 完整下游 stdout/stderr、HTTP body 和 kubectl 读命令输出写入 UniDesk `.state/hwlab-cd//` dump 目录;CLI stdout 只显示有界摘要和 dump path。 +- wrapper 显式注入 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`。若 `kubectl config current-context`、server 或 node 摘要出现 `docker-desktop`、`desktop-control-plane` 或 `127.0.0.1:11700`,命令必须拒绝继续。 + +真实 DEV apply 只允许 host commander 在明确授权后执行。UniDesk wrapper 可以展示受控命令形状: + +```sh +node scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --write-report --kubeconfig /etc/rancher/k3s/k3s.yaml +``` + +本 Code Queue runner 没有 `ROLLOUT_OK` 时不得执行真实 apply、rollout、CD lock 竞争或 live health 复验。 + ## HWLAB 热修边界 当 HWLAB DEV runtime 的 Secret、环境变量、rollout、DNS、egress 或 provider 权限缺口阻塞业务闭环,并且 Code Queue runner 无法安全完成对应运行态操作时,UniDesk 指挥官只能承担指挥入口、授权确认、监督验收和误判边界收敛。UniDesk reference 可以记录“哪些状态不能被误判”和“哪些动作必须上收”,但不能成为 HWLAB runtime truth。 diff --git a/scripts/cli.ts b/scripts/cli.ts index ec1f426a..bc796101 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -23,6 +23,7 @@ import { runGhCommand } from "./src/gh"; import { runCommanderCommand } from "./src/commander"; import { isHelpToken, rootHelp, serverHelp, sshHelp, staticNamespaceHelp } from "./src/help"; import { runServerCleanupCommand } from "./src/server-cleanup"; +import { runHwlabCdCommand } from "./src/hwlab-cd"; const remoteOptions = extractRemoteCliOptions(process.argv.slice(2)); const args = remoteOptions.args; @@ -267,6 +268,14 @@ async function main(): Promise { return; } + if (top === "hwlab") { + const result = await runHwlabCdCommand(args.slice(1)); + const ok = (result as { ok?: unknown }).ok !== false; + emitJson(commandName, result, ok); + if (!ok) process.exitCode = 1; + return; + } + const config = readConfig(); const autoRemoteCiPublishPlan = autoRemoteCiPublishUserServiceDryRunPlan(config, args); if (autoRemoteCiPublishPlan.enabled && autoRemoteCiPublishPlan.host !== null) { diff --git a/scripts/hwlab-cd-wrapper-contract-test.ts b/scripts/hwlab-cd-wrapper-contract-test.ts new file mode 100644 index 00000000..44dae090 --- /dev/null +++ b/scripts/hwlab-cd-wrapper-contract-test.ts @@ -0,0 +1,158 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import assert from "node:assert/strict"; + +type JsonRecord = Record; + +function runCli(args: string[], env: NodeJS.ProcessEnv = {}): JsonRecord { + const result = spawnSync("bun", ["scripts/cli.ts", ...args], { + cwd: process.cwd(), + env: { ...process.env, ...env }, + encoding: "utf8", + timeout: 20_000, + }); + assert.equal(result.stderr, "", `stderr should be empty: ${result.stderr}`); + assert.notEqual(result.stdout.trim(), "", "CLI must not produce empty output"); + const parsed = JSON.parse(result.stdout) as JsonRecord; + if (result.status !== 0) { + assert.equal(parsed.ok, false, `nonzero CLI should return ok=false: ${result.stdout}`); + } + return parsed; +} + +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-deploy-apply.mjs"), [ + "const dryRun = process.argv.includes('--dry-run');", + "const kubeconfigIndex = process.argv.indexOf('--kubeconfig');", + "process.stdout.write(JSON.stringify({", + " reportVersion: 'v1',", + " status: dryRun ? 'pass' : 'blocked',", + " commitId: 'abc1234',", + " namespace: 'hwlab-dev',", + " endpoint: 'http://74.48.78.17:16667',", + " blockers: [],", + " devDeployApply: {", + " conclusion: { status: 'ready', blockerCount: 0 },", + " artifactPlan: { expectedArtifactCommit: 'abc1234', deployCommitId: 'abc1234', catalogCommitId: 'abc1234', published: true, registryVerified: true, imageCount: 13, requiredServiceCount: 13, unpublishedServices: [] },", + " applyBoundary: { currentMode: 'dry-run', defaultNoWrite: true, mutationAttempted: false, mutationAllowed: false, kubeconfigSource: 'flag:--kubeconfig', writeScope: 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl apply -k deploy/k8s/dev', noWriteScope: 'server-side dry-run only', forbiddenActions: ['prod-deploy'] },", + " applyStep: { status: 'pass', command: 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl apply --dry-run=server -k deploy/k8s/dev', mutationAttempted: false },", + " manualCommands: { status: 'ready' }", + " },", + " kubeconfig: kubeconfigIndex >= 0 ? process.argv[kubeconfigIndex + 1] : null", + "}, null, 2));", + ].join("\n")); + writeFileSync(join(root, "scripts/deploy-desired-state-plan.mjs"), [ + "process.stdout.write(JSON.stringify({", + " kind: 'hwlab-deploy-desired-state-plan',", + " status: 'pass',", + " 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")); + return root; +} + +function makeFakeBin(mode: "native" | "desktop"): string { + const bin = join(tmpdir(), `unidesk-hwlab-cd-bin-${process.pid}-${Date.now()}-${mode}`); + mkdirSync(bin, { recursive: true }); + const context = mode === "desktop" ? "docker-desktop" : "default"; + const server = mode === "desktop" ? "https://127.0.0.1:11700" : "https://127.0.0.1:6443"; + const nodes = mode === "desktop" ? "desktop-control-plane" : "d601"; + writeFileSync(join(bin, "kubectl"), [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "printf 'KUBECONFIG=%s\\n' \"${KUBECONFIG:-}\" >&2", + "if [[ \"$*\" == 'config current-context' ]]; then printf '%s\\n' " + JSON.stringify(context) + "; exit 0; fi", + "if [[ \"$*\" == 'config view --minify -o jsonpath={.clusters[0].cluster.server}' ]]; then printf '%s' " + JSON.stringify(server) + "; exit 0; fi", + "if [[ \"$*\" == 'get nodes -o jsonpath={range .items[*]}{.metadata.name}{\"\\n\"}{end}' ]]; then printf '%s\\n' " + JSON.stringify(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", + "printf '{}\\n'", + ].join("\n")); + spawnSync("chmod", ["+x", join(bin, "kubectl")]); + return bin; +} + +const fakeRepo = makeFakeHwlabRepo(); +const nativeBin = makeFakeBin("native"); +const desktopBin = makeFakeBin("desktop"); +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"; + +const help = runCli(["hwlab", "help"]); +assert.equal(help.ok, true); +assert.equal((help.data as JsonRecord).command, "hwlab cd"); + +const applyDryRun = runCli([ + "hwlab", + "cd", + "apply", + "--env", + "dev", + "--dry-run", + "--hwlab-repo", + fakeRepo, +], { + PATH: `${nativeBin}:${process.env.PATH ?? ""}`, +}); +assert.equal(applyDryRun.ok, true); +const dryRunData = applyDryRun.data as JsonRecord; +assert.equal(dryRunData.dryRun, true); +assert.equal(dryRunData.mutation, false); +assert.equal(((dryRunData.d601NativeK3sGuard as JsonRecord).injectedEnv as JsonRecord).KUBECONFIG, "/etc/rancher/k3s/k3s.yaml"); +assert.equal((dryRunData.controlledDryRun as JsonRecord).commandOk, true); +assert.equal(((dryRunData.hostCommanderOnlyLiveApply as JsonRecord).commandShape as unknown[]).includes("scripts/dev-cd-apply.mjs"), true); + +const realApply = runCli([ + "hwlab", + "cd", + "apply", + "--env", + "dev", + "--hwlab-repo", + fakeRepo, +], { + PATH: `${nativeBin}:${process.env.PATH ?? ""}`, +}); +assert.equal(realApply.ok, false); +assert.equal((realApply.data as JsonRecord).error, "host-commander-only-real-apply"); + +const status = runCli([ + "hwlab", + "cd", + "status", + "--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(status.ok, true); +const statusData = status.data as JsonRecord; +assert.equal(((statusData.d601NativeK3sGuard as JsonRecord).injectedEnv as JsonRecord).KUBECONFIG, "/etc/rancher/k3s/k3s.yaml"); +assert.equal((statusData.liveRevisions as JsonRecord).status, "observed"); +assert.ok(typeof statusData.dumpDir === "string" && String(statusData.dumpDir).includes(".state/hwlab-cd")); + +const desktopRefusal = runCli([ + "hwlab", + "cd", + "apply", + "--env", + "dev", + "--dry-run", + "--hwlab-repo", + fakeRepo, +], { + PATH: `${desktopBin}:${process.env.PATH ?? ""}`, +}); +assert.equal(desktopRefusal.ok, false); +assert.equal((desktopRefusal.data as JsonRecord).error, "native-k3s-guard-refused"); +assert.deepEqual((desktopRefusal.data as JsonRecord).d601NativeK3sGuard && ((desktopRefusal.data as JsonRecord).d601NativeK3sGuard as JsonRecord).refusalSignals, ["docker-desktop", "desktop-control-plane", "127.0.0.1:11700"]); + +console.log(JSON.stringify({ ok: true, checked: "hwlab-cd-wrapper-contract" })); diff --git a/scripts/src/help.ts b/scripts/src/help.ts index fbecbf91..795a861f 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -1,5 +1,6 @@ import { ghHelp } from "./gh"; import { authBrokerHelp } from "./auth-broker"; +import { hwlabHelp } from "./hwlab-cd"; export function rootHelp(): unknown { return { @@ -48,6 +49,7 @@ export function rootHelp(): unknown { { command: "auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run", description: "Inspect the P0 Rust auth broker and CLI adapter contract without reading token values, writing GitHub, or starting services." }, { command: "gh preflight|auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, comment delete, token diagnostics, PR closeout preflight, hard delete unsupported, and merge blocked." }, { command: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run|prompt-lint --kind gpt55-pr", description: "Host Codex commander skeleton contract, no-daemon smoke plan, dry-run approval preview, and advisory GPT-5.5 PR prompt boundary lint without live bridges, message sends, or submit gating." }, + { command: "hwlab cd status --env dev | hwlab cd apply --env dev --dry-run", description: "Bounded HWLAB DEV CD wrapper that calls HWLAB repo-owned scripts, forces D601 native k3s kubeconfig, refuses Docker Desktop control-plane signals, and writes full stdout/stderr dumps." }, { command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." }, { command: "schedule list|get|runs|run|retry-run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run supports --wait-ms N and retry-run reuses the failed run's schedule." }, { command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." }, @@ -483,5 +485,6 @@ export function staticNamespaceHelp(args: string[]): unknown | null { if (top === "artifact-registry") return artifactRegistryHelp(); if (top === "auth-broker") return authBrokerHelp(); if (top === "gh") return ghHelp(); + if (top === "hwlab") return hwlabHelp(); return null; } diff --git a/scripts/src/hwlab-cd.ts b/scripts/src/hwlab-cd.ts new file mode 100644 index 00000000..a2298dbc --- /dev/null +++ b/scripts/src/hwlab-cd.ts @@ -0,0 +1,871 @@ +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { createWriteStream, existsSync, mkdirSync, openSync, readSync, statSync, writeFileSync, closeSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { repoRoot, rootPath } from "./config"; + +type HwlabCdAction = "status" | "apply"; +type HwlabCdEnvironment = "dev"; + +interface HwlabCdOptions { + action: HwlabCdAction; + environment: HwlabCdEnvironment; + dryRun: boolean; + repoPath: string | null; + kubeconfig: string; + timeoutMs: number; + frontendLiveUrl: string; + apiLiveUrl: string; +} + +interface CapturedCommand { + id: string; + command: string[]; + cwd: string; + ok: boolean; + exitCode: number | null; + signal: NodeJS.Signals | null; + timedOut: boolean; + durationMs: number; + stdoutText: string; + stderrText: string; + dump: { + stdout: string; + stderr: string; + stdoutBytes: number; + stderrBytes: number; + stdoutTail: string; + stderrTail: string; + }; +} + +interface CommandView { + id: string; + command: string[]; + cwd: string; + ok: boolean; + exitCode: number | null; + signal: NodeJS.Signals | null; + timedOut: boolean; + durationMs: number; + dump: { + stdout: string; + stderr: string; + stdoutBytes: number; + stderrBytes: number; + }; +} + +const namespace = "hwlab-dev"; +const lockName = "hwlab-dev-cd-lock"; +const nativeKubeconfig = "/etc/rancher/k3s/k3s.yaml"; +const defaultFrontendLiveUrl = "http://74.48.78.17:16666/health/live"; +const defaultApiLiveUrl = "http://74.48.78.17:16667/health/live"; +const parseCaptureLimitBytes = 4 * 1024 * 1024; +const tailChars = 1000; + +function isHelpArg(value: string | undefined): boolean { + return value === "help" || value === "--help" || value === "-h"; +} + +function readOption(argv: string[], index: number, option: string): string { + const value = argv[index]; + if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${option} requires a value`); + return value; +} + +function parsePositiveInteger(value: string, option: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${option} must be a positive integer`); + return parsed; +} + +function parseOptions(args: string[]): HwlabCdOptions { + const [scope, actionArg] = args; + if (scope !== "cd") throw new Error("hwlab usage: bun scripts/cli.ts hwlab cd status|apply --env dev"); + if (actionArg !== "status" && actionArg !== "apply") throw new Error("hwlab cd usage: status|apply"); + + const options: HwlabCdOptions = { + action: actionArg, + environment: "dev", + dryRun: false, + repoPath: null, + kubeconfig: nativeKubeconfig, + timeoutMs: 60_000, + frontendLiveUrl: process.env.UNIDESK_HWLAB_CD_TEST_FRONTEND_LIVE_URL ?? defaultFrontendLiveUrl, + apiLiveUrl: process.env.UNIDESK_HWLAB_CD_TEST_API_LIVE_URL ?? defaultApiLiveUrl, + }; + let envSeen = false; + + for (let index = 2; index < args.length; index += 1) { + const arg = args[index] ?? ""; + if (arg === "--env") { + const value = readOption(args, index + 1, arg); + if (value !== "dev") throw new Error("hwlab cd only supports --env dev"); + options.environment = value; + envSeen = true; + index += 1; + } else if (arg === "--dry-run") { + options.dryRun = true; + } else if (arg === "--hwlab-repo" || arg === "--repo") { + options.repoPath = readOption(args, index + 1, arg); + index += 1; + } else if (arg === "--kubeconfig") { + const value = readOption(args, index + 1, arg); + if (value !== nativeKubeconfig) { + throw new Error(`hwlab cd requires the D601 native k3s kubeconfig: ${nativeKubeconfig}`); + } + options.kubeconfig = value; + index += 1; + } else if (arg === "--timeout-ms") { + options.timeoutMs = parsePositiveInteger(readOption(args, index + 1, arg), arg); + index += 1; + } else if (arg === "--frontend-live-url") { + options.frontendLiveUrl = readOption(args, index + 1, arg); + index += 1; + } else if (arg === "--api-live-url") { + options.apiLiveUrl = readOption(args, index + 1, arg); + index += 1; + } else if (!isHelpArg(arg)) { + throw new Error(`unknown hwlab cd option: ${arg}`); + } + } + + if (!envSeen) throw new Error("hwlab cd requires --env dev"); + return options; +} + +function makeRunId(): string { + const timestamp = new Date().toISOString().replace(/[:.]/gu, "-"); + return `${timestamp}-${process.pid}-${randomBytes(4).toString("hex")}`; +} + +function redact(value: string): string { + return value + .replace(/\b(Bearer\s+)[A-Za-z0-9._~+/=-]+/giu, "$1") + .replace(/\b(token|password|client-key-data|client-certificate-data|certificate-authority-data)\b(\s*[:=]\s*)(["']?)([^"'\s,}]+)/giu, "$1$2$3") + .replace(/\b(postgres(?:ql)?:\/\/[^:\s/@]+:)([^@\s]+)(@)/giu, "$1$3"); +} + +function boundedTail(value: string): string { + const redacted = redact(value); + return redacted.slice(Math.max(0, redacted.length - tailChars)); +} + +async function runCaptured(command: string[], cwd: string, dumpDir: string, id: string, options: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {}): Promise { + await mkdir(dumpDir, { recursive: true }); + const stdoutPath = join(dumpDir, `${id}.stdout.txt`); + const stderrPath = join(dumpDir, `${id}.stderr.txt`); + writeFileSync(stdoutPath, "", { mode: 0o600 }); + writeFileSync(stderrPath, "", { mode: 0o600 }); + + const startedAt = Date.now(); + let timedOut = false; + let stdoutBytes = 0; + let stderrBytes = 0; + let spawnErrorMessage = ""; + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + const stdout = createWriteStream(stdoutPath, { flags: "a" }); + const stderr = createWriteStream(stderrPath, { flags: "a" }); + + const child = spawn(command[0] ?? "", command.slice(1), { + cwd, + env: options.env ?? process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + const timeout = options.timeoutMs === undefined + ? null + : setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => child.kill("SIGKILL"), 1000).unref(); + }, options.timeoutMs); + + child.stdout.on("data", (chunk: Buffer) => { + stdoutBytes += chunk.byteLength; + stdout.write(chunk); + if (stdoutBytes <= parseCaptureLimitBytes) stdoutChunks.push(Buffer.from(chunk)); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderrBytes += chunk.byteLength; + stderr.write(chunk); + if (stderrBytes <= parseCaptureLimitBytes) stderrChunks.push(Buffer.from(chunk)); + }); + + const closed = await new Promise<{ exitCode: number | null; signal: NodeJS.Signals | null }>((resolveClose) => { + child.on("error", (error) => { + spawnErrorMessage = error.message; + }); + child.on("close", (exitCode, signal) => { + resolveClose({ exitCode, signal }); + }); + }); + if (timeout !== null) clearTimeout(timeout); + await new Promise((resolveEnd) => stdout.end(resolveEnd)); + await new Promise((resolveEnd) => stderr.end(resolveEnd)); + + const stdoutText = stdoutBytes <= parseCaptureLimitBytes ? Buffer.concat(stdoutChunks).toString("utf8") : ""; + const stderrText = stderrBytes <= parseCaptureLimitBytes ? Buffer.concat(stderrChunks).toString("utf8") : spawnErrorMessage; + const exitCode = spawnErrorMessage.length > 0 && closed.exitCode === null ? 127 : closed.exitCode; + return { + id, + command, + cwd, + ok: exitCode === 0 && !timedOut, + exitCode, + signal: closed.signal, + timedOut, + durationMs: Date.now() - startedAt, + stdoutText, + stderrText, + dump: { + stdout: stdoutPath, + stderr: stderrPath, + stdoutBytes, + stderrBytes, + stdoutTail: boundedTail(stdoutText || readFileTail(stdoutPath)), + stderrTail: boundedTail(stderrText || readFileTail(stderrPath)), + }, + }; +} + +function readFileTail(path: string): string { + try { + const size = statSync(path).size; + const bytesToRead = Math.min(size, tailChars * 4); + const buffer = Buffer.alloc(bytesToRead); + const fd = openSync(path, "r"); + try { + readSync(fd, buffer, 0, bytesToRead, size - bytesToRead); + } finally { + closeSync(fd); + } + return buffer.toString("utf8").slice(-tailChars); + } catch { + return ""; + } +} + +function commandView(result: CapturedCommand): CommandView { + return { + id: result.id, + command: result.command, + cwd: result.cwd, + ok: result.ok, + exitCode: result.exitCode, + signal: result.signal, + timedOut: result.timedOut, + durationMs: result.durationMs, + dump: { + stdout: result.dump.stdout, + stderr: result.dump.stderr, + stdoutBytes: result.dump.stdoutBytes, + stderrBytes: result.dump.stderrBytes, + }, + }; +} + +function parseJson(text: string): unknown | null { + if (text.trim().length === 0) return null; + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function defaultRepoCandidates(provided: string | null): { source: string; path: string }[] { + return [ + ...(provided === null ? [] : [{ source: "option", path: provided }]), + ...(process.env.UNIDESK_HWLAB_REPO === undefined ? [] : [{ source: "env:UNIDESK_HWLAB_REPO", path: process.env.UNIDESK_HWLAB_REPO }]), + { source: "default", path: "/workspace/hwlab" }, + { source: "default", path: "/home/ubuntu/workspace/hwlab" }, + { source: "default", path: "/home/ubuntu/hwlab-cd-master-cli" }, + { source: "default", path: "/home/ubuntu/hwlab" }, + ]; +} + +function resolveHwlabRepo(provided: string | null): Record { + const candidates = defaultRepoCandidates(provided).map((candidate) => { + const absolutePath = resolve(candidate.path); + const devCdApply = join(absolutePath, "scripts/dev-cd-apply.mjs"); + const devDeployApply = join(absolutePath, "scripts/dev-deploy-apply.mjs"); + return { + ...candidate, + path: absolutePath, + exists: existsSync(absolutePath), + hasDevCdApply: existsSync(devCdApply), + hasDevDeployApply: existsSync(devDeployApply), + }; + }); + const selected = candidates.find((candidate) => candidate.exists && candidate.hasDevCdApply && candidate.hasDevDeployApply) ?? null; + return { + ok: selected !== null, + selected, + candidates, + }; +} + +async function gitSummary(repoPath: string, dumpDir: string, timeoutMs: number): Promise> { + const [branch, head, originMain, statusShort, statusPorcelain] = await Promise.all([ + runCaptured(["git", "rev-parse", "--abbrev-ref", "HEAD"], repoPath, dumpDir, "git-branch", { timeoutMs }), + runCaptured(["git", "rev-parse", "HEAD"], repoPath, dumpDir, "git-head", { timeoutMs }), + runCaptured(["git", "rev-parse", "--verify", "origin/main^{commit}"], repoPath, dumpDir, "git-origin-main", { timeoutMs }), + runCaptured(["git", "status", "--short", "--branch"], repoPath, dumpDir, "git-status-short", { timeoutMs }), + runCaptured(["git", "status", "--porcelain=v1"], repoPath, dumpDir, "git-status-porcelain", { timeoutMs }), + ]); + const branchName = branch.stdoutText.trim(); + const headCommit = head.stdoutText.trim(); + const originMainCommit = originMain.stdoutText.trim(); + const porcelain = statusPorcelain.stdoutText.trim(); + const statusLines = statusShort.stdoutText.trim().split("\n").filter((line) => line.length > 0); + const dirtyLines = porcelain.length === 0 ? [] : porcelain.split("\n").filter((line) => line.length > 0); + return { + ok: branch.ok && head.ok && statusShort.ok && statusPorcelain.ok, + clean: dirtyLines.length === 0, + branch: branchName || null, + onMain: branchName === "main", + headCommit: headCommit || null, + originMainCommit: originMain.ok ? originMainCommit || null : null, + headMatchesOriginMain: originMain.ok && headCommit.length > 0 && headCommit === originMainCommit, + statusShort: statusLines.slice(0, 40), + dirtyCount: dirtyLines.length, + dirtyPreview: dirtyLines.slice(0, 30), + commands: [branch, head, originMain, statusShort, statusPorcelain].map(commandView), + }; +} + +async function nativeK3sGuard(kubeconfig: string, dumpDir: string, timeoutMs: number): Promise> { + const env = { ...process.env, KUBECONFIG: kubeconfig }; + const [context, server, nodes] = await Promise.all([ + runCaptured(["kubectl", "config", "current-context"], repoRoot, dumpDir, "k3s-current-context", { env, timeoutMs }), + runCaptured(["kubectl", "config", "view", "--minify", "-o", "jsonpath={.clusters[0].cluster.server}"], repoRoot, dumpDir, "k3s-server", { env, timeoutMs }), + runCaptured(["kubectl", "get", "nodes", "-o", "jsonpath={range .items[*]}{.metadata.name}{\"\\n\"}{end}"], repoRoot, dumpDir, "k3s-nodes", { env, timeoutMs }), + ]); + const contextText = context.stdoutText.trim(); + const serverText = server.stdoutText.trim(); + const nodeNames = nodes.stdoutText.split("\n").map((line) => line.trim()).filter((line) => line.length > 0); + const combined = `${context.stdoutText}\n${context.stderrText}\n${server.stdoutText}\n${server.stderrText}\n${nodes.stdoutText}\n${nodes.stderrText}`; + const refusalSignals = [ + /docker-desktop/iu.test(combined) ? "docker-desktop" : null, + /desktop-control-plane/iu.test(combined) ? "desktop-control-plane" : null, + /127\.0\.0\.1:11700/u.test(combined) ? "127.0.0.1:11700" : null, + ].filter((signal): signal is string => signal !== null); + const refusal = refusalSignals.length > 0; + const readable = context.ok && server.ok && nodes.ok; + return { + status: refusal ? "refused" : readable ? "pass" : "blocked", + refusal, + refusalSignals, + kubeconfig, + injectedEnv: { KUBECONFIG: kubeconfig }, + currentContext: contextText || null, + apiServer: serverText || null, + nodeNames, + nodeCount: nodeNames.length, + summary: refusal + ? "Refusing HWLAB CD because kubectl resolved to a Docker Desktop control plane signal." + : readable + ? "D601 native k3s guard passed with explicit KUBECONFIG." + : "D601 native k3s guard could not fully read context, server, and nodes.", + commands: [context, server, nodes].map(commandView), + }; +} + +function annotation(annotations: Record, name: string): string | null { + return stringValue(annotations[`hwlab.pikastech.local/${name}`]); +} + +function classifyLock(lock: Record | null): Record { + if (lock === null || lock.phase === "released") return { held: false, stale: false, retryAfterSeconds: 0, expiresAt: null }; + const updatedAt = stringValue(lock.updatedAt) ?? ""; + const ttlSeconds = Number(lock.ttlSeconds ?? 3600); + const updatedMs = Number.isFinite(Date.parse(updatedAt)) ? Date.parse(updatedAt) : 0; + const expiresAtMs = updatedMs + (Number.isFinite(ttlSeconds) ? ttlSeconds : 3600) * 1000; + const retryAfterSeconds = Math.max(0, Math.ceil((expiresAtMs - Date.now()) / 1000)); + return { + held: retryAfterSeconds > 0, + stale: retryAfterSeconds <= 0, + retryAfterSeconds, + expiresAt: Number.isFinite(expiresAtMs) ? new Date(expiresAtMs).toISOString() : null, + }; +} + +async function cdLockStatus(kubeconfig: string, guard: Record, dumpDir: string, timeoutMs: number): Promise> { + if (guard.refusal === true) { + return { + status: "skipped", + reason: "native-k3s-guard-refused", + present: null, + lockName, + namespace, + }; + } + const env = { ...process.env, KUBECONFIG: kubeconfig }; + const result = await runCaptured(["kubectl", "-n", namespace, "get", "lease", lockName, "-o", "json"], repoRoot, dumpDir, "cd-lock", { env, timeoutMs }); + const text = `${result.stderrText}\n${result.stdoutText}`; + if (!result.ok && /not\s*found|notfound/iu.test(text)) { + return { + status: "absent", + present: false, + held: false, + stale: false, + lockName, + namespace, + command: commandView(result), + }; + } + const parsed = asRecord(parseJson(result.stdoutText)); + const annotations = asRecord(asRecord(parsed?.metadata)?.annotations) ?? {}; + const lock = parsed === null ? null : { + lockBackend: "Lease", + lockName: stringValue(asRecord(parsed.metadata)?.name) ?? lockName, + namespace: stringValue(asRecord(parsed.metadata)?.namespace) ?? namespace, + holderIdentity: stringValue(asRecord(parsed.spec)?.holderIdentity), + resourceVersion: stringValue(asRecord(parsed.metadata)?.resourceVersion), + ownerTaskId: annotation(annotations, "ownerTaskId"), + transactionId: annotation(annotations, "transactionId"), + phase: annotation(annotations, "phase") ?? "unknown", + promotionCommit: annotation(annotations, "promotionCommit"), + deployJsonHash: annotation(annotations, "deployJsonHash"), + targetRef: annotation(annotations, "targetRef"), + targetNamespace: annotation(annotations, "targetNamespace"), + updatedAt: annotation(annotations, "updatedAt") ?? stringValue(asRecord(parsed.spec)?.renewTime), + ttlSeconds: Number(annotation(annotations, "ttlSeconds") ?? asRecord(parsed.spec)?.leaseDurationSeconds ?? 3600), + releaseStatus: annotation(annotations, "releaseStatus"), + releasedAt: annotation(annotations, "releasedAt"), + }; + return { + status: result.ok && lock !== null ? "observed" : "blocked", + present: result.ok && lock !== null, + ...(lock ?? {}), + ...classifyLock(lock), + command: commandView(result), + }; +} + +function summarizeDesiredState(parsed: unknown, command: CapturedCommand): Record { + const record = asRecord(parsed); + const summary = asRecord(record?.summary); + return { + status: stringValue(record?.status) ?? (command.ok ? "unknown" : "blocked"), + kind: stringValue(record?.kind), + desiredCommitId: stringValue(summary?.desiredCommitId), + desiredImageTag: stringValue(summary?.desiredImageTag), + artifactState: stringValue(summary?.artifactState), + ciPublished: summary?.ciPublished ?? null, + registryVerified: summary?.registryVerified ?? null, + services: summary?.services ?? null, + workloadContainers: summary?.workloadContainers ?? null, + diagnostics: summary?.diagnostics ?? null, + blockers: summary?.blockers ?? null, + targetConvergence: summary?.targetConvergence ?? null, + command: commandView(command), + parsed: record !== null, + }; +} + +async function desiredStateStatus(repoPath: string, dumpDir: string, timeoutMs: number): Promise> { + const script = join(repoPath, "scripts/deploy-desired-state-plan.mjs"); + if (!existsSync(script)) { + return { status: "skipped", reason: "scripts/deploy-desired-state-plan.mjs not found" }; + } + const result = await runCaptured(["node", "scripts/deploy-desired-state-plan.mjs", "--pretty"], repoPath, dumpDir, "desired-state-plan", { timeoutMs }); + return summarizeDesiredState(parseJson(result.stdoutText), result); +} + +async function controlledObservability(repoPath: string, dumpDir: string, timeoutMs: number): Promise> { + const script = join(repoPath, "scripts/d601-k3s-readonly-observability.mjs"); + if (!existsSync(script)) { + return { status: "skipped", reason: "scripts/d601-k3s-readonly-observability.mjs not found" }; + } + const result = await runCaptured(["node", "scripts/d601-k3s-readonly-observability.mjs", "--no-write", "--timeout-ms", String(Math.min(timeoutMs, 10_000))], repoPath, dumpDir, "d601-readonly-observability", { + env: { ...process.env, KUBECONFIG: nativeKubeconfig }, + timeoutMs: Math.max(timeoutMs, 15_000), + }); + const parsed = asRecord(parseJson(result.stdoutText)); + return { + status: stringValue(parsed?.conclusion) ?? (result.ok ? "unknown" : "blocked"), + reportKind: stringValue(parsed?.reportKind), + readable: parsed?.readable ?? null, + runnerKubeconfigReadable: parsed?.runnerKubeconfigReadable ?? null, + d601PublicEndpointsReachable: parsed?.d601PublicEndpointsReachable ?? null, + d601K3sUnavailable: parsed?.d601K3sUnavailable ?? null, + attemptedExecutors: parsed?.attemptedExecutors ?? [], + blockers: parsed?.blockers ?? [], + command: commandView(result), + parsed: parsed !== null, + }; +} + +function revisionFromHealth(json: Record | null): string | null { + const commit = asRecord(json?.commit); + const image = asRecord(json?.image); + return stringValue(json?.revision) + ?? stringValue(json?.commitId) + ?? stringValue(commit?.id) + ?? stringValue(image?.tag) + ?? null; +} + +function imageFromHealth(json: Record | null): string | null { + const image = json?.image; + if (typeof image === "string") return image; + return stringValue(asRecord(image)?.reference); +} + +async function fetchLive(url: string, dumpDir: string, id: string, timeoutMs: number): Promise> { + const startedAt = Date.now(); + await mkdir(dumpDir, { recursive: true }); + const bodyPath = join(dumpDir, `${id}.body.txt`); + const errorPath = join(dumpDir, `${id}.error.txt`); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { method: "GET", signal: controller.signal }); + const body = await response.text(); + await writeFile(bodyPath, body, { mode: 0o600 }); + await writeFile(errorPath, "", { mode: 0o600 }); + const json = asRecord(parseJson(body)); + return { + id, + url, + ok: response.ok && json !== null, + httpStatus: response.status, + durationMs: Date.now() - startedAt, + serviceId: stringValue(json?.serviceId) ?? stringValue(asRecord(json?.service)?.id), + environment: stringValue(json?.environment), + applicationStatus: stringValue(json?.status), + ready: json?.ready ?? null, + revision: revisionFromHealth(json), + image: imageFromHealth(json), + blockerCodes: Array.isArray(json?.blockerCodes) ? json.blockerCodes.slice(0, 20) : [], + dump: { + body: bodyPath, + error: errorPath, + bodyBytes: Buffer.byteLength(body, "utf8"), + bodyTail: boundedTail(body), + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await writeFile(bodyPath, "", { mode: 0o600 }); + await writeFile(errorPath, message, { mode: 0o600 }); + return { + id, + url, + ok: false, + httpStatus: null, + durationMs: Date.now() - startedAt, + error: message, + dump: { + body: bodyPath, + error: errorPath, + bodyBytes: 0, + bodyTail: "", + }, + }; + } finally { + clearTimeout(timeout); + } +} + +async function liveRevisionStatus(options: HwlabCdOptions, dumpDir: string): Promise> { + const [frontend, api] = await Promise.all([ + fetchLive(options.frontendLiveUrl, dumpDir, "live-16666", Math.min(options.timeoutMs, 10_000)), + fetchLive(options.apiLiveUrl, dumpDir, "live-16667", Math.min(options.timeoutMs, 10_000)), + ]); + return { + status: frontend.ok === true && api.ok === true ? "observed" : "blocked", + endpoints: [frontend, api], + }; +} + +function collectBlockers(parts: { + git?: Record; + desired?: Record; + guard?: Record; + lock?: Record; + live?: 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.headMatchesOriginMain === false) blockers.push({ scope: "hwlab-git-origin-main", summary: "HWLAB HEAD does not match local origin/main." }); + } + if (parts.desired !== undefined && parts.desired.status !== "pass") { + blockers.push({ scope: "desired-state", summary: `HWLAB desired-state status is ${String(parts.desired.status)}` }); + } + if (parts.guard !== undefined && parts.guard.status !== "pass") { + blockers.push({ scope: "d601-native-k3s-guard", summary: String(parts.guard.summary ?? "D601 native k3s guard is not pass."), refusal: parts.guard.refusal === true }); + } + if (parts.lock !== undefined && parts.lock.held === true) { + blockers.push({ scope: "cd-lock", summary: `HWLAB DEV CD lock is held by ${String(parts.lock.ownerTaskId ?? parts.lock.holderIdentity ?? "unknown")}.` }); + } + if (parts.live !== undefined && parts.live.status !== "observed") { + blockers.push({ scope: "live-revision", summary: "16666/16667 live revision summary is not fully observable." }); + } + if (parts.dryRun !== undefined && parts.dryRun.commandOk === false) { + blockers.push({ scope: "apply-dry-run-command", summary: "HWLAB controlled dry-run command failed." }); + } + if (parts.dryRun !== undefined && parts.dryRun.status === "blocked") { + blockers.push({ scope: "apply-dry-run-blockers", summary: "HWLAB controlled dry-run reported open blockers." }); + } + return blockers; +} + +async function status(options: HwlabCdOptions): Promise> { + const dumpDir = rootPath(".state", "hwlab-cd", makeRunId()); + mkdirSync(dumpDir, { recursive: true, mode: 0o700 }); + const repo = resolveHwlabRepo(options.repoPath); + const selected = asRecord(repo.selected); + if (selected === null) { + return { + ok: false, + status: "blocked", + environment: options.environment, + dumpDir, + repo, + error: "hwlab-repo-not-found", + }; + } + const repoPath = String(selected.path); + const [git, desired, guard, controlled, live] = await Promise.all([ + gitSummary(repoPath, dumpDir, Math.min(options.timeoutMs, 15_000)), + desiredStateStatus(repoPath, dumpDir, options.timeoutMs), + nativeK3sGuard(options.kubeconfig, dumpDir, Math.min(options.timeoutMs, 15_000)), + controlledObservability(repoPath, dumpDir, Math.min(options.timeoutMs, 15_000)), + liveRevisionStatus(options, dumpDir), + ]); + const lock = await cdLockStatus(options.kubeconfig, guard, dumpDir, Math.min(options.timeoutMs, 15_000)); + const blockers = collectBlockers({ git, desired, guard, lock, live }); + return { + ok: guard.refusal !== true, + status: blockers.length === 0 ? "ready" : "blocked", + environment: options.environment, + dryRun: false, + mutation: false, + dumpDir, + hwlabRepo: { + path: repoPath, + source: selected.source, + controlledEntrypoints: { + devCdApply: join(repoPath, "scripts/dev-cd-apply.mjs"), + devDeployApply: join(repoPath, "scripts/dev-deploy-apply.mjs"), + }, + }, + git, + desiredState: desired, + d601NativeK3sGuard: guard, + controlledObservability: controlled, + cdLock: lock, + liveRevisions: live, + blockers, + nextCommands: { + dryRunApply: "bun scripts/cli.ts hwlab cd apply --env dev --dry-run", + fullDump: `find ${JSON.stringify(dumpDir)} -type f -maxdepth 1 -print`, + }, + }; +} + +function summarizeApplyDryRun(parsed: unknown, command: CapturedCommand): Record { + 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", + "scripts/dev-cd-apply.mjs", + "--apply", + "--confirm-dev", + "--confirmed-non-production", + "--write-report", + "--kubeconfig", + options.kubeconfig, + ]; + return { + ok: false, + status: "refused", + environment: options.environment, + dryRun: false, + mutation: false, + dumpDir, + hwlabRepo: { path: repoPath }, + error: "host-commander-only-real-apply", + summary: "UniDesk HWLAB CD wrapper exposes the real DEV apply shape but does not execute it from this runner path. Use dry-run here; host commander must run live apply only after explicit DEV CD authorization.", + hostCommanderOnly: true, + requiredDefaultCommand: "bun scripts/cli.ts hwlab cd apply --env dev --dry-run", + controlledEntrypoint: "scripts/dev-cd-apply.mjs", + controlledCommandShape: controlledCommand, + safety: { + prodTouched: false, + kubectlApplyExecuted: false, + rolloutExecuted: false, + secretValuesRead: false, + }, + }; +} + +async function apply(options: HwlabCdOptions): Promise> { + const dumpDir = rootPath(".state", "hwlab-cd", makeRunId()); + mkdirSync(dumpDir, { recursive: true, mode: 0o700 }); + const repo = resolveHwlabRepo(options.repoPath); + const selected = asRecord(repo.selected); + if (selected === null) { + return { + ok: false, + status: "blocked", + environment: options.environment, + dryRun: options.dryRun, + mutation: false, + dumpDir, + repo, + error: "hwlab-repo-not-found", + }; + } + const repoPath = String(selected.path); + if (!options.dryRun) return realApplyRefusal(options, dumpDir, repoPath); + + const guard = await nativeK3sGuard(options.kubeconfig, dumpDir, Math.min(options.timeoutMs, 15_000)); + if (guard.refusal === true) { + return { + ok: false, + status: "refused", + environment: options.environment, + dryRun: true, + mutation: false, + dumpDir, + hwlabRepo: { path: repoPath, source: selected.source }, + d601NativeK3sGuard: guard, + error: "native-k3s-guard-refused", + summary: "Refusing HWLAB DEV CD dry-run because kubectl resolved to a forbidden Docker Desktop control plane signal.", + }; + } + + const command = await runCaptured([ + "node", + "scripts/dev-deploy-apply.mjs", + "--dry-run", + "--expect-blocked", + "--kubeconfig", + options.kubeconfig, + ], repoPath, dumpDir, "controlled-dev-deploy-apply-dry-run", { + env: { ...process.env, KUBECONFIG: options.kubeconfig }, + timeoutMs: options.timeoutMs, + }); + const dryRun = summarizeApplyDryRun(parseJson(command.stdoutText), command); + const blockers = collectBlockers({ guard, dryRun }); + return { + ok: command.ok, + status: blockers.length === 0 ? "prepared" : "blocked", + environment: options.environment, + dryRun: true, + mutation: false, + dumpDir, + hwlabRepo: { + path: repoPath, + source: selected.source, + controlledEntrypoint: join(repoPath, "scripts/dev-deploy-apply.mjs"), + liveApplyEntrypointShape: join(repoPath, "scripts/dev-cd-apply.mjs"), + }, + d601NativeK3sGuard: guard, + controlledDryRun: dryRun, + blockers, + hostCommanderOnlyLiveApply: { + supportedByShapeOnly: true, + notExecuted: true, + commandShape: [ + "node", + "scripts/dev-cd-apply.mjs", + "--apply", + "--confirm-dev", + "--confirmed-non-production", + "--write-report", + "--kubeconfig", + options.kubeconfig, + ], + }, + }; +} + +export function hwlabHelp(): Record { + return { + command: "hwlab cd", + output: "json", + usage: [ + "bun scripts/cli.ts hwlab cd status --env dev", + "bun scripts/cli.ts hwlab cd apply --env dev --dry-run", + ], + description: "Inspect and prepare HWLAB DEV CD from UniDesk without embedding release kubectl logic. The wrapper calls HWLAB repo-owned scripts and writes full stdout/stderr dumps under .state/hwlab-cd/.", + boundary: [ + `KUBECONFIG is forced to ${nativeKubeconfig}`, + "docker-desktop, desktop-control-plane, and 127.0.0.1:11700 are refusal signals", + "status is read-only and bounded; live apply is host-commander-only and not executed by the dry-run path", + "dry-run apply calls HWLAB scripts/dev-deploy-apply.mjs; live apply shape points at scripts/dev-cd-apply.mjs", + ], + }; +} + +export async function runHwlabCdCommand(args: string[]): Promise> { + if (args.length === 0 || args.some(isHelpArg)) return { ok: true, ...hwlabHelp() }; + const options = parseOptions(args); + if (options.action === "status") return status(options); + return apply(options); +}