Files
pikasTech-unidesk/scripts/src/cicd-agentrun-reuse.ts
T

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;
}