187 lines
6.9 KiB
JavaScript
187 lines
6.9 KiB
JavaScript
import { readFileSync } from "node:fs";
|
|
import https from "node:https";
|
|
|
|
const namespace = process.env.NAMESPACE || "";
|
|
const jobName = process.env.JOB_NAME || "";
|
|
const logContainer = process.env.LOG_CONTAINER || "";
|
|
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();
|
|
const ca = readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt");
|
|
const manifest = JSON.parse(Buffer.from(readFileSync(0, "utf8").replace(/\s+/g, ""), "base64").toString("utf8"));
|
|
|
|
function request(method, path, body, contentType = "application/json") {
|
|
return new Promise((resolve, reject) => {
|
|
const headers = { authorization: `Bearer ${token}` };
|
|
const payload = body === undefined ? null : typeof body === "string" ? body : JSON.stringify(body);
|
|
if (payload !== null) {
|
|
headers["content-type"] = contentType;
|
|
headers["content-length"] = Buffer.byteLength(payload);
|
|
}
|
|
const req = https.request({ host, port, path, method, ca, headers }, (res) => {
|
|
let text = "";
|
|
res.setEncoding("utf8");
|
|
res.on("data", (chunk) => { text += chunk; });
|
|
res.on("end", () => resolve({ status: res.statusCode || 0, text }));
|
|
});
|
|
req.on("error", reject);
|
|
if (payload !== null) req.write(payload);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function parse(text) {
|
|
try {
|
|
return text ? JSON.parse(text) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function condition(job, type) {
|
|
return (Array.isArray(job?.status?.conditions) ? job.status.conditions : []).find((item) => item?.type === type && item?.status === "True") || null;
|
|
}
|
|
|
|
async function getJob() {
|
|
const result = await request("GET", `/apis/batch/v1/namespaces/${encodeURIComponent(namespace)}/jobs/${encodeURIComponent(jobName)}`);
|
|
if (result.status === 404) return null;
|
|
if (result.status < 200 || result.status >= 300) throw new Error(result.text || `kube api GET job status ${result.status}`);
|
|
return parse(result.text);
|
|
}
|
|
|
|
async function podNames() {
|
|
const selector = encodeURIComponent(`job-name=${jobName}`);
|
|
const result = await request("GET", `/api/v1/namespaces/${encodeURIComponent(namespace)}/pods?labelSelector=${selector}`);
|
|
if (result.status < 200 || result.status >= 300) return [];
|
|
const list = parse(result.text);
|
|
return (Array.isArray(list?.items) ? list.items : []).map((pod) => pod?.metadata?.name).filter(Boolean);
|
|
}
|
|
|
|
async function logsTail() {
|
|
const names = await podNames();
|
|
let combined = "";
|
|
for (const pod of names.slice(-2)) {
|
|
const container = logContainer ? `&container=${encodeURIComponent(logContainer)}` : "";
|
|
const result = await request("GET", `/api/v1/namespaces/${encodeURIComponent(namespace)}/pods/${encodeURIComponent(pod)}/log?tailLines=120${container}`);
|
|
if (result.status >= 200 && result.status < 300) combined += `${result.text}\n`;
|
|
}
|
|
return combined.length > 6000 ? combined.slice(-6000) : combined;
|
|
}
|
|
|
|
let created = false;
|
|
let reused = false;
|
|
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 {
|
|
const result = await request("POST", `/apis/batch/v1/namespaces/${encodeURIComponent(namespace)}/jobs`, manifest);
|
|
if (result.status === 409) reused = true;
|
|
else if (result.status >= 200 && result.status < 300) created = true;
|
|
else {
|
|
process.stderr.write(result.text || `kube api POST job status ${result.status}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const startedAt = Date.now();
|
|
const startedAtIso = new Date(startedAt).toISOString();
|
|
const deadline = startedAt + timeoutSeconds * 1000;
|
|
let polls = 0;
|
|
let latest = await getJob();
|
|
while (Date.now() <= deadline) {
|
|
const complete = condition(latest, "Complete");
|
|
const failed = condition(latest, "Failed");
|
|
if (complete || failed) break;
|
|
polls += 1;
|
|
await delay(pollIntervalSeconds * 1000);
|
|
latest = await getJob();
|
|
}
|
|
|
|
const complete = condition(latest, "Complete");
|
|
const failed = condition(latest, "Failed");
|
|
const logs = await logsTail();
|
|
const summary = parseLastJsonSummary(logs);
|
|
const timedOut = !complete && !failed;
|
|
const finishedAt = Date.now();
|
|
const output = {
|
|
ok: Boolean(complete) && !timedOut,
|
|
completed: Boolean(complete),
|
|
failed: Boolean(failed),
|
|
timedOut,
|
|
created,
|
|
reused,
|
|
replacedFailed,
|
|
jobName,
|
|
namespace,
|
|
polls,
|
|
elapsedMs: finishedAt - startedAt,
|
|
startedAt: startedAtIso,
|
|
finishedAt: new Date(finishedAt).toISOString(),
|
|
conditionReason: complete?.reason || failed?.reason || null,
|
|
conditionMessage: complete?.message || failed?.message || null,
|
|
logsTail: logs || null,
|
|
summary,
|
|
statusAuthority: "kubernetes-api-serviceaccount",
|
|
parsedDownstreamCliOutput: false,
|
|
valuesRedacted: true,
|
|
};
|
|
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;
|
|
}
|
|
|
|
function parseLastJsonSummary(text) {
|
|
const lines = String(text || "").split(/\r?\n/u).map((item) => item.trim()).filter(Boolean);
|
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
try {
|
|
const parsed = JSON.parse(lines[index]);
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return compactValue(parsed, 0);
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function compactValue(value, depth) {
|
|
if (typeof value === "string") return value.length <= 240 ? value : `${value.slice(0, 160)} ... ${value.slice(-60)}`;
|
|
if (typeof value !== "object" || value === null) return value;
|
|
if (Array.isArray(value)) return value.slice(0, 8).map((item) => compactValue(item, depth + 1));
|
|
if (depth >= 3) return "[bounded-object]";
|
|
const output = {};
|
|
for (const [key, child] of Object.entries(value).slice(0, 16)) output[key] = compactValue(child, depth + 1);
|
|
return output;
|
|
}
|