diff --git a/.agents/skills/unidesk-cicd/references/branch-follower.md b/.agents/skills/unidesk-cicd/references/branch-follower.md index 39ca0292..bf94eb39 100644 --- a/.agents/skills/unidesk-cicd/references/branch-follower.md +++ b/.agents/skills/unidesk-cicd/references/branch-follower.md @@ -106,6 +106,8 @@ The controller must preserve the runtime reuse capabilities that already exist i - env reuse: if code changed but env identity is unchanged, reuse the previous environment image and publish only the changed service artifact; - git mirror: source sync, immutable source snapshot creation and GitOps flush are generic branch-follower stages, not adapter-local afterthoughts. +Runtime/env reuse configuration for branch-followed source repositories must live in the followed repository at `./gitops/reuse.ymal`. The branch-follower reads that file from the k8s git-mirror source snapshot, parses it through the shared reuse-config parser, and passes only the bounded redacted summary to adapter status/trigger payloads. Do not keep separate adapter-local reuse config as the authoritative source for branch-follower runs. + Adapters should expose reuse evidence through compact native state. HWLAB uses the `plan-artifacts` task event summary (`affectedServices`, `buildServices`, `reusedServices`, `artifactProvenanceAudit`). AgentRun publishes deterministic image/GitOps/git-mirror stage names and source-commit labels so a later loop can resume closeout without rebuilding completed stages. Sentinel keeps the same source/CI/Argo/runtime contract but has no GitOps branch flush gate. The normal convergence budget is 120 seconds per source change. A follower may report `ClosingOut` while waiting for Argo/runtime readiness, but it must not report `Noop` when the source sha matches and required native gates such as git-mirror flush are still incomplete. diff --git a/gitops/reuse.ymal b/gitops/reuse.ymal new file mode 100644 index 00000000..7c1a593a --- /dev/null +++ b/gitops/reuse.ymal @@ -0,0 +1,22 @@ +apiVersion: unidesk.pikapython.com/v1alpha1 +kind: RuntimeReuseConfig +metadata: + name: unidesk-web-probe-sentinel-reuse + ownerRepository: pikasTech/unidesk +spec: + services: + - id: web-probe-sentinel + runtimeReuse: + enabled: true + codeIdentity: + paths: + - scripts/assets/web-probe-sentinel-monitor-web + - scripts/verify-web-probe-sentinel-monitor-web.ts + envIdentity: + paths: + - package.json + - bun.lock + envReuse: + enabled: true + mode: k8s-buildkit-and-ci-node-deps + nodeDepsPath: /opt/hwlab-ci-node-deps/node_modules diff --git a/scripts/native/cicd/read-state-summary.mjs b/scripts/native/cicd/read-state-summary.mjs index c5a9fffe..b10fd6ea 100644 --- a/scripts/native/cicd/read-state-summary.mjs +++ b/scripts/native/cicd/read-state-summary.mjs @@ -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; diff --git a/scripts/src/cicd-branch-follower.ts b/scripts/src/cicd-branch-follower.ts index ade468fd..70bce39c 100644 --- a/scripts/src/cicd-branch-follower.ts +++ b/scripts/src/cicd-branch-follower.ts @@ -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, reuseConfig: RuntimeReuseConfig): Record { + 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["spec"], reuseConfig: RuntimeReuseConfig): ReturnType["spec"] { + const service = runtimeReuseService(reuseConfig, ["agentrun-mgr", "manager"]); + const envReuse = service?.envReuse; + if (envReuse === undefined || envReuse === null) return spec; + if (envReuse.enabled === false) return spec; + const imageBuild = spec.deployment.manager.imageBuild; + return { + ...spec, + deployment: { + ...spec.deployment, + manager: { + ...spec.deployment.manager, + imageBuild: { + ...imageBuild, + buildArgs: Object.keys(envReuse.buildArgs).length === 0 ? imageBuild.buildArgs : envReuse.buildArgs, + envIdentityFiles: envReuse.envIdentityFiles.length === 0 ? imageBuild.envIdentityFiles : envReuse.envIdentityFiles, + }, + }, + }, + }; +} + function shouldFlushNativeGitMirrorDuringCloseout(follower: FollowerSpec, gitMirror: Record | null): boolean { if (!nativeGitMirrorRequired(follower) || gitMirror === null) return false; if (gitMirror.sourceSnapshotReady === false) return false; @@ -1542,6 +1639,7 @@ function nativeCloseoutSummary(live: AdapterSummary): Record { 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 | null): Record | null): Record | null): Record | 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 | null): Record | null { if (value === null) return null; return { @@ -2263,6 +2383,10 @@ function stageTimingsFromNativePayload(payload: Record | 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; diff --git a/scripts/src/cicd-debug.ts b/scripts/src/cicd-debug.ts index e54890e1..3862f886 100644 --- a/scripts/src/cicd-debug.ts +++ b/scripts/src/cicd-debug.ts @@ -369,6 +369,7 @@ function compactAdapterStatus(live: AdapterSummary): Record { function compactStatusGates(payload: Record | null): Record | 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 | null): Record | 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"; diff --git a/scripts/src/cicd-reuse-config.ts b/scripts/src/cicd-reuse-config.ts new file mode 100644 index 00000000..1a548d2e --- /dev/null +++ b/scripts/src/cicd-reuse-config.ts @@ -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; + } | 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 | 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, 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 | 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 | 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 | null, path: string, errors: string[]): Record { + if (record === null) return {}; + const out: Record = {}; + 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 | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : 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; +} diff --git a/scripts/src/hwlab-node-web-sentinel-cicd.ts b/scripts/src/hwlab-node-web-sentinel-cicd.ts index aca03b5c..b288d0e6 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd.ts @@ -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, reuseConfig: RuntimeReuseConfig | null): Record { + 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, 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);