1076 lines
44 KiB
JavaScript
1076 lines
44 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { execFileSync } from "node:child_process";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import http from "node:http";
|
|
import https from "node:https";
|
|
import { parseRuntimeReuseConfig, summarizeRuntimeReuseConfig } from "./reuse-config-summary.mjs";
|
|
|
|
const gate = requiredEnv("GATE");
|
|
const follower = requiredEnv("FOLLOWER_ID");
|
|
const repository = requiredEnv("REPOSITORY");
|
|
const repoPath = requiredEnv("REPO_PATH");
|
|
const sourceBranch = requiredEnv("SOURCE_BRANCH");
|
|
const snapshotPrefix = requiredEnv("SNAPSHOT_PREFIX").replace(/\/+$/u, "");
|
|
const selectedSource = process.env.SOURCE_COMMIT || "";
|
|
const gitopsBranch = process.env.GITOPS_BRANCH || "";
|
|
const tektonNamespace = process.env.TEKTON_NAMESPACE || "";
|
|
const pipelineRunPrefix = process.env.PIPELINE_RUN_PREFIX || "";
|
|
const argoNamespace = process.env.ARGO_NAMESPACE || "";
|
|
const argoApplication = process.env.ARGO_APPLICATION || "";
|
|
const runtimeNamespace = process.env.RUNTIME_NAMESPACE || "";
|
|
const stateNamespace = process.env.STATE_NAMESPACE || "";
|
|
const stateConfigMap = process.env.STATE_CONFIGMAP || "";
|
|
const workloads = parseWorkloads(process.env.WORKLOADS_B64 || "");
|
|
const healthUrl = process.env.HEALTH_URL || "";
|
|
const slowTaskSeconds = requiredPositiveIntEnv("SLOW_TASK_SECONDS");
|
|
const healthTimeoutMs = requiredPositiveIntEnv("HEALTH_TIMEOUT_MS");
|
|
const gateTimeoutMs = optionalPositiveIntEnv("GATE_TIMEOUT_MS") ?? healthTimeoutMs;
|
|
|
|
const errors = [];
|
|
const branchCommit = rev(`refs/heads/${sourceBranch}`);
|
|
const sourceCommit = selectedSource || branchCommit || "";
|
|
const sourceStageRef = sourceCommit ? `${snapshotPrefix}/${sourceCommit}` : null;
|
|
const sourceSnapshot = sourceStageRef ? rev(sourceStageRef) : null;
|
|
const source = {
|
|
repository,
|
|
branch: sourceBranch,
|
|
sourceCommit: sourceCommit || null,
|
|
stageRef: sourceStageRef,
|
|
snapshotReady: Boolean(sourceCommit && sourceSnapshot === sourceCommit),
|
|
authority: "k8s-git-mirror-snapshot",
|
|
};
|
|
const gitMirror = gitMirrorSummary(sourceCommit);
|
|
|
|
let evidence;
|
|
if (gate === "reuse-plan") evidence = await reusePlanEvidence(sourceCommit);
|
|
else if (gate === "ci-taskrun-plan") evidence = await ciTaskRunEvidence(sourceCommit);
|
|
else if (gate === "cd-rollout-plan") evidence = await cdRolloutEvidence(sourceCommit);
|
|
else if (gate === "post-deploy-health") evidence = await postDeployHealthEvidence(sourceCommit);
|
|
else if (gate === "runtime-closeout") evidence = await runtimeCloseoutEvidence(sourceCommit);
|
|
else fail(`unsupported gate ${gate}`);
|
|
|
|
const ok = errors.length === 0 && evidence?.ok === true;
|
|
const payload = {
|
|
ok,
|
|
gate,
|
|
follower,
|
|
source,
|
|
evidence,
|
|
policy: {
|
|
slowTaskSeconds,
|
|
healthTimeoutMs,
|
|
},
|
|
errors: errors.slice(0, 6),
|
|
statusAuthority: "kubernetes-api-serviceaccount",
|
|
parsedDownstreamCliOutput: false,
|
|
bounded: true,
|
|
};
|
|
console.log(JSON.stringify(payload));
|
|
if (!ok) process.exit(2);
|
|
|
|
async function reusePlanEvidence(commit) {
|
|
const reuse = readReuseConfig(commit);
|
|
const decisions = reuse.present === true ? reusePlanDecisions(reuse) : [];
|
|
const summary = reusePlanDecisionSummary(decisions);
|
|
return {
|
|
ok: source.snapshotReady && gitMirror.ok === true && reuse.present === true && reuse.serviceCount > 0 && decisions.length > 0,
|
|
gitMirror: compactGitMirrorEvidence(gitMirror),
|
|
reuse: compactReuseEvidence(reuse),
|
|
decisions: boundedDecisions(decisions),
|
|
decisionSummary: summary,
|
|
contract: {
|
|
consumer: "adapter-ci",
|
|
fields: "serviceId sourceIdentity envIdentity runtimeReuse envReuse skipImageBuild buildDecision reusableImageRef reason",
|
|
expectation: "CI consumes skipImageBuild from this plan; it does not re-infer build/skip from TaskRun logs or source file scans.",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function ciTaskRunEvidence(commit) {
|
|
if (!commit || !tektonNamespace || !pipelineRunPrefix) return notConfigured("tekton");
|
|
const pipelineRunName = `${pipelineRunPrefix}-${commit.slice(0, 12)}`;
|
|
const reusePlan = await reusePlanEvidence(commit);
|
|
const pipelineRun = await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/pipelineruns/${encodeURIComponent(pipelineRunName)}`, false);
|
|
const prStatus = pipelineRunStatus(pipelineRun);
|
|
const pipelineRef = str(pipelineRun?.spec?.pipelineRef?.name);
|
|
const pipeline = pipelineRef ? await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/pipelines/${encodeURIComponent(pipelineRef)}`, false) : null;
|
|
const taskRuns = await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/taskruns?labelSelector=${encodeURIComponent(`tekton.dev/pipelineRun=${pipelineRunName}`)}`, false);
|
|
const taskSummary = taskRunsSummary(taskRuns);
|
|
const planArtifacts = planArtifactsEvidence(pipelineRunName);
|
|
const storedCiConsumption = await storedAgentRunCiConsumption(commit);
|
|
const buildTaskRunServices = buildTaskServices(taskRuns);
|
|
const ciConsumption = ciConsumptionSummary(reusePlan.decisions || [], planArtifacts, buildTaskRunServices, storedCiConsumption);
|
|
return {
|
|
ok: prStatus.succeeded === true && taskSummary.failedCount === 0 && taskSummary.activeCount === 0 && ciConsumption.ok === true,
|
|
reusePlan: {
|
|
ok: reusePlan.ok,
|
|
decisionSummary: compactDecisionSummary(reusePlan.decisionSummary),
|
|
},
|
|
planArtifacts,
|
|
storedCiConsumption,
|
|
ciConsumption,
|
|
pipelineRun: prStatus,
|
|
pipeline: {
|
|
name: pipelineRef,
|
|
taskCount: Array.isArray(pipeline?.spec?.tasks) ? pipeline.spec.tasks.length : null,
|
|
tasks: Array.isArray(pipeline?.spec?.tasks) ? pipeline.spec.tasks.slice(0, 12).map((task) => ({ name: str(task?.name), runAfter: Array.isArray(task?.runAfter) ? task.runAfter.slice(0, 6) : [] })) : [],
|
|
tasksTruncated: Array.isArray(pipeline?.spec?.tasks) ? pipeline.spec.tasks.length > 12 : false,
|
|
},
|
|
taskRuns: compactCiTaskRuns(taskSummary),
|
|
};
|
|
}
|
|
|
|
function reusePlanDecisions(reuse) {
|
|
const baseRef = sourceStageRef ? parentRef(sourceStageRef) : null;
|
|
return (reuse.serviceSpecs || []).map((service) => {
|
|
const codePaths = service.runtimeReuse?.codeIdentityPaths || [];
|
|
const envPaths = uniqueStrings([...(service.runtimeReuse?.envIdentityPaths || []), ...(service.envReuse?.envIdentityFiles || [])]);
|
|
const sourceIdentity = identityComparison(sourceStageRef, baseRef, codePaths);
|
|
const envIdentity = identityComparison(sourceStageRef, baseRef, envPaths);
|
|
const runtimeEnabled = service.runtimeReuse?.enabled !== false;
|
|
const envEnabled = service.envReuse?.enabled !== false;
|
|
const runtimeHit = runtimeEnabled && sourceIdentity.hit === true && envIdentity.hit === true;
|
|
const envHit = envEnabled && envIdentity.hit === true;
|
|
const skipImageBuild = runtimeHit || envHit;
|
|
return {
|
|
serviceId: service.id,
|
|
sourceIdentity,
|
|
envIdentity,
|
|
runtimeReuse: {
|
|
enabled: runtimeEnabled,
|
|
hit: runtimeHit,
|
|
reason: runtimeHit ? "source-and-env-identity-hit" : missReason(sourceIdentity, envIdentity, runtimeEnabled),
|
|
},
|
|
envReuse: {
|
|
enabled: envEnabled,
|
|
hit: envHit,
|
|
reason: envHit ? "env-identity-hit" : missReason(null, envIdentity, envEnabled),
|
|
},
|
|
skipImageBuild,
|
|
buildDecision: skipImageBuild ? "skipImageBuild" : "buildImage",
|
|
reusableImageRef: null,
|
|
reusableImageRefKnown: false,
|
|
reason: decisionReason({ runtimeHit, envHit, runtimeEnabled, envEnabled, sourceIdentity, envIdentity }),
|
|
};
|
|
});
|
|
}
|
|
|
|
function reusePlanDecisionSummary(decisions) {
|
|
const skip = decisions.filter((item) => item.skipImageBuild === true).map((item) => item.serviceId).sort();
|
|
const build = decisions.filter((item) => item.skipImageBuild !== true).map((item) => item.serviceId).sort();
|
|
return {
|
|
serviceCount: decisions.length,
|
|
skipImageBuildCount: skip.length,
|
|
buildImageCount: build.length,
|
|
skipImageBuildServices: skip,
|
|
buildImageServices: build,
|
|
};
|
|
}
|
|
|
|
function compactDecisionSummary(summary) {
|
|
return {
|
|
serviceCount: summary?.serviceCount ?? null,
|
|
skipImageBuildCount: summary?.skipImageBuildCount ?? null,
|
|
buildImageCount: summary?.buildImageCount ?? null,
|
|
};
|
|
}
|
|
|
|
function identityComparison(currentRef, baseRef, paths) {
|
|
const current = identityDigest(currentRef, paths);
|
|
const previous = baseRef ? identityDigest(baseRef, paths) : null;
|
|
const configured = paths.length > 0;
|
|
const hit = configured && previous !== null && current.sha256 !== null && previous.sha256 !== null && current.sha256 === previous.sha256;
|
|
return {
|
|
configured,
|
|
paths: paths.slice(0, 12),
|
|
pathsTruncated: paths.length > 12,
|
|
pathCount: paths.length,
|
|
current: current.sha256,
|
|
previous: previous?.sha256 ?? null,
|
|
currentMissingCount: current.missingCount,
|
|
previousMissingCount: previous?.missingCount ?? null,
|
|
hit,
|
|
status: !configured ? "not-configured" : previous === null ? "base-missing" : hit ? "hit" : "miss",
|
|
};
|
|
}
|
|
|
|
function identityDigest(ref, paths) {
|
|
if (!ref || paths.length === 0) return { sha256: null, missingCount: 0 };
|
|
const hash = createHash("sha256");
|
|
let missingCount = 0;
|
|
for (const path of paths) {
|
|
const entries = gitTreeEntries(ref, path);
|
|
if (entries.length === 0) missingCount += 1;
|
|
hash.update(path);
|
|
hash.update("\0");
|
|
for (const entry of entries) {
|
|
hash.update(entry);
|
|
hash.update("\0");
|
|
}
|
|
}
|
|
return { sha256: hash.digest("hex"), missingCount };
|
|
}
|
|
|
|
function gitTreeEntries(ref, path) {
|
|
try {
|
|
const out = execFileSync("git", [`--git-dir=${repoPath}`, "ls-tree", "-r", "-z", "--full-tree", ref, "--", path], { encoding: "utf8", maxBuffer: 1024 * 1024 });
|
|
return out.split("\0").filter(Boolean).sort();
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function parentRef(ref) {
|
|
try {
|
|
const out = execFileSync("git", [`--git-dir=${repoPath}`, "rev-parse", "--verify", `${ref}^`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
return sha(out) ? out : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function missReason(sourceIdentity, envIdentity, enabled) {
|
|
if (!enabled) return "reuse-disabled";
|
|
if (sourceIdentity?.configured === false || envIdentity?.configured === false) return "identity-not-configured";
|
|
if (sourceIdentity?.status === "base-missing" || envIdentity?.status === "base-missing") return "base-identity-missing";
|
|
if (sourceIdentity?.status === "miss") return "source-identity-miss";
|
|
if (envIdentity?.status === "miss") return "env-identity-miss";
|
|
return "identity-miss";
|
|
}
|
|
|
|
function decisionReason(input) {
|
|
if (input.runtimeHit) return "runtime-reuse-hit";
|
|
if (input.envHit) return "env-reuse-hit";
|
|
if (input.envIdentity.configured === false) return "env-identity-not-configured";
|
|
if (input.envIdentity.status === "base-missing") return "previous-env-identity-unavailable";
|
|
if (input.envIdentity.status === "miss") return "env-identity-changed";
|
|
if (!input.runtimeEnabled && !input.envEnabled) return "reuse-disabled";
|
|
return "reuse-miss";
|
|
}
|
|
|
|
function planArtifactsEvidence(pipelineRunName) {
|
|
if (!tektonNamespace || !pipelineRunName) return { ok: false, degradedReason: "tekton-not-configured" };
|
|
try {
|
|
const text = execFileSync("node", ["./plan-artifacts.mjs", tektonNamespace, pipelineRunName], { encoding: "utf8", maxBuffer: 512 * 1024 });
|
|
return JSON.parse(text);
|
|
} catch (error) {
|
|
return { ok: false, degradedReason: "plan-artifacts-query-failed", reason: shortText(error?.message || String(error)) };
|
|
}
|
|
}
|
|
|
|
function ciConsumptionSummary(decisions, planArtifacts, buildTaskRunServices, storedCiConsumption) {
|
|
const skipExpected = decisions.filter((item) => item.skipImageBuild === true).map((item) => item.serviceId).sort();
|
|
const buildExpected = decisions.filter((item) => item.skipImageBuild !== true).map((item) => item.serviceId).sort();
|
|
if (/agentrun/u.test(follower) && storedCiConsumption?.present === true && storedCiConsumption.ok !== true) {
|
|
return {
|
|
ok: false,
|
|
reason: storedCiConsumption.reason || "ci-consumption-evidence-missing",
|
|
source: "branch-follower-compact-state",
|
|
expected: {
|
|
skipImageBuildCount: skipExpected.length,
|
|
buildImageCount: buildExpected.length,
|
|
},
|
|
observed: {
|
|
buildServicesCount: 0,
|
|
buildTaskRunServices,
|
|
reusedServicesCount: 0,
|
|
skippedOrReusedServicesCount: 0,
|
|
buildSkippedCount: null,
|
|
},
|
|
mismatches: [],
|
|
};
|
|
}
|
|
if (storedCiConsumption?.ok === true) {
|
|
const observed = storedCiConsumption.observed || {};
|
|
const unexpectedStoredBuild = skipExpected.filter((serviceId) => observed.imageBuildDecision === "buildImage" && serviceId === "agentrun-mgr");
|
|
const missingStoredBuild = buildExpected.filter((serviceId) => observed.imageBuildDecision !== "buildImage" && serviceId === "agentrun-mgr");
|
|
const storedOk = unexpectedStoredBuild.length === 0 && missingStoredBuild.length === 0;
|
|
return {
|
|
ok: storedOk,
|
|
reason: storedOk ? "ci-consumed-reuse-plan" : "ci-consumption-mismatch",
|
|
source: "branch-follower-compact-state",
|
|
expected: {
|
|
skipImageBuildCount: skipExpected.length,
|
|
buildImageCount: buildExpected.length,
|
|
},
|
|
observed: {
|
|
buildServicesCount: numberOrNull(observed.buildServicesCount),
|
|
buildTaskRunServices,
|
|
reusedServicesCount: numberOrNull(observed.reusedServicesCount),
|
|
skippedOrReusedServicesCount: numberOrNull(observed.reusedServicesCount),
|
|
buildSkippedCount: numberOrNull(observed.buildSkippedCount),
|
|
imageBuildDecision: str(observed.imageBuildDecision),
|
|
imageBuildStatus: str(observed.imageBuildStatus),
|
|
imageBuildCreated: observed.imageBuildCreated === true,
|
|
imageBuildReused: observed.imageBuildReused === true,
|
|
},
|
|
mismatches: compactCiMismatches(unexpectedStoredBuild, missingStoredBuild),
|
|
};
|
|
}
|
|
const buildObserved = uniqueStrings([...strings(planArtifacts?.buildServices), ...buildTaskRunServices]).sort();
|
|
const reusedObserved = strings(planArtifacts?.reusedServices).sort();
|
|
const skippedObserved = uniqueStrings(reusedObserved).sort();
|
|
const unexpectedBuild = skipExpected.filter((serviceId) => buildObserved.includes(serviceId) && !reusedObserved.includes(serviceId));
|
|
const missingBuild = buildExpected.filter((serviceId) => !buildObserved.includes(serviceId) && !reusedObserved.includes(serviceId));
|
|
const ok = planArtifacts?.ok === true && unexpectedBuild.length === 0 && missingBuild.length === 0;
|
|
return {
|
|
ok,
|
|
reason: unexpectedBuild.length > 0 || missingBuild.length > 0 ? "ci-consumption-mismatch" : planArtifacts?.ok === true ? "ci-consumed-reuse-plan" : "plan-artifacts-event-missing",
|
|
source: "reuse-plan-decisions-compared-to-plan-artifacts-g14-ci-plan",
|
|
expected: {
|
|
skipImageBuildCount: skipExpected.length,
|
|
buildImageCount: buildExpected.length,
|
|
},
|
|
observed: {
|
|
buildServicesCount: buildObserved.length,
|
|
buildTaskRunServices,
|
|
reusedServicesCount: reusedObserved.length,
|
|
skippedOrReusedServicesCount: skippedObserved.length,
|
|
buildSkippedCount: typeof planArtifacts?.buildSkippedCount === "number" ? planArtifacts.buildSkippedCount : null,
|
|
},
|
|
mismatches: compactCiMismatches(unexpectedBuild, missingBuild),
|
|
};
|
|
}
|
|
|
|
async function storedAgentRunCiConsumption(commit) {
|
|
if (!stateNamespace || !stateConfigMap || !follower || !commit) return null;
|
|
const cm = await getJson(`/api/v1/namespaces/${encodeURIComponent(stateNamespace)}/configmaps/${encodeURIComponent(stateConfigMap)}`, false);
|
|
const raw = cm?.data?.[follower];
|
|
if (typeof raw !== "string" || raw.length === 0) return { ok: false, present: false, reason: "compact-state-missing" };
|
|
const state = parseJson(raw);
|
|
if (state === null) return { ok: false, present: true, reason: "state-json-parse-failed" };
|
|
const command = state?.command || {};
|
|
const payload = command.payload || {};
|
|
const agentrun = payload.agentrun || {};
|
|
const sourceCommit = str(agentrun.sourceCommit) || str(command.sourceCommit);
|
|
const ci = agentrun.ciConsumption || null;
|
|
if (sourceCommit === null && (!ci || typeof ci !== "object")) return { ok: false, present: true, reason: "ci-consumption-evidence-missing", sourceCommit: null };
|
|
if (sourceCommit !== commit) return { ok: false, present: true, reason: "state-source-commit-mismatch", sourceCommit };
|
|
if (!ci || typeof ci !== "object") return { ok: false, present: true, reason: "ci-consumption-evidence-missing", sourceCommit };
|
|
return {
|
|
ok: ci.ok === true,
|
|
present: true,
|
|
sourceCommit,
|
|
source: str(ci.source),
|
|
expected: ci.expected || null,
|
|
observed: ci.observed || null,
|
|
mismatches: Array.isArray(ci.mismatches) ? ci.mismatches.slice(0, 6) : [],
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function compactCiMismatches(unexpectedBuild, missingBuild) {
|
|
return [
|
|
...(unexpectedBuild.length > 0 ? [{ reason: "expected-skipImageBuild-but-ci-buildServices-includes-service", serviceIds: unexpectedBuild }] : []),
|
|
...(missingBuild.length > 0 ? [{ reason: "expected-buildImage-but-ci-plan-has-no-build-or-reuse-service", serviceIds: missingBuild }] : []),
|
|
];
|
|
}
|
|
|
|
function buildTaskServices(list) {
|
|
const items = Array.isArray(list?.items) ? list.items : [];
|
|
return uniqueStrings(items.flatMap((item) => {
|
|
const taskName = str(item?.metadata?.labels?.["tekton.dev/pipelineTask"]) || str(item?.spec?.taskRef?.name) || "";
|
|
const match = /^build-(.+)$/u.exec(taskName);
|
|
return match ? [match[1]] : [];
|
|
})).sort();
|
|
}
|
|
|
|
function compactCiTaskRuns(summary) {
|
|
return {
|
|
count: summary.count,
|
|
slowThresholdSeconds: summary.slowThresholdSeconds,
|
|
failedCount: summary.failedCount,
|
|
activeCount: summary.activeCount,
|
|
slowCount: summary.slowCount,
|
|
failedItems: summary.failedItems.slice(0, 4).map(compactCiTaskItem),
|
|
activeItems: summary.activeItems.slice(0, 4).map(compactCiTaskItem),
|
|
slowItems: summary.slowItems.slice(0, 4).map(compactCiTaskItem),
|
|
timeline: summary.timeline.slice(0, 10),
|
|
timelineTruncated: summary.timeline.length > 10 || summary.timelineTruncated === true,
|
|
performance: summary.performance,
|
|
};
|
|
}
|
|
|
|
function compactCiTaskItem(item) {
|
|
return {
|
|
taskName: item.taskName,
|
|
status: item.status,
|
|
reason: item.reason,
|
|
durationSeconds: item.durationSeconds,
|
|
};
|
|
}
|
|
|
|
async function cdRolloutEvidence(commit) {
|
|
const argo = await argoSummary();
|
|
const runtime = await runtimeSummary(commit);
|
|
return {
|
|
ok: gitMirror.githubInSync !== false && argo.ready === true && runtime.ready === true && runtime.aligned === true,
|
|
gitMirror,
|
|
argo,
|
|
runtime,
|
|
};
|
|
}
|
|
|
|
async function postDeployHealthEvidence(commit) {
|
|
const runtime = await runtimeSummary(commit);
|
|
const health = await healthSummary();
|
|
return {
|
|
ok: runtime.ready === true && runtime.aligned === true && health.ok === true,
|
|
runtime,
|
|
health,
|
|
};
|
|
}
|
|
|
|
async function runtimeCloseoutEvidence(commit) {
|
|
const startedAt = Date.now();
|
|
const refresh = await argoRefresh();
|
|
const deadline = Date.now() + gateTimeoutMs;
|
|
let polls = 0;
|
|
let latest = null;
|
|
while (Date.now() <= deadline) {
|
|
polls += 1;
|
|
latest = await runtimeCloseoutStatus(commit);
|
|
if (latest.ok === true) break;
|
|
if (Date.now() + 2000 > deadline) break;
|
|
await delay(2000);
|
|
}
|
|
return {
|
|
ok: latest?.ok === true,
|
|
sourceCommit: shortSha(commit),
|
|
gitMirror: compactGitMirrorEvidence(gitMirror),
|
|
refresh,
|
|
closeout: latest,
|
|
polls,
|
|
elapsedMs: Date.now() - startedAt,
|
|
writesState: false,
|
|
contract: {
|
|
gate: "argo-runtime-only",
|
|
excludes: ["git-mirror-flush", "state-write"],
|
|
expectation: "Argo refresh and runtime readiness are independently triggerable; git-mirror post-flush is validated by the git-mirror-flush gate.",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function runtimeCloseoutStatus(commit) {
|
|
const pipelineRunName = commit && pipelineRunPrefix ? `${pipelineRunPrefix}-${commit.slice(0, 12)}` : "";
|
|
const pipelineRun = pipelineRunName && tektonNamespace
|
|
? pipelineRunStatus(await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/pipelineruns/${encodeURIComponent(pipelineRunName)}`, false))
|
|
: { present: null, succeeded: null, reason: "tekton-not-configured" };
|
|
const argo = await argoSummary();
|
|
const runtime = await runtimeSummary(commit);
|
|
return {
|
|
ok: source.snapshotReady === true && pipelineRun.succeeded === true && argo.ready === true && runtime.ready === true && runtime.aligned === true,
|
|
source,
|
|
pipelineRun,
|
|
argo,
|
|
runtime,
|
|
};
|
|
}
|
|
|
|
async function argoRefresh() {
|
|
if (!argoNamespace || !argoApplication) return { ok: true, skipped: true, reason: "argo-not-configured" };
|
|
const path = `/apis/argoproj.io/v1alpha1/namespaces/${encodeURIComponent(argoNamespace)}/applications/${encodeURIComponent(argoApplication)}`;
|
|
const patched = await patchJson(path, { metadata: { annotations: { "argocd.argoproj.io/refresh": "hard" } } });
|
|
return {
|
|
ok: patched !== null,
|
|
namespace: argoNamespace,
|
|
application: argoApplication,
|
|
refresh: "hard",
|
|
resourceVersion: str(patched?.metadata?.resourceVersion),
|
|
statusAuthority: "kubernetes-api-serviceaccount",
|
|
parsedDownstreamCliOutput: false,
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function readReuseConfig(commit) {
|
|
if (!commit || !sourceStageRef) return { present: false, reason: "source-commit-missing" };
|
|
try {
|
|
const text = execFileSync("git", [`--git-dir=${repoPath}`, "show", `${sourceStageRef}:gitops/reuse.ymal`], { encoding: "utf8", maxBuffer: 256 * 1024 });
|
|
const parsed = parseRuntimeReuseConfig(text, { sourceCommit: commit, stageRef: sourceStageRef });
|
|
const summary = summarizeRuntimeReuseConfig(parsed);
|
|
return {
|
|
...summary,
|
|
serviceSpecs: parsed.services,
|
|
bytes: Buffer.byteLength(text, "utf8"),
|
|
sha256: summary.sha256 ?? createHash("sha256").update(text).digest("hex"),
|
|
};
|
|
} catch (error) {
|
|
return { present: false, path: "gitops/reuse.ymal", reason: shortText(error?.message || String(error)) };
|
|
}
|
|
}
|
|
|
|
function compactReuseEvidence(reuse) {
|
|
return {
|
|
ok: reuse.ok === true,
|
|
present: reuse.present === true,
|
|
path: reuse.path || "gitops/reuse.ymal",
|
|
sha256: shortFingerprint(reuse.sha256),
|
|
serviceCount: reuse.serviceCount ?? null,
|
|
errors: Array.isArray(reuse.errors) ? reuse.errors.slice(0, 3) : [],
|
|
bytes: reuse.bytes ?? null,
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function compactGitMirrorEvidence(value) {
|
|
return {
|
|
ok: value.ok === true,
|
|
sourceSnapshotReady: value.sourceSnapshotReady === true,
|
|
githubInSync: value.githubInSync,
|
|
pendingFlush: value.pendingFlush,
|
|
};
|
|
}
|
|
|
|
function boundedDecisions(decisions) {
|
|
return decisions.slice(0, 16).map((item) => ({
|
|
serviceId: item.serviceId,
|
|
sourceIdentity: compactIdentity(item.sourceIdentity),
|
|
envIdentity: compactIdentity(item.envIdentity),
|
|
runtimeReuse: compactReuseHit(item.runtimeReuse),
|
|
envReuse: compactReuseHit(item.envReuse),
|
|
skipImageBuild: item.skipImageBuild,
|
|
buildDecision: item.buildDecision,
|
|
reusableImageRef: item.reusableImageRef,
|
|
reusableImageRefKnown: item.reusableImageRefKnown,
|
|
reason: item.reason,
|
|
}));
|
|
}
|
|
|
|
function compactIdentity(value) {
|
|
return {
|
|
configured: value.configured,
|
|
pathCount: value.pathCount,
|
|
hit: value.hit,
|
|
status: value.status,
|
|
missing: {
|
|
current: value.currentMissingCount,
|
|
previous: value.previousMissingCount,
|
|
},
|
|
};
|
|
}
|
|
|
|
function compactReuseHit(value) {
|
|
return {
|
|
enabled: value.enabled,
|
|
hit: value.hit,
|
|
};
|
|
}
|
|
|
|
function gitMirrorSummary(commit) {
|
|
const localSource = rev(`refs/heads/${sourceBranch}`);
|
|
const githubSource = rev(`refs/mirror-stage/heads/${sourceBranch}`);
|
|
const localGitops = gitopsBranch ? rev(`refs/heads/${gitopsBranch}`) : null;
|
|
const githubGitops = gitopsBranch ? rev(`refs/mirror-stage/heads/${gitopsBranch}`) : null;
|
|
return {
|
|
ok: Boolean(localSource) && source.snapshotReady && (!gitopsBranch || !localGitops || localGitops === githubGitops),
|
|
localSource,
|
|
githubSource,
|
|
sourceStageRef,
|
|
sourceSnapshot,
|
|
sourceSnapshotReady: source.snapshotReady,
|
|
gitopsBranch: gitopsBranch || null,
|
|
localGitops,
|
|
githubGitops,
|
|
pendingFlush: gitopsBranch ? Boolean(localGitops && localGitops !== githubGitops) : null,
|
|
githubInSync: gitopsBranch ? Boolean(!localGitops || localGitops === githubGitops) : null,
|
|
};
|
|
}
|
|
|
|
async function argoSummary() {
|
|
if (!argoNamespace || !argoApplication) return { ready: null, reason: "argo-not-configured" };
|
|
const app = await getJson(`/apis/argoproj.io/v1alpha1/namespaces/${encodeURIComponent(argoNamespace)}/applications/${encodeURIComponent(argoApplication)}`, false);
|
|
const sync = app?.status?.sync || {};
|
|
const health = app?.status?.health || {};
|
|
const op = app?.status?.operationState || {};
|
|
const problemWorkloads = await argoProblemWorkloadSummaries(app);
|
|
return {
|
|
name: argoApplication,
|
|
namespace: argoNamespace,
|
|
syncStatus: str(sync.status),
|
|
healthStatus: str(health.status),
|
|
revision: str(sync.revision),
|
|
operationPhase: str(op.phase),
|
|
operationMessage: str(op.message),
|
|
conditions: compactArgoConditions(app?.status?.conditions),
|
|
nonReadyResources: compactArgoNonReadyResources(app?.status?.resources),
|
|
syncResultResources: compactArgoSyncResultResources(op?.syncResult?.resources),
|
|
problemWorkloads,
|
|
ready: sync.status === "Synced" && health.status === "Healthy",
|
|
};
|
|
}
|
|
|
|
async function argoProblemWorkloadSummaries(app) {
|
|
const resources = Array.isArray(app?.status?.resources) ? app.status.resources : [];
|
|
const syncResources = Array.isArray(app?.status?.operationState?.syncResult?.resources) ? app.status.operationState.syncResult.resources : [];
|
|
const names = uniqueStrings([
|
|
...resources
|
|
.filter((item) => item?.kind === "Deployment" && (item.status === "OutOfSync" || (item.health?.status && item.health.status !== "Healthy")))
|
|
.map((item) => str(item.name))
|
|
.filter(Boolean),
|
|
...syncResources
|
|
.filter((item) => item?.kind === "Deployment" && problemArgoSyncResource(item))
|
|
.map((item) => str(item.name))
|
|
.filter(Boolean),
|
|
]).slice(0, 8);
|
|
const rows = [];
|
|
for (const name of names) rows.push(await deploymentProblemSummary(runtimeNamespace || "default", name));
|
|
return rows;
|
|
}
|
|
|
|
async function deploymentProblemSummary(namespace, name) {
|
|
const deployment = await getJson(`/apis/apps/v1/namespaces/${encodeURIComponent(namespace)}/deployments/${encodeURIComponent(name)}`, false);
|
|
const selector = deploymentSelector(deployment);
|
|
const pods = selector ? await getJson(`/api/v1/namespaces/${encodeURIComponent(namespace)}/pods?labelSelector=${encodeURIComponent(selector)}`, false) : null;
|
|
const conditions = Array.isArray(deployment?.status?.conditions)
|
|
? deployment.status.conditions
|
|
.filter((item) => item?.status !== "True" || item?.type === "Progressing")
|
|
.slice(-4)
|
|
.map((item) => ({
|
|
type: str(item?.type),
|
|
status: str(item?.status),
|
|
reason: str(item?.reason),
|
|
message: shortText(str(item?.message) || ""),
|
|
}))
|
|
: [];
|
|
return {
|
|
kind: "Deployment",
|
|
namespace,
|
|
name,
|
|
desired: deployment?.spec?.replicas ?? 1,
|
|
readyReplicas: deployment?.status?.readyReplicas ?? 0,
|
|
updatedReplicas: deployment?.status?.updatedReplicas ?? 0,
|
|
unavailableReplicas: deployment?.status?.unavailableReplicas ?? null,
|
|
conditions,
|
|
pods: compactProblemPods(pods),
|
|
};
|
|
}
|
|
|
|
function deploymentSelector(deployment) {
|
|
const labels = deployment?.spec?.selector?.matchLabels;
|
|
if (!labels || typeof labels !== "object" || Array.isArray(labels)) return null;
|
|
const pairs = Object.entries(labels)
|
|
.filter((entry) => typeof entry[0] === "string" && typeof entry[1] === "string")
|
|
.map(([key, value]) => `${key}=${value}`);
|
|
return pairs.length === 0 ? null : pairs.join(",");
|
|
}
|
|
|
|
function compactProblemPods(list) {
|
|
const items = Array.isArray(list?.items) ? list.items : [];
|
|
return items.slice(0, 6).map((pod) => {
|
|
const statuses = Array.isArray(pod?.status?.containerStatuses) ? pod.status.containerStatuses : [];
|
|
return {
|
|
name: str(pod?.metadata?.name),
|
|
phase: str(pod?.status?.phase),
|
|
ready: statuses.every((item) => item?.ready === true),
|
|
containers: statuses.slice(0, 4).map((item) => ({
|
|
name: str(item?.name),
|
|
ready: item?.ready === true,
|
|
restartCount: typeof item?.restartCount === "number" ? item.restartCount : null,
|
|
waitingReason: str(item?.state?.waiting?.reason),
|
|
waitingMessage: shortText(str(item?.state?.waiting?.message) || ""),
|
|
terminatedReason: str(item?.lastState?.terminated?.reason) || str(item?.state?.terminated?.reason),
|
|
exitCode: typeof item?.lastState?.terminated?.exitCode === "number" ? item.lastState.terminated.exitCode : typeof item?.state?.terminated?.exitCode === "number" ? item.state.terminated.exitCode : null,
|
|
})),
|
|
};
|
|
});
|
|
}
|
|
|
|
function compactArgoConditions(value) {
|
|
return Array.isArray(value)
|
|
? value.slice(0, 5).map((item) => ({
|
|
type: str(item?.type),
|
|
message: shortText(str(item?.message) || ""),
|
|
lastTransitionTime: str(item?.lastTransitionTime),
|
|
}))
|
|
: [];
|
|
}
|
|
|
|
function compactArgoNonReadyResources(value) {
|
|
return Array.isArray(value)
|
|
? value
|
|
.filter((item) => item?.health?.status && item.health.status !== "Healthy")
|
|
.slice(0, 5)
|
|
.map((item) => ({
|
|
kind: str(item?.kind),
|
|
namespace: str(item?.namespace),
|
|
name: str(item?.name),
|
|
status: str(item?.status),
|
|
healthStatus: str(item?.health?.status),
|
|
healthMessage: shortText(str(item?.health?.message) || ""),
|
|
}))
|
|
: [];
|
|
}
|
|
|
|
function compactArgoSyncResultResources(value) {
|
|
return Array.isArray(value)
|
|
? value
|
|
.filter(problemArgoSyncResource)
|
|
.slice(0, 8)
|
|
.map((item) => ({
|
|
group: str(item?.group),
|
|
kind: str(item?.kind),
|
|
namespace: str(item?.namespace),
|
|
name: str(item?.name),
|
|
status: str(item?.status),
|
|
hookPhase: str(item?.hookPhase),
|
|
syncPhase: str(item?.syncPhase),
|
|
message: shortText(str(item?.message) || ""),
|
|
}))
|
|
: [];
|
|
}
|
|
|
|
function problemArgoSyncResource(item) {
|
|
const message = String(item?.message || "");
|
|
return (item?.status && item.status !== "Synced")
|
|
|| (item?.hookPhase && item.hookPhase !== "Succeeded")
|
|
|| /fail|error|backoff|forbidden|invalid|denied|exceeded/iu.test(message);
|
|
}
|
|
|
|
async function runtimeSummary(expected) {
|
|
if (!runtimeNamespace || workloads.length === 0) return { ready: null, aligned: null, reason: "runtime-not-configured" };
|
|
const rows = [];
|
|
for (const workload of workloads) {
|
|
const resource = workload.kind === "StatefulSet" ? "statefulsets" : "deployments";
|
|
const item = await getJson(`/apis/apps/v1/namespaces/${encodeURIComponent(runtimeNamespace)}/${resource}/${encodeURIComponent(workload.name)}`, false);
|
|
rows.push(workloadSummary(workload, item, expected));
|
|
}
|
|
return {
|
|
namespace: runtimeNamespace,
|
|
expectedSha: shortSha(expected),
|
|
ready: rows.length > 0 && rows.every((row) => row.ready === true),
|
|
aligned: rows.length > 0 && rows.every((row) => row.aligned === true),
|
|
workloads: rows,
|
|
};
|
|
}
|
|
|
|
async function healthSummary() {
|
|
if (!healthUrl) return { ok: null, reason: "health-url-not-configured" };
|
|
const targets = healthProbeTargets(healthUrl);
|
|
const probes = [];
|
|
for (const url of targets) probes.push(await httpProbe(url));
|
|
return { ok: probes.every((probe) => probe.ok), probes };
|
|
}
|
|
|
|
function healthProbeTargets(value) {
|
|
const trimmed = value.replace(/\/+$/u, "");
|
|
try {
|
|
const parsed = new URL(trimmed);
|
|
const pathname = parsed.pathname.replace(/\/+$/u, "");
|
|
if (pathname.endsWith("/health") || pathname.endsWith("/api/health") || pathname.endsWith("/health/readiness") || pathname.endsWith("/health/live")) {
|
|
return [trimmed];
|
|
}
|
|
} catch {
|
|
// Fall back to the historical base-url contract when HEALTH_URL is not a full URL.
|
|
}
|
|
return [`${trimmed}/health/readiness`, `${trimmed}/health/live`];
|
|
}
|
|
|
|
function workloadSummary(spec, item, expected) {
|
|
const status = item?.status || {};
|
|
const desired = item?.spec?.replicas ?? 1;
|
|
const readyCount = spec.kind === "StatefulSet" ? status.readyReplicas ?? 0 : status.availableReplicas ?? 0;
|
|
const updated = status.updatedReplicas ?? readyCount;
|
|
const commit = workloadCommit(spec, item);
|
|
return {
|
|
kind: spec.kind,
|
|
name: spec.name,
|
|
ready: readyCount >= desired && updated >= desired,
|
|
desired,
|
|
readyReplicas: readyCount,
|
|
updatedReplicas: updated,
|
|
sourceCommit: shortSha(commit),
|
|
aligned: Boolean(expected && commit && commit === expected),
|
|
};
|
|
}
|
|
|
|
function workloadCommit(spec, item) {
|
|
const meta = item?.metadata || {};
|
|
const tmpl = item?.spec?.template || {};
|
|
const podMeta = tmpl.metadata || {};
|
|
for (const key of spec.sourceCommit.labels || []) if (sha(meta.labels?.[key])) return meta.labels[key];
|
|
for (const key of spec.sourceCommit.annotations || []) if (sha(meta.annotations?.[key])) return meta.annotations[key];
|
|
for (const key of spec.sourceCommit.podLabels || []) if (sha(podMeta.labels?.[key])) return podMeta.labels[key];
|
|
for (const key of spec.sourceCommit.podAnnotations || []) if (sha(podMeta.annotations?.[key])) return podMeta.annotations[key];
|
|
for (const envName of spec.sourceCommit.env || []) {
|
|
for (const container of tmpl.spec?.containers || []) {
|
|
for (const env of container.env || []) if (env?.name === envName && sha(env.value)) return env.value;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function pipelineRunStatus(value) {
|
|
const condition = (value?.status?.conditions || []).find((item) => item?.type === "Succeeded") || null;
|
|
return {
|
|
name: str(value?.metadata?.name),
|
|
namespace: str(value?.metadata?.namespace),
|
|
pipelineRefName: str(value?.spec?.pipelineRef?.name),
|
|
present: value !== null,
|
|
succeeded: condition?.status === "True" ? true : condition?.status === "False" ? false : null,
|
|
reason: str(condition?.reason),
|
|
startTime: str(value?.status?.startTime),
|
|
completionTime: str(value?.status?.completionTime),
|
|
durationSeconds: durationSeconds(value?.status?.startTime, value?.status?.completionTime),
|
|
};
|
|
}
|
|
|
|
function taskRunsSummary(list) {
|
|
const items = Array.isArray(list?.items) ? list.items : [];
|
|
const rows = items.map((item) => {
|
|
const condition = (item?.status?.conditions || []).find((entry) => entry?.type === "Succeeded") || {};
|
|
return {
|
|
name: str(item?.metadata?.name),
|
|
taskName: str(item?.metadata?.labels?.["tekton.dev/pipelineTask"]) || str(item?.spec?.taskRef?.name),
|
|
status: str(condition.status) || "Unknown",
|
|
reason: str(condition.reason),
|
|
startTime: str(item?.status?.startTime),
|
|
completionTime: str(item?.status?.completionTime),
|
|
durationSeconds: durationSeconds(item?.status?.startTime, item?.status?.completionTime),
|
|
};
|
|
});
|
|
const failed = rows.filter((item) => item.status === "False");
|
|
const active = rows.filter((item) => item.status !== "True" && item.status !== "False");
|
|
const slow = rows
|
|
.filter((item) => typeof item.durationSeconds === "number" && item.durationSeconds >= slowTaskSeconds)
|
|
.sort((left, right) => (right.durationSeconds || 0) - (left.durationSeconds || 0));
|
|
const timeline = rows.slice().sort(compareTaskRunRows);
|
|
const performance = taskRunPerformance(rows);
|
|
return {
|
|
count: rows.length,
|
|
slowThresholdSeconds: slowTaskSeconds,
|
|
failedCount: failed.length,
|
|
activeCount: active.length,
|
|
slowCount: slow.length,
|
|
failedItems: failed.slice(0, 6).map(compactNamedTaskRunRow),
|
|
activeItems: active.slice(0, 6).map(compactNamedTaskRunRow),
|
|
slowItems: slow.slice(0, 6).map(compactNamedTaskRunRow),
|
|
timeline: timeline.slice(0, 16).map((item) => compactTimelineTaskRunRow(item, performance.firstStart)),
|
|
timelineTruncated: timeline.length > 16,
|
|
performance,
|
|
};
|
|
}
|
|
|
|
function taskRunPerformance(rows) {
|
|
const starts = rows.map((item) => Date.parse(item.startTime || "")).filter((value) => Number.isFinite(value));
|
|
const finishes = rows.map((item) => Date.parse(item.completionTime || "")).filter((value) => Number.isFinite(value));
|
|
const durations = rows.map((item) => item.durationSeconds).filter((value) => typeof value === "number");
|
|
const firstStart = starts.length === 0 ? null : new Date(Math.min(...starts)).toISOString();
|
|
const lastCompletion = finishes.length === 0 ? null : new Date(Math.max(...finishes)).toISOString();
|
|
return {
|
|
firstStart,
|
|
lastCompletion,
|
|
spanSeconds: firstStart && lastCompletion ? durationSeconds(firstStart, lastCompletion) : null,
|
|
taskDurationSumSeconds: durations.length === 0 ? null : Math.round(durations.reduce((sum, value) => sum + value, 0) * 10) / 10,
|
|
maxTaskDurationSeconds: durations.length === 0 ? null : Math.max(...durations),
|
|
};
|
|
}
|
|
|
|
function compareTaskRunRows(left, right) {
|
|
const leftTime = Date.parse(left.startTime || "");
|
|
const rightTime = Date.parse(right.startTime || "");
|
|
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) return leftTime - rightTime;
|
|
if (Number.isFinite(leftTime)) return -1;
|
|
if (Number.isFinite(rightTime)) return 1;
|
|
return String(left.name || "").localeCompare(String(right.name || ""));
|
|
}
|
|
|
|
function compactNamedTaskRunRow(item) {
|
|
return {
|
|
name: item.name,
|
|
taskName: item.taskName,
|
|
status: item.status,
|
|
reason: item.reason,
|
|
durationSeconds: item.durationSeconds,
|
|
};
|
|
}
|
|
|
|
function compactTimelineTaskRunRow(item, firstStart) {
|
|
return {
|
|
taskName: item.taskName,
|
|
status: item.status,
|
|
startOffsetSeconds: firstStart && item.startTime ? durationSeconds(firstStart, item.startTime) : null,
|
|
durationSeconds: item.durationSeconds,
|
|
};
|
|
}
|
|
|
|
async function getJson(path, required) {
|
|
const host = process.env.KUBERNETES_SERVICE_HOST;
|
|
const port = Number(process.env.KUBERNETES_SERVICE_PORT || "443");
|
|
const tokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token";
|
|
const caPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt";
|
|
if (!host || !existsSync(tokenPath) || !existsSync(caPath)) fail("kubernetes serviceaccount is unavailable");
|
|
const token = readFileSync(tokenPath, "utf8").trim();
|
|
const ca = readFileSync(caPath);
|
|
return await new Promise((resolve, reject) => {
|
|
const req = https.request({ host, port, path, method: "GET", ca, headers: { authorization: `Bearer ${token}` } }, (res) => {
|
|
let body = "";
|
|
res.setEncoding("utf8");
|
|
res.on("data", (chunk) => { body += chunk; });
|
|
res.on("end", () => {
|
|
if (res.statusCode === 404 && !required) return resolve(null);
|
|
if ((res.statusCode || 0) < 200 || (res.statusCode || 0) >= 300) return reject(new Error(shortText(body || `kube api ${res.statusCode}`)));
|
|
try { resolve(JSON.parse(body)); } catch (error) { reject(error); }
|
|
});
|
|
});
|
|
req.on("error", reject);
|
|
req.end();
|
|
}).catch((error) => {
|
|
errors.push(`${path}: ${shortText(error?.message || String(error))}`);
|
|
return null;
|
|
});
|
|
}
|
|
|
|
async function patchJson(path, value) {
|
|
const host = process.env.KUBERNETES_SERVICE_HOST;
|
|
const port = Number(process.env.KUBERNETES_SERVICE_PORT || "443");
|
|
const tokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token";
|
|
const caPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt";
|
|
if (!host || !existsSync(tokenPath) || !existsSync(caPath)) fail("kubernetes serviceaccount is unavailable");
|
|
const token = readFileSync(tokenPath, "utf8").trim();
|
|
const ca = readFileSync(caPath);
|
|
const payload = JSON.stringify(value);
|
|
return await new Promise((resolve, reject) => {
|
|
const req = https.request({
|
|
host,
|
|
port,
|
|
path,
|
|
method: "PATCH",
|
|
ca,
|
|
headers: {
|
|
authorization: `Bearer ${token}`,
|
|
"content-type": "application/merge-patch+json",
|
|
"content-length": Buffer.byteLength(payload),
|
|
},
|
|
}, (res) => {
|
|
let body = "";
|
|
res.setEncoding("utf8");
|
|
res.on("data", (chunk) => { body += chunk; });
|
|
res.on("end", () => {
|
|
if ((res.statusCode || 0) < 200 || (res.statusCode || 0) >= 300) return reject(new Error(shortText(body || `kube api ${res.statusCode}`)));
|
|
try { resolve(JSON.parse(body)); } catch (error) { reject(error); }
|
|
});
|
|
});
|
|
req.on("error", reject);
|
|
req.write(payload);
|
|
req.end();
|
|
}).catch((error) => {
|
|
errors.push(`${path}: ${shortText(error?.message || String(error))}`);
|
|
return null;
|
|
});
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function httpProbe(url) {
|
|
const client = url.startsWith("https:") ? https : http;
|
|
const started = Date.now();
|
|
return await new Promise((resolve) => {
|
|
const req = client.get(url, { timeout: healthTimeoutMs }, (res) => {
|
|
res.resume();
|
|
res.on("end", () => resolve({ url, ok: (res.statusCode || 0) >= 200 && (res.statusCode || 0) < 300, statusCode: res.statusCode || null, elapsedMs: Date.now() - started }));
|
|
});
|
|
req.on("timeout", () => { req.destroy(); resolve({ url, ok: false, statusCode: null, elapsedMs: Date.now() - started, reason: "timeout" }); });
|
|
req.on("error", (error) => resolve({ url, ok: false, statusCode: null, elapsedMs: Date.now() - started, reason: shortText(error?.message || String(error)) }));
|
|
});
|
|
}
|
|
|
|
function parseWorkloads(value) {
|
|
if (!value) return [];
|
|
try {
|
|
const parsed = JSON.parse(Buffer.from(value, "base64").toString("utf8"));
|
|
return Array.isArray(parsed) ? parsed.filter((item) => item && typeof item === "object").slice(0, 8) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function optionalPositiveIntEnv(name) {
|
|
const raw = process.env[name] || "";
|
|
if (!raw) return null;
|
|
const value = Number.parseInt(raw, 10);
|
|
if (!Number.isInteger(value) || value <= 0) fail(`${name} must be a positive integer`);
|
|
return value;
|
|
}
|
|
|
|
function rev(ref) {
|
|
try {
|
|
const out = execFileSync("git", [`--git-dir=${repoPath}`, "rev-parse", "--verify", `${ref}^{commit}`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
return sha(out) ? out : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function durationSeconds(start, finish) {
|
|
if (!start || !finish) return null;
|
|
const value = (Date.parse(finish) - Date.parse(start)) / 1000;
|
|
return Number.isFinite(value) && value >= 0 ? Math.round(value * 10) / 10 : null;
|
|
}
|
|
|
|
function notConfigured(name) {
|
|
return { ok: false, reason: `${name}-not-configured` };
|
|
}
|
|
|
|
function str(value) {
|
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
}
|
|
|
|
function parseJson(value) {
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function numberOrNull(value) {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
function strings(value) {
|
|
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
}
|
|
|
|
function uniqueStrings(value) {
|
|
return [...new Set(value.filter((item) => typeof item === "string" && item.length > 0))];
|
|
}
|
|
|
|
function sha(value) {
|
|
return typeof value === "string" && /^[0-9a-f]{40}$/iu.test(value);
|
|
}
|
|
|
|
function shortSha(value) {
|
|
return sha(value) ? value.slice(0, 12) : null;
|
|
}
|
|
|
|
function shortFingerprint(value) {
|
|
return typeof value === "string" && value.length >= 12 ? value.slice(0, 12) : null;
|
|
}
|
|
|
|
function shortText(value) {
|
|
const text = String(value || "").replace(/\s+/gu, " ").trim();
|
|
return text.length <= 300 ? text : text.slice(0, 300);
|
|
}
|
|
|
|
function requiredEnv(name) {
|
|
const value = process.env[name];
|
|
if (!value) fail(`${name} is required`);
|
|
return value;
|
|
}
|
|
|
|
function requiredPositiveIntEnv(name) {
|
|
const value = requiredEnv(name);
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) fail(`${name} must be a positive integer`);
|
|
return parsed;
|
|
}
|
|
|
|
function fail(message) {
|
|
console.error(message);
|
|
process.exit(1);
|
|
}
|