From 6512fc84d6fd4c130c4e646054aeb93429b83871 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 03:44:57 +0200 Subject: [PATCH] =?UTF-8?q?fix(cicd):=20=E6=B6=88=E9=99=A4=20PaC=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E8=AF=BB=E5=8F=96=E8=AF=AF=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/unidesk-cicd/SKILL.md | 4 ++- .../references/incident-recovery.md | 12 +++++-- .../src/platform-infra-pac-provenance.test.ts | 4 +-- ...-infra-pipelines-as-code-bootstrap.test.ts | 7 ++++ ...platform-infra-pipelines-as-code-remote.sh | 33 +++++++++++-------- .../src/platform-infra-pipelines-as-code.ts | 22 +++++++++++-- 6 files changed, 60 insertions(+), 22 deletions(-) diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index d98e093d..c2ca6950 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -172,7 +172,9 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - PaC `status` 必须在 `observability.readOnlyCapture.timeoutMs` 内完成: - 目标侧通过 `pac-status-progress` 披露最后阶段,CLI 将其投影为 `observation.remoteStage`; - PipelineRun 按 YAML 选中的 Repository CR label 做服务端 selector;TaskRun 与 Argo 原始对象各只读取一次并复用,禁止 status 为同一 run 串行读取每个 Pod 日志或重复读取 Argo; - - 单次 Kubernetes/Gitea/registry/runtime 读取上限必须从 YAML 总 capture 预算派生;慢调用返回 unknown/false 和阶段证据,不得吞完整体预算; + - YAML 总 capture 预算由调用侧作为整次观察的硬上限;单次读取上限可以使用同一预算,禁止按预估调用次数切分成更短的隐藏上限; + - PipelineRun 读取 timeout 或失败必须返回 `pac-pipelinerun-read-failed`,不得改写为空数组并误报 `pipeline-missing`; + - default ServiceAccount 的 Tekton mutation provenance 只执行一次 `kubectl auth can-i --list` 并在目标侧聚合,禁止串行枚举 verb/resource; - status 的 TaskRun 日志证据模式固定为 `taskrun-status-only`,需要日志时使用 id-specific 下钻; - 不得通过提高 YAML timeout 掩盖重复采集、同步日志或无界远端查询。 - PaC `debug-step --id ` 必须读取目标侧真实 PipelineRun、TaskRun condition/result 与合同 Task 日志: diff --git a/.agents/skills/unidesk-cicd/references/incident-recovery.md b/.agents/skills/unidesk-cicd/references/incident-recovery.md index 7e79d384..1316d090 100644 --- a/.agents/skills/unidesk-cicd/references/incident-recovery.md +++ b/.agents/skills/unidesk-cicd/references/incident-recovery.md @@ -138,14 +138,20 @@ - 首个修复 PR 合并后,canary 又在 `remoteStage=argo` 间歇超过 `25000ms`; - 给 PipelineRun 查询直接设置过短的单次上限会返回空列表并制造 `pipeline-missing` false negative,不能以“命令不再超时”冒充恢复; - 最终按 Repository CR label 在服务端筛选 PipelineRun,同时保留 consumer classifier 区分共享 repository lanes; - - Kubernetes、Gitea、registry 和 runtime 的单次读取上限从 YAML 总预算派生;Argo 原始 JSON 只读一次并由 Argo 摘要与 runtime 共同复用; - - 阶段事件同时输出 elapsed 与 step timeout,连续两轮 canary 分别在 `16.3s` 与 `17.9s` 完成且保持 `ok=true`。 + - PR `#2256` 把 `25000ms` 总预算按 `/8000` 派生为 `3s` 单次上限,合并后再次把超时的 PipelineRun 查询改写为空数组并误报 `pipeline-missing`; + - 同时,admission provenance 为 default ServiceAccount 串行执行十次 `kubectl auth can-i`,累计占用总预算; + - 恢复时把十次权限探测收敛为一次 `kubectl auth can-i --list`,并在目标侧聚合 PipelineRun/TaskRun mutation verbs; + - 精确回退 `/8000` 错误切分,单次读取上限恢复为 YAML 总预算本身,整次 status 仍由调用侧 `25000ms` 硬上限约束; + - PipelineRun 读取失败不再改写为空数组,而是返回 `pac-pipelinerun-read-failed`,明确表示缺失尚未成立; + - Argo 原始 JSON 仍只读一次并由 Argo 摘要与 runtime 共同复用;阶段事件继续输出 elapsed 与 step timeout; + - 最终补丁连续两轮 canary 分别在 `15.7s` 与 `11.3s` 完成且保持 `ok=true`。 - 验收结论: - - 原 `status --target NC01 --consumer selfmedia-nc01 --json` 在 `14.5s` 内返回合法 JSON; + - 原 `status --target NC01 --consumer selfmedia-nc01 --json` 稳定在 YAML `25000ms` 预算内返回合法 JSON; - 输出 `ok=true`、`remoteStage=complete`、PipelineRun `Succeeded`、Argo `Synced/Healthy`、runtime `1/1`; - 全程未修改 SelfMedia 私有 YAML,未人工执行 PipelineRun、mirror、trigger、sync、flush、Argo sync 或 runtime patch。 - 长期结论: - 先区分 delivery failure 与 observer failure; - 修复共享 renderer 必须取得公共服务面授权; - 先恢复最后已知正确语义,再删除重复、无界和同步日志读取; + - 总预算不得按假定调用数切成会制造 false negative 的局部超时,读取失败也不得伪装成对象不存在; - status 负责快速结构化摘要,id-specific 入口负责慢日志证据。 diff --git a/scripts/src/platform-infra-pac-provenance.test.ts b/scripts/src/platform-infra-pac-provenance.test.ts index ffd5f403..d3e057c9 100644 --- a/scripts/src/platform-infra-pac-provenance.test.ts +++ b/scripts/src/platform-infra-pac-provenance.test.ts @@ -396,8 +396,8 @@ test("history evaluates admission and default RBAC in each consumer namespace", expect(remote).toContain("kubectlJson(['-n', consumer.namespace, 'get', 'role', 'unidesk-pac-consumer-runner'"); expect(remote).toContain("kubectlJson(['-n', consumer.namespace, 'get', 'rolebinding', 'unidesk-pac-consumer-runner-default'"); expect(remote).toContain("defaultCanMutate(consumer.namespace)"); - expect(remote).toContain("for verb in create patch update delete deletecollection; do"); - expect(remote).toContain('timeout "$UNIDESK_PAC_WAIT_TIMEOUT_SECONDS" kubectl auth can-i'); + expect(remote).toContain("kubectl auth can-i --list"); + expect(remote).not.toContain("for verb in create patch update delete deletecollection; do"); expect(remote).toContain("for (const verb of ['create', 'patch', 'update', 'delete', 'deletecollection'])"); const defaultMutationProbe = remote.slice(remote.indexOf("function defaultCanMutate(namespace)"), remote.indexOf("function admissionStateForConsumer(consumer)")); expect(defaultMutationProbe).toContain("timeout: waitTimeoutSeconds * 1000"); 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 b68ad367..4b15926f 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -201,6 +201,7 @@ test("Gitea mirror bootstrap defaults to a visible dry-run instead of an empty r test("PaC apply checks the Gitea repository before dry-run return and cluster mutation", () => { const remote = readFileSync(resolve(import.meta.dir, "platform-infra-pipelines-as-code-remote.sh"), "utf8"); + const source = readFileSync(resolve(import.meta.dir, "platform-infra-pipelines-as-code.ts"), "utf8"); const action = remote.indexOf("apply_action() {"); const preflight = remote.indexOf("if ! repository_preflight", action); const dryRun = remote.indexOf('if [ "$UNIDESK_PAC_DRY_RUN" = "1" ]', action); @@ -224,6 +225,12 @@ test("PaC apply checks the Gitea repository before dry-run return and cluster mu expect(remote).toContain('pac_read kubectl'); expect(remote).toContain('export UNIDESK_PAC_ARGO_FILE="$argo_file"'); expect(remote).toContain('get pipelinerun -l "pipelinesascode.tekton.dev/repository=$UNIDESK_PAC_REPOSITORY_NAME"'); + expect(remote).toContain('"error":"pac-pipelinerun-read-failed"'); + expect(remote).not.toContain('get pipelinerun -l "pipelinesascode.tekton.dev/repository=$UNIDESK_PAC_REPOSITORY_NAME" -o json >"$payload_file" 2>/dev/null ||'); + expect(remote).toContain('kubectl auth can-i --list'); + expect(remote).not.toContain('for verb in create patch update delete deletecollection'); + expect(source).toContain("Math.ceil(pac.observability.readOnlyCapture.timeoutMs / 1_000)"); + expect(source).not.toContain("Math.floor(pac.observability.readOnlyCapture.timeoutMs / 8_000)"); expect(statusAction).toContain("status_progress artifact-evidence"); expect(statusAction).toContain("status_progress diagnostics"); expect(statusAction).toContain("status_progress complete"); diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 70959e9e..390dcc45 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -400,17 +400,12 @@ admission_provenance_state() { pac_read kubectl get validatingadmissionpolicybinding "$UNIDESK_PAC_ADMISSION_BINDING_NAME" -o json >"$binding_file" 2>/dev/null || printf '{}' >"$binding_file" pac_read kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get role unidesk-pac-consumer-runner -o json >"$role_file" 2>/dev/null || printf '{}' >"$role_file" pac_read kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get rolebinding unidesk-pac-consumer-runner-default -o json >"$role_binding_file" 2>/dev/null || printf '{}' >"$role_binding_file" - default_can_mutate=false - for verb in create patch update delete deletecollection; do - for resource in pipelineruns.tekton.dev taskruns.tekton.dev; do - if answer=$(pac_read kubectl auth can-i --as="system:serviceaccount:$UNIDESK_PAC_TARGET_NAMESPACE:default" "$verb" "$resource" -n "$UNIDESK_PAC_TARGET_NAMESPACE" 2>/dev/null); then :; else :; fi - case "$answer" in - yes) default_can_mutate=true ;; - no) ;; - *) [ "$default_can_mutate" = true ] || default_can_mutate=unknown ;; - esac - done - done + default_rules=$(pac_read kubectl auth can-i --list --as="system:serviceaccount:$UNIDESK_PAC_TARGET_NAMESPACE:default" -n "$UNIDESK_PAC_TARGET_NAMESPACE" 2>/dev/null || true) + default_can_mutate=unknown + if [ -n "$default_rules" ]; then + default_can_mutate=false + if printf '%s\n' "$default_rules" | awk '$1 == "pipelineruns.tekton.dev" || $1 == "taskruns.tekton.dev" { if ($0 ~ /(create|patch|update|delete)/) found=1 } END { exit found ? 0 : 1 }'; then default_can_mutate=true; fi + fi export UNIDESK_PAC_DEFAULT_CAN_MUTATE="$default_can_mutate" node - "$policy_file" "$binding_file" "$role_file" "$role_binding_file" <<'NODE' const fs = require('node:fs'); @@ -446,7 +441,10 @@ prepare_admission_provenance_state() { pipeline_rows() { payload_file=$(mktemp) - pac_read kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get pipelinerun -l "pipelinesascode.tekton.dev/repository=$UNIDESK_PAC_REPOSITORY_NAME" -o json >"$payload_file" 2>/dev/null || printf '{"items":[]}' >"$payload_file" + if ! pac_read kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get pipelinerun -l "pipelinesascode.tekton.dev/repository=$UNIDESK_PAC_REPOSITORY_NAME" -o json >"$payload_file" 2>/dev/null; then + rm -f "$payload_file" + return 1 + fi node - "$payload_file" <<'NODE' const fs = require('node:fs'); const { classifyPacPipelineRun, pipelineRunMatchesConsumer } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH); @@ -1701,7 +1699,16 @@ status_action() { consumer_bootstrap=$(consumer_bootstrap_state) bootstrap_ready=$(UNIDESK_PAC_BOOTSTRAP_STATE="$consumer_bootstrap" node -e 'const s=JSON.parse(process.env.UNIDESK_PAC_BOOTSTRAP_STATE||"{}"); process.stdout.write(s.configured===true&&s.ready!==true?"false":"true")') status_progress pipeline-runs - pipelines=$(pipeline_rows) + if ! pipelines=$(pipeline_rows); then + status_progress pipeline-runs-read-failed + printf '{"ok":false,"action":"platform-infra-pipelines-as-code-status","mutation":false,"crdPresent":%s,"controllerReady":"%s","repositoryCondition":"%s","admissionProvenance":%s,"consumerBootstrap":%s,"error":"pac-pipelinerun-read-failed","errorStage":"pipeline-runs","valuesPrinted":false}\n' \ + "$([ -n "$crd" ] && echo true || echo false)" \ + "$(json_string "$controller_ready")" \ + "$(json_string "$repository_condition")" \ + "$UNIDESK_PAC_ADMISSION_STATE_JSON" \ + "$consumer_bootstrap" + return 1 + fi latest=$(printf '%s' "$pipelines" | node -e 'const fs=require("fs"); const a=JSON.parse(fs.readFileSync(0,"utf8")||"[]"); process.stdout.write(a[0]?.name||"")') export UNIDESK_PAC_TARGET_PIPELINERUN="$latest" status_progress task-runs diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 4f7f6592..42e2bd93 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -2059,7 +2059,7 @@ function remoteScript(action: "apply" | "status" | "history" | "diagnose-regress UNIDESK_PAC_RELEASE_MANIFEST_B64: Buffer.from(releaseManifest, "utf8").toString("base64"), UNIDESK_PAC_CONTROLLER_SERVICE_NAME: pac.release.controllerServiceName, UNIDESK_PAC_WAIT_TIMEOUT_SECONDS: String(pac.release.waitTimeoutSeconds), - UNIDESK_PAC_READ_STEP_TIMEOUT_SECONDS: String(Math.max(1, Math.floor(pac.observability.readOnlyCapture.timeoutMs / 8_000))), + UNIDESK_PAC_READ_STEP_TIMEOUT_SECONDS: String(Math.max(1, Math.ceil(pac.observability.readOnlyCapture.timeoutMs / 1_000))), UNIDESK_PAC_DRY_RUN: "dryRun" in options && options.dryRun ? "1" : "0", UNIDESK_PAC_GITEA_BASE_URL: pac.gitea.internalBaseUrl.replace(/\/+$/u, ""), UNIDESK_PAC_GITEA_ADMIN_USERNAME: secrets.adminUsername, @@ -2304,12 +2304,21 @@ function secretSummaries(pac: PacConfig): Array> { } function statusSummary(payload: Record): Record { + const readError = stringValue(payload.error); const pipelineRuns = arrayRecords(payload.pipelineRuns); const latest = pipelineRuns[0] ?? {}; const taskRuns = arrayRecords(payload.taskRuns); const argo = record(payload.argo); const runtime = record(payload.runtime); - const diagnostics = record(payload.diagnostics); + const diagnostics = readError === "pac-pipelinerun-read-failed" + ? { + ok: false, + code: readError, + phase: stringValue(payload.errorStage, "pipeline-runs"), + hint: "PipelineRun read failed; retry the read-only status command or inspect the target control plane", + valuesPrinted: false, + } + : record(payload.diagnostics); const admissionProvenance = record(payload.admissionProvenance); const consumerBootstrap = record(payload.consumerBootstrap); const webhooks = arrayRecords(payload.webhooks); @@ -2326,7 +2335,14 @@ function statusSummary(payload: Record): Record