Merge pull request #2257 from pikasTech/fix/2039-pac-status-json-ready
修复 PaC 状态读取误判
This commit is contained in:
@@ -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>` 必须读取目标侧真实 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,
|
||||
|
||||
Reference in New Issue
Block a user