cicd summarize follower state on target node
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const namespace = process.env.NAMESPACE || "";
|
||||
const configMap = process.env.CONFIGMAP || "";
|
||||
const followerIds = parseFollowerIds(process.env.FOLLOWERS_JSON || "[]");
|
||||
const maxTimingStages = Number(process.env.MAX_TIMING_STAGES || "24");
|
||||
|
||||
function parseFollowerIds(text) {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.length > 0) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function kubectlConfigMap() {
|
||||
try {
|
||||
const stdout = execFileSync("kubectl", ["-n", namespace, "get", "configmap", configMap, "-o", "json"], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
return { ok: true, present: true, object: JSON.parse(stdout), error: "" };
|
||||
} catch (error) {
|
||||
const stderr = String(error?.stderr || error?.message || "");
|
||||
if (/not found/i.test(stderr)) return { ok: true, present: false, object: null, error: stderr };
|
||||
return { ok: false, present: false, object: null, error: stderr || "kubectl configmap read failed" };
|
||||
}
|
||||
}
|
||||
|
||||
function compactStateText(text) {
|
||||
if (typeof text !== "string" || text.length === 0) return null;
|
||||
let state;
|
||||
try {
|
||||
state = JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: stringOrNull(state.id),
|
||||
adapter: stringOrNull(state.adapter),
|
||||
enabled: state.enabled === true,
|
||||
phase: stringOrNull(state.phase),
|
||||
source: compactSource(state.source),
|
||||
target: compactTarget(state.target),
|
||||
lastTriggeredSha: stringOrNull(state.lastTriggeredSha),
|
||||
lastSucceededSha: stringOrNull(state.lastSucceededSha),
|
||||
pipelineRun: stringOrNull(state.pipelineRun),
|
||||
inFlightJob: stringOrNull(state.inFlightJob),
|
||||
controller: compactController(state.controller),
|
||||
decision: stringOrNull(state.decision),
|
||||
dryRun: state.dryRun === true,
|
||||
updatedAt: stringOrNull(state.updatedAt),
|
||||
timings: compactTimings(state.timings),
|
||||
warnings: arrayStrings(state.warnings).slice(0, 6),
|
||||
stateFormat: stringOrNull(state.stateFormat),
|
||||
};
|
||||
}
|
||||
|
||||
function compactSource(source) {
|
||||
const value = recordOrNull(source);
|
||||
if (value === null) return null;
|
||||
return {
|
||||
repository: stringOrNull(value.repository),
|
||||
branch: stringOrNull(value.branch),
|
||||
branchRef: stringOrNull(value.branchRef),
|
||||
snapshotPrefix: stringOrNull(value.snapshotPrefix),
|
||||
observedSha: stringOrNull(value.observedSha),
|
||||
};
|
||||
}
|
||||
|
||||
function compactTarget(target) {
|
||||
const value = recordOrNull(target);
|
||||
if (value === null) return null;
|
||||
return {
|
||||
node: stringOrNull(value.node),
|
||||
lane: stringOrNull(value.lane),
|
||||
namespace: stringOrNull(value.namespace),
|
||||
sentinel: stringOrNull(value.sentinel),
|
||||
targetSha: stringOrNull(value.targetSha),
|
||||
};
|
||||
}
|
||||
|
||||
function compactController(controller) {
|
||||
const value = recordOrNull(controller);
|
||||
if (value === null) return null;
|
||||
return {
|
||||
mode: stringOrNull(value.mode),
|
||||
stateConfigMap: stringOrNull(value.stateConfigMap),
|
||||
leaseName: stringOrNull(value.leaseName),
|
||||
};
|
||||
}
|
||||
|
||||
function compactTimings(timings) {
|
||||
const value = recordOrNull(timings);
|
||||
if (value === null) return null;
|
||||
return {
|
||||
budgetSeconds: numberOrNull(value.budgetSeconds),
|
||||
totalSeconds: numberOrNull(value.totalSeconds),
|
||||
totalStatus: stringOrNull(value.totalStatus),
|
||||
totalSource: stringOrNull(value.totalSource),
|
||||
startedAt: stringOrNull(value.startedAt),
|
||||
finishedAt: stringOrNull(value.finishedAt),
|
||||
overBudget: typeof value.overBudget === "boolean" ? value.overBudget : null,
|
||||
stages: arrayRecords(value.stages).slice(0, maxTimingStages).map(compactStageTiming),
|
||||
};
|
||||
}
|
||||
|
||||
function compactStageTiming(stage) {
|
||||
return {
|
||||
stage: stringOrNull(stage.stage),
|
||||
status: stringOrNull(stage.status),
|
||||
seconds: numberOrNull(stage.seconds),
|
||||
budgetSeconds: numberOrNull(stage.budgetSeconds),
|
||||
source: stringOrNull(stage.source),
|
||||
object: stringOrNull(stage.object),
|
||||
};
|
||||
}
|
||||
|
||||
function recordOrNull(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
function arrayRecords(value) {
|
||||
return Array.isArray(value) ? value.filter((item) => recordOrNull(item) !== null) : [];
|
||||
}
|
||||
|
||||
function arrayStrings(value) {
|
||||
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
function stringOrNull(value) {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function numberOrNull(value) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
const result = kubectlConfigMap();
|
||||
const errors = [];
|
||||
const stateByFollower = {};
|
||||
const valueBytes = {};
|
||||
|
||||
if (result.ok && result.present) {
|
||||
const data = recordOrNull(result.object?.data) || {};
|
||||
for (const id of followerIds) {
|
||||
const text = typeof data[id] === "string" ? data[id] : "";
|
||||
if (text.length === 0) continue;
|
||||
valueBytes[id] = Buffer.byteLength(text, "utf8");
|
||||
const compact = compactStateText(text);
|
||||
if (compact === null) errors.push(`${id}: invalid state json`);
|
||||
else stateByFollower[id] = compact;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.ok) errors.push(result.error);
|
||||
|
||||
process.stdout.write(JSON.stringify({
|
||||
ok: result.ok && errors.length === 0,
|
||||
present: result.present,
|
||||
stateByFollower,
|
||||
valueBytes,
|
||||
errors,
|
||||
statusAuthority: "target-node-summary",
|
||||
parsedDownstreamCliOutput: false,
|
||||
}));
|
||||
+23
-78
@@ -3,7 +3,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { repoRoot, rootPath, type UniDeskConfig } from "./config";
|
||||
import { runCommand, type CommandResult } from "./command";
|
||||
import { startJob } from "./jobs";
|
||||
@@ -1546,7 +1545,11 @@ const NATIVE_BUNDLE_SCRIPT_NAMES = [
|
||||
] as const;
|
||||
|
||||
function nativeBundleScriptLoadShell(): string {
|
||||
return NATIVE_BUNDLE_SCRIPT_NAMES.map((name) => {
|
||||
return nativeScriptLoadShell(NATIVE_BUNDLE_SCRIPT_NAMES);
|
||||
}
|
||||
|
||||
function nativeScriptLoadShell(names: readonly string[]): string {
|
||||
return names.map((name) => {
|
||||
const encoded = Buffer.from(readFileSync(rootPath("scripts/native/cicd", name), "utf8"), "utf8").toString("base64");
|
||||
return [
|
||||
`printf '%s' ${shQuote(encoded)} | base64 -d > "$tmpdir/${name}"`,
|
||||
@@ -1850,95 +1853,37 @@ function readK8sState(registry: BranchFollowerRegistry, options: ParsedOptions):
|
||||
}
|
||||
|
||||
function kubeConfigMapFollowerState(registry: BranchFollowerRegistry, options: ParsedOptions): K8sFollowerStateRead {
|
||||
const stateByFollower: Record<string, Record<string, unknown>> = {};
|
||||
const errors: string[] = [];
|
||||
let present = false;
|
||||
for (const follower of registry.followers) {
|
||||
const result = kubeConfigMapDataValue(registry, options, follower.id);
|
||||
if (!result.ok) {
|
||||
errors.push(result.error);
|
||||
continue;
|
||||
}
|
||||
if (!result.present) {
|
||||
return { ok: true, stateByFollower: {}, present: false, error: "" };
|
||||
}
|
||||
present = true;
|
||||
if (result.omitted) continue;
|
||||
if (result.value === null || result.value.length === 0) continue;
|
||||
const parsed = parseJsonObject(result.value);
|
||||
if (parsed === null) {
|
||||
errors.push(`${follower.id}: invalid state json`);
|
||||
continue;
|
||||
}
|
||||
stateByFollower[follower.id] = parsed;
|
||||
}
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
stateByFollower,
|
||||
present,
|
||||
error: errors.join("; "),
|
||||
};
|
||||
}
|
||||
|
||||
function kubeConfigMapDataValue(registry: BranchFollowerRegistry, options: ParsedOptions, key: string): { ok: boolean; present: boolean; value: string | null; omitted: boolean; error: string } {
|
||||
const template = `{{ with index .data ${JSON.stringify(key)} }}{{ . }}{{ end }}`;
|
||||
const maxValueBytes = 32_768;
|
||||
const script = [
|
||||
"set -eu",
|
||||
"tmpdir=$(mktemp -d)",
|
||||
"cleanup() { rm -rf \"$tmpdir\"; }",
|
||||
"trap cleanup EXIT INT TERM",
|
||||
nativeScriptLoadShell(["read-state-summary.mjs"]),
|
||||
`NAMESPACE=${shQuote(registry.controller.namespace)}`,
|
||||
`CONFIGMAP=${shQuote(registry.controller.stateConfigMapName)}`,
|
||||
`MAX_VALUE_BYTES=${maxValueBytes}`,
|
||||
"export NAMESPACE CONFIGMAP MAX_VALUE_BYTES",
|
||||
`if ! value=$(kubectl -n "$NAMESPACE" get configmap "$CONFIGMAP" -o go-template=${shQuote(template)} 2>"$tmpdir/error"); then`,
|
||||
" error_b64=$(tail -c 800 \"$tmpdir/error\" | base64 | tr -d '\\n')",
|
||||
" if grep -qi 'not found' \"$tmpdir/error\"; then",
|
||||
" printf '{\"ok\":true,\"present\":false,\"valueB64\":null,\"omitted\":false,\"errorB64\":\"%s\"}' \"$error_b64\"",
|
||||
" exit 0",
|
||||
" fi",
|
||||
" printf '{\"ok\":false,\"present\":false,\"valueB64\":null,\"omitted\":false,\"errorB64\":\"%s\"}' \"$error_b64\"",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"value_bytes=$(printf '%s' \"$value\" | wc -c | tr -d ' ')",
|
||||
"if [ \"${value_bytes:-0}\" -gt \"$MAX_VALUE_BYTES\" ]; then",
|
||||
" printf '{\"ok\":true,\"present\":true,\"valueB64\":null,\"omitted\":true,\"valueBytes\":%s,\"errorB64\":\"\"}' \"$value_bytes\"",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"if command -v gzip >/dev/null 2>&1; then",
|
||||
" value_b64=$(printf '%s' \"$value\" | gzip -c | base64 | tr -d '\\n')",
|
||||
" printf '{\"ok\":true,\"present\":true,\"valueB64\":\"%s\",\"encoding\":\"gzip-base64\",\"omitted\":false,\"valueBytes\":%s,\"errorB64\":\"\"}' \"$value_b64\" \"$value_bytes\"",
|
||||
"else",
|
||||
" value_b64=$(printf '%s' \"$value\" | base64 | tr -d '\\n')",
|
||||
" printf '{\"ok\":true,\"present\":true,\"valueB64\":\"%s\",\"encoding\":\"base64\",\"omitted\":false,\"valueBytes\":%s,\"errorB64\":\"\"}' \"$value_b64\" \"$value_bytes\"",
|
||||
"fi",
|
||||
`FOLLOWERS_JSON=${shQuote(JSON.stringify(registry.followers.map((follower) => follower.id)))}`,
|
||||
"MAX_TIMING_STAGES=24",
|
||||
"export NAMESPACE CONFIGMAP FOLLOWERS_JSON MAX_TIMING_STAGES",
|
||||
"node \"$tmpdir/read-state-summary.mjs\"",
|
||||
].join("\n");
|
||||
const result = runKubeScript(registry, options, script, "", 10_000);
|
||||
const parsed = result.exitCode === 0 ? parseJsonObject(result.stdout) : null;
|
||||
if (parsed === null) {
|
||||
return {
|
||||
ok: false,
|
||||
present: false,
|
||||
value: null,
|
||||
omitted: false,
|
||||
error: redactText(tailText(result.stderr || result.stdout, 800)),
|
||||
};
|
||||
const error = redactText(tailText(result.stderr || result.stdout, 800));
|
||||
return { ok: false, stateByFollower: {}, present: false, error };
|
||||
}
|
||||
const errorB64 = typeof parsed.errorB64 === "string" ? parsed.errorB64 : "";
|
||||
const error = errorB64.length === 0 ? "" : Buffer.from(errorB64, "base64").toString("utf8");
|
||||
const ok = parsed.ok === true;
|
||||
const present = parsed.present === true;
|
||||
const omitted = parsed.omitted === true;
|
||||
const valueB64 = typeof parsed.valueB64 === "string" ? parsed.valueB64 : null;
|
||||
const encoding = stringOrNull(parsed.encoding) ?? "base64";
|
||||
const valueBuffer = valueB64 === null ? null : Buffer.from(valueB64, "base64");
|
||||
const parsedStates = asOptionalRecord(parsed.stateByFollower) ?? {};
|
||||
const stateByFollower: Record<string, Record<string, unknown>> = {};
|
||||
for (const follower of registry.followers) {
|
||||
const state = asOptionalRecord(parsedStates[follower.id]);
|
||||
if (state !== null) stateByFollower[follower.id] = state;
|
||||
}
|
||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(String).filter((item) => item.length > 0) : [];
|
||||
return {
|
||||
ok,
|
||||
present,
|
||||
value: valueBuffer === null ? null : encoding === "gzip-base64" ? gunzipSync(valueBuffer).toString("utf8") : valueBuffer.toString("utf8"),
|
||||
omitted,
|
||||
error: redactText(error),
|
||||
ok: parsed.ok === true && errors.length === 0,
|
||||
stateByFollower,
|
||||
present: parsed.present === true,
|
||||
error: errors.join("; "),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user