From e73ea83760d6dc6b1d957306c7570cb8bb7242b6 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 10 Jul 2026 05:26:28 +0200 Subject: [PATCH] fix: recognize descendant gitops revisions --- config/platform-infra/pipelines-as-code.yaml | 1 + scripts/src/cicd-node-status.ts | 3 +- ...platform-infra-pipelines-as-code-remote.sh | 197 ++++++++++++++++-- .../src/platform-infra-pipelines-as-code.ts | 22 +- 4 files changed, 201 insertions(+), 22 deletions(-) diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 488efe0d..9b8687b6 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -9,6 +9,7 @@ metadata: - 1555 - 1557 - 1560 + - 1649 defaults: targetId: NC01 consumerId: hwlab-nc01-v03 diff --git a/scripts/src/cicd-node-status.ts b/scripts/src/cicd-node-status.ts index 779e958c..1ef5b6b4 100644 --- a/scripts/src/cicd-node-status.ts +++ b/scripts/src/cicd-node-status.ts @@ -88,6 +88,7 @@ function renderNodeStatus(result: Record, full: boolean): Rende short(stringValue(row.sourceCommit)), short(stringValue(row.gitopsCommit)), `${stringValue(record(row.argo).sync)}/${stringValue(record(row.argo).health)}`, + stringValue(record(record(row.argo).revisionRelation).relation), runtimeText(record(row.runtime)), stringValue(row.reason), ]); @@ -110,7 +111,7 @@ function renderNodeStatus(result: Record, full: boolean): Rende ]]), "", "CONSUMERS", - ...(rows.length === 0 ? ["-"] : table(["CONSUMER", "READY", "PIPELINE", "DUR", "SOURCE", "GITOPS", "ARGO", "RUNTIME", "REASON"], rows)), + ...(rows.length === 0 ? ["-"] : table(["CONSUMER", "READY", "PIPELINE", "DUR", "SOURCE", "GITOPS", "ARGO", "RELATION", "RUNTIME", "REASON"], rows)), "", ...(full ? [ "DETAIL", diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index e657a263..a4f58e0b 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -693,6 +693,10 @@ for (const line of lines) { } const publish = [...records].reverse().find((item) => item.phase === 'gitops-publish' || item.gitopsCommit); const image = publish || [...records].reverse().find((item) => item.imageStatus || item.status === 'reused' || item.status === 'built'); +const gitopsCommit = image?.gitopsCommit + || [...lines].reverse().map((line) => line.match(/^\[[^\]]+\s+([0-9a-f]{7,64})\]/u)?.[1] || null).find(Boolean) + || null; +const loggedDigests = [...new Set(lines.flatMap((line) => [...line.matchAll(/"(?:digest|environmentDigest)"\s*:\s*"(sha256:[0-9a-f]{64})"/gu)].map((match) => match[1])))]; const envHeaderIndex = lines.findIndex((line) => /^ENV_REUSE\s+NODE_DEPS\s+/u.test(line.trim())); let humanEnv = null; if (envHeaderIndex >= 0) { @@ -719,22 +723,24 @@ if (image) { envReuse: envReuseOf(image), nodeDepsReuse: null, buildCache: null, - digest: digestOf(image), - gitopsCommit: image.gitopsCommit || null, + digest: digestOf(image) || (loggedDigests.length === 1 ? loggedDigests[0] : null), + digests: loggedDigests, + gitopsCommit, sourceCommit: image.sourceCommit || null, valuesPrinted: false, }; -} else if (humanEnv) { +} else if (humanEnv || gitopsCommit || loggedDigests.length > 0) { out = { - imageStatus: 'published', + imageStatus: humanEnv ? 'published' : null, envIdentity: null, - envReuse: humanEnv.envReuse, - nodeDepsReuse: humanEnv.nodeDeps, - buildCache: humanEnv.cache, - buildPackage: humanEnv.buildPackage, - buildNetwork: humanEnv.buildNetwork, - digest: null, - gitopsCommit: null, + envReuse: humanEnv?.envReuse || null, + nodeDepsReuse: humanEnv?.nodeDeps || null, + buildCache: humanEnv?.cache || null, + buildPackage: humanEnv?.buildPackage || null, + buildNetwork: humanEnv?.buildNetwork || null, + digest: loggedDigests.length === 1 ? loggedDigests[0] : null, + digests: loggedDigests, + gitopsCommit, sourceCommit: null, valuesPrinted: false, }; @@ -795,6 +801,124 @@ NODE rm -f "$deploy_file" } +resolve_git_service_url() { + repo_url="$1" + host=$(UNIDESK_PAC_GIT_URL="$repo_url" node <<'NODE' +try { + process.stdout.write(new URL(process.env.UNIDESK_PAC_GIT_URL || '').hostname); +} catch {} +NODE +) + case "$host" in + *.svc.cluster.local) + service=${host%%.*} + rest=${host#*.} + namespace=${rest%%.*} + cluster_ip=$(kubectl -n "$namespace" get svc "$service" -o jsonpath='{.spec.clusterIP}' 2>/dev/null || true) + if [ -z "$cluster_ip" ]; then + return 1 + fi + UNIDESK_PAC_GIT_URL="$repo_url" UNIDESK_PAC_GIT_CLUSTER_IP="$cluster_ip" node <<'NODE' +try { + const url = new URL(process.env.UNIDESK_PAC_GIT_URL || ''); + url.hostname = process.env.UNIDESK_PAC_GIT_CLUSTER_IP || ''; + process.stdout.write(url.toString()); +} catch { + process.exit(1); +} +NODE + ;; + *) printf '%s' "$repo_url" ;; + esac +} + +gitops_revision_relation() { + artifact_json="${1:-}" + argo_json="${2:-}" + [ -n "$artifact_json" ] || artifact_json='{}' + [ -n "$argo_json" ] || argo_json='{}' + fields=$(UNIDESK_PAC_ARTIFACT_JSON="$artifact_json" UNIDESK_PAC_ARGO_JSON="$argo_json" node <<'NODE' +function parse(value) { + try { return JSON.parse(value || '{}'); } catch { return {}; } +} +function line(key, value) { + process.stdout.write(`${key}=${Buffer.from(String(value || ''), 'utf8').toString('base64')}\n`); +} +const artifact = parse(process.env.UNIDESK_PAC_ARTIFACT_JSON); +const argo = parse(process.env.UNIDESK_PAC_ARGO_JSON); +line('expected', artifact.gitopsCommit); +line('observed', argo.revision); +line('repoUrl', argo.repoURL); +line('targetRevision', argo.targetRevision); +NODE +) + expected=$(printf '%s\n' "$fields" | sed -n 's/^expected=//p' | base64 -d 2>/dev/null || true) + observed=$(printf '%s\n' "$fields" | sed -n 's/^observed=//p' | base64 -d 2>/dev/null || true) + repo_url=$(printf '%s\n' "$fields" | sed -n 's/^repoUrl=//p' | base64 -d 2>/dev/null || true) + target_revision=$(printf '%s\n' "$fields" | sed -n 's/^targetRevision=//p' | base64 -d 2>/dev/null || true) + relation=unknown + proof=unavailable + reason=missing-revision-context + fetch_ok=false + if printf '%s\n%s\n' "$expected" "$observed" | grep -Eqv '^[0-9a-fA-F]{7,64}$'; then + reason=invalid-or-missing-revision + elif [ -z "$repo_url" ] \ + || ! git check-ref-format "refs/heads/$target_revision" >/dev/null 2>&1; then + reason=invalid-or-missing-git-context + else + repo_dir=$(mktemp -d) + if resolved_url=$(resolve_git_service_url "$repo_url") \ + && git init --bare -q "$repo_dir/repo.git" \ + && timeout "$UNIDESK_PAC_WAIT_TIMEOUT_SECONDS" git --git-dir="$repo_dir/repo.git" fetch -q --no-tags "$resolved_url" "+refs/heads/$target_revision:refs/remotes/origin/$target_revision" >/dev/null 2>&1; then + fetch_ok=true + if git --git-dir="$repo_dir/repo.git" cat-file -e "$expected^{commit}" 2>/dev/null \ + && git --git-dir="$repo_dir/repo.git" cat-file -e "$observed^{commit}" 2>/dev/null; then + proof=owning-git-commit-graph + expected=$(git --git-dir="$repo_dir/repo.git" rev-parse "$expected^{commit}") + observed=$(git --git-dir="$repo_dir/repo.git" rev-parse "$observed^{commit}") + if [ "$expected" = "$observed" ]; then + relation=exact + reason=commits-equal + elif git --git-dir="$repo_dir/repo.git" merge-base --is-ancestor "$expected" "$observed" >/dev/null 2>&1; then + relation=descendant + reason=observed-descends-from-selected + elif git --git-dir="$repo_dir/repo.git" merge-base --is-ancestor "$observed" "$expected" >/dev/null 2>&1; then + relation=stale + reason=observed-precedes-selected + else + relation=diverged + reason=commits-have-diverged + fi + else + reason=commit-object-missing + fi + else + reason=owning-git-fetch-failed + fi + rm -rf "$repo_dir" + fi + UNIDESK_PAC_GITOPS_EXPECTED="$expected" \ + UNIDESK_PAC_GITOPS_OBSERVED="$observed" \ + UNIDESK_PAC_GITOPS_REPO_URL="$repo_url" \ + UNIDESK_PAC_GITOPS_TARGET_REVISION="$target_revision" \ + UNIDESK_PAC_GITOPS_RELATION="$relation" \ + UNIDESK_PAC_GITOPS_PROOF="$proof" \ + UNIDESK_PAC_GITOPS_REASON="$reason" \ + UNIDESK_PAC_GITOPS_FETCH_OK="$fetch_ok" node <<'NODE' +process.stdout.write(JSON.stringify({ + relation: process.env.UNIDESK_PAC_GITOPS_RELATION || 'unknown', + expectedRevision: process.env.UNIDESK_PAC_GITOPS_EXPECTED || null, + observedRevision: process.env.UNIDESK_PAC_GITOPS_OBSERVED || null, + repoURL: process.env.UNIDESK_PAC_GITOPS_REPO_URL || null, + targetRevision: process.env.UNIDESK_PAC_GITOPS_TARGET_REVISION || null, + proof: process.env.UNIDESK_PAC_GITOPS_PROOF || 'unavailable', + reason: process.env.UNIDESK_PAC_GITOPS_REASON || null, + fetchOk: process.env.UNIDESK_PAC_GITOPS_FETCH_OK === 'true', + valuesPrinted: false, +})); +NODE +} + cicd_diagnostics() { params_file=$(mktemp) pipelines_file=$(mktemp) @@ -926,12 +1050,17 @@ const expectedDigest = registryDigest || artifactDigest; const runtimeImage = runtime.image || null; const runtimeDigest = runtime.digest || (typeof runtimeImage === 'string' && runtimeImage.includes('@') ? runtimeImage.split('@').slice(1).join('@') : null); const runtimeMatches = !expectedDigest || runtimeDigest === expectedDigest; +const runtimeReady = Number.isInteger(runtime.replicas) && runtime.replicas > 0 && runtime.readyReplicas === runtime.replicas; +const selectedArtifactDigests = [...new Set([artifactDigest, ...(Array.isArray(artifact.digests) ? artifact.digests : [])].filter(Boolean))]; +const selectedArtifactMatchesRuntime = Boolean(runtimeDigest && selectedArtifactDigests.includes(runtimeDigest)); const registryMatchesArtifact = !registryDigest || !artifactDigest || registryDigest === artifactDigest; const healthUrl = process.env.UNIDESK_PAC_DIAG_HEALTH_URL || null; const healthStatus = process.env.UNIDESK_PAC_DIAG_HEALTH_STATUS || null; const healthReady = !healthUrl || healthStatus === '200'; -const gitopsReady = Boolean(artifact.gitopsCommit || argo.revision); -const gitopsRevisionMatches = !artifact.gitopsCommit || artifact.gitopsCommit === argo.revision; +const revisionRelation = argo.revisionRelation || { relation: 'unknown' }; +const gitopsRelation = revisionRelation.relation || 'unknown'; +const gitopsReady = Boolean(artifact.gitopsCommit && argo.revision); +const gitopsRevisionAligned = gitopsRelation === 'exact' || gitopsRelation === 'descendant'; const deliveryDisabled = artifact.imageStatus === 'disabled'; let code = 'pac-diagnostics-not-applicable'; let phase = 'not-applicable'; @@ -949,14 +1078,22 @@ if (deliveryDisabled) { ok = false; code = 'pac-gitops-missing'; phase = 'artifact-ready-gitops-missing'; hint = 'PipelineRun completed but its GitOps commit is not visible'; } else if (argo.sync !== 'Synced' || argo.health !== 'Healthy') { ok = false; code = 'pac-argo-not-ready'; phase = 'gitops-ready-argo-pending'; hint = 'GitOps exists but Argo is not Synced/Healthy'; -} else if (!gitopsRevisionMatches) { - ok = false; code = 'pac-argo-revision-not-aligned'; phase = 'gitops-ready-argo-revision-mismatch'; hint = 'Argo has not observed the GitOps commit produced by the selected PipelineRun'; +} else if (!gitopsRevisionAligned) { + ok = false; code = `pac-argo-revision-${gitopsRelation}`; phase = `gitops-ready-argo-revision-${gitopsRelation}`; hint = `Argo revision relation is ${gitopsRelation}; only exact or commit-graph-proven descendant is deployable`; +} else if (gitopsRelation === 'descendant' && !selectedArtifactMatchesRuntime) { + ok = false; code = 'pac-descendant-artifact-runtime-mismatch'; phase = 'argo-descendant-runtime-mismatch'; hint = 'a GitOps descendant is ready only when the selected PipelineRun artifact digest exactly matches the runtime digest'; } else if (!runtimeMatches) { ok = false; code = 'pac-runtime-not-aligned'; phase = 'argo-ready-runtime-mismatch'; hint = 'runtime image digest does not match the artifact digest produced by the selected PipelineRun'; +} else if (!runtimeReady) { + ok = false; code = 'pac-runtime-not-ready'; phase = 'runtime-replicas-not-ready'; hint = 'runtime ready replicas do not match the desired replica count'; } else if (!healthReady) { ok = false; code = 'pac-health-not-ready'; phase = 'runtime-ready-health-pending'; hint = 'runtime image is aligned but the configured health endpoint is not ready'; -} else if (artifactDigest || process.env.UNIDESK_PAC_DIAG_IMAGE_REPOSITORY) { - code = 'pac-ready'; phase = 'ready'; hint = 'source, artifact, GitOps, Argo and runtime are aligned'; +} else if (artifactDigest || selectedArtifactDigests.length > 0 || process.env.UNIDESK_PAC_DIAG_IMAGE_REPOSITORY) { + code = gitopsRelation === 'descendant' ? 'pac-ready-gitops-descendant' : 'pac-ready-gitops-exact'; + phase = 'ready'; + hint = gitopsRelation === 'descendant' + ? 'Argo is on a commit-graph-proven GitOps descendant and the selected artifact, runtime, Argo and health are aligned' + : 'Argo is on the exact selected GitOps commit and the artifact, runtime and health are aligned'; } process.stdout.write(JSON.stringify({ ok, @@ -972,12 +1109,21 @@ process.stdout.write(JSON.stringify({ digest: registryDigest, url: process.env.UNIDESK_PAC_DIAG_REGISTRY_URL || null, }, + selectedArtifactDigests, gitops: { branch: process.env.UNIDESK_PAC_DIAG_GITOPS_BRANCH || null, manifestPath: process.env.UNIDESK_PAC_DIAG_GITOPS_MANIFEST_PATH || null, commit: artifact.gitopsCommit || argo.revision || null, + revisionRelation, + }, + argo: { + sync: argo.sync || null, + health: argo.health || null, + revision: argo.revision || null, + repoURL: argo.repoURL || null, + targetRevision: argo.targetRevision || null, + revisionRelation, }, - argo: { sync: argo.sync || null, health: argo.health || null, revision: argo.revision || null }, runtime: { image: runtimeImage, digest: runtimeDigest, readyReplicas: runtime.readyReplicas ?? null, replicas: runtime.replicas ?? null }, health: { url: healthUrl, status: healthStatus, ready: healthReady }, pipelineRun: latest.name || null, @@ -1008,8 +1154,21 @@ status_action() { tasks=$(task_rows) artifact=$(json_normalize "$(artifact_summary)") hooks=$(hook_summary) - argo=$(kubectl -n "$UNIDESK_PAC_ARGO_NAMESPACE" get application "$UNIDESK_PAC_ARGO_APPLICATION" -o json 2>/dev/null | node -e 'const fs=require("fs"); const s=fs.readFileSync(0,"utf8").trim(); if(!s){process.stdout.write("{}"); process.exit(0)} const a=JSON.parse(s); process.stdout.write(JSON.stringify({sync:a.status?.sync?.status||null, health:a.status?.health?.status||null, revision:a.status?.sync?.revision||null}))' || echo '{}') + argo=$(kubectl -n "$UNIDESK_PAC_ARGO_NAMESPACE" get application "$UNIDESK_PAC_ARGO_APPLICATION" -o json 2>/dev/null | node -e 'const fs=require("fs"); const s=fs.readFileSync(0,"utf8").trim(); if(!s){process.stdout.write("{}"); process.exit(0)} const a=JSON.parse(s); const source=a.spec?.source || a.spec?.sources?.[0] || {}; let repoURL=source.repoURL||null; try { const url=new URL(repoURL); url.username=""; url.password=""; repoURL=url.toString(); } catch {} process.stdout.write(JSON.stringify({sync:a.status?.sync?.status||null, health:a.status?.health?.status||null, revision:a.status?.sync?.revision||null, repoURL, targetRevision:source.targetRevision||null}))' || echo '{}') argo=$(json_normalize "$argo") + revision_relation=$(gitops_revision_relation "$artifact" "$argo") + artifact=$(UNIDESK_PAC_ARTIFACT_JSON="$artifact" UNIDESK_PAC_REVISION_RELATION_JSON="$revision_relation" node <<'NODE' +const artifact = JSON.parse(process.env.UNIDESK_PAC_ARTIFACT_JSON || '{}'); +const revisionRelation = JSON.parse(process.env.UNIDESK_PAC_REVISION_RELATION_JSON || '{}'); +process.stdout.write(JSON.stringify({ ...artifact, gitopsCommit: revisionRelation.expectedRevision || artifact.gitopsCommit || null })); +NODE +) + argo=$(UNIDESK_PAC_ARGO_JSON="$argo" UNIDESK_PAC_REVISION_RELATION_JSON="$revision_relation" node <<'NODE' +const argo = JSON.parse(process.env.UNIDESK_PAC_ARGO_JSON || '{}'); +const revisionRelation = JSON.parse(process.env.UNIDESK_PAC_REVISION_RELATION_JSON || '{"relation":"unknown"}'); +process.stdout.write(JSON.stringify({ ...argo, revisionRelation })); +NODE +) runtime=$(json_normalize "$(runtime_summary)") diagnostics=$(cicd_diagnostics "$pipelines" "$artifact" "$argo" "$runtime") printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","repository":{"name":"%s","repo":"%s/%s","url":"%s","condition":"%s"},"repositoryCondition":"%s","webhooks":%s,"pipelineRuns":%s,"taskRuns":%s,"artifact":%s,"argo":%s,"runtime":%s,"diagnostics":%s,"valuesPrinted":false}\n' \ diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 71f4063c..a12cc216 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -925,6 +925,9 @@ function nodeStatusRow(targetId: string, consumer: PacConsumer, repository: PacR sync: stringValue(argo.sync), health: stringValue(argo.health), revision: stringValue(argo.revision), + repoURL: stringValue(argo.repoURL), + targetRevision: stringValue(argo.targetRevision), + revisionRelation: record(argo.revisionRelation), }, runtime: { deployment: stringValue(runtime.deployment), @@ -1141,7 +1144,14 @@ function renderStatus(result: Record): RenderedCliResult { ...table(["IMAGE_STATUS", "ENV_REUSE", "ENV_ID", "DIGEST", "GITOPS"], [[stringValue(artifact.imageStatus), stringValue(artifact.envReuse), stringValue(artifact.envIdentity), short(stringValue(artifact.digest), 18), short(stringValue(artifact.gitopsCommit))]]), "", "ARGO", - ...table(["SYNC", "HEALTH", "REVISION"], [[stringValue(argo.sync), stringValue(argo.health), short(stringValue(argo.revision))]]), + ...table(["SYNC", "HEALTH", "REVISION", "RELATION", "TARGET_REVISION"], [[ + stringValue(argo.sync), + stringValue(argo.health), + short(stringValue(argo.revision)), + stringValue(record(argo.revisionRelation).relation), + stringValue(argo.targetRevision), + ]]), + ` repo-url: ${compactLine(stringValue(argo.repoURL))}`, "", "CICD DIAGNOSIS", ...table(["OK", "CODE", "PHASE", "SOURCE", "REGISTRY", "GITOPS", "RUNTIME"], [[ @@ -1195,7 +1205,15 @@ function renderCloseout(result: Record): RenderedCliResult { ...table(["EXPECTED", "OBSERVED", "PIPELINERUN", "STATUS", "DURATION_S"], [[short(stringValue(result.sourceCommit), 16), short(stringValue(result.observedSourceCommit), 16), stringValue(latest.name), stringValue(latest.reason ?? latest.status), stringValue(latest.durationSeconds)]]), "", "RUNTIME", - ...table(["IMAGE_STATUS", "ENV_REUSE", "DIGEST", "GITOPS", "ARGO", "RUNTIME"], [[stringValue(artifact.imageStatus), stringValue(artifact.envReuse), short(stringValue(artifact.digest), 18), short(stringValue(artifact.gitopsCommit), 12), `${stringValue(argo.sync)}/${stringValue(argo.health)}`, runtimeText(runtime)]]), + ...table(["IMAGE_STATUS", "ENV_REUSE", "DIGEST", "GITOPS", "ARGO", "RELATION", "RUNTIME"], [[ + stringValue(artifact.imageStatus), + stringValue(artifact.envReuse), + short(stringValue(artifact.digest), 18), + short(stringValue(artifact.gitopsCommit), 12), + `${stringValue(argo.sync)}/${stringValue(argo.health)}`, + stringValue(record(argo.revisionRelation).relation), + runtimeText(runtime), + ]]), "", "CICD DIAGNOSIS", ...table(["OK", "CODE", "PHASE", "HINT"], [[boolText(diagnostics.ok !== false), stringValue(diagnostics.code), stringValue(diagnostics.phase), compactLine(stringValue(diagnostics.hint))]]),