fix: speed up native branch follower refresh
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
// SPEC: PJ2026-01060703 CI/CD branch follower native HWLAB control-plane refresh.
|
||||
// Responsibility: render and apply the HWLAB node Tekton Pipeline from a k8s git-mirror snapshot.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -17,6 +17,7 @@ const overlay = JSON.parse(Buffer.from(requiredEnv("HWLAB_RENDER_OVERLAY_B64"),
|
||||
const workDir = mkdtempSync(path.join(tmpdir(), `hwlab-control-plane-${sourceCommit.slice(0, 12)}-`));
|
||||
const repoDir = path.join(workDir, "repo");
|
||||
const renderDir = path.join(workDir, "render");
|
||||
const depsDir = path.join(workDir, "deps");
|
||||
|
||||
try {
|
||||
checkoutSnapshot();
|
||||
@@ -39,6 +40,8 @@ function checkoutSnapshot() {
|
||||
}
|
||||
|
||||
function prepareYamlDependency() {
|
||||
mkdirSync(depsDir, { recursive: true });
|
||||
writeFileSync(path.join(depsDir, "package.json"), JSON.stringify({ private: true, dependencies: {} }), "utf8");
|
||||
if (canResolveYaml()) return;
|
||||
const registry = requiredOverlayString("npmRegistry");
|
||||
const timeoutMs = String(requiredPositiveNumber("npmFetchTimeoutMs"));
|
||||
@@ -51,10 +54,10 @@ function prepareYamlDependency() {
|
||||
npm_config_fetch_retries: retries,
|
||||
});
|
||||
if (commandExists("bun")) {
|
||||
const result = runOptional("bun", ["add", "--no-save", "--ignore-scripts", "--registry", registry, yamlSpec], { cwd: repoDir, env });
|
||||
const result = runOptional("bun", ["add", "--no-save", "--ignore-scripts", "--registry", registry, yamlSpec], { cwd: depsDir, env });
|
||||
if (result.status === 0 && canResolveYaml()) return;
|
||||
}
|
||||
run("npm", ["install", "--package-lock=false", "--no-save", "--ignore-scripts", "--no-audit", "--no-fund", "--omit=dev", "--registry", registry, yamlSpec], { cwd: repoDir, env });
|
||||
run("npm", ["install", "--package-lock=false", "--no-save", "--ignore-scripts", "--no-audit", "--no-fund", "--omit=dev", "--registry", registry, yamlSpec], { cwd: depsDir, env });
|
||||
if (!canResolveYaml()) throw new Error("yaml dependency remains unresolved after install");
|
||||
}
|
||||
|
||||
@@ -66,13 +69,13 @@ function yamlDependencySpec() {
|
||||
}
|
||||
|
||||
function canResolveYaml() {
|
||||
const result = runOptional("node", ["-e", "require.resolve('yaml')"], { cwd: repoDir, stdio: "ignore" });
|
||||
const result = runOptional("node", ["-e", "require.resolve('yaml')"], { cwd: depsDir, stdio: "ignore" });
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function applyDeployOverlay() {
|
||||
const requireFromRepo = createRequire(path.join(repoDir, "package.json"));
|
||||
const YAML = requireFromRepo("yaml");
|
||||
const requireFromDeps = createRequire(path.join(depsDir, "package.json"));
|
||||
const YAML = requireFromDeps("yaml");
|
||||
const deployPath = path.join(repoDir, "deploy/deploy.yaml");
|
||||
const doc = YAML.parse(readFileSync(deployPath, "utf8"));
|
||||
doc.nodes = doc.nodes || {};
|
||||
@@ -155,13 +158,13 @@ function renderEnv(extra = {}) {
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = runOptional(command, args, options);
|
||||
if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed with exit ${result.status}`);
|
||||
if (result.status !== 0) throw new Error(failedCommandMessage(command, args, result));
|
||||
return result;
|
||||
}
|
||||
|
||||
function capture(command, args) {
|
||||
const result = runOptional(command, args, { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
|
||||
if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed with exit ${result.status}`);
|
||||
if (result.status !== 0) throw new Error(failedCommandMessage(command, args, result));
|
||||
return result.stdout || "";
|
||||
}
|
||||
|
||||
@@ -178,6 +181,17 @@ function commandExists(command) {
|
||||
return runOptional("sh", ["-lc", `command -v ${command} >/dev/null 2>&1`], { cwd: repoDir, stdio: "ignore" }).status === 0;
|
||||
}
|
||||
|
||||
function failedCommandMessage(command, args, result) {
|
||||
const signal = result.signal ? ` signal=${result.signal}` : "";
|
||||
const error = result.error ? ` error=${result.error.message}` : "";
|
||||
const stderr = typeof result.stderr === "string" && result.stderr.length > 0 ? ` stderr=${tail(result.stderr, 1000)}` : "";
|
||||
return `${command} ${args.join(" ")} failed with exit ${result.status}${signal}${error}${stderr}`;
|
||||
}
|
||||
|
||||
function tail(value, maxChars) {
|
||||
return value.length <= maxChars ? value : value.slice(value.length - maxChars);
|
||||
}
|
||||
|
||||
function requiredEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`${name} is required`);
|
||||
|
||||
@@ -4,7 +4,8 @@ import https from "node:https";
|
||||
const namespace = process.env.NAMESPACE || "";
|
||||
const jobName = process.env.JOB_NAME || "";
|
||||
const logContainer = process.env.LOG_CONTAINER || "";
|
||||
const timeoutSeconds = Number(process.env.TIMEOUT_SECONDS || "60");
|
||||
const timeoutSeconds = requiredPositiveNumber("TIMEOUT_SECONDS");
|
||||
const pollIntervalSeconds = requiredPositiveNumber("POLL_INTERVAL_SECONDS");
|
||||
const host = process.env.KUBERNETES_SERVICE_HOST;
|
||||
const port = Number(process.env.KUBERNETES_SERVICE_PORT || "443");
|
||||
const token = readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8").trim();
|
||||
@@ -75,7 +76,23 @@ async function logsTail() {
|
||||
|
||||
let created = false;
|
||||
let reused = false;
|
||||
const existing = await getJob();
|
||||
let replacedFailed = false;
|
||||
let existing = await getJob();
|
||||
if (existing && condition(existing, "Failed")) {
|
||||
await deleteJob();
|
||||
replacedFailed = true;
|
||||
existing = null;
|
||||
const deleteDeadline = Date.now() + timeoutSeconds * 1000;
|
||||
let deleted = false;
|
||||
while (Date.now() <= deleteDeadline) {
|
||||
if (await getJob() === null) {
|
||||
deleted = true;
|
||||
break;
|
||||
}
|
||||
await delay(pollIntervalSeconds * 1000);
|
||||
}
|
||||
if (!deleted) throw new Error(`timed out deleting failed job ${jobName}`);
|
||||
}
|
||||
if (existing) {
|
||||
reused = true;
|
||||
} else {
|
||||
@@ -89,7 +106,7 @@ if (existing) {
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const deadline = startedAt + Math.max(1, timeoutSeconds) * 1000;
|
||||
const deadline = startedAt + timeoutSeconds * 1000;
|
||||
let polls = 0;
|
||||
let latest = await getJob();
|
||||
while (Date.now() <= deadline) {
|
||||
@@ -97,7 +114,7 @@ while (Date.now() <= deadline) {
|
||||
const failed = condition(latest, "Failed");
|
||||
if (complete || failed) break;
|
||||
polls += 1;
|
||||
await delay(2000);
|
||||
await delay(pollIntervalSeconds * 1000);
|
||||
latest = await getJob();
|
||||
}
|
||||
|
||||
@@ -112,6 +129,7 @@ const output = {
|
||||
timedOut,
|
||||
created,
|
||||
reused,
|
||||
replacedFailed,
|
||||
jobName,
|
||||
namespace,
|
||||
polls,
|
||||
@@ -125,3 +143,15 @@ const output = {
|
||||
};
|
||||
process.stdout.write(JSON.stringify(output));
|
||||
if (!output.ok) process.exit(1);
|
||||
|
||||
async function deleteJob() {
|
||||
const result = await request("DELETE", `/apis/batch/v1/namespaces/${encodeURIComponent(namespace)}/jobs/${encodeURIComponent(jobName)}?propagationPolicy=Background`);
|
||||
if (result.status === 404) return;
|
||||
if (result.status < 200 || result.status >= 300) throw new Error(result.text || `kube api DELETE job status ${result.status}`);
|
||||
}
|
||||
|
||||
function requiredPositiveNumber(name) {
|
||||
const value = Number(process.env[name]);
|
||||
if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import https from "node:https";
|
||||
const namespace = process.env.NAMESPACE || "";
|
||||
const pipelineRun = process.env.PIPELINERUN || "";
|
||||
const shouldWait = process.env.WAIT === "true";
|
||||
const timeoutSeconds = Number(process.env.TIMEOUT_SECONDS || "60");
|
||||
const timeoutSeconds = requiredPositiveNumber("TIMEOUT_SECONDS");
|
||||
const pollIntervalSeconds = requiredPositiveNumber("POLL_INTERVAL_SECONDS");
|
||||
const host = process.env.KUBERNETES_SERVICE_HOST;
|
||||
const port = Number(process.env.KUBERNETES_SERVICE_PORT || "443");
|
||||
const token = readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8").trim();
|
||||
@@ -89,7 +90,7 @@ if (latest.found) {
|
||||
latest = await getPipelineRun();
|
||||
}
|
||||
|
||||
const deadline = Date.now() + Math.max(1, timeoutSeconds) * 1000;
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
let polls = 0;
|
||||
while (shouldWait) {
|
||||
const condition = succeededCondition(latest.object);
|
||||
@@ -97,7 +98,7 @@ while (shouldWait) {
|
||||
if (Date.now() >= deadline) break;
|
||||
polls += 1;
|
||||
process.stderr.write(JSON.stringify({ event: "cicd.branch-follower.native-tekton.wait", pipelineRun, namespace, polls, conditionStatus: condition?.status || null, valuesRedacted: true }) + "\n");
|
||||
await delay(2000);
|
||||
await delay(pollIntervalSeconds * 1000);
|
||||
latest = await getPipelineRun();
|
||||
}
|
||||
|
||||
@@ -125,3 +126,9 @@ const output = {
|
||||
};
|
||||
process.stdout.write(JSON.stringify(output));
|
||||
if (failed) process.exit(1);
|
||||
|
||||
function requiredPositiveNumber(name) {
|
||||
const value = Number(process.env[name]);
|
||||
if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`);
|
||||
return value;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user