feat: extend G14 runtime automation
This commit is contained in:
@@ -115,6 +115,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
|
||||
- P0: 每次开始 G14 HWLAB runtime lane 工作前,目标固定仓库必须先即时快进到目标 remote 分支最新;例如 v0.2 使用 `trans G14:/root/hwlab-v02 script -- 'git fetch origin v0.2 && git pull --ff-only origin v0.2 && git status --short --branch && git remote -v'`。若有未提交并行变更阻碍快进,必须先判断是否能与最新 remote 快速合并;能合并则立即合并,禁止默认 stash、丢弃或绕到落后 worktree;只有确实无法自动合并时才隔离保存并停止交给人工决策。禁止用落后的固定仓库继续预检、创建 worktree、开发、render、polling 或部署。
|
||||
- G14 HWLAB service/runtime 开发必须先以目标 runtime lane 固定 repo 做预检,再按目标 lane 创建独立 worktree/PR;v0.2 CaseRun、短连接 CLI、trace、docs/reference 和配置治理这类无服务任务按 `docs/reference/g14.md` 的 `direct-lightweight` 规则可在 `G14:/root/hwlab-v02` 直接提交推送,不触发 PR/CI/CD/rollout。
|
||||
- P0: G14 已有节点本地 GitHub/Google/DockerHub/npm 等 bootstrap 加速 proxy;在 G14 host、临时 pod、构建 pod 或透传 pod 里拉 GitHub/Google 资源前必须优先使用该 proxy,避免把网络直连失败当作代码阻塞。主入口:HTTP/HTTPS `http://127.0.0.1:10808`、SOCKS `socks5h://127.0.0.1:10808`,备份入口和 NO_PROXY 规则见 `docs/reference/g14.md`。
|
||||
- P0: G14 platform PostgreSQL 是 host-native 底层基础设施,HWLAB v0.3+ 通过 namespace-local `g14-platform-postgres` bridge 使用它;迁移后必须清理旧自有 Postgres Secret/PVC,长期规则见 `docs/reference/g14-platform-db.md`。
|
||||
- 禁止把 master server、D601、`/root/HWLAB`、`/home/ubuntu/hwlab`、`/workspace/hwlab`、retired `/root/hwlab` 或临时 clone 当作当前 G14 runtime lane source truth;master server 也不得运行 HWLAB check、Playwright/browser smoke、镜像构建或其他重型验证,验证必须走目标 runtime lane workspace、G14 k3s/Tekton 或其他获批外部执行面。
|
||||
- 长期细节见 `docs/reference/g14.md`;G14 节点本地也保留 `/root/docs/hwlab-g14-workspace.md`,两处口径必须保持一致。
|
||||
|
||||
@@ -223,7 +224,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|merge` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、分页 issue list、inactive issue stale-close、脱敏 auth/status 诊断、heredoc/stdin 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、guarded PR merge 与 runner PR preflight;`gh issue/pr read|view` 支持 `owner/repo#number` shorthand,`--raw|--full` 是显式完整披露别名,`gh pr diff` 仅支持 `--stat` 紧凑 JSON,`gh pr merge` 会先执行 closeout 预检并拒绝非 open、draft、冲突、非 CLEAN、失败或 pending checks 的 PR,规则见 `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 g14 retirement status|plan|execute --confirm`:受控退役 legacy G14 DEV/PROD Argo Application 和 namespace,并写本地退役 marker 记录执行证据;base=`G14` monitor 按退役合同固定阻止重启,`bun scripts/cli.ts hwlab g14 monitor-prs --lane v02` 是当前 v0.2 PR 自动 CI/CD 入口,规则见 `docs/reference/g14.md` 与 `docs/reference/cli.md`。
|
||||
- `bun scripts/cli.ts hwlab g14 retirement status|plan|execute --confirm`:受控退役 legacy G14 DEV/PROD Argo Application 和 namespace,并写本地退役 marker 记录执行证据;base=`G14` monitor 按退役合同固定阻止重启,`bun scripts/cli.ts hwlab g14 monitor-prs --lane v02|v03` 是当前 runtime lane PR 自动 CI/CD 入口,规则见 `docs/reference/g14.md` 与 `docs/reference/cli.md`。
|
||||
- `bun scripts/cli.ts agentrun v01 control-plane status|trigger-current|refresh|cleanup-runs|cleanup-released-pvs [--dry-run|--confirm]`:通过 G14 route 只读观察、手动触发、刷新 Argo 或清理 AgentRun `v0.1` completed CI workspace retention,规则见 `docs/reference/agentrun.md`、`docs/reference/gc.md` 与 `docs/reference/cli.md`。
|
||||
- `bun scripts/cli.ts hwlab cd audit --env dev` / `status|preflight|apply --dry-run`:旧 D601 HWLAB DEV CD 指挥侧 wrapper,仅用于显式 legacy 诊断和迁移对照;当前 HWLAB runtime truth 已迁到 G14 runtime lane,规则见 `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`。
|
||||
|
||||
@@ -10,13 +10,13 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI
|
||||
|
||||
## CI/CD Control Boundary
|
||||
|
||||
CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动、PipelineRun 重跑/清理、Argo refresh、运行面 retention 和 legacy runtime 退役都必须由 UniDesk CLI 的高层子命令控制。稳定入口包括 `gh pr ...`、`hwlab g14 retirement ...`、`hwlab g14 monitor-prs --lane v02`、`agentrun v01 control-plane ...`、`deploy check|plan|apply`、`ci install|status|run|publish-*|logs`、`artifact-registry ...`、`server rebuild ...`、`dev-env ...` 和后续为特定运行面补充的同级命令。原生 `kubectl`、`argo`、`tkn`、`gh`、`curl` 或临时 shell 可以作为实现细节存在于 CLI 内部,但不能作为人工或 runner 的正式控制面。
|
||||
CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动、PipelineRun 重跑/清理、Argo refresh、运行面 retention 和 legacy runtime 退役都必须由 UniDesk CLI 的高层子命令控制。稳定入口包括 `gh pr ...`、`hwlab g14 retirement ...`、`hwlab g14 monitor-prs --lane v02|v03`、`agentrun v01 control-plane ...`、`deploy check|plan|apply`、`ci install|status|run|publish-*|logs`、`artifact-registry ...`、`server rebuild ...`、`dev-env ...` 和后续为特定运行面补充的同级命令。原生 `kubectl`、`argo`、`tkn`、`gh`、`curl` 或临时 shell 可以作为实现细节存在于 CLI 内部,但不能作为人工或 runner 的正式控制面。
|
||||
|
||||
`trans <route> kubectl|logs|get|describe` / `tran <route> ...` 仍是 CLI 介导的低层诊断底座,用于短查询、日志尾部、只读证据和一次性故障定位。它不应承载可重复的 CI/CD 写操作:创建/删除 PipelineRun、patch Pipeline/CronJob/RBAC、annotate Argo Application、触发/回滚 rollout、修改 retention 策略、确保 SecretRef 或清理运行面资源,都应该先落成 `bun scripts/cli.ts ...` 高层子命令,再由该子命令输出结构化 dry-run、执行摘要、保护对象、后续观察命令和失败分类。
|
||||
|
||||
当现有 CLI 对某个 CI/CD 操作缺字段、缺动作、缺状态或缺权限时,处理顺序是先补 CLI,再执行发布或治理动作。临时低层 route 写操作只允许用于一次性止血,并且必须随后把稳定能力补进 CLI 与本参考文档;不能把手工 `kubectl apply/delete/annotate`、原生 GitHub CLI、手写 REST 请求或 registry shell 脚本沉淀成长期流程。长时观察仍遵守 60 秒短查询和 submit-and-poll 语义,不用单个 `trans`/`tran` 等待完整 PipelineRun 或 Argo rollout 结束。
|
||||
|
||||
`hwlab nodes secret status|ensure --node G14 --lane v03 --name hwlab-cloud-api-v03-db` 是 v03 Cloud API Postgres runtime SecretRef 的受控 bootstrap 入口;`ensure` 从 lane-local Postgres Secret 派生 `database-url`、确保同名 runtime role/database 存在,并且只在 Secret/DB 有实际补齐时滚动 `hwlab-cloud-api` 以重新注入 env。输出仅披露 Secret 名、key presence、decoded byte count、DB role/database presence、rollout exit code、mutation 和后续命令,禁止打印 base64、database URL、password 或可复用凭据。
|
||||
`hwlab nodes secret cleanup-owned-postgres --node G14 --lane v03 [--dry-run|--confirm]` 是 v03 迁移到 G14 platform PostgreSQL 后清理旧 repo-owned Postgres Secret/PVC 的受控入口。迁移后的 `hwlab-cloud-api-v03-db` 和 `hwlab-v03-openfga` SecretRef 来自 G14 host platform DB 凭据文件,不再从 lane-local Postgres Secret 派生;旧 `hwlab nodes secret ensure --name hwlab-cloud-api-v03-db` 只适用于仍使用 repo-owned lane-local Postgres 的路径,不是 v03 platform DB 轮换入口。平台 DB 运行、SecretRef 轮换边界和 health 验证见 `docs/reference/g14-platform-db.md`。
|
||||
|
||||
`hwlab nodes secret status|ensure --node G14 --lane v03 --name hwlab-v03-code-agent-provider` 是 v03 Code Agent / MoonBridge provider SecretRef 的受控 bootstrap 入口;`ensure` 只从集群内既有 `hwlab-v02/hwlab-v02-code-agent-provider` 复制 `openai-api-key`、`opencode-api-key` 到 lane-local Secret,输出仅披露 source/target Secret 名、key presence、decoded byte count、mutation 和后续命令,禁止打印 base64、解码值、完整 API key 或可复用凭据。OpenFGA 和 master admin API key 继续使用同一命名空间下的 `hwlab nodes secret ... --name hwlab-v03-openfga|hwlab-v03-master-server-admin-api-key`。
|
||||
|
||||
@@ -51,8 +51,9 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动
|
||||
- `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 门禁;legacy `codex submit` 已冻结,新任务 payload 审查走 AgentRun Queue。`plan`、`smoke` 与 `approval request` 必须带 `--dry-run`;缺少时返回 `error=dry-run-required`。长期规则见 `docs/reference/host-codex-commander.md`。
|
||||
- `hwlab g14 retirement status|plan|execute --confirm [--wait]` 是 legacy G14 DEV/PROD 的受控退役入口。`status` 只读报告 `argocd/hwlab-g14-dev`、`argocd/hwlab-g14-prod`、`hwlab-dev`、`hwlab-prod`、bounded legacy resource preview、受保护的 `hwlab-g14-v02`/`hwlab-node-v03` 和 `hwlab-v02`/`hwlab-v03`,以及 `.state/hwlab-g14/legacy-g14-retirement.json` marker。`plan` 是 dry-run,只列出 destructive targets 和 protected targets。`execute --confirm` 删除 legacy Argo Applications 与 legacy namespaces,取消本地 active `hwlab_g14_pr_monitor` job,并写 retirement marker 记录执行证据;`hwlab g14 monitor-prs --lane g14` 按退役合同固定结构化失败并指向 `retirement status` 和 `--lane v02`。该入口禁止触碰 v0.2/v0.3 Application、namespace、PipelineRun、Secret、Git mirror 或 FRP desired state。
|
||||
- `hwlab g14 retirement status|plan|execute --confirm [--wait]` 是 legacy G14 DEV/PROD 的受控退役入口。`status` 只读报告 `argocd/hwlab-g14-dev`、`argocd/hwlab-g14-prod`、`hwlab-dev`、`hwlab-prod`、bounded legacy resource preview、受保护的 `hwlab-g14-v02`/`hwlab-node-v03` 和 `hwlab-v02`/`hwlab-v03`,以及 `.state/hwlab-g14/legacy-g14-retirement.json` marker。`plan` 是 dry-run,只列出 destructive targets 和 protected targets。`execute --confirm` 删除 legacy Argo Applications 与 legacy namespaces,取消本地 active `hwlab_g14_pr_monitor` job,并写 retirement marker 记录执行证据;`hwlab g14 monitor-prs --lane g14` 按退役合同固定结构化失败并指向 `retirement status` 和 runtime lane monitor `--lane v02|v03`。该入口禁止触碰 v0.2/v0.3 Application、namespace、PipelineRun、Secret、Git mirror 或 FRP desired state。
|
||||
- `hwlab g14 monitor-prs --lane v02` 是 HWLAB `v0.2` 的 PR -> CI -> CD 自动化入口。它只监控 base=`v0.2` 的 open PR:每轮先用 UniDesk `gh pr preflight` 读取 GitHub CI/checks、mergeability 和冲突状态;pending 时在 PR 下写等待评论,blocked/conflict 时写阻塞评论;ready 时直接用 UniDesk `gh pr merge` 合并,不因为其他 commit 的运行中 PipelineRun 阻塞 merge 或 CI 启动。合并后执行受控 `control-plane trigger-current --lane v02 --confirm --wait`、轮询定点 `control-plane status --lane v02 --source-commit <merge-sha>`,必要时执行 `git-mirror flush --confirm --wait`。v0.2 CD 采用 latest-only:旧 PipelineRun 不取消、不等待,但 promotion 写 `v0.2-gitops` 前必须重新确认 source head,stale commit 只能以 superseded/no-op 收口,不能回滚 runtime。不管 CD 成功、superseded、失败或超时,都在原 PR 下用 `gh pr comment create --body-stdin <<'EOF'` 追加语义化状态,正文固定包含起止时间、总耗时、冲突状态、CI/preflight conclusion、source commit、PipelineRun、targetValidation、Argo/webAssets 和 git mirror pendingFlush/githubInSync。评论去重状态写入 `.state/hwlab-g14/v02-pr-comment-signatures.json`,同一状态签名不会重复刷评论;v0.2 monitor 指针使用 `.state/hwlab-g14/latest-v02-monitor-job.json`、`latest-v02-once-job.json`、`latest-v02-dry-run-job.json` 和 `latest-v02-once-dry-run-job.json`,不会覆盖默认 G14 monitor 指针。`--lane v02 --once --dry-run` 只做单轮 preflight/merge/CD/comment plan,不写 GitHub、不触发 CD。
|
||||
- `hwlab g14 monitor-prs --lane v03` 是 HWLAB `v0.3` 的 PR -> CI -> 自动合并 -> CD 入口。它只监控 base=`v0.3` 的 open PR:每轮先通过 UniDesk `gh pr preflight` 读取 GitHub checks、mergeability 和冲突状态;pending 时只在 PR 下写等待评论;失败 check、preflight blocker 或 conflict 时在 PR 下写阻塞评论,并按标题去重创建或更新 HWLAB failure issue。ready 时通过 UniDesk `gh pr merge` 合并,随后执行 runtime lane `control-plane trigger-current --lane v03 --confirm --wait`,轮询 `control-plane status --lane v03 --source-commit <merge-sha>`,判定 PipelineRun `True`、Argo `Synced/Healthy`、`hwlab-v03` runtime workload 可见、20666/20667 public probes 通过,并在必要时执行 `git-mirror flush --lane v03 --confirm --wait`。CD 成功、失败或超时都会在原 PR 下写语义化状态评论;失败和超时同时创建或更新 failure issue,正文必须包含 PR、base/head、commit、PipelineRun、失败阶段、preflight/CD 摘要和下一步 CLI。评论去重状态写入 `.state/hwlab-g14/v03-pr-comment-signatures.json`,monitor 指针使用 `.state/hwlab-g14/latest-v03-monitor-job.json`、`latest-v03-once-job.json`、`latest-v03-dry-run-job.json` 和 `latest-v03-once-dry-run-job.json`。`--lane v03 --once --dry-run` 只做单轮 preflight/merge/CD/comment/issue plan,不写 GitHub、不触发 CD。
|
||||
- `agentrun v01 control-plane status|trigger-current|refresh|cleanup-runs|cleanup-released-pvs [--dry-run|--confirm]` 是 AgentRun `v0.1` 在 G14 k3s 的受控 Tekton/Argo 入口。`status` 只读汇总固定 source worktree commit、对应 commit-pinned PipelineRun、GitOps latest、Argo Application、`agentrun-v01` manager source commit、`planArtifacts.summary`、env image result 和 git mirror 摘要,并报告 manager/Argo/GitOps 是否对齐当前 source commit。默认输出是 compact commander 视图:`summary` 给出 source、PipelineRun、Argo、manager image、git mirror 和 `aligned` 结论;`timings` 给出 `sourceMs`、`runtimeMs`、`gitMirrorMs` 和 `totalMs`;远端 stdout/stderr tail 默认省略,失败时仍展开必要 tail,完整 tail 用 `--full`,原始 git mirror cache 用 `--raw`。`status` 聚合 source 后会并行读取 runtime 和 git mirror,并向 stderr 输出 `agentrun.control-plane.status.progress` JSON 事件,覆盖 `source`、`runtime`、`git-mirror` 的 started/succeeded/failed 和 elapsedMs,避免 10s 以上状态聚合期间无可见进展;`trigger-current` 先快进 `G14:/root/agentrun-v01` 到 `origin/v0.1`,检查 `devops-infra` mirror 的 `localV01` 是否等于目标 source commit,必要时先执行受控 mirror sync,再创建 `agentrun-v01-ci-<short12>` PipelineRun。confirmed trigger 只提交 CI/CD 工作并返回后续 `status` 命令,不等待完整 PipelineRun;同名 PipelineRun 运行中或已成功时拒绝重复触发,只允许失败态重建或首次创建。`refresh` 只对 `argocd/agentrun-g14-v01` 执行 hard refresh,用于 GitOps promotion 已完成但 Argo 仍停留旧 revision 时的受控同步入口;它不直接 patch runtime workload。`cleanup-runs` 只清理 `agentrun-ci` 中已完成且超过 `--min-age-minutes` 的 `agentrun-v01-ci-*` PipelineRun,通过 Tekton ownerRef 回收临时 workspace PVC;dry-run 必须输出候选 PipelineRun、owned PVC、active mount 保护、local-path 实际估算 bytes 和 confirm 命令。`cleanup-released-pvs` 只处理 `agentrun-ci`、`local-path`、`Delete` reclaim policy 的 `Released` PV,用于 PipelineRun 删除后残留 PV 的二次回收;它不触碰 runtime namespace、业务 PVC、Secret、registry storage 或 GitOps desired state。AgentRun 运行时和 SPEC 事实来源仍在 AgentRun 仓库,UniDesk 只维护受控运维入口。
|
||||
- `agentrun v01 git-mirror status|sync|flush [--dry-run|--confirm]` 是 AgentRun `v0.1` 使用 `devops-infra` git mirror/relay 的受控维护入口。`status` 默认返回 read/write URL、`localV01`、`githubV01`、`localGitops`、`githubGitops`、`pendingFlush`、`githubInSync` 和 exact full-SHA shallow fetch 摘要,不默认展开完整 cache stdout;需要探测 tail 时用 `--full`,需要原始 cache 输出时用 `--raw`。`sync` 创建 manual Job,把 GitHub `v0.1` 和 `v0.1-gitops` refs 拉入 `/cache/pikasTech/agentrun.git`;`flush` 把本地 `v0.1-gitops` 快进推回 GitHub。confirmed `sync`/`flush` 默认创建 `.state/jobs/` 异步 job 并立刻返回 `job.id`、`statusCommand` 和日志路径;只有现场同步调试才显式加 `--wait`。该入口与 HWLAB v0.2 mirror 共用 `devops-infra` 服务和 cache PVC,但 repo path、refs、status 文件和 CLI 命令彼此独立。
|
||||
- `hwlab g14 control-plane status|apply --lane v02 [--dry-run|--confirm]` 是 HWLAB `v0.2` 加法 lane 的受控 Tekton/Argo 控制面维护入口,source commit 只来自 G14 专用 bare repo `/root/hwlab-v02-cicd.git` 的 `refs/remotes/origin/v0.2`;`/root/hwlab-v02` 只作为人工开发和短连接源码工具 workspace 被观测,dirty/stale 状态必须输出为 isolated warning 而不能阻塞 CI/CD。该入口面向 branch `v0.2`、namespace `hwlab-ci` 和 Argo application `hwlab-g14-v02`;默认 `status` 只读汇总最新 source head 的 pipeline、RBAC/ServiceAccount、Argo、当前 commit PipelineRun、当前 PipelineRun 的 TaskRun 条件摘要、最近 PipelineRun 摘要、活跃 PipelineRun、遗留 v02 CronJob 清理状态、commit alignment,以及 19666/19667 的 Cloud Web 静态资源和 API live 探针。分支被后续提交推进后,要复查已完成 run 时使用 `status --lane v02 --pipeline-run hwlab-v02-ci-poll-<short-sha>`;已知完整 source SHA 但不想依赖最新 head 时使用 `status --lane v02 --source-commit <full-sha>`。定点 `status` 输出 `statusTarget.mode` 和 `targetValidation`,只检查指定 PipelineRun/source commit 的证据;`targetValidation.state=passed` 表示该目标已满足 PipelineRun succeeded、Argo `Synced/Healthy`、19666/19667 探针、Git mirror flushed,并且该 run 的 `planArtifacts.rolloutServices` 运行时 source commit 对齐;`planArtifacts.reusedServices` 作为 runtime/provenance 证据呈现,但不能被强制要求等于目标 source commit。`targetValidation.state=superseded` 表示该目标已成功且 runtime 已被同一分支后续成功 PipelineRun 取代,`falseGreenGuard` 在该状态下应标为 superseded/not-applicable。两种状态都不得因为 `origin/v0.2` 后续推进而把历史 run 判为失败;默认不带定点参数时仍严格判定最新 source head alignment。TaskRun 摘要的 `performance` 字段会把超过 120s 的 build TaskRun 标为慢任务、超过 180s 标为 critical warning,用于暴露 env reuse/git mirror 命中率回归,但不作为阻断门禁;CI/CD 性能验收应同时看 `planArtifacts.summary`、`taskRuns.performance.warningCount` 和 PipelineRun duration,纯 CLI/文档或无 runtime 重建需求的后续提交应稳定表现为 `build=0 reuse=<service-count>` 且无 build TaskRun warning,首次引入或切换 env image 时允许只构建必要 env image 一次。`webAssets` 必须直接给出 `readonly-rpc` 删除、sidebar/workspace/event panel 关键 CSS、`/app.js` 是否可读取和字节数、`/health/live` 与 API revision;`apiRevision` 是 cloud-api 服务自身 revision,Cloud Web 静态资源变更时允许它与 source commit 不同,不能把这种差异误判成 Cloud Web 未发布。默认只读取必要字段,禁止把完整 PipelineRun spec、Tekton 内联脚本、历史大对象或整份 CSS/HTML/JS 展开到默认输出;`apply` 先自动 fetch `/root/hwlab-v02-cicd.git` 并从 commit-pinned detached worktree 执行 render check,再经 `G14:k3s` server-side apply `tekton-v02/rbac.yaml`、`pipeline.yaml`、`argocd/project.yaml` 和 `argocd/application-v02.yaml`,confirmed apply 会删除遗留 v02 CronJob,但不会应用 runtime-v02 workload、Secret 或数据迁移。
|
||||
@@ -61,11 +62,12 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动
|
||||
创建 PipelineRun 前会读取 `devops-infra` mirror refs,若 `localV02` 未等于当前 source commit,则自动执行一次受控 manual `git-mirror sync` Job 并复核 ref,复核失败时停止触发,避免 Tekton `prepare-source` 已知失败;services 参数只包含 v02 runtime service matrix,`hwlab-cli` 是固定 repo 短连接源码工具,不进入 PipelineRun service build。
|
||||
`--dry-run` 只报告是否会 pre-sync,不创建 Job;confirmed trigger 默认创建 `.state/jobs/` 异步 job 并立刻返回 `job.id`、`statusCommand`、stdout/stderr 路径,避免 git mirror pre-sync 或 PipelineRun 创建期间长时间阻塞;`--wait` 路径也必须向 stderr 输出 `hwlab.v02.trigger.progress` JSON 事件,覆盖 `control-plane-refresh`、`git-mirror-pre-sync` 和 `create-pipelinerun`,避免异步 job 长时间只有启动命令而无法判断卡点;默认 JSON 必须对 `manifest_b64`、长脚本和远端 stdout/stderr 做有界摘要,保留长度与 hash,最终 trigger 结果只返回阶段摘要和关键 tail,完整内容通过 job stdout/stderr 文件渐进披露;只有现场同步调试才显式加 `--wait`;旧 `rerun-current` 只作为输入别名保留。PipelineRun `Completed`、Argo `Synced/Healthy` 和 `webAssets.ok=true` 只证明 G14 runtime 已更新;交付收口还必须用 `hwlab g14 git-mirror status` 查看 `cache.summary.pendingFlush`,若为 true,继续执行受控 `hwlab g14 git-mirror flush --confirm` 并用 job status 轮询到 `pendingFlush=false`。
|
||||
- `hwlab g14 control-plane runtime-migration --lane v02 [--dry-run|--allow-live-db-read --dry-run|--confirm]` 只通过 `hwlab-v02` namespace 当前 `deployment/hwlab-cloud-api -c hwlab-cloud-api` 内 repo-owned migration CLI 执行;不读取或打印 Secret 值、不触碰 PROD、不绕到手工 `psql`。
|
||||
- `hwlab g14 secret status|ensure --lane v02 --name hwlab-v02-openfga|hwlab-v02-master-server-admin-api-key [--dry-run|--confirm]` 和 `hwlab nodes secret status|ensure --node G14 --lane v03 --name hwlab-v03-openfga|hwlab-cloud-api-v03-db|hwlab-v03-master-server-admin-api-key [--dry-run|--confirm]` 是 HWLAB runtime lane SecretRef bootstrap 的标准入口:OpenFGA preset 确保 preshared token、datastore URI 和 Postgres password 存在;Cloud API DB preset 确保 lane-local `database-url` SecretRef、runtime role/database 和必要的 Cloud API env 重新注入;v02 继续确保独立 `hwlab_openfga` 数据库/角色存在,v03+ 默认从 lane spec 派生 namespace、Postgres Secret 和主 DB 用户,避免复制 v02 专用硬编码。master server admin API key preset 确保本机 `/root/.config/hwlab-v0x/master-server-admin-api-key.env` 以 0600 保存 `HWLAB_API_KEY`,并同步到对应 lane 的 `*-master-server-admin-api-key/api-key`。`status` 只返回 key 是否存在、解码后字节数、key prefix、DB 对象存在性和 rollout exit code,永远不读取、不打印、不回传 secret 明文。`hwlab g14 secret delete --lane v02 --name <obsolete-hwlab-v02-secret> [--dry-run|--confirm]` 只用于删除确认已不被 workload 引用的 v0.2 废弃 Secret,默认 dry-run,拒绝删除 OpenFGA/Postgres/master admin API key 等必需 Secret;共享 device-pod API key 已退出当前授权路径,不再提供 ensure/bootstrap 入口。
|
||||
- `hwlab g14 secret status|ensure --lane v02 --name hwlab-v02-openfga|hwlab-v02-master-server-admin-api-key [--dry-run|--confirm]` 和 `hwlab nodes secret status|ensure --node G14 --lane v03 --name hwlab-v03-openfga|hwlab-cloud-api-v03-db|hwlab-v03-master-server-admin-api-key [--dry-run|--confirm]` 是 HWLAB runtime lane SecretRef bootstrap 的历史入口:OpenFGA preset 确保 preshared token、datastore URI 和 Postgres password 存在;Cloud API DB preset 只适用于仍使用 repo-owned lane-local Postgres 的路径。v0.3 迁移到 G14 platform PostgreSQL 后,Cloud API/OpenFGA datastore SecretRef 不再从 `hwlab-v03-postgres` Secret 或 StatefulSet 派生;平台库凭据、桥接 Service 和 SecretRef 轮换边界见 `docs/reference/g14-platform-db.md`。master server admin API key preset 确保本机 `/root/.config/hwlab-v0x/master-server-admin-api-key.env` 以 0600 保存 `HWLAB_API_KEY`,并同步到对应 lane 的 `*-master-server-admin-api-key/api-key`。`status` 只返回 key 是否存在、解码后字节数、key prefix、DB 对象存在性和 rollout exit code,永远不读取、不打印、不回传 secret 明文。`hwlab nodes secret cleanup-owned-postgres --node G14 --lane v03 [--dry-run|--confirm]` 是 v0.3+ 迁移到 G14 平台 Postgres 后的受控残留清理入口,只删除旧 repo-owned `hwlab-v03-postgres` Secret 和 `data-hwlab-v03-postgres-0` PVC;它要求 `g14-platform-postgres` Service 已存在、旧 StatefulSet 不存在,默认 dry-run,不触碰平台数据库、OpenFGA/Cloud API 当前 SecretRef 或 GitOps desired state。`hwlab g14 secret delete --lane v02 --name <obsolete-hwlab-v02-secret> [--dry-run|--confirm]` 只用于删除确认已不被 workload 引用的 v0.2 废弃 Secret,默认 dry-run,拒绝删除 OpenFGA/Postgres/master admin API key 等必需 Secret;共享 device-pod API key 已退出当前授权路径,不再提供 ensure/bootstrap 入口。
|
||||
- `hwlab g14 control-plane cleanup-runs --lane v02|v03|g14|all [--min-age-minutes N] [--limit N] [--dry-run|--confirm]` 是完成态 PipelineRun 工作区 retention 入口;真实清理只删除已完成 PipelineRun,让 Tekton/local-path 回收临时 PVC,不触碰 registry storage、业务 PVC、Secret、runtime workload 或 GitOps desired state。带 `--pipeline-run <name>` 或 `--source-commit <full-sha>` 的定点清理必须先直接查询目标 PipelineRun,而不是只从全量列表过滤;不存在的目标返回 `target-pipelinerun-not-found`,未完成目标返回 `target-pipelinerun-not-terminal`,空查询和读取失败分别返回 `target-pipelinerun-query-empty` / `target-pipelinerun-query-failed`,年龄保护仍返回 `below-min-age`。`hwlab nodes control-plane cleanup-runs --node G14 --lane v03 --pipeline-run <name>` 是 v0.3 failed run 受控重试前的清理入口。
|
||||
- `hwlab g14 control-plane cleanup-released-pvs --lane all [--limit N] [--dry-run|--confirm]` 是 local-path 未自动回收后的补充 retention 入口;只列并删除 `Released`、`local-path`、`Delete`、`claimNamespace=hwlab-ci` 且 claim 名称形如 Tekton 临时 `pvc-*` 的 PV。
|
||||
- `hwlab g14 git-mirror status|apply|sync|flush [--dry-run|--confirm]` 是 `devops-infra` git mirror/relay 的受控维护入口:`apply` 渲染并 server-side apply `devops-infra/git-mirror.yaml`,同时删除遗留 `git-mirror-hwlab-sync` CronJob;`sync` 创建一次性 manual Job,把 GitHub allowlist refs 拉入本地 mirror;`flush` 创建一次性 manual Job,把本地 `v0.2-gitops` 快进推回 GitHub。
|
||||
`status` 返回 read/write URL、last sync/write/flush、本地 ref、GitHub staging ref 和 pending flush 状态,并在 `cache.summary` 给出 `localV02`、`localGitops`、`githubGitops`、`pendingFlush`、`flushNeeded`、`githubInSync` 和下一条受控 `flushCommand`。confirmed `sync` 和 `flush` 默认创建 `.state/jobs/` 异步 job 并立刻返回可查询状态,只有现场同步调试才显式加 `--wait`;mirror 不设置 CronJob。
|
||||
如果 PipelineRun 的 `gitops-promote` 阶段报 `git mirror write rejected: path ... is outside ... GitOps outputs`,优先按 live mirror 控制面漂移处理:先执行 `hwlab g14 git-mirror apply --confirm` 重新应用当前 `devops-infra/git-mirror.yaml` hook/ConfigMap,再执行 `hwlab g14 git-mirror sync --confirm --wait` 复核 refs;失败的同名 PipelineRun 只能通过 `hwlab g14 control-plane cleanup-runs --lane <lane> --pipeline-run <name> --confirm` 受控清理后重试,不要用原生 `kubectl delete` 或手工改 mirror hook。修复后仍必须用 `control-plane status --pipeline-run <name>` 和 `git-mirror status` 分别确认 runtime closeout 与 GitHub flush。
|
||||
- `hwlab g14 observability status|apply|query|targets|boundary|closeout [--lane v02] [--promql <expr>] [--expect-count N] [--expect-value V] [--dry-run|--confirm]` 是 G14 `devops-infra` 共享监控基础设施和 HWLAB v0.2 监控 closeout 的受控入口。`apply` 固定安装 Prometheus Operator `v0.91.0`、Prometheus `v3.12.0`、Prometheus 发现 RBAC、`devops-infra` 内 Prometheus 实例和 ClusterIP query Service,并给被允许发现的 workload namespace 打低风险 label;它不把 Prometheus、Grafana 或 Alertmanager 部署到 `hwlab-v02`,也不接管 HWLAB runtime Deployment/Service。`status` 只读汇总 CRD、operator Deployment、Prometheus CR/pod/service、`hwlab-v02` ServiceMonitor/PrometheusRule 和 bounded `up` 查询;`query` 只通过 Kubernetes service proxy 查询 Prometheus,支持 `--expect-count` / `--expect-value` 输出 `assertion`、bad values 和 missing/extra series;`targets` 汇总 ServiceMonitor/PrometheusRule、metrics sidecar readiness/restart、三层指标值和 `metrics.k8s.io` 当前 CPU/内存资源快照;`boundary` 验证 workload namespace 没有 Prometheus/Alertmanager,并对 `19666/19667` 公网 `/metrics` 做负向验证;`closeout` 聚合平台 ready、scrape reachable、sidecar serving、business health probe、resource snapshot、namespace boundary 和 public metrics exposure 语义结论。长期边界见 `docs/reference/g14-observability-infra.md`。
|
||||
- `hwlab g14 tools-image status|build --name ci-node-tools --tag <tag> [--dockerfile deploy/ci/hwlab-ci-node-tools.Dockerfile] [--dry-run|--confirm]` 是 G14 固定 HWLAB CI tools image 的受控 host build/push 入口;构建和 push 只发生在 G14 host 与本地 registry,不在 master server 构建,也不把 `apk add`/runtime install 塞进 Tekton PipelineRun。
|
||||
- `trans gh:/owner/repo ...` 把 GitHub issue/PR 映射成只读/受控写入的虚拟文本目录,适合日报、PR 正文和 issue 正文的小补丁维护:`trans gh:/pikasTech/HWLAB ls` 展示 `pr/` 与 `issue/`,`trans gh:/pikasTech/HWLAB/pr ls [--limit N] [--full]` 和 `trans gh:/pikasTech/HWLAB/issue ls [--limit N] [--full]` 展示条目状态、楼层数、正文长度和标题,`trans gh:/pikasTech/HWLAB/pr/507 ls` 展示单个 PR 的一楼正文文件,`trans gh:/pikasTech/HWLAB/505/1 cat|rg|patch-apply` 兼容旧式 issue/PR number route。`patch-apply` 使用 UniDesk 默认 apply-patch v2 的虚拟文件 executor,把正文一楼映射为 `body.md`,写回仍走 `bun scripts/cli.ts gh issue/pr update` 的 guard/concurrency 规则;`rm` 对正文一楼结构化拒绝,避免误删 issue/PR 正文。大正文读取必须展开 UniDesk gh dump 文件,否则 `cat/rg/patch-apply` 会误读为空,这是 `gh:` 虚拟文件接口的 P0 可见性契约。
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# G14 Platform PostgreSQL
|
||||
|
||||
本文是 G14 节点原生 PostgreSQL 平台库的长期参考。它覆盖 host-native PostgreSQL 的定位、连接边界、HWLAB v0.3 接入方式、备份恢复和旧自有 DB 清理入口。Redis 方案暂不纳入当前标准路径。
|
||||
|
||||
## 定位
|
||||
|
||||
G14 platform DB 是 G14 host OS 上的原生 PostgreSQL,不是 k3s workload,也不属于 `devops-infra` namespace。它位于 HWLAB runtime lane、AgentRun、HWPOD 和未来 G14 平台控制能力之下,作为节点级基础设施承载 durable runtime、OpenFGA datastore、平台控制元数据、租约、审计和状态观测。
|
||||
|
||||
数据库可以保存一部分 k3s 管理所需的状态,例如节点 inventory、runtime lane registry、operation lease、k3s state observation 和 audit event;它不直接执行 `kubectl`、Argo、Tekton 或 GitHub 写操作。k3s 写操作仍必须通过 UniDesk 受控 CLI 或对应控制器执行,数据库只作为状态与协调底座。
|
||||
|
||||
不要把平台 DB 部署成每个业务 namespace 内的自有 StatefulSet。业务 namespace 只保留指向 host-native PostgreSQL 的 ClusterIP Service/Endpoint 桥接对象和自身 SecretRef。
|
||||
|
||||
## Host 边界
|
||||
|
||||
G14 平台库固定由 systemd 管理:
|
||||
|
||||
```bash
|
||||
trans G14 script -- 'systemctl status postgresql'
|
||||
trans G14 script -- '/usr/local/sbin/g14-platform-db-health'
|
||||
```
|
||||
|
||||
PostgreSQL 只监听 G14 host loopback 与 k3s pod 可达的 node gateway 地址:
|
||||
|
||||
- `127.0.0.1:5432`
|
||||
- `10.42.0.1:5432`
|
||||
|
||||
不得开放 `0.0.0.0:5432`、NodePort、FRP 公网端口或直接公网 DNS。`pg_hba.conf` 只允许 localhost、k3s pod CIDR 和 service CIDR 通过 SCRAM 认证连接。凭据文件固定在 G14 host:
|
||||
|
||||
```text
|
||||
/root/.config/g14-platform-db/credentials.env
|
||||
```
|
||||
|
||||
该文件必须保持 root-only;任何 CLI、issue、日志和长期文档只允许输出 secret 是否存在、路径和 redacted 摘要,不得打印完整密码、完整 URI 或可复用凭据。
|
||||
|
||||
## 数据库与角色
|
||||
|
||||
平台库当前按用途拆分 database/role:
|
||||
|
||||
- `g14_platform_control` / `g14_platform_app`:G14 节点平台控制元数据、租约、观测和审计。
|
||||
- `hwlab_v03` / `hwlab_v03_app`:HWLAB v0.3 cloud-api durable runtime。
|
||||
- `openfga_v03` / `openfga_v03_app`:HWLAB v0.3 OpenFGA datastore。
|
||||
- `hwpod_v03` / `hwpod_v03_app`:HWPOD v0.3 迁移后的平台库目标;HWPOD 不应继续拥有自带 DB。
|
||||
|
||||
`postgres` 只作为管理数据库保留。新增 runtime lane 时按同一模式分配独立 database/role,不复用业务应用账号访问 `g14_platform_control`。
|
||||
|
||||
## k3s 桥接
|
||||
|
||||
业务 Pod 不直接访问 host IP 字面量,而是访问所在 namespace 内的 `g14-platform-postgres` Service。HWLAB v0.3 的桥接对象在 `hwlab-v03` namespace 中:
|
||||
|
||||
- `Service/g14-platform-postgres`
|
||||
- `Endpoints/g14-platform-postgres`
|
||||
- `EndpointSlice/g14-platform-postgres-host`
|
||||
|
||||
目标地址固定为 `10.42.0.1:5432`。当前 G14 k3s service path 需要同时保留 legacy `Endpoints` 与 `EndpointSlice`;只保留手写 EndpointSlice 时 ClusterIP 转发可能不可用。移除 `Endpoints` 之前必须先用运行面连接验证证明 kube-proxy/EndpointSlice 路径已经稳定。
|
||||
|
||||
业务 SecretRef 固定使用现有应用 Secret 名称,不迁移为共享明文配置:
|
||||
|
||||
- `hwlab-cloud-api-v03-db/database-url` 指向 `hwlab_v03`。
|
||||
- `hwlab-v03-openfga/datastore-uri` 指向 `openfga_v03`。
|
||||
- `hwpod-v03-db/database-url` 指向 `hwpod_v03`。
|
||||
|
||||
这些 SecretRef 的源凭据来自 G14 host 的 platform DB credentials file,不再从旧 lane-local Postgres Secret 派生。迁移完成后,不要用旧的 repo-owned PostgreSQL bootstrap 路径轮换 `hwlab-cloud-api-v03-db` 或 `hwlab-v03-openfga`。如需重复轮换,必须先补齐平台 DB 专用 UniDesk CLI,让它从 host 凭据文件读取、写入 k3s Secret,并输出 redacted 状态;不得把手写 `kubectl create secret` 沉淀成长期流程。
|
||||
|
||||
当前运行面验证使用 control-plane status 和 live health:
|
||||
|
||||
```bash
|
||||
bun scripts/cli.ts hwlab nodes control-plane status --node G14 --lane v03
|
||||
curl -fsS http://74.48.78.17:20667/health/live | jq '{status, ready, db: {ready: .db.ready, connectionResult: .db.connectionResult}, runtime: {ready: .runtime.ready, queryResult: .runtime.connection.queryResult}}'
|
||||
```
|
||||
|
||||
任何 CLI 输出都不得泄露 database URL 或 password,只披露 Secret/key 是否存在、字节数、DB 对象是否存在和 rollout/health 结果。
|
||||
|
||||
## HWLAB v0.3 GitOps
|
||||
|
||||
HWLAB v0.3 的 source truth 在 `G14:/root/hwlab-v03`、branch `v0.3`。`deploy/deploy.yaml` 的 `lanes.v03.externalPostgres` 是 v0.3 使用 G14 platform DB 的声明入口;render 结果必须包含 `external-postgres.yaml`,并且不得再包含旧 `postgres.yaml`。
|
||||
|
||||
标准验证:
|
||||
|
||||
```bash
|
||||
trans G14:/root/hwlab-v03 script -- 'npm run gitops:ts:check'
|
||||
trans G14:/root/hwlab-v03 script -- 'npm run gitops:render -- --lane v03 --out /tmp/hwlab-v03-render-check'
|
||||
bun scripts/cli.ts hwlab nodes control-plane trigger-current --node G14 --lane v03 --confirm
|
||||
bun scripts/cli.ts hwlab nodes control-plane status --node G14 --lane v03 --pipeline-run <pipeline-run>
|
||||
```
|
||||
|
||||
Argo Application `hwlab-node-v03` 应显示 `Synced/Healthy`,runtime workloads 中应只保留 `service/g14-platform-postgres`,不应再出现 `statefulset.apps/hwlab-v03-postgres`、`service/hwlab-v03-postgres` 或 `configmap/hwlab-v03-postgres-init`。
|
||||
|
||||
## 旧自有 DB 清理
|
||||
|
||||
迁移到平台 DB 后,v0.3 自有 PostgreSQL StatefulSet、Service、ConfigMap、Secret 和 PVC 都必须清理。GitOps 负责移除 desired-state workload;PVC 和旧 admin Secret 由受控 CLI 清理:
|
||||
|
||||
```bash
|
||||
bun scripts/cli.ts hwlab nodes secret cleanup-owned-postgres --node G14 --lane v03 --dry-run
|
||||
bun scripts/cli.ts hwlab nodes secret cleanup-owned-postgres --node G14 --lane v03 --confirm
|
||||
```
|
||||
|
||||
该入口只允许 v0.3+ lane 使用,默认 dry-run。它必须先确认:
|
||||
|
||||
- `g14-platform-postgres` Service 存在。
|
||||
- 旧 `hwlab-v03-postgres` StatefulSet 不存在。
|
||||
- 清理目标仅为旧 `hwlab-v03-postgres` Secret 和 `data-hwlab-v03-postgres-0` PVC。
|
||||
|
||||
它不得删除平台 database、平台 role、当前 `hwlab-cloud-api-v03-db`、当前 `hwlab-v03-openfga`、OpenFGA/Cloud API Deployment 或 GitOps desired state。PVC 删除前必须已经有平台库备份或迁移 dump 可用;迁移 dump 归档在 `/var/backups/g14-platform-db/migration-*`。
|
||||
|
||||
清理后用以下只读检查确认旧自有 DB 不再存在:
|
||||
|
||||
```bash
|
||||
trans G14:k3s kubectl -n hwlab-v03 get statefulset,svc,endpoints,endpointslice,configmap,secret,pvc
|
||||
curl -fsS http://74.48.78.17:20667/health/live | jq '{status, ready, environment, db: {ready: .db.ready, connectionResult: .db.connectionResult}, runtime: {ready: .runtime.ready, queryResult: .runtime.connection.queryResult}}'
|
||||
```
|
||||
|
||||
## 备份与恢复
|
||||
|
||||
备份脚本固定在 G14 host:
|
||||
|
||||
```bash
|
||||
trans G14 script -- 'systemctl status g14-platform-db-backup.timer'
|
||||
trans G14 script -- '/usr/local/sbin/g14-platform-db-backup'
|
||||
```
|
||||
|
||||
备份目录:
|
||||
|
||||
```text
|
||||
/var/backups/g14-platform-db
|
||||
```
|
||||
|
||||
目录权限必须允许 `postgres` 写入备份文件,但不允许普通用户读取。备份文件默认归属 `postgres:postgres`,权限不应宽于 `0640`。恢复操作必须先在 issue/运维记录中说明目标 database、源 dump、停机或切换窗口和 SecretRef 影响面;不得把恢复命令写入业务 Pod 或 GitOps hook 作为默认流程。
|
||||
|
||||
最小恢复原则:
|
||||
|
||||
- 恢复单个应用 database,不整库覆盖 PostgreSQL cluster。
|
||||
- 先恢复到临时 database 做 schema/table/count 校验,再切换业务 SecretRef 或执行受控 rename。
|
||||
- OpenFGA datastore 恢复后必须运行对应版本的 `openfga migrate`,再重启/观察 OpenFGA Deployment。
|
||||
- 恢复完成后运行 `/usr/local/sbin/g14-platform-db-health`、目标 lane `/health/live` 和 `hwlab nodes control-plane status`。
|
||||
|
||||
## 运维判定
|
||||
|
||||
平台 DB 可用的最低标准:
|
||||
|
||||
- `postgresql` systemd service active。
|
||||
- `ss -ltnp` 只显示 `127.0.0.1:5432` 和 `10.42.0.1:5432` 监听。
|
||||
- `/usr/local/sbin/g14-platform-db-health` 能列出预期 database。
|
||||
- `hwlab-v03` 中 `g14-platform-postgres` Service/Endpoints 可见。
|
||||
- `hwlab-cloud-api` `/health/live` 返回 `status=ok`、`ready=true`、`db.connectionResult=connected`、`runtime.connection.queryResult=durable_readiness_ready`。
|
||||
- `hwlab nodes control-plane status --node G14 --lane v03` 显示 Argo `Synced/Healthy`,runtime workload 摘要不包含旧自有 Postgres。
|
||||
|
||||
Redis 暂停期间,不为 HWLAB v0.3 新增 Redis 原生部署、SecretRef 或 runtime 依赖。后续如确需 Redis,应先单独形成平台级方案,明确是否 host-native、是否 namespace bridge、是否需要持久化、备份与业务隔离,再更新本文和对应 issue。
|
||||
@@ -4,6 +4,8 @@ G14 is the current HWLAB runtime-lane node and a UniDesk provider node for stagi
|
||||
|
||||
G14's long-lived k3s control bridge is `k3sctl-adapter-g14`, a UniDesk direct service outside the k3s fault domain. It listens on the G14 host loopback port `127.0.0.1:4266` and is registered separately from the D601 `k3sctl-adapter`, so G14 infrastructure services can be built and tested without taking over user services that still run on D601.
|
||||
|
||||
G14's node-level PostgreSQL platform database is host-native infrastructure, not a k3s workload or `devops-infra` namespace service. It is documented in `docs/reference/g14-platform-db.md`; HWLAB v0.3 uses it through the namespace-local `g14-platform-postgres` bridge and must not keep its old repo-owned PostgreSQL StatefulSet/PVC after migration.
|
||||
|
||||
For Code Queue and non-HWLAB CI/CD migration preparation, G14 uses native k3s labels `unidesk.ai/node-id=G14` and `unidesk.ai/provider-id=G14`. The G14 Code Queue manifests `src/components/microservices/k3sctl-adapter/k3s/code-queue.g14.k8s.yaml` and `src/components/microservices/k3sctl-adapter/k3s/code-queue.g14.k3s.json` are candidate staging artifacts only until an explicit production cutover is approved. Non-HWLAB production Code Queue, CI/CD and user-service execution must remain on D601 while D601 is carrying those services.
|
||||
|
||||
## Legacy DEV/PROD Retirement
|
||||
@@ -73,10 +75,12 @@ For current G14/v0.2, `deploy/deploy.yaml` is the single human-authored deploy/r
|
||||
|
||||
On the UniDesk control-plane side, HWLAB G14 runtime lane expansion is sourced from `/root/unidesk/config/hwlab-node-lanes.yaml`. That YAML owns `nodes`, `lanes`, `networkProfiles`, `downloadProfiles` and public entrypoints for the UniDesk trigger/status/apply layer: `lanes.v03.node` points at `nodes.G14`, `lanes.v03.runtime.namespace` is `hwlab-v03`, and the v0.3 public entrypoints are `http://74.48.78.17:20666/` and `http://74.48.78.17:20667/health/live`. Proxy URLs, `NO_PROXY`, git/npm/pip/docker/curl retry/download defaults and Docker build proxy settings live under profile objects. G14 must remain a node config value, not a hardcoded code path such as `g14Proxy` or `g14GitOps`; future `v0.4+` lanes should be added by adding YAML entries and then consuming the generated spec. `hyueapi.com` and `.hyueapi.com` are required `NO_PROXY` entries and must remain present in effective runtime and Docker build proxy environments.
|
||||
|
||||
The `devops-infra` git mirror/relay remains manual and CLI-controlled, not CronJob-driven. The standard `v0.2` delivery trigger is `bun scripts/cli.ts hwlab g14 monitor-prs --lane v02`: it watches base=`v0.2` PRs, waits for GitHub preflight/CI readiness, auto-merges only ready and non-conflicting PRs, then drives the same controlled CD path and comments pending/blocked/succeeded/failed/timeout state back to the PR. The lower-level `bun scripts/cli.ts hwlab g14 control-plane trigger-current --lane v02 --confirm` remains the manual recovery or diagnosis entry; it must fetch `/root/hwlab-v02-cicd.git`, resolve the current `origin/v0.2` source commit, check the mirror's `localV02` ref before creating the PipelineRun, run one bounded manual `git-mirror sync` Job when the mirror is stale, and only continue after the mirror ref matches the current source commit. Use `hwlab g14 git-mirror sync --confirm` directly only for explicit mirror maintenance or diagnosis.
|
||||
The `devops-infra` git mirror/relay remains manual and CLI-controlled, not CronJob-driven. The standard `v0.2` delivery trigger is `bun scripts/cli.ts hwlab g14 monitor-prs --lane v02`: it watches base=`v0.2` PRs, waits for GitHub preflight/CI readiness, auto-merges only ready and non-conflicting PRs, then drives the same controlled CD path and comments pending/blocked/succeeded/failed/timeout state back to the PR. The `v0.3` delivery trigger is `bun scripts/cli.ts hwlab g14 monitor-prs --lane v03`: it watches base=`v0.3` PRs, uses the runtime lane control-plane to trigger CD, verifies PipelineRun/Argo/`hwlab-v03` runtime public probes/Git mirror flush, comments PR progress, and creates or updates failure issues for failed checks, conflicts, merge blockers, CD failures and timeouts. The lower-level `bun scripts/cli.ts hwlab g14 control-plane trigger-current --lane v02|v03 --confirm` remains the manual recovery or diagnosis entry; it must fetch the lane CI/CD bare repo, resolve the current source branch commit, check the mirror source ref before creating the PipelineRun, run one bounded manual `git-mirror sync` Job when the mirror is stale, and only continue after the mirror ref matches the current source commit. Use `hwlab g14 git-mirror sync --confirm` directly only for explicit mirror maintenance or diagnosis.
|
||||
|
||||
After a `v0.2` PipelineRun completes, treat runtime rollout and remote GitOps persistence as two separate checks. `hwlab g14 control-plane status --lane v02` is the runtime check: it must show the expected source commit, PipelineRun completed, Argo `Synced/Healthy`, public 19666/19667 probes passing, and Cloud Web asset probes such as `/app.js` readable. `hwlab g14 git-mirror status` is the persistence check: `cache.summary.pendingFlush` must be false and `cache.summary.githubInSync` true before declaring GitOps fully flushed back to GitHub. The PR monitor performs this flush automatically for its own merged PRs and records the result in the PR comment. Manual operators should run `bun scripts/cli.ts hwlab g14 git-mirror flush --confirm` and poll the returned job with `bun scripts/cli.ts job status <jobId> --tail-bytes 12000` only when they used lower-level manual trigger/status paths or when the monitor reports a flush failure; do not replace this with raw `kubectl`, native `git push`, or a long SSH wait.
|
||||
|
||||
If `gitops-promote` fails because the mirror write hook rejects a rendered GitOps path as outside the allowed lane outputs, treat it as `devops-infra` mirror control-plane drift until proven otherwise. The recovery path is `hwlab g14 git-mirror apply --confirm` to reinstall the current hook/ConfigMap, `hwlab g14 git-mirror sync --confirm --wait` to realign source and GitOps refs, then a targeted `control-plane cleanup-runs --pipeline-run <failed-run> --confirm` before retriggering the same lane. Do not patch the hook inside the pod, delete PipelineRuns with raw kubectl, or bypass `git-mirror flush`; closeout still requires the target PipelineRun status, Argo health, public probes, and `git-mirror status` with `pendingFlush=false`.
|
||||
|
||||
When closing an issue against a specific completed `v0.2` PipelineRun, use targeted status instead of the latest-head status if `origin/v0.2` has already advanced through a parallel task:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -34,6 +34,13 @@ assertCondition(
|
||||
&& hwlabG14MonitorStateFileName({ lane: "v02", once: true, dryRun: true }) === "latest-v02-once-dry-run-job.json",
|
||||
"v0.2 PR monitor state pointers must not overwrite the legacy G14 monitor pointers",
|
||||
);
|
||||
assertCondition(
|
||||
hwlabG14MonitorStateFileName({ lane: "v03", once: false, dryRun: false }) === "latest-v03-monitor-job.json"
|
||||
&& hwlabG14MonitorStateFileName({ lane: "v03", once: true, dryRun: false }) === "latest-v03-once-job.json"
|
||||
&& hwlabG14MonitorStateFileName({ lane: "v03", once: false, dryRun: true }) === "latest-v03-dry-run-job.json"
|
||||
&& hwlabG14MonitorStateFileName({ lane: "v03", once: true, dryRun: true }) === "latest-v03-once-dry-run-job.json",
|
||||
"v0.3 PR monitor state pointers must not overwrite v0.2 or legacy G14 monitor pointers",
|
||||
);
|
||||
const hwlabHelp = hwlabG14Help();
|
||||
const hwlabHelpUsage = Array.isArray(hwlabHelp.usage) ? hwlabHelp.usage.map((line: unknown) => String(line)) : [];
|
||||
const hwlabHelpJson = JSON.stringify(hwlabHelp);
|
||||
@@ -131,9 +138,12 @@ assertCondition(
|
||||
assertCondition(
|
||||
hwlabHelpUsage.some((line) => line.includes("monitor-prs --lane v02"))
|
||||
&& hwlabHelpUsage.some((line) => line.includes("monitor-prs --lane v02 --status"))
|
||||
&& hwlabHelpUsage.some((line) => line.includes("monitor-prs --lane v03"))
|
||||
&& hwlabHelpUsage.some((line) => line.includes("monitor-prs --lane v03 --status"))
|
||||
&& hwlabHelpJson.includes("v02-pr-comment-signatures.json")
|
||||
&& hwlabHelpJson.includes("v03-pr-comment-signatures.json")
|
||||
&& hwlabHelpJson.includes("latest-only"),
|
||||
"v0.2 PR monitor help must expose the auto CI/CD lane, status query, latest-only CD, and dedupe comment state",
|
||||
"runtime lane PR monitor help must expose v0.2/v0.3 auto CI/CD lanes, status query, latest-only CD, and dedupe comment state",
|
||||
hwlabHelp,
|
||||
);
|
||||
assertCondition(
|
||||
@@ -452,6 +462,18 @@ const renderScript = v02ControlPlaneRenderScript("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
const renderScriptHash = v02ControlPlaneRefreshScriptHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
const sourceText = await Bun.file(new URL("./src/hwlab-g14.ts", import.meta.url)).text();
|
||||
const jobsSourceText = await Bun.file(new URL("./src/jobs.ts", import.meta.url)).text();
|
||||
assertCondition(
|
||||
sourceText.includes("monitor-prs --lane must be g14 or")
|
||||
&& sourceText.includes("monitorRuntimeLaneCycle")
|
||||
&& sourceText.includes("reportRuntimeLaneAutomationIssue")
|
||||
&& sourceText.includes("runtimeLanePublicProbeScript")
|
||||
&& sourceText.includes("publicProbes")
|
||||
&& sourceText.includes("runtimeLaneRerunPipelineRunName")
|
||||
&& sourceText.includes("source-commit=$source_commit")
|
||||
&& sourceText.includes("same source commit is idempotent only after")
|
||||
&& sourceText.includes("creates or updates failure issues"),
|
||||
"v0.3 monitor must accept runtime lane ids, use runtime lane control-plane/public probes, rerun incomplete CD, and create/update failure issues for blocked automation",
|
||||
);
|
||||
assertCondition(
|
||||
renderScript.includes("git clone --shared --no-checkout \"$cicd_repo\" \"$worktree_dir\"")
|
||||
&& renderScript.includes("git -C \"$worktree_dir\" checkout --detach \"$source_commit\"")
|
||||
|
||||
+779
-116
File diff suppressed because it is too large
Load Diff
+141
-8
@@ -5,8 +5,8 @@ import { startJob } from "./jobs";
|
||||
import { runHwlabG14Command } from "./hwlab-g14";
|
||||
import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane } from "./hwlab-node-lanes";
|
||||
|
||||
type SecretAction = "status" | "ensure";
|
||||
type SecretPreset = "openfga" | "master-server-admin-api-key" | "code-agent-provider" | "cloud-api-db";
|
||||
type SecretAction = "status" | "ensure" | "cleanup-owned-postgres";
|
||||
type SecretPreset = "openfga" | "master-server-admin-api-key" | "code-agent-provider" | "cloud-api-db" | "owned-postgres-cleanup";
|
||||
type DelegatedNodeDomain = "control-plane" | "git-mirror";
|
||||
|
||||
interface NodeSecretOptions {
|
||||
@@ -86,6 +86,8 @@ export function hwlabNodeHelp(): Record<string, unknown> {
|
||||
"bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-master-server-admin-api-key --confirm",
|
||||
"bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name hwlab-cloud-api-v03-db",
|
||||
"bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-cloud-api-v03-db --confirm",
|
||||
"bun scripts/cli.ts hwlab nodes secret cleanup-owned-postgres --node G14 --lane v03 --dry-run",
|
||||
"bun scripts/cli.ts hwlab nodes secret cleanup-owned-postgres --node G14 --lane v03 --confirm",
|
||||
"bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name hwlab-v03-code-agent-provider",
|
||||
"bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-code-agent-provider --confirm",
|
||||
],
|
||||
@@ -203,8 +205,8 @@ function rewriteDelegatedNodeString(value: string, scoped: ReturnType<typeof par
|
||||
|
||||
function parseSecretOptions(args: string[]): NodeSecretOptions {
|
||||
const [actionRaw] = args;
|
||||
if (actionRaw !== "status" && actionRaw !== "ensure") {
|
||||
throw new Error("secret usage: status|ensure --node NODE --lane vNN --name hwlab-vNN-openfga|hwlab-vNN-master-server-admin-api-key|hwlab-cloud-api-vNN-db|hwlab-vNN-code-agent-provider [--dry-run|--confirm]");
|
||||
if (actionRaw !== "status" && actionRaw !== "ensure" && actionRaw !== "cleanup-owned-postgres") {
|
||||
throw new Error("secret usage: status|ensure --node NODE --lane vNN --name hwlab-vNN-openfga|hwlab-vNN-master-server-admin-api-key|hwlab-cloud-api-vNN-db|hwlab-vNN-code-agent-provider [--dry-run|--confirm] | cleanup-owned-postgres --node NODE --lane vNN [--dry-run|--confirm]");
|
||||
}
|
||||
const node = requiredOption(args, "--node");
|
||||
assertNodeId(node);
|
||||
@@ -216,6 +218,21 @@ function parseSecretOptions(args: string[]): NodeSecretOptions {
|
||||
const confirm = args.includes("--confirm");
|
||||
const explicitDryRun = args.includes("--dry-run");
|
||||
if (confirm && explicitDryRun) throw new Error("secret accepts only one of --confirm or --dry-run");
|
||||
if (actionRaw === "cleanup-owned-postgres") {
|
||||
if (lane === "v02") throw new Error("secret cleanup-owned-postgres is only for v0.3+ lanes after migration to G14 platform Postgres");
|
||||
if (key !== undefined) throw new Error("secret cleanup-owned-postgres does not accept --key");
|
||||
if (name !== spec.openFgaSecret && name !== spec.postgresSecret) throw new Error(`secret cleanup-owned-postgres for --lane ${lane} targets ${spec.postgresSecret}; omit --name or pass --name ${spec.postgresSecret}`);
|
||||
return {
|
||||
action: actionRaw,
|
||||
node,
|
||||
lane,
|
||||
name: spec.postgresSecret,
|
||||
preset: "owned-postgres-cleanup",
|
||||
confirm,
|
||||
dryRun: explicitDryRun || !confirm,
|
||||
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 180, 900),
|
||||
};
|
||||
}
|
||||
if (name === spec.masterAdminApiKeySecret) {
|
||||
if (key !== undefined && key !== MASTER_ADMIN_API_KEY_KEY) throw new Error(`secret ${name} supports only key ${MASTER_ADMIN_API_KEY_KEY}`);
|
||||
return {
|
||||
@@ -317,11 +334,14 @@ function runNodeSecret(options: NodeSecretOptions): Record<string, unknown> {
|
||||
? masterAdminApiKeySecretScript(options, spec)
|
||||
: options.preset === "cloud-api-db"
|
||||
? cloudApiDbSecretScript(options, spec)
|
||||
: codeAgentProviderSecretScript(options, spec);
|
||||
: options.preset === "owned-postgres-cleanup"
|
||||
? ownedPostgresCleanupScript(options, spec)
|
||||
: codeAgentProviderSecretScript(options, spec);
|
||||
const result = runTransScript(options.node, script, input, options.timeoutSeconds);
|
||||
const status = secretStatusFromText(statusText(result), result.exitCode === 0, result.exitCode, result.stderr, spec);
|
||||
const dryRunOk = options.action === "ensure" && options.dryRun && result.exitCode === 0;
|
||||
const ok = dryRunOk ? true : status.ok === true;
|
||||
const cleanupDryRunOk = options.action === "cleanup-owned-postgres" && options.dryRun && result.exitCode === 0;
|
||||
const ok = dryRunOk || cleanupDryRunOk ? true : status.ok === true;
|
||||
return {
|
||||
ok,
|
||||
command: `hwlab nodes secret ${options.action}`,
|
||||
@@ -331,13 +351,15 @@ function runNodeSecret(options: NodeSecretOptions): Record<string, unknown> {
|
||||
secret: options.name,
|
||||
key: options.key ?? null,
|
||||
preset: options.preset,
|
||||
mode: options.action === "status" ? "status" : options.dryRun ? "dry-run" : "confirmed-ensure",
|
||||
mode: options.action === "status" ? "status" : options.dryRun ? "dry-run" : options.action === "cleanup-owned-postgres" ? "confirmed-delete" : "confirmed-ensure",
|
||||
status,
|
||||
mutation: status.mutation === true,
|
||||
result: compactCommandResult(result),
|
||||
valuesRedacted: true,
|
||||
next: ok && options.action === "status" ? undefined : {
|
||||
ensure: `bun scripts/cli.ts hwlab nodes secret ensure --node ${options.node} --lane ${options.lane} --name ${options.name}${options.key ? ` --key ${options.key}` : ""} --confirm`,
|
||||
ensure: options.action === "cleanup-owned-postgres"
|
||||
? `bun scripts/cli.ts hwlab nodes secret cleanup-owned-postgres --node ${options.node} --lane ${options.lane} --confirm`
|
||||
: `bun scripts/cli.ts hwlab nodes secret ensure --node ${options.node} --lane ${options.lane} --name ${options.name}${options.key ? ` --key ${options.key}` : ""} --confirm`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -346,6 +368,76 @@ function runTransScript(node: string, script: string, input: string, timeoutSeco
|
||||
return runCommand(["/root/.local/bin/trans", `${node}:k3s`, "script", "--", script], repoRoot, { input, timeoutMs: timeoutSeconds * 1000 });
|
||||
}
|
||||
|
||||
function ownedPostgresCleanupScript(options: NodeSecretOptions, spec: RuntimeSecretSpec): string {
|
||||
const pvc = `data-${spec.postgresSecret}-0`;
|
||||
const platformService = "g14-platform-postgres";
|
||||
return [
|
||||
"set +e",
|
||||
`namespace=${shellQuote(spec.namespace)}`,
|
||||
`postgres_secret=${shellQuote(spec.postgresSecret)}`,
|
||||
`postgres_statefulset=${shellQuote(spec.postgresStatefulSet)}`,
|
||||
`pvc=${shellQuote(pvc)}`,
|
||||
`platform_service=${shellQuote(platformService)}`,
|
||||
`dry_run=${shellQuote(options.dryRun ? "true" : "false")}`,
|
||||
"preset=owned-postgres-cleanup",
|
||||
"exists_flag() { kind=\"$1\"; item=\"$2\"; kubectl -n \"$namespace\" get \"$kind\" \"$item\" >/dev/null 2>&1 && printf yes || printf no; }",
|
||||
"pv_name() { kubectl -n \"$namespace\" get pvc \"$pvc\" -o jsonpath='{.spec.volumeName}' 2>/dev/null; }",
|
||||
"phase_of_pvc() { kubectl -n \"$namespace\" get pvc \"$pvc\" -o jsonpath='{.status.phase}' 2>/dev/null; }",
|
||||
"before_secret_exists=$(exists_flag secret \"$postgres_secret\")",
|
||||
"before_pvc_exists=$(exists_flag pvc \"$pvc\")",
|
||||
"before_pvc_phase=$(phase_of_pvc)",
|
||||
"before_pv=$(pv_name)",
|
||||
"before_statefulset_exists=$(exists_flag statefulset \"$postgres_statefulset\")",
|
||||
"platform_service_exists=$(exists_flag service \"$platform_service\")",
|
||||
"action=observed",
|
||||
"mutation=false",
|
||||
"delete_secret_exit=",
|
||||
"delete_pvc_exit=",
|
||||
"if [ \"$dry_run\" = true ]; then",
|
||||
" if [ \"$before_secret_exists\" = yes ] || [ \"$before_pvc_exists\" = yes ]; then action=would-delete; else action=already-absent; fi",
|
||||
"else",
|
||||
" kubectl -n \"$namespace\" delete secret \"$postgres_secret\" --ignore-not-found=true >/tmp/hwlab-owned-postgres-secret-delete.out 2>/tmp/hwlab-owned-postgres-secret-delete.err",
|
||||
" delete_secret_exit=$?",
|
||||
" kubectl -n \"$namespace\" delete pvc \"$pvc\" --ignore-not-found=true >/tmp/hwlab-owned-postgres-pvc-delete.out 2>/tmp/hwlab-owned-postgres-pvc-delete.err",
|
||||
" delete_pvc_exit=$?",
|
||||
" if [ \"$delete_secret_exit\" -eq 0 ] && [ \"$delete_pvc_exit\" -eq 0 ]; then",
|
||||
" if [ \"$before_secret_exists\" = yes ] || [ \"$before_pvc_exists\" = yes ]; then action=deleted; mutation=true; else action=already-absent; fi",
|
||||
" else",
|
||||
" action=delete-failed",
|
||||
" fi",
|
||||
"fi",
|
||||
"after_secret_exists=$(exists_flag secret \"$postgres_secret\")",
|
||||
"after_pvc_exists=$(exists_flag pvc \"$pvc\")",
|
||||
"after_pvc_phase=$(phase_of_pvc)",
|
||||
"after_pv=$(pv_name)",
|
||||
"after_statefulset_exists=$(exists_flag statefulset \"$postgres_statefulset\")",
|
||||
"printf 'namespace\\t%s\\n' \"$namespace\"",
|
||||
"printf 'secret\\t%s\\n' \"$postgres_secret\"",
|
||||
"printf 'pvc\\t%s\\n' \"$pvc\"",
|
||||
"printf 'preset\\t%s\\n' \"$preset\"",
|
||||
"printf 'action\\t%s\\n' \"$action\"",
|
||||
"printf 'dryRun\\t%s\\n' \"$dry_run\"",
|
||||
"printf 'mutation\\t%s\\n' \"$mutation\"",
|
||||
"printf 'beforeSecretExists\\t%s\\n' \"$before_secret_exists\"",
|
||||
"printf 'beforePvcExists\\t%s\\n' \"$before_pvc_exists\"",
|
||||
"printf 'beforePvcPhase\\t%s\\n' \"$before_pvc_phase\"",
|
||||
"printf 'beforePersistentVolume\\t%s\\n' \"$before_pv\"",
|
||||
"printf 'beforeStatefulSetExists\\t%s\\n' \"$before_statefulset_exists\"",
|
||||
"printf 'platformServiceExists\\t%s\\n' \"$platform_service_exists\"",
|
||||
"printf 'afterSecretExists\\t%s\\n' \"$after_secret_exists\"",
|
||||
"printf 'afterPvcExists\\t%s\\n' \"$after_pvc_exists\"",
|
||||
"printf 'afterPvcPhase\\t%s\\n' \"$after_pvc_phase\"",
|
||||
"printf 'afterPersistentVolume\\t%s\\n' \"$after_pv\"",
|
||||
"printf 'afterStatefulSetExists\\t%s\\n' \"$after_statefulset_exists\"",
|
||||
"printf 'deleteSecretExitCode\\t%s\\n' \"$delete_secret_exit\"",
|
||||
"printf 'deletePvcExitCode\\t%s\\n' \"$delete_pvc_exit\"",
|
||||
"if [ \"$platform_service_exists\" != yes ]; then exit 44; fi",
|
||||
"if [ \"$before_statefulset_exists\" = yes ] || [ \"$after_statefulset_exists\" = yes ]; then exit 45; fi",
|
||||
"if [ -n \"$delete_secret_exit\" ] && [ \"$delete_secret_exit\" != 0 ]; then exit \"$delete_secret_exit\"; fi",
|
||||
"if [ -n \"$delete_pvc_exit\" ] && [ \"$delete_pvc_exit\" != 0 ]; then exit \"$delete_pvc_exit\"; fi",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function openFgaSecretScript(options: NodeSecretOptions, spec: RuntimeSecretSpec): string {
|
||||
return [
|
||||
"set +e",
|
||||
@@ -803,6 +895,47 @@ function cloudApiDbSecretScript(options: NodeSecretOptions, spec: RuntimeSecretS
|
||||
|
||||
function secretStatusFromText(text: string, commandOk: boolean, exitCode: number | null, stderr: string, spec: RuntimeSecretSpec): Record<string, unknown> {
|
||||
const fields = keyValueLinesFromText(text);
|
||||
if (fields.preset === "owned-postgres-cleanup") {
|
||||
const absent = fields.afterSecretExists !== "yes" && fields.afterPvcExists !== "yes";
|
||||
const oldWorkloadAbsent = fields.afterStatefulSetExists !== "yes";
|
||||
const platformServiceReady = fields.platformServiceExists === "yes";
|
||||
return {
|
||||
ok: commandOk && absent && oldWorkloadAbsent && platformServiceReady,
|
||||
namespace: fields.namespace || spec.namespace,
|
||||
secret: fields.secret || spec.postgresSecret,
|
||||
pvc: fields.pvc || `data-${spec.postgresSecret}-0`,
|
||||
preset: "owned-postgres-cleanup",
|
||||
action: fields.action || null,
|
||||
dryRun: fields.dryRun === "true",
|
||||
mutation: fields.mutation === "true",
|
||||
before: {
|
||||
secretExists: fields.beforeSecretExists === "yes",
|
||||
pvcExists: fields.beforePvcExists === "yes",
|
||||
pvcPhase: fields.beforePvcPhase || null,
|
||||
persistentVolume: fields.beforePersistentVolume || null,
|
||||
statefulSetExists: fields.beforeStatefulSetExists === "yes",
|
||||
},
|
||||
after: {
|
||||
secretExists: fields.afterSecretExists === "yes",
|
||||
pvcExists: fields.afterPvcExists === "yes",
|
||||
pvcPhase: fields.afterPvcPhase || null,
|
||||
persistentVolume: fields.afterPersistentVolume || null,
|
||||
statefulSetExists: fields.afterStatefulSetExists === "yes",
|
||||
},
|
||||
platformService: {
|
||||
name: "g14-platform-postgres",
|
||||
exists: platformServiceReady,
|
||||
},
|
||||
deleteSecretExitCode: numericField(fields.deleteSecretExitCode),
|
||||
deletePvcExitCode: numericField(fields.deletePvcExitCode),
|
||||
exitCode,
|
||||
stderr: commandOk ? "" : stderr.trim().slice(0, 2000),
|
||||
valuesRedacted: true,
|
||||
summary: absent
|
||||
? `${fields.secret || spec.postgresSecret} and ${fields.pvc || `data-${spec.postgresSecret}-0`} absent`
|
||||
: `${fields.secret || spec.postgresSecret} or ${fields.pvc || `data-${spec.postgresSecret}-0`} still exists`,
|
||||
};
|
||||
}
|
||||
if (fields.preset === "master-server-admin-api-key") {
|
||||
const afterBytes = numericField(fields.afterApiKeyBytes);
|
||||
const healthy = fields.afterExists === "yes" && fields.afterApiKeyPresent === "yes" && typeof afterBytes === "number" && afterBytes > 0;
|
||||
|
||||
@@ -0,0 +1,848 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { UniDeskConfig } from "./config";
|
||||
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
|
||||
|
||||
const g14K3sRoute = "G14:k3s";
|
||||
const namespace = "platform-infra";
|
||||
const serviceName = "sub2api";
|
||||
const serviceDns = `${serviceName}.${namespace}.svc.cluster.local:8080`;
|
||||
const fieldManager = "unidesk-platform-infra";
|
||||
const appSecretName = "sub2api-secrets";
|
||||
const poolGroupName = "unidesk-codex-pool";
|
||||
const poolApiKeyName = "unidesk-codex-pool-api-key";
|
||||
const poolApiKeySecretName = "sub2api-codex-pool-api-key";
|
||||
const poolApiKeySecretKey = "API_KEY";
|
||||
|
||||
interface DisclosureOptions {
|
||||
full: boolean;
|
||||
raw: boolean;
|
||||
}
|
||||
|
||||
interface SyncOptions extends DisclosureOptions {
|
||||
confirm: boolean;
|
||||
}
|
||||
|
||||
interface CodexProfile {
|
||||
profile: string;
|
||||
accountName: string;
|
||||
configFile: string;
|
||||
authFile: string;
|
||||
provider: string;
|
||||
baseUrl: string;
|
||||
wireApi: string | null;
|
||||
model: string | null;
|
||||
envKey: string | null;
|
||||
apiKey: string | null;
|
||||
apiKeySource: "auth-json" | "env" | null;
|
||||
authOpenAIKeyShape: string;
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function codexPoolHelp(): unknown {
|
||||
return {
|
||||
command: "platform-infra sub2api codex-pool plan|sync|validate",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool plan",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool validate [--full|--raw]",
|
||||
],
|
||||
description: "Import ~/.codex/config.toml plus config.toml.* API-key profiles into one Sub2API OpenAI pool and expose one internal API_KEY stored in a k3s Secret.",
|
||||
target: {
|
||||
route: g14K3sRoute,
|
||||
namespace,
|
||||
serviceDns,
|
||||
poolGroupName,
|
||||
poolApiKeySecretName,
|
||||
poolApiKeySecretKey,
|
||||
secretValuesPrinted: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCodexPoolCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
|
||||
const [action = "plan"] = args;
|
||||
if (action === "plan") return codexPoolPlan();
|
||||
if (action === "sync") return await codexPoolSync(config, parseSyncOptions(args.slice(1)));
|
||||
if (action === "validate") return await codexPoolValidate(config, parseDisclosureOptions(args.slice(1)));
|
||||
return {
|
||||
ok: false,
|
||||
error: "unsupported-platform-infra-sub2api-codex-pool-command",
|
||||
args,
|
||||
help: codexPoolHelp(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSyncOptions(args: string[]): SyncOptions {
|
||||
validateOptions(args, new Set(["--confirm", "--full", "--raw"]));
|
||||
const disclosure = parseDisclosureOptions(args.filter((arg) => arg !== "--confirm"));
|
||||
return { ...disclosure, confirm: args.includes("--confirm") };
|
||||
}
|
||||
|
||||
function parseDisclosureOptions(args: string[]): DisclosureOptions {
|
||||
validateOptions(args, new Set(["--full", "--raw"]));
|
||||
const raw = args.includes("--raw");
|
||||
return { full: raw || args.includes("--full"), raw };
|
||||
}
|
||||
|
||||
function validateOptions(args: string[], booleanOptions: Set<string>): void {
|
||||
for (const arg of args) {
|
||||
if (booleanOptions.has(arg)) continue;
|
||||
throw new Error(`unsupported option: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function codexPoolPlan(): Record<string, unknown> {
|
||||
const profiles = collectCodexProfiles();
|
||||
const ok = profiles.length > 0 && profiles.every((profile) => profile.ok);
|
||||
return {
|
||||
ok,
|
||||
action: "platform-infra-sub2api-codex-pool-plan",
|
||||
source: {
|
||||
directory: join(homedir(), ".codex"),
|
||||
configPattern: "config.toml and config.toml.*",
|
||||
authPattern: "auth.json and auth.json.*",
|
||||
valuesPrinted: false,
|
||||
},
|
||||
target: poolTarget(),
|
||||
profiles: profiles.map(redactProfile),
|
||||
decision: {
|
||||
accountType: "openai/apikey",
|
||||
grouping: `All discovered Codex profiles are bound to one Sub2API group named ${poolGroupName}.`,
|
||||
unifiedApiKey: `The client-facing API_KEY is controlled by k3s Secret ${namespace}/${poolApiKeySecretName}.${poolApiKeySecretKey}.`,
|
||||
idempotency: "sync reuses the group, account names, and k3s Secret when they already exist; credentials are updated from the current local Codex files.",
|
||||
configPolicy: "UniDesk-owned durable configuration remains YAML-first; local ~/.codex files and runtime Secrets are not committed.",
|
||||
},
|
||||
next: ok
|
||||
? { sync: "bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm" }
|
||||
: { fix: "Ensure every discovered config.toml profile has a base_url and either auth.json OPENAI_API_KEY or the configured env_key present in this shell." },
|
||||
};
|
||||
}
|
||||
|
||||
async function codexPoolSync(config: UniDeskConfig, options: SyncOptions): Promise<Record<string, unknown>> {
|
||||
const profiles = collectCodexProfiles();
|
||||
const planOk = profiles.length > 0 && profiles.every((profile) => profile.ok);
|
||||
if (!options.confirm || !planOk) {
|
||||
return {
|
||||
...codexPoolPlan(),
|
||||
ok: !options.confirm ? planOk : false,
|
||||
mode: options.confirm ? "blocked-invalid-local-profile" : "dry-run",
|
||||
next: options.confirm
|
||||
? { fix: "Repair invalid local Codex profiles, then rerun sync --confirm." }
|
||||
: { confirm: "bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm" },
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
pool: {
|
||||
groupName: poolGroupName,
|
||||
apiKeyName: poolApiKeyName,
|
||||
apiKeySecretName: poolApiKeySecretName,
|
||||
apiKeySecretKey: poolApiKeySecretKey,
|
||||
},
|
||||
profiles: profiles.map((profile) => ({
|
||||
profile: profile.profile,
|
||||
accountName: profile.accountName,
|
||||
configFile: profile.configFile,
|
||||
authFile: profile.authFile,
|
||||
provider: profile.provider,
|
||||
baseUrl: profile.baseUrl,
|
||||
wireApi: profile.wireApi,
|
||||
model: profile.model,
|
||||
apiKey: profile.apiKey,
|
||||
apiKeySource: profile.apiKeySource,
|
||||
apiKeyFingerprint: fingerprint(profile.apiKey ?? ""),
|
||||
})),
|
||||
};
|
||||
const result = await capture(config, g14K3sRoute, ["script"], syncScript(payload));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
if (options.raw) {
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
action: "platform-infra-sub2api-codex-pool-sync",
|
||||
remote: compactCapture(result, { full: true }),
|
||||
parsed,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
action: "platform-infra-sub2api-codex-pool-sync",
|
||||
local: {
|
||||
profiles: profiles.map(redactProfile),
|
||||
valuesPrinted: false,
|
||||
},
|
||||
remote: parsed ?? compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
||||
next: {
|
||||
validate: "bun scripts/cli.ts platform-infra sub2api codex-pool validate",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function codexPoolValidate(config: UniDeskConfig, options: DisclosureOptions): Promise<Record<string, unknown>> {
|
||||
const result = await capture(config, g14K3sRoute, ["script"], validateScript());
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
if (options.raw) {
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
action: "platform-infra-sub2api-codex-pool-validate",
|
||||
remote: compactCapture(result, { full: true }),
|
||||
parsed,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
action: "platform-infra-sub2api-codex-pool-validate",
|
||||
summary: parsed,
|
||||
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
||||
};
|
||||
}
|
||||
|
||||
function collectCodexProfiles(): CodexProfile[] {
|
||||
const codexDir = join(homedir(), ".codex");
|
||||
if (!existsSync(codexDir)) return [];
|
||||
const configFiles = readdirSync(codexDir)
|
||||
.filter((file) => file === "config.toml" || file.startsWith("config.toml."))
|
||||
.sort((a, b) => {
|
||||
if (a === "config.toml") return -1;
|
||||
if (b === "config.toml") return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
const seenAccountNames = new Set<string>();
|
||||
return configFiles.map((configFile) => {
|
||||
const suffix = configFile === "config.toml" ? "" : configFile.slice("config.toml.".length);
|
||||
const profile = suffix === "" ? "default" : suffix;
|
||||
const accountName = uniqueAccountName(profile, seenAccountNames);
|
||||
const authFile = suffix === "" ? "auth.json" : `auth.json.${suffix}`;
|
||||
const configPath = join(codexDir, configFile);
|
||||
const authPath = join(codexDir, authFile);
|
||||
const base: CodexProfile = {
|
||||
profile,
|
||||
accountName,
|
||||
configFile,
|
||||
authFile,
|
||||
provider: "",
|
||||
baseUrl: "",
|
||||
wireApi: null,
|
||||
model: null,
|
||||
envKey: null,
|
||||
apiKey: null,
|
||||
apiKeySource: null,
|
||||
authOpenAIKeyShape: existsSync(authPath) ? "unknown" : "missing",
|
||||
ok: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const parsed = Bun.TOML.parse(readFileSync(configPath, "utf8")) as unknown;
|
||||
if (!isRecord(parsed)) throw new Error("config is not a TOML object");
|
||||
const providers = parsed.model_providers;
|
||||
if (!isRecord(providers)) throw new Error("model_providers is missing");
|
||||
const provider = stringValue(parsed.model_provider) ?? Object.keys(providers)[0] ?? "";
|
||||
if (provider === "") throw new Error("model_provider is missing");
|
||||
const providerConfig = providers[provider];
|
||||
if (!isRecord(providerConfig)) throw new Error(`model provider ${provider} is missing`);
|
||||
const baseUrl = normalizeBaseUrl(stringValue(providerConfig.base_url));
|
||||
if (baseUrl === null) throw new Error(`model provider ${provider} base_url is missing or invalid`);
|
||||
base.provider = provider;
|
||||
base.baseUrl = baseUrl;
|
||||
base.envKey = stringValue(providerConfig.env_key);
|
||||
base.wireApi = stringValue(providerConfig.wire_api);
|
||||
base.model = stringValue(parsed.model);
|
||||
|
||||
const auth = readAuthAPIKey(authPath);
|
||||
base.authOpenAIKeyShape = auth.shape;
|
||||
if (auth.apiKey !== null) {
|
||||
base.apiKey = auth.apiKey;
|
||||
base.apiKeySource = "auth-json";
|
||||
} else if (base.envKey !== null && typeof process.env[base.envKey] === "string" && process.env[base.envKey]!.length > 0) {
|
||||
base.apiKey = process.env[base.envKey]!;
|
||||
base.apiKeySource = "env";
|
||||
}
|
||||
if (base.apiKey === null || base.apiKey.length === 0) {
|
||||
throw new Error(base.envKey === null ? "auth OPENAI_API_KEY is missing or empty" : `auth OPENAI_API_KEY is missing and env ${base.envKey} is not present`);
|
||||
}
|
||||
base.ok = true;
|
||||
return base;
|
||||
} catch (error) {
|
||||
base.error = error instanceof Error ? error.message : String(error);
|
||||
return base;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function readAuthAPIKey(authPath: string): { apiKey: string | null; shape: string } {
|
||||
if (!existsSync(authPath)) return { apiKey: null, shape: "missing" };
|
||||
const parsed = JSON.parse(readFileSync(authPath, "utf8")) as unknown;
|
||||
if (!isRecord(parsed)) return { apiKey: null, shape: "non-object" };
|
||||
const value = parsed.OPENAI_API_KEY;
|
||||
const shape = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
||||
if (typeof value === "string" && value.length > 0) return { apiKey: value, shape };
|
||||
return { apiKey: null, shape };
|
||||
}
|
||||
|
||||
function uniqueAccountName(profile: string, seen: Set<string>): string {
|
||||
const normalized = profile
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/gu, "-")
|
||||
.replace(/^-+|-+$/gu, "") || "default";
|
||||
let candidate = `unidesk-codex-${normalized}`;
|
||||
let counter = 2;
|
||||
while (seen.has(candidate)) {
|
||||
candidate = `unidesk-codex-${normalized}-${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
seen.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function redactProfile(profile: CodexProfile): Record<string, unknown> {
|
||||
return {
|
||||
profile: profile.profile,
|
||||
accountName: profile.accountName,
|
||||
configFile: profile.configFile,
|
||||
authFile: profile.authFile,
|
||||
provider: profile.provider || null,
|
||||
baseUrl: profile.baseUrl || null,
|
||||
wireApi: profile.wireApi,
|
||||
model: profile.model,
|
||||
envKey: profile.envKey,
|
||||
apiKeySource: profile.apiKeySource,
|
||||
apiKeyPresent: profile.apiKey !== null && profile.apiKey.length > 0,
|
||||
apiKeyBytes: profile.apiKey === null ? 0 : Buffer.byteLength(profile.apiKey, "utf8"),
|
||||
apiKeyFingerprint: profile.apiKey === null ? null : fingerprint(profile.apiKey),
|
||||
authOpenAIKeyShape: profile.authOpenAIKeyShape,
|
||||
ok: profile.ok,
|
||||
error: profile.error,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function poolTarget(): Record<string, unknown> {
|
||||
return {
|
||||
route: g14K3sRoute,
|
||||
namespace,
|
||||
service: serviceName,
|
||||
serviceDns,
|
||||
groupName: poolGroupName,
|
||||
apiKeyName: poolApiKeyName,
|
||||
apiKeySecret: `${namespace}/${poolApiKeySecretName}.${poolApiKeySecretKey}`,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string | null): string | null {
|
||||
if (value === null) return null;
|
||||
const trimmed = value.trim().replace(/\/+$/u, "");
|
||||
if (trimmed.length === 0) return null;
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
||||
return parsed.toString().replace(/\/+$/u, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function fingerprint(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function syncScript(payload: unknown): string {
|
||||
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
||||
return remotePythonScript("sync", encoded);
|
||||
}
|
||||
|
||||
function validateScript(): string {
|
||||
return remotePythonScript("validate", "");
|
||||
}
|
||||
|
||||
function remotePythonScript(mode: "sync" | "validate", encodedPayload: string): string {
|
||||
return `
|
||||
set -u
|
||||
python3 - <<'PY'
|
||||
import base64
|
||||
import json
|
||||
import secrets
|
||||
import string
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
NAMESPACE = "${namespace}"
|
||||
SERVICE_NAME = "${serviceName}"
|
||||
SERVICE_DNS = "${serviceDns}"
|
||||
FIELD_MANAGER = "${fieldManager}"
|
||||
APP_SECRET_NAME = "${appSecretName}"
|
||||
POOL_GROUP_NAME = "${poolGroupName}"
|
||||
POOL_API_KEY_NAME = "${poolApiKeyName}"
|
||||
POOL_API_KEY_SECRET_NAME = "${poolApiKeySecretName}"
|
||||
POOL_API_KEY_SECRET_KEY = "${poolApiKeySecretKey}"
|
||||
MODE = "${mode}"
|
||||
PAYLOAD_B64 = "${encodedPayload}"
|
||||
|
||||
def run(cmd, input_bytes=None):
|
||||
return subprocess.run(cmd, input=input_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
def text(data, limit=4000):
|
||||
if isinstance(data, bytes):
|
||||
data = data.decode("utf-8", errors="replace")
|
||||
return data[-limit:]
|
||||
|
||||
def kubectl(args, input_obj=None):
|
||||
if isinstance(input_obj, str):
|
||||
input_bytes = input_obj.encode("utf-8")
|
||||
else:
|
||||
input_bytes = input_obj
|
||||
return run(["kubectl", *args], input_bytes)
|
||||
|
||||
def require_kubectl(args, input_obj=None, label="kubectl"):
|
||||
proc = kubectl(args, input_obj)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"{label} failed: {text(proc.stderr, 1000)}")
|
||||
return proc.stdout
|
||||
|
||||
def kube_json(args, label):
|
||||
raw = require_kubectl([*args, "-o", "json"], label=label)
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
|
||||
def decode_secret_value(name, key):
|
||||
data = kube_json(["-n", NAMESPACE, "get", "secret", name], f"secret/{name}").get("data") or {}
|
||||
if key not in data:
|
||||
return None
|
||||
return base64.b64decode(data[key]).decode("utf-8")
|
||||
|
||||
def get_config_value(name, key):
|
||||
data = kube_json(["-n", NAMESPACE, "get", "configmap", name], f"configmap/{name}").get("data") or {}
|
||||
value = data.get(key)
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
def select_app_pod():
|
||||
pods = kube_json(["-n", NAMESPACE, "get", "pods", "-l", "app.kubernetes.io/name=sub2api"], "sub2api pods").get("items") or []
|
||||
for pod in pods:
|
||||
status = pod.get("status") or {}
|
||||
if status.get("phase") != "Running":
|
||||
continue
|
||||
statuses = status.get("containerStatuses") or []
|
||||
if statuses and all(item.get("ready") is True for item in statuses):
|
||||
return pod["metadata"]["name"]
|
||||
if pods:
|
||||
return pods[0]["metadata"]["name"]
|
||||
raise RuntimeError("sub2api app pod not found")
|
||||
|
||||
APP_POD = select_app_pod()
|
||||
|
||||
def parse_curl_output(proc):
|
||||
stdout = proc.stdout.decode("utf-8", errors="replace")
|
||||
marker = "\\n__HTTP_CODE__:"
|
||||
pos = stdout.rfind(marker)
|
||||
if pos < 0:
|
||||
return {
|
||||
"ok": False,
|
||||
"httpStatus": 0,
|
||||
"json": None,
|
||||
"body": stdout,
|
||||
"stderr": text(proc.stderr, 1000),
|
||||
"transportExitCode": proc.returncode,
|
||||
}
|
||||
body = stdout[:pos]
|
||||
status_text = stdout[pos + len(marker):].strip()
|
||||
try:
|
||||
http_status = int(status_text[-3:])
|
||||
except ValueError:
|
||||
http_status = 0
|
||||
try:
|
||||
parsed = json.loads(body) if body.strip() else None
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
return {
|
||||
"ok": proc.returncode == 0 and 200 <= http_status < 300,
|
||||
"httpStatus": http_status,
|
||||
"json": parsed,
|
||||
"body": body,
|
||||
"stderr": text(proc.stderr, 1000),
|
||||
"transportExitCode": proc.returncode,
|
||||
}
|
||||
|
||||
def curl_api(method, path, bearer=None, payload=None):
|
||||
body = b"" if payload is None else json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
script = r'''
|
||||
set -eu
|
||||
method="$1"
|
||||
url="$2"
|
||||
token="\${3:-}"
|
||||
tmp="$(mktemp)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
cat > "$tmp"
|
||||
if [ -n "$token" ]; then
|
||||
if [ "$method" = "GET" ] && [ ! -s "$tmp" ]; then
|
||||
curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" -H "Authorization: Bearer $token" "$url"
|
||||
else
|
||||
curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' -H "Authorization: Bearer $token" --data-binary @"$tmp" "$url"
|
||||
fi
|
||||
else
|
||||
if [ "$method" = "GET" ] && [ ! -s "$tmp" ]; then
|
||||
curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" "$url"
|
||||
else
|
||||
curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' --data-binary @"$tmp" "$url"
|
||||
fi
|
||||
fi
|
||||
'''
|
||||
proc = run([
|
||||
"kubectl", "-n", NAMESPACE, "exec", "-i", APP_POD,
|
||||
"--", "sh", "-c", script, "sh", method, f"http://127.0.0.1:8080{path}", bearer or "",
|
||||
], body)
|
||||
return parse_curl_output(proc)
|
||||
|
||||
def envelope_data(parsed):
|
||||
if isinstance(parsed, dict) and "data" in parsed:
|
||||
return parsed.get("data")
|
||||
return parsed
|
||||
|
||||
def ensure_success(resp, label):
|
||||
parsed = resp.get("json")
|
||||
code = parsed.get("code") if isinstance(parsed, dict) else None
|
||||
if not resp.get("ok") or (code is not None and code != 0):
|
||||
message = parsed.get("message") if isinstance(parsed, dict) else text(resp.get("body", ""), 500)
|
||||
raise RuntimeError(f"{label} failed: http={resp.get('httpStatus')} message={message}")
|
||||
return envelope_data(parsed)
|
||||
|
||||
def extract_items(data):
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
if isinstance(data.get("items"), list):
|
||||
return data["items"]
|
||||
for key in ("groups", "accounts", "api_keys", "keys"):
|
||||
if isinstance(data.get(key), list):
|
||||
return data[key]
|
||||
return []
|
||||
|
||||
def find_access_token(data):
|
||||
if isinstance(data, dict):
|
||||
for key in ("access_token", "token"):
|
||||
if isinstance(data.get(key), str) and data[key]:
|
||||
return data[key]
|
||||
for value in data.values():
|
||||
token = find_access_token(value)
|
||||
if token:
|
||||
return token
|
||||
return None
|
||||
|
||||
def login():
|
||||
admin_email = get_config_value("sub2api-config", "ADMIN_EMAIL") or "admin@sub2api.platform-infra.local"
|
||||
admin_password = decode_secret_value(APP_SECRET_NAME, "ADMIN_PASSWORD")
|
||||
if not admin_password:
|
||||
raise RuntimeError("ADMIN_PASSWORD missing from sub2api-secrets")
|
||||
data = ensure_success(curl_api("POST", "/api/v1/auth/login", payload={"email": admin_email, "password": admin_password}), "admin login")
|
||||
token = find_access_token(data)
|
||||
if not token:
|
||||
raise RuntimeError("admin login response did not contain access_token")
|
||||
return admin_email, token
|
||||
|
||||
def group_payload():
|
||||
return {
|
||||
"name": POOL_GROUP_NAME,
|
||||
"description": "UniDesk-managed Codex API-key pool for G14 k3s internal Sub2API clients.",
|
||||
"platform": "openai",
|
||||
"rate_multiplier": 1,
|
||||
"is_exclusive": False,
|
||||
"subscription_type": "standard",
|
||||
"allow_messages_dispatch": True,
|
||||
"require_oauth_only": False,
|
||||
"require_privacy_set": False,
|
||||
"rpm_limit": 0,
|
||||
}
|
||||
|
||||
def ensure_group(token):
|
||||
existing_data = ensure_success(curl_api("GET", "/api/v1/admin/groups/all?platform=openai", bearer=token), "list groups")
|
||||
existing = next((item for item in extract_items(existing_data) if item.get("name") == POOL_GROUP_NAME), None)
|
||||
payload = group_payload()
|
||||
if existing is None:
|
||||
created = ensure_success(curl_api("POST", "/api/v1/admin/groups", bearer=token, payload=payload), "create group")
|
||||
return created, "created"
|
||||
group_id = existing.get("id")
|
||||
if group_id is None:
|
||||
raise RuntimeError("existing group has no id")
|
||||
payload["status"] = "active"
|
||||
updated = ensure_success(curl_api("PUT", f"/api/v1/admin/groups/{group_id}", bearer=token, payload=payload), "update group")
|
||||
return updated if isinstance(updated, dict) else existing, "updated"
|
||||
|
||||
def list_accounts(token):
|
||||
path = "/api/v1/admin/accounts?page=1&page_size=200&platform=openai&type=apikey&search=" + quote("unidesk-codex-")
|
||||
data = ensure_success(curl_api("GET", path, bearer=token), "list accounts")
|
||||
return extract_items(data)
|
||||
|
||||
def account_payload(profile, group_id):
|
||||
return {
|
||||
"name": profile["accountName"],
|
||||
"notes": f"UniDesk-managed Codex profile {profile['profile']} from {profile['configFile']} and {profile['authFile']}; secret source={profile['apiKeySource']}; fingerprint={profile['apiKeyFingerprint']}.",
|
||||
"platform": "openai",
|
||||
"type": "apikey",
|
||||
"credentials": {
|
||||
"api_key": profile["apiKey"],
|
||||
"base_url": profile["baseUrl"],
|
||||
},
|
||||
"extra": {
|
||||
"openai_responses_mode": "force_responses",
|
||||
"unidesk_codex_profile": profile["profile"],
|
||||
"unidesk_managed": True,
|
||||
},
|
||||
"concurrency": 1,
|
||||
"priority": 1,
|
||||
"rate_multiplier": 1,
|
||||
"load_factor": 1,
|
||||
"group_ids": [group_id],
|
||||
"confirm_mixed_channel_risk": True,
|
||||
}
|
||||
|
||||
def ensure_accounts(token, profiles, group_id):
|
||||
existing = {item.get("name"): item for item in list_accounts(token)}
|
||||
results = []
|
||||
for profile in profiles:
|
||||
payload = account_payload(profile, group_id)
|
||||
current = existing.get(profile["accountName"])
|
||||
if current and current.get("id") is not None:
|
||||
account_id = current["id"]
|
||||
update_payload = dict(payload)
|
||||
update_payload.pop("platform", None)
|
||||
update_payload["status"] = "active"
|
||||
data = ensure_success(curl_api("PUT", f"/api/v1/admin/accounts/{account_id}", bearer=token, payload=update_payload), f"update account {profile['accountName']}")
|
||||
action = "updated"
|
||||
else:
|
||||
data = ensure_success(curl_api("POST", "/api/v1/admin/accounts", bearer=token, payload=payload), f"create account {profile['accountName']}")
|
||||
action = "created"
|
||||
results.append({
|
||||
"profile": profile["profile"],
|
||||
"accountName": profile["accountName"],
|
||||
"accountId": data.get("id") if isinstance(data, dict) else None,
|
||||
"action": action,
|
||||
"baseUrl": profile["baseUrl"],
|
||||
"apiKeySource": profile["apiKeySource"],
|
||||
"apiKeyFingerprint": profile["apiKeyFingerprint"],
|
||||
"valuesPrinted": False,
|
||||
})
|
||||
return results
|
||||
|
||||
def generate_api_key():
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return "sk-unidesk-codex-" + "".join(secrets.choice(alphabet) for _ in range(48))
|
||||
|
||||
def ensure_api_key_secret(group_id):
|
||||
existing = None
|
||||
try:
|
||||
existing = decode_secret_value(POOL_API_KEY_SECRET_NAME, POOL_API_KEY_SECRET_KEY)
|
||||
except Exception:
|
||||
existing = None
|
||||
api_key = existing if existing else generate_api_key()
|
||||
secret_action = "kept-existing" if existing else "created"
|
||||
manifest = {
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": {
|
||||
"name": POOL_API_KEY_SECRET_NAME,
|
||||
"namespace": NAMESPACE,
|
||||
"labels": {
|
||||
"app.kubernetes.io/name": "sub2api",
|
||||
"app.kubernetes.io/part-of": "platform-infra",
|
||||
"app.kubernetes.io/managed-by": "unidesk",
|
||||
"unidesk.ai/secret-purpose": "sub2api-codex-pool-api-key",
|
||||
},
|
||||
},
|
||||
"type": "Opaque",
|
||||
"stringData": {
|
||||
POOL_API_KEY_SECRET_KEY: api_key,
|
||||
"GROUP_ID": str(group_id),
|
||||
"GROUP_NAME": POOL_GROUP_NAME,
|
||||
"SERVICE_DNS": SERVICE_DNS,
|
||||
},
|
||||
}
|
||||
proc = kubectl(["apply", "--server-side", "--force-conflicts", f"--field-manager={FIELD_MANAGER}", "-f", "-"], json.dumps(manifest))
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"apply API key secret failed: {text(proc.stderr, 1000)}")
|
||||
return api_key, secret_action, text(proc.stdout, 1000)
|
||||
|
||||
def list_user_keys(token):
|
||||
data = ensure_success(curl_api("GET", "/api/v1/keys?page=1&page_size=200", bearer=token), "list user keys")
|
||||
return extract_items(data)
|
||||
|
||||
def ensure_sub2api_api_key(token, api_key, group_id):
|
||||
keys = list_user_keys(token)
|
||||
existing = next((item for item in keys if item.get("key") == api_key), None)
|
||||
action = "kept-existing"
|
||||
if existing is None:
|
||||
existing = next((item for item in keys if item.get("name") == POOL_API_KEY_NAME), None)
|
||||
if existing is None or existing.get("key") != api_key:
|
||||
payload = {
|
||||
"name": POOL_API_KEY_NAME,
|
||||
"group_id": group_id,
|
||||
"custom_key": api_key,
|
||||
"quota": 0,
|
||||
"rate_limit_5h": 0,
|
||||
"rate_limit_1d": 0,
|
||||
"rate_limit_7d": 0,
|
||||
}
|
||||
created = ensure_success(curl_api("POST", "/api/v1/keys", bearer=token, payload=payload), "create pool API key")
|
||||
existing = created if isinstance(created, dict) else existing
|
||||
action = "created"
|
||||
elif existing.get("id") is not None and existing.get("group_id") != group_id:
|
||||
updated = ensure_success(curl_api("PUT", f"/api/v1/keys/{existing['id']}", bearer=token, payload={"name": POOL_API_KEY_NAME, "group_id": group_id}), "update pool API key group")
|
||||
existing = updated if isinstance(updated, dict) else existing
|
||||
action = "updated-group"
|
||||
return {
|
||||
"action": action,
|
||||
"id": existing.get("id") if isinstance(existing, dict) else None,
|
||||
"name": existing.get("name") if isinstance(existing, dict) else POOL_API_KEY_NAME,
|
||||
"groupId": existing.get("group_id") if isinstance(existing, dict) else group_id,
|
||||
}
|
||||
|
||||
def validate_gateway(api_key):
|
||||
resp = curl_api("GET", "/v1/models", bearer=api_key)
|
||||
parsed = resp.get("json")
|
||||
model_count = None
|
||||
if isinstance(parsed, dict) and isinstance(parsed.get("data"), list):
|
||||
model_count = len(parsed["data"])
|
||||
return {
|
||||
"ok": resp.get("ok"),
|
||||
"httpStatus": resp.get("httpStatus"),
|
||||
"transportExitCode": resp.get("transportExitCode"),
|
||||
"modelCount": model_count,
|
||||
"bodyPreview": text(resp.get("body", ""), 500) if not resp.get("ok") else "",
|
||||
"stderr": resp.get("stderr", ""),
|
||||
"method": "GET /v1/models",
|
||||
"serviceDns": SERVICE_DNS,
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
|
||||
def api_key_preview(api_key):
|
||||
if len(api_key) <= 14:
|
||||
return "***"
|
||||
return api_key[:10] + "..." + api_key[-4:]
|
||||
|
||||
def run_sync():
|
||||
payload = json.loads(base64.b64decode(PAYLOAD_B64).decode("utf-8"))
|
||||
profiles = payload.get("profiles") or []
|
||||
if not profiles:
|
||||
raise RuntimeError("sync payload has no profiles")
|
||||
admin_email, token = login()
|
||||
group, group_action = ensure_group(token)
|
||||
group_id = group.get("id") if isinstance(group, dict) else None
|
||||
if group_id is None:
|
||||
raise RuntimeError("pool group id missing after ensure")
|
||||
account_results = ensure_accounts(token, profiles, group_id)
|
||||
api_key, secret_action, secret_apply_stdout = ensure_api_key_secret(group_id)
|
||||
api_key_result = ensure_sub2api_api_key(token, api_key, group_id)
|
||||
gateway = validate_gateway(api_key)
|
||||
return {
|
||||
"ok": gateway["ok"] is True,
|
||||
"mode": "sync",
|
||||
"namespace": NAMESPACE,
|
||||
"serviceDns": SERVICE_DNS,
|
||||
"appPod": APP_POD,
|
||||
"admin": {"email": admin_email, "tokenPrinted": False},
|
||||
"pool": {"name": POOL_GROUP_NAME, "id": group_id, "action": group_action, "platform": group.get("platform") if isinstance(group, dict) else "openai"},
|
||||
"accounts": {
|
||||
"desired": len(profiles),
|
||||
"created": sum(1 for item in account_results if item["action"] == "created"),
|
||||
"updated": sum(1 for item in account_results if item["action"] == "updated"),
|
||||
"items": account_results,
|
||||
"valuesPrinted": False,
|
||||
},
|
||||
"apiKey": {
|
||||
"name": POOL_API_KEY_NAME,
|
||||
"secret": f"{NAMESPACE}/{POOL_API_KEY_SECRET_NAME}.{POOL_API_KEY_SECRET_KEY}",
|
||||
"secretAction": secret_action,
|
||||
"secretApply": secret_apply_stdout,
|
||||
"sub2apiAction": api_key_result["action"],
|
||||
"sub2apiId": api_key_result["id"],
|
||||
"groupId": api_key_result["groupId"],
|
||||
"keyPreview": api_key_preview(api_key),
|
||||
"valuesPrinted": False,
|
||||
},
|
||||
"validation": {"gatewayModels": gateway},
|
||||
}
|
||||
|
||||
def run_validate():
|
||||
api_key = decode_secret_value(POOL_API_KEY_SECRET_NAME, POOL_API_KEY_SECRET_KEY)
|
||||
if not api_key:
|
||||
raise RuntimeError(f"{POOL_API_KEY_SECRET_NAME}.{POOL_API_KEY_SECRET_KEY} missing")
|
||||
gateway = validate_gateway(api_key)
|
||||
return {
|
||||
"ok": gateway["ok"] is True,
|
||||
"mode": "validate",
|
||||
"namespace": NAMESPACE,
|
||||
"serviceDns": SERVICE_DNS,
|
||||
"appPod": APP_POD,
|
||||
"apiKey": {
|
||||
"secret": f"{NAMESPACE}/{POOL_API_KEY_SECRET_NAME}.{POOL_API_KEY_SECRET_KEY}",
|
||||
"keyPreview": api_key_preview(api_key),
|
||||
"valuesPrinted": False,
|
||||
},
|
||||
"validation": {"gatewayModels": gateway},
|
||||
}
|
||||
|
||||
try:
|
||||
result = run_sync() if MODE == "sync" else run_validate()
|
||||
except Exception as exc:
|
||||
result = {
|
||||
"ok": False,
|
||||
"mode": MODE,
|
||||
"namespace": NAMESPACE,
|
||||
"serviceDns": SERVICE_DNS,
|
||||
"appPod": globals().get("APP_POD"),
|
||||
"error": str(exc),
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
sys.exit(0 if result.get("ok") else 1)
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
async function capture(config: UniDeskConfig, target: string, args: string[], input?: string): Promise<SshCaptureResult> {
|
||||
return await runSshCommandCapture(config, target, args, input);
|
||||
}
|
||||
|
||||
function parseJsonOutput(stdout: string): Record<string, unknown> | null {
|
||||
const trimmed = stdout.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
const start = trimmed.indexOf("{");
|
||||
const end = trimmed.lastIndexOf("}");
|
||||
if (start === -1 || end === -1 || end <= start) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown;
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function boolField(value: Record<string, unknown> | null, key: string, defaultValue: boolean): boolean {
|
||||
if (value === null) return defaultValue;
|
||||
const field = value[key];
|
||||
return typeof field === "boolean" ? field : defaultValue;
|
||||
}
|
||||
|
||||
function compactCapture(result: SshCaptureResult, options: { full?: boolean } = {}): Record<string, unknown> {
|
||||
const full = options.full ?? false;
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
stdoutBytes: Buffer.byteLength(result.stdout, "utf8"),
|
||||
stderrBytes: Buffer.byteLength(result.stderr, "utf8"),
|
||||
stdoutTail: full || result.exitCode !== 0 ? result.stdout.slice(-8000) : "",
|
||||
stderrTail: full || result.exitCode !== 0 ? result.stderr.slice(-4000) : "",
|
||||
};
|
||||
}
|
||||
@@ -23,7 +23,7 @@ interface Sub2ApiConfig {
|
||||
|
||||
export function platformInfraHelp(): unknown {
|
||||
return {
|
||||
command: "platform-infra sub2api plan|apply|status|validate",
|
||||
command: "platform-infra sub2api plan|apply|status|validate|codex-pool",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra sub2api plan",
|
||||
@@ -31,6 +31,9 @@ export function platformInfraHelp(): unknown {
|
||||
"bun scripts/cli.ts platform-infra sub2api apply --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2api status [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2api validate [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool plan",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool validate",
|
||||
],
|
||||
description: "Operate the G14 k3s internal-only Sub2API deployment in the shared platform-infra namespace. This entry creates no Ingress, NodePort, LoadBalancer, hostPort, hostNetwork, ResourceQuota, LimitRange, or CPU/memory resource requests/limits.",
|
||||
target: {
|
||||
@@ -42,6 +45,14 @@ export function platformInfraHelp(): unknown {
|
||||
resourceLimits: "unset-by-policy",
|
||||
versionConfigPath: configPath,
|
||||
},
|
||||
codexPool: {
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool plan",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool validate",
|
||||
],
|
||||
module: "scripts/src/platform-infra-sub2api-codex.ts",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +63,10 @@ export async function runPlatformInfraCommand(config: UniDeskConfig, args: strin
|
||||
if (action === "apply") return await apply(config, parseApplyOptions(args.slice(2)));
|
||||
if (action === "status") return await status(config, parseDisclosureOptions(args.slice(2)));
|
||||
if (action === "validate") return await validate(config, parseDisclosureOptions(args.slice(2)));
|
||||
if (action === "codex-pool") {
|
||||
const { runCodexPoolCommand } = await import("./platform-infra-sub2api-codex");
|
||||
return await runCodexPoolCommand(config, args.slice(2));
|
||||
}
|
||||
return unsupported(args);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user