From 3d807062ed724d192b78a8fd93e5315fe6da43cb Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 20:18:13 +0200 Subject: [PATCH 01/43] =?UTF-8?q?docs:=20=E5=BB=BA=E7=AB=8B=20PikaOA=20?= =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=8A=9E=E5=85=AC=E5=B9=B3=E5=8F=B0=E8=A7=84?= =?UTF-8?q?=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/pikaoa-enterprise-platform.md | 45 ++ .../PJ2026-03/specs/PJ2026-03-pikaoa.md | 479 ++++++++++++++++++ 2 files changed, 524 insertions(+) create mode 100644 docs/MDTODO/pikaoa-enterprise-platform.md create mode 100644 project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md new file mode 100644 index 00000000..0189dd72 --- /dev/null +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -0,0 +1,45 @@ +# PikaOA 企业办公平台 + +- 规格真相:`project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md`。 +- 产品仓库:`pikaInc/pikaoa`,固定主工作区为 `/root/pikaoa`。 +- 目标运行面:owning YAML 选中的 NC01 Kubernetes 独立 namespace。 +- 公网原入口:`https://oa.pikapython.com`。 +- 数据真相:PK01 host PostgreSQL 中的独立数据库与角色。 +- 执行证据:GitHub issue/PR、任务报告、PaC/PipelineRun/GitOps/Argo 和 Web 原入口验证。 + + +## R1 [in_progress] + +依据 PJ2026-03 建设企业级可扩展 PikaOA:在 /root/pikaoa 建立 pikaInc 仓库,采用 HWLAB v0.3 同族 Vue 技术栈和 Go 后端;CLI-first 完成员工/RBAC、甲方分类、合同多版本、发票关联与废弃、审计、OTel/Prometheus,再开发 Web;通过独立 K8s namespace、PK01 host PostgreSQL、YAML-first、PaC bootstrap、自动 CI/CD 和 https://oa.pikapython.com 原入口交付,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1_Task_Report.md)。 + +### R1.1 + +调研成熟开源 OA、ERP、合同、发票、Go 微服务与 Vue 组件方案,形成采用/复用/不采用结论,依赖 R1 规格,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.1_Task_Report.md)。 + +### R1.2 + +建立 pikaInc/pikaoa 私有仓库、/root/pikaoa 固定 master 和独立任务 worktree,依赖 R1.1 的技术选型,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.2_Task_Report.md)。 + +### R1.3 + +实现企业模块内核、Go API/Worker、PostgreSQL 迁移、员工/RBAC、伙伴分类、合同版本、发票废弃、审计、OTel/Prometheus 与 CLI,并通过 CLI 后端验收,依赖 R1.1-R1.2,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3_Task_Report.md)。 + +### R1.4 + +在 R1.3 CLI 后端验收通过后实现 HWLAB v0.3 同族 Vue 工作台、响应式页面与同路径业务流,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.4_Task_Report.md)。 + +### R1.5 + +在 UniDesk 中声明 PK01 PostgreSQL、Secret、NC01 独立 namespace、Gitea/PaC、GitOps、OTel/Prometheus 和 PK01 公网暴露,并改进可复用 bootstrap 工具,依赖 R1.1-R1.2,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5_Task_Report.md)。 + +### R1.6 + +审核并合并双仓 PR,执行首次受控 PaC bootstrap,由正常 source PR merge 自动发布,依赖 R1.3-R1.5,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6_Task_Report.md)。 + +### R1.7 + +通过 CLI、OTel、Prometheus 和 https://oa.pikapython.com 桌面/移动原入口验证完整主路径,依赖 R1.6,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.7_Task_Report.md)。 + +### R1.8 + +完成 issue、MDTODO、阶段报告、post-task、已吸收 worktree 与分支清理,依赖 R1.7,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.8_Task_Report.md)。 diff --git a/project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md b/project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md new file mode 100644 index 00000000..acda64e5 --- /dev/null +++ b/project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md @@ -0,0 +1,479 @@ +# PJ2026-03 PikaOA 企业办公平台总规格 + +## 修改历史 + +| 版本 | 对应 commit id | 更新日期 | 变更说明 | +| --- | --- | --- | --- | +| v0.1 | 待本版本提交 | 2026-07-13 | 创建企业办公平台预期终态、模块架构和首批合同与发票能力规格。 | + +修改历史只记录规格语义变更,不记录实现进度、阶段基线或一次性证据。 + +## 正文 + +## PJ2026-03 PikaOA 企业办公平台总项目需求规格 + +## 1. 文档控制 + +| 字段 | 内容 | +| --- | --- | +| 编号 | PJ2026-03 | +| 短名 | PikaOA | +| 层级 | L0 总项目 | +| 规格状态 | 已生效 | +| 需求规格模板 | [ISO/IEC/IEEE 29148 需求规格模板](../../templates/iso-iec-ieee-29148-requirements-spec-template.md) | +| 规格治理索引 | 本文第 9 章 | +| 实现引用版本 | v0.1 | + +本文采用 ISO/IEC/IEEE 29148 需求规格模板的项目裁剪版。 + +本文只定义: + +- 企业办公平台的预期终态; +- 稳定业务边界; +- 可扩展模块架构; +- 首批合同与发票能力; +- 可重复执行的验收契约。 + +当前实现状态、阶段差距、提交、PR、作业、日志和截图进入阶段报告、任务报告或 GitHub issue,不进入本规格正文。 + +## 2. 目的和范围 + +### 2.1 项目使命 + +PikaOA 的使命是为公司提供长期演进的企业办公平台: + +- 统一承载员工、组织、业务伙伴、文档、流程、审计和业务模块; +- 让合同、发票等首批模块形成可追溯、可检索、可关联的业务事实; +- 让后续审批、采购、报销、项目、人事、资产和知识管理模块复用同一平台能力; +- 通过稳定模块边界避免每增加一个功能就复制账号、权限、审计、附件和部署逻辑; +- 在 Kubernetes 中以可审计、可重复、自动交付的方式运行。 + +### 2.2 预期终态 + +PikaOA 终态必须同时具备: + +- 企业平台内核: + - 统一员工身份、组织成员、角色、权限和会话; + - 统一业务伙伴、分类、标签、联系人和归档状态; + - 统一附件、审计、模块注册、搜索和分页契约; + - 统一 API 错误、字段校验和幂等写入语义。 +- CLI-first 交付: + - 每个后端业务用例先提供稳定 CLI 入口; + - CLI 与 Web 复用同一 HTTP API、身份、权限和业务语义; + - 后端、数据库、审计和可观测性通过 CLI 原入口验收后,才实现对应前端工作流; + - 自动化和人工排障不依赖浏览器才能完成核心业务闭环。 +- 可扩展业务模块: + - 每个模块拥有独立领域模型、应用服务、持久化迁移、权限声明、HTTP API 和前端路由; + - 模块通过稳定端口、领域事件和对象引用协作; + - 新模块不得复制用户、组织、业务伙伴、审计或附件真相; + - 成熟模块可以沿既有端口从业务 API 中提取为独立微服务。 +- 首批可用能力: + - 合同管理支持多版本、当前版本、版本历史和关联业务伙伴; + - 发票管理支持合同关联、合同版本关联、正常与废弃状态以及废弃原因; + - 员工管理支持管理员和普通员工两类初始角色; + - 按不同甲方及其分类筛选合同、发票和统计摘要。 +- 企业交付能力: + - Web、业务 API 和异步 Worker 是可独立扩缩和发布的工作负载; + - PostgreSQL 是业务数据真相; + - 文件内容通过可替换存储端口管理,元数据与哈希进入业务数据; + - 配置、Secret、数据库绑定、运行目标和公网暴露均由 owning YAML 管理; + - GitHub 合并触发自动 CI/CD、GitOps 和运行面收敛。 +- 首版可观测能力: + - Web、CLI、API、Worker 和 CI/CD 统一传播 W3C trace context; + - Go 服务从第一版接入 OpenTelemetry traces、metrics 和结构化日志关联; + - Prometheus 从第一版采集服务、HTTP、数据库、领域用例、outbox 和运行时指标; + - trace、metric 和日志均携带稳定 service、module、operation 和结果属性; + - 可观测后端不可用时形成显式非阻塞 warning,不阻塞已经成功的用户业务。 + +### 2.3 范围内 + +- 员工账户、启停、角色、权限和登录会话。 +- 业务伙伴、甲方分类、联系人、标签和归档。 +- 合同主记录、多版本、附件、状态、检索和关联。 +- 发票主记录、合同/版本关联、附件、废弃标记和检索。 +- 审计记录、领域事件、模块注册和模块级导航。 +- CLI、Web 工作台、HTTP API、健康接口和异步任务入口。 +- OpenTelemetry Collector 接入、Prometheus 指标、结构化日志和 trace/metric 关联。 +- PostgreSQL、Kubernetes namespace、Secret、CI/CD、GitOps 和公网 HTTPS。 + +### 2.4 范围外 + +- 首期自动签章、电子税务局直连、OCR 自动识别、付款和银行流水对账。 +- 首期通用 BPMN 设计器、复杂会签、组织同步和单点登录。 +- 用合同版本覆盖历史版本或物理删除已废弃发票。 +- 为每个业务模块部署独立身份系统、独立业务伙伴表或独立审计实现。 +- 把运行面 Secret、Pod 环境变量或数据库现状反向保存为配置真相。 +- 未经后续规格确认的租约、选主、分布式事务协调器或第二权限体系。 + +## 3. 术语表 + +| 术语 | 定义 | +| --- | --- | +| 员工 | 可以登录 PikaOA 并按角色执行操作的公司成员。 | +| 管理员 | 可以管理员工、角色、业务伙伴分类和全部首批业务模块的员工角色。 | +| 普通员工 | 可以使用被授予模块能力但不能管理系统身份与权限的员工角色。 | +| 业务伙伴 | 与公司发生业务关系的外部组织或个人;甲方是业务伙伴的一种业务角色。 | +| 甲方分类 | 用于组织、筛选和汇总甲方的可维护分类。 | +| 合同主记录 | 表示一份合同长期身份、甲方、编号、状态和当前版本指针的聚合根。 | +| 合同版本 | 合同在某一时点的不可变业务内容快照,具有合同内单调递增的版本号。 | +| 当前版本 | 合同主记录明确指向、供默认查询和新关联使用的合同版本。 | +| 发票废弃 | 保留发票记录并记录废弃人、时间和原因,使其不再计入有效业务结果。 | +| 模块 | 具有独立领域边界、权限、迁移、API、事件和前端入口的可插拔业务能力。 | +| 领域事件 | 模块完成业务事务后写入的结构化事实,用于同进程订阅或异步扩展。 | + +## 4. 系统边界和内部模块 + +### 4.1 外部边界 + +| 边界项 | 内容 | +| --- | --- | +| 外部使用者 | 管理员、普通员工和经批准的自动化调用方。 | +| 外部输入 | 登录凭据、员工信息、业务伙伴信息、合同版本、发票信息、附件和筛选条件。 | +| 受控资源 | 员工身份、业务伙伴、合同、发票、附件、审计记录、数据库和运行配置。 | +| 外部输出 | 工作台、列表、详情、版本历史、关联结果、状态、审计和健康摘要。 | +| 用户接口 | CLI、响应式 Web 与版本化 HTTP API。 | +| 系统边界 | PikaOA 管理内部办公业务事实;不替代税务、签章、银行和外部身份平台。 | + +### 4.2 L1 能力域 + +| 编号 | 短名 | 主责边界 | 下游支撑 | +| --- | --- | --- | --- | +| PJ2026-0301 | 组织权限 | 员工、角色、权限、会话和模块授权。 | 全部业务模块。 | +| PJ2026-0302 | 伙伴主数据 | 业务伙伴、甲方角色、分类、联系人和标签。 | 合同、发票及后续业务模块。 | +| PJ2026-0303 | 合同管理 | 合同身份、多版本、当前版本、状态、附件和历史。 | 发票、审批、项目和归档。 | +| PJ2026-0304 | 发票管理 | 发票、合同关联、合同版本关联、废弃和附件。 | 财务、报销、对账和统计。 | +| PJ2026-0305 | 平台内核 | 模块注册、审计、领域事件、附件端口、搜索和 API 规范。 | 全部业务模块和自动化。 | +| PJ2026-0306 | 平台交付 | Web/API/Worker 运行、数据库、Secret、CI/CD、GitOps 和公网入口。 | 全部产品能力。 | + +### 4.3 模块接入合同 + +每个业务模块必须声明: + +- 唯一模块标识、显示名称和生命周期状态; +- 领域实体、应用用例、数据库迁移和对象引用类型; +- 权限集合及其管理员、普通员工默认授权映射; +- 版本化 HTTP 路由、OpenAPI 描述和稳定错误码; +- 领域事件名称、版本和最小载荷; +- 前端路由、导航项、权限要求和列表/详情入口; +- 审计动作、资源类型和可安全披露的变更摘要; +- 健康、迁移和部署后验证入口。 + +新增模块不得通过修改其他模块私有表完成集成。跨模块读取使用应用端口或稳定对象引用;异步扩展使用事务内写入的领域事件/outbox,不新增第二业务真相。 + +## 5. 目标架构和数据流 + +### 5.1 目标架构图 + +```mermaid +flowchart LR + U[管理员与员工] --> W[Vue Web 工作台] + U --> CLI[PikaOA CLI] + W --> A[Go 业务 API] + CLI --> A + A --> I[组织权限模块] + A --> P[伙伴主数据模块] + A --> C[合同模块] + A --> F[发票模块] + A --> K[平台内核] + I --> DB[(PostgreSQL)] + P --> DB + C --> DB + F --> DB + K --> DB + A --> S[文件存储端口] + A --> O[(事务 Outbox)] + O --> J[Go Worker] + J --> X[后续通知、索引与集成适配器] + W --> OT[OpenTelemetry Collector] + CLI --> OT + A --> OT + J --> OT + A --> PM[Prometheus metrics] + J --> PM + PM --> PR[Prometheus] + OT --> TB[Trace backend] +``` + +部署单元采用三类独立工作负载: + +- Web:使用与 HWLAB v0.3 同族的 Vue 3、Vite、TypeScript、Vue Router、Pinia 和 Tailwind 技术栈; +- API:使用成熟 Go HTTP/服务框架、PostgreSQL 驱动、迁移工具、RBAC 库和 OpenAPI 工具; +- Worker:复用 Go 领域与应用层,消费 PostgreSQL outbox,承载非请求内任务。 + +CLI 是后端业务的第一用户入口: + +- 使用与 Web 相同的公开 HTTP API,不通过数据库直连或内部 manager 绕过业务语义; +- 默认输出适合人工扫描的紧凑文本,机器调用显式请求 JSON; +- 长任务使用提交与短轮询,不保持无界阻塞连接; +- 输出包含请求标识和 traceId,但不显示密码、token、完整数据库连接串或附件正文; +- 前端开发必须以对应 CLI 用例已经通过为前置。 + +业务 API 在首期采用模块化单体内核: + +- 保持单事务、单数据库和低运维复杂度; +- 模块边界与端口从第一版生效; +- Web、API 和 Worker 已是独立工作负载; +- 当容量、团队或故障域需要时,单个模块可以沿既有 API、事件和数据所有权边界提取为独立微服务。 + +### 5.2 业务数据流 + +```mermaid +flowchart TD + R[用户请求] --> G[认证、授权与输入校验] + G --> M[目标模块应用用例] + M --> T[领域规则与事务] + T --> D[(模块拥有的数据表)] + T --> A[(统一审计记录)] + T --> O[(事务 Outbox)] + D --> Q[查询投影与分页] + Q --> V[Web/API 响应] + O --> W[异步 Worker] + W --> E[扩展适配器] +``` + +### 5.3 合同多版本时序 + +```mermaid +sequenceDiagram + participant U as 员工 + participant W as Web + participant A as 合同应用服务 + participant D as PostgreSQL + U->>W: 新建合同或创建新版本 + W->>A: 提交合同身份、甲方和版本内容 + A->>D: 校验权限、甲方和合同内版本号 + A->>D: 事务写入不可变版本、更新当前版本指针和审计/outbox + D-->>A: 返回合同与新版本 + A-->>W: 返回当前版本和完整版本摘要 + W-->>U: 展示详情与版本历史 +``` + +### 5.4 发票废弃与合同关联时序 + +```mermaid +sequenceDiagram + participant U as 员工 + participant W as Web + participant A as 发票应用服务 + participant D as PostgreSQL + U->>W: 登记发票并选择合同/版本 + W->>A: 提交发票与关联标识 + A->>D: 校验合同、版本、甲方一致性并写入发票 + D-->>A: 返回有效发票 + U->>W: 标记发票废弃并填写原因 + W->>A: 提交废弃动作 + A->>D: 原子写入废弃人、时间、原因和审计/outbox + D-->>A: 返回保留的废弃发票 + A-->>W: 展示废弃标识且默认排除有效统计 +``` + +## 6. 全局原子需求 + +### 6.1 PIKAOA-L0-REQ-001 模块化平台内核 + +PikaOA 必须以第 4.3 节的模块接入合同组织业务能力。模块必须拥有清晰数据所有权,复用统一身份、伙伴、附件、审计和事件能力,并能在不改写既有模块私有实现的情况下增加导航、权限、API、迁移和事件订阅。 + +接受标准包括: + +- 模块注册表可以枚举已启用模块及其权限和前端入口; +- 一个示例新增模块可以只通过自身声明与公共端口接入; +- 禁止跨模块直接更新私有表; +- API 与领域事件具有显式版本。 + +### 6.2 PIKAOA-L0-REQ-002 员工与权限 + +系统必须支持员工的创建、编辑、启用、停用、角色分配和登录。管理员与普通员工的初始账号由 owning YAML 的 Secret `sourceRef`/`targetKey` 注入,不在 Git、镜像、日志或前端代码中保存密码。 + +接受标准包括: + +- 管理员可以管理员工和角色; +- 普通员工不能管理员工、角色或 Secret; +- 停用员工不能建立新会话; +- 所有业务写入记录实际员工身份。 + +### 6.3 PIKAOA-L0-REQ-003 业务伙伴和甲方分类 + +系统必须维护业务伙伴的唯一身份、名称、统一社会信用代码或其他外部标识、角色、分类、标签、联系人、备注和归档状态。合同与发票通过稳定标识关联甲方,不复制甲方名称作为关系真相。 + +接受标准包括: + +- 管理员可以维护甲方分类; +- 员工可以按甲方、分类、标签和归档状态筛选; +- 同一甲方的合同与发票可以从伙伴详情统一查看; +- 甲方改名不破坏历史关联。 + +### 6.4 PIKAOA-L0-REQ-004 合同多版本 + +每份合同必须有稳定主记录和一个或多个不可变版本。合同内版本号单调递增;创建新版本必须保留旧版本、更新当前版本指针并记录变更说明、操作者和时间。 + +每个版本至少包含: + +- 版本号和版本说明; +- 合同标题、合同编号和甲方引用; +- 签署日期、生效日期、到期日期和币种; +- 金额、摘要、状态和附件引用; +- 创建人、创建时间和内容哈希。 + +接受标准包括: + +- 详情默认显示当前版本并可查看全部历史版本; +- 历史版本不可被当前编辑覆盖; +- 并发创建相同下一版本时只能有一个事务成功; +- 合同可以按甲方、分类、编号、状态和日期筛选。 + +### 6.5 PIKAOA-L0-REQ-005 发票管理和废弃 + +发票必须支持号码、类型、开票日期、金额、税额、价税合计、币种、甲方、合同和可选合同版本关联。发票不得物理删除;废弃动作必须保留原记录,并记录废弃人、废弃时间和非空原因。 + +接受标准包括: + +- 发票可以关联合同主记录与具体合同版本; +- 关联版本必须属于所选合同; +- 发票甲方与合同甲方不一致时返回明确校验错误; +- 默认有效列表和汇总排除废弃发票; +- 用户可以显式筛选全部、有效或废弃发票。 + +### 6.6 PIKAOA-L0-REQ-006 附件完整性 + +合同版本和发票可以关联附件。附件元数据必须记录原文件名、媒体类型、字节数、内容哈希、存储键、上传人和上传时间。存储实现通过端口替换,业务表不保存宿主绝对路径。 + +接受标准包括: + +- 上传、下载和详情读取均执行权限检查; +- 相同对象的附件可独立增删,不改写历史合同版本内容; +- 内容哈希与下载内容一致; +- 文件存储不可用时不生成成功业务引用。 + +### 6.7 PIKAOA-L0-REQ-007 审计和领域事件 + +所有身份、伙伴、合同、版本、发票和模块配置写入必须生成不可变审计记录。需要异步扩展的业务事实必须在同一数据库事务中写入 outbox;Worker 只消费已提交事件,不反向成为业务真相。 + +接受标准包括: + +- 审计记录包含员工、动作、资源类型、资源标识、时间和安全摘要; +- Secret、密码、会话和附件正文不进入审计; +- 事务回滚时审计和 outbox 同时回滚; +- Worker 重试不重复改变业务主记录。 + +### 6.8 PIKAOA-L0-REQ-008 企业工作台 + +Web 必须是高密度、可持续使用的办公工作台。首屏直接显示导航、筛选、列表、状态和主要动作,不使用营销式落地页。合同、发票、伙伴和员工页面必须具备分页、空态、加载态、错误态、权限态、创建/编辑表单和详情深链。 + +接受标准包括: + +- 桌面与移动视口无控件重叠或不可达操作; +- 列表筛选和详情选择进入 URL 并可刷新恢复; +- 版本历史、废弃标识和甲方分类可直接扫描; +- 图标按钮有可访问名称和工具提示。 + +### 6.9 PIKAOA-L0-REQ-009 自动交付和配置真相 + +PikaOA 必须运行在独立 Kubernetes namespace。目标、namespace、镜像、数据库引用、Secret、资源、探针、持久卷、公网域名和 CI/CD 参数均由 owning YAML 声明;代码只校验和渲染。 + +GitHub 目标分支合并必须自动驱动 Gitea 受控镜像、不可变快照、Pipelines-as-Code、Tekton、GitOps、Argo 和运行面收敛。首次 bootstrap 只初始化空镜像仓库和控制面,不得同步业务分支或人工创建 PipelineRun。 + +接受标准包括: + +- Web、API 和 Worker 在同一独立 namespace 内运行; +- API 直接连接 YAML 声明的 host PostgreSQL; +- 运行面 Secret 只显示对象、key、presence 和 fingerprint; +- 健康接口证明进程、数据库和迁移状态; +- 公网 HTTPS 入口通过 YAML 声明的 PK01 edge 暴露。 + +### 6.10 PIKAOA-L0-REQ-010 OpenTelemetry 与 Prometheus + +PikaOA 必须从首版提供 OpenTelemetry 与 Prometheus 可观测性,而不是在业务上线后补接。 + +OpenTelemetry 必须覆盖: + +- Web 和 CLI 发起请求时生成或传播 W3C `traceparent`/`baggage`; +- API HTTP server/client、认证、授权、领域用例、PostgreSQL、附件存储和 outbox 写入; +- Worker 消费、重试、处理结果和下游适配器; +- CI/CD 从 source、build、artifact、GitOps 到 runtime health 的同一 trace 上下文; +- 结构化日志中的 traceId、spanId、service、module、operation、result 和稳定错误码。 + +Prometheus 必须覆盖: + +- HTTP 请求量、延迟、状态族和在途请求; +- 登录成功/失败、授权拒绝和会话状态摘要; +- 合同创建、版本创建、发票创建、发票废弃等领域动作计数与延迟; +- PostgreSQL连接池、查询延迟和迁移状态; +- outbox pending、oldest age、processed、retry 和 failed; +- Worker 队列、处理耗时和错误; +- Go runtime、进程和健康状态。 + +可观测属性不得包含密码、token、完整合同正文、附件正文、个人敏感字段或无界业务载荷。甲方、合同、发票和员工只在明确需要下钻时使用不可逆或受权限保护的稳定标识,禁止把高基数自由文本作为 metric label。 + +接受标准包括: + +- 一次 CLI 合同版本操作可以从 CLI requestId/traceId 查询到 API、授权、数据库和审计/outbox span; +- 一次 Worker 处理可以通过同一业务 correlation 与来源事件关联; +- Prometheus 可以抓取 API 与 Worker 的 `/metrics` 并显示上述核心指标; +- 健康状态区分应用 ready、数据库 ready、迁移 ready、OTel exporter warning 和 metrics scrape 状态; +- OTel export 超时或 trace backend 暂时不可用只输出 `warning=true`、`blocking=false`,不改变成功业务事务终态。 + +### 6.11 PIKAOA-L0-REQ-011 CLI-first 业务验收 + +每个后端业务能力必须先通过 `pikaoa` CLI 验收,再实现对应 Web 页面。CLI 必须调用同一公开 HTTP API、使用同一用户身份与权限,并覆盖员工、伙伴、合同版本、发票废弃、审计和模块枚举。 + +接受标准包括: + +- CLI 支持登录、当前身份、员工管理、伙伴/分类管理、合同 CRUD/版本历史、发票 CRUD/废弃、审计查询和健康/metrics 摘要; +- 默认文本输出有界且非空,显式 JSON 输出为单一合法 JSON; +- 业务失败返回稳定非零退出码和 typed error,不以 traceback 或空输出代替; +- CLI 业务成功同时返回资源标识、requestId 和 traceId; +- 后端 CLI 验收未通过时,对应前端任务保持未开始。 + +### 6.12 PIKAOA-L0-REQ-012 非阻塞版本告警 + +Web、API、Worker 和 CI/CD 必须输出结构化日志、健康状态和 trace 上下文。源码版本、镜像版本和 API 版本漂移只能形成非阻塞 warning,不得阻塞可安全解释的用户业务。 + +## 7. API、数据与兼容边界 + +- 外部 HTTP API 使用 `/api/v1` 版本前缀。 +- 模块路由使用稳定复数资源名和标准分页参数。 +- 写请求返回稳定业务错误码、字段路径和可读消息。 +- ID 使用不可枚举、全局唯一的稳定标识。 +- 时间以 UTC 保存并在用户界面按配置时区显示。 +- 金额使用定点十进制语义,不使用二进制浮点保存。 +- 数据库迁移单向、版本化、可审计;应用启动不静默修改未知结构。 +- 跨模块对象引用包含资源类型和稳定 ID,不复制私有载荷。 +- 兼容归一化只处理可安全解释的旧字段;不可解释业务数据返回字段级错误。 + +## 8. 全局验收契约 + +### 8.1 功能验收 + +- 先使用 CLI 管理员账号登录,创建普通员工、甲方分类和甲方。 +- 通过 CLI 创建合同 v1,再创建 v2,确认 v1 保留且 v2 成为当前版本。 +- 通过 CLI 在甲方维度查看合同,并按甲方分类筛选合同。 +- 通过 CLI 创建关联合同 v2 的发票,确认合同、版本和甲方关系正确。 +- 通过 CLI 标记发票废弃,确认原因和审计存在,默认有效汇总不包含该发票。 +- 使用 CLI 普通员工账号确认允许的业务操作可用、管理员操作不可用。 +- 确认 CLI 后端验收通过后,再用 Web 重复同一业务主路径并比对结果。 + +### 8.2 扩展性验收 + +- 注册一个不修改首批模块私有实现的示例模块; +- 模块声明权限、路由、导航、迁移、审计动作和事件订阅; +- 示例模块可通过公共伙伴引用和领域事件完成集成; +- 禁用模块后,其导航和新请求入口关闭,但历史业务数据不被删除。 + +### 8.3 运行验收 + +- CI/CD 绑定 source commit、PipelineRun、镜像 digest、GitOps revision、Argo 状态和运行面 ready; +- Web、API 和 Worker 运行在 owning YAML 声明的 namespace; +- PostgreSQL 连接、迁移、附件存储和健康检查通过; +- OTel Collector 接收 Web/CLI/API/Worker trace,Prometheus 抓取 API/Worker 指标; +- CLI 主路径均返回可查询的 requestId/traceId,并能关联业务审计; +- `https://oa.pikapython.com` 返回登录页和可用业务工作台; +- 桌面与移动浏览器完成登录、合同版本和发票废弃主路径; +- 运行和验证输出不披露密码、数据库连接串、token 或附件正文。 + +## 9. 规格治理 + +- 本规格是 PikaOA L0 长期真相。 +- 稳定需求、模块边界、数据流、接口和验收口径变化先更新本规格或对应 L1 规格。 +- 实现进度、当前阻塞、提交、PR、PipelineRun、截图和一次性验证进入 GitHub issue、MDTODO 报告和阶段报告。 +- 本项目新增或修改的源码文件必须在文件头部或包级文档中标注 `SPEC: PJ2026-03 PikaOA v0.1`;自动生成文件、第三方代码、纯配置、锁文件和二进制产物例外,但其生成器或 owning 配置必须可追溯。 +- L1 规格按第 4.2 节编号建立;优先拆分具有独立生命周期、数据所有权或验收入口的能力域。 From dcff1b2057264293907410201e1edd465695b14c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 20:25:34 +0200 Subject: [PATCH 02/43] =?UTF-8?q?chore:=20=E9=85=8D=E7=BD=AE=20PikaOA=20?= =?UTF-8?q?=E5=8F=97=E6=8E=A7=E5=BB=BA=E4=BB=93=E5=87=AD=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/unidesk-cli.yaml | 6 ++++++ docs/MDTODO/pikaoa-enterprise-platform.md | 1 + 2 files changed, 7 insertions(+) diff --git a/config/unidesk-cli.yaml b/config/unidesk-cli.yaml index 3264444b..cd1914de 100644 --- a/config/unidesk-cli.yaml +++ b/config/unidesk-cli.yaml @@ -15,6 +15,12 @@ github: sourceKey: GH_TOKEN format: raw-token repositoryOverrides: + - repository: pikainc/pikaoa + priority: before-env + root: /root/.unidesk/.env + sourceRef: pikainc-selfmedia-gh-token.txt + sourceKey: GH_TOKEN + format: raw-token - repository: pikainc/selfmedia priority: before-env root: /root/.unidesk/.env diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 0189dd72..cc15dadd 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -1,6 +1,7 @@ # PikaOA 企业办公平台 - 规格真相:`project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md`。 +- 主执行记录:[pikasTech/unidesk#1952](https://github.com/pikasTech/unidesk/issues/1952)。 - 产品仓库:`pikaInc/pikaoa`,固定主工作区为 `/root/pikaoa`。 - 目标运行面:owning YAML 选中的 NC01 Kubernetes 独立 namespace。 - 公网原入口:`https://oa.pikapython.com`。 From 804a905972e3aade510afde7d04636cf3b1f768d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 20:28:34 +0200 Subject: [PATCH 03/43] =?UTF-8?q?docs:=20=E6=B4=BE=E5=8F=91=20PikaOA=20?= =?UTF-8?q?=E5=BC=80=E6=BA=90=E6=96=B9=E6=A1=88=E8=B0=83=E7=A0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/pikaoa-enterprise-platform.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index cc15dadd..d522d2c7 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -13,9 +13,9 @@ 依据 PJ2026-03 建设企业级可扩展 PikaOA:在 /root/pikaoa 建立 pikaInc 仓库,采用 HWLAB v0.3 同族 Vue 技术栈和 Go 后端;CLI-first 完成员工/RBAC、甲方分类、合同多版本、发票关联与废弃、审计、OTel/Prometheus,再开发 Web;通过独立 K8s namespace、PK01 host PostgreSQL、YAML-first、PaC bootstrap、自动 CI/CD 和 https://oa.pikapython.com 原入口交付,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1_Task_Report.md)。 -### R1.1 +### R1.1 [in_progress] -调研成熟开源 OA、ERP、合同、发票、Go 微服务与 Vue 组件方案,形成采用/复用/不采用结论,依赖 R1 规格,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.1_Task_Report.md)。 +调研成熟开源 OA、ERP、合同、发票、Go 微服务与 Vue 组件方案,形成采用/复用/不采用结论,依赖 R1 规格,执行记录见 [pikasTech/unidesk#1953](https://github.com/pikasTech/unidesk/issues/1953),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.1_Task_Report.md)。 ### R1.2 From 91bb12b5aa1464bdc644172aae843ee18b57cd1b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 20:30:24 +0200 Subject: [PATCH 04/43] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20PikaOA=20?= =?UTF-8?q?=E5=BB=BA=E4=BB=93=E6=9D=83=E9=99=90=E9=98=BB=E5=A1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/pikaoa-enterprise-platform.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index d522d2c7..4948007c 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -17,9 +17,9 @@ 调研成熟开源 OA、ERP、合同、发票、Go 微服务与 Vue 组件方案,形成采用/复用/不采用结论,依赖 R1 规格,执行记录见 [pikasTech/unidesk#1953](https://github.com/pikasTech/unidesk/issues/1953),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.1_Task_Report.md)。 -### R1.2 +### R1.2 [in_progress] -建立 pikaInc/pikaoa 私有仓库、/root/pikaoa 固定 master 和独立任务 worktree,依赖 R1.1 的技术选型,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.2_Task_Report.md)。 +建立 pikaInc/pikaoa 私有仓库、/root/pikaoa 固定 master 和独立任务 worktree;受 `pikaInc` 组织建仓权限阻塞,证据见 [主 issue 评论](https://github.com/pikasTech/unidesk/issues/1952#issuecomment-4961261444),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.2_Task_Report.md)。 ### R1.3 From d8fd3ba92021fbf45efcd7a197344763203c22b1 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 20:34:54 +0200 Subject: [PATCH 05/43] =?UTF-8?q?docs:=20=E6=B4=BE=E5=8F=91=20PaC=20bootst?= =?UTF-8?q?rap=20=E6=98=93=E7=94=A8=E6=80=A7=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/pikaoa-enterprise-platform.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 4948007c..0d434fb1 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -29,10 +29,13 @@ 在 R1.3 CLI 后端验收通过后实现 HWLAB v0.3 同族 Vue 工作台、响应式页面与同路径业务流,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.4_Task_Report.md)。 -### R1.5 +### R1.5 [in_progress] 在 UniDesk 中声明 PK01 PostgreSQL、Secret、NC01 独立 namespace、Gitea/PaC、GitOps、OTel/Prometheus 和 PK01 公网暴露,并改进可复用 bootstrap 工具,依赖 R1.1-R1.2,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5_Task_Report.md)。 +#### R1.5.1 [in_progress] + +改进 PaC bootstrap 的新服务首发预检、分阶段紧凑输出、typed failure 与中文说明,保持 PR merge 唯一交付 authority,执行记录见 [pikasTech/unidesk#1955](https://github.com/pikasTech/unidesk/issues/1955),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.1_Task_Report.md)。 ### R1.6 审核并合并双仓 PR,执行首次受控 PaC bootstrap,由正常 source PR merge 自动发布,依赖 R1.3-R1.5,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6_Task_Report.md)。 From 58c692fd1b88ee7685052fcef2bb0d723cd0a45c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 20:39:37 +0200 Subject: [PATCH 06/43] =?UTF-8?q?docs:=20=E6=B4=BE=E5=8F=91=20Prometheus?= =?UTF-8?q?=20=E5=B9=B3=E5=8F=B0=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/pikaoa-enterprise-platform.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 0d434fb1..e340c520 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -36,6 +36,9 @@ #### R1.5.1 [in_progress] 改进 PaC bootstrap 的新服务首发预检、分阶段紧凑输出、typed failure 与中文说明,保持 PR merge 唯一交付 authority,执行记录见 [pikasTech/unidesk#1955](https://github.com/pikasTech/unidesk/issues/1955),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.1_Task_Report.md)。 +#### R1.5.2 [in_progress] + +扩展 NC01 共享 observability,使 OTel/Tempo 与 Prometheus 由同一 YAML-first 控制面管理并提供跨 namespace 指标抓取和有界查询,执行记录见 [pikasTech/unidesk#1956](https://github.com/pikasTech/unidesk/issues/1956),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.2_Task_Report.md)。 ### R1.6 审核并合并双仓 PR,执行首次受控 PaC bootstrap,由正常 source PR merge 自动发布,依赖 R1.3-R1.5,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6_Task_Report.md)。 From 9b28815741355eb630ffa57cf1c0ac71534f024b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 20:39:56 +0200 Subject: [PATCH 07/43] =?UTF-8?q?docs:=20=E8=B0=83=E7=A0=94=20PikaOA=20?= =?UTF-8?q?=E5=BC=80=E6=BA=90=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../R1.1_Task_Report.md | 508 ++++++++++++++++++ 1 file changed, 508 insertions(+) create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.1_Task_Report.md diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.1_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.1_Task_Report.md new file mode 100644 index 00000000..d4cc6e99 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.1_Task_Report.md @@ -0,0 +1,508 @@ +# PikaOA R1.1 开源办公系统与组件调研报告 + +## 1. 任务信息 + +| 字段 | 内容 | +| --- | --- | +| MDTODO | [R1.1](../../pikaoa-enterprise-platform.md) | +| 执行 issue | [pikasTech/unidesk#1953](https://github.com/pikasTech/unidesk/issues/1953) | +| 上层规格 | [PJ2026-03](../../../../project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md) | +| 调研日期 | 2026-07-13 | +| 交付性质 | 隔离选型文档,不包含业务代码、运行面、Secret 或 CI/CD 变更 | + +本报告只为 PikaOA 后续实现提供证据和建议,不改变 PJ2026-03 已冻结的模块化单体、CLI-first、 +HWLAB v0.3 同族前端和 YAML-first 方向。 + +## 2. 决策摘要 + +### 2.1 总体结论 + +- 不直接采用 ERPNext、Odoo、Twenty、EspoCRM、Dolibarr 或 OpenProject 作为 PikaOA 底座: + - 这些系统的领域边界、扩展模型、技术栈和升级路径会反向约束 PJ2026-03; + - 多数完整系统使用 GPL 或 AGPL,闭源可能性未知时不应复制源码或形成紧耦合派生实现; + - PikaOA 首期需要的是稳定平台内核与合同、发票事实,而不是迁移完整 ERP/CRM 产品。 +- 直接采用宽松许可证的 Go、Vue 和可观测性基础组件: + - HTTP:`chi`; + - PostgreSQL:`pgx` 与 `sqlc`; + - 数据库迁移:`goose`; + - CLI:`Cobra`; + - 授权:首期 `Casbin`,保留以后提取到 `OpenFGA` 的边界; + - 事务消息:PostgreSQL outbox 与 `Watermill`; + - 前端:Vue 3、Vite、TypeScript、Vue Router、Pinia、Tailwind; + - 可观测性:OpenTelemetry Go、OTLP Collector、Prometheus Go client 和结构化日志关联。 +- 仅参考成熟系统的业务建模,不复制实现: + - ERPNext/Frappe 的单据提交、取消、修订和审计思路; + - Odoo 的发票冲销、贷项通知单与会计事实保留思路; + - CRM 的统一伙伴、联系人、标签与上下文角色模型; + - Paperless-ngx 的文档归档、检索、标签和权限思路; + - Documenso 的签署流程与签署证据思路。 +- 首期不采用 `OpenFGA`、`Asynq`、`Atlas`: + - `OpenFGA` 增加独立服务和授权模型运维成本,当前 RBAC 可由 `Casbin` 满足; + - `Asynq` 依赖 Redis,首期已有 PostgreSQL outbox 即可提供可审计异步处理; + - `Atlas` 与 `goose` 职责重叠,首期应保持单一迁移真相。 + +### 2.2 推荐分类 + +- 直接采用: + - `chi`、`pgx`、`sqlc`、`goose`、`Cobra`、`Casbin` 和 `Watermill`; + - Vue 3、Vue Router、Pinia 和 Tailwind; + - OpenTelemetry Go、Prometheus Go client 和 OTLP Collector。 +- 组件复用: + - HWLAB v0.3 的前端布局语言、路由守卫和 API 错误呈现; + - HWLAB v0.3 的响应式工作台模式; + - 现有 UniDesk OTel、Tempo 和 Prometheus 运行能力。 +- 仅参考: + - ERPNext/Frappe、Odoo、Twenty、EspoCRM 和 Dolibarr; + - OpenProject、Documenso 和 Paperless-ngx; + - OpenFGA 和 Asynq。 +- 不采用: + - 完整 OA、ERP 或 CRM 产品作为 PikaOA 底座; + - GPL 或 AGPL 源码复制; + - 首期微服务拆分; + - 同时使用多套 HTTP、迁移或异步任务框架。 + +## 3. 调研方法与活跃度口径 + +- 来源优先级: + - 项目官方 GitHub 仓库及其许可证文件; + - 项目官方文档; + - GitHub Releases 和仓库最近推送时间。 +- 活跃度判定: + - `高`:调研日附近仍有提交,并在近数月内发布版本; + - `中`:持续维护,但发布节奏较慢或主要以稳定维护为主; + - 活跃度只说明维护状态,不等于适合 PikaOA。 +- 表中版本和活跃度是 2026-07-13 的采样证据: + - 最终依赖版本必须由实现任务根据锁文件、安全公告和兼容性验证选择; + - 不把本报告中的版本固化为部署策略。 + +## 4. 完整办公、ERP、CRM 与文档系统 + +### 4.1 ERPNext 与 Frappe + +- 来源: + - [ERPNext](https://github.com/frappe/erpnext); + - [Frappe](https://github.com/frappe/frappe)。 +- 活跃度与技术栈: + - 高;Python、JavaScript 和元数据驱动框架; + - ERPNext 于调研日仍有发布。 +- 许可证: + - ERPNext 为 GPL-3.0; + - Frappe 框架和附属组件需按锁定版本逐项复核。 +- 可复用层次: + - 单据状态、提交后修改限制、取消与修订; + - 审计时间线和模块元数据思路。 +- 成本与风险: + - 领域和框架绑定深; + - 采用整套系统会替换 Go 与既定模块边界; + - GPL 代码复制风险高。 +- 结论:仅参考业务语义,不采用产品或源码。 + +### 4.2 Odoo + +- 来源:[Odoo](https://github.com/odoo/odoo)。 +- 活跃度与技术栈:高;Python、JavaScript 和模块化 ERP。 +- 许可证: + - Community 核心通常为 LGPL-3.0; + - Enterprise 和单独模块另有许可,必须逐项核验。 +- 可复用层次: + - 发票、贷项通知单和冲销; + - 伙伴、模块注册和工作流。 +- 成本与风险: + - 社区版与企业版边界复杂; + - 直接采用会偏离 Go 和 CLI-first。 +- 结论:仅参考会计与伙伴模型。 + +### 4.3 Twenty + +- 来源:[Twenty](https://github.com/twentyhq/twenty)。 +- 活跃度与技术栈:高;TypeScript、React、NestJS 和 PostgreSQL。 +- 许可证:仓库包含开源与商业边界,采用前必须逐文件核对。 +- 可复用层次: + - 现代 CRM 交互; + - 统一对象、筛选、视图和活动时间线。 +- 成本与风险: + - 技术栈与 HWLAB Vue 不同; + - 许可证与企业功能边界增加合规审查。 +- 结论:仅参考产品交互和对象模型。 + +### 4.4 EspoCRM + +- 来源:[EspoCRM](https://github.com/espocrm/espocrm)。 +- 活跃度与技术栈:高;PHP 和 JavaScript。 +- 许可证:AGPL-3.0。 +- 可复用层次:CRM 实体、角色、团队、关系和审计思路。 +- 成本与风险:AGPL 网络服务义务,且 PHP 技术栈不匹配。 +- 结论:仅参考,不复制源码。 + +### 4.5 Dolibarr + +- 来源:[Dolibarr](https://github.com/Dolibarr/dolibarr)。 +- 活跃度与技术栈:高;PHP。 +- 许可证:GPL-3.0。 +- 可复用层次: + - 轻量 ERP 和 CRM 模块边界; + - 第三方与发票关联。 +- 成本与风险:领域覆盖广但架构迁移成本较高,且存在 GPL 风险。 +- 结论:仅参考模块目录和业务关系。 + +### 4.6 OpenProject + +- 来源:[OpenProject](https://github.com/opf/openproject)。 +- 活跃度与技术栈:高;Ruby 和 TypeScript。 +- 许可证:GPL-3.0。 +- 可复用层次:项目、工作包、活动记录和权限矩阵。 +- 成本与风险:面向项目管理而非企业平台内核,技术栈与许可证不匹配。 +- 结论:不作为底座,仅参考工作项审计。 + +### 4.7 Documenso + +- 来源:[Documenso](https://github.com/documenso/documenso)。 +- 活跃度与技术栈:高;TypeScript。 +- 许可证:AGPL-3.0。 +- 可复用层次:文档签署状态、参与者、签署证据和模板体验。 +- 成本与风险: + - 电子签名涉及合规、身份与证据链; + - 存在 AGPL 风险。 +- 结论:首期仅预留集成边界,不内嵌源码。 + +### 4.8 Paperless-ngx + +- 来源:[Paperless-ngx](https://github.com/paperless-ngx/paperless-ngx)。 +- 活跃度与技术栈:高;Python 和 Django。 +- 许可证:GPL-3.0。 +- 可复用层次: + - 文档归档、标签和对应方; + - 全文检索、权限和消费管线。 +- 成本与风险: + - 适合作为独立文档系统,但不是合同事实主库; + - 存在 GPL 风险。 +- 结论:参考归档模型,未来可评估松耦合集成。 + +### 4.9 完整系统不直接采用的根因 + +- 产品边界不一致: + - ERPNext、Odoo 和 Dolibarr 以 ERP 全域为中心; + - Twenty 和 EspoCRM 以 CRM 为中心; + - OpenProject 以项目协作为中心; + - Documenso 和 Paperless-ngx 只覆盖文档生命周期的一部分。 +- 架构代价大于复用收益: + - 直接采用意味着把权限、扩展、数据迁移、前端和运维入口交给上游框架; + - PikaOA 仍需额外实现 CLI-first、YAML-first、UniDesk 交付和既定审计契约。 +- 许可证不确定性: + - GPL/AGPL 项目可合法研究、部署和集成,但复制、修改、分发或通过网络提供修改版时存在义务; + - PikaOA 的最终分发模式尚未确定,因此本阶段只提炼思想和公开接口,不复制实现。 + +## 5. 关键业务语义建议 + +### 5.1 合同不可变多版本 + +推荐把“合同身份”和“合同版本”分离: + +- `contract`: + - 保存稳定编号、业务伙伴、负责人、当前有效版本指针和总体状态; + - 不直接承载会被修订覆盖的条款正文。 +- `contract_version`: + - 保存版本号、结构化条款、金额、币种、日期、附件摘要、创建人和创建时间; + - `draft` 可编辑;一旦进入 `approved`、`signed`、`effective` 或 `superseded`,内容不可原地修改; + - 修订必须创建新版本,并记录 `previous_version_id`、修订原因和操作者。 +- 证据: + - 附件保存内容摘要、对象存储定位和媒体类型; + - 审批、签署、启用、终止和版本切换进入 append-only 审计记录; + - 查询必须能重建任一时点的有效版本。 + +该模型吸收 Frappe 的提交、取消、修订理念,但不复刻其 DocType 或框架实现。 + +### 5.2 发票作废而非物理删除 + +推荐状态至少区分: + +- `draft`:尚未形成对外事实,可按权限编辑或删除; +- `issued`:已开具,不允许物理删除或覆盖关键字段; +- `voided`:保留原编号、金额、关联合同与附件,并记录作废原因、时间和操作者; +- `credited`:未来需要会计冲销时,通过独立贷项记录关联原发票,不改写原事实。 + +约束: + +- 合同与发票使用稳定外键关联,同时保存开具时必要快照,避免伙伴名称后续变更破坏历史显示; +- `issued` 之后的任何纠正都产生新事实; +- 搜索默认可隐藏作废项,但审计、CLI 和明确过滤必须可查询; +- 数据保留策略不得把业务作废等同于数据库删除。 + +### 5.3 伙伴与甲方分类 + +采用统一 `business_partner`,不要为甲方、供应商、客户分别复制主表: + +- 稳定字段包括法定名称、统一标识、归档状态、联系人和地址; +- `partner_category` 支持层级、标签和有效期; +- “甲方”“供应商”“客户”是上下文角色或分类,可同时存在; +- 合同保存签约时的伙伴快照和角色,伙伴主数据后续变更不覆盖历史合同; +- 分类变更必须审计,归档代替物理删除。 + +### 5.4 员工、身份与权限 + +- 身份与员工档案分离: + - 身份负责登录、会话和外部身份提供方映射; + - 员工负责组织、岗位、在职状态和业务归属。 +- 首期采用 RBAC: + - 资源与动作使用稳定 capability; + - 角色只聚合 capability; + - 数据范围通过明确条件实现,不把前端路由可见性当作后端授权。 +- `Casbin` 适合首期进程内策略判定; +- 当出现跨服务关系授权、对象级共享或多租户关系图时,再评估 `OpenFGA`。 + +### 5.5 审计与 outbox + +- 业务写入、审计事件和 outbox 记录必须在同一 PostgreSQL 事务提交; +- outbox 记录包含稳定事件类型、聚合 ID、聚合版本、发生时间和脱敏载荷; +- worker 以至少一次语义处理,消费者通过事件 ID 或业务幂等键去重; +- `Watermill` 负责消息抽象和处理管线,不替代数据库事务真相; +- 审计记录和集成事件分开: + - 审计面向人和合规追溯; + - outbox 面向系统集成和未来服务提取。 + +### 5.6 模块扩展与未来微服务提取 + +首期保持模块化单体: + +- 每个模块拥有自己的 handler、use case、repository、迁移和事件定义; +- 跨模块写入只能通过应用服务或已声明接口,不允许直接修改他模块表; +- 共享内核只保留身份、伙伴、附件、审计、分页、错误和幂等契约; +- 只有满足以下事实后才提取微服务: + - 独立扩缩容或故障隔离需求持续存在; + - 数据所有权和事件契约稳定; + - 跨模块同步事务已消除; + - 独立部署收益高于网络、可观测性和运维成本。 + +## 6. Go 后端组件评估 + +### 6.1 直接采用 + +- HTTP: + - [chi](https://github.com/go-chi/chi),MIT; + - 轻量、标准库兼容、路由组合自然,适合模块化单体。 +- PostgreSQL: + - [pgx](https://github.com/jackc/pgx),MIT; + - PostgreSQL 原生能力完整,适配事务、批量和通知。 +- SQL 代码生成: + - [sqlc](https://github.com/sqlc-dev/sqlc),MIT; + - SQL-first、静态类型,便于审查真实查询。 +- 数据库迁移: + - [goose](https://github.com/pressly/goose),MIT; + - 简单、Go 生态成熟,适合显式 SQL 迁移。 +- CLI: + - [Cobra](https://github.com/spf13/cobra),Apache-2.0; + - 子命令和 help 生态成熟,输出契约仍遵循 PikaOA CLI 规格。 +- RBAC: + - [Casbin](https://github.com/casbin/casbin),Apache-2.0; + - 进程内、模型灵活,首期运维成本低。 +- 事件: + - [Watermill](https://github.com/ThreeDotsLabs/watermill),MIT; + - 消息处理抽象成熟,可与事务 outbox 结合。 + +### 6.2 仅比较或预留 + +- [Echo](https://github.com/labstack/echo),MIT: + - 功能完整但约定较多; + - 不与 chi 并用,仅在发现明确缺口时重新评估。 +- [Gin](https://github.com/gin-gonic/gin),MIT: + - 生态和认知度高,但自有 Context 侵入更明显; + - 不采用,避免无收益的框架抽象。 +- [Atlas](https://github.com/ariga/atlas),Apache-2.0: + - 部分能力与商业产品边界需核验; + - 声明式 schema 与检查能力强,但会引入第二套迁移模型; + - 首期不采用。 +- [OpenFGA](https://github.com/openfga/openfga),Apache-2.0: + - 适合复杂对象关系授权; + - 会增加独立服务和模型治理; + - 预留边界,首期不采用。 +- [Asynq](https://github.com/hibiken/asynq),MIT: + - Redis 队列成熟; + - 会增加首期基础设施和第二事实源; + - 首期不采用。 + +### 6.3 推荐最小组合 + +```text +Cobra CLI / Vue Web + | + chi HTTP + | +应用服务与模块边界 + | +sqlc + pgx + PostgreSQL + | +业务表 + audit_log + outbox + | +Watermill worker / 外部集成 +``` + +组合原则: + +- 不引入 ORM 作为默认数据访问层; +- 不同时维护 `goose` 与 `Atlas` 两套迁移真相; +- 不为异步任务额外引入 Redis,除非后续有独立证据; +- CLI 和 Web 必须调用同一 HTTP API 与业务用例,不形成旁路管理逻辑。 + +## 7. Vue 3 与 HWLAB v0.3 复用边界 + +### 7.1 直接采用 + +| 组件 | 许可证 | 用途 | +| --- | --- | --- | +| [Vue 3](https://github.com/vuejs/core) | MIT | 组件与响应式基础 | +| [Vue Router](https://github.com/vuejs/router) | MIT | 路由、模块入口和守卫 | +| [Pinia](https://github.com/vuejs/pinia) | MIT | 会话、访问摘要和跨页面客户端状态 | +| [Tailwind CSS](https://github.com/tailwindlabs/tailwindcss) | MIT | 设计令牌与响应式布局 | + +### 7.2 复用 HWLAB v0.3 的内容 + +- 复用同族技术栈、设计语言和已验证的响应式布局方法; +- 复用以下模式,而不是复制 HWLAB 业务模块: + - 应用壳、侧边导航、顶栏、内容区和移动端折叠逻辑; + - 登录态、路由守卫、403/404 和 API 错误呈现; + - 表格筛选、分页、详情抽屉、时间线和状态徽标; + - 可访问性、加载态、空态和失败可见性; + - 与 CLI 同源的 capability 驱动导航。 + +### 7.3 禁止复用 + +- 不复制 Workbench、AgentRun、Code Queue 等领域组件和状态投影; +- 不把 HWLAB 的 session、run、event 模型引入合同或发票领域; +- 不让前端 Pinia store 成为业务事实真相; +- 不在 CLI 后端契约完成前先冻结 Web 私有接口。 + +## 8. 可观测性方案 + +| 组件 | 许可证 | 结论 | +| --- | --- | --- | +| [OpenTelemetry Go](https://github.com/open-telemetry/opentelemetry-go) | Apache-2.0 | 直接采用,负责 trace、metric 和 context 传播 | +| [OpenTelemetry Collector](https://github.com/open-telemetry/opentelemetry-collector) | Apache-2.0 | 直接采用现有平台能力,应用只发 OTLP | +| [Prometheus Go client](https://github.com/prometheus/client_golang) | Apache-2.0 | 直接采用,暴露低基数业务与运行指标 | +| [Tempo](https://github.com/grafana/tempo) | AGPL-3.0 | 复用 UniDesk 独立运行服务,不把源码或服务端实现嵌入 PikaOA | + +### 8.1 关联方式 + +- HTTP、CLI 请求和 worker 消费均传播 `trace_id` 与 `span_id`; +- 结构化日志至少包含: + - 时间、级别、服务、模块、事件名; + - `trace_id`、`span_id`、request ID; + - 脱敏后的 actor、聚合类型和聚合 ID; + - 稳定错误码,不记录 Secret、令牌或合同敏感正文。 +- 指标保持低基数: + - 请求量、错误量、延迟; + - outbox backlog、处理成功和失败; + - 合同与发票状态变化计数; + - 禁止把员工 ID、合同 ID、发票号作为 metric label。 +- Collector、采样、保留和导出参数继续由 owning YAML 控制,本报告不固化数值。 + +## 9. 许可证边界 + +本节是工程风险提示,不替代法律意见。 + +- MIT、Apache-2.0: + - 可作为首选依赖; + - 仍需保留许可证和版权通知,并维护依赖清单。 +- LGPL-3.0: + - 动态链接、修改库和分发场景有不同义务; + - Odoo 必须按核心、社区模块、企业模块逐项确认,不以仓库总体印象替代检查。 +- GPL-3.0: + - ERPNext、Dolibarr、OpenProject 和 Paperless-ngx 等项目只提炼公开业务思想; + - 禁止复制源码、迁移脚本、模板或前端组件进入闭源可能性未知的仓库。 +- AGPL-3.0: + - EspoCRM、Documenso 和 Tempo 等网络服务软件需要特别审查修改版网络提供义务; + - 优先采用进程外、协议级集成,并保留独立部署和许可证材料; + - 任何修改、再分发或嵌入决定必须先完成法律与架构审查。 +- 上游可能调整许可证或采用多许可证: + - 实现任务必须锁定 commit/tag; + - 对锁定版本重新读取 `LICENSE`、`NOTICE`、依赖清单和商业功能说明; + - 自动生成软件物料清单并在发布前执行许可证扫描。 + +## 10. 实施建议 + +### 10.1 首期基线 + +- 建立 Go 模块化单体与单一 PostgreSQL 数据库; +- 用 chi、sqlc、pgx、goose、Cobra 建立最小 API/CLI 骨架; +- 先实现员工/RBAC、伙伴、合同版本、发票作废、审计和 outbox; +- 用 Casbin 做 capability 判定,策略存储与业务事务边界明确分离; +- 用 Watermill worker 消费 PostgreSQL outbox; +- 接入 OpenTelemetry Go、OTLP 和 Prometheus 指标; +- CLI 验收通过后再实现 Vue 3 Web。 + +### 10.2 需要保留的替换边界 + +- HTTP 框架只暴露在 transport 层,应用用例不依赖 chi 类型; +- Casbin 通过授权接口接入,未来可替换或桥接 OpenFGA; +- Watermill 通过事件发布与消费接口接入,未来可迁移 Kafka 等 broker; +- 对 Documenso、Paperless-ngx 等系统只定义外部连接器,不共享业务数据库; +- Tempo 作为 OTel 后端,不让业务代码依赖 Tempo 私有 API。 + +### 10.3 后续任务门槛 + +- R1.2 建仓前: + - 确认最终 Go module、Node package 和许可证清单; + - 明确依赖锁定和 SBOM 生成入口。 +- R1.3 实现前: + - 先把合同版本、发票作废、伙伴角色、授权和 outbox 写成 API/CLI 验收契约; + - 禁止从完整开源系统复制 schema 或代码。 +- R1.4 Web 开发前: + - CLI 后端主路径通过; + - 明确 HWLAB 可复用的布局、基础组件和设计令牌清单; + - Web 不得创建后端没有的隐式状态。 +- 评估微服务前: + - 收集独立扩缩容、故障隔离、团队所有权和数据边界证据; + - 没有证据时继续保持模块化单体。 + +## 11. 来源索引 + +### 11.1 业务系统 + +- ERPNext 仓库: +- Frappe 仓库: +- Frappe 单据状态文档: +- ERPNext 不可变账本说明: +- Odoo 仓库: +- Odoo 许可证说明: +- Odoo 贷项与退款文档: +- Twenty 仓库: +- EspoCRM 仓库: +- Dolibarr 仓库: +- OpenProject 仓库: +- Documenso 仓库: +- Paperless-ngx 仓库: +- Paperless-ngx 权限文档: + +### 11.2 Go 组件 + +- chi: +- Echo: +- Gin: +- pgx: +- sqlc: +- goose: +- Atlas: +- Cobra: +- Casbin: +- OpenFGA: +- Watermill: +- Asynq: + +### 11.3 前端与可观测性 + +- Vue 3: +- Vue Router: +- Pinia: +- Tailwind CSS: +- OpenTelemetry Go: +- OpenTelemetry Collector: +- Prometheus Go client: +- Tempo: + +## 12. 风险与未决项 + +- Odoo、Twenty、Atlas 等项目存在模块级、目录级或商业能力边界,不能只看仓库首页许可证; +- 电子签名、发票与会计凭证的法律效力依赖适用地区,后续需求需单独合规确认; +- Casbin 足以覆盖首期 RBAC,但对象级共享和关系授权增长后可能需要 OpenFGA; +- PostgreSQL outbox 可满足首期,但高吞吐、多消费者和跨区域需求出现后可能需要 Kafka 等 broker; +- HWLAB v0.3 的具体组件可复用清单应在 R1.4 基于当时源码逐项确认; +- 本报告没有发现阻塞 PJ2026-03 继续执行的问题。 From 697b6933fe591a1c8bb6b24acb15942f40718ad0 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 20:50:58 +0200 Subject: [PATCH 08/43] =?UTF-8?q?feat:=20=E6=94=B9=E8=BF=9B=20PaC=20bootst?= =?UTF-8?q?rap=20=E9=A6=96=E5=8F=91=E7=8A=B6=E6=80=81=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unidesk-cicd/references/gitea-pac.md | 4 + scripts/src/platform-infra-gitea-remote.sh | 35 ++++ scripts/src/platform-infra-gitea.ts | 35 +++- ...-infra-pipelines-as-code-bootstrap.test.ts | 52 +++++- ...tform-infra-pipelines-as-code-bootstrap.ts | 173 ++++++++++++++++++ .../src/platform-infra-pipelines-as-code.ts | 65 +++---- 6 files changed, 319 insertions(+), 45 deletions(-) diff --git a/.agents/skills/unidesk-cicd/references/gitea-pac.md b/.agents/skills/unidesk-cicd/references/gitea-pac.md index 1339e4a6..671836fb 100644 --- a/.agents/skills/unidesk-cicd/references/gitea-pac.md +++ b/.agents/skills/unidesk-cicd/references/gitea-pac.md @@ -64,6 +64,10 @@ bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target -- - 安装或更新 PaC controller、consumer RBAC、Repository CR、Argo bootstrap 与 Gitea webhook; - 不同步 source branch,不创建 snapshot,不触发业务 PipelineRun。 - 预检与失败边界: + - 默认 human 与显式 `--json` 使用同一份紧凑 typed projection,按 `yaml-exact-match`、`github-upstream`、`gitea-repository` 前置条件,以及 `gitea-init`、`pac-controller`、`tekton-consumer`、`gitops-argo` 阶段展示结果;只有显式 `--full` 或 `--raw` 才披露底层组合结果; + - YAML 精确匹配必须显示匹配数量,并区分 `yaml-repository-no-match` 与 `yaml-repository-multiple-matches`; + - GitHub 上游或凭据不可用、Gitea 初始化失败与 PaC apply 失败必须使用不同 blocker code;不得折叠为同一条未知错误; + - 失败输出只给 owning YAML 修正提示或 consumer 只读 `status` 下钻;不得给 mirror sync、人工 PipelineRun、Argo refresh 或其他 mutation 恢复命令; - PaC `apply` 在任何 Kubernetes 写入前读取 Gitea repository; - repository 缺失时返回 `gitea-repository-missing`,不留下半套 Tekton/RBAC/Argo 状态; - YAML 零匹配或多匹配时在本地拒绝,不猜测 repository; diff --git a/scripts/src/platform-infra-gitea-remote.sh b/scripts/src/platform-infra-gitea-remote.sh index d429837f..f91c62f1 100644 --- a/scripts/src/platform-infra-gitea-remote.sh +++ b/scripts/src/platform-infra-gitea-remote.sh @@ -221,11 +221,45 @@ payload = { }, "valuesPrinted": False, } + print(json.dumps(payload, ensure_ascii=False, indent=2)) sys.exit(0 if payload["ok"] else 1) PY } +run_mirror_preflight() { + python3 - "$tmp/repos.json" <<'PY' +import base64, json, os, subprocess, sys +repos = json.load(open(sys.argv[1], encoding="utf-8")) +rows = [] +timeout = int(os.environ.get("UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS", "20")) +for repo in repos: + credential = repo.get("githubCredential") or {} + token_env = credential.get("tokenEnv") or os.environ.get("UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV", "") + token = os.environ.get(token_env, "") + clone_url = repo["upstream"]["cloneUrl"] + branch = repo["upstream"]["branch"] + if not token: + rows.append({"key": repo["key"], "repository": repo["upstream"]["repository"], "branch": branch, "ok": False, "code": "github-credential-unavailable", "valuesPrinted": False}) + continue + basic = base64.b64encode(("x-access-token:" + token).encode()).decode() + try: + completed = subprocess.run( + ["git", "-c", "http.extraHeader=Authorization: Basic " + basic, "ls-remote", "--exit-code", clone_url, "refs/heads/" + branch], + capture_output=True, + text=True, + timeout=timeout, + ) + error = " ".join(completed.stderr.split())[-800:] + rows.append({"key": repo["key"], "repository": repo["upstream"]["repository"], "branch": branch, "ok": completed.returncode == 0, "code": "github-upstream-available" if completed.returncode == 0 else "github-repository-not-found-or-no-access", "exitCode": completed.returncode, "errorTail": error, "valuesPrinted": False}) + except subprocess.TimeoutExpired: + rows.append({"key": repo["key"], "repository": repo["upstream"]["repository"], "branch": branch, "ok": False, "code": "github-upstream-timeout", "valuesPrinted": False}) +payload = {"ok": bool(rows) and all(row["ok"] for row in rows), "mutation": False, "repositories": rows, "valuesPrinted": False} +print(json.dumps(payload, ensure_ascii=False)) +sys.exit(0 if payload["ok"] else 1) +PY +} + run_status() { selector="app.kubernetes.io/name=$UNIDESK_GITEA_APP_NAME,app.kubernetes.io/component=gitea" capture_json namespace kubectl get namespace "$UNIDESK_GITEA_NAMESPACE" @@ -1125,6 +1159,7 @@ case "$UNIDESK_GITEA_ACTION" in apply) run_apply ;; status) run_status ;; validate) run_validate ;; + mirror-preflight) run_mirror_preflight ;; mirror-bootstrap) run_mirror_bootstrap ;; mirror-sync) run_mirror_sync ;; mirror-status) run_mirror_status ;; diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index 5cfb4e25..b737b545 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -123,6 +123,39 @@ export async function runPlatformInfraGiteaCommand(config: UniDeskConfig, args: }; } +export async function runGiteaMirrorBootstrapPreflight( + config: UniDeskConfig, + targetId: string, + repoKey: string, +): Promise> { + const gitea = readGiteaConfig(); + const target = resolveCommandTarget(gitea, targetId); + const repos = selectedRepositories(gitea, target, repoKey); + const credentials = credentialSummaries(gitea, repos); + const blockers = credentialBlockers(credentials); + if (blockers.length > 0) { + return { ok: false, mutation: false, code: "github-credential-unavailable", blockers, repositories: repos.map((repo) => repositorySummary(gitea, repo)), valuesPrinted: false }; + } + const secrets = mirrorPreflightSecrets(gitea, repos); + const result = await capture(config, target.route, ["sh"], remoteScript("mirror-preflight", gitea, target, "", { targetId, repoKey, confirm: false, dryRun: true, wait: false, full: false, raw: false }, { repos, secrets }).script); + const parsed = parseJsonOutput(result.stdout); + return parsed ?? { ok: false, mutation: false, code: "github-upstream-preflight-failed", remote: compactCapture(result, { full: true }), valuesPrinted: false }; +} + +function mirrorPreflightSecrets(gitea: GiteaConfig, repositories: GiteaMirrorRepository[]): MirrorSecrets { + const githubTokens: Record = {}; + for (const repo of repositories) { + const github = githubCredentialForRepo(gitea, repo); + const githubPath = credentialPath(gitea, github.sourceRef); + if (!existsSync(githubPath)) throw new Error(`${github.sourceRef} is missing; cannot probe GitHub upstream`); + const githubEnv = parseGithubCredentialFile(githubPath, github); + const githubToken = githubEnv[github.sourceKey]; + if (!githubToken) throw new Error(`${github.sourceRef} must contain ${github.sourceKey}`); + githubTokens[github.gitFetchCredential.secretRef.key] = githubToken; + } + return { adminUsername: "", adminPassword: "", githubTokens, webhookSecret: "" }; +} + export function giteaHelp(scope?: string): Record { if (scope === "platform-bootstrap") { return { @@ -1116,7 +1149,7 @@ function envVars(gitea: GiteaConfig, target: GiteaTarget): string { value: ${yamlQuote(value)}`).join("\n"); } -function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): { script: string; payloadSummary: Record } { +function remoteScript(action: "apply" | "status" | "validate" | "mirror-preflight" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): { script: string; payloadSummary: Record } { const frpcExposure = targetFrpcExposure(gitea, target); const sync = targetWebhookSync(gitea, target); const env: Record = { diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts index bb8d7bd8..6a00d504 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import type { GiteaConfig, GiteaMirrorRepository } from "./platform-infra-gitea-config"; import { renderMirrorBootstrap } from "./platform-infra-gitea-render"; -import { resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap"; +import { pacBootstrapYamlMatchFailure, projectPacBootstrapResult, renderPacBootstrap, resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap"; import { runPlatformInfraPipelinesAsCodeCommand } from "./platform-infra-pipelines-as-code"; const mirror: GiteaMirrorRepository = { @@ -78,3 +78,53 @@ test("platform bootstrap help advertises the composite entry before low-level ap expect(usage[0]).toContain("pipelines-as-code bootstrap"); expect(usage).toContain("bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target --consumer --confirm"); }); + +test("PaC bootstrap projects typed stages and compact JSON", () => { + const result = { + ok: true, + action: "platform-infra-pipelines-as-code-bootstrap", + mutation: false, + mode: "dry-run", + target: { id: "NC01" }, + consumer: { id: "widgets", node: "NC01", lane: "v01" }, + repository: { id: "widgets", namespace: "ci" }, + mirrorRepository: { key: mirror.key, upstreamRepository: mirror.upstream.repository, upstreamBranch: mirror.upstream.branch, owner: mirror.gitea.owner, repo: mirror.gitea.name }, + githubPreflight: { ok: true, mutation: false, repositories: [{ key: mirror.key, code: "github-upstream-available", ok: true }] }, + giteaBootstrap: { ok: true, mutation: false, mode: "dry-run", blockers: [] }, + pipelinesAsCodeApply: { ok: true, mutation: false, mode: "dry-run", remote: { ok: true, preflight: { repositoryExists: true } } }, + }; + const projection = projectPacBootstrapResult(result); + expect(projection.ok).toBe(true); + expect(JSON.stringify(projection)).not.toContain("pipelinesAsCodeApply"); + expect((projection.stages as Record[]).map((item) => item.id)).toEqual(["gitea-init", "pac-controller", "tekton-consumer", "gitops-argo"]); + const rendered = renderPacBootstrap(result).renderedText; + expect(rendered).toContain("PRECONDITIONS"); + expect(rendered).toContain("yaml-repository-exact-match"); + expect(rendered).toContain("confirm:"); +}); + +test("PaC bootstrap returns typed YAML match failures without mutation", () => { + const result = pacBootstrapYamlMatchFailure("NC01", "widgets", new Error("cannot resolve repository: 2 exact matches")); + expect(projectPacBootstrapResult(result)).toBe(result); + expect(result.ok).toBe(false); + expect(result.mutation).toBe(false); + expect((result.blockers as Record[])[0]?.code).toBe("yaml-repository-multiple-matches"); +}); + +test("PaC bootstrap distinguishes GitHub access failure from Gitea and PaC stages", () => { + const projection = projectPacBootstrapResult({ + ok: false, + action: "platform-infra-pipelines-as-code-bootstrap", + mutation: false, + mode: "dry-run", + target: { id: "NC01" }, + consumer: { id: "widgets", node: "NC01", lane: "v01" }, + repository: { id: "widgets", namespace: "ci" }, + mirrorRepository: { key: mirror.key, upstreamRepository: mirror.upstream.repository, upstreamBranch: mirror.upstream.branch, owner: mirror.gitea.owner, repo: mirror.gitea.name }, + githubPreflight: { ok: false, mutation: false, repositories: [{ key: mirror.key, code: "github-repository-not-found-or-no-access", errorTail: "repository unavailable" }] }, + giteaBootstrap: { ok: false, mutation: false, mode: "skipped", githubPreflight: { ok: false, repositories: [{ code: "github-repository-not-found-or-no-access", errorTail: "repository unavailable" }] } }, + pipelinesAsCodeApply: { ok: false, mutation: false, mode: "skipped" }, + }); + expect((projection.blockers as Record[])).toEqual([{ code: "github-repository-not-found-or-no-access", stage: "github-upstream", reason: "repository unavailable" }]); + expect((projection.next as Record).kind).toBe("read-only-status"); +}); diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts index 29451758..053c0b05 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts @@ -1,4 +1,5 @@ import type { GiteaConfig, GiteaMirrorRepository } from "./platform-infra-gitea-config"; +import type { RenderedCliResult } from "./output"; export interface PacBootstrapRepositoryIdentity { readonly id: string; @@ -28,6 +29,178 @@ export function resolvePacBootstrapMirrorRepository( return candidates[0]; } +export function pacBootstrapYamlMatchFailure( + targetId: string, + consumerId: string, + error: unknown, +): Record { + const reason = error instanceof Error ? error.message : String(error); + const matchCount = reason.includes("no exact match") ? 0 : Number.parseInt(reason.match(/(\d+) exact matches/u)?.[1] ?? "-1", 10); + const status = `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId} --consumer ${consumerId}`; + return { + ok: false, + action: "platform-infra-pipelines-as-code-bootstrap", + mutation: false, + mode: "precondition-failed", + target: { id: targetId }, + consumer: { id: consumerId }, + preconditions: [ + { id: "yaml-exact-match", ok: false, code: matchCount === 0 ? "yaml-repository-no-match" : "yaml-repository-multiple-matches", matchCount, detail: reason }, + ], + stages: [], + blockers: [{ code: matchCount === 0 ? "yaml-repository-no-match" : "yaml-repository-multiple-matches", stage: "yaml-exact-match", reason }], + next: { command: status, kind: "read-only-status" }, + valuesPrinted: false, + }; +} + +export function projectPacBootstrapResult(result: Record): Record { + if (Array.isArray(result.preconditions) && Array.isArray(result.stages) && Array.isArray(result.blockers)) return result; + const target = record(result.target); + const consumer = record(result.consumer); + const repository = record(result.repository); + const mirror = record(result.mirrorRepository); + const githubPreflight = record(result.githubPreflight); + const githubRepository = records(githubPreflight.repositories)[0] ?? {}; + const gitea = record(result.giteaBootstrap); + const apply = record(result.pipelinesAsCodeApply); + const remote = record(apply.remote); + const preflight = record(remote.preflight); + const blockers = bootstrapBlockers(githubPreflight, gitea, apply, remote); + const dryRun = result.mode === "dry-run"; + const repositoryMissing = remote.error === "gitea-repository-missing"; + const githubReady = githubPreflight.ok === true; + const giteaReady = gitea.ok === true; + const applyReady = apply.ok === true || (dryRun && repositoryMissing); + const next = bootstrapNext(result, blockers); + return { + ok: blockers.length === 0 && giteaReady && applyReady, + action: result.action, + mutation: result.mutation === true, + mode: result.mode, + target: { id: target.id }, + consumer: { id: consumer.id, node: consumer.node, lane: consumer.lane }, + sourceAuthority: { + upstreamRepository: mirror.upstreamRepository, + upstreamBranch: mirror.upstreamBranch, + github: { ok: githubReady, code: stringValue(githubRepository.code, githubReady ? "github-upstream-available" : "github-upstream-preflight-failed") }, + giteaRepository: `${stringValue(mirror.owner)}/${stringValue(mirror.repo)}`, + giteaKey: mirror.key, + valuesPrinted: false, + }, + preconditions: [ + { id: "yaml-exact-match", ok: true, code: "yaml-repository-exact-match", matchCount: 1 }, + { id: "github-upstream", ok: githubReady, code: stringValue(githubRepository.code, githubReady ? "github-upstream-available" : "github-upstream-preflight-failed") }, + { id: "gitea-repository", ok: giteaReady, code: giteaReady ? (dryRun ? "gitea-bootstrap-planned" : "gitea-bootstrap-ready") : "gitea-bootstrap-failed" }, + ], + stages: [ + { id: "gitea-init", ok: giteaReady, state: giteaReady ? (dryRun ? "planned" : "ready") : "failed", mutation: gitea.mutation === true }, + { id: "pac-controller", ok: applyReady, state: repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, + { id: "tekton-consumer", ok: applyReady, state: repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, + { id: "gitops-argo", ok: applyReady, state: repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, + ], + repository: { id: repository.id, namespace: repository.namespace }, + blockers, + next, + valuesPrinted: false, + }; +} + +export function renderPacBootstrap(result: Record): RenderedCliResult { + const projection = projectPacBootstrapResult(result); + const target = record(projection.target); + const consumer = record(projection.consumer); + const source = record(projection.sourceAuthority); + const preconditions = records(projection.preconditions); + const stages = records(projection.stages); + const blockers = records(projection.blockers); + const next = record(projection.next); + const lines = [ + "PLATFORM-INFRA PAC / TEKTON / GITOPS BOOTSTRAP", + ...table(["TARGET", "CONSUMER", "MODE", "MUTATION", "OK"], [[stringValue(target.id), stringValue(consumer.id), stringValue(projection.mode), boolText(projection.mutation), boolText(projection.ok)]]), + "", + "SOURCE AUTHORITY", + ...table(["UPSTREAM", "BRANCH", "GITEA_KEY", "GITEA_REPOSITORY"], [[stringValue(source.upstreamRepository), stringValue(source.upstreamBranch), stringValue(source.giteaKey), stringValue(source.giteaRepository)]]), + "", + "PRECONDITIONS", + ...table(["PRECONDITION", "OK", "CODE"], preconditions.map((item) => [stringValue(item.id), boolText(item.ok), stringValue(item.code)])), + "", + "STAGES", + ...table(["STAGE", "OK", "STATE", "MUTATION"], stages.map((item) => [stringValue(item.id), boolText(item.ok), stringValue(item.state), boolText(item.mutation)])), + ...(blockers.length === 0 ? [] : ["", "BLOCKERS", ...blockers.map((item) => ` ${stringValue(item.code)}: ${compactLine(stringValue(item.reason))}`)]), + "", + "NEXT", + ` ${stringValue(next.kind)}: ${stringValue(next.command)}`, + ]; + return { ok: projection.ok === true, command: "platform-infra pipelines-as-code bootstrap", renderedText: lines.join("\n"), contentType: "text/plain", projection }; +} + +function bootstrapBlockers(githubPreflight: Record, gitea: Record, apply: Record, remote: Record): Record[] { + const blockers: Record[] = []; + const githubRepository = records(githubPreflight.repositories)[0] ?? {}; + if (githubPreflight.ok === false) { + blockers.push({ code: stringValue(githubRepository.code, stringValue(githubPreflight.code, "github-upstream-preflight-failed")), stage: "github-upstream", reason: stringValue(githubRepository.errorTail, errorText(githubPreflight)) }); + return blockers; + } + for (const code of Array.isArray(gitea.blockers) ? gitea.blockers.map(String) : []) { + blockers.push({ code: code.includes("credential") ? "source-credential-unavailable" : "github-upstream-unavailable", stage: "github-upstream", reason: code }); + } + if (gitea.ok !== true && blockers.length === 0) blockers.push({ code: "gitea-bootstrap-failed", stage: "gitea-init", reason: errorText(gitea) }); + if (apply.ok !== true && remote.error !== "gitea-repository-missing") { + blockers.push({ code: remote.error === "gitea-credential-unavailable" ? "gitea-credential-unavailable" : "pac-apply-failed", stage: "pac-controller", reason: errorText(remote, apply) }); + } + return blockers; +} + +function bootstrapNext(result: Record, blockers: Record[]): Record { + const target = record(result.target); + const consumer = record(result.consumer); + const source = record(result.mirrorRepository); + if (blockers.length > 0) { + const yamlFailure = blockers.some((item) => String(item.code).startsWith("yaml-")); + return yamlFailure + ? { kind: "fix-yaml", command: "检查 config/platform-infra/gitea.yaml 与 config/platform-infra/pipelines-as-code.yaml 的 target、owner/name 和 read URL 精确匹配" } + : { kind: "read-only-status", command: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${target.id} --consumer ${consumer.id}` }; + } + if (result.mode === "dry-run") return { kind: "confirm", command: `bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target ${target.id} --consumer ${consumer.id} --confirm` }; + return { kind: "source-pr-merge", command: `合并 ${source.upstreamRepository}@${source.upstreamBranch} 的 GitHub PR,由自动链继续交付` }; +} + +function errorText(...values: Record[]): string { + for (const value of values) { + for (const candidate of [value.error, value.stderrTail, record(value.remote).error, record(value.remote).stderrTail]) { + if (typeof candidate === "string" && candidate.length > 0) return candidate; + } + } + return "unknown-bootstrap-failure"; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function records(value: unknown): Record[] { + return Array.isArray(value) ? value.map(record) : []; +} + +function stringValue(value: unknown, fallback = "-"): string { + return typeof value === "string" && value.length > 0 ? value : value === undefined || value === null ? fallback : String(value); +} + +function boolText(value: unknown): string { + return value === true ? "true" : value === false ? "false" : "-"; +} + +function compactLine(value: string): string { + return value.replace(/\s+/gu, " ").trim().slice(0, 240) || "-"; +} + +function table(headers: string[], rows: string[][]): string[] { + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length))); + const format = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ").trimEnd(); + return [format(headers), format(widths.map((width) => "-".repeat(width))), ...rows.map(format)]; +} + function repositoryUrlIdentity(value: string): string { const parsed = new URL(value); const path = parsed.pathname.replace(/\/+$/u, ""); diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 6d7b6089..672b985a 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -28,9 +28,14 @@ import { } from "./platform-infra-pipelines-as-code-source-artifact"; import { pacAdmissionDesiredIdentity } from "./platform-infra-pac-provenance"; import { pacConsumerRbacDesiredIdentity, renderPacConsumerRbacDesiredFragment } from "./platform-infra-pac-consumer-rbac"; -import { runPlatformInfraGiteaCommand } from "./platform-infra-gitea"; +import { runGiteaMirrorBootstrapPreflight, runPlatformInfraGiteaCommand } from "./platform-infra-gitea"; import { readGiteaConfig } from "./platform-infra-gitea-config"; -import { resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap"; +import { + pacBootstrapYamlMatchFailure, + projectPacBootstrapResult, + renderPacBootstrap, + resolvePacBootstrapMirrorRepository, +} from "./platform-infra-pipelines-as-code-bootstrap"; const configFile = rootPath("config", "platform-infra", "pipelines-as-code.yaml"); const configLabel = "config/platform-infra/pipelines-as-code.yaml"; @@ -255,7 +260,7 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf if (action === "bootstrap") { const options = parseApplyOptions(args.slice(1)); const result = await bootstrap(config, options); - return options.json || options.full || options.raw ? result : renderBootstrap(result); + return options.full || options.raw ? result : options.json ? projectPacBootstrapResult(result) : renderPacBootstrap(result); } if (action === "status") { const options = parseCommonOptions(args.slice(1)); @@ -974,11 +979,20 @@ async function bootstrap(config: UniDeskConfig, options: ApplyOptions): Promise< throw new Error(`Pipelines-as-Code consumer ${consumer.id} belongs to ${consumer.node}, not target ${target.id}`); } const repository = resolveRepository(pac, consumer.repositoryRef); - const mirror = resolvePacBootstrapMirrorRepository(readGiteaConfig(), target.id, repository); + let mirror: ReturnType; + try { + mirror = resolvePacBootstrapMirrorRepository(readGiteaConfig(), target.id, repository); + } catch (error) { + return pacBootstrapYamlMatchFailure(target.id, consumer.id, error); + } + const githubPreflight = record(await runGiteaMirrorBootstrapPreflight(config, target.id, mirror.key)); + if (githubPreflight.ok !== true) { + return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, { ok: false, mutation: false, mode: "skipped", githubPreflight, valuesPrinted: false }, { ok: false, mutation: false, mode: "skipped", valuesPrinted: false }); + } const giteaArgs = ["mirror", "bootstrap", "--target", target.id, "--repo", mirror.key, ...(options.confirm ? ["--confirm"] : []), "--full"]; const giteaBootstrap = record(await runPlatformInfraGiteaCommand(config, giteaArgs)); if (options.confirm && giteaBootstrap.ok !== true) { - return bootstrapResult(target, consumer, repository, mirror, options, giteaBootstrap, { + return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, giteaBootstrap, { ok: false, mutation: false, mode: "skipped", @@ -987,7 +1001,7 @@ async function bootstrap(config: UniDeskConfig, options: ApplyOptions): Promise< }); } const pacApply = await apply(config, options); - return bootstrapResult(target, consumer, repository, mirror, options, giteaBootstrap, pacApply); + return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, giteaBootstrap, pacApply); } function bootstrapResult( @@ -996,6 +1010,7 @@ function bootstrapResult( repository: PacRepository, mirror: ReturnType, options: ApplyOptions, + githubPreflight: Record, giteaBootstrap: Record, pacApply: Record, ): Record { @@ -1020,6 +1035,7 @@ function bootstrapResult( repo: mirror.gitea.name, valuesPrinted: false, }, + githubPreflight, steps: [ { id: "gitea-repository", ok: giteaBootstrap.ok === true, mutation: giteaBootstrap.mutation === true, mode: giteaBootstrap.mode, valuesPrinted: false }, { id: "pac-tekton-gitops", ok: pacReady, mutation: pacApply.mutation === true, mode: pacApply.mode, repositoryMissing, valuesPrinted: false }, @@ -2207,43 +2223,6 @@ function renderApply(result: Record): RenderedCliResult { return rendered(result, "platform-infra pipelines-as-code apply", lines); } -function renderBootstrap(result: Record): RenderedCliResult { - const target = record(result.target); - const consumer = record(result.consumer); - const mirror = record(result.mirrorRepository); - const steps = arrayRecords(result.steps); - const pacApply = record(result.pipelinesAsCodeApply); - const pacRemote = record(pacApply.remote); - const giteaBootstrap = record(result.giteaBootstrap); - const next = record(result.next); - const errorValue = [pacRemote.error, giteaBootstrap.error, pacApply.error] - .find((value): value is string => typeof value === "string" && value.length > 0) ?? ""; - const error = errorValue.length === 0 ? "" : compactLine(errorValue); - const lines = [ - "PLATFORM-INFRA PAC / TEKTON / GITOPS BOOTSTRAP", - ...table(["TARGET", "CONSUMER", "MODE", "MUTATION", "OK"], [[stringValue(target.id), stringValue(consumer.id), stringValue(result.mode), boolText(result.mutation), boolText(result.ok)]]), - "", - "SOURCE AUTHORITY", - ...table(["GITEA_KEY", "GITEA_REPOSITORY", "UPSTREAM", "BRANCH"], [[stringValue(mirror.key), `${stringValue(mirror.owner)}/${stringValue(mirror.repo)}`, stringValue(mirror.upstreamRepository), stringValue(mirror.upstreamBranch)]]), - "", - "STEPS", - ...table(["STEP", "OK", "MODE", "MUTATION", "DETAIL"], steps.map((step) => [ - stringValue(step.id), - boolText(step.ok), - stringValue(step.mode), - boolText(step.mutation), - step.repositoryMissing === true ? "repository-will-be-created-on-confirm" : "ready", - ])), - ...(error.length === 0 ? [] : ["", `ERROR ${error}`]), - "", - "NEXT", - ...(result.mode === "dry-run" - ? [` confirm: ${stringValue(next.confirm)}`, ` boundary: ${stringValue(next.boundary)}`] - : [` delivery: ${stringValue(next.deliveryTrigger)}`, ` investigate: ${stringValue(next.investigate)}`]), - ]; - return rendered(result, "platform-infra pipelines-as-code bootstrap", lines); -} - export function renderStatus(result: Record): RenderedCliResult { const summary = record(result.summary); const consumer = record(result.consumer); From 9b6921adebe554e2ff209900d34037fd3d90672b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 20:56:31 +0200 Subject: [PATCH 09/43] =?UTF-8?q?feat:=20=E5=A3=B0=E6=98=8E=20PikaOA=20?= =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=B9=B3=E5=8F=B0=E4=BA=A4=E4=BB=98=E5=9F=BA?= =?UTF-8?q?=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/pikaoa.yaml | 164 +++++++++++++++++++ config/platform-db/postgres-pk01.yaml | 62 +++++++ config/platform-infra/gitea.yaml | 46 ++++++ config/platform-infra/pipelines-as-code.yaml | 63 +++++++ config/secrets-distribution.yaml | 52 ++++++ 5 files changed, 387 insertions(+) create mode 100644 config/pikaoa.yaml diff --git a/config/pikaoa.yaml b/config/pikaoa.yaml new file mode 100644 index 00000000..9e55d3e9 --- /dev/null +++ b/config/pikaoa.yaml @@ -0,0 +1,164 @@ +version: 1 +kind: pikaoa-platform-delivery + +metadata: + id: pikaoa-enterprise + owner: unidesk + repository: pikaInc/pikaoa + spec: PJ2026-03 + relatedIssues: + - 1952 + +defaults: + targetId: NC01 + +modules: + builtIn: + - identity + - partners + - contracts + - invoices + - audit + extensionContract: + registration: module-descriptor + persistence: schema-owned-migrations + api: versioned-http-routes + events: transactional-outbox + workers: named-consumer + +delivery: + targets: + NC01: + node: NC01 + lane: pikaoa + route: NC01:k3s + namespace: pikaoa + ci: + namespace: pikaoa-ci + pipeline: pikaoa-nc01-pac + pipelineRunPrefix: pikaoa-nc01 + serviceAccountName: pikaoa-nc01-tekton-runner + serviceAccountAutomount: false + roleBindingName: pikaoa-nc01-tekton-runner + workspaceSize: 8Gi + pipelineTimeout: 20m0s + toolsImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + buildkitImage: 127.0.0.1:5000/hwlab/buildkit:rootless + source: + repository: pikaInc/pikaoa + worktreeRemote: https://github.com/pikaInc/pikaoa.git + branch: master + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + snapshotPrefix: refs/unidesk/snapshots/gitea-actions/pikaoa-master-nc01 + build: + dockerfiles: + api: deploy/api.Dockerfile + worker: deploy/worker.Dockerfile + web: deploy/web.Dockerfile + images: + api: 127.0.0.1:5000/pikaoa/api + worker: 127.0.0.1:5000/pikaoa/worker + web: 127.0.0.1:5000/pikaoa/web + networkMode: host + proxy: + http: http://127.0.0.1:10808 + https: http://127.0.0.1:10808 + all: http://127.0.0.1:10808 + noProxy: + - localhost + - 127.0.0.1 + - "::1" + - 127.0.0.1:5000 + - localhost:5000 + - .svc + - .svc.cluster.local + - .cluster.local + - hyueapi.com + - .hyueapi.com + gitops: + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + writeUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + branch: nc01-pikaoa-gitops + manifestPath: deploy/gitops/nc01/resources.yaml + releaseStatePath: deploy/gitops-state/nc01/release.json + credentialSecretName: pac-gitea-pikaoa-nc01 + credentialTokenKey: token + credentialUsername: unidesk-admin + author: + name: PikaOA NC01 CI + email: pikaoa-nc01-ci@unidesk.local + runtime: + workloads: + api: + kind: Deployment + name: pikaoa-api + replicas: 2 + serviceName: pikaoa-api + port: 8080 + healthPath: /healthz + metricsPath: /metrics + worker: + kind: Deployment + name: pikaoa-worker + replicas: 1 + healthPort: 8081 + healthPath: /healthz + metricsPath: /metrics + web: + kind: Deployment + name: pikaoa-web + replicas: 2 + serviceName: pikaoa-web + port: 8080 + healthPath: /healthz + secret: + configRef: config/secrets-distribution.yaml + declarationId: pikaoa-runtime + database: + configRef: config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-database-url + observability: + configRef: config/platform-infra/observability.yaml + otlpHttpEndpoint: http://otel-collector.platform-infra.svc.cluster.local:4318 + prometheusScrape: true + propagation: + - tracecontext + - baggage + exporterFailure: + warning: true + blocking: false + bootstrapUsers: + administrator: + username: admin + passwordSourceRef: ~/.unidesk/.env/pikaoa-admin-password.txt + employee: + username: employee + passwordSourceRef: ~/.unidesk/.env/pikaoa-employee-password.txt + publicExposure: + enabled: true + publicBaseUrl: https://oa.pikapython.com + dns: + hostname: oa.pikapython.com + expectedA: 82.156.23.220 + resolvers: + - 1.1.1.1 + - 8.8.8.8 + - 223.5.5.5 + - 114.114.114.114 + frpc: + deploymentName: pikaoa-frpc + configMapName: pikaoa-frpc-config + configKey: frpc.toml + image: 127.0.0.1:5000/hwlab/frpc:v0.68.1 + serverAddr: 82.156.23.220 + serverPort: 22000 + proxyName: pikaoa-nc01-web + remotePort: 22096 + localIP: pikaoa-web.pikaoa.svc.cluster.local + localPort: 8080 + tokenEnvName: FRP_TOKEN + tokenTargetKey: FRP_TOKEN + pk01: + route: PK01 + caddyConfigPath: /etc/caddy/Caddyfile + caddyServiceName: caddy + responseHeaderTimeoutSeconds: 60 diff --git a/config/platform-db/postgres-pk01.yaml b/config/platform-db/postgres-pk01.yaml index 949b05c9..8795f1c6 100644 --- a/config/platform-db/postgres-pk01.yaml +++ b/config/platform-db/postgres-pk01.yaml @@ -359,6 +359,21 @@ postgres: user: hwlab_jd01_v03_app address: 74.48.78.17/32 method: scram-sha-256 + - type: hostssl + database: pikaoa + user: pikaoa + address: 10.0.8.0/22 + method: scram-sha-256 + - type: hostssl + database: pikaoa + user: pikaoa + address: 74.48.78.17/32 + method: scram-sha-256 + - type: hostssl + database: pikaoa + user: pikaoa + address: 152.53.229.148/32 + method: scram-sha-256 secrets: source: master-local @@ -462,6 +477,20 @@ secrets: HWLAB_JD01_V03_DB_NAME: hwlab_jd01_v03 randomHex: HWLAB_JD01_V03_DB_PASSWORD: 32 + - name: pikaoa-db-credentials + sourceRef: platform-db/pikaoa-db.env + type: env + requiredKeys: + - PIKAOA_DB_USER + - PIKAOA_DB_PASSWORD + - PIKAOA_DB_NAME + createIfMissing: + enabled: true + values: + PIKAOA_DB_USER: pikaoa + PIKAOA_DB_NAME: pikaoa + randomHex: + PIKAOA_DB_PASSWORD: 32 objects: roles: - name: sub2api @@ -527,6 +556,15 @@ objects: createdb: false createrole: false superuser: false + - name: pikaoa + passwordRef: + sourceRef: platform-db/pikaoa-db.env + key: PIKAOA_DB_PASSWORD + login: true + attributes: + createdb: false + createrole: false + superuser: false databases: - name: sub2api owner: sub2api @@ -563,6 +601,11 @@ objects: encoding: UTF8 locale: C.UTF-8 extensions: [] + - name: pikaoa + owner: pikaoa + encoding: UTF8 + locale: C.UTF-8 + extensions: [] exports: connectionStrings: @@ -705,6 +748,22 @@ exports: - scope: hwlab-jd01-v03 secret: hwlab-v03-openfga key: DATASTORE_URI + - name: pikaoa-database-url + sourceSecretRef: platform-db/pikaoa-db.env + render: + envKey: DATABASE_URL + format: postgresql://$(PIKAOA_DB_USER):$(PIKAOA_DB_PASSWORD)@$(PGHOST):$(PGPORT)/$(PIKAOA_DB_NAME)?sslmode=require + variables: + PGHOST: 82.156.23.220 + PGPORT: "5432" + writeToSecretSource: + sourceRef: platform-infra/pikaoa.env + key: DATABASE_URL + mode: update-or-insert + consumers: + - scope: pikaoa + secret: pikaoa-runtime + key: DATABASE_URL backup: phase: minimum-restoreable @@ -750,6 +809,9 @@ observability: - kind: psql-app-role database: hwlab_d601_v03 user: hwlab_d601_v03_app + - kind: psql-app-role + database: pikaoa + user: pikaoa - kind: disk-free path: /var/lib/postgresql/16/main minFreeGiB: 10 diff --git a/config/platform-infra/gitea.yaml b/config/platform-infra/gitea.yaml index 684cf036..75de5401 100644 --- a/config/platform-infra/gitea.yaml +++ b/config/platform-infra/gitea.yaml @@ -400,6 +400,52 @@ sourceAuthority: naming: gitea-actions-immutable-source prefix: refs/unidesk/snapshots/gitea-actions/selfmedia-master-nc01 legacyGitMirror: null + - key: pikaoa-nc01 + targetId: NC01 + credentialOverride: + github: + transport: https-token + sourceRef: pikainc-selfmedia-gh-token.txt + sourceKey: GH_TOKEN + format: raw-token + requiredFor: + - upstream-mirror + - mirror-sync + - managed-repository-fetch + - github-head-observe + - github-hooks-list + - github-hooks-reconcile + permissions: + contents: read + metadata: read + webhooks: read-write + gitFetchCredential: + apiVersion: unidesk.ai/v1 + kind: GitFetchCredential + authMode: github-https-token + host: github.com + secretRef: + namespace: devops-infra + name: gitea-github-sync-secrets + key: github-token-pikainc-pikaoa + upstream: + repository: pikaInc/pikaoa + cloneUrl: https://github.com/pikaInc/pikaoa.git + branch: master + visibility: private + gitea: + owner: mirrors + name: pikainc-pikaoa + mirrorMode: controlled-push + publicRead: false + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + gitops: + branch: nc01-pikaoa-gitops + flushDisposition: gitea-writeback + snapshot: + naming: gitea-actions-immutable-source + prefix: refs/unidesk/snapshots/gitea-actions/pikaoa-master-nc01 + legacyGitMirror: null targets: - id: JD01 diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index b798ac44..a7cc847e 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -211,6 +211,45 @@ repositories: runtime_service_port: "4317" health_path: /healthz health_url: http://selfmedia.selfmedia.svc.cluster.local:4317/healthz + - id: pikaoa-nc01 + name: pikaoa-nc01 + namespace: pikaoa-ci + providerType: gitea + url: https://gitea.pikapython.com/mirrors/pikainc-pikaoa + cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + owner: mirrors + repo: pikainc-pikaoa + secretName: pac-gitea-pikaoa-nc01 + tokenKey: token + webhookSecretKey: webhook.secret + concurrencyLimit: 1 + params: + git_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + source_branch: master + source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/pikaoa-master-nc01 + node: NC01 + pipeline_name: pikaoa-nc01-pac + pipeline_run_prefix: pikaoa-nc01 + service_account: pikaoa-nc01-tekton-runner + pipeline_timeout: 20m0s + workspace_pvc_size: 8Gi + image_repository: 127.0.0.1:5000/pikaoa/api + api_image_repository: 127.0.0.1:5000/pikaoa/api + worker_image_repository: 127.0.0.1:5000/pikaoa/worker + web_image_repository: 127.0.0.1:5000/pikaoa/web + registry_probe_base: http://127.0.0.1:5000 + gitops_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + gitops_write_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + gitops_branch: nc01-pikaoa-gitops + gitops_username: unidesk-admin + gitops_secret_name: pac-gitea-pikaoa-nc01 + gitops_manifest_path: deploy/gitops/nc01/resources.yaml + runtime_namespace: pikaoa + runtime_deployment: pikaoa-api + runtime_service: pikaoa-web + runtime_service_port: "8080" + health_path: /healthz + health_url: http://pikaoa-web.pikaoa.svc.cluster.local:8080/healthz consumers: - extends: templates.consumers.agentrunV02 variables: @@ -372,6 +411,30 @@ consumers: hostNetwork: true dnsPolicy: ClusterFirstWithHostNet fsGroup: 1000 + - id: pikaoa-nc01 + repositoryRef: pikaoa-nc01 + node: NC01 + lane: pikaoa + namespace: pikaoa-ci + pipeline: pikaoa-nc01-pac + pipelineRunPrefix: pikaoa-nc01 + argoNamespace: argocd + argoApplication: pikaoa-nc01 + closeoutGitOpsMirrorFlush: false + argoBootstrap: + project: default + repoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + targetRevision: nc01-pikaoa-gitops + path: deploy/gitops/nc01 + destinationNamespace: pikaoa + automated: true + repositoryCredential: + secretName: argocd-repo-pikaoa-nc01 + username: unidesk-admin + runnerServiceAccount: + name: pikaoa-nc01-tekton-runner + automountServiceAccountToken: false + roleBindingName: pikaoa-nc01-tekton-runner templates: repositories: agentrunV02: diff --git a/config/secrets-distribution.yaml b/config/secrets-distribution.yaml index 763323c0..ae2a973a 100644 --- a/config/secrets-distribution.yaml +++ b/config/secrets-distribution.yaml @@ -87,6 +87,17 @@ sources: SUB2RANK_ADMIN_TOKEN: bytes: 32 prefix: srk_ + - sourceRef: platform-infra/pikaoa.env + type: env + requiredKeys: + - DATABASE_URL + - PIKAOA_SESSION_SECRET + createIfMissing: + enabled: true + randomBase64Url: + PIKAOA_SESSION_SECRET: + bytes: 32 + prefix: poa_ - sourceRef: hwlab/jd01-v03-opencode.env type: env requiredKeys: @@ -120,6 +131,22 @@ sources: createIfMissing: enabled: false externalFiles: + - sourceRef: ~/.unidesk/.env/pikaoa-admin-password.txt + type: raw-file + required: true + createIfMissing: + enabled: true + randomBase64Url: + bytes: 24 + prefix: poa_ + - sourceRef: ~/.unidesk/.env/pikaoa-employee-password.txt + type: raw-file + required: true + createIfMissing: + enabled: true + randomBase64Url: + bytes: 24 + prefix: poa_ - sourceRef: ~/.env/TOKEN type: raw-file required: true @@ -165,8 +192,33 @@ targets: namespace: selfmedia scope: selfmedia enabled: true + - id: pikaoa-nc01 + route: NC01:k3s + namespace: pikaoa + scope: pikaoa + enabled: true kubernetesSecrets: + - name: pikaoa-runtime + targetId: pikaoa-nc01 + secretName: pikaoa-runtime + type: Opaque + data: + - sourceRef: platform-infra/pikaoa.env + sourceKey: DATABASE_URL + targetKey: DATABASE_URL + - sourceRef: platform-infra/pikaoa.env + sourceKey: PIKAOA_SESSION_SECRET + targetKey: PIKAOA_SESSION_SECRET + - sourceRef: ~/.unidesk/.env/pikaoa-admin-password.txt + sourceKey: contents + targetKey: PIKAOA_BOOTSTRAP_ADMIN_PASSWORD + - sourceRef: ~/.unidesk/.env/pikaoa-employee-password.txt + sourceKey: contents + targetKey: PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD + - sourceRef: platform-infra/pk01-frp.env + sourceKey: FRP_TOKEN + targetKey: FRP_TOKEN - name: selfmedia-runtime targetId: selfmedia-nc01 secretName: selfmedia-runtime From 88eb8fdb6c8cf80bf26795168239649dc6738d08 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 20:57:05 +0200 Subject: [PATCH 10/43] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=20PostgreSQL=20?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/pikaoa-enterprise-platform.md | 3 + scripts/src/platform-db.ts | 138 +++++++++++++++++++++- 2 files changed, 138 insertions(+), 3 deletions(-) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index e340c520..01e22dc5 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -39,6 +39,9 @@ #### R1.5.2 [in_progress] 扩展 NC01 共享 observability,使 OTel/Tempo 与 Prometheus 由同一 YAML-first 控制面管理并提供跨 namespace 指标抓取和有界查询,执行记录见 [pikasTech/unidesk#1956](https://github.com/pikasTech/unidesk/issues/1956),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.2_Task_Report.md)。 +#### R1.5.3 [in_progress] + +修复 `platform-db postgres plan` 默认 30KB JSON 被输出预算截断的可见性缺陷,提供数据库、角色、Secret/export、检查与下一步的紧凑 CLI-first 投影,执行记录沿用 [主 issue](https://github.com/pikasTech/unidesk/issues/1952),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.3_Task_Report.md)。 ### R1.6 审核并合并双仓 PR,执行首次受控 PaC bootstrap,由正常 source PR merge 自动发布,依赖 R1.3-R1.5,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6_Task_Report.md)。 diff --git a/scripts/src/platform-db.ts b/scripts/src/platform-db.ts index 96141acd..95496255 100644 --- a/scripts/src/platform-db.ts +++ b/scripts/src/platform-db.ts @@ -302,7 +302,7 @@ export function platformDbHelp(): unknown { command: "platform-db postgres plan|status|apply|export-secrets", output: "json", usage: [ - `bun scripts/cli.ts platform-db postgres plan --config ${defaultConfigPath}`, + `bun scripts/cli.ts platform-db postgres plan --config ${defaultConfigPath} [--full|--raw]`, `bun scripts/cli.ts platform-db postgres status --config ${defaultConfigPath} [--full|--raw]`, `bun scripts/cli.ts platform-db postgres export-secrets --config ${defaultConfigPath} --dry-run`, `bun scripts/cli.ts platform-db postgres export-secrets --config ${defaultConfigPath} --confirm`, @@ -439,14 +439,15 @@ async function plan(config: UniDeskConfig, options: PlatformDbOptions): Promise< const remote = await remoteFacts(config, pg, null); const facts = remote.parsed; const checks = facts === null ? [] : desiredChecks(pg, facts, secrets); - return { + const desired = desiredSummary(pg, secrets, facts); + const result = { ok: remote.capture.exitCode === 0 && facts !== null && secrets.ok, action: "platform-db-postgres-plan", mutation: false, config: configSummary(pg), secrets: secretSummary(secrets), remoteFacts: facts, - desired: desiredSummary(pg, secrets, facts), + desired, checks, next: { apply: `bun scripts/cli.ts platform-db postgres apply --config ${pg.configPath} --confirm`, @@ -456,6 +457,8 @@ async function plan(config: UniDeskConfig, options: PlatformDbOptions): Promise< remote: compactCapture(remote.capture, { full: options.full || remote.capture.exitCode !== 0 }), ...(options.raw ? { raw: remote.capture } : {}), }; + if (options.full) return result; + return compactPlan(pg, secrets, facts, checks, remote.capture, result.ok); } async function status(config: UniDeskConfig, options: PlatformDbOptions): Promise> { @@ -1071,6 +1074,135 @@ function configSummary(pg: PostgresHostConfig): Record { }; } +function compactPlan( + pg: PostgresHostConfig, + secrets: SecretInspection, + facts: RemoteFacts | null, + checks: Array>, + capture: SshCaptureResult, + ok: boolean, +): Record { + const observedRoles = facts?.postgres.roles ?? []; + const observedDatabases = facts?.postgres.databases ?? []; + return { + ok, + action: "platform-db-postgres-plan", + mutation: false, + target: { + node: pg.node.id, + route: pg.node.route, + mode: pg.node.mode, + }, + config: { + path: pg.configPath, + id: pg.metadata.id, + pgVersion: pg.postgres.package.version, + connectionHost: pg.postgres.network.connectionHost, + publicDns: pg.postgres.network.publicDns, + port: pg.postgres.network.port, + transport: pg.postgres.network.transport, + sslmode: pg.postgres.network.sslmode, + }, + roles: pg.objects.roles.map((role) => ({ + name: role.name, + exists: observedRoles.find((item) => item.name === role.name)?.exists ?? null, + action: observedRoles.some((item) => item.name === role.name && item.exists) ? "none" : "create-or-update", + })), + databases: pg.objects.databases.map((database) => ({ + name: database.name, + owner: database.owner, + exists: observedDatabases.find((item) => item.name === database.name)?.exists ?? null, + action: observedDatabases.some((item) => item.name === database.name && item.exists) ? "none" : "create", + })), + secrets: { + ok: secrets.ok, + root: secrets.root, + entries: secrets.entries.map((entry) => ({ + sourceRef: entry.sourceRef, + missingKeys: entry.missingKeys, + action: entry.action, + })), + valuesPrinted: false, + }, + exports: pg.exports.connectionStrings.map((item) => ({ + name: item.name, + sourceRef: item.sourceSecretRef, + targetRef: item.writeToSecretSource.sourceRef, + key: item.writeToSecretSource.key, + consumerScopes: item.consumers.map((consumer) => consumer.scope), + })), + checks: { + ok: checks.every((check) => check.ok === true), + passed: checks.filter((check) => check.ok === true).length, + total: checks.length, + items: checks.map((check) => ({ + name: check.name, + ok: check.ok, + ...(check.ok === true ? {} : { detail: compactText(JSON.stringify(check)).slice(0, 480) }), + })), + }, + remoteFacts: compactRemoteFacts(facts), + remote: compactPlanCapture(capture), + next: { + apply: `bun scripts/cli.ts platform-db postgres apply --config ${pg.configPath} --confirm`, + status: `bun scripts/cli.ts platform-db postgres status --config ${pg.configPath}`, + full: `bun scripts/cli.ts platform-db postgres plan --config ${pg.configPath} --full`, + raw: `bun scripts/cli.ts platform-db postgres plan --config ${pg.configPath} --raw`, + issues: pg.metadata.relatedIssues.map((issue) => `https://github.com/pikasTech/unidesk/issues/${issue}`), + }, + disclosure: { + compact: true, + omitted: ["package", "host policy", "TLS paths", "backup", "full remote facts"], + fullAvailable: true, + rawAvailable: true, + }, + valuesPrinted: false, + }; +} + +function compactRemoteFacts(facts: RemoteFacts | null): Record | null { + if (facts === null) return null; + return { + ok: facts.ok, + observedAt: facts.observedAt, + host: { + hostname: facts.host.hostname, + cpuCount: facts.host.cpuCount, + memoryTotalMiB: facts.host.memoryTotalMiB, + swapTotalMiB: facts.host.swapTotalMiB, + rootAvailGiB: facts.host.rootAvailGiB, + targetDataAvailGiB: facts.host.targetDataAvailGiB, + }, + network: { + port5432Listening: facts.network.port5432Listening, + dns: facts.network.dns, + }, + repo: { + reachable: facts.repo.reachable, + releaseUrl: facts.repo.releaseUrl, + }, + postgres: { + packageInstalled: facts.postgres.packageInstalled, + serviceActive: facts.postgres.serviceActive, + serviceEnabled: facts.postgres.serviceEnabled, + roleCount: facts.postgres.roles.length, + databaseCount: facts.postgres.databases.length, + serverVersion: facts.postgres.serverVersion, + sslOn: facts.postgres.sslOn, + }, + }; +} + +function compactPlanCapture(result: SshCaptureResult): Record { + return { + exitCode: result.exitCode, + stdoutBytes: Buffer.byteLength(result.stdout, "utf8"), + stderrBytes: Buffer.byteLength(result.stderr, "utf8"), + stdoutTail: result.exitCode === 0 ? "" : result.stdout.slice(-1200), + stderrTail: result.exitCode === 0 ? "" : result.stderr.slice(-1200), + }; +} + function requireMonitorResetConfig(pg: PostgresHostConfig): MonitorResetConfig { const target = pg.managedOperations?.monitorReset; if (target === undefined) throw new Error(`${pg.configPath}.managedOperations.monitorReset is required`); From f516691a6dde4ab6fef81fa8f815fe5d18446416 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 20:58:00 +0200 Subject: [PATCH 11/43] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=20Prometheus=20=E5=8F=AF=E8=A7=82=E6=B5=8B=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/observability.yaml | 40 +++++++++++++++ docs/reference/observability.md | 9 ++++ .../src/platform-infra-observability.test.ts | 9 ++-- .../platform-infra-observability/actions.ts | 3 ++ .../platform-infra-observability/config.ts | 45 ++++++++++++++++- .../platform-infra-observability/options.ts | 37 ++++++++++++-- .../plan-render.ts | 4 ++ .../prometheus.ts | 50 +++++++++++++++++++ .../search-script.ts | 17 ++++++- .../platform-infra-observability/summary.ts | 21 +++++++- .../trace-script.ts | 2 + .../src/platform-infra-observability/types.ts | 30 +++++++++++ 12 files changed, 255 insertions(+), 12 deletions(-) create mode 100644 scripts/src/platform-infra-observability/prometheus.ts diff --git a/config/platform-infra/observability.yaml b/config/platform-infra/observability.yaml index 3bb2e19a..045b2760 100644 --- a/config/platform-infra/observability.yaml +++ b/config/platform-infra/observability.yaml @@ -21,6 +21,10 @@ images: repository: docker.m.daocloud.io/grafana/tempo tag: 2.8.1 pullPolicy: IfNotPresent + prometheus: + repository: docker.m.daocloud.io/prom/prometheus + tag: v3.5.0 + pullPolicy: IfNotPresent targets: - id: NC01 @@ -66,6 +70,38 @@ traceBackend: mode: emptyDir retention: 24h +metricsBackend: + type: prometheus + enabled: true + deploymentName: prometheus + serviceName: prometheus + configMapName: prometheus-config + serviceAccountName: prometheus + clusterRoleName: platform-infra-prometheus + clusterRoleBindingName: platform-infra-prometheus + persistentVolumeClaimName: prometheus-data + replicas: 1 + httpPort: 9090 + storage: + mode: persistentVolumeClaim + size: 20Gi + retention: 15d + discovery: + namespaces: [] + labelSelector: unidesk.ai/metrics=true + annotationKeys: + scrape: prometheus.io/scrape + path: prometheus.io/path + port: prometheus.io/port + defaultPath: /metrics + scrapeInterval: 30s + scrapeTimeout: 10s + query: + path: /api/v1/query + timeoutSeconds: 15 + maxSeries: 200 + maxResponseBytes: 262144 + sampling: mode: parentbased_traceidratio ratio: 1 @@ -185,3 +221,7 @@ probes: service: otel-collector portName: health path: / + - name: prometheus-ready + service: prometheus + portName: http + path: /-/ready diff --git a/docs/reference/observability.md b/docs/reference/observability.md index a109cdf6..4ae7d50c 100644 --- a/docs/reference/observability.md +++ b/docs/reference/observability.md @@ -2,6 +2,15 @@ UniDesk 的可观测性优先级高于静默成功。CLI、服务日志、Docker 日志和数据库状态都必须能通过短命令查询。 +## 共享 Prometheus 指标面 + +- `config/platform-infra/observability.yaml` 同时拥有 Collector、Tempo 和 Prometheus 的 image、retention、storage、port、服务发现与查询预算;TypeScript 只校验和渲染。 +- Prometheus 使用 Kubernetes 原生 pod 服务发现。服务通过稳定 label `unidesk.ai/metrics=true` 与 `prometheus.io/scrape=true`、`prometheus.io/path`、`prometheus.io/port` annotation 声明 `/metrics` 抓取,不允许业务专属分支。 +- `bun scripts/cli.ts platform-infra observability plan --target ` 展示 Prometheus enabled/disabled、Service、发现选择器和 manifest 摘要。 +- `bun scripts/cli.ts platform-infra observability status --target ` 与 `validate` 将 Prometheus readiness 单列为 warning;不得改变业务 readiness 或交付成功结论。 +- `bun scripts/cli.ts platform-infra observability metrics-query --target --query ` 只执行 YAML 预算约束的瞬时 PromQL 查询;默认有界,`--full` 展开结构,`--raw` 仅用于解析诊断。 +- Collector + Tempo 继续是 trace authority;Prometheus 只承担 metrics,不成为第二业务状态库或交付门禁。 + ## Capability Boundary 版本、commit 和微服务契约漂移只属于可观测性信号。OTel、日志、diagnose 和 status 必须把它们投影为非阻塞 warning,并继续以真实业务结果为权威;不得把漂移告警升级为业务失败、admission blocker、readiness failure 或 CI/CD gate。完整边界见 [DevOps Hygiene](devops-hygiene.md#prohibited-deployment-truth)。 diff --git a/scripts/src/platform-infra-observability.test.ts b/scripts/src/platform-infra-observability.test.ts index 064b904c..e86c0421 100644 --- a/scripts/src/platform-infra-observability.test.ts +++ b/scripts/src/platform-infra-observability.test.ts @@ -13,6 +13,8 @@ describe("platform-infra observability progressive disclosure", () => { expect(Buffer.byteLength(compact.renderedText, "utf8")).toBeLessThan(10_240); expect(compact.renderedText).toContain("serviceConnections=8"); expect(compact.renderedText).toContain("configRefs=6/6 missing=0"); + expect(compact.renderedText).toContain("prometheus=true service=prometheus"); + expect(compact.renderedText).toContain("scrapeSelector=unidesk.ai/metrics=true"); expect(compact.renderedText).toContain("--full"); expect(compact.renderedText).toContain("--raw"); @@ -25,6 +27,7 @@ describe("platform-infra observability progressive disclosure", () => { renderPlan: { target: { id: "NC01", route: "NC01:k3s", namespace: "platform-infra" } }, }); expect((expanded as Record).renderPlan.instrumentation).toHaveLength(8); + expect((expanded as Record).config.metricsBackend).toMatchObject({ type: "prometheus", enabled: true }); } }); @@ -38,8 +41,8 @@ describe("platform-infra observability progressive disclosure", () => { expect(result.stdout).not.toContain("/tmp/unidesk-cli-output"); }); - test("search, trace and diagnose-code-agent expose only scoped help", () => { - for (const operation of ["search", "trace", "diagnose-code-agent"]) { + test("query commands expose only scoped help", () => { + for (const operation of ["search", "trace", "diagnose-code-agent", "metrics-query"]) { const result = cli(["platform-infra", "observability", operation, "--help"]); expect(result.status).toBe(0); const payload = JSON.parse(result.stdout) as Record; @@ -57,7 +60,7 @@ describe("platform-infra observability progressive disclosure", () => { expect(result.status).toBe(0); const payload = JSON.parse(result.stdout) as Record; expect(payload.data.operations.map((item: Record) => item.name)).toEqual([ - "plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent", + "plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent", "metrics-query", ]); expect(payload.data.scopedHelp).toContain(" --help"); expect(payload.data.usage).toBeUndefined(); diff --git a/scripts/src/platform-infra-observability/actions.ts b/scripts/src/platform-infra-observability/actions.ts index fbbc182e..24369aa0 100644 --- a/scripts/src/platform-infra-observability/actions.ts +++ b/scripts/src/platform-infra-observability/actions.ts @@ -69,6 +69,8 @@ export function plan(options: CommonOptions): Record { collectorGrpcEndpoint: `${observability.collector.serviceName}.${target.namespace}.svc.cluster.local:${observability.collector.otlp.grpcPort}`, collectorHttpEndpoint: `http://${observability.collector.serviceName}.${target.namespace}.svc.cluster.local:${observability.collector.otlp.httpPort}`, backendGrpcEndpoint: `${observability.traceBackend.serviceName}.${target.namespace}.svc.cluster.local:${observability.traceBackend.otlp.grpcPort}`, + prometheusHttpEndpoint: observability.metricsBackend.enabled ? `http://${observability.metricsBackend.serviceName}.${target.namespace}.svc.cluster.local:${observability.metricsBackend.httpPort}` : null, + scrapeDiscovery: observability.metricsBackend.discovery, }, instrumentation: observability.instrumentation.serviceConnections, resourceAttributes: observability.resourceAttributes, @@ -80,6 +82,7 @@ export function plan(options: CommonOptions): Record { status: `bun scripts/cli.ts platform-infra observability status --target ${target.id}`, validate: `bun scripts/cli.ts platform-infra observability validate --target ${target.id}`, trace: `bun scripts/cli.ts platform-infra observability trace --target ${target.id} --trace-id `, + metricsQuery: `bun scripts/cli.ts platform-infra observability metrics-query --target ${target.id} --query `, }, }; } diff --git a/scripts/src/platform-infra-observability/config.ts b/scripts/src/platform-infra-observability/config.ts index 119f4b19..a941c7be 100644 --- a/scripts/src/platform-infra-observability/config.ts +++ b/scripts/src/platform-infra-observability/config.ts @@ -39,6 +39,11 @@ export function readObservabilityConfig(): ObservabilityConfig { const traceBackend = objectField(root, "traceBackend", ""); const traceBackendOtlp = objectField(traceBackend, "otlp", "traceBackend"); const traceBackendStorage = objectField(traceBackend, "storage", "traceBackend"); + const metricsBackend = objectField(root, "metricsBackend", ""); + const metricsStorage = objectField(metricsBackend, "storage", "metricsBackend"); + const metricsDiscovery = objectField(metricsBackend, "discovery", "metricsBackend"); + const metricsAnnotations = objectField(metricsDiscovery, "annotationKeys", "metricsBackend.discovery"); + const metricsQuery = objectField(metricsBackend, "query", "metricsBackend"); const sampling = objectField(root, "sampling", ""); const instrumentation = objectField(root, "instrumentation", ""); const resourceAttributes = objectField(root, "resourceAttributes", ""); @@ -56,6 +61,7 @@ export function readObservabilityConfig(): ObservabilityConfig { images: { collector: imageSpec(objectField(images, "collector", "images"), "images.collector"), tempo: imageSpec(objectField(images, "tempo", "images"), "images.tempo"), + prometheus: imageSpec(objectField(images, "prometheus", "images"), "images.prometheus"), }, targets: arrayOfRecords(root.targets, "targets").map(parseTarget), collector: { @@ -79,6 +85,42 @@ export function readObservabilityConfig(): ObservabilityConfig { retention: stringField(traceBackendStorage, "retention", "traceBackend.storage"), }, }, + metricsBackend: { + type: enumField(metricsBackend, "type", "metricsBackend", ["prometheus"] as const), + enabled: booleanField(metricsBackend, "enabled", "metricsBackend"), + deploymentName: kubernetesNameField(metricsBackend, "deploymentName", "metricsBackend"), + serviceName: kubernetesNameField(metricsBackend, "serviceName", "metricsBackend"), + configMapName: kubernetesNameField(metricsBackend, "configMapName", "metricsBackend"), + serviceAccountName: kubernetesNameField(metricsBackend, "serviceAccountName", "metricsBackend"), + clusterRoleName: kubernetesNameField(metricsBackend, "clusterRoleName", "metricsBackend"), + clusterRoleBindingName: kubernetesNameField(metricsBackend, "clusterRoleBindingName", "metricsBackend"), + persistentVolumeClaimName: kubernetesNameField(metricsBackend, "persistentVolumeClaimName", "metricsBackend"), + replicas: integerField(metricsBackend, "replicas", "metricsBackend"), + httpPort: portField(metricsBackend, "httpPort", "metricsBackend"), + storage: { + mode: enumField(metricsStorage, "mode", "metricsBackend.storage", ["persistentVolumeClaim"] as const), + size: stringField(metricsStorage, "size", "metricsBackend.storage"), + retention: stringField(metricsStorage, "retention", "metricsBackend.storage"), + }, + discovery: { + namespaces: stringArrayField(metricsDiscovery, "namespaces", "metricsBackend.discovery"), + labelSelector: stringField(metricsDiscovery, "labelSelector", "metricsBackend.discovery"), + annotationKeys: { + scrape: stringField(metricsAnnotations, "scrape", "metricsBackend.discovery.annotationKeys"), + path: stringField(metricsAnnotations, "path", "metricsBackend.discovery.annotationKeys"), + port: stringField(metricsAnnotations, "port", "metricsBackend.discovery.annotationKeys"), + }, + defaultPath: apiPathField(metricsDiscovery, "defaultPath", "metricsBackend.discovery"), + scrapeInterval: stringField(metricsDiscovery, "scrapeInterval", "metricsBackend.discovery"), + scrapeTimeout: stringField(metricsDiscovery, "scrapeTimeout", "metricsBackend.discovery"), + }, + query: { + path: apiPathField(metricsQuery, "path", "metricsBackend.query"), + timeoutSeconds: integerField(metricsQuery, "timeoutSeconds", "metricsBackend.query"), + maxSeries: integerField(metricsQuery, "maxSeries", "metricsBackend.query"), + maxResponseBytes: integerField(metricsQuery, "maxResponseBytes", "metricsBackend.query"), + }, + }, sampling: { mode: enumField(sampling, "mode", "sampling", ["parentbased_traceidratio"] as const), ratio: numberField(sampling, "ratio", "sampling"), @@ -99,7 +141,8 @@ export function readObservabilityConfig(): ObservabilityConfig { }; if (config.targets.length === 0) throw new Error(`${configLabel}.targets must not be empty`); assertKnownEnabledTarget(config.targets, config.defaults.targetId, "defaults.targetId"); - if (config.collector.replicas < 0 || config.traceBackend.replicas < 0) throw new Error(`${configLabel} replicas must be >= 0`); + if (config.collector.replicas < 0 || config.traceBackend.replicas < 0 || config.metricsBackend.replicas < 0) throw new Error(`${configLabel} replicas must be >= 0`); + if (config.metricsBackend.query.timeoutSeconds < 1 || config.metricsBackend.query.maxSeries < 1 || config.metricsBackend.query.maxResponseBytes < 1024) throw new Error(`${configLabel}.metricsBackend.query budgets must be positive and maxResponseBytes must be >= 1024`); if (config.sampling.ratio < 0 || config.sampling.ratio > 1) throw new Error(`${configLabel}.sampling.ratio must be between 0 and 1`); if (!config.probes.traceQueryPathTemplate.includes("{{traceId}}")) throw new Error(`${configLabel}.probes.traceQueryPathTemplate must include {{traceId}}`); return config; diff --git a/scripts/src/platform-infra-observability/options.ts b/scripts/src/platform-infra-observability/options.ts index 46800fc9..b9bea663 100644 --- a/scripts/src/platform-infra-observability/options.ts +++ b/scripts/src/platform-infra-observability/options.ts @@ -19,18 +19,19 @@ import { capture, } from "../platform-infra-ops-library"; -import type { ApplyOptions, CommonOptions, DiagnoseCodeAgentOptions, SearchOptions, TraceOptions } from "./types"; +import type { ApplyOptions, CommonOptions, DiagnoseCodeAgentOptions, MetricsQueryOptions, SearchOptions, TraceOptions } from "./types"; import { apply, plan, status, validate } from "./actions"; import { diagnoseCodeAgent, search, trace } from "./render"; import { renderObservabilityPlan, renderObservabilityPlanFailure } from "./plan-render"; +import { metricsQuery } from "./prometheus"; -const observabilityOperations = ["plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent"] as const; +const observabilityOperations = ["plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent", "metrics-query"] as const; export function observabilityHelp(scope: string | null = null): Record { const scoped = scope === null ? null : observabilityOperationHelp(scope); if (scoped !== null) return scoped; return { - command: "platform-infra observability plan|apply|status|validate|trace|search|diagnose-code-agent", + command: "platform-infra observability plan|apply|status|validate|trace|search|diagnose-code-agent|metrics-query", output: "默认输出为有界摘要;--full/--raw 显式披露完整结构", configTruth: "config/platform-infra/observability.yaml", spec: "PJ2026-01060501 OTel追踪 draft-2026-06-19-p0", @@ -39,7 +40,7 @@ export function observabilityHelp(scope: string | null = null): Record --help", - boundary: "Prometheus 仍是指标来源;本命令只负责 platform-infra OTel Collector、Tempo readiness 与 trace 查询。", + boundary: "Collector + Tempo 是 trace authority;Prometheus 仅承担 metrics,故障和漂移保持非阻塞 warning。", }; } @@ -62,6 +63,7 @@ export async function runPlatformObservabilityCommand(config: UniDeskConfig, arg if (action === "trace") return await trace(config, parseTraceOptions(args.slice(1))); if (action === "search") return await search(config, parseSearchOptions(args.slice(1))); if (action === "diagnose-code-agent") return await diagnoseCodeAgent(config, parseDiagnoseCodeAgentOptions(args.slice(1))); + if (action === "metrics-query") return await metricsQuery(config, parseMetricsQueryOptions(args.slice(1))); return { ok: false, error: "unsupported-platform-infra-observability-command", args, help: observabilityHelp() }; } @@ -105,6 +107,12 @@ function observabilityOperationHelp(scope: string): Record | nu ], options: ["--trace-id <32-hex>", "--target ", "--grep ", "--limit <1..500>", "--full", "--raw"], }; + if (scope === "metrics-query") return { + ...common, + command: "platform-infra observability metrics-query", + usage: ["bun scripts/cli.ts platform-infra observability metrics-query --target --query [--full|--raw]"], + options: ["--target ", "--query ", "--full", "--raw"], + }; if (scope === "search") return { ...common, command: "platform-infra observability search", @@ -126,6 +134,27 @@ function observabilityOperationHelp(scope: string): Record | nu return null; } +export function parseMetricsQueryOptions(args: string[]): MetricsQueryOptions { + const commonArgs: string[] = []; + let query: string | null = null; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--query") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error("--query requires a value"); + query = value; + index += 1; + } else { + commonArgs.push(arg); + if (arg === "--target") { + commonArgs.push(args[index + 1] ?? ""); + index += 1; + } + } + } + return { ...parseCommonOptions(commonArgs), query }; +} + export function parseCommonOptions(args: string[]): CommonOptions { let targetId: string | null = null; let full = false; diff --git a/scripts/src/platform-infra-observability/plan-render.ts b/scripts/src/platform-infra-observability/plan-render.ts index cac6c86f..aa02c981 100644 --- a/scripts/src/platform-infra-observability/plan-render.ts +++ b/scripts/src/platform-infra-observability/plan-render.ts @@ -8,6 +8,7 @@ export function renderObservabilityPlan(result: Record): Render const images = record(config.images); const collector = record(config.collector); const traceBackend = record(config.traceBackend); + const metricsBackend = record(config.metricsBackend); const renderPlan = record(result.renderPlan); const otlp = record(renderPlan.otlp); const connections = records(config.serviceConnections); @@ -39,6 +40,8 @@ export function renderObservabilityPlan(result: Record): Render `target=${textValue(target.id)} route=${textValue(target.route)} namespace=${textValue(target.namespace)} role=${textValue(target.role)}`, `collector=${textValue(collector.serviceName)} replicas=${textValue(collector.replicas)} image=${textValue(images.collector)}`, `tempo=${textValue(traceBackend.serviceName)} replicas=${textValue(traceBackend.replicas)} retention=${textValue(record(traceBackend.storage).retention)} image=${textValue(images.traceBackend)}`, + `prometheus=${textValue(metricsBackend.enabled)} service=${textValue(metricsBackend.serviceName)} replicas=${textValue(metricsBackend.replicas)} retention=${textValue(record(metricsBackend.storage).retention)} image=${textValue(images.metricsBackend)}`, + `scrapeSelector=${textValue(record(metricsBackend.discovery).labelSelector)} namespaces=${values(record(metricsBackend.discovery).namespaces).length === 0 ? "all" : values(record(metricsBackend.discovery).namespaces).join(",")} queryBudget=${textValue(record(metricsBackend.query).timeoutSeconds)}s/${textValue(record(metricsBackend.query).maxSeries)}series/${textValue(record(metricsBackend.query).maxResponseBytes)}bytes`, `serviceConnections=${connections.length} services=${serviceNames.size} nodes=${nodes.size} configRefs=${presentConfigRefs.length}/${configRefs.length} missing=${missingConfigRefs.length} objects=${objects.length}`, "", "Service connections:", @@ -77,6 +80,7 @@ export function renderObservabilityPlan(result: Record): Render ` collector-grpc: ${textValue(otlp.collectorGrpcEndpoint)}`, ` collector-http: ${textValue(otlp.collectorHttpEndpoint)}`, ` tempo-grpc: ${textValue(otlp.backendGrpcEndpoint)}`, + ` prometheus-http: ${textValue(otlp.prometheusHttpEndpoint)}`, "", "Next:", ` bun scripts/cli.ts platform-infra observability plan --target ${textValue(target.id)} --full`, diff --git a/scripts/src/platform-infra-observability/prometheus.ts b/scripts/src/platform-infra-observability/prometheus.ts new file mode 100644 index 00000000..dfd3536b --- /dev/null +++ b/scripts/src/platform-infra-observability/prometheus.ts @@ -0,0 +1,50 @@ +import type { UniDeskConfig } from "../config"; +import type { RenderedCliResult } from "../output"; +import { capture, compactCapture, parseJsonOutput, redactSensitiveUnknown, shQuote } from "../platform-infra-ops-library"; +import { resolveTarget } from "./actions"; +import { readObservabilityConfig } from "./config"; +import { formatTable, shortenEnd, textValue } from "./manifest"; +import { imageReference, indent, targetSummary } from "./summary"; +import type { MetricsQueryOptions, ObservabilityConfig, ObservabilityTarget } from "./types"; + +export function prometheusManifests(observability: ObservabilityConfig, target: ObservabilityTarget): string[] { + const prometheus = observability.metricsBackend; + if (!prometheus.enabled) return []; + const namespaceNames = prometheus.discovery.namespaces.length > 0 + ? `\n namespaces:\n names:\n${prometheus.discovery.namespaces.map((name) => ` - ${name}`).join("\n")}` + : ""; + const config = `global:\n scrape_interval: ${prometheus.discovery.scrapeInterval}\n scrape_timeout: ${prometheus.discovery.scrapeTimeout}\nscrape_configs:\n - job_name: kubernetes-pods\n kubernetes_sd_configs:\n - role: pod${namespaceNames}\n relabel_configs:\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.scrape)}]\n action: keep\n regex: \"true\"\n - source_labels: [__meta_kubernetes_pod_label_${labelMeta(prometheus.discovery.labelSelector.split("=")[0] ?? "")} ]\n action: keep\n regex: \"${prometheus.discovery.labelSelector.split("=")[1] ?? ".+"}\"\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: (.+)\n - source_labels: [__address__, __meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.port)}]\n action: replace\n regex: ([^:]+)(?::\\d+)?;(\\d+)\n replacement: \$1:\$2\n target_label: __address__\n - target_label: __metrics_path__\n replacement: ${prometheus.discovery.defaultPath}\n`; + const renderedConfig = config.replace( + " - target_label: __metrics_path__\n replacement:", + ` - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: ^$\n replacement:`, + ); + const labels = ` app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n app.kubernetes.io/part-of: platform-infra\n app.kubernetes.io/managed-by: unidesk`; + return [ + `apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: ${prometheus.serviceAccountName}\n namespace: ${target.namespace}\n labels:\n${labels}\n`, + `apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n name: ${prometheus.clusterRoleName}\nrules:\n - apiGroups: [\"\"]\n resources: [\"nodes\", \"nodes/proxy\", \"services\", \"endpoints\", \"pods\"]\n verbs: [\"get\", \"list\", \"watch\"]\n`, + `apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n name: ${prometheus.clusterRoleBindingName}\nroleRef:\n apiGroup: rbac.authorization.k8s.io\n kind: ClusterRole\n name: ${prometheus.clusterRoleName}\nsubjects:\n - kind: ServiceAccount\n name: ${prometheus.serviceAccountName}\n namespace: ${target.namespace}\n`, + `apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: ${prometheus.configMapName}\n namespace: ${target.namespace}\n labels:\n${labels}\ndata:\n prometheus.yml: |\n${indent(renderedConfig, 4)}\n`, + `apiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n name: ${prometheus.persistentVolumeClaimName}\n namespace: ${target.namespace}\n labels:\n${labels}\nspec:\n accessModes: [ReadWriteOnce]\n resources:\n requests:\n storage: ${prometheus.storage.size}\n`, + `apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: ${prometheus.deploymentName}\n namespace: ${target.namespace}\n labels:\n${labels}\nspec:\n replicas: ${prometheus.replicas}\n selector:\n matchLabels:\n app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n template:\n metadata:\n labels:\n app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n app.kubernetes.io/part-of: platform-infra\n spec:\n serviceAccountName: ${prometheus.serviceAccountName}\n containers:\n - name: prometheus\n image: ${imageReference(observability.images.prometheus)}\n imagePullPolicy: ${observability.images.prometheus.pullPolicy}\n args:\n - --config.file=/etc/prometheus/prometheus.yml\n - --storage.tsdb.path=/prometheus\n - --storage.tsdb.retention.time=${prometheus.storage.retention}\n ports:\n - name: http\n containerPort: ${prometheus.httpPort}\n readinessProbe:\n httpGet:\n path: /-/ready\n port: http\n volumeMounts:\n - name: config\n mountPath: /etc/prometheus/prometheus.yml\n subPath: prometheus.yml\n readOnly: true\n - name: data\n mountPath: /prometheus\n volumes:\n - name: config\n configMap:\n name: ${prometheus.configMapName}\n - name: data\n persistentVolumeClaim:\n claimName: ${prometheus.persistentVolumeClaimName}\n`, + `apiVersion: v1\nkind: Service\nmetadata:\n name: ${prometheus.serviceName}\n namespace: ${target.namespace}\n labels:\n${labels}\nspec:\n type: ClusterIP\n selector:\n app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n ports:\n - name: http\n port: ${prometheus.httpPort}\n targetPort: http\n`, + ]; +} + +export async function metricsQuery(config: UniDeskConfig, options: MetricsQueryOptions): Promise | RenderedCliResult> { + if (options.query === null || options.query.trim() === "") throw new Error("observability metrics-query requires --query "); + const observability = readObservabilityConfig(); + const target = resolveTarget(observability, options.targetId); + const prometheus = observability.metricsBackend; + if (!prometheus.enabled) return { ok: false, action: "platform-infra-observability-metrics-query", mutation: false, target: targetSummary(target), metrics: { enabled: false }, warnings: ["Prometheus is disabled in config/platform-infra/observability.yaml"] }; + const path = `${prometheus.query.path}?query=${encodeURIComponent(options.query)}`; + const script = `set -u\npython3 - <<'PY'\nimport json, subprocess\npath = ${JSON.stringify(`/api/v1/namespaces/${target.namespace}/services/http:${prometheus.serviceName}:http/proxy${path}`)}\nproc = subprocess.run([\"kubectl\", \"get\", \"--raw\", path], text=True, capture_output=True, timeout=${prometheus.query.timeoutSeconds})\nbody = proc.stdout[:${prometheus.query.maxResponseBytes}]\ntry:\n parsed = json.loads(body) if body else None\nexcept Exception:\n parsed = None\nresult = parsed.get(\"data\", {}).get(\"result\", []) if isinstance(parsed, dict) else []\nprint(json.dumps({\"ok\": proc.returncode == 0 and isinstance(parsed, dict) and parsed.get(\"status\") == \"success\", \"exitCode\": proc.returncode, \"truncated\": len(proc.stdout) > ${prometheus.query.maxResponseBytes} or len(result) > ${prometheus.query.maxSeries}, \"resultType\": parsed.get(\"data\", {}).get(\"resultType\") if isinstance(parsed, dict) else None, \"result\": result[:${prometheus.query.maxSeries}], \"stderrTail\": proc.stderr[-2000:]}, ensure_ascii=False))\nPY`; + const result = await capture(config, target.route, ["sh"], script); + const parsed = parseJsonOutput(result.stdout); + const ok = result.exitCode === 0 && parsed?.ok === true; + if (options.full || options.raw) return { ok, action: "platform-infra-observability-metrics-query", mutation: false, target: targetSummary(target), query: options.query, budgets: prometheus.query, result: parsed === null ? compactCapture(result, { full: true }) : redactSensitiveUnknown(parsed) }; + const rows = Array.isArray(parsed?.result) ? parsed.result.slice(0, 20).map((item: unknown) => [shortenEnd(JSON.stringify(item), 160)]) : []; + return { ok, command: "platform-infra observability metrics-query", contentType: "text/plain", renderedText: [`platform-infra observability metrics-query (${ok ? "ok" : "not-ok"})`, "", `target=${target.id} prometheus=${prometheus.serviceName}.${target.namespace}.svc.cluster.local:${prometheus.httpPort}`, `query=${options.query}`, `budget=timeout:${prometheus.query.timeoutSeconds}s series:${prometheus.query.maxSeries} bytes:${prometheus.query.maxResponseBytes} truncated=${textValue(parsed?.truncated)}`, "", formatTable(["RESULT"], rows.length > 0 ? rows : [["-"]]), "", `Next: bun scripts/cli.ts platform-infra observability metrics-query --target ${target.id} --query ${shQuote(options.query)} --full`].join("\n") }; +} + +function annotationMeta(value: string): string { return value.replaceAll(".", "_").replaceAll("/", "_"); } +function labelMeta(value: string): string { return value.replaceAll(".", "_").replaceAll("/", "_").replaceAll("-", "_"); } diff --git a/scripts/src/platform-infra-observability/search-script.ts b/scripts/src/platform-infra-observability/search-script.ts index 1a1b0538..1bd8df23 100644 --- a/scripts/src/platform-infra-observability/search-script.ts +++ b/scripts/src/platform-infra-observability/search-script.ts @@ -102,7 +102,13 @@ PY } export function statusScript(observability: ObservabilityConfig, target: ObservabilityTarget, full: boolean, generateValidationTrace = false): string { - const endpointsJson = JSON.stringify(observability.probes.statusEndpoints); + const enabledEndpoints = observability.metricsBackend.enabled + ? observability.probes.statusEndpoints + : observability.probes.statusEndpoints.filter((endpoint) => endpoint.service !== observability.metricsBackend.serviceName); + const endpointsJson = JSON.stringify(enabledEndpoints); + const metricsCaptures = observability.metricsBackend.enabled + ? `capture_json metrics_deployments kubectl -n ${shQuote(target.namespace)} get deployment ${shQuote(observability.metricsBackend.deploymentName)} -o json\ncapture_json metrics_services kubectl -n ${shQuote(target.namespace)} get service ${shQuote(observability.metricsBackend.serviceName)} -o json\ncapture_json metrics_pods kubectl -n ${shQuote(target.namespace)} get pods -l ${shQuote(`app.kubernetes.io/name=${observability.metricsBackend.deploymentName}`)} -o json` + : ""; return ` set -u tmp="$(mktemp -d)" @@ -123,6 +129,7 @@ capture_json namespace kubectl get namespace ${shQuote(target.namespace)} -o jso capture_json deployments kubectl -n ${shQuote(target.namespace)} get deployment ${shQuote(observability.collector.deploymentName)} ${shQuote(observability.traceBackend.deploymentName)} -o json capture_json services kubectl -n ${shQuote(target.namespace)} get service ${shQuote(observability.collector.serviceName)} ${shQuote(observability.traceBackend.serviceName)} -o json capture_json pods kubectl -n ${shQuote(target.namespace)} get pods -l ${shQuote(`app.kubernetes.io/name in (${observability.collector.deploymentName},${observability.traceBackend.deploymentName})`)} -o json +${metricsCaptures} capture_json events kubectl -n ${shQuote(target.namespace)} get events -o json python3 - "$tmp" '${endpointsJson.replaceAll("'", "'\"'\"'")}' <<'PY' import json, random, socket, subprocess, sys, time, urllib.error, urllib.request @@ -304,7 +311,9 @@ def generate_test_trace(): except subprocess.TimeoutExpired: proc.kill() proc.wait(timeout=3) -runtime_ready = rc("namespace") == 0 and rc("deployments") == 0 and rc("services") == 0 and all(item["ok"] for item in probe_results) +blocking_probes = [item for item in probe_results if item.get("service") != "${observability.metricsBackend.serviceName}"] +metrics_probes = [item for item in probe_results if item.get("service") == "${observability.metricsBackend.serviceName}"] +runtime_ready = rc("namespace") == 0 and rc("deployments") == 0 and rc("services") == 0 and all(item["ok"] for item in blocking_probes) validation_trace = generate_test_trace() if (${generateValidationTrace ? "True" : "False"} and runtime_ready) else None payload = { "ok": runtime_ready and (validation_trace is None or validation_trace.get("ok") is True), @@ -316,8 +325,12 @@ payload = { "services": compact_section("services"), "pods": compact_section("pods"), "events": compact_section("events"), + "metricsDeployments": compact_section("metrics_deployments") if ${observability.metricsBackend.enabled ? "True" : "False"} else None, + "metricsServices": compact_section("metrics_services") if ${observability.metricsBackend.enabled ? "True" : "False"} else None, + "metricsPods": compact_section("metrics_pods") if ${observability.metricsBackend.enabled ? "True" : "False"} else None, }, "probes": probe_results, + "warnings": ([{"code": "prometheus-not-ready", "detail": item} for item in metrics_probes if item.get("ok") is not True] if ${observability.metricsBackend.enabled ? "True" : "False"} else [{"code": "prometheus-disabled"}]), "validationTrace": validation_trace, } print(json.dumps(payload, ensure_ascii=False, indent=2 if ${full ? "True" : "False"} else None)) diff --git a/scripts/src/platform-infra-observability/summary.ts b/scripts/src/platform-infra-observability/summary.ts index 3f530e1d..0c035f87 100644 --- a/scripts/src/platform-infra-observability/summary.ts +++ b/scripts/src/platform-infra-observability/summary.ts @@ -32,9 +32,11 @@ export function configSummary(observability: ObservabilityConfig, target: Observ images: { collector: imageReference(observability.images.collector), traceBackend: imageReference(observability.images.tempo), + metricsBackend: imageReference(observability.images.prometheus), }, collector: observability.collector, traceBackend: observability.traceBackend, + metricsBackend: observability.metricsBackend, sampling: observability.sampling, serviceConnections: observability.instrumentation.serviceConnections.map((item) => ({ serviceName: item.serviceName, @@ -62,8 +64,8 @@ export function targetSummary(target: ObservabilityTarget): Record> { return [ - { name: "yaml-source-of-truth", ok: true, detail: "All concrete images, routes, namespace, ports, retention and sampling values are read from config/platform-infra/observability.yaml." }, - { name: "clusterip-only", ok: !/^\s*type:\s*(NodePort|LoadBalancer)\s*$/mu.test(yaml), detail: "Collector and trace backend stay ClusterIP-only." }, + { name: "yaml-source-of-truth", ok: true, detail: "All concrete images, routes, namespace, ports, retention, storage, scrape discovery and query budgets are read from config/platform-infra/observability.yaml." }, + { name: "clusterip-only", ok: !/^\s*type:\s*(NodePort|LoadBalancer)\s*$/mu.test(yaml), detail: "Collector, Tempo and Prometheus stay ClusterIP-only." }, { name: "no-ingress", ok: !/^\s*kind:\s*Ingress\s*$/mu.test(yaml), detail: "No public ingress is rendered for the first tracing backend." }, { name: "no-host-network", ok: !/^\s*hostNetwork:\s*true\s*$/mu.test(yaml), detail: "Pods must not use host network." }, { name: "allow-all-network-policy", ok: yaml.includes("kind: NetworkPolicy") && yaml.includes("name: allow-all") && yaml.includes(`namespace: ${target.namespace}`), detail: `NetworkPolicy/allow-all is rendered in ${target.namespace}.` }, @@ -76,6 +78,9 @@ export function statusSummary(payload: Record): Record> : []; const readyDeployments = deployments.map((item) => deploymentSummary(item)); @@ -92,6 +97,12 @@ export function statusSummary(payload: Record): Record deploymentSummary(item)), + services: metricsServices.map((item) => metadataName(item)), + pods: metricsPods.map((item) => podSummary(item, podEvents)), + }, }; } @@ -106,6 +117,12 @@ export function sectionJson(sections: Record, name: string): un return section.json; } +function optionalSectionJson(sections: Record, name: string): unknown { + const value = sections[name]; + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return (value as Record).json; +} + export function objectList(value: unknown): Record[] { if (typeof value !== "object" || value === null) return []; const items = (value as Record).items; diff --git a/scripts/src/platform-infra-observability/trace-script.ts b/scripts/src/platform-infra-observability/trace-script.ts index a9c123c3..acbda357 100644 --- a/scripts/src/platform-infra-observability/trace-script.ts +++ b/scripts/src/platform-infra-observability/trace-script.ts @@ -22,6 +22,7 @@ import { import type { ObservabilityConfig, ObservabilityTarget } from "./types"; import { tempoService } from "./search-script"; import { imageReference, indent } from "./summary"; +import { prometheusManifests } from "./prometheus"; export function asArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; @@ -43,6 +44,7 @@ export function renderManifest(observability: ObservabilityConfig, target: Obser tempoConfigMap(observability, target), tempoDeployment(observability, target, tempoImage), tempoService(observability, target), + ...prometheusManifests(observability, target), ].filter((item) => item.trim().length > 0).join("\n---\n"); } diff --git a/scripts/src/platform-infra-observability/types.ts b/scripts/src/platform-infra-observability/types.ts index 3fc94b97..2ce889df 100644 --- a/scripts/src/platform-infra-observability/types.ts +++ b/scripts/src/platform-infra-observability/types.ts @@ -51,6 +51,7 @@ export interface ObservabilityConfig { images: { collector: ImageSpec; tempo: ImageSpec; + prometheus: ImageSpec; }; targets: ObservabilityTarget[]; collector: { @@ -71,6 +72,7 @@ export interface ObservabilityConfig { otlp: OtlpPorts; storage: { mode: "emptyDir"; retention: string }; }; + metricsBackend: PrometheusConfig; sampling: { mode: "parentbased_traceidratio"; ratio: number }; instrumentation: { contextPropagation: string[]; @@ -87,6 +89,30 @@ export interface ObservabilityConfig { }; } +export interface PrometheusConfig { + type: "prometheus"; + enabled: boolean; + deploymentName: string; + serviceName: string; + configMapName: string; + serviceAccountName: string; + clusterRoleName: string; + clusterRoleBindingName: string; + persistentVolumeClaimName: string; + replicas: number; + httpPort: number; + storage: { mode: "persistentVolumeClaim"; size: string; retention: string }; + discovery: { + namespaces: string[]; + labelSelector: string; + annotationKeys: { scrape: string; path: string; port: string }; + defaultPath: string; + scrapeInterval: string; + scrapeTimeout: string; + }; + query: { path: string; timeoutSeconds: number; maxSeries: number; maxResponseBytes: number }; +} + export interface ImageSpec { repository: string; tag: string; @@ -165,3 +191,7 @@ export interface DiagnoseCodeAgentOptions extends CommonOptions { candidateLimit: number; lookbackMinutes: number; } + +export interface MetricsQueryOptions extends CommonOptions { + query: string | null; +} From a716199b9b2c48e01c80a3483d614663263afcb6 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 21:01:26 +0200 Subject: [PATCH 12/43] =?UTF-8?q?docs:=20=E5=AE=8C=E6=88=90=20PikaOA=20?= =?UTF-8?q?=E8=B0=83=E7=A0=94=E4=B8=8E=E6=95=B0=E6=8D=AE=E5=BA=93=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../R1.5.3_Task_Report.md | 25 +++++++++++++++++++ docs/MDTODO/pikaoa-enterprise-platform.md | 4 +-- 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.3_Task_Report.md diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.3_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.3_Task_Report.md new file mode 100644 index 00000000..b1c7f07f --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.3_Task_Report.md @@ -0,0 +1,25 @@ +# R1.5.3 任务报告 + +## 结论 + +`platform-db postgres plan` 已改为默认返回有界 CLI-first 投影,并保留 `--full` 与 `--raw` 完整下钻。默认输出不会再触发 10 KiB 全局截断保护,Secret 值始终不披露。 + +## 变更 + +- 默认投影包含 target、config、全部角色和数据库的期望动作、Secret source 摘要、connection string export 摘要、检查汇总、远端事实摘要和下一步命令。 +- 失败检查才携带 480 字符以内的有界详情。 +- 远端失败输出分别限制为 1,200 字符 stdout/stderr 尾部。 +- `--full` 保留完整 `config`、`secrets`、`remoteFacts`、`desired`、`checks` 和 remote capture;`--raw` 继续附加原始 capture。 + +## 验证 + +- 默认 CLI 返回码为 0,stdout 为 9,589 字节,`outputTruncated=false`。 +- 默认投影明确包含 `pikaoa` role、`pikaoa` database 和 `pikaoa-database-url` 的 `DATABASE_URL` export,且 `valuesPrinted=false`。 +- `--full` 完整对象为 34,475 字节,由显式 disclosure policy 创建受保护 dump 并返回有界索引。 +- `bun test scripts/src/platform-db-monitor-reset.test.ts scripts/src/platform-db-nc01-monitor-config.test.ts`:8 pass,0 fail。 +- `git diff --check` 通过。 + +## 交付 + +- Commit:`88eb8fdb`(`fix: 收敛 PostgreSQL 计划输出`)。 +- 目标分支:`feat/pikaoa-platform`。 diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 01e22dc5..c32a07f7 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -13,7 +13,7 @@ 依据 PJ2026-03 建设企业级可扩展 PikaOA:在 /root/pikaoa 建立 pikaInc 仓库,采用 HWLAB v0.3 同族 Vue 技术栈和 Go 后端;CLI-first 完成员工/RBAC、甲方分类、合同多版本、发票关联与废弃、审计、OTel/Prometheus,再开发 Web;通过独立 K8s namespace、PK01 host PostgreSQL、YAML-first、PaC bootstrap、自动 CI/CD 和 https://oa.pikapython.com 原入口交付,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1_Task_Report.md)。 -### R1.1 [in_progress] +### R1.1 [completed] 调研成熟开源 OA、ERP、合同、发票、Go 微服务与 Vue 组件方案,形成采用/复用/不采用结论,依赖 R1 规格,执行记录见 [pikasTech/unidesk#1953](https://github.com/pikasTech/unidesk/issues/1953),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.1_Task_Report.md)。 @@ -39,7 +39,7 @@ #### R1.5.2 [in_progress] 扩展 NC01 共享 observability,使 OTel/Tempo 与 Prometheus 由同一 YAML-first 控制面管理并提供跨 namespace 指标抓取和有界查询,执行记录见 [pikasTech/unidesk#1956](https://github.com/pikasTech/unidesk/issues/1956),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.2_Task_Report.md)。 -#### R1.5.3 [in_progress] +#### R1.5.3 [completed] 修复 `platform-db postgres plan` 默认 30KB JSON 被输出预算截断的可见性缺陷,提供数据库、角色、Secret/export、检查与下一步的紧凑 CLI-first 投影,执行记录沿用 [主 issue](https://github.com/pikasTech/unidesk/issues/1952),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.3_Task_Report.md)。 ### R1.6 From 1a04b0298598a983a5e2fde3e09a5207ca33fe34 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 21:05:04 +0200 Subject: [PATCH 13/43] =?UTF-8?q?fix:=20=E6=94=B6=E7=AA=84=20PaC=20bootstr?= =?UTF-8?q?ap=20=E9=A2=84=E6=A3=80=E5=87=AD=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/platform-infra-gitea-config.test.ts | 24 ++++++++++++- scripts/src/platform-infra-gitea.ts | 34 +++++++++++++++++-- ...-infra-pipelines-as-code-bootstrap.test.ts | 15 ++++++-- ...tform-infra-pipelines-as-code-bootstrap.ts | 7 ++-- 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/scripts/src/platform-infra-gitea-config.test.ts b/scripts/src/platform-infra-gitea-config.test.ts index eb512873..be9f6171 100644 --- a/scripts/src/platform-infra-gitea-config.test.ts +++ b/scripts/src/platform-infra-gitea-config.test.ts @@ -4,7 +4,7 @@ import { readGiteaConfig, resolveTarget, } from "./platform-infra-gitea-config"; -import { buildMirrorWebhookCredentialBlockedResult, renderManifest } from "./platform-infra-gitea"; +import { buildGiteaMirrorBootstrapCredentialPreflight, buildMirrorWebhookCredentialBlockedResult, renderManifest } from "./platform-infra-gitea"; import { renderMirrorWebhookCredentialBlocked } from "./platform-infra-gitea-render"; function syntheticMissingSelfmediaCredential(): Record { @@ -65,6 +65,28 @@ describe("platform-infra Gitea repository GitHub credentials", () => { expect(nc01Overrides.map((credential) => credential.sourceRef)).toContain("pikainc-selfmedia-gh-token.txt"); }); + test("bootstrap preflight only requires the selected repository effective credential", () => { + const config = readGiteaConfig(); + const repository = config.sourceAuthority.repositories.find((repo) => repo.key === "selfmedia-nc01"); + expect(repository).toBeDefined(); + const result = buildGiteaMirrorBootstrapCredentialPreflight(config, [repository!], (credential) => ( + credential.sourceRef === "pikainc-selfmedia-gh-token.txt" + ? { [credential.sourceKey]: "fixture-token" } + : null + )); + + expect(result.blockers).toEqual([]); + expect(result.credentials).toHaveLength(1); + expect(result.credentials[0]).toMatchObject({ + id: "github-upstream:selfmedia-nc01", + sourceRef: "pikainc-selfmedia-gh-token.txt", + present: true, + requiredKeysPresent: true, + }); + expect(JSON.stringify(result)).not.toContain(config.sourceAuthority.credentials.admin.sourceRef); + expect(JSON.stringify(result)).not.toContain(config.sourceAuthority.credentials.github.sourceRef); + }); + test("does not inject the NC01 selfmedia token into JD01 bridge containers", () => { const config = readGiteaConfig(); const selfmediaTokenEnv = githubTokenEnvNameForSecretKey("github-token-pikainc-selfmedia"); diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index b737b545..2e85410c 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -131,8 +131,7 @@ export async function runGiteaMirrorBootstrapPreflight( const gitea = readGiteaConfig(); const target = resolveCommandTarget(gitea, targetId); const repos = selectedRepositories(gitea, target, repoKey); - const credentials = credentialSummaries(gitea, repos); - const blockers = credentialBlockers(credentials); + const { credentials, blockers } = buildGiteaMirrorBootstrapCredentialPreflight(gitea, repos); if (blockers.length > 0) { return { ok: false, mutation: false, code: "github-credential-unavailable", blockers, repositories: repos.map((repo) => repositorySummary(gitea, repo)), valuesPrinted: false }; } @@ -142,6 +141,37 @@ export async function runGiteaMirrorBootstrapPreflight( return parsed ?? { ok: false, mutation: false, code: "github-upstream-preflight-failed", remote: compactCapture(result, { full: true }), valuesPrinted: false }; } +export function buildGiteaMirrorBootstrapCredentialPreflight( + gitea: GiteaConfig, + repositories: GiteaMirrorRepository[], + readCredential: (credential: GiteaGithubCredential) => Record | null = (credential) => readGithubCredential(gitea, credential), +): { credentials: Array>; blockers: string[] } { + const credentials = repositories.map((repo) => { + const github = githubCredentialForRepo(gitea, repo); + const values = readCredential(github); + const present = values !== null; + const requiredKeysPresent = Boolean(values?.[github.sourceKey]); + return { + id: `github-upstream:${repo.key}`, + repository: repo.upstream.repository, + sourceRef: github.sourceRef, + present, + requiredKeysPresent, + fingerprint: present ? fingerprintKeys(values, [github.sourceKey]) : null, + permissionResult: present && requiredKeysPresent + ? { status: "not-probed", permissions: github.permissions, error: null } + : { status: "blocked-missing-credential", permissions: github.permissions, error: "credential material is absent" }, + valuesPrinted: false, + }; + }); + return { credentials, blockers: credentialBlockers(credentials) }; +} + +function readGithubCredential(gitea: GiteaConfig, github: GiteaGithubCredential): Record | null { + const sourcePath = credentialPath(gitea, github.sourceRef); + return existsSync(sourcePath) ? parseGithubCredentialFile(sourcePath, github) : null; +} + function mirrorPreflightSecrets(gitea: GiteaConfig, repositories: GiteaMirrorRepository[]): MirrorSecrets { const githubTokens: Record = {}; for (const repo of repositories) { diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts index 6a00d504..940e6e55 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -103,12 +103,21 @@ test("PaC bootstrap projects typed stages and compact JSON", () => { expect(rendered).toContain("confirm:"); }); -test("PaC bootstrap returns typed YAML match failures without mutation", () => { - const result = pacBootstrapYamlMatchFailure("NC01", "widgets", new Error("cannot resolve repository: 2 exact matches")); +test("PaC bootstrap returns fix-yaml next for zero YAML matches", () => { + const result = pacBootstrapYamlMatchFailure("NC01", "widgets", new Error("cannot resolve repository: no exact match")); expect(projectPacBootstrapResult(result)).toBe(result); - expect(result.ok).toBe(false); + expect((result.blockers as Record[])[0]?.code).toBe("yaml-repository-no-match"); + expect(result.next).toEqual({ + kind: "fix-yaml", + command: "检查 config/platform-infra/gitea.yaml 与 config/platform-infra/pipelines-as-code.yaml 的 target、owner/name 和 read URL 精确匹配", + }); +}); + +test("PaC bootstrap returns fix-yaml next for multiple YAML matches", () => { + const result = pacBootstrapYamlMatchFailure("NC01", "widgets", new Error("cannot resolve repository: 2 exact matches")); expect(result.mutation).toBe(false); expect((result.blockers as Record[])[0]?.code).toBe("yaml-repository-multiple-matches"); + expect((result.next as Record).kind).toBe("fix-yaml"); }); test("PaC bootstrap distinguishes GitHub access failure from Gitea and PaC stages", () => { diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts index 053c0b05..17419c52 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts @@ -36,7 +36,6 @@ export function pacBootstrapYamlMatchFailure( ): Record { const reason = error instanceof Error ? error.message : String(error); const matchCount = reason.includes("no exact match") ? 0 : Number.parseInt(reason.match(/(\d+) exact matches/u)?.[1] ?? "-1", 10); - const status = `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId} --consumer ${consumerId}`; return { ok: false, action: "platform-infra-pipelines-as-code-bootstrap", @@ -49,7 +48,10 @@ export function pacBootstrapYamlMatchFailure( ], stages: [], blockers: [{ code: matchCount === 0 ? "yaml-repository-no-match" : "yaml-repository-multiple-matches", stage: "yaml-exact-match", reason }], - next: { command: status, kind: "read-only-status" }, + next: { + kind: "fix-yaml", + command: "检查 config/platform-infra/gitea.yaml 与 config/platform-infra/pipelines-as-code.yaml 的 target、owner/name 和 read URL 精确匹配", + }, valuesPrinted: false, }; } @@ -65,7 +67,6 @@ export function projectPacBootstrapResult(result: Record): Reco const gitea = record(result.giteaBootstrap); const apply = record(result.pipelinesAsCodeApply); const remote = record(apply.remote); - const preflight = record(remote.preflight); const blockers = bootstrapBlockers(githubPreflight, gitea, apply, remote); const dryRun = result.mode === "dry-run"; const repositoryMissing = remote.error === "gitea-repository-missing"; From e808b2989a1c91bf145b5df557b82225e24906ac Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 21:13:16 +0200 Subject: [PATCH 14/43] =?UTF-8?q?fix:=20=E5=8C=BA=E5=88=86=20bootstrap=20?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=E9=98=B6=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...latform-infra-pipelines-as-code-bootstrap.test.ts | 2 ++ .../platform-infra-pipelines-as-code-bootstrap.ts | 12 +++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts index 940e6e55..da326b19 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -135,5 +135,7 @@ test("PaC bootstrap distinguishes GitHub access failure from Gitea and PaC stage pipelinesAsCodeApply: { ok: false, mutation: false, mode: "skipped" }, }); expect((projection.blockers as Record[])).toEqual([{ code: "github-repository-not-found-or-no-access", stage: "github-upstream", reason: "repository unavailable" }]); + expect((projection.preconditions as Record[])[2]).toMatchObject({ ok: null, code: "gitea-bootstrap-skipped" }); + expect((projection.stages as Record[]).map((item) => item.state)).toEqual(["skipped", "skipped", "skipped", "skipped"]); expect((projection.next as Record).kind).toBe("read-only-status"); }); diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts index 17419c52..86b3d309 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts @@ -72,6 +72,8 @@ export function projectPacBootstrapResult(result: Record): Reco const repositoryMissing = remote.error === "gitea-repository-missing"; const githubReady = githubPreflight.ok === true; const giteaReady = gitea.ok === true; + const giteaSkipped = gitea.mode === "skipped"; + const applySkipped = apply.mode === "skipped"; const applyReady = apply.ok === true || (dryRun && repositoryMissing); const next = bootstrapNext(result, blockers); return { @@ -92,13 +94,13 @@ export function projectPacBootstrapResult(result: Record): Reco preconditions: [ { id: "yaml-exact-match", ok: true, code: "yaml-repository-exact-match", matchCount: 1 }, { id: "github-upstream", ok: githubReady, code: stringValue(githubRepository.code, githubReady ? "github-upstream-available" : "github-upstream-preflight-failed") }, - { id: "gitea-repository", ok: giteaReady, code: giteaReady ? (dryRun ? "gitea-bootstrap-planned" : "gitea-bootstrap-ready") : "gitea-bootstrap-failed" }, + { id: "gitea-repository", ok: giteaSkipped ? null : giteaReady, code: giteaSkipped ? "gitea-bootstrap-skipped" : giteaReady ? (dryRun ? "gitea-bootstrap-planned" : "gitea-bootstrap-ready") : "gitea-bootstrap-failed" }, ], stages: [ - { id: "gitea-init", ok: giteaReady, state: giteaReady ? (dryRun ? "planned" : "ready") : "failed", mutation: gitea.mutation === true }, - { id: "pac-controller", ok: applyReady, state: repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, - { id: "tekton-consumer", ok: applyReady, state: repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, - { id: "gitops-argo", ok: applyReady, state: repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, + { id: "gitea-init", ok: giteaSkipped ? null : giteaReady, state: giteaSkipped ? "skipped" : giteaReady ? (dryRun ? "planned" : "ready") : "failed", mutation: gitea.mutation === true }, + { id: "pac-controller", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, + { id: "tekton-consumer", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, + { id: "gitops-argo", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, ], repository: { id: repository.id, namespace: repository.namespace }, blockers, From 979428a12a03cafc1147836a6638b7e47ce0d5f0 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 21:21:51 +0200 Subject: [PATCH 15/43] =?UTF-8?q?docs:=20=E5=AE=8C=E6=88=90=20PikaOA=20boo?= =?UTF-8?q?tstrap=20=E4=BB=BB=E5=8A=A1=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../R1.5.1_Task_Report.md | 32 +++++++++++++++++++ docs/MDTODO/pikaoa-enterprise-platform.md | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.1_Task_Report.md diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.1_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.1_Task_Report.md new file mode 100644 index 00000000..3b114215 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.1_Task_Report.md @@ -0,0 +1,32 @@ +# R1.5.1 任务报告 + +## 结论 + +PaC 新 consumer 首发 bootstrap 已具备 YAML 唯一匹配、selected repository effective credential、GitHub 上游只读预检、分阶段紧凑输出和 typed failure。该实现保持 GitHub PR merge 为唯一正式交付 authority,没有引入第二 source authority 或人工交付恢复入口。 + +## 变更 + +- 默认 human 和显式 JSON 共用有界投影,展示 `yaml-exact-match`、`github-upstream`、`gitea-repository` 前置条件,以及 Gitea、PaC、Tekton、GitOps/Argo 阶段。 +- YAML 零匹配、多匹配、GitHub 仓库不存在或无权限、Gitea 初始化失败和 PaC apply 失败使用独立 typed code。 +- GitHub 预检只校验 selected repositories 各自实际使用的 effective credential,不再被无关 Gitea admin 或默认 credential 形成伪 blocker。 +- YAML 匹配失败直接给 `fix-yaml`,失败态不提供 mirror sync、人工 PipelineRun、Argo refresh 等 mutation 恢复命令。 +- 未执行 `bootstrap --confirm`、部署或其他运行面 mutation。 + +## 验证 + +- Target:`NC01:/root/unidesk/.worktree/pikaoa-bootstrap-ux`。 +- 定向测试:19 pass,0 fail。 +- `sh -n scripts/src/platform-infra-gitea-remote.sh` 通过。 +- `platform-infra pipelines-as-code help platform-bootstrap` 通过。 +- `unidesk-host` 默认 credential dry-run 通过。 +- `selfmedia-nc01` repository override credential dry-run 通过。 +- PikaOA dry-run 返回 `yaml-repository-exact-match` 与 `github-repository-not-found-or-no-access`,后续阶段均为 `skipped`,`mutation=false`,准确暴露当前外部建仓权限阻塞。 +- `git diff --check` 通过。 + +## 交付 + +- 实现 commit:`697b6933`(`feat: 改进 PaC bootstrap 首发状态投影`)。 +- 纠偏 commit:`1a04b029`(`fix: 收窄 PaC bootstrap 预检凭据`)。 +- 目标分支已通过 `fa730e1e` 吸收上述实现,并通过 `e808b298` 补齐未执行阶段的 `skipped` 投影。 +- 子 issue:[pikasTech/unidesk#1955](https://github.com/pikasTech/unidesk/issues/1955)。 +- Post-task 未发现需要另建 FEATURE/BUG issue 的残余问题。 diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index c32a07f7..ff0bb631 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -33,7 +33,7 @@ 在 UniDesk 中声明 PK01 PostgreSQL、Secret、NC01 独立 namespace、Gitea/PaC、GitOps、OTel/Prometheus 和 PK01 公网暴露,并改进可复用 bootstrap 工具,依赖 R1.1-R1.2,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5_Task_Report.md)。 -#### R1.5.1 [in_progress] +#### R1.5.1 [completed] 改进 PaC bootstrap 的新服务首发预检、分阶段紧凑输出、typed failure 与中文说明,保持 PR merge 唯一交付 authority,执行记录见 [pikasTech/unidesk#1955](https://github.com/pikasTech/unidesk/issues/1955),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.1_Task_Report.md)。 #### R1.5.2 [in_progress] From ef3b5fde28308fec25df614798de4b5692c5cbfd Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 21:27:44 +0200 Subject: [PATCH 16/43] =?UTF-8?q?fix:=20=E9=99=90=E5=AE=9A=20Prometheus=20?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E4=B8=8E=E5=8F=91=E7=8E=B0=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/observability.yaml | 6 ++- docs/reference/observability.md | 5 ++- .../src/platform-infra-observability.test.ts | 44 ++++++++++++++++++- .../platform-infra-observability/actions.ts | 8 +++- .../platform-infra-observability/config.ts | 12 ++++- .../metrics-target.ts | 12 +++++ .../plan-render.ts | 5 ++- .../prometheus.ts | 14 +++--- .../search-script.ts | 15 ++++--- .../src/platform-infra-observability/types.ts | 3 +- 10 files changed, 101 insertions(+), 23 deletions(-) create mode 100644 scripts/src/platform-infra-observability/metrics-target.ts diff --git a/config/platform-infra/observability.yaml b/config/platform-infra/observability.yaml index 045b2760..1c4e3dd8 100644 --- a/config/platform-infra/observability.yaml +++ b/config/platform-infra/observability.yaml @@ -73,6 +73,8 @@ traceBackend: metricsBackend: type: prometheus enabled: true + targetIds: + - NC01 deploymentName: prometheus serviceName: prometheus configMapName: prometheus-config @@ -88,7 +90,9 @@ metricsBackend: retention: 15d discovery: namespaces: [] - labelSelector: unidesk.ai/metrics=true + labelSelector: + key: unidesk.ai/metrics + value: "true" annotationKeys: scrape: prometheus.io/scrape path: prometheus.io/path diff --git a/docs/reference/observability.md b/docs/reference/observability.md index 4ae7d50c..760b58cc 100644 --- a/docs/reference/observability.md +++ b/docs/reference/observability.md @@ -4,8 +4,9 @@ UniDesk 的可观测性优先级高于静默成功。CLI、服务日志、Docker ## 共享 Prometheus 指标面 -- `config/platform-infra/observability.yaml` 同时拥有 Collector、Tempo 和 Prometheus 的 image、retention、storage、port、服务发现与查询预算;TypeScript 只校验和渲染。 -- Prometheus 使用 Kubernetes 原生 pod 服务发现。服务通过稳定 label `unidesk.ai/metrics=true` 与 `prometheus.io/scrape=true`、`prometheus.io/path`、`prometheus.io/port` annotation 声明 `/metrics` 抓取,不允许业务专属分支。 +- `config/platform-infra/observability.yaml` 同时拥有 Collector、Tempo 和 Prometheus 的 image、retention、storage、port、目标 `targetIds`、服务发现与查询预算;TypeScript 只校验和渲染。 +- Prometheus 仅作用于 `metricsBackend.targetIds` 选中的 observability target;其他 target 的 plan 显示 `disabled-for-target`,manifest、status probe/capture 和 `metrics-query` 均不访问 Prometheus。 +- Prometheus 使用 Kubernetes 原生 pod 服务发现。YAML 通过结构化 `labelSelector.key/value` 声明稳定 label,并用 `prometheus.io/scrape=true`、`prometheus.io/path`、`prometheus.io/port` annotation 声明抓取;显式 path 优先,仅在 annotation 缺失时使用 `defaultPath`,不允许业务专属分支。 - `bun scripts/cli.ts platform-infra observability plan --target ` 展示 Prometheus enabled/disabled、Service、发现选择器和 manifest 摘要。 - `bun scripts/cli.ts platform-infra observability status --target ` 与 `validate` 将 Prometheus readiness 单列为 warning;不得改变业务 readiness 或交付成功结论。 - `bun scripts/cli.ts platform-infra observability metrics-query --target --query ` 只执行 YAML 预算约束的瞬时 PromQL 查询;默认有界,`--full` 展开结构,`--raw` 仅用于解析诊断。 diff --git a/scripts/src/platform-infra-observability.test.ts b/scripts/src/platform-infra-observability.test.ts index e86c0421..6022844d 100644 --- a/scripts/src/platform-infra-observability.test.ts +++ b/scripts/src/platform-infra-observability.test.ts @@ -3,7 +3,11 @@ import { spawnSync } from "node:child_process"; import { rootPath } from "./config"; import { isRenderedCliResult } from "./output"; import { runPlatformObservabilityCommand } from "./platform-infra-observability"; +import { resolveTarget } from "./platform-infra-observability/actions"; +import { readObservabilityConfig } from "./platform-infra-observability/config"; import { renderObservabilityPlanFailure } from "./platform-infra-observability/plan-render"; +import { statusScript } from "./platform-infra-observability/search-script"; +import { renderManifest } from "./platform-infra-observability/trace-script"; describe("platform-infra observability progressive disclosure", () => { test("default plan is a bounded table while full and raw preserve the complete plan", async () => { @@ -13,7 +17,7 @@ describe("platform-infra observability progressive disclosure", () => { expect(Buffer.byteLength(compact.renderedText, "utf8")).toBeLessThan(10_240); expect(compact.renderedText).toContain("serviceConnections=8"); expect(compact.renderedText).toContain("configRefs=6/6 missing=0"); - expect(compact.renderedText).toContain("prometheus=true service=prometheus"); + expect(compact.renderedText).toContain("prometheus=enabled service=prometheus targets=NC01"); expect(compact.renderedText).toContain("scrapeSelector=unidesk.ai/metrics=true"); expect(compact.renderedText).toContain("--full"); expect(compact.renderedText).toContain("--raw"); @@ -41,6 +45,44 @@ describe("platform-infra observability progressive disclosure", () => { expect(result.stdout).not.toContain("/tmp/unidesk-cli-output"); }); + test("Prometheus manifests and runtime access only apply to YAML-selected targets", async () => { + const observability = readObservabilityConfig(); + const nc01 = resolveTarget(observability, "NC01"); + const d518 = resolveTarget(observability, "D518"); + const jd01 = resolveTarget(observability, "JD01"); + const nc01Manifest = renderManifest(observability, nc01); + for (const target of [d518, jd01]) { + const manifest = renderManifest(observability, target); + expect(manifest).not.toContain("kind: ClusterRole\nmetadata:\n name: platform-infra-prometheus"); + expect(manifest).not.toContain("kind: PersistentVolumeClaim\nmetadata:\n name: prometheus-data"); + expect(manifest).not.toContain("app.kubernetes.io/component: metrics-backend"); + const plan = await runPlatformObservabilityCommand({} as never, ["plan", "--target", target.id]); + expect(isRenderedCliResult(plan)).toBe(true); + if (!isRenderedCliResult(plan)) throw new Error("expected rendered plan"); + expect(plan.renderedText).toContain("prometheus=disabled-for-target"); + expect(plan.renderedText).toContain("objects=8"); + const script = statusScript(observability, target, false); + expect(script).not.toContain("capture_json metrics_deployments"); + expect(script).not.toContain("prometheus-ready"); + const query = await runPlatformObservabilityCommand({} as never, ["metrics-query", "--target", target.id, "--query", "up", "--full"]); + expect(query).toMatchObject({ metrics: { enabled: false, disposition: "disabled-for-target", targetIds: ["NC01"] } }); + } + expect(nc01Manifest).toContain("kind: ClusterRole\nmetadata:\n name: platform-infra-prometheus"); + expect(nc01Manifest).toContain("kind: PersistentVolumeClaim\nmetadata:\n name: prometheus-data"); + }); + + test("Prometheus relabel rules prefer an explicit path and only default when absent", () => { + const observability = readObservabilityConfig(); + const manifest = renderManifest(observability, resolveTarget(observability, "NC01")); + const explicitPath = "source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]\n action: replace\n target_label: __metrics_path__\n regex: (/.+)"; + const defaultPath = "source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]\n action: replace\n target_label: __metrics_path__\n regex: ^$\n replacement: /metrics"; + expect(manifest).toContain("source_labels: [__meta_kubernetes_pod_label_unidesk_ai_metrics]"); + expect(manifest).toContain('regex: "true"'); + expect(manifest).toContain(explicitPath); + expect(manifest).toContain(defaultPath); + expect(manifest.indexOf(explicitPath)).toBeLessThan(manifest.indexOf(defaultPath)); + }); + test("query commands expose only scoped help", () => { for (const operation of ["search", "trace", "diagnose-code-agent", "metrics-query"]) { const result = cli(["platform-infra", "observability", operation, "--help"]); diff --git a/scripts/src/platform-infra-observability/actions.ts b/scripts/src/platform-infra-observability/actions.ts index 24369aa0..ba8cbd14 100644 --- a/scripts/src/platform-infra-observability/actions.ts +++ b/scripts/src/platform-infra-observability/actions.ts @@ -27,6 +27,7 @@ import { compactStatus, configSummary, manifestObjectSummary, policyChecks, stat import { renderManifest } from "./trace-script"; import { formatTable, shortenEnd, textValue } from "./manifest"; import { apiPathField, configLabel, kubernetesNameField, stringField } from "./types"; +import { metricsEnabledForTarget, metricsTargetDisposition } from "./metrics-target"; export function parseStatusEndpoint(record: Record, index: number): StatusEndpoint { const path = `probes.statusEndpoints[${index}]`; @@ -57,6 +58,7 @@ export function plan(options: CommonOptions): Record { const target = resolveTarget(observability, options.targetId); const yaml = renderManifest(observability, target); const policy = policyChecks(yaml, target); + const metricsDisposition = metricsTargetDisposition(observability, target); return { ok: policy.every((check) => check.ok), action: "platform-infra-observability-plan", @@ -65,11 +67,15 @@ export function plan(options: CommonOptions): Record { renderPlan: { target: targetSummary(target), objects: manifestObjectSummary(yaml), + metrics: { + disposition: metricsDisposition, + targetIds: observability.metricsBackend.targetIds, + }, otlp: { collectorGrpcEndpoint: `${observability.collector.serviceName}.${target.namespace}.svc.cluster.local:${observability.collector.otlp.grpcPort}`, collectorHttpEndpoint: `http://${observability.collector.serviceName}.${target.namespace}.svc.cluster.local:${observability.collector.otlp.httpPort}`, backendGrpcEndpoint: `${observability.traceBackend.serviceName}.${target.namespace}.svc.cluster.local:${observability.traceBackend.otlp.grpcPort}`, - prometheusHttpEndpoint: observability.metricsBackend.enabled ? `http://${observability.metricsBackend.serviceName}.${target.namespace}.svc.cluster.local:${observability.metricsBackend.httpPort}` : null, + prometheusHttpEndpoint: metricsEnabledForTarget(observability, target) ? `http://${observability.metricsBackend.serviceName}.${target.namespace}.svc.cluster.local:${observability.metricsBackend.httpPort}` : null, scrapeDiscovery: observability.metricsBackend.discovery, }, instrumentation: observability.instrumentation.serviceConnections, diff --git a/scripts/src/platform-infra-observability/config.ts b/scripts/src/platform-infra-observability/config.ts index a941c7be..33b1fb10 100644 --- a/scripts/src/platform-infra-observability/config.ts +++ b/scripts/src/platform-infra-observability/config.ts @@ -42,6 +42,7 @@ export function readObservabilityConfig(): ObservabilityConfig { const metricsBackend = objectField(root, "metricsBackend", ""); const metricsStorage = objectField(metricsBackend, "storage", "metricsBackend"); const metricsDiscovery = objectField(metricsBackend, "discovery", "metricsBackend"); + const metricsLabelSelector = objectField(metricsDiscovery, "labelSelector", "metricsBackend.discovery"); const metricsAnnotations = objectField(metricsDiscovery, "annotationKeys", "metricsBackend.discovery"); const metricsQuery = objectField(metricsBackend, "query", "metricsBackend"); const sampling = objectField(root, "sampling", ""); @@ -88,6 +89,7 @@ export function readObservabilityConfig(): ObservabilityConfig { metricsBackend: { type: enumField(metricsBackend, "type", "metricsBackend", ["prometheus"] as const), enabled: booleanField(metricsBackend, "enabled", "metricsBackend"), + targetIds: stringArrayField(metricsBackend, "targetIds", "metricsBackend"), deploymentName: kubernetesNameField(metricsBackend, "deploymentName", "metricsBackend"), serviceName: kubernetesNameField(metricsBackend, "serviceName", "metricsBackend"), configMapName: kubernetesNameField(metricsBackend, "configMapName", "metricsBackend"), @@ -104,7 +106,10 @@ export function readObservabilityConfig(): ObservabilityConfig { }, discovery: { namespaces: stringArrayField(metricsDiscovery, "namespaces", "metricsBackend.discovery"), - labelSelector: stringField(metricsDiscovery, "labelSelector", "metricsBackend.discovery"), + labelSelector: { + key: stringField(metricsLabelSelector, "key", "metricsBackend.discovery.labelSelector"), + value: stringField(metricsLabelSelector, "value", "metricsBackend.discovery.labelSelector"), + }, annotationKeys: { scrape: stringField(metricsAnnotations, "scrape", "metricsBackend.discovery.annotationKeys"), path: stringField(metricsAnnotations, "path", "metricsBackend.discovery.annotationKeys"), @@ -141,6 +146,11 @@ export function readObservabilityConfig(): ObservabilityConfig { }; if (config.targets.length === 0) throw new Error(`${configLabel}.targets must not be empty`); assertKnownEnabledTarget(config.targets, config.defaults.targetId, "defaults.targetId"); + for (const targetId of config.metricsBackend.targetIds) assertKnownEnabledTarget(config.targets, targetId, "metricsBackend.targetIds"); + if (config.metricsBackend.enabled && config.metricsBackend.targetIds.length === 0) throw new Error(`${configLabel}.metricsBackend.targetIds must not be empty when metricsBackend.enabled is true`); + if (new Set(config.metricsBackend.targetIds.map((targetId) => targetId.toLowerCase())).size !== config.metricsBackend.targetIds.length) throw new Error(`${configLabel}.metricsBackend.targetIds must not contain duplicates`); + if (!/^[A-Za-z0-9]([A-Za-z0-9._/-]*[A-Za-z0-9])?$/u.test(config.metricsBackend.discovery.labelSelector.key)) throw new Error(`${configLabel}.metricsBackend.discovery.labelSelector.key must be a Kubernetes label key`); + if (!/^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)?$/u.test(config.metricsBackend.discovery.labelSelector.value) || config.metricsBackend.discovery.labelSelector.value.length > 63) throw new Error(`${configLabel}.metricsBackend.discovery.labelSelector.value must be a Kubernetes label value`); if (config.collector.replicas < 0 || config.traceBackend.replicas < 0 || config.metricsBackend.replicas < 0) throw new Error(`${configLabel} replicas must be >= 0`); if (config.metricsBackend.query.timeoutSeconds < 1 || config.metricsBackend.query.maxSeries < 1 || config.metricsBackend.query.maxResponseBytes < 1024) throw new Error(`${configLabel}.metricsBackend.query budgets must be positive and maxResponseBytes must be >= 1024`); if (config.sampling.ratio < 0 || config.sampling.ratio > 1) throw new Error(`${configLabel}.sampling.ratio must be between 0 and 1`); diff --git a/scripts/src/platform-infra-observability/metrics-target.ts b/scripts/src/platform-infra-observability/metrics-target.ts new file mode 100644 index 00000000..1fa27a3b --- /dev/null +++ b/scripts/src/platform-infra-observability/metrics-target.ts @@ -0,0 +1,12 @@ +import type { ObservabilityConfig, ObservabilityTarget } from "./types"; + +export type MetricsTargetDisposition = "enabled" | "disabled" | "disabled-for-target"; + +export function metricsTargetDisposition(observability: ObservabilityConfig, target: ObservabilityTarget): MetricsTargetDisposition { + if (!observability.metricsBackend.enabled) return "disabled"; + return observability.metricsBackend.targetIds.some((targetId) => targetId.toLowerCase() === target.id.toLowerCase()) ? "enabled" : "disabled-for-target"; +} + +export function metricsEnabledForTarget(observability: ObservabilityConfig, target: ObservabilityTarget): boolean { + return metricsTargetDisposition(observability, target) === "enabled"; +} diff --git a/scripts/src/platform-infra-observability/plan-render.ts b/scripts/src/platform-infra-observability/plan-render.ts index aa02c981..c41f365e 100644 --- a/scripts/src/platform-infra-observability/plan-render.ts +++ b/scripts/src/platform-infra-observability/plan-render.ts @@ -10,6 +10,7 @@ export function renderObservabilityPlan(result: Record): Render const traceBackend = record(config.traceBackend); const metricsBackend = record(config.metricsBackend); const renderPlan = record(result.renderPlan); + const metricsPlan = record(renderPlan.metrics); const otlp = record(renderPlan.otlp); const connections = records(config.serviceConnections); const objects = records(renderPlan.objects); @@ -40,8 +41,8 @@ export function renderObservabilityPlan(result: Record): Render `target=${textValue(target.id)} route=${textValue(target.route)} namespace=${textValue(target.namespace)} role=${textValue(target.role)}`, `collector=${textValue(collector.serviceName)} replicas=${textValue(collector.replicas)} image=${textValue(images.collector)}`, `tempo=${textValue(traceBackend.serviceName)} replicas=${textValue(traceBackend.replicas)} retention=${textValue(record(traceBackend.storage).retention)} image=${textValue(images.traceBackend)}`, - `prometheus=${textValue(metricsBackend.enabled)} service=${textValue(metricsBackend.serviceName)} replicas=${textValue(metricsBackend.replicas)} retention=${textValue(record(metricsBackend.storage).retention)} image=${textValue(images.metricsBackend)}`, - `scrapeSelector=${textValue(record(metricsBackend.discovery).labelSelector)} namespaces=${values(record(metricsBackend.discovery).namespaces).length === 0 ? "all" : values(record(metricsBackend.discovery).namespaces).join(",")} queryBudget=${textValue(record(metricsBackend.query).timeoutSeconds)}s/${textValue(record(metricsBackend.query).maxSeries)}series/${textValue(record(metricsBackend.query).maxResponseBytes)}bytes`, + `prometheus=${textValue(metricsPlan.disposition)} service=${textValue(metricsBackend.serviceName)} targets=${values(metricsPlan.targetIds).join(",")} replicas=${textValue(metricsBackend.replicas)} retention=${textValue(record(metricsBackend.storage).retention)} image=${textValue(images.metricsBackend)}`, + `scrapeSelector=${textValue(record(record(metricsBackend.discovery).labelSelector).key)}=${textValue(record(record(metricsBackend.discovery).labelSelector).value)} namespaces=${values(record(metricsBackend.discovery).namespaces).length === 0 ? "all" : values(record(metricsBackend.discovery).namespaces).join(",")} queryBudget=${textValue(record(metricsBackend.query).timeoutSeconds)}s/${textValue(record(metricsBackend.query).maxSeries)}series/${textValue(record(metricsBackend.query).maxResponseBytes)}bytes`, `serviceConnections=${connections.length} services=${serviceNames.size} nodes=${nodes.size} configRefs=${presentConfigRefs.length}/${configRefs.length} missing=${missingConfigRefs.length} objects=${objects.length}`, "", "Service connections:", diff --git a/scripts/src/platform-infra-observability/prometheus.ts b/scripts/src/platform-infra-observability/prometheus.ts index dfd3536b..bb28d59c 100644 --- a/scripts/src/platform-infra-observability/prometheus.ts +++ b/scripts/src/platform-infra-observability/prometheus.ts @@ -6,24 +6,21 @@ import { readObservabilityConfig } from "./config"; import { formatTable, shortenEnd, textValue } from "./manifest"; import { imageReference, indent, targetSummary } from "./summary"; import type { MetricsQueryOptions, ObservabilityConfig, ObservabilityTarget } from "./types"; +import { metricsEnabledForTarget, metricsTargetDisposition } from "./metrics-target"; export function prometheusManifests(observability: ObservabilityConfig, target: ObservabilityTarget): string[] { const prometheus = observability.metricsBackend; - if (!prometheus.enabled) return []; + if (!metricsEnabledForTarget(observability, target)) return []; const namespaceNames = prometheus.discovery.namespaces.length > 0 ? `\n namespaces:\n names:\n${prometheus.discovery.namespaces.map((name) => ` - ${name}`).join("\n")}` : ""; - const config = `global:\n scrape_interval: ${prometheus.discovery.scrapeInterval}\n scrape_timeout: ${prometheus.discovery.scrapeTimeout}\nscrape_configs:\n - job_name: kubernetes-pods\n kubernetes_sd_configs:\n - role: pod${namespaceNames}\n relabel_configs:\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.scrape)}]\n action: keep\n regex: \"true\"\n - source_labels: [__meta_kubernetes_pod_label_${labelMeta(prometheus.discovery.labelSelector.split("=")[0] ?? "")} ]\n action: keep\n regex: \"${prometheus.discovery.labelSelector.split("=")[1] ?? ".+"}\"\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: (.+)\n - source_labels: [__address__, __meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.port)}]\n action: replace\n regex: ([^:]+)(?::\\d+)?;(\\d+)\n replacement: \$1:\$2\n target_label: __address__\n - target_label: __metrics_path__\n replacement: ${prometheus.discovery.defaultPath}\n`; - const renderedConfig = config.replace( - " - target_label: __metrics_path__\n replacement:", - ` - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: ^$\n replacement:`, - ); + const config = `global:\n scrape_interval: ${prometheus.discovery.scrapeInterval}\n scrape_timeout: ${prometheus.discovery.scrapeTimeout}\nscrape_configs:\n - job_name: kubernetes-pods\n kubernetes_sd_configs:\n - role: pod${namespaceNames}\n relabel_configs:\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.scrape)}]\n action: keep\n regex: \"true\"\n - source_labels: [__meta_kubernetes_pod_label_${labelMeta(prometheus.discovery.labelSelector.key)}]\n action: keep\n regex: \"${prometheus.discovery.labelSelector.value}\"\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: (/.+)\n - source_labels: [__address__, __meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.port)}]\n action: replace\n regex: ([^:]+)(?::\\d+)?;(\\d+)\n replacement: \$1:\$2\n target_label: __address__\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: ^$\n replacement: ${prometheus.discovery.defaultPath}\n`; const labels = ` app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n app.kubernetes.io/part-of: platform-infra\n app.kubernetes.io/managed-by: unidesk`; return [ `apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: ${prometheus.serviceAccountName}\n namespace: ${target.namespace}\n labels:\n${labels}\n`, `apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n name: ${prometheus.clusterRoleName}\nrules:\n - apiGroups: [\"\"]\n resources: [\"nodes\", \"nodes/proxy\", \"services\", \"endpoints\", \"pods\"]\n verbs: [\"get\", \"list\", \"watch\"]\n`, `apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n name: ${prometheus.clusterRoleBindingName}\nroleRef:\n apiGroup: rbac.authorization.k8s.io\n kind: ClusterRole\n name: ${prometheus.clusterRoleName}\nsubjects:\n - kind: ServiceAccount\n name: ${prometheus.serviceAccountName}\n namespace: ${target.namespace}\n`, - `apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: ${prometheus.configMapName}\n namespace: ${target.namespace}\n labels:\n${labels}\ndata:\n prometheus.yml: |\n${indent(renderedConfig, 4)}\n`, + `apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: ${prometheus.configMapName}\n namespace: ${target.namespace}\n labels:\n${labels}\ndata:\n prometheus.yml: |\n${indent(config, 4)}\n`, `apiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n name: ${prometheus.persistentVolumeClaimName}\n namespace: ${target.namespace}\n labels:\n${labels}\nspec:\n accessModes: [ReadWriteOnce]\n resources:\n requests:\n storage: ${prometheus.storage.size}\n`, `apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: ${prometheus.deploymentName}\n namespace: ${target.namespace}\n labels:\n${labels}\nspec:\n replicas: ${prometheus.replicas}\n selector:\n matchLabels:\n app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n template:\n metadata:\n labels:\n app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n app.kubernetes.io/part-of: platform-infra\n spec:\n serviceAccountName: ${prometheus.serviceAccountName}\n containers:\n - name: prometheus\n image: ${imageReference(observability.images.prometheus)}\n imagePullPolicy: ${observability.images.prometheus.pullPolicy}\n args:\n - --config.file=/etc/prometheus/prometheus.yml\n - --storage.tsdb.path=/prometheus\n - --storage.tsdb.retention.time=${prometheus.storage.retention}\n ports:\n - name: http\n containerPort: ${prometheus.httpPort}\n readinessProbe:\n httpGet:\n path: /-/ready\n port: http\n volumeMounts:\n - name: config\n mountPath: /etc/prometheus/prometheus.yml\n subPath: prometheus.yml\n readOnly: true\n - name: data\n mountPath: /prometheus\n volumes:\n - name: config\n configMap:\n name: ${prometheus.configMapName}\n - name: data\n persistentVolumeClaim:\n claimName: ${prometheus.persistentVolumeClaimName}\n`, `apiVersion: v1\nkind: Service\nmetadata:\n name: ${prometheus.serviceName}\n namespace: ${target.namespace}\n labels:\n${labels}\nspec:\n type: ClusterIP\n selector:\n app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n ports:\n - name: http\n port: ${prometheus.httpPort}\n targetPort: http\n`, @@ -35,7 +32,8 @@ export async function metricsQuery(config: UniDeskConfig, options: MetricsQueryO const observability = readObservabilityConfig(); const target = resolveTarget(observability, options.targetId); const prometheus = observability.metricsBackend; - if (!prometheus.enabled) return { ok: false, action: "platform-infra-observability-metrics-query", mutation: false, target: targetSummary(target), metrics: { enabled: false }, warnings: ["Prometheus is disabled in config/platform-infra/observability.yaml"] }; + const disposition = metricsTargetDisposition(observability, target); + if (disposition !== "enabled") return { ok: false, action: "platform-infra-observability-metrics-query", mutation: false, target: targetSummary(target), metrics: { enabled: false, disposition, targetIds: prometheus.targetIds }, warnings: [`Prometheus is ${disposition} in config/platform-infra/observability.yaml`] }; const path = `${prometheus.query.path}?query=${encodeURIComponent(options.query)}`; const script = `set -u\npython3 - <<'PY'\nimport json, subprocess\npath = ${JSON.stringify(`/api/v1/namespaces/${target.namespace}/services/http:${prometheus.serviceName}:http/proxy${path}`)}\nproc = subprocess.run([\"kubectl\", \"get\", \"--raw\", path], text=True, capture_output=True, timeout=${prometheus.query.timeoutSeconds})\nbody = proc.stdout[:${prometheus.query.maxResponseBytes}]\ntry:\n parsed = json.loads(body) if body else None\nexcept Exception:\n parsed = None\nresult = parsed.get(\"data\", {}).get(\"result\", []) if isinstance(parsed, dict) else []\nprint(json.dumps({\"ok\": proc.returncode == 0 and isinstance(parsed, dict) and parsed.get(\"status\") == \"success\", \"exitCode\": proc.returncode, \"truncated\": len(proc.stdout) > ${prometheus.query.maxResponseBytes} or len(result) > ${prometheus.query.maxSeries}, \"resultType\": parsed.get(\"data\", {}).get(\"resultType\") if isinstance(parsed, dict) else None, \"result\": result[:${prometheus.query.maxSeries}], \"stderrTail\": proc.stderr[-2000:]}, ensure_ascii=False))\nPY`; const result = await capture(config, target.route, ["sh"], script); diff --git a/scripts/src/platform-infra-observability/search-script.ts b/scripts/src/platform-infra-observability/search-script.ts index 1bd8df23..83295560 100644 --- a/scripts/src/platform-infra-observability/search-script.ts +++ b/scripts/src/platform-infra-observability/search-script.ts @@ -21,6 +21,7 @@ import { import type { ObservabilityConfig, ObservabilityTarget } from "./types"; import { fieldManager } from "./types"; +import { metricsEnabledForTarget, metricsTargetDisposition } from "./metrics-target"; export function tempoService(observability: ObservabilityConfig, target: ObservabilityTarget): string { return `apiVersion: v1 @@ -102,11 +103,13 @@ PY } export function statusScript(observability: ObservabilityConfig, target: ObservabilityTarget, full: boolean, generateValidationTrace = false): string { - const enabledEndpoints = observability.metricsBackend.enabled + const metricsEnabled = metricsEnabledForTarget(observability, target); + const metricsDisposition = metricsTargetDisposition(observability, target); + const enabledEndpoints = metricsEnabled ? observability.probes.statusEndpoints : observability.probes.statusEndpoints.filter((endpoint) => endpoint.service !== observability.metricsBackend.serviceName); const endpointsJson = JSON.stringify(enabledEndpoints); - const metricsCaptures = observability.metricsBackend.enabled + const metricsCaptures = metricsEnabled ? `capture_json metrics_deployments kubectl -n ${shQuote(target.namespace)} get deployment ${shQuote(observability.metricsBackend.deploymentName)} -o json\ncapture_json metrics_services kubectl -n ${shQuote(target.namespace)} get service ${shQuote(observability.metricsBackend.serviceName)} -o json\ncapture_json metrics_pods kubectl -n ${shQuote(target.namespace)} get pods -l ${shQuote(`app.kubernetes.io/name=${observability.metricsBackend.deploymentName}`)} -o json` : ""; return ` @@ -325,12 +328,12 @@ payload = { "services": compact_section("services"), "pods": compact_section("pods"), "events": compact_section("events"), - "metricsDeployments": compact_section("metrics_deployments") if ${observability.metricsBackend.enabled ? "True" : "False"} else None, - "metricsServices": compact_section("metrics_services") if ${observability.metricsBackend.enabled ? "True" : "False"} else None, - "metricsPods": compact_section("metrics_pods") if ${observability.metricsBackend.enabled ? "True" : "False"} else None, + "metricsDeployments": compact_section("metrics_deployments") if ${metricsEnabled ? "True" : "False"} else None, + "metricsServices": compact_section("metrics_services") if ${metricsEnabled ? "True" : "False"} else None, + "metricsPods": compact_section("metrics_pods") if ${metricsEnabled ? "True" : "False"} else None, }, "probes": probe_results, - "warnings": ([{"code": "prometheus-not-ready", "detail": item} for item in metrics_probes if item.get("ok") is not True] if ${observability.metricsBackend.enabled ? "True" : "False"} else [{"code": "prometheus-disabled"}]), + "warnings": ([{"code": "prometheus-not-ready", "detail": item} for item in metrics_probes if item.get("ok") is not True] if ${metricsEnabled ? "True" : "False"} else [{"code": "prometheus-${metricsDisposition}"}]), "validationTrace": validation_trace, } print(json.dumps(payload, ensure_ascii=False, indent=2 if ${full ? "True" : "False"} else None)) diff --git a/scripts/src/platform-infra-observability/types.ts b/scripts/src/platform-infra-observability/types.ts index 2ce889df..629ff0d7 100644 --- a/scripts/src/platform-infra-observability/types.ts +++ b/scripts/src/platform-infra-observability/types.ts @@ -92,6 +92,7 @@ export interface ObservabilityConfig { export interface PrometheusConfig { type: "prometheus"; enabled: boolean; + targetIds: string[]; deploymentName: string; serviceName: string; configMapName: string; @@ -104,7 +105,7 @@ export interface PrometheusConfig { storage: { mode: "persistentVolumeClaim"; size: string; retention: string }; discovery: { namespaces: string[]; - labelSelector: string; + labelSelector: { key: string; value: string }; annotationKeys: { scrape: string; path: string; port: string }; defaultPath: string; scrapeInterval: string; From c3762b310cf34266c166113527832396fd9edb22 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 21:35:36 +0200 Subject: [PATCH 17/43] =?UTF-8?q?fix:=20=E4=B8=A5=E6=A0=BC=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=20Prometheus=20=E9=80=89=E6=8B=A9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/platform-infra-observability.test.ts | 25 +++++++++++++++++-- .../platform-infra-observability/config.ts | 23 +++++++++++++++-- .../prometheus.ts | 11 +++++--- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/scripts/src/platform-infra-observability.test.ts b/scripts/src/platform-infra-observability.test.ts index 6022844d..8c4eec8a 100644 --- a/scripts/src/platform-infra-observability.test.ts +++ b/scripts/src/platform-infra-observability.test.ts @@ -4,8 +4,9 @@ import { rootPath } from "./config"; import { isRenderedCliResult } from "./output"; import { runPlatformObservabilityCommand } from "./platform-infra-observability"; import { resolveTarget } from "./platform-infra-observability/actions"; -import { readObservabilityConfig } from "./platform-infra-observability/config"; +import { isKubernetesLabelValue, isKubernetesQualifiedName, readObservabilityConfig } from "./platform-infra-observability/config"; import { renderObservabilityPlanFailure } from "./platform-infra-observability/plan-render"; +import { prometheusMetaLabel, re2Literal } from "./platform-infra-observability/prometheus"; import { statusScript } from "./platform-infra-observability/search-script"; import { renderManifest } from "./platform-infra-observability/trace-script"; @@ -77,12 +78,32 @@ describe("platform-infra observability progressive disclosure", () => { const explicitPath = "source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]\n action: replace\n target_label: __metrics_path__\n regex: (/.+)"; const defaultPath = "source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]\n action: replace\n target_label: __metrics_path__\n regex: ^$\n replacement: /metrics"; expect(manifest).toContain("source_labels: [__meta_kubernetes_pod_label_unidesk_ai_metrics]"); - expect(manifest).toContain('regex: "true"'); + expect(manifest).toContain("regex: '^true$'"); expect(manifest).toContain(explicitPath); expect(manifest).toContain(defaultPath); expect(manifest.indexOf(explicitPath)).toBeLessThan(manifest.indexOf(defaultPath)); }); + test("Prometheus selector rendering uses qualified names, stable meta labels and literal RE2", () => { + const observability = readObservabilityConfig(); + observability.metricsBackend.discovery.labelSelector = { + key: "metrics.example.com/release-track", + value: "v1.2-beta", + }; + observability.metricsBackend.discovery.annotationKeys.scrape = "prometheus.io/scrape-enabled"; + const manifest = renderManifest(observability, resolveTarget(observability, "NC01")); + expect(manifest).toContain("__meta_kubernetes_pod_label_metrics_example_com_release_track"); + expect(manifest).toContain("__meta_kubernetes_pod_annotation_prometheus_io_scrape_enabled"); + expect(manifest).toContain("regex: '^v1\\.2-beta$'"); + expect(prometheusMetaLabel("a.b/c-d_e")).toBe("a_b_c_d_e"); + expect(re2Literal("v1.2-beta")).toBe("^v1\\.2-beta$"); + expect(isKubernetesQualifiedName("metrics.example.com/release-track")).toBe(true); + expect(isKubernetesQualifiedName("metrics.example.com/release/track")).toBe(false); + expect(isKubernetesQualifiedName("Metrics.example.com/release-track")).toBe(false); + expect(isKubernetesQualifiedName(`${"a".repeat(64)}.example/release-track`)).toBe(false); + expect(isKubernetesLabelValue("v1.2-beta")).toBe(true); + }); + test("query commands expose only scoped help", () => { for (const operation of ["search", "trace", "diagnose-code-agent", "metrics-query"]) { const result = cli(["platform-infra", "observability", operation, "--help"]); diff --git a/scripts/src/platform-infra-observability/config.ts b/scripts/src/platform-infra-observability/config.ts index 33b1fb10..31c86b15 100644 --- a/scripts/src/platform-infra-observability/config.ts +++ b/scripts/src/platform-infra-observability/config.ts @@ -149,8 +149,8 @@ export function readObservabilityConfig(): ObservabilityConfig { for (const targetId of config.metricsBackend.targetIds) assertKnownEnabledTarget(config.targets, targetId, "metricsBackend.targetIds"); if (config.metricsBackend.enabled && config.metricsBackend.targetIds.length === 0) throw new Error(`${configLabel}.metricsBackend.targetIds must not be empty when metricsBackend.enabled is true`); if (new Set(config.metricsBackend.targetIds.map((targetId) => targetId.toLowerCase())).size !== config.metricsBackend.targetIds.length) throw new Error(`${configLabel}.metricsBackend.targetIds must not contain duplicates`); - if (!/^[A-Za-z0-9]([A-Za-z0-9._/-]*[A-Za-z0-9])?$/u.test(config.metricsBackend.discovery.labelSelector.key)) throw new Error(`${configLabel}.metricsBackend.discovery.labelSelector.key must be a Kubernetes label key`); - if (!/^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)?$/u.test(config.metricsBackend.discovery.labelSelector.value) || config.metricsBackend.discovery.labelSelector.value.length > 63) throw new Error(`${configLabel}.metricsBackend.discovery.labelSelector.value must be a Kubernetes label value`); + if (!isKubernetesQualifiedName(config.metricsBackend.discovery.labelSelector.key)) throw new Error(`${configLabel}.metricsBackend.discovery.labelSelector.key must be a Kubernetes qualified name`); + if (!isKubernetesLabelValue(config.metricsBackend.discovery.labelSelector.value)) throw new Error(`${configLabel}.metricsBackend.discovery.labelSelector.value must be a Kubernetes label value`); if (config.collector.replicas < 0 || config.traceBackend.replicas < 0 || config.metricsBackend.replicas < 0) throw new Error(`${configLabel} replicas must be >= 0`); if (config.metricsBackend.query.timeoutSeconds < 1 || config.metricsBackend.query.maxSeries < 1 || config.metricsBackend.query.maxResponseBytes < 1024) throw new Error(`${configLabel}.metricsBackend.query budgets must be positive and maxResponseBytes must be >= 1024`); if (config.sampling.ratio < 0 || config.sampling.ratio > 1) throw new Error(`${configLabel}.sampling.ratio must be between 0 and 1`); @@ -158,6 +158,25 @@ export function readObservabilityConfig(): ObservabilityConfig { return config; } +export function isKubernetesQualifiedName(value: string): boolean { + const slash = value.indexOf("/"); + if (slash !== value.lastIndexOf("/")) return false; + const prefix = slash < 0 ? null : value.slice(0, slash); + const name = slash < 0 ? value : value.slice(slash + 1); + if (!isKubernetesNamePart(name)) return false; + if (prefix === null) return true; + if (prefix.length === 0 || prefix.length > 253) return false; + return prefix.split(".").every((segment) => segment.length <= 63 && /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(segment)); +} + +export function isKubernetesLabelValue(value: string): boolean { + return value.length <= 63 && (value === "" || isKubernetesNamePart(value)); +} + +function isKubernetesNamePart(value: string): boolean { + return value.length > 0 && value.length <= 63 && /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/u.test(value); +} + export function imageSpec(record: Record, path: string): ImageSpec { const image = { repository: stringField(record, "repository", path), diff --git a/scripts/src/platform-infra-observability/prometheus.ts b/scripts/src/platform-infra-observability/prometheus.ts index bb28d59c..0b1b893a 100644 --- a/scripts/src/platform-infra-observability/prometheus.ts +++ b/scripts/src/platform-infra-observability/prometheus.ts @@ -14,7 +14,7 @@ export function prometheusManifests(observability: ObservabilityConfig, target: const namespaceNames = prometheus.discovery.namespaces.length > 0 ? `\n namespaces:\n names:\n${prometheus.discovery.namespaces.map((name) => ` - ${name}`).join("\n")}` : ""; - const config = `global:\n scrape_interval: ${prometheus.discovery.scrapeInterval}\n scrape_timeout: ${prometheus.discovery.scrapeTimeout}\nscrape_configs:\n - job_name: kubernetes-pods\n kubernetes_sd_configs:\n - role: pod${namespaceNames}\n relabel_configs:\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.scrape)}]\n action: keep\n regex: \"true\"\n - source_labels: [__meta_kubernetes_pod_label_${labelMeta(prometheus.discovery.labelSelector.key)}]\n action: keep\n regex: \"${prometheus.discovery.labelSelector.value}\"\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: (/.+)\n - source_labels: [__address__, __meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.port)}]\n action: replace\n regex: ([^:]+)(?::\\d+)?;(\\d+)\n replacement: \$1:\$2\n target_label: __address__\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: ^$\n replacement: ${prometheus.discovery.defaultPath}\n`; + const config = `global:\n scrape_interval: ${prometheus.discovery.scrapeInterval}\n scrape_timeout: ${prometheus.discovery.scrapeTimeout}\nscrape_configs:\n - job_name: kubernetes-pods\n kubernetes_sd_configs:\n - role: pod${namespaceNames}\n relabel_configs:\n - source_labels: [__meta_kubernetes_pod_annotation_${prometheusMetaLabel(prometheus.discovery.annotationKeys.scrape)}]\n action: keep\n regex: \"true\"\n - source_labels: [__meta_kubernetes_pod_label_${prometheusMetaLabel(prometheus.discovery.labelSelector.key)}]\n action: keep\n regex: '${re2Literal(prometheus.discovery.labelSelector.value)}'\n - source_labels: [__meta_kubernetes_pod_annotation_${prometheusMetaLabel(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: (/.+)\n - source_labels: [__address__, __meta_kubernetes_pod_annotation_${prometheusMetaLabel(prometheus.discovery.annotationKeys.port)}]\n action: replace\n regex: ([^:]+)(?::\\d+)?;(\\d+)\n replacement: \$1:\$2\n target_label: __address__\n - source_labels: [__meta_kubernetes_pod_annotation_${prometheusMetaLabel(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: ^$\n replacement: ${prometheus.discovery.defaultPath}\n`; const labels = ` app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n app.kubernetes.io/part-of: platform-infra\n app.kubernetes.io/managed-by: unidesk`; return [ `apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: ${prometheus.serviceAccountName}\n namespace: ${target.namespace}\n labels:\n${labels}\n`, @@ -44,5 +44,10 @@ export async function metricsQuery(config: UniDeskConfig, options: MetricsQueryO return { ok, command: "platform-infra observability metrics-query", contentType: "text/plain", renderedText: [`platform-infra observability metrics-query (${ok ? "ok" : "not-ok"})`, "", `target=${target.id} prometheus=${prometheus.serviceName}.${target.namespace}.svc.cluster.local:${prometheus.httpPort}`, `query=${options.query}`, `budget=timeout:${prometheus.query.timeoutSeconds}s series:${prometheus.query.maxSeries} bytes:${prometheus.query.maxResponseBytes} truncated=${textValue(parsed?.truncated)}`, "", formatTable(["RESULT"], rows.length > 0 ? rows : [["-"]]), "", `Next: bun scripts/cli.ts platform-infra observability metrics-query --target ${target.id} --query ${shQuote(options.query)} --full`].join("\n") }; } -function annotationMeta(value: string): string { return value.replaceAll(".", "_").replaceAll("/", "_"); } -function labelMeta(value: string): string { return value.replaceAll(".", "_").replaceAll("/", "_").replaceAll("-", "_"); } +export function prometheusMetaLabel(value: string): string { + return value.replace(/[^A-Za-z0-9_]/gu, "_"); +} + +export function re2Literal(value: string): string { + return `^${value.replace(/[\\.^$|?*+()[\]{}]/gu, "\\$&")}$`; +} From a1de439174b0f1dea7f3db9bb9805dc672836fd1 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 21:44:06 +0200 Subject: [PATCH 18/43] =?UTF-8?q?docs:=20=E5=AE=8C=E6=88=90=20PikaOA=20?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E4=BA=A4=E4=BB=98=E5=9F=BA=E7=BA=BF=E6=94=B6?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../R1.5.2_Task_Report.md | 31 +++++++++++++++++ .../R1.5_Task_Report.md | 34 +++++++++++++++++++ docs/MDTODO/pikaoa-enterprise-platform.md | 4 +-- 3 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.2_Task_Report.md create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.5_Task_Report.md diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.2_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.2_Task_Report.md new file mode 100644 index 00000000..d0938854 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.2_Task_Report.md @@ -0,0 +1,31 @@ +# R1.5.2 任务报告 + +## 结论 + +共享 observability 控制面已在同一 YAML-first 架构中加入 Prometheus。Prometheus 只作用于 `metricsBackend.targetIds` 选中的 target,OTel Collector 与 Tempo 继续承担 trace authority;指标故障和版本漂移只形成非阻塞 warning。 + +## 变更 + +- `config/platform-infra/observability.yaml` 拥有 Prometheus image、目标、存储、保留、端口、服务发现和查询预算。 +- NC01 渲染 ServiceAccount、RBAC、ConfigMap、PVC、Deployment 与 ClusterIP Service;D518/JD01 不渲染或访问 Prometheus。 +- `metrics-query` 提供受 YAML timeout、series 和 response bytes 预算约束的有界 PromQL 查询。 +- selector 使用严格 Kubernetes qualified name/value,渲染为锚定且转义的 RE2 字面量;label/annotation meta-label 统一规范化。 +- 显式 metrics path 优先,仅在 annotation 缺失时回退到 YAML `defaultPath`。 +- 未部署、未执行 confirm;真实 StorageClass、镜像拉取和 scrape target 留给 R1.6 受控 rollout 验证。 + +## 验证 + +- `bun test scripts/src/platform-infra-observability.test.ts`:8 pass,0 fail,100 assertions。 +- `platform-infra observability --help` 暴露 scoped `metrics-query`。 +- NC01 plan:`prometheus=enabled`,15 个对象。 +- D518/JD01 plan:`prometheus=disabled-for-target`,各 8 个对象,无 Prometheus manifest、status capture/probe 或远端 query。 +- 模块导入与 `git diff --check` 通过。 + +## 交付 + +- 实现 commit:`f516691a`。 +- target 隔离与发现规则纠偏:`ef3b5fde`。 +- selector 严格渲染纠偏:`c3762b31`。 +- 目标分支 merge commit:`2ee27b0c`。 +- 子 issue:[pikasTech/unidesk#1956](https://github.com/pikasTech/unidesk/issues/1956)。 +- Post-task 未建议新增 FEATURE/BUG issue。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5_Task_Report.md new file mode 100644 index 00000000..c5d2f5ec --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5_Task_Report.md @@ -0,0 +1,34 @@ +# R1.5 任务报告 + +## 结论 + +PikaOA 的 UniDesk 交付基线已完成 YAML-first 声明,并补齐可复用的 PaC bootstrap、PostgreSQL 紧凑 plan 和 NC01 Prometheus 控制面。全部变更仍处于源码与 dry-run 阶段,未执行首次 bootstrap、数据库 apply 或运行面部署。 + +## Owning YAML + +- `config/pikaoa.yaml`:NC01 独立 namespace、Web/API/Worker、Secret、资源、探针、OTel/metrics 标注和 `https://oa.pikapython.com` PK01 公网暴露。 +- `config/platform-db/postgres-pk01.yaml`:host PostgreSQL 的 PikaOA role、database 与 Secret export。 +- `config/secrets-distribution.yaml`:管理员、普通员工和数据库连接的 `sourceRef`/`targetKey`。 +- `config/platform-infra/gitea.yaml` 与 `config/platform-infra/pipelines-as-code.yaml`:Gitea repository、PaC consumer、Tekton/GitOps/Argo 首发基线。 +- `config/platform-infra/observability.yaml`:共享 OTel/Tempo/Prometheus 与 NC01 target 隔离。 + +## CLI 与工具改进 + +- PaC bootstrap 现在提供 YAML 精确匹配、effective credential、GitHub 上游预检、分阶段 typed 状态和唯一下一步。 +- 未执行阶段显示 `skipped`,外部建仓阻塞不再误报内部阶段失败。 +- `platform-db postgres plan` 默认输出保持在预算内,并显示 PikaOA role、database 和 redacted Secret/export 摘要。 +- `platform-infra observability` 增加 target-scoped Prometheus plan/status/query;指标故障不阻塞业务。 + +## 验证与交付 + +- 交付基线:`9b6921ad`。 +- PostgreSQL plan:`88eb8fdb`,8 tests 通过,默认输出 9,589 bytes。 +- Bootstrap:`697b6933`、`1a04b029`、`e808b298`,19 项定向测试通过。 +- Prometheus:`f516691a`、`ef3b5fde`、`c3762b31`,8 tests / 100 assertions 通过。 +- 平台分支当前 merge commit:`2ee27b0c`。 +- PikaOA bootstrap dry-run 为 `mutation=false`,准确阻塞于 `github-repository-not-found-or-no-access`。 + +## 未完成边界 + +- `pikaInc/pikaoa` 仍因组织建仓权限缺失而不存在,R1.2 保持阻塞。 +- 首次 bootstrap、自动 source PR delivery、NC01 rollout、PK01 PostgreSQL apply、OTel/Prometheus 运行面与公网入口验收属于 R1.6-R1.7。 diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index ff0bb631..3bfeb400 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -29,14 +29,14 @@ 在 R1.3 CLI 后端验收通过后实现 HWLAB v0.3 同族 Vue 工作台、响应式页面与同路径业务流,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.4_Task_Report.md)。 -### R1.5 [in_progress] +### R1.5 [completed] 在 UniDesk 中声明 PK01 PostgreSQL、Secret、NC01 独立 namespace、Gitea/PaC、GitOps、OTel/Prometheus 和 PK01 公网暴露,并改进可复用 bootstrap 工具,依赖 R1.1-R1.2,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5_Task_Report.md)。 #### R1.5.1 [completed] 改进 PaC bootstrap 的新服务首发预检、分阶段紧凑输出、typed failure 与中文说明,保持 PR merge 唯一交付 authority,执行记录见 [pikasTech/unidesk#1955](https://github.com/pikasTech/unidesk/issues/1955),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.1_Task_Report.md)。 -#### R1.5.2 [in_progress] +#### R1.5.2 [completed] 扩展 NC01 共享 observability,使 OTel/Tempo 与 Prometheus 由同一 YAML-first 控制面管理并提供跨 namespace 指标抓取和有界查询,执行记录见 [pikasTech/unidesk#1956](https://github.com/pikasTech/unidesk/issues/1956),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.2_Task_Report.md)。 #### R1.5.3 [completed] From 240c19391ae338eab51816f483df107fd6fae8e3 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 21:49:59 +0200 Subject: [PATCH 19/43] =?UTF-8?q?docs:=20=E7=99=BB=E8=AE=B0=20PikaOA=20?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=20CLI=20=E5=8F=AF=E8=A7=81=E6=80=A7?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/pikaoa-enterprise-platform.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 3bfeb400..5e1f8c77 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -29,7 +29,7 @@ 在 R1.3 CLI 后端验收通过后实现 HWLAB v0.3 同族 Vue 工作台、响应式页面与同路径业务流,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.4_Task_Report.md)。 -### R1.5 [completed] +### R1.5 [in_progress] 在 UniDesk 中声明 PK01 PostgreSQL、Secret、NC01 独立 namespace、Gitea/PaC、GitOps、OTel/Prometheus 和 PK01 公网暴露,并改进可复用 bootstrap 工具,依赖 R1.1-R1.2,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5_Task_Report.md)。 @@ -42,6 +42,9 @@ #### R1.5.3 [completed] 修复 `platform-db postgres plan` 默认 30KB JSON 被输出预算截断的可见性缺陷,提供数据库、角色、Secret/export、检查与下一步的紧凑 CLI-first 投影,执行记录沿用 [主 issue](https://github.com/pikasTech/unidesk/issues/1952),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.3_Task_Report.md)。 +#### R1.5.4 [in_progress] + +修复 `platform-db postgres status` 与 `export-secrets --dry-run` 默认输出超预算截断,提供与 plan 同族的 Secret/export 有界投影并保持 valuesPrinted=false,执行记录见 [pikasTech/unidesk#1958](https://github.com/pikasTech/unidesk/issues/1958),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.4_Task_Report.md)。 ### R1.6 审核并合并双仓 PR,执行首次受控 PaC bootstrap,由正常 source PR merge 自动发布,依赖 R1.3-R1.5,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6_Task_Report.md)。 From 740957570a1d3fc026ae06509e2f37717bad923d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 22:07:26 +0200 Subject: [PATCH 20/43] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=20PostgreSQL=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=B8=8E=20Secret=20=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/cli.md | 6 +- scripts/src/platform-db.ts | 218 ++++++++++++++++++++++++++++++++++++- 2 files changed, 217 insertions(+), 7 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d8da82dc..401a1228 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -115,7 +115,11 @@ PipelineRun 失败或长时间未完成时,先按定点 `control-plane status - `gc plan|run --confirm|db-trace|policy|remote` 是主 server 和受控 provider 的磁盘高水位一次性缓解与长期防膨胀入口。`plan` 只读输出候选、风险、估算收益和保护对象;`run` 必须显式 `--confirm`;`gc remote ...` 通过 UniDesk SSH 透传执行远端 GC,`--target-use-percent N` 会在 `summary.target` 中报告目标水位所需释放量、候选估算、预计水位、缺口和 safe-stop 决策。默认只包含 allowlisted `/tmp` 诊断目录;非 allowlist stale `/tmp` 直接子项必须显式 `--include-stale-tmp`,并只允许删除 `/tmp` 一级子项且避开系统 socket/session 前缀。G14/HWLAB registry retention、受限 core dump、保护对象、safe-stop 线和长期收益表的权威规则见 `docs/reference/gc.md`。 - `server rebuild ` 创建异步 job,先构建目标服务镜像,随后在 `.state/locks/server-compose.lock` 串行保护下用 `--no-deps --force-recreate` 替换目标 service 并等待容器 `healthy/running`。Todo Note 的 CC01/旧 Compose 回退入口已在迁移验收后删除;正式发布只走 NC01 PaC/Argo。D601 Code Queue 执行面不由 `server rebuild` 管理;Rust backend-core 常规迭代不得用该命令在 master server 编译,只有明确的 backend-core 主 server 上线例外可以按限流、异步轮询和 health 证据执行,规则见 `docs/reference/dev-environment.md`。 - `provider attach [--master-server URL] [--up] [--force]` 在新计算节点生成两项配置的 provider-gateway 挂载包:`.state/provider-.env` 默认只包含 `UNIDESK_MASTER_SERVER` 与 `PROVIDER_ID`,`provider-.yml` 固定 Docker socket、`pid: "host"`、`restart: always`、只读 `/workspace` 和 SSH 维护私钥挂载;`--up` 会立即执行生成的 `docker compose up -d --build`。`provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]` 是只读多信号健康裁决入口,会把单路径 `provider is not online`、SSH 超时、registry 失败和 service proxy 失败归类成 `runner-local-observation-gap`、`service-degraded`、`provider-degraded` 或 `global-blocker`。默认输出只返回裁决、scope、失败/降级/未知信号和有界 evidence 摘要,完整 evidence 必须显式加 `--full` 或 `--raw`;推荐交叉验证命令仍包含 `debug health`、`debug dispatch host.ssh --wait-ms 15000`、`trans argv true`、`artifact-registry health --provider-id `、`microservice health k3sctl-adapter`、`microservice health code-queue` 和 `codex tasks --view supervisor --limit 20`。 -- `platform-db postgres plan|status|export-secrets|apply --config config/platform-db/postgres-pk01.yaml` 管理 YAML 声明的 PK01 host-native PostgreSQL。`plan` 和 `status` 只读采集远端 host、PostgreSQL、TLS、DNS alias 和 Secret 形态;`export-secrets --confirm` 只按 YAML 物化本地 Secret source/export 文件,不触碰远端 PostgreSQL;`apply --confirm` 默认创建本地异步 job,`apply --confirm --wait` 用远端 root-owned job 收敛 systemd PostgreSQL、TLS、`pg_hba`、role/database、Secret export 和备份 timer。输出不得打印密码或完整 `DATABASE_URL`。跨节点消费者使用 YAML 中的 `connectionHost` 直连 PK01 公网 endpoint,DNS alias 不作为 `sslmode=require` 切库 blocker,PK01 规则见 `docs/reference/pk01.md`。 +- `platform-db postgres plan|status|export-secrets|apply --config config/platform-db/postgres-pk01.yaml` 管理 YAML 声明的 PK01 host-native PostgreSQL: + - `plan`、`status` 和 `export-secrets` 默认返回有界摘要,保留 role/database、Secret/export key 的 presence、fingerprint、mutation、typed failure、`valuesPrinted=false` 和语义化下钻;只有显式 `--full` / `--raw` 才披露完整结构化结果。 + - `plan` 和 `status` 只读采集远端 host、PostgreSQL、TLS、DNS alias 和 Secret 形态;`export-secrets --confirm` 只按 YAML 物化本地 Secret source/export 文件,不触碰远端 PostgreSQL。 + - `apply --confirm` 默认创建本地异步 job;`apply --confirm --wait` 用远端 root-owned job 收敛 systemd PostgreSQL、TLS、`pg_hba`、role/database、Secret export 和备份 timer。 + - 输出不得打印密码或完整 `DATABASE_URL`;跨节点消费者使用 YAML 中的 `connectionHost` 直连 PK01 公网 endpoint,DNS alias 不作为 `sslmode=require` 切库 blocker,PK01 规则见 `docs/reference/pk01.md`。 - `trans [operation args...]` / `tran [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点: - `route` 基础形态是 provider id,例如 `D601` 或 `G14`;也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `D601:win`、`D601:win/c/test`、`G14:k3s`、`D601:k3s` 或 `G14:k3s::`。 - WSL provider 的 Windows plane 固定使用 `win`,不得使用 `win32`;Windows route 中 `win` 只负责定位,operation 直接写 `ps`、`cmd`、`git` 或 fs helper。 diff --git a/scripts/src/platform-db.ts b/scripts/src/platform-db.ts index 95496255..b2441927 100644 --- a/scripts/src/platform-db.ts +++ b/scripts/src/platform-db.ts @@ -304,8 +304,8 @@ export function platformDbHelp(): unknown { usage: [ `bun scripts/cli.ts platform-db postgres plan --config ${defaultConfigPath} [--full|--raw]`, `bun scripts/cli.ts platform-db postgres status --config ${defaultConfigPath} [--full|--raw]`, - `bun scripts/cli.ts platform-db postgres export-secrets --config ${defaultConfigPath} --dry-run`, - `bun scripts/cli.ts platform-db postgres export-secrets --config ${defaultConfigPath} --confirm`, + `bun scripts/cli.ts platform-db postgres export-secrets --config ${defaultConfigPath} --dry-run [--full|--raw]`, + `bun scripts/cli.ts platform-db postgres export-secrets --config ${defaultConfigPath} --confirm [--full|--raw]`, `bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --dry-run`, `bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --confirm`, `bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --confirm --wait`, @@ -318,6 +318,7 @@ export function platformDbHelp(): unknown { configTruth: defaultConfigPath, defaultMutation: false, exportSecrets: "export-secrets only materializes YAML-declared local Secret source/export files; it does not touch the remote PostgreSQL service.", + disclosure: "plan, status and export-secrets use bounded summaries by default; --full and --raw explicitly disclose the complete structured result without printing Secret values.", apply: "apply --confirm returns an async UniDesk job; the job uses short trans calls and a remote PK01 job instead of keeping one long SSH session open.", monitorReset: "monitor reset is restricted by owning YAML to the dedicated web_probe_monitor database and requires --confirm; it cannot accept a database name or arbitrary SQL.", redaction: "Passwords and full DATABASE_URL values are never printed; output is limited to key names, presence, fingerprints and state.", @@ -488,7 +489,8 @@ async function status(config: UniDeskConfig, options: PlatformDbOptions): Promis ...(secrets.ok ? [] : ["secrets-unhealthy"]), ...(endpointHealthy ? [] : [controllerConnection.attempted ? "connection-host-probe-failed" : "connection-host-probe-unavailable"]), ]; - return { + const exportTargets = inspectExportTargets(pg); + const result = { ok: remote.capture.exitCode === 0 && facts !== null && deploymentHealthy && secrets.ok, action: "platform-db-postgres-status", mutation: false, @@ -525,9 +527,12 @@ async function status(config: UniDeskConfig, options: PlatformDbOptions): Promis }, remoteFacts: facts, secrets: secretSummary(secrets), + exports: exportTargets, remote: compactCapture(remote.capture, { full: options.full || remote.capture.exitCode !== 0 }), ...(options.raw ? { raw: remote.capture } : {}), }; + if (options.full) return result; + return compactStatus(pg, secrets, exportTargets, facts, controllerConnection, remote.capture, result.summary, result.ok); } async function apply(config: UniDeskConfig, options: PlatformDbOptions): Promise> { @@ -603,7 +608,7 @@ async function exportSecrets(options: PlatformDbOptions): Promise>, + facts: RemoteFacts | null, + controllerConnection: ControllerConnectionProbe, + capture: SshCaptureResult, + summary: Record | null, + ok: boolean, +): Record { + const blockers = summary === null + ? ["remote-facts-unavailable"] + : Array.isArray(summary.cutoverBlockers) + ? summary.cutoverBlockers + : []; + return { + ok, + action: "platform-db-postgres-status", + mutation: false, + failure: ok ? null : { + type: capture.exitCode !== 0 || facts === null + ? "remote-facts-unavailable" + : secrets.ok + ? "deployment-unhealthy" + : "secrets-unhealthy", + blockers, + }, + target: { + node: pg.node.id, + route: pg.node.route, + mode: pg.node.mode, + }, + config: { + path: pg.configPath, + id: pg.metadata.id, + pgVersion: pg.postgres.package.version, + connectionHost: pg.postgres.network.connectionHost, + port: pg.postgres.network.port, + sslmode: pg.postgres.network.sslmode, + }, + summary: summary === null ? null : { + healthy: summary.healthy, + deploymentHealthy: summary.deploymentHealthy, + cutoverReady: summary.cutoverReady, + cutoverBlockers: summary.cutoverBlockers, + packageInstalled: summary.packageInstalled, + serviceActive: summary.serviceActive, + serviceEnabled: summary.serviceEnabled, + sslOn: summary.sslOn, + port5432Listening: summary.port5432Listening, + dnsResolves: summary.dnsResolves, + dnsDisposition: summary.dnsDisposition, + secretsOk: summary.secretsOk, + connectionHostProbe: { + attempted: controllerConnection.attempted, + ok: controllerConnection.ok, + ssl: controllerConnection.ssl, + user: controllerConnection.user, + database: controllerConnection.database, + host: controllerConnection.host, + port: controllerConnection.port, + error: controllerConnection.error, + }, + }, + roles: pg.objects.roles.map((role) => ({ + name: role.name, + exists: facts?.postgres.roles.find((item) => item.name === role.name)?.exists ?? null, + })), + databases: pg.objects.databases.map((database) => ({ + name: database.name, + owner: database.owner, + exists: facts?.postgres.databases.find((item) => item.name === database.name)?.exists ?? null, + })), + appConnections: { + ok: facts?.postgres.appConnections.every((connection) => connection.ok === true && connection.ssl === true) ?? false, + passed: facts?.postgres.appConnections.filter((connection) => connection.ok === true && connection.ssl === true).length ?? 0, + total: facts?.postgres.appConnections.length ?? 0, + failed: facts?.postgres.appConnections + .filter((connection) => connection.ok !== true || connection.ssl !== true) + .map((connection) => ({ user: connection.user, database: connection.database, error: connection.error })) ?? [], + }, + secrets: compactSecretInspection(secrets), + exports: exports.map((item) => ({ + targetRef: item.targetRef, + key: item.key, + exists: item.exists, + present: item.present, + fingerprint: item.fingerprint, + })), + remote: compactPlanCapture(capture), + next: { + plan: `bun scripts/cli.ts platform-db postgres plan --config ${pg.configPath}`, + full: `bun scripts/cli.ts platform-db postgres status --config ${pg.configPath} --full`, + raw: `bun scripts/cli.ts platform-db postgres status --config ${pg.configPath} --raw`, + }, + disclosure: { + compact: true, + omitted: ["host facts", "package repository", "TLS paths", "raw remote capture"], + fullAvailable: true, + rawAvailable: true, + }, + valuesPrinted: false, + }; +} + +function compactExportSecrets( + pg: PostgresHostConfig, + localState: { inspection: SecretInspection; summary: Record; exports: Array> }, + mode: "dry-run" | "confirmed", + mutation: boolean, + ok: boolean, +): Record { + const suffix = mode === "dry-run" ? " --dry-run" : " --confirm"; + return { + ok, + action: "platform-db-postgres-export-secrets", + mode, + mutation, + failure: ok ? null : { + type: "secrets-unhealthy", + blockers: localState.inspection.entries.filter((entry) => entry.missingKeys.length > 0).map((entry) => entry.sourceRef), + }, + config: { + path: pg.configPath, + id: pg.metadata.id, + secretRoot: localState.inspection.root, + }, + secrets: compactSecretInspection(localState.inspection), + exports: localState.exports.map((item) => ({ + name: item.name, + sourceRef: item.sourceRef, + targetRef: item.targetRef, + key: item.key, + exists: item.exists, + present: item.present, + action: item.action, + fingerprint: item.fingerprint, + })), + next: { + ...(mode === "dry-run" ? { confirm: `bun scripts/cli.ts platform-db postgres export-secrets --config ${pg.configPath} --confirm` } : {}), + full: `bun scripts/cli.ts platform-db postgres export-secrets --config ${pg.configPath}${suffix} --full`, + raw: `bun scripts/cli.ts platform-db postgres export-secrets --config ${pg.configPath}${suffix} --raw`, + syncConsumers: "run the consumer-specific secret sync command after confirmed export", + }, + disclosure: { + compact: true, + omitted: ["source paths", "required key lists", "consumer Secret details"], + fullAvailable: true, + rawAvailable: true, + }, + valuesPrinted: false, + }; +} + +function compactSecretInspection(secrets: SecretInspection): Record { + return { + ok: secrets.ok, + root: secrets.root, + entries: secrets.entries.map((entry) => ({ + sourceRef: entry.sourceRef, + exists: entry.exists, + keys: { + present: entry.presentKeys, + missing: entry.missingKeys, + }, + action: entry.action, + fingerprint: entry.fingerprint, + })), + valuesPrinted: false, + }; +} + function compactRemoteFacts(facts: RemoteFacts | null): Record | null { if (facts === null) return null; return { @@ -1384,7 +1565,8 @@ function connectionStringExportState(pg: PostgresHostConfig, inspection: SecretI const rendered = renderConnectionString(item, source.values); const root = secretRoot(pg); const targetPath = join(root, item.writeToSecretSource.sourceRef); - const existing = existsSync(targetPath) ? parseEnvFile(readFileSync(targetPath, "utf8")) : {}; + const exists = existsSync(targetPath); + const existing = exists ? parseEnvFile(readFileSync(targetPath, "utf8")) : {}; const before = existing[item.writeToSecretSource.key]; const next = { ...existing, [item.writeToSecretSource.key]: rendered }; if (materialize) writeEnvFile(targetPath, next); @@ -1393,6 +1575,8 @@ function connectionStringExportState(pg: PostgresHostConfig, inspection: SecretI sourceRef: item.sourceSecretRef, targetRef: item.writeToSecretSource.sourceRef, key: item.writeToSecretSource.key, + exists, + present: before !== undefined && before.length > 0, action: before === rendered ? "none" : before === undefined ? "create" : "update", fingerprint: fingerprintValues({ [item.writeToSecretSource.key]: rendered }, [item.writeToSecretSource.key]), consumers: item.consumers, @@ -1400,6 +1584,28 @@ function connectionStringExportState(pg: PostgresHostConfig, inspection: SecretI }; } +function inspectExportTargets(pg: PostgresHostConfig): Array> { + const root = secretRoot(pg); + return pg.exports.connectionStrings.map((item) => { + const targetPath = join(root, item.writeToSecretSource.sourceRef); + const exists = existsSync(targetPath); + const values = exists ? parseEnvFile(readFileSync(targetPath, "utf8")) : {}; + const value = values[item.writeToSecretSource.key]; + const present = value !== undefined && value.length > 0; + return { + name: item.name, + sourceRef: item.sourceSecretRef, + targetRef: item.writeToSecretSource.sourceRef, + key: item.writeToSecretSource.key, + exists, + present, + fingerprint: present ? fingerprintValues({ [item.writeToSecretSource.key]: value }, [item.writeToSecretSource.key]) : null, + consumerScopes: item.consumers.map((consumer) => consumer.scope), + valuesPrinted: false, + }; + }); +} + function renderConnectionString(item: ConnectionStringExportConfig, values: Record): string { let output = item.render.format; const merged = { ...values, ...(item.render.variables ?? {}) }; From 8d1cd6759788dc22184652b131f749815e179f68 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 22:09:11 +0200 Subject: [PATCH 21/43] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=20PostgreSQL=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=91=98=E8=A6=81=E9=A2=84=E7=AE=97=E4=BD=99?= =?UTF-8?q?=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/platform-db.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/src/platform-db.ts b/scripts/src/platform-db.ts index b2441927..219aca1e 100644 --- a/scripts/src/platform-db.ts +++ b/scripts/src/platform-db.ts @@ -1248,7 +1248,7 @@ function compactStatus( total: facts?.postgres.appConnections.length ?? 0, failed: facts?.postgres.appConnections .filter((connection) => connection.ok !== true || connection.ssl !== true) - .map((connection) => ({ user: connection.user, database: connection.database, error: connection.error })) ?? [], + .map((connection) => connection.database) ?? [], }, secrets: compactSecretInspection(secrets), exports: exports.map((item) => ({ From 9040d6d6f02851e7677211edd768377e8564845e Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 22:20:44 +0200 Subject: [PATCH 22/43] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=20PostgreSQL=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E8=A7=84=E6=A8=A1=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/cli.md | 4 +- scripts/src/platform-db.ts | 159 ++++++++++++++++++++++++++----------- 2 files changed, 114 insertions(+), 49 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 401a1228..fbae2efa 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -116,8 +116,8 @@ PipelineRun 失败或长时间未完成时,先按定点 `control-plane status - `server rebuild ` 创建异步 job,先构建目标服务镜像,随后在 `.state/locks/server-compose.lock` 串行保护下用 `--no-deps --force-recreate` 替换目标 service 并等待容器 `healthy/running`。Todo Note 的 CC01/旧 Compose 回退入口已在迁移验收后删除;正式发布只走 NC01 PaC/Argo。D601 Code Queue 执行面不由 `server rebuild` 管理;Rust backend-core 常规迭代不得用该命令在 master server 编译,只有明确的 backend-core 主 server 上线例外可以按限流、异步轮询和 health 证据执行,规则见 `docs/reference/dev-environment.md`。 - `provider attach [--master-server URL] [--up] [--force]` 在新计算节点生成两项配置的 provider-gateway 挂载包:`.state/provider-.env` 默认只包含 `UNIDESK_MASTER_SERVER` 与 `PROVIDER_ID`,`provider-.yml` 固定 Docker socket、`pid: "host"`、`restart: always`、只读 `/workspace` 和 SSH 维护私钥挂载;`--up` 会立即执行生成的 `docker compose up -d --build`。`provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]` 是只读多信号健康裁决入口,会把单路径 `provider is not online`、SSH 超时、registry 失败和 service proxy 失败归类成 `runner-local-observation-gap`、`service-degraded`、`provider-degraded` 或 `global-blocker`。默认输出只返回裁决、scope、失败/降级/未知信号和有界 evidence 摘要,完整 evidence 必须显式加 `--full` 或 `--raw`;推荐交叉验证命令仍包含 `debug health`、`debug dispatch host.ssh --wait-ms 15000`、`trans argv true`、`artifact-registry health --provider-id `、`microservice health k3sctl-adapter`、`microservice health code-queue` 和 `codex tasks --view supervisor --limit 20`。 - `platform-db postgres plan|status|export-secrets|apply --config config/platform-db/postgres-pk01.yaml` 管理 YAML 声明的 PK01 host-native PostgreSQL: - - `plan`、`status` 和 `export-secrets` 默认返回有界摘要,保留 role/database、Secret/export key 的 presence、fingerprint、mutation、typed failure、`valuesPrinted=false` 和语义化下钻;只有显式 `--full` / `--raw` 才披露完整结构化结果。 - - `plan` 和 `status` 只读采集远端 host、PostgreSQL、TLS、DNS alias 和 Secret 形态;`export-secrets --confirm` 只按 YAML 物化本地 Secret source/export 文件,不触碰远端 PostgreSQL。 + - `plan`、`status` 和 `export-secrets` 默认返回有界摘要;配置项使用 total/passed/missing/actionable/omitted 计数,只预览固定数量的失败、缺失或待处理项,并保留 mutation、typed failure、`valuesPrinted=false` 和语义化下钻。只有显式 `--full` / `--raw` 才披露完整结构化结果。 + - `plan` 和 `status` 只读采集远端 host、PostgreSQL、TLS、DNS alias 和 Secret 形态;`export-secrets --confirm` 只按 YAML 物化本地 Secret source/export 文件,不触碰远端 PostgreSQL。export 结果用 `before` / `after` 区分写入前观察与写入后 presence,fingerprint 不披露 Secret value。 - `apply --confirm` 默认创建本地异步 job;`apply --confirm --wait` 用远端 root-owned job 收敛 systemd PostgreSQL、TLS、`pg_hba`、role/database、Secret export 和备份 timer。 - 输出不得打印密码或完整 `DATABASE_URL`;跨节点消费者使用 YAML 中的 `connectionHost` 直连 PK01 公网 endpoint,DNS alias 不作为 `sslmode=require` 切库 blocker,PK01 规则见 `docs/reference/pk01.md`。 - `trans [operation args...]` / `tran [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点: diff --git a/scripts/src/platform-db.ts b/scripts/src/platform-db.ts index 219aca1e..c5da285a 100644 --- a/scripts/src/platform-db.ts +++ b/scripts/src/platform-db.ts @@ -12,6 +12,7 @@ import { compactText, fingerprintEnvValues as fingerprintValues, parseEnvFile, p const defaultConfigPath = "config/platform-db/postgres-pk01.yaml"; const managedHbaStart = "# BEGIN unidesk managed pk01-platform-postgres"; const managedHbaEnd = "# END unidesk managed pk01-platform-postgres"; +const compactActionablePreviewLimit = 4; interface PlatformDbOptions { configPath: string; @@ -1184,6 +1185,16 @@ function compactStatus( : Array.isArray(summary.cutoverBlockers) ? summary.cutoverBlockers : []; + const roles = pg.objects.roles.map((role) => ({ + name: role.name, + exists: facts?.postgres.roles.find((item) => item.name === role.name)?.exists ?? null, + })); + const databases = pg.objects.databases.map((database) => ({ + name: database.name, + owner: database.owner, + exists: facts?.postgres.databases.find((item) => item.name === database.name)?.exists ?? null, + })); + const appConnections = facts?.postgres.appConnections ?? []; return { ok, action: "platform-db-postgres-status", @@ -1233,31 +1244,37 @@ function compactStatus( error: controllerConnection.error, }, }, - roles: pg.objects.roles.map((role) => ({ - name: role.name, - exists: facts?.postgres.roles.find((item) => item.name === role.name)?.exists ?? null, - })), - databases: pg.objects.databases.map((database) => ({ - name: database.name, - owner: database.owner, - exists: facts?.postgres.databases.find((item) => item.name === database.name)?.exists ?? null, - })), - appConnections: { - ok: facts?.postgres.appConnections.every((connection) => connection.ok === true && connection.ssl === true) ?? false, - passed: facts?.postgres.appConnections.filter((connection) => connection.ok === true && connection.ssl === true).length ?? 0, - total: facts?.postgres.appConnections.length ?? 0, - failed: facts?.postgres.appConnections - .filter((connection) => connection.ok !== true || connection.ssl !== true) - .map((connection) => connection.database) ?? [], - }, + roles: compactActionableSummary(roles, { + passed: (item) => item.exists === true, + missing: (item) => item.exists === false, + actionable: (item) => item.exists !== true, + project: (item) => item, + }), + databases: compactActionableSummary(databases, { + passed: (item) => item.exists === true, + missing: (item) => item.exists === false, + actionable: (item) => item.exists !== true, + project: (item) => item, + }), + appConnections: compactActionableSummary(appConnections, { + passed: (item) => item.ok === true && item.ssl === true, + missing: (item) => item.ok !== true || item.ssl !== true, + actionable: (item) => item.ok !== true || item.ssl !== true, + project: (item) => ({ user: item.user, database: item.database, ok: item.ok, ssl: item.ssl, error: item.error }), + }), secrets: compactSecretInspection(secrets), - exports: exports.map((item) => ({ - targetRef: item.targetRef, - key: item.key, - exists: item.exists, - present: item.present, - fingerprint: item.fingerprint, - })), + exports: compactActionableSummary(exports, { + passed: (item) => item.present === true, + missing: (item) => item.present !== true, + actionable: (item) => item.present !== true, + project: (item) => ({ + targetRef: item.targetRef, + key: item.key, + exists: item.exists, + present: item.present, + fingerprint: item.fingerprint, + }), + }), remote: compactPlanCapture(capture), next: { plan: `bun scripts/cli.ts platform-db postgres plan --config ${pg.configPath}`, @@ -1282,6 +1299,20 @@ function compactExportSecrets( ok: boolean, ): Record { const suffix = mode === "dry-run" ? " --dry-run" : " --confirm"; + const exports = localState.exports.map((item) => { + const before = asRecord(item.before, "export before"); + const after = asRecord(item.after, "export after"); + return { + name: item.name, + sourceRef: item.sourceRef, + targetRef: item.targetRef, + key: item.key, + before, + after, + action: item.action, + desiredFingerprint: item.desiredFingerprint, + }; + }); return { ok, action: "platform-db-postgres-export-secrets", @@ -1297,16 +1328,12 @@ function compactExportSecrets( secretRoot: localState.inspection.root, }, secrets: compactSecretInspection(localState.inspection), - exports: localState.exports.map((item) => ({ - name: item.name, - sourceRef: item.sourceRef, - targetRef: item.targetRef, - key: item.key, - exists: item.exists, - present: item.present, - action: item.action, - fingerprint: item.fingerprint, - })), + exports: compactActionableSummary(exports, { + passed: (item) => item.action === "none" && item.after.present === true, + missing: (item) => item.after.present !== true, + actionable: (item) => item.action !== "none" || item.after.present !== true, + project: (item) => item, + }), next: { ...(mode === "dry-run" ? { confirm: `bun scripts/cli.ts platform-db postgres export-secrets --config ${pg.configPath} --confirm` } : {}), full: `bun scripts/cli.ts platform-db postgres export-secrets --config ${pg.configPath}${suffix} --full`, @@ -1324,23 +1351,50 @@ function compactExportSecrets( } function compactSecretInspection(secrets: SecretInspection): Record { + const entries = secrets.entries.map((entry) => ({ + sourceRef: entry.sourceRef, + exists: entry.exists, + presentKeys: entry.presentKeys, + missingKeys: entry.missingKeys, + action: entry.action, + fingerprint: entry.fingerprint, + })); return { ok: secrets.ok, root: secrets.root, - entries: secrets.entries.map((entry) => ({ - sourceRef: entry.sourceRef, - exists: entry.exists, - keys: { - present: entry.presentKeys, - missing: entry.missingKeys, - }, - action: entry.action, - fingerprint: entry.fingerprint, - })), + ...compactActionableSummary(entries, { + passed: (item) => item.exists && item.missingKeys.length === 0 && item.action === "none", + missing: (item) => !item.exists || item.missingKeys.length > 0, + actionable: (item) => item.action !== "none" || !item.exists || item.missingKeys.length > 0, + project: (item) => item, + }), valuesPrinted: false, }; } +function compactActionableSummary( + items: T[], + selectors: { + passed: (item: T) => boolean; + missing: (item: T) => boolean; + actionable: (item: T) => boolean; + project: (item: T) => unknown; + }, +): Record { + const actionableItems = items.filter(selectors.actionable); + const visibleItems = actionableItems.slice(0, compactActionablePreviewLimit); + return { + total: items.length, + passed: items.filter(selectors.passed).length, + missing: items.filter(selectors.missing).length, + actionable: actionableItems.length, + listed: visibleItems.length, + omitted: items.length - visibleItems.length, + actionableOmitted: actionableItems.length - visibleItems.length, + items: visibleItems.map(selectors.project), + }; +} + function compactRemoteFacts(facts: RemoteFacts | null): Record | null { if (facts === null) return null; return { @@ -1568,6 +1622,9 @@ function connectionStringExportState(pg: PostgresHostConfig, inspection: SecretI const exists = existsSync(targetPath); const existing = exists ? parseEnvFile(readFileSync(targetPath, "utf8")) : {}; const before = existing[item.writeToSecretSource.key]; + const beforePresent = before !== undefined && before.length > 0; + const beforeFingerprint = beforePresent ? fingerprintValues({ [item.writeToSecretSource.key]: before }, [item.writeToSecretSource.key]) : null; + const desiredFingerprint = fingerprintValues({ [item.writeToSecretSource.key]: rendered }, [item.writeToSecretSource.key]); const next = { ...existing, [item.writeToSecretSource.key]: rendered }; if (materialize) writeEnvFile(targetPath, next); return { @@ -1575,10 +1632,18 @@ function connectionStringExportState(pg: PostgresHostConfig, inspection: SecretI sourceRef: item.sourceSecretRef, targetRef: item.writeToSecretSource.sourceRef, key: item.writeToSecretSource.key, - exists, - present: before !== undefined && before.length > 0, + before: { + exists, + present: beforePresent, + fingerprint: beforeFingerprint, + }, + after: { + exists: materialize ? true : exists, + present: materialize ? true : beforePresent, + fingerprint: materialize ? desiredFingerprint : beforeFingerprint, + }, action: before === rendered ? "none" : before === undefined ? "create" : "update", - fingerprint: fingerprintValues({ [item.writeToSecretSource.key]: rendered }, [item.writeToSecretSource.key]), + desiredFingerprint, consumers: item.consumers, valuesPrinted: false, }; From 42e23345cd2043fe366ccad064a656538d76b735 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 22:28:37 +0200 Subject: [PATCH 23/43] =?UTF-8?q?docs:=20=E6=94=B6=E5=8F=A3=20PikaOA=20?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=20CLI=20=E5=8F=AF=E8=A7=81=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../R1.5.4_Task_Report.md | 24 +++++++++++++++++++ .../R1.5_Task_Report.md | 23 +++++++++--------- docs/MDTODO/pikaoa-enterprise-platform.md | 4 ++-- 3 files changed, 37 insertions(+), 14 deletions(-) create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.4_Task_Report.md diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.4_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.4_Task_Report.md new file mode 100644 index 00000000..0cd1aed5 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.4_Task_Report.md @@ -0,0 +1,24 @@ +# R1.5.4 任务报告 + +## 结论 + +已修复 `platform-db postgres status` 与 `export-secrets` 默认输出截断,并将默认投影改为按配置规模有界的计数、异常/待处理预览与 omitted 摘要。confirmed export 明确区分 `before`、`action`、`after`,不会把写入前 presence 当成写入后事实。 + +## 交付 + +- 子 issue:pikasTech/unidesk#1958。 +- PR:pikasTech/unidesk#1960。 +- merge commit:`34f1f9339168b518ca1f706cc7bc44880924171d`。 +- 变更:`scripts/src/platform-db.ts`、`docs/reference/cli.md`。 + +## 验证 + +- 真实配置:status 4264 bytes,export dry-run 2679 bytes,plan 9589 bytes,均未截断。 +- 扩展规模:48 个 role/database/Secret、50 个 export 时,status 7589 bytes,export 5624 bytes。 +- confirmed fixture:首次 `before=false/false` 到 `after=true/true`;二次 confirmed 无 actionable 项。 +- 相关测试:8 pass、0 fail。 +- 合并后的原 CLI 复测准确显示 PikaOA role/database/export 缺失,`mutation=false`、`valuesPrinted=false`。 + +## 边界 + +未执行 PostgreSQL apply、Secret sync、PaC bootstrap 或运行面修改。Artificer post-task 评论为 issue #1958 comment 4962360252;未发现需要扩展本轮范围的问题。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5_Task_Report.md index c5d2f5ec..0dc2b897 100644 --- a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5_Task_Report.md +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5_Task_Report.md @@ -2,7 +2,7 @@ ## 结论 -PikaOA 的 UniDesk 交付基线已完成 YAML-first 声明,并补齐可复用的 PaC bootstrap、PostgreSQL 紧凑 plan 和 NC01 Prometheus 控制面。全部变更仍处于源码与 dry-run 阶段,未执行首次 bootstrap、数据库 apply 或运行面部署。 +PikaOA 的 UniDesk 交付基线已完成 YAML-first 声明,并补齐可复用的 PaC bootstrap、PostgreSQL 紧凑 plan/status/export 和 NC01 Prometheus 控制面。全部变更仍处于源码、只读 plan/status 与 dry-run 阶段,未执行首次 bootstrap、数据库 apply 或运行面部署。 ## Owning YAML @@ -14,21 +14,20 @@ PikaOA 的 UniDesk 交付基线已完成 YAML-first 声明,并补齐可复用 ## CLI 与工具改进 -- PaC bootstrap 现在提供 YAML 精确匹配、effective credential、GitHub 上游预检、分阶段 typed 状态和唯一下一步。 -- 未执行阶段显示 `skipped`,外部建仓阻塞不再误报内部阶段失败。 -- `platform-db postgres plan` 默认输出保持在预算内,并显示 PikaOA role、database 和 redacted Secret/export 摘要。 -- `platform-infra observability` 增加 target-scoped Prometheus plan/status/query;指标故障不阻塞业务。 +- PaC bootstrap 提供 YAML 精确匹配、effective credential、GitHub 上游预检、分阶段 typed 状态、skipped 阶段和唯一下一步。 +- `platform-db postgres plan/status/export-secrets` 默认输出按配置规模保持有界,披露 PikaOA role/database、Secret/export presence/fingerprint、mutation、typed failure 和完整下钻。 +- confirmed export 区分写入前、动作与写入后事实。 +- `platform-infra observability` 提供 target-scoped Prometheus plan/status/query;指标故障不阻塞业务。 ## 验证与交付 -- 交付基线:`9b6921ad`。 -- PostgreSQL plan:`88eb8fdb`,8 tests 通过,默认输出 9,589 bytes。 -- Bootstrap:`697b6933`、`1a04b029`、`e808b298`,19 项定向测试通过。 -- Prometheus:`f516691a`、`ef3b5fde`、`c3762b31`,8 tests / 100 assertions 通过。 -- 平台分支当前 merge commit:`2ee27b0c`。 -- PikaOA bootstrap dry-run 为 `mutation=false`,准确阻塞于 `github-repository-not-found-or-no-access`。 +- PaC bootstrap 定向测试 19 项通过,PikaOA dry-run 为 `mutation=false`,准确阻塞于 GitHub repository 不存在或无权限。 +- Prometheus 8 tests、100 assertions 通过;NC01 enabled,其他 target disabled-for-target。 +- PostgreSQL status 4264 bytes、export dry-run 2679 bytes;扩大到 48 个 role/database/Secret 和 50 个 export 后仍低于预算;相关测试 8 pass。 +- 平台分支当前 merge commit:`34f1f9339168b518ca1f706cc7bc44880924171d`。 ## 未完成边界 - `pikaInc/pikaoa` 仍因组织建仓权限缺失而不存在,R1.2 保持阻塞。 -- 首次 bootstrap、自动 source PR delivery、NC01 rollout、PK01 PostgreSQL apply、OTel/Prometheus 运行面与公网入口验收属于 R1.6-R1.7。 +- PK01 PostgreSQL role/database 尚未 apply,PikaOA 本地 DATABASE_URL、session secret 与两个初始账号密码尚未 materialize/sync。 +- 首次 bootstrap、正常 source PR 自动交付、NC01 rollout、OTel/Prometheus 运行面与公网入口验收属于 R1.6-R1.7。 diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 5e1f8c77..0ace65dd 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -29,7 +29,7 @@ 在 R1.3 CLI 后端验收通过后实现 HWLAB v0.3 同族 Vue 工作台、响应式页面与同路径业务流,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.4_Task_Report.md)。 -### R1.5 [in_progress] +### R1.5 [completed] 在 UniDesk 中声明 PK01 PostgreSQL、Secret、NC01 独立 namespace、Gitea/PaC、GitOps、OTel/Prometheus 和 PK01 公网暴露,并改进可复用 bootstrap 工具,依赖 R1.1-R1.2,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5_Task_Report.md)。 @@ -42,7 +42,7 @@ #### R1.5.3 [completed] 修复 `platform-db postgres plan` 默认 30KB JSON 被输出预算截断的可见性缺陷,提供数据库、角色、Secret/export、检查与下一步的紧凑 CLI-first 投影,执行记录沿用 [主 issue](https://github.com/pikasTech/unidesk/issues/1952),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.3_Task_Report.md)。 -#### R1.5.4 [in_progress] +#### R1.5.4 [completed] 修复 `platform-db postgres status` 与 `export-secrets --dry-run` 默认输出超预算截断,提供与 plan 同族的 Secret/export 有界投影并保持 valuesPrinted=false,执行记录见 [pikasTech/unidesk#1958](https://github.com/pikasTech/unidesk/issues/1958),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.4_Task_Report.md)。 ### R1.6 From e79c2cc8fb2d1668471c1f8d8aa5f9241b274d83 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 02:47:01 +0200 Subject: [PATCH 24/43] docs: complete PikaOA repository initialization --- .../R1.2_Task_Report.md | 19 +++++++++++++++++++ docs/MDTODO/pikaoa-enterprise-platform.md | 4 ++-- 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.2_Task_Report.md diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.2_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.2_Task_Report.md new file mode 100644 index 00000000..8daa92d0 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.2_Task_Report.md @@ -0,0 +1,19 @@ +# R1.2 任务报告 + +## 结果 + +- 产品仓库:`pikainc/pikaoa`,私有远端可通过既有 SSH 身份读取和推送。 +- 固定主工作区:`/root/pikaoa`,分支固定为 `master`。 +- 初始提交:`1fc2353 chore: initialize PikaOA repository`,已推送并建立 `origin/master` 跟踪关系。 +- 远端在初始化前无 refs,未覆盖任何既有提交或用户内容。 +- 后续执行型任务使用 `/root/pikaoa/.worktree/` 独立 worktree、独立 branch 和 PR。 + +## 验证 + +- `git ls-remote origin` 初始化前成功且无输出,证明仓库存在并为空。 +- `git push -u origin master` 成功,GitHub 返回 canonical repository `git@github.com:pikainc/pikaoa.git`。 +- UniDesk GitHub CLI 当前 token 对产品仓返回 `repo-not-found`;这不影响既有 SSH source authority,但产品仓 issue/PR 元数据写入需在后续修复 token scope,当前治理 issue 继续落在 `pikasTech/unidesk`。 + +## 结论 + +R1.2 已完成;R1.3 可以从 `origin/master` 创建独立 foundation worktree。 diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 0ace65dd..9dfad548 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -17,9 +17,9 @@ 调研成熟开源 OA、ERP、合同、发票、Go 微服务与 Vue 组件方案,形成采用/复用/不采用结论,依赖 R1 规格,执行记录见 [pikasTech/unidesk#1953](https://github.com/pikasTech/unidesk/issues/1953),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.1_Task_Report.md)。 -### R1.2 [in_progress] +### R1.2 [completed] -建立 pikaInc/pikaoa 私有仓库、/root/pikaoa 固定 master 和独立任务 worktree;受 `pikaInc` 组织建仓权限阻塞,证据见 [主 issue 评论](https://github.com/pikasTech/unidesk/issues/1952#issuecomment-4961261444),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.2_Task_Report.md)。 +建立 pikainc/pikaoa 私有仓库、/root/pikaoa 固定 master 和独立任务 worktree;远端已由 SSH 验证并以 [1fc2353](https://github.com/pikainc/pikaoa/commit/1fc2353) 建立唯一初始提交,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.2_Task_Report.md)。 ### R1.3 From b1b79af2d359daa4a27e68fb56dcb71c547626f4 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 02:48:52 +0200 Subject: [PATCH 25/43] docs: track PikaOA shared kernel implementation --- docs/MDTODO/pikaoa-enterprise-platform.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 9dfad548..17c63b0c 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -21,10 +21,13 @@ 建立 pikainc/pikaoa 私有仓库、/root/pikaoa 固定 master 和独立任务 worktree;远端已由 SSH 验证并以 [1fc2353](https://github.com/pikainc/pikaoa/commit/1fc2353) 建立唯一初始提交,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.2_Task_Report.md)。 -### R1.3 +### R1.3 [in_progress] 实现企业模块内核、Go API/Worker、PostgreSQL 迁移、员工/RBAC、伙伴分类、合同版本、发票废弃、审计、OTel/Prometheus 与 CLI,并通过 CLI 后端验收,依赖 R1.1-R1.2,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3_Task_Report.md)。 +#### R1.3.1 [in_progress] + +建立 Go API/Worker/CLI 企业模块共享内核、PostgreSQL 迁移与审计/outbox 端口、OTel/Prometheus 基线和 CLI health/modules 验收,执行记录见 [pikasTech/unidesk#1977](https://github.com/pikasTech/unidesk/issues/1977),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.1_Task_Report.md)。 ### R1.4 在 R1.3 CLI 后端验收通过后实现 HWLAB v0.3 同族 Vue 工作台、响应式页面与同路径业务流,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.4_Task_Report.md)。 From 95dbd66a19263782b231949836c4aad0e5429572 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 02:55:18 +0200 Subject: [PATCH 26/43] docs: track PikaOA GitHub token routing --- docs/MDTODO/pikaoa-enterprise-platform.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 17c63b0c..e3832505 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -32,7 +32,7 @@ 在 R1.3 CLI 后端验收通过后实现 HWLAB v0.3 同族 Vue 工作台、响应式页面与同路径业务流,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.4_Task_Report.md)。 -### R1.5 [completed] +### R1.5 [in_progress] 在 UniDesk 中声明 PK01 PostgreSQL、Secret、NC01 独立 namespace、Gitea/PaC、GitOps、OTel/Prometheus 和 PK01 公网暴露,并改进可复用 bootstrap 工具,依赖 R1.1-R1.2,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5_Task_Report.md)。 @@ -48,6 +48,9 @@ #### R1.5.4 [completed] 修复 `platform-db postgres status` 与 `export-secrets --dry-run` 默认输出超预算截断,提供与 plan 同族的 Secret/export 有界投影并保持 valuesPrinted=false,执行记录见 [pikasTech/unidesk#1958](https://github.com/pikasTech/unidesk/issues/1958),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.4_Task_Report.md)。 +#### R1.5.5 [in_progress] + +为 pikainc/pikaoa 补齐 YAML-first GitHub token repository override,使受控 CLI 可管理产品 PR 且不打印凭据,执行记录见 [pikasTech/unidesk#1978](https://github.com/pikasTech/unidesk/issues/1978),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.5_Task_Report.md)。 ### R1.6 审核并合并双仓 PR,执行首次受控 PaC bootstrap,由正常 source PR merge 自动发布,依赖 R1.3-R1.5,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6_Task_Report.md)。 From 5b20ac8850cc5b96bd7fc9a36af47e2e4ea5a3e2 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 03:05:18 +0200 Subject: [PATCH 27/43] docs: close PikaOA GitHub token routing --- .../R1.5.5_Task_Report.md | 20 +++++++++++++++++++ .../R1.5_Task_Report.md | 11 +++++----- docs/MDTODO/pikaoa-enterprise-platform.md | 4 ++-- 3 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.5_Task_Report.md diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.5_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.5_Task_Report.md new file mode 100644 index 00000000..3c5e029d --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.5_Task_Report.md @@ -0,0 +1,20 @@ +# R1.5.5 任务报告 + +## 结果 + +- Issue:`pikasTech/unidesk#1978`。 +- PR:`pikasTech/unidesk#1979`,合并提交 `9e4efb50`。 +- Owning YAML:`config/unidesk-cli.yaml#github.auth.repositoryOverrides`。 +- 为 `pikainc/pikaoa` 增加精确 repository override,复用既有 PikaInc token sourceRef;未读取、打印、复制或提交 token value。 + +## 验证 + +- `gh auth status --repo pikainc/pikaoa` 成功,命中 `scope=repository-override`、`valuesPrinted=false`。 +- `gh repo view pikainc/pikaoa` 成功,私有仓可见。 +- `gh auth status --repo pikasTech/unidesk` 成功,默认全局 sourceRef 未受影响。 +- 定向测试 3 pass、0 fail,`git diff --check` 通过。 +- 本任务无运行面部署,`rollout=not-applicable`。 + +## 边界 + +Artificer 的当前产品任务仍可通过 SSH 提交代码;产品 PR 的 create/review/merge 由合并后的受控 host CLI 完成。owner 大小写匹配问题已作为独立 BUG 候选登记,不改变本任务完成结论。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5_Task_Report.md index 0dc2b897..95dcbe6e 100644 --- a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5_Task_Report.md +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5_Task_Report.md @@ -2,7 +2,7 @@ ## 结论 -PikaOA 的 UniDesk 交付基线已完成 YAML-first 声明,并补齐可复用的 PaC bootstrap、PostgreSQL 紧凑 plan/status/export 和 NC01 Prometheus 控制面。全部变更仍处于源码、只读 plan/status 与 dry-run 阶段,未执行首次 bootstrap、数据库 apply 或运行面部署。 +PikaOA 的 UniDesk 交付基线已完成 YAML-first 声明,并补齐可复用的 PaC bootstrap、PostgreSQL 紧凑 plan/status/export、NC01 Prometheus 控制面和产品仓 GitHub token 路由。全部运行面变更仍处于源码、只读 plan/status 与 dry-run 阶段,未执行首次 bootstrap、数据库 apply 或部署。 ## Owning YAML @@ -11,6 +11,7 @@ PikaOA 的 UniDesk 交付基线已完成 YAML-first 声明,并补齐可复用 - `config/secrets-distribution.yaml`:管理员、普通员工和数据库连接的 `sourceRef`/`targetKey`。 - `config/platform-infra/gitea.yaml` 与 `config/platform-infra/pipelines-as-code.yaml`:Gitea repository、PaC consumer、Tekton/GitOps/Argo 首发基线。 - `config/platform-infra/observability.yaml`:共享 OTel/Tempo/Prometheus 与 NC01 target 隔离。 +- `config/unidesk-cli.yaml`:`pikainc/pikaoa` GitHub repository token override。 ## CLI 与工具改进 @@ -18,16 +19,16 @@ PikaOA 的 UniDesk 交付基线已完成 YAML-first 声明,并补齐可复用 - `platform-db postgres plan/status/export-secrets` 默认输出按配置规模保持有界,披露 PikaOA role/database、Secret/export presence/fingerprint、mutation、typed failure 和完整下钻。 - confirmed export 区分写入前、动作与写入后事实。 - `platform-infra observability` 提供 target-scoped Prometheus plan/status/query;指标故障不阻塞业务。 +- GitHub CLI 可通过 YAML repository override 访问 PikaOA 私有仓,且保持 `valuesPrinted=false`。 ## 验证与交付 -- PaC bootstrap 定向测试 19 项通过,PikaOA dry-run 为 `mutation=false`,准确阻塞于 GitHub repository 不存在或无权限。 +- PaC bootstrap 定向测试 19 项通过;PikaOA dry-run 为 `mutation=false`。 - Prometheus 8 tests、100 assertions 通过;NC01 enabled,其他 target disabled-for-target。 - PostgreSQL status 4264 bytes、export dry-run 2679 bytes;扩大到 48 个 role/database/Secret 和 50 个 export 后仍低于预算;相关测试 8 pass。 -- 平台分支当前 merge commit:`34f1f9339168b518ca1f706cc7bc44880924171d`。 +- GitHub repository override 定向测试 3 pass;合并后 PikaOA auth/repo view 和默认 UniDesk auth 均成功。 ## 未完成边界 -- `pikaInc/pikaoa` 仍因组织建仓权限缺失而不存在,R1.2 保持阻塞。 -- PK01 PostgreSQL role/database 尚未 apply,PikaOA 本地 DATABASE_URL、session secret 与两个初始账号密码尚未 materialize/sync。 +- PikaOA role/database 尚未 apply,`DATABASE_URL`、session secret 与两个初始账号密码尚未 materialize/sync。 - 首次 bootstrap、正常 source PR 自动交付、NC01 rollout、OTel/Prometheus 运行面与公网入口验收属于 R1.6-R1.7。 diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index e3832505..80559ead 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -32,7 +32,7 @@ 在 R1.3 CLI 后端验收通过后实现 HWLAB v0.3 同族 Vue 工作台、响应式页面与同路径业务流,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.4_Task_Report.md)。 -### R1.5 [in_progress] +### R1.5 [completed] 在 UniDesk 中声明 PK01 PostgreSQL、Secret、NC01 独立 namespace、Gitea/PaC、GitOps、OTel/Prometheus 和 PK01 公网暴露,并改进可复用 bootstrap 工具,依赖 R1.1-R1.2,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5_Task_Report.md)。 @@ -48,7 +48,7 @@ #### R1.5.4 [completed] 修复 `platform-db postgres status` 与 `export-secrets --dry-run` 默认输出超预算截断,提供与 plan 同族的 Secret/export 有界投影并保持 valuesPrinted=false,执行记录见 [pikasTech/unidesk#1958](https://github.com/pikasTech/unidesk/issues/1958),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.4_Task_Report.md)。 -#### R1.5.5 [in_progress] +#### R1.5.5 [completed] 为 pikainc/pikaoa 补齐 YAML-first GitHub token repository override,使受控 CLI 可管理产品 PR 且不打印凭据,执行记录见 [pikasTech/unidesk#1978](https://github.com/pikasTech/unidesk/issues/1978),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.5_Task_Report.md)。 ### R1.6 From fbc91143ecf789909d6c4ea6af92882431235fcf Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 03:05:56 +0200 Subject: [PATCH 28/43] docs: track GitHub repository casefold fix --- docs/MDTODO/pikaoa-enterprise-platform.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 80559ead..2bcf0471 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -32,7 +32,7 @@ 在 R1.3 CLI 后端验收通过后实现 HWLAB v0.3 同族 Vue 工作台、响应式页面与同路径业务流,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.4_Task_Report.md)。 -### R1.5 [completed] +### R1.5 [in_progress] 在 UniDesk 中声明 PK01 PostgreSQL、Secret、NC01 独立 namespace、Gitea/PaC、GitOps、OTel/Prometheus 和 PK01 公网暴露,并改进可复用 bootstrap 工具,依赖 R1.1-R1.2,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5_Task_Report.md)。 @@ -51,6 +51,9 @@ #### R1.5.5 [completed] 为 pikainc/pikaoa 补齐 YAML-first GitHub token repository override,使受控 CLI 可管理产品 PR 且不打印凭据,执行记录见 [pikasTech/unidesk#1978](https://github.com/pikasTech/unidesk/issues/1978),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.5_Task_Report.md)。 +#### R1.5.6 [in_progress] + +修复 GitHub repository override 对 owner/repo 大小写敏感,确保 pikaInc/pikaoa 与 canonical 名称选择同一受控 token,执行记录见 [pikasTech/unidesk#1980](https://github.com/pikasTech/unidesk/issues/1980),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.6_Task_Report.md)。 ### R1.6 审核并合并双仓 PR,执行首次受控 PaC bootstrap,由正常 source PR merge 自动发布,依赖 R1.3-R1.5,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6_Task_Report.md)。 From 39684e7b2f89cc8cdb9c964f8ef7c400cad7c9f5 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 03:13:40 +0200 Subject: [PATCH 29/43] docs: close GitHub repository casefold fix --- .../R1.5.6_Task_Report.md | 19 +++++++++++++++++++ docs/MDTODO/pikaoa-enterprise-platform.md | 4 ++-- 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.6_Task_Report.md diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.6_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.6_Task_Report.md new file mode 100644 index 00000000..c41b1558 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.6_Task_Report.md @@ -0,0 +1,19 @@ +# R1.5.6 任务报告 + +## 结果 + +- Issue:`pikasTech/unidesk#1980`。 +- PR:`pikasTech/unidesk#1982`,合并提交 `7eebc513`。 +- GitHub credential resolver 使用统一的小写 lookup key 比较请求仓库与 YAML repository override,并以相同规则拒绝仅大小写不同的重复配置。 +- YAML 原值、GitHub API path、CLI 输出和 token sourceRef/value 均未改写。 + +## 验证 + +- `pikainc/pikaoa`、`pikaInc/pikaoa`、`PIKAINC/PIKAOA` 均命中同一 repository override。 +- `pikasTech/unidesk` 继续命中全局 token;所有输出 `valuesPrinted=false`。 +- 定向测试 4 pass、0 fail,`bun --check` 和 `git diff --check` 通过。 +- 未部署,`rollout=not-applicable`。 + +## Post-task + +未发现需要继续转为正式 issue 的新反馈。 diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 2bcf0471..ed3d3a4f 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -32,7 +32,7 @@ 在 R1.3 CLI 后端验收通过后实现 HWLAB v0.3 同族 Vue 工作台、响应式页面与同路径业务流,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.4_Task_Report.md)。 -### R1.5 [in_progress] +### R1.5 [completed] 在 UniDesk 中声明 PK01 PostgreSQL、Secret、NC01 独立 namespace、Gitea/PaC、GitOps、OTel/Prometheus 和 PK01 公网暴露,并改进可复用 bootstrap 工具,依赖 R1.1-R1.2,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5_Task_Report.md)。 @@ -51,7 +51,7 @@ #### R1.5.5 [completed] 为 pikainc/pikaoa 补齐 YAML-first GitHub token repository override,使受控 CLI 可管理产品 PR 且不打印凭据,执行记录见 [pikasTech/unidesk#1978](https://github.com/pikasTech/unidesk/issues/1978),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.5_Task_Report.md)。 -#### R1.5.6 [in_progress] +#### R1.5.6 [completed] 修复 GitHub repository override 对 owner/repo 大小写敏感,确保 pikaInc/pikaoa 与 canonical 名称选择同一受控 token,执行记录见 [pikasTech/unidesk#1980](https://github.com/pikasTech/unidesk/issues/1980),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.6_Task_Report.md)。 ### R1.6 From fa3c4c90b37743e5bf0e1c79d9072d285e4bec38 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 03:50:42 +0200 Subject: [PATCH 30/43] docs: close PikaOA shared kernel task --- .../R1.3.1_Task_Report.md | 32 +++++++++++++++++++ docs/MDTODO/pikaoa-enterprise-platform.md | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.1_Task_Report.md diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.1_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.1_Task_Report.md new file mode 100644 index 00000000..72d68a91 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.1_Task_Report.md @@ -0,0 +1,32 @@ +# R1.3.1 任务报告 + +## 结果 + +- `pikainc/pikaoa#1` 已通过主代理架构 review 和 guarded preflight,以 merge commit `042bf34` 合入 `master`。 +- 建立 Go 1.24 API、Worker、CLI 三入口,以及严格 YAML 配置、typed error、模块注册表和优雅停机。 +- 模块描述符覆盖稳定 ID、版本、状态、权限、API、事件、导航、迁移、审计动作和健康声明,并校验重复声明与权限引用。 +- 建立 pgx-compatible `Executor`/`UnitOfWork`,保证 repository、audit 和 outbox 可共享同一事务句柄。 +- goose foundation migration 使用严格 owned-object 创建和逐项非级联回滚,不掩盖 schema drift。 +- API、Worker、CLI 已接入 W3C trace 传播、稳定结构化日志字段和 Prometheus 指标;CLI `health`、`modules` 只调用公开 HTTP API。 +- 未连接 PK01 PostgreSQL,未同步 Secret,未执行 PaC bootstrap、K8s rollout、公网变更或 Web 开发。 + +## 验证 + +- Artificer 在 NC01 任务 worktree 完成 `gofmt -d`、`go test ./...`、`go vet ./...` 和三入口 build。 +- 真实 API 进程下 CLI text/JSON、typed readiness、完整 module JSON、API/Worker metrics、SIGTERM 和 OTLP 不可达降级均通过。 +- CLI `traceparent` 与 API 返回 `traceId` 一致;API/Worker 日志包含 `traceId`、`spanId`、`module`、`operation`、`result`、`errorCode`。 +- PR preflight:`MERGEABLE/CLEAN`,无 pending 或 failed check。 + +## 证据 + +- Issue:https://github.com/pikasTech/unidesk/issues/1977 +- PR:https://github.com/pikainc/pikaoa/pull/1 +- 实现提交:https://github.com/pikainc/pikaoa/commit/40ae8ae5836ccafbaf78c09d8f98463c45365e7d +- 合并提交:https://github.com/pikainc/pikaoa/commit/042bf34 +- Artificer post-task:https://github.com/pikasTech/unidesk/issues/1977#issuecomment-4964589017 + +## 后续边界 + +- 真实 PK01 migration apply/rollback、Secret、PaC bootstrap 和运行面验收由 R1.6-R1.7 承接。 +- post-task 提到的 GitHub 仓库凭据选择与大小写匹配已由 `#1978`、`#1980` 修复并关闭,不重复创建 issue。 +- 员工/RBAC、伙伴分类、合同多版本、发票废弃和审计/outbox 实现继续在 R1.3 下拆分独立子任务。 diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index ed3d3a4f..f8c96c17 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -25,7 +25,7 @@ 实现企业模块内核、Go API/Worker、PostgreSQL 迁移、员工/RBAC、伙伴分类、合同版本、发票废弃、审计、OTel/Prometheus 与 CLI,并通过 CLI 后端验收,依赖 R1.1-R1.2,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3_Task_Report.md)。 -#### R1.3.1 [in_progress] +#### R1.3.1 [completed] 建立 Go API/Worker/CLI 企业模块共享内核、PostgreSQL 迁移与审计/outbox 端口、OTel/Prometheus 基线和 CLI health/modules 验收,执行记录见 [pikasTech/unidesk#1977](https://github.com/pikasTech/unidesk/issues/1977),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.1_Task_Report.md)。 ### R1.4 From 1d7f579d9c88dff391e8264c66ea3cedf917f5ca Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 03:54:39 +0200 Subject: [PATCH 31/43] docs: track PikaOA module runtime anchor --- docs/MDTODO/pikaoa-enterprise-platform.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index f8c96c17..fd4305d9 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -28,6 +28,9 @@ #### R1.3.1 [completed] 建立 Go API/Worker/CLI 企业模块共享内核、PostgreSQL 迁移与审计/outbox 端口、OTel/Prometheus 基线和 CLI health/modules 验收,执行记录见 [pikasTech/unidesk#1977](https://github.com/pikasTech/unidesk/issues/1977),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.1_Task_Report.md)。 +#### R1.3.2 [in_progress] + +采用 chi 建立可插拔模块 HTTP 运行时、disabled 模块关闭入口、示例模块扩展验收,并补齐 PostgreSQL OTel 与 HTTP/runtime/pool/migration Prometheus 基线,执行记录见 [pikasTech/unidesk#1983](https://github.com/pikasTech/unidesk/issues/1983),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.2_Task_Report.md)。 ### R1.4 在 R1.3 CLI 后端验收通过后实现 HWLAB v0.3 同族 Vue 工作台、响应式页面与同路径业务流,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.4_Task_Report.md)。 From c239b430a5f99179ae5fecd7273deb70a17be9d2 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 04:03:34 +0200 Subject: [PATCH 32/43] docs: plan PikaOA identity and outbox work --- docs/MDTODO/pikaoa-enterprise-platform.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index fd4305d9..6ae35896 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -31,6 +31,12 @@ #### R1.3.2 [in_progress] 采用 chi 建立可插拔模块 HTTP 运行时、disabled 模块关闭入口、示例模块扩展验收,并补齐 PostgreSQL OTel 与 HTTP/runtime/pool/migration Prometheus 基线,执行记录见 [pikasTech/unidesk#1983](https://github.com/pikasTech/unidesk/issues/1983),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.2_Task_Report.md)。 +#### R1.3.3 + +实现员工身份、Casbin RBAC、会话与 CLI login/logout/whoami/employees/roles,使用 YAML Secret 投影初始管理员和员工,执行记录见 [pikasTech/unidesk#1984](https://github.com/pikasTech/unidesk/issues/1984),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.3_Task_Report.md)。 +#### R1.3.4 + +实现 pgx 同事务审计、PostgreSQL outbox、Watermill handler pipeline、幂等 Worker 与 OTel/Prometheus,执行记录见 [pikasTech/unidesk#1985](https://github.com/pikasTech/unidesk/issues/1985),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.4_Task_Report.md)。 ### R1.4 在 R1.3 CLI 后端验收通过后实现 HWLAB v0.3 同族 Vue 工作台、响应式页面与同路径业务流,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.4_Task_Report.md)。 From 9c78309a018e25e6b4be7d659741b3d27dcbfcd4 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 04:34:32 +0200 Subject: [PATCH 33/43] docs: close PikaOA module runtime task --- .../R1.3.2_Task_Report.md | 22 +++++++++++++++++++ docs/MDTODO/pikaoa-enterprise-platform.md | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.2_Task_Report.md diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.2_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.2_Task_Report.md new file mode 100644 index 00000000..3a54c2fe --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.2_Task_Report.md @@ -0,0 +1,22 @@ +# R1.3.2 任务报告 + +## 结果 + +- 已在 `pikainc/pikaoa#2` 建立并合并可插拔模块 HTTP 运行时,产品 `master` 合并提交为 `ebe70a3`。 +- foundation 路由已由模块自身注册;disabled 模块不启动、不挂载 HTTP、不发布导航。 +- 模块挂载路由受 descriptor 的 API BasePath 与 health Path 约束,未声明路由和路由冲突稳定失败。 +- PostgreSQL 使用 `otelpgx` 覆盖 query/transaction,并禁用连接详情、SQL 文本和参数属性。 +- migration readiness 改为只读比较数据库当前 goose version 与本构建嵌入迁移最新版本。 +- Prometheus 已覆盖 HTTP in-flight、Go runtime/process、pgx pool、数据库/迁移 readiness 和 Worker 基线;累计 pool 指标为 Counter,当前状态为 Gauge。 +- 共享 observability 暴露标准 `prometheus.Registerer`,业务模块可在不扩展中央 Metrics 结构体的情况下注册自有 collector。 + +## 验证 + +- NC01 目标 worktree:`go test ./...`、`go vet ./...`、`make build`、`git diff --check` 全部通过。 +- 真实 API 下 CLI `health`、`modules` 的 text/JSON 通过,`/metrics` 类型与 readiness 脱敏通过。 +- 受控 PR preflight:`MERGEABLE/CLEAN`,合并前无 blocker。 + +## 边界与后续 + +- 本任务未连接 PK01、未执行生产 migration、未部署;这些动作保留给 R1.6-R1.7。 +- 身份/RBAC 与审计/outbox 从本合并提交创建独立 worktree,并使用不同 migration version 并行实施。 diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 6ae35896..76d98830 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -28,7 +28,7 @@ #### R1.3.1 [completed] 建立 Go API/Worker/CLI 企业模块共享内核、PostgreSQL 迁移与审计/outbox 端口、OTel/Prometheus 基线和 CLI health/modules 验收,执行记录见 [pikasTech/unidesk#1977](https://github.com/pikasTech/unidesk/issues/1977),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.1_Task_Report.md)。 -#### R1.3.2 [in_progress] +#### R1.3.2 [completed] 采用 chi 建立可插拔模块 HTTP 运行时、disabled 模块关闭入口、示例模块扩展验收,并补齐 PostgreSQL OTel 与 HTTP/runtime/pool/migration Prometheus 基线,执行记录见 [pikasTech/unidesk#1983](https://github.com/pikasTech/unidesk/issues/1983),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.2_Task_Report.md)。 #### R1.3.3 From eb6614af118227e530fab9f9abffebe322d6792b Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 04:36:20 +0200 Subject: [PATCH 34/43] docs: start PikaOA identity and outbox tasks --- docs/MDTODO/pikaoa-enterprise-platform.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 76d98830..e106b07d 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -31,10 +31,10 @@ #### R1.3.2 [completed] 采用 chi 建立可插拔模块 HTTP 运行时、disabled 模块关闭入口、示例模块扩展验收,并补齐 PostgreSQL OTel 与 HTTP/runtime/pool/migration Prometheus 基线,执行记录见 [pikasTech/unidesk#1983](https://github.com/pikasTech/unidesk/issues/1983),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.2_Task_Report.md)。 -#### R1.3.3 +#### R1.3.3 [in_progress] 实现员工身份、Casbin RBAC、会话与 CLI login/logout/whoami/employees/roles,使用 YAML Secret 投影初始管理员和员工,执行记录见 [pikasTech/unidesk#1984](https://github.com/pikasTech/unidesk/issues/1984),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.3_Task_Report.md)。 -#### R1.3.4 +#### R1.3.4 [in_progress] 实现 pgx 同事务审计、PostgreSQL outbox、Watermill handler pipeline、幂等 Worker 与 OTel/Prometheus,执行记录见 [pikasTech/unidesk#1985](https://github.com/pikasTech/unidesk/issues/1985),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.4_Task_Report.md)。 ### R1.4 From 49e2c0bf263bccaee68b5574e8fd21d28abe3031 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 06:03:23 +0200 Subject: [PATCH 35/43] docs: close PikaOA audit outbox task --- .../R1.3.4_Task_Report.md | 33 +++++++++++++++++++ docs/MDTODO/pikaoa-enterprise-platform.md | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.4_Task_Report.md diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.4_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.4_Task_Report.md new file mode 100644 index 00000000..35ed0d56 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.4_Task_Report.md @@ -0,0 +1,33 @@ +# R1.3.4 任务报告 + +## 交付结果 + +- 产品 PR [pikainc/pikaoa#4](https://github.com/pikainc/pikaoa/pull/4) 已受控合并到 `master@e1b570188cf39c7cf5e1210b0a477aabe7fd5fb3`。 +- 通过共享 `transaction.Executor` 实现业务写入、append-only 审计和 PostgreSQL outbox 同事务提交或回滚。 +- outbox 提供 `SKIP LOCKED` 有界 claim、成功、重试、失败和处理中断恢复;Watermill handler registry 以事件 ID 为稳定幂等键。 +- Worker 暴露纯 liveness `/healthz`、typed readiness `/readyz` 和 Prometheus `/metrics`,处理链写入 OTel span,并保持低基数标签。 +- handler 原始错误不进入 `last_error`、日志、trace、metric 或返回值,仅保留稳定 `FailureCode`;Secret 输出保持 `valuesPrinted=false`。 + +## 主代理审核纠偏 + +- 补齐失败信息脱敏,真实 PostgreSQL 测试覆盖包含 password、token 和 secret 的 handler error,确认不泄漏。 +- 增加 database、migration、outbox 的 typed readiness 投影,保持 liveness 与依赖健康分离。 + +## 验证 + +- `go test -race ./internal/outbox ./internal/worker` +- `go test ./...` +- `go vet ./...` +- `make build` +- 隔离 PostgreSQL 16 集成测试验证事务回滚、提交后 claim、失败恢复、再次 claim 和 processed 终态。 +- PR preflight 为 `MERGEABLE/CLEAN`;仓库当前没有 status checks。 + +## 收口 + +- 原 Artificer 纠偏与 post-task command 均达到权威 `completed`。 +- `feat/r134-audit-outbox` 任务 worktree 和已吸收本地分支已清理。 +- 本任务按 issue 范围未部署、未连接 PK01;部署与公网验收留在后续 YAML-first/CI/CD 阶段。 + +## 残余风险 + +`RecoverProcessing` 会恢复全部 processing 事件,多 Worker 副本可能互相干扰。本阶段不新增租约或围栏,运行面在完成独立水平扩展规格前保持 Worker 单副本。 diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index e106b07d..8e3fc65b 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -34,7 +34,7 @@ #### R1.3.3 [in_progress] 实现员工身份、Casbin RBAC、会话与 CLI login/logout/whoami/employees/roles,使用 YAML Secret 投影初始管理员和员工,执行记录见 [pikasTech/unidesk#1984](https://github.com/pikasTech/unidesk/issues/1984),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.3_Task_Report.md)。 -#### R1.3.4 [in_progress] +#### R1.3.4 [completed] 实现 pgx 同事务审计、PostgreSQL outbox、Watermill handler pipeline、幂等 Worker 与 OTel/Prometheus,执行记录见 [pikasTech/unidesk#1985](https://github.com/pikasTech/unidesk/issues/1985),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.4_Task_Report.md)。 ### R1.4 From 167deae0b3cc3f7857ac6cc82b2603b7e966ecdf Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 06:19:58 +0200 Subject: [PATCH 36/43] docs: define PikaOA global resource ids --- docs/MDTODO/pikaoa-enterprise-platform.md | 8 ++++ .../PJ2026-03/specs/PJ2026-03-pikaoa.md | 39 +++++++++++++++---- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 8e3fc65b..1a33dd57 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -37,6 +37,14 @@ #### R1.3.4 [completed] 实现 pgx 同事务审计、PostgreSQL outbox、Watermill handler pipeline、幂等 Worker 与 OTel/Prometheus,执行记录见 [pikasTech/unidesk#1985](https://github.com/pikasTech/unidesk/issues/1985),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.4_Task_Report.md)。 +#### R1.3.5 + +实现统一业务伙伴、甲方分类、角色、联系人、标签、归档、筛选和 CLI;持久化 REST 资源使用 UUIDv7,伙伴与分类只通过 typed ID 关联,并将业务、审计、outbox 保持同事务,执行记录见 [pikasTech/unidesk#1990](https://github.com/pikasTech/unidesk/issues/1990),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.5_Task_Report.md)。 + +#### R1.3.6 + +实现统一附件元数据、对象引用、可替换存储端口、完整性校验、授权 API 和 CLI;附件使用 UUIDv7 平级资源身份,业务对象仅通过 typed ID 关联,执行记录见 [pikasTech/unidesk#1991](https://github.com/pikasTech/unidesk/issues/1991),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.6_Task_Report.md)。 + ### R1.4 在 R1.3 CLI 后端验收通过后实现 HWLAB v0.3 同族 Vue 工作台、响应式页面与同路径业务流,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.4_Task_Report.md)。 diff --git a/project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md b/project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md index acda64e5..55fe8d79 100644 --- a/project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md +++ b/project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md @@ -5,6 +5,7 @@ | 版本 | 对应 commit id | 更新日期 | 变更说明 | | --- | --- | --- | --- | | v0.1 | 待本版本提交 | 2026-07-13 | 创建企业办公平台预期终态、模块架构和首批合同与发票能力规格。 | +| v0.2 | 待本版本提交 | 2026-07-14 | 统一平级 REST 资源、UUIDv7 全局标识和仅通过 typed ID 建立对象关联的契约。 | 修改历史只记录规格语义变更,不记录实现进度、阶段基线或一次性证据。 @@ -56,7 +57,8 @@ PikaOA 终态必须同时具备: - 统一员工身份、组织成员、角色、权限和会话; - 统一业务伙伴、分类、标签、联系人和归档状态; - 统一附件、审计、模块注册、搜索和分页契约; - - 统一 API 错误、字段校验和幂等写入语义。 + - 统一 API 错误、字段校验和幂等写入语义; + - 统一持久化 REST 资源的 UUIDv7 全局标识和 typed ID 关联语义。 - CLI-first 交付: - 每个后端业务用例先提供稳定 CLI 入口; - CLI 与 Web 复用同一 HTTP API、身份、权限和业务语义; @@ -114,10 +116,11 @@ PikaOA 终态必须同时具备: | 普通员工 | 可以使用被授予模块能力但不能管理系统身份与权限的员工角色。 | | 业务伙伴 | 与公司发生业务关系的外部组织或个人;甲方是业务伙伴的一种业务角色。 | | 甲方分类 | 用于组织、筛选和汇总甲方的可维护分类。 | -| 合同主记录 | 表示一份合同长期身份、甲方、编号、状态和当前版本指针的聚合根。 | -| 合同版本 | 合同在某一时点的不可变业务内容快照,具有合同内单调递增的版本号。 | +| 合同主记录 | 表示一份合同长期身份、甲方、编号、状态和当前版本 ID 指针的独立业务资源。 | +| 合同版本 | 具有全局唯一 ID 的独立业务资源,通过合同 ID 和前一版本 ID 建立关联,并具有合同内单调递增的版本号。 | | 当前版本 | 合同主记录明确指向、供默认查询和新关联使用的合同版本。 | | 发票废弃 | 保留发票记录并记录废弃人、时间和原因,使其不再计入有效业务结果。 | +| Typed ID 关联 | 资源只保存关联对象的资源类型和全局唯一 ID,不复制关联对象私有载荷,也不以嵌套路径或表级从属关系定义资源身份。 | | 模块 | 具有独立领域边界、权限、迁移、API、事件和前端入口的可插拔业务能力。 | | 领域事件 | 模块完成业务事务后写入的结构化事实,用于同进程订阅或异步扩展。 | @@ -158,6 +161,13 @@ PikaOA 终态必须同时具备: - 审计动作、资源类型和可安全披露的变更摘要; - 健康、迁移和部署后验证入口。 +所有持久化 REST 资源必须遵循统一对象契约: + +- 由服务端生成不可变 UUIDv7,业务编号、名称、版本号和外部标识不得充当资源主键; +- 伙伴、合同、合同版本、发票和附件等资源在 REST、领域模型和存储层均保持平级身份; +- 关联只使用 typed ID 字段或显式关联记录,不使用嵌套 REST 路径、级联从属对象或复制载荷形成第二关系真相; +- 删除、归档、废弃或改名不得改变其他资源的 ID,也不得使历史关联失效。 + 新增模块不得通过修改其他模块私有表完成集成。跨模块读取使用应用端口或稳定对象引用;异步扩展使用事务内写入的领域事件/outbox,不新增第二业务真相。 ## 5. 目标架构和数据流 @@ -304,7 +314,11 @@ PikaOA 必须以第 4.3 节的模块接入合同组织业务能力。模块必 ### 6.4 PIKAOA-L0-REQ-004 合同多版本 -每份合同必须有稳定主记录和一个或多个不可变版本。合同内版本号单调递增;创建新版本必须保留旧版本、更新当前版本指针并记录变更说明、操作者和时间。 +- 每份合同和每个合同版本都是具有独立 UUIDv7 的平级 REST 资源; +- 合同版本通过 `contractId` 关联合同,并通过可选 `previousVersionId` 关联前一版本; +- 合同通过 `currentVersionId` 关联当前版本; +- 合同内版本号单调递增,但版本号不得充当资源主键; +- 创建新版本必须保留旧版本、更新当前版本 ID 指针并记录变更说明、操作者和时间。 每个版本至少包含: @@ -317,13 +331,18 @@ PikaOA 必须以第 4.3 节的模块接入合同组织业务能力。模块必 接受标准包括: - 详情默认显示当前版本并可查看全部历史版本; +- 任一版本可以通过 `/api/v1/contract-versions/{id}` 独立读取,版本关系只由 ID 字段表达; - 历史版本不可被当前编辑覆盖; - 并发创建相同下一版本时只能有一个事务成功; - 合同可以按甲方、分类、编号、状态和日期筛选。 ### 6.5 PIKAOA-L0-REQ-005 发票管理和废弃 -发票必须支持号码、类型、开票日期、金额、税额、价税合计、币种、甲方、合同和可选合同版本关联。发票不得物理删除;废弃动作必须保留原记录,并记录废弃人、废弃时间和非空原因。 +- 发票是具有独立 UUIDv7 的平级 REST 资源; +- 发票通过 `partnerId`、可选 `contractId` 和可选 `contractVersionId` 建立关联; +- 发票必须支持号码、类型、开票日期、金额、税额、价税合计和币种; +- 发票不得物理删除; +- 废弃动作必须保留原记录,并记录废弃人、废弃时间和非空原因。 接受标准包括: @@ -335,7 +354,10 @@ PikaOA 必须以第 4.3 节的模块接入合同组织业务能力。模块必 ### 6.6 PIKAOA-L0-REQ-006 附件完整性 -合同版本和发票可以关联附件。附件元数据必须记录原文件名、媒体类型、字节数、内容哈希、存储键、上传人和上传时间。存储实现通过端口替换,业务表不保存宿主绝对路径。 +- 附件是具有独立 UUIDv7 的平级 REST 资源; +- 合同版本和发票通过显式 typed ID 关联记录引用附件,不把附件作为嵌套从属对象; +- 附件元数据必须记录原文件名、媒体类型、字节数、内容哈希、存储键、上传人和上传时间; +- 存储实现通过端口替换,业务表不保存宿主绝对路径。 接受标准包括: @@ -433,11 +455,12 @@ Web、API、Worker 和 CI/CD 必须输出结构化日志、健康状态和 trace - 外部 HTTP API 使用 `/api/v1` 版本前缀。 - 模块路由使用稳定复数资源名和标准分页参数。 - 写请求返回稳定业务错误码、字段路径和可读消息。 -- ID 使用不可枚举、全局唯一的稳定标识。 +- 所有持久化 REST 资源 ID 使用服务端生成、不可枚举、不可变的 UUIDv7。 +- 集合和单资源路径使用平级复数资源名,例如 `/api/v1/contracts/{id}`、`/api/v1/contract-versions/{id}` 和 `/api/v1/invoices/{id}`;嵌套路径只可作为有界查询入口,不得成为资源唯一身份。 - 时间以 UTC 保存并在用户界面按配置时区显示。 - 金额使用定点十进制语义,不使用二进制浮点保存。 - 数据库迁移单向、版本化、可审计;应用启动不静默修改未知结构。 -- 跨模块对象引用包含资源类型和稳定 ID,不复制私有载荷。 +- 跨模块对象引用包含资源类型和 UUIDv7,只建立 ID 关联,不复制私有载荷或形成对象隶属真相。 - 兼容归一化只处理可安全解释的旧字段;不可解释业务数据返回字段级错误。 ## 8. 全局验收契约 From 62b9d97692c4257d5772ab1da86a3c5f267b3526 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 06:23:20 +0200 Subject: [PATCH 37/43] docs: plan PikaOA contracts and invoices --- docs/MDTODO/pikaoa-enterprise-platform.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 1a33dd57..a01606f7 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -45,6 +45,12 @@ 实现统一附件元数据、对象引用、可替换存储端口、完整性校验、授权 API 和 CLI;附件使用 UUIDv7 平级资源身份,业务对象仅通过 typed ID 关联,执行记录见 [pikasTech/unidesk#1991](https://github.com/pikasTech/unidesk/issues/1991),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.6_Task_Report.md)。 +#### R1.3.7 + +实现合同与合同版本平级 UUIDv7 REST 资源,通过 contractId、previousVersionId、currentVersionId 建立 ID 关系,保证版本不可变、并发序号唯一、附件关联和全量 CLI,执行记录见 [pikasTech/unidesk#1992](https://github.com/pikasTech/unidesk/issues/1992),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.7_Task_Report.md)。 +#### R1.3.8 + +实现平级 UUIDv7 发票资源,通过 partnerId、contractId、contractVersionId 和 attachmentId 建立关联,支持定点金额、typed 一致性校验、不可删除废弃事实、筛选汇总和全量 CLI,执行记录见 [pikasTech/unidesk#1993](https://github.com/pikasTech/unidesk/issues/1993),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.8_Task_Report.md)。 ### R1.4 在 R1.3 CLI 后端验收通过后实现 HWLAB v0.3 同族 Vue 工作台、响应式页面与同路径业务流,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.4_Task_Report.md)。 From 9acee27c571f92635dbda78998256968e652d4f4 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 06:37:31 +0200 Subject: [PATCH 38/43] docs: plan PikaOA audit queries --- docs/MDTODO/pikaoa-enterprise-platform.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index a01606f7..a9781fbe 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -51,6 +51,9 @@ #### R1.3.8 实现平级 UUIDv7 发票资源,通过 partnerId、contractId、contractVersionId 和 attachmentId 建立关联,支持定点金额、typed 一致性校验、不可删除废弃事实、筛选汇总和全量 CLI,执行记录见 [pikasTech/unidesk#1993](https://github.com/pikasTech/unidesk/issues/1993),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.8_Task_Report.md)。 +#### R1.3.9 + +复用 append-only 审计唯一真相实现 UUIDv7 平级审计资源、RBAC 查询 API、actor/resource/action/time/requestId/traceId 筛选和 audit list/get CLI,执行记录见 [pikasTech/unidesk#1994](https://github.com/pikasTech/unidesk/issues/1994),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.9_Task_Report.md)。 ### R1.4 在 R1.3 CLI 后端验收通过后实现 HWLAB v0.3 同族 Vue 工作台、响应式页面与同路径业务流,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.4_Task_Report.md)。 From a26e51d2ba0c0a9e0e4bcae2f925682375dd6c9c Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 07:19:32 +0200 Subject: [PATCH 39/43] docs: make PikaOA audit validation non-blocking --- project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md b/project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md index 55fe8d79..2858e184 100644 --- a/project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md +++ b/project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md @@ -6,6 +6,7 @@ | --- | --- | --- | --- | | v0.1 | 待本版本提交 | 2026-07-13 | 创建企业办公平台预期终态、模块架构和首批合同与发票能力规格。 | | v0.2 | 待本版本提交 | 2026-07-14 | 统一平级 REST 资源、UUIDv7 全局标识和仅通过 typed ID 建立对象关联的契约。 | +| v0.3 | 待本版本提交 | 2026-07-14 | 将审计语义校验收敛为非阻塞 warning 和最小安全归一化,避免开发期复杂审计门禁阻塞业务。 | 修改历史只记录规格语义变更,不记录实现进度、阶段基线或一次性证据。 @@ -370,10 +371,18 @@ PikaOA 必须以第 4.3 节的模块接入合同组织业务能力。模块必 所有身份、伙伴、合同、版本、发票和模块配置写入必须生成不可变审计记录。需要异步扩展的业务事实必须在同一数据库事务中写入 outbox;Worker 只消费已提交事件,不反向成为业务真相。 +首期审计校验采用最小、非阻塞语义: + +- 缺失可补足字段、格式偏差或安全摘要校验只产生低基数 `warning`,不得单独改变成功业务终态; +- 可安全解释的问题使用稳定 system identity、默认时间或省略摘要完成最小归一化,不建立复杂策略引擎或额外状态; +- 发现疑似 Secret、密码、会话、token 或附件正文时直接删除或替换对应摘要,不允许以 warning 为由披露敏感值; +- PostgreSQL 持久化、事务和 outbox 的真实故障仍按现有一致性契约处理,不把基础设施失败伪装成校验 warning。 + 接受标准包括: - 审计记录包含员工、动作、资源类型、资源标识、时间和安全摘要; - Secret、密码、会话和附件正文不进入审计; +- 业务写入不因审计字段完整性、格式或安全摘要校验 warning 而失败; - 事务回滚时审计和 outbox 同时回滚; - Worker 重试不重复改变业务主记录。 From 0910bd87c955a90e4ec4087bd67750f189301950 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 10:32:09 +0200 Subject: [PATCH 40/43] docs: allow direct post-task improvement dispatch --- .agents/skills/unidesk-subagent/SKILL.md | 4 ++++ .agents/skills/unidesk-subagent/references/gh-workflow.md | 1 + 2 files changed, 5 insertions(+) diff --git a/.agents/skills/unidesk-subagent/SKILL.md b/.agents/skills/unidesk-subagent/SKILL.md index 94c6b7f6..9ba352d2 100644 --- a/.agents/skills/unidesk-subagent/SKILL.md +++ b/.agents/skills/unidesk-subagent/SKILL.md @@ -68,6 +68,10 @@ description: UniDesk 主代理调度子代理的必读技能。用户提到子 - 子代理完成后必须留下可审查工件:PR、issue comment、commit、验证输出摘要、部署/observer/trace 证据或阻塞说明;主代理不能只凭口头结论合并。 - 子代理长时间无响应时,主代理按“问询 -> 只读检查 worktree/PR/issue -> 再窄问询”的顺序处理;多次问询仍无回复时,可以关闭旧子代理,并在原 worktree/原分支/原 issue 边界上重开新子代理接续。重开前不得删除或重置旧 worktree;新子代理必须先读取原 worktree 状态和既有 issue/PR/comment 链接,再继续最小下一步。 - 子代理完成任务后,主代理必须再给该子代理发送 post-task 收口要求;若主代理要求子代理纠偏或补验证,则等纠偏完成后再发 post-task。post-task 反馈由子代理按 `$post-task` 自行给出判断,主代理不负责反馈池去重;主代理只从子代理提好的反馈中挑选适合工程化的项转成正式 FEATURE/BUG issue,并优先派回提出该反馈的子代理执行。 +- post-task 反馈明确有助于达到“下次同类任务可以减少不必要工具调用”时,主代理可以直接创建对应 issue、登记 MDTODO,并派后续子代理改进工具、文档或 skill,无需再次等待用户确认: + - 仍须遵守子 issue、MDTODO、独立 worktree、目标分支、受控 GitHub 入口和主代理审核要求; + - 改进任务必须与原业务主线隔离并优先并发推进,不得成为当前业务交付、合并或上线门禁; + - 不得借反馈扩展业务功能、安全机制、权限契约、审计门禁或其他未经授权的范围。 - 主代理结束条件: - 当前用户任务只要派出过子代理: - 主代理就必须保持任务进行中; diff --git a/.agents/skills/unidesk-subagent/references/gh-workflow.md b/.agents/skills/unidesk-subagent/references/gh-workflow.md index 69f5c913..acf81765 100644 --- a/.agents/skills/unidesk-subagent/references/gh-workflow.md +++ b/.agents/skills/unidesk-subagent/references/gh-workflow.md @@ -55,6 +55,7 @@ - 每个子代理完成主任务后,主代理必须发送 post-task 收口要求;如果主代理发现需要纠偏、补证据或返工,应先让子代理完成纠偏,再发送 post-task。post-task 只收集反馈和后续问题线索,不应让子代理自行扩大代码修改范围。 - 子代理按 `$post-task` 自行输出已判断的 feedback 候选、疑似归属和是否建议转正式 issue;主代理不负责反馈池去重,不代替子代理维护 `[FEEDBACK]` issue。主代理只从子代理提好的 feedback 中挑选适合直接工程化的项,另起正式 FEATURE/BUG issue 后继续调度。 - feedback 转正式 issue 后,优先派回提出该 feedback 的子代理执行,使调查上下文和修复上下文保持连续;只有该子代理不可用或任务边界已变化时才派给其他子代理。 +- feedback 明确能减少下次同类任务不必要工具调用时,主代理可以直接建立正式 issue、登记 MDTODO 并派后续子代理改进工具、文档或 skill,无需再次等待用户确认。该任务使用独立 worktree/PR 并与业务主线并发,不能成为当前业务交付门禁,也不能借机扩展未经授权的业务或安全范围。 ## PR 工作流 From 35aafbc4a8dfb5e8f4ee9c76eb05e4863b2e1f2f Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 10:35:40 +0200 Subject: [PATCH 41/43] docs: start parallel pikaoa MVP modules --- .../R1.3.3_Task_Report.md | 24 +++++++++++++++++++ docs/MDTODO/pikaoa-enterprise-platform.md | 8 +++---- 2 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.3_Task_Report.md diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.3_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.3_Task_Report.md new file mode 100644 index 00000000..ffb9675a --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.3_Task_Report.md @@ -0,0 +1,24 @@ +# R1.3.3 任务报告 + +## 结论 + +员工身份、固定管理员/员工角色、会话、基础 capability 授权和 CLI 已合并到 `pikainc/pikaoa` `master`。完整身份/RBAC 契约不作为伙伴、附件、合同、发票或审计查询的开发与合并门禁;仅保留公网 MVP 的登录、固定角色和基础接口鉴权。 + +## 交付证据 + +- Issue:https://github.com/pikasTech/unidesk/issues/1984 +- PR:https://github.com/pikainc/pikaoa/pull/3 +- 合并后提交:`f024d6c` +- 最终实现提交:`3f6b3dc366fb606d9b58c245f15433c4964bebc6` + +## 验证 + +- `go test ./...` 通过。 +- `go vet ./...` 通过。 +- `make build` 通过。 +- 管理员与普通员工登录、权限拒绝、停用后禁止新登录及 Secret 不泄露路径通过。 +- 受控 PR preflight 为 `MERGEABLE/CLEAN`,PR 已合并。 + +## 剩余边界 + +动态角色治理、完整共享 RBAC 契约和进一步权限抽象不阻塞第一版 MVP;如后续业务需要,独立 issue 推进。 diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index a9781fbe..e30d67e3 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -31,17 +31,17 @@ #### R1.3.2 [completed] 采用 chi 建立可插拔模块 HTTP 运行时、disabled 模块关闭入口、示例模块扩展验收,并补齐 PostgreSQL OTel 与 HTTP/runtime/pool/migration Prometheus 基线,执行记录见 [pikasTech/unidesk#1983](https://github.com/pikasTech/unidesk/issues/1983),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.2_Task_Report.md)。 -#### R1.3.3 [in_progress] +#### R1.3.3 [completed] 实现员工身份、Casbin RBAC、会话与 CLI login/logout/whoami/employees/roles,使用 YAML Secret 投影初始管理员和员工,执行记录见 [pikasTech/unidesk#1984](https://github.com/pikasTech/unidesk/issues/1984),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.3_Task_Report.md)。 #### R1.3.4 [completed] 实现 pgx 同事务审计、PostgreSQL outbox、Watermill handler pipeline、幂等 Worker 与 OTel/Prometheus,执行记录见 [pikasTech/unidesk#1985](https://github.com/pikasTech/unidesk/issues/1985),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.4_Task_Report.md)。 -#### R1.3.5 +#### R1.3.5 [in_progress] 实现统一业务伙伴、甲方分类、角色、联系人、标签、归档、筛选和 CLI;持久化 REST 资源使用 UUIDv7,伙伴与分类只通过 typed ID 关联,并将业务、审计、outbox 保持同事务,执行记录见 [pikasTech/unidesk#1990](https://github.com/pikasTech/unidesk/issues/1990),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.5_Task_Report.md)。 -#### R1.3.6 +#### R1.3.6 [in_progress] 实现统一附件元数据、对象引用、可替换存储端口、完整性校验、授权 API 和 CLI;附件使用 UUIDv7 平级资源身份,业务对象仅通过 typed ID 关联,执行记录见 [pikasTech/unidesk#1991](https://github.com/pikasTech/unidesk/issues/1991),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.6_Task_Report.md)。 @@ -51,7 +51,7 @@ #### R1.3.8 实现平级 UUIDv7 发票资源,通过 partnerId、contractId、contractVersionId 和 attachmentId 建立关联,支持定点金额、typed 一致性校验、不可删除废弃事实、筛选汇总和全量 CLI,执行记录见 [pikasTech/unidesk#1993](https://github.com/pikasTech/unidesk/issues/1993),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.8_Task_Report.md)。 -#### R1.3.9 +#### R1.3.9 [in_progress] 复用 append-only 审计唯一真相实现 UUIDv7 平级审计资源、RBAC 查询 API、actor/resource/action/time/requestId/traceId 筛选和 audit list/get CLI,执行记录见 [pikasTech/unidesk#1994](https://github.com/pikasTech/unidesk/issues/1994),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.9_Task_Report.md)。 ### R1.4 From c4668ce3c81b63841a85e158610276122f1e7413 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 10:50:34 +0200 Subject: [PATCH 42/43] docs: track Artificer private PR credentials --- docs/MDTODO/agentrun-runtime-reliability.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/MDTODO/agentrun-runtime-reliability.md b/docs/MDTODO/agentrun-runtime-reliability.md index 42174808..ee4a146a 100644 --- a/docs/MDTODO/agentrun-runtime-reliability.md +++ b/docs/MDTODO/agentrun-runtime-reliability.md @@ -113,3 +113,7 @@ ### R5.6 [completed] 解决 [UniDesk #1883](https://github.com/pikasTech/unidesk/issues/1883) 与 [AgentRun #342](https://github.com/pikasTech/agentrun/issues/342):让 Artificer runner 的受控 trans 复用 UniDesk Windows route 解析与 frontend transport,支持 D518:win 及 win// 原入口;区分 primary source workspace 与用户授权的远端 tool target,删除 unsupported-operation 和 host path 误判,不新增节点特例、旁路 SSH 或隐藏 fallback。两个 PR 自动上线后用最短 Artificer canary 验收 pwd、ls、cat 和 apply-patch,完成任务后将详细报告写入[任务报告](./details/agentrun-runtime-reliability/R5.6_Task_Report.md)。 + +### R5.7 [in_progress] + +解决 [UniDesk #2014](https://github.com/pikasTech/unidesk/issues/2014):把 `config/unidesk-cli.yaml#github.auth.repositoryOverrides` 的既有私有仓凭据通过 YAML-first tool credential/resource bundle 投影给 Artificer,使受控 `unidesk-gh` 可读取、创建和评论任务私有仓 PR;Secret 只披露 SecretRef presence/fingerprint/valuesPrinted=false,不新增第二 GitHub 客户端、权限契约、租约或围栏,并与 PikaOA MVP 主线并行且不作为业务门禁,完成任务后将详细报告写入[任务报告](./details/agentrun-runtime-reliability/R5.7_Task_Report.md)。 From 5cff28c3fdf06b3fbe61a39f863c68e37236835d Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 12:00:48 +0200 Subject: [PATCH 43/43] fix: canonicalize PikaOA repository identity --- config/pikaoa.yaml | 6 +++--- config/platform-infra/gitea.yaml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config/pikaoa.yaml b/config/pikaoa.yaml index 9e55d3e9..c7dd7e55 100644 --- a/config/pikaoa.yaml +++ b/config/pikaoa.yaml @@ -4,7 +4,7 @@ kind: pikaoa-platform-delivery metadata: id: pikaoa-enterprise owner: unidesk - repository: pikaInc/pikaoa + repository: pikainc/pikaoa spec: PJ2026-03 relatedIssues: - 1952 @@ -45,8 +45,8 @@ delivery: toolsImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 buildkitImage: 127.0.0.1:5000/hwlab/buildkit:rootless source: - repository: pikaInc/pikaoa - worktreeRemote: https://github.com/pikaInc/pikaoa.git + repository: pikainc/pikaoa + worktreeRemote: https://github.com/pikainc/pikaoa.git branch: master readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git snapshotPrefix: refs/unidesk/snapshots/gitea-actions/pikaoa-master-nc01 diff --git a/config/platform-infra/gitea.yaml b/config/platform-infra/gitea.yaml index 75de5401..eb551567 100644 --- a/config/platform-infra/gitea.yaml +++ b/config/platform-infra/gitea.yaml @@ -429,8 +429,8 @@ sourceAuthority: name: gitea-github-sync-secrets key: github-token-pikainc-pikaoa upstream: - repository: pikaInc/pikaoa - cloneUrl: https://github.com/pikaInc/pikaoa.git + repository: pikainc/pikaoa + cloneUrl: https://github.com/pikainc/pikaoa.git branch: master visibility: private gitea: