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; }