feat: add branch follower gate probes
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user