// SPEC: PJ2026-01060703 CI/CD branch follower native object bundle reader. // Responsibility: read compact Kubernetes-native source/Tekton/Argo/runtime state through file-backed scripts. import { readFileSync } from "node:fs"; import { rootPath } from "./config"; import type { CommandResult } from "./command"; import { resolveAgentRunLaneTarget } from "./agentrun-lanes"; import type { BranchFollowerRegistry, FollowerSpec, NativeObjectBundle, ParsedOptions } from "./cicd-types"; import { hwlabRuntimeLaneSpecForNode } from "./hwlab-node-lanes"; import { redactText, shQuote } from "./platform-infra-ops-library"; type KubeScriptRunner = (registry: BranchFollowerRegistry, options: ParsedOptions, script: string, input: string, timeoutMs: number) => CommandResult; const NATIVE_BUNDLE_SCRIPT_NAMES = [ "read-native-bundle.sh", "kube-get.mjs", "compact-native-object.mjs", "compact-git-mirror.mjs", "plan-artifacts.mjs", ] as const; export function readNativeObjectBundle(registry: BranchFollowerRegistry, follower: FollowerSpec, options: ParsedOptions, timeoutSeconds: number, runKubeScript: KubeScriptRunner): NativeObjectBundle { const native = follower.nativeStatus; const source = native.source; const tekton = native.tekton; const argo = native.argo; const runtime = native.runtime; const gitopsBranch = nativeGitMirrorGitopsBranch(follower); const workloadRefs = (runtime?.workloads ?? []).map((workload, index) => { const resource = workload.kind === "Deployment" ? "deployments" : "statefulsets"; return `workload${index}\t/apis/apps/v1/namespaces/${runtime?.namespace ?? follower.target.namespace}/${resource}/${workload.name}`; }); const workloadRefsText = workloadRefs.length === 0 ? "" : `${workloadRefs.join("\n")}\n`; const script = [ "set +e", "tmpdir=$(mktemp -d)", "cleanup() { rm -rf \"$tmpdir\"; }", "trap cleanup EXIT INT TERM", nativeCicdScriptLoadShell(NATIVE_BUNDLE_SCRIPT_NAMES), `REPO_PATH=${shQuote(source.repoPath)}`, `SOURCE_BRANCH=${shQuote(follower.source.branch)}`, `REPOSITORY=${shQuote(follower.source.repository)}`, `SNAPSHOT_PREFIX=${shQuote(follower.source.snapshotPrefix)}`, `GITOPS_BRANCH=${shQuote(gitopsBranch ?? "")}`, `TEKTON_NAMESPACE=${shQuote(tekton?.namespace ?? "")}`, `PIPELINE_RUN_PREFIX=${shQuote(tekton?.pipelineRunPrefix ?? "")}`, `ARGO_NAMESPACE=${shQuote(argo?.namespace ?? "")}`, `ARGO_APPLICATION=${shQuote(argo?.application ?? "")}`, `WORKLOAD_REFS_B64=${shQuote(Buffer.from(workloadRefsText, "utf8").toString("base64"))}`, "NATIVE_CICD_SCRIPT_DIR=\"$tmpdir\"", "export NATIVE_CICD_SCRIPT_DIR REPO_PATH SOURCE_BRANCH REPOSITORY SNAPSHOT_PREFIX GITOPS_BRANCH TEKTON_NAMESPACE PIPELINE_RUN_PREFIX ARGO_NAMESPACE ARGO_APPLICATION WORKLOAD_REFS_B64", "\"$tmpdir/read-native-bundle.sh\"", ].join("\n"); const startedAt = Date.now(); const result = runKubeScript(registry, options, script, "", timeoutSeconds * 1000); const parsed = parseNativeBundleLines(result.stdout); const sourceRecord = asOptionalRecord(parsed.objects.source); return { ok: result.exitCode === 0 && sourceRecord !== null && parsed.fatalErrors.length === 0, source: sourceRecord, gitMirror: asOptionalRecord(parsed.objects.gitMirror), pipelineRun: asOptionalRecord(parsed.objects.pipelineRun), pipeline: asOptionalRecord(parsed.objects.pipeline), taskRuns: asOptionalRecord(parsed.objects.taskRuns), planArtifacts: asOptionalRecord(parsed.objects.planArtifacts), argoApplication: asOptionalRecord(parsed.objects.argoApplication), workloads: Object.entries(parsed.objects) .filter(([key]) => /^workload\d+$/u.test(key)) .sort(([left], [right]) => left.localeCompare(right)) .map(([, value]) => asOptionalRecord(value)) .filter((item): item is Record => item !== null), errors: [ ...parsed.errors, ...(result.exitCode === 0 ? [] : [`native bundle command failed: exitCode=${result.exitCode}`]), ...parsed.fatalErrors, ], exitCode: result.exitCode, timedOut: result.timedOut, elapsedMs: Date.now() - startedAt, stdoutTail: redactText(tailText(result.stdout, 1000)), stderrTail: redactText(tailText(result.stderr, 1000)), }; } export function nativeCicdScriptLoadShell(names: readonly string[]): string { return names.map((name) => { const encoded = Buffer.from(readFileSync(rootPath("scripts/native/cicd", name), "utf8"), "utf8").toString("base64"); return [ `printf '%s' ${shQuote(encoded)} | base64 -d > "$tmpdir/${name}"`, `chmod +x "$tmpdir/${name}"`, ].join("\n"); }).join("\n"); } function parseNativeBundleLines(stdout: string): { objects: Record; errors: string[]; fatalErrors: string[] } { const objects: Record = {}; const errors: string[] = []; const fatalErrors: string[] = []; for (const line of stdout.split(/\r?\n/u)) { if (!line.startsWith("UNIDESK_NATIVE_")) continue; const [kind, key, payload] = line.split("\t"); if (kind === undefined || key === undefined || payload === undefined) continue; const decoded = Buffer.from(payload, "base64").toString("utf8").trim(); if (kind === "UNIDESK_NATIVE_JSON") { const parsed = parseJsonObject(decoded); if (parsed !== null) objects[key] = parsed; else errors.push(`${key}: invalid native JSON payload`); } else if (kind === "UNIDESK_NATIVE_ERROR") { const message = `${key}: ${redactText(tailText(decoded, 500)) || "not found"}`; errors.push(message); if (key === "source") fatalErrors.push(message); } } return { objects, errors, fatalErrors }; } function nativeGitMirrorGitopsBranch(follower: FollowerSpec): string | null { if (follower.adapter === "hwlab-node-runtime") { return hwlabRuntimeLaneSpecForNode(follower.target.lane, follower.target.node).gitopsBranch; } if (follower.adapter === "agentrun-yaml-lane") { return resolveAgentRunLaneTarget({ node: follower.target.node, lane: follower.target.lane }).spec.gitops.branch; } return null; } function parseJsonObject(text: string): Record | null { const trimmed = text.trim(); if (trimmed.length === 0) return null; try { return asOptionalRecord(JSON.parse(trimmed)); } catch { return null; } } function asOptionalRecord(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; } function tailText(text: string, maxChars: number): string { return text.length <= maxChars ? text : text.slice(text.length - maxChars); }