427 lines
17 KiB
TypeScript
427 lines
17 KiB
TypeScript
// 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, timeoutSeconds: number): 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, timeoutSeconds)];
|
|
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<string, unknown> {
|
|
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, timeoutSeconds: number): { image: AgentRunArtifactService | null; evidence: Record<string, unknown> } {
|
|
const artifactRef = `refs/heads/${spec.gitops.branch}:${spec.deployment.artifactCatalogPath}`;
|
|
const result = runCommand(["git", "--git-dir", repoPath, "show", artifactRef], repoRoot, { timeoutMs: budgetMs(timeoutSeconds) });
|
|
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<string, unknown>): 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<string, unknown> };
|
|
gitopsPublish: { jobName: string; result: NativeK8sJobResult; payload: Record<string, unknown> };
|
|
}): Record<string, unknown> {
|
|
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<string, unknown> | null): Record<string, unknown> | 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<ReturnType<typeof runtimeReuseService>>, timeoutSeconds: number): AgentRunReuseDecision {
|
|
const baseRef = parentRef(repoPath, stageRef, timeoutSeconds);
|
|
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, timeoutSeconds);
|
|
const envIdentity = identityComparison(repoPath, stageRef, baseRef, envPaths, timeoutSeconds);
|
|
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[], timeoutSeconds: number): AgentRunIdentityComparison {
|
|
const current = identityDigest(repoPath, currentRef, paths, timeoutSeconds);
|
|
const previous = baseRef === null ? null : identityDigest(repoPath, baseRef, paths, timeoutSeconds);
|
|
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[], timeoutSeconds: number): { 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: budgetMs(timeoutSeconds) });
|
|
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, timeoutSeconds: number): string | null {
|
|
const result = runCommand(["git", "--git-dir", repoPath, "rev-parse", "--verify", `${ref}^`], repoRoot, { timeoutMs: budgetMs(timeoutSeconds) });
|
|
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<string, unknown> {
|
|
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<string, unknown> | 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<string, unknown> | null {
|
|
const row = record(value);
|
|
return Object.keys(row).length === 0 ? null : JSON.parse(JSON.stringify(row)) as Record<string, unknown>;
|
|
}
|
|
|
|
function parseJsonRecord(text: string): Record<string, unknown> | null {
|
|
try {
|
|
const parsed = JSON.parse(text) as unknown;
|
|
return record(parsed);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function budgetMs(timeoutSeconds: number): number {
|
|
return Math.max(1, timeoutSeconds) * 1000;
|
|
}
|