import fs from 'fs'; import https from 'https'; import { prisma } from './prisma'; type HetznerServerSummary = { id: number; name: string; status: string; type?: string; datacenter?: string; publicIp?: string; privateIp?: string; created?: string; }; export type HetznerStatus = { ok: boolean; error?: string; missingToken?: boolean; servers?: HetznerServerSummary[]; }; type KubernetesClientConfig = { server: string; token?: string; ca?: string; insecureSkipTlsVerify?: boolean; }; type KubernetesNodeSummary = { name: string; ready: boolean; status: string; roles: string[]; internalIp?: string; kubeletVersion?: string; osImage?: string; lastReadyTransition?: string | null; }; type KubernetesPodContainer = { name: string; ready: boolean; restartCount: number; state: string; lastState?: string | null; }; type KubernetesPodSummary = { name: string; namespace: string; phase: string; reason?: string | null; message?: string | null; readyCount: number; totalContainers: number; restarts: number; nodeName?: string; hostIP?: string | null; podIP?: string | null; startedAt?: string | null; containers: KubernetesPodContainer[]; }; export type KubernetesStatus = { ok: boolean; error?: string; nodes?: KubernetesNodeSummary[]; pods?: KubernetesPodSummary[]; }; export type DbStatus = { ok: boolean; error?: string; serverTime?: string; recovery?: boolean; databaseSizeBytes?: number; connections?: { state: string; count: number }[]; }; function readFileSafe(path: string): string | null { try { return fs.readFileSync(path, 'utf8'); } catch { return null; } } export async function fetchHetznerServers(): Promise { const token = process.env.HCLOUD_TOKEN ?? process.env.HETZNER_TOKEN; if (!token) { return { ok: false, missingToken: true, error: 'HCLOUD_TOKEN not configured' }; } try { const res = await fetch('https://api.hetzner.cloud/v1/servers', { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(8000), }); if (!res.ok) { const body = await res.text(); return { ok: false, error: `Hetzner API ${res.status}: ${body.slice(0, 200)}` }; } const json = (await res.json()) as { servers?: any[] }; const servers = json.servers?.map((s) => ({ id: s.id, name: s.name, status: s.status, type: s.server_type?.name, datacenter: s.datacenter?.name ?? s.datacenter?.description ?? s.datacenter?.location?.name, publicIp: s.public_net?.ipv4?.ip, privateIp: s.private_net?.[0]?.ip, created: s.created, })) ?? []; return { ok: true, servers }; } catch (error: any) { return { ok: false, error: error?.message ?? 'Hetzner API request failed' }; } } function loadKubernetesConfig(): KubernetesClientConfig | null { const token = readFileSafe('/var/run/secrets/kubernetes.io/serviceaccount/token'); const ca = readFileSafe('/var/run/secrets/kubernetes.io/serviceaccount/ca.crt'); const serviceHost = process.env.KUBERNETES_SERVICE_HOST ?? 'kubernetes.default.svc'; const servicePort = process.env.KUBERNETES_SERVICE_PORT ?? '443'; if (token) { return { server: `https://${serviceHost}:${servicePort}`, token: token.trim(), ca: ca ?? undefined, }; } if (process.env.K8S_API_SERVER && process.env.K8S_BEARER_TOKEN) { const caCert = process.env.K8S_CA_CERT; return { server: process.env.K8S_API_SERVER.replace(/\/$/, ''), token: process.env.K8S_BEARER_TOKEN, ca: caCert, insecureSkipTlsVerify: process.env.K8S_INSECURE_SKIP_TLS === 'true', }; } return null; } function k8sRequest(path: string, cfg: KubernetesClientConfig): Promise { const url = `${cfg.server}${path}`; const headers: Record = { Accept: 'application/json' }; if (cfg.token) headers.Authorization = `Bearer ${cfg.token}`; return new Promise((resolve, reject) => { const req = https.request( url, { method: 'GET', headers, ca: cfg.ca, rejectUnauthorized: cfg.insecureSkipTlsVerify ? false : true, timeout: 7000, }, (res) => { const chunks: Buffer[] = []; res.on('data', (d) => chunks.push(typeof d === 'string' ? Buffer.from(d) : d)); res.on('end', () => { const body = Buffer.concat(chunks).toString('utf8'); if (res.statusCode && res.statusCode >= 400) { reject(new Error(`Kubernetes ${res.statusCode}: ${body.slice(0, 200)}`)); return; } try { resolve(body ? JSON.parse(body) : {}); } catch (err) { reject(err); } }); }, ); req.on('error', reject); req.on('timeout', () => req.destroy(new Error('Kubernetes request timed out'))); req.end(); }); } function parseK8sNodes(data: any): KubernetesNodeSummary[] { const items: any[] = data?.items ?? []; return items.map((node) => { const labels = node?.metadata?.labels ?? {}; const roles = Object.keys(labels) .filter((key) => key.startsWith('node-role.kubernetes.io/')) .map((key) => key.replace('node-role.kubernetes.io/', '') || 'control-plane'); const readyCondition = (node?.status?.conditions ?? []).find((c: any) => c.type === 'Ready'); const addresses = node?.status?.addresses ?? []; const internal = addresses.find((a: any) => a.type === 'InternalIP')?.address; return { name: node?.metadata?.name ?? 'unknown', ready: readyCondition?.status === 'True', status: readyCondition?.status === 'True' ? 'Ready' : readyCondition?.reason ?? 'NotReady', roles, internalIp: internal, kubeletVersion: node?.status?.nodeInfo?.kubeletVersion, osImage: node?.status?.nodeInfo?.osImage, lastReadyTransition: readyCondition?.lastTransitionTime ?? null, }; }); } function parseContainerState(status: any): string { if (!status) return 'unknown'; if (status.state?.running) return 'running'; if (status.state?.waiting?.reason) return status.state.waiting.reason; if (status.state?.terminated?.reason) return status.state.terminated.reason; if (status.state?.waiting) return 'waiting'; if (status.state?.terminated) return 'terminated'; return 'unknown'; } function parseLastState(status: any): string | null { if (!status?.lastState) return null; const { lastState } = status; if (lastState.terminated?.reason) return `terminated: ${lastState.terminated.reason}`; if (lastState.terminated?.exitCode !== undefined) return `terminated: code ${lastState.terminated.exitCode}`; if (lastState.waiting?.reason) return `waiting: ${lastState.waiting.reason}`; return 'previous state recorded'; } function parseK8sPods(data: any): KubernetesPodSummary[] { const items: any[] = data?.items ?? []; return items .filter((pod) => { const ns = pod?.metadata?.namespace ?? ''; return ns.startsWith('lomavuokraus-') || ns === 'default'; }) .map((pod) => { const containers: KubernetesPodContainer[] = (pod?.status?.containerStatuses ?? []).map((c: any) => ({ name: c.name, ready: Boolean(c.ready), restartCount: Number(c.restartCount ?? 0), state: parseContainerState(c), lastState: parseLastState(c), })); const readyCount = containers.filter((c) => c.ready).length; const restarts = containers.reduce((sum, c) => sum + c.restartCount, 0); return { name: pod?.metadata?.name ?? 'pod', namespace: pod?.metadata?.namespace ?? 'unknown', phase: pod?.status?.phase ?? 'Unknown', reason: pod?.status?.reason ?? null, message: pod?.status?.message ?? null, readyCount, totalContainers: containers.length, restarts, nodeName: pod?.spec?.nodeName, hostIP: pod?.status?.hostIP ?? null, podIP: pod?.status?.podIP ?? null, startedAt: pod?.status?.startTime ?? null, containers, }; }); } export async function fetchKubernetesStatus(): Promise { const config = loadKubernetesConfig(); if (!config) { return { ok: false, error: 'Kubernetes config not found (service account or env K8S_API_SERVER/K8S_BEARER_TOKEN required)' }; } try { const [nodes, pods] = await Promise.all([k8sRequest('/api/v1/nodes', config), k8sRequest('/api/v1/pods', config)]); return { ok: true, nodes: parseK8sNodes(nodes), pods: parseK8sPods(pods) }; } catch (error: any) { return { ok: false, error: error?.message ?? 'Kubernetes query failed' }; } } export async function fetchDbStatus(): Promise { try { const [summary, activity] = await Promise.all([ prisma.$queryRaw<{ server_time: Date; recovery: boolean; size_bytes: bigint | number }[]>` SELECT now() as server_time, pg_is_in_recovery() as recovery, pg_database_size(current_database()) as size_bytes `, prisma.$queryRaw<{ state: string | null; count: number }[]>` SELECT COALESCE(state, 'unknown') as state, count(*)::int as count FROM pg_stat_activity GROUP BY state `, ]); const row = summary?.[0]; const serverTime = row?.server_time instanceof Date ? row.server_time.toISOString() : row?.server_time ? String(row.server_time) : undefined; const sizeVal = row?.size_bytes ?? 0; const sizeNumber = typeof sizeVal === 'bigint' ? Number(sizeVal) : Number(sizeVal); const connections = activity?.map((a) => ({ state: a.state ?? 'unknown', count: Number(a.count ?? 0) })) ?? []; return { ok: true, serverTime, recovery: Boolean(row?.recovery), databaseSizeBytes: Number.isFinite(sizeNumber) ? sizeNumber : undefined, connections, }; } catch (error: any) { return { ok: false, error: error?.message ?? 'Database query failed' }; } }