import { existsSync, readFileSync } from "node:fs"; import https from "node:https"; import { spawnSync } from "node:child_process"; const namespace = requiredEnv("TEKTON_NAMESPACE"); const query = requiredEnv("TASKRUN_QUERY"); const pipelineRunName = process.env.PIPELINE_RUN_NAME || ""; const pipelineRunPrefix = process.env.PIPELINE_RUN_PREFIX || ""; const logsTailLines = positiveInt(process.env.LOGS_TAIL_LINES, "LOGS_TAIL_LINES"); const maxLogBytes = positiveInt(process.env.MAX_LOG_BYTES, "MAX_LOG_BYTES"); const maxMessageBytes = positiveInt(process.env.MAX_MESSAGE_BYTES, "MAX_MESSAGE_BYTES"); const maxContainers = positiveInt(process.env.MAX_CONTAINERS, "MAX_CONTAINERS"); const useServiceAccount = Boolean(process.env.KUBERNETES_SERVICE_HOST && process.env.KUBERNETES_SERVICE_PORT) && existsSync("/var/run/secrets/kubernetes.io/serviceaccount/token") && existsSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"); class KubeReadError extends Error { constructor(reason, message, details = {}) { super(shortText(message) || reason); this.name = "KubeReadError"; this.reason = reason; this.statusCode = details.statusCode ?? null; this.notFound = details.notFound === true; this.path = details.path || null; } } main().catch((error) => { process.stderr.write(error?.message || String(error)); process.exit(1); }); async function main() { const resolution = await resolveTaskRun(); if (resolution.taskRun === null) { console.log(JSON.stringify({ ok: false, degradedReason: resolution.degradedReason || "taskrun-not-found", message: resolution.message || null, query: { namespace, taskRun: query, pipelineRun: pipelineRunName || null, pipelineRunPrefix: pipelineRunPrefix || null }, candidates: resolution.candidates, resolution: { mode: resolution.mode, bounded: true, namespaceTaskRunList: false, }, statusAuthority: useServiceAccount ? "kubernetes-api-serviceaccount" : "target-node-kubectl-raw", parsedDownstreamCliOutput: false, })); return; } const taskRun = resolution.taskRun; const metadata = taskRun.metadata || {}; const status = taskRun.status || {}; const condition = conditionByType(status, "Succeeded"); const podName = status.podName || await resolvePodName(taskRun); const pod = podName ? await getJson(`/api/v1/namespaces/${encodeURIComponent(namespace)}/pods/${encodeURIComponent(podName)}`, false) : null; const podStatuses = podStatusRows(pod); const taskRunSteps = stepRows(status); const containers = mergeContainerRows(taskRunSteps, podStatuses).slice(0, maxContainers); const logContainers = selectLogContainers(containers); const includeLogTails = process.env.INCLUDE_LOG_TAILS === "true" || condition?.status !== "True"; const logs = []; const perContainerBytes = Math.max(1, Math.floor(maxLogBytes / Math.max(1, logContainers.length))); for (const container of logContainers) { const name = container.containerName || container.name; if (!podName || !name) continue; const read = await readPodLog(podName, name, logsTailLines, perContainerBytes); const text = read.tail || ""; logs.push({ ok: read.ok, degradedReason: read.degradedReason, message: read.message, pod: podName, container: name, lineCount: text.length === 0 ? 0 : text.split(/\r?\n/u).filter((line) => line.length > 0).length, bytes: Buffer.byteLength(text, "utf8"), ...(includeLogTails || read.ok === false ? { tail: text } : {}), nodeCicdTiming: lastNodeCicdTiming(text), }); } const timing = lastTiming(logs); const logFailures = logs.filter((item) => item.ok === false); console.log(JSON.stringify({ ok: logFailures.length === 0, degradedReason: logFailures.length === 0 ? null : "log-read-failed", errors: logFailures.map((item) => ({ pod: item.pod, container: item.container, degradedReason: item.degradedReason, message: item.message, })), taskRun: { name: metadata.name || null, namespace: metadata.namespace || namespace, pipelineRun: metadata.labels?.["tekton.dev/pipelineRun"] || null, pipelineTask: metadata.labels?.["tekton.dev/pipelineTask"] || metadata.labels?.["tekton.dev/task"] || null, podName, condition: { status: condition?.status || null, reason: condition?.reason || null, message: shortText(condition?.message || null), }, startTime: status.startTime || null, completionTime: status.completionTime || null, durationSeconds: durationSeconds(status.startTime, status.completionTime), }, pod: pod === null ? null : { name: pod.metadata?.name || podName, phase: pod.status?.phase || null, containerCount: podStatuses.length, initContainerCount: Array.isArray(pod.status?.initContainerStatuses) ? pod.status.initContainerStatuses.length : 0, startTime: pod.status?.startTime || null, }, containers, logs, nodeCicdTiming: timing, resolution: { mode: resolution.mode, candidates: resolution.candidates, }, statusAuthority: useServiceAccount ? "kubernetes-api-serviceaccount" : "target-node-kubectl-raw", parsedDownstreamCliOutput: false, })); } async function resolveTaskRun() { const direct = await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(namespace)}/taskruns/${encodeURIComponent(query)}`, false); if (direct !== null) return { mode: "direct-name", taskRun: direct, candidates: [] }; if (!pipelineRunName) { const fullNameMiss = pipelineRunPrefix.length > 0 && query.startsWith(`${pipelineRunPrefix}-`); return { mode: fullNameMiss ? "direct-name-not-found" : "pipeline-run-required", degradedReason: fullNameMiss ? "taskrun-not-found" : "pipeline-run-required", message: fullNameMiss ? `TaskRun ${query} was not found in namespace ${namespace}` : "--pipeline-run is required for pipeline-task alias lookup; namespace-wide TaskRun listing is disabled", taskRun: null, candidates: [], }; } const selectors = []; selectors.push(`tekton.dev/pipelineRun=${pipelineRunName},tekton.dev/pipelineTask=${query}`); selectors.push(`tekton.dev/pipelineRun=${pipelineRunName},tekton.dev/task=${query}`); for (const selector of selectors) { const list = await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(namespace)}/taskruns?labelSelector=${encodeURIComponent(selector)}`, false); const candidates = taskRunCandidates(list); const filtered = candidates.filter((item) => pipelineRunPrefix.length === 0 || String(item.pipelineRun || "").startsWith(pipelineRunPrefix)); const picked = filtered[0] || null; if (picked !== null) { const item = await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(namespace)}/taskruns/${encodeURIComponent(picked.name)}`, true); return { mode: "pipeline-task-label", taskRun: item, candidates: filtered.slice(0, 8) }; } if (candidates.length > 0) return { mode: "pipeline-task-label-no-prefix-match", taskRun: null, candidates: candidates.slice(0, 8) }; } return { mode: "not-found", taskRun: null, candidates: [] }; } async function resolvePodName(taskRun) { const uid = taskRun?.metadata?.uid || ""; const name = taskRun?.metadata?.name || query; const selectors = uid ? [`tekton.dev/taskRunUID=${uid}`, `tekton.dev/taskRun=${name}`] : [`tekton.dev/taskRun=${name}`]; for (const selector of selectors) { const list = await getJson(`/api/v1/namespaces/${encodeURIComponent(namespace)}/pods?labelSelector=${encodeURIComponent(selector)}`, false); const items = Array.isArray(list?.items) ? list.items : []; const pod = items[0]; if (pod?.metadata?.name) return pod.metadata.name; } return null; } function taskRunCandidates(list) { const items = Array.isArray(list?.items) ? list.items : []; return items.map((item) => ({ name: item?.metadata?.name || null, pipelineRun: item?.metadata?.labels?.["tekton.dev/pipelineRun"] || null, pipelineTask: item?.metadata?.labels?.["tekton.dev/pipelineTask"] || item?.metadata?.labels?.["tekton.dev/task"] || null, startTime: item?.status?.startTime || item?.metadata?.creationTimestamp || null, status: conditionByType(item?.status || {}, "Succeeded")?.status || null, reason: conditionByType(item?.status || {}, "Succeeded")?.reason || null, })).filter((item) => item.name !== null).sort((left, right) => String(right.startTime || "").localeCompare(String(left.startTime || ""))); } function stepRows(status) { const steps = Array.isArray(status?.steps) ? status.steps : []; return steps.map((step) => ({ source: "taskrun-step", name: step.name || null, containerName: step.container || (step.name ? `step-${step.name}` : null), terminated: compactTerminated(step.terminated), waiting: compactWaiting(step.waiting), running: step.running ? { startedAt: step.running.startedAt || null } : null, })); } function podStatusRows(pod) { const status = pod?.status || {}; return [ ...statusArray(status.initContainerStatuses, "pod-init-container"), ...statusArray(status.containerStatuses, "pod-container"), ...statusArray(status.ephemeralContainerStatuses, "pod-ephemeral-container"), ]; } function statusArray(value, source) { return (Array.isArray(value) ? value : []).map((item) => ({ source, name: item.name || null, containerName: item.name || null, ready: item.ready === true, restartCount: Number.isInteger(item.restartCount) ? item.restartCount : null, terminated: compactTerminated(item.state?.terminated || item.lastState?.terminated), waiting: compactWaiting(item.state?.waiting), running: item.state?.running ? { startedAt: item.state.running.startedAt || null } : null, })); } function mergeContainerRows(steps, podStatuses) { const rows = []; const byName = new Map(podStatuses.map((item) => [item.containerName, item])); for (const step of steps) rows.push({ ...step, ...(byName.get(step.containerName) || {}) }); const seen = new Set(rows.map((item) => item.containerName)); for (const item of podStatuses) { if (!seen.has(item.containerName)) rows.push(item); } return rows.map((item) => ({ source: item.source || null, name: item.name || null, containerName: item.containerName || null, ready: item.ready === undefined ? null : item.ready, restartCount: Number.isInteger(item.restartCount) ? item.restartCount : null, terminated: item.terminated || null, waiting: item.waiting || null, running: item.running || null, })); } function selectLogContainers(containers) { const failed = containers.filter((item) => item.terminated?.exitCode !== null && item.terminated?.exitCode !== 0); const waiting = containers.filter((item) => item.waiting !== null); const step = containers.filter((item) => String(item.containerName || "").startsWith("step-")); const selected = []; for (const item of [...failed, ...waiting, ...step, ...containers]) { if (selected.some((existing) => existing.containerName === item.containerName)) continue; selected.push(item); if (selected.length >= maxContainers) break; } return selected; } async function readPodLog(podName, container, tailLines, limitBytes) { const path = `/api/v1/namespaces/${encodeURIComponent(namespace)}/pods/${encodeURIComponent(podName)}/log?container=${encodeURIComponent(container)}&tailLines=${tailLines}&limitBytes=${limitBytes}`; try { const text = tailBytes(await getText(path, false), limitBytes); return { ok: true, degradedReason: null, message: null, tail: text }; } catch (error) { return { ok: false, degradedReason: isNotFoundError(error) ? "log-not-found" : error instanceof KubeReadError ? error.reason : "log-read-failed", message: shortText(error?.message || String(error)), tail: "", }; } } async function getJson(path, required) { let text = ""; try { text = await getText(path, required); } catch (error) { if (!required && isNotFoundError(error)) return null; throw error; } try { return JSON.parse(text); } catch (error) { throw new KubeReadError("invalid-json", `kube api returned non-JSON for ${path}: ${error?.message || String(error)}`, { path }); } } async function getText(path, required) { if (useServiceAccount) return kubeApiGet(path, required); const result = spawnSync("kubectl", ["get", "--raw", path], { encoding: "utf8", maxBuffer: Math.max(maxLogBytes * 2, 1024 * 1024) }); if (result.error) { throw new KubeReadError("transport-error", `kubectl get --raw transport error for ${path}: ${result.error.message}`, { path }); } if (result.status === 0) return result.stdout; const body = result.stderr || result.stdout || `kubectl get --raw failed with exit ${result.status}`; throw new KubeReadError(isNotFoundText(body) ? "not-found" : "kube-api-error", `kubectl get --raw failed for ${path}: ${body}`, { path, notFound: isNotFoundText(body), }); } function kubeApiGet(path, required) { 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"); return 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", () => { const code = res.statusCode || 0; if (code >= 200 && code < 300) resolve(body); else reject(new KubeReadError(code === 404 || isNotFoundText(body) ? "not-found" : "kube-api-error", `kube api GET ${path} status ${code}: ${body || "-"}`, { path, statusCode: code, notFound: code === 404 || isNotFoundText(body), })); }); }); req.on("error", (error) => reject(new KubeReadError("transport-error", `kube api GET ${path} transport error: ${error?.message || String(error)}`, { path }))); req.end(); }); } function isNotFoundError(error) { return error instanceof KubeReadError && error.notFound === true; } function isNotFoundText(value) { return /\b404\b|not found|NotFound/u.test(String(value || "")); } function compactTerminated(value) { if (!value) return null; return { reason: value.reason || null, exitCode: Number.isInteger(value.exitCode) ? value.exitCode : null, message: shortText(value.message || null), startedAt: value.startedAt || null, finishedAt: value.finishedAt || null, }; } function compactWaiting(value) { if (!value) return null; return { reason: value.reason || null, message: shortText(value.message || null), }; } function lastNodeCicdTiming(text) { const lines = text.split(/\r?\n/u).filter((line) => line.includes("node-cicd-timing")); for (let index = lines.length - 1; index >= 0; index -= 1) { const parsed = parseTimingLine(lines[index]); if (parsed !== null) return parsed; } return null; } function parseTimingLine(line) { const candidates = [line.trim()]; const brace = line.indexOf("{"); if (brace >= 0) candidates.push(line.slice(brace).trim()); for (const candidate of candidates) { try { const parsed = JSON.parse(candidate); if (parsed && typeof parsed === "object" && JSON.stringify(parsed).includes("node-cicd-timing")) return parsed; } catch { // Keep trying the next bounded candidate. } } return { rawTail: shortText(line) }; } function lastTiming(logs) { for (let index = logs.length - 1; index >= 0; index -= 1) { if (logs[index].nodeCicdTiming !== null) return logs[index].nodeCicdTiming; } return null; } function conditionByType(status, type) { const conditions = Array.isArray(status?.conditions) ? status.conditions : []; return conditions.find((item) => item?.type === type) || null; } function timestampMs(value) { const parsed = Date.parse(String(value || "")); return Number.isFinite(parsed) ? parsed : null; } function durationSeconds(start, end) { const s = timestampMs(start); const e = timestampMs(end); return s === null || e === null || e < s ? null : Math.round((e - s) / 1000); } function shortText(value) { if (value === null || value === undefined) return null; const text = String(value).replace(/\s+/gu, " ").trim(); return text.length <= maxMessageBytes ? text : `${text.slice(0, maxMessageBytes - 3)}...`; } function tailBytes(value, maxBytes) { const buffer = Buffer.from(value, "utf8"); if (buffer.length <= maxBytes) return value; return buffer.subarray(buffer.length - maxBytes).toString("utf8"); } function positiveInt(value, name) { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`); return parsed; } function requiredEnv(name) { const value = process.env[name]; if (!value) throw new Error(`${name} is required`); return value; }