feat: load branch follower reuse config from source repos
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user