1628 lines
66 KiB
TypeScript
1628 lines
66 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 { existsSync, 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;
|
|
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;
|
|
};
|
|
closeoutChecks: 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;
|
|
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 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-d601-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, 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 === "-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,
|
|
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"),
|
|
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 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 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`),
|
|
},
|
|
closeoutChecks: stringArrayField(closeout, "checks", `${label}.closeout`),
|
|
};
|
|
}
|
|
|
|
function parseCommand(root: Record<string, unknown>, label: string): CommandSpec {
|
|
return {
|
|
argv: stringArrayField(root, "argv", label),
|
|
timeoutSeconds: integerField(root, "timeoutSeconds", 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),
|
|
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>> {
|
|
const k8s = readK8sState(registry, options);
|
|
const shouldLive = options.live || (!options.noLive && Object.keys(k8s.stateByFollower).length === 0);
|
|
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(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,
|
|
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>> {
|
|
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(follower, options);
|
|
const state = await decideAndMaybeTrigger(registry, follower, oldState, live, options);
|
|
if (!options.dryRun) {
|
|
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",
|
|
},
|
|
};
|
|
}
|
|
|
|
function runFollowerDrillDown(registry: BranchFollowerRegistry, options: ParsedOptions): Record<string, unknown> {
|
|
if (options.followerId === null) {
|
|
return {
|
|
ok: true,
|
|
action: options.action,
|
|
message: "select one follower to run the configured drill-down command",
|
|
followers: registry.followers.map((follower) => ({
|
|
id: follower.id,
|
|
adapter: follower.adapter,
|
|
command: (options.action === "events" ? follower.commands.events : follower.commands.logs).argv.join(" "),
|
|
})),
|
|
};
|
|
}
|
|
const follower = registry.followers.find((item) => item.id === options.followerId);
|
|
if (follower === undefined) throw new Error(`unknown follower ${options.followerId}`);
|
|
const spec = options.action === "events" ? follower.commands.events : follower.commands.logs;
|
|
const result = runCommand(spec.argv, repoRoot, { timeoutMs: (options.timeoutSeconds ?? spec.timeoutSeconds) * 1000 });
|
|
return {
|
|
ok: result.exitCode === 0,
|
|
action: options.action,
|
|
follower: follower.id,
|
|
adapter: follower.adapter,
|
|
command: spec.argv.join(" "),
|
|
result: {
|
|
exitCode: result.exitCode,
|
|
timedOut: result.timedOut,
|
|
stdoutBytes: Buffer.byteLength(result.stdout),
|
|
stderrBytes: Buffer.byteLength(result.stderr),
|
|
stdoutTail: redactText(tailText(result.stdout, options.tailBytes)),
|
|
stderrTail: redactText(tailText(result.stderr, options.tailBytes)),
|
|
},
|
|
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;
|
|
const targetSha = live.targetSha;
|
|
const previousLastTriggered = stringOrNull(previous.lastTriggeredSha);
|
|
const previousInFlight = stringOrNull(previous.inFlightJob);
|
|
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 ?? stringOrNull(previous.lastSucceededSha);
|
|
|
|
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 && !options.confirm) {
|
|
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(follower: FollowerSpec, options: ParsedOptions): Promise<AdapterSummary> {
|
|
const spec = follower.commands.status;
|
|
const timeoutSeconds = options.timeoutSeconds ?? spec.timeoutSeconds;
|
|
const result = runCommand(spec.argv, repoRoot, { timeoutMs: timeoutSeconds * 1000 });
|
|
const rawPayload = parseJsonObject(result.stdout) ?? parseJsonObject(result.stderr);
|
|
const payload = recoverDumpPayload(rawPayload) ?? rawPayload;
|
|
const body = payload === null ? null : unwrapEnvelope(payload);
|
|
const observedSha = firstStringPath(body, [
|
|
"summary.sourceCommit",
|
|
"summary.observedSha",
|
|
"summary.observedCommit",
|
|
"sourceHead.commit",
|
|
"sourceHead.sha",
|
|
"source.commit",
|
|
"source.sha",
|
|
"selectedSource.commit",
|
|
"selectedSource.sha",
|
|
"selectedCommit",
|
|
"sourceCommit",
|
|
"observedSha",
|
|
"alignment.sourceCommit",
|
|
], ["sourceCommit", "observedSha", "observedCommit", "selectedCommit", "selectedSourceCommit"]);
|
|
const targetSha = firstStringPath(body, [
|
|
"summary.targetCommit",
|
|
"summary.targetSha",
|
|
"summary.runtimeCommit",
|
|
"summary.gitopsCommit",
|
|
"runtime.sourceCommit",
|
|
"runtime.commit",
|
|
"target.commit",
|
|
"target.sha",
|
|
"gitops.sourceCommit",
|
|
"deployedCommit",
|
|
"currentCommit",
|
|
"targetSha",
|
|
"summary.runtimeAlignment.managerSourceCommit",
|
|
"alignment.runtimeAlignment.managerSourceCommit",
|
|
"runtime.manager.sourceCommit",
|
|
"manager.sourceCommit",
|
|
], ["targetCommit", "targetSha", "runtimeCommit", "gitopsCommit", "deployedCommit", "currentCommit"]);
|
|
const lastTriggeredSha = firstStringPath(body, [
|
|
"summary.lastTriggeredSha",
|
|
"summary.lastTriggeredCommit",
|
|
"trigger.sourceCommit",
|
|
"pipelineRun.sourceCommit",
|
|
"lastTriggeredSha",
|
|
], ["lastTriggeredSha", "lastTriggeredCommit"]);
|
|
const lastSucceededSha = firstStringPath(body, [
|
|
"summary.lastSucceededSha",
|
|
"summary.lastSucceededCommit",
|
|
"lastSucceededSha",
|
|
"runtime.succeededCommit",
|
|
], ["lastSucceededSha", "lastSucceededCommit", "succeededCommit"]);
|
|
const pipelineRun = firstStringPath(body, [
|
|
"summary.pipelineRun",
|
|
"summary.expectedPipelineRun",
|
|
"alignment.expectedPipelineRun",
|
|
"pipelineRun.name",
|
|
"pipelineRun",
|
|
"latestPipelineRun.name",
|
|
], ["pipelineRun", "pipelineRunName", "expectedPipelineRun"]);
|
|
const inFlightJob = firstStringPath(body, [
|
|
"summary.inFlightJob",
|
|
"job.id",
|
|
"inFlightJob",
|
|
"latestJob.id",
|
|
], ["inFlightJob", "jobId"]);
|
|
const aligned = firstBooleanPath(body, [
|
|
"summary.aligned",
|
|
"alignment.aligned",
|
|
"aligned",
|
|
"runtime.aligned",
|
|
]);
|
|
const ok = result.exitCode === 0;
|
|
const phase = inferPhase(ok, aligned, observedSha, targetSha, result.timedOut);
|
|
return {
|
|
ok,
|
|
command: spec.argv.join(" "),
|
|
exitCode: result.exitCode,
|
|
timedOut: result.timedOut,
|
|
observedSha,
|
|
targetSha,
|
|
lastTriggeredSha,
|
|
lastSucceededSha,
|
|
pipelineRun,
|
|
inFlightJob,
|
|
aligned,
|
|
phase,
|
|
message: statusMessage(ok, phase, observedSha, targetSha, result),
|
|
payload: body,
|
|
stderrTail: redactText(tailText(result.stderr, 1000)),
|
|
stdoutTail: redactText(tailText(result.stdout, 1000)),
|
|
};
|
|
}
|
|
|
|
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 statusMessage(ok: boolean, phase: BranchFollowerPhase, observedSha: string | null, targetSha: string | null, result: CommandResult): string {
|
|
if (!ok) return `status command failed: exitCode=${result.exitCode}${result.timedOut ? " timedOut=true" : ""}`;
|
|
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) return `observed ${shortSha(observedSha)} target unknown`;
|
|
return "status command completed; observed sha not exposed in compact payload";
|
|
}
|
|
|
|
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 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: "v1",
|
|
kind: "ConfigMap",
|
|
metadata: { name: registry.controller.configMapName, namespace: registry.controller.namespace, labels },
|
|
data: { "cicd-branch-followers.yaml": registry.rawText },
|
|
},
|
|
{
|
|
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 } },
|
|
{ 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 },
|
|
],
|
|
volumeMounts: [
|
|
{ name: "registry", mountPath: "/etc/unidesk-cicd-branch-follower", 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",
|
|
" git clone --depth=1 --branch \"${UNIDESK_CONTROLLER_SOURCE_BRANCH}\" \"${UNIDESK_CONTROLLER_GIT_MIRROR_READ_URL}\" /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}\" --json || true",
|
|
" echo \"branch-follower loop finished $(date -Iseconds)\"",
|
|
" cd /work",
|
|
" sleep \"${interval}\"",
|
|
"done",
|
|
].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: follower.commands.status.argv.join(" "),
|
|
trigger: follower.commands.trigger.argv.join(" "),
|
|
events: follower.commands.events.argv.join(" "),
|
|
logs: follower.commands.logs.argv.join(" "),
|
|
};
|
|
}
|
|
|
|
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> {
|
|
return {
|
|
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}`,
|
|
adapterStatus: follower.commands.status.argv.join(" "),
|
|
};
|
|
}
|
|
|
|
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 unwrapEnvelope(payload: Record<string, unknown>): Record<string, unknown> {
|
|
const data = asOptionalRecord(payload.data);
|
|
return data ?? payload;
|
|
}
|
|
|
|
function recoverDumpPayload(payload: Record<string, unknown> | null): Record<string, unknown> | null {
|
|
if (payload === null) return null;
|
|
const data = asOptionalRecord(payload.data);
|
|
const dump = asOptionalRecord(data?.dump) ?? asOptionalRecord(payload.dump);
|
|
const path = stringOrNull(dump?.path);
|
|
if (path === null || !existsSync(path)) return payload;
|
|
try {
|
|
return parseJsonObject(readFileSync(path, "utf8")) ?? payload;
|
|
} catch {
|
|
return payload;
|
|
}
|
|
}
|
|
|
|
function firstStringPath(root: Record<string, unknown> | null, paths: string[], fallbackKeys: string[] = []): string | null {
|
|
if (root === null) return null;
|
|
for (const path of paths) {
|
|
const value = valueAt(root, path);
|
|
if (typeof value === "string" && value.length > 0) return value;
|
|
}
|
|
return fallbackKeys.length === 0 ? null : firstStringByKey(root, fallbackKeys);
|
|
}
|
|
|
|
function firstBooleanPath(root: Record<string, unknown> | null, paths: string[]): boolean | null {
|
|
if (root === null) return null;
|
|
for (const path of paths) {
|
|
const value = valueAt(root, path);
|
|
if (typeof value === "boolean") return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function valueAt(root: unknown, path: string): unknown {
|
|
let current = root;
|
|
for (const part of path.split(".")) {
|
|
if (typeof current !== "object" || current === null || Array.isArray(current)) return undefined;
|
|
current = (current as Record<string, unknown>)[part];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function firstStringByKey(root: unknown, keys: string[]): string | null {
|
|
if (typeof root !== "object" || root === null) return null;
|
|
if (Array.isArray(root)) {
|
|
for (const item of root) {
|
|
const found = firstStringByKey(item, keys);
|
|
if (found !== null) return found;
|
|
}
|
|
return null;
|
|
}
|
|
const record = root as Record<string, unknown>;
|
|
for (const key of keys) {
|
|
const value = record[key];
|
|
if (typeof value === "string" && value.length > 0) return value;
|
|
}
|
|
for (const value of Object.values(record)) {
|
|
const found = firstStringByKey(value, keys);
|
|
if (found !== null) return found;
|
|
}
|
|
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", "COMMAND"], followers.map((item) => [item.id, item.adapter, item.command])),
|
|
"",
|
|
].join("\n");
|
|
}
|
|
const result = asOptionalRecord(payload.result);
|
|
return [
|
|
`CI/CD BRANCH-FOLLOWER ${String(payload.action ?? "drill-down").toUpperCase()} (${payload.ok === false ? "failed" : "ok"})`,
|
|
"",
|
|
table(["FOLLOWER", "ADAPTER", "EXIT", "TIMED_OUT", "STDOUT", "STDERR"], [[payload.follower, payload.adapter ?? "-", result?.exitCode ?? "-", result?.timedOut ?? "-", result?.stdoutBytes ?? "-", result?.stderrBytes ?? "-"]]),
|
|
result?.stdoutTail ? `\nSTDOUT_TAIL\n${result.stdoutTail}` : "",
|
|
result?.stderrTail ? `\nSTDERR_TAIL\n${result.stderrTail}` : "",
|
|
"",
|
|
].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;
|
|
}
|