import { readFileSync } from "node:fs"; import { randomBytes } from "node:crypto"; import https from "node:https"; const namespace = process.env.NAMESPACE || ""; const pipelineRun = process.env.PIPELINERUN || ""; const shouldWait = process.env.WAIT === "true"; const timeoutSeconds = requiredPositiveNumber("TIMEOUT_SECONDS"); const pollIntervalSeconds = requiredPositiveNumber("POLL_INTERVAL_SECONDS"); const logsTailLines = Number(process.env.LOGS_TAIL_LINES || "240"); const maxLogBytes = Number(process.env.MAX_LOG_BYTES || "32000"); 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")); const startedAt = Date.now(); const traceContext = resolveTraceContext(); function request(method, path, body, contentType = "application/json") { return new Promise((resolve, reject) => { const headers = { authorization: `Bearer ${token}`, traceparent: traceContext.traceparent }; if (traceContext.tracestate !== null) headers.tracestate = traceContext.tracestate; 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 parseBody(result) { if (!result.text) return null; try { return JSON.parse(result.text); } catch { return null; } } async function getPipelineRun() { const result = await request("GET", `/apis/tekton.dev/v1/namespaces/${encodeURIComponent(namespace)}/pipelineruns/${encodeURIComponent(pipelineRun)}`); if (result.status === 404) return { found: false, object: null, status: result.status, text: result.text }; if (result.status < 200 || result.status >= 300) throw new Error(result.text || `kube api GET pipelinerun status ${result.status}`); return { found: true, object: parseBody(result), status: result.status, text: result.text }; } function succeededCondition(object) { const conditions = Array.isArray(object?.status?.conditions) ? object.status.conditions : []; return conditions.find((item) => item && item.type === "Succeeded") || null; } function compact(object) { const condition = succeededCondition(object); return { name: object?.metadata?.name || pipelineRun, namespace: object?.metadata?.namespace || namespace, generation: object?.metadata?.generation ?? null, startTime: object?.status?.startTime || null, completionTime: object?.status?.completionTime || null, conditionStatus: condition?.status || null, conditionReason: condition?.reason || null, conditionMessage: condition?.message || null, }; } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } let created = false; let reused = false; let latest = await getPipelineRun(); if (latest.found) { reused = true; } else { const result = await request("POST", `/apis/tekton.dev/v1/namespaces/${encodeURIComponent(namespace)}/pipelineruns`, manifest); if (result.status === 409) { reused = true; } else if (result.status >= 200 && result.status < 300) { created = true; } else { const error = kubernetesApiError(result, "pipelinerun-create"); const firstBreak = { code: "kubernetes-api-request-failed", phase: "pipelinerun-create", reason: error.message, valuesRedacted: true }; process.stdout.write(JSON.stringify({ ok: false, submitted: false, created: false, reused: false, wait: shouldWait, polls: 0, completed: false, failed: true, terminal: true, stillRunning: false, timedOutWait: false, elapsedMs: Date.now() - startedAt, pipelineRun: compact(null), traceId: traceContext.traceId, traceparent: traceContext.traceparent, firstBreak, error, statusAuthority: "kubernetes-api-serviceaccount", parsedDownstreamCliOutput: false, warning: false, blocking: true, valuesRedacted: true, })); await emitSpan("cicd.tekton.pipelinerun.create", false, { error, firstBreak, httpStatus: error.httpStatus, exitCode: 1 }); process.exit(1); } latest = await getPipelineRun(); } const deadline = Date.now() + timeoutSeconds * 1000; let polls = 0; while (shouldWait) { const condition = succeededCondition(latest.object); if (condition?.status === "True" || condition?.status === "False") break; 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(pollIntervalSeconds * 1000); latest = await getPipelineRun(); } const condition = succeededCondition(latest.object); const completed = condition?.status === "True"; const failed = condition?.status === "False"; const terminal = completed || failed; const logsTail = terminal ? await pipelineRunLogsTail() : ""; const output = { ok: !failed, submitted: true, created, reused, wait: shouldWait, polls, completed, failed, terminal, stillRunning: !terminal, timedOutWait: shouldWait && !terminal, elapsedMs: Date.now() - startedAt, traceId: traceContext.traceId, traceparent: traceContext.traceparent, pipelineRun: compact(latest.object), logsTail, statusAuthority: "kubernetes-api-serviceaccount", parsedDownstreamCliOutput: false, valuesRedacted: true, }; process.stdout.write(JSON.stringify(output)); await emitSpan("cicd.tekton.pipelinerun.wait", !failed, { phase: failed ? "pipelinerun-terminal-failed" : completed ? "pipelinerun-terminal-succeeded" : "pipelinerun-wait", status: condition?.status || null, durationMs: Date.now() - startedAt, exitCode: failed ? 1 : 0, }); 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; } function resolveTraceContext() { const incoming = process.env.TRACEPARENT || process.env.OTEL_TRACEPARENT || ""; const matched = incoming.match(/^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/i); const traceId = matched?.[1]?.toLowerCase() || randomBytes(16).toString("hex"); const parentSpanId = matched?.[2]?.toLowerCase() || null; const spanId = randomBytes(8).toString("hex"); return { traceId, parentSpanId, spanId, traceparent: `00-${traceId}-${spanId}-${matched?.[3]?.toLowerCase() || "01"}`, tracestate: boundedHeader(process.env.TRACESTATE), }; } function boundedHeader(value) { return typeof value === "string" && value.length > 0 && value.length <= 512 ? value : null; } function kubernetesApiError(result, phase) { const body = parseBody(result); const code = safeToken(body?.reason) || `HTTP_${result.status || 0}`; const message = redactSummary(body?.message || result.text || `Kubernetes API request failed with HTTP ${result.status || 0}`); return { type: "KubernetesApiError", code, phase, httpStatus: result.status || null, exitCode: 1, stderrSummary: message, message, valuesRedacted: true, }; } function safeToken(value) { return typeof value === "string" && /^[A-Za-z0-9_.-]{1,80}$/.test(value) ? value : null; } function redactSummary(value) { return String(value || "") .replace(/(authorization\s*[:=]\s*)(?:bearer\s+)?\S+/gi, "$1[REDACTED]") .replace(/(token|password|secret)(\s*[:=]\s*)\S+/gi, "$1$2[REDACTED]") .replace(/https?:\/\/[^\s/]+@/gi, "https://[REDACTED]@") .replace(/([?&][^=\s]+)=([^&\s]+)/g, "$1=[REDACTED]") .replace(/\s+/g, " ") .trim() .slice(0, 480); } async function emitSpan(name, ok, attributes) { const endpoint = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT; if (!endpoint) return; const started = BigInt(startedAt) * 1_000_000n; const ended = BigInt(Date.now()) * 1_000_000n; const payload = { resourceSpans: [{ resource: { attributes: otelAttributes({ "service.name": process.env.OTEL_SERVICE_NAME || "unidesk-cicd", "deployment.environment": process.env.UNIDESK_LANE || null, "unidesk.node": process.env.UNIDESK_NODE || null, "unidesk.values_redacted": true, }) }, scopeSpans: [{ scope: { name: "unidesk.cicd.submit_pipelinerun", version: "1" }, spans: [{ traceId: traceContext.traceId, spanId: traceContext.spanId, ...(traceContext.parentSpanId === null ? {} : { parentSpanId: traceContext.parentSpanId }), name, kind: 3, startTimeUnixNano: started.toString(), endTimeUnixNano: ended.toString(), attributes: otelAttributes({ "cicd.consumer": process.env.UNIDESK_PAC_CONSUMER || null, "cicd.pipeline_run": pipelineRun, "k8s.namespace.name": namespace, "cicd.source_commit": process.env.SOURCE_COMMIT || null, "cicd.phase": attributes.phase || null, "cicd.status": attributes.status || null, "cicd.duration_ms": attributes.durationMs || null, "process.exit.code": attributes.exitCode ?? null, "http.response.status_code": attributes.httpStatus ?? null, "error.type": attributes.error?.type || null, "error.code": attributes.error?.code || null, "cicd.first_break": attributes.firstBreak?.code || null, "warning": false, "blocking": attributes.exitCode === 1, "valuesRedacted": true, }), status: { code: ok ? 1 : 2 }, }], }], }], }; try { const controller = new AbortController(); const timeoutMs = exporterTimeoutMs(); const timeout = setTimeout(() => controller.abort(), timeoutMs); const response = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json", traceparent: traceContext.traceparent }, body: JSON.stringify(payload), signal: controller.signal }); clearTimeout(timeout); if (!response.ok) process.stderr.write(JSON.stringify({ event: "cicd.otel.export", warning: true, blocking: false, httpStatus: response.status, traceId: traceContext.traceId, valuesRedacted: true }) + "\n"); } catch (error) { process.stderr.write(JSON.stringify({ event: "cicd.otel.export", warning: true, blocking: false, errorType: error instanceof Error ? error.name : "Error", timedOut: error instanceof Error && error.name === "AbortError", timeoutMs: exporterTimeoutMs(), traceId: traceContext.traceId, valuesRedacted: true }) + "\n"); } } function exporterTimeoutMs() { const value = Number(process.env.OTEL_EXPORTER_TIMEOUT_MS || "300"); return Number.isInteger(value) && value >= 50 && value <= 2000 ? value : 300; } function otelAttributes(values) { return Object.entries(values).filter(([, value]) => value !== null && value !== undefined).map(([key, value]) => ({ key, value: otelValue(value) })); } function otelValue(value) { if (typeof value === "boolean") return { boolValue: value }; if (typeof value === "number" && Number.isFinite(value)) return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value }; return { stringValue: typeof value === "string" ? value : JSON.stringify(value) }; } async function pipelineRunLogsTail() { const selector = encodeURIComponent(`tekton.dev/pipelineRun=${pipelineRun}`); const podsResult = await request("GET", `/api/v1/namespaces/${encodeURIComponent(namespace)}/pods?labelSelector=${selector}`); if (podsResult.status < 200 || podsResult.status >= 300) return ""; const pods = parseBody(podsResult); const chunks = []; for (const pod of Array.isArray(pods?.items) ? pods.items : []) { const podName = pod?.metadata?.name; const containers = [ ...(Array.isArray(pod?.spec?.initContainers) ? pod.spec.initContainers : []), ...(Array.isArray(pod?.spec?.containers) ? pod.spec.containers : []), ]; for (const container of containers) { if (!podName || !container?.name) continue; const path = `/api/v1/namespaces/${encodeURIComponent(namespace)}/pods/${encodeURIComponent(podName)}/log?container=${encodeURIComponent(container.name)}&tailLines=${Math.max(1, logsTailLines)}`; const log = await request("GET", path); if (log.status >= 200 && log.status < 300 && log.text) chunks.push(log.text); } } return chunks.join("\n").slice(-Math.max(1, maxLogBytes)); }