fix: verify branch follower reuse plan consumption
This commit is contained in:
@@ -39,7 +39,7 @@ const source = {
|
||||
const gitMirror = gitMirrorSummary(sourceCommit);
|
||||
|
||||
let evidence;
|
||||
if (gate === "reuse-plan") evidence = reusePlanEvidence(sourceCommit);
|
||||
if (gate === "reuse-plan") evidence = await reusePlanEvidence(sourceCommit);
|
||||
else if (gate === "ci-taskrun-plan") evidence = await ciTaskRunEvidence(sourceCommit);
|
||||
else if (gate === "cd-rollout-plan") evidence = await cdRolloutEvidence(sourceCommit);
|
||||
else if (gate === "post-deploy-health") evidence = await postDeployHealthEvidence(sourceCommit);
|
||||
@@ -62,26 +62,45 @@ console.log(JSON.stringify({
|
||||
bounded: true,
|
||||
}));
|
||||
|
||||
function reusePlanEvidence(commit) {
|
||||
async function reusePlanEvidence(commit) {
|
||||
const reuse = readReuseConfig(commit);
|
||||
const decisions = reuse.present === true ? reusePlanDecisions(reuse) : [];
|
||||
const summary = reusePlanDecisionSummary(decisions);
|
||||
return {
|
||||
ok: source.snapshotReady && gitMirror.ok === true && reuse.present === true && reuse.serviceCount > 0,
|
||||
gitMirror,
|
||||
reuse,
|
||||
ok: source.snapshotReady && gitMirror.ok === true && reuse.present === true && reuse.serviceCount > 0 && decisions.length > 0,
|
||||
gitMirror: compactGitMirrorEvidence(gitMirror),
|
||||
reuse: compactReuseEvidence(reuse),
|
||||
decisions: boundedDecisions(decisions),
|
||||
decisionSummary: summary,
|
||||
contract: {
|
||||
consumer: "adapter-ci",
|
||||
fields: "serviceId sourceIdentity envIdentity runtimeReuse envReuse skipImageBuild buildDecision reusableImageRef reason",
|
||||
expectation: "CI consumes skipImageBuild from this plan; it does not re-infer build/skip from TaskRun logs or source file scans.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function ciTaskRunEvidence(commit) {
|
||||
if (!commit || !tektonNamespace || !pipelineRunPrefix) return notConfigured("tekton");
|
||||
const pipelineRunName = `${pipelineRunPrefix}-${commit.slice(0, 12)}`;
|
||||
const reusePlan = await reusePlanEvidence(commit);
|
||||
const pipelineRun = await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/pipelineruns/${encodeURIComponent(pipelineRunName)}`, false);
|
||||
const prStatus = pipelineRunStatus(pipelineRun);
|
||||
const pipelineRef = str(pipelineRun?.spec?.pipelineRef?.name);
|
||||
const pipeline = pipelineRef ? await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/pipelines/${encodeURIComponent(pipelineRef)}`, false) : null;
|
||||
const taskRuns = await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/taskruns?labelSelector=${encodeURIComponent(`tekton.dev/pipelineRun=${pipelineRunName}`)}`, false);
|
||||
const taskSummary = taskRunsSummary(taskRuns);
|
||||
const planArtifacts = planArtifactsEvidence(pipelineRunName);
|
||||
const buildTaskRunServices = buildTaskServices(taskRuns);
|
||||
const ciConsumption = ciConsumptionSummary(reusePlan.decisions || [], planArtifacts, buildTaskRunServices);
|
||||
return {
|
||||
ok: prStatus.succeeded === true && taskSummary.failedCount === 0 && taskSummary.activeCount === 0,
|
||||
ok: prStatus.succeeded === true && taskSummary.failedCount === 0 && taskSummary.activeCount === 0 && ciConsumption.ok === true,
|
||||
reusePlan: {
|
||||
ok: reusePlan.ok,
|
||||
decisionSummary: compactDecisionSummary(reusePlan.decisionSummary),
|
||||
},
|
||||
planArtifacts,
|
||||
ciConsumption,
|
||||
pipelineRun: prStatus,
|
||||
pipeline: {
|
||||
name: pipelineRef,
|
||||
@@ -89,7 +108,214 @@ async function ciTaskRunEvidence(commit) {
|
||||
tasks: Array.isArray(pipeline?.spec?.tasks) ? pipeline.spec.tasks.slice(0, 12).map((task) => ({ name: str(task?.name), runAfter: Array.isArray(task?.runAfter) ? task.runAfter.slice(0, 6) : [] })) : [],
|
||||
tasksTruncated: Array.isArray(pipeline?.spec?.tasks) ? pipeline.spec.tasks.length > 12 : false,
|
||||
},
|
||||
taskRuns: taskSummary,
|
||||
taskRuns: compactCiTaskRuns(taskSummary),
|
||||
};
|
||||
}
|
||||
|
||||
function reusePlanDecisions(reuse) {
|
||||
const baseRef = sourceStageRef ? parentRef(sourceStageRef) : null;
|
||||
return (reuse.serviceSpecs || []).map((service) => {
|
||||
const codePaths = service.runtimeReuse?.codeIdentityPaths || [];
|
||||
const envPaths = uniqueStrings([...(service.runtimeReuse?.envIdentityPaths || []), ...(service.envReuse?.envIdentityFiles || [])]);
|
||||
const sourceIdentity = identityComparison(sourceStageRef, baseRef, codePaths);
|
||||
const envIdentity = identityComparison(sourceStageRef, baseRef, envPaths);
|
||||
const runtimeEnabled = service.runtimeReuse?.enabled !== false;
|
||||
const envEnabled = service.envReuse?.enabled !== false;
|
||||
const runtimeHit = runtimeEnabled && sourceIdentity.hit === true && envIdentity.hit === true;
|
||||
const envHit = envEnabled && envIdentity.hit === true;
|
||||
const skipImageBuild = runtimeHit || envHit;
|
||||
return {
|
||||
serviceId: service.id,
|
||||
sourceIdentity,
|
||||
envIdentity,
|
||||
runtimeReuse: {
|
||||
enabled: runtimeEnabled,
|
||||
hit: runtimeHit,
|
||||
reason: runtimeHit ? "source-and-env-identity-hit" : missReason(sourceIdentity, envIdentity, runtimeEnabled),
|
||||
},
|
||||
envReuse: {
|
||||
enabled: envEnabled,
|
||||
hit: envHit,
|
||||
reason: envHit ? "env-identity-hit" : missReason(null, envIdentity, envEnabled),
|
||||
},
|
||||
skipImageBuild,
|
||||
buildDecision: skipImageBuild ? "skipImageBuild" : "buildImage",
|
||||
reusableImageRef: null,
|
||||
reusableImageRefKnown: false,
|
||||
reason: decisionReason({ runtimeHit, envHit, runtimeEnabled, envEnabled, sourceIdentity, envIdentity }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function reusePlanDecisionSummary(decisions) {
|
||||
const skip = decisions.filter((item) => item.skipImageBuild === true).map((item) => item.serviceId).sort();
|
||||
const build = decisions.filter((item) => item.skipImageBuild !== true).map((item) => item.serviceId).sort();
|
||||
return {
|
||||
serviceCount: decisions.length,
|
||||
skipImageBuildCount: skip.length,
|
||||
buildImageCount: build.length,
|
||||
skipImageBuildServices: skip,
|
||||
buildImageServices: build,
|
||||
};
|
||||
}
|
||||
|
||||
function compactDecisionSummary(summary) {
|
||||
return {
|
||||
serviceCount: summary?.serviceCount ?? null,
|
||||
skipImageBuildCount: summary?.skipImageBuildCount ?? null,
|
||||
buildImageCount: summary?.buildImageCount ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function identityComparison(currentRef, baseRef, paths) {
|
||||
const current = identityDigest(currentRef, paths);
|
||||
const previous = baseRef ? identityDigest(baseRef, paths) : null;
|
||||
const configured = paths.length > 0;
|
||||
const hit = configured && previous !== null && current.sha256 !== null && previous.sha256 !== null && current.sha256 === previous.sha256;
|
||||
return {
|
||||
configured,
|
||||
paths: paths.slice(0, 12),
|
||||
pathsTruncated: paths.length > 12,
|
||||
pathCount: paths.length,
|
||||
current: current.sha256,
|
||||
previous: previous?.sha256 ?? null,
|
||||
currentMissingCount: current.missingCount,
|
||||
previousMissingCount: previous?.missingCount ?? null,
|
||||
hit,
|
||||
status: !configured ? "not-configured" : previous === null ? "base-missing" : hit ? "hit" : "miss",
|
||||
};
|
||||
}
|
||||
|
||||
function identityDigest(ref, paths) {
|
||||
if (!ref || paths.length === 0) return { sha256: null, missingCount: 0 };
|
||||
const hash = createHash("sha256");
|
||||
let missingCount = 0;
|
||||
for (const path of paths) {
|
||||
const entries = gitTreeEntries(ref, path);
|
||||
if (entries.length === 0) missingCount += 1;
|
||||
hash.update(path);
|
||||
hash.update("\0");
|
||||
for (const entry of entries) {
|
||||
hash.update(entry);
|
||||
hash.update("\0");
|
||||
}
|
||||
}
|
||||
return { sha256: hash.digest("hex"), missingCount };
|
||||
}
|
||||
|
||||
function gitTreeEntries(ref, path) {
|
||||
try {
|
||||
const out = execFileSync("git", [`--git-dir=${repoPath}`, "ls-tree", "-r", "-z", "--full-tree", ref, "--", path], { encoding: "utf8", maxBuffer: 1024 * 1024 });
|
||||
return out.split("\0").filter(Boolean).sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parentRef(ref) {
|
||||
try {
|
||||
const out = execFileSync("git", [`--git-dir=${repoPath}`, "rev-parse", "--verify", `${ref}^`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
||||
return sha(out) ? out : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function missReason(sourceIdentity, envIdentity, enabled) {
|
||||
if (!enabled) return "reuse-disabled";
|
||||
if (sourceIdentity?.configured === false || envIdentity?.configured === false) return "identity-not-configured";
|
||||
if (sourceIdentity?.status === "base-missing" || envIdentity?.status === "base-missing") return "base-identity-missing";
|
||||
if (sourceIdentity?.status === "miss") return "source-identity-miss";
|
||||
if (envIdentity?.status === "miss") return "env-identity-miss";
|
||||
return "identity-miss";
|
||||
}
|
||||
|
||||
function decisionReason(input) {
|
||||
if (input.runtimeHit) return "runtime-reuse-hit";
|
||||
if (input.envHit) return "env-reuse-hit";
|
||||
if (input.envIdentity.configured === false) return "env-identity-not-configured";
|
||||
if (input.envIdentity.status === "base-missing") return "previous-env-identity-unavailable";
|
||||
if (input.envIdentity.status === "miss") return "env-identity-changed";
|
||||
if (!input.runtimeEnabled && !input.envEnabled) return "reuse-disabled";
|
||||
return "reuse-miss";
|
||||
}
|
||||
|
||||
function planArtifactsEvidence(pipelineRunName) {
|
||||
if (!tektonNamespace || !pipelineRunName) return { ok: false, degradedReason: "tekton-not-configured" };
|
||||
try {
|
||||
const text = execFileSync("node", ["./plan-artifacts.mjs", tektonNamespace, pipelineRunName], { encoding: "utf8", maxBuffer: 512 * 1024 });
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
return { ok: false, degradedReason: "plan-artifacts-query-failed", reason: shortText(error?.message || String(error)) };
|
||||
}
|
||||
}
|
||||
|
||||
function ciConsumptionSummary(decisions, planArtifacts, buildTaskRunServices) {
|
||||
const skipExpected = decisions.filter((item) => item.skipImageBuild === true).map((item) => item.serviceId).sort();
|
||||
const buildExpected = decisions.filter((item) => item.skipImageBuild !== true).map((item) => item.serviceId).sort();
|
||||
const buildObserved = uniqueStrings([...strings(planArtifacts?.buildServices), ...buildTaskRunServices]).sort();
|
||||
const reusedObserved = strings(planArtifacts?.reusedServices).sort();
|
||||
const skippedObserved = uniqueStrings(reusedObserved).sort();
|
||||
const unexpectedBuild = skipExpected.filter((serviceId) => buildObserved.includes(serviceId) && !reusedObserved.includes(serviceId));
|
||||
const missingBuild = buildExpected.filter((serviceId) => !buildObserved.includes(serviceId) && !reusedObserved.includes(serviceId));
|
||||
const ok = planArtifacts?.ok === true && unexpectedBuild.length === 0 && missingBuild.length === 0;
|
||||
return {
|
||||
ok,
|
||||
reason: unexpectedBuild.length > 0 || missingBuild.length > 0 ? "ci-consumption-mismatch" : planArtifacts?.ok === true ? "ci-consumed-reuse-plan" : "plan-artifacts-event-missing",
|
||||
source: "reuse-plan-decisions-compared-to-plan-artifacts-g14-ci-plan",
|
||||
expected: {
|
||||
skipImageBuildCount: skipExpected.length,
|
||||
buildImageCount: buildExpected.length,
|
||||
},
|
||||
observed: {
|
||||
buildServicesCount: buildObserved.length,
|
||||
buildTaskRunServices,
|
||||
reusedServicesCount: reusedObserved.length,
|
||||
skippedOrReusedServicesCount: skippedObserved.length,
|
||||
buildSkippedCount: typeof planArtifacts?.buildSkippedCount === "number" ? planArtifacts.buildSkippedCount : null,
|
||||
},
|
||||
mismatches: compactCiMismatches(unexpectedBuild, missingBuild),
|
||||
};
|
||||
}
|
||||
|
||||
function compactCiMismatches(unexpectedBuild, missingBuild) {
|
||||
return [
|
||||
...(unexpectedBuild.length > 0 ? [{ reason: "expected-skipImageBuild-but-ci-buildServices-includes-service", serviceIds: unexpectedBuild }] : []),
|
||||
...(missingBuild.length > 0 ? [{ reason: "expected-buildImage-but-ci-plan-has-no-build-or-reuse-service", serviceIds: missingBuild }] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function buildTaskServices(list) {
|
||||
const items = Array.isArray(list?.items) ? list.items : [];
|
||||
return uniqueStrings(items.flatMap((item) => {
|
||||
const taskName = str(item?.metadata?.labels?.["tekton.dev/pipelineTask"]) || str(item?.spec?.taskRef?.name) || "";
|
||||
const match = /^build-(.+)$/u.exec(taskName);
|
||||
return match ? [match[1]] : [];
|
||||
})).sort();
|
||||
}
|
||||
|
||||
function compactCiTaskRuns(summary) {
|
||||
return {
|
||||
count: summary.count,
|
||||
slowThresholdSeconds: summary.slowThresholdSeconds,
|
||||
failedCount: summary.failedCount,
|
||||
activeCount: summary.activeCount,
|
||||
slowCount: summary.slowCount,
|
||||
failedItems: summary.failedItems.slice(0, 4).map(compactCiTaskItem),
|
||||
activeItems: summary.activeItems.slice(0, 4).map(compactCiTaskItem),
|
||||
slowItems: summary.slowItems.slice(0, 4).map(compactCiTaskItem),
|
||||
timeline: summary.timeline.slice(0, 10),
|
||||
timelineTruncated: summary.timeline.length > 10 || summary.timelineTruncated === true,
|
||||
performance: summary.performance,
|
||||
};
|
||||
}
|
||||
|
||||
function compactCiTaskItem(item) {
|
||||
return {
|
||||
taskName: item.taskName,
|
||||
status: item.status,
|
||||
reason: item.reason,
|
||||
durationSeconds: item.durationSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,6 +348,7 @@ function readReuseConfig(commit) {
|
||||
const summary = summarizeRuntimeReuseConfig(parsed);
|
||||
return {
|
||||
...summary,
|
||||
serviceSpecs: parsed.services,
|
||||
bytes: Buffer.byteLength(text, "utf8"),
|
||||
sha256: summary.sha256 ?? createHash("sha256").update(text).digest("hex"),
|
||||
};
|
||||
@@ -130,6 +357,63 @@ function readReuseConfig(commit) {
|
||||
}
|
||||
}
|
||||
|
||||
function compactReuseEvidence(reuse) {
|
||||
return {
|
||||
ok: reuse.ok === true,
|
||||
present: reuse.present === true,
|
||||
path: reuse.path || "gitops/reuse.ymal",
|
||||
sha256: shortFingerprint(reuse.sha256),
|
||||
serviceCount: reuse.serviceCount ?? null,
|
||||
errors: Array.isArray(reuse.errors) ? reuse.errors.slice(0, 3) : [],
|
||||
bytes: reuse.bytes ?? null,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function compactGitMirrorEvidence(value) {
|
||||
return {
|
||||
ok: value.ok === true,
|
||||
sourceSnapshotReady: value.sourceSnapshotReady === true,
|
||||
githubInSync: value.githubInSync,
|
||||
pendingFlush: value.pendingFlush,
|
||||
};
|
||||
}
|
||||
|
||||
function boundedDecisions(decisions) {
|
||||
return decisions.slice(0, 16).map((item) => ({
|
||||
serviceId: item.serviceId,
|
||||
sourceIdentity: compactIdentity(item.sourceIdentity),
|
||||
envIdentity: compactIdentity(item.envIdentity),
|
||||
runtimeReuse: compactReuseHit(item.runtimeReuse),
|
||||
envReuse: compactReuseHit(item.envReuse),
|
||||
skipImageBuild: item.skipImageBuild,
|
||||
buildDecision: item.buildDecision,
|
||||
reusableImageRef: item.reusableImageRef,
|
||||
reusableImageRefKnown: item.reusableImageRefKnown,
|
||||
reason: item.reason,
|
||||
}));
|
||||
}
|
||||
|
||||
function compactIdentity(value) {
|
||||
return {
|
||||
configured: value.configured,
|
||||
pathCount: value.pathCount,
|
||||
hit: value.hit,
|
||||
status: value.status,
|
||||
missing: {
|
||||
current: value.currentMissingCount,
|
||||
previous: value.previousMissingCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function compactReuseHit(value) {
|
||||
return {
|
||||
enabled: value.enabled,
|
||||
hit: value.hit,
|
||||
};
|
||||
}
|
||||
|
||||
function gitMirrorSummary(commit) {
|
||||
const localSource = rev(`refs/heads/${sourceBranch}`);
|
||||
const githubSource = rev(`refs/mirror-stage/heads/${sourceBranch}`);
|
||||
@@ -408,6 +692,14 @@ function str(value) {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function strings(value) {
|
||||
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
function uniqueStrings(value) {
|
||||
return [...new Set(value.filter((item) => typeof item === "string" && item.length > 0))];
|
||||
}
|
||||
|
||||
function sha(value) {
|
||||
return typeof value === "string" && /^[0-9a-f]{40}$/iu.test(value);
|
||||
}
|
||||
@@ -416,6 +708,10 @@ function shortSha(value) {
|
||||
return sha(value) ? value.slice(0, 12) : null;
|
||||
}
|
||||
|
||||
function shortFingerprint(value) {
|
||||
return typeof value === "string" && value.length >= 12 ? value.slice(0, 12) : null;
|
||||
}
|
||||
|
||||
function shortText(value) {
|
||||
const text = String(value || "").replace(/\s+/gu, " ").trim();
|
||||
return text.length <= 300 ? text : text.slice(0, 300);
|
||||
|
||||
Reference in New Issue
Block a user