|
|
|
@@ -1,6 +1,9 @@
|
|
|
|
|
// SPEC: PJ2026-01060703 CI/CD branch follower debug steps.
|
|
|
|
|
// Responsibility: bounded single-step debugging for branch follower state and decision paths.
|
|
|
|
|
import type { CommandResult } from "./command";
|
|
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
|
|
|
import { runCommand, type CommandResult } from "./command";
|
|
|
|
|
import { repoRoot, rootPath } from "./config";
|
|
|
|
|
import type { AdapterSummary, BranchFollowerDebugStep, BranchFollowerRegistry, FollowerSpec, FollowerState, K8sStateRead, ParsedOptions } from "./cicd-types";
|
|
|
|
|
import { renderControllerDebugJob, waitForJobShell } from "./cicd-controller-render";
|
|
|
|
|
import { redactText, shQuote } from "./platform-infra-ops-library";
|
|
|
|
@@ -30,7 +33,11 @@ export async function buildDebugStep(registry: BranchFollowerRegistry, options:
|
|
|
|
|
let decided: FollowerState | null = null;
|
|
|
|
|
let write: Record<string, unknown> | null = null;
|
|
|
|
|
let after: K8sStateRead | null = null;
|
|
|
|
|
let controllerSource: Record<string, unknown> | null = null;
|
|
|
|
|
|
|
|
|
|
if (step === "controller-source") {
|
|
|
|
|
controllerSource = controllerSourceSnapshot(registry);
|
|
|
|
|
}
|
|
|
|
|
if (step === "status-read" || step === "decide") {
|
|
|
|
|
live = await deps.readAdapterStatus(registry, follower, options);
|
|
|
|
|
}
|
|
|
|
@@ -69,6 +76,7 @@ export async function buildDebugStep(registry: BranchFollowerRegistry, options:
|
|
|
|
|
execution: "k8s-native-in-cluster",
|
|
|
|
|
dryRun: !options.confirm,
|
|
|
|
|
stateBefore: stateSnapshot(before, follower.id),
|
|
|
|
|
controllerSource,
|
|
|
|
|
status: live === null ? null : compactAdapterStatus(live),
|
|
|
|
|
decision: decided === null ? null : compactFollowerDecision(decided),
|
|
|
|
|
stateWrite: write,
|
|
|
|
@@ -84,6 +92,7 @@ export function renderDebugStepHuman(payload: Record<string, unknown>): string {
|
|
|
|
|
const write = asOptionalRecord(payload.stateWrite);
|
|
|
|
|
const status = asOptionalRecord(payload.status);
|
|
|
|
|
const decision = asOptionalRecord(payload.decision);
|
|
|
|
|
const controllerSource = asOptionalRecord(payload.controllerSource);
|
|
|
|
|
const target = asOptionalRecord(payload.target);
|
|
|
|
|
const next = asOptionalRecord(payload.next);
|
|
|
|
|
const rows = [[
|
|
|
|
@@ -109,11 +118,13 @@ export function renderDebugStepHuman(payload: Record<string, unknown>): string {
|
|
|
|
|
`CI/CD BRANCH-FOLLOWER DEBUG-STEP (${payload.ok === false ? "failed" : "ok"})`,
|
|
|
|
|
"",
|
|
|
|
|
table(["FOLLOWER", "STEP", "EXECUTION", "DRY_RUN", "BEFORE", "AFTER", "BEFORE_SHA", "AFTER_SHA"], rows),
|
|
|
|
|
controllerSource === null ? "" : `\nCONTROLLER SOURCE\n${table(["HEAD", "BRANCH", "REGISTRY", "REREAD_MARKER", "FILE_SHA"], [[shortSha(stringOrNull(controllerSource.head)), controllerSource.branch ?? "-", shortSha(stringOrNull(controllerSource.registrySha256)), controllerSource.closeoutRereadMarker === true ? "present" : "missing", shortSha(stringOrNull(asOptionalRecord(controllerSource.branchFollowerFile)?.sha256))]])}`,
|
|
|
|
|
target === null ? "" : `\nTARGET JOB\n${table(["JOB", "EXIT", "TIMED_OUT", "PARSED"], [[target.name ?? "-", target.exitCode ?? "-", target.timedOut ?? "-", target.parsed === true ? "yes" : "no"]])}`,
|
|
|
|
|
writeRows.length === 0 ? "" : `\nSTATE WRITE\n${table(["STATUS", "MODE", "INPUT", "BEFORE_RV", "AFTER_RV", "EXIT", "MESSAGE"], writeRows)}`,
|
|
|
|
|
"",
|
|
|
|
|
"NEXT",
|
|
|
|
|
`state-read: ${next?.stateRead ?? "-"}`,
|
|
|
|
|
`controller-source: ${next?.controllerSource ?? "-"}`,
|
|
|
|
|
`status-read: ${next?.statusRead ?? "-"}`,
|
|
|
|
|
`decide: ${next?.decide ?? "-"}`,
|
|
|
|
|
`state-write: ${next?.stateWrite ?? "-"}`,
|
|
|
|
@@ -152,6 +163,7 @@ function runTargetDebugStepJob(registry: BranchFollowerRegistry, options: Parsed
|
|
|
|
|
execution: "k8s-native-debug-job",
|
|
|
|
|
dryRun: !options.confirm,
|
|
|
|
|
stateBefore: compact?.stateBefore ?? compactStateLike(asOptionalRecord(parsed?.stateBefore)),
|
|
|
|
|
controllerSource: compact?.controllerSource ?? asOptionalRecord(parsed?.controllerSource),
|
|
|
|
|
status: compact?.status ?? null,
|
|
|
|
|
decision: compact?.decision ?? null,
|
|
|
|
|
stateWrite: compact?.stateWrite ?? null,
|
|
|
|
@@ -173,6 +185,7 @@ function runTargetDebugStepJob(registry: BranchFollowerRegistry, options: Parsed
|
|
|
|
|
function compactTargetDebugResult(parsed: Record<string, unknown> | null): Record<string, unknown> | null {
|
|
|
|
|
if (parsed === null) return null;
|
|
|
|
|
const stateBefore = asOptionalRecord(parsed.stateBefore);
|
|
|
|
|
const controllerSource = asOptionalRecord(parsed.controllerSource);
|
|
|
|
|
const status = asOptionalRecord(parsed.status);
|
|
|
|
|
const decision = asOptionalRecord(parsed.decision);
|
|
|
|
|
const stateWrite = asOptionalRecord(parsed.stateWrite);
|
|
|
|
@@ -183,6 +196,16 @@ function compactTargetDebugResult(parsed: Record<string, unknown> | null): Recor
|
|
|
|
|
step: stringOrNull(parsed.step),
|
|
|
|
|
follower: stringOrNull(parsed.follower),
|
|
|
|
|
stateBefore: compactStateLike(stateBefore),
|
|
|
|
|
controllerSource: controllerSource === null ? null : {
|
|
|
|
|
ok: controllerSource.ok === true,
|
|
|
|
|
repository: stringOrNull(controllerSource.repository),
|
|
|
|
|
branch: stringOrNull(controllerSource.branch),
|
|
|
|
|
head: stringOrNull(controllerSource.head),
|
|
|
|
|
registrySha256: stringOrNull(controllerSource.registrySha256),
|
|
|
|
|
closeoutRereadMarker: controllerSource.closeoutRereadMarker === true,
|
|
|
|
|
branchFollowerFile: asOptionalRecord(controllerSource.branchFollowerFile),
|
|
|
|
|
errors: Array.isArray(controllerSource.errors) ? controllerSource.errors.map(String).slice(0, 5) : [],
|
|
|
|
|
},
|
|
|
|
|
status: status === null ? null : {
|
|
|
|
|
ok: status.ok === true,
|
|
|
|
|
phase: stringOrNull(status.phase),
|
|
|
|
@@ -220,6 +243,61 @@ function compactStateLike(value: Record<string, unknown> | null): Record<string,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function controllerSourceSnapshot(registry: BranchFollowerRegistry): Record<string, unknown> {
|
|
|
|
|
const head = commandText(["git", "rev-parse", "HEAD"]);
|
|
|
|
|
const branch = commandText(["git", "symbolic-ref", "--short", "HEAD"]);
|
|
|
|
|
const file = fileSnapshot("scripts/src/cicd-branch-follower.ts");
|
|
|
|
|
const text = file.text;
|
|
|
|
|
const errors = [
|
|
|
|
|
head.ok ? null : `git head: ${head.error}`,
|
|
|
|
|
branch.ok ? null : `git branch: ${branch.error}`,
|
|
|
|
|
file.ok ? null : `branch follower file: ${file.error}`,
|
|
|
|
|
].filter((item): item is string => item !== null);
|
|
|
|
|
return {
|
|
|
|
|
ok: errors.length === 0,
|
|
|
|
|
repository: registry.controller.source.repository,
|
|
|
|
|
branch: branch.value,
|
|
|
|
|
head: head.value,
|
|
|
|
|
registrySha256: registry.rawSha256,
|
|
|
|
|
controllerConfigMap: registry.controller.configMapName,
|
|
|
|
|
closeoutRereadMarker: text.includes("post-closeout status re-read") && text.includes("automaticCloseoutAccelerated"),
|
|
|
|
|
branchFollowerFile: {
|
|
|
|
|
path: file.path,
|
|
|
|
|
present: file.present,
|
|
|
|
|
bytes: file.bytes,
|
|
|
|
|
sha256: file.sha256,
|
|
|
|
|
},
|
|
|
|
|
statusAuthority: "target-controller-checkout",
|
|
|
|
|
parsedDownstreamCliOutput: false,
|
|
|
|
|
errors,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function commandText(command: string[]): { ok: boolean; value: string | null; error: string | null } {
|
|
|
|
|
const result = runCommand(command, repoRoot, { timeoutMs: 5_000 });
|
|
|
|
|
const value = result.exitCode === 0 ? result.stdout.trim() : null;
|
|
|
|
|
return {
|
|
|
|
|
ok: result.exitCode === 0 && value !== null && value.length > 0,
|
|
|
|
|
value: value === null || value.length === 0 ? null : value,
|
|
|
|
|
error: result.exitCode === 0 ? null : redactText(tailText(result.stderr || result.stdout, 300)),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fileSnapshot(path: string): { ok: boolean; path: string; present: boolean; bytes: number | null; sha256: string | null; text: string; error: string | null } {
|
|
|
|
|
const absolute = rootPath(path);
|
|
|
|
|
if (!existsSync(absolute)) return { ok: false, path, present: false, bytes: null, sha256: null, text: "", error: "missing" };
|
|
|
|
|
const text = readFileSync(absolute, "utf8");
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
path,
|
|
|
|
|
present: true,
|
|
|
|
|
bytes: Buffer.byteLength(text),
|
|
|
|
|
sha256: createHash("sha256").update(text).digest("hex"),
|
|
|
|
|
text,
|
|
|
|
|
error: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function debugDecisionOptions(options: ParsedOptions): ParsedOptions {
|
|
|
|
|
return { ...options, confirm: false, dryRun: true, wait: false, recordState: false };
|
|
|
|
|
}
|
|
|
|
@@ -407,6 +485,7 @@ function compactDebugTimings(timings: FollowerState["timings"]): Record<string,
|
|
|
|
|
function debugNext(followerId: string): Record<string, string> {
|
|
|
|
|
return {
|
|
|
|
|
stateRead: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step state-read`,
|
|
|
|
|
controllerSource: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step controller-source`,
|
|
|
|
|
statusRead: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step status-read`,
|
|
|
|
|
decide: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step decide`,
|
|
|
|
|
stateWrite: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step state-write --confirm`,
|
|
|
|
|