feat: load branch follower reuse config from source repos

This commit is contained in:
Codex
2026-07-03 20:42:46 +00:00
parent 19d270b44b
commit b0cb23b0e0
8 changed files with 435 additions and 13 deletions
@@ -143,6 +143,7 @@ function compactNativePayload(payload) {
if (value === null) return null;
return {
gitMirror: compactGitMirror(value.gitMirror),
reuseConfig: compactReuseConfig(value.reuseConfig),
tekton: compactTekton(value.tekton),
taskRuns: compactTaskRuns(value.taskRuns),
planArtifacts: compactPlanArtifacts(value.planArtifacts),
@@ -154,6 +155,27 @@ function compactNativePayload(payload) {
};
}
function compactReuseConfig(reuseConfig) {
const value = recordOrNull(reuseConfig);
if (value === null) return null;
return {
ok: value.ok === true,
present: value.present === true,
path: stringOrNull(value.path),
sourceCommit: stringOrNull(value.sourceCommit),
stageRef: stringOrNull(value.stageRef),
sha256: stringOrNull(value.sha256),
serviceCount: numberOrNull(value.serviceCount),
services: arrayRecords(value.services).slice(0, 8).map((service) => ({
id: stringOrNull(service.id),
runtimeReuse: recordOrNull(service.runtimeReuse),
envReuse: recordOrNull(service.envReuse),
})),
errors: arrayStrings(value.errors).slice(0, 5),
valuesRedacted: true,
};
}
function compactGitMirror(gitMirror) {
const value = recordOrNull(gitMirror);
if (value === null) return null;
+132 -8
View File
@@ -27,6 +27,7 @@ import { runNativeHwlabControlPlaneRefresh } from "./cicd-hwlab-refresh";
import { nativeCicdScriptLoadShell, readNativeObjectBundle } from "./cicd-native-bundle";
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 { prioritizedTaskRunItems } from "./cicd-taskruns";
import type { AdapterSummary, BranchFollowerAction, BranchFollowerDebugStep, BranchFollowerPhase, BranchFollowerRegistry, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, StageTiming, TriggerResult } from "./cicd-types";
import {
@@ -1048,12 +1049,16 @@ async function executeNativeHwlabNodeTrigger(registry: BranchFollowerRegistry, f
const spec = hwlabRuntimeLaneSpecForNode(follower.target.lane, follower.target.node);
const pipelineRun = nodeRuntimePipelineRunName(spec, observedSha);
const namespace = follower.nativeStatus.tekton.namespace;
const manifest = nodeRuntimePipelineRunManifest(spec, observedSha, pipelineRun);
const startedAt = Date.now();
const sync = runNativeGitMirrorStage(registry, follower, observedSha, "sync", Math.min(remainingSeconds(startedAt, timeoutSeconds), follower.budgets.sourceSyncSeconds));
if (sync !== null && !sync.result.ok) {
return nativeK8sStageFailure(follower, observedSha, "git-mirror-sync", sync.jobName, sync.result, { action: "sync" }, "native git-mirror sync failed", startedAt);
}
const reuseConfig = requireFollowerRuntimeReuseConfig(follower, observedSha, Math.min(remainingSeconds(startedAt, timeoutSeconds), follower.budgets.statusSeconds));
if (!reuseConfig.ok) return nativeReuseConfigFailure(follower, observedSha, reuseConfig, startedAt);
const hwlabReuseError = requiredReuseServiceError(reuseConfig, ["hwlab-cloud-api", "hwlab-runtime"], "runtimeReuse");
if (hwlabReuseError !== null) return nativeReuseConfigFailure(follower, observedSha, invalidRuntimeReuseConfig(reuseConfig, hwlabReuseError), startedAt);
const manifest = annotatePipelineRunReuseConfig(nodeRuntimePipelineRunManifest(spec, observedSha, pipelineRun), reuseConfig);
const refresh = runNativeHwlabControlPlaneRefresh(registry, follower, spec, observedSha, Math.min(remainingSeconds(startedAt, timeoutSeconds), follower.budgets.controlPlaneRefreshSeconds), nativeCapabilityJobName(follower.id, "control-plane-refresh", observedSha));
if (!refresh.result.ok) {
return nativeK8sStageFailure(follower, observedSha, "control-plane-refresh", refresh.jobName, refresh.result, { action: "control-plane-refresh" }, "native HWLAB control-plane refresh failed", startedAt);
@@ -1083,6 +1088,7 @@ async function executeNativeHwlabNodeTrigger(registry: BranchFollowerRegistry, f
payload: {
...payload,
nativeCapabilities: {
reuseConfig: summarizeRuntimeReuseConfig(reuseConfig),
gitMirrorSync: sync === null ? null : sync.result,
controlPlaneRefresh: refresh.result,
gitMirrorFlush: flush === null ? null : flush.result,
@@ -1108,18 +1114,23 @@ async function executeNativeAgentRunTrigger(registry: BranchFollowerRegistry, fo
if (sync !== null && !sync.result.ok) {
return nativeK8sStageFailure(follower, observedSha, "git-mirror-sync", sync.jobName, sync.result, { action: "sync" }, "native AgentRun git-mirror sync failed", startedAt);
}
const reuseConfig = requireFollowerRuntimeReuseConfig(follower, observedSha, Math.min(remainingSeconds(startedAt, timeoutSeconds), follower.budgets.statusSeconds));
if (!reuseConfig.ok) return nativeReuseConfigFailure(follower, observedSha, reuseConfig, startedAt);
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 buildJob = `${jobPrefix}-build-${observedSha.slice(0, 12)}`.slice(0, 63);
const build = runNativeK8sJob(spec.ci.namespace, buildJob, yamlLaneK3sBuildImageJobManifest(spec, observedSha, buildJob), Math.min(remainingSeconds(startedAt, timeoutSeconds), spec.deployment.manager.imageBuild.timeoutSeconds), "buildkit", registry.controller.budgets);
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 digest = stringOrNull(buildPayload.digest);
const envIdentity = stringOrNull(buildPayload.envIdentity);
if (!build.ok || digest === null || envIdentity === null) {
return nativeK8sStageFailure(follower, observedSha, "image-build", buildJob, build, buildPayload, "native AgentRun image build failed", startedAt);
}
const image = agentRunImageArtifact(spec, { sourceCommit: observedSha, envIdentity, digest, status: stringOrNull(buildPayload.status) ?? "built" });
const renderedFiles = renderAgentRunGitopsFiles(spec, { sourceCommit: observedSha, image });
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(spec.gitMirror.namespace, publishJob, yamlLaneGitopsPublishJobManifest(spec, renderedFiles, publishJob), remainingSeconds(startedAt, timeoutSeconds), "publish", registry.controller.budgets);
const publish = runNativeK8sJob(effectiveSpec.gitMirror.namespace, publishJob, yamlLaneGitopsPublishJobManifest(effectiveSpec, renderedFiles, publishJob), remainingSeconds(startedAt, timeoutSeconds), "publish", registry.controller.budgets);
const publishPayload = yamlLaneGitopsPublishPayloadFromProbe({ logsTail: stringOrNull(publish.logsTail) ?? "" });
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);
@@ -1128,8 +1139,8 @@ async function executeNativeAgentRunTrigger(registry: BranchFollowerRegistry, fo
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);
}
const pipelineRun = agentRunPipelineRunName(spec, observedSha);
const tektonResult = runNativeTektonPipelineRun(follower.nativeStatus.tekton.namespace, pipelineRun, yamlLanePipelineRunManifest(spec, observedSha, pipelineRun), options.wait, remainingSeconds(startedAt, timeoutSeconds), registry.controller.budgets);
const pipelineRun = agentRunPipelineRunName(effectiveSpec, observedSha);
const tektonResult = runNativeTektonPipelineRun(follower.nativeStatus.tekton.namespace, pipelineRun, yamlLanePipelineRunManifest(effectiveSpec, observedSha, pipelineRun), options.wait, remainingSeconds(startedAt, timeoutSeconds), registry.controller.budgets);
const tektonPayload = parseJsonObject(tektonResult.stdout) ?? {};
const pipelineRunCompleted = tektonPayload.completed === true;
const failed = tektonPayload.failed === true || tektonResult.exitCode !== 0;
@@ -1149,6 +1160,7 @@ async function executeNativeAgentRunTrigger(registry: BranchFollowerRegistry, fo
...tektonPayload,
agentrun: {
configPath,
reuseConfig: summarizeRuntimeReuseConfig(reuseConfig),
gitMirrorSync: sync === null ? null : { jobName: sync.jobName, payload: sync.result },
imageBuild: { jobName: buildJob, result: build, payload: buildPayload },
gitopsPublish: { jobName: publishJob, result: publish, payload: publishPayload },
@@ -1340,12 +1352,16 @@ async function executeNativeSentinelTrigger(registry: BranchFollowerRegistry, fo
}
const spec = hwlabRuntimeLaneSpecForNode(follower.target.lane, follower.target.node);
const stageRef = `${follower.source.snapshotPrefix.replace(/\/+$/u, "")}/${observedSha}`;
const reuseConfig = requireFollowerRuntimeReuseConfig(follower, observedSha, Math.min(timeoutSeconds, follower.budgets.statusSeconds));
if (!reuseConfig.ok) return nativeReuseConfigFailure(follower, observedSha, reuseConfig);
const sentinelReuseError = requiredReuseServiceError(reuseConfig, ["web-probe-sentinel", "monitor-web"], "envReuse");
if (sentinelReuseError !== null) return nativeReuseConfigFailure(follower, observedSha, invalidRuntimeReuseConfig(reuseConfig, sentinelReuseError));
const state = loadSentinelCicdState(spec, follower.target.sentinel, timeoutSeconds, "cached", {
commit: observedSha,
stageRef,
mirrorCommit: observedSha,
sourceAuthority: "git-mirror-snapshot",
});
}, reuseConfig);
const pipelineRun = sentinelPipelineRunName(state, false);
const namespace = stringField(recordField(state.cicd, "builder", `${follower.id}.sentinel.cicd`), "namespace", `${follower.id}.sentinel.cicd.builder`);
const manifest = sentinelPublishPipelineRunManifest(state, pipelineRun, true);
@@ -1393,6 +1409,7 @@ async function executeNativeSentinelTrigger(registry: BranchFollowerRegistry, fo
finishedAt,
elapsedMs: Date.now() - startedAt,
closeout,
reuseConfig: summarizeRuntimeReuseConfig(reuseConfig),
statusAuthority: "kubernetes-api-serviceaccount",
parsedDownstreamCliOutput: false,
payload,
@@ -1478,6 +1495,86 @@ async function waitNativeSentinelCloseout(
};
}
function requireFollowerRuntimeReuseConfig(follower: FollowerSpec, observedSha: string, timeoutSeconds: number): RuntimeReuseConfig {
const stageRef = `${follower.source.snapshotPrefix.replace(/\/+$/u, "")}/${observedSha}`;
const objectRef = `${stageRef}:${RUNTIME_REUSE_CONFIG_PATH}`;
const result = runCommand(["git", "--git-dir", follower.nativeStatus.source.repoPath, "show", objectRef], repoRoot, { timeoutMs: Math.max(1, timeoutSeconds) * 1000 });
if (result.exitCode !== 0) {
return missingRuntimeReuseConfig({ sourceCommit: observedSha, stageRef }, redactText(tailText(result.stderr || result.stdout || `${RUNTIME_REUSE_CONFIG_PATH} missing`, 500)));
}
return parseRuntimeReuseConfig(result.stdout, { sourceCommit: observedSha, stageRef });
}
function nativeReuseConfigFailure(follower: FollowerSpec, observedSha: string, reuseConfig: RuntimeReuseConfig, startedAt?: number): TriggerResult {
return {
ok: false,
completed: false,
message: `${RUNTIME_REUSE_CONFIG_PATH} is required for ${follower.id}: ${reuseConfig.errors[0] ?? "invalid reuse config"}`,
jobId: null,
command: {
mode: "k8s-native-reuse-config",
adapter: follower.adapter,
sourceCommit: observedSha,
ok: false,
startedAt: startedAt === undefined ? null : new Date(startedAt).toISOString(),
finishedAt: new Date().toISOString(),
elapsedMs: startedAt === undefined ? null : Date.now() - startedAt,
reuseConfig: summarizeRuntimeReuseConfig(reuseConfig),
statusAuthority: "git-mirror-snapshot",
parsedDownstreamCliOutput: false,
},
};
}
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<string, unknown>, reuseConfig: RuntimeReuseConfig): Record<string, unknown> {
const metadata = asOptionalRecord(manifest.metadata) ?? {};
const annotations = asOptionalRecord(metadata.annotations) ?? {};
metadata.annotations = {
...annotations,
"unidesk.ai/reuse-config-path": reuseConfig.path,
"unidesk.ai/reuse-config-sha256": reuseConfig.sha256 ?? "",
};
manifest.metadata = metadata;
return manifest;
}
function applyAgentRunReuseConfig(spec: ReturnType<typeof resolveAgentRunLaneTarget>["spec"], reuseConfig: RuntimeReuseConfig): ReturnType<typeof resolveAgentRunLaneTarget>["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<string, unknown> | null): boolean {
if (!nativeGitMirrorRequired(follower) || gitMirror === null) return false;
if (gitMirror.sourceSnapshotReady === false) return false;
@@ -1542,6 +1639,7 @@ function nativeCloseoutSummary(live: AdapterSummary): Record<string, unknown> {
pipelineRunPresent: live.pipelineRunPresent,
message: live.message,
gitMirror: asOptionalRecord(payload?.gitMirror),
reuseConfig: summarizeRuntimeReuseConfigFromRecord(asOptionalRecord(payload?.reuseConfig)),
tekton: asOptionalRecord(payload?.tekton),
taskRuns: compactTaskRunsPayload(asOptionalRecord(payload?.taskRuns)),
planArtifacts: compactPlanArtifactsPayload(asOptionalRecord(payload?.planArtifacts)),
@@ -1651,6 +1749,7 @@ async function readAdapterStatus(registry: BranchFollowerRegistry, follower: Fol
payload: {
source: bundle.source,
sourceSync: sourceSync === null ? null : sourceSync.result,
reuseConfig: observedSha === null ? null : summarizeRuntimeReuseConfig(requireFollowerRuntimeReuseConfig(follower, observedSha, Math.min(timeoutSeconds, 5))),
gitMirror: nativeGitMirrorSummary(bundle.gitMirror),
tekton: nativePipelineRunSummary(bundle.pipelineRun),
taskRuns: bundle.taskRuns,
@@ -2018,6 +2117,7 @@ function compactNativePayload(payload: Record<string, unknown> | null): Record<s
return {
source: compactSourcePayload(asOptionalRecord(payload.source)),
sourceSync: compactSourceSyncPayload(asOptionalRecord(payload.sourceSync)),
reuseConfig: summarizeRuntimeReuseConfigFromRecord(asOptionalRecord(payload.reuseConfig)),
gitMirror: asOptionalRecord(payload.gitMirror),
tekton: asOptionalRecord(payload.tekton),
taskRuns: compactTaskRunsPayload(asOptionalRecord(payload.taskRuns)),
@@ -2030,6 +2130,26 @@ function compactNativePayload(payload: Record<string, unknown> | null): Record<s
};
}
function summarizeRuntimeReuseConfigFromRecord(value: Record<string, unknown> | null): Record<string, unknown> | null {
if (value === null) return null;
return {
ok: value.ok === true,
present: value.present === true,
path: stringOrNull(value.path),
sourceCommit: stringOrNull(value.sourceCommit),
stageRef: stringOrNull(value.stageRef),
sha256: stringOrNull(value.sha256),
serviceCount: numberOrNull(value.serviceCount),
services: arrayRecords(value.services).slice(0, 8).map((service) => ({
id: stringOrNull(service.id),
runtimeReuse: asOptionalRecord(service.runtimeReuse),
envReuse: asOptionalRecord(service.envReuse),
})),
errors: Array.isArray(value.errors) ? value.errors.map(String).slice(0, 5) : [],
valuesRedacted: true,
};
}
function compactSourceSyncPayload(value: Record<string, unknown> | null): Record<string, unknown> | null {
if (value === null) return null;
return {
@@ -2263,6 +2383,10 @@ function stageTimingsFromNativePayload(payload: Record<string, unknown> | null):
stages.push(stageTiming("status-read", "ok", secondsFromMs(numberOrNull(statusRead?.elapsedMs)), numberOrNull(statusRead?.budgetSeconds), "native-status", null));
const sourceSyncStage = k8sJobTiming("git-mirror-sync", asOptionalRecord(payload.sourceSync));
if (sourceSyncStage !== null) stages.push(sourceSyncStage);
const reuseConfig = asOptionalRecord(payload.reuseConfig);
if (reuseConfig !== null) {
stages.push(stageTiming("reuse-config", reuseConfig.ok === true ? "ready" : "missing-or-invalid", null, null, "source-gitops", stringOrNull(reuseConfig.path)));
}
const gitMirror = asOptionalRecord(payload.gitMirror);
if (gitMirror !== null) {
const hasGitopsBranch = stringOrNull(gitMirror.gitopsBranch) !== null;
+11
View File
@@ -369,6 +369,7 @@ function compactAdapterStatus(live: AdapterSummary): Record<string, unknown> {
function compactStatusGates(payload: Record<string, unknown> | null): Record<string, unknown> | null {
if (payload === null) return null;
const gitMirror = asOptionalRecord(payload.gitMirror);
const reuseConfig = asOptionalRecord(payload.reuseConfig);
const tekton = asOptionalRecord(payload.tekton);
const taskRuns = asOptionalRecord(payload.taskRuns);
const argo = asOptionalRecord(payload.argo);
@@ -384,6 +385,16 @@ function compactStatusGates(payload: Record<string, unknown> | null): Record<str
localGitops: stringOrNull(gitMirror.localGitops),
githubGitops: stringOrNull(gitMirror.githubGitops),
},
reuseConfig: reuseConfig === null ? null : {
ok: reuseConfig.ok === true,
present: reuseConfig.present === true,
path: stringOrNull(reuseConfig.path),
sourceCommit: stringOrNull(reuseConfig.sourceCommit),
stageRef: stringOrNull(reuseConfig.stageRef),
sha256: stringOrNull(reuseConfig.sha256),
serviceCount: numberOrNull(reuseConfig.serviceCount),
errors: Array.isArray(reuseConfig.errors) ? reuseConfig.errors.map(String).slice(0, 5) : [],
},
tekton: tekton === null ? null : {
name: stringOrNull(tekton.name),
succeeded: tekton.succeeded === true ? true : tekton.succeeded === false ? false : null,
+6
View File
@@ -39,6 +39,12 @@ function nativeGateRows(native: Record<string, unknown> | null): unknown[][] {
: gitMirror.sourceSnapshotReady === true ? "source-ready" : "source-not-ready";
rows.push(["git-mirror", status, `${shortSha(stringOrNull(gitMirror.localSource))}/${shortSha(stringOrNull(gitMirror.githubSource))}`, stringOrNull(gitMirror.gitopsBranch) ?? stringOrNull(gitMirror.sourceBranch) ?? "-"]);
}
const reuseConfig = asOptionalRecord(native.reuseConfig);
if (reuseConfig !== null) {
const status = reuseConfig.ok === true ? "ready" : reuseConfig.present === true ? "invalid" : "missing";
const detail = reuseConfig.ok === true ? `${reuseConfig.serviceCount ?? "-"} services` : arrayTextItems(reuseConfig.errors)[0] ?? "-";
rows.push(["reuse-config", status, detail, stringOrNull(reuseConfig.path) ?? "-"]);
}
const tekton = asOptionalRecord(native.tekton);
if (tekton !== null) {
const status = tekton.succeeded === true ? "succeeded" : tekton.succeeded === false ? `failed:${stringOrNull(tekton.reason) ?? "unknown"}` : "running";
+211
View File
@@ -0,0 +1,211 @@
// SPEC: PJ2026-01060703 CI/CD branch follower reuse config.
// Responsibility: shared parser for runtime/env reuse declarations stored in source repos.
import { createHash } from "node:crypto";
export const RUNTIME_REUSE_CONFIG_PATH = "gitops/reuse.ymal";
export interface RuntimeReuseConfig {
ok: boolean;
present: boolean;
path: string;
sourceCommit: string | null;
stageRef: string | null;
sha256: string | null;
apiVersion: string | null;
kind: string | null;
serviceCount: number;
services: RuntimeReuseService[];
errors: string[];
valuesRedacted: true;
}
export interface RuntimeReuseService {
id: string;
runtimeReuse: {
enabled: boolean | null;
codeIdentityPaths: string[];
envIdentityPaths: string[];
} | null;
envReuse: {
enabled: boolean | null;
mode: string | null;
nodeDepsPath: string | null;
envIdentityFiles: string[];
buildArgs: Record<string, string>;
} | null;
}
export function parseRuntimeReuseConfig(text: string, source: { sourceCommit: string | null; stageRef: string | null }): RuntimeReuseConfig {
const errors: string[] = [];
let parsed: unknown = null;
try {
parsed = Bun.YAML.parse(text) as unknown;
} catch (error) {
errors.push(`${RUNTIME_REUSE_CONFIG_PATH} YAML parse failed: ${error instanceof Error ? error.message : String(error)}`);
}
const root = asOptionalRecord(parsed);
if (root === null) errors.push(`${RUNTIME_REUSE_CONFIG_PATH} must be a YAML object`);
const spec = asOptionalRecord(root?.spec) ?? root ?? {};
const services = parseServices(spec, errors);
if (services.length === 0) errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.services must declare at least one service`);
const kind = stringOrNull(root?.kind);
if (kind !== null && kind !== "RuntimeReuseConfig") errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.kind must be RuntimeReuseConfig`);
return {
ok: errors.length === 0,
present: true,
path: RUNTIME_REUSE_CONFIG_PATH,
sourceCommit: source.sourceCommit,
stageRef: source.stageRef,
sha256: createHash("sha256").update(text).digest("hex"),
apiVersion: stringOrNull(root?.apiVersion),
kind,
serviceCount: services.length,
services,
errors,
valuesRedacted: true,
};
}
export function missingRuntimeReuseConfig(source: { sourceCommit: string | null; stageRef: string | null }, reason: string): RuntimeReuseConfig {
return {
ok: false,
present: false,
path: RUNTIME_REUSE_CONFIG_PATH,
sourceCommit: source.sourceCommit,
stageRef: source.stageRef,
sha256: null,
apiVersion: null,
kind: null,
serviceCount: 0,
services: [],
errors: [reason],
valuesRedacted: true,
};
}
export function invalidRuntimeReuseConfig(config: RuntimeReuseConfig, reason: string): RuntimeReuseConfig {
return {
...config,
ok: false,
errors: [reason, ...config.errors].slice(0, 6),
};
}
export function summarizeRuntimeReuseConfig(config: RuntimeReuseConfig | null): Record<string, unknown> | null {
if (config === null) return null;
return {
ok: config.ok,
present: config.present,
path: config.path,
sourceCommit: config.sourceCommit,
stageRef: config.stageRef,
sha256: config.sha256,
serviceCount: config.serviceCount,
services: config.services.map((service) => ({
id: service.id,
runtimeReuse: service.runtimeReuse === null ? null : {
enabled: service.runtimeReuse.enabled,
codeIdentityPathCount: service.runtimeReuse.codeIdentityPaths.length,
envIdentityPathCount: service.runtimeReuse.envIdentityPaths.length,
},
envReuse: service.envReuse === null ? null : {
enabled: service.envReuse.enabled,
mode: service.envReuse.mode,
nodeDepsPath: service.envReuse.nodeDepsPath,
envIdentityFileCount: service.envReuse.envIdentityFiles.length,
buildArgNames: Object.keys(service.envReuse.buildArgs).sort(),
},
})),
errors: config.errors.slice(0, 5),
valuesRedacted: true,
};
}
export function runtimeReuseService(config: RuntimeReuseConfig | null, ids: readonly string[]): RuntimeReuseService | null {
if (config === null) return null;
const wanted = new Set(ids);
return config.services.find((service) => wanted.has(service.id)) ?? null;
}
function parseServices(spec: Record<string, unknown>, errors: string[]): RuntimeReuseService[] {
const raw = spec.services;
const items = Array.isArray(raw)
? raw.map((value, index) => ({ id: stringOrNull(asOptionalRecord(value)?.id) ?? String(index), value, path: `services[${index}]` }))
: Object.entries(asOptionalRecord(raw) ?? {}).map(([id, value]) => ({ id, value, path: `services.${id}` }));
return items.flatMap(({ id, value, path }) => {
const record = asOptionalRecord(value);
if (record === null) {
errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.${path} must be an object`);
return [];
}
const serviceId = stringOrNull(record.id) ?? id;
if (serviceId.length === 0) errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.${path}.id is required`);
return [{
id: serviceId,
runtimeReuse: parseRuntimeReuse(asOptionalRecord(record.runtimeReuse), `${path}.runtimeReuse`, errors),
envReuse: parseEnvReuse(asOptionalRecord(record.envReuse), `${path}.envReuse`, errors),
}];
});
}
function parseRuntimeReuse(record: Record<string, unknown> | null, path: string, errors: string[]): RuntimeReuseService["runtimeReuse"] {
if (record === null) return null;
return {
enabled: booleanOrNull(record.enabled),
codeIdentityPaths: safePathArray(asOptionalRecord(record.codeIdentity)?.paths ?? record.codeIdentityPaths, `${path}.codeIdentity.paths`, errors),
envIdentityPaths: safePathArray(asOptionalRecord(record.envIdentity)?.paths ?? record.envIdentityPaths, `${path}.envIdentity.paths`, errors),
};
}
function parseEnvReuse(record: Record<string, unknown> | null, path: string, errors: string[]): RuntimeReuseService["envReuse"] {
if (record === null) return null;
return {
enabled: booleanOrNull(record.enabled),
mode: stringOrNull(record.mode),
nodeDepsPath: stringOrNull(record.nodeDepsPath),
envIdentityFiles: safePathArray(record.envIdentityFiles ?? asOptionalRecord(record.envIdentity)?.paths, `${path}.envIdentityFiles`, errors),
buildArgs: stringRecord(asOptionalRecord(record.buildArgs), `${path}.buildArgs`, errors),
};
}
function safePathArray(value: unknown, path: string, errors: string[]): string[] {
if (value === undefined || value === null) return [];
if (!Array.isArray(value)) {
errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.${path} must be a string array`);
return [];
}
return value.flatMap((item, index) => {
if (typeof item !== "string" || item.length === 0) {
errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.${path}[${index}] must be a non-empty string`);
return [];
}
if (item.startsWith("/") || item.includes("..")) {
errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.${path}[${index}] must be a relative path without ..`);
return [];
}
return [item];
});
}
function stringRecord(record: Record<string, unknown> | null, path: string, errors: string[]): Record<string, string> {
if (record === null) return {};
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(record)) {
if (typeof value !== "string") errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.${path}.${key} must be a string`);
else out[key] = value;
}
return out;
}
function asOptionalRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function stringOrNull(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function booleanOrNull(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null;
}
+29 -5
View File
@@ -15,6 +15,7 @@ import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { repoRoot, rootPath } from "./config";
import { runCommand, type CommandResult } from "./command";
import { runtimeReuseService, summarizeRuntimeReuseConfig, type RuntimeReuseConfig } from "./cicd-reuse-config";
import { startJob } from "./jobs";
import { webProbeSentinelConfigPlan, withWebProbeSentinelConfigRendered } from "./hwlab-node-web-sentinel-config";
import { readWebProbeSentinelConfigRefTarget } from "./hwlab-node-web-sentinel-config-ref";
@@ -547,6 +548,7 @@ export function loadSentinelCicdState(
timeoutSeconds: number,
sourceResolveMode: SourceResolveMode,
sourceOverride: SentinelSourceOverride | null = null,
reuseConfig: RuntimeReuseConfig | null = null,
): SentinelCicdState {
const sentinel = resolveWebProbeSentinel(spec, sentinelId);
const configPlan = webProbeSentinelConfigPlan(spec, "status", sentinel.id);
@@ -562,11 +564,12 @@ export function loadSentinelCicdState(
const nodeId = stringField(controlPlaneTarget, "node");
const controlPlaneNode = recordTarget(valueAtPath(controlPlaneConfig, `nodes.${nodeId}`), `${configRefFile(controlPlaneRef)}#nodes.${nodeId}`);
validateSentinelSourceAuthority(cicd);
const effectiveCicd = applySentinelRuntimeReuseConfig(cicd, reuseConfig);
const sourceHead = sourceOverride === null
? resolveSourceHead(spec, cicd, controlPlaneTarget, controlPlaneNode, timeoutSeconds, sourceResolveMode)
: sourceHeadFromOverride(cicd, sourceOverride);
const image = sentinelImagePlan(spec, cicd, sourceHead);
const manifests = renderSentinelManifests(spec, sentinel.id, runtime, cicd, scenarios, publicExposure, secrets, image, sourceHead);
? resolveSourceHead(spec, effectiveCicd, controlPlaneTarget, controlPlaneNode, timeoutSeconds, sourceResolveMode)
: sourceHeadFromOverride(effectiveCicd, sourceOverride);
const image = sentinelImagePlan(spec, effectiveCicd, sourceHead);
const manifests = renderSentinelManifests(spec, sentinel.id, runtime, effectiveCicd, scenarios, publicExposure, secrets, image, sourceHead);
const manifestYaml = `${manifests.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`;
return {
spec,
@@ -574,7 +577,7 @@ export function loadSentinelCicdState(
configRefs: sentinel.configRefs,
configReady: configPlan.ok,
runtime,
cicd,
cicd: effectiveCicd,
scenarios,
publicExposure,
secrets,
@@ -588,6 +591,27 @@ export function loadSentinelCicdState(
};
}
function applySentinelRuntimeReuseConfig(cicd: Record<string, unknown>, reuseConfig: RuntimeReuseConfig | null): Record<string, unknown> {
const service = runtimeReuseService(reuseConfig, ["web-probe-sentinel", "monitor-web"]);
const envReuse = service?.envReuse;
if (envReuse === undefined || envReuse === null) return cicd;
if (envReuse.enabled === false) return cicd;
const monitorWeb = record(cicd.monitorWeb);
const existingEnvReuse = record(monitorWeb.envReuse);
return {
...cicd,
monitorWeb: {
...monitorWeb,
envReuse: {
...existingEnvReuse,
...(envReuse.mode === null ? {} : { mode: envReuse.mode }),
...(envReuse.nodeDepsPath === null ? {} : { nodeDepsPath: envReuse.nodeDepsPath }),
source: summarizeRuntimeReuseConfig(reuseConfig),
},
},
};
}
function sourceHeadFromOverride(cicd: Record<string, unknown>, override: SentinelSourceOverride): SourceHead {
if (!/^[0-9a-f]{40}$/iu.test(override.commit)) throw new Error(`sentinel source override commit must be a full sha, got ${override.commit}`);
const stageRef = override.stageRef ?? sentinelSourceSnapshotRef(cicd, override.commit);