import { Injectable } from '@nestjs/common'; import { BackupTargetService } from '../backup/backup-target.service'; import { AppConfig } from '../config/app-config.service'; import { PrismaService } from '../prisma/prisma.service'; import { InstanceSettingsService } from '../settings/instance-settings.service'; import { backupFreshnessCheck, backupRemoteFreshnessCheck } from './backup-freshness'; export interface ReadinessCheck { /** `warn` reports a degraded-but-serving dependency: the instance still * works, only some feature is unavailable. It never flips overall * readiness to `unready` (that is reserved for `failed`). */ name: string; status: 'ok' | 'warn' | 'failed'; detail?: string; } export interface ReadinessReport { /** * `degraded` = at least one warning-level check (converter/renderer down, * backup stale) while the instance still serves; monitors alert on it via * the body, but only `unready` turns into HTTP 503 (issue #85, * operations.md §Health). */ status: 'ok' | 'degraded' | 'unready'; checks: ReadinessCheck[]; } /** Converter reachability is a warning, not a failure — the pandoc probe is * given a short budget so readyz stays fast even when the sidecar is down. */ const CONVERTER_PROBE_TIMEOUT_MS = 2000; @Injectable() export class ReadinessService { constructor( private readonly prisma: PrismaService, private readonly config: AppConfig, private readonly backupTarget: BackupTargetService, private readonly settings: InstanceSettingsService, ) {} /** * Readiness = the api can do real work: database reachable and all * migrations applied — those two are the hard failures behind HTTP 503. * Everything else (converter, renderer, backup freshness) is * warning-level: the feature degrades, the instance stays ready, and the * overall status says `degraded` so monitors can alert on the body. */ async report(): Promise { const checks: ReadinessCheck[] = [ await this.databaseReachable(), await this.migrationsApplied(), await this.converterReachable(), await this.rendererReachable(), backupFreshnessCheck(this.config.env.BACKUPS_DIR, new Date()), ...(await this.remoteBackupCheck()), ]; const status = checks.some((c) => c.status === 'failed') ? 'unready' : checks.some((c) => c.status === 'warn') ? 'degraded' : 'ok'; return { status, checks }; } /** Off-host copy freshness (issue #103) — only while a target is * configured; the check itself never talks to the network, it reads the * sidecar's status.json. A database hiccup here must not break readyz. */ private async remoteBackupCheck(): Promise { try { if (!(await this.backupTarget.resolveTarget())) return []; const schedule = await this.settings.get('backup.nextcloud.uploadSchedule'); return [backupRemoteFreshnessCheck(this.config.env.BACKUPS_DIR, new Date(), schedule)]; } catch { return []; } } private async converterReachable(): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), CONVERTER_PROBE_TIMEOUT_MS); try { const response = await fetch(`${this.config.env.PANDOC_URL}/version`, { signal: controller.signal, }); return response.ok ? { name: 'converter', status: 'ok' } : { name: 'converter', status: 'warn', detail: `pandoc returned ${response.status}` }; } catch (error) { return { name: 'converter', status: 'warn', detail: shortMessage(error) }; } finally { clearTimeout(timer); } } /** The Gotenberg PDF renderer (issue #67) — warning-level like the converter: * PDF export degrades when it is down, but the instance stays ready. */ private async rendererReachable(): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), CONVERTER_PROBE_TIMEOUT_MS); try { const response = await fetch(`${this.config.env.GOTENBERG_URL}/health`, { signal: controller.signal, }); return response.ok ? { name: 'renderer', status: 'ok' } : { name: 'renderer', status: 'warn', detail: `gotenberg returned ${response.status}` }; } catch (error) { return { name: 'renderer', status: 'warn', detail: shortMessage(error) }; } finally { clearTimeout(timer); } } private async databaseReachable(): Promise { try { await this.prisma.$queryRaw`SELECT 1`; return { name: 'database', status: 'ok' }; } catch (error) { return { name: 'database', status: 'failed', detail: shortMessage(error) }; } } private async migrationsApplied(): Promise { try { // `prisma migrate deploy` records every migration here; an entry // without finished_at is pending or failed. const rows = await this.prisma.$queryRaw<{ pending: bigint }[]>` SELECT count(*)::bigint AS pending FROM _prisma_migrations WHERE finished_at IS NULL AND rolled_back_at IS NULL `; const pending = Number(rows[0]?.pending ?? 0); return pending === 0 ? { name: 'migrations', status: 'ok' } : { name: 'migrations', status: 'failed', detail: `${pending} migration(s) pending` }; } catch (error) { return { name: 'migrations', status: 'failed', detail: shortMessage(error) }; } } } function shortMessage(error: unknown): string { const message = error instanceof Error ? error.message : String(error); // Keep readiness output single-line and free of connection strings. return message.split('\n').filter(Boolean).slice(-1)[0]?.slice(0, 200) ?? 'unknown error'; }