import { defineCommand } from "citty"; import https from "node:https"; import { createLogger } from "../lib/logger"; import { core, kc } from "../lib/k8s"; import { delay, parseRetryAfter } from "../lib/k8s-http"; import { getPodContainerName, listManagedDeployments, listPodsForDeployment, } from "../lib/shared"; function formatError(error: unknown): string { if (error instanceof Error) return error.message; return String(error); } async function streamResponseLines( stream: NodeJS.ReadableStream, onLine: (line: string) => void, ) { let buffer = ""; for await (const chunk of stream) { buffer += chunk.toString(); let newline = buffer.indexOf("\n"); while (newline !== -1) { const line = buffer.slice(0, newline).replace(/\r$/, ""); buffer = buffer.slice(newline + 1); if (line) onLine(line); newline = buffer.indexOf("\n"); } } const line = buffer.replace(/\r$/, ""); if (line) onLine(line); } async function followPodLogs( namespace: string, podName: string, containerName: string, writeLine: (line: string) => void, signal: AbortSignal, ) { const cluster = kc.getCurrentCluster(); if (!cluster) throw new Error("No currently active cluster"); const requestURL = new URL( `${cluster.server}/api/v1/namespaces/${namespace}/pods/${podName}/log`, ); requestURL.searchParams.set("container", containerName); requestURL.searchParams.set("follow", "true"); const options: https.RequestOptions = { method: "GET", protocol: requestURL.protocol, hostname: requestURL.hostname, port: requestURL.port, path: `${requestURL.pathname}${requestURL.search}`, signal, }; await kc.applyToHTTPSOptions(options); for (let attempt = 0; ; attempt += 1) { const result = await new Promise<{ statusCode: number; retryAfter?: string; body?: string; }>((resolve, reject) => { const request = https.request(options, async (response) => { const statusCode = response.statusCode ?? 0; if (statusCode < 200 || statusCode > 299) { const chunks: Buffer[] = []; response.on("data", (chunk) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); response.on("end", () => { resolve({ statusCode, retryAfter: response.headers["retry-after"], body: Buffer.concat(chunks).toString("utf8"), }); }); return; } try { await streamResponseLines(response, writeLine); resolve({ statusCode }); } catch (error) { reject(error); } }); request.on("error", reject); request.end(); }); if (result.statusCode >= 200 && result.statusCode <= 299) return; if (result.statusCode !== 429 || attempt >= 3) { throw new Error(result.body || `HTTP ${result.statusCode}`); } const retryAfter = parseRetryAfter(result.retryAfter); await delay(retryAfter ?? 250 * 2 ** attempt, signal); } } async function followDeploymentLogs(name: string, signal: AbortSignal) { const logger = createLogger(name); const activePods = new Set(); while (!signal.aborted) { const pods = await listPodsForDeployment(name); for (const pod of pods) { const podName = pod.metadata?.name; if ( !podName || activePods.has(podName) || pod.status?.phase !== "Running" ) { continue; } activePods.add(podName); void followPodLogs( pod.metadata?.namespace ?? "default", podName, getPodContainerName(pod), (line) => logger`${line}`, signal, ) .catch((error) => { if (!signal.aborted) logger.warn`${podName}: ${formatError(error)}`; }) .finally(() => { activePods.delete(podName); }); } await delay(2000, signal); } } async function logDeployment(name: string, follow: boolean) { const logger = createLogger(name); if (!follow) { const pods = await listPodsForDeployment(name); if (pods.length === 0) throw new Error(`No pods found for deployment ${name}`); for (const pod of pods) { const podName = pod.metadata?.name; if (!podName) continue; try { const text = await core.readNamespacedPodLog({ namespace: pod.metadata?.namespace ?? "default", name: podName, container: getPodContainerName(pod), }); for (const line of text.split(/\r?\n/)) { if (line) logger`${line}`; } } catch (error) { logger.warn`${podName}: ${formatError(error)}`; } } return; } const controller = new AbortController(); await new Promise((resolve, reject) => { const cleanup = () => { process.off("SIGINT", abort); process.off("SIGTERM", abort); }; const abort = () => { controller.abort(); cleanup(); resolve(); }; process.on("SIGINT", abort); process.on("SIGTERM", abort); void followDeploymentLogs(name, controller.signal) .then(() => { cleanup(); resolve(); }) .catch((error) => { cleanup(); if (controller.signal.aborted) { resolve(); return; } controller.abort(); reject(error); }); }); } export const logs = defineCommand({ meta: { name: "logs", description: "Show deployment logs", }, args: { follow: { type: "boolean", alias: "f", description: "Follow log output", }, }, async run({ args }) { const [deployment] = args._; const names = deployment ? [deployment] : (await listManagedDeployments()) .map((item) => item.metadata?.name) .filter((item): item is string => Boolean(item)); if (args.follow) { await Promise.all(names.map((name) => logDeployment(name, true))); return; } for (const name of names) await logDeployment(name, false); }, });