diff --git a/scripts/native/cicd/branch-follower-gate.mjs b/scripts/native/cicd/branch-follower-gate.mjs new file mode 100644 index 00000000..6604ab9f --- /dev/null +++ b/scripts/native/cicd/branch-follower-gate.mjs @@ -0,0 +1,388 @@ +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import http from "node:http"; +import https from "node:https"; + +const gate = requiredEnv("GATE"); +const follower = requiredEnv("FOLLOWER_ID"); +const repository = requiredEnv("REPOSITORY"); +const repoPath = requiredEnv("REPO_PATH"); +const sourceBranch = requiredEnv("SOURCE_BRANCH"); +const snapshotPrefix = requiredEnv("SNAPSHOT_PREFIX").replace(/\/+$/u, ""); +const selectedSource = process.env.SOURCE_COMMIT || ""; +const gitopsBranch = process.env.GITOPS_BRANCH || ""; +const tektonNamespace = process.env.TEKTON_NAMESPACE || ""; +const pipelineRunPrefix = process.env.PIPELINE_RUN_PREFIX || ""; +const argoNamespace = process.env.ARGO_NAMESPACE || ""; +const argoApplication = process.env.ARGO_APPLICATION || ""; +const runtimeNamespace = process.env.RUNTIME_NAMESPACE || ""; +const workloads = parseWorkloads(process.env.WORKLOADS_B64 || ""); +const healthUrl = process.env.HEALTH_URL || ""; + +const errors = []; +const branchCommit = rev(`refs/heads/${sourceBranch}`); +const sourceCommit = selectedSource || branchCommit || ""; +const sourceStageRef = sourceCommit ? `${snapshotPrefix}/${sourceCommit}` : null; +const sourceSnapshot = sourceStageRef ? rev(sourceStageRef) : null; +const source = { + repository, + branch: sourceBranch, + sourceCommit: sourceCommit || null, + stageRef: sourceStageRef, + snapshotReady: Boolean(sourceCommit && sourceSnapshot === sourceCommit), + authority: "k8s-git-mirror-snapshot", +}; +const gitMirror = gitMirrorSummary(sourceCommit); + +let evidence; +if (gate === "reuse-plan") evidence = reusePlanEvidence(sourceCommit); +else if (gate === "ci-taskrun-plan") evidence = await ciTaskRunEvidence(sourceCommit); +else if (gate === "cd-rollout-plan") evidence = await cdRolloutEvidence(sourceCommit); +else if (gate === "post-deploy-health") evidence = await postDeployHealthEvidence(sourceCommit); +else fail(`unsupported gate ${gate}`); + +const ok = errors.length === 0 && evidence?.ok === true; +console.log(JSON.stringify({ + ok, + gate, + follower, + source, + evidence, + errors: errors.slice(0, 6), + statusAuthority: "kubernetes-api-serviceaccount", + parsedDownstreamCliOutput: false, + bounded: true, +})); + +function reusePlanEvidence(commit) { + const reuse = readReuseConfig(commit); + return { + ok: source.snapshotReady && gitMirror.ok === true && reuse.present === true && reuse.serviceCount > 0, + gitMirror, + reuse, + }; +} + +async function ciTaskRunEvidence(commit) { + if (!commit || !tektonNamespace || !pipelineRunPrefix) return notConfigured("tekton"); + const pipelineRunName = `${pipelineRunPrefix}-${commit.slice(0, 12)}`; + const pipelineRun = await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/pipelineruns/${encodeURIComponent(pipelineRunName)}`, false); + const prStatus = pipelineRunStatus(pipelineRun); + const pipelineRef = str(pipelineRun?.spec?.pipelineRef?.name); + const pipeline = pipelineRef ? await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/pipelines/${encodeURIComponent(pipelineRef)}`, false) : null; + const taskRuns = await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/taskruns?labelSelector=${encodeURIComponent(`tekton.dev/pipelineRun=${pipelineRunName}`)}`, false); + const taskSummary = taskRunsSummary(taskRuns); + return { + ok: prStatus.succeeded === true && taskSummary.failedCount === 0 && taskSummary.activeCount === 0, + pipelineRun: prStatus, + pipeline: { + name: pipelineRef, + taskCount: Array.isArray(pipeline?.spec?.tasks) ? pipeline.spec.tasks.length : null, + tasks: Array.isArray(pipeline?.spec?.tasks) ? pipeline.spec.tasks.slice(0, 12).map((task) => ({ name: str(task?.name), runAfter: Array.isArray(task?.runAfter) ? task.runAfter.slice(0, 6) : [] })) : [], + }, + taskRuns: taskSummary, + }; +} + +async function cdRolloutEvidence(commit) { + const argo = await argoSummary(); + const runtime = await runtimeSummary(commit); + return { + ok: gitMirror.githubInSync !== false && argo.ready === true && runtime.ready === true && runtime.aligned === true, + gitMirror, + argo, + runtime, + }; +} + +async function postDeployHealthEvidence(commit) { + const runtime = await runtimeSummary(commit); + const health = await healthSummary(); + return { + ok: runtime.ready === true && runtime.aligned === true && health.ok === true, + runtime, + health, + }; +} + +function readReuseConfig(commit) { + if (!commit || !sourceStageRef) return { present: false, reason: "source-commit-missing" }; + try { + const text = execFileSync("git", [`--git-dir=${repoPath}`, "show", `${sourceStageRef}:gitops/reuse.ymal`], { encoding: "utf8", maxBuffer: 256 * 1024 }); + const services = reuseServiceIds(text); + return { + present: true, + path: "gitops/reuse.ymal", + bytes: Buffer.byteLength(text, "utf8"), + sha256: createHash("sha256").update(text).digest("hex"), + serviceCount: services.length, + serviceIds: services.slice(0, 12), + runtimeReuseMentioned: /\bruntimeReuse\b/u.test(text), + envReuseMentioned: /\benvReuse\b/u.test(text), + }; + } catch (error) { + return { present: false, path: "gitops/reuse.ymal", reason: shortText(error?.message || String(error)) }; + } +} + +function reuseServiceIds(text) { + const ids = new Set([...text.matchAll(/(?:^|\n)\s*-\s*id:\s*([A-Za-z0-9_.-]+)/gu)].map((match) => match[1]).filter(Boolean)); + const lines = text.split(/\r?\n/u); + let inServices = false; + let servicesIndent = 0; + for (const line of lines) { + const services = /^(\s*)services:\s*$/u.exec(line); + if (services) { + inServices = true; + servicesIndent = services[1].length; + continue; + } + if (!inServices) continue; + const match = /^(\s*)([A-Za-z0-9_.-]+):\s*$/u.exec(line); + if (!match) continue; + const indent = match[1].length; + if (indent <= servicesIndent) { + inServices = false; + continue; + } + if (indent === servicesIndent + 2) ids.add(match[2]); + } + return Array.from(ids); +} + +function gitMirrorSummary(commit) { + const localSource = rev(`refs/heads/${sourceBranch}`); + const githubSource = rev(`refs/mirror-stage/heads/${sourceBranch}`); + const localGitops = gitopsBranch ? rev(`refs/heads/${gitopsBranch}`) : null; + const githubGitops = gitopsBranch ? rev(`refs/mirror-stage/heads/${gitopsBranch}`) : null; + return { + ok: Boolean(localSource) && source.snapshotReady && (!gitopsBranch || !localGitops || localGitops === githubGitops), + localSource, + githubSource, + sourceStageRef, + sourceSnapshot, + sourceSnapshotReady: source.snapshotReady, + gitopsBranch: gitopsBranch || null, + localGitops, + githubGitops, + pendingFlush: gitopsBranch ? Boolean(localGitops && localGitops !== githubGitops) : null, + githubInSync: gitopsBranch ? Boolean(!localGitops || localGitops === githubGitops) : null, + }; +} + +async function argoSummary() { + if (!argoNamespace || !argoApplication) return { ready: null, reason: "argo-not-configured" }; + const app = await getJson(`/apis/argoproj.io/v1alpha1/namespaces/${encodeURIComponent(argoNamespace)}/applications/${encodeURIComponent(argoApplication)}`, false); + const sync = app?.status?.sync || {}; + const health = app?.status?.health || {}; + const op = app?.status?.operationState || {}; + return { + name: argoApplication, + namespace: argoNamespace, + syncStatus: str(sync.status), + healthStatus: str(health.status), + revision: str(sync.revision), + operationPhase: str(op.phase), + operationMessage: str(op.message), + ready: sync.status === "Synced" && health.status === "Healthy", + }; +} + +async function runtimeSummary(expected) { + if (!runtimeNamespace || workloads.length === 0) return { ready: null, aligned: null, reason: "runtime-not-configured" }; + const rows = []; + for (const workload of workloads) { + const resource = workload.kind === "StatefulSet" ? "statefulsets" : "deployments"; + const item = await getJson(`/apis/apps/v1/namespaces/${encodeURIComponent(runtimeNamespace)}/${resource}/${encodeURIComponent(workload.name)}`, false); + rows.push(workloadSummary(workload, item, expected)); + } + return { + namespace: runtimeNamespace, + expectedSha: shortSha(expected), + ready: rows.length > 0 && rows.every((row) => row.ready === true), + aligned: rows.length > 0 && rows.every((row) => row.aligned === true), + workloads: rows, + }; +} + +async function healthSummary() { + if (!healthUrl) return { ok: null, reason: "health-url-not-configured" }; + const targets = [`${healthUrl.replace(/\/+$/u, "")}/health/readiness`, `${healthUrl.replace(/\/+$/u, "")}/health/live`]; + const probes = []; + for (const url of targets) probes.push(await httpProbe(url)); + return { ok: probes.every((probe) => probe.ok), probes }; +} + +function workloadSummary(spec, item, expected) { + const status = item?.status || {}; + const desired = item?.spec?.replicas ?? 1; + const readyCount = spec.kind === "StatefulSet" ? status.readyReplicas ?? 0 : status.availableReplicas ?? 0; + const updated = status.updatedReplicas ?? readyCount; + const commit = workloadCommit(spec, item); + return { + kind: spec.kind, + name: spec.name, + ready: readyCount >= desired && updated >= desired, + desired, + readyReplicas: readyCount, + updatedReplicas: updated, + sourceCommit: shortSha(commit), + aligned: Boolean(expected && commit && commit === expected), + }; +} + +function workloadCommit(spec, item) { + const meta = item?.metadata || {}; + const tmpl = item?.spec?.template || {}; + const podMeta = tmpl.metadata || {}; + for (const key of spec.sourceCommit.labels || []) if (sha(meta.labels?.[key])) return meta.labels[key]; + for (const key of spec.sourceCommit.annotations || []) if (sha(meta.annotations?.[key])) return meta.annotations[key]; + for (const key of spec.sourceCommit.podLabels || []) if (sha(podMeta.labels?.[key])) return podMeta.labels[key]; + for (const key of spec.sourceCommit.podAnnotations || []) if (sha(podMeta.annotations?.[key])) return podMeta.annotations[key]; + for (const envName of spec.sourceCommit.env || []) { + for (const container of tmpl.spec?.containers || []) { + for (const env of container.env || []) if (env?.name === envName && sha(env.value)) return env.value; + } + } + return null; +} + +function pipelineRunStatus(value) { + const condition = (value?.status?.conditions || []).find((item) => item?.type === "Succeeded") || null; + return { + name: str(value?.metadata?.name), + namespace: str(value?.metadata?.namespace), + pipelineRefName: str(value?.spec?.pipelineRef?.name), + present: value !== null, + succeeded: condition?.status === "True" ? true : condition?.status === "False" ? false : null, + reason: str(condition?.reason), + startTime: str(value?.status?.startTime), + completionTime: str(value?.status?.completionTime), + durationSeconds: durationSeconds(value?.status?.startTime, value?.status?.completionTime), + }; +} + +function taskRunsSummary(list) { + const items = Array.isArray(list?.items) ? list.items : []; + const rows = items.map((item) => { + const condition = (item?.status?.conditions || []).find((entry) => entry?.type === "Succeeded") || {}; + return { + name: str(item?.metadata?.name), + taskName: str(item?.metadata?.labels?.["tekton.dev/pipelineTask"]) || str(item?.spec?.taskRef?.name), + status: str(condition.status) || "Unknown", + reason: str(condition.reason), + durationSeconds: durationSeconds(item?.status?.startTime, item?.status?.completionTime), + }; + }); + const failed = rows.filter((item) => item.status === "False"); + const active = rows.filter((item) => item.status !== "True" && item.status !== "False"); + const slow = rows.filter((item) => typeof item.durationSeconds === "number" && item.durationSeconds >= 60); + return { + count: rows.length, + failedCount: failed.length, + activeCount: active.length, + slowCount: slow.length, + failedItems: failed.slice(0, 5), + activeItems: active.slice(0, 5), + slowItems: slow.slice(0, 5), + items: rows.slice(0, 12), + }; +} + +async function getJson(path, required) { + const host = process.env.KUBERNETES_SERVICE_HOST; + const port = Number(process.env.KUBERNETES_SERVICE_PORT || "443"); + const tokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"; + const caPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"; + if (!host || !existsSync(tokenPath) || !existsSync(caPath)) fail("kubernetes serviceaccount is unavailable"); + const token = readFileSync(tokenPath, "utf8").trim(); + const ca = readFileSync(caPath); + return await new Promise((resolve, reject) => { + const req = https.request({ host, port, path, method: "GET", ca, headers: { authorization: `Bearer ${token}` } }, (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { body += chunk; }); + res.on("end", () => { + if (res.statusCode === 404 && !required) return resolve(null); + if ((res.statusCode || 0) < 200 || (res.statusCode || 0) >= 300) return reject(new Error(shortText(body || `kube api ${res.statusCode}`))); + try { resolve(JSON.parse(body)); } catch (error) { reject(error); } + }); + }); + req.on("error", reject); + req.end(); + }).catch((error) => { + errors.push(`${path}: ${shortText(error?.message || String(error))}`); + return null; + }); +} + +async function httpProbe(url) { + const client = url.startsWith("https:") ? https : http; + const started = Date.now(); + return await new Promise((resolve) => { + const req = client.get(url, { timeout: 5000 }, (res) => { + res.resume(); + res.on("end", () => resolve({ url, ok: (res.statusCode || 0) >= 200 && (res.statusCode || 0) < 300, statusCode: res.statusCode || null, elapsedMs: Date.now() - started })); + }); + req.on("timeout", () => { req.destroy(); resolve({ url, ok: false, statusCode: null, elapsedMs: Date.now() - started, reason: "timeout" }); }); + req.on("error", (error) => resolve({ url, ok: false, statusCode: null, elapsedMs: Date.now() - started, reason: shortText(error?.message || String(error)) })); + }); +} + +function parseWorkloads(value) { + if (!value) return []; + try { + const parsed = JSON.parse(Buffer.from(value, "base64").toString("utf8")); + return Array.isArray(parsed) ? parsed.filter((item) => item && typeof item === "object").slice(0, 8) : []; + } catch { + return []; + } +} + +function rev(ref) { + try { + const out = execFileSync("git", [`--git-dir=${repoPath}`, "rev-parse", "--verify", `${ref}^{commit}`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + return sha(out) ? out : null; + } catch { + return null; + } +} + +function durationSeconds(start, finish) { + if (!start || !finish) return null; + const value = (Date.parse(finish) - Date.parse(start)) / 1000; + return Number.isFinite(value) && value >= 0 ? Math.round(value * 10) / 10 : null; +} + +function notConfigured(name) { + return { ok: false, reason: `${name}-not-configured` }; +} + +function str(value) { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function sha(value) { + return typeof value === "string" && /^[0-9a-f]{40}$/iu.test(value); +} + +function shortSha(value) { + return sha(value) ? value.slice(0, 12) : null; +} + +function shortText(value) { + const text = String(value || "").replace(/\s+/gu, " ").trim(); + return text.length <= 300 ? text : text.slice(0, 300); +} + +function requiredEnv(name) { + const value = process.env[name]; + if (!value) fail(`${name} is required`); + return value; +} + +function fail(message) { + console.error(message); + process.exit(1); +} diff --git a/scripts/src/cicd-branch-follower.ts b/scripts/src/cicd-branch-follower.ts index 20689382..b0955582 100644 --- a/scripts/src/cicd-branch-follower.ts +++ b/scripts/src/cicd-branch-follower.ts @@ -32,8 +32,9 @@ import { invalidRuntimeReuseConfig, missingRuntimeReuseConfig, parseRuntimeReuse import { prioritizedTaskRunItems } from "./cicd-taskruns"; import { runBranchFollowerTaskRunDrillDown } from "./cicd-taskrun-drilldown"; import { runBranchFollowerJobDrillDown, runBranchFollowerRuntimeDrillDown } from "./cicd-job-runtime-drilldown"; +import { runBranchFollowerGate } from "./cicd-gates"; import { attachReconcileTimeline, compactReconcileTimeline, finishReconcileStep, finishReconcileTimeline, startReconcileStep, startReconcileTimeline } from "./cicd-reconcile-timeline"; -import type { AdapterSummary, BranchFollowerAction, BranchFollowerDebugStep, BranchFollowerPhase, BranchFollowerRegistry, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, StageTiming, TriggerResult } from "./cicd-types"; +import type { AdapterSummary, BranchFollowerAction, BranchFollowerDebugStep, BranchFollowerGate, BranchFollowerPhase, BranchFollowerRegistry, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, StageTiming, TriggerResult } from "./cicd-types"; import { arrayField, asRecord, @@ -53,7 +54,7 @@ const SPEC_VERSION = "draft-2026-07-03-p0-branch-follower"; export function cicdHelp(): unknown { return { - command: "cicd branch-follower plan|apply|status|run-once|debug-step|cleanup-state|events|logs|taskrun|job|runtime", + command: "cicd branch-follower plan|apply|status|run-once|debug-step|cleanup-state|events|logs|taskrun|job|runtime|gate", output: "text by default; use --json, --raw, or -o json|yaml for machine output", usage: [ "bun scripts/cli.ts cicd branch-follower plan", @@ -68,10 +69,9 @@ export function cicdHelp(): unknown { "bun scripts/cli.ts cicd branch-follower cleanup-state --follower web-probe-sentinel-master --confirm", "bun scripts/cli.ts cicd branch-follower events --follower agentrun-jd01-v02", "bun scripts/cli.ts cicd branch-follower logs --follower web-probe-sentinel-master", - "bun scripts/cli.ts cicd branch-follower status --follower hwlab-jd01-v03 --taskrun runtime-ready --logs-tail 120 --json", "bun scripts/cli.ts cicd branch-follower taskrun --follower hwlab-jd01-v03 --taskrun runtime-ready --logs-tail 120 --json", "bun scripts/cli.ts cicd branch-follower job --follower agentrun-jd01-v02 --source-commit --job image-build --json", - "bun scripts/cli.ts cicd branch-follower runtime --follower agentrun-jd01-v02 --workload agentrun-mgr --source-commit --json", + "bun scripts/cli.ts cicd branch-follower gate --follower agentrun-jd01-v02 --gate reuse-plan --source-commit --json", ], config: DEFAULT_CONFIG_PATH, spec: `${SPEC_REF} ${SPEC_VERSION}`, @@ -83,7 +83,7 @@ export async function runCicdCommand(_config: UniDeskConfig | null, args: string const top = args[0]; if (top === undefined || isHelpToken(top)) return renderMachine("cicd", cicdHelp(), "json"); if (top !== "branch-follower") { - throw new Error("cicd usage: cicd branch-follower plan|apply|status|run-once|debug-step|cleanup-state|events|logs|taskrun|job|runtime"); + throw new Error("cicd usage: cicd branch-follower plan|apply|status|run-once|debug-step|cleanup-state|events|logs|taskrun|job|runtime|gate"); } const options = parseOptions(args.slice(1)); const command = commandLabel(options); @@ -112,6 +112,8 @@ export async function runCicdCommand(_config: UniDeskConfig | null, args: string return renderResult(command, await runJobDrillDown(registry, options), options); case "runtime": return renderResult(command, await runRuntimeDrillDown(registry, options), options); + case "gate": + return renderResult(command, await runGate(registry, options), options); case "help": return renderMachine(command, cicdHelp(), "json"); } @@ -122,7 +124,7 @@ function parseOptions(args: string[]): ParsedOptions { if (actionToken === undefined || isHelpToken(actionToken)) { return defaultOptions("help", args.slice(actionToken === undefined ? 0 : 1)); } - if (!["plan", "apply", "status", "run-once", "debug-step", "cleanup-state", "events", "logs", "taskrun", "job", "runtime"].includes(actionToken)) { + if (!["plan", "apply", "status", "run-once", "debug-step", "cleanup-state", "events", "logs", "taskrun", "job", "runtime", "gate"].includes(actionToken)) { throw new Error(`cicd branch-follower unknown action: ${actionToken}`); } const action = actionToken as BranchFollowerAction; @@ -162,6 +164,8 @@ function parseOptions(args: string[]): ParsedOptions { options.recordState = true; } else if (arg === "--step") { options.debugStep = debugStepOption(valueOption(rest, ++index, arg)); + } else if (arg === "--gate") { + options.gate = gateOption(valueOption(rest, ++index, arg)); } else if (arg === "--taskrun" || arg === "--task-run") { options.taskRunName = simpleK8sObjectName(valueOption(rest, ++index, arg), arg); } else if (arg === "--pipelinerun" || arg === "--pipeline-run") { @@ -219,6 +223,7 @@ function parseOptions(args: string[]): ParsedOptions { if ((options.action === "job" || options.action === "runtime" || options.jobName !== null || options.workloadName !== null) && options.followerId === null) { throw new Error(`${options.action} requires --follower `); } + if (options.action === "gate" && (options.followerId === null || options.gate === null)) throw new Error("gate requires --follower --gate "); return options; } @@ -227,6 +232,11 @@ function debugStepOption(value: string): BranchFollowerDebugStep { throw new Error("--step must be state-read, controller-source, status-read, decide, or state-write"); } +function gateOption(value: string): BranchFollowerGate { + if (value === "reuse-plan" || value === "ci-taskrun-plan" || value === "cd-rollout-plan" || value === "post-deploy-health") return value; + throw new Error("--gate must be reuse-plan, ci-taskrun-plan, cd-rollout-plan, or post-deploy-health"); +} + function isInClusterRuntime(): boolean { return Boolean(process.env.KUBERNETES_SERVICE_HOST && process.env.KUBERNETES_SERVICE_PORT); } @@ -247,6 +257,7 @@ function defaultOptions(action: BranchFollowerAction, _args: string[]): ParsedOp raw: false, recordState: false, debugStep: null, + gate: null, taskRunName: null, pipelineRunName: null, jobName: null, @@ -858,6 +869,13 @@ async function runTaskRunDrillDown(registry: BranchFollowerRegistry, options: Pa return runBranchFollowerTaskRunDrillDown(registry, follower, options, runKubeScript); } +async function runGate(registry: BranchFollowerRegistry, options: ParsedOptions): Promise> { + if (options.followerId === null) throw new Error("gate requires --follower "); + const follower = registry.followers.find((item) => item.id === options.followerId); + if (follower === undefined) throw new Error(`unknown follower ${options.followerId}`); + return runBranchFollowerGate(registry, follower, options, runKubeScript); +} + async function runJobDrillDown(registry: BranchFollowerRegistry, options: ParsedOptions): Promise> { if (options.followerId === null) throw new Error("job requires --follower "); const follower = registry.followers.find((item) => item.id === options.followerId); diff --git a/scripts/src/cicd-gates.ts b/scripts/src/cicd-gates.ts new file mode 100644 index 00000000..7cecbc19 --- /dev/null +++ b/scripts/src/cicd-gates.ts @@ -0,0 +1,155 @@ +// SPEC: PJ2026-01060703 CI/CD branch follower independently executable gate probes. +// Responsibility: submit bounded target-side gate Jobs and return compact evidence. +import type { CommandResult } from "./command"; +import { resolveAgentRunLaneTarget } from "./agentrun-lanes"; +import { nativeCicdScriptLoadShell } from "./cicd-native-bundle"; +import { waitForJobShell } from "./cicd-controller-render"; +import type { BranchFollowerRegistry, FollowerSpec, ParsedOptions } from "./cicd-types"; +import { shQuote, redactText } from "./platform-infra-ops-library"; + +type KubeScriptRunner = (registry: BranchFollowerRegistry, options: ParsedOptions, script: string, input: string, timeoutMs: number) => CommandResult; + +export async function runBranchFollowerGate(registry: BranchFollowerRegistry, follower: FollowerSpec, options: ParsedOptions, runKubeScript: KubeScriptRunner): Promise> { + if (options.gate === null) throw new Error("gate requires --gate "); + if (options.inCluster) return { ok: false, action: "gate", gate: options.gate, follower: follower.id, degradedReason: "operator-entry-required" }; + const timeoutSeconds = options.timeoutSeconds ?? follower.budgets.statusSeconds; + const jobName = `bf-gate-${safeName(follower.id)}-${safeName(options.gate)}-${Date.now().toString(36)}`.slice(0, 63); + const manifest = gateJobManifest(registry, follower, options, jobName, timeoutSeconds); + const manifestYaml = `${Bun.YAML.stringify(manifest).trim()}\n`; + const script = [ + "set -eu", + "tmp=$(mktemp)", + "base64 -d >\"$tmp\" <<'UNIDESK_GATE_JOB'", + Buffer.from(manifestYaml, "utf8").toString("base64"), + "UNIDESK_GATE_JOB", + `kubectl -n ${shQuote(registry.controller.namespace)} delete job ${shQuote(jobName)} --ignore-not-found=true >/dev/null 2>&1 || true`, + `kubectl apply --server-side --force-conflicts --field-manager=${shQuote(registry.controller.fieldManager)} -f "$tmp" >/dev/null`, + waitForJobShell(registry.controller.namespace, jobName, timeoutSeconds), + ].join("\n"); + const startedAt = Date.now(); + const command = runKubeScript(registry, options, script, "", (timeoutSeconds + registry.controller.budgets.reconcileTransportGraceSeconds) * 1000); + const parsed = command.exitCode === 0 ? parseFirstJsonObject(command.stdout) : null; + const ok = command.exitCode === 0 && parsed !== null && parsed.ok === true; + return { + ok, + action: "gate", + gate: options.gate, + follower: follower.id, + target: { name: jobName, namespace: registry.controller.namespace, execution: "k8s-native-gate-job" }, + result: parsed, + command: { + exitCode: command.exitCode, + timedOut: command.timedOut, + elapsedMs: Date.now() - startedAt, + parseError: parsed === null ? "stdout-json-parse-failed" : null, + stdoutTail: ok ? "" : redactText(tailText(command.stdout, 1600)), + stderrTail: ok ? "" : redactText(tailText(command.stderr, 1200)), + }, + parsedDownstreamCliOutput: false, + next: { gate: `bun scripts/cli.ts cicd branch-follower gate --follower ${follower.id} --gate ${options.gate} --json` }, + }; +} + +function gateJobManifest(registry: BranchFollowerRegistry, follower: FollowerSpec, options: ParsedOptions, jobName: string, timeoutSeconds: number): Record { + const labels = { ...registry.controller.labels, "app.kubernetes.io/component": "cicd-gate-job" }; + const agentrun = follower.adapter === "agentrun-yaml-lane" ? resolveAgentRunLaneTarget({ node: follower.target.node, lane: follower.target.lane }).spec : null; + const gitopsBranch = agentrun?.gitops.branch ?? ""; + const healthUrl = agentrun?.runtime.internalBaseUrl ?? ""; + const workloads = (follower.nativeStatus.runtime?.workloads ?? []).map((item) => ({ kind: item.kind, name: item.name, sourceCommit: item.sourceCommit })); + const gateScript = [ + "set -eu", + "tmpdir=$(mktemp -d)", + "cleanup() { rm -rf \"$tmpdir\"; }", + "trap cleanup EXIT INT TERM", + nativeCicdScriptLoadShell(["branch-follower-gate.mjs"]), + "/etc/unidesk-cicd-branch-follower/sync-source.sh \"$REPOSITORY\" \"$SOURCE_BRANCH\" \"$SNAPSHOT_PREFIX\" \"$REPO_PATH\" >/tmp/bf-gate-source-sync.json 2>/tmp/bf-gate-source-sync.err || true", + "node \"$tmpdir/branch-follower-gate.mjs\"", + ].join("\n"); + return { + apiVersion: "batch/v1", + kind: "Job", + metadata: { name: jobName, namespace: registry.controller.namespace, labels }, + spec: { + backoffLimit: registry.controller.budgets.reconcileJobBackoffLimit, + ttlSecondsAfterFinished: registry.controller.budgets.reconcileJobTtlSeconds, + activeDeadlineSeconds: timeoutSeconds + registry.controller.budgets.reconcileJobDeadlineGraceSeconds, + template: { + metadata: { labels }, + spec: { + restartPolicy: "Never", + serviceAccountName: registry.controller.serviceAccountName, + volumes: [ + { name: "registry", configMap: { name: registry.controller.configMapName, defaultMode: 0o755 } }, + { name: "git-mirror-cache", persistentVolumeClaim: { claimName: registry.controller.source.gitMirrorCachePvcName } }, + { name: "git-ssh", secret: { secretName: registry.controller.source.githubSsh.secretName, defaultMode: 0o400 } }, + ], + containers: [{ + name: "gate", + image: registry.controller.image, + imagePullPolicy: "IfNotPresent", + command: ["/bin/sh", "-lc", gateScript], + env: [ + { name: "GATE", value: options.gate }, + { name: "FOLLOWER_ID", value: follower.id }, + { name: "REPOSITORY", value: follower.source.repository }, + { name: "SOURCE_BRANCH", value: follower.source.branch }, + { name: "SNAPSHOT_PREFIX", value: follower.source.snapshotPrefix }, + { name: "SOURCE_COMMIT", value: options.sourceCommit ?? "" }, + { name: "REPO_PATH", value: follower.nativeStatus.source.repoPath }, + { name: "GITOPS_BRANCH", value: gitopsBranch }, + { name: "TEKTON_NAMESPACE", value: follower.nativeStatus.tekton?.namespace ?? "" }, + { name: "PIPELINE_RUN_PREFIX", value: follower.nativeStatus.tekton?.pipelineRunPrefix ?? "" }, + { name: "ARGO_NAMESPACE", value: follower.nativeStatus.argo?.namespace ?? "" }, + { name: "ARGO_APPLICATION", value: follower.nativeStatus.argo?.application ?? "" }, + { name: "RUNTIME_NAMESPACE", value: follower.nativeStatus.runtime?.namespace ?? "" }, + { name: "WORKLOADS_B64", value: Buffer.from(JSON.stringify(workloads), "utf8").toString("base64") }, + { name: "HEALTH_URL", value: healthUrl }, + { name: "UNIDESK_CONTROLLER_GITHUB_SSH_PRIVATE_KEY", value: `/git-ssh/${registry.controller.source.githubSsh.privateKeySecretKey}` }, + { name: "UNIDESK_CONTROLLER_GITHUB_PROXY_HOST", value: registry.controller.source.githubSsh.proxyHost }, + { name: "UNIDESK_CONTROLLER_GITHUB_PROXY_PORT", value: String(registry.controller.source.githubSsh.proxyPort) }, + ], + volumeMounts: [ + { name: "registry", mountPath: "/etc/unidesk-cicd-branch-follower", readOnly: true }, + { name: "git-mirror-cache", mountPath: "/cache" }, + { name: "git-ssh", mountPath: "/git-ssh", readOnly: true }, + ], + }], + }, + }, + }, + }; +} + +function parseFirstJsonObject(text: string): Record | null { + const start = text.indexOf("{"); + if (start < 0) return null; + let depth = 0; + let inString = false; + let escaped = false; + for (let index = start; index < text.length; index += 1) { + const char = text[index]; + if (inString) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === "\"") inString = false; + } else if (char === "\"") inString = true; + else if (char === "{") depth += 1; + else if (char === "}" && --depth === 0) { + try { + const parsed = JSON.parse(text.slice(start, index + 1)) as unknown; + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record : null; + } catch { + return null; + } + } + } + return null; +} + +function safeName(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9-]+/gu, "-").replace(/-+/gu, "-").replace(/^-|-$/gu, "").slice(0, 32); +} + +function tailText(text: string, maxChars: number): string { + return text.length <= maxChars ? text : text.slice(text.length - maxChars); +} diff --git a/scripts/src/cicd-types.ts b/scripts/src/cicd-types.ts index 97798615..62dabe20 100644 --- a/scripts/src/cicd-types.ts +++ b/scripts/src/cicd-types.ts @@ -2,8 +2,9 @@ // Responsibility: type contracts shared by branch follower entry, controller render, and native K8s helpers. export type OutputMode = "human" | "json" | "yaml"; -export type BranchFollowerAction = "help" | "plan" | "apply" | "status" | "run-once" | "debug-step" | "cleanup-state" | "events" | "logs" | "taskrun" | "job" | "runtime"; +export type BranchFollowerAction = "help" | "plan" | "apply" | "status" | "run-once" | "debug-step" | "cleanup-state" | "events" | "logs" | "taskrun" | "job" | "runtime" | "gate"; export type BranchFollowerDebugStep = "state-read" | "controller-source" | "status-read" | "decide" | "state-write"; +export type BranchFollowerGate = "reuse-plan" | "ci-taskrun-plan" | "cd-rollout-plan" | "post-deploy-health"; export type BranchFollowerPhase = | "Observed" | "Noop" @@ -31,6 +32,7 @@ export interface ParsedOptions { raw: boolean; recordState: boolean; debugStep: BranchFollowerDebugStep | null; + gate: BranchFollowerGate | null; taskRunName: string | null; pipelineRunName: string | null; jobName: string | null;