Merge pull request #2725 from pikasTech/fix/2721-manual-cicd-trigger
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Failed
Pipelines as Code CI / unidesk-host- Success

fix(cicd): 切断 L2/L3 自动 PaC webhook
This commit is contained in:
Lyon
2026-07-21 12:03:08 +08:00
committed by GitHub
8 changed files with 118 additions and 60 deletions
+7 -2
View File
@@ -17,6 +17,7 @@ metadata:
- 2165
- 2274
- 2628
- 2721
defaults:
targetId: NC01
consumerId: hwlab-nc01-v03
@@ -24,6 +25,9 @@ display:
timeZone: Asia/Shanghai
deliveryTiming:
endToEndBudgetSeconds: 120
deliveryTrigger:
mode: manual-plan-confirm
mechanism: pac-webhook
observability:
configRef: config/platform-infra/observability.yaml
readOnlyCapture:
@@ -119,12 +123,12 @@ targets:
- id: JD01
route: JD01:k3s
namespace: agentrun-ci
role: active-cicd-trigger-authority
role: manual-cicd-trigger-authority
enabled: true
- id: NC01
route: NC01:k3s
namespace: agentrun-ci
role: active-cicd-trigger-authority
role: manual-cicd-trigger-authority
enabled: true
gitea:
configRef: "config/platform-infra/gitea.yaml#sourceAuthority.repositories"
@@ -137,6 +141,7 @@ gitea:
passwordLine: 2
apiUsername: unidesk-admin
webhook:
enabled: false
secretRoot: /root/.unidesk/.env
secretSourceRef: pipelines-as-code.webhook
events:
@@ -58,6 +58,8 @@
| GitOps desired state | GitOps branch 中声明的目标运行面资源状态。 |
| runtime live state | 目标 namespace 中实际运行的 workload、image、config、health 和 readiness 状态。 |
| env image reuse | 基础执行环境镜像按 env identity 复用,普通业务源码变更只改变 boot commit。 |
| 发布计划 | 对精确 source commit 和目标 lane 的只读发布影响分析,至少包含 env reuse、待构建镜像、rollout 服务和范围变化。 |
| 手动发布触发 | 操作者审阅发布计划后,通过受控 CLI 向 PaC 发送 webhook,由 PaC 创建 commit-pinned PipelineRun 的唯一发布 mutation。 |
| 综合联调 | 在真实目标运行面、真实 SecretRef、真实 backend/provider 或服务入口上完成的发布候选验证。 |
## 4. 系统边界和接口
@@ -66,8 +68,8 @@
| 边界项 | 内容 |
| --- | --- |
| 外部使用者 | 平台管理员、发布操作人员、服务维护者、CI/CD 自动化任务和需要发布状态的业务模块。 |
| 外部输入 | source commit、deploy 配置、lane 选择、镜像构建请求、GitOps promotion 请求、rollout 请求、SecretRef presence 和验证请求。 |
| 外部使用者 | 平台管理员、发布操作人员、服务维护者和需要发布状态的业务模块。 |
| 外部输入 | source commit、deploy 配置、lane 选择、发布计划请求、带计划指纹的手动触发请求、SecretRef presence 和验证请求。 |
| 受控资源 | Tekton PipelineRun、Argo Application、runtime namespace、workload、image、release status、health/readiness 和发布判定结果。 |
| 外部输出 | PipelineRun 状态、image digest、promotion revision、Argo sync 状态、runtime readiness、发布候选结论和 redacted 失败信息。 |
| 用户接口 | UniDesk CI/CD CLI、服务自有 CLI 发布/验证相关入口、服务 health/readiness、Tekton/Argo 受控状态查询入口。 |
@@ -83,6 +85,7 @@
| PJ2026-01060102 | 镜像Promotion | 本规格 6.2 | env image reuse、digest-pinned image、artifact promotion 和 image readiness | Source commit、Containerfile、registry | 需要运行镜像的服务 |
| PJ2026-01060103 | 发布判定 | 本规格 6.3 | source/GitOps/runtime 一致性、health/readiness 和 migration/SecretRef presence | CI/CD、GitOps、YAML运维 | 平台管理员、业务模块 |
| PJ2026-01060104 | 验证分层 | 本规格 6.4 | 自测试、综合联调、CLI/API 交互和真实运行面通过口径 | 目标 runtime、业务模块测试需求 | 发布决策 |
| PJ2026-01060108 | 手动发布 | 本规格 6.5 | L2/L3 发布计划、范围审阅和带计划指纹的受控手动触发 | 源码同步、YAML运维、镜像Promotion | 全部 L2/L3 runtime 服务 |
| PJ2026-01060105 | AgentRun发布 | [PJ2026-01060105 AgentRun发布Lane](PJ2026-01060105-agentrun-v01-release-lane.md) | AgentRun `v0.1` 的发布 lane、Pipeline、runtime namespace 和真实联调细则 | 源码同步、YAML运维、Agent编排 | AgentRun runtime |
| PJ2026-01060106 | HWLAB双环境 | [PJ2026-01060106 HWLAB双环境](PJ2026-01060106-hwlab-dev-production-lanes.md) | HWLAB `v0.3` development 与 `release` production 的分支、流水线、公开入口和数据隔离 | 源码同步、YAML运维、Workbench实时权威 | HWLAB runtime |
| PJ2026-01060107 | NC01 CI资源治理 | [PJ2026-01060107 NC01 CI资源治理](PJ2026-01060107-nc01-ci-resource-governance.md) | NC01 构建并发、排队、资源合同、稳定去重和核心运行面保护 | 发布流水、YAML运维、CI/CD目标治理 | NC01 CI 与核心服务 |
@@ -132,3 +135,17 @@ PipelineRun 成功、Argo Synced、health 可访问或 source check 通过都不
发布流水应区分组件自测试和综合联调:自测试允许 mock,用于快速反馈;综合联调必须在真实目标运行面、真实依赖、真实配置和服务原入口上完成。
mock、fake dependency、source-only、dry-run、缺关键配置时的 skip、只读 health 成功或没有真实业务终态的运行面状态,都不能作为综合联调或发布通过证据。具体服务需要哪些真实依赖和终态,由对应服务专项规格定义。
### 6.5 OPS-RELEASE-REQ-005 L2/L3手动计划与触发
| 编号 | 短名 | 主责模块 | 关联模块 |
| --- | --- | --- | --- |
| OPS-RELEASE-REQ-005 | 手动发布 | PJ2026-01060108 手动发布 | [源码同步](PJ2026-010602-source-sync.md)、[YAML运维](PJ2026-010603-yaml-first-ops.md)、[NC01 CI资源治理](PJ2026-01060107-nc01-ci-resource-governance.md) |
L2 与 L3 发布必须使用 `plan -> 人工审阅 -> trigger` 的受控手动链。PR merge、push、branch update、mirror 同步、定时任务和 L0/L1 开发动作只能更新 source authority 或只读状态,不得创建 PipelineRun、构建镜像、推进 GitOps 或触发 rollout。
`plan` 必须是只读操作,并针对精确 target、lane 和 source commit 输出:env identity 及 reuse/build 判定、待构建镜像数量与服务列表、待 rollout 服务、全部受影响服务、范围基线和相对基线新增项。`plan` 不得同步 mirror、创建 snapshot、修改 branch、创建 PipelineRun 或写入运行面。
范围相对 owning YAML 声明的发布意图扩大时,`plan` 必须明确列出新增项。操作者应先修正源码、配置或 planner,使范围回到预期后重新生成计划;不得通过确认参数、白名单、忽略 warning 或扩大资源预算继续触发。
唯一 mutation 是受控手动 `trigger`。它必须显式选择 target、lane、精确 source commit 和 pipeline intent,并向 PaC 发送 webhookPaC 从同一 source authority 创建 commit-pinned PipelineRun。触发前必须先审阅当前输入的 plan;计划输入或范围已变化时必须重新 plan。禁止 CLI 直建或裸创建 PipelineRun、由 PR 合并回调 trigger,或为兼容旧自动链保留第二入口。
@@ -25,7 +25,7 @@
| 关联规格 | [PJ2026-010602 源码同步](PJ2026-010602-source-sync.md)、[PJ2026-010603 YAML运维](PJ2026-010603-yaml-first-ops.md)、[PJ2026-010401080313 Workbench实时权威](PJ2026-010401080313-workbench-realtime-authority.md) |
| 规格治理索引 | [规格治理](spec-governance.md) |
本文定义 HWLAB 在 NC01 上的 development/production 双环境。所有可调事实由 owning YAML 控制,自动发布只由目标 source branch 的 PR merge/branch update 驱动,禁止人工补跑 PipelineRun、手工 Argo sync、运行时 patch 或从集群反解配置。
本文定义 HWLAB 在 NC01 上的 development/production 双环境。所有可调事实由 owning YAML 控制,L2/L3 发布只允许通过受控 `plan` 和手动 `trigger` 执行;PR merge/branch update 不得创建 PipelineRun。禁止裸补跑 PipelineRun、手工 Argo sync、运行时 patch 或从集群反解配置。
## 2. 目的和范围
@@ -39,7 +39,7 @@
- development 的 NC01 公网 IP HTTP 入口与 production 的 `https://hwlab.pikapython.com` 入口。
- 共用 NC01 host PostgreSQL 服务时,两套独立 database、role、Secret consumer 和 migration ledger。
- 两套独立 Kafka consumer group identity,以及纯 Kafka实时/回放权威不被数据库迁移阻塞的边界。
- `v0.3``release` 的受控晋升、自动部署和原入口验证。
- `v0.3``release` 的受控晋升、手动计划发布和原入口验证。
### 2.3 范围外
@@ -54,7 +54,7 @@
| --- | --- |
| development lane | source 为 `v0.3`、保留当前开发 namespace、通过 NC01 公网 IP 空闲 HTTP 端口访问的 HWLAB 环境。 |
| production lane | source 为 `release`、使用独立 production namespace、通过 `https://hwlab.pikapython.com` 访问的 HWLAB 环境。 |
| 晋升 | 把已在 development 完成原入口验证的精确 commit 纳入 `release`,随后由 production 自动链发布。 |
| 晋升 | 把已在 development 完成原入口验证的精确 commit 纳入 `release`;分支推进不发布,仍需对 production 单独 plan 和手动 trigger。 |
| 数据域隔离 | 两个环境不共享 PostgreSQL database、role、Secret consumer 或 migration ledger,任何 schema/data mutation 只作用于选中 lane。 |
| 入口所有权 | owning YAML 中唯一声明某 public origin 的 consumer;同一 host/origin 不得同时归 development 和 production。 |
@@ -96,7 +96,7 @@ flowchart LR
Kafka event 是实时与回放 authority。数据库只保存各 lane 的业务数据和派生读模型;任一数据库 schema 缺失不得改变 Kafka direct publish/live/replay 路径,也不得成为 Cloud API 启动或自动滚动前置。
### 4.3 晋升与动发布时序
### 4.3 晋升与动发布时序
```mermaid
sequenceDiagram
@@ -106,16 +106,18 @@ sequenceDiagram
participant R as release
participant PC as production CI/CD
participant PV as production 原入口验证
D->>DC: merge/update commit C
DC->>DV: 自动构建、GitOps、rollout
D->>DC: merge/update commit C,不触发发布
DC->>DC: plan C,审阅 env reuse 与构建范围
DC->>DV: 手动 trigger C,构建、GitOps、rollout
DV-->>D: C 验证通过
D->>R: 受控晋升精确 commit C
R->>PC: branch update 自动触发
PC->>PV: 自动构建、GitOps、rollout
R->>PC: branch update,不触发发布
PC->>PC: plan C,审阅 env reuse 与构建范围
PC->>PV: 获得当次授权后手动 trigger C
PV-->>R: digest/source/runtime/入口证据
```
初始 `release` 分支只能从完成 P0 纯 Kafka纠偏并在 development 原入口验证通过的 `v0.3` commit 创建。不得从已知强制 PostgreSQL transactional projector 的事故 commit 创建,也不得把 production 自动链的缺失用人工 PipelineRun 或 runtime patch 填补。
初始 `release` 分支只能从完成 P0 纯 Kafka纠偏并在 development 原入口验证通过的 `v0.3` commit 创建。不得从已知强制 PostgreSQL transactional projector 的事故 commit 创建,也不得绕过 plan 和受控 trigger,用裸 PipelineRun 或 runtime patch 填补发布链
## 5. 配置与隔离
@@ -150,11 +152,11 @@ owning YAML 至少声明:
### 6.1 HWLAB-LANE-REQ-001 Development迁移
`v0.3` 自动链必须继续部署到 development namespace,并迁移到 YAML 声明的 NC01 公网 IP HTTP 入口。迁移完成后 `https://hwlab.pikapython.com` 只指向 production,不再路由到 development。
`v0.3` 的受控手动发布链必须部署到 development namespace,并迁移到 YAML 声明的 NC01 公网 IP HTTP 入口。迁移完成后 `https://hwlab.pikapython.com` 只指向 production,不再路由到 development。
### 6.2 HWLAB-LANE-REQ-002 Production自动
### 6.2 HWLAB-LANE-REQ-002 Production手动发布
`release` 必须拥有独立 PaC consumer、Tekton Pipeline/ServiceAccount、GitOps branch/path、Argo Application、namespace、image provenance 和 release status。`release` update 自动触发完整链,不依赖主代理或值班人员人工补跑。
`release` 必须拥有独立 PaC consumer、Tekton Pipeline/ServiceAccount、GitOps branch/path、Argo Application、namespace、image provenance 和 release status。`release` update 不得触发完整链;获得当次 L3 授权后,发布人员必须先审阅精确 commit 的 plan,再用受控 trigger 发布,禁止裸补跑。
### 6.3 HWLAB-LANE-REQ-003 Source与制品对齐
@@ -31,7 +31,7 @@
单个 HWLAB PipelineRun 曾同时展开 14 个 `build-services` TaskRun,使 8 核 NC01 的 load、Swap 和 k3s Kine/SQLite 延迟同时上升,并造成核心 API 约四分钟 502。PaC Repository 的 `concurrencyLimit` 只限制 Repository 触发层的并发 PipelineRun,不能限制一个 PipelineRun 内 matrix 展开的 TaskRun 数量。
治理目标是让每个发布意图只产生一个受控运行,并让单次运行中的构建工作按 YAML 预算进入有界 worker 队列。CI 压力下,k3s 控制面、CoreDNS、Sub2API 和 Public Edge 的可用性优先于构建吞吐。
治理目标是让 PR merge 和 branch update 不再产生发布运行;每个经人工审阅的发布意图只产生一个受控运行,并让单次运行中的构建工作按 YAML 预算进入有界 worker 队列。CI 压力下,k3s 控制面、CoreDNS、Sub2API 和 Public Edge 的可用性优先于构建吞吐。
## 3. 架构与数据流
@@ -46,7 +46,7 @@ flowchart LR
K --> C[核心运行面保护]
```
Repository 触发排队和 Pipeline 内构建排队是两个独立层次。前者继续由 PaC Repository `concurrencyLimit` 管理,后者由 renderer 将目标 matrix 收敛为一个受资源约束的 TaskRun,并在 Pod 内按 `maxParallelServices` 执行有界 worker 队列。不得复制大型内联 `taskSpec`,不得把二者合并为同名字段,也不得创建数据库、ConfigMap 或常驻控制器作为第二队列 authority。
受控手动发布触发和 Pipeline 内构建排队是两个独立层次。前者由发布 `plan`、人工审阅和带计划指纹的 `trigger` 管理,PaC Repository `concurrencyLimit` 只作为运行并发上限;后者由 renderer 将目标 matrix 收敛为一个受资源约束的 TaskRun,并在 Pod 内按 `maxParallelServices` 执行有界 worker 队列。不得复制大型内联 `taskSpec`,不得把二者合并为同名字段,也不得创建数据库、ConfigMap 或常驻控制器作为第二队列 authority。
## 4. 配置合同
@@ -57,6 +57,8 @@ HWLAB node/lane target 的 `ciResourceGovernance` 至少声明:
- `build.stepResources`:构建工具 step 的 CPU/内存 requests 与 limits。
- `build.sidecarResources`BuildKit sidecar 的 CPU/内存 requests 与 limits。
- `build.priorityClassName`:构建 Pod 的可抢占优先级;必须低于核心运行面。
- `deliveryTrigger.mode`L2/L3 固定为 `manual-plan-confirm`,禁止配置自动 push、merge 或 branch follower 触发。
- `deliveryTrigger.mechanism`:固定为 `pac-webhook`;受控 CLI 发送 webhook,禁止直建 PipelineRun。
- `dedupe.identityFields`:稳定意图键字段,固定为 repository、lane、source commit 和 pipeline intent。
- `coreProtection`:核心服务优先级和最小资源预算的配置引用;各服务事实仍归自身 owning YAML,不复制进 HWLAB lane YAML。
@@ -74,7 +76,7 @@ Renderer 必须只对 YAML 选中的构建 matrix 建立有界 worker 队列。
### 5.3 CI-RESOURCE-REQ-003 稳定去重
动触发的发布意图键固定为 `repository + lane + source commit + pipeline intent`。相同键已处于 queued、running 或 succeeded 时,自动 poll 不得再创建第二次构建。人工重跑必须使用显式受控入口,携带独立、可审计的 rerun intent;不得通过删除旧 PipelineRun、修改 label 或人工创建 PipelineRun 绕过去重。
动触发的发布意图键固定为 `repository + lane + source commit + pipeline intent`。相同键已处于 queued、running 或 succeeded 时,受控入口不得再创建第二次构建。人工重跑必须使用显式受控入口,携带独立、可审计的 rerun intent,并先重新 plan;不得通过删除旧 PipelineRun、修改 label 或人工创建 PipelineRun 绕过去重。
### 5.4 CI-RESOURCE-REQ-004 核心运行面保护
@@ -82,12 +84,16 @@ CoreDNS 和 k3s 控制面使用 Kubernetes 系统关键优先级。Sub2API 和
### 5.5 CI-RESOURCE-REQ-005 状态与诊断
`plan``status``diagnosis` 必须在一次输出中披露:YAML configRef、触发层 concurrency、构建层 max parallel、批次数、运行/排队 TaskRun 数、step/sidecar 资源合同、priority class、稳定意图键状态和核心保护摘要。共享读取超时只能投影为 warning 或 unavailable,不得把全部 consumer 伪装为真实失败。
`plan` 必须针对精确 source commit 在一次只读输出中披露:YAML configRef、env identity、env reuse/build 判定、待构建镜像数量与服务列表、待 rollout 服务、全部受影响服务、范围基线、新增范围、触发层 concurrency、构建层 max parallel、批次数、step/sidecar 资源合同、priority class 和核心保护摘要。`status``diagnosis` 必须披露运行/排队 TaskRun 数与稳定意图键状态。共享读取超时只能投影为 warning 或 unavailable,不得把全部 consumer 伪装为真实失败。
### 5.6 CI-RESOURCE-REQ-006 中心自动触发禁用
中心 PaC owning YAML 必须声明 `deliveryTrigger.mode=manual-plan-confirm``deliveryTrigger.mechanism=pac-webhook`。PaC webhook 接收端继续作为唯一 PipelineRun 创建入口,但 Gitea push hook、PR merge callback、branch follower、poller 和其他自动发送方不得调用它;PR merge 后只允许 source mirror 或 authority 更新。受控 apply 必须删除或禁用 Gitea 自动 hook,并在 status 中把自动发送方启用视为配置漂移。
## 6. 验收
- L0:配置解析、资源 quantity、requests/limits 关系、matrix 收敛、有界 worker 队列和依赖保持通过轻量验证。
- L1:受控 `plan/status/diagnosis` 显示 YAML 来源、并发预算、批次、资源和保护状态,不依赖裸 Kubernetes 命令。
- L2正常 HWLAB source PR merge 自动触发,禁止人工补 PipelineRun观察到同一时刻构建 TaskRun 不超过 YAML 预算,相同 source commit 的自动 poll 不产生第二次构建,所有构建 Pod 带资源合同,并且 NC01 Node、CoreDNS、Sub2API、Public Edge 和公开 API 在构建期间保持健康。
- L2:正常 HWLAB source PR merge 后不产生 PipelineRun。受控 `plan` 显示 env reuse、镜像构建数量、精确构建与 rollout 范围且没有非预期扩大;随后使用手动 `trigger` 向 PaC 发送一次 webhook,并由 PaC 创建一次 PipelineRun观察到同一时刻构建 TaskRun 不超过 YAML 预算,相同发布意图不产生第二次构建,所有构建 Pod 带资源合同,并且 NC01 Node、CoreDNS、Sub2API、Public Edge 和公开 API 在构建期间保持健康。
验收失败时保留自动链证据并修复 owning YAML 或 renderer。重启 k3s、删除 Pod/PipelineRun、手工 Argo sync、运行时 patch、mirror flush 或人工补跑均不能作为最终通过证据。
验收失败时保留计划、手动触发链和运行证据并修复 owning YAML、planner 或 renderer。重启 k3s、删除 Pod/PipelineRun、手工 Argo sync、运行时 patch、mirror flush 或补跑均不能作为最终通过证据。
@@ -152,9 +152,9 @@ sequenceDiagram
participant Argo
participant Runtime
CLI->>Target: resolve CI/CD target
CLI->>Mirror: read/sync/flush via controlled entry
Mirror-->>CLI: refs and pendingFlush summary
CLI->>Tekton: PipelineRun status or dry-run manifest
CLI->>Mirror: read source authority
Mirror-->>CLI: refs and source summary
CLI->>Tekton: PipelineRun status or read-only plan
Tekton-->>CLI: PipelineRun/TaskRun bounded summary
CLI->>Argo: Application sync/health status
Argo-->>Runtime: desired runtime state
@@ -173,15 +173,17 @@ sequenceDiagram
participant Mirror as git-mirror cache
participant Tekton
CLI->>Target: resolve node/lane/sourceAuthority
CLI->>Resolver: ensure or read source snapshot
Resolver->>Mirror: sync/read selected commit and create stageRef
Mirror-->>Resolver: selectedCommit + stageRef + objectPresent
Resolver-->>CLI: sourceSnapshot summary
CLI->>Tekton: create PipelineRun with sourceSnapshot
CLI->>Resolver: read selected commit and calculate plan
Resolver->>Mirror: read selected commit and object presence
Mirror-->>Resolver: selectedCommit + objectPresent
Resolver-->>CLI: env reuse + build scope
CLI->>PaC: manual webhook after plan review
Resolver->>Mirror: create immutable stageRef
PaC->>Tekton: create PipelineRun with sourceSnapshot
Tekton->>Mirror: fetch/checkout immutable stageRef or snapshot artifact
```
正式 CI/CD 的源码身份以 source snapshot 为准。`trigger-current`、image publish、GitOps render 和 closeout 不得再从 operator host 的 dirty/stale worktree、当前 branch tip 或 GitHub direct fetch 推导本轮 source identity。upstream fetch 只允许发生在受控 k8s git-mirror resolver/sync 组件内;PipelineRun 只消费 node-local mirror 的不可变 stageRef、PVC 或 bundle artifact。
正式 CI/CD 的源码身份以 source snapshot 为准。只读 `plan` 不得同步 mirror、创建 snapshot 或修改运行面;手动 `trigger` 在完成当前 plan 审阅后才可创建本轮不可变 snapshot。`trigger-current`、image publish、GitOps render 和 closeout 不得再从 operator host 的 dirty/stale worktree、当前 branch tip 或 GitHub direct fetch 推导本轮 source identity。upstream fetch 只允许发生在受控 k8s git-mirror resolver/sync 组件内;PipelineRun 只消费 node-local mirror 的不可变 stageRef、PVC 或 bundle artifact。
## 6. 原子需求
@@ -251,6 +253,18 @@ PipelineRun source step 不得以 mutable `refs/heads/<branch>` 作为本轮源
status/closeout 必须同时展示 selected snapshot、PipelineRun snapshot、latest mirror head、GitOps revision 和 runtime provenance。branch 在发布过程中前进只能标记为 `latest-drift``superseded`,不得把 selected commit 的成功发布误判为本轮失败。
### 6.11 OPS-TARGET-REQ-011 L2/L3手动触发配置
| 编号 | 短名 | 主责模块 | 关联模块 |
| --- | --- | --- | --- |
| OPS-TARGET-REQ-011 | 手动触发 | PJ2026-01060308 YAML目标治理 | [发布流水](PJ2026-010601-controlled-release.md)、[NC01 CI资源治理](PJ2026-01060107-nc01-ci-resource-governance.md) |
CI/CD owning YAML 必须为 L2/L3 target 声明 `deliveryTrigger.mode=manual-plan-confirm``deliveryTrigger.mechanism=pac-webhook`。这些字段是发布触发方式的唯一真相;PaC webhook 接收端保留,Gitea 自动 hook 必须移除。代码、环境变量、Repository annotation 和 consumer 私有配置不得恢复自动 push、PR merge、branch update、poll 或 follow 发送行为。
只读 `plan` 必须从 YAML 和精确 source commit 解析 env reuse、待构建镜像、rollout 服务、全部受影响服务、范围基线和新增范围。发现新增范围时必须先修复配置或 planner 后重新 plan,不得使用强制参数越过。
受控手动 `trigger` 必须同时接收 target、consumer 或 lane、精确 source commit 和 pipeline intent,并向 PaC 发送 webhookCLI 不得直接创建 PipelineRun。输入、YAML、env identity 或构建/rollout 范围变化后必须重新 plan。PR merge 后不自动 triggerL0/L1 操作也不得调用该 mutation。
### 6.8 OPS-TARGET-REQ-008 k3s server 重启门禁非阻塞
| 编号 | 短名 | 主责模块 | 关联模块 |
@@ -217,6 +217,9 @@ test("PaC apply checks the Gitea repository before dry-run return and cluster mu
expect(remote).toContain('"admission":{"applied":%s,"ready":%s}');
expect(remote).toContain('"admissionProvenance":%s');
expect(remote).toContain('"error":"gitea-repository-missing"');
expect(remote).toContain("remove_automatic_webhooks");
expect(remote).not.toContain("ensure_webhook");
expect(remote).not.toContain('"active":true');
const statusAction = remote.slice(remote.indexOf("status_action() {"), remote.indexOf("history_action() {"));
const bootstrapReadyAssignment = statusAction.split("\n").find((line) => line.includes("bootstrap_ready=$("));
expect(bootstrapReadyAssignment).toBeDefined();
@@ -178,7 +178,7 @@ ensure_token() {
printf '%s' "$out" | sed -n 's/.*"sha1"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p'
}
ensure_webhook() {
remove_automatic_webhooks() {
hooks=$(gitea_api GET "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks")
hook_ids=$(HOOKS_JSON="$hooks" node <<'NODE'
const data = JSON.parse(process.env.HOOKS_JSON || '[]');
@@ -186,23 +186,10 @@ const url = process.env.UNIDESK_PAC_WEBHOOK_URL;
for (const item of data) if (item?.config?.url === url && item?.id) console.log(item.id);
NODE
)
hook_id=$(printf '%s\n' "$hook_ids" | sed -n '1p')
body=$(printf '{"type":"gitea","config":{"url":"%s","content_type":"json","secret":"%s"},"events":["push"],"active":true}' "$UNIDESK_PAC_WEBHOOK_URL" "$UNIDESK_PAC_WEBHOOK_SECRET")
if [ -n "$hook_id" ]; then
if ! gitea_api PATCH "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks/$hook_id" "$body" >/dev/null; then
gitea_api DELETE "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks/$hook_id" >/dev/null || true
created=$(gitea_api POST "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks" "$body")
hook_id=$(printf '%s' "$created" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p')
fi
printf '%s\n' "$hook_ids" | sed -n '2,$p' | while IFS= read -r duplicate_id; do
[ -n "$duplicate_id" ] || continue
gitea_api DELETE "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks/$duplicate_id" >/dev/null || true
done
else
created=$(gitea_api POST "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks" "$body")
hook_id=$(printf '%s' "$created" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p')
fi
printf '%s' "$hook_id"
printf '%s\n' "$hook_ids" | while IFS= read -r hook_id; do
[ -n "$hook_id" ] || continue
gitea_api DELETE "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks/$hook_id" >/dev/null
done
}
repository_manifest() {
@@ -404,7 +391,8 @@ apply_action() {
kubectl -n "$UNIDESK_PAC_ARGO_NAMESPACE" annotate application "$UNIDESK_PAC_ARGO_APPLICATION" argocd.argoproj.io/refresh=hard --overwrite >/dev/null
argo_bootstrap=true
fi
hook_id=$(ensure_webhook)
remove_automatic_webhooks
hook_id=""
ready=false
if wait_ready; then ready=true; fi
prepare_admission_provenance_state
@@ -414,7 +402,8 @@ apply_action() {
fi
ok=false
if [ "$ready" = true ]; then ok=true; fi
printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"admission":{"applied":%s,"ready":%s},"admissionProvenance":%s,"argoBootstrap":%s,"preflight":{"repositoryExists":true,"httpStatus":"2xx","repository":"%s/%s","valuesPrinted":false},"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ok" "$ready" "$admission_applied" "$admission_ready" "$UNIDESK_PAC_ADMISSION_STATE_JSON" "$argo_bootstrap" "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" "$(json_string "$UNIDESK_PAC_GITEA_REPO")" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")"
webhook_present=false
printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"admission":{"applied":%s,"ready":%s},"admissionProvenance":%s,"argoBootstrap":%s,"preflight":{"repositoryExists":true,"httpStatus":"2xx","repository":"%s/%s","valuesPrinted":false},"webhook":{"present":%s,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ok" "$ready" "$admission_applied" "$admission_ready" "$UNIDESK_PAC_ADMISSION_STATE_JSON" "$argo_bootstrap" "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" "$(json_string "$UNIDESK_PAC_GITEA_REPO")" "$webhook_present" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")"
}
condition_status() {
@@ -113,6 +113,10 @@ export interface PacConfig {
timeZone: string;
};
deliveryTiming: PacDeliveryTimingPolicy;
deliveryTrigger: {
mode: "manual-plan-confirm";
mechanism: "pac-webhook";
};
observability: {
configRef: string;
readOnlyCapture: {
@@ -171,6 +175,7 @@ export interface PacConfig {
apiUsername: string;
};
webhook: {
enabled: false;
secretRoot: string;
secretSourceRef: string;
events: string[];
@@ -616,6 +621,7 @@ export function parsePacConfigDocument(
const gitea = y.objectField(root, "gitea", "");
const display = y.objectField(root, "display", "");
const deliveryTiming = y.objectField(root, "deliveryTiming", "");
const deliveryTrigger = y.objectField(root, "deliveryTrigger", "");
const observability = y.objectField(root, "observability", "");
const readOnlyCapture = y.objectField(observability, "readOnlyCapture", "observability");
const observabilitySampling = y.objectField(observability, "sampling", "observability");
@@ -655,6 +661,10 @@ export function parsePacConfigDocument(
endToEndBudgetSeconds: positiveInteger(deliveryTiming, "endToEndBudgetSeconds", "deliveryTiming"),
configPath: `${configLabel}#deliveryTiming.endToEndBudgetSeconds`,
},
deliveryTrigger: {
mode: y.enumField(deliveryTrigger, "mode", "deliveryTrigger", ["manual-plan-confirm"] as const),
mechanism: y.enumField(deliveryTrigger, "mechanism", "deliveryTrigger", ["pac-webhook"] as const),
},
observability: {
configRef: y.stringField(observability, "configRef", "observability"),
readOnlyCapture: {
@@ -717,6 +727,7 @@ export function parsePacConfigDocument(
apiUsername: y.stringField(admin, "apiUsername", "gitea.admin"),
},
webhook: {
enabled: parseFalse(webhook, "enabled", "gitea.webhook"),
secretRoot: y.absolutePathField(webhook, "secretRoot", "gitea.webhook"),
secretSourceRef: y.sourceRefField(webhook, "secretSourceRef", "gitea.webhook"),
events: y.stringArrayField(webhook, "events", "gitea.webhook"),
@@ -1254,7 +1265,7 @@ function validateConfig(
warnings.push(pacConfigWarning("pac-unselected-consumer-invalid", "consumer", consumer.id, `consumers.${consumer.id}`, error));
}
}
if (!config.gitea.webhook.events.includes("push")) throw new Error(`${configLabel}.gitea.webhook.events must include push`);
if (!config.gitea.webhook.events.includes("push")) throw new Error(`${configLabel}.gitea.webhook.events must retain push as the disabled legacy hook identity`);
validateTimeZone(config.display.timeZone, `${configLabel}.display.timeZone`);
return warnings;
}
@@ -1474,7 +1485,7 @@ function plan(options: CommonOptions): Record<string, unknown> {
consumer: consumerObservationSummary(consumer),
ciResourceGovernance: ciResourceGovernanceSummary(consumer, repository),
secrets,
policy: policyChecks(repository),
policy: policyChecks(pac, repository),
warnings: pac.validationWarnings,
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
};
@@ -1604,7 +1615,7 @@ async function status(config: UniDeskConfig, options: CommonOptions, collectArti
const next = nextCommands(target.id, consumer.id, pac.defaults.consumerId);
const observed = await capturePacReadOnly(config, pac, "status", target, consumer, remoteScript("status", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, "", [consumer], collectArtifactLogs), `${stringValue(next.status)} --full`);
const { result, parsed, observation } = observed;
const summary = parsed === null ? null : statusSummary(parsed);
const summary = parsed === null ? null : statusSummary(parsed, pac);
const ciResourceGovernance = ciResourceGovernanceSummary(
consumer,
repository,
@@ -2503,7 +2514,7 @@ function secretSummaries(pac: PacConfig): Array<Record<string, unknown>> {
];
}
function statusSummary(payload: Record<string, unknown>): Record<string, unknown> {
function statusSummary(payload: Record<string, unknown>, pac?: PacConfig): Record<string, unknown> {
const readError = stringValue(payload.error);
const pipelineRuns = arrayRecords(payload.pipelineRuns);
const latest = pipelineRuns[0] ?? {};
@@ -2522,6 +2533,8 @@ function statusSummary(payload: Record<string, unknown>): Record<string, unknown
const admissionProvenance = record(payload.admissionProvenance);
const consumerBootstrap = record(payload.consumerBootstrap);
const webhooks = arrayRecords(payload.webhooks);
const activeWebhooks = webhooks.filter((item) => item.active === true);
const automaticTriggerDrift = pac?.deliveryTrigger.mode === "manual-plan-confirm" && activeWebhooks.length > 0;
const rawArtifact = record(payload.artifact);
const sourceObservation = record(rawArtifact.sourceObservation);
const provenance = record(diagnostics.provenance);
@@ -2544,13 +2557,20 @@ function statusSummary(payload: Record<string, unknown>): Record<string, unknown
}
: pipelineRunGate(latest);
return {
ready: payload.crdPresent === true && String(payload.controllerReady ?? "0/0") !== "0/0" && pipelineGate.ok && diagnostics.ok !== false && consumerBootstrap.ready !== false,
ready: payload.crdPresent === true && String(payload.controllerReady ?? "0/0") !== "0/0" && pipelineGate.ok && diagnostics.ok !== false && consumerBootstrap.ready !== false && !automaticTriggerDrift,
crdPresent: payload.crdPresent === true,
controllerReady: payload.controllerReady,
repositoryCondition: payload.repositoryCondition,
repository: record(payload.repository),
webhooks,
webhookCount: webhooks.length,
automaticTrigger: {
mode: pac?.deliveryTrigger.mode ?? null,
enabled: activeWebhooks.length > 0,
drift: automaticTriggerDrift,
activeWebhookCount: activeWebhooks.length,
configPath: `${configLabel}#deliveryTrigger.mode`,
},
pipelineRuns,
latestPipelineRun: latest,
taskRuns,
@@ -2567,9 +2587,9 @@ function statusSummary(payload: Record<string, unknown>): Record<string, unknown
};
}
function policyChecks(repository: PacRepository): Array<Record<string, unknown>> {
function policyChecks(pac: PacConfig, repository: PacRepository): Array<Record<string, unknown>> {
return [
{ name: "single-path", ok: true, detail: "Gitea webhook -> Pipelines-as-Code -> Tekton -> Argo/k8s runtime; no Gitea Actions/act_runner/branch-follower fallback." },
{ name: "manual-trigger-only", ok: pac.deliveryTrigger.mode === "manual-plan-confirm" && pac.gitea.webhook.enabled === false, detail: "PR merge and push do not create PipelineRuns; the controlled plan/manual trigger path owns L2/L3 release." },
{ name: "yaml-source-of-truth", ok: true, detail: `${configLabel} owns PaC release, Repository CR, Gitea webhook and consumer params.` },
{ name: "public-repository-url", ok: !isInternalRepositoryMatchUrl(repository.url), detail: "Repository CR url matches the public Gitea webhook payload URL; internal service URLs remain clone/read URLs only." },
{ name: "gitea-internal-source", ok: repository.cloneUrl.includes(".svc.cluster.local"), detail: "Tekton source clone uses the internal k3s Gitea service URL." },
@@ -2598,7 +2618,8 @@ function configSummary(pac: PacConfig, consumer: PacConsumer, repository: PacRep
release: pac.release,
capabilities: capabilitySummary(pac),
deliveryTiming: pac.deliveryTiming,
gitea: { configRef: pac.gitea.configRef, internalBaseUrl: pac.gitea.internalBaseUrl, webhookBranch: pac.gitea.webhook.branch },
deliveryTrigger: pac.deliveryTrigger,
gitea: { configRef: pac.gitea.configRef, internalBaseUrl: pac.gitea.internalBaseUrl, webhookEnabled: pac.gitea.webhook.enabled, webhookBranch: pac.gitea.webhook.branch },
display: pac.display,
repositories: pac.repositories.map(repositorySummary),
consumers: pac.consumers.map(consumerObservationSummary),
@@ -2618,6 +2639,7 @@ function compactConfigSummary(pac: PacConfig, consumer: PacConsumer, repository:
sourceUrl: repository.cloneUrl,
displayTimeZone: pac.display.timeZone,
deliveryTiming: pac.deliveryTiming,
deliveryTrigger: pac.deliveryTrigger,
consumer: `${consumer.node}/${consumer.lane}`,
consumerId: consumer.id,
pipeline: consumer.pipeline,
@@ -3083,7 +3105,7 @@ function renderPlan(result: Record<string, unknown>): RenderedCliResult {
"PATH",
...table(["STAGE", "AUTHORITY", "DETAIL"], [
["source", "Gitea", stringValue(repository.cloneUrl)],
["trigger", "Pipelines-as-Code", "Gitea push webhook"],
["trigger", "Pipelines-as-Code", "controlled manual trigger after reviewed plan"],
["ci", "Tekton", stringValue(consumer.pipeline)],
["cd", "Argo", stringValue(consumer.argoApplication)],
["runtime evidence", "owning YAML", `${stringValue(deliveryObservation.runtimeEvidence)} (${stringValue(deliveryObservation.configPath)})`],