diff --git a/src/cli/program.ts b/src/cli/program.ts index d7ead468..91306287 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -20,6 +20,7 @@ import { registerPauseCommand } from "../commands/pause.js"; import { registerResumeCommand } from "../commands/resume.js"; import { registerMetricsCommand } from "../commands/metrics.js"; import { registerAuditLogCommand } from "../commands/audit-log.js"; +import { registerDoctorCommand } from "../commands/doctor.js"; import { registerInitCommand } from "../commands/init.js"; /** @@ -101,6 +102,7 @@ export function createProgram() { registerResumeCommand(program); registerMetricsCommand(program); registerAuditLogCommand(program); + registerDoctorCommand(program); registerInitCommand(program); return program; diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts new file mode 100644 index 00000000..189a6a30 --- /dev/null +++ b/src/commands/doctor.ts @@ -0,0 +1,26 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import { runDiagnostics } from "../core/doctor.js"; + +export function registerDoctorCommand(program: Command): void { + program + .command("doctor") + .description("Run diagnostics for the local Sorokeep installation") + .action(async () => { + const results = await runDiagnostics(); + + for (const result of results) { + const prefix = + result.status === "ok" + ? chalk.green("PASS") + : result.status === "warn" + ? chalk.yellow("WARN") + : chalk.red("FAIL"); + console.log(`${prefix} ${result.check}: ${result.detail}`); + } + + if (results.some((result) => result.status === "fail")) { + process.exitCode = 1; + } + }); +} diff --git a/src/core/doctor.ts b/src/core/doctor.ts new file mode 100644 index 00000000..a3b1a6ce --- /dev/null +++ b/src/core/doctor.ts @@ -0,0 +1,253 @@ +import fs from "node:fs"; +import path from "node:path"; +import Database from "better-sqlite3"; +import { getDatabase } from "../db/database.js"; +import { getAlertConfigsForContract, getAllContracts } from "../db/repositories.js"; +import { StellarRpcClient } from "../rpc/client.js"; +import { listAlertChannels } from "../alerts/registry.js"; +import { registerBuiltinChannels } from "../alerts/builtins.js"; +import { getSorokeepDir } from "../utils/config.js"; + +export interface DiagnosticResult { + check: string; + status: "ok" | "warn" | "fail"; + detail: string; +} + +/** Core tables that must exist for the Sorokeep database to be usable. */ +const REQUIRED_TABLES = [ + "contracts", + "contract_entries", + "alert_configs", + "alerts_fired", + "channel_accounts", + "schema_migrations", +]; + +/** + * Columns that were added through schema migrations / live migrations. + * A pre-existing database that predates these migrations is missing them, + * which `getDatabase()` would silently repair — so they are checked here on a + * non-migrating connection to surface an outdated schema. + */ +const REQUIRED_COLUMNS: Record = { + contracts: ["id", "name", "network", "poll_interval_seconds", "active", "last_introspected_at"], + alert_configs: [ + "contract_id", + "channel_type", + "channel_target", + "threshold_ledgers", + "webhook_secret", + "quiet_hours_start", + "quiet_hours_end", + "quiet_hours_timezone", + ], + alerts_fired: ["alert_config_id", "contract_entry_id", "delivered", "delivered_at", "retry_count"], +}; + +function getRequiredCredentialEnvVar(channelType: string): string | undefined { + switch (channelType) { + case "slack": + return "SOROKEEP_SLACK_TOKEN"; + case "telegram": + return "SOROKEEP_TELEGRAM_BOT_TOKEN"; + default: + return undefined; + } +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Inspects the on-disk database without applying migrations. `getDatabase()` + * always migrates the schema before returning, so it cannot report an + * outdated database — this read-only check inspects the raw file instead. + */ +function checkDatabaseSchema(): DiagnosticResult { + const dbPath = path.join(getSorokeepDir(), "sorokeep.db"); + + if (!fs.existsSync(dbPath)) { + return { + check: "schema", + status: "warn", + detail: `Database not yet initialized (expected at ${dbPath})`, + }; + } + + let db: Database.Database; + try { + db = new Database(dbPath, { readonly: true, fileMustExist: true }); + } catch (error: unknown) { + return { + check: "schema", + status: "fail", + detail: `Database unavailable: ${formatError(error)}`, + }; + } + + try { + const missingTables = REQUIRED_TABLES.filter( + (table) => !db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table), + ); + + const missingColumns: string[] = []; + for (const [table, columns] of Object.entries(REQUIRED_COLUMNS)) { + if (missingTables.includes(table)) { + continue; + } + const present = new Set( + (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((column) => column.name), + ); + for (const column of columns) { + if (!present.has(column)) { + missingColumns.push(`'${table}.${column}'`); + } + } + } + + if (missingTables.length === 0 && missingColumns.length === 0) { + return { + check: "schema", + status: "ok", + detail: "Database schema is available and up to date", + }; + } + + const missing = [ + ...missingTables.map((table) => `table '${table}'`), + ...missingColumns.map((column) => `column ${column}`), + ]; + return { + check: "schema", + status: "fail", + detail: `Database schema is outdated or incomplete (missing ${missing.join(", ")})`, + }; + } catch (error: unknown) { + return { + check: "schema", + status: "fail", + detail: `Database schema could not be inspected: ${formatError(error)}`, + }; + } finally { + db.close(); + } +} + +/** + * Bounds an awaited operation so a stalled network call cannot hang + * `sorokeep doctor` indefinitely. + */ +function withTimeout(promise: Promise, ms: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), ms); + void promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +export async function runDiagnostics(): Promise { + const results: DiagnosticResult[] = []; + registerBuiltinChannels(); + + const nodeVersion = process.versions.node; + results.push({ + check: "node version", + status: "ok", + detail: `Node.js ${nodeVersion}`, + }); + + const dataDir = getSorokeepDir(); + try { + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + if (!fs.statSync(dataDir).isDirectory()) { + throw new Error(`Not a directory: ${dataDir}`); + } + fs.accessSync(dataDir, fs.constants.W_OK); + results.push({ + check: "data directory", + status: "ok", + detail: `Writable: ${dataDir}`, + }); + } catch (error: unknown) { + results.push({ + check: "data directory", + status: "fail", + detail: `Unable to write to ${dataDir}: ${formatError(error)}`, + }); + } + + results.push(checkDatabaseSchema()); + + try { + const db = getDatabase(); + const configuredChannels = new Set(listAlertChannels().map((channel) => channel.name)); + const contracts = getAllContracts(db); + const credentialWarnings: string[] = []; + + for (const contract of contracts) { + const alertConfigs = getAlertConfigsForContract(db, contract.id); + for (const config of alertConfigs) { + if (!configuredChannels.has(config.channel_type)) { + continue; + } + const envVar = getRequiredCredentialEnvVar(config.channel_type); + if (!envVar) { + continue; + } + if (!process.env[envVar]) { + credentialWarnings.push(`${config.channel_type} (${contract.id}) -> ${envVar}`); + } + } + } + + if (credentialWarnings.length > 0) { + results.push({ + check: "alert-channel credentials", + status: "warn", + detail: `Missing env vars: ${credentialWarnings.join(", ")}`, + }); + } else { + results.push({ + check: "alert-channel credentials", + status: "ok", + detail: "All configured alert channel credentials are present", + }); + } + } catch (error: unknown) { + results.push({ + check: "alert-channel credentials", + status: "warn", + detail: `Skipped: database unavailable (${formatError(error)})`, + }); + } + + try { + const client = new StellarRpcClient("testnet"); + await withTimeout(client.checkHealth(), 10_000, "RPC health check timed out"); + results.push({ + check: "rpc reachability", + status: "ok", + detail: "RPC endpoint responded", + }); + } catch (error: unknown) { + results.push({ + check: "rpc reachability", + status: "fail", + detail: `RPC check failed: ${formatError(error)}`, + }); + } + + return results; +} diff --git a/tests/commands/doctor.test.ts b/tests/commands/doctor.test.ts new file mode 100644 index 00000000..cefef798 --- /dev/null +++ b/tests/commands/doctor.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Command } from "commander"; + +const { mockRunDiagnostics } = vi.hoisted(() => ({ + mockRunDiagnostics: vi.fn(), +})); + +vi.mock("../../src/core/doctor.js", () => ({ + runDiagnostics: mockRunDiagnostics, +})); + +import { registerDoctorCommand } from "../../src/commands/doctor"; + +describe("doctor command", () => { + let originalExitCode: number; + + beforeEach(() => { + mockRunDiagnostics.mockReset(); + vi.spyOn(console, "log").mockImplementation(() => {}); + originalExitCode = process.exitCode; + }); + + afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = originalExitCode; + }); + + it("exits with code 1 when any check fails", async () => { + mockRunDiagnostics.mockResolvedValue([ + { check: "node version", status: "ok", detail: "ok" }, + { check: "rpc reachability", status: "fail", detail: "unreachable" }, + ]); + + const program = new Command(); + registerDoctorCommand(program); + + await program.parseAsync(["node", "sorokeep", "doctor"]); + + expect(process.exitCode).toBe(1); + }); + + it("does not exit with an error when all checks pass or warn", async () => { + mockRunDiagnostics.mockResolvedValue([ + { check: "node version", status: "ok", detail: "ok" }, + { check: "rpc reachability", status: "warn", detail: "slow" }, + ]); + + const program = new Command(); + registerDoctorCommand(program); + + await program.parseAsync(["node", "sorokeep", "doctor"]); + expect(process.exitCode).toBe(undefined); + }); +}); diff --git a/tests/core/doctor.test.ts b/tests/core/doctor.test.ts new file mode 100644 index 00000000..10b4fc3b --- /dev/null +++ b/tests/core/doctor.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import Database from "better-sqlite3"; +import type DatabaseType from "better-sqlite3"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { getDatabaseForTesting } from "../../src/db/database"; +import { insertAlertConfig, insertContract } from "../../src/db/repositories"; +import { runDiagnostics } from "../../src/core/doctor"; +import * as config from "../../src/utils/config"; + +const { mockGetDatabase } = vi.hoisted(() => ({ + mockGetDatabase: vi.fn(), +})); + +let mockDb: DatabaseType.Database; +let tempDir: string; +let originalSlackToken: string | undefined; +let originalTelegramToken: string | undefined; + +vi.mock("../../src/db/database.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDatabase: mockGetDatabase, + }; +}); + +vi.mock("../../src/rpc/client.js", () => ({ + StellarRpcClient: class { + async checkHealth(): Promise { + throw new Error("unreachable"); + } + }, +})); + +describe("runDiagnostics", () => { + beforeEach(() => { + originalSlackToken = process.env.SOROKEEP_SLACK_TOKEN; + originalTelegramToken = process.env.SOROKEEP_TELEGRAM_BOT_TOKEN; + delete process.env.SOROKEEP_SLACK_TOKEN; + delete process.env.SOROKEEP_TELEGRAM_BOT_TOKEN; + + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "sorokeep-doctor-test-")); + vi.spyOn(config, "getSorokeepDir").mockReturnValue(tempDir); + + mockDb = getDatabaseForTesting(); + mockGetDatabase.mockReset(); + mockGetDatabase.mockImplementation(() => mockDb); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (originalSlackToken === undefined) { + delete process.env.SOROKEEP_SLACK_TOKEN; + } else { + process.env.SOROKEEP_SLACK_TOKEN = originalSlackToken; + } + if (originalTelegramToken === undefined) { + delete process.env.SOROKEEP_TELEGRAM_BOT_TOKEN; + } else { + process.env.SOROKEEP_TELEGRAM_BOT_TOKEN = originalTelegramToken; + } + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("reports fail for an unreachable RPC URL", async () => { + const results = await runDiagnostics(); + const rpcCheck = results.find((result) => result.check === "rpc reachability"); + + expect(rpcCheck).toBeDefined(); + expect(rpcCheck?.status).toBe("fail"); + expect(rpcCheck?.detail).toContain("unreachable"); + }); + + it("reports warn when an alert config references an unset channel credential env var", async () => { + insertContract(mockDb, { + id: "C123", + name: "demo-contract", + network: "testnet", + }); + insertAlertConfig(mockDb, { + contract_id: "C123", + channel_type: "slack", + channel_target: "#alerts", + threshold_ledgers: 1000, + }); + + const results = await runDiagnostics(); + const credentialCheck = results.find((result) => result.check === "alert-channel credentials"); + + expect(credentialCheck).toBeDefined(); + expect(credentialCheck?.status).toBe("warn"); + expect(credentialCheck?.detail).toContain("SOROKEEP_SLACK_TOKEN"); + }); + + it("reports a schema failure when the database schema is outdated", async () => { + const dbPath = path.join(tempDir, "sorokeep.db"); + const rawDb = new Database(dbPath); + rawDb.exec("CREATE TABLE contracts (id TEXT PRIMARY KEY, name TEXT);"); + rawDb.close(); + + const results = await runDiagnostics(); + const schemaCheck = results.find((result) => result.check === "schema"); + + expect(schemaCheck).toBeDefined(); + expect(schemaCheck?.status).toBe("fail"); + expect(schemaCheck?.detail).toContain("outdated"); + expect(schemaCheck?.detail).toContain("poll_interval_seconds"); + }); + + it("reports a schema failure when the database cannot be opened", async () => { + const dbPath = path.join(tempDir, "sorokeep.db"); + fs.writeFileSync(dbPath, "this is not a sqlite database"); + + const results = await runDiagnostics(); + const schemaCheck = results.find((result) => result.check === "schema"); + + expect(schemaCheck).toBeDefined(); + expect(schemaCheck?.status).toBe("fail"); + }); + + it("reports a warning when no database has been initialized yet", async () => { + const results = await runDiagnostics(); + const schemaCheck = results.find((result) => result.check === "schema"); + + expect(schemaCheck).toBeDefined(); + expect(schemaCheck?.status).toBe("warn"); + expect(schemaCheck?.detail).toContain("not yet initialized"); + }); + + it("reports a failure when the data directory path is not a directory", async () => { + const filePath = path.join(tempDir, "not-a-directory"); + fs.writeFileSync(filePath, "plain file"); + vi.mocked(config.getSorokeepDir).mockReturnValue(filePath); + + const results = await runDiagnostics(); + const dataDirCheck = results.find((result) => result.check === "data directory"); + + expect(dataDirCheck).toBeDefined(); + expect(dataDirCheck?.status).toBe("fail"); + expect(dataDirCheck?.detail).toContain("Not a directory"); + }); + + it("reports the credential check as skipped when the database cannot be opened", async () => { + mockGetDatabase.mockImplementation(() => { + throw new Error("sqlite open failed"); + }); + + const results = await runDiagnostics(); + const credentialCheck = results.find((result) => result.check === "alert-channel credentials"); + + expect(credentialCheck).toBeDefined(); + expect(credentialCheck?.status).toBe("warn"); + expect(credentialCheck?.detail).toContain("sqlite open failed"); + }); +});