2277 lines
99 KiB
TypeScript
2277 lines
99 KiB
TypeScript
// SPEC: PJ2026-01060703 CI/CD branch follower draft-2026-07-03-p0-branch-follower.
|
|
// Responsibility: YAML-first K8s branch-follower controller, status, and adapter orchestration.
|
|
import { createHash } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import { isAbsolute } from "node:path";
|
|
import { repoRoot, rootPath, type UniDeskConfig } from "./config";
|
|
import { runCommand, type CommandResult } from "./command";
|
|
import { startJob } from "./jobs";
|
|
import type { RenderedCliResult } from "./output";
|
|
import { transPath } from "./hwlab-node/runtime-common";
|
|
import { configRefGraph, resolveConfigRefString } from "./ops/config-refs";
|
|
import {
|
|
arrayField,
|
|
asRecord,
|
|
booleanField,
|
|
integerField,
|
|
readYamlRecord,
|
|
recordField,
|
|
redactText,
|
|
shQuote,
|
|
stringArrayField,
|
|
stringField,
|
|
} from "./platform-infra-ops-library";
|
|
|
|
const DEFAULT_CONFIG_PATH = "config/cicd-branch-followers.yaml";
|
|
const SPEC_REF = "PJ2026-01060703";
|
|
const SPEC_VERSION = "draft-2026-07-03-p0-branch-follower";
|
|
|
|
type OutputMode = "human" | "json" | "yaml";
|
|
type BranchFollowerAction = "help" | "plan" | "apply" | "status" | "run-once" | "events" | "logs";
|
|
type BranchFollowerPhase =
|
|
| "Observed"
|
|
| "Noop"
|
|
| "PendingTrigger"
|
|
| "Triggering"
|
|
| "ClosingOut"
|
|
| "Succeeded"
|
|
| "Failed"
|
|
| "Superseded"
|
|
| "Blocked"
|
|
| "Skipped";
|
|
|
|
interface ParsedOptions {
|
|
action: BranchFollowerAction;
|
|
configPath: string;
|
|
followerId: string | null;
|
|
all: boolean;
|
|
confirm: boolean;
|
|
dryRun: boolean;
|
|
wait: boolean;
|
|
controller: boolean;
|
|
live: boolean;
|
|
noLive: boolean;
|
|
full: boolean;
|
|
raw: boolean;
|
|
recordState: boolean;
|
|
output: OutputMode;
|
|
limit: number;
|
|
tailBytes: number;
|
|
timeoutSeconds: number | null;
|
|
}
|
|
|
|
interface CommandSpec {
|
|
argv: string[];
|
|
timeoutSeconds: number;
|
|
}
|
|
|
|
interface FollowerSpec {
|
|
id: string;
|
|
enabled: boolean;
|
|
adapter: string;
|
|
description: string;
|
|
source: {
|
|
repository: string;
|
|
branch: string;
|
|
branchRef: string;
|
|
authorityRef: string;
|
|
snapshotPrefix: string;
|
|
snapshotRef: string;
|
|
};
|
|
target: {
|
|
node: string;
|
|
lane: string;
|
|
namespace: string;
|
|
sentinel: string | null;
|
|
configRefs: Record<string, string>;
|
|
};
|
|
budgets: {
|
|
endToEndSeconds: number;
|
|
statusSeconds: number;
|
|
triggerSeconds: number;
|
|
sourceSyncSeconds: number;
|
|
};
|
|
commands: {
|
|
plan: CommandSpec;
|
|
status: CommandSpec;
|
|
trigger: CommandSpec;
|
|
events: CommandSpec;
|
|
logs: CommandSpec;
|
|
};
|
|
nativeStatus: NativeStatusSpec;
|
|
closeoutChecks: string[];
|
|
}
|
|
|
|
interface NativeStatusSpec {
|
|
source: {
|
|
gitMirrorReadUrl: string;
|
|
gitMirrorNamespace: string;
|
|
gitMirrorDeployment: string;
|
|
repoPath: string;
|
|
};
|
|
tekton: {
|
|
namespace: string;
|
|
pipelineRunPrefix: string;
|
|
} | null;
|
|
argo: {
|
|
namespace: string;
|
|
application: string;
|
|
} | null;
|
|
runtime: {
|
|
namespace: string;
|
|
workloads: NativeWorkloadSpec[];
|
|
} | null;
|
|
}
|
|
|
|
interface NativeWorkloadSpec {
|
|
kind: "Deployment" | "StatefulSet";
|
|
name: string;
|
|
sourceCommit: {
|
|
labels: string[];
|
|
annotations: string[];
|
|
podLabels: string[];
|
|
podAnnotations: string[];
|
|
env: string[];
|
|
};
|
|
}
|
|
|
|
interface ControllerSpec {
|
|
namespace: string;
|
|
kubeRoute: string;
|
|
fieldManager: string;
|
|
serviceAccountName: string;
|
|
deploymentName: string;
|
|
configMapName: string;
|
|
stateConfigMapName: string;
|
|
leaseName: string;
|
|
image: string;
|
|
labels: Record<string, string>;
|
|
source: {
|
|
repository: string;
|
|
branch: string;
|
|
gitMirrorReadUrl: string;
|
|
gitMirrorCachePvcName: string;
|
|
githubSsh: {
|
|
secretName: string;
|
|
privateKeySecretKey: string;
|
|
proxyHost: string;
|
|
proxyPort: number;
|
|
};
|
|
sourceAuthority: {
|
|
mode: string;
|
|
resolver: string;
|
|
allowHostGit: boolean;
|
|
allowHostWorkspace: boolean;
|
|
allowGithubDirectInPipeline: boolean;
|
|
};
|
|
sourceSnapshot: {
|
|
stageRefPrefix: string;
|
|
missingObjectPolicy: string;
|
|
refreshPolicy: string;
|
|
};
|
|
};
|
|
loop: {
|
|
intervalSeconds: number;
|
|
reconcileTimeoutSeconds: number;
|
|
};
|
|
budgets: {
|
|
applyWaitSeconds: number;
|
|
statusSeconds: number;
|
|
runOnceSeconds: number;
|
|
};
|
|
}
|
|
|
|
interface BranchFollowerRegistry {
|
|
path: string;
|
|
rawText: string;
|
|
rawSha256: string;
|
|
metadata: {
|
|
id: string;
|
|
owner: string;
|
|
specRef: string;
|
|
version: string;
|
|
};
|
|
controller: ControllerSpec;
|
|
followers: FollowerSpec[];
|
|
}
|
|
|
|
interface AdapterSummary {
|
|
ok: boolean;
|
|
command: string;
|
|
exitCode: number | null;
|
|
timedOut: boolean;
|
|
observedSha: string | null;
|
|
targetSha: string | null;
|
|
lastTriggeredSha: string | null;
|
|
lastSucceededSha: string | null;
|
|
pipelineRun: string | null;
|
|
inFlightJob: string | null;
|
|
aligned: boolean | null;
|
|
phase: BranchFollowerPhase;
|
|
message: string;
|
|
payload: Record<string, unknown> | null;
|
|
stderrTail: string;
|
|
stdoutTail: string;
|
|
}
|
|
|
|
interface NativeObjectBundle {
|
|
ok: boolean;
|
|
source: Record<string, unknown> | null;
|
|
pipelineRun: Record<string, unknown> | null;
|
|
argoApplication: Record<string, unknown> | null;
|
|
workloads: Record<string, unknown>[];
|
|
errors: string[];
|
|
exitCode: number | null;
|
|
timedOut: boolean;
|
|
stdoutTail: string;
|
|
stderrTail: string;
|
|
}
|
|
|
|
interface FollowerState {
|
|
id: string;
|
|
adapter: string;
|
|
enabled: boolean;
|
|
phase: BranchFollowerPhase;
|
|
source: {
|
|
repository: string;
|
|
branch: string;
|
|
branchRef: string;
|
|
snapshotPrefix: string;
|
|
observedSha: string | null;
|
|
};
|
|
target: {
|
|
node: string;
|
|
lane: string;
|
|
namespace: string;
|
|
sentinel: string | null;
|
|
targetSha: string | null;
|
|
};
|
|
lastTriggeredSha: string | null;
|
|
lastSucceededSha: string | null;
|
|
pipelineRun: string | null;
|
|
inFlightJob: string | null;
|
|
budgetSource: Record<string, number>;
|
|
controller: {
|
|
mode: "local-cli" | "k8s-controller";
|
|
stateConfigMap: string;
|
|
leaseName: string;
|
|
};
|
|
decision: string;
|
|
dryRun: boolean;
|
|
updatedAt: string;
|
|
warnings: string[];
|
|
next: Record<string, string>;
|
|
command?: Record<string, unknown>;
|
|
}
|
|
|
|
interface K8sStateRead {
|
|
ok: boolean;
|
|
stateByFollower: Record<string, Record<string, unknown>>;
|
|
stateConfigMapPresent: boolean;
|
|
deployment: Record<string, unknown> | null;
|
|
lease: Record<string, unknown> | null;
|
|
pods: Record<string, unknown> | null;
|
|
errors: string[];
|
|
}
|
|
|
|
export function cicdHelp(): unknown {
|
|
return {
|
|
command: "cicd branch-follower plan|apply|status|run-once|events|logs",
|
|
output: "text by default; use --json, --raw, or -o json|yaml for machine output",
|
|
usage: [
|
|
"bun scripts/cli.ts cicd branch-follower plan",
|
|
"bun scripts/cli.ts cicd branch-follower apply --confirm --wait",
|
|
"bun scripts/cli.ts cicd branch-follower status",
|
|
"bun scripts/cli.ts cicd branch-follower status --live",
|
|
"bun scripts/cli.ts cicd branch-follower run-once --all --dry-run",
|
|
"bun scripts/cli.ts cicd branch-follower run-once --follower hwlab-jd01-v03 --confirm --wait",
|
|
"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",
|
|
],
|
|
config: DEFAULT_CONFIG_PATH,
|
|
spec: `${SPEC_REF} ${SPEC_VERSION}`,
|
|
description: "Deploy and inspect the YAML-first Kubernetes branch follower that follows HWLAB v0.3, AgentRun v0.2, and the selected web-probe sentinel master lane without using host worktrees as source authority.",
|
|
};
|
|
}
|
|
|
|
export async function runCicdCommand(_config: UniDeskConfig | null, args: string[]): Promise<RenderedCliResult> {
|
|
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|events|logs");
|
|
}
|
|
const options = parseOptions(args.slice(1));
|
|
const command = commandLabel(options);
|
|
if (options.action === "help") return renderMachine(command, cicdHelp(), "json");
|
|
const registry = readRegistry(options.configPath);
|
|
switch (options.action) {
|
|
case "plan":
|
|
return renderResult(command, buildPlan(registry, options), options);
|
|
case "apply":
|
|
return renderResult(command, await applyController(registry, options), options);
|
|
case "status":
|
|
return renderResult(command, await buildStatus(registry, options), options);
|
|
case "run-once":
|
|
return renderResult(command, await runOnce(registry, options), options);
|
|
case "events":
|
|
case "logs":
|
|
return renderResult(command, await runFollowerDrillDown(registry, options), options);
|
|
case "help":
|
|
return renderMachine(command, cicdHelp(), "json");
|
|
}
|
|
}
|
|
|
|
function parseOptions(args: string[]): ParsedOptions {
|
|
const actionToken = args[0];
|
|
if (actionToken === undefined || isHelpToken(actionToken)) {
|
|
return defaultOptions("help", args.slice(actionToken === undefined ? 0 : 1));
|
|
}
|
|
if (!["plan", "apply", "status", "run-once", "events", "logs"].includes(actionToken)) {
|
|
throw new Error(`cicd branch-follower unknown action: ${actionToken}`);
|
|
}
|
|
const action = actionToken as BranchFollowerAction;
|
|
const options = defaultOptions(action, []);
|
|
const rest = args.slice(1);
|
|
for (let index = 0; index < rest.length; index += 1) {
|
|
const arg = rest[index] ?? "";
|
|
if (isHelpToken(arg)) {
|
|
options.action = "help";
|
|
} else if (arg === "--config") {
|
|
options.configPath = valueOption(rest, ++index, arg);
|
|
} else if (arg === "--follower" || arg === "--target") {
|
|
options.followerId = simpleId(valueOption(rest, ++index, arg), arg);
|
|
} else if (arg === "--all") {
|
|
options.all = true;
|
|
} else if (arg === "--confirm") {
|
|
options.confirm = true;
|
|
} else if (arg === "--dry-run") {
|
|
options.dryRun = true;
|
|
} else if (arg === "--wait") {
|
|
options.wait = true;
|
|
} else if (arg === "--controller") {
|
|
options.controller = true;
|
|
} else if (arg === "--live") {
|
|
options.live = true;
|
|
} else if (arg === "--no-live") {
|
|
options.noLive = true;
|
|
} else if (arg === "--full") {
|
|
options.full = true;
|
|
} else if (arg === "--raw" || arg === "--json") {
|
|
options.raw = true;
|
|
options.full = true;
|
|
options.output = "json";
|
|
} else if (arg === "--record-state") {
|
|
options.recordState = true;
|
|
} else if (arg === "-o" || arg === "--output") {
|
|
const value = valueOption(rest, ++index, arg);
|
|
if (value !== "json" && value !== "yaml" && value !== "wide" && value !== "text") throw new Error(`${arg} must be json, yaml, wide, or text`);
|
|
options.output = value === "wide" || value === "text" ? "human" : value;
|
|
if (value === "json" || value === "yaml") {
|
|
options.raw = true;
|
|
options.full = true;
|
|
}
|
|
} else if (arg === "--limit") {
|
|
options.limit = positiveInt(valueOption(rest, ++index, arg), arg);
|
|
} else if (arg === "--tail-bytes" || arg === "--tail") {
|
|
options.tailBytes = positiveInt(valueOption(rest, ++index, arg), arg);
|
|
} else if (arg === "--timeout-seconds") {
|
|
options.timeoutSeconds = positiveInt(valueOption(rest, ++index, arg), arg);
|
|
} else {
|
|
throw new Error(`unsupported cicd branch-follower option: ${arg}`);
|
|
}
|
|
}
|
|
if (options.confirm && options.dryRun) throw new Error("cicd branch-follower accepts only one of --confirm or --dry-run");
|
|
if (options.action === "apply" && !options.confirm) options.dryRun = true;
|
|
if (options.action === "run-once" && !options.confirm) options.dryRun = true;
|
|
if (options.action === "run-once" && options.confirm && !options.all && options.followerId === null) {
|
|
throw new Error("run-once --confirm requires --all or --follower <id>");
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function defaultOptions(action: BranchFollowerAction, _args: string[]): ParsedOptions {
|
|
return {
|
|
action,
|
|
configPath: DEFAULT_CONFIG_PATH,
|
|
followerId: null,
|
|
all: false,
|
|
confirm: false,
|
|
dryRun: false,
|
|
wait: false,
|
|
controller: false,
|
|
live: false,
|
|
noLive: false,
|
|
full: false,
|
|
raw: false,
|
|
recordState: false,
|
|
output: "human",
|
|
limit: 20,
|
|
tailBytes: 12000,
|
|
timeoutSeconds: null,
|
|
};
|
|
}
|
|
|
|
function valueOption(args: string[], index: number, option: string): string {
|
|
const value = args[index];
|
|
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${option} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
function simpleId(value: string, option: string): string {
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${option} must be a simple id`);
|
|
return value;
|
|
}
|
|
|
|
function positiveInt(value: string, option: string): number {
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${option} must be a positive integer`);
|
|
return parsed;
|
|
}
|
|
|
|
function isHelpToken(value: string): boolean {
|
|
return value === "-h" || value === "--help" || value === "help";
|
|
}
|
|
|
|
function readRegistry(configPath: string): BranchFollowerRegistry {
|
|
const absolute = isAbsolute(configPath) ? configPath : rootPath(configPath);
|
|
const rawText = readFileSync(absolute, "utf8");
|
|
const root = readYamlRecord<Record<string, unknown>>(absolute, "CicdBranchFollowerRegistry");
|
|
const metadata = recordField(root, "metadata", configPath);
|
|
const controller = parseController(recordField(root, "controller", configPath));
|
|
const followers = arrayField(root, "followers", configPath).map(parseFollower);
|
|
const ids = new Set<string>();
|
|
for (const follower of followers) {
|
|
if (ids.has(follower.id)) throw new Error(`${configPath}.followers has duplicate id ${follower.id}`);
|
|
ids.add(follower.id);
|
|
}
|
|
return {
|
|
path: configPath,
|
|
rawText,
|
|
rawSha256: createHash("sha256").update(rawText).digest("hex"),
|
|
metadata: {
|
|
id: stringField(metadata, "id", `${configPath}.metadata`),
|
|
owner: stringField(metadata, "owner", `${configPath}.metadata`),
|
|
specRef: stringField(metadata, "specRef", `${configPath}.metadata`),
|
|
version: stringField(metadata, "version", `${configPath}.metadata`),
|
|
},
|
|
controller,
|
|
followers,
|
|
};
|
|
}
|
|
|
|
function parseController(root: Record<string, unknown>): ControllerSpec {
|
|
const source = recordField(root, "source", "controller");
|
|
const authority = recordField(source, "sourceAuthority", "controller.source");
|
|
const snapshot = recordField(source, "sourceSnapshot", "controller.source");
|
|
const loop = recordField(root, "loop", "controller");
|
|
const budgets = recordField(root, "budgets", "controller");
|
|
const result: ControllerSpec = {
|
|
namespace: stringField(root, "namespace", "controller"),
|
|
kubeRoute: stringField(root, "kubeRoute", "controller"),
|
|
fieldManager: stringField(root, "fieldManager", "controller"),
|
|
serviceAccountName: stringField(root, "serviceAccountName", "controller"),
|
|
deploymentName: stringField(root, "deploymentName", "controller"),
|
|
configMapName: stringField(root, "configMapName", "controller"),
|
|
stateConfigMapName: stringField(root, "stateConfigMapName", "controller"),
|
|
leaseName: stringField(root, "leaseName", "controller"),
|
|
image: stringField(root, "image", "controller"),
|
|
labels: stringMap(recordField(root, "labels", "controller"), "controller.labels"),
|
|
source: {
|
|
repository: stringField(source, "repository", "controller.source"),
|
|
branch: stringField(source, "branch", "controller.source"),
|
|
gitMirrorReadUrl: stringField(source, "gitMirrorReadUrl", "controller.source"),
|
|
gitMirrorCachePvcName: stringField(source, "gitMirrorCachePvcName", "controller.source"),
|
|
githubSsh: parseControllerGithubSsh(recordField(source, "githubSsh", "controller.source")),
|
|
sourceAuthority: {
|
|
mode: stringField(authority, "mode", "controller.source.sourceAuthority"),
|
|
resolver: stringField(authority, "resolver", "controller.source.sourceAuthority"),
|
|
allowHostGit: booleanField(authority, "allowHostGit", "controller.source.sourceAuthority"),
|
|
allowHostWorkspace: booleanField(authority, "allowHostWorkspace", "controller.source.sourceAuthority"),
|
|
allowGithubDirectInPipeline: booleanField(authority, "allowGithubDirectInPipeline", "controller.source.sourceAuthority"),
|
|
},
|
|
sourceSnapshot: {
|
|
stageRefPrefix: stringField(snapshot, "stageRefPrefix", "controller.source.sourceSnapshot"),
|
|
missingObjectPolicy: stringField(snapshot, "missingObjectPolicy", "controller.source.sourceSnapshot"),
|
|
refreshPolicy: stringField(snapshot, "refreshPolicy", "controller.source.sourceSnapshot"),
|
|
},
|
|
},
|
|
loop: {
|
|
intervalSeconds: integerField(loop, "intervalSeconds", "controller.loop"),
|
|
reconcileTimeoutSeconds: integerField(loop, "reconcileTimeoutSeconds", "controller.loop"),
|
|
},
|
|
budgets: {
|
|
applyWaitSeconds: integerField(budgets, "applyWaitSeconds", "controller.budgets"),
|
|
statusSeconds: integerField(budgets, "statusSeconds", "controller.budgets"),
|
|
runOnceSeconds: integerField(budgets, "runOnceSeconds", "controller.budgets"),
|
|
},
|
|
};
|
|
if (result.source.sourceAuthority.allowHostGit || result.source.sourceAuthority.allowHostWorkspace || result.source.sourceAuthority.allowGithubDirectInPipeline) {
|
|
throw new Error("controller.source.sourceAuthority must disable host git, host workspace, and direct GitHub pipeline fallback");
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function parseControllerGithubSsh(root: Record<string, unknown>): ControllerSpec["source"]["githubSsh"] {
|
|
return {
|
|
secretName: stringField(root, "secretName", "controller.source.githubSsh"),
|
|
privateKeySecretKey: stringField(root, "privateKeySecretKey", "controller.source.githubSsh"),
|
|
proxyHost: stringField(root, "proxyHost", "controller.source.githubSsh"),
|
|
proxyPort: integerField(root, "proxyPort", "controller.source.githubSsh"),
|
|
};
|
|
}
|
|
|
|
function parseFollower(root: Record<string, unknown>, index: number): FollowerSpec {
|
|
const label = `followers[${index}]`;
|
|
const source = recordField(root, "source", label);
|
|
const target = recordField(root, "target", label);
|
|
const budgets = recordField(root, "budgets", label);
|
|
const commands = recordField(root, "commands", label);
|
|
const nativeStatus = recordField(root, "nativeStatus", label);
|
|
const closeout = recordField(root, "closeout", label);
|
|
const configRefs = stringMap(recordField(target, "configRefs", `${label}.target`), `${label}.target.configRefs`);
|
|
return {
|
|
id: simpleId(stringField(root, "id", label), `${label}.id`),
|
|
enabled: booleanField(root, "enabled", label),
|
|
adapter: stringField(root, "adapter", label),
|
|
description: stringField(root, "description", label),
|
|
source: {
|
|
repository: stringField(source, "repository", `${label}.source`),
|
|
branch: stringField(source, "branch", `${label}.source`),
|
|
branchRef: stringField(source, "branchRef", `${label}.source`),
|
|
authorityRef: stringField(source, "authorityRef", `${label}.source`),
|
|
snapshotPrefix: stringField(source, "snapshotPrefix", `${label}.source`),
|
|
snapshotRef: stringField(source, "snapshotRef", `${label}.source`),
|
|
},
|
|
target: {
|
|
node: stringField(target, "node", `${label}.target`),
|
|
lane: stringField(target, "lane", `${label}.target`),
|
|
namespace: stringField(target, "namespace", `${label}.target`),
|
|
sentinel: typeof target.sentinel === "string" && target.sentinel.length > 0 ? target.sentinel : null,
|
|
configRefs,
|
|
},
|
|
budgets: {
|
|
endToEndSeconds: integerField(budgets, "endToEndSeconds", `${label}.budgets`),
|
|
statusSeconds: integerField(budgets, "statusSeconds", `${label}.budgets`),
|
|
triggerSeconds: integerField(budgets, "triggerSeconds", `${label}.budgets`),
|
|
sourceSyncSeconds: integerField(budgets, "sourceSyncSeconds", `${label}.budgets`),
|
|
},
|
|
commands: {
|
|
plan: parseCommand(recordField(commands, "plan", `${label}.commands`), `${label}.commands.plan`),
|
|
status: parseCommand(recordField(commands, "status", `${label}.commands`), `${label}.commands.status`),
|
|
trigger: parseCommand(recordField(commands, "trigger", `${label}.commands`), `${label}.commands.trigger`),
|
|
events: parseCommand(recordField(commands, "events", `${label}.commands`), `${label}.commands.events`),
|
|
logs: parseCommand(recordField(commands, "logs", `${label}.commands`), `${label}.commands.logs`),
|
|
},
|
|
nativeStatus: parseNativeStatus(nativeStatus, `${label}.nativeStatus`),
|
|
closeoutChecks: stringArrayField(closeout, "checks", `${label}.closeout`),
|
|
};
|
|
}
|
|
|
|
function parseNativeStatus(root: Record<string, unknown>, label: string): NativeStatusSpec {
|
|
const source = recordField(root, "source", label);
|
|
const tekton = asOptionalRecord(root.tekton);
|
|
const argo = asOptionalRecord(root.argo);
|
|
const runtime = asOptionalRecord(root.runtime);
|
|
return {
|
|
source: {
|
|
gitMirrorReadUrl: stringField(source, "gitMirrorReadUrl", `${label}.source`),
|
|
gitMirrorNamespace: stringField(source, "gitMirrorNamespace", `${label}.source`),
|
|
gitMirrorDeployment: stringField(source, "gitMirrorDeployment", `${label}.source`),
|
|
repoPath: stringField(source, "repoPath", `${label}.source`),
|
|
},
|
|
tekton: tekton === null
|
|
? null
|
|
: {
|
|
namespace: stringField(tekton, "namespace", `${label}.tekton`),
|
|
pipelineRunPrefix: stringField(tekton, "pipelineRunPrefix", `${label}.tekton`),
|
|
},
|
|
argo: argo === null
|
|
? null
|
|
: {
|
|
namespace: stringField(argo, "namespace", `${label}.argo`),
|
|
application: stringField(argo, "application", `${label}.argo`),
|
|
},
|
|
runtime: runtime === null
|
|
? null
|
|
: {
|
|
namespace: stringField(runtime, "namespace", `${label}.runtime`),
|
|
workloads: arrayField(runtime, "workloads", `${label}.runtime`).map((item, index) => parseNativeWorkload(item, `${label}.runtime.workloads[${index}]`)),
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseNativeWorkload(value: unknown, label: string): NativeWorkloadSpec {
|
|
const root = asRecord(value, label);
|
|
const kind = stringField(root, "kind", label);
|
|
if (kind !== "Deployment" && kind !== "StatefulSet") throw new Error(`${label}.kind must be Deployment or StatefulSet`);
|
|
const sourceCommit = asOptionalRecord(root.sourceCommit) ?? {};
|
|
return {
|
|
kind,
|
|
name: stringField(root, "name", label),
|
|
sourceCommit: {
|
|
labels: optionalStringArrayField(sourceCommit, "labels", `${label}.sourceCommit`),
|
|
annotations: optionalStringArrayField(sourceCommit, "annotations", `${label}.sourceCommit`),
|
|
podLabels: optionalStringArrayField(sourceCommit, "podLabels", `${label}.sourceCommit`),
|
|
podAnnotations: optionalStringArrayField(sourceCommit, "podAnnotations", `${label}.sourceCommit`),
|
|
env: optionalStringArrayField(sourceCommit, "env", `${label}.sourceCommit`),
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseCommand(root: Record<string, unknown>, label: string): CommandSpec {
|
|
return {
|
|
argv: stringArrayField(root, "argv", label),
|
|
timeoutSeconds: integerField(root, "timeoutSeconds", label),
|
|
};
|
|
}
|
|
|
|
function optionalStringArrayField(root: Record<string, unknown>, key: string, label: string): string[] {
|
|
return root[key] === undefined ? [] : stringArrayField(root, key, label);
|
|
}
|
|
|
|
function stringMap(root: Record<string, unknown>, label: string): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(root)) {
|
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${label}.${key} must be a non-empty string`);
|
|
result[key] = value;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function buildPlan(registry: BranchFollowerRegistry, options: ParsedOptions): Record<string, unknown> {
|
|
const selected = selectFollowers(registry, options, { includeDisabled: true });
|
|
const followers = selected.map((follower) => {
|
|
const branchValue = safeResolveString(follower.source.branchRef);
|
|
const graph = configRefGraph([
|
|
{ id: "source.branch", ref: follower.source.branchRef },
|
|
{ id: "source.authority", ref: follower.source.authorityRef },
|
|
{ id: "source.snapshot", ref: follower.source.snapshotRef },
|
|
...Object.entries(follower.target.configRefs).map(([id, ref]) => ({ id: `target.${id}`, ref })),
|
|
]);
|
|
const warnings: string[] = [];
|
|
if (branchValue !== null && branchValue !== follower.source.branch) warnings.push(`source.branch ${follower.source.branch} differs from ${follower.source.branchRef} value ${branchValue}`);
|
|
return {
|
|
id: follower.id,
|
|
enabled: follower.enabled,
|
|
adapter: follower.adapter,
|
|
description: follower.description,
|
|
source: {
|
|
repository: follower.source.repository,
|
|
branch: follower.source.branch,
|
|
branchRef: follower.source.branchRef,
|
|
resolvedBranch: branchValue,
|
|
snapshotPrefix: follower.source.snapshotPrefix,
|
|
},
|
|
target: follower.target,
|
|
budgets: follower.budgets,
|
|
commands: redactCommands(follower),
|
|
nativeStatus: nativeStatusPlan(follower.nativeStatus),
|
|
closeoutChecks: follower.closeoutChecks,
|
|
configRefGraph: graph,
|
|
warnings,
|
|
};
|
|
});
|
|
return {
|
|
ok: true,
|
|
action: "plan",
|
|
spec: `${SPEC_REF} ${SPEC_VERSION}`,
|
|
registry: registrySummary(registry),
|
|
hostWorktreeAuthority: false,
|
|
sourceAuthority: {
|
|
mode: registry.controller.source.sourceAuthority.mode,
|
|
resolver: registry.controller.source.sourceAuthority.resolver,
|
|
allowHostGit: registry.controller.source.sourceAuthority.allowHostGit,
|
|
allowHostWorkspace: registry.controller.source.sourceAuthority.allowHostWorkspace,
|
|
allowGithubDirectInPipeline: registry.controller.source.sourceAuthority.allowGithubDirectInPipeline,
|
|
},
|
|
controller: {
|
|
namespace: registry.controller.namespace,
|
|
kubeRoute: registry.controller.kubeRoute,
|
|
deploymentName: registry.controller.deploymentName,
|
|
stateConfigMapName: registry.controller.stateConfigMapName,
|
|
leaseName: registry.controller.leaseName,
|
|
image: registry.controller.image,
|
|
loop: registry.controller.loop,
|
|
budgets: registry.controller.budgets,
|
|
},
|
|
followers,
|
|
next: {
|
|
apply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait",
|
|
status: "bun scripts/cli.ts cicd branch-follower status",
|
|
dryRun: "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function applyController(registry: BranchFollowerRegistry, options: ParsedOptions): Promise<Record<string, unknown>> {
|
|
const manifests = renderControllerManifests(registry);
|
|
const manifestYaml = `${manifests.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`;
|
|
const manifestBase64 = Buffer.from(manifestYaml, "utf8").toString("base64");
|
|
const waitSeconds = options.timeoutSeconds ?? registry.controller.budgets.applyWaitSeconds;
|
|
const script = [
|
|
"set -eu",
|
|
"tmp=$(mktemp)",
|
|
"base64 -d >\"$tmp\" <<'UNIDESK_CICD_BRANCH_FOLLOWER_MANIFEST_B64'",
|
|
manifestBase64,
|
|
"UNIDESK_CICD_BRANCH_FOLLOWER_MANIFEST_B64",
|
|
options.dryRun
|
|
? `kubectl apply --dry-run=server --field-manager=${shQuote(registry.controller.fieldManager)} -f "$tmp"`
|
|
: `kubectl apply --server-side --force-conflicts --field-manager=${shQuote(registry.controller.fieldManager)} -f "$tmp"`,
|
|
!options.dryRun && options.wait
|
|
? `kubectl -n ${shQuote(registry.controller.namespace)} rollout status deploy/${shQuote(registry.controller.deploymentName)} --timeout=${waitSeconds}s`
|
|
: "true",
|
|
`kubectl -n ${shQuote(registry.controller.namespace)} get deploy/${shQuote(registry.controller.deploymentName)} cm/${shQuote(registry.controller.configMapName)} cm/${shQuote(registry.controller.stateConfigMapName)} lease/${shQuote(registry.controller.leaseName)} -o wide 2>/dev/null || true`,
|
|
].join("\n");
|
|
const result = runKubeScript(registry, options, script, "", (waitSeconds + 15) * 1000);
|
|
return {
|
|
ok: result.exitCode === 0,
|
|
action: "apply",
|
|
dryRun: options.dryRun,
|
|
wait: options.wait,
|
|
registry: registrySummary(registry),
|
|
objects: manifests.map((item) => objectRef(item)),
|
|
manifestSha256: createHash("sha256").update(manifestYaml).digest("hex"),
|
|
controller: {
|
|
namespace: registry.controller.namespace,
|
|
route: registry.controller.kubeRoute,
|
|
deploymentName: registry.controller.deploymentName,
|
|
stateConfigMapName: registry.controller.stateConfigMapName,
|
|
leaseName: registry.controller.leaseName,
|
|
hostWorktreeMounted: false,
|
|
sourceMode: "k8s-git-mirror-to-emptyDir",
|
|
},
|
|
command: commandCompact(result, options),
|
|
next: {
|
|
status: "bun scripts/cli.ts cicd branch-follower status",
|
|
logs: `bun scripts/cli.ts cicd branch-follower logs --follower ${registry.followers[0]?.id ?? "<id>"}`,
|
|
dryRun: "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function buildStatus(registry: BranchFollowerRegistry, options: ParsedOptions): Promise<Record<string, unknown>> {
|
|
let k8s = readK8sState(registry, options);
|
|
const wantsLive = options.live || (!options.noLive && Object.keys(k8s.stateByFollower).length === 0);
|
|
const refresh = wantsLive && !options.controller ? runControllerReconcileJob(registry, options, { dryRun: true, wait: true, recordState: true }) : null;
|
|
if (refresh !== null) k8s = readK8sState(registry, options);
|
|
const shouldLive = wantsLive && options.controller;
|
|
const selected = selectFollowers(registry, options, { includeDisabled: true });
|
|
const followers = [];
|
|
for (const follower of selected) {
|
|
const stored = k8s.stateByFollower[follower.id] ?? {};
|
|
const live = shouldLive && follower.enabled ? await readAdapterStatus(registry, follower, options) : null;
|
|
followers.push(mergeFollowerStatus(registry, follower, stored, live, shouldLive));
|
|
}
|
|
return {
|
|
ok: k8s.ok && followers.every((item) => item.ok !== false),
|
|
action: "status",
|
|
live: shouldLive,
|
|
registry: registrySummary(registry),
|
|
controller: controllerStatusSummary(registry, k8s),
|
|
followers,
|
|
refresh,
|
|
errors: k8s.errors,
|
|
next: {
|
|
apply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait",
|
|
liveStatus: "bun scripts/cli.ts cicd branch-follower status --live",
|
|
dryRun: "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function runOnce(registry: BranchFollowerRegistry, options: ParsedOptions): Promise<Record<string, unknown>> {
|
|
if (!options.controller) {
|
|
const refresh = runControllerReconcileJob(registry, options, { dryRun: options.dryRun, wait: true, recordState: true });
|
|
const k8s = readK8sState(registry, options);
|
|
const selected = selectFollowers(registry, options, { includeDisabled: false });
|
|
return {
|
|
ok: refresh.ok,
|
|
action: "run-once",
|
|
dryRun: options.dryRun,
|
|
confirm: options.confirm,
|
|
wait: true,
|
|
controller: false,
|
|
execution: "k8s-native-reconcile-job",
|
|
registry: registrySummary(registry),
|
|
job: refresh,
|
|
followers: selected.map((follower) => mergeFollowerStatus(registry, follower, k8s.stateByFollower[follower.id] ?? {}, null, false)),
|
|
warnings: refresh.ok ? [] : [`reconcile job failed: ${refresh.message}`],
|
|
next: {
|
|
status: "bun scripts/cli.ts cicd branch-follower status",
|
|
liveStatus: "bun scripts/cli.ts cicd branch-follower status --live",
|
|
},
|
|
};
|
|
}
|
|
const selected = selectFollowers(registry, options, { includeDisabled: false });
|
|
const previous = readK8sState(registry, options);
|
|
const results: FollowerState[] = [];
|
|
const stateWriteWarnings: string[] = [];
|
|
for (const follower of selected) {
|
|
const oldState = previous.stateByFollower[follower.id] ?? {};
|
|
const live = await readAdapterStatus(registry, follower, options);
|
|
const state = await decideAndMaybeTrigger(registry, follower, oldState, live, options);
|
|
if (!options.dryRun || options.recordState) {
|
|
const write = writeFollowerState(registry, state, options);
|
|
if (write.exitCode !== 0) {
|
|
const warning = `state write failed for ${follower.id}: ${tailText(write.stderr || write.stdout, 300)}`;
|
|
state.warnings.push(warning);
|
|
stateWriteWarnings.push(warning);
|
|
}
|
|
}
|
|
results.push(state);
|
|
}
|
|
return {
|
|
ok: results.every((item) => item.phase !== "Failed" && item.phase !== "Blocked"),
|
|
action: "run-once",
|
|
dryRun: options.dryRun,
|
|
confirm: options.confirm,
|
|
wait: options.wait,
|
|
controller: options.controller,
|
|
registry: registrySummary(registry),
|
|
followers: results,
|
|
warnings: stateWriteWarnings,
|
|
next: {
|
|
status: "bun scripts/cli.ts cicd branch-follower status",
|
|
liveStatus: "bun scripts/cli.ts cicd branch-follower status --live",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function runFollowerDrillDown(registry: BranchFollowerRegistry, options: ParsedOptions): Promise<Record<string, unknown>> {
|
|
if (options.followerId === null) {
|
|
return {
|
|
ok: true,
|
|
action: options.action,
|
|
message: "select one follower to inspect native Kubernetes status objects",
|
|
followers: registry.followers.map((follower) => ({
|
|
id: follower.id,
|
|
adapter: follower.adapter,
|
|
statusAuthority: "k8s-native",
|
|
nativeStatus: nativeStatusPlan(follower.nativeStatus),
|
|
})),
|
|
};
|
|
}
|
|
const follower = registry.followers.find((item) => item.id === options.followerId);
|
|
if (follower === undefined) throw new Error(`unknown follower ${options.followerId}`);
|
|
const live = await readAdapterStatus(registry, follower, options);
|
|
return {
|
|
ok: live.ok,
|
|
action: options.action,
|
|
follower: follower.id,
|
|
adapter: follower.adapter,
|
|
statusAuthority: "k8s-native",
|
|
parsedDownstreamCliOutput: false,
|
|
summary: {
|
|
phase: live.phase,
|
|
observedSha: live.observedSha,
|
|
targetSha: live.targetSha,
|
|
pipelineRun: live.pipelineRun,
|
|
aligned: live.aligned,
|
|
message: live.message,
|
|
},
|
|
native: live.payload,
|
|
next: {
|
|
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
|
|
runOnceDryRun: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --dry-run`,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function decideAndMaybeTrigger(
|
|
registry: BranchFollowerRegistry,
|
|
follower: FollowerSpec,
|
|
previous: Record<string, unknown>,
|
|
live: AdapterSummary,
|
|
options: ParsedOptions,
|
|
): Promise<FollowerState> {
|
|
const warnings: string[] = [];
|
|
if (!live.ok) warnings.push(`status command failed: exitCode=${live.exitCode}${live.timedOut ? " timedOut=true" : ""}`);
|
|
const observedSha = live.observedSha;
|
|
let targetSha = live.targetSha;
|
|
const previousLastTriggered = stringOrNull(previous.lastTriggeredSha);
|
|
const previousInFlight = stringOrNull(previous.inFlightJob);
|
|
const previousLastSucceeded = stringOrNull(previous.lastSucceededSha);
|
|
const previousObserved = stringOrNull(recordAt(previous, ["source"])?.observedSha);
|
|
const superseded = previousInFlight !== null && previousObserved !== null && observedSha !== null && previousObserved !== observedSha;
|
|
let phase: BranchFollowerPhase;
|
|
let decision: string;
|
|
let triggerCommand: Record<string, unknown> | undefined;
|
|
let inFlightJob: string | null = live.inFlightJob;
|
|
let lastTriggeredSha = live.lastTriggeredSha ?? previousLastTriggered;
|
|
let lastSucceededSha = live.lastSucceededSha ?? previousLastSucceeded;
|
|
if (targetSha === null && observedSha !== null && previousLastSucceeded === observedSha) targetSha = observedSha;
|
|
|
|
if (!follower.enabled) {
|
|
phase = "Skipped";
|
|
decision = "follower disabled";
|
|
} else if (observedSha === null) {
|
|
phase = live.ok ? "Observed" : "Blocked";
|
|
decision = "status did not expose an observed source sha; adapter trigger-current remains the dedupe authority";
|
|
} else if (superseded) {
|
|
phase = "Superseded";
|
|
decision = `previous in-flight sha ${shortSha(previousObserved)} was superseded by ${shortSha(observedSha)}`;
|
|
} else if (targetSha !== null && targetSha === observedSha) {
|
|
phase = "Noop";
|
|
decision = "target already matches observed source sha";
|
|
lastSucceededSha = observedSha;
|
|
} else if (previousLastTriggered !== null && previousLastTriggered === observedSha) {
|
|
phase = "ClosingOut";
|
|
decision = "same sha was already triggered; use status/events/logs for closeout";
|
|
} else {
|
|
phase = "PendingTrigger";
|
|
decision = targetSha === null
|
|
? "target sha is unknown; trigger-current adapter will dedupe by source snapshot"
|
|
: `observed ${shortSha(observedSha)} differs from target ${shortSha(targetSha)}`;
|
|
}
|
|
|
|
if (options.confirm && (phase === "PendingTrigger" || phase === "Superseded" || (phase === "Observed" && observedSha !== null))) {
|
|
const trigger = await executeTrigger(follower, observedSha, options);
|
|
triggerCommand = trigger.command;
|
|
phase = trigger.ok ? (options.wait || options.controller ? "ClosingOut" : "Triggering") : "Failed";
|
|
decision = trigger.ok ? `trigger submitted for ${shortSha(observedSha)}` : `trigger failed for ${shortSha(observedSha)}`;
|
|
inFlightJob = trigger.jobId ?? live.inFlightJob;
|
|
lastTriggeredSha = observedSha;
|
|
if (trigger.ok && options.wait) {
|
|
targetSha = observedSha;
|
|
lastSucceededSha = observedSha;
|
|
}
|
|
if (!trigger.ok) warnings.push(trigger.message);
|
|
}
|
|
|
|
if (options.dryRun && phase === "PendingTrigger") decision = `${decision}; dry-run did not trigger`;
|
|
|
|
return {
|
|
id: follower.id,
|
|
adapter: follower.adapter,
|
|
enabled: follower.enabled,
|
|
phase,
|
|
source: {
|
|
repository: follower.source.repository,
|
|
branch: follower.source.branch,
|
|
branchRef: follower.source.branchRef,
|
|
snapshotPrefix: follower.source.snapshotPrefix,
|
|
observedSha,
|
|
},
|
|
target: {
|
|
node: follower.target.node,
|
|
lane: follower.target.lane,
|
|
namespace: follower.target.namespace,
|
|
sentinel: follower.target.sentinel,
|
|
targetSha,
|
|
},
|
|
lastTriggeredSha,
|
|
lastSucceededSha,
|
|
pipelineRun: live.pipelineRun,
|
|
inFlightJob,
|
|
budgetSource: follower.budgets,
|
|
controller: {
|
|
mode: options.controller ? "k8s-controller" : "local-cli",
|
|
stateConfigMap: registry.controller.stateConfigMapName,
|
|
leaseName: registry.controller.leaseName,
|
|
},
|
|
decision,
|
|
dryRun: options.dryRun,
|
|
updatedAt: new Date().toISOString(),
|
|
warnings,
|
|
next: followerNextCommands(follower),
|
|
command: triggerCommand ?? {
|
|
status: live.command,
|
|
exitCode: live.exitCode,
|
|
timedOut: live.timedOut,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function executeTrigger(follower: FollowerSpec, observedSha: string | null, options: ParsedOptions): Promise<{ ok: boolean; message: string; jobId: string | null; command: Record<string, unknown> }> {
|
|
const spec = follower.commands.trigger;
|
|
const timeoutSeconds = options.timeoutSeconds ?? spec.timeoutSeconds;
|
|
if (!options.wait && !options.controller) {
|
|
const job = startJob(`cicd_branch_follower_${safeJobSegment(follower.id)}`, spec.argv, `Trigger ${follower.id} for observed sha ${observedSha ?? "unknown"}`);
|
|
return {
|
|
ok: true,
|
|
message: `started async job ${job.id}`,
|
|
jobId: job.id,
|
|
command: {
|
|
mode: "async-job",
|
|
argv: spec.argv,
|
|
jobId: job.id,
|
|
status: `bun scripts/cli.ts job status ${job.id}`,
|
|
},
|
|
};
|
|
}
|
|
const result = runCommand(spec.argv, repoRoot, { timeoutMs: timeoutSeconds * 1000 });
|
|
return {
|
|
ok: result.exitCode === 0,
|
|
message: result.exitCode === 0 ? "trigger command completed" : tailText(result.stderr || result.stdout, 500),
|
|
jobId: null,
|
|
command: commandCompact(result, options),
|
|
};
|
|
}
|
|
|
|
async function readAdapterStatus(registry: BranchFollowerRegistry, follower: FollowerSpec, options: ParsedOptions): Promise<AdapterSummary> {
|
|
const timeoutSeconds = options.timeoutSeconds ?? follower.budgets.statusSeconds;
|
|
const bundle = readNativeObjectBundle(registry, follower, options, timeoutSeconds);
|
|
const observedSha = stringOrNull(bundle.source?.commit);
|
|
const runtimeTargetSha = runtimeTargetShaFromWorkloads(follower.nativeStatus.runtime, bundle.workloads);
|
|
const pipelineRunName = stringOrNull(asOptionalRecord(bundle.pipelineRun?.metadata)?.name) ?? expectedPipelineRunName(follower, observedSha);
|
|
const pipelineSucceeded = pipelineRunSucceeded(bundle.pipelineRun);
|
|
const argoReady = follower.nativeStatus.argo === null ? null : argoApplicationReady(bundle.argoApplication);
|
|
const runtimeReady = follower.nativeStatus.runtime === null ? null : runtimeWorkloadsReady(follower.nativeStatus.runtime, bundle.workloads);
|
|
const hasTektonGate = follower.nativeStatus.tekton !== null;
|
|
const hasRuntimeTarget = runtimeTargetSha !== null;
|
|
const hasTargetEvidence = hasRuntimeTarget || hasTektonGate;
|
|
const pipelineGateOk = !hasTektonGate
|
|
|| pipelineSucceeded === true
|
|
|| (hasRuntimeTarget && runtimeTargetSha === observedSha && argoReady !== false && runtimeReady !== false);
|
|
const aligned = observedSha !== null
|
|
&& hasTargetEvidence
|
|
&& (hasRuntimeTarget ? runtimeTargetSha === observedSha : true)
|
|
&& pipelineGateOk
|
|
&& argoReady !== false
|
|
&& runtimeReady !== false;
|
|
const targetSha = hasRuntimeTarget && runtimeTargetSha === observedSha && aligned ? runtimeTargetSha : runtimeTargetSha;
|
|
const ok = bundle.ok;
|
|
const phase = inferPhase(ok, aligned, observedSha, targetSha, bundle.timedOut);
|
|
return {
|
|
ok,
|
|
command: "native:k8s-git-mirror+tekton+argocd+runtime",
|
|
exitCode: bundle.exitCode,
|
|
timedOut: bundle.timedOut,
|
|
observedSha,
|
|
targetSha,
|
|
lastTriggeredSha: null,
|
|
lastSucceededSha: aligned && observedSha !== null ? observedSha : null,
|
|
pipelineRun: pipelineRunName,
|
|
inFlightJob: pipelineSucceeded === null && pipelineRunName !== null ? pipelineRunName : null,
|
|
aligned,
|
|
phase,
|
|
message: nativeStatusMessage(ok, phase, observedSha, targetSha, {
|
|
pipelineSucceeded,
|
|
argoReady,
|
|
runtimeReady,
|
|
errors: bundle.errors,
|
|
}),
|
|
payload: {
|
|
source: bundle.source,
|
|
tekton: nativePipelineRunSummary(bundle.pipelineRun),
|
|
argo: nativeArgoSummary(bundle.argoApplication),
|
|
runtime: nativeRuntimeSummary(follower.nativeStatus.runtime, bundle.workloads),
|
|
errors: bundle.errors,
|
|
statusAuthority: "k8s-native",
|
|
parsedDownstreamCliOutput: false,
|
|
},
|
|
stderrTail: bundle.stderrTail,
|
|
stdoutTail: bundle.stdoutTail,
|
|
};
|
|
}
|
|
|
|
function inferPhase(ok: boolean, aligned: boolean | null, observedSha: string | null, targetSha: string | null, timedOut: boolean): BranchFollowerPhase {
|
|
if (!ok || timedOut) return "Blocked";
|
|
if (aligned === true) return "Succeeded";
|
|
if (observedSha !== null && targetSha !== null && observedSha === targetSha) return "Noop";
|
|
if (observedSha !== null) return "PendingTrigger";
|
|
return "Observed";
|
|
}
|
|
|
|
function nativeStatusMessage(ok: boolean, phase: BranchFollowerPhase, observedSha: string | null, targetSha: string | null, gates: { pipelineSucceeded: boolean | null; argoReady: boolean | null; runtimeReady: boolean | null; errors: string[] }): string {
|
|
if (!ok) return gates.errors[0] ?? "native status read failed";
|
|
if (phase === "Noop" || phase === "Succeeded") return `target matches ${shortSha(observedSha)}`;
|
|
if (observedSha !== null && targetSha !== null) return `observed ${shortSha(observedSha)} target ${shortSha(targetSha)}`;
|
|
if (observedSha !== null) {
|
|
const gatesText = [
|
|
gates.pipelineSucceeded === false ? "pipelineRun not successful" : null,
|
|
gates.argoReady === false ? "argo not healthy/synced" : null,
|
|
gates.runtimeReady === false ? "runtime not ready" : null,
|
|
].filter((item): item is string => item !== null).join("; ");
|
|
return gatesText.length > 0 ? `observed ${shortSha(observedSha)}; ${gatesText}` : `observed ${shortSha(observedSha)} target unknown`;
|
|
}
|
|
return "k8s-native status did not expose observed source sha";
|
|
}
|
|
|
|
function readNativeObjectBundle(registry: BranchFollowerRegistry, follower: FollowerSpec, options: ParsedOptions, timeoutSeconds: number): NativeObjectBundle {
|
|
const native = follower.nativeStatus;
|
|
const source = native.source;
|
|
const tekton = native.tekton;
|
|
const argo = native.argo;
|
|
const runtime = native.runtime;
|
|
const workloadCommands = (runtime?.workloads ?? []).map((workload, index) => {
|
|
const resource = workload.kind === "Deployment" ? "deployment" : "statefulset";
|
|
return `emit_json ${shQuote(`workload${index}`)} kubectl -n ${shQuote(runtime?.namespace ?? follower.target.namespace)} get ${resource} ${shQuote(workload.name)} -o json`;
|
|
});
|
|
const script = [
|
|
"set +e",
|
|
"tmpdir=$(mktemp -d)",
|
|
"cleanup() { rm -rf \"$tmpdir\"; }",
|
|
"trap cleanup EXIT INT TERM",
|
|
"cat >\"$tmpdir/compact-native-object.mjs\" <<'NODE_COMPACT'",
|
|
"import { readFileSync } from 'node:fs';",
|
|
"const key = process.argv[2] || '';",
|
|
"const input = JSON.parse(readFileSync(0, 'utf8'));",
|
|
"const cleanMap = (value) => {",
|
|
" if (!value || typeof value !== 'object' || Array.isArray(value)) return {};",
|
|
" const out = {};",
|
|
" for (const [k, v] of Object.entries(value)) {",
|
|
" if (k === 'kubectl.kubernetes.io/last-applied-configuration') continue;",
|
|
" out[k] = v;",
|
|
" }",
|
|
" return out;",
|
|
"};",
|
|
"const metadata = (obj) => ({",
|
|
" name: obj?.metadata?.name || null,",
|
|
" namespace: obj?.metadata?.namespace || null,",
|
|
" labels: cleanMap(obj?.metadata?.labels),",
|
|
" annotations: cleanMap(obj?.metadata?.annotations),",
|
|
"});",
|
|
"const compactContainer = (container) => ({",
|
|
" name: container?.name || null,",
|
|
" image: container?.image || null,",
|
|
" env: Array.isArray(container?.env) ? container.env.filter((item) => item && typeof item.name === 'string' && typeof item.value === 'string').map((item) => ({ name: item.name, value: item.value })) : [],",
|
|
"});",
|
|
"let output = input;",
|
|
"if (key === 'pipelineRun') {",
|
|
" output = {",
|
|
" apiVersion: input.apiVersion,",
|
|
" kind: input.kind,",
|
|
" metadata: metadata(input),",
|
|
" spec: { params: Array.isArray(input?.spec?.params) ? input.spec.params : [] },",
|
|
" status: { conditions: Array.isArray(input?.status?.conditions) ? input.status.conditions : [], startTime: input?.status?.startTime || null, completionTime: input?.status?.completionTime || null },",
|
|
" };",
|
|
"} else if (key === 'argoApplication') {",
|
|
" output = {",
|
|
" apiVersion: input.apiVersion,",
|
|
" kind: input.kind,",
|
|
" metadata: metadata(input),",
|
|
" status: { sync: input?.status?.sync || null, health: input?.status?.health || null, operationState: input?.status?.operationState ? { phase: input.status.operationState.phase || null, message: input.status.operationState.message || null, finishedAt: input.status.operationState.finishedAt || null } : null },",
|
|
" };",
|
|
"} else if (/^workload\\d+$/.test(key)) {",
|
|
" const template = input?.spec?.template || {};",
|
|
" output = {",
|
|
" apiVersion: input.apiVersion,",
|
|
" kind: input.kind,",
|
|
" metadata: metadata(input),",
|
|
" spec: { replicas: input?.spec?.replicas ?? null, template: { metadata: { labels: cleanMap(template?.metadata?.labels), annotations: cleanMap(template?.metadata?.annotations) }, spec: { containers: Array.isArray(template?.spec?.containers) ? template.spec.containers.map(compactContainer) : [] } } },",
|
|
" status: { replicas: input?.status?.replicas ?? null, readyReplicas: input?.status?.readyReplicas ?? null, availableReplicas: input?.status?.availableReplicas ?? null, updatedReplicas: input?.status?.updatedReplicas ?? null, conditions: Array.isArray(input?.status?.conditions) ? input.status.conditions.map((item) => ({ type: item.type || null, status: item.status || null, reason: item.reason || null })) : [] },",
|
|
" };",
|
|
"}",
|
|
"console.log(JSON.stringify(output));",
|
|
"NODE_COMPACT",
|
|
"emit_json() {",
|
|
" key=\"$1\"",
|
|
" shift",
|
|
" raw=\"$tmpdir/$key.raw\"",
|
|
" out=\"$tmpdir/$key.out\"",
|
|
" err=\"$tmpdir/$key.err\"",
|
|
" if \"$@\" >\"$raw\" 2>\"$err\" && node \"$tmpdir/compact-native-object.mjs\" \"$key\" <\"$raw\" >\"$out\" 2>>\"$err\"; then",
|
|
" printf 'UNIDESK_NATIVE_JSON\\t%s\\t' \"$key\"",
|
|
" base64 \"$out\" | tr -d '\\n'",
|
|
" printf '\\n'",
|
|
" else",
|
|
" printf 'UNIDESK_NATIVE_ERROR\\t%s\\t' \"$key\"",
|
|
" base64 \"$err\" | tr -d '\\n'",
|
|
" printf '\\n'",
|
|
" fi",
|
|
"}",
|
|
`repo_path=${shQuote(source.repoPath)}`,
|
|
`branch=${shQuote(follower.source.branch)}`,
|
|
`repository=${shQuote(follower.source.repository)}`,
|
|
`snapshot_prefix=${shQuote(follower.source.snapshotPrefix)}`,
|
|
`read_url=${shQuote(source.gitMirrorReadUrl)}`,
|
|
`mirror_ns=${shQuote(source.gitMirrorNamespace)}`,
|
|
`mirror_deploy=${shQuote(source.gitMirrorDeployment)}`,
|
|
"source_commit=",
|
|
"source_err=\"$tmpdir/source.err\"",
|
|
"if [ -x /etc/unidesk-cicd-branch-follower/sync-source.sh ]; then",
|
|
" /etc/unidesk-cicd-branch-follower/sync-source.sh \"$repository\" \"$branch\" \"$snapshot_prefix\" \"$repo_path\" >/dev/null 2>\"$source_err\" || true",
|
|
"fi",
|
|
"if [ -d \"$repo_path/objects\" ]; then",
|
|
" source_commit=$(git --git-dir=\"$repo_path\" rev-parse --verify \"refs/heads/$branch^{commit}\" 2>>\"$source_err\" | head -n 1 | tr -d '\\r' || true)",
|
|
"else",
|
|
" source_commit=$(kubectl -n \"$mirror_ns\" exec \"deploy/$mirror_deploy\" -- env REPO_PATH=\"$repo_path\" BRANCH=\"$branch\" sh -lc 'git --git-dir=\"$REPO_PATH\" rev-parse --verify \"refs/heads/$BRANCH^{commit}\"' 2>>\"$source_err\" | head -n 1 | tr -d '\\r' || true)",
|
|
"fi",
|
|
"case \"$source_commit\" in",
|
|
" [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f])",
|
|
" printf 'UNIDESK_NATIVE_JSON\\tsource\\t'",
|
|
" printf '{\"commit\":\"%s\",\"branch\":\"%s\",\"mode\":\"k8s-git-mirror-cache\",\"repoPath\":\"%s\"}' \"$source_commit\" \"$branch\" \"$repo_path\" | base64 | tr -d '\\n'",
|
|
" printf '\\n'",
|
|
" ;;",
|
|
" *)",
|
|
" printf 'UNIDESK_NATIVE_ERROR\\tsource\\t'",
|
|
" base64 \"$source_err\" | tr -d '\\n'",
|
|
" printf '\\n'",
|
|
" ;;",
|
|
"esac",
|
|
tekton === null
|
|
? "true"
|
|
: [
|
|
"if [ -n \"$source_commit\" ]; then",
|
|
" sha12=$(printf '%s' \"$source_commit\" | cut -c1-12)",
|
|
` emit_json pipelineRun kubectl -n ${shQuote(tekton.namespace)} get pipelinerun ${shQuote(`${tekton.pipelineRunPrefix}-`)}"$sha12" -o json`,
|
|
"fi",
|
|
].join("\n"),
|
|
argo === null
|
|
? "true"
|
|
: `emit_json argoApplication kubectl -n ${shQuote(argo.namespace)} get application ${shQuote(argo.application)} -o json`,
|
|
...workloadCommands,
|
|
"exit 0",
|
|
].join("\n");
|
|
const result = runKubeScript(registry, options, script, "", Math.max(5, 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,
|
|
pipelineRun: asOptionalRecord(parsed.objects.pipelineRun),
|
|
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<string, unknown> => item !== null),
|
|
errors: [
|
|
...parsed.errors,
|
|
...(result.exitCode === 0 ? [] : [`native bundle command failed: exitCode=${result.exitCode}`]),
|
|
...parsed.fatalErrors,
|
|
],
|
|
exitCode: result.exitCode,
|
|
timedOut: result.timedOut,
|
|
stdoutTail: redactText(tailText(result.stdout, 1000)),
|
|
stderrTail: redactText(tailText(result.stderr, 1000)),
|
|
};
|
|
}
|
|
|
|
function parseNativeBundleLines(stdout: string): { objects: Record<string, unknown>; errors: string[]; fatalErrors: string[] } {
|
|
const objects: Record<string, unknown> = {};
|
|
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 expectedPipelineRunName(follower: FollowerSpec, observedSha: string | null): string | null {
|
|
if (observedSha === null || follower.nativeStatus.tekton === null) return null;
|
|
return `${follower.nativeStatus.tekton.pipelineRunPrefix}-${shortSha(observedSha)}`;
|
|
}
|
|
|
|
function pipelineRunSucceeded(pipelineRun: Record<string, unknown> | null): boolean | null {
|
|
if (pipelineRun === null) return null;
|
|
const conditions = Array.isArray(asOptionalRecord(pipelineRun.status)?.conditions) ? asOptionalRecord(pipelineRun.status)?.conditions : [];
|
|
for (const condition of conditions as unknown[]) {
|
|
const record = asOptionalRecord(condition);
|
|
if (record?.type !== "Succeeded") continue;
|
|
if (record.status === "True") return true;
|
|
if (record.status === "False") return false;
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function argoApplicationReady(application: Record<string, unknown> | null): boolean {
|
|
if (application === null) return false;
|
|
const status = asOptionalRecord(application.status);
|
|
const sync = asOptionalRecord(status?.sync);
|
|
const health = asOptionalRecord(status?.health);
|
|
return sync?.status === "Synced" && health?.status === "Healthy";
|
|
}
|
|
|
|
function runtimeWorkloadsReady(runtime: NativeStatusSpec["runtime"], workloads: Record<string, unknown>[]): boolean {
|
|
if (runtime === null) return true;
|
|
if (workloads.length < runtime.workloads.length) return false;
|
|
return runtime.workloads.every((spec, index) => workloadReady(spec, workloads[index] ?? null));
|
|
}
|
|
|
|
function workloadReady(spec: NativeWorkloadSpec, workload: Record<string, unknown> | null): boolean {
|
|
if (workload === null) return false;
|
|
const status = asOptionalRecord(workload.status);
|
|
if (spec.kind === "Deployment") {
|
|
const desired = numberOrNull(asOptionalRecord(workload.spec)?.replicas) ?? 1;
|
|
const available = numberOrNull(status?.availableReplicas) ?? 0;
|
|
const updated = numberOrNull(status?.updatedReplicas) ?? 0;
|
|
return available >= desired && updated >= desired;
|
|
}
|
|
const desired = numberOrNull(asOptionalRecord(workload.spec)?.replicas) ?? 1;
|
|
const ready = numberOrNull(status?.readyReplicas) ?? 0;
|
|
return ready >= desired;
|
|
}
|
|
|
|
function runtimeTargetShaFromWorkloads(runtime: NativeStatusSpec["runtime"], workloads: Record<string, unknown>[]): string | null {
|
|
if (runtime === null) return null;
|
|
const commits: string[] = [];
|
|
runtime.workloads.forEach((spec, index) => {
|
|
const commit = workloadSourceCommit(spec, workloads[index] ?? null);
|
|
if (commit !== null) commits.push(commit);
|
|
});
|
|
const unique = Array.from(new Set(commits));
|
|
return unique.length === 1 ? unique[0] ?? null : null;
|
|
}
|
|
|
|
function workloadSourceCommit(spec: NativeWorkloadSpec, workload: Record<string, unknown> | null): string | null {
|
|
if (workload === null) return null;
|
|
const metadata = asOptionalRecord(workload.metadata);
|
|
const labels = asOptionalRecord(metadata?.labels);
|
|
const annotations = asOptionalRecord(metadata?.annotations);
|
|
const template = asOptionalRecord(asOptionalRecord(workload.spec)?.template);
|
|
const podMetadata = asOptionalRecord(template?.metadata);
|
|
const podLabels = asOptionalRecord(podMetadata?.labels);
|
|
const podAnnotations = asOptionalRecord(podMetadata?.annotations);
|
|
for (const key of spec.sourceCommit.labels) {
|
|
const value = shaOrNull(labels?.[key]);
|
|
if (value !== null) return value;
|
|
}
|
|
for (const key of spec.sourceCommit.annotations) {
|
|
const value = shaOrNull(annotations?.[key]);
|
|
if (value !== null) return value;
|
|
}
|
|
for (const key of spec.sourceCommit.podLabels) {
|
|
const value = shaOrNull(podLabels?.[key]);
|
|
if (value !== null) return value;
|
|
}
|
|
for (const key of spec.sourceCommit.podAnnotations) {
|
|
const value = shaOrNull(podAnnotations?.[key]);
|
|
if (value !== null) return value;
|
|
}
|
|
const containers = Array.isArray(asOptionalRecord(template?.spec)?.containers) ? asOptionalRecord(template?.spec)?.containers : [];
|
|
for (const envName of spec.sourceCommit.env) {
|
|
for (const container of containers as unknown[]) {
|
|
const env = Array.isArray(asOptionalRecord(container)?.env) ? asOptionalRecord(container)?.env : [];
|
|
for (const entry of env as unknown[]) {
|
|
const record = asOptionalRecord(entry);
|
|
if (record?.name !== envName) continue;
|
|
const value = shaOrNull(record.value);
|
|
if (value !== null) return value;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function shaOrNull(value: unknown): string | null {
|
|
return typeof value === "string" && /^[0-9a-f]{40}$/iu.test(value) ? value : null;
|
|
}
|
|
|
|
function nativePipelineRunSummary(pipelineRun: Record<string, unknown> | null): Record<string, unknown> | null {
|
|
if (pipelineRun === null) return null;
|
|
const metadata = asOptionalRecord(pipelineRun.metadata);
|
|
const status = asOptionalRecord(pipelineRun.status);
|
|
const condition = latestCondition(status, "Succeeded");
|
|
return {
|
|
name: stringOrNull(metadata?.name),
|
|
namespace: stringOrNull(metadata?.namespace),
|
|
succeeded: pipelineRunSucceeded(pipelineRun),
|
|
reason: stringOrNull(condition?.reason),
|
|
startTime: stringOrNull(status?.startTime),
|
|
completionTime: stringOrNull(status?.completionTime),
|
|
};
|
|
}
|
|
|
|
function nativeArgoSummary(application: Record<string, unknown> | null): Record<string, unknown> | null {
|
|
if (application === null) return null;
|
|
const metadata = asOptionalRecord(application.metadata);
|
|
const status = asOptionalRecord(application.status);
|
|
const sync = asOptionalRecord(status?.sync);
|
|
const health = asOptionalRecord(status?.health);
|
|
return {
|
|
name: stringOrNull(metadata?.name),
|
|
namespace: stringOrNull(metadata?.namespace),
|
|
syncStatus: stringOrNull(sync?.status),
|
|
healthStatus: stringOrNull(health?.status),
|
|
revision: stringOrNull(sync?.revision),
|
|
ready: argoApplicationReady(application),
|
|
};
|
|
}
|
|
|
|
function nativeRuntimeSummary(runtime: NativeStatusSpec["runtime"], workloads: Record<string, unknown>[]): Record<string, unknown> | null {
|
|
if (runtime === null) return null;
|
|
return {
|
|
namespace: runtime.namespace,
|
|
ready: runtimeWorkloadsReady(runtime, workloads),
|
|
workloads: runtime.workloads.map((spec, index) => {
|
|
const workload = workloads[index] ?? null;
|
|
return {
|
|
kind: spec.kind,
|
|
name: spec.name,
|
|
ready: workloadReady(spec, workload),
|
|
sourceCommit: workloadSourceCommit(spec, workload),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
function latestCondition(status: Record<string, unknown> | null, type: string): Record<string, unknown> | null {
|
|
const conditions = Array.isArray(status?.conditions) ? status.conditions : [];
|
|
for (const condition of conditions as unknown[]) {
|
|
const record = asOptionalRecord(condition);
|
|
if (record?.type === type) return record;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function mergeFollowerStatus(
|
|
registry: BranchFollowerRegistry,
|
|
follower: FollowerSpec,
|
|
stored: Record<string, unknown>,
|
|
live: AdapterSummary | null,
|
|
liveRequested: boolean,
|
|
): Record<string, unknown> {
|
|
const storedSource = asOptionalRecord(stored.source);
|
|
const storedTarget = asOptionalRecord(stored.target);
|
|
const phase = live?.phase ?? stringOrNull(stored.phase) ?? "Observed";
|
|
const observedSha = live?.observedSha ?? stringOrNull(storedSource?.observedSha);
|
|
const targetSha = live?.targetSha ?? stringOrNull(storedTarget?.targetSha);
|
|
const lastTriggeredSha = live?.lastTriggeredSha ?? stringOrNull(stored.lastTriggeredSha);
|
|
const lastSucceededSha = live?.lastSucceededSha ?? stringOrNull(stored.lastSucceededSha);
|
|
return {
|
|
ok: live === null ? true : live.ok,
|
|
id: follower.id,
|
|
enabled: follower.enabled,
|
|
adapter: follower.adapter,
|
|
phase,
|
|
source: {
|
|
repository: follower.source.repository,
|
|
branch: follower.source.branch,
|
|
observedSha,
|
|
snapshotPrefix: follower.source.snapshotPrefix,
|
|
},
|
|
target: {
|
|
node: follower.target.node,
|
|
lane: follower.target.lane,
|
|
namespace: follower.target.namespace,
|
|
sentinel: follower.target.sentinel,
|
|
targetSha,
|
|
},
|
|
lastTriggeredSha,
|
|
lastSucceededSha,
|
|
pipelineRun: live?.pipelineRun ?? stringOrNull(stored.pipelineRun),
|
|
inFlightJob: live?.inFlightJob ?? stringOrNull(stored.inFlightJob),
|
|
budgetSource: follower.budgets,
|
|
updatedAt: stringOrNull(stored.updatedAt),
|
|
stateConfigMap: registry.controller.stateConfigMapName,
|
|
live: liveRequested,
|
|
message: live?.message ?? stringOrNull(stored.decision) ?? "no controller state yet",
|
|
warnings: Array.isArray(stored.warnings) ? stored.warnings.slice(0, 6) : [],
|
|
next: followerNextCommands(follower),
|
|
};
|
|
}
|
|
|
|
function readK8sState(registry: BranchFollowerRegistry, options: ParsedOptions): K8sStateRead {
|
|
const errors: string[] = [];
|
|
const stateResult = kubeJson(registry, options, `kubectl -n ${shQuote(registry.controller.namespace)} get configmap ${shQuote(registry.controller.stateConfigMapName)} -o json`, 10_000);
|
|
const deploymentResult = kubeJson(registry, options, `kubectl -n ${shQuote(registry.controller.namespace)} get deploy ${shQuote(registry.controller.deploymentName)} -o json`, 10_000);
|
|
const leaseResult = kubeJson(registry, options, `kubectl -n ${shQuote(registry.controller.namespace)} get lease ${shQuote(registry.controller.leaseName)} -o json`, 10_000);
|
|
const podSelector = labelSelector(registry.controller.labels);
|
|
const podsResult = kubePodList(registry, options, podSelector);
|
|
if (!stateResult.ok && !isNotFoundText(stateResult.error)) errors.push(`state configmap: ${stateResult.error}`);
|
|
if (!deploymentResult.ok && !isNotFoundText(deploymentResult.error)) errors.push(`deployment: ${deploymentResult.error}`);
|
|
if (!leaseResult.ok && !isNotFoundText(leaseResult.error)) errors.push(`lease: ${leaseResult.error}`);
|
|
if (!podsResult.ok && !isNotFoundText(podsResult.error)) errors.push(`pods: ${podsResult.error}`);
|
|
const stateByFollower: Record<string, Record<string, unknown>> = {};
|
|
const data = asOptionalRecord(stateResult.value?.data);
|
|
if (data !== null) {
|
|
for (const [key, value] of Object.entries(data)) {
|
|
if (key.startsWith("_")) continue;
|
|
if (typeof value !== "string") continue;
|
|
const parsed = parseJsonObject(value);
|
|
if (parsed !== null) stateByFollower[key] = parsed;
|
|
}
|
|
}
|
|
return {
|
|
ok: errors.length === 0,
|
|
stateByFollower,
|
|
stateConfigMapPresent: stateResult.value !== null,
|
|
deployment: deploymentResult.value,
|
|
lease: leaseResult.value,
|
|
pods: podsResult.value,
|
|
errors,
|
|
};
|
|
}
|
|
|
|
function kubeJson(registry: BranchFollowerRegistry, options: ParsedOptions, command: string, timeoutMs: number): { ok: boolean; value: Record<string, unknown> | null; error: string } {
|
|
const result = runKubeScript(registry, options, `set -eu\n${command}`, "", timeoutMs);
|
|
const value = result.exitCode === 0 ? parseJsonObject(result.stdout) : null;
|
|
return {
|
|
ok: result.exitCode === 0 && value !== null,
|
|
value,
|
|
error: redactText(tailText(result.stderr || result.stdout, 800)),
|
|
};
|
|
}
|
|
|
|
function kubePodList(registry: BranchFollowerRegistry, options: ParsedOptions, selector: string): { ok: boolean; value: Record<string, unknown> | null; error: string } {
|
|
const command = `kubectl -n ${shQuote(registry.controller.namespace)} get pods -l ${shQuote(selector)} -o name`;
|
|
const result = runKubeScript(registry, options, `set -eu\n${command}`, "", 10_000);
|
|
const names = result.stdout
|
|
.split(/\r?\n/u)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.map((line) => line.replace(/^pod\//u, ""));
|
|
return {
|
|
ok: result.exitCode === 0,
|
|
value: result.exitCode === 0 ? { items: names.map((name) => ({ metadata: { name } })) } : null,
|
|
error: redactText(tailText(result.stderr || result.stdout, 800)),
|
|
};
|
|
}
|
|
|
|
function runKubeScript(registry: BranchFollowerRegistry, options: ParsedOptions, script: string, input: string, timeoutMs: number): CommandResult {
|
|
if (options.controller) {
|
|
return runCommand(["sh", "-lc", script], repoRoot, { input, timeoutMs });
|
|
}
|
|
return runCommand([transPath(), registry.controller.kubeRoute, "sh"], repoRoot, { input: `${script}\n`, timeoutMs });
|
|
}
|
|
|
|
function writeFollowerState(registry: BranchFollowerRegistry, state: FollowerState, options: ParsedOptions): CommandResult {
|
|
const json = JSON.stringify(state);
|
|
const dataPatch = JSON.stringify({ data: { [state.id]: json, _updatedAt: new Date().toISOString(), _specRef: SPEC_REF } });
|
|
const script = [
|
|
"set -eu",
|
|
`kubectl -n ${shQuote(registry.controller.namespace)} create configmap ${shQuote(registry.controller.stateConfigMapName)} --from-literal=_createdAt="$(date -Iseconds)" --dry-run=client -o yaml | kubectl apply -f - >/dev/null`,
|
|
`kubectl -n ${shQuote(registry.controller.namespace)} patch configmap ${shQuote(registry.controller.stateConfigMapName)} --type merge -p ${shQuote(dataPatch)} >/dev/null`,
|
|
].join("\n");
|
|
return runKubeScript(registry, options, script, "", 10_000);
|
|
}
|
|
|
|
function runControllerReconcileJob(registry: BranchFollowerRegistry, options: ParsedOptions, mode: { dryRun: boolean; wait: boolean; recordState: boolean }): Record<string, unknown> {
|
|
const timeoutSeconds = options.timeoutSeconds ?? registry.controller.budgets.runOnceSeconds;
|
|
const jobName = `${registry.controller.deploymentName}-once-${Date.now().toString(36)}`.slice(0, 63);
|
|
const manifest = renderControllerReconcileJob(registry, options, jobName, mode, timeoutSeconds);
|
|
const manifestYaml = `${Bun.YAML.stringify(manifest).trim()}\n`;
|
|
const manifestBase64 = Buffer.from(manifestYaml, "utf8").toString("base64");
|
|
const script = [
|
|
"set -eu",
|
|
"tmp=$(mktemp)",
|
|
"base64 -d >\"$tmp\" <<'UNIDESK_CICD_RECONCILE_JOB_B64'",
|
|
manifestBase64,
|
|
"UNIDESK_CICD_RECONCILE_JOB_B64",
|
|
`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`,
|
|
mode.wait ? waitForJobShell(registry.controller.namespace, jobName, timeoutSeconds) : "true",
|
|
].join("\n");
|
|
const result = runKubeScript(registry, options, script, "", (timeoutSeconds + 20) * 1000);
|
|
return {
|
|
ok: result.exitCode === 0,
|
|
name: jobName,
|
|
namespace: registry.controller.namespace,
|
|
mode: mode.dryRun ? "status-refresh" : "confirm-reconcile",
|
|
execution: "k8s-native-job",
|
|
exitCode: result.exitCode,
|
|
timedOut: result.timedOut,
|
|
message: result.exitCode === 0 ? "reconcile job completed" : redactText(tailText(result.stderr || result.stdout, 800)),
|
|
};
|
|
}
|
|
|
|
function renderControllerReconcileJob(registry: BranchFollowerRegistry, options: ParsedOptions, jobName: string, mode: { dryRun: boolean; recordState: boolean }, timeoutSeconds: number): Record<string, unknown> {
|
|
const labels = { ...registry.controller.labels, "app.kubernetes.io/component": "cicd-reconcile-job" };
|
|
const commandArgs = [
|
|
"bun",
|
|
"scripts/cli.ts",
|
|
"cicd",
|
|
"branch-follower",
|
|
"run-once",
|
|
...(options.followerId === null ? ["--all"] : ["--follower", options.followerId]),
|
|
mode.dryRun ? "--dry-run" : "--confirm",
|
|
"--wait",
|
|
"--controller",
|
|
"--config",
|
|
"config/cicd-branch-followers.yaml",
|
|
"--timeout-seconds",
|
|
String(timeoutSeconds),
|
|
...(mode.recordState ? ["--record-state"] : []),
|
|
];
|
|
return {
|
|
apiVersion: "batch/v1",
|
|
kind: "Job",
|
|
metadata: { name: jobName, namespace: registry.controller.namespace, labels },
|
|
spec: {
|
|
backoffLimit: 0,
|
|
ttlSecondsAfterFinished: 600,
|
|
activeDeadlineSeconds: timeoutSeconds + 30,
|
|
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 } },
|
|
{ name: "work", emptyDir: {} },
|
|
],
|
|
containers: [
|
|
{
|
|
name: "reconcile",
|
|
image: registry.controller.image,
|
|
imagePullPolicy: "IfNotPresent",
|
|
command: ["/bin/sh", "-lc"],
|
|
args: [controllerOneShotScript(commandArgs)],
|
|
env: [
|
|
{ name: "UNIDESK_CONTROLLER_SOURCE_BRANCH", value: registry.controller.source.branch },
|
|
{ name: "UNIDESK_CONTROLLER_SOURCE_REPOSITORY", value: registry.controller.source.repository },
|
|
{ name: "UNIDESK_CONTROLLER_SOURCE_SNAPSHOT_PREFIX", value: registry.controller.source.sourceSnapshot.stageRefPrefix.replaceAll("{branch}", registry.controller.source.branch) },
|
|
],
|
|
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 },
|
|
{ name: "work", mountPath: "/work" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function controllerOneShotScript(commandArgs: string[]): string {
|
|
return [
|
|
"set -eu",
|
|
"cd /work",
|
|
"rm -rf /work/unidesk",
|
|
"started_at=$(date -Iseconds)",
|
|
"echo \"branch-follower one-shot started ${started_at}\"",
|
|
"/etc/unidesk-cicd-branch-follower/sync-source.sh \"${UNIDESK_CONTROLLER_SOURCE_REPOSITORY}\" \"${UNIDESK_CONTROLLER_SOURCE_BRANCH}\" \"${UNIDESK_CONTROLLER_SOURCE_SNAPSHOT_PREFIX}\" \"/cache/${UNIDESK_CONTROLLER_SOURCE_REPOSITORY}.git\"",
|
|
"git clone --branch \"${UNIDESK_CONTROLLER_SOURCE_BRANCH}\" \"/cache/${UNIDESK_CONTROLLER_SOURCE_REPOSITORY}.git\" /work/unidesk",
|
|
"cp /etc/unidesk-cicd-branch-follower/cicd-branch-followers.yaml /work/unidesk/config/cicd-branch-followers.yaml",
|
|
"cd /work/unidesk",
|
|
`${commandArgs.map(shQuote).join(" ")}`,
|
|
"echo \"branch-follower one-shot finished $(date -Iseconds)\"",
|
|
].join("\n");
|
|
}
|
|
|
|
function waitForJobShell(namespace: string, jobName: string, timeoutSeconds: number): string {
|
|
return [
|
|
`deadline=$(( $(date +%s) + ${timeoutSeconds} ))`,
|
|
"while true; do",
|
|
` job_json=$(kubectl -n ${shQuote(namespace)} get job ${shQuote(jobName)} -o json)`,
|
|
" phase=$(printf '%s' \"$job_json\" | node -e \"let s='';process.stdin.on('data',c=>s+=c);process.stdin.on('end',()=>{const j=JSON.parse(s);const c=j.status?.conditions||[];const done=c.find(x=>x.type==='Complete'&&x.status==='True');const failed=c.find(x=>x.type==='Failed'&&x.status==='True');process.stdout.write(done?'complete':failed?'failed':'running');})\")",
|
|
" if [ \"$phase\" = complete ]; then exit 0; fi",
|
|
" if [ \"$phase\" = failed ]; then exit 1; fi",
|
|
" if [ \"$(date +%s)\" -ge \"$deadline\" ]; then exit 124; fi",
|
|
" sleep 2",
|
|
"done",
|
|
].join("\n");
|
|
}
|
|
|
|
function renderControllerManifests(registry: BranchFollowerRegistry): Record<string, unknown>[] {
|
|
const labels = registry.controller.labels;
|
|
const selector = labels;
|
|
return [
|
|
{
|
|
apiVersion: "v1",
|
|
kind: "Namespace",
|
|
metadata: { name: registry.controller.namespace, labels },
|
|
},
|
|
{
|
|
apiVersion: "v1",
|
|
kind: "ServiceAccount",
|
|
metadata: { name: registry.controller.serviceAccountName, namespace: registry.controller.namespace, labels },
|
|
},
|
|
{
|
|
apiVersion: "rbac.authorization.k8s.io/v1",
|
|
kind: "Role",
|
|
metadata: { name: registry.controller.serviceAccountName, namespace: registry.controller.namespace, labels },
|
|
rules: [
|
|
{ apiGroups: [""], resources: ["configmaps", "pods", "events"], verbs: ["get", "list", "watch", "create", "update", "patch"] },
|
|
{ apiGroups: ["apps"], resources: ["deployments"], verbs: ["get", "list", "watch"] },
|
|
{ apiGroups: ["batch"], resources: ["jobs"], verbs: ["get", "list", "watch", "create", "update", "patch"] },
|
|
{ apiGroups: ["coordination.k8s.io"], resources: ["leases"], verbs: ["get", "list", "watch", "create", "update", "patch"] },
|
|
],
|
|
},
|
|
{
|
|
apiVersion: "rbac.authorization.k8s.io/v1",
|
|
kind: "RoleBinding",
|
|
metadata: { name: registry.controller.serviceAccountName, namespace: registry.controller.namespace, labels },
|
|
subjects: [{ kind: "ServiceAccount", name: registry.controller.serviceAccountName, namespace: registry.controller.namespace }],
|
|
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: registry.controller.serviceAccountName },
|
|
},
|
|
{
|
|
apiVersion: "rbac.authorization.k8s.io/v1",
|
|
kind: "ClusterRole",
|
|
metadata: { name: registry.controller.serviceAccountName, labels },
|
|
rules: [
|
|
{ apiGroups: [""], resources: ["pods", "pods/log", "configmaps", "events"], verbs: ["get", "list", "watch"] },
|
|
{ apiGroups: [""], resources: ["pods/exec"], verbs: ["create"] },
|
|
{ apiGroups: ["apps"], resources: ["deployments", "statefulsets"], verbs: ["get", "list", "watch"] },
|
|
{ apiGroups: ["tekton.dev"], resources: ["pipelineruns"], verbs: ["get", "list", "watch"] },
|
|
{ apiGroups: ["argoproj.io"], resources: ["applications"], verbs: ["get", "list", "watch"] },
|
|
],
|
|
},
|
|
{
|
|
apiVersion: "rbac.authorization.k8s.io/v1",
|
|
kind: "ClusterRoleBinding",
|
|
metadata: { name: registry.controller.serviceAccountName, labels },
|
|
subjects: [{ kind: "ServiceAccount", name: registry.controller.serviceAccountName, namespace: registry.controller.namespace }],
|
|
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "ClusterRole", name: registry.controller.serviceAccountName },
|
|
},
|
|
{
|
|
apiVersion: "v1",
|
|
kind: "ConfigMap",
|
|
metadata: { name: registry.controller.configMapName, namespace: registry.controller.namespace, labels },
|
|
data: {
|
|
"cicd-branch-followers.yaml": registry.rawText,
|
|
"sync-source.sh": nativeGitMirrorSyncShell(registry),
|
|
},
|
|
},
|
|
{
|
|
apiVersion: "v1",
|
|
kind: "ConfigMap",
|
|
metadata: { name: registry.controller.stateConfigMapName, namespace: registry.controller.namespace, labels },
|
|
data: {
|
|
_createdAt: new Date().toISOString(),
|
|
_specRef: SPEC_REF,
|
|
_registrySha256: registry.rawSha256,
|
|
},
|
|
},
|
|
{
|
|
apiVersion: "coordination.k8s.io/v1",
|
|
kind: "Lease",
|
|
metadata: { name: registry.controller.leaseName, namespace: registry.controller.namespace, labels },
|
|
spec: { holderIdentity: "unidesk-cicd-branch-follower", leaseDurationSeconds: Math.max(30, registry.controller.loop.reconcileTimeoutSeconds + 30) },
|
|
},
|
|
{
|
|
apiVersion: "apps/v1",
|
|
kind: "Deployment",
|
|
metadata: { name: registry.controller.deploymentName, namespace: registry.controller.namespace, labels },
|
|
spec: {
|
|
replicas: 1,
|
|
selector: { matchLabels: selector },
|
|
template: {
|
|
metadata: {
|
|
labels: selector,
|
|
annotations: {
|
|
"unidesk.pikapython.com/spec-ref": SPEC_REF,
|
|
"unidesk.pikapython.com/registry-sha256": registry.rawSha256,
|
|
"unidesk.pikapython.com/host-worktree-authority": "false",
|
|
},
|
|
},
|
|
spec: {
|
|
serviceAccountName: registry.controller.serviceAccountName,
|
|
terminationGracePeriodSeconds: 30,
|
|
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 } },
|
|
{ name: "work", emptyDir: {} },
|
|
],
|
|
containers: [
|
|
{
|
|
name: "controller",
|
|
image: registry.controller.image,
|
|
imagePullPolicy: "IfNotPresent",
|
|
command: ["/bin/sh", "-lc"],
|
|
args: [controllerLoopScript()],
|
|
env: [
|
|
{ name: "UNIDESK_CICD_BRANCH_FOLLOWER_INTERVAL_SECONDS", value: String(registry.controller.loop.intervalSeconds) },
|
|
{ name: "UNIDESK_CICD_BRANCH_FOLLOWER_TIMEOUT_SECONDS", value: String(registry.controller.loop.reconcileTimeoutSeconds) },
|
|
{ name: "UNIDESK_CONTROLLER_GIT_MIRROR_READ_URL", value: registry.controller.source.gitMirrorReadUrl },
|
|
{ name: "UNIDESK_CONTROLLER_SOURCE_BRANCH", value: registry.controller.source.branch },
|
|
{ name: "UNIDESK_CONTROLLER_SOURCE_REPOSITORY", value: registry.controller.source.repository },
|
|
{ name: "UNIDESK_CONTROLLER_SOURCE_SNAPSHOT_PREFIX", value: registry.controller.source.sourceSnapshot.stageRefPrefix.replaceAll("{branch}", registry.controller.source.branch) },
|
|
],
|
|
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 },
|
|
{ name: "work", mountPath: "/work" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
function controllerLoopScript(): string {
|
|
return [
|
|
"set -eu",
|
|
"interval=\"${UNIDESK_CICD_BRANCH_FOLLOWER_INTERVAL_SECONDS}\"",
|
|
"timeout=\"${UNIDESK_CICD_BRANCH_FOLLOWER_TIMEOUT_SECONDS}\"",
|
|
"while true; do",
|
|
" started_at=$(date -Iseconds)",
|
|
" echo \"branch-follower loop started ${started_at}\"",
|
|
" cd /work",
|
|
" rm -rf /work/unidesk",
|
|
" /etc/unidesk-cicd-branch-follower/sync-source.sh \"${UNIDESK_CONTROLLER_SOURCE_REPOSITORY}\" \"${UNIDESK_CONTROLLER_SOURCE_BRANCH}\" \"${UNIDESK_CONTROLLER_SOURCE_SNAPSHOT_PREFIX}\" \"/cache/${UNIDESK_CONTROLLER_SOURCE_REPOSITORY}.git\"",
|
|
" git clone --branch \"${UNIDESK_CONTROLLER_SOURCE_BRANCH}\" \"/cache/${UNIDESK_CONTROLLER_SOURCE_REPOSITORY}.git\" /work/unidesk",
|
|
" cp /etc/unidesk-cicd-branch-follower/cicd-branch-followers.yaml /work/unidesk/config/cicd-branch-followers.yaml",
|
|
" cd /work/unidesk",
|
|
" bun scripts/cli.ts cicd branch-follower run-once --all --confirm --wait --controller --config config/cicd-branch-followers.yaml --timeout-seconds \"${timeout}\" || true",
|
|
" echo \"branch-follower loop finished $(date -Iseconds)\"",
|
|
" cd /work",
|
|
" sleep \"${interval}\"",
|
|
"done",
|
|
].join("\n");
|
|
}
|
|
|
|
function nativeGitMirrorSyncShell(registry: BranchFollowerRegistry): string {
|
|
const ssh = registry.controller.source.githubSsh;
|
|
return [
|
|
"#!/bin/sh",
|
|
"set -eu",
|
|
"repository=\"$1\"",
|
|
"branch=\"$2\"",
|
|
"snapshot_prefix=\"$3\"",
|
|
"repo_path=\"$4\"",
|
|
`private_key=${shQuote(`/git-ssh/${ssh.privateKeySecretKey}`)}`,
|
|
`proxy_host=${shQuote(ssh.proxyHost)}`,
|
|
`proxy_port=${shQuote(String(ssh.proxyPort))}`,
|
|
"mkdir -p \"$(dirname \"$repo_path\")\" /root/.ssh",
|
|
"cp \"$private_key\" /root/.ssh/id_rsa",
|
|
"chmod 0400 /root/.ssh/id_rsa",
|
|
"touch /root/.ssh/known_hosts",
|
|
"cat >/tmp/unidesk-cicd-github-proxy-connect.mjs <<'NODE_PROXY'",
|
|
"#!/usr/bin/env node",
|
|
"import net from 'node:net';",
|
|
"const [proxyHost, proxyPortRaw, targetHost, targetPortRaw] = process.argv.slice(2);",
|
|
"const proxyPort = Number.parseInt(proxyPortRaw || '', 10);",
|
|
"const targetPort = Number.parseInt(targetPortRaw || '', 10);",
|
|
"if (!proxyHost || !Number.isInteger(proxyPort) || !targetHost || !Number.isInteger(targetPort)) process.exit(64);",
|
|
"let settled = false;",
|
|
"let tunnel = false;",
|
|
"function finish(code) { if (settled) return; settled = true; process.exit(code); }",
|
|
"const socket = net.createConnection({ host: proxyHost, port: proxyPort });",
|
|
"let buffer = Buffer.alloc(0);",
|
|
"socket.setTimeout(15000, () => { socket.destroy(); finish(65); });",
|
|
"socket.on('connect', () => socket.write(`CONNECT ${targetHost}:${targetPort} HTTP/1.1\\r\\nHost: ${targetHost}:${targetPort}\\r\\nProxy-Connection: Keep-Alive\\r\\n\\r\\n`));",
|
|
"socket.on('error', () => finish(tunnel ? 69 : 66));",
|
|
"socket.on('close', () => finish(tunnel ? 0 : 68));",
|
|
"socket.on('data', function onData(chunk) {",
|
|
" buffer = Buffer.concat([buffer, chunk]);",
|
|
" const headerEnd = buffer.indexOf('\\r\\n\\r\\n');",
|
|
" if (headerEnd === -1 && buffer.length < 8192) return;",
|
|
" if (headerEnd === -1) { socket.destroy(); finish(68); return; }",
|
|
" const statusLine = buffer.slice(0, headerEnd).toString('latin1').split('\\r\\n', 1)[0] || '';",
|
|
" const statusCode = Number.parseInt(statusLine.split(' ')[1] || '', 10);",
|
|
" if (!statusLine.startsWith('HTTP/1.') || !Number.isInteger(statusCode) || statusCode < 200 || statusCode > 299) { socket.destroy(); finish(67); return; }",
|
|
" socket.off('data', onData);",
|
|
" socket.setTimeout(0);",
|
|
" tunnel = true;",
|
|
" const rest = buffer.slice(headerEnd + 4);",
|
|
" if (rest.length) process.stdout.write(rest);",
|
|
" process.stdin.on('error', () => {});",
|
|
" process.stdout.on('error', () => {});",
|
|
" process.stdin.pipe(socket);",
|
|
" socket.pipe(process.stdout);",
|
|
"});",
|
|
"NODE_PROXY",
|
|
"chmod 0700 /tmp/unidesk-cicd-github-proxy-connect.mjs",
|
|
"cat >/tmp/unidesk-cicd-git-ssh.sh <<SH_PROXY",
|
|
"#!/bin/sh",
|
|
"exec ssh -i /root/.ssh/id_rsa -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/root/.ssh/known_hosts -o ConnectTimeout=15 -o ServerAliveInterval=5 -o ServerAliveCountMax=1 -o \"ProxyCommand=node /tmp/unidesk-cicd-github-proxy-connect.mjs ${proxy_host} ${proxy_port} %h %p\" \"\\$@\"",
|
|
"SH_PROXY",
|
|
"chmod 0700 /tmp/unidesk-cicd-git-ssh.sh",
|
|
"export GIT_SSH=/tmp/unidesk-cicd-git-ssh.sh",
|
|
"unset GIT_SSH_COMMAND",
|
|
"remote=\"ssh://git@ssh.github.com:443/${repository}.git\"",
|
|
"if [ -d \"$repo_path/objects\" ] && [ -f \"$repo_path/HEAD\" ]; then",
|
|
" git --git-dir=\"$repo_path\" remote set-url origin \"$remote\" || git --git-dir=\"$repo_path\" remote add origin \"$remote\"",
|
|
"else",
|
|
" rm -rf \"$repo_path\"",
|
|
" git init --bare \"$repo_path\" >/dev/null",
|
|
" git --git-dir=\"$repo_path\" remote add origin \"$remote\"",
|
|
"fi",
|
|
"git --git-dir=\"$repo_path\" config uploadpack.allowReachableSHA1InWant true",
|
|
"git --git-dir=\"$repo_path\" config uploadpack.allowAnySHA1InWant true",
|
|
"timeout 30 git --git-dir=\"$repo_path\" fetch --quiet --prune origin \"+refs/heads/${branch}:refs/mirror-stage/heads/${branch}\"",
|
|
"source_sha=$(git --git-dir=\"$repo_path\" rev-parse --verify \"refs/mirror-stage/heads/${branch}^{commit}\")",
|
|
"git --git-dir=\"$repo_path\" update-ref \"refs/heads/${branch}\" \"$source_sha\"",
|
|
"if [ -n \"$snapshot_prefix\" ]; then",
|
|
" git --git-dir=\"$repo_path\" update-ref \"${snapshot_prefix%/}/$source_sha\" \"$source_sha\"",
|
|
"fi",
|
|
"git --git-dir=\"$repo_path\" update-server-info",
|
|
"printf '{\"event\":\"unidesk-cicd-git-mirror-sync\",\"repository\":\"%s\",\"branch\":\"%s\",\"commit\":\"%s\",\"sourceAuthority\":\"k8s-git-mirror-cache\"}\\n' \"$repository\" \"$branch\" \"$source_sha\"",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function selectFollowers(registry: BranchFollowerRegistry, options: ParsedOptions, opts: { includeDisabled: boolean }): FollowerSpec[] {
|
|
let selected = registry.followers;
|
|
if (options.followerId !== null) selected = selected.filter((item) => item.id === options.followerId);
|
|
else if (!options.all && options.action === "run-once") selected = selected.filter((item) => item.enabled);
|
|
if (!opts.includeDisabled) selected = selected.filter((item) => item.enabled);
|
|
if (selected.length === 0) throw new Error(options.followerId === null ? "no followers selected" : `unknown or disabled follower ${options.followerId}`);
|
|
return selected;
|
|
}
|
|
|
|
function registrySummary(registry: BranchFollowerRegistry): Record<string, unknown> {
|
|
return {
|
|
path: registry.path,
|
|
sha256: registry.rawSha256,
|
|
metadata: registry.metadata,
|
|
controller: {
|
|
namespace: registry.controller.namespace,
|
|
kubeRoute: registry.controller.kubeRoute,
|
|
deploymentName: registry.controller.deploymentName,
|
|
stateConfigMapName: registry.controller.stateConfigMapName,
|
|
leaseName: registry.controller.leaseName,
|
|
},
|
|
followers: registry.followers.map((item) => item.id),
|
|
};
|
|
}
|
|
|
|
function redactCommands(follower: FollowerSpec): Record<string, string> {
|
|
return {
|
|
plan: follower.commands.plan.argv.join(" "),
|
|
status: "native:k8s-git-mirror+tekton+argocd+runtime",
|
|
trigger: follower.commands.trigger.argv.join(" "),
|
|
events: follower.commands.events.argv.join(" "),
|
|
logs: follower.commands.logs.argv.join(" "),
|
|
};
|
|
}
|
|
|
|
function nativeStatusPlan(native: NativeStatusSpec): Record<string, unknown> {
|
|
return {
|
|
source: {
|
|
mode: "k8s-git-mirror-cache",
|
|
gitMirrorNamespace: native.source.gitMirrorNamespace,
|
|
gitMirrorDeployment: native.source.gitMirrorDeployment,
|
|
repoPath: native.source.repoPath,
|
|
},
|
|
tekton: native.tekton,
|
|
argo: native.argo,
|
|
runtime: native.runtime === null
|
|
? null
|
|
: {
|
|
namespace: native.runtime.namespace,
|
|
workloads: native.runtime.workloads.map((item) => ({ kind: item.kind, name: item.name })),
|
|
},
|
|
parsedDownstreamCliOutput: false,
|
|
};
|
|
}
|
|
|
|
function controllerStatusSummary(registry: BranchFollowerRegistry, k8s: K8sStateRead): Record<string, unknown> {
|
|
const deploymentStatus = asOptionalRecord(k8s.deployment?.status);
|
|
const available = numberOrNull(deploymentStatus?.availableReplicas) ?? 0;
|
|
const replicas = numberOrNull(deploymentStatus?.replicas) ?? 0;
|
|
const leaseSpec = asOptionalRecord(k8s.lease?.spec);
|
|
const podItems = Array.isArray(k8s.pods?.items) ? k8s.pods.items.length : null;
|
|
return {
|
|
namespace: registry.controller.namespace,
|
|
route: registry.controller.kubeRoute,
|
|
deploymentName: registry.controller.deploymentName,
|
|
deploymentPresent: k8s.deployment !== null,
|
|
availableReplicas: available,
|
|
replicas,
|
|
pods: podItems,
|
|
stateConfigMapName: registry.controller.stateConfigMapName,
|
|
stateConfigMapPresent: k8s.stateConfigMapPresent,
|
|
leaseName: registry.controller.leaseName,
|
|
leaseHolder: stringOrNull(leaseSpec?.holderIdentity),
|
|
noHostWorktreeAuthority: true,
|
|
};
|
|
}
|
|
|
|
function followerNextCommands(follower: FollowerSpec): Record<string, string> {
|
|
const next: Record<string, string> = {
|
|
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
|
|
liveStatus: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id} --live`,
|
|
dryRun: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --dry-run`,
|
|
trigger: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --confirm --wait`,
|
|
events: `bun scripts/cli.ts cicd branch-follower events --follower ${follower.id}`,
|
|
logs: `bun scripts/cli.ts cicd branch-follower logs --follower ${follower.id}`,
|
|
};
|
|
if (follower.nativeStatus.tekton !== null) next.pipelineRuns = `bun scripts/cli.ts cicd branch-follower events --follower ${follower.id}`;
|
|
if (follower.nativeStatus.argo !== null) next.argoApplication = `bun scripts/cli.ts cicd branch-follower events --follower ${follower.id}`;
|
|
return next;
|
|
}
|
|
|
|
function safeResolveString(ref: string): string | null {
|
|
try {
|
|
return resolveConfigRefString(ref, ref);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseJsonObject(text: string): Record<string, unknown> | null {
|
|
const trimmed = text.trim();
|
|
if (trimmed.length === 0) return null;
|
|
try {
|
|
const parsed = JSON.parse(trimmed) as unknown;
|
|
return asOptionalRecord(parsed);
|
|
} catch {
|
|
const start = trimmed.indexOf("{");
|
|
const end = trimmed.lastIndexOf("}");
|
|
if (start < 0 || end <= start) return null;
|
|
try {
|
|
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown;
|
|
return asOptionalRecord(parsed);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function recordAt(root: Record<string, unknown>, path: string[]): Record<string, unknown> | null {
|
|
let current: unknown = root;
|
|
for (const item of path) {
|
|
if (typeof current !== "object" || current === null || Array.isArray(current)) return null;
|
|
current = (current as Record<string, unknown>)[item];
|
|
}
|
|
return asOptionalRecord(current);
|
|
}
|
|
|
|
function asOptionalRecord(value: unknown): Record<string, unknown> | null {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function stringOrNull(value: unknown): string | null {
|
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
}
|
|
|
|
function numberOrNull(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
function commandCompact(result: CommandResult, options: ParsedOptions): Record<string, unknown> {
|
|
return {
|
|
argv: result.command,
|
|
exitCode: result.exitCode,
|
|
timedOut: result.timedOut,
|
|
stdoutBytes: Buffer.byteLength(result.stdout),
|
|
stderrBytes: Buffer.byteLength(result.stderr),
|
|
stdoutTail: options.full || result.exitCode !== 0 ? redactText(tailText(result.stdout, options.tailBytes)) : "",
|
|
stderrTail: options.full || result.exitCode !== 0 ? redactText(tailText(result.stderr, Math.min(options.tailBytes, 4000))) : "",
|
|
};
|
|
}
|
|
|
|
function objectRef(item: Record<string, unknown>): Record<string, string> {
|
|
const metadata = asRecord(item.metadata, "metadata");
|
|
return {
|
|
kind: stringField(item, "kind", "manifest"),
|
|
namespace: typeof metadata.namespace === "string" ? metadata.namespace : "-",
|
|
name: stringField(metadata, "name", "manifest.metadata"),
|
|
};
|
|
}
|
|
|
|
function labelSelector(labels: Record<string, string>): string {
|
|
return Object.entries(labels).map(([key, value]) => `${key}=${value}`).join(",");
|
|
}
|
|
|
|
function isNotFoundText(value: string): boolean {
|
|
return /notfound|not found|notfound|NotFound/u.test(value);
|
|
}
|
|
|
|
function shortSha(value: string | null): string {
|
|
if (value === null) return "-";
|
|
return value.length > 12 ? value.slice(0, 12) : value;
|
|
}
|
|
|
|
function safeJobSegment(value: string): string {
|
|
return value.replace(/[^A-Za-z0-9_.-]/gu, "_").slice(0, 60);
|
|
}
|
|
|
|
function tailText(text: string, maxChars: number): string {
|
|
if (text.length <= maxChars) return text;
|
|
return text.slice(text.length - maxChars);
|
|
}
|
|
|
|
function commandLabel(options: ParsedOptions): string {
|
|
return `cicd branch-follower ${options.action}`;
|
|
}
|
|
|
|
function renderResult(command: string, payload: Record<string, unknown>, options: ParsedOptions): RenderedCliResult {
|
|
const ok = payload.ok !== false;
|
|
if (options.output === "json") return renderMachine(command, payload, "json", ok);
|
|
if (options.output === "yaml") return renderMachine(command, payload, "yaml", ok);
|
|
return rendered(ok, command, renderHuman(command, payload, options));
|
|
}
|
|
|
|
function renderMachine(command: string, value: unknown, mode: "json" | "yaml", ok = true): RenderedCliResult {
|
|
return rendered(ok, command, mode === "json" ? `${JSON.stringify(value, null, 2)}\n` : `${Bun.YAML.stringify(value)}\n`, mode === "json" ? "application/json" : "application/yaml");
|
|
}
|
|
|
|
function rendered(ok: boolean, command: string, renderedText: string, contentType: RenderedCliResult["contentType"] = "text/plain"): RenderedCliResult {
|
|
return { ok, command, renderedText, contentType };
|
|
}
|
|
|
|
function renderHuman(command: string, payload: Record<string, unknown>, options: ParsedOptions): string {
|
|
if (command.endsWith(" plan")) return renderPlanHuman(payload);
|
|
if (command.endsWith(" apply")) return renderApplyHuman(payload);
|
|
if (command.endsWith(" status")) return renderStatusHuman(payload, options);
|
|
if (command.endsWith(" run-once")) return renderRunOnceHuman(payload);
|
|
if (command.endsWith(" events") || command.endsWith(" logs")) return renderDrillDownHuman(payload);
|
|
return `${JSON.stringify(payload, null, 2)}\n`;
|
|
}
|
|
|
|
function renderPlanHuman(payload: Record<string, unknown>): string {
|
|
const followers = arrayRecords(payload.followers);
|
|
const rows = followers.map((item) => {
|
|
const source = asOptionalRecord(item.source);
|
|
const target = asOptionalRecord(item.target);
|
|
const budgets = asOptionalRecord(item.budgets);
|
|
return [
|
|
item.id,
|
|
item.enabled,
|
|
item.adapter,
|
|
`${source?.repository ?? "-"}@${source?.branch ?? "-"}`,
|
|
`${target?.node ?? "-"}/${target?.lane ?? "-"}`,
|
|
budgets?.endToEndSeconds ?? "-",
|
|
arrayRecords(item.configRefGraph).length,
|
|
arrayText(item.closeoutChecks),
|
|
];
|
|
});
|
|
const next = asOptionalRecord(payload.next);
|
|
return [
|
|
`CI/CD BRANCH-FOLLOWER PLAN (${payload.ok === false ? "blocked" : "ok"})`,
|
|
"",
|
|
table(["FOLLOWER", "ENABLED", "ADAPTER", "SOURCE", "TARGET", "BUDGET", "REFS", "CHECKS"], rows),
|
|
"",
|
|
"SOURCE AUTHORITY",
|
|
`hostWorktreeAuthority=${payload.hostWorktreeAuthority === true ? "true" : "false"} mode=${asOptionalRecord(payload.sourceAuthority)?.mode ?? "-"} resolver=${asOptionalRecord(payload.sourceAuthority)?.resolver ?? "-"}`,
|
|
"",
|
|
"NEXT",
|
|
`apply: ${next?.apply ?? "-"}`,
|
|
`status: ${next?.status ?? "-"}`,
|
|
`dry-run: ${next?.dryRun ?? "-"}`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function renderApplyHuman(payload: Record<string, unknown>): string {
|
|
const controller = asOptionalRecord(payload.controller);
|
|
const command = asOptionalRecord(payload.command);
|
|
const next = asOptionalRecord(payload.next);
|
|
return [
|
|
`CI/CD BRANCH-FOLLOWER APPLY (${payload.ok === false ? "failed" : payload.dryRun === true ? "dry-run" : "ok"})`,
|
|
"",
|
|
table(
|
|
["NAMESPACE", "ROUTE", "DEPLOYMENT", "STATE_CM", "LEASE", "HOST_WORKTREE"],
|
|
[[controller?.namespace ?? "-", controller?.route ?? "-", controller?.deploymentName ?? "-", controller?.stateConfigMapName ?? "-", controller?.leaseName ?? "-", controller?.hostWorktreeMounted === true ? "mounted" : "not-mounted"]],
|
|
),
|
|
"",
|
|
table(["OBJECTS", "MANIFEST_SHA", "EXIT", "TIMED_OUT"], [[arrayRecords(payload.objects).length, shortSha(stringOrNull(payload.manifestSha256)), command?.exitCode ?? "-", command?.timedOut ?? "-"]]),
|
|
command?.stderrTail ? `\nSTDERR\n${command.stderrTail}` : "",
|
|
"",
|
|
"NEXT",
|
|
`status: ${next?.status ?? "-"}`,
|
|
`dry-run: ${next?.dryRun ?? "-"}`,
|
|
"",
|
|
].filter((line) => line !== "").join("\n");
|
|
}
|
|
|
|
function renderStatusHuman(payload: Record<string, unknown>, _options: ParsedOptions): string {
|
|
const controller = asOptionalRecord(payload.controller);
|
|
const followers = arrayRecords(payload.followers);
|
|
const rows = followers.map((item) => {
|
|
const source = asOptionalRecord(item.source);
|
|
const target = asOptionalRecord(item.target);
|
|
const budgets = asOptionalRecord(item.budgetSource);
|
|
return [
|
|
item.id,
|
|
item.phase,
|
|
item.adapter,
|
|
`${source?.branch ?? "-"}:${shortSha(stringOrNull(source?.observedSha))}`,
|
|
shortSha(stringOrNull(target?.targetSha)),
|
|
shortSha(stringOrNull(item.lastTriggeredSha)),
|
|
shortSha(stringOrNull(item.lastSucceededSha)),
|
|
item.pipelineRun ?? item.inFlightJob ?? "-",
|
|
budgets?.endToEndSeconds ?? "-",
|
|
item.message ?? "-",
|
|
];
|
|
});
|
|
const next = asOptionalRecord(payload.next);
|
|
const errors = Array.isArray(payload.errors) ? payload.errors : [];
|
|
return [
|
|
`CI/CD BRANCH-FOLLOWER STATUS (${payload.ok === false ? "degraded" : "ok"})`,
|
|
"",
|
|
table(
|
|
["CTRL_NS", "ROUTE", "DEPLOY", "READY", "PODS", "STATE_CM", "LEASE"],
|
|
[[controller?.namespace ?? "-", controller?.route ?? "-", controller?.deploymentName ?? "-", `${controller?.availableReplicas ?? 0}/${controller?.replicas ?? 0}`, controller?.pods ?? "-", controller?.stateConfigMapPresent === true ? "present" : "missing", controller?.leaseHolder ?? "-"]],
|
|
),
|
|
"",
|
|
table(["FOLLOWER", "PHASE", "ADAPTER", "OBSERVED", "TARGET", "TRIGGERED", "SUCCEEDED", "IN_FLIGHT", "BUDGET", "MESSAGE"], rows),
|
|
errors.length === 0 ? "" : `\nERRORS\n${errors.map((item) => `- ${item}`).join("\n")}`,
|
|
"",
|
|
"NEXT",
|
|
`live-status: ${next?.liveStatus ?? "-"}`,
|
|
`dry-run: ${next?.dryRun ?? "-"}`,
|
|
"",
|
|
].filter((line) => line !== "").join("\n");
|
|
}
|
|
|
|
function renderRunOnceHuman(payload: Record<string, unknown>): string {
|
|
const followers = arrayRecords(payload.followers);
|
|
const rows = followers.map((item) => {
|
|
const source = asOptionalRecord(item.source);
|
|
const target = asOptionalRecord(item.target);
|
|
return [
|
|
item.id,
|
|
item.phase,
|
|
`${source?.branch ?? "-"}:${shortSha(stringOrNull(source?.observedSha))}`,
|
|
shortSha(stringOrNull(target?.targetSha)),
|
|
shortSha(stringOrNull(item.lastTriggeredSha)),
|
|
item.inFlightJob ?? "-",
|
|
item.decision ?? "-",
|
|
];
|
|
});
|
|
const next = asOptionalRecord(payload.next);
|
|
return [
|
|
`CI/CD BRANCH-FOLLOWER RUN-ONCE (${payload.ok === false ? "blocked" : payload.dryRun === true ? "dry-run" : "ok"})`,
|
|
"",
|
|
table(["FOLLOWER", "PHASE", "OBSERVED", "TARGET", "TRIGGERED", "IN_FLIGHT", "DECISION"], rows),
|
|
"",
|
|
"NEXT",
|
|
`status: ${next?.status ?? "-"}`,
|
|
`live-status: ${next?.liveStatus ?? "-"}`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function renderDrillDownHuman(payload: Record<string, unknown>): string {
|
|
if (payload.follower === undefined) {
|
|
const followers = arrayRecords(payload.followers);
|
|
return [
|
|
`CI/CD BRANCH-FOLLOWER ${String(payload.action ?? "drill-down").toUpperCase()}`,
|
|
"",
|
|
table(["FOLLOWER", "ADAPTER", "STATUS_AUTHORITY"], followers.map((item) => [item.id, item.adapter, item.statusAuthority ?? "k8s-native"])),
|
|
"",
|
|
].join("\n");
|
|
}
|
|
const summary = asOptionalRecord(payload.summary);
|
|
return [
|
|
`CI/CD BRANCH-FOLLOWER ${String(payload.action ?? "drill-down").toUpperCase()} (${payload.ok === false ? "failed" : "ok"})`,
|
|
"",
|
|
table(
|
|
["FOLLOWER", "ADAPTER", "AUTHORITY", "PHASE", "OBSERVED", "TARGET", "PIPELINERUN", "MESSAGE"],
|
|
[[payload.follower, payload.adapter ?? "-", payload.statusAuthority ?? "k8s-native", summary?.phase ?? "-", shortSha(stringOrNull(summary?.observedSha)), shortSha(stringOrNull(summary?.targetSha)), summary?.pipelineRun ?? "-", summary?.message ?? "-"]],
|
|
),
|
|
"",
|
|
].filter((line) => line !== "").join("\n");
|
|
}
|
|
|
|
function arrayRecords(value: unknown): Record<string, unknown>[] {
|
|
return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null && !Array.isArray(item)) : [];
|
|
}
|
|
|
|
function arrayText(value: unknown): string {
|
|
return Array.isArray(value) ? value.map(String).join(",") : "-";
|
|
}
|
|
|
|
function table(headers: readonly string[], rows: readonly (readonly unknown[])[]): string {
|
|
const normalized = rows.map((row) => headers.map((_, index) => cell(row[index])));
|
|
const widths = headers.map((header, index) => Math.max(header.length, ...normalized.map((row) => row[index]?.length ?? 0)));
|
|
const format = (row: readonly string[]) => row.map((value, index) => value.padEnd(widths[index] ?? 0)).join(" ").trimEnd();
|
|
return [format(headers), format(headers.map((header) => "-".repeat(header.length))), ...normalized.map(format)].join("\n");
|
|
}
|
|
|
|
function cell(value: unknown): string {
|
|
if (value === null || value === undefined || value === "") return "-";
|
|
const text = String(value).replace(/\s+/gu, " ");
|
|
return text.length > 96 ? `${text.slice(0, 93)}...` : text;
|
|
}
|