feat: add branch follower gate probes

This commit is contained in:
Codex
2026-07-04 04:38:11 +00:00
parent 59c0f5f564
commit 8d37a7a6a5
4 changed files with 570 additions and 7 deletions
+24 -6
View File
@@ -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 <sha> --job image-build --json",
"bun scripts/cli.ts cicd branch-follower runtime --follower agentrun-jd01-v02 --workload agentrun-mgr --source-commit <sha> --json",
"bun scripts/cli.ts cicd branch-follower gate --follower agentrun-jd01-v02 --gate reuse-plan --source-commit <sha> --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 <id>`);
}
if (options.action === "gate" && (options.followerId === null || options.gate === null)) throw new Error("gate requires --follower <id> --gate <name>");
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<Record<string, unknown>> {
if (options.followerId === null) throw new Error("gate requires --follower <id>");
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<Record<string, unknown>> {
if (options.followerId === null) throw new Error("job requires --follower <id>");
const follower = registry.followers.find((item) => item.id === options.followerId);
+155
View File
@@ -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<Record<string, unknown>> {
if (options.gate === null) throw new Error("gate requires --gate <reuse-plan|ci-taskrun-plan|cd-rollout-plan|post-deploy-health>");
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<string, unknown> {
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<string, unknown> | 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<string, unknown> : 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);
}
+3 -1
View File
@@ -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;