Merge pull request #2257 from pikasTech/fix/2039-pac-status-json-ready
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success

修复 PaC 状态读取误判
This commit is contained in:
Lyon
2026-07-16 09:46:35 +08:00
committed by GitHub
6 changed files with 60 additions and 22 deletions
+3 -1
View File
@@ -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 做服务端 selectorTaskRun 与 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>` 必须读取目标侧真实 PipelineRun、TaskRun condition/result 与合同 Task 日志:
@@ -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 入口负责慢日志证据。
@@ -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");
@@ -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");
@@ -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
@@ -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<Record<string, unknown>> {
}
function statusSummary(payload: Record<string, unknown>): Record<string, unknown> {
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<string, unknown>): Record<string, unknown
gitopsCommit: noRuntimeChange ? provenance.gitopsRevision ?? argo.revision : rawArtifact.gitopsCommit ?? argo.revision,
provenanceRelation: noRuntimeChange ? "retained" : record(argo.revisionRelation).relation,
};
const pipelineGate = pipelineRunGate(latest);
const pipelineGate = readError === "pac-pipelinerun-read-failed"
? {
ok: false,
code: readError,
reason: "PipelineRun read failed; absence was not established",
valuesPrinted: false,
}
: pipelineRunGate(latest);
return {
ready: payload.crdPresent === true && String(payload.controllerReady ?? "0/0") !== "0/0" && pipelineGate.ok && diagnostics.ok !== false && consumerBootstrap.ready !== false,
crdPresent: payload.crdPresent === true,