feat: harden HWLAB dev CD wrapper
This commit is contained in:
@@ -31,7 +31,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI
|
||||
- `dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]` 创建异步 job,通过 UniDesk SSH 维护桥在 D601 上把开发底座依赖镜像从 Docker 缓存导入原生 k3s containerd。默认镜像是 `postgres:16-alpine` 和 `rancher/mirrored-library-busybox:1.36.1`,用于避免 `postgres-dev` 与 local-path helper pod 卡在外部 registry 拉取。该命令固定验证 `/etc/rancher/k3s/k3s.yaml` 指向的 native k3s 上下文,并输出 `dev_env_containerd_image_ready=...` 作为成功判据;它不 apply manifest、不修改生产 `unidesk` namespace。
|
||||
- `artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service` 管理 D601 host-managed CNCF Distribution registry 的声明、安装、只读检查和 pull-only artifact CD。该 registry 固定为 D601 loopback `127.0.0.1:5000`,由 systemd + Docker Compose 管理,位于 native k3s 故障域外;`deploy-service` 只拉取 CI 已发布的 commit-pinned 镜像、retag/recreate 或导入 native k3s,并做 live commit 验证,不构建 runtime source。`deploy-backend-core` 是 deprecated 兼容名,标准 backend-core prod CD 入口是 `deploy apply --env prod --service backend-core`。长期规则见 `docs/reference/artifact-registry.md`。
|
||||
- `commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run|prompt-lint --kind gpt55-pr` 是 host Codex 指挥官直管微服务 skeleton 入口。当前命令返回 `phase=source-contract`、service/API/state/bridge/prompt/trace/#20/#46/ClaudeQQ 审批边界、.state/commander/ 状态模型、dev 无 daemon smoke contract、dry-run 计划和 GPT-5.5 PR prompt 边界辅助 lint,不接 live bridge、不注入 prompt、不发送 ClaudeQQ。`approval request --dry-run` 会生成 200 字以内中文纯文本 ClaudeQQ 审批草案、`notification-path-unavailable` blocker 和授权后唯一可用的 `bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw` 命令;不得提示使用本机 ClaudeQQ skill、powershell 或本地 server。`prompt-lint` 支持 `--prompt-file` 与 `--stdin`,输出 `ok`、`missingClauses`、`riskLevel`、`suggestedPatchSnippet` 且不回显完整 prompt;它是 commander 辅助检查,不是业务 PR 门禁,也不改变 `codex submit` 默认行为。`plan`、`smoke` 与 `approval request` 必须带 `--dry-run`;缺少时返回 `error=dry-run-required`。长期规则见 `docs/reference/host-codex-commander.md`。
|
||||
- `hwlab cd status --env dev` 和 `hwlab cd apply --env dev --dry-run` 是 HWLAB DEV CD 指挥侧 wrapper。它只调用 HWLAB repo-owned 受控入口,不内嵌发布 kubectl 逻辑:`status` 汇总 HWLAB repo path、Git clean/main/origin-main、`deploy/deploy.json`/artifact catalog/workloads 一致性、D601 native k3s guard、CD Lease lock、16666/16667 live revision;完整 stdout/stderr 写入 `.state/hwlab-cd/<run-id>/`,stdout 只返回有界摘要。wrapper 强制 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并只以这个显式目标作为 gate;显式目标出现 `docker-desktop`、`desktop-control-plane` 或 `127.0.0.1:11700` 信号会结构化拒绝,裸 `kubectl` 默认 context 只作为诊断。`apply --dry-run` 调用 HWLAB `scripts/dev-deploy-apply.mjs --dry-run --expect-blocked --kubeconfig /etc/rancher/k3s/k3s.yaml`;真实 apply 只暴露 `scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --write-report` 命令形状并标注 host-commander-only,本 runner 不执行 live apply。长期规则见 `docs/reference/hwlab.md`。
|
||||
- `hwlab cd status|preflight|apply --env dev [--dry-run]` 是 HWLAB DEV CD 指挥侧 wrapper。它只调用 HWLAB repo-owned 受控入口,不内嵌发布 kubectl 逻辑:`status` 汇总固定 CD mirror、Git clean/main/origin-main、`deploy/deploy.json`/artifact catalog/workloads 同源收敛、D601 native k3s guard、CD Lease lock、16666/16667 live revision 和当前 live workload image;`preflight` 进一步检查必需 SecretRef 对象/键存在性并运行 HWLAB `scripts/dev-cd-apply.mjs --dry-run` 受控事务摘要;完整 stdout/stderr 写入 `.state/hwlab-cd/<run-id>/`,stdout 只返回有界摘要。默认 HWLAB CD repo 是 `/home/ubuntu/hwlab_cd`,`/home/ubuntu/hwlab` runner 历史目录不得作为发布真相。wrapper 强制 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并只以这个显式目标作为 gate;显式目标出现 `docker-desktop`、`desktop-control-plane` 或 `127.0.0.1:11700` 信号会结构化拒绝,写操作计划还必须观察到 node `d601`。`apply --dry-run` 只调用 HWLAB `scripts/dev-cd-apply.mjs --dry-run --kubeconfig /etc/rancher/k3s/k3s.yaml`;真实 apply 只暴露 `scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --write-report` 命令形状并标注 host-commander-only,本 runner 不执行 live apply。长期规则见 `docs/reference/hwlab.md`。
|
||||
- `gh auth status [--repo owner/name]` 探测 GitHub 操作前置条件并输出脱敏 JSON:是否存在 `gh` binary、是否存在 `GH_TOKEN`/`GITHUB_TOKEN` 或可用 `gh auth token` fallback、REST API 是否可达、目标 repo 是否可见、issue 是否可读。degraded reason 必须归类为 `missing-binary`、`missing-token`、`auth-failed`、`github-transient`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response` 或 `unsupported-command`,不得打印 token;失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应优先用该字段分流。`github-transient` 表示 GitHub DNS/API 连接在收到 HTTP 状态前失败,输出应带 `retryable=true` 或等价 commander action;这不是缺 token、认证失败、权限不足或 PR 语义失败。
|
||||
- `codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]` 是派发/steer 前的本地 dry-run prompt lint。它只读取 prompt 文本,返回 `dryRun=true`、`mutation=false`、`declaredClass`、`effectiveClass`、`requiredClass`、`dispatchDisposition`、缺失或矛盾项和有界 evidence,不访问 live service、不提交任务、不打印完整 prompt。分级固定为 `read-only`、`live-read`、`live-mutating`;未声明时按 `read-only` 处理。`codex submit --dry-run` 与 `codex steer --dry-run` 会嵌入同一 `promptLint` 结果,帮助指挥官在 dispatch/steer 前发现缺失或矛盾的 live mutation 授权。长期规则见 `docs/reference/code-queue-supervision.md` 的 DEV 测试授权分级。
|
||||
- `gh issue list [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]` 通过 GitHub REST 列出 issue,默认 `state=open`、`limit=30`,输出稳定 JSON 且不依赖系统 `gh` binary。`--limit` 会映射到 GitHub `per_page` 并限制返回数量,避免一次拉爆上下文;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。GitHub issues API 可能混入 PR,CLI 会从 `.data.issues` 中过滤 pull request。
|
||||
|
||||
@@ -46,15 +46,19 @@ UniDesk 指挥侧固定入口:
|
||||
|
||||
```sh
|
||||
bun scripts/cli.ts hwlab cd status --env dev
|
||||
bun scripts/cli.ts hwlab cd preflight --env dev
|
||||
bun scripts/cli.ts hwlab cd apply --env dev --dry-run
|
||||
```
|
||||
|
||||
wrapper 的职责是把 host commander 常用的 HWLAB DEV rollout 查看/准备动作收敛到单一入口。它只调用 HWLAB repo-owned 受控脚本,不在 UniDesk 内重写发布流程或拼接 ad hoc `kubectl apply`:
|
||||
|
||||
- `status` 只读汇总 HWLAB repo path、Git clean/main/origin-main、`deploy/deploy.json`/`deploy/artifact-catalog.dev.json`/`deploy/k8s/base/workloads.yaml` 一致性、D601 native k3s guard、`Lease/hwlab-dev/hwlab-dev-cd-lock`、公网 `16666/16667` live revision。
|
||||
- `apply --dry-run` 调用 HWLAB `scripts/dev-deploy-apply.mjs --dry-run --expect-blocked --kubeconfig /etc/rancher/k3s/k3s.yaml`,只生成准备/阻塞摘要,不做真实 apply、rollout 或 live verification。
|
||||
- 默认 HWLAB CD repo 是 D601 固定干净 mirror `/home/ubuntu/hwlab_cd`,也可用 `--hwlab-repo` 显式指定同等干净 clone。wrapper 必须检查 `git status --short --branch`、origin remote、当前 branch `main`、本地 `origin/main`、`FETCH_HEAD` 和 worktree 权限;任何 dirty worktree、错误 remote、非 main、HEAD 未跟上本地 `origin/main` 或权限异常都返回结构化 blocker。`/home/ubuntu/hwlab` 是 runner 历史目录,不得作为发布真相。
|
||||
- `deploy/deploy.json` 是唯一 desired-state。wrapper 只把 `deploy/artifact-catalog.dev.json`、`deploy/k8s/base/workloads.yaml` 和 `reports/dev-gate/dev-artifacts.json` 当作派生/证据读数;`status`/`preflight` 必须显示 target commit/ref、deploy.json、artifact catalog、workloads 和 live workload image 是否同源/收敛,不引入第二套 desired state。
|
||||
- `status` 只读汇总 HWLAB repo path、Git clean/main/origin-main、desired-state 收敛、D601 native k3s guard、`Lease/hwlab-dev/hwlab-dev-cd-lock`、公网 `16666/16667` live revision 和当前 live workload image。
|
||||
- `preflight` 在 `status` 的基础上检查 apply 前 SecretRef:`hwlab-cloud-api-dev-db/database-url`、`hwlab-cloud-api-dev-db-admin/admin-url`、`hwlab-code-agent-provider/openai-api-key`。只验证 Secret 对象和 key 元数据存在性,缺失时返回 blocker、影响范围和修复 runbook;禁止读取或打印 Secret value。
|
||||
- `apply --dry-run` 调用 HWLAB `scripts/dev-cd-apply.mjs --dry-run --kubeconfig /etc/rancher/k3s/k3s.yaml`,只生成受控事务准备/阻塞摘要,不做真实 apply、rollout 或 live verification。历史 `scripts/dev-deploy-apply.mjs` 可作为 HWLAB 内部支持脚本出现,但 UniDesk wrapper 不能把它当成平行 CD 入口。
|
||||
- 完整下游 stdout/stderr、HTTP body 和 kubectl 读命令输出写入 UniDesk `.state/hwlab-cd/<run-id>/` dump 目录;CLI stdout 只显示有界摘要和 dump path。
|
||||
- wrapper 显式注入 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并以这个显式目标作为唯一 gate:目标 context/server/nodes 若出现 `docker-desktop`、`desktop-control-plane` 或 `127.0.0.1:11700` 必须拒绝继续,目标 nodes 未包含 `d601` 必须阻断。裸 `kubectl` 默认 context 只作为诊断输出;即使默认 kubeconfig 仍残留 Docker Desktop,只要显式 D601 kubeconfig 通过,也不能把默认 context 当成 CD blocker。
|
||||
- wrapper 显式注入 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并以这个显式目标作为唯一 gate:目标 context/server/nodes 若出现 `docker-desktop`、`desktop-control-plane` 或 `127.0.0.1:11700` 必须拒绝继续,写操作计划或 `apply --dry-run` 前目标 nodes 必须包含 `d601`。裸 `kubectl` 默认 context 只作为诊断输出;即使默认 kubeconfig 仍残留 Docker Desktop,只要显式 D601 kubeconfig 通过,也不能把默认 context 当成 CD blocker。
|
||||
|
||||
真实 DEV apply 只允许 host commander 在明确授权后执行。UniDesk wrapper 可以展示受控命令形状:
|
||||
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -22,6 +22,7 @@ const syntaxFiles = [
|
||||
"scripts/src/code-queue.ts",
|
||||
"scripts/src/command.ts",
|
||||
"scripts/src/d601-k3s-guard.ts",
|
||||
"scripts/src/hwlab-cd.ts",
|
||||
"scripts/src/decision-center.ts",
|
||||
"scripts/src/dev-env.ts",
|
||||
"scripts/src/deploy.ts",
|
||||
@@ -48,6 +49,7 @@ const syntaxFiles = [
|
||||
"scripts/code-queue-cli-read-terminal-contract-test.ts",
|
||||
"scripts/code-queue-gh-auth-redaction-contract-test.ts",
|
||||
"scripts/d601-recovery-guardrails-contract-test.ts",
|
||||
"scripts/hwlab-cd-wrapper-contract-test.ts",
|
||||
"scripts/code-queue-queues-shape-contract-test.ts",
|
||||
"scripts/microservice-health-output-contract-test.ts",
|
||||
"scripts/code-queue-supervisor-disclosure-contract-test.ts",
|
||||
@@ -386,6 +388,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
fileItem("scripts/src/auth-broker.ts"),
|
||||
fileItem("scripts/auth-broker-contract-test.ts"),
|
||||
fileItem("scripts/d601-recovery-guardrails-contract-test.ts"),
|
||||
fileItem("scripts/hwlab-cd-wrapper-contract-test.ts"),
|
||||
fileItem("src/components/microservices/auth-broker/Cargo.toml"),
|
||||
fileItem("src/components/microservices/auth-broker/Dockerfile"),
|
||||
fileItem("src/components/microservices/auth-broker/src/main.rs"),
|
||||
@@ -439,6 +442,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
items.push(commandItem("playwright:cli-wrapper-contract", ["bun", "scripts/playwright-cli-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("auth-broker:p0-contract", ["bun", "scripts/auth-broker-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("d601:recovery-guardrails-contract", ["bun", "scripts/d601-recovery-guardrails-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("hwlab:cd-wrapper-contract", ["bun", "scripts/hwlab-cd-wrapper-contract-test.ts"], 30_000));
|
||||
} else {
|
||||
items.push(skippedItem("typescript:scripts", "scripts TypeScript typecheck is opt-in", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:prompt-observation-contract", "prompt observation contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
@@ -479,6 +483,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
items.push(skippedItem("playwright:cli-wrapper-contract", "Playwright wrapper/headless/session contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("auth-broker:p0-contract", "Auth Broker P0 skeleton and CLI adapter contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("d601:recovery-guardrails-contract", "D601 recovery guardrails fixture contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("hwlab:cd-wrapper-contract", "HWLAB DEV CD wrapper contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
}
|
||||
if (options.logs) {
|
||||
items.push(unifiedLogRotationItem());
|
||||
|
||||
+356
-88
@@ -1,6 +1,6 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createWriteStream, existsSync, mkdirSync, openSync, readSync, statSync, writeFileSync, closeSync } from "node:fs";
|
||||
import { accessSync, constants as fsConstants, createWriteStream, existsSync, mkdirSync, openSync, readSync, statSync, writeFileSync, closeSync } from "node:fs";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { repoRoot, rootPath } from "./config";
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
d601NativeKubeconfig,
|
||||
} from "./d601-k3s-guard";
|
||||
|
||||
type HwlabCdAction = "status" | "apply";
|
||||
type HwlabCdAction = "status" | "preflight" | "apply";
|
||||
type HwlabCdEnvironment = "dev";
|
||||
|
||||
interface HwlabCdOptions {
|
||||
@@ -65,10 +65,16 @@ interface CommandView {
|
||||
const namespace = "hwlab-dev";
|
||||
const lockName = "hwlab-dev-cd-lock";
|
||||
const nativeKubeconfig = d601NativeKubeconfig;
|
||||
const defaultHwlabCdRepoPath = "/home/ubuntu/hwlab_cd";
|
||||
const defaultFrontendLiveUrl = "http://74.48.78.17:16666/health/live";
|
||||
const defaultApiLiveUrl = "http://74.48.78.17:16667/health/live";
|
||||
const parseCaptureLimitBytes = 4 * 1024 * 1024;
|
||||
const tailChars = 1000;
|
||||
const requiredSecretRefs = [
|
||||
{ secretName: "hwlab-cloud-api-dev-db", secretKey: "database-url", consumers: ["hwlab-cloud-api"] },
|
||||
{ secretName: "hwlab-cloud-api-dev-db-admin", secretKey: "admin-url", consumers: ["runtime provisioning", "runtime migration"] },
|
||||
{ secretName: "hwlab-code-agent-provider", secretKey: "openai-api-key", consumers: ["hwlab-cloud-api", "code agent provider"] },
|
||||
];
|
||||
|
||||
function isHelpArg(value: string | undefined): boolean {
|
||||
return value === "help" || value === "--help" || value === "-h";
|
||||
@@ -88,8 +94,8 @@ function parsePositiveInteger(value: string, option: string): number {
|
||||
|
||||
function parseOptions(args: string[]): HwlabCdOptions {
|
||||
const [scope, actionArg] = args;
|
||||
if (scope !== "cd") throw new Error("hwlab usage: bun scripts/cli.ts hwlab cd status|apply --env dev");
|
||||
if (actionArg !== "status" && actionArg !== "apply") throw new Error("hwlab cd usage: status|apply");
|
||||
if (scope !== "cd") throw new Error("hwlab usage: bun scripts/cli.ts hwlab cd status|preflight|apply --env dev");
|
||||
if (actionArg !== "status" && actionArg !== "preflight" && actionArg !== "apply") throw new Error("hwlab cd usage: status|preflight|apply");
|
||||
|
||||
const options: HwlabCdOptions = {
|
||||
action: actionArg,
|
||||
@@ -289,14 +295,21 @@ function stringValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function accessCheck(path: string, mode: number): Record<string, unknown> {
|
||||
try {
|
||||
accessSync(path, mode);
|
||||
return { ok: true, path };
|
||||
} catch (error) {
|
||||
return { ok: false, path, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function defaultRepoCandidates(provided: string | null): { source: string; path: string }[] {
|
||||
if (provided !== null) return [{ source: "option", path: provided }];
|
||||
if (process.env.UNIDESK_HWLAB_REPO !== undefined) return [{ source: "env:UNIDESK_HWLAB_REPO", path: process.env.UNIDESK_HWLAB_REPO }];
|
||||
return [
|
||||
...(provided === null ? [] : [{ source: "option", path: provided }]),
|
||||
...(process.env.UNIDESK_HWLAB_REPO === undefined ? [] : [{ source: "env:UNIDESK_HWLAB_REPO", path: process.env.UNIDESK_HWLAB_REPO }]),
|
||||
{ source: "default", path: "/workspace/hwlab" },
|
||||
{ source: "default", path: "/home/ubuntu/workspace/hwlab" },
|
||||
{ source: "default", path: "/home/ubuntu/hwlab-cd-master-cli" },
|
||||
{ source: "default", path: "/home/ubuntu/hwlab" },
|
||||
{ source: "default:d601-clean-mirror", path: defaultHwlabCdRepoPath },
|
||||
{ source: "rejected:runner-history", path: "/home/ubuntu/hwlab" },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -304,49 +317,72 @@ function resolveHwlabRepo(provided: string | null): Record<string, unknown> {
|
||||
const candidates = defaultRepoCandidates(provided).map((candidate) => {
|
||||
const absolutePath = resolve(candidate.path);
|
||||
const devCdApply = join(absolutePath, "scripts/dev-cd-apply.mjs");
|
||||
const devDeployApply = join(absolutePath, "scripts/dev-deploy-apply.mjs");
|
||||
const desiredStatePlan = join(absolutePath, "scripts/deploy-desired-state-plan.mjs");
|
||||
const rejected = candidate.source.startsWith("rejected:") || absolutePath === "/home/ubuntu/hwlab";
|
||||
return {
|
||||
...candidate,
|
||||
path: absolutePath,
|
||||
exists: existsSync(absolutePath),
|
||||
eligible: !rejected,
|
||||
rejected,
|
||||
rejectionReason: rejected ? "runner-history-directory-is-not-hwlab-cd-release-truth" : null,
|
||||
hasDevCdApply: existsSync(devCdApply),
|
||||
hasDevDeployApply: existsSync(devDeployApply),
|
||||
hasDesiredStatePlan: existsSync(desiredStatePlan),
|
||||
};
|
||||
});
|
||||
const selected = candidates.find((candidate) => candidate.exists && candidate.hasDevCdApply && candidate.hasDevDeployApply) ?? null;
|
||||
const selected = candidates.find((candidate) => candidate.eligible && candidate.exists && candidate.hasDevCdApply && candidate.hasDesiredStatePlan) ?? null;
|
||||
return {
|
||||
ok: selected !== null,
|
||||
defaultPath: defaultHwlabCdRepoPath,
|
||||
selected,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
async function gitSummary(repoPath: string, dumpDir: string, timeoutMs: number): Promise<Record<string, unknown>> {
|
||||
const [branch, head, originMain, statusShort, statusPorcelain] = await Promise.all([
|
||||
const [branch, head, originMain, remote, gitDir, statusShort, statusPorcelain] = await Promise.all([
|
||||
runCaptured(["git", "rev-parse", "--abbrev-ref", "HEAD"], repoPath, dumpDir, "git-branch", { timeoutMs }),
|
||||
runCaptured(["git", "rev-parse", "HEAD"], repoPath, dumpDir, "git-head", { timeoutMs }),
|
||||
runCaptured(["git", "rev-parse", "--verify", "origin/main^{commit}"], repoPath, dumpDir, "git-origin-main", { timeoutMs }),
|
||||
runCaptured(["git", "remote", "get-url", "origin"], repoPath, dumpDir, "git-remote-origin", { timeoutMs }),
|
||||
runCaptured(["git", "rev-parse", "--path-format=absolute", "--git-dir"], repoPath, dumpDir, "git-dir", { timeoutMs }),
|
||||
runCaptured(["git", "status", "--short", "--branch"], repoPath, dumpDir, "git-status-short", { timeoutMs }),
|
||||
runCaptured(["git", "status", "--porcelain=v1"], repoPath, dumpDir, "git-status-porcelain", { timeoutMs }),
|
||||
]);
|
||||
const branchName = branch.stdoutText.trim();
|
||||
const headCommit = head.stdoutText.trim();
|
||||
const originMainCommit = originMain.stdoutText.trim();
|
||||
const remoteUrl = remote.stdoutText.trim();
|
||||
const gitDirPath = gitDir.stdoutText.trim();
|
||||
const fetchHeadPath = gitDirPath.length > 0 ? join(gitDirPath, "FETCH_HEAD") : join(repoPath, ".git", "FETCH_HEAD");
|
||||
const porcelain = statusPorcelain.stdoutText.trim();
|
||||
const statusLines = statusShort.stdoutText.trim().split("\n").filter((line) => line.length > 0);
|
||||
const dirtyLines = porcelain.length === 0 ? [] : porcelain.split("\n").filter((line) => line.length > 0);
|
||||
const worktreeAccess = accessCheck(repoPath, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK);
|
||||
const fetchHeadAccess = accessCheck(fetchHeadPath, fsConstants.R_OK);
|
||||
const remoteMatches = /github\.com[:/]pikasTech\/HWLAB(?:\.git)?$/u.test(remoteUrl);
|
||||
return {
|
||||
ok: branch.ok && head.ok && statusShort.ok && statusPorcelain.ok,
|
||||
ok: branch.ok && head.ok && remote.ok && gitDir.ok && statusShort.ok && statusPorcelain.ok && worktreeAccess.ok && fetchHeadAccess.ok,
|
||||
clean: dirtyLines.length === 0,
|
||||
branch: branchName || null,
|
||||
onMain: branchName === "main",
|
||||
remote: remoteUrl || null,
|
||||
remoteMatches,
|
||||
expectedRemote: "git@github.com:pikasTech/HWLAB.git or https://github.com/pikasTech/HWLAB.git",
|
||||
headCommit: headCommit || null,
|
||||
originMainCommit: originMain.ok ? originMainCommit || null : null,
|
||||
headMatchesOriginMain: originMain.ok && headCommit.length > 0 && headCommit === originMainCommit,
|
||||
worktreeAccess,
|
||||
fetchHead: {
|
||||
path: fetchHeadPath,
|
||||
exists: existsSync(fetchHeadPath),
|
||||
readable: fetchHeadAccess.ok,
|
||||
error: fetchHeadAccess.error,
|
||||
},
|
||||
statusShort: statusLines.slice(0, 40),
|
||||
dirtyCount: dirtyLines.length,
|
||||
dirtyPreview: dirtyLines.slice(0, 30),
|
||||
commands: [branch, head, originMain, statusShort, statusPorcelain].map(commandView),
|
||||
commands: [branch, head, originMain, remote, gitDir, statusShort, statusPorcelain].map(commandView),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -385,6 +421,48 @@ async function nativeK3sGuard(kubeconfig: string, dumpDir: string, timeoutMs: nu
|
||||
};
|
||||
}
|
||||
|
||||
async function liveWorkloadStatus(kubeconfig: string, guard: Record<string, unknown>, dumpDir: string, timeoutMs: number): Promise<Record<string, unknown>> {
|
||||
if (guard.refusal === true || guard.status !== "pass") {
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "d601-native-k3s-guard-not-pass",
|
||||
mutation: false,
|
||||
namespace,
|
||||
};
|
||||
}
|
||||
const env = { ...process.env, KUBECONFIG: kubeconfig };
|
||||
const jsonpath = "{range .items[*]}{.metadata.name}{\"\\t\"}{range .spec.template.spec.containers[*]}{.name}{\"=\"}{.image}{\",\"}{end}{\"\\n\"}{end}";
|
||||
const result = await runCaptured(["kubectl", "-n", namespace, "get", "deploy", "-o", `jsonpath=${jsonpath}`], repoRoot, dumpDir, "live-workload-images", { env, timeoutMs });
|
||||
const workloads = result.stdoutText
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.slice(0, 80)
|
||||
.map((line) => {
|
||||
const [deployment, rawContainers = ""] = line.split("\t");
|
||||
const containers = rawContainers
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
.slice(0, 20)
|
||||
.map((item) => {
|
||||
const separator = item.indexOf("=");
|
||||
const name = separator === -1 ? item : item.slice(0, separator);
|
||||
const image = separator === -1 ? null : item.slice(separator + 1);
|
||||
return { name, image, tag: image === null ? null : image.split(":").at(-1) ?? null };
|
||||
});
|
||||
return { deployment, containers };
|
||||
});
|
||||
return {
|
||||
status: result.ok ? "observed" : "blocked",
|
||||
namespace,
|
||||
mutation: false,
|
||||
workloadCount: workloads.length,
|
||||
workloads,
|
||||
command: commandView(result),
|
||||
};
|
||||
}
|
||||
|
||||
function annotation(annotations: Record<string, unknown>, name: string): string | null {
|
||||
return stringValue(annotations[`hwlab.pikastech.local/${name}`]);
|
||||
}
|
||||
@@ -460,9 +538,24 @@ async function cdLockStatus(kubeconfig: string, guard: Record<string, unknown>,
|
||||
function summarizeDesiredState(parsed: unknown, command: CapturedCommand): Record<string, unknown> {
|
||||
const record = asRecord(parsed);
|
||||
const summary = asRecord(record?.summary);
|
||||
const source = asRecord(record?.source);
|
||||
const promotionBoundary = asRecord(record?.promotionBoundary);
|
||||
return {
|
||||
status: stringValue(record?.status) ?? (command.ok ? "unknown" : "blocked"),
|
||||
kind: stringValue(record?.kind),
|
||||
mode: stringValue(record?.mode),
|
||||
source: {
|
||||
deployJson: stringValue(source?.deploy) ?? "deploy/deploy.json",
|
||||
artifactCatalog: stringValue(source?.artifactCatalog) ?? "deploy/artifact-catalog.dev.json",
|
||||
workloads: stringValue(source?.workloads) ?? "deploy/k8s/base/workloads.yaml",
|
||||
optionalReport: stringValue(source?.optionalReport),
|
||||
},
|
||||
authoritativeDesiredState: Array.isArray(promotionBoundary?.authoritativeDesiredState)
|
||||
? promotionBoundary.authoritativeDesiredState.slice(0, 10)
|
||||
: ["deploy/deploy.json", "deploy/artifact-catalog.dev.json", "deploy/k8s/base/workloads.yaml"],
|
||||
nonAuthoritativeEvidence: Array.isArray(promotionBoundary?.nonAuthoritativeEvidence)
|
||||
? promotionBoundary.nonAuthoritativeEvidence.slice(0, 10)
|
||||
: [],
|
||||
desiredCommitId: stringValue(summary?.desiredCommitId),
|
||||
desiredImageTag: stringValue(summary?.desiredImageTag),
|
||||
artifactState: stringValue(summary?.artifactState),
|
||||
@@ -511,6 +604,117 @@ async function controlledObservability(repoPath: string, dumpDir: string, timeou
|
||||
};
|
||||
}
|
||||
|
||||
async function controlledDevCdDryRun(repoPath: string, kubeconfig: string, dumpDir: string, timeoutMs: number): Promise<Record<string, unknown>> {
|
||||
const result = await runCaptured(["node", "scripts/dev-cd-apply.mjs", "--dry-run", "--kubeconfig", kubeconfig, "--skip-live-verify"], repoPath, dumpDir, "controlled-dev-cd-apply-dry-run", {
|
||||
env: { ...process.env, KUBECONFIG: kubeconfig },
|
||||
timeoutMs,
|
||||
});
|
||||
const parsed = asRecord(parseJson(result.stdoutText));
|
||||
const target = asRecord(parsed?.target);
|
||||
const desiredStateCheck = asRecord(target?.desiredStateCheck);
|
||||
const artifactBoundary = asRecord(target?.artifactBoundary);
|
||||
return {
|
||||
status: stringValue(parsed?.status) ?? (result.ok ? "unknown" : "blocked"),
|
||||
commandOk: result.ok,
|
||||
mode: stringValue(parsed?.mode),
|
||||
mutationAttempted: parsed?.mutationAttempted ?? false,
|
||||
prodTouched: parsed?.prodTouched ?? false,
|
||||
target: target === null ? null : {
|
||||
ref: stringValue(target.ref),
|
||||
promotionCommit: stringValue(target.promotionCommit),
|
||||
shortCommitId: stringValue(target.shortCommitId),
|
||||
promotionSource: stringValue(target.promotionSource),
|
||||
publishRequired: target.publishRequired ?? null,
|
||||
headCommitId: stringValue(target.headCommitId),
|
||||
headMatchesTarget: target.headMatchesTarget ?? null,
|
||||
desiredStateCheck,
|
||||
artifactBoundary: artifactBoundary === null ? null : {
|
||||
status: stringValue(artifactBoundary.status),
|
||||
desiredState: artifactBoundary.desiredState ?? null,
|
||||
},
|
||||
namespace: stringValue(target.namespace) ?? namespace,
|
||||
},
|
||||
deployJson: asRecord(parsed?.deployJson),
|
||||
artifactCatalog: asRecord(parsed?.artifactCatalog),
|
||||
artifactReport: asRecord(parsed?.artifactReport),
|
||||
lock: asRecord(parsed?.lock),
|
||||
liveDelta: asRecord(parsed?.liveDelta),
|
||||
nextActions: Array.isArray(parsed?.nextActions) ? parsed.nextActions.slice(0, 20) : [],
|
||||
controlledEntrypoint: "scripts/dev-cd-apply.mjs",
|
||||
command: commandView(result),
|
||||
parsed: parsed !== null,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
}
|
||||
|
||||
async function requiredSecretRefPreflight(kubeconfig: string, guard: Record<string, unknown>, dumpDir: string, timeoutMs: number): Promise<Record<string, unknown>> {
|
||||
if (guard.refusal === true || guard.status !== "pass") {
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "d601-native-k3s-guard-not-pass",
|
||||
namespace,
|
||||
mutation: false,
|
||||
safety: {
|
||||
secretValuesRead: false,
|
||||
secretValuesPrinted: false,
|
||||
secretKeyNamesOnly: true,
|
||||
},
|
||||
secretRefs: requiredSecretRefs.map((ref) => ({ ...ref, status: "not_checked" })),
|
||||
blockers: [],
|
||||
};
|
||||
}
|
||||
const env = { ...process.env, KUBECONFIG: kubeconfig };
|
||||
const observations = await Promise.all(requiredSecretRefs.map(async (ref) => {
|
||||
const idBase = `secretref-${ref.secretName}-${ref.secretKey}`.replace(/[^A-Za-z0-9_.-]/gu, "-");
|
||||
const exists = await runCaptured(["kubectl", "-n", namespace, "get", "secret", ref.secretName, "-o", "name"], repoRoot, dumpDir, `${idBase}-exists`, { env, timeoutMs });
|
||||
if (!exists.ok) {
|
||||
return {
|
||||
...ref,
|
||||
status: "missing-secret",
|
||||
exists: false,
|
||||
keyPresent: false,
|
||||
command: commandView(exists),
|
||||
};
|
||||
}
|
||||
const describe = await runCaptured(["kubectl", "-n", namespace, "describe", "secret", ref.secretName], repoRoot, dumpDir, `${idBase}-describe`, { env, timeoutMs });
|
||||
const keyPattern = new RegExp(`(?:^|\\s)${escapeRegExp(ref.secretKey)}(?:\\s|:|$)`, "mu");
|
||||
const keyPresent = describe.ok && keyPattern.test(describe.stdoutText);
|
||||
return {
|
||||
...ref,
|
||||
status: keyPresent ? "present" : describe.ok ? "missing-key" : "key-observation-blocked",
|
||||
exists: true,
|
||||
keyPresent,
|
||||
command: commandView(describe),
|
||||
};
|
||||
}));
|
||||
const blockers = observations
|
||||
.filter((observation) => observation.status !== "present")
|
||||
.map((observation) => ({
|
||||
scope: `secretref:${observation.secretName}/${observation.secretKey}`,
|
||||
summary: observation.status === "missing-secret"
|
||||
? `Required Secret ${observation.secretName} is missing in ${namespace}.`
|
||||
: `Required SecretRef ${observation.secretName}/${observation.secretKey} is not present as key metadata in ${namespace}.`,
|
||||
impact: `${observation.consumers.join(", ")} would fail after a DEV CD apply or runtime preflight Job.`,
|
||||
runbook: `Create or repair Secret ${observation.secretName} with key ${observation.secretKey} in namespace ${namespace}; verify key presence only and do not print the Secret value.`,
|
||||
}));
|
||||
return {
|
||||
status: blockers.length === 0 ? "pass" : "blocked",
|
||||
namespace,
|
||||
mutation: false,
|
||||
safety: {
|
||||
secretValuesRead: false,
|
||||
secretValuesPrinted: false,
|
||||
secretKeyNamesOnly: true,
|
||||
},
|
||||
requiredSecretRefs: requiredSecretRefs.map((ref) => `${ref.secretName}/${ref.secretKey}`),
|
||||
secretRefs: observations,
|
||||
blockers,
|
||||
};
|
||||
}
|
||||
|
||||
function revisionFromHealth(json: Record<string, unknown> | null): string | null {
|
||||
const commit = asRecord(json?.commit);
|
||||
const image = asRecord(json?.image);
|
||||
@@ -600,13 +804,20 @@ function collectBlockers(parts: {
|
||||
guard?: Record<string, unknown>;
|
||||
lock?: Record<string, unknown>;
|
||||
live?: Record<string, unknown>;
|
||||
secretRefs?: Record<string, unknown>;
|
||||
controlled?: Record<string, unknown>;
|
||||
dryRun?: Record<string, unknown>;
|
||||
}): Record<string, unknown>[] {
|
||||
const blockers: Record<string, unknown>[] = [];
|
||||
if (parts.git !== undefined) {
|
||||
if (parts.git.clean === false) blockers.push({ scope: "hwlab-git-clean", summary: "HWLAB repo has local modifications." });
|
||||
if (parts.git.onMain === false) blockers.push({ scope: "hwlab-git-main", summary: "HWLAB repo is not on main." });
|
||||
if (parts.git.remoteMatches === false) blockers.push({ scope: "hwlab-git-remote", summary: "HWLAB repo origin remote is not pikasTech/HWLAB." });
|
||||
if (parts.git.headMatchesOriginMain === false) blockers.push({ scope: "hwlab-git-origin-main", summary: "HWLAB HEAD does not match local origin/main." });
|
||||
const worktreeAccess = asRecord(parts.git.worktreeAccess);
|
||||
if (worktreeAccess?.ok === false) blockers.push({ scope: "hwlab-worktree-permission", summary: "HWLAB CD worktree is not readable/writable by the wrapper." });
|
||||
const fetchHead = asRecord(parts.git.fetchHead);
|
||||
if (fetchHead?.readable === false) blockers.push({ scope: "hwlab-fetch-head-permission", summary: "HWLAB CD FETCH_HEAD is missing or unreadable." });
|
||||
}
|
||||
if (parts.desired !== undefined && parts.desired.status !== "pass") {
|
||||
blockers.push({ scope: "desired-state", summary: `HWLAB desired-state status is ${String(parts.desired.status)}` });
|
||||
@@ -620,6 +831,22 @@ function collectBlockers(parts: {
|
||||
if (parts.live !== undefined && parts.live.status !== "observed") {
|
||||
blockers.push({ scope: "live-revision", summary: "16666/16667 live revision summary is not fully observable." });
|
||||
}
|
||||
if (parts.secretRefs !== undefined && parts.secretRefs.status === "blocked") {
|
||||
const secretBlockers = Array.isArray(parts.secretRefs.blockers) ? parts.secretRefs.blockers : [];
|
||||
blockers.push(...secretBlockers.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null && !Array.isArray(item)));
|
||||
}
|
||||
if (parts.controlled !== undefined) {
|
||||
if (parts.controlled.commandOk === false) blockers.push({ scope: "controlled-dev-cd-dry-run", summary: "HWLAB scripts/dev-cd-apply.mjs --dry-run did not complete successfully." });
|
||||
const target = asRecord(parts.controlled.target);
|
||||
const desiredStateCheck = asRecord(target?.desiredStateCheck);
|
||||
if (desiredStateCheck !== null && desiredStateCheck.status !== "pass") {
|
||||
blockers.push({ scope: "controlled-dev-cd-desired-state", summary: `HWLAB dev-cd-apply desired-state check is ${String(desiredStateCheck.status)}.` });
|
||||
}
|
||||
const artifactBoundary = asRecord(target?.artifactBoundary);
|
||||
if (artifactBoundary !== null && artifactBoundary.status !== "pass") {
|
||||
blockers.push({ scope: "controlled-dev-cd-artifact-boundary", summary: `HWLAB dev-cd-apply artifact boundary is ${String(artifactBoundary.status)}.` });
|
||||
}
|
||||
}
|
||||
if (parts.dryRun !== undefined && parts.dryRun.commandOk === false) {
|
||||
blockers.push({ scope: "apply-dry-run-command", summary: "HWLAB controlled dry-run command failed." });
|
||||
}
|
||||
@@ -645,15 +872,19 @@ async function status(options: HwlabCdOptions): Promise<Record<string, unknown>>
|
||||
};
|
||||
}
|
||||
const repoPath = String(selected.path);
|
||||
const [git, desired, guard, controlled, live] = await Promise.all([
|
||||
const [git, desired, guard, controlled, controlledCd, live] = await Promise.all([
|
||||
gitSummary(repoPath, dumpDir, Math.min(options.timeoutMs, 15_000)),
|
||||
desiredStateStatus(repoPath, dumpDir, options.timeoutMs),
|
||||
nativeK3sGuard(options.kubeconfig, dumpDir, Math.min(options.timeoutMs, 15_000)),
|
||||
controlledObservability(repoPath, dumpDir, Math.min(options.timeoutMs, 15_000)),
|
||||
controlledDevCdDryRun(repoPath, options.kubeconfig, dumpDir, Math.min(options.timeoutMs, 30_000)),
|
||||
liveRevisionStatus(options, dumpDir),
|
||||
]);
|
||||
const lock = await cdLockStatus(options.kubeconfig, guard, dumpDir, Math.min(options.timeoutMs, 15_000));
|
||||
const blockers = collectBlockers({ git, desired, guard, lock, live });
|
||||
const [lock, liveWorkloads] = await Promise.all([
|
||||
cdLockStatus(options.kubeconfig, guard, dumpDir, Math.min(options.timeoutMs, 15_000)),
|
||||
liveWorkloadStatus(options.kubeconfig, guard, dumpDir, Math.min(options.timeoutMs, 15_000)),
|
||||
]);
|
||||
const blockers = collectBlockers({ git, desired, guard, lock, live, controlled: controlledCd });
|
||||
return {
|
||||
ok: guard.refusal !== true,
|
||||
status: blockers.length === 0 ? "ready" : "blocked",
|
||||
@@ -666,72 +897,26 @@ async function status(options: HwlabCdOptions): Promise<Record<string, unknown>>
|
||||
source: selected.source,
|
||||
controlledEntrypoints: {
|
||||
devCdApply: join(repoPath, "scripts/dev-cd-apply.mjs"),
|
||||
devDeployApply: join(repoPath, "scripts/dev-deploy-apply.mjs"),
|
||||
desiredStatePlan: join(repoPath, "scripts/deploy-desired-state-plan.mjs"),
|
||||
},
|
||||
},
|
||||
git,
|
||||
desiredState: desired,
|
||||
controlledDevCdDryRun: controlledCd,
|
||||
d601NativeK3sGuard: guard,
|
||||
controlledObservability: controlled,
|
||||
cdLock: lock,
|
||||
liveRevisions: live,
|
||||
liveWorkloads,
|
||||
blockers,
|
||||
nextCommands: {
|
||||
preflight: "bun scripts/cli.ts hwlab cd preflight --env dev",
|
||||
dryRunApply: "bun scripts/cli.ts hwlab cd apply --env dev --dry-run",
|
||||
fullDump: `find ${JSON.stringify(dumpDir)} -type f -maxdepth 1 -print`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeApplyDryRun(parsed: unknown, command: CapturedCommand): Record<string, unknown> {
|
||||
const report = asRecord(parsed);
|
||||
const apply = asRecord(report?.devDeployApply);
|
||||
const conclusion = asRecord(apply?.conclusion);
|
||||
const boundary = asRecord(apply?.applyBoundary);
|
||||
const artifactPlan = asRecord(apply?.artifactPlan);
|
||||
const applyStep = asRecord(apply?.applyStep);
|
||||
return {
|
||||
status: stringValue(report?.status) ?? (command.ok ? "unknown" : "blocked"),
|
||||
commandOk: command.ok,
|
||||
reportVersion: stringValue(report?.reportVersion),
|
||||
commitId: stringValue(report?.commitId),
|
||||
namespace: stringValue(report?.namespace) ?? namespace,
|
||||
endpoint: stringValue(report?.endpoint) ?? "http://74.48.78.17:16667",
|
||||
blockers: Array.isArray(report?.blockers) ? report.blockers.slice(0, 30) : [],
|
||||
blockerCount: Array.isArray(report?.blockers) ? report.blockers.length : null,
|
||||
conclusion,
|
||||
artifactPlan: artifactPlan === null ? null : {
|
||||
expectedArtifactCommit: artifactPlan.expectedArtifactCommit,
|
||||
deployCommitId: artifactPlan.deployCommitId,
|
||||
catalogCommitId: artifactPlan.catalogCommitId,
|
||||
published: artifactPlan.published,
|
||||
registryVerified: artifactPlan.registryVerified,
|
||||
imageCount: artifactPlan.imageCount,
|
||||
requiredServiceCount: artifactPlan.requiredServiceCount,
|
||||
unpublishedServices: artifactPlan.unpublishedServices,
|
||||
},
|
||||
applyBoundary: boundary === null ? null : {
|
||||
currentMode: boundary.currentMode,
|
||||
defaultNoWrite: boundary.defaultNoWrite,
|
||||
mutationAttempted: boundary.mutationAttempted,
|
||||
mutationAllowed: boundary.mutationAllowed,
|
||||
kubeconfigSource: boundary.kubeconfigSource,
|
||||
writeScope: boundary.writeScope,
|
||||
noWriteScope: boundary.noWriteScope,
|
||||
forbiddenActions: boundary.forbiddenActions,
|
||||
},
|
||||
applyStep: applyStep === null ? null : {
|
||||
status: applyStep.status,
|
||||
command: applyStep.command,
|
||||
mutationAttempted: applyStep.mutationAttempted,
|
||||
expectedImmutableTemplateJobDryRun: applyStep.expectedImmutableTemplateJobDryRun,
|
||||
},
|
||||
manualCommands: asRecord(apply?.manualCommands),
|
||||
command: commandView(command),
|
||||
parsed: report !== null,
|
||||
};
|
||||
}
|
||||
|
||||
function realApplyRefusal(options: HwlabCdOptions, dumpDir: string, repoPath: string): Record<string, unknown> {
|
||||
const controlledCommand = [
|
||||
"node",
|
||||
@@ -786,7 +971,11 @@ async function apply(options: HwlabCdOptions): Promise<Record<string, unknown>>
|
||||
const repoPath = String(selected.path);
|
||||
if (!options.dryRun) return realApplyRefusal(options, dumpDir, repoPath);
|
||||
|
||||
const guard = await nativeK3sGuard(options.kubeconfig, dumpDir, Math.min(options.timeoutMs, 15_000));
|
||||
const [git, desired, guard] = await Promise.all([
|
||||
gitSummary(repoPath, dumpDir, Math.min(options.timeoutMs, 15_000)),
|
||||
desiredStateStatus(repoPath, dumpDir, options.timeoutMs),
|
||||
nativeK3sGuard(options.kubeconfig, dumpDir, Math.min(options.timeoutMs, 15_000)),
|
||||
]);
|
||||
if (guard.refusal === true) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -802,21 +991,14 @@ async function apply(options: HwlabCdOptions): Promise<Record<string, unknown>>
|
||||
};
|
||||
}
|
||||
|
||||
const command = await runCaptured([
|
||||
"node",
|
||||
"scripts/dev-deploy-apply.mjs",
|
||||
"--dry-run",
|
||||
"--expect-blocked",
|
||||
"--kubeconfig",
|
||||
options.kubeconfig,
|
||||
], repoPath, dumpDir, "controlled-dev-deploy-apply-dry-run", {
|
||||
env: { ...process.env, KUBECONFIG: options.kubeconfig },
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
const dryRun = summarizeApplyDryRun(parseJson(command.stdoutText), command);
|
||||
const blockers = collectBlockers({ guard, dryRun });
|
||||
const secretRefs = await requiredSecretRefPreflight(options.kubeconfig, guard, dumpDir, Math.min(options.timeoutMs, 15_000));
|
||||
const preCommandBlockers = collectBlockers({ git, desired, guard, secretRefs });
|
||||
const dryRun = preCommandBlockers.length === 0
|
||||
? await controlledDevCdDryRun(repoPath, options.kubeconfig, dumpDir, options.timeoutMs)
|
||||
: { status: "skipped", commandOk: true, reason: "preflight-blockers-before-controlled-dev-cd-dry-run" };
|
||||
const blockers = collectBlockers({ git, desired, guard, secretRefs, controlled: dryRun });
|
||||
return {
|
||||
ok: command.ok,
|
||||
ok: blockers.length === 0,
|
||||
status: blockers.length === 0 ? "prepared" : "blocked",
|
||||
environment: options.environment,
|
||||
dryRun: true,
|
||||
@@ -825,10 +1007,13 @@ async function apply(options: HwlabCdOptions): Promise<Record<string, unknown>>
|
||||
hwlabRepo: {
|
||||
path: repoPath,
|
||||
source: selected.source,
|
||||
controlledEntrypoint: join(repoPath, "scripts/dev-deploy-apply.mjs"),
|
||||
liveApplyEntrypointShape: join(repoPath, "scripts/dev-cd-apply.mjs"),
|
||||
controlledEntrypoint: join(repoPath, "scripts/dev-cd-apply.mjs"),
|
||||
desiredStatePlan: join(repoPath, "scripts/deploy-desired-state-plan.mjs"),
|
||||
},
|
||||
git,
|
||||
desiredState: desired,
|
||||
d601NativeK3sGuard: guard,
|
||||
secretRefPreflight: secretRefs,
|
||||
controlledDryRun: dryRun,
|
||||
blockers,
|
||||
hostCommanderOnlyLiveApply: {
|
||||
@@ -848,20 +1033,102 @@ async function apply(options: HwlabCdOptions): Promise<Record<string, unknown>>
|
||||
};
|
||||
}
|
||||
|
||||
async function preflight(options: HwlabCdOptions): Promise<Record<string, unknown>> {
|
||||
const dumpDir = rootPath(".state", "hwlab-cd", makeRunId());
|
||||
mkdirSync(dumpDir, { recursive: true, mode: 0o700 });
|
||||
const repo = resolveHwlabRepo(options.repoPath);
|
||||
const selected = asRecord(repo.selected);
|
||||
if (selected === null) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "blocked",
|
||||
environment: options.environment,
|
||||
dryRun: true,
|
||||
mutation: false,
|
||||
dumpDir,
|
||||
repo,
|
||||
error: "hwlab-repo-not-found",
|
||||
};
|
||||
}
|
||||
const repoPath = String(selected.path);
|
||||
const [git, desired, guard, live] = await Promise.all([
|
||||
gitSummary(repoPath, dumpDir, Math.min(options.timeoutMs, 15_000)),
|
||||
desiredStateStatus(repoPath, dumpDir, options.timeoutMs),
|
||||
nativeK3sGuard(options.kubeconfig, dumpDir, Math.min(options.timeoutMs, 15_000)),
|
||||
liveRevisionStatus(options, dumpDir),
|
||||
]);
|
||||
if (guard.refusal === true) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "refused",
|
||||
environment: options.environment,
|
||||
dryRun: true,
|
||||
mutation: false,
|
||||
dumpDir,
|
||||
hwlabRepo: { path: repoPath, source: selected.source },
|
||||
git,
|
||||
desiredState: desired,
|
||||
d601NativeK3sGuard: guard,
|
||||
error: "native-k3s-guard-refused",
|
||||
summary: "Refusing HWLAB DEV CD preflight because kubectl resolved to a forbidden Docker Desktop control plane signal.",
|
||||
};
|
||||
}
|
||||
const [lock, liveWorkloads, secretRefs] = await Promise.all([
|
||||
cdLockStatus(options.kubeconfig, guard, dumpDir, Math.min(options.timeoutMs, 15_000)),
|
||||
liveWorkloadStatus(options.kubeconfig, guard, dumpDir, Math.min(options.timeoutMs, 15_000)),
|
||||
requiredSecretRefPreflight(options.kubeconfig, guard, dumpDir, Math.min(options.timeoutMs, 15_000)),
|
||||
]);
|
||||
const preCommandBlockers = collectBlockers({ git, desired, guard, lock, live, secretRefs });
|
||||
const controlled = preCommandBlockers.length === 0
|
||||
? await controlledDevCdDryRun(repoPath, options.kubeconfig, dumpDir, Math.min(options.timeoutMs, 30_000))
|
||||
: { status: "skipped", commandOk: true, reason: "preflight-blockers-before-controlled-dev-cd-dry-run" };
|
||||
const blockers = collectBlockers({ git, desired, guard, lock, live, secretRefs, controlled });
|
||||
return {
|
||||
ok: blockers.length === 0,
|
||||
status: blockers.length === 0 ? "pass" : "blocked",
|
||||
environment: options.environment,
|
||||
dryRun: true,
|
||||
mutation: false,
|
||||
dumpDir,
|
||||
hwlabRepo: {
|
||||
path: repoPath,
|
||||
source: selected.source,
|
||||
controlledEntrypoint: join(repoPath, "scripts/dev-cd-apply.mjs"),
|
||||
desiredStatePlan: join(repoPath, "scripts/deploy-desired-state-plan.mjs"),
|
||||
},
|
||||
git,
|
||||
desiredState: desired,
|
||||
d601NativeK3sGuard: guard,
|
||||
cdLock: lock,
|
||||
liveRevisions: live,
|
||||
liveWorkloads,
|
||||
secretRefPreflight: secretRefs,
|
||||
controlledDevCdDryRun: controlled,
|
||||
blockers,
|
||||
nextCommands: {
|
||||
dryRunApply: "bun scripts/cli.ts hwlab cd apply --env dev --dry-run",
|
||||
fullDump: `find ${JSON.stringify(dumpDir)} -type f -maxdepth 1 -print`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function hwlabHelp(): Record<string, unknown> {
|
||||
return {
|
||||
command: "hwlab cd",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts hwlab cd status --env dev",
|
||||
"bun scripts/cli.ts hwlab cd preflight --env dev",
|
||||
"bun scripts/cli.ts hwlab cd apply --env dev --dry-run",
|
||||
],
|
||||
description: "Inspect and prepare HWLAB DEV CD from UniDesk without embedding release kubectl logic. The wrapper calls HWLAB repo-owned scripts and writes full stdout/stderr dumps under .state/hwlab-cd/.",
|
||||
boundary: [
|
||||
`KUBECONFIG is forced to ${nativeKubeconfig}`,
|
||||
"docker-desktop, desktop-control-plane, and 127.0.0.1:11700 are refusal signals",
|
||||
"status is read-only and bounded; live apply is host-commander-only and not executed by the dry-run path",
|
||||
"dry-run apply calls HWLAB scripts/dev-deploy-apply.mjs; live apply shape points at scripts/dev-cd-apply.mjs",
|
||||
`default HWLAB CD repo is ${defaultHwlabCdRepoPath}; /home/ubuntu/hwlab is rejected as runner history`,
|
||||
"status and preflight are read-only and bounded; live apply is host-commander-only and not executed by the dry-run path",
|
||||
"preflight checks required SecretRef object/key presence without reading or printing Secret values",
|
||||
"dry-run apply calls HWLAB scripts/dev-cd-apply.mjs --dry-run; live apply shape points at scripts/dev-cd-apply.mjs --apply",
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -870,5 +1137,6 @@ export async function runHwlabCdCommand(args: string[]): Promise<Record<string,
|
||||
if (args.length === 0 || args.some(isHelpArg)) return { ok: true, ...hwlabHelp() };
|
||||
const options = parseOptions(args);
|
||||
if (options.action === "status") return status(options);
|
||||
if (options.action === "preflight") return preflight(options);
|
||||
return apply(options);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user