From ddc72c6e13da240c58fd392975e98b82fbb7b293 Mon Sep 17 00:00:00 2001 From: pikastech Date: Thu, 23 Jul 2026 05:36:32 +0200 Subject: [PATCH 1/9] =?UTF-8?q?fix:=20=E8=AE=A9=E5=86=B3=E7=AD=96=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=20rebase=20=E7=BB=A7=E6=89=BF=20Git=20=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../microservices/decision-center/src/index.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/microservices/decision-center/src/index.ts b/src/components/microservices/decision-center/src/index.ts index ea00404d..a3c99511 100644 --- a/src/components/microservices/decision-center/src/index.ts +++ b/src/components/microservices/decision-center/src/index.ts @@ -885,7 +885,14 @@ function health(): JsonRecord { } function runGit(args: string[], options: { cwd?: string; allowFailure?: boolean; env?: Record } = {}): { ok: boolean; stdout: string; stderr: string; exitCode: number } { - const env = { ...process.env, ...options.env }; + const env: NodeJS.ProcessEnv = { + ...process.env, + GIT_AUTHOR_NAME: config.storage.authorName, + GIT_AUTHOR_EMAIL: config.storage.authorEmail, + GIT_COMMITTER_NAME: config.storage.authorName, + GIT_COMMITTER_EMAIL: config.storage.authorEmail, + ...options.env, + }; if (config.storage.sshCommand) env.GIT_SSH_COMMAND = config.storage.sshCommand; const result = Bun.spawnSync(["git", ...args], { cwd: options.cwd ?? config.storage.worktreePath, @@ -1147,14 +1154,7 @@ async function writeSnapshotToGit(reason: string, dryRun: boolean): Promise Date: Thu, 23 Jul 2026 05:38:24 +0200 Subject: [PATCH 2/9] docs: define empty firmware resource differential --- .../PJ2026-05/specs/PJ2026-0501-v2-kernel.md | 90 ++++++++++++------- 1 file changed, 58 insertions(+), 32 deletions(-) diff --git a/project-management/PJ2026-05/specs/PJ2026-0501-v2-kernel.md b/project-management/PJ2026-05/specs/PJ2026-0501-v2-kernel.md index 9e66408c..7beafecb 100644 --- a/project-management/PJ2026-05/specs/PJ2026-0501-v2-kernel.md +++ b/project-management/PJ2026-05/specs/PJ2026-0501-v2-kernel.md @@ -21,10 +21,11 @@ | v0.15 | `5962dd9a` | 2026-07-23 | 已批准:固定公共 case 单源、平台宏隔离、显式跳过语义和默认 embedded-app 回归覆盖下限。 | | v0.16 | `3915d014` | 2026-07-23 | 已批准:固定编译器内置平台宏优先、未知平台手动 fallback 和构建系统不重复注入平台事实。 | | v0.17 | `704ce23c` | 2026-07-23 | 已批准:固定平台基线、runtime 与 testcase 的互斥资源分区,以及排除 testcase 的 runtime 净峰值口径。 | +| v0.18 | `PENDING` | 2026-07-23 | 已批准:改用完全不链接 runtime 与 testcase 的空固件实测平台基线,并固定完整固件、空固件和 testcase 的差分资源口径。 | 修改历史只记录规格语义变更,不记录实现进度、阶段基线或一次性证据。 -v0.17 及其引用的 PIKA-CAP v0.8、PIKA-SL v0.1 已批准生效,作为当前实现合同。 +v0.18 及其引用的 PIKA-CAP v0.8、PIKA-SL v0.1 已批准生效,作为当前实现合同。 ## 正文 @@ -38,8 +39,8 @@ v0.17 及其引用的 PIKA-CAP v0.8、PIKA-SL v0.1 已批准生效,作为当 | 短名 | V2内核 | | 层级 | L1 方向 | | 规格状态 | 已生效 | -| 当前生效版本 | v0.17 | -| 实现引用版本 | v0.17 | +| 当前生效版本 | v0.18 | +| 实现引用版本 | v0.18 | | 需求规格模板 | [ISO/IEC/IEEE 29148 需求规格模板](../../templates/iso-iec-ieee-29148-requirements-spec-template.md) | | 上级规格 | [PJ2026-05 PikaPython 总规格](PJ2026-05-pikapython.md) | @@ -160,8 +161,9 @@ V2 的目标是: | 模块图 | 从目录入口 `main.py` 可达的 Python 模块及其 C 模块依赖形成的确定预编译集合。 | | 嵌入式验证配置 | 由 owning YAML 完整声明目标芯片、工具链、固件、测试、QEMU 运行方式、产物和资源预算的可复现配置。 | | 目标侧峰值已用 RAM | 固件执行 workload 时,由互不重叠的目标内存区域和水位 instrumentation 计算的峰值活跃 RAM,不是 QEMU 宿主进程 RSS。 | -| 平台基线资源 | 最终固件中不归属于 runtime 或 testcase 链接段的 startup、平台适配、工具链支持和链接对齐资源;必须由同一 ELF 与 linker map 的互斥分区得到,不得使用手填常量。 | -| runtime 净资源 | 排除平台基线和 testcase 后,runtime 自有链接段及 runtime 入口内部实际使用的执行存储、阶段堆和栈增量。 | +| 空平台固件 | 使用与完整固件相同目标、工具链、startup、平台适配、链接脚本及编译和链接参数,但完全不编译或链接 runtime 与 testcase 的最小可启动固件。 | +| 平台基线资源 | 空平台固件自身 ELF 与 linker map 实测的 Flash 和静态 RAM;不得使用完整固件对象归属、手填常量或跨配置复用值替代。 | +| runtime 净资源 | 完整固件资源减去空平台固件实测资源和完整固件内 testcase 自有资源后的 Flash 与静态 RAM,以及 runtime 入口内部实际使用的执行存储、阶段堆和栈增量。 | ## 4. 系统边界和接口 @@ -942,9 +944,10 @@ V1 NANO 只作为迁移参考: - 声明 QEMU machine 和 CPU; - 声明 C99 交叉工具链、编译参数、链接脚本和 startup; - 声明平台适配器和项目自有源码清单; + - 声明完全不包含 runtime 与 testcase 的空平台固件入口源码; - 声明 PSL、capability/profile 和资源预算; - 声明公共测试 suite、测试源码清单和 benchmark workload; - - 声明 ELF、固件、linker map 和串口日志的产物位置及采集方式; + - 分别声明完整固件与空平台固件的 ELF、固件、linker map 产物位置及采集方式; - 声明结果摘要和资源摘要的产物位置及采集方式。 - workspace CLI: - 只读取、校验和执行 owning YAML; @@ -968,6 +971,16 @@ V1 NANO 只作为迁移参考: - QEMU 数据不得形成排名、加速百分比、性能回退或性能优化结论; - Linux 原生纵向 benchmark 继续承担快速性能裁决; - QEMU 结果不得与 Linux 样本合并。 +- 空平台固件构建: + - 必须与完整固件使用相同目标、工具链、startup、平台源码、链接脚本、 + 优化级别及其他编译和链接参数; + - 只能使用独立的最小入口替代完整测试入口,不得编译或链接 runtime、 + 公共 testcase、registry、fixture、executor adapter 或测试专用存储; + - 不得为逼近完整固件数值而手工链接 runtime 才会引入的 libc、libm、 + newlib 或其他工具链支撑;这些增量必须归入 runtime 差分; + - 必须产出独立 ELF、固件和 linker map,并与完整固件的目标内存区域、 + startup、平台对象和构建参数逐项对账; + - 空平台固件无需执行公共 suite,其唯一职责是提供同配置平台资源基线。 - STM32 资源基准: - 必须来自目标产物和目标侧 instrumentation; - `flashBytes` 来自 ELF/linker map 中映射到非易失存储的实际装载段; @@ -986,32 +999,36 @@ V1 NANO 只作为迁移参考: - 资源摘要给出绝对字节和相对 profile 预算占比; - 资源摘要给出相对选中里程碑基线的变化百分比; - 不可观测字段必须给出结构化原因。 -- 净资源分区: - - 最终链接必须将 runtime、testcase 和平台基线的 Flash 与静态 RAM - 贡献划分为互不重叠的区间; - - 三类分区必须来自同一 ELF 和 linker map,并逐字节合计回整固件对应 - 资源; - - runtime Flash 固定包含 runtime 自有已链接代码、只读数据和 `.data` - 初始化镜像; - - runtime 静态 RAM 固定包含 runtime 自有 `.data + .bss`; - - testcase 分区包含公共 case、registry、fixture、executor adapter、测试 - 输出和测试专用固定存储; - - 平台基线分区包含 startup、平台适配、工具链支持和无法脱离目标平台的 - 链接对齐; - - 平台基线不得跨工具链、链接脚本、优化级别或目标配置复用; - - 分区缺失、重叠、越界或无法合计回整固件时,净资源必须报告 - `unavailable` 并使资源验收失败。 +- 净资源差分: + - 平台基线 Flash 与静态 RAM 必须直接取自空平台固件 ELF 和 linker map; + - testcase Flash 和静态 RAM 必须取自完整固件中显式 testcase 自有链接段, + 并覆盖公共 case、registry、fixture、executor adapter、测试输出和测试专用 + 固定存储; + - runtime Flash 固定为完整固件 Flash 减空平台固件 Flash,再减 testcase + Flash; + - runtime 静态 RAM 固定为完整固件静态 RAM 减空平台固件静态 RAM,再减 + testcase 静态 RAM; + - runtime 差分自然包含仅因 runtime 引用而进入完整固件的 libc、libm、 + newlib、工具链支撑、链接填充和初始化镜像增量; + - 空平台固件和完整固件必须分别交叉核对各自 ELF 与 linker map; + - 差分结果为负、testcase 自有段缺失或重叠、构建参数无法对账、产物缺失, + 或三项无法按公式回和整固件时,净资源必须报告 `unavailable` 并使资源 + 验收失败; + - 差分资源 schema 必须版本化;首个空固件差分里程碑不得与对象归属或 + 整固件补算的旧 schema 计算纵向变化。 - runtime 净峰值: - 每次 runtime 入口执行前复位阶段水位,并在返回后立即读取; - 跨 suite 取各次 runtime 调用的最大实际执行存储、阶段堆增量和 runtime 内部本机栈增量; - - runtime 净峰值固定为 runtime 静态 RAM、实际执行存储峰值、runtime - 阶段堆峰值和 runtime 内部本机栈峰值之和; + - runtime 净峰值固定为差分得到的 runtime 静态 RAM、实际执行存储峰值、 + runtime 阶段堆峰值和 runtime 内部本机栈峰值之和; - 执行存储峰值只计实际使用的 frame、value、binding、对象槽和对象 arena, 不计 testcase 预留池的未使用容量; - 对象 arena 和 VM 栈是执行存储峰值的明细,不得再次加入合计; - testcase 函数栈、case registry、测试输出、测试固定池和 suite 调度栈不得 进入 runtime 净峰值; + - 空平台固件与完整固件的整固件峰值不得直接相减,因为不同执行路径的峰值 + 不具有可加减性; - runtime 堆或栈无法独立于 testcase 观测时,runtime 净峰值必须报告 `unavailable`,不得用整固件峰值相减推测。 @@ -1187,10 +1204,12 @@ Linux、模拟器和真实嵌入式目标必须运行同一份公共测试定义 - 未声明或不可观测字段的结构化 `unavailable` 原因。 - STM32 目标资源 summary 必须同时报告: - 整固件 Flash、静态 RAM、保留 RAM 和峰值已用 RAM; - - 平台基线、runtime 和 testcase 的 Flash 与静态 RAM 互斥分区; + - 空平台固件 Flash、静态 RAM 及其 ELF、固件和 linker map 产物; + - 完整固件中的 testcase 自有 Flash 与静态 RAM; + - 由完整固件减空平台固件再减 testcase 得到的 runtime Flash 与静态 RAM; - runtime 实际执行存储、阶段堆、本机栈和净峰值 RAM; - runtime 净值与整固件值各自的预算占比; - - 只有采用相同净资源 schema 和分区合同的里程碑才能计算净值纵向变化。 + - 只有采用相同净资源 schema 和差分合同的里程碑才能计算净值纵向变化。 ### 7.4 可裁剪验收 @@ -1350,17 +1369,24 @@ Linux、模拟器和真实嵌入式目标必须运行同一份公共测试定义 - 资源采集必须交叉核对 ELF 与 linker map,并输出: - `flashBytes`、`staticRamBytes` 和 `reservedRamBytes`; - 常驻静态、arena/heap、VM 栈、本机栈和 `peakUsedRamBytes`; - - 平台基线、runtime 和 testcase 各自的 Flash 与静态 RAM; + - 空平台固件的 Flash 与静态 RAM; + - 完整固件中 testcase 自有 Flash 与静态 RAM; + - 由完整固件、空平台固件和 testcase 计算的 runtime Flash 与静态 RAM; - runtime 实际执行存储、阶段堆、本机栈和净峰值 RAM; - 各字段预算占比和相对基线变化百分比; -- 资源分区必须满足: - - 三类 Flash 分区之和等于 `flashBytes`; - - 三类静态 RAM 分区之和等于 `staticRamBytes`; +- 资源差分必须满足: + - 空平台固件与完整固件使用相同目标配置且各自 ELF 与 linker map 一致; + - 空平台固件完全不包含 runtime 与 testcase; + - 空平台固件 Flash、testcase Flash 和 runtime Flash 之和等于 + `flashBytes`; + - 空平台固件静态 RAM、testcase 静态 RAM 和 runtime 静态 RAM 之和等于 + `staticRamBytes`; + - 任一 runtime 差分结果不得为负; - runtime 净峰值不包含 testcase 固定池、未使用容量、调度栈或输出路径; - runtime 净峰值各组成项来自 runtime 内部指标或 runtime 阶段目标水位; - 整固件字段继续用于目标容量和区域互斥校验,不得替代 runtime 净值; - - 首个净资源里程碑或旧 schema 基线必须将净值变化标记为 - `unavailable`,不得拿旧整固件总量补算; + - 首个空固件差分里程碑或旧 schema 基线必须将净值变化标记为 + `unavailable`,不得拿旧对象归属值或整固件总量补算; - RAM 区域无法证明互斥时: - 拒绝合计; - 报告 `unavailable` 原因; @@ -1409,7 +1435,7 @@ Linux、模拟器和真实嵌入式目标必须运行同一份公共测试定义 ## 8. 过程控制 -- V2 新增或修改的手写内核源码应标注 `SPEC: PJ2026-0501 V2内核 v0.17` 和文件职责。 +- V2 新增或修改的手写内核源码应标注 `SPEC: PJ2026-0501 V2内核 v0.18` 和文件职责。 - 自动生成、vendored、配置和二进制产物可不加源码头,但生成器或配置入口必须能追溯本规格。 - 架构选择必须记录候选方案、适用前提、benchmark 热点和资源影响;当前结果进入 TaskTree 或 issue,不进入 SPEC。 - 任何实现若让 V1 内部结构越过中性类型化 V2 IR 边界,必须先更新本规格或作为偏离停止合并。 From 31dac4153765028740e3a3e5a02bbd2d7879769b Mon Sep 17 00:00:00 2001 From: pikastech Date: Thu, 23 Jul 2026 05:39:05 +0200 Subject: [PATCH 3/9] docs: record resource differential spec revision --- project-management/PJ2026-05/specs/PJ2026-0501-v2-kernel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project-management/PJ2026-05/specs/PJ2026-0501-v2-kernel.md b/project-management/PJ2026-05/specs/PJ2026-0501-v2-kernel.md index 7beafecb..8f9285a3 100644 --- a/project-management/PJ2026-05/specs/PJ2026-0501-v2-kernel.md +++ b/project-management/PJ2026-05/specs/PJ2026-0501-v2-kernel.md @@ -21,7 +21,7 @@ | v0.15 | `5962dd9a` | 2026-07-23 | 已批准:固定公共 case 单源、平台宏隔离、显式跳过语义和默认 embedded-app 回归覆盖下限。 | | v0.16 | `3915d014` | 2026-07-23 | 已批准:固定编译器内置平台宏优先、未知平台手动 fallback 和构建系统不重复注入平台事实。 | | v0.17 | `704ce23c` | 2026-07-23 | 已批准:固定平台基线、runtime 与 testcase 的互斥资源分区,以及排除 testcase 的 runtime 净峰值口径。 | -| v0.18 | `PENDING` | 2026-07-23 | 已批准:改用完全不链接 runtime 与 testcase 的空固件实测平台基线,并固定完整固件、空固件和 testcase 的差分资源口径。 | +| v0.18 | `48a31854` | 2026-07-23 | 已批准:改用完全不链接 runtime 与 testcase 的空固件实测平台基线,并固定完整固件、空固件和 testcase 的差分资源口径。 | 修改历史只记录规格语义变更,不记录实现进度、阶段基线或一次性证据。 From 865a69ad2b530d0bb6c7e37188e3d198bf93e9f3 Mon Sep 17 00:00:00 2001 From: pikastech Date: Thu, 23 Jul 2026 05:42:40 +0200 Subject: [PATCH 4/9] docs: require complete TypeScript type checks --- .agents/skills/unidesk-devlevel/SKILL.md | 1 + docs/reference/devops-hygiene.md | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/.agents/skills/unidesk-devlevel/SKILL.md b/.agents/skills/unidesk-devlevel/SKILL.md index 03caaf26..6baecbe1 100644 --- a/.agents/skills/unidesk-devlevel/SKILL.md +++ b/.agents/skills/unidesk-devlevel/SKILL.md @@ -60,6 +60,7 @@ HWLAB 与 AgentRun 的全部有状态微服务默认使用以下数据划分: - 单个微服务内部、不启动服务进程且不跨服务通信的单元测试和组件测试属于 L0。 - 适合函数逻辑、配置解析、数据转换、领域服务、微服务内部单元和本地文件操作的快速开发。 - 只加载当前功能需要的本地依赖。 +- TypeScript 仓库的全量 `tsc --noEmit` 错误必须逐项处理:每轮按当前编译器输出修复一处或同根因错误后重新跑全量检查,禁止把历史或并行改动错误当作可忽略背景。 - 测试一旦跨越 API、Worker、网络、独立常驻进程或前后端边界,就使用 L1;native function 同步调用的有界本地 helper 进程仍属于 L0。 - L0 的完成判定以本次变更实际触及的完整逻辑操作为边界: - 变更默认值、控制标志或分支选择时,沿既有调用链核对该值实际控制的结果和副作用,不能只验证参数解析或首个返回值; diff --git a/docs/reference/devops-hygiene.md b/docs/reference/devops-hygiene.md index 0abd567e..2bb1950b 100644 --- a/docs/reference/devops-hygiene.md +++ b/docs/reference/devops-hygiene.md @@ -22,6 +22,13 @@ Large shell, Node, Python or other script bodies must live in files with their n When declared validation dependencies are missing, install them with the repository's package manager and lockfile, then continue the original validation. Do not skip, downgrade or terminate validation merely because the local environment is incomplete. If the required dependency is undeclared or installation would introduce a lockfile change, first add the dependency through the repository's normal declaration workflow and keep that change in the task's reviewed scope. +## 全量 TypeScript 类型门禁 + +- 受 TypeScript `tsc --noEmit` 覆盖的仓库必须把全量类型检查作为真实验证,而不是把它降级为语法检查。 +- 全量检查出现错误时,必须按编译器输出逐项处理:每轮确认当前错误集合,修复一处或一组同根因错误后重新执行全量检查,再处理下一处。 +- 不得因为错误来自既有脏改、历史代码、并行任务或非核心模块就直接标记为“已知错误”并交付;必须修复、隔离到明确的独立检查范围,或报告具体外部阻塞及其证据。 +- 窄路径测试可以用于缩短反馈,但不能替代最终全量 `tsc`;最终结论必须披露全量检查是否通过及剩余错误数量。 + ## Prohibited Deployment Truth The following practices are not acceptable as the long-term or hidden source of a working environment: From 6c645b6e43010f6a93cbbc289705ee12cce12c0e Mon Sep 17 00:00:00 2001 From: pikastech Date: Thu, 23 Jul 2026 05:43:08 +0200 Subject: [PATCH 5/9] docs: define bounded Argo debug sync --- .agents/skills/unidesk-cicd/SKILL.md | 10 ++++++++-- docs/reference/platform-infra.md | 6 ++++++ ...J2026-01060308-cicd-yaml-first-target-governance.md | 9 +++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 772b583f..61525419 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -118,13 +118,13 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - downstream hook 只是低延迟唤醒优化,不是交付完成或稳定性的唯一依赖; - hook 没有 delivery 记录时: - 由节点控制面 YAML 声明的 Argo 有界重协调周期继续收敛 GitOps desired state; - - 禁止改用人工 sync/refresh 或第二发布入口; + - 正常发布禁止改用人工 sync/refresh 或第二发布入口;仅在用户明确授权的故障调试阶段,才适用下方“调试期 Argo 单步同步”边界; - 节点控制面 Argo apply 的 dry-run: - 必须完整构造 mutation runner; - 只输出 runner 的字节数与 SHA-256; - 模块依赖或 renderer 错误必须在运行面 mutation 前可见; - downstream hook 不使用签名、fingerprint 或额外发布门禁,不接收 source branch 或 PR merge,不创建 PipelineRun,固定 `automatic=false`; - - 禁止人工 Argo sync、refresh annotation 或其他运行面强写。 + - 正式交付禁止人工 Argo sync、refresh annotation 或其他运行面强写;调试期单步同步不能作为交付完成证据。 - L3 继续要求对应 L2 已通过和用户对当次生产发布的明确授权。 - 每次修改发布入口后,必须复核 `pipelines-as-code --help`、`release --help`、`release plan --help`、`release trigger --help` 与一条动态 `next`:帮助必须完整展示 plan/trigger 的参数、范围审阅字段和 `--confirm` 边界;动态 `next` 只能在范围准确时指向 trigger,范围扩大或触发失败时必须指向修复后重新 plan,不得遗漏或引导自动交付。 - release plan 的 changed paths、domain plan 和 webhook payload 等可增长结构化数据必须通过临时文件或 stdin 传递,禁止放入环境变量或命令行参数;默认/`--json` 输出必须有界保留 env reuse、build、rollout、affected 与 scopeReview,完整 changed-path 明细只在显式 `--full|--raw` 披露。 @@ -351,6 +351,12 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - 旧对象仍占用 repository concurrency、阻塞更新事件或已知会执行错误参数时,无需等待其自然超时; - 优先使用现有受控取消入口;该入口不可用且控制面本身处于调试恢复期时,允许按 `$unidesk-daddev` P2 对精确对象执行一次直接取消,并记录取消前后状态; - 取消只是调试期队列解阻,不是交付成功证据;最终仍须由 owning YAML、正常 source event、PaC、GitOps/Argo 和 runtime 自动链收敛。 +- 调试期 Argo 单步同步: + - 仅在用户明确授权调试,且已证明 PipelineRun 成功、GitOps commit 已生成、Argo 未自动消费该精确 revision 时允许;传播延迟未排除、source 或 revision 不明确时不得执行; + - 每次只允许对 owning YAML 选中的一个 Application 同步到一个精确 GitOps revision,执行前后记录 Application、targetRevision、namespace、source commit、GitOps commit、sync/health 和 workload readiness; + - 单步同步不得修改 source/ref authority、GitOps desired state、Application source 配置、runtime workload 或业务数据,也不得创建 PipelineRun、重复 trigger 或恢复第二发布入口; + - 优先使用受控 CLI;入口缺失且控制面处于调试恢复期时,可按 `$unidesk-daddev` P2 对精确 Application 执行一次最小 mutation,并立即只读复核; + - 单步同步只用于定位或解除 Argo 自动消费断点,不能作为最终交付证据;debug 结束前必须修复 owning YAML、downstream webhook 或 Argo 重协调链,并用后续自然 source commit 的一次 `release plan`、一次 `release trigger --confirm` 证明 PaC、GitOps、Argo 与 runtime 全自动完成,期间不得再人工 sync。 - CI/CD、GitOps、rollout、PipelineRun、Argo、git-mirror 和 AgentRun 的观察、诊断与控制必须走受控 CLI;不要用裸 `kubectl`、`argo`、`tkn`、`curl` 当正式控制入口。PaC migrated consumer 只允许 `release plan` 后由 `release trigger --confirm` 发送 webhook。 - CI/CD 校验默认不得阻塞交付: - 除非用户明确要求某项校验成为门禁,禁止新增会让 Pipeline、artifact、GitOps promote、Argo reconcile、runtime rollout 或 `/health` closeout 失败的配置一致性、版本、契约、schema、preflight 或跨对象校验; diff --git a/docs/reference/platform-infra.md b/docs/reference/platform-infra.md index e68ed0ba..51336ff2 100644 --- a/docs/reference/platform-infra.md +++ b/docs/reference/platform-infra.md @@ -224,6 +224,12 @@ - 取消只终止精确旧 PipelineRun、TaskRun 或事件,不删除 source/ref、GitOps desired state、Argo Application、runtime workload、Secret 或业务数据; - 优先使用受控取消入口;入口不可用且控制面处于调试恢复期时,允许按 `$unidesk-daddev` P2 直接取消并记录前后证据; - 取消只用于释放调试期队列,不能替代正常 PaC、GitOps/Argo 和 runtime 自动链验收。 +- 调试阶段允许 Argo 单步同步: + - 前提是用户明确授权,且 PipelineRun 已成功、精确 GitOps commit 已生成、传播延迟已排除并确认 Argo 没有自动消费该 revision; + - 每次只对 owning YAML 选中的一个 Application 和一个精确 GitOps revision 执行一次同步,并记录 source commit、GitOps commit、Application source、namespace、同步前后状态和 workload readiness; + - 单步同步不得改 source/ref authority、GitOps desired state、Application source 配置或 runtime workload,不得创建 PipelineRun、重复 trigger 或形成第二发布入口; + - 优先使用受控 CLI;入口缺失时仅可按 `$unidesk-daddev` P2 做一次最小 mutation,随后立即只读复核; + - 单步同步只能作为断点诊断或临时恢复证据,不能作为交付证据;调试结束前必须修复声明式自动消费链,并由后续自然 source commit 通过一次 `release plan` 和一次 `release trigger --confirm` 自动完成 PaC、GitOps、Argo 与 runtime 收敛,过程中不得人工 sync。 - Migrated consumer 的默认 help、status、`Next` 与 `REPAIR` 只能给 `status`、`history`、`events`、`logs`、只读单步下钻以及本节稳定引用。CLI 必须省略 mutation command,而不是只给命令加警告文字。`unknown` authority 同样 fail-closed。 - 旧 `trigger-current`、`refresh` 和 mirror `sync|flush` 已退役,只能返回只读诊断,不得创建 Job、写 ref 或重建 runtime。平台 bootstrap、Secret 与配置维护属于独立职责,只在 `platform-bootstrap` 或 `platform-maintenance` scoped help 中展示,不能作为 source delivery recovery。 - `closeout` 仅保留只读历史/诊断兼容入口,并且只能从 `compatibility-diagnostics` scoped help 发现。会 POST hook test 的 `webhook-test` mutation 入口已经删除;连通性只能通过真正只读的 status、GET 与 readiness 观察,禁止制造伪 push。 diff --git a/project-management/PJ2026-01/specs/PJ2026-01060308-cicd-yaml-first-target-governance.md b/project-management/PJ2026-01/specs/PJ2026-01060308-cicd-yaml-first-target-governance.md index fc21ccd2..f1d1c149 100644 --- a/project-management/PJ2026-01/specs/PJ2026-01060308-cicd-yaml-first-target-governance.md +++ b/project-management/PJ2026-01/specs/PJ2026-01060308-cicd-yaml-first-target-governance.md @@ -290,6 +290,15 @@ target 内同名资源必须先按 namespace、kind 和 name 去重;只有完 一致时才可复用,任一字段冲突必须在发布写入前 fail-closed。渲染结果不得 包含 Secret 值或从运行面反解凭据。 +用户明确授权的故障调试阶段,在 PipelineRun 已成功、精确 GitOps commit +已生成且 Argo 未自动消费该 revision 时,可以对 owning YAML 选中的单个 +Application 和单个精确 revision 执行一次 Argo 单步同步。该操作不得修改 +source/ref authority、GitOps desired state、Application source 配置或 +runtime workload,不得创建第二发布入口,也不得作为交付证据。调试结束前 +必须修复声明式自动消费链,并由后续自然 source commit 通过一次受控 +`release trigger` 自动完成 PaC、GitOps、Argo 与 runtime 收敛,过程中不得 +再次人工同步。 + ### 6.8 OPS-TARGET-REQ-008 k3s server 重启门禁非阻塞 | 编号 | 短名 | 主责模块 | 关联模块 | From 1d25222fbd6a58c043beb656625ad5ad0b1963a4 Mon Sep 17 00:00:00 2001 From: pikastech Date: Thu, 23 Jul 2026 05:43:25 +0200 Subject: [PATCH 6/9] docs: define Kafka replay watermark contract --- docs/reference/hwlab.md | 12 +++++++----- ...2026-010401080313-workbench-realtime-authority.md | 10 ++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/reference/hwlab.md b/docs/reference/hwlab.md index 43620392..f5c26b18 100644 --- a/docs/reference/hwlab.md +++ b/docs/reference/hwlab.md @@ -279,6 +279,7 @@ Workbench 浏览器回归需求以 UniDesk OA [PJ2026-010401 Web工作台](../.. - API 依赖就绪入口固定为 `/health/ready`; - `/health` 不是 Workbench L1 health 路由,禁止通过试探 404 判断服务异常; - 实时验收先启动 `hwlab-cli workbench events inspect --over-api --wait-for user,backend,assistant,terminal,final`,再向同一 session/trace 提交一个新 turn; + - `events inspect` 的 `kafkaTransport` 必须披露业务帧总数、有效/缺失/非法 SSE identity 数和各 topic-partition 首末 offset;完整回放要求 `identifiedCount=eventCount`、`missingCount=0`、`invalidCount=0`、`nonMonotonicCount=0`; - 已有 session 的刷新验收必须先检查 connected contract: - `kafkaRefreshReplay=true` 时要求 `deliverySemantics=kafka-retention-then-live`、`replay=true`、`liveOnly=false`; - capability 已启用但仍返回 `live-only` 时,优先定位 L1 native API 是否仍保留独立 live-only SSE adapter; @@ -513,11 +514,12 @@ HWLAB v0.3 订阅 AgentRun Kafka event 的权威输入是 `agentrun.event.v1`, - 实时阶段继续复用进程级 shared consumer/fanout; - 同一 Workbench session 只建立一个产品 EventSource,刷新与重连不得复制业务 subscriber。 - retention replay 的页面可见性采用单一事务边界: - - 返回已有 session 或 SSE 重连时,connected 前的 Kafka business frame 可以在后台追赶,但不得逐帧发布历史前缀到当前可见 session bucket; - - 追赶期间必须保留切走前的稳定消息、Trace、terminal 和最近更新时间,错误与基础设施诊断仍应立即可见; - - replay 提交必须同时满足以下条件: - - 同一 session scope 的 `workbench.connected` 已证明 retention 到 live handoff 完成; - - 待处理 frame 在同一浏览器任务内交给原生产 reducer,并且只发布一次最终投影; + - 唯一判定是 replay 是否追上 active session 最后已经发布的 Kafka `topic/partition/offset` watermark,禁止按消息数量、空会话、业务时间戳、DOM 或本地缓存分支; + - SSE 使用标准 `id:` 行透传 Kafka transport identity,`data:` 中的业务 envelope 必须保持不变; + - 未追上时必须保留切换前的稳定消息、Trace、terminal 和最近更新时间,错误与基础设施诊断仍应立即可见; + - 精确命中 watermark 时,待处理 frame 在同一浏览器任务内进入原生产 reducer 并一次发布追平结果;命中后的 frame 立即可见,不再等待完整 replay 或 `workbench.connected`; + - 没有已发布 watermark 时天然已经追上,首个 business frame 立即进入同一 SSE ingress 和原 reducer; + - `workbench.connected` 只证明 retention 到 live handoff 完成;若此时仍未命中旧 watermark,必须保留旧投影并报告 typed failure; - scope 被替换、连接失败或 handoff 合同无效时必须丢弃未完成事务,禁止提交部分历史前缀; - 该事务只控制同一 Kafka+SSE reducer 的可见时机,不得演变为 snapshot、HTTP history、时间戳仲裁、第二 reducer 或第二状态权威。 - 单一路径禁止用状态优先级伪装: diff --git a/project-management/PJ2026-01/specs/PJ2026-010401080313-workbench-realtime-authority.md b/project-management/PJ2026-01/specs/PJ2026-010401080313-workbench-realtime-authority.md index 299303de..d15e6b05 100644 --- a/project-management/PJ2026-01/specs/PJ2026-010401080313-workbench-realtime-authority.md +++ b/project-management/PJ2026-01/specs/PJ2026-010401080313-workbench-realtime-authority.md @@ -104,6 +104,16 @@ Workbench session rail 使用集合型实时 scope: - rail reducer 禁止写 conversation、Trace、turn、Final Response 和 detail bucket; - 禁止为每个 session 创建 EventSource 或 Kafka consumer、轮询后台 session、从 session list/detail snapshot 更新 rail 状态,或在 `SessionRail` 中锁定、去抖、缓存覆盖和重排结果。 +Kafka retention replay 的用户可见提交只按是否追上当前已发布的 Kafka transport watermark 区分: + +- 页面记录 active session 最后已经交给生产 reducer 并发布的 `topic/partition/offset`;SSE 必须用标准 `id:` 行透传该 transport identity,`data:` 中的 `hwlab.event.v1` 业务 envelope 保持不变。 +- replay 未达到该 watermark 时只在后台追赶,页面保持切换前已经发布的消息、Trace、terminal 和最近更新时间。 +- replay 精确命中该 watermark 时,在同一浏览器任务内把暂存 frame 依序交给原 reducer 并一次发布追平结果;命中后的 frame 立即走同一 reducer,不再等待完整 replay 或 `workbench.connected`。 +- 没有已发布 watermark 时天然视为已经追上,首个 business frame 立即进入同一 SSE ingress 和原 reducer;这不是按消息数量、空会话或 DOM 状态建立的特殊分支。 +- `workbench.connected` 只证明 retention 到 live handoff 完成;若完成时仍未命中旧 watermark,必须保持旧投影并报告 typed failure,禁止提交历史前缀。 +- watermark 判断只允许使用 Kafka `topic/partition/offset`,禁止使用 message count、业务时间戳、Trace seq、rail revision、DOM 或本地缓存代替。 +- 该机制只改变同一 reducer 的可见提交时机,不得增加 snapshot、HTTP history、第二 reducer、时间戳仲裁或本地状态权威。 + 禁止路径: - 页面初始加载、刷新、切页或重连通过 session list/detail/messages、turn、trace 或 result HTTP 响应写入 Workbench 主状态。 From 5c6dd88e445cbb468fd136b78fe93e76abc62c70 Mon Sep 17 00:00:00 2001 From: pikastech Date: Thu, 23 Jul 2026 05:43:41 +0200 Subject: [PATCH 7/9] chore: align HWPOD node secret distribution --- config/secrets-distribution.yaml | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/config/secrets-distribution.yaml b/config/secrets-distribution.yaml index 895c2c28..f203ce57 100644 --- a/config/secrets-distribution.yaml +++ b/config/secrets-distribution.yaml @@ -182,6 +182,12 @@ sources: OPENCODE_SERVER_PASSWORD: bytes: 32 prefix: oc_ + - sourceRef: hwlab/nc01-v03-hwpod-node.env + type: env + requiredKeys: + - HWLAB_HWPOD_NODE_WS_TOKEN + createIfMissing: + enabled: false - sourceRef: hwlab/web-probe-monitor-db.env type: env requiredKeys: @@ -585,15 +591,6 @@ kubernetesSecrets: - sourceRef: hwlab/web-probe-monitor-db.env sourceKey: DATABASE_URL targetKey: DATABASE_URL - - name: hwlab-nc01-v03-cloud-api-db - targetId: hwlab-nc01-v03 - secretName: hwlab-cloud-api-v03-db - type: Opaque - namespace: hwlab-v03 - data: - - sourceRef: hwlab/nc01-v03-cloud-api-db.env - sourceKey: DATABASE_URL - targetKey: database-url - name: hwlab-tasktree-db targetId: hwlab-nc01-v03 secretName: hwlab-tasktree-db @@ -634,6 +631,14 @@ kubernetesSecrets: - sourceRef: /root/.unidesk/.env/unified-admin-password.txt sourceKey: contents targetKey: password + - name: hwlab-nc01-production-hwpod-node-auth + targetId: hwlab-nc01-production + secretName: hwlab-production-hwpod-node-auth + type: Opaque + data: + - sourceRef: hwlab/nc01-v03-hwpod-node.env + sourceKey: HWLAB_HWPOD_NODE_WS_TOKEN + targetKey: token - name: hwlab-jd01-v03-opencode-server-auth targetId: hwlab-jd01-v03 secretName: hwlab-v03-opencode-server-auth From c2c1b1c1ddbab1525658cc3cfbc24fd1805b410d Mon Sep 17 00:00:00 2001 From: pikastech Date: Thu, 23 Jul 2026 05:53:22 +0200 Subject: [PATCH 8/9] feat: add isolated Sub2API upstream groups --- .agents/skills/unidesk-sub2api/SKILL.md | 2 + .../unidesk-sub2api/references/upstreams.md | 36 + .../sub2api-upstream-groups.yaml | 46 ++ .../platform-infra-sub2api-upstream-groups.ts | 759 ++++++++++++++++++ scripts/src/platform-infra/options.ts | 4 + 5 files changed, 847 insertions(+) create mode 100644 config/platform-infra/sub2api-upstream-groups.yaml create mode 100644 scripts/src/platform-infra-sub2api-upstream-groups.ts diff --git a/.agents/skills/unidesk-sub2api/SKILL.md b/.agents/skills/unidesk-sub2api/SKILL.md index 2bdf693e..7cf978c6 100644 --- a/.agents/skills/unidesk-sub2api/SKILL.md +++ b/.agents/skills/unidesk-sub2api/SKILL.md @@ -26,6 +26,8 @@ bun scripts/cli.ts platform-infra sub2api ops channels --target PK01 --window 7d bun scripts/cli.ts platform-infra sub2api plan --target PK01 bun scripts/cli.ts platform-infra sub2api codex-pool runtime get --target PK01 --account bun scripts/cli.ts platform-infra sub2api codex-pool runtime events --target PK01 --since 8h +bun scripts/cli.ts platform-infra sub2api upstream-groups status --target PK01 +bun scripts/cli.ts platform-infra sub2api upstream-groups smoke --target PK01 ``` 先看状态,再做计划或变更。详细规则按职责拆在 `references/` 下;不要新增 `full.md`、`all.md`、`guide.md` 这类变相超级文件。 diff --git a/.agents/skills/unidesk-sub2api/references/upstreams.md b/.agents/skills/unidesk-sub2api/references/upstreams.md index 90d74feb..790afd27 100644 --- a/.agents/skills/unidesk-sub2api/references/upstreams.md +++ b/.agents/skills/unidesk-sub2api/references/upstreams.md @@ -10,6 +10,42 @@ 普通新增上游是 YAML 操作,不走 CI/CD,不改代码。只有需要渲染或校验上游 Sub2API 已经存在的可复用能力时才修改 `scripts/src/platform-infra-sub2api-codex.ts`;Sub2API 本身不支持的能力不在 UniDesk 侧魔改实现。 +## 独立 OpenAI-compatible 分组 + +- 不进入 Codex pool 的第三方套餐统一声明在 + `config/platform-infra/sub2api-upstream-groups.yaml`: + - 分组、消费 Key sourceRef、账号、模型前缀、协议模式、优先级和容量都以该 + YAML 为唯一真相; + - 凭据使用 `credential-init --account --confirm` 从标准输入写入 + owner-only sourceRef,禁止放入 YAML 或命令参数。 +- 受控入口: + +```bash +bun scripts/cli.ts platform-infra sub2api upstream-groups plan --target PK01 +bun scripts/cli.ts platform-infra sub2api upstream-groups apply --target PK01 --confirm +bun scripts/cli.ts platform-infra sub2api upstream-groups status --target PK01 +bun scripts/cli.ts platform-infra sub2api upstream-groups smoke --target PK01 +``` + +- 模型隔离使用 YAML 声明的账号前缀: + - CLI 从上游 `/models` 同步为 `/`; + - 映射值保留上游原始模型 ID; + - 不同账号即使暴露同名模型也不会互相误投或切号。 +- 仅支持 Chat Completions 的上游必须显式设置 + `responsesMode: force_chat_completions`: + - 对不存在的 `/responses` 返回 `401` 或 `403` 的服务,Sub2API 自动探测可能 + 保守判为支持 Responses; + - 官方账号字段 `openai_responses_mode=force_chat_completions` 强制走 + `/v1/chat/completions`,无需修改 Sub2API 源码。 +- `smoke` 同时执行目标主机直连和独立消费 Key 公网转发: + - 两层均为 `200` 才算通过; + - 结果只展示账号名、模型、状态、request ID 和短响应,不打印任何凭据; + - 公网失败时使用同一 request ID 通过 `codex-pool trace` 下钻。 +- `status` 不能直接相信原生账号列表的 `group_id` 过滤: + - 当前接口可能返回全量账号; + - CLI 必须按 YAML 声明的账号名精确投影,并披露 + `nativeFilterOverbroad`、原始数量和过滤数量。 + ## 删除上游 删除上游只用于明确退役、凭据所有权变更或用户明确要求移除 provider;不能作为上游 5xx、compact 失败、限流、模型路由失败或哨兵隔离/恢复问题的处理手段。 diff --git a/config/platform-infra/sub2api-upstream-groups.yaml b/config/platform-infra/sub2api-upstream-groups.yaml new file mode 100644 index 00000000..6ae42da7 --- /dev/null +++ b/config/platform-infra/sub2api-upstream-groups.yaml @@ -0,0 +1,46 @@ +version: 1 +kind: platform-infra-sub2api-upstream-groups + +metadata: + id: sub2api-upstream-groups + owner: unidesk + relatedIssues: [2892] + +defaults: + targetId: PK01 + +groups: + - id: short-term-openai-compatible + name: 独立套餐 + description: 独立于 Codex pool 的短期 OpenAI-compatible 上游套餐。 + platform: openai + consumerApiKey: + name: unidesk-independent-upstreams-api-key + sourceRef: /root/.unidesk/.env/sub2api-upstream-groups/independent-upstreams-consumer-key.txt + accounts: + - id: scnet-token-plan + name: 超算互联网 Token Plan + protocol: openai-chat + responsesMode: force_chat_completions + modelPrefix: scnet + baseUrl: https://api.scnet.cn/api/llm/v1 + modelsPath: /models + smokeModel: DeepSeek-V4-Flash + credential: + sourceRef: /root/.unidesk/.env/sub2api-upstream-groups/scnet-token-plan-key.txt + priority: 10 + capacity: 5 + loadFactor: 10 + - id: opencode-go + name: OpenCode Go + protocol: openai-chat + responsesMode: force_chat_completions + modelPrefix: opencode-go + baseUrl: https://opencode.ai/zen/go/v1 + modelsPath: /models + smokeModel: deepseek-v4-flash + credential: + sourceRef: /root/.unidesk/.env/sub2api-upstream-groups/opencode-go-key.txt + priority: 10 + capacity: 5 + loadFactor: 10 diff --git a/scripts/src/platform-infra-sub2api-upstream-groups.ts b/scripts/src/platform-infra-sub2api-upstream-groups.ts new file mode 100644 index 00000000..1405eed2 --- /dev/null +++ b/scripts/src/platform-infra-sub2api-upstream-groups.ts @@ -0,0 +1,759 @@ +// SPEC: PJ2026-010603 YAML运维。独立于 Codex pool 的 Sub2API 上游分组。 + +import { createHash, randomBytes } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import type { UniDeskConfig } from "./config"; +import { rootPath } from "./config"; +import type { RenderedCliResult } from "./output"; +import { runSshCommandCapture } from "./ssh"; +import { codexPoolRuntimeTarget, defaultCodexPoolRuntimeTargetId } from "./platform-infra-sub2api-codex/runtime-target"; + +const configPath = rootPath("config/platform-infra/sub2api-upstream-groups.yaml"); + +type Protocol = "openai-chat"; + +interface AccountConfig { + id: string; + name: string; + protocol: Protocol; + responsesMode: "force_chat_completions"; + modelPrefix: string; + baseUrl: string; + modelsPath: string; + smokeModel: string | null; + sourceRef: string; + priority: number; + capacity: number; + loadFactor: number; +} + +interface GroupConfig { + id: string; + name: string; + description: string; + platform: "openai"; + consumerApiKeyName: string; + consumerApiKeySourceRef: string; + accounts: AccountConfig[]; +} + +interface UpstreamGroupsConfig { + defaultTargetId: string; + groups: GroupConfig[]; +} + +interface CliOptions { + action: "plan" | "apply" | "status" | "smoke" | "credential-init"; + targetId: string; + groupId: string | null; + accountId: string | null; + confirm: boolean; + json: boolean; +} + +export async function runSub2ApiUpstreamGroupsCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { + const options = parseOptions(args); + const desired = readConfig(); + const targetId = options.targetId || desired.defaultTargetId; + if (options.action === "credential-init") return credentialInit(desired, options); + const selected = selectGroups(desired, options.groupId); + const local = await localPlan(selected); + if (options.action === "plan") return renderResult(planResult(targetId, selected, local), options); + if (options.action === "smoke") return renderResult(await smokeGroups(config, targetId, selected, local), options); + if (options.action === "apply") { + if (!options.confirm || !local.ok) { + return renderResult({ + ...planResult(targetId, selected, local), + mode: options.confirm ? "blocked-invalid-secret-or-upstream" : "dry-run", + mutation: false, + next: options.confirm ? "修复缺失 Secret 或上游模型探针后重试。" : confirmCommand(options), + }, options); + } + return renderResult(await applyGroups(config, targetId, selected, local), options); + } + return renderResult(await statusGroups(config, targetId, selected, local), options); +} + +function parseOptions(args: string[]): CliOptions { + const [actionRaw = "plan", ...rest] = args; + if (!["plan", "apply", "status", "smoke", "credential-init"].includes(actionRaw)) { + throw new Error("upstream-groups usage: plan|apply|status|smoke|credential-init [--group ] [--account ] [--target ] [--confirm] [--json]"); + } + let targetId = defaultCodexPoolRuntimeTargetId(); + let groupId: string | null = null; + let accountId: string | null = null; + let confirm = false; + let json = false; + for (let index = 0; index < rest.length; index += 1) { + const arg = rest[index]!; + const value = (name: string): string => { + const next = rest[index + 1]; + if (next === undefined || next.startsWith("--")) throw new Error(`${name} requires a value`); + index += 1; + return next; + }; + if (arg === "--confirm") confirm = true; + else if (arg === "--json") json = true; + else if (arg === "--target") targetId = value("--target"); + else if (arg.startsWith("--target=")) targetId = arg.slice(9); + else if (arg === "--group") groupId = value("--group"); + else if (arg.startsWith("--group=")) groupId = arg.slice(8); + else if (arg === "--account") accountId = value("--account"); + else if (arg.startsWith("--account=")) accountId = arg.slice(10); + else throw new Error(`unsupported upstream-groups option: ${arg}`); + } + const action = actionRaw as CliOptions["action"]; + if (action === "credential-init" && accountId === null) throw new Error("credential-init requires --account "); + if (action !== "credential-init" && accountId !== null) throw new Error("--account only supports credential-init"); + if (action !== "apply" && action !== "credential-init" && confirm) throw new Error(`${action} does not accept --confirm`); + return { action, targetId, groupId, accountId, confirm, json }; +} + +function readConfig(): UpstreamGroupsConfig { + const raw = Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown; + const root = record(raw, configPath); + const defaults = record(root.defaults, `${configPath}.defaults`); + const defaultTargetId = text(defaults.targetId, `${configPath}.defaults.targetId`); + if (!Array.isArray(root.groups) || root.groups.length === 0) throw new Error(`${configPath}.groups must be a non-empty list`); + const groups = root.groups.map((entry, groupIndex): GroupConfig => { + const group = record(entry, `${configPath}.groups[${groupIndex}]`); + const consumer = record(group.consumerApiKey, `${configPath}.groups[${groupIndex}].consumerApiKey`); + const platform = text(group.platform, `${configPath}.groups[${groupIndex}].platform`); + if (platform !== "openai") throw new Error(`${configPath}.groups[${groupIndex}].platform must be openai`); + if (!Array.isArray(group.accounts) || group.accounts.length === 0) throw new Error(`${configPath}.groups[${groupIndex}].accounts must be non-empty`); + return { + id: identifier(group.id, `${configPath}.groups[${groupIndex}].id`), + name: text(group.name, `${configPath}.groups[${groupIndex}].name`), + description: text(group.description, `${configPath}.groups[${groupIndex}].description`), + platform, + consumerApiKeyName: text(consumer.name, `${configPath}.groups[${groupIndex}].consumerApiKey.name`), + consumerApiKeySourceRef: absolutePath(consumer.sourceRef, `${configPath}.groups[${groupIndex}].consumerApiKey.sourceRef`), + accounts: group.accounts.map((item, accountIndex): AccountConfig => { + const account = record(item, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}]`); + const credential = record(account.credential, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}].credential`); + const protocol = text(account.protocol, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}].protocol`); + if (protocol !== "openai-chat") throw new Error(`${configPath}.groups[${groupIndex}].accounts[${accountIndex}].protocol must be openai-chat`); + const responsesMode = text(account.responsesMode, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}].responsesMode`); + if (responsesMode !== "force_chat_completions") throw new Error(`${configPath}.groups[${groupIndex}].accounts[${accountIndex}].responsesMode must be force_chat_completions`); + const baseUrl = new URL(text(account.baseUrl, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}].baseUrl`)); + if (baseUrl.protocol !== "https:") throw new Error("upstream baseUrl must use https"); + return { + id: identifier(account.id, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}].id`), + name: text(account.name, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}].name`), + protocol, + responsesMode, + modelPrefix: identifier(account.modelPrefix, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}].modelPrefix`), + baseUrl: baseUrl.toString().replace(/\/$/u, ""), + modelsPath: pathValue(account.modelsPath, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}].modelsPath`), + smokeModel: account.smokeModel === undefined ? null : text(account.smokeModel, "smokeModel"), + sourceRef: absolutePath(credential.sourceRef, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}].credential.sourceRef`), + priority: boundedInteger(account.priority, "priority", 1, 1000), + capacity: boundedInteger(account.capacity, "capacity", 1, 100000), + loadFactor: boundedInteger(account.loadFactor, "loadFactor", 1, 1000), + }; + }), + }; + }); + const ids = groups.flatMap((group) => [group.id, ...group.accounts.map((account) => account.id)]); + if (new Set(ids).size !== ids.length) throw new Error(`${configPath} group/account ids must be globally unique`); + return { defaultTargetId, groups }; +} + +function credentialInit(config: UpstreamGroupsConfig, options: CliOptions): Record { + const account = config.groups.flatMap((group) => group.accounts).find((item) => item.id === options.accountId); + if (account === undefined) throw new Error(`unknown upstream account id: ${options.accountId}`); + if (!options.confirm) { + return { + ok: true, + action: "platform-infra-sub2api-upstream-groups-credential-init", + mode: "dry-run", + account: { id: account.id, name: account.name }, + secret: secretSummary(account.sourceRef), + mutation: false, + valuesPrinted: false, + next: `将 API key 通过标准输入传给:bun scripts/cli.ts platform-infra sub2api upstream-groups credential-init --account ${account.id} --confirm`, + }; + } + const value = readFileSync(0, "utf8").trim(); + if (!/^sk-[A-Za-z0-9_=+/.-]{16,}$/u.test(value)) throw new Error("stdin must contain one supported API key"); + mkdirSync(dirname(account.sourceRef), { recursive: true, mode: 0o700 }); + writeFileSync(account.sourceRef, `${value}\n`, { encoding: "utf8", mode: 0o600 }); + chmodSync(account.sourceRef, 0o600); + return { + ok: true, + action: "platform-infra-sub2api-upstream-groups-credential-init", + mode: "confirmed", + account: { id: account.id, name: account.name }, + secret: secretSummary(account.sourceRef), + mutation: true, + valuesPrinted: false, + }; +} + +async function localPlan(groups: GroupConfig[]): Promise> { + const accounts = []; + for (const group of groups) { + for (const account of group.accounts) { + const secret = secretSummary(account.sourceRef); + const models = secret.present ? await probeModels(account, readSecret(account.sourceRef)) : { ok: false, status: null, models: [], error: "secret-missing" }; + accounts.push({ + groupId: group.id, + id: account.id, + name: account.name, + protocol: account.protocol, + baseUrl: account.baseUrl, + secret, + models: { ok: models.ok, status: models.status, count: models.models.length, sample: models.models.slice(0, 8), all: models.models, error: models.error }, + }); + } + } + return { ok: accounts.every((item) => item.secret.present === true && item.models.ok === true), accounts, valuesPrinted: false }; +} + +async function probeModels(account: AccountConfig, apiKey: string): Promise<{ ok: boolean; status: number | null; models: string[]; error: string | null }> { + try { + const response = await fetch(`${account.baseUrl}${account.modelsPath}`, { + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + signal: AbortSignal.timeout(20_000), + }); + const body = await response.json() as unknown; + const models = modelIds(body); + return { ok: response.ok && models.length > 0, status: response.status, models, error: response.ok ? null : `http-${response.status}` }; + } catch (error) { + return { ok: false, status: null, models: [], error: error instanceof Error ? error.message.slice(0, 160) : "probe-failed" }; + } +} + +function planResult(targetId: string, groups: GroupConfig[], local: Record): Record { + return { + ok: local.ok === true, + action: "platform-infra-sub2api-upstream-groups-plan", + mode: "dry-run", + targetId, + configPath, + groups: groups.map(redactGroup), + local, + isolation: { codexPoolMembership: false, codexSettingsApplied: false }, + mutation: false, + valuesPrinted: false, + next: `bun scripts/cli.ts platform-infra sub2api upstream-groups apply --target ${targetId} --confirm`, + }; +} + +async function applyGroups(config: UniDeskConfig, targetId: string, groups: GroupConfig[], local: Record): Promise> { + const target = codexPoolRuntimeTarget(targetId); + if (target.runtimeMode !== "host-docker" || target.hostDockerAppPort === null || target.hostDockerEnvPath === null) { + throw new Error("upstream-groups apply currently requires a YAML-selected host-docker target"); + } + const payloadGroups = groups.map((group) => { + const consumerKey = ensureConsumerKey(group.consumerApiKeySourceRef); + return { + id: group.id, + name: group.name, + description: group.description, + platform: group.platform, + consumerApiKeyName: group.consumerApiKeyName, + consumerApiKey: consumerKey, + accounts: group.accounts.map((account) => { + const planned = Array.isArray(local.accounts) + ? local.accounts.filter(isRecord).find((item) => item.id === account.id) + : undefined; + const modelProbe = planned !== undefined && isRecord(planned.models) ? planned.models : {}; + const models = Array.isArray(modelProbe.all) + ? modelProbe.all.filter((model): model is string => typeof model === "string") + : []; + return { + id: account.id, + name: account.name, + protocol: account.protocol, + responsesMode: account.responsesMode, + modelPrefix: account.modelPrefix, + baseUrl: account.baseUrl, + apiKey: readSecret(account.sourceRef), + sourceRef: account.sourceRef, + secretFingerprint: fingerprint(readSecret(account.sourceRef)), + modelMapping: Object.fromEntries(models.map((model) => [`${account.modelPrefix}/${model.toLowerCase()}`, model])), + priority: account.priority, + capacity: account.capacity, + loadFactor: account.loadFactor, + }; + }), + }; + }); + const script = remoteApplyScript(target.hostDockerAppPort, target.hostDockerEnvPath, payloadGroups); + const result = await runSshCommandCapture(config, target.route, ["sh"], script); + if (result.exitCode !== 0) throw new Error(`upstream-groups apply failed: ${result.stderr.slice(-1200)}`); + const remote = JSON.parse(result.stdout.trim()) as Record; + return { + ok: remote.ok === true, + action: "platform-infra-sub2api-upstream-groups-apply", + mode: "confirmed", + targetId, + configPath, + groups: groups.map(redactGroup), + local, + remote, + isolation: { codexPoolMembership: false, codexSettingsApplied: false }, + mutation: true, + valuesPrinted: false, + }; +} + +async function statusGroups(config: UniDeskConfig, targetId: string, groups: GroupConfig[], local: Record): Promise> { + const target = codexPoolRuntimeTarget(targetId); + if (target.runtimeMode !== "host-docker" || target.hostDockerAppPort === null || target.hostDockerEnvPath === null) { + throw new Error("upstream-groups status currently requires a YAML-selected host-docker target"); + } + const script = remoteStatusScript(target.hostDockerAppPort, target.hostDockerEnvPath, groups.map((group) => ({ + id: group.id, + name: group.name, + accountNames: group.accounts.map((account) => account.name), + }))); + const result = await runSshCommandCapture(config, target.route, ["sh"], script); + if (result.exitCode !== 0) throw new Error(`upstream-groups status failed: ${result.stderr.slice(-1200)}`); + const remote = JSON.parse(result.stdout.trim()) as Record; + return { + ok: local.ok === true && remote.ok === true, + action: "platform-infra-sub2api-upstream-groups-status", + targetId, + configPath, + local, + remote, + isolation: { codexPoolMembership: false, codexSettingsApplied: false }, + mutation: false, + valuesPrinted: false, + }; +} + +async function smokeGroups(config: UniDeskConfig, targetId: string, groups: GroupConfig[], local: Record): Promise> { + const target = codexPoolRuntimeTarget(targetId); + if (target.publicBaseUrl === null) throw new Error(`target ${targetId} has no enabled publicBaseUrl`); + const directPayload = groups.flatMap((group) => group.accounts.map((account) => ({ + groupId: group.id, + account: account.name, + url: `${account.baseUrl}/chat/completions`, + apiKey: readSecret(account.sourceRef), + model: account.smokeModel, + }))); + const directCapture = await runSshCommandCapture(config, target.route, ["sh"], remoteDirectSmokeScript(directPayload)); + if (directCapture.exitCode !== 0) throw new Error(`upstream-groups direct smoke failed: ${directCapture.stderr.slice(-1200)}`); + const direct = JSON.parse(directCapture.stdout.trim()) as Record; + const results = []; + for (const group of groups) { + const apiKey = readSecret(group.consumerApiKeySourceRef); + for (const account of group.accounts) { + if (account.smokeModel === null) { + results.push({ groupId: group.id, account: account.name, ok: false, model: null, status: null, error: "smokeModel-not-configured" }); + continue; + } + try { + const response = await fetch(`${target.publicBaseUrl.replace(/\/+$/u, "")}/v1/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + model: `${account.modelPrefix}/${account.smokeModel.toLowerCase()}`, + messages: [{ role: "user", content: "Reply with exactly: OK" }], + stream: false, + max_tokens: 16, + }), + signal: AbortSignal.timeout(90_000), + }); + const body = await response.json().catch(() => ({})) as unknown; + const root = isRecord(body) ? body : {}; + const choices = Array.isArray(root.choices) ? root.choices : []; + const first = choices.find(isRecord); + const message = first !== undefined && isRecord(first.message) ? first.message : {}; + const content = typeof message.content === "string" ? message.content.replace(/\s+/gu, " ").trim().slice(0, 120) : null; + const requestId = response.headers.get("x-request-id") ?? (typeof root.id === "string" ? root.id : null); + results.push({ + groupId: group.id, + account: account.name, + ok: response.ok && content !== null, + model: `${account.modelPrefix}/${account.smokeModel.toLowerCase()}`, + status: response.status, + requestId, + responsePreview: content, + error: response.ok ? null : `http-${response.status}`, + }); + } catch (error) { + results.push({ + groupId: group.id, + account: account.name, + ok: false, + model: `${account.modelPrefix}/${account.smokeModel.toLowerCase()}`, + status: null, + requestId: null, + responsePreview: null, + error: error instanceof Error ? error.message.slice(0, 160) : "smoke-failed", + }); + } + } + } + return { + ok: local.ok === true && results.every((item) => item.ok), + action: "platform-infra-sub2api-upstream-groups-smoke", + targetId, + publicBaseUrl: target.publicBaseUrl, + local, + direct, + results, + isolation: { codexPoolMembership: false, codexSettingsApplied: false }, + mutation: false, + valuesPrinted: false, + }; +} + +function remoteDirectSmokeScript(accounts: unknown): string { + const payload = Buffer.from(JSON.stringify(accounts), "utf8").toString("base64"); + return ` +set -eu +python3 - <<'PY' +import base64, json, subprocess +accounts = json.loads(base64.b64decode(${JSON.stringify(payload)}).decode()) +results = [] +for account in accounts: + if not account.get("model"): + results.append({"groupId": account["groupId"], "account": account["account"], "ok": False, "status": None, "model": None, "error": "smokeModel-not-configured"}) + continue + body = json.dumps({"model": account["model"], "messages": [{"role": "user", "content": "Reply with exactly: OK"}], "stream": False, "max_tokens": 16}, separators=(",", ":")) + proc = subprocess.run(["curl", "-sS", "--max-time", "90", "-w", "\\n__HTTP__:%{http_code}", "-H", "Authorization: Bearer " + account["apiKey"], "-H", "Content-Type: application/json", "--data-binary", body, account["url"]], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output = proc.stdout.decode("utf-8", errors="replace") + marker = "\\n__HTTP__:" + content, status = output.rsplit(marker, 1) if marker in output else ("", "000") + parsed = {} + try: + parsed = json.loads(content) + except Exception: + pass + choices = parsed.get("choices") if isinstance(parsed, dict) else None + message = choices[0].get("message") if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {} + preview = message.get("content") if isinstance(message, dict) else None + error = parsed.get("error") if isinstance(parsed, dict) else None + error_message = error.get("message") if isinstance(error, dict) else None + results.append({"groupId": account["groupId"], "account": account["account"], "ok": proc.returncode == 0 and status.startswith("2"), "status": int(status) if status.isdigit() else None, "model": account["model"], "responsePreview": str(preview)[:120] if preview is not None else None, "error": str(error_message)[:160] if error_message is not None else (None if status.startswith("2") else "http-" + status)}) +print(json.dumps({"ok": all(item["ok"] for item in results), "results": results, "valuesPrinted": False}, ensure_ascii=False)) +PY +`; +} + +function remoteApplyScript(appPort: number, envPath: string, groups: unknown): string { + const payload = Buffer.from(JSON.stringify(groups), "utf8").toString("base64"); + return remotePython(appPort, envPath, ` +groups = json.loads(base64.b64decode(${JSON.stringify(payload)}).decode()) +token = login() +results = [] +for desired in groups: + listed = items(api("GET", "/api/v1/admin/groups/all?platform=openai", token)) + current = next((item for item in listed if item.get("name") == desired["name"]), None) + group_payload = {"name": desired["name"], "description": desired["description"], "platform": "openai", "rate_multiplier": 1, "is_exclusive": False, "subscription_type": "standard", "allow_messages_dispatch": False, "require_oauth_only": False, "require_privacy_set": False, "rpm_limit": 0} + if current is None: + current = data(api("POST", "/api/v1/admin/groups", token, group_payload)) + group_action = "created" + else: + current = data(api("PUT", "/api/v1/admin/groups/" + str(current["id"]), token, {**group_payload, "status": "active"})) + group_action = "updated" + group_id = current["id"] + account_results = [] + for account in desired["accounts"]: + found = items(api("GET", "/api/v1/admin/accounts?page=1&page_size=50&platform=openai&search=" + quote(account["name"]), token)) + existing = next((item for item in found if item.get("name") == account["name"]), None) + account_payload = { + "name": account["name"], + "notes": "UniDesk YAML-managed independent upstream; id=" + account["id"] + "; source=" + account["sourceRef"] + "; fingerprint=" + account["secretFingerprint"], + "platform": "openai", + "type": "apikey", + "credentials": {"api_key": account["apiKey"], "base_url": account["baseUrl"], "model_mapping": account["modelMapping"]}, + "extra": {"unidesk_managed_upstream_group": True, "unidesk_upstream_id": account["id"], "openai_responses_mode": account["responsesMode"]}, + "concurrency": account["capacity"], + "priority": account["priority"], + "rate_multiplier": 1, + "load_factor": account["loadFactor"], + "group_ids": [group_id], + "confirm_mixed_channel_risk": True, + } + if existing and existing.get("id") is not None: + account_payload.pop("platform", None) + account_payload["status"] = "active" + saved = data(api("PUT", "/api/v1/admin/accounts/" + str(existing["id"]), token, account_payload)) + action = "updated" + else: + saved = data(api("POST", "/api/v1/admin/accounts", token, account_payload)) + action = "created" + saved = data(api("POST", "/api/v1/admin/accounts/" + str(saved["id"]) + "/schedulable", token, {"schedulable": True})) + account_results.append({"id": saved.get("id"), "name": account["name"], "action": action, "groupId": group_id}) + keys = items(api("GET", "/api/v1/keys?page=1&page_size=200", token)) + key = next((item for item in keys if item.get("name") == desired["consumerApiKeyName"]), None) + if key is None: + key = data(api("POST", "/api/v1/keys", token, {"name": desired["consumerApiKeyName"], "group_id": group_id, "custom_key": desired["consumerApiKey"], "quota": 0, "rate_limit_5h": 0, "rate_limit_1d": 0, "rate_limit_7d": 0})) + key_action = "created" + elif key.get("id") is not None and key.get("group_id") != group_id: + key = data(api("PUT", "/api/v1/keys/" + str(key["id"]), token, {"name": desired["consumerApiKeyName"], "group_id": group_id})) + key_action = "updated" + else: + key_action = "kept" + results.append({"groupId": group_id, "groupName": desired["name"], "groupAction": group_action, "accounts": account_results, "apiKey": {"id": key.get("id"), "name": desired["consumerApiKeyName"], "groupId": group_id, "action": key_action}}) +print(json.dumps({"ok": True, "groups": results, "valuesPrinted": False}, ensure_ascii=False)) +`); +} + +function remoteStatusScript(appPort: number, envPath: string, groups: unknown): string { + const payload = Buffer.from(JSON.stringify(groups), "utf8").toString("base64"); + return remotePython(appPort, envPath, ` +desired = json.loads(base64.b64decode(${JSON.stringify(payload)}).decode()) +token = login() +all_groups = items(api("GET", "/api/v1/admin/groups/all?platform=openai", token)) +results = [] +for item in desired: + group = next((candidate for candidate in all_groups if candidate.get("name") == item["name"]), None) + if group is None: + results.append({"id": item["id"], "name": item["name"], "present": False, "accounts": []}) + continue + raw_accounts = items(api("GET", "/api/v1/admin/accounts?group_id=" + str(group["id"]) + "&page=1&page_size=500&platform=openai", token)) + expected_names = set(item["accountNames"]) + accounts = [account for account in raw_accounts if account.get("name") in expected_names] + results.append({"id": item["id"], "name": item["name"], "present": True, "groupId": group.get("id"), "expectedAccountCount": len(expected_names), "filteredAccountCount": len(accounts), "rawReturnedCount": len(raw_accounts), "nativeFilterOverbroad": len(raw_accounts) > len(accounts), "accounts": [{"id": account.get("id"), "name": account.get("name"), "status": account.get("status"), "schedulable": account.get("schedulable"), "priority": account.get("priority")} for account in accounts]}) +print(json.dumps({"ok": all(item.get("present") and item.get("filteredAccountCount") == item.get("expectedAccountCount") for item in results), "groups": results, "valuesPrinted": False}, ensure_ascii=False)) +`); +} + +function remotePython(appPort: number, envPath: string, body: string): string { + return ` +set -eu +python3 - <<'PY' +import base64, json, subprocess +from urllib.parse import quote + +APP_PORT = ${JSON.stringify(appPort)} +ENV_PATH = ${JSON.stringify(envPath)} + +def env_values(): + try: + raw = open(ENV_PATH, "r", encoding="utf-8").read() + except PermissionError: + proc = subprocess.run(["sudo", "-n", "cat", ENV_PATH], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if proc.returncode != 0: + raise RuntimeError("cannot read host env source") + raw = proc.stdout.decode() + result = {} + for line in raw.splitlines(): + text = line.strip() + if not text or text.startswith("#") or "=" not in text: + continue + key, value = text.split("=", 1) + value = value.strip() + if len(value) > 1 and value[0] == value[-1] and value[0] in ("'", '"'): + value = value[1:-1] + result[key.strip()] = value + return result + +def api(method, path, token=None, payload=None): + command = ["curl", "-sS", "-w", "\\n__HTTP__:%{http_code}", "-X", method, "-H", "Content-Type: application/json"] + if token: + command += ["-H", "Authorization: Bearer " + token] + if payload is not None: + command += ["--data-binary", json.dumps(payload, separators=(",", ":"))] + command += ["http://127.0.0.1:" + str(APP_PORT) + path] + proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output = proc.stdout.decode("utf-8", errors="replace") + marker = "\\n__HTTP__:" + content, status = output.rsplit(marker, 1) + parsed = json.loads(content) if content.strip() else {} + code = parsed.get("code") if isinstance(parsed, dict) else None + if proc.returncode != 0 or int(status) >= 400 or (code is not None and code != 0): + message = parsed.get("message") if isinstance(parsed, dict) else "request failed" + raise RuntimeError("Sub2API request failed: http=" + status + " message=" + str(message)[:300]) + return parsed + +def data(value): + return value.get("data") if isinstance(value, dict) and "data" in value else value + +def items(value): + value = data(value) + if isinstance(value, list): + return value + if isinstance(value, dict): + if isinstance(value.get("items"), list): + return value["items"] + for key in ("groups", "accounts", "api_keys", "keys"): + if isinstance(value.get(key), list): + return value[key] + return [] + +def login(): + env = env_values() + email = env.get("ADMIN_EMAIL") or "admin@sub2api.platform-infra.local" + password = env.get("ADMIN_PASSWORD") + if not password: + raise RuntimeError("ADMIN_PASSWORD missing") + value = data(api("POST", "/api/v1/auth/login", payload={"email": email, "password": password})) + token = value.get("access_token") or value.get("token") + if not token: + raise RuntimeError("admin login token missing") + return token + +${body} +PY +`; +} + +function selectGroups(config: UpstreamGroupsConfig, groupId: string | null): GroupConfig[] { + if (groupId === null) return config.groups; + const selected = config.groups.find((group) => group.id === groupId); + if (selected === undefined) throw new Error(`unknown upstream group id: ${groupId}`); + return [selected]; +} + +function redactGroup(group: GroupConfig): Record { + return { + id: group.id, + name: group.name, + platform: group.platform, + consumerApiKey: { name: group.consumerApiKeyName, ...secretSummary(group.consumerApiKeySourceRef) }, + accounts: group.accounts.map((account) => ({ + id: account.id, + name: account.name, + protocol: account.protocol, + responsesMode: account.responsesMode, + modelPrefix: account.modelPrefix, + baseUrl: account.baseUrl, + priority: account.priority, + capacity: account.capacity, + loadFactor: account.loadFactor, + credential: secretSummary(account.sourceRef), + })), + }; +} + +function renderResult(result: Record, options: CliOptions): Record | RenderedCliResult { + if (options.json) return result; + const local = isRecord(result.local) ? result.local : {}; + const accounts = Array.isArray(local.accounts) ? local.accounts.filter(isRecord) : []; + const rows = accounts.map((account) => { + const secret = isRecord(account.secret) ? account.secret : {}; + const models = isRecord(account.models) ? account.models : {}; + return [ + String(account.name ?? "-"), + String(account.protocol ?? "-"), + secret.present === true ? "present" : "missing", + String(secret.fingerprint ?? "-"), + models.ok === true ? "ok" : "failed", + String(models.count ?? 0), + Array.isArray(models.sample) ? models.sample.slice(0, 3).join(",") : "-", + ]; + }); + const remote = isRecord(result.remote) ? result.remote : {}; + const direct = isRecord(result.direct) ? result.direct : {}; + const smokeResults = Array.isArray(result.results) ? result.results.filter(isRecord) : []; + const lines = [ + `SUB2API UPSTREAM GROUPS ${String(result.mode ?? options.action).toUpperCase()} ok=${result.ok === true}`, + `TARGET ${String(result.targetId ?? options.targetId)} CONFIG ${configPath}`, + ...table(["ACCOUNT", "PROTOCOL", "SECRET", "FINGERPRINT", "MODELS", "COUNT", "SAMPLE"], rows), + `ISOLATION codexPoolMembership=false codexSettingsApplied=false valuesPrinted=false`, + ]; + if (Object.keys(remote).length > 0) lines.push(`REMOTE groups=${Array.isArray(remote.groups) ? remote.groups.length : 0} ok=${remote.ok === true}`); + if (Object.keys(direct).length > 0) lines.push(`DIRECT ok=${direct.ok === true}`); + if (smokeResults.length > 0) { + lines.push(...table( + ["SMOKE_ACCOUNT", "MODEL", "STATUS", "REQUEST_ID", "RESULT"], + smokeResults.map((item) => [ + String(item.account ?? "-"), + String(item.model ?? "-"), + String(item.status ?? "-"), + String(item.requestId ?? "-"), + item.ok === true ? "ok" : String(item.error ?? "failed"), + ]), + )); + } + if (typeof result.next === "string") lines.push(`NEXT ${result.next}`); + return { + ok: result.ok === true, + command: `platform-infra sub2api upstream-groups ${options.action}`, + renderedText: `${lines.join("\n")}\n`, + contentType: "text/plain", + projection: result, + }; +} + +function confirmCommand(options: CliOptions): string { + return `bun scripts/cli.ts platform-infra sub2api upstream-groups apply --target ${options.targetId}${options.groupId ? ` --group ${options.groupId}` : ""} --confirm`; +} + +function ensureConsumerKey(path: string): string { + if (!existsSync(path)) { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, `sk-unidesk-upstream-${randomBytes(32).toString("base64url")}\n`, { mode: 0o600 }); + chmodSync(path, 0o600); + } + return readSecret(path); +} + +function readSecret(path: string): string { + const value = readFileSync(path, "utf8").trim(); + if (!value) throw new Error(`Secret source is empty: ${path}`); + return value; +} + +function secretSummary(path: string): Record { + if (!existsSync(path)) return { sourceRef: path, present: false, bytes: 0, fingerprint: null, valuesPrinted: false }; + const stat = statSync(path); + const value = readSecret(path); + return { sourceRef: path, present: true, bytes: stat.size, mode: (stat.mode & 0o777).toString(8).padStart(4, "0"), fingerprint: fingerprint(value), valuesPrinted: false }; +} + +function fingerprint(value: string): string { + return `sha256:${createHash("sha256").update(value).digest("hex").slice(0, 12)}`; +} + +function modelIds(value: unknown): string[] { + const root = isRecord(value) ? value : {}; + const data = Array.isArray(root.data) ? root.data : Array.isArray(root.models) ? root.models : []; + return [...new Set(data.map((item) => isRecord(item) ? item.id ?? item.name : null).filter((item): item is string => typeof item === "string" && item.length > 0))].sort(); +} + +function record(value: unknown, key: string): Record { + if (!isRecord(value)) throw new Error(`${key} must be an object`); + return value; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function text(value: unknown, key: string): string { + if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${key} must be a non-empty string`); + return value.trim(); +} + +function identifier(value: unknown, key: string): string { + const result = text(value, key); + if (!/^[a-z0-9][a-z0-9-]{1,62}$/u.test(result)) throw new Error(`${key} must be a lowercase identifier`); + return result; +} + +function absolutePath(value: unknown, key: string): string { + const result = text(value, key); + if (!result.startsWith("/") || /[\r\n]/u.test(result)) throw new Error(`${key} must be an absolute path`); + return result; +} + +function pathValue(value: unknown, key: string): string { + const result = text(value, key); + if (!result.startsWith("/") || result.includes("..")) throw new Error(`${key} must be an absolute URL path`); + return result; +} + +function boundedInteger(value: unknown, key: string, min: number, max: number): number { + if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) throw new Error(`${key} must be an integer from ${min} to ${max}`); + return value as number; +} + +function table(headers: string[], rows: string[][]): string[] { + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0))); + const renderRow = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd(); + return [renderRow(headers), ...rows.map(renderRow)]; +} diff --git a/scripts/src/platform-infra/options.ts b/scripts/src/platform-infra/options.ts index 88adb79a..93de6ee3 100644 --- a/scripts/src/platform-infra/options.ts +++ b/scripts/src/platform-infra/options.ts @@ -147,6 +147,10 @@ export async function runPlatformInfraCommand(config: UniDeskConfig, args: strin const { runCodexPoolCommand } = await import("../platform-infra-sub2api-codex"); return await runCodexPoolCommand(config, args.slice(2)); } + if (action === "upstream-groups") { + const { runSub2ApiUpstreamGroupsCommand } = await import("../platform-infra-sub2api-upstream-groups"); + return await runSub2ApiUpstreamGroupsCommand(config, args.slice(2)); + } return unsupported(args); } From 8a23eb12900bd08acc8bae9ea9b250e56cf3c721 Mon Sep 17 00:00:00 2001 From: pikastech Date: Thu, 23 Jul 2026 06:03:29 +0200 Subject: [PATCH 9/9] fix: retire HWLAB FRPC through local k3s delivery --- config/secrets-distribution.yaml | 78 ++++++++++--- ...60308-cicd-yaml-first-target-governance.md | 10 +- .../hwlab/runtime-gitops-postprocess.mjs | 109 +++++++++++++----- .../hwlab/runtime-gitops-postprocess.test.ts | 78 ++++++++++++- scripts/src/secrets.ts | 78 +++++++++++-- 5 files changed, 294 insertions(+), 59 deletions(-) diff --git a/config/secrets-distribution.yaml b/config/secrets-distribution.yaml index f203ce57..9f669b1f 100644 --- a/config/secrets-distribution.yaml +++ b/config/secrets-distribution.yaml @@ -255,12 +255,18 @@ sources: targets: - id: platform-infra-g14 - route: G14:k3s + execution: + executionPlane: route + route: G14:k3s + timeoutSeconds: 60 namespace: platform-infra scope: platform-infra enabled: true - id: sub2rank-nc01 - route: NC01:k3s + execution: + executionPlane: local-k3s + kubeconfig: /etc/rancher/k3s/k3s.yaml + timeoutSeconds: 60 namespace: platform-infra scope: sub2rank enabled: true @@ -270,7 +276,10 @@ targets: timeoutSeconds: 180 pollIntervalSeconds: 5 - id: sub2rank-development-nc01 - route: NC01:k3s + execution: + executionPlane: local-k3s + kubeconfig: /etc/rancher/k3s/k3s.yaml + timeoutSeconds: 60 namespace: platform-infra-development scope: sub2rank-development enabled: true @@ -281,22 +290,34 @@ targets: timeoutSeconds: 180 pollIntervalSeconds: 5 - id: hwlab-jd01-v03 - route: JD01:k3s + execution: + executionPlane: route + route: JD01:k3s + timeoutSeconds: 60 namespace: hwlab-v03 scope: hwlab enabled: true - id: hwlab-nc01-v03 - route: NC01:k3s + execution: + executionPlane: local-k3s + kubeconfig: /etc/rancher/k3s/k3s.yaml + timeoutSeconds: 60 namespace: hwlab-v03 scope: hwlab enabled: true - id: hwlab-nc01-production - route: NC01:k3s + execution: + executionPlane: local-k3s + kubeconfig: /etc/rancher/k3s/k3s.yaml + timeoutSeconds: 60 namespace: hwlab-production scope: hwlab enabled: true - id: selfmedia-nc01 - route: NC01:k3s + execution: + executionPlane: local-k3s + kubeconfig: /etc/rancher/k3s/k3s.yaml + timeoutSeconds: 60 namespace: selfmedia scope: selfmedia enabled: true @@ -306,7 +327,10 @@ targets: timeoutSeconds: 180 pollIntervalSeconds: 5 - id: selfmedia-production-nc01 - route: NC01:k3s + execution: + executionPlane: local-k3s + kubeconfig: /etc/rancher/k3s/k3s.yaml + timeoutSeconds: 60 namespace: selfmedia-production scope: selfmedia-production enabled: true @@ -316,7 +340,10 @@ targets: timeoutSeconds: 180 pollIntervalSeconds: 5 - id: pikaoa-nc01 - route: NC01:k3s + execution: + executionPlane: local-k3s + kubeconfig: /etc/rancher/k3s/k3s.yaml + timeoutSeconds: 60 namespace: pikaoa scope: pikaoa enabled: true @@ -326,7 +353,10 @@ targets: timeoutSeconds: 180 pollIntervalSeconds: 5 - id: pikaoa-dev-nc01 - route: NC01:k3s + execution: + executionPlane: local-k3s + kubeconfig: /etc/rancher/k3s/k3s.yaml + timeoutSeconds: 60 namespace: pikaoa-dev scope: pikaoa-dev enabled: true @@ -336,7 +366,10 @@ targets: timeoutSeconds: 180 pollIntervalSeconds: 5 - id: superapi-development-nc01 - route: NC01:k3s + execution: + executionPlane: local-k3s + kubeconfig: /etc/rancher/k3s/k3s.yaml + timeoutSeconds: 60 namespace: superapi-development scope: superapi-development enabled: true @@ -349,7 +382,10 @@ targets: timeoutSeconds: 180 pollIntervalSeconds: 5 - id: superapi-production-nc01 - route: NC01:k3s + execution: + executionPlane: local-k3s + kubeconfig: /etc/rancher/k3s/k3s.yaml + timeoutSeconds: 60 namespace: superapi-production scope: superapi-production enabled: true @@ -362,12 +398,18 @@ targets: timeoutSeconds: 180 pollIntervalSeconds: 5 - id: temporal-nc01 - route: NC01:k3s + execution: + executionPlane: local-k3s + kubeconfig: /etc/rancher/k3s/k3s.yaml + timeoutSeconds: 60 namespace: temporal scope: temporal enabled: true - id: public-edge-delivery-nc01 - route: NC01:k3s + execution: + executionPlane: local-k3s + kubeconfig: /etc/rancher/k3s/k3s.yaml + timeoutSeconds: 60 namespace: devops-infra scope: public-edge-delivery enabled: true @@ -620,6 +662,14 @@ kubernetesSecrets: - sourceRef: hwlab/nc01-v03-opencode.env sourceKey: OPENCODE_SERVER_PASSWORD targetKey: password + - name: hwlab-nc01-v03-hwpod-node-auth + targetId: hwlab-nc01-v03 + secretName: hwlab-v03-hwpod-node-auth + type: Opaque + data: + - sourceRef: hwlab/nc01-v03-hwpod-node.env + sourceKey: HWLAB_HWPOD_NODE_WS_TOKEN + targetKey: token - name: hwlab-nc01-production-opencode-server-auth targetId: hwlab-nc01-production secretName: hwlab-production-opencode-server-auth diff --git a/project-management/PJ2026-01/specs/PJ2026-01060308-cicd-yaml-first-target-governance.md b/project-management/PJ2026-01/specs/PJ2026-01060308-cicd-yaml-first-target-governance.md index f1d1c149..1f2329e0 100644 --- a/project-management/PJ2026-01/specs/PJ2026-01060308-cicd-yaml-first-target-governance.md +++ b/project-management/PJ2026-01/specs/PJ2026-01060308-cicd-yaml-first-target-governance.md @@ -196,14 +196,20 @@ CI/CD、deploy、artifact-registry、HWLAB node/lane、AgentRun 和 platform-inf 代码不得在主路径中新增隐藏默认 `G14`、`D601`、`v02`、`v03`、namespace、route、registry endpoint 或工作目录。缺少配置时应报出 YAML 路径、字段名和下一步配置入口。 -CI/CD target 的只读采集执行面必须由 owning YAML 显式声明: +所有 Kubernetes target 的执行面必须由 owning YAML 显式声明,并同时用于 +`plan`、`status`、`sync`、发布计划和发送前重算: - `local-k3s` 表示控制 CLI 与目标 k3s 位于同一 host,必须直接使用 YAML 声明的 kubeconfig 执行一次本地目标侧采集; - `route` 表示目标位于异地,必须通过 YAML 声明的 route 执行一次远端目标侧采集; - `local-k3s` 禁止进入 SSH broker、Provider 数据通道或同机回连路径; - 代码不得按 `NC01`、hostname、公网 IP 或 route 字符串猜测本地性; -- 状态输出必须披露实际 execution plane,同时保留单次目标侧采集计数。 +- owning target 必须声明有界执行 timeout; +- `local-k3s` target 必须声明绝对 kubeconfig,且不得同时声明 route; +- `route` target 必须声明 route,且不得同时声明 kubeconfig; +- plan/status 输出必须披露实际 execution plane 和配置路径; +- Secret target 的写操作与只读操作必须复用同一 execution plane,禁止 sync + 经 route、status 经 local-k3s 的路径分叉。 ### 6.2 OPS-TARGET-REQ-002 configRef 解析 diff --git a/scripts/native/hwlab/runtime-gitops-postprocess.mjs b/scripts/native/hwlab/runtime-gitops-postprocess.mjs index 1b65077c..30c05561 100644 --- a/scripts/native/hwlab/runtime-gitops-postprocess.mjs +++ b/scripts/native/hwlab/runtime-gitops-postprocess.mjs @@ -31,11 +31,13 @@ function postprocessRuntimeGitops() { if (changed === "deleted") deletedFiles.push(path.relative(repoDir, file)); if (changed === "changed") changedFiles.push(path.relative(repoDir, file)); } - const kustomizationChanged = prometheusOperatorDisabled ? pruneMissingKustomizationResources() : false; + const publicExposure = patchPublicExposure(); + const kustomizationChanged = reconcileKustomizationResources(); const codeAgentRuntimeChanged = patchCodeAgentRuntimeWorkloads(); return { observabilityPrometheusOperator: overlay?.observability?.prometheusOperator ?? null, observabilityWorkloadsChanged: changedFiles.length > 0 || deletedFiles.length > 0, + publicExposure, kustomizationChanged, codeAgentRuntimeChanged, changedFiles, @@ -43,6 +45,65 @@ function postprocessRuntimeGitops() { }; } +function patchPublicExposure() { + const exposure = overlay?.publicExposure; + const frpcFile = path.join(runtimeDir, "node-frpc.yaml"); + const nodePortFile = path.join(runtimeDir, "node-public-service.yaml"); + if (!exposure?.enabled) { + const frpcRemoved = removeFile(frpcFile); + const nodePortRemoved = removeFile(nodePortFile); + return { configured: false, changed: frpcRemoved || nodePortRemoved }; + } + if (exposure.mode !== "node-port-shared-edge") { + return { configured: true, mode: exposure.mode, changed: false }; + } + if (typeof overlay.environment !== "string" || overlay.environment.length === 0) { + throw new Error("node-port-shared-edge public exposure requires overlay.environment"); + } + const service = { + apiVersion: "v1", + kind: "Service", + metadata: { + name: String(exposure.serviceName), + namespace: String(overlay.runtimeNamespace), + labels: { + "app.kubernetes.io/name": String(exposure.serviceName), + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/environment": overlay.environment, + }, + }, + spec: { + type: "NodePort", + selector: exposure.selector, + ports: [{ + name: "http", + protocol: "TCP", + port: Number(exposure.port), + targetPort: Number(exposure.targetPort), + nodePort: Number(exposure.nodePort), + }], + }, + }; + const rendered = YAML.stringify(service); + const previous = existsSync(nodePortFile) ? readFileSync(nodePortFile, "utf8") : ""; + if (previous !== rendered) writeFileSync(nodePortFile, rendered, "utf8"); + const frpcRemoved = removeFile(frpcFile); + return { + configured: true, + mode: exposure.mode, + changed: previous !== rendered || frpcRemoved, + serviceName: exposure.serviceName, + nodePort: exposure.nodePort, + frpcRemoved, + }; +} + +function removeFile(file) { + if (!existsSync(file)) return false; + unlinkSync(file); + return true; +} + function patchCodeAgentRuntimeWorkloads() { const runtime = overlay?.codeAgentRuntime; if (!runtime?.enabled) return false; @@ -225,35 +286,24 @@ function stripPrometheusOperatorResourcesFromJsonValue(value) { return { changed: false, value }; } -function pruneMissingKustomizationResources() { +function reconcileKustomizationResources() { const file = path.join(runtimeDir, "kustomization.yaml"); if (!existsSync(file)) return false; - const original = readFileSync(file, "utf8"); - const lines = original.split(/\n/u); - const next = []; - let changed = false; - let inResources = false; - let resourcesIndent = 0; - for (const line of lines) { - const resourcesMatch = line.match(/^(\s*)resources:\s*(?:#.*)?$/u); - if (resourcesMatch) { - inResources = true; - resourcesIndent = resourcesMatch[1].length; - next.push(line); - continue; - } - if (inResources && line.trim() !== "" && !line.match(/^\s*-/u) && leadingSpaces(line) <= resourcesIndent) inResources = false; - if (inResources) { - const item = line.match(/^(\s*)-\s+["']?([^"'#\s]+)["']?\s*(?:#.*)?$/u); - if (item && !existsSync(path.resolve(runtimeDir, item[2]))) { - changed = true; - continue; - } - } - next.push(line); + const document = YAML.parse(readFileSync(file, "utf8")) ?? {}; + const resources = Array.isArray(document.resources) ? document.resources.map(String) : []; + const exposureMode = overlay?.publicExposure?.enabled ? overlay.publicExposure.mode : null; + const next = resources.filter((resource) => { + if (resource === "node-frpc.yaml" && exposureMode !== "pk01-caddy-frp") return false; + if (resource === "node-public-service.yaml" && exposureMode !== "node-port-shared-edge") return false; + return !/\.ya?ml$/u.test(resource) || existsSync(path.resolve(runtimeDir, resource)); + }); + if (exposureMode === "node-port-shared-edge" && !next.includes("node-public-service.yaml")) { + next.push("node-public-service.yaml"); } - if (changed) writeFileSync(file, next.join("\n"), "utf8"); - return changed; + if (JSON.stringify(next) === JSON.stringify(resources)) return false; + document.resources = next; + writeFileSync(file, YAML.stringify(document), "utf8"); + return true; } function splitYamlDocuments(text) { @@ -307,11 +357,6 @@ function serializeStructuredDocuments(documents) { return `${documents.values.map((document) => YAML.stringify(document).trimEnd()).join("\n---\n")}\n`; } -function leadingSpaces(value) { - const match = value.match(/^ */u); - return match ? match[0].length : 0; -} - function listYamlFiles(root) { const out = []; for (const name of readdirSync(root)) { diff --git a/scripts/native/hwlab/runtime-gitops-postprocess.test.ts b/scripts/native/hwlab/runtime-gitops-postprocess.test.ts index a04e00fb..9294b816 100644 --- a/scripts/native/hwlab/runtime-gitops-postprocess.test.ts +++ b/scripts/native/hwlab/runtime-gitops-postprocess.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -80,6 +80,78 @@ test("patches YAML-owned Code Agent and Kafka runtime settings", () => { expect(snapshot(runtimeDir)).toEqual(afterFirstRun); }); +test("replaces retired FRPC resources with the YAML-owned NodePort service", () => { + const root = mkdtempSync(join(tmpdir(), "runtime-gitops-postprocess-node-port-")); + roots.push(root); + const runtimeDir = join(root, "runtime"); + mkdirSync(runtimeDir); + writeFileSync(join(root, "overlay.json"), JSON.stringify({ + runtimePath: "runtime", + runtimeNamespace: "hwlab-v03", + environment: "development", + publicExposure: { + enabled: true, + mode: "node-port-shared-edge", + serviceName: "hwlab-cloud-web-public", + selector: { "app.kubernetes.io/name": "hwlab-cloud-web" }, + port: 80, + targetPort: 8080, + nodePort: 32009, + }, + })); + writeFileSync(join(runtimeDir, "node-frpc.yaml"), "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: hwlab-v03-frpc\n"); + writeFileSync(join(runtimeDir, "workloads.yaml"), deploymentYaml("hwlab-cloud-web")); + writeFileSync(join(runtimeDir, "kustomization.yaml"), "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - workloads.yaml\n - node-frpc.yaml\n"); + + const first = run(root); + expect(first.stderr).toContain('"frpcRemoved":true'); + expect(existsSync(join(runtimeDir, "node-frpc.yaml"))).toBe(false); + const service = Bun.YAML.parse(readFileSync(join(runtimeDir, "node-public-service.yaml"), "utf8")) as any; + expect(service).toMatchObject({ + kind: "Service", + metadata: { name: "hwlab-cloud-web-public", namespace: "hwlab-v03" }, + spec: { type: "NodePort", ports: [{ port: 80, targetPort: 8080, nodePort: 32009 }] }, + }); + const kustomization = Bun.YAML.parse(readFileSync(join(runtimeDir, "kustomization.yaml"), "utf8")) as any; + expect(kustomization.resources).toEqual(["workloads.yaml", "node-public-service.yaml"]); + + const afterFirstRun = snapshotFiles(runtimeDir, ["workloads.yaml", "node-public-service.yaml", "kustomization.yaml"]); + const second = run(root); + expect(second.stderr).toContain('"changed":false'); + expect(snapshotFiles(runtimeDir, ["workloads.yaml", "node-public-service.yaml", "kustomization.yaml"])).toEqual(afterFirstRun); +}); + +test("removes every retired public exposure resource when exposure is disabled", () => { + const root = mkdtempSync(join(tmpdir(), "runtime-gitops-postprocess-no-public-")); + roots.push(root); + const runtimeDir = join(root, "runtime"); + mkdirSync(runtimeDir); + writeFileSync(join(root, "overlay.json"), JSON.stringify({ + runtimePath: "runtime", + publicExposure: { enabled: false }, + })); + writeFileSync(join(runtimeDir, "node-frpc.yaml"), "kind: Deployment\n"); + writeFileSync(join(runtimeDir, "node-public-service.yaml"), "kind: Service\n"); + writeFileSync(join(runtimeDir, "kustomization.yaml"), "resources:\n - node-frpc.yaml\n - node-public-service.yaml\n"); + + run(root); + + expect(existsSync(join(runtimeDir, "node-frpc.yaml"))).toBe(false); + expect(existsSync(join(runtimeDir, "node-public-service.yaml"))).toBe(false); + const kustomization = Bun.YAML.parse(readFileSync(join(runtimeDir, "kustomization.yaml"), "utf8")) as any; + expect(kustomization.resources).toEqual([]); +}); + +function run(root: string) { + const result = spawnSync(process.execPath, [script], { + cwd: root, + env: { ...process.env, UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE: join(root, "overlay.json") }, + encoding: "utf8", + }); + if (result.status !== 0) throw new Error(result.stderr); + return result; +} + function overlay(runtimePath: string) { return { runtimePath, @@ -147,3 +219,7 @@ function envSecret(workload: any, name: string) { function snapshot(runtimeDir: string) { return ["cloud-api.yaml", "cloud-web.yaml", "list.yaml", "json-workload.yaml"].map((name) => readFileSync(join(runtimeDir, name), "utf8")); } + +function snapshotFiles(runtimeDir: string, names: string[]) { + return names.map((name) => readFileSync(join(runtimeDir, name), "utf8")); +} diff --git a/scripts/src/secrets.ts b/scripts/src/secrets.ts index 04a40442..b22aeeac 100644 --- a/scripts/src/secrets.ts +++ b/scripts/src/secrets.ts @@ -8,6 +8,7 @@ import { startJob } from "./jobs"; import type { SshCaptureResult } from "./ssh"; import { capture, fingerprintValues, parseJsonOutput } from "./platform-infra-public-service"; import { + captureLocal, yamlBooleanField, yamlIntegerArrayField, yamlIntegerField, @@ -70,9 +71,23 @@ interface RawSourceFileConfig { type SourceFileConfig = EnvSourceFileConfig | RawSourceFileConfig; +type DistributionTargetExecution = { + executionPlane: "local-k3s"; + route: null; + kubeconfig: string; + timeoutSeconds: number; + configPath: string; +} | { + executionPlane: "route"; + route: string; + kubeconfig: null; + timeoutSeconds: number; + configPath: string; +}; + interface DistributionTarget { id: string; - route: string; + execution: DistributionTargetExecution; namespace: string; scope: string; enabled: boolean; @@ -235,8 +250,9 @@ function plan(options: SecretsOptions): Record { action: "secrets-plan", mutation: false, config: configSummary(distribution, options), - localSources: sourceSummary(sources), - desiredSecrets: desired.map(desiredSecretSummary), + ...(options.full + ? { localSources: sourceSummary(sources), desiredSecrets: desired.map(desiredSecretSummary) } + : { localSources: compactSourceSummary(sources), desiredSecrets: desired.map(compactDesiredSecretSummary) }), policy: { sourceAuthority: "local YAML-declared managed and external sourceRef files", runtimeReverseEngineering: false, @@ -304,8 +320,9 @@ async function status(config: UniDeskConfig, options: SecretsOptions): Promise, path: string): } function parseTarget(record: Record, path: string): DistributionTarget { + const executionPath = `${path}.execution`; + const execution = objectField(record, "execution", path); + const executionPlane = stringField(execution, "executionPlane", executionPath); + if (executionPlane !== "local-k3s" && executionPlane !== "route") { + throw new Error(`${executionPath}.executionPlane must be local-k3s or route`); + } + const executionTimeoutSeconds = integerField(execution, "timeoutSeconds", executionPath); + if (executionTimeoutSeconds <= 0) throw new Error(`${executionPath}.timeoutSeconds must be positive`); + let targetExecution: DistributionTargetExecution; + if (executionPlane === "local-k3s") { + if (execution.route !== undefined) throw new Error(`${executionPath}.route is only valid for executionPlane=route`); + const kubeconfig = stringField(execution, "kubeconfig", executionPath); + if (!isAbsolute(kubeconfig)) throw new Error(`${executionPath}.kubeconfig must be an absolute path`); + targetExecution = { executionPlane, route: null, kubeconfig, timeoutSeconds: executionTimeoutSeconds, configPath: executionPath }; + } else { + if (execution.kubeconfig !== undefined) throw new Error(`${executionPath}.kubeconfig is only valid for executionPlane=local-k3s`); + targetExecution = { + executionPlane, + route: stringField(execution, "route", executionPath), + kubeconfig: null, + timeoutSeconds: executionTimeoutSeconds, + configPath: executionPath, + }; + } const rollout = record.consumerRollout === undefined ? null : objectField(record, "consumerRollout", path); const deployments = rollout === null ? [] @@ -404,7 +445,7 @@ function parseTarget(record: Record, path: string): Distributio } return { id: simpleId(stringField(record, "id", path), `${path}.id`), - route: stringField(record, "route", path), + execution: targetExecution, namespace: kubernetesNameField(record, "namespace", path), scope: simpleId(stringField(record, "scope", path), `${path}.scope`), enabled: booleanField(record, "enabled", path), @@ -627,7 +668,7 @@ function selectedSourceRefs(config: SecretDistributionConfig, options: SecretsOp async function applyTargetSecrets(config: UniDeskConfig, target: DistributionTarget, secrets: DesiredSecret[], options: SecretsOptions): Promise> { if (secrets.length === 0) return { ok: true, target: targetSummary(target), mode: "skipped-no-secrets" }; const yaml = renderSecretManifest(target, secrets); - const result = await capture(config, target.route, ["sh"], applySecretScript(target, secrets, yaml)); + const result = await captureTarget(config, target, applySecretScript(target, secrets, yaml)); const parsed = parseJsonOutput(result.stdout); const applied = result.exitCode === 0 && boolField(parsed, "ok", false); const consumerRolloutStatus = applied && target.consumerRollout !== null @@ -646,7 +687,7 @@ async function applyTargetSecrets(config: UniDeskConfig, target: DistributionTar } async function statusTargetSecrets(config: UniDeskConfig, target: DistributionTarget, secrets: DesiredSecret[], options: SecretsOptions): Promise> { - const result = await capture(config, target.route, ["sh"], statusSecretScript(target, secrets)); + const result = await captureTarget(config, target, statusSecretScript(target, secrets)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), @@ -755,7 +796,7 @@ async function waitForConsumerRollout(config: UniDeskConfig, target: Distributio let lastRemote: Record | null = null; while (true) { attempts += 1; - const result = await capture(config, target.route, ["sh"], consumerRolloutStatusScript(target)); + const result = await captureTarget(config, target, consumerRolloutStatusScript(target)); const parsed = parseJsonOutput(result.stdout); lastSummary = parsed; lastRemote = secretCaptureSummary(result); @@ -977,7 +1018,24 @@ function remoteSecretSummary(secret: DesiredSecret): Record { } function targetSummary(target: DistributionTarget): Record { - return { id: target.id, route: target.route, namespace: target.namespace, scope: target.scope, consumerRollout: target.consumerRollout }; + return { + id: target.id, + execution: target.execution, + namespace: target.namespace, + scope: target.scope, + consumerRollout: target.consumerRollout, + }; +} + +async function captureTarget(config: UniDeskConfig, target: DistributionTarget, script: string): Promise { + const timeoutMs = target.execution.timeoutSeconds * 1000; + if (target.execution.executionPlane === "local-k3s") { + return await captureLocal(["sh"], script, { + runtimeTimeoutMs: timeoutMs, + env: { ...process.env, KUBECONFIG: target.execution.kubeconfig }, + }); + } + return await capture(config, target.execution.route, ["sh"], script, { runtimeTimeoutMs: timeoutMs }); } function readOptionValue(args: string[], index: number, option: string): string {