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 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 timedOut = !complete && !failed; const output = { ok: Boolean(complete) && !timedOut, completed: Boolean(complete), failed: Boolean(failed), timedOut, created, reused, replacedFailed, jobName, namespace, polls, elapsedMs: Date.now() - startedAt, conditionReason: complete?.reason || failed?.reason || null, conditionMessage: complete?.message || failed?.message || null, logsTail: logs || null, 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; }