fix: recognize descendant gitops revisions

This commit is contained in:
Codex
2026-07-10 05:26:28 +02:00
parent 976934be77
commit e73ea83760
4 changed files with 201 additions and 22 deletions
@@ -9,6 +9,7 @@ metadata:
- 1555
- 1557
- 1560
- 1649
defaults:
targetId: NC01
consumerId: hwlab-nc01-v03
+2 -1
View File
@@ -88,6 +88,7 @@ function renderNodeStatus(result: Record<string, unknown>, 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<string, unknown>, 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",
@@ -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' \
@@ -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<string, unknown>): 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<string, unknown>): 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))]]),