import { constants } from 'node:fs' import { access, mkdir } from 'node:fs/promises' import path from 'node:path' import { db } from '@/lib/db' import { getEnv } from '@/lib/env' import { pingClamav } from '@/lib/files/clamav' import { QUARANTINE, STORE } from '@/lib/files/paths' export type ComponentState = 'up' | 'degraded' | 'down' export type HealthReport = { status: ComponentState checkedAt: string components: Record<'database' | 'discordGateway' | 'clamav' | 'fileStore', { status: ComponentState detail?: string }> } /** Der Worker gilt als abgehaengt, wenn sein Lebenszeichen aelter ist. */ const HEARTBEAT_MAX_AGE_MS = 3 * 60_000 async function checkDatabase(): Promise<{ status: ComponentState; detail?: string }> { try { await db.$queryRaw`SELECT 1` return { status: 'up' } } catch (error) { return { status: 'down', detail: error instanceof Error ? error.message : 'unbekannt' } } } async function checkDiscordGateway(): Promise<{ status: ComponentState; detail?: string }> { try { const beat = await db.serviceHeartbeat.findUnique({ where: { service: 'worker' } }) if (!beat) return { status: 'down', detail: 'kein Lebenszeichen' } const ageMs = Date.now() - beat.at.getTime() if (ageMs > HEARTBEAT_MAX_AGE_MS) { return { status: 'down', detail: `Lebenszeichen ${Math.round(ageMs / 1000)} s alt` } } if (beat.status !== 'up') { return { status: beat.status === 'degraded' ? 'degraded' : 'down', detail: beat.detail ?? undefined } } return { status: 'up' } } catch { return { status: 'down', detail: 'nicht abfragbar' } } } async function checkClamav(): Promise<{ status: ComponentState; detail?: string }> { const ok = await pingClamav(getEnv().CLAMAV_ADDRESS) return ok ? { status: 'up' } : { status: 'down', detail: 'clamd antwortet nicht' } } /** * Ist die Dateiablage beschreibbar? * * Steht hier, weil genau das einmal unbemerkt kaputt war: das Volume wurde auf * einen Pfad gelegt, den es im Image nicht gab, Docker legte ihn als root an, * und der Dienst laeuft als afis. Jeder Upload scheiterte, und zu sehen war * davon nichts - bis jemand eine Datei anhaengen wollte. Ein Zustand, den man * nur durch Ausprobieren erfaehrt, gehoert in die Statusanzeige. * * Geprueft wird das Recht, nicht durch eine Probedatei: ein Healthcheck, der * im Sekundentakt schreibt und aufraeumt, ist selbst ein Risiko. */ async function checkFileStore(): Promise<{ status: ComponentState; detail?: string }> { const wurzel = path.resolve(getEnv().FILES_DIR) try { for (const bereich of [QUARANTINE, STORE]) { const verzeichnis = path.join(wurzel, bereich) // Anlegen, falls es fehlt - das ist der Normalfall bei einer frischen // Ablage und kein Fehler. await mkdir(verzeichnis, { recursive: true }) await access(verzeichnis, constants.W_OK | constants.X_OK) } return { status: 'up' } } catch (error) { const grund = error instanceof Error && 'code' in error ? String(error.code) : 'unbekannt' return { status: 'down', detail: grund === 'EACCES' ? `${wurzel} ist fuer den Dienst nicht beschreibbar` : `${wurzel}: ${grund}`, } } } /** * Sammelt den Zustand der Abhaengigkeiten. Die Gesamtbewertung faellt * bewusst nur auf `down`, wenn die Datenbank weg ist: Worker, clamav und pdf * haben eigene Healthchecks im Stack, und ein ausgefallener Virenscanner soll * nicht den ganzen Web-Container als tot markieren. Sichtbar bleibt der Ausfall * trotzdem, in dieser Antwort und in Dockge am jeweiligen Dienst. */ export async function collectHealth(): Promise { const [database, discordGateway, clamav, fileStore] = await Promise.all([ checkDatabase(), checkDiscordGateway(), checkClamav(), checkFileStore(), ]) const status: ComponentState = database.status === 'down' ? 'down' : discordGateway.status === 'up' && clamav.status === 'up' && fileStore.status === 'up' ? 'up' : 'degraded' return { status, checkedAt: new Date().toISOString(), components: { database, discordGateway, clamav, fileStore }, } }