lomavuokraus/lib/monitoring.ts
2025-12-07 01:53:10 +02:00

305 lines
9.8 KiB
TypeScript

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<HetznerStatus> {
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<any> {
const url = `${cfg.server}${path}`;
const headers: Record<string, string> = { 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<KubernetesStatus> {
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<DbStatus> {
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' };
}
}