diff --git a/scripts/native/cicd/branch-follower-gate.mjs b/scripts/native/cicd/branch-follower-gate.mjs index 1d559839..93e16283 100644 --- a/scripts/native/cicd/branch-follower-gate.mjs +++ b/scripts/native/cicd/branch-follower-gate.mjs @@ -18,6 +18,8 @@ const pipelineRunPrefix = process.env.PIPELINE_RUN_PREFIX || ""; const argoNamespace = process.env.ARGO_NAMESPACE || ""; const argoApplication = process.env.ARGO_APPLICATION || ""; const runtimeNamespace = process.env.RUNTIME_NAMESPACE || ""; +const stateNamespace = process.env.STATE_NAMESPACE || ""; +const stateConfigMap = process.env.STATE_CONFIGMAP || ""; const workloads = parseWorkloads(process.env.WORKLOADS_B64 || ""); const healthUrl = process.env.HEALTH_URL || ""; const slowTaskSeconds = requiredPositiveIntEnv("SLOW_TASK_SECONDS"); @@ -91,8 +93,9 @@ async function ciTaskRunEvidence(commit) { 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 storedCiConsumption = await storedAgentRunCiConsumption(commit); const buildTaskRunServices = buildTaskServices(taskRuns); - const ciConsumption = ciConsumptionSummary(reusePlan.decisions || [], planArtifacts, buildTaskRunServices); + const ciConsumption = ciConsumptionSummary(reusePlan.decisions || [], planArtifacts, buildTaskRunServices, storedCiConsumption); return { ok: prStatus.succeeded === true && taskSummary.failedCount === 0 && taskSummary.activeCount === 0 && ciConsumption.ok === true, reusePlan: { @@ -100,6 +103,7 @@ async function ciTaskRunEvidence(commit) { decisionSummary: compactDecisionSummary(reusePlan.decisionSummary), }, planArtifacts, + storedCiConsumption, ciConsumption, pipelineRun: prStatus, pipeline: { @@ -250,9 +254,55 @@ function planArtifactsEvidence(pipelineRunName) { } } -function ciConsumptionSummary(decisions, planArtifacts, buildTaskRunServices) { +function ciConsumptionSummary(decisions, planArtifacts, buildTaskRunServices, storedCiConsumption) { 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(); + if (/agentrun/u.test(follower) && storedCiConsumption?.present === true && storedCiConsumption.ok !== true) { + return { + ok: false, + reason: storedCiConsumption.reason || "ci-consumption-evidence-missing", + source: "branch-follower-compact-state", + expected: { + skipImageBuildCount: skipExpected.length, + buildImageCount: buildExpected.length, + }, + observed: { + buildServicesCount: 0, + buildTaskRunServices, + reusedServicesCount: 0, + skippedOrReusedServicesCount: 0, + buildSkippedCount: null, + }, + mismatches: [], + }; + } + if (storedCiConsumption?.ok === true) { + const observed = storedCiConsumption.observed || {}; + const unexpectedStoredBuild = skipExpected.filter((serviceId) => observed.imageBuildDecision === "buildImage" && serviceId === "agentrun-mgr"); + const missingStoredBuild = buildExpected.filter((serviceId) => observed.imageBuildDecision !== "buildImage" && serviceId === "agentrun-mgr"); + const storedOk = unexpectedStoredBuild.length === 0 && missingStoredBuild.length === 0; + return { + ok: storedOk, + reason: storedOk ? "ci-consumed-reuse-plan" : "ci-consumption-mismatch", + source: "branch-follower-compact-state", + expected: { + skipImageBuildCount: skipExpected.length, + buildImageCount: buildExpected.length, + }, + observed: { + buildServicesCount: numberOrNull(observed.buildServicesCount), + buildTaskRunServices, + reusedServicesCount: numberOrNull(observed.reusedServicesCount), + skippedOrReusedServicesCount: numberOrNull(observed.reusedServicesCount), + buildSkippedCount: numberOrNull(observed.buildSkippedCount), + imageBuildDecision: str(observed.imageBuildDecision), + imageBuildStatus: str(observed.imageBuildStatus), + imageBuildCreated: observed.imageBuildCreated === true, + imageBuildReused: observed.imageBuildReused === true, + }, + mismatches: compactCiMismatches(unexpectedStoredBuild, missingStoredBuild), + }; + } const buildObserved = uniqueStrings([...strings(planArtifacts?.buildServices), ...buildTaskRunServices]).sort(); const reusedObserved = strings(planArtifacts?.reusedServices).sort(); const skippedObserved = uniqueStrings(reusedObserved).sort(); @@ -278,6 +328,33 @@ function ciConsumptionSummary(decisions, planArtifacts, buildTaskRunServices) { }; } +async function storedAgentRunCiConsumption(commit) { + if (!stateNamespace || !stateConfigMap || !follower || !commit) return null; + const cm = await getJson(`/api/v1/namespaces/${encodeURIComponent(stateNamespace)}/configmaps/${encodeURIComponent(stateConfigMap)}`, false); + const raw = cm?.data?.[follower]; + if (typeof raw !== "string" || raw.length === 0) return { ok: false, present: false, reason: "compact-state-missing" }; + const state = parseJson(raw); + if (state === null) return { ok: false, present: true, reason: "state-json-parse-failed" }; + const command = state?.command || {}; + const payload = command.payload || {}; + const agentrun = payload.agentrun || {}; + const sourceCommit = str(agentrun.sourceCommit) || str(command.sourceCommit); + const ci = agentrun.ciConsumption || null; + if (sourceCommit === null && (!ci || typeof ci !== "object")) return { ok: false, present: true, reason: "ci-consumption-evidence-missing", sourceCommit: null }; + if (sourceCommit !== commit) return { ok: false, present: true, reason: "state-source-commit-mismatch", sourceCommit }; + if (!ci || typeof ci !== "object") return { ok: false, present: true, reason: "ci-consumption-evidence-missing", sourceCommit }; + return { + ok: ci.ok === true, + present: true, + sourceCommit, + source: str(ci.source), + expected: ci.expected || null, + observed: ci.observed || null, + mismatches: Array.isArray(ci.mismatches) ? ci.mismatches.slice(0, 6) : [], + valuesRedacted: true, + }; +} + function compactCiMismatches(unexpectedBuild, missingBuild) { return [ ...(unexpectedBuild.length > 0 ? [{ reason: "expected-skipImageBuild-but-ci-buildServices-includes-service", serviceIds: unexpectedBuild }] : []), @@ -692,6 +769,19 @@ function str(value) { return typeof value === "string" && value.length > 0 ? value : null; } +function parseJson(value) { + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + +function numberOrNull(value) { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + function strings(value) { return Array.isArray(value) ? value.filter((item) => typeof item === "string") : []; } diff --git a/scripts/src/cicd-agentrun-reuse.ts b/scripts/src/cicd-agentrun-reuse.ts new file mode 100644 index 00000000..377e2644 --- /dev/null +++ b/scripts/src/cicd-agentrun-reuse.ts @@ -0,0 +1,422 @@ +// SPEC: PJ2026-01060703 CI/CD branch follower AgentRun reuse decisions. +// Responsibility: compute AgentRun per-service reuse decisions and bounded CI consumption evidence. +import { createHash } from "node:crypto"; +import { repoRoot } from "./config"; +import { runCommand } from "./command"; +import type { AgentRunArtifactService } from "./agentrun-manifests"; +import type { AgentRunLaneSpec } from "./agentrun-lanes"; +import type { FollowerSpec, NativeK8sJobResult } from "./cicd-types"; +import { RUNTIME_REUSE_CONFIG_PATH, runtimeReuseService, type RuntimeReuseConfig } from "./cicd-reuse-config"; + +export interface AgentRunReusePlan { + ok: boolean; + sourceCommit: string; + stageRef: string; + decisions: AgentRunReuseDecision[]; + decisionSummary: { + serviceCount: number; + skipImageBuildCount: number; + buildImageCount: number; + skipImageBuildServices: string[]; + buildImageServices: string[]; + }; + errors: string[]; + valuesRedacted: true; +} + +export interface AgentRunReuseDecision { + serviceId: string; + sourceIdentity: AgentRunIdentityComparison; + envIdentity: AgentRunIdentityComparison; + runtimeReuse: { enabled: boolean; hit: boolean; reason: string }; + envReuse: { enabled: boolean; hit: boolean; reason: string }; + skipImageBuild: boolean; + buildDecision: "skipImageBuild" | "buildImage"; + reusableImageRef: string | null; + reusableImageRefKnown: boolean; + reason: string; +} + +interface AgentRunIdentityComparison { + configured: boolean; + pathCount: number; + hit: boolean; + status: "not-configured" | "base-missing" | "hit" | "miss"; + currentMissingCount: number; + previousMissingCount: number | null; +} + +export function buildAgentRunReusePlan(follower: FollowerSpec, reuseConfig: RuntimeReuseConfig, sourceCommit: string): AgentRunReusePlan { + const stageRef = `${follower.source.snapshotPrefix.replace(/\/+$/u, "")}/${sourceCommit}`; + const service = runtimeReuseService(reuseConfig, ["agentrun-mgr", "manager"]); + const decisions = service === null ? [] : [agentRunReuseDecision(follower.nativeStatus.source.repoPath, stageRef, service)]; + const summary = agentRunDecisionSummary(decisions); + return { + ok: reuseConfig.ok && decisions.length > 0, + sourceCommit, + stageRef, + decisions, + decisionSummary: summary, + errors: service === null ? [`${RUNTIME_REUSE_CONFIG_PATH} must declare service agentrun-mgr|manager`] : reuseConfig.errors.slice(0, 5), + valuesRedacted: true, + }; +} + +export function compactAgentRunReusePlan(plan: AgentRunReusePlan): Record { + return { + ok: plan.ok, + sourceCommit: plan.sourceCommit, + stageRef: plan.stageRef, + decisionSummary: plan.decisionSummary, + decisions: plan.decisions.slice(0, 8).map((decision) => ({ + serviceId: decision.serviceId, + sourceIdentity: compactIdentity(decision.sourceIdentity), + envIdentity: compactIdentity(decision.envIdentity), + runtimeReuse: { enabled: decision.runtimeReuse.enabled, hit: decision.runtimeReuse.hit }, + envReuse: { enabled: decision.envReuse.enabled, hit: decision.envReuse.hit }, + skipImageBuild: decision.skipImageBuild, + buildDecision: decision.buildDecision, + reusableImageRef: decision.reusableImageRef, + reusableImageRefKnown: decision.reusableImageRefKnown, + reason: decision.reason, + })), + errors: plan.errors.slice(0, 5), + valuesRedacted: true, + }; +} + +export function agentRunManagerDecision(plan: AgentRunReusePlan): AgentRunReuseDecision | null { + return plan.decisions.find((decision) => decision.serviceId === "agentrun-mgr" || decision.serviceId === "manager") ?? null; +} + +export function applyAgentRunReuseConfig(spec: AgentRunLaneSpec, reuseConfig: RuntimeReuseConfig): AgentRunLaneSpec { + const service = runtimeReuseService(reuseConfig, ["agentrun-mgr", "manager"]); + const envReuse = service?.envReuse; + if (envReuse === undefined || envReuse === null) return spec; + if (envReuse.enabled === false) return spec; + const imageBuild = spec.deployment.manager.imageBuild; + return { + ...spec, + deployment: { + ...spec.deployment, + manager: { + ...spec.deployment.manager, + imageBuild: { + ...imageBuild, + buildArgs: Object.keys(envReuse.buildArgs).length === 0 ? imageBuild.buildArgs : envReuse.buildArgs, + envIdentityFiles: envReuse.envIdentityFiles.length === 0 ? imageBuild.envIdentityFiles : envReuse.envIdentityFiles, + }, + }, + }, + }; +} + +export function reusableAgentRunImageArtifact(spec: AgentRunLaneSpec, repoPath: string, sourceCommit: string): { image: AgentRunArtifactService | null; evidence: Record } { + const artifactRef = `refs/heads/${spec.gitops.branch}:${spec.deployment.artifactCatalogPath}`; + const result = runCommand(["git", "--git-dir", repoPath, "show", artifactRef], repoRoot, { timeoutMs: 5_000 }); + if (result.exitCode !== 0) { + return { + image: null, + evidence: { + ok: false, + status: "missing", + artifactRef, + reason: shortText(result.stderr || result.stdout || "artifact catalog missing"), + valuesRedacted: true, + }, + }; + } + const parsed = parseJsonRecord(result.stdout); + const services = Array.isArray(parsed?.services) ? parsed.services : []; + const service = services.find((item) => record(item).serviceId === "agentrun-mgr"); + const row = record(service); + const digest = stringOrNull(row.digest) ?? stringOrNull(row.envDigest); + const envIdentity = stringOrNull(row.envIdentity) ?? stringOrNull(row.imageTag); + const image = stringOrNull(row.image) ?? stringOrNull(row.envImage); + if (digest === null || envIdentity === null || image === null) { + return { + image: null, + evidence: { + ok: false, + status: "invalid", + artifactRef, + reason: "artifact catalog lacks image/digest/envIdentity for agentrun-mgr", + valuesRedacted: true, + }, + }; + } + const repositoryDigest = stringOrNull(row.repositoryDigest) ?? `${imageRepository(image)}@${digest}`; + const artifact: AgentRunArtifactService = { + serviceId: "agentrun-mgr", + artifactKind: stringOrNull(row.artifactKind) ?? "env-reuse", + status: "reused", + image, + digest, + repositoryDigest, + imageTag: envIdentity, + envIdentity, + envImage: stringOrNull(row.envImage) ?? image, + envDigest: stringOrNull(row.envDigest) ?? digest, + envRepositoryDigest: stringOrNull(row.envRepositoryDigest) ?? repositoryDigest, + bootCommit: sourceCommit, + bootScript: stringOrNull(row.bootScript) ?? "deploy/runtime/boot/agentrun-boot.sh", + provenance: { + sourceCommitId: sourceCommit, + previousSourceCommitId: stringOrNull(parsed?.sourceCommitId), + source: "unidesk-yaml-only-reuse", + artifactRef, + valuesPrinted: false, + }, + }; + return { + image: artifact, + evidence: { + ok: true, + status: "reused", + artifactRef, + previousSourceCommitId: stringOrNull(parsed?.sourceCommitId), + image, + digest, + envIdentity, + valuesRedacted: true, + }, + }; +} + +export function skippedAgentRunImageBuildResult(namespace: string, jobName: string, summary: Record): NativeK8sJobResult { + return { + ok: true, + completed: true, + failed: false, + timedOut: false, + created: false, + reused: true, + jobName, + namespace, + polls: 0, + elapsedMs: 0, + logsTail: null, + summary, + conditionReason: "Skipped", + conditionMessage: "reuse-plan skipImageBuild consumed by AgentRun trigger", + statusAuthority: "kubernetes-api-serviceaccount", + parsedDownstreamCliOutput: false, + }; +} + +export function agentRunCiConsumptionEvidence(input: { + sourceCommit: string; + plan: AgentRunReusePlan; + imageBuild: { jobName: string; result: NativeK8sJobResult; payload: Record }; + gitopsPublish: { jobName: string; result: NativeK8sJobResult; payload: Record }; +}): Record { + const decision = agentRunManagerDecision(input.plan); + const expectedSkip = input.plan.decisionSummary.skipImageBuildCount; + const expectedBuild = input.plan.decisionSummary.buildImageCount; + const skipped = decision?.skipImageBuild === true; + const imageBuildConsumed = skipped + ? input.imageBuild.result.reused === true && input.imageBuild.payload.status === "skipped" + : input.imageBuild.result.completed === true && input.imageBuild.payload.status !== "skipped"; + return { + ok: input.plan.ok && imageBuildConsumed && input.gitopsPublish.result.completed === true, + sourceCommit: input.sourceCommit, + source: "agentrun-native-trigger", + expected: { + skipImageBuildCount: expectedSkip, + buildImageCount: expectedBuild, + skipImageBuildServices: input.plan.decisionSummary.skipImageBuildServices, + buildImageServices: input.plan.decisionSummary.buildImageServices, + }, + observed: { + imageBuildDecision: skipped ? "skipImageBuild" : "buildImage", + imageBuildStatus: stringOrNull(input.imageBuild.payload.status), + imageBuildJobName: input.imageBuild.jobName, + imageBuildCreated: input.imageBuild.result.created, + imageBuildReused: input.imageBuild.result.reused, + gitopsPublishJobName: input.gitopsPublish.jobName, + gitopsPublishCompleted: input.gitopsPublish.result.completed, + buildServicesCount: skipped ? 0 : 1, + reusedServicesCount: skipped ? 1 : 0, + buildSkippedCount: skipped ? 1 : 0, + }, + mismatches: imageBuildConsumed ? [] : [{ reason: "image-build-stage-did-not-consume-reuse-plan", serviceIds: ["agentrun-mgr"] }], + valuesRedacted: true, + }; +} + +export function compactAgentRunPayload(value: Record | null): Record | null { + if (value === null) return null; + return { + configPath: stringOrNull(value.configPath), + sourceCommit: stringOrNull(value.sourceCommit), + reusePlan: compactRecord(value.reusePlan), + ciConsumption: compactRecord(value.ciConsumption), + gitMirrorSync: compactStageRecord(value.gitMirrorSync), + imageBuild: compactStageRecord(value.imageBuild), + gitopsPublish: compactStageRecord(value.gitopsPublish), + gitMirrorFlush: compactStageRecord(value.gitMirrorFlush), + valuesRedacted: true, + }; +} + +function agentRunReuseDecision(repoPath: string, stageRef: string, service: NonNullable>): AgentRunReuseDecision { + const baseRef = parentRef(repoPath, stageRef); + const runtime = service.runtimeReuse; + const envReuse = service.envReuse; + const codePaths = runtime?.codeIdentityPaths ?? []; + const envPaths = uniqueStrings([...(runtime?.envIdentityPaths ?? []), ...(envReuse?.envIdentityFiles ?? [])]); + const sourceIdentity = identityComparison(repoPath, stageRef, baseRef, codePaths); + const envIdentity = identityComparison(repoPath, stageRef, baseRef, envPaths); + const runtimeEnabled = runtime?.enabled !== false; + const envEnabled = envReuse?.enabled !== false; + const runtimeHit = runtimeEnabled && sourceIdentity.hit && envIdentity.hit; + const envHit = envEnabled && envIdentity.hit; + 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: runtimeHit ? "runtime-reuse-hit" : envHit ? "env-reuse-hit" : "reuse-miss", + }; +} + +function identityComparison(repoPath: string, currentRef: string, baseRef: string | null, paths: string[]): AgentRunIdentityComparison { + const current = identityDigest(repoPath, currentRef, paths); + const previous = baseRef === null ? null : identityDigest(repoPath, baseRef, paths); + const configured = paths.length > 0; + const hit = configured && previous !== null && current.sha256 !== null && previous.sha256 !== null && current.sha256 === previous.sha256; + return { + configured, + pathCount: paths.length, + hit, + status: !configured ? "not-configured" : previous === null ? "base-missing" : hit ? "hit" : "miss", + currentMissingCount: current.missingCount, + previousMissingCount: previous?.missingCount ?? null, + }; +} + +function identityDigest(repoPath: string, ref: string, paths: string[]): { sha256: string | null; missingCount: number } { + if (paths.length === 0) return { sha256: null, missingCount: 0 }; + const hash = createHash("sha256"); + let missingCount = 0; + for (const path of paths) { + const result = runCommand(["git", "--git-dir", repoPath, "ls-tree", "-r", "-z", "--full-tree", ref, "--", path], repoRoot, { timeoutMs: 5_000 }); + const entries = result.exitCode === 0 ? result.stdout.split("\0").filter(Boolean).sort() : []; + 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 parentRef(repoPath: string, ref: string): string | null { + const result = runCommand(["git", "--git-dir", repoPath, "rev-parse", "--verify", `${ref}^`], repoRoot, { timeoutMs: 5_000 }); + const value = result.stdout.trim(); + return result.exitCode === 0 && /^[0-9a-f]{40}$/iu.test(value) ? value : null; +} + +function agentRunDecisionSummary(decisions: AgentRunReuseDecision[]): AgentRunReusePlan["decisionSummary"] { + const skip = decisions.filter((item) => item.skipImageBuild).map((item) => item.serviceId).sort(); + const build = decisions.filter((item) => !item.skipImageBuild).map((item) => item.serviceId).sort(); + return { + serviceCount: decisions.length, + skipImageBuildCount: skip.length, + buildImageCount: build.length, + skipImageBuildServices: skip, + buildImageServices: build, + }; +} + +function missReason(sourceIdentity: AgentRunIdentityComparison | null, envIdentity: AgentRunIdentityComparison, enabled: boolean): string { + 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 compactIdentity(value: AgentRunIdentityComparison): Record { + return { + configured: value.configured, + pathCount: value.pathCount, + hit: value.hit, + status: value.status, + missing: { + current: value.currentMissingCount, + previous: value.previousMissingCount, + }, + }; +} + +function compactStageRecord(value: unknown): Record | null { + const stage = record(value); + if (Object.keys(stage).length === 0) return null; + const result = record(stage.result); + const payload = record(stage.payload); + return { + jobName: stringOrNull(stage.jobName), + result: Object.keys(result).length === 0 ? null : { + ok: result.ok === true, + completed: result.completed === true, + failed: result.failed === true, + timedOut: result.timedOut === true, + created: result.created === true, + reused: result.reused === true, + jobName: stringOrNull(result.jobName), + namespace: stringOrNull(result.namespace), + elapsedMs: typeof result.elapsedMs === "number" ? result.elapsedMs : null, + conditionReason: stringOrNull(result.conditionReason), + conditionMessage: stringOrNull(result.conditionMessage), + statusAuthority: stringOrNull(result.statusAuthority), + parsedDownstreamCliOutput: false, + }, + payload: compactRecord(payload), + }; +} + +function compactRecord(value: unknown): Record | null { + const row = record(value); + return Object.keys(row).length === 0 ? null : JSON.parse(JSON.stringify(row)) as Record; +} + +function parseJsonRecord(text: string): Record | null { + try { + const parsed = JSON.parse(text) as unknown; + return record(parsed); + } catch { + return null; + } +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values.filter((value) => value.length > 0))]; +} + +function imageRepository(image: string): string { + const index = image.lastIndexOf(":"); + return index > 0 ? image.slice(0, index) : image; +} + +function shortText(value: string): string { + const text = value.replace(/\s+/gu, " ").trim(); + return text.length <= 300 ? text : text.slice(0, 300); +} diff --git a/scripts/src/cicd-branch-follower.ts b/scripts/src/cicd-branch-follower.ts index b0955582..9cb94415 100644 --- a/scripts/src/cicd-branch-follower.ts +++ b/scripts/src/cicd-branch-follower.ts @@ -24,11 +24,12 @@ import { configRefGraph, resolveConfigRefString } from "./ops/config-refs"; import { renderControllerManifests, renderControllerReconcileJob, waitForJobShell } from "./cicd-controller-render"; import { buildDebugStep } from "./cicd-debug"; import { runNativeHwlabControlPlaneRefresh } from "./cicd-hwlab-refresh"; +import { agentRunCiConsumptionEvidence, agentRunManagerDecision, applyAgentRunReuseConfig, buildAgentRunReusePlan, compactAgentRunPayload, compactAgentRunReusePlan, reusableAgentRunImageArtifact, skippedAgentRunImageBuildResult } from "./cicd-agentrun-reuse"; import { nativeCicdScriptLoadShell, readNativeObjectBundle } from "./cicd-native-bundle"; import { compactRefreshEvidence, followerEvidenceSummary } from "./cicd-evidence"; import { runNativeK8sJob, runNativeTektonPipelineRun } from "./cicd-native"; import { argoApplicationReady, nativeArgoSummary, nativeGitMirrorReady, nativeGitMirrorRequired, nativeGitMirrorSummary, nativePipelineRunSummary, nativeRuntimeSummary, pipelineRunSucceeded, runtimeTargetShaFromWorkloads, runtimeWorkloadsReady } from "./cicd-native-summary"; -import { invalidRuntimeReuseConfig, missingRuntimeReuseConfig, parseRuntimeReuseConfig, RUNTIME_REUSE_CONFIG_PATH, runtimeReuseService, summarizeRuntimeReuseConfig, type RuntimeReuseConfig } from "./cicd-reuse-config"; +import { invalidRuntimeReuseConfig, missingRuntimeReuseConfig, parseRuntimeReuseConfig, requiredReuseServiceError, RUNTIME_REUSE_CONFIG_PATH, summarizeRuntimeReuseConfig, type RuntimeReuseConfig } from "./cicd-reuse-config"; import { prioritizedTaskRunItems } from "./cicd-taskruns"; import { runBranchFollowerTaskRunDrillDown } from "./cicd-taskrun-drilldown"; import { runBranchFollowerJobDrillDown, runBranchFollowerRuntimeDrillDown } from "./cicd-job-runtime-drilldown"; @@ -1275,15 +1276,30 @@ async function executeNativeAgentRunTrigger(registry: BranchFollowerRegistry, fo const agentRunReuseError = requiredReuseServiceError(reuseConfig, ["agentrun-mgr", "manager"], "envReuse"); if (agentRunReuseError !== null) return nativeReuseConfigFailure(follower, observedSha, invalidRuntimeReuseConfig(reuseConfig, agentRunReuseError), startedAt); const effectiveSpec = applyAgentRunReuseConfig(spec, reuseConfig); + const reusePlan = buildAgentRunReusePlan(follower, reuseConfig, observedSha); + const managerDecision = agentRunManagerDecision(reusePlan); const buildJob = `${jobPrefix}-build-${observedSha.slice(0, 12)}`.slice(0, 63); - const build = runNativeK8sJob(effectiveSpec.ci.namespace, buildJob, yamlLaneK3sBuildImageJobManifest(effectiveSpec, observedSha, buildJob), Math.min(remainingSeconds(startedAt, timeoutSeconds), effectiveSpec.deployment.manager.imageBuild.timeoutSeconds), "buildkit", registry.controller.budgets); - const buildPayload = yamlLaneGitopsPublishPayloadFromProbe({ logsTail: stringOrNull(build.logsTail) ?? "" }); + const buildSkipped = managerDecision?.skipImageBuild === true; + const reusedArtifact = buildSkipped ? reusableAgentRunImageArtifact(effectiveSpec, follower.nativeStatus.source.repoPath, observedSha) : null; + if (buildSkipped && reusedArtifact?.image === null) { + return nativeTriggerError(follower, `native AgentRun image reuse failed: ${stringOrNull(reusedArtifact.evidence.reason) ?? "reusable image artifact missing"}`, "agentrun-reusable-image-missing"); + } + const build = buildSkipped && reusedArtifact !== null + ? skippedAgentRunImageBuildResult(effectiveSpec.ci.namespace, buildJob, { decision: "skipImageBuild", reason: managerDecision?.reason ?? "reuse-plan-hit", artifact: reusedArtifact.evidence }) + : runNativeK8sJob(effectiveSpec.ci.namespace, buildJob, yamlLaneK3sBuildImageJobManifest(effectiveSpec, observedSha, buildJob), Math.min(remainingSeconds(startedAt, timeoutSeconds), effectiveSpec.deployment.manager.imageBuild.timeoutSeconds), "buildkit", registry.controller.budgets); + const buildPayload = buildSkipped && reusedArtifact !== null + ? { ok: true, status: "skipped", sourceCommit: observedSha, skipImageBuild: true, buildDecision: "skipImageBuild", reason: managerDecision?.reason ?? "reuse-plan-hit", artifact: reusedArtifact.evidence, valuesPrinted: false } + : yamlLaneGitopsPublishPayloadFromProbe({ logsTail: stringOrNull(build.logsTail) ?? "" }); const digest = stringOrNull(buildPayload.digest); const envIdentity = stringOrNull(buildPayload.envIdentity); - if (!build.ok || digest === null || envIdentity === null) { + const image = buildSkipped && reusedArtifact !== null + ? reusedArtifact.image + : digest !== null && envIdentity !== null + ? agentRunImageArtifact(effectiveSpec, { sourceCommit: observedSha, envIdentity, digest, status: stringOrNull(buildPayload.status) ?? "built" }) + : null; + if (!build.ok || image === null) { return nativeK8sStageFailure(follower, observedSha, "image-build", buildJob, build, buildPayload, "native AgentRun image build failed", startedAt); } - const image = agentRunImageArtifact(effectiveSpec, { sourceCommit: observedSha, envIdentity, digest, status: stringOrNull(buildPayload.status) ?? "built" }); const renderedFiles = renderAgentRunGitopsFiles(effectiveSpec, { sourceCommit: observedSha, image }); const publishJob = `${jobPrefix}-gitops-${observedSha.slice(0, 12)}`.slice(0, 63); const publish = runNativeK8sJob(effectiveSpec.gitMirror.namespace, publishJob, yamlLaneGitopsPublishJobManifest(effectiveSpec, renderedFiles, publishJob), remainingSeconds(startedAt, timeoutSeconds), "publish", registry.controller.budgets); @@ -1291,6 +1307,12 @@ async function executeNativeAgentRunTrigger(registry: BranchFollowerRegistry, fo if (!publish.ok || publishPayload.ok === false || stringOrNull(publishPayload.gitopsCommit) === null) { return nativeK8sStageFailure(follower, observedSha, "gitops-publish", publishJob, publish, publishPayload, "native AgentRun GitOps publish failed", startedAt); } + const ciConsumption = agentRunCiConsumptionEvidence({ + sourceCommit: observedSha, + plan: reusePlan, + imageBuild: { jobName: buildJob, result: build, payload: buildPayload }, + gitopsPublish: { jobName: publishJob, result: publish, payload: publishPayload }, + }); const flush = runNativeGitMirrorStage(registry, follower, observedSha, "flush", remainingSeconds(startedAt, timeoutSeconds)); if (flush !== null && !flush.result.ok) { return nativeK8sStageFailure(follower, observedSha, "git-mirror-flush", flush.jobName, flush.result, { action: "flush" }, "native AgentRun git-mirror flush failed", startedAt); @@ -1316,7 +1338,10 @@ async function executeNativeAgentRunTrigger(registry: BranchFollowerRegistry, fo ...tektonPayload, agentrun: { configPath, + sourceCommit: observedSha, reuseConfig: summarizeRuntimeReuseConfig(reuseConfig), + reusePlan: compactAgentRunReusePlan(reusePlan), + ciConsumption, gitMirrorSync: sync === null ? null : { jobName: sync.jobName, payload: sync.result }, imageBuild: { jobName: buildJob, result: build, payload: buildPayload }, gitopsPublish: { jobName: publishJob, result: publish, payload: publishPayload }, @@ -1692,21 +1717,6 @@ function nativeReuseConfigFailure(follower: FollowerSpec, observedSha: string, r }; } -function requiredReuseServiceError(reuseConfig: RuntimeReuseConfig, ids: readonly string[], mode: "runtimeReuse" | "envReuse"): string | null { - const service = runtimeReuseService(reuseConfig, ids); - if (service === null) return `${RUNTIME_REUSE_CONFIG_PATH} must declare service ${ids.join("|")}`; - if (mode === "runtimeReuse") { - if (service.runtimeReuse === null) return `${RUNTIME_REUSE_CONFIG_PATH} service ${service.id} must declare runtimeReuse`; - if (service.runtimeReuse.enabled === false) return `${RUNTIME_REUSE_CONFIG_PATH} service ${service.id} runtimeReuse is disabled`; - if (service.runtimeReuse.codeIdentityPaths.length === 0 && service.runtimeReuse.envIdentityPaths.length === 0) return `${RUNTIME_REUSE_CONFIG_PATH} service ${service.id} runtimeReuse must declare codeIdentity or envIdentity paths`; - return null; - } - if (service.envReuse === null) return `${RUNTIME_REUSE_CONFIG_PATH} service ${service.id} must declare envReuse`; - if (service.envReuse.enabled === false) return `${RUNTIME_REUSE_CONFIG_PATH} service ${service.id} envReuse is disabled`; - if (service.envReuse.envIdentityFiles.length === 0 && service.envReuse.nodeDepsPath === null) return `${RUNTIME_REUSE_CONFIG_PATH} service ${service.id} envReuse must declare envIdentityFiles or nodeDepsPath`; - return null; -} - function annotatePipelineRunReuseConfig(manifest: Record, reuseConfig: RuntimeReuseConfig): Record { const metadata = asOptionalRecord(manifest.metadata) ?? {}; const annotations = asOptionalRecord(metadata.annotations) ?? {}; @@ -1719,28 +1729,6 @@ function annotatePipelineRunReuseConfig(manifest: Record, reuse return manifest; } -function applyAgentRunReuseConfig(spec: ReturnType["spec"], reuseConfig: RuntimeReuseConfig): ReturnType["spec"] { - const service = runtimeReuseService(reuseConfig, ["agentrun-mgr", "manager"]); - const envReuse = service?.envReuse; - if (envReuse === undefined || envReuse === null) return spec; - if (envReuse.enabled === false) return spec; - const imageBuild = spec.deployment.manager.imageBuild; - return { - ...spec, - deployment: { - ...spec.deployment, - manager: { - ...spec.deployment.manager, - imageBuild: { - ...imageBuild, - buildArgs: Object.keys(envReuse.buildArgs).length === 0 ? imageBuild.buildArgs : envReuse.buildArgs, - envIdentityFiles: envReuse.envIdentityFiles.length === 0 ? imageBuild.envIdentityFiles : envReuse.envIdentityFiles, - }, - }, - }, - }; -} - function shouldFlushNativeGitMirrorDuringCloseout(follower: FollowerSpec, gitMirror: Record | null): boolean { if (!nativeGitMirrorRequired(follower) || gitMirror === null) return false; if (gitMirror.sourceSnapshotReady === false) return false; @@ -2333,6 +2321,7 @@ function compactNativePayload(payload: Record | null): Record wanted.has(service.id)) ?? null; } +export function requiredReuseServiceError(reuseConfig: RuntimeReuseConfig, ids: readonly string[], mode: "runtimeReuse" | "envReuse"): string | null { + const service = runtimeReuseService(reuseConfig, ids); + if (service === null) return `${RUNTIME_REUSE_CONFIG_PATH} must declare service ${ids.join("|")}`; + if (mode === "runtimeReuse") { + if (service.runtimeReuse === null) return `${RUNTIME_REUSE_CONFIG_PATH} service ${service.id} must declare runtimeReuse`; + if (service.runtimeReuse.enabled === false) return `${RUNTIME_REUSE_CONFIG_PATH} service ${service.id} runtimeReuse is disabled`; + if (service.runtimeReuse.codeIdentityPaths.length === 0 && service.runtimeReuse.envIdentityPaths.length === 0) return `${RUNTIME_REUSE_CONFIG_PATH} service ${service.id} runtimeReuse must declare codeIdentity or envIdentity paths`; + return null; + } + if (service.envReuse === null) return `${RUNTIME_REUSE_CONFIG_PATH} service ${service.id} must declare envReuse`; + if (service.envReuse.enabled === false) return `${RUNTIME_REUSE_CONFIG_PATH} service ${service.id} envReuse is disabled`; + if (service.envReuse.envIdentityFiles.length === 0 && service.envReuse.nodeDepsPath === null) return `${RUNTIME_REUSE_CONFIG_PATH} service ${service.id} envReuse must declare envIdentityFiles or nodeDepsPath`; + return null; +} + function parseServices(spec: Record, errors: string[]): RuntimeReuseService[] { const raw = spec.services; const items = Array.isArray(raw)