diff --git a/.gitignore b/.gitignore index 8490f6b..015196e 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,10 @@ cli/**/*.js.map cli/**/*.d.ts cli/**/*.d.ts.map !cli/jest.config.js +!cli/packages/postgres-ai/bin/postgres-ai.js + +# Generated at build time from metrics.yml +cli/lib/metrics-embedded.ts # Generated config files (these are created by the sources-generator) config/pgwatch-postgres/sources.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2fb92f8..c29e08e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -217,7 +217,8 @@ cli:node:tests: - su - pgtest -c "curl -fsSL https://bun.sh/install | bash" - su - pgtest -c "cd \"$CI_PROJECT_DIR/cli\" && export PATH=\"\$HOME/.bun/bin:\$PATH\" && bun install" script: - - su - pgtest -c "cd \"$CI_PROJECT_DIR/cli\" && export PATH=\"\$HOME/.bun/bin:\$PATH\" && bun test" + # Use 'bun run test' (not 'bun test') to invoke the npm script which generates metrics-embedded.ts first + - su - pgtest -c "cd \"$CI_PROJECT_DIR/cli\" && export PATH=\"\$HOME/.bun/bin:\$PATH\" && bun run test" rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' diff --git a/cli/README.md b/cli/README.md index 0007bfb..a94c018 100644 --- a/cli/README.md +++ b/cli/README.md @@ -29,11 +29,10 @@ brew install postgresai ## Usage -The `postgresai` package provides three command aliases (prefer `postgresai`): +The `postgresai` package provides two command aliases: ```bash -postgres-ai --help -postgresai --help -pgai --help +postgresai --help # Canonical, discoverable +pgai --help # Short and convenient ``` You can also run it without installing via `npx`: @@ -126,17 +125,17 @@ This will: Start monitoring with demo database: ```bash -postgres-ai mon local-install --demo +postgresai mon local-install --demo ``` Start monitoring with your own database: ```bash -postgres-ai mon local-install --db-url postgresql://user:pass@host:5432/db +postgresai mon local-install --db-url postgresql://user:pass@host:5432/db ``` Complete automated setup with API key and database: ```bash -postgres-ai mon local-install --api-key your_key --db-url postgresql://user:pass@host:5432/db -y +postgresai mon local-install --api-key your_key --db-url postgresql://user:pass@host:5432/db -y ``` This will: @@ -153,19 +152,19 @@ This will: #### Service lifecycle ```bash # Complete setup with various options -postgres-ai mon local-install # Interactive setup for production -postgres-ai mon local-install --demo # Demo mode with sample database -postgres-ai mon local-install --api-key # Setup with API key -postgres-ai mon local-install --db-url # Setup with database URL -postgres-ai mon local-install --api-key --db-url # Complete automated setup -postgres-ai mon local-install -y # Auto-accept all defaults +postgresai mon local-install # Interactive setup for production +postgresai mon local-install --demo # Demo mode with sample database +postgresai mon local-install --api-key # Setup with API key +postgresai mon local-install --db-url # Setup with database URL +postgresai mon local-install --api-key --db-url # Complete automated setup +postgresai mon local-install -y # Auto-accept all defaults # Service management -postgres-ai mon start # Start monitoring services -postgres-ai mon stop # Stop monitoring services -postgres-ai mon restart [service] # Restart all or specific monitoring service -postgres-ai mon status # Show monitoring services status -postgres-ai mon health [--wait ] # Check monitoring services health +postgresai mon start # Start monitoring services +postgresai mon stop # Stop monitoring services +postgresai mon restart [service] # Restart all or specific monitoring service +postgresai mon status # Show monitoring services status +postgresai mon health [--wait ] # Check monitoring services health ``` ##### local-install options @@ -176,21 +175,21 @@ postgres-ai mon health [--wait ] # Check monitoring services health #### Monitoring target databases (`mon targets` subgroup) ```bash -postgres-ai mon targets list # List databases to monitor -postgres-ai mon targets add # Add database to monitor -postgres-ai mon targets remove # Remove monitoring target -postgres-ai mon targets test # Test target connectivity +postgresai mon targets list # List databases to monitor +postgresai mon targets add # Add database to monitor +postgresai mon targets remove # Remove monitoring target +postgresai mon targets test # Test target connectivity ``` #### Configuration and maintenance ```bash -postgres-ai mon config # Show monitoring configuration -postgres-ai mon update-config # Apply configuration changes -postgres-ai mon update # Update monitoring stack -postgres-ai mon reset [service] # Reset service data -postgres-ai mon clean # Cleanup artifacts -postgres-ai mon check # System readiness check -postgres-ai mon shell # Open shell to monitoring service +postgresai mon config # Show monitoring configuration +postgresai mon update-config # Apply configuration changes +postgresai mon update # Update monitoring stack +postgresai mon reset [service] # Reset service data +postgresai mon clean # Cleanup artifacts +postgresai mon check # System readiness check +postgresai mon shell # Open shell to monitoring service ``` ### MCP server (`mcp` group) @@ -250,16 +249,16 @@ postgresai issues view > issue.json #### Grafana management ```bash -postgres-ai mon generate-grafana-password # Generate new Grafana password -postgres-ai mon show-grafana-credentials # Show Grafana credentials +postgresai mon generate-grafana-password # Generate new Grafana password +postgresai mon show-grafana-credentials # Show Grafana credentials ``` ### Authentication and API key management ```bash -postgres-ai auth # Authenticate via browser (OAuth) -postgres-ai auth --set-key # Store API key directly -postgres-ai show-key # Show stored key (masked) -postgres-ai remove-key # Remove stored key +postgresai auth # Authenticate via browser (OAuth) +postgresai auth --set-key # Store API key directly +postgresai show-key # Show stored key (masked) +postgresai remove-key # Remove stored key ``` ## Configuration diff --git a/cli/bin/postgres-ai.ts b/cli/bin/postgres-ai.ts index 552c6dd..7978d5c 100644 --- a/cli/bin/postgres-ai.ts +++ b/cli/bin/postgres-ai.ts @@ -7,6 +7,7 @@ import * as yaml from "js-yaml"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; +import * as crypto from "node:crypto"; import { Client } from "pg"; import { startMcpServer } from "../lib/mcp-server"; import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue } from "../lib/issues"; @@ -17,6 +18,8 @@ import * as authServer from "../lib/auth-server"; import { maskSecret } from "../lib/util"; import { createInterface } from "readline"; import * as childProcess from "child_process"; +import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checkup"; +import { createCheckupReport, uploadCheckupReportJson, RpcError, formatRpcErrorForDisplay, withRetry } from "../lib/checkup-api"; // Singleton readline interface for stdin prompts let rl: ReturnType | null = null; @@ -109,6 +112,255 @@ async function question(prompt: string): Promise { }); } +function expandHomePath(p: string): string { + const s = (p || "").trim(); + if (!s) return s; + if (s === "~") return os.homedir(); + if (s.startsWith("~/") || s.startsWith("~\\")) { + return path.join(os.homedir(), s.slice(2)); + } + return s; +} + +function createTtySpinner( + enabled: boolean, + initialText: string +): { update: (text: string) => void; stop: (finalText?: string) => void } { + if (!enabled) { + return { + update: () => {}, + stop: () => {}, + }; + } + + const frames = ["|", "/", "-", "\\"]; + const startTs = Date.now(); + let text = initialText; + let frameIdx = 0; + let stopped = false; + + const render = (): void => { + if (stopped) return; + const elapsedSec = ((Date.now() - startTs) / 1000).toFixed(1); + const frame = frames[frameIdx % frames.length] ?? frames[0] ?? "⠿"; + frameIdx += 1; + process.stdout.write(`\r\x1b[2K${frame} ${text} (${elapsedSec}s)`); + }; + + const timer = setInterval(render, 120); + render(); // immediate feedback + + return { + update: (t: string) => { + text = t; + render(); + }, + stop: (finalText?: string) => { + if (stopped) return; + // Set flag first so any queued render() calls exit early. + // JavaScript is single-threaded, so this is safe: queued callbacks + // run after stop() returns and will see stopped=true immediately. + stopped = true; + clearInterval(timer); + process.stdout.write("\r\x1b[2K"); + if (finalText && finalText.trim()) { + process.stdout.write(finalText); + } + process.stdout.write("\n"); + }, + }; +} + +// ============================================================================ +// Checkup command helpers +// ============================================================================ + +interface CheckupOptions { + checkId: string; + nodeName: string; + output?: string; + upload?: boolean; + project?: string; + json?: boolean; +} + +interface UploadConfig { + apiKey: string; + apiBaseUrl: string; + project: string; +} + +interface UploadSummary { + project: string; + reportId: number; + uploaded: Array<{ checkId: string; filename: string; chunkId: number }>; +} + +/** + * Prepare and validate output directory for checkup reports. + * @returns Output path if valid, null if should exit with error + */ +function prepareOutputDirectory(outputOpt: string | undefined): string | null | undefined { + if (!outputOpt) return undefined; + + const outputDir = expandHomePath(outputOpt); + const outputPath = path.isAbsolute(outputDir) ? outputDir : path.resolve(process.cwd(), outputDir); + + if (!fs.existsSync(outputPath)) { + try { + fs.mkdirSync(outputPath, { recursive: true }); + } catch (e) { + const errAny = e as any; + const code = typeof errAny?.code === "string" ? errAny.code : ""; + const msg = errAny instanceof Error ? errAny.message : String(errAny); + if (code === "EACCES" || code === "EPERM" || code === "ENOENT") { + console.error(`Error: Failed to create output directory: ${outputPath}`); + console.error(`Reason: ${msg}`); + console.error("Tip: choose a writable path, e.g. --output ./reports or --output ~/reports"); + return null; // Signal to exit + } + throw e; + } + } + return outputPath; +} + +/** + * Prepare upload configuration for checkup reports. + * @returns Upload config if valid, null if should exit, undefined if upload not needed + */ +function prepareUploadConfig( + opts: CheckupOptions, + rootOpts: CliOptions, + shouldUpload: boolean, + uploadExplicitlyRequested: boolean +): { config: UploadConfig; projectWasGenerated: boolean } | null | undefined { + if (!shouldUpload) return undefined; + + const { apiKey } = getConfig(rootOpts); + if (!apiKey) { + if (uploadExplicitlyRequested) { + console.error("Error: API key is required for upload"); + console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY"); + return null; // Signal to exit + } + return undefined; // Skip upload silently + } + + const cfg = config.readConfig(); + const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg); + let project = ((opts.project || cfg.defaultProject) || "").trim(); + let projectWasGenerated = false; + + if (!project) { + project = `project_${crypto.randomBytes(6).toString("hex")}`; + projectWasGenerated = true; + try { + config.writeConfig({ defaultProject: project }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + console.error(`Warning: Failed to save generated default project: ${message}`); + } + } + + return { + config: { apiKey, apiBaseUrl, project }, + projectWasGenerated, + }; +} + +/** + * Upload checkup reports to PostgresAI API. + */ +async function uploadCheckupReports( + uploadCfg: UploadConfig, + reports: Record, + spinner: ReturnType, + logUpload: (msg: string) => void +): Promise { + spinner.update("Creating remote checkup report"); + const created = await withRetry( + () => createCheckupReport({ + apiKey: uploadCfg.apiKey, + apiBaseUrl: uploadCfg.apiBaseUrl, + project: uploadCfg.project, + }), + { maxAttempts: 3 }, + (attempt, err, delayMs) => { + const errMsg = err instanceof Error ? err.message : String(err); + logUpload(`[Retry ${attempt}/3] createCheckupReport failed: ${errMsg}, retrying in ${delayMs}ms...`); + } + ); + + const reportId = created.reportId; + logUpload(`Created remote checkup report: ${reportId}`); + + const uploaded: Array<{ checkId: string; filename: string; chunkId: number }> = []; + for (const [checkId, report] of Object.entries(reports)) { + spinner.update(`Uploading ${checkId}.json`); + const jsonText = JSON.stringify(report, null, 2); + const r = await withRetry( + () => uploadCheckupReportJson({ + apiKey: uploadCfg.apiKey, + apiBaseUrl: uploadCfg.apiBaseUrl, + reportId, + filename: `${checkId}.json`, + checkId, + jsonText, + }), + { maxAttempts: 3 }, + (attempt, err, delayMs) => { + const errMsg = err instanceof Error ? err.message : String(err); + logUpload(`[Retry ${attempt}/3] Upload ${checkId}.json failed: ${errMsg}, retrying in ${delayMs}ms...`); + } + ); + uploaded.push({ checkId, filename: `${checkId}.json`, chunkId: r.reportChunkId }); + } + logUpload("Upload completed"); + + return { project: uploadCfg.project, reportId, uploaded }; +} + +/** + * Write checkup reports to files. + */ +function writeReportFiles(reports: Record, outputPath: string): void { + for (const [checkId, report] of Object.entries(reports)) { + const filePath = path.join(outputPath, `${checkId}.json`); + fs.writeFileSync(filePath, JSON.stringify(report, null, 2), "utf8"); + console.log(`✓ ${checkId}: ${filePath}`); + } +} + +/** + * Print upload summary to console. + */ +function printUploadSummary( + summary: UploadSummary, + projectWasGenerated: boolean, + useStderr: boolean +): void { + const out = useStderr ? console.error : console.log; + out("\nCheckup report uploaded"); + out("======================\n"); + if (projectWasGenerated) { + out(`Project: ${summary.project} (generated and saved as default)`); + } else { + out(`Project: ${summary.project}`); + } + out(`Report ID: ${summary.reportId}`); + out("View in Console: console.postgres.ai → Support → checkup reports"); + out(""); + out("Files:"); + for (const item of summary.uploaded) { + out(`- ${item.checkId}: ${item.filename}`); + } +} + +// ============================================================================ +// CLI configuration +// ============================================================================ + /** * CLI configuration options */ @@ -286,6 +538,20 @@ program "UI base URL for browser routes (overrides PGAI_UI_BASE_URL)" ); +program + .command("set-default-project ") + .description("store default project for checkup uploads") + .action(async (project: string) => { + const value = (project || "").trim(); + if (!value) { + console.error("Error: project is required"); + process.exitCode = 1; + return; + } + config.writeConfig({ defaultProject: value }); + console.log(`Default project saved: ${value}`); + }); + program .command("prepare-db [conn]") .description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)") @@ -613,6 +879,143 @@ program } }); +program + .command("checkup [conn]") + .description("generate health check reports directly from PostgreSQL (express mode)") + .option("--check-id ", `specific check to run: ${Object.keys(CHECK_INFO).join(", ")}, or ALL`, "ALL") + .option("--node-name ", "node name for reports", "node-01") + .option("--output ", "output directory for JSON files") + .option("--[no-]upload", "upload JSON results to PostgresAI (default: enabled; requires API key)", undefined) + .option( + "--project ", + "project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)" + ) + .option("--json", "output JSON to stdout (implies --no-upload)") + .addHelpText( + "after", + [ + "", + "Available checks:", + ...Object.entries(CHECK_INFO).map(([id, title]) => ` ${id}: ${title}`), + "", + "Examples:", + " postgresai checkup postgresql://user:pass@host:5432/db", + " postgresai checkup postgresql://user:pass@host:5432/db --check-id A003", + " postgresai checkup postgresql://user:pass@host:5432/db --output ./reports", + " postgresai checkup postgresql://user:pass@host:5432/db --project my_project", + " postgresai set-default-project my_project", + " postgresai checkup postgresql://user:pass@host:5432/db", + " postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json", + ].join("\n") + ) + .action(async (conn: string | undefined, opts: CheckupOptions, cmd: Command) => { + if (!conn) { + cmd.outputHelp(); + process.exitCode = 1; + return; + } + + const shouldPrintJson = !!opts.json; + const uploadExplicitlyRequested = opts.upload === true; + const uploadExplicitlyDisabled = opts.upload === false || shouldPrintJson; + let shouldUpload = !uploadExplicitlyDisabled; + + // Preflight: validate/create output directory BEFORE connecting / running checks. + const outputPath = prepareOutputDirectory(opts.output); + if (outputPath === null) { + process.exitCode = 1; + return; + } + + // Preflight: validate upload flags/credentials BEFORE connecting / running checks. + const rootOpts = program.opts() as CliOptions; + const uploadResult = prepareUploadConfig(opts, rootOpts, shouldUpload, uploadExplicitlyRequested); + if (uploadResult === null) { + process.exitCode = 1; + return; + } + const uploadCfg = uploadResult?.config; + const projectWasGenerated = uploadResult?.projectWasGenerated ?? false; + shouldUpload = !!uploadCfg; + + // Connect and run checks + const adminConn = resolveAdminConnection({ + conn, + envPassword: process.env.PGPASSWORD, + }); + let client: Client | undefined; + const spinnerEnabled = !!process.stdout.isTTY && shouldUpload; + const spinner = createTtySpinner(spinnerEnabled, "Connecting to Postgres"); + + try { + spinner.update("Connecting to Postgres"); + const connResult = await connectWithSslFallback(Client, adminConn); + client = connResult.client as Client; + + // Generate reports + let reports: Record; + if (opts.checkId === "ALL") { + reports = await generateAllReports(client, opts.nodeName, (p) => { + spinner.update(`Running ${p.checkId}: ${p.checkTitle} (${p.index}/${p.total})`); + }); + } else { + const checkId = opts.checkId.toUpperCase(); + const generator = REPORT_GENERATORS[checkId]; + if (!generator) { + spinner.stop(); + console.error(`Unknown check ID: ${opts.checkId}`); + console.error(`Available: ${Object.keys(CHECK_INFO).join(", ")}, ALL`); + process.exitCode = 1; + return; + } + spinner.update(`Running ${checkId}: ${CHECK_INFO[checkId] || checkId}`); + reports = { [checkId]: await generator(client, opts.nodeName) }; + } + + // Upload to PostgresAI API (if configured) + let uploadSummary: UploadSummary | undefined; + if (uploadCfg) { + const logUpload = (msg: string): void => { + (shouldPrintJson ? console.error : console.log)(msg); + }; + uploadSummary = await uploadCheckupReports(uploadCfg, reports, spinner, logUpload); + } + + spinner.stop(); + + // Write to files (if output path specified) + if (outputPath) { + writeReportFiles(reports, outputPath); + } + + // Print upload summary + if (uploadSummary) { + printUploadSummary(uploadSummary, projectWasGenerated, shouldPrintJson); + } + + // Output JSON to stdout + if (shouldPrintJson || (!shouldUpload && !opts.output)) { + console.log(JSON.stringify(reports, null, 2)); + } + } catch (error) { + if (error instanceof RpcError) { + for (const line of formatRpcErrorForDisplay(error)) { + console.error(line); + } + } else { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); + } + process.exitCode = 1; + } finally { + // Always stop spinner to prevent interval leak (idempotent - safe to call multiple times) + spinner.stop(); + if (client) { + await client.end(); + } + } + }); + /** * Stub function for not implemented commands */ @@ -1596,8 +1999,21 @@ auth return; } + // Read existing config to check for defaultProject before updating + const existingConfig = config.readConfig(); + const existingProject = existingConfig.defaultProject; + config.writeConfig({ apiKey: trimmedKey }); + // When API key is set directly, only clear orgId (org selection may differ). + // Preserve defaultProject to avoid orphaning historical reports. + // If the new key lacks access to the project, upload will fail with a clear error. + config.deleteConfigKeys(["orgId"]); + console.log(`API key saved to ${config.getConfigPath()}`); + if (existingProject) { + console.log(`Note: Your default project "${existingProject}" has been preserved.`); + console.log(` If this key belongs to a different account, use --project to specify a new one.`); + } return; } @@ -1770,15 +2186,31 @@ auth const orgId = result.org_id || result?.[0]?.result?.org_id; // There is a bug with PostgREST Caching that may return an array, not single object, it's a workaround to support both cases. // Step 6: Save token to config + // Check if org changed to decide whether to preserve defaultProject + const existingConfig = config.readConfig(); + const existingOrgId = existingConfig.orgId; + const existingProject = existingConfig.defaultProject; + const orgChanged = existingOrgId && existingOrgId !== orgId; + config.writeConfig({ apiKey: apiToken, baseUrl: apiBaseUrl, orgId: orgId, }); + + // Only clear defaultProject if org actually changed + if (orgChanged && existingProject) { + config.deleteConfigKeys(["defaultProject"]); + console.log(`\nNote: Organization changed (${existingOrgId} → ${orgId}).`); + console.log(` Default project "${existingProject}" has been cleared.`); + } console.log("\nAuthentication successful!"); console.log(`API key saved to: ${config.getConfigPath()}`); console.log(`Organization ID: ${orgId}`); + if (!orgChanged && existingProject) { + console.log(`Default project: ${existingProject} (preserved)`); + } console.log(`\nYou can now use the CLI without specifying an API key.`); process.exit(0); } catch (err) { diff --git a/cli/bun.lock b/cli/bun.lock index 7f56c74..183ea86 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -14,6 +14,8 @@ "@types/bun": "^1.1.14", "@types/js-yaml": "^4.0.9", "@types/pg": "^8.15.6", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "typescript": "^5.3.3", }, }, @@ -129,7 +131,7 @@ "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], diff --git a/cli/bunfig.toml b/cli/bunfig.toml new file mode 100644 index 0000000..913ab8a --- /dev/null +++ b/cli/bunfig.toml @@ -0,0 +1,11 @@ +# Bun configuration for postgres_ai CLI +# https://bun.sh/docs/runtime/bunfig + +[test] +# Default timeout for all tests (30 seconds) +# Integration tests that connect to databases need longer timeouts +timeout = 30000 + +# Coverage settings (if needed in future) +# coverage = true +# coverageDir = "coverage" diff --git a/cli/lib/checkup-api.ts b/cli/lib/checkup-api.ts new file mode 100644 index 0000000..fe33d86 --- /dev/null +++ b/cli/lib/checkup-api.ts @@ -0,0 +1,386 @@ +import * as https from "https"; +import { URL } from "url"; +import { normalizeBaseUrl } from "./util"; + +/** + * Retry configuration for network operations + */ +export interface RetryConfig { + maxAttempts: number; + initialDelayMs: number; + maxDelayMs: number; + backoffMultiplier: number; +} + +const DEFAULT_RETRY_CONFIG: RetryConfig = { + maxAttempts: 3, + initialDelayMs: 1000, + maxDelayMs: 10000, + backoffMultiplier: 2, +}; + +/** + * Check if an error is retryable (network errors, timeouts, 5xx errors) + */ +function isRetryableError(err: unknown): boolean { + if (err instanceof RpcError) { + // Retry on server errors (5xx), not on client errors (4xx) + return err.statusCode >= 500 && err.statusCode < 600; + } + + // Check for Node.js error codes (works on Error and Error-like objects) + if (typeof err === "object" && err !== null && "code" in err) { + const code = String((err as { code: unknown }).code); + if (["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT"].includes(code)) { + return true; + } + } + + if (err instanceof Error) { + const msg = err.message.toLowerCase(); + // Retry on network-related errors based on message content + return ( + msg.includes("timeout") || + msg.includes("timed out") || + msg.includes("econnreset") || + msg.includes("econnrefused") || + msg.includes("enotfound") || + msg.includes("socket hang up") || + msg.includes("network") + ); + } + + return false; +} + +/** + * Execute an async function with exponential backoff retry. + * Retries on network errors, timeouts, and 5xx server errors. + * Does not retry on 4xx client errors. + * + * @param fn - Async function to execute + * @param config - Optional retry configuration (uses defaults if not provided) + * @param onRetry - Optional callback invoked before each retry attempt + * @returns Promise resolving to the function result + * @throws The last error if all retry attempts fail or error is non-retryable + * + * @example + * ```typescript + * const result = await withRetry( + * () => fetchData(), + * { maxAttempts: 3 }, + * (attempt, err, delay) => console.log(`Retry ${attempt}, waiting ${delay}ms`) + * ); + * ``` + */ +export async function withRetry( + fn: () => Promise, + config: Partial = {}, + onRetry?: (attempt: number, error: unknown, delayMs: number) => void +): Promise { + const { maxAttempts, initialDelayMs, maxDelayMs, backoffMultiplier } = { + ...DEFAULT_RETRY_CONFIG, + ...config, + }; + + let lastError: unknown; + let delayMs = initialDelayMs; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (err) { + lastError = err; + + if (attempt === maxAttempts || !isRetryableError(err)) { + throw err; + } + + if (onRetry) { + onRetry(attempt, err, delayMs); + } + + await new Promise((resolve) => setTimeout(resolve, delayMs)); + delayMs = Math.min(delayMs * backoffMultiplier, maxDelayMs); + } + } + + throw lastError; +} + +/** + * Error thrown when an RPC call to the PostgresAI API fails. + * Contains detailed information about the failure for debugging and display. + */ +export class RpcError extends Error { + /** Name of the RPC endpoint that failed */ + rpcName: string; + /** HTTP status code returned by the server */ + statusCode: number; + /** Raw response body text */ + payloadText: string; + /** Parsed JSON response body, or null if parsing failed */ + payloadJson: any | null; + + constructor(params: { rpcName: string; statusCode: number; payloadText: string; payloadJson: any | null }) { + const { rpcName, statusCode, payloadText, payloadJson } = params; + super(`RPC ${rpcName} failed: HTTP ${statusCode}`); + this.name = "RpcError"; + this.rpcName = rpcName; + this.statusCode = statusCode; + this.payloadText = payloadText; + this.payloadJson = payloadJson; + } +} + +/** + * Format an RpcError for human-readable console display. + * Extracts message, details, and hint from the error payload if available. + * + * @param err - The RpcError to format + * @returns Array of lines suitable for console output + */ +export function formatRpcErrorForDisplay(err: RpcError): string[] { + const lines: string[] = []; + lines.push(`Error: RPC ${err.rpcName} failed: HTTP ${err.statusCode}`); + + const obj = err.payloadJson && typeof err.payloadJson === "object" ? err.payloadJson : null; + const details = obj && typeof (obj as any).details === "string" ? (obj as any).details : ""; + const hint = obj && typeof (obj as any).hint === "string" ? (obj as any).hint : ""; + const message = obj && typeof (obj as any).message === "string" ? (obj as any).message : ""; + + if (message) lines.push(`Message: ${message}`); + if (details) lines.push(`Details: ${details}`); + if (hint) lines.push(`Hint: ${hint}`); + + // Fallback to raw payload if we couldn't extract anything useful. + if (!message && !details && !hint) { + const t = (err.payloadText || "").trim(); + if (t) lines.push(t); + } + return lines; +} + +function unwrapRpcResponse(parsed: unknown): any { + // Some deployments return a plain object, others return an array of rows, + // and some wrap OUT params under a "result" key. + if (Array.isArray(parsed)) { + if (parsed.length === 1) return unwrapRpcResponse(parsed[0]); + return parsed; + } + if (parsed && typeof parsed === "object") { + const obj = parsed as any; + if (obj.result !== undefined) return obj.result; + } + return parsed as any; +} + +// Default timeout for HTTP requests (30 seconds) +const HTTP_TIMEOUT_MS = 30_000; + +async function postRpc(params: { + apiKey: string; + apiBaseUrl: string; + rpcName: string; + bodyObj: Record; + timeoutMs?: number; +}): Promise { + const { apiKey, apiBaseUrl, rpcName, bodyObj, timeoutMs = HTTP_TIMEOUT_MS } = params; + if (!apiKey) throw new Error("API key is required"); + const base = normalizeBaseUrl(apiBaseUrl); + const url = new URL(`${base}/rpc/${rpcName}`); + const body = JSON.stringify(bodyObj); + + const headers: Record = { + // API key is sent in BOTH header and body (see bodyObj.access_token): + // - Header: Used by the API gateway/proxy for HTTP authentication + // - Body: Passed to PostgreSQL RPC function for in-database authorization + // This is intentional for defense-in-depth; backend validates both. + "access-token": apiKey, + "Prefer": "return=representation", + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body).toString(), + }; + + // Use AbortController for clean timeout handling + const controller = new AbortController(); + let timeoutId: ReturnType | null = null; + let settled = false; + + return new Promise((resolve, reject) => { + const settledReject = (err: Error) => { + if (settled) return; + settled = true; + if (timeoutId) clearTimeout(timeoutId); + reject(err); + }; + + const settledResolve = (value: T) => { + if (settled) return; + settled = true; + if (timeoutId) clearTimeout(timeoutId); + resolve(value); + }; + + const req = https.request( + url, + { + method: "POST", + headers, + signal: controller.signal, + }, + (res) => { + // Response started (headers received) - clear the connection timeout. + // Once the server starts responding, we let it complete rather than + // timing out mid-response which would cause confusing errors. + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + try { + const parsed = JSON.parse(data); + settledResolve(unwrapRpcResponse(parsed) as T); + } catch { + settledReject(new Error(`Failed to parse RPC response: ${data}`)); + } + } else { + const statusCode = res.statusCode || 0; + let payloadJson: any | null = null; + if (data) { + try { + payloadJson = JSON.parse(data); + } catch { + payloadJson = null; + } + } + settledReject(new RpcError({ rpcName, statusCode, payloadText: data, payloadJson })); + } + }); + res.on("error", (err) => { + settledReject(err); + }); + } + ); + + // Set up connection timeout - applies until response headers are received. + // Once response starts, timeout is cleared (see response callback above). + timeoutId = setTimeout(() => { + controller.abort(); + req.destroy(); // Backup: ensure request is terminated + settledReject(new Error(`RPC ${rpcName} timed out after ${timeoutMs}ms (no response)`)); + }, timeoutMs); + + req.on("error", (err: Error) => { + // Handle abort as timeout (may already be rejected by timeout handler) + if (err.name === "AbortError" || (err as any).code === "ABORT_ERR") { + settledReject(new Error(`RPC ${rpcName} timed out after ${timeoutMs}ms`)); + return; + } + // Provide clearer error for common network issues + if ((err as any).code === "ECONNREFUSED") { + settledReject(new Error(`RPC ${rpcName} failed: connection refused to ${url.host}`)); + } else if ((err as any).code === "ENOTFOUND") { + settledReject(new Error(`RPC ${rpcName} failed: DNS lookup failed for ${url.host}`)); + } else if ((err as any).code === "ECONNRESET") { + settledReject(new Error(`RPC ${rpcName} failed: connection reset by server`)); + } else { + settledReject(err); + } + }); + + req.write(body); + req.end(); + }); +} + +/** + * Create a new checkup report in the PostgresAI backend. + * This creates the parent report container; individual check results + * are uploaded separately via uploadCheckupReportJson(). + * + * @param params - Configuration for report creation + * @param params.apiKey - PostgresAI API access token + * @param params.apiBaseUrl - Base URL of the PostgresAI API + * @param params.project - Project name or ID to associate the report with + * @param params.status - Optional initial status for the report + * @returns Promise resolving to the created report ID + * @throws {RpcError} On API failures (4xx/5xx responses) + * @throws {Error} On network errors or unexpected response format + */ +export async function createCheckupReport(params: { + apiKey: string; + apiBaseUrl: string; + project: string; + status?: string; +}): Promise<{ reportId: number }> { + const { apiKey, apiBaseUrl, project, status } = params; + const bodyObj: Record = { + access_token: apiKey, + project, + }; + if (status) bodyObj.status = status; + + const resp = await postRpc({ + apiKey, + apiBaseUrl, + rpcName: "checkup_report_create", + bodyObj, + }); + const reportId = Number(resp?.report_id); + if (!Number.isFinite(reportId) || reportId <= 0) { + throw new Error(`Unexpected checkup_report_create response: ${JSON.stringify(resp)}`); + } + return { reportId }; +} + +/** + * Upload a JSON check result to an existing checkup report. + * Each check (e.g., H001, A003) is uploaded as a separate JSON file. + * + * @param params - Configuration for the upload + * @param params.apiKey - PostgresAI API access token + * @param params.apiBaseUrl - Base URL of the PostgresAI API + * @param params.reportId - ID of the parent report (from createCheckupReport) + * @param params.filename - Filename for the uploaded JSON (e.g., "H001.json") + * @param params.checkId - Check identifier (e.g., "H001", "A003") + * @param params.jsonText - JSON content as a string + * @returns Promise resolving to the created report chunk ID + * @throws {RpcError} On API failures (4xx/5xx responses) + * @throws {Error} On network errors or unexpected response format + */ +export async function uploadCheckupReportJson(params: { + apiKey: string; + apiBaseUrl: string; + reportId: number; + filename: string; + checkId: string; + jsonText: string; +}): Promise<{ reportChunkId: number }> { + const { apiKey, apiBaseUrl, reportId, filename, checkId, jsonText } = params; + const bodyObj: Record = { + access_token: apiKey, + checkup_report_id: reportId, + filename, + check_id: checkId, + data: jsonText, + type: "json", + generate_issue: true, + }; + + const resp = await postRpc({ + apiKey, + apiBaseUrl, + rpcName: "checkup_report_file_post", + bodyObj, + }); + // Backend has a typo: "report_chunck_id" (with 'ck') - handle both spellings for compatibility + const chunkId = Number(resp?.report_chunck_id ?? resp?.report_chunk_id); + if (!Number.isFinite(chunkId) || chunkId <= 0) { + throw new Error(`Unexpected checkup_report_file_post response: ${JSON.stringify(resp)}`); + } + return { reportChunkId: chunkId }; +} diff --git a/cli/lib/checkup.ts b/cli/lib/checkup.ts new file mode 100644 index 0000000..5207bd4 --- /dev/null +++ b/cli/lib/checkup.ts @@ -0,0 +1,1327 @@ +/** + * Express Checkup Module + * ====================== + * Generates JSON health check reports directly from PostgreSQL without Prometheus. + * + * ARCHITECTURAL DECISIONS + * ----------------------- + * + * 1. SINGLE SOURCE OF TRUTH FOR SQL QUERIES + * Complex metrics (index health, settings, db_stats) are loaded from + * config/pgwatch-prometheus/metrics.yml via getMetricSql() from metrics-loader.ts. + * + * Simple queries (version, database list, connection states, uptime) use + * inline SQL as they're trivial and CLI-specific. + * + * 2. JSON SCHEMA COMPLIANCE + * All generated reports MUST comply with JSON schemas in reporter/schemas/. + * These schemas define the expected format for both: + * - Full-fledged monitoring reporter output + * - Express checkup output + * + * Before adding or modifying a report, verify the corresponding schema exists + * and ensure the output matches. Run schema validation tests to confirm. + * + * 3. ERROR HANDLING STRATEGY + * Functions follow two patterns based on criticality: + * + * PROPAGATING (throws on error): + * - Core data functions: getPostgresVersion, getSettings, getAlteredSettings, + * getDatabaseSizes, getInvalidIndexes, getUnusedIndexes, getRedundantIndexes + * - If these fail, the entire report should fail (data is required) + * - Callers should handle errors at the report generation level + * + * GRACEFUL DEGRADATION (catches errors, includes error in output): + * - Optional/supplementary queries: pg_stat_statements, pg_stat_kcache checks, + * memory calculations, postmaster startup time + * - These are nice-to-have; missing data shouldn't fail the whole report + * - Errors are logged and included in report output for visibility + * + * ADDING NEW REPORTS + * ------------------ + * 1. Add/verify the metric exists in config/pgwatch-prometheus/metrics.yml + * 2. Add the metric name mapping to METRIC_NAMES in metrics-loader.ts + * 3. Verify JSON schema exists in reporter/schemas/{CHECK_ID}.schema.json + * 4. Implement the generator function using getMetricSql() + * 5. Add schema validation test in test/schema-validation.test.ts + */ + +import { Client } from "pg"; +import * as fs from "fs"; +import * as path from "path"; +import * as pkg from "../package.json"; +import { getMetricSql, transformMetricRow, METRIC_NAMES } from "./metrics-loader"; + +// Time constants +const SECONDS_PER_DAY = 86400; +const SECONDS_PER_HOUR = 3600; +const SECONDS_PER_MINUTE = 60; + +/** + * Convert various boolean representations to boolean. + * PostgreSQL returns booleans as true/false, 1/0, 't'/'f', or 'true'/'false' + * depending on context (query result, JDBC driver, etc.). + */ +function toBool(val: unknown): boolean { + return val === true || val === 1 || val === "t" || val === "true"; +} + +/** + * PostgreSQL version information + */ +export interface PostgresVersion { + version: string; + server_version_num: string; + server_major_ver: string; + server_minor_ver: string; +} + +/** + * Setting information from pg_settings + */ +export interface SettingInfo { + setting: string; + unit: string; + category: string; + context: string; + vartype: string; + pretty_value: string; +} + +/** + * Altered setting (A007) - subset of SettingInfo + */ +export interface AlteredSetting { + value: string; + unit: string; + category: string; + pretty_value: string; +} + +/** + * Cluster metric (A004) + */ +export interface ClusterMetric { + value: string; + unit: string; + description: string; +} + +/** + * Invalid index entry (H001) - matches H001.schema.json invalidIndex + */ +export interface InvalidIndex { + schema_name: string; + table_name: string; + index_name: string; + relation_name: string; + index_size_bytes: number; + index_size_pretty: string; + supports_fk: boolean; +} + +/** + * Unused index entry (H002) - matches H002.schema.json unusedIndex + */ +export interface UnusedIndex { + schema_name: string; + table_name: string; + index_name: string; + index_definition: string; + reason: string; + idx_scan: number; + index_size_bytes: number; + idx_is_btree: boolean; + supports_fk: boolean; + index_size_pretty: string; +} + +/** + * Stats reset info for H002 - matches H002.schema.json statsReset + */ +export interface StatsReset { + stats_reset_epoch: number | null; + stats_reset_time: string | null; + days_since_reset: number | null; + postmaster_startup_epoch: number | null; + postmaster_startup_time: string | null; + /** Set when postmaster startup time query fails - indicates data availability issue */ + postmaster_startup_error?: string; +} + +/** + * Redundant index entry (H004) - matches H004.schema.json redundantIndex + */ +/** + * Index that makes another index redundant. + * Used in redundant_to array to show which indexes this one is redundant to. + */ +export interface RedundantToIndex { + index_name: string; + index_definition: string; + index_size_bytes: number; + index_size_pretty: string; +} + +export interface RedundantIndex { + schema_name: string; + table_name: string; + index_name: string; + relation_name: string; + access_method: string; + reason: string; + index_size_bytes: number; + table_size_bytes: number; + index_usage: number; + supports_fk: boolean; + index_definition: string; + index_size_pretty: string; + table_size_pretty: string; + redundant_to: RedundantToIndex[]; + /** Set when redundant_to_json parsing fails - indicates data quality issue */ + redundant_to_parse_error?: string; +} + +/** + * Node result for reports + */ +export interface NodeResult { + data: Record; + postgres_version?: PostgresVersion; +} + +/** + * Report structure matching JSON schemas + */ +export interface Report { + version: string | null; + build_ts: string | null; + generation_mode: string | null; + checkId: string; + checkTitle: string; + timestamptz: string; + nodes: { + primary: string; + standbys: string[]; + }; + results: Record; +} + +/** + * Parse PostgreSQL version number into major and minor components + */ +export function parseVersionNum(versionNum: string): { major: string; minor: string } { + if (!versionNum || versionNum.length < 6) { + return { major: "", minor: "" }; + } + try { + const num = parseInt(versionNum, 10); + return { + major: Math.floor(num / 10000).toString(), + minor: (num % 10000).toString(), + }; + } catch (err) { + // parseInt shouldn't throw, but handle edge cases defensively + const errorMsg = err instanceof Error ? err.message : String(err); + console.log(`[parseVersionNum] Warning: Failed to parse "${versionNum}": ${errorMsg}`); + return { major: "", minor: "" }; + } +} + +/** + * Format bytes to human readable string using binary units (1024-based). + * Uses IEC standard: KiB, MiB, GiB, etc. + * + * Note: PostgreSQL's pg_size_pretty() uses kB/MB/GB with 1024 base (technically + * incorrect SI usage), but we follow IEC binary units per project style guide. + */ +export function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + if (bytes < 0) return `-${formatBytes(-bytes)}`; // Handle negative values + if (!Number.isFinite(bytes)) return `${bytes} B`; // Handle NaN/Infinity + const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`; +} + +/** + * Format a setting's pretty value from the normalized value and unit. + * The settings metric provides setting_normalized (bytes or seconds) and unit_normalized. + */ +function formatSettingPrettyValue( + settingNormalized: number | null, + unitNormalized: string | null, + rawValue: string +): string { + if (settingNormalized === null || unitNormalized === null) { + return rawValue; + } + + if (unitNormalized === "bytes") { + return formatBytes(settingNormalized); + } + + if (unitNormalized === "seconds") { + // Format time values with appropriate units based on magnitude: + // - Sub-second values (< 1s): show in milliseconds for precision + // - Small values (< 60s): show in seconds + // - Larger values (>= 60s): show in minutes for readability + const MS_PER_SECOND = 1000; + if (settingNormalized < 1) { + return `${(settingNormalized * MS_PER_SECOND).toFixed(0)} ms`; + } else if (settingNormalized < SECONDS_PER_MINUTE) { + return `${settingNormalized} s`; + } else { + return `${(settingNormalized / SECONDS_PER_MINUTE).toFixed(1)} min`; + } + } + + return rawValue; +} + +/** + * Get PostgreSQL version information. + * Uses simple inline SQL (trivial query, CLI-specific). + * + * @throws {Error} If database query fails (propagating - critical data) + */ +export async function getPostgresVersion(client: Client): Promise { + const result = await client.query(` + select name, setting + from pg_settings + where name in ('server_version', 'server_version_num') + `); + + let version = ""; + let serverVersionNum = ""; + + for (const row of result.rows) { + if (row.name === "server_version") { + version = row.setting; + } else if (row.name === "server_version_num") { + serverVersionNum = row.setting; + } + } + + const { major, minor } = parseVersionNum(serverVersionNum); + + return { + version, + server_version_num: serverVersionNum, + server_major_ver: major, + server_minor_ver: minor, + }; +} + +/** + * Get all PostgreSQL settings + * Uses 'settings' metric from metrics.yml + */ +export async function getSettings(client: Client, pgMajorVersion: number = 16): Promise> { + const sql = getMetricSql(METRIC_NAMES.settings, pgMajorVersion); + const result = await client.query(sql); + const settings: Record = {}; + + for (const row of result.rows) { + // The settings metric uses tag_setting_name, tag_setting_value, etc. + const name = row.tag_setting_name; + const settingValue = row.tag_setting_value; + const unit = row.tag_unit || ""; + const category = row.tag_category || ""; + const vartype = row.tag_vartype || ""; + const settingNormalized = row.setting_normalized !== null ? parseFloat(row.setting_normalized) : null; + const unitNormalized = row.unit_normalized || null; + + settings[name] = { + setting: settingValue, + unit, + category, + context: "", // Not available in the monitoring metric + vartype, + pretty_value: formatSettingPrettyValue(settingNormalized, unitNormalized, settingValue), + }; + } + + return settings; +} + +/** + * Get altered (non-default) PostgreSQL settings + * Uses 'settings' metric from metrics.yml and filters for non-default + */ +export async function getAlteredSettings(client: Client, pgMajorVersion: number = 16): Promise> { + const sql = getMetricSql(METRIC_NAMES.settings, pgMajorVersion); + const result = await client.query(sql); + const settings: Record = {}; + + for (const row of result.rows) { + // Filter for non-default settings (is_default = 0 means non-default) + if (!toBool(row.is_default)) { + const name = row.tag_setting_name; + const settingValue = row.tag_setting_value; + const unit = row.tag_unit || ""; + const category = row.tag_category || ""; + const settingNormalized = row.setting_normalized !== null ? parseFloat(row.setting_normalized) : null; + const unitNormalized = row.unit_normalized || null; + + settings[name] = { + value: settingValue, + unit, + category, + pretty_value: formatSettingPrettyValue(settingNormalized, unitNormalized, settingValue), + }; + } + } + + return settings; +} + +/** + * Get database sizes (all non-template databases) + * Uses simple inline SQL (lists all databases, CLI-specific) + */ +export async function getDatabaseSizes(client: Client): Promise> { + const result = await client.query(` + select + datname, + pg_database_size(datname) as size_bytes + from pg_database + where datistemplate = false + order by size_bytes desc + `); + const sizes: Record = {}; + + for (const row of result.rows) { + sizes[row.datname] = parseInt(row.size_bytes, 10); + } + + return sizes; +} + +/** + * Get cluster general info metrics + * Uses 'db_stats' metric and inline SQL for connection states/uptime + */ +export async function getClusterInfo(client: Client, pgMajorVersion: number = 16): Promise> { + const info: Record = {}; + + // Get database statistics from db_stats metric + const dbStatsSql = getMetricSql(METRIC_NAMES.dbStats, pgMajorVersion); + const statsResult = await client.query(dbStatsSql); + if (statsResult.rows.length > 0) { + const stats = statsResult.rows[0]; + + info.total_connections = { + value: String(stats.numbackends || 0), + unit: "connections", + description: "Current database connections", + }; + + info.total_commits = { + value: String(stats.xact_commit || 0), + unit: "transactions", + description: "Total committed transactions", + }; + + info.total_rollbacks = { + value: String(stats.xact_rollback || 0), + unit: "transactions", + description: "Total rolled back transactions", + }; + + const blocksHit = parseInt(stats.blks_hit || "0", 10); + const blocksRead = parseInt(stats.blks_read || "0", 10); + const totalBlocks = blocksHit + blocksRead; + const cacheHitRatio = totalBlocks > 0 ? ((blocksHit / totalBlocks) * 100).toFixed(2) : "0.00"; + + info.cache_hit_ratio = { + value: cacheHitRatio, + unit: "%", + description: "Buffer cache hit ratio", + }; + + info.blocks_read = { + value: String(blocksRead), + unit: "blocks", + description: "Total disk blocks read", + }; + + info.blocks_hit = { + value: String(blocksHit), + unit: "blocks", + description: "Total buffer cache hits", + }; + + info.tuples_returned = { + value: String(stats.tup_returned || 0), + unit: "rows", + description: "Total rows returned by queries", + }; + + info.tuples_fetched = { + value: String(stats.tup_fetched || 0), + unit: "rows", + description: "Total rows fetched by queries", + }; + + info.tuples_inserted = { + value: String(stats.tup_inserted || 0), + unit: "rows", + description: "Total rows inserted", + }; + + info.tuples_updated = { + value: String(stats.tup_updated || 0), + unit: "rows", + description: "Total rows updated", + }; + + info.tuples_deleted = { + value: String(stats.tup_deleted || 0), + unit: "rows", + description: "Total rows deleted", + }; + + info.total_deadlocks = { + value: String(stats.deadlocks || 0), + unit: "deadlocks", + description: "Total deadlocks detected", + }; + + info.temp_files_created = { + value: String(stats.temp_files || 0), + unit: "files", + description: "Total temporary files created", + }; + + const tempBytes = parseInt(stats.temp_bytes || "0", 10); + info.temp_bytes_written = { + value: formatBytes(tempBytes), + unit: "bytes", + description: "Total temporary file bytes written", + }; + + // Uptime from db_stats + if (stats.postmaster_uptime_s) { + const uptimeSeconds = parseInt(stats.postmaster_uptime_s, 10); + const days = Math.floor(uptimeSeconds / SECONDS_PER_DAY); + const hours = Math.floor((uptimeSeconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR); + const minutes = Math.floor((uptimeSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE); + info.uptime = { + value: `${days} days ${hours}:${String(minutes).padStart(2, "0")}:${String(uptimeSeconds % SECONDS_PER_MINUTE).padStart(2, "0")}`, + unit: "interval", + description: "Server uptime", + }; + } + } + + // Get connection states (simple inline SQL) + const connResult = await client.query(` + select + coalesce(state, 'null') as state, + count(*) as count + from pg_stat_activity + group by state + `); + for (const row of connResult.rows) { + const stateKey = `connections_${row.state.replace(/\s+/g, "_")}`; + info[stateKey] = { + value: String(row.count), + unit: "connections", + description: `Connections in '${row.state}' state`, + }; + } + + // Get uptime info (simple inline SQL) + const uptimeResult = await client.query(` + select + pg_postmaster_start_time() as start_time, + current_timestamp - pg_postmaster_start_time() as uptime + `); + if (uptimeResult.rows.length > 0) { + const uptime = uptimeResult.rows[0]; + const startTime = uptime.start_time instanceof Date + ? uptime.start_time.toISOString() + : String(uptime.start_time); + info.start_time = { + value: startTime, + unit: "timestamp", + description: "PostgreSQL server start time", + }; + if (!info.uptime) { + info.uptime = { + value: String(uptime.uptime), + unit: "interval", + description: "Server uptime", + }; + } + } + + return info; +} + +/** + * Get invalid indexes from the database (H001). + * Invalid indexes are indexes that failed to build (e.g., due to CONCURRENTLY failure). + * + * @param client - Connected PostgreSQL client + * @param pgMajorVersion - PostgreSQL major version (default: 16) + * @returns Array of invalid index entries with size and FK support info + */ +export async function getInvalidIndexes(client: Client, pgMajorVersion: number = 16): Promise { + const sql = getMetricSql(METRIC_NAMES.H001, pgMajorVersion); + const result = await client.query(sql); + return result.rows.map((row) => { + const transformed = transformMetricRow(row); + const indexSizeBytes = parseInt(String(transformed.index_size_bytes || 0), 10); + return { + schema_name: String(transformed.schema_name || ""), + table_name: String(transformed.table_name || ""), + index_name: String(transformed.index_name || ""), + relation_name: String(transformed.relation_name || ""), + index_size_bytes: indexSizeBytes, + index_size_pretty: formatBytes(indexSizeBytes), + supports_fk: toBool(transformed.supports_fk), + }; + }); +} + +/** + * Get unused indexes from the database (H002). + * Unused indexes have zero scans since stats were last reset. + * + * @param client - Connected PostgreSQL client + * @param pgMajorVersion - PostgreSQL major version (default: 16) + * @returns Array of unused index entries with scan counts and FK support info + */ +export async function getUnusedIndexes(client: Client, pgMajorVersion: number = 16): Promise { + const sql = getMetricSql(METRIC_NAMES.H002, pgMajorVersion); + const result = await client.query(sql); + return result.rows.map((row) => { + const transformed = transformMetricRow(row); + const indexSizeBytes = parseInt(String(transformed.index_size_bytes || 0), 10); + return { + schema_name: String(transformed.schema_name || ""), + table_name: String(transformed.table_name || ""), + index_name: String(transformed.index_name || ""), + index_definition: String(transformed.index_definition || ""), + reason: String(transformed.reason || ""), + idx_scan: parseInt(String(transformed.idx_scan || 0), 10), + index_size_bytes: indexSizeBytes, + idx_is_btree: toBool(transformed.idx_is_btree), + supports_fk: toBool(transformed.supports_fk), + index_size_pretty: formatBytes(indexSizeBytes), + }; + }); +} + +/** + * Get stats reset info (H002) + * SQL loaded from config/pgwatch-prometheus/metrics.yml (stats_reset) + */ +export async function getStatsReset(client: Client, pgMajorVersion: number = 16): Promise { + const sql = getMetricSql(METRIC_NAMES.statsReset, pgMajorVersion); + const result = await client.query(sql); + const row = result.rows[0] || {}; + + // The stats_reset metric returns stats_reset_epoch and seconds_since_reset + // We need to calculate additional fields + const statsResetEpoch = row.stats_reset_epoch ? parseFloat(row.stats_reset_epoch) : null; + const secondsSinceReset = row.seconds_since_reset ? parseInt(row.seconds_since_reset, 10) : null; + + // Calculate stats_reset_time from epoch + const statsResetTime = statsResetEpoch + ? new Date(statsResetEpoch * 1000).toISOString() + : null; + + // Calculate days since reset + const daysSinceReset = secondsSinceReset !== null + ? Math.floor(secondsSinceReset / SECONDS_PER_DAY) + : null; + + // Get postmaster startup time separately (simple inline SQL) + // This is supplementary data - errors are captured in output, not propagated + let postmasterStartupEpoch: number | null = null; + let postmasterStartupTime: string | null = null; + let postmasterStartupError: string | undefined; + try { + const pmResult = await client.query(` + select + extract(epoch from pg_postmaster_start_time()) as postmaster_startup_epoch, + pg_postmaster_start_time()::text as postmaster_startup_time + `); + if (pmResult.rows.length > 0) { + postmasterStartupEpoch = pmResult.rows[0].postmaster_startup_epoch + ? parseFloat(pmResult.rows[0].postmaster_startup_epoch) + : null; + postmasterStartupTime = pmResult.rows[0].postmaster_startup_time || null; + } + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + postmasterStartupError = `Failed to query postmaster start time: ${errorMsg}`; + console.log(`[getStatsReset] Warning: ${postmasterStartupError}`); + } + + const statsResult: StatsReset = { + stats_reset_epoch: statsResetEpoch, + stats_reset_time: statsResetTime, + days_since_reset: daysSinceReset, + postmaster_startup_epoch: postmasterStartupEpoch, + postmaster_startup_time: postmasterStartupTime, + }; + + // Only include error field if there was an error (keeps output clean) + if (postmasterStartupError) { + statsResult.postmaster_startup_error = postmasterStartupError; + } + + return statsResult; +} + +/** + * Get current database name and size + * Uses 'db_size' metric from metrics.yml + */ +export async function getCurrentDatabaseInfo(client: Client, pgMajorVersion: number = 16): Promise<{ datname: string; size_bytes: number }> { + const sql = getMetricSql(METRIC_NAMES.dbSize, pgMajorVersion); + const result = await client.query(sql); + const row = result.rows[0] || {}; + + // db_size metric returns tag_datname and size_b + return { + datname: row.tag_datname || "postgres", + size_bytes: parseInt(row.size_b || "0", 10), + }; +} + +/** + * Type guard to validate redundant_to_json item structure. + * Returns true if item is a valid object (may have expected properties). + */ +function isValidRedundantToItem(item: unknown): item is Record { + return typeof item === "object" && item !== null && !Array.isArray(item); +} + +/** + * Get redundant indexes from the database (H004). + * Redundant indexes are covered by other indexes (same leading columns). + * + * @param client - Connected PostgreSQL client + * @param pgMajorVersion - PostgreSQL major version (default: 16) + * @returns Array of redundant index entries with covering index info + */ +export async function getRedundantIndexes(client: Client, pgMajorVersion: number = 16): Promise { + const sql = getMetricSql(METRIC_NAMES.H004, pgMajorVersion); + const result = await client.query(sql); + return result.rows.map((row) => { + const transformed = transformMetricRow(row); + const indexSizeBytes = parseInt(String(transformed.index_size_bytes || 0), 10); + const tableSizeBytes = parseInt(String(transformed.table_size_bytes || 0), 10); + + // Parse redundant_to JSON array (indexes that make this one redundant) + let redundantTo: RedundantToIndex[] = []; + let parseError: string | undefined; + try { + const jsonStr = String(transformed.redundant_to_json || "[]"); + const parsed = JSON.parse(jsonStr); + if (Array.isArray(parsed)) { + redundantTo = parsed + .filter(isValidRedundantToItem) + .map((item) => { + const sizeBytes = parseInt(String(item.index_size_bytes ?? 0), 10); + return { + index_name: String(item.index_name ?? ""), + index_definition: String(item.index_definition ?? ""), + index_size_bytes: sizeBytes, + index_size_pretty: formatBytes(sizeBytes), + }; + }); + } + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + const indexName = String(transformed.index_name || "unknown"); + parseError = `Failed to parse redundant_to_json: ${errorMsg}`; + console.log(`[H004] Warning: ${parseError} for index "${indexName}"`); + } + + const result: RedundantIndex = { + schema_name: String(transformed.schema_name || ""), + table_name: String(transformed.table_name || ""), + index_name: String(transformed.index_name || ""), + relation_name: String(transformed.relation_name || ""), + access_method: String(transformed.access_method || ""), + reason: String(transformed.reason || ""), + index_size_bytes: indexSizeBytes, + table_size_bytes: tableSizeBytes, + index_usage: parseInt(String(transformed.index_usage || 0), 10), + supports_fk: toBool(transformed.supports_fk), + index_definition: String(transformed.index_definition || ""), + index_size_pretty: formatBytes(indexSizeBytes), + table_size_pretty: formatBytes(tableSizeBytes), + redundant_to: redundantTo, + }; + + // Only include parse error field if there was an error (keeps output clean) + if (parseError) { + result.redundant_to_parse_error = parseError; + } + + return result; + }); +} + +/** + * Create base report structure + */ +export function createBaseReport( + checkId: string, + checkTitle: string, + nodeName: string +): Report { + const buildTs = resolveBuildTs(); + return { + version: pkg.version || null, + build_ts: buildTs, + generation_mode: "express", + checkId, + checkTitle, + timestamptz: new Date().toISOString(), + nodes: { + primary: nodeName, + standbys: [], + }, + results: {}, + }; +} + +function readTextFileSafe(p: string): string | null { + try { + const value = fs.readFileSync(p, "utf8").trim(); + return value || null; + } catch { + // Intentionally silent: this is a "safe" read that returns null on any error + // (file not found, permission denied, etc.) - used for optional config files + return null; + } +} + +function resolveBuildTs(): string | null { + // Follow reporter.py approach: read BUILD_TS from filesystem, with env override. + // Default: /BUILD_TS (useful in container images). + const envPath = process.env.PGAI_BUILD_TS_FILE; + const p = (envPath && envPath.trim()) ? envPath.trim() : "/BUILD_TS"; + + const fromFile = readTextFileSafe(p); + if (fromFile) return fromFile; + + // Fallback for packaged CLI: allow placing BUILD_TS next to dist/ (package root). + // dist/lib/checkup.js => package root: dist/.. + try { + const pkgRoot = path.resolve(__dirname, ".."); + const fromPkgFile = readTextFileSafe(path.join(pkgRoot, "BUILD_TS")); + if (fromPkgFile) return fromPkgFile; + } catch (err) { + // Path resolution failing is unexpected - warn about it + const errorMsg = err instanceof Error ? err.message : String(err); + console.warn(`[resolveBuildTs] Warning: path resolution failed: ${errorMsg}`); + } + + // Last resort: use package.json mtime as an approximation (non-null, stable-ish). + try { + const pkgJsonPath = path.resolve(__dirname, "..", "package.json"); + const st = fs.statSync(pkgJsonPath); + return st.mtime.toISOString(); + } catch (err) { + // package.json not found is expected in some environments (e.g., bundled) - debug only + if (process.env.DEBUG) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.log(`[resolveBuildTs] Could not stat package.json, using current time: ${errorMsg}`); + } + return new Date().toISOString(); + } +} + +// ============================================================================ +// Unified Report Generator Helpers +// ============================================================================ + +/** + * Generate a simple version report (A002, A013). + * These reports only contain PostgreSQL version information. + */ +async function generateVersionReport( + client: Client, + nodeName: string, + checkId: string, + checkTitle: string +): Promise { + const report = createBaseReport(checkId, checkTitle, nodeName); + const postgresVersion = await getPostgresVersion(client); + report.results[nodeName] = { data: { version: postgresVersion } }; + return report; +} + +/** + * Generate a settings-based report (A003, A007). + * Fetches settings using provided function and includes postgres_version. + */ +async function generateSettingsReport( + client: Client, + nodeName: string, + checkId: string, + checkTitle: string, + fetchSettings: (client: Client, pgMajorVersion: number) => Promise> +): Promise { + const report = createBaseReport(checkId, checkTitle, nodeName); + const postgresVersion = await getPostgresVersion(client); + const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10) || 16; + const settings = await fetchSettings(client, pgMajorVersion); + report.results[nodeName] = { data: settings, postgres_version: postgresVersion }; + return report; +} + +/** + * Generate an index report (H001, H002, H004). + * Common structure: index list + totals + database info, keyed by database name. + */ +async function generateIndexReport( + client: Client, + nodeName: string, + checkId: string, + checkTitle: string, + indexFieldName: string, + fetchIndexes: (client: Client, pgMajorVersion: number) => Promise, + extraFields?: (client: Client, pgMajorVersion: number) => Promise> +): Promise { + const report = createBaseReport(checkId, checkTitle, nodeName); + const postgresVersion = await getPostgresVersion(client); + const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10) || 16; + const indexes = await fetchIndexes(client, pgMajorVersion); + const { datname: dbName, size_bytes: dbSizeBytes } = await getCurrentDatabaseInfo(client, pgMajorVersion); + + const totalCount = indexes.length; + const totalSizeBytes = indexes.reduce((sum, idx) => sum + idx.index_size_bytes, 0); + + const dbEntry: Record = { + [indexFieldName]: indexes, + total_count: totalCount, + total_size_bytes: totalSizeBytes, + total_size_pretty: formatBytes(totalSizeBytes), + database_size_bytes: dbSizeBytes, + database_size_pretty: formatBytes(dbSizeBytes), + }; + + // Add extra fields if provided (e.g., stats_reset for H002) + if (extraFields) { + Object.assign(dbEntry, await extraFields(client, pgMajorVersion)); + } + + report.results[nodeName] = { data: { [dbName]: dbEntry }, postgres_version: postgresVersion }; + return report; +} + +// ============================================================================ +// Report Generators (using unified helpers) +// ============================================================================ + +/** Generate A002 report - Postgres major version */ +export const generateA002 = (client: Client, nodeName = "node-01") => + generateVersionReport(client, nodeName, "A002", "Postgres major version"); + +/** Generate A003 report - Postgres settings */ +export const generateA003 = (client: Client, nodeName = "node-01") => + generateSettingsReport(client, nodeName, "A003", "Postgres settings", getSettings); + +/** Generate A004 report - Cluster information (custom structure) */ +export async function generateA004(client: Client, nodeName: string = "node-01"): Promise { + const report = createBaseReport("A004", "Cluster information", nodeName); + const postgresVersion = await getPostgresVersion(client); + const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10) || 16; + report.results[nodeName] = { + data: { + general_info: await getClusterInfo(client, pgMajorVersion), + database_sizes: await getDatabaseSizes(client), + }, + postgres_version: postgresVersion, + }; + return report; +} + +/** Generate A007 report - Altered settings */ +export const generateA007 = (client: Client, nodeName = "node-01") => + generateSettingsReport(client, nodeName, "A007", "Altered settings", getAlteredSettings); + +/** Generate A013 report - Postgres minor version */ +export const generateA013 = (client: Client, nodeName = "node-01") => + generateVersionReport(client, nodeName, "A013", "Postgres minor version"); + +/** Generate H001 report - Invalid indexes */ +export const generateH001 = (client: Client, nodeName = "node-01") => + generateIndexReport(client, nodeName, "H001", "Invalid indexes", "invalid_indexes", getInvalidIndexes); + +/** Generate H002 report - Unused indexes (includes stats_reset) */ +export const generateH002 = (client: Client, nodeName = "node-01") => + generateIndexReport(client, nodeName, "H002", "Unused indexes", "unused_indexes", getUnusedIndexes, + async (c, v) => ({ stats_reset: await getStatsReset(c, v) })); + +/** Generate H004 report - Redundant indexes */ +export const generateH004 = (client: Client, nodeName = "node-01") => + generateIndexReport(client, nodeName, "H004", "Redundant indexes", "redundant_indexes", getRedundantIndexes); + +/** + * Generate D004 report - pg_stat_statements and pg_stat_kcache settings. + * + * Uses graceful degradation: extension queries are wrapped in try-catch + * because extensions may not be installed. Errors are included in the + * report output rather than failing the entire report. + */ +async function generateD004(client: Client, nodeName: string): Promise { + const report = createBaseReport("D004", "pg_stat_statements and pg_stat_kcache settings", nodeName); + const postgresVersion = await getPostgresVersion(client); + const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10) || 16; + const allSettings = await getSettings(client, pgMajorVersion); + + // Filter settings related to pg_stat_statements and pg_stat_kcache + const pgssSettings: Record = {}; + for (const [name, setting] of Object.entries(allSettings)) { + if (name.startsWith("pg_stat_statements") || name.startsWith("pg_stat_kcache")) { + pgssSettings[name] = setting; + } + } + + // Check pg_stat_statements extension + let pgssAvailable = false; + let pgssMetricsCount = 0; + let pgssTotalCalls = 0; + let pgssError: string | null = null; + const pgssSampleQueries: Array<{ queryid: string; user: string; database: string; calls: number }> = []; + + try { + const extCheck = await client.query( + "select 1 from pg_extension where extname = 'pg_stat_statements'" + ); + if (extCheck.rows.length > 0) { + pgssAvailable = true; + const statsResult = await client.query(` + select count(*) as cnt, coalesce(sum(calls), 0) as total_calls + from pg_stat_statements + `); + pgssMetricsCount = parseInt(statsResult.rows[0]?.cnt || "0", 10); + pgssTotalCalls = parseInt(statsResult.rows[0]?.total_calls || "0", 10); + + // Get sample queries (top 5 by calls) + const sampleResult = await client.query(` + select + queryid::text as queryid, + coalesce(usename, 'unknown') as "user", + coalesce(datname, 'unknown') as database, + calls + from pg_stat_statements s + left join pg_database d on s.dbid = d.oid + left join pg_user u on s.userid = u.usesysid + order by calls desc + limit 5 + `); + for (const row of sampleResult.rows) { + pgssSampleQueries.push({ + queryid: row.queryid, + user: row.user, + database: row.database, + calls: parseInt(row.calls, 10), + }); + } + } + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.log(`[D004] Error querying pg_stat_statements: ${errorMsg}`); + pgssError = errorMsg; + } + + // Check pg_stat_kcache extension + let kcacheAvailable = false; + let kcacheMetricsCount = 0; + let kcacheTotalExecTime = 0; + let kcacheTotalUserTime = 0; + let kcacheTotalSystemTime = 0; + let kcacheError: string | null = null; + const kcacheSampleQueries: Array<{ queryid: string; user: string; exec_total_time: number }> = []; + + try { + const extCheck = await client.query( + "select 1 from pg_extension where extname = 'pg_stat_kcache'" + ); + if (extCheck.rows.length > 0) { + kcacheAvailable = true; + const statsResult = await client.query(` + select + count(*) as cnt, + coalesce(sum(exec_user_time + exec_system_time), 0) as total_exec_time, + coalesce(sum(exec_user_time), 0) as total_user_time, + coalesce(sum(exec_system_time), 0) as total_system_time + from pg_stat_kcache + `); + kcacheMetricsCount = parseInt(statsResult.rows[0]?.cnt || "0", 10); + kcacheTotalExecTime = parseFloat(statsResult.rows[0]?.total_exec_time || "0"); + kcacheTotalUserTime = parseFloat(statsResult.rows[0]?.total_user_time || "0"); + kcacheTotalSystemTime = parseFloat(statsResult.rows[0]?.total_system_time || "0"); + + // Get sample queries (top 5 by exec time) + const sampleResult = await client.query(` + select + queryid::text as queryid, + coalesce(usename, 'unknown') as "user", + (exec_user_time + exec_system_time) as exec_total_time + from pg_stat_kcache k + left join pg_user u on k.userid = u.usesysid + order by (exec_user_time + exec_system_time) desc + limit 5 + `); + for (const row of sampleResult.rows) { + kcacheSampleQueries.push({ + queryid: row.queryid, + user: row.user, + exec_total_time: parseFloat(row.exec_total_time), + }); + } + } + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.log(`[D004] Error querying pg_stat_kcache: ${errorMsg}`); + kcacheError = errorMsg; + } + + report.results[nodeName] = { + data: { + settings: pgssSettings, + pg_stat_statements_status: { + extension_available: pgssAvailable, + metrics_count: pgssMetricsCount, + total_calls: pgssTotalCalls, + sample_queries: pgssSampleQueries, + ...(pgssError && { error: pgssError }), + }, + pg_stat_kcache_status: { + extension_available: kcacheAvailable, + metrics_count: kcacheMetricsCount, + total_exec_time: kcacheTotalExecTime, + total_user_time: kcacheTotalUserTime, + total_system_time: kcacheTotalSystemTime, + sample_queries: kcacheSampleQueries, + ...(kcacheError && { error: kcacheError }), + }, + }, + postgres_version: postgresVersion, + }; + + return report; +} + +/** + * Generate F001 report - Autovacuum: current settings + */ +async function generateF001(client: Client, nodeName: string): Promise { + const report = createBaseReport("F001", "Autovacuum: current settings", nodeName); + const postgresVersion = await getPostgresVersion(client); + const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10) || 16; + const allSettings = await getSettings(client, pgMajorVersion); + + // Filter autovacuum-related settings + const autovacuumSettings: Record = {}; + for (const [name, setting] of Object.entries(allSettings)) { + if (name.includes("autovacuum") || name.includes("vacuum")) { + autovacuumSettings[name] = setting; + } + } + + report.results[nodeName] = { + data: autovacuumSettings, + postgres_version: postgresVersion, + }; + + return report; +} + +/** + * Generate G001 report - Memory-related settings + */ +async function generateG001(client: Client, nodeName: string): Promise { + const report = createBaseReport("G001", "Memory-related settings", nodeName); + const postgresVersion = await getPostgresVersion(client); + const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10) || 16; + const allSettings = await getSettings(client, pgMajorVersion); + + // Memory-related setting names + const memorySettingNames = [ + "shared_buffers", + "work_mem", + "maintenance_work_mem", + "effective_cache_size", + "wal_buffers", + "temp_buffers", + "max_connections", + "autovacuum_work_mem", + "hash_mem_multiplier", + "logical_decoding_work_mem", + "max_stack_depth", + "max_prepared_transactions", + "max_locks_per_transaction", + "max_pred_locks_per_transaction", + ]; + + const memorySettings: Record = {}; + for (const name of memorySettingNames) { + if (allSettings[name]) { + memorySettings[name] = allSettings[name]; + } + } + + // Calculate memory usage estimates + interface MemoryUsage { + shared_buffers_bytes: number; + shared_buffers_pretty: string; + wal_buffers_bytes: number; + wal_buffers_pretty: string; + shared_memory_total_bytes: number; + shared_memory_total_pretty: string; + work_mem_per_connection_bytes: number; + work_mem_per_connection_pretty: string; + max_work_mem_usage_bytes: number; + max_work_mem_usage_pretty: string; + maintenance_work_mem_bytes: number; + maintenance_work_mem_pretty: string; + effective_cache_size_bytes: number; + effective_cache_size_pretty: string; + } + + let memoryUsage: MemoryUsage | Record = {}; + let memoryError: string | null = null; + + try { + // Get actual byte values from PostgreSQL + const memQuery = await client.query(` + select + pg_size_bytes(current_setting('shared_buffers')) as shared_buffers_bytes, + pg_size_bytes(current_setting('wal_buffers')) as wal_buffers_bytes, + pg_size_bytes(current_setting('work_mem')) as work_mem_bytes, + pg_size_bytes(current_setting('maintenance_work_mem')) as maintenance_work_mem_bytes, + pg_size_bytes(current_setting('effective_cache_size')) as effective_cache_size_bytes, + current_setting('max_connections')::int as max_connections + `); + + if (memQuery.rows.length > 0) { + const row = memQuery.rows[0]; + const sharedBuffersBytes = parseInt(row.shared_buffers_bytes, 10); + const walBuffersBytes = parseInt(row.wal_buffers_bytes, 10); + const workMemBytes = parseInt(row.work_mem_bytes, 10); + const maintenanceWorkMemBytes = parseInt(row.maintenance_work_mem_bytes, 10); + const effectiveCacheSizeBytes = parseInt(row.effective_cache_size_bytes, 10); + const maxConnections = row.max_connections; + + const sharedMemoryTotal = sharedBuffersBytes + walBuffersBytes; + const maxWorkMemUsage = workMemBytes * maxConnections; + + memoryUsage = { + shared_buffers_bytes: sharedBuffersBytes, + shared_buffers_pretty: formatBytes(sharedBuffersBytes), + wal_buffers_bytes: walBuffersBytes, + wal_buffers_pretty: formatBytes(walBuffersBytes), + shared_memory_total_bytes: sharedMemoryTotal, + shared_memory_total_pretty: formatBytes(sharedMemoryTotal), + work_mem_per_connection_bytes: workMemBytes, + work_mem_per_connection_pretty: formatBytes(workMemBytes), + max_work_mem_usage_bytes: maxWorkMemUsage, + max_work_mem_usage_pretty: formatBytes(maxWorkMemUsage), + maintenance_work_mem_bytes: maintenanceWorkMemBytes, + maintenance_work_mem_pretty: formatBytes(maintenanceWorkMemBytes), + effective_cache_size_bytes: effectiveCacheSizeBytes, + effective_cache_size_pretty: formatBytes(effectiveCacheSizeBytes), + }; + } + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.log(`[G001] Error calculating memory usage: ${errorMsg}`); + memoryError = errorMsg; + } + + report.results[nodeName] = { + data: { + settings: memorySettings, + analysis: { + estimated_total_memory_usage: memoryUsage, + ...(memoryError && { error: memoryError }), + }, + }, + postgres_version: postgresVersion, + }; + + return report; +} + +/** + * Available report generators + */ +export const REPORT_GENERATORS: Record Promise> = { + A002: generateA002, + A003: generateA003, + A004: generateA004, + A007: generateA007, + A013: generateA013, + D004: generateD004, + F001: generateF001, + G001: generateG001, + H001: generateH001, + H002: generateH002, + H004: generateH004, +}; + +/** + * Check IDs and titles + */ +export const CHECK_INFO: Record = { + A002: "Postgres major version", + A003: "Postgres settings", + A004: "Cluster information", + A007: "Altered settings", + A013: "Postgres minor version", + D004: "pg_stat_statements and pg_stat_kcache settings", + F001: "Autovacuum: current settings", + G001: "Memory-related settings", + H001: "Invalid indexes", + H002: "Unused indexes", + H004: "Redundant indexes", +}; + +/** + * Generate all available health check reports. + * This is the main entry point for express mode checkup generation. + * + * @param client - Connected PostgreSQL client + * @param nodeName - Node identifier for the report (default: "node-01") + * @param onProgress - Optional callback for progress updates during generation + * @returns Object mapping check IDs (e.g., "H001", "A002") to their reports + * @throws {Error} If any critical report generation fails + */ +export async function generateAllReports( + client: Client, + nodeName: string = "node-01", + onProgress?: (info: { checkId: string; checkTitle: string; index: number; total: number }) => void +): Promise> { + const reports: Record = {}; + + const entries = Object.entries(REPORT_GENERATORS); + const total = entries.length; + let index = 0; + + for (const [checkId, generator] of entries) { + index += 1; + onProgress?.({ + checkId, + checkTitle: CHECK_INFO[checkId] || checkId, + index, + total, + }); + reports[checkId] = await generator(client, nodeName); + } + + return reports; +} diff --git a/cli/lib/config.ts b/cli/lib/config.ts index 7e6f33c..2246030 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -9,6 +9,7 @@ export interface Config { apiKey: string | null; baseUrl: string | null; orgId: number | null; + defaultProject: string | null; } /** @@ -46,6 +47,7 @@ export function readConfig(): Config { apiKey: null, baseUrl: null, orgId: null, + defaultProject: null, }; // Try user-level config first @@ -57,6 +59,7 @@ export function readConfig(): Config { config.apiKey = parsed.apiKey || null; config.baseUrl = parsed.baseUrl || null; config.orgId = parsed.orgId || null; + config.defaultProject = parsed.defaultProject || null; return config; } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/cli/lib/metrics-loader.ts b/cli/lib/metrics-loader.ts new file mode 100644 index 0000000..c8e5acd --- /dev/null +++ b/cli/lib/metrics-loader.ts @@ -0,0 +1,127 @@ +/** + * Metrics loader for express checkup reports + * + * Loads SQL queries from embedded metrics data (generated from metrics.yml at build time). + * Provides version-aware query selection and row transformation utilities. + */ + +import { METRICS, MetricDefinition } from "./metrics-embedded"; + +/** + * Get SQL query for a specific metric, selecting the appropriate version. + * + * @param metricName - Name of the metric (e.g., "settings", "db_stats") + * @param pgMajorVersion - PostgreSQL major version (default: 16) + * @returns SQL query string + * @throws Error if metric not found or no compatible version available + */ +export function getMetricSql(metricName: string, pgMajorVersion: number = 16): string { + const metric = METRICS[metricName]; + + if (!metric) { + throw new Error(`Metric "${metricName}" not found. Available metrics: ${Object.keys(METRICS).join(", ")}`); + } + + // Find the best matching version: highest version <= pgMajorVersion + const availableVersions = Object.keys(metric.sqls) + .map(v => parseInt(v, 10)) + .sort((a, b) => b - a); // Sort descending + + const matchingVersion = availableVersions.find(v => v <= pgMajorVersion); + + if (matchingVersion === undefined) { + throw new Error( + `No compatible SQL version for metric "${metricName}" with PostgreSQL ${pgMajorVersion}. ` + + `Available versions: ${availableVersions.join(", ")}` + ); + } + + return metric.sqls[matchingVersion]; +} + +/** + * Get metric definition including all metadata. + * + * @param metricName - Name of the metric + * @returns MetricDefinition or undefined if not found + */ +export function getMetricDefinition(metricName: string): MetricDefinition | undefined { + return METRICS[metricName]; +} + +/** + * List all available metric names. + */ +export function listMetricNames(): string[] { + return Object.keys(METRICS); +} + +/** + * Metric names that correspond to express report checks. + * Maps check IDs and logical names to metric names in the METRICS object. + */ +export const METRIC_NAMES = { + // Index health checks + H001: "pg_invalid_indexes", + H002: "unused_indexes", + H004: "redundant_indexes", + // Settings and version info (A002, A003, A007, A013) + settings: "settings", + // Database statistics (A004) + dbStats: "db_stats", + dbSize: "db_size", + // Stats reset info (H002) + statsReset: "stats_reset", +} as const; + +/** + * Transform a row from metrics query output to JSON report format. + * Metrics use `tag_` prefix for dimensions; we strip it for JSON reports. + * Also removes Prometheus-specific fields like epoch_ns, num, tag_datname. + */ +export function transformMetricRow(row: Record): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(row)) { + // Skip Prometheus-specific fields + if (key === "epoch_ns" || key === "num" || key === "tag_datname") { + continue; + } + + // Strip tag_ prefix + const newKey = key.startsWith("tag_") ? key.slice(4) : key; + result[newKey] = value; + } + + return result; +} + +/** + * Transform settings metric row to the format expected by express reports. + * The settings metric returns one row per setting with tag_setting_name as key. + */ +export function transformSettingsRow(row: Record): { + name: string; + setting: string; + unit: string; + category: string; + vartype: string; + is_default: boolean; +} { + return { + name: String(row.tag_setting_name || ""), + setting: String(row.tag_setting_value || ""), + unit: String(row.tag_unit || ""), + category: String(row.tag_category || ""), + vartype: String(row.tag_vartype || ""), + is_default: row.is_default === 1 || row.is_default === true, + }; +} + +// Re-export types for convenience +export type { MetricDefinition } from "./metrics-embedded"; + +// Legacy export for backward compatibility +export function loadMetricsYml(): { metrics: Record } { + return { metrics: METRICS }; +} diff --git a/cli/package.json b/cli/package.json index 1e4e2bf..9df00e9 100644 --- a/cli/package.json +++ b/cli/package.json @@ -13,22 +13,26 @@ "url": "https://gitlab.com/postgres-ai/postgres_ai/-/issues" }, "bin": { - "postgres-ai": "./dist/bin/postgres-ai.js", "postgresai": "./dist/bin/postgres-ai.js", "pgai": "./dist/bin/postgres-ai.js" }, + "exports": { + ".": "./dist/bin/postgres-ai.js", + "./cli": "./dist/bin/postgres-ai.js" + }, "type": "module", "engines": { "node": ">=18" }, "scripts": { - "build": "bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e \"const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))\" && cp -r sql dist/", + "embed-metrics": "bun run scripts/embed-metrics.ts", + "build": "bun run embed-metrics && bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e \"const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))\"", "prepublishOnly": "npm run build", "start": "bun ./bin/postgres-ai.ts --help", "start:node": "node ./dist/bin/postgres-ai.js --help", - "dev": "bun --watch ./bin/postgres-ai.ts", - "test": "bun test", - "typecheck": "bunx tsc --noEmit" + "dev": "bun run embed-metrics && bun --watch ./bin/postgres-ai.ts", + "test": "bun run embed-metrics && bun test", + "typecheck": "bun run embed-metrics && bunx tsc --noEmit" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.20.2", @@ -40,6 +44,8 @@ "@types/bun": "^1.1.14", "@types/js-yaml": "^4.0.9", "@types/pg": "^8.15.6", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "typescript": "^5.3.3" }, "publishConfig": { diff --git a/cli/packages/postgres-ai/README.md b/cli/packages/postgres-ai/README.md new file mode 100644 index 0000000..397bf32 --- /dev/null +++ b/cli/packages/postgres-ai/README.md @@ -0,0 +1,26 @@ +# postgres-ai + +This is a wrapper package for [postgresai](https://www.npmjs.com/package/postgresai). + +## Prefer installing postgresai directly + +```bash +npm install -g postgresai +``` + +This gives you two commands: +- `postgresai` — canonical, discoverable +- `pgai` — short and convenient + +## Why this package exists + +This package exists for discoverability on npm. If you search for "postgres-ai", you'll find this package which depends on and forwards to `postgresai`. + +Installing this package (`npm install -g postgres-ai`) will install both packages, giving you all three command aliases: +- `postgres-ai` (from this package) +- `postgresai` (from the main package) +- `pgai` (from the main package) + +## Documentation + +See the main package for full documentation: https://www.npmjs.com/package/postgresai diff --git a/cli/packages/postgres-ai/bin/postgres-ai.js b/cli/packages/postgres-ai/bin/postgres-ai.js new file mode 100644 index 0000000..1f09e38 --- /dev/null +++ b/cli/packages/postgres-ai/bin/postgres-ai.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node +/** + * postgres-ai wrapper - forwards all commands to postgresai CLI + * + * This package exists for discoverability. For direct installation, + * prefer: npm install -g postgresai + */ +const { spawn } = require('child_process'); + +// Find postgresai binary from the dependency +// Uses the "cli" export defined in postgresai's package.json +const postgresaiBin = require.resolve('postgresai/cli'); + +// Forward all arguments to postgresai +const child = spawn(process.execPath, [postgresaiBin, ...process.argv.slice(2)], { + stdio: 'inherit', + env: process.env, +}); + +child.on('close', (code) => { + process.exit(code ?? 0); +}); + +child.on('error', (err) => { + console.error(`Failed to start postgresai: ${err.message}`); + process.exit(1); +}); diff --git a/cli/packages/postgres-ai/package.json b/cli/packages/postgres-ai/package.json new file mode 100644 index 0000000..668bba6 --- /dev/null +++ b/cli/packages/postgres-ai/package.json @@ -0,0 +1,27 @@ +{ + "name": "postgres-ai", + "version": "0.0.0-dev.0", + "description": "PostgresAI CLI (wrapper package - prefer installing postgresai directly)", + "license": "Apache-2.0", + "private": false, + "repository": { + "type": "git", + "url": "git+https://gitlab.com/postgres-ai/postgres_ai.git" + }, + "homepage": "https://gitlab.com/postgres-ai/postgres_ai", + "bugs": { + "url": "https://gitlab.com/postgres-ai/postgres_ai/-/issues" + }, + "bin": { + "postgres-ai": "./bin/postgres-ai.js" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "postgresai": ">=0.12.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/cli/scripts/embed-metrics.ts b/cli/scripts/embed-metrics.ts new file mode 100644 index 0000000..b4b614f --- /dev/null +++ b/cli/scripts/embed-metrics.ts @@ -0,0 +1,154 @@ +#!/usr/bin/env bun +/** + * Build script to embed metrics.yml into the CLI bundle. + * + * This script reads config/pgwatch-prometheus/metrics.yml and generates + * cli/lib/metrics-embedded.ts with the metrics data embedded as TypeScript. + * + * The generated file is NOT committed to git - it's regenerated at build time. + * + * Usage: bun run scripts/embed-metrics.ts + */ + +import * as fs from "fs"; +import * as path from "path"; +import * as yaml from "js-yaml"; + +// Resolve paths relative to cli/ directory +const CLI_DIR = path.resolve(__dirname, ".."); +const METRICS_YML_PATH = path.resolve(CLI_DIR, "../config/pgwatch-prometheus/metrics.yml"); +const OUTPUT_PATH = path.resolve(CLI_DIR, "lib/metrics-embedded.ts"); + +interface MetricDefinition { + description?: string; + // YAML parses numeric keys (e.g., 11:, 14:) as numbers, representing PG major versions + sqls: Record; + gauges?: string[]; + statement_timeout_seconds?: number; + is_instance_level?: boolean; + node_status?: string; +} + +interface MetricsYml { + metrics: Record; +} + +// Metrics needed for express mode reports +const REQUIRED_METRICS = [ + // Settings and version (A002, A003, A007, A013) + "settings", + // Database stats (A004) + "db_stats", + "db_size", + // Index health (H001, H002, H004) + "pg_invalid_indexes", + "unused_indexes", + "redundant_indexes", + // Stats reset info (H002) + "stats_reset", +]; + +function main() { + console.log(`Reading metrics from: ${METRICS_YML_PATH}`); + + if (!fs.existsSync(METRICS_YML_PATH)) { + console.error(`ERROR: metrics.yml not found at ${METRICS_YML_PATH}`); + process.exit(1); + } + + const yamlContent = fs.readFileSync(METRICS_YML_PATH, "utf8"); + const parsed = yaml.load(yamlContent) as MetricsYml; + + if (!parsed.metrics) { + console.error("ERROR: No 'metrics' section found in metrics.yml"); + process.exit(1); + } + + // Extract only required metrics + const extractedMetrics: Record = {}; + const missingMetrics: string[] = []; + + for (const metricName of REQUIRED_METRICS) { + if (parsed.metrics[metricName]) { + extractedMetrics[metricName] = parsed.metrics[metricName]; + } else { + missingMetrics.push(metricName); + } + } + + if (missingMetrics.length > 0) { + console.error(`ERROR: Missing required metrics: ${missingMetrics.join(", ")}`); + process.exit(1); + } + + // Generate TypeScript code + const tsCode = generateTypeScript(extractedMetrics); + + // Write output + fs.writeFileSync(OUTPUT_PATH, tsCode, "utf8"); + console.log(`Generated: ${OUTPUT_PATH}`); + console.log(`Embedded ${Object.keys(extractedMetrics).length} metrics`); +} + +function generateTypeScript(metrics: Record): string { + const lines: string[] = [ + "// AUTO-GENERATED FILE - DO NOT EDIT", + "// Generated from config/pgwatch-prometheus/metrics.yml by scripts/embed-metrics.ts", + `// Generated at: ${new Date().toISOString()}`, + "", + "/**", + " * Metric definition from metrics.yml", + " */", + "export interface MetricDefinition {", + " description?: string;", + " sqls: Record; // PG major version -> SQL query", + " gauges?: string[];", + " statement_timeout_seconds?: number;", + "}", + "", + "/**", + " * Embedded metrics for express mode reports.", + " * Only includes metrics required for CLI checkup reports.", + " */", + "export const METRICS: Record = {", + ]; + + for (const [name, metric] of Object.entries(metrics)) { + lines.push(` ${JSON.stringify(name)}: {`); + + if (metric.description) { + // Escape description for TypeScript string + const desc = metric.description.trim().replace(/\n/g, " ").replace(/\s+/g, " "); + lines.push(` description: ${JSON.stringify(desc)},`); + } + + // sqls keys are PG major versions (numbers in YAML, but Object.entries returns strings) + lines.push(" sqls: {"); + for (const [versionKey, sql] of Object.entries(metric.sqls)) { + // YAML numeric keys may be parsed as numbers or strings depending on context; + // explicitly convert to ensure consistent numeric keys in output + const versionNum = typeof versionKey === "number" ? versionKey : parseInt(versionKey, 10); + // Use JSON.stringify for robust escaping of all special characters + lines.push(` ${versionNum}: ${JSON.stringify(sql)},`); + } + lines.push(" },"); + + if (metric.gauges) { + lines.push(` gauges: ${JSON.stringify(metric.gauges)},`); + } + + if (metric.statement_timeout_seconds !== undefined) { + lines.push(` statement_timeout_seconds: ${metric.statement_timeout_seconds},`); + } + + lines.push(" },"); + } + + lines.push("};"); + lines.push(""); + + return lines.join("\n"); +} + +main(); + diff --git a/cli/test/checkup.integration.test.ts b/cli/test/checkup.integration.test.ts new file mode 100644 index 0000000..82c44fd --- /dev/null +++ b/cli/test/checkup.integration.test.ts @@ -0,0 +1,273 @@ +/** + * Integration tests for checkup command (express mode) + * Validates that CLI-generated reports match JSON schemas used by the Python reporter. + * This ensures compatibility between "express" and "full" (monitoring) modes. + */ +import { describe, test, expect, afterAll, beforeAll } from "bun:test"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as net from "net"; +import { Client } from "pg"; +import { resolve } from "path"; +import { readFileSync } from "fs"; +import Ajv2020 from "ajv/dist/2020"; + +import * as checkup from "../lib/checkup"; + +const ajv = new Ajv2020({ allErrors: true, strict: false }); +const schemasDir = resolve(import.meta.dir, "../../reporter/schemas"); + +function findOnPath(cmd: string): string | null { + const result = Bun.spawnSync(["sh", "-c", `command -v ${cmd}`]); + if (result.exitCode === 0) { + return new TextDecoder().decode(result.stdout).trim(); + } + return null; +} + +function findPgBin(cmd: string): string | null { + const p = findOnPath(cmd); + if (p) return p; + const probe = Bun.spawnSync([ + "sh", + "-c", + `ls -1 /usr/lib/postgresql/*/bin/${cmd} 2>/dev/null | head -n 1 || true`, + ]); + const out = new TextDecoder().decode(probe.stdout).trim(); + if (out) return out; + return null; +} + +function havePostgresBinaries(): boolean { + return !!(findPgBin("initdb") && findPgBin("postgres")); +} + +function isRunningAsRoot(): boolean { + return process.getuid?.() === 0; +} + +async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address() as net.AddressInfo; + srv.close((err) => { + if (err) return reject(err); + resolve(addr.port); + }); + }); + srv.on("error", reject); + }); +} + +async function waitFor( + fn: () => Promise, + { timeoutMs = 10000, intervalMs = 100 } = {} +): Promise { + const start = Date.now(); + while (true) { + try { + return await fn(); + } catch (e) { + if (Date.now() - start > timeoutMs) throw e; + await new Promise((r) => setTimeout(r, intervalMs)); + } + } +} + +interface TempPostgres { + port: number; + socketDir: string; + cleanup: () => Promise; + connect: (database?: string) => Promise; +} + +async function createTempPostgres(): Promise { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "postgresai-checkup-")); + const dataDir = path.join(tmpRoot, "data"); + const socketDir = path.join(tmpRoot, "sock"); + fs.mkdirSync(socketDir, { recursive: true }); + + const initdb = findPgBin("initdb"); + const postgresBin = findPgBin("postgres"); + if (!initdb || !postgresBin) { + throw new Error("PostgreSQL binaries not found"); + } + + const init = Bun.spawnSync([initdb, "-D", dataDir, "-U", "postgres", "-A", "trust"]); + if (init.exitCode !== 0) { + throw new Error(new TextDecoder().decode(init.stderr) || new TextDecoder().decode(init.stdout)); + } + + const hbaPath = path.join(dataDir, "pg_hba.conf"); + fs.appendFileSync(hbaPath, "\nlocal all all trust\n", "utf8"); + + const port = await getFreePort(); + const postgresProc = Bun.spawn( + [postgresBin, "-D", dataDir, "-k", socketDir, "-h", "127.0.0.1", "-p", String(port)], + { stdio: ["ignore", "pipe", "pipe"] } + ); + + const cleanup = async () => { + postgresProc.kill("SIGTERM"); + try { + await waitFor( + async () => { + if (postgresProc.exitCode === null) throw new Error("still running"); + }, + { timeoutMs: 5000, intervalMs: 100 } + ); + } catch { + postgresProc.kill("SIGKILL"); + } + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }; + + const connect = async (database = "postgres"): Promise => { + const c = new Client({ host: socketDir, port, user: "postgres", database }); + await c.connect(); + return c; + }; + + // Wait for Postgres to start + await waitFor(async () => { + const c = await connect(); + await c.end(); + }); + + return { port, socketDir, cleanup, connect }; +} + +function validateAgainstSchema(report: any, checkId: string): void { + const schemaPath = resolve(schemasDir, `${checkId}.schema.json`); + if (!fs.existsSync(schemaPath)) { + throw new Error(`Schema not found: ${schemaPath}`); + } + const schema = JSON.parse(readFileSync(schemaPath, "utf8")); + const validate = ajv.compile(schema); + const valid = validate(report); + if (!valid) { + const errors = validate.errors?.map(e => `${e.instancePath}: ${e.message}`).join(", "); + throw new Error(`${checkId} schema validation failed: ${errors}`); + } +} + +// Skip tests if PostgreSQL binaries are not available +const skipReason = !havePostgresBinaries() + ? "PostgreSQL binaries not available" + : isRunningAsRoot() + ? "Cannot run as root (PostgreSQL refuses)" + : null; + +// In CI, warn if integration tests are being skipped (helps catch configuration issues) +const isCI = process.env.CI === "true" || process.env.GITLAB_CI === "true"; +if (skipReason && isCI) { + console.warn(`[CI WARNING] Integration tests skipped: ${skipReason}`); + console.warn("This may indicate a CI configuration issue - PostgreSQL binaries should be available."); +} + +describe.skipIf(!!skipReason)("checkup integration: express mode schema compatibility", () => { + let pg: TempPostgres; + let client: Client; + + beforeAll(async () => { + pg = await createTempPostgres(); + client = await pg.connect(); + }); + + afterAll(async () => { + if (client) await client.end(); + if (pg) await pg.cleanup(); + }); + + // Test all checks supported by express mode + const expressChecks = Object.keys(checkup.CHECK_INFO); + + for (const checkId of expressChecks) { + test(`${checkId} report validates against shared schema`, async () => { + const generator = checkup.REPORT_GENERATORS[checkId]; + expect(generator).toBeDefined(); + + const report = await generator(client, "test-node"); + + // Validate basic report structure (matching schema requirements) + expect(report).toHaveProperty("checkId", checkId); + expect(report).toHaveProperty("checkTitle"); + expect(report).toHaveProperty("timestamptz"); + expect(report).toHaveProperty("nodes"); + expect(report).toHaveProperty("results"); + expect(report.results).toHaveProperty("test-node"); + + // Validate against JSON schema (same schema used by Python reporter) + validateAgainstSchema(report, checkId); + }); + } + + test("generateAllReports produces valid reports for all checks", async () => { + const reports = await checkup.generateAllReports(client, "test-node"); + + expect(Object.keys(reports).length).toBe(expressChecks.length); + + for (const [checkId, report] of Object.entries(reports)) { + validateAgainstSchema(report, checkId); + } + }); + + test("report structure matches Python reporter format", async () => { + // Generate A003 (settings) report and verify structure matches what Python produces + const report = await checkup.generateA003(client, "test-node"); + + // Check required fields match Python reporter output structure (per schema) + expect(report).toHaveProperty("checkId", "A003"); + expect(report).toHaveProperty("checkTitle", "Postgres settings"); + expect(report).toHaveProperty("timestamptz"); + expect(report).toHaveProperty("nodes"); + expect(report.nodes).toHaveProperty("primary"); + expect(report.nodes).toHaveProperty("standbys"); + expect(report).toHaveProperty("results"); + + // Results should have node-specific data + const nodeResult = report.results["test-node"]; + expect(nodeResult).toHaveProperty("data"); + + // A003 should have settings as keyed object + expect(typeof nodeResult.data).toBe("object"); + + // Check postgres_version if present + if (nodeResult.postgres_version) { + expect(nodeResult.postgres_version).toHaveProperty("version"); + expect(nodeResult.postgres_version).toHaveProperty("server_version_num"); + expect(nodeResult.postgres_version).toHaveProperty("server_major_ver"); + expect(nodeResult.postgres_version).toHaveProperty("server_minor_ver"); + } + }); + + test("H001 (invalid indexes) has correct data structure", async () => { + const report = await checkup.generateH001(client, "test-node"); + validateAgainstSchema(report, "H001"); + + const nodeResult = report.results["test-node"]; + expect(nodeResult).toHaveProperty("data"); + // data should be an object with indexes (may be empty on fresh DB) + expect(typeof nodeResult.data).toBe("object"); + }); + + test("H002 (unused indexes) has correct data structure", async () => { + const report = await checkup.generateH002(client, "test-node"); + validateAgainstSchema(report, "H002"); + + const nodeResult = report.results["test-node"]; + expect(nodeResult).toHaveProperty("data"); + expect(typeof nodeResult.data).toBe("object"); + }); + + test("H004 (redundant indexes) has correct data structure", async () => { + const report = await checkup.generateH004(client, "test-node"); + validateAgainstSchema(report, "H004"); + + const nodeResult = report.results["test-node"]; + expect(nodeResult).toHaveProperty("data"); + expect(typeof nodeResult.data).toBe("object"); + }); +}); diff --git a/cli/test/checkup.test.ts b/cli/test/checkup.test.ts new file mode 100644 index 0000000..67a66eb --- /dev/null +++ b/cli/test/checkup.test.ts @@ -0,0 +1,890 @@ +import { describe, test, expect } from "bun:test"; +import { resolve } from "path"; + +// Import from source directly since we're using Bun +import * as checkup from "../lib/checkup"; +import * as api from "../lib/checkup-api"; +import { createMockClient } from "./test-utils"; + + +function runCli(args: string[], env: Record = {}) { + const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts"); + const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun"; + const result = Bun.spawnSync([bunBin, cliPath, ...args], { + env: { ...process.env, ...env }, + }); + return { + status: result.exitCode, + stdout: new TextDecoder().decode(result.stdout), + stderr: new TextDecoder().decode(result.stderr), + }; +} + +// Unit tests for parseVersionNum +describe("parseVersionNum", () => { + test("parses PG 16.3 version number", () => { + const result = checkup.parseVersionNum("160003"); + expect(result.major).toBe("16"); + expect(result.minor).toBe("3"); + }); + + test("parses PG 15.7 version number", () => { + const result = checkup.parseVersionNum("150007"); + expect(result.major).toBe("15"); + expect(result.minor).toBe("7"); + }); + + test("parses PG 14.12 version number", () => { + const result = checkup.parseVersionNum("140012"); + expect(result.major).toBe("14"); + expect(result.minor).toBe("12"); + }); + + test("handles empty string", () => { + const result = checkup.parseVersionNum(""); + expect(result.major).toBe(""); + expect(result.minor).toBe(""); + }); + + test("handles null/undefined", () => { + const result = checkup.parseVersionNum(null as any); + expect(result.major).toBe(""); + expect(result.minor).toBe(""); + }); + + test("handles short string", () => { + const result = checkup.parseVersionNum("123"); + expect(result.major).toBe(""); + expect(result.minor).toBe(""); + }); +}); + +// Unit tests for createBaseReport +describe("createBaseReport", () => { + test("creates correct structure", () => { + const report = checkup.createBaseReport("A002", "Postgres major version", "test-node"); + + expect(report.checkId).toBe("A002"); + expect(report.checkTitle).toBe("Postgres major version"); + expect(typeof report.version).toBe("string"); + expect(report.version!.length).toBeGreaterThan(0); + expect(typeof report.build_ts).toBe("string"); + expect(report.nodes.primary).toBe("test-node"); + expect(report.nodes.standbys).toEqual([]); + expect(report.results).toEqual({}); + expect(typeof report.timestamptz).toBe("string"); + // Verify timestamp is ISO format + expect(new Date(report.timestamptz).toISOString()).toBe(report.timestamptz); + }); + + test("uses provided node name", () => { + const report = checkup.createBaseReport("A003", "Postgres settings", "my-custom-node"); + expect(report.nodes.primary).toBe("my-custom-node"); + }); +}); + +// Tests for CHECK_INFO +describe("CHECK_INFO and REPORT_GENERATORS", () => { + const expectedChecks: Record = { + A002: "Postgres major version", + A003: "Postgres settings", + A004: "Cluster information", + A007: "Altered settings", + A013: "Postgres minor version", + D004: "pg_stat_statements and pg_stat_kcache settings", + F001: "Autovacuum: current settings", + G001: "Memory-related settings", + H001: "Invalid indexes", + H002: "Unused indexes", + H004: "Redundant indexes", + }; + + test("CHECK_INFO contains all expected checks with correct descriptions", () => { + for (const [checkId, description] of Object.entries(expectedChecks)) { + expect(checkup.CHECK_INFO[checkId]).toBe(description); + } + }); + + test("REPORT_GENERATORS has function for each check", () => { + for (const checkId of Object.keys(expectedChecks)) { + expect(typeof checkup.REPORT_GENERATORS[checkId]).toBe("function"); + } + }); + + test("REPORT_GENERATORS and CHECK_INFO have same keys", () => { + const generatorKeys = Object.keys(checkup.REPORT_GENERATORS).sort(); + const infoKeys = Object.keys(checkup.CHECK_INFO).sort(); + expect(generatorKeys).toEqual(infoKeys); + }); +}); + +// Tests for formatBytes +describe("formatBytes", () => { + test("formats zero bytes", () => { + expect(checkup.formatBytes(0)).toBe("0 B"); + }); + + test("formats bytes", () => { + expect(checkup.formatBytes(500)).toBe("500.00 B"); + }); + + test("formats kibibytes", () => { + expect(checkup.formatBytes(1024)).toBe("1.00 KiB"); + expect(checkup.formatBytes(1536)).toBe("1.50 KiB"); + }); + + test("formats mebibytes", () => { + expect(checkup.formatBytes(1048576)).toBe("1.00 MiB"); + }); + + test("formats gibibytes", () => { + expect(checkup.formatBytes(1073741824)).toBe("1.00 GiB"); + }); + + test("handles negative bytes", () => { + expect(checkup.formatBytes(-1024)).toBe("-1.00 KiB"); + expect(checkup.formatBytes(-1048576)).toBe("-1.00 MiB"); + }); + + test("handles edge cases", () => { + expect(checkup.formatBytes(NaN)).toBe("NaN B"); + expect(checkup.formatBytes(Infinity)).toBe("Infinity B"); + }); +}); + +// Mock client tests for report generators +describe("Report generators with mock client", () => { + test("getPostgresVersion extracts version info", async () => { + const mockClient = createMockClient({ + versionRows: [ + { name: "server_version", setting: "16.3" }, + { name: "server_version_num", setting: "160003" }, + ], + }); + + const version = await checkup.getPostgresVersion(mockClient as any); + expect(version.version).toBe("16.3"); + expect(version.server_version_num).toBe("160003"); + expect(version.server_major_ver).toBe("16"); + expect(version.server_minor_ver).toBe("3"); + }); + + test("getSettings transforms rows to keyed object", async () => { + const mockClient = createMockClient({ + settingsRows: [ + { + tag_setting_name: "shared_buffers", + tag_setting_value: "16384", + tag_unit: "8kB", + tag_category: "Resource Usage / Memory", + tag_vartype: "integer", + is_default: 1, + setting_normalized: "134217728", // 16384 * 8192 + unit_normalized: "bytes", + }, + { + tag_setting_name: "work_mem", + tag_setting_value: "4096", + tag_unit: "kB", + tag_category: "Resource Usage / Memory", + tag_vartype: "integer", + is_default: 1, + setting_normalized: "4194304", // 4096 * 1024 + unit_normalized: "bytes", + }, + ], + }); + + const settings = await checkup.getSettings(mockClient as any); + expect("shared_buffers" in settings).toBe(true); + expect("work_mem" in settings).toBe(true); + expect(settings.shared_buffers.setting).toBe("16384"); + expect(settings.shared_buffers.unit).toBe("8kB"); + // pretty_value is now computed from setting_normalized + expect(settings.shared_buffers.pretty_value).toBe("128.00 MiB"); + expect(settings.work_mem.pretty_value).toBe("4.00 MiB"); + }); + + test("generateA002 creates report with version data", async () => { + const mockClient = createMockClient({ + versionRows: [ + { name: "server_version", setting: "16.3" }, + { name: "server_version_num", setting: "160003" }, + ], + }); + + const report = await checkup.generateA002(mockClient as any, "test-node"); + expect(report.checkId).toBe("A002"); + expect(report.checkTitle).toBe("Postgres major version"); + expect(report.nodes.primary).toBe("test-node"); + expect("test-node" in report.results).toBe(true); + expect("version" in report.results["test-node"].data).toBe(true); + expect(report.results["test-node"].data.version.version).toBe("16.3"); + }); + + test("generateA003 creates report with settings and version", async () => { + const mockClient = createMockClient({ + versionRows: [ + { name: "server_version", setting: "16.3" }, + { name: "server_version_num", setting: "160003" }, + ], + settingsRows: [ + { + tag_setting_name: "shared_buffers", + tag_setting_value: "16384", + tag_unit: "8kB", + tag_category: "Resource Usage / Memory", + tag_vartype: "integer", + is_default: 1, + setting_normalized: "134217728", + unit_normalized: "bytes", + }, + ], + }); + + const report = await checkup.generateA003(mockClient as any, "test-node"); + expect(report.checkId).toBe("A003"); + expect(report.checkTitle).toBe("Postgres settings"); + expect("test-node" in report.results).toBe(true); + expect("shared_buffers" in report.results["test-node"].data).toBe(true); + expect(report.results["test-node"].postgres_version).toBeTruthy(); + expect(report.results["test-node"].postgres_version!.version).toBe("16.3"); + }); + + test("generateA013 creates report with minor version data", async () => { + const mockClient = createMockClient({ + versionRows: [ + { name: "server_version", setting: "16.3" }, + { name: "server_version_num", setting: "160003" }, + ], + }); + + const report = await checkup.generateA013(mockClient as any, "test-node"); + expect(report.checkId).toBe("A013"); + expect(report.checkTitle).toBe("Postgres minor version"); + expect(report.nodes.primary).toBe("test-node"); + expect("test-node" in report.results).toBe(true); + expect("version" in report.results["test-node"].data).toBe(true); + expect(report.results["test-node"].data.version.server_minor_ver).toBe("3"); + }); + + test("generateAllReports returns reports for all checks", async () => { + const mockClient = createMockClient({ + versionRows: [ + { name: "server_version", setting: "16.3" }, + { name: "server_version_num", setting: "160003" }, + ], + settingsRows: [ + { + tag_setting_name: "shared_buffers", + tag_setting_value: "16384", + tag_unit: "8kB", + tag_category: "Resource Usage / Memory", + tag_vartype: "integer", + is_default: 0, // Non-default for A007 + setting_normalized: "134217728", + unit_normalized: "bytes", + }, + ], + databaseSizesRows: [{ datname: "postgres", size_bytes: "1073741824" }], + dbStatsRows: [{ + numbackends: 5, + xact_commit: 100, + xact_rollback: 1, + blks_read: 1000, + blks_hit: 9000, + tup_returned: 500, + tup_fetched: 400, + tup_inserted: 50, + tup_updated: 30, + tup_deleted: 10, + deadlocks: 0, + temp_files: 0, + temp_bytes: 0, + postmaster_uptime_s: 864000 + }], + connectionStatesRows: [{ state: "active", count: 2 }, { state: "idle", count: 3 }], + uptimeRows: [{ start_time: new Date("2024-01-01T00:00:00Z"), uptime: "10 days" }], + invalidIndexesRows: [], + unusedIndexesRows: [], + redundantIndexesRows: [], + } + ); + + const reports = await checkup.generateAllReports(mockClient as any, "test-node"); + expect("A002" in reports).toBe(true); + expect("A003" in reports).toBe(true); + expect("A004" in reports).toBe(true); + expect("A007" in reports).toBe(true); + expect("A013" in reports).toBe(true); + expect("H001" in reports).toBe(true); + expect("H002" in reports).toBe(true); + expect("H004" in reports).toBe(true); + expect(reports.A002.checkId).toBe("A002"); + expect(reports.A003.checkId).toBe("A003"); + expect(reports.A004.checkId).toBe("A004"); + expect(reports.A007.checkId).toBe("A007"); + expect(reports.A013.checkId).toBe("A013"); + expect(reports.H001.checkId).toBe("H001"); + expect(reports.H002.checkId).toBe("H002"); + expect(reports.H004.checkId).toBe("H004"); + }); +}); + +// Tests for A007 (Altered settings) +describe("A007 - Altered settings", () => { + test("getAlteredSettings returns non-default settings", async () => { + const mockClient = createMockClient({ + settingsRows: [ + { tag_setting_name: "shared_buffers", tag_setting_value: "256MB", tag_unit: "", tag_category: "Resource Usage / Memory", tag_vartype: "string", is_default: 0, setting_normalized: null, unit_normalized: null }, + { tag_setting_name: "work_mem", tag_setting_value: "64MB", tag_unit: "", tag_category: "Resource Usage / Memory", tag_vartype: "string", is_default: 0, setting_normalized: null, unit_normalized: null }, + { tag_setting_name: "default_setting", tag_setting_value: "on", tag_unit: "", tag_category: "Other", tag_vartype: "bool", is_default: 1, setting_normalized: null, unit_normalized: null }, + ], + }); + + const settings = await checkup.getAlteredSettings(mockClient as any); + expect("shared_buffers" in settings).toBe(true); + expect("work_mem" in settings).toBe(true); + expect("default_setting" in settings).toBe(false); // Should be filtered out + expect(settings.shared_buffers.value).toBe("256MB"); + expect(settings.work_mem.value).toBe("64MB"); + }); + + test("generateA007 creates report with altered settings", async () => { + const mockClient = createMockClient({ + versionRows: [ + { name: "server_version", setting: "16.3" }, + { name: "server_version_num", setting: "160003" }, + ], + settingsRows: [ + { tag_setting_name: "max_connections", tag_setting_value: "200", tag_unit: "", tag_category: "Connections and Authentication", tag_vartype: "integer", is_default: 0, setting_normalized: null, unit_normalized: null }, + ], + } + ); + + const report = await checkup.generateA007(mockClient as any, "test-node"); + expect(report.checkId).toBe("A007"); + expect(report.checkTitle).toBe("Altered settings"); + expect(report.nodes.primary).toBe("test-node"); + expect("test-node" in report.results).toBe(true); + expect("max_connections" in report.results["test-node"].data).toBe(true); + expect(report.results["test-node"].data.max_connections.value).toBe("200"); + expect(report.results["test-node"].postgres_version).toBeTruthy(); + }); +}); + +// Tests for A004 (Cluster information) +describe("A004 - Cluster information", () => { + test("getDatabaseSizes returns database sizes", async () => { + const mockClient = createMockClient({ + databaseSizesRows: [ + { datname: "postgres", size_bytes: "1073741824" }, + { datname: "mydb", size_bytes: "536870912" }, + ], + }); + + const sizes = await checkup.getDatabaseSizes(mockClient as any); + expect("postgres" in sizes).toBe(true); + expect("mydb" in sizes).toBe(true); + expect(sizes.postgres).toBe(1073741824); + expect(sizes.mydb).toBe(536870912); + }); + + test("getClusterInfo returns cluster metrics", async () => { + const mockClient = createMockClient({ + dbStatsRows: [{ + numbackends: 10, + xact_commit: 1000, + xact_rollback: 5, + blks_read: 500, + blks_hit: 9500, + tup_returned: 5000, + tup_fetched: 4000, + tup_inserted: 100, + tup_updated: 50, + tup_deleted: 25, + deadlocks: 0, + temp_files: 2, + temp_bytes: 1048576, + postmaster_uptime_s: 2592000, // 30 days + }], + connectionStatesRows: [ + { state: "active", count: 3 }, + { state: "idle", count: 7 }, + ], + uptimeRows: [{ + start_time: new Date("2024-01-01T00:00:00Z"), + uptime: "30 days", + }], + }); + + const info = await checkup.getClusterInfo(mockClient as any); + expect("total_connections" in info).toBe(true); + expect("cache_hit_ratio" in info).toBe(true); + expect("connections_active" in info).toBe(true); + expect("connections_idle" in info).toBe(true); + expect("start_time" in info).toBe(true); + expect(info.total_connections.value).toBe("10"); + expect(info.cache_hit_ratio.value).toBe("95.00"); + expect(info.connections_active.value).toBe("3"); + }); + + test("generateA004 creates report with cluster info and database sizes", async () => { + const mockClient = createMockClient({ + versionRows: [ + { name: "server_version", setting: "16.3" }, + { name: "server_version_num", setting: "160003" }, + ], + databaseSizesRows: [ + { datname: "postgres", size_bytes: "1073741824" }, + ], + dbStatsRows: [{ + numbackends: 5, + xact_commit: 100, + xact_rollback: 1, + blks_read: 100, + blks_hit: 900, + tup_returned: 500, + tup_fetched: 400, + tup_inserted: 50, + tup_updated: 30, + tup_deleted: 10, + deadlocks: 0, + temp_files: 0, + temp_bytes: 0, + postmaster_uptime_s: 864000, + }], + connectionStatesRows: [{ state: "active", count: 2 }], + uptimeRows: [{ start_time: new Date("2024-01-01T00:00:00Z"), uptime: "10 days" }], + } + ); + + const report = await checkup.generateA004(mockClient as any, "test-node"); + expect(report.checkId).toBe("A004"); + expect(report.checkTitle).toBe("Cluster information"); + expect(report.nodes.primary).toBe("test-node"); + expect("test-node" in report.results).toBe(true); + + const data = report.results["test-node"].data; + expect("general_info" in data).toBe(true); + expect("database_sizes" in data).toBe(true); + expect("total_connections" in data.general_info).toBe(true); + expect("postgres" in data.database_sizes).toBe(true); + expect(data.database_sizes.postgres).toBe(1073741824); + expect(report.results["test-node"].postgres_version).toBeTruthy(); + }); +}); + +// Tests for H001 (Invalid indexes) +describe("H001 - Invalid indexes", () => { + test("getInvalidIndexes returns invalid indexes", async () => { + const mockClient = createMockClient({ + invalidIndexesRows: [ + { schema_name: "public", table_name: "users", index_name: "users_email_idx", relation_name: "users", index_size_bytes: "1048576", supports_fk: false }, + ], + }); + + const indexes = await checkup.getInvalidIndexes(mockClient as any); + expect(indexes.length).toBe(1); + expect(indexes[0].schema_name).toBe("public"); + expect(indexes[0].table_name).toBe("users"); + expect(indexes[0].index_name).toBe("users_email_idx"); + expect(indexes[0].index_size_bytes).toBe(1048576); + expect(indexes[0].index_size_pretty).toBeTruthy(); + expect(indexes[0].relation_name).toBe("users"); + expect(indexes[0].supports_fk).toBe(false); + }); + + test("generateH001 creates report with invalid indexes", async () => { + const mockClient = createMockClient({ + versionRows: [ + { name: "server_version", setting: "16.3" }, + { name: "server_version_num", setting: "160003" }, + ], + invalidIndexesRows: [ + { schema_name: "public", table_name: "orders", index_name: "orders_status_idx", relation_name: "orders", index_size_bytes: "2097152", supports_fk: false }, + ], + } + ); + + const report = await checkup.generateH001(mockClient as any, "test-node"); + expect(report.checkId).toBe("H001"); + expect(report.checkTitle).toBe("Invalid indexes"); + expect("test-node" in report.results).toBe(true); + + // Data is now keyed by database name + const data = report.results["test-node"].data; + expect("testdb" in data).toBe(true); + const dbData = data["testdb"] as any; + expect(dbData.invalid_indexes).toBeTruthy(); + expect(dbData.total_count).toBe(1); + expect(dbData.total_size_bytes).toBe(2097152); + expect(dbData.total_size_pretty).toBeTruthy(); + expect(dbData.database_size_bytes).toBeTruthy(); + expect(dbData.database_size_pretty).toBeTruthy(); + expect(report.results["test-node"].postgres_version).toBeTruthy(); + }); + // Top-level structure tests removed - covered by schema-validation.test.ts +}); + +// Tests for H002 (Unused indexes) +describe("H002 - Unused indexes", () => { + test("getUnusedIndexes returns unused indexes", async () => { + const mockClient = createMockClient({ + unusedIndexesRows: [ + { + schema_name: "public", + table_name: "products", + index_name: "products_old_idx", + index_definition: "CREATE INDEX products_old_idx ON public.products USING btree (old_column)", + reason: "Never Used Indexes", + index_size_bytes: "4194304", + idx_scan: "0", + idx_is_btree: true, + supports_fk: false, + }, + ], + }); + + const indexes = await checkup.getUnusedIndexes(mockClient as any); + expect(indexes.length).toBe(1); + expect(indexes[0].schema_name).toBe("public"); + expect(indexes[0].index_name).toBe("products_old_idx"); + expect(indexes[0].index_size_bytes).toBe(4194304); + expect(indexes[0].idx_scan).toBe(0); + expect(indexes[0].supports_fk).toBe(false); + expect(indexes[0].index_definition).toBeTruthy(); + expect(indexes[0].idx_is_btree).toBe(true); + }); + + test("generateH002 creates report with unused indexes", async () => { + const mockClient = createMockClient({ + versionRows: [ + { name: "server_version", setting: "16.3" }, + { name: "server_version_num", setting: "160003" }, + ], + unusedIndexesRows: [ + { + schema_name: "public", + table_name: "logs", + index_name: "logs_created_idx", + index_definition: "CREATE INDEX logs_created_idx ON public.logs USING btree (created_at)", + reason: "Never Used Indexes", + index_size_bytes: "8388608", + idx_scan: "0", + idx_is_btree: true, + supports_fk: false, + }, + ], + } + ); + + const report = await checkup.generateH002(mockClient as any, "test-node"); + expect(report.checkId).toBe("H002"); + expect(report.checkTitle).toBe("Unused indexes"); + expect("test-node" in report.results).toBe(true); + + // Data is now keyed by database name + const data = report.results["test-node"].data; + expect("testdb" in data).toBe(true); + const dbData = data["testdb"] as any; + expect(dbData.unused_indexes).toBeTruthy(); + expect(dbData.total_count).toBe(1); + expect(dbData.total_size_bytes).toBe(8388608); + expect(dbData.total_size_pretty).toBeTruthy(); + expect(dbData.stats_reset).toBeTruthy(); + expect(report.results["test-node"].postgres_version).toBeTruthy(); + }); + // Top-level structure tests removed - covered by schema-validation.test.ts +}); + +// Tests for H004 (Redundant indexes) +describe("H004 - Redundant indexes", () => { + test("getRedundantIndexes returns redundant indexes", async () => { + const mockClient = createMockClient({ + redundantIndexesRows: [ + { + schema_name: "public", + table_name: "orders", + index_name: "orders_user_id_idx", + relation_name: "orders", + access_method: "btree", + reason: "public.orders_user_id_created_idx", + index_size_bytes: "2097152", + table_size_bytes: "16777216", + index_usage: "0", + supports_fk: false, + index_definition: "CREATE INDEX orders_user_id_idx ON public.orders USING btree (user_id)", + redundant_to_json: JSON.stringify([ + { index_name: "public.orders_user_id_created_idx", index_definition: "CREATE INDEX orders_user_id_created_idx ON public.orders USING btree (user_id, created_at)", index_size_bytes: 1048576 } + ]), + }, + ], + }); + + const indexes = await checkup.getRedundantIndexes(mockClient as any); + expect(indexes.length).toBe(1); + expect(indexes[0].schema_name).toBe("public"); + expect(indexes[0].index_name).toBe("orders_user_id_idx"); + expect(indexes[0].reason).toBe("public.orders_user_id_created_idx"); + expect(indexes[0].index_size_bytes).toBe(2097152); + expect(indexes[0].supports_fk).toBe(false); + expect(indexes[0].index_definition).toBeTruthy(); + expect(indexes[0].relation_name).toBe("orders"); + // Verify redundant_to is populated with definitions and sizes + expect(indexes[0].redundant_to).toBeInstanceOf(Array); + expect(indexes[0].redundant_to.length).toBe(1); + expect(indexes[0].redundant_to[0].index_name).toBe("public.orders_user_id_created_idx"); + expect(indexes[0].redundant_to[0].index_definition).toContain("CREATE INDEX"); + expect(indexes[0].redundant_to[0].index_size_bytes).toBe(1048576); + expect(indexes[0].redundant_to[0].index_size_pretty).toBe("1.00 MiB"); + }); + + test("generateH004 creates report with redundant indexes", async () => { + const mockClient = createMockClient({ + versionRows: [ + { name: "server_version", setting: "16.3" }, + { name: "server_version_num", setting: "160003" }, + ], + redundantIndexesRows: [ + { + schema_name: "public", + table_name: "products", + index_name: "products_category_idx", + relation_name: "products", + access_method: "btree", + reason: "public.products_category_name_idx", + index_size_bytes: "4194304", + table_size_bytes: "33554432", + index_usage: "5", + supports_fk: false, + index_definition: "CREATE INDEX products_category_idx ON public.products USING btree (category)", + redundant_to_json: JSON.stringify([ + { index_name: "public.products_category_name_idx", index_definition: "CREATE INDEX products_category_name_idx ON public.products USING btree (category, name)", index_size_bytes: 2097152 } + ]), + }, + ], + } + ); + + const report = await checkup.generateH004(mockClient as any, "test-node"); + expect(report.checkId).toBe("H004"); + expect(report.checkTitle).toBe("Redundant indexes"); + expect("test-node" in report.results).toBe(true); + + // Data is now keyed by database name + const data = report.results["test-node"].data; + expect("testdb" in data).toBe(true); + const dbData = data["testdb"] as any; + expect(dbData.redundant_indexes).toBeTruthy(); + expect(dbData.total_count).toBe(1); + expect(dbData.total_size_bytes).toBe(4194304); + expect(dbData.total_size_pretty).toBeTruthy(); + expect(dbData.database_size_bytes).toBeTruthy(); + expect(report.results["test-node"].postgres_version).toBeTruthy(); + }); + // Top-level structure tests removed - covered by schema-validation.test.ts +}); + +// CLI tests +describe("CLI tests", () => { + test("checkup command exists and shows help", () => { + const r = runCli(["checkup", "--help"]); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/express mode/i); + expect(r.stdout).toMatch(/--check-id/); + expect(r.stdout).toMatch(/--node-name/); + expect(r.stdout).toMatch(/--output/); + expect(r.stdout).toMatch(/upload/); + expect(r.stdout).toMatch(/--json/); + }); + + test("checkup --help shows available check IDs", () => { + const r = runCli(["checkup", "--help"]); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/A002/); + expect(r.stdout).toMatch(/A003/); + expect(r.stdout).toMatch(/A004/); + expect(r.stdout).toMatch(/A007/); + expect(r.stdout).toMatch(/A013/); + expect(r.stdout).toMatch(/H001/); + expect(r.stdout).toMatch(/H002/); + expect(r.stdout).toMatch(/H004/); + }); + + test("checkup without connection shows help", () => { + const r = runCli(["checkup"]); + expect(r.status).not.toBe(0); + // Should show full help (options + examples), like `checkup --help` + expect(r.stdout).toMatch(/generate health check reports/i); + expect(r.stdout).toMatch(/--check-id/); + expect(r.stdout).toMatch(/available checks/i); + expect(r.stdout).toMatch(/A002/); + }); +}); + +// Tests for checkup-api module +describe("checkup-api", () => { + test("formatRpcErrorForDisplay formats details/hint nicely", () => { + const err = new api.RpcError({ + rpcName: "checkup_report_file_post", + statusCode: 402, + payloadText: JSON.stringify({ + hint: "Start an express checkup subscription for the organization or contact support.", + details: "Checkup report uploads require an active checkup subscription", + }), + payloadJson: { + hint: "Start an express checkup subscription for the organization or contact support.", + details: "Checkup report uploads require an active checkup subscription.", + }, + }); + const lines = api.formatRpcErrorForDisplay(err); + const text = lines.join("\n"); + expect(text).toMatch(/RPC checkup_report_file_post failed: HTTP 402/); + expect(text).toMatch(/Details:/); + expect(text).toMatch(/Hint:/); + }); + + test("withRetry succeeds on first attempt", async () => { + let attempts = 0; + const result = await api.withRetry(async () => { + attempts++; + return "success"; + }); + expect(result).toBe("success"); + expect(attempts).toBe(1); + }); + + test("withRetry retries on retryable errors and succeeds", async () => { + let attempts = 0; + const result = await api.withRetry( + async () => { + attempts++; + if (attempts < 3) { + throw new Error("connection timeout"); + } + return "success after retry"; + }, + { maxAttempts: 3, initialDelayMs: 10 } + ); + expect(result).toBe("success after retry"); + expect(attempts).toBe(3); + }); + + test("withRetry calls onRetry callback", async () => { + let attempts = 0; + const retryLogs: string[] = []; + await api.withRetry( + async () => { + attempts++; + if (attempts < 2) { + throw new Error("socket hang up"); + } + return "ok"; + }, + { maxAttempts: 3, initialDelayMs: 10 }, + (attempt, err, delayMs) => { + retryLogs.push(`attempt ${attempt}, delay ${delayMs}ms`); + } + ); + expect(retryLogs.length).toBe(1); + expect(retryLogs[0]).toMatch(/attempt 1/); + }); + + test("withRetry does not retry on non-retryable errors", async () => { + let attempts = 0; + try { + await api.withRetry( + async () => { + attempts++; + throw new Error("invalid input"); + }, + { maxAttempts: 3, initialDelayMs: 10 } + ); + } catch (err) { + expect((err as Error).message).toBe("invalid input"); + } + expect(attempts).toBe(1); + }); + + test("withRetry does not retry on 4xx RpcError", async () => { + let attempts = 0; + try { + await api.withRetry( + async () => { + attempts++; + throw new api.RpcError({ + rpcName: "test", + statusCode: 400, + payloadText: "bad request", + payloadJson: null, + }); + }, + { maxAttempts: 3, initialDelayMs: 10 } + ); + } catch (err) { + expect(err).toBeInstanceOf(api.RpcError); + } + expect(attempts).toBe(1); + }); + + test("withRetry retries on 5xx RpcError", async () => { + let attempts = 0; + try { + await api.withRetry( + async () => { + attempts++; + throw new api.RpcError({ + rpcName: "test", + statusCode: 503, + payloadText: "service unavailable", + payloadJson: null, + }); + }, + { maxAttempts: 2, initialDelayMs: 10 } + ); + } catch (err) { + expect(err).toBeInstanceOf(api.RpcError); + } + expect(attempts).toBe(2); + }); + + test("withRetry retries on timeout errors", async () => { + // Tests that timeout-like error messages are considered retryable + let attempts = 0; + try { + await api.withRetry( + async () => { + attempts++; + throw new Error("RPC test timed out after 30000ms (no response)"); + }, + { maxAttempts: 3, initialDelayMs: 10 } + ); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain("timed out"); + } + expect(attempts).toBe(3); // Should retry on timeout + }); + + test("withRetry retries on ECONNRESET errors", async () => { + // Tests that connection reset errors are considered retryable + let attempts = 0; + try { + await api.withRetry( + async () => { + attempts++; + const err = new Error("connection reset") as Error & { code: string }; + err.code = "ECONNRESET"; + throw err; + }, + { maxAttempts: 2, initialDelayMs: 10 } + ); + } catch (err) { + expect(err).toBeInstanceOf(Error); + } + expect(attempts).toBe(2); // Should retry on ECONNRESET + }); +}); + + diff --git a/cli/test/init.integration.test.ts b/cli/test/init.integration.test.ts index 146e3eb..c8c6932 100644 --- a/cli/test/init.integration.test.ts +++ b/cli/test/init.integration.test.ts @@ -362,8 +362,7 @@ describe.skipIf(skipTests)("integration: prepare-db", () => { } finally { await pg.cleanup(); } - }, - { timeout: 15000 } + } ); test("--reset-password updates the monitoring role login password", async () => { diff --git a/cli/test/schema-validation.test.ts b/cli/test/schema-validation.test.ts new file mode 100644 index 0000000..a15e6b9 --- /dev/null +++ b/cli/test/schema-validation.test.ts @@ -0,0 +1,81 @@ +/** + * JSON Schema validation tests for express checkup reports. + * Validates that generated reports match schemas in reporter/schemas/. + */ +import { describe, test, expect } from "bun:test"; +import { resolve } from "path"; +import { readFileSync } from "fs"; +import Ajv2020 from "ajv/dist/2020"; + +import * as checkup from "../lib/checkup"; +import { createMockClient } from "./test-utils"; + +const ajv = new Ajv2020({ allErrors: true, strict: false }); +const schemasDir = resolve(import.meta.dir, "../../reporter/schemas"); + +function validateAgainstSchema(report: any, checkId: string): void { + const schemaPath = resolve(schemasDir, `${checkId}.schema.json`); + const schema = JSON.parse(readFileSync(schemaPath, "utf8")); + const validate = ajv.compile(schema); + const valid = validate(report); + if (!valid) { + const errors = validate.errors?.map(e => `${e.instancePath}: ${e.message}`).join(", "); + throw new Error(`${checkId} schema validation failed: ${errors}`); + } +} + +// Test data for index reports +const indexTestData = { + H001: { + emptyRows: { invalidIndexesRows: [] }, + dataRows: { + invalidIndexesRows: [ + { schema_name: "public", table_name: "users", index_name: "users_email_idx", relation_name: "users", index_size_bytes: "1048576", supports_fk: false }, + ], + }, + }, + H002: { + emptyRows: { unusedIndexesRows: [] }, + dataRows: { + unusedIndexesRows: [ + { schema_name: "public", table_name: "logs", index_name: "logs_created_idx", index_definition: "CREATE INDEX logs_created_idx ON public.logs USING btree (created_at)", reason: "Never Used Indexes", idx_scan: "0", index_size_bytes: "8388608", idx_is_btree: true, supports_fk: false }, + ], + }, + }, + H004: { + emptyRows: { redundantIndexesRows: [] }, + dataRows: { + redundantIndexesRows: [ + { schema_name: "public", table_name: "orders", index_name: "orders_user_id_idx", relation_name: "orders", access_method: "btree", reason: "public.orders_user_id_created_idx", index_size_bytes: "2097152", table_size_bytes: "16777216", index_usage: "0", supports_fk: false, index_definition: "CREATE INDEX orders_user_id_idx ON public.orders USING btree (user_id)", redundant_to_json: JSON.stringify([{ index_name: "public.orders_user_id_created_idx", index_definition: "CREATE INDEX ...", index_size_bytes: 1048576 }]) }, + ], + }, + }, +}; + +describe("Schema validation", () => { + // Index health checks (H001, H002, H004) - test empty and with data + for (const [checkId, testData] of Object.entries(indexTestData)) { + const generator = checkup.REPORT_GENERATORS[checkId]; + + test(`${checkId} validates with empty data`, async () => { + const mockClient = createMockClient(testData.emptyRows); + const report = await generator(mockClient as any, "node-01"); + validateAgainstSchema(report, checkId); + }); + + test(`${checkId} validates with sample data`, async () => { + const mockClient = createMockClient(testData.dataRows); + const report = await generator(mockClient as any, "node-01"); + validateAgainstSchema(report, checkId); + }); + } + + // Settings reports (D004, F001, G001) - single test each + for (const checkId of ["D004", "F001", "G001"]) { + test(`${checkId} validates against schema`, async () => { + const mockClient = createMockClient(); + const report = await checkup.REPORT_GENERATORS[checkId](mockClient as any, "node-01"); + validateAgainstSchema(report, checkId); + }); + } +}); diff --git a/cli/test/test-utils.ts b/cli/test/test-utils.ts new file mode 100644 index 0000000..0b5f6f0 --- /dev/null +++ b/cli/test/test-utils.ts @@ -0,0 +1,122 @@ +/** + * Shared test utilities for CLI tests. + */ + +export interface MockClientOptions { + /** Database name returned by current_database() queries (default: "testdb") */ + databaseName?: string; + /** Version rows for pg_settings version query (default: PG 16.3) */ + versionRows?: any[]; + settingsRows?: any[]; + databaseSizesRows?: any[]; + dbStatsRows?: any[]; + connectionStatesRows?: any[]; + uptimeRows?: any[]; + invalidIndexesRows?: any[]; + unusedIndexesRows?: any[]; + redundantIndexesRows?: any[]; +} + +const DEFAULT_VERSION_ROWS = [ + { name: "server_version", setting: "16.3" }, + { name: "server_version_num", setting: "160003" }, +]; + +const defaultSettingsRows = [ + { tag_setting_name: "shared_buffers", tag_setting_value: "128MB", tag_unit: "", tag_category: "Resource Usage / Memory", tag_vartype: "string", is_default: 1, setting_normalized: null, unit_normalized: null }, + { tag_setting_name: "work_mem", tag_setting_value: "4MB", tag_unit: "", tag_category: "Resource Usage / Memory", tag_vartype: "string", is_default: 1, setting_normalized: null, unit_normalized: null }, + { tag_setting_name: "autovacuum", tag_setting_value: "on", tag_unit: "", tag_category: "Autovacuum", tag_vartype: "bool", is_default: 1, setting_normalized: null, unit_normalized: null }, + { tag_setting_name: "pg_stat_statements.max", tag_setting_value: "5000", tag_unit: "", tag_category: "Custom", tag_vartype: "integer", is_default: 0, setting_normalized: null, unit_normalized: null }, +]; + +/** + * Create a mock PostgreSQL client for testing report generators. + * Routes SQL queries to appropriate mock data based on query patterns. + */ +export function createMockClient(options: MockClientOptions = {}) { + const { + databaseName = "testdb", + versionRows = DEFAULT_VERSION_ROWS, + settingsRows = defaultSettingsRows, + databaseSizesRows = [], + dbStatsRows = [], + connectionStatesRows = [], + uptimeRows = [], + invalidIndexesRows = [], + unusedIndexesRows = [], + redundantIndexesRows = [], + } = options; + + return { + query: async (sql: string) => { + // Version query (simple inline - used by getPostgresVersion) + if (sql.includes("server_version") && sql.includes("server_version_num") && sql.includes("pg_settings") && !sql.includes("tag_setting_name")) { + return { rows: versionRows }; + } + // Settings metric query (from metrics.yml - has tag_setting_name, tag_setting_value) + if (sql.includes("tag_setting_name") && sql.includes("tag_setting_value") && sql.includes("pg_settings")) { + return { rows: settingsRows }; + } + // Database sizes (simple inline - lists all databases) + if (sql.includes("pg_database") && sql.includes("pg_database_size") && sql.includes("datistemplate")) { + return { rows: databaseSizesRows }; + } + // db_size metric (current database size from metrics.yml) + if (sql.includes("pg_database_size(current_database())") && sql.includes("size_b")) { + return { rows: [{ tag_datname: databaseName, size_b: "1073741824" }] }; + } + // db_stats metric (from metrics.yml) + if (sql.includes("pg_stat_database") && sql.includes("xact_commit") && sql.includes("pg_control_system")) { + return { rows: dbStatsRows }; + } + // Stats reset metric (from metrics.yml) + if (sql.includes("stats_reset") && sql.includes("pg_stat_database") && sql.includes("seconds_since_reset")) { + return { rows: [{ tag_database_name: databaseName, stats_reset_epoch: "1704067200", seconds_since_reset: "2592000" }] }; + } + // Postmaster startup time (simple inline - used by getStatsReset) + if (sql.includes("pg_postmaster_start_time") && sql.includes("postmaster_startup_epoch")) { + return { rows: [{ postmaster_startup_epoch: "1704067200", postmaster_startup_time: "2024-01-01 00:00:00+00" }] }; + } + // Connection states (simple inline) + if (sql.includes("pg_stat_activity") && sql.includes("state") && sql.includes("group by")) { + return { rows: connectionStatesRows }; + } + // Uptime info (simple inline) + if (sql.includes("pg_postmaster_start_time()") && sql.includes("uptime") && !sql.includes("postmaster_startup_epoch")) { + return { rows: uptimeRows }; + } + // Invalid indexes (H001) - from metrics.yml + if (sql.includes("indisvalid = false") && sql.includes("fk_indexes")) { + return { rows: invalidIndexesRows }; + } + // Unused indexes (H002) - from metrics.yml + if (sql.includes("Never Used Indexes") && sql.includes("idx_scan = 0")) { + return { rows: unusedIndexesRows }; + } + // Redundant indexes (H004) - from metrics.yml + if (sql.includes("redundant_indexes_grouped") && sql.includes("columns like")) { + return { rows: redundantIndexesRows }; + } + // D004: pg_stat_statements extension check + if (sql.includes("pg_extension") && sql.includes("pg_stat_statements")) { + return { rows: [] }; + } + // D004: pg_stat_kcache extension check + if (sql.includes("pg_extension") && sql.includes("pg_stat_kcache")) { + return { rows: [] }; + } + // G001: Memory settings query + if (sql.includes("pg_size_bytes") && sql.includes("shared_buffers") && sql.includes("work_mem")) { + return { rows: [{ + shared_buffers_bytes: "134217728", + wal_buffers_bytes: "4194304", + work_mem_bytes: "4194304", + maintenance_work_mem_bytes: "67108864", + effective_cache_size_bytes: "4294967296", + max_connections: 100, + }] }; + } + throw new Error(`Unexpected query: ${sql}`); + }, + }; +} diff --git a/config/pgwatch-prometheus/metrics.yml b/config/pgwatch-prometheus/metrics.yml index 2dca755..9835d10 100644 --- a/config/pgwatch-prometheus/metrics.yml +++ b/config/pgwatch-prometheus/metrics.yml @@ -366,21 +366,67 @@ metrics: pgwatch overrides these during metric collection, which would mask the actual configured values. sqls: 11: |- - select /* pgwatch_generated */ + with base as ( /* pgwatch_generated */ + select + name, + -- Use reset_val for lock_timeout/statement_timeout because pgwatch overrides them + -- during collection (lock_timeout=100ms, statement_timeout per-metric). + case + when name in ('lock_timeout', 'statement_timeout') then reset_val + else setting + end as effective_setting, + unit, + category, + vartype, + -- For lock_timeout/statement_timeout, compare reset_val with boot_val + -- since source becomes 'session' during collection. + case + when name in ('lock_timeout', 'statement_timeout') then (reset_val = boot_val) + else (source = 'default') + end as is_default_bool + from pg_settings + ), with_numeric as ( + select + *, + case + when effective_setting ~ '^-?[0-9]+$' then effective_setting::bigint + else null + end as numeric_value + from base + ) + select (extract(epoch from now()) * 1e9)::int8 as epoch_ns, current_database() as tag_datname, name as tag_setting_name, - -- Use reset_val for lock_timeout/statement_timeout because pgwatch overrides them during - -- collection (lock_timeout=100ms, statement_timeout per-metric), masking actual config. - case when name in ('lock_timeout', 'statement_timeout') then reset_val else setting end as tag_setting_value, + effective_setting as tag_setting_value, unit as tag_unit, category as tag_category, vartype as tag_vartype, - case when (case when name in ('lock_timeout', 'statement_timeout') then reset_val else setting end) ~ '^-?[0-9]+$' then (case when name in ('lock_timeout', 'statement_timeout') then reset_val else setting end)::bigint else null end as numeric_value, - -- For these settings, compare reset_val with boot_val since source becomes 'session' during collection - case when name in ('lock_timeout', 'statement_timeout') then (case when reset_val = boot_val then 1 else 0 end) else (case when source <> 'default' then 0 else 1 end) end as is_default, + numeric_value, + case + when numeric_value is null then null + when unit = '8kB' then numeric_value * 8192 + when unit = 'kB' then numeric_value * 1024 + when unit = 'MB' then numeric_value * 1024 * 1024 + when unit = 'B' then numeric_value + when unit = 'ms' then numeric_value::numeric / 1000 + when unit = 's' then numeric_value::numeric + when unit = 'min' then numeric_value::numeric * 60 + else null + end as setting_normalized, + case unit + when '8kB' then 'bytes' + when 'kB' then 'bytes' + when 'MB' then 'bytes' + when 'B' then 'bytes' + when 'ms' then 'seconds' + when 's' then 'seconds' + when 'min' then 'seconds' + else null + end as unit_normalized, + case when is_default_bool then 1 else 0 end as is_default, 1 as configured - from pg_settings + from with_numeric gauges: - '*' is_instance_level: true @@ -1637,7 +1683,7 @@ metrics: (i1.indexrelid::regclass)::text as reason, i1.indexrelid as reason_index_id, pg_get_indexdef(i1.indexrelid) main_index_def, - pg_size_pretty(pg_relation_size(i1.indexrelid)) main_index_size, + pg_relation_size(i1.indexrelid) main_index_size_bytes, pg_get_indexdef(i2.indexrelid) index_def, pg_relation_size(i2.indexrelid) index_size_bytes, s.idx_scan as index_usage, @@ -1717,11 +1763,19 @@ metrics: string_agg(distinct reason, ', ') as tag_reason, index_size_bytes, index_usage, + index_def as index_definition, formated_index_name as tag_index_name, formated_schema_name as tag_schema_name, formated_table_name as tag_table_name, formated_relation_name as tag_relation_name, - supports_fk::int as supports_fk + supports_fk::int as supports_fk, + json_agg( + distinct jsonb_build_object( + 'index_name', reason, + 'index_definition', main_index_def, + 'index_size_bytes', main_index_size_bytes + ) + )::text as redundant_to_json from redundant_indexes_cut_grouped group by index_id, @@ -1836,6 +1890,7 @@ metrics: schema_name as tag_schema_name, table_name as tag_table_name, index_name as tag_index_name, + pg_get_indexdef(index_id) as index_definition, idx_scan, all_scans, index_scan_pct, @@ -2021,6 +2076,7 @@ metrics: - 'stats_reset_epoch' - 'seconds_since_reset' statement_timeout_seconds: 15 + archive_lag: description: > This metric measures the lag in WAL archive processing. diff --git a/reporter/postgres_reports.py b/reporter/postgres_reports.py index 93fae64..14bf7ef 100644 --- a/reporter/postgres_reports.py +++ b/reporter/postgres_reports.py @@ -2,8 +2,23 @@ """ PostgreSQL Reports Generator using PromQL -This script generates reports for specific PostgreSQL check types (A002, A003, A004, A007, D004, F001, F004, F005, H001, H002, H004, K001, K003, K004, K005, K006, K007, K008, M001, M002, M003, N001) -by querying Prometheus metrics using PromQL queries. +This script generates JSON reports containing Observations for specific PostgreSQL +check types (A002, A003, A004, A007, D004, F001, F004, F005, H001, H002, H004, +K001, K003, K004, K005, K006, K007, K008, M001, M002, M003, N001) by querying +Prometheus metrics using PromQL. + +IMPORTANT: Scope of this module +------------------------------- +This module ONLY generates JSON reports with raw Observations (data collected +from Prometheus/PostgreSQL). The following are explicitly OUT OF SCOPE: + + - Converting JSON reports to other formats (Markdown, HTML, PDF, etc.) + - Generating Conclusions based on Observations + - Generating Recommendations based on Conclusions + - Any report rendering or presentation logic + +These responsibilities are handled by separate components in the system. +The JSON output from this module serves as input for downstream processing. """ __version__ = "1.0.2" @@ -32,7 +47,73 @@ class PostgresReportGenerator: # Default databases to always exclude DEFAULT_EXCLUDED_DATABASES = {'template0', 'template1', 'rdsadmin', 'azure_maintenance', 'cloudsqladmin'} - + + # Settings filter lists for reports based on A003 + D004_SETTINGS = [ + 'pg_stat_statements.max', + 'pg_stat_statements.track', + 'pg_stat_statements.track_utility', + 'pg_stat_statements.save', + 'pg_stat_statements.track_planning', + 'shared_preload_libraries', + 'track_activities', + 'track_counts', + 'track_functions', + 'track_io_timing', + 'track_wal_io_timing' + ] + + F001_SETTINGS = [ + 'autovacuum', + 'autovacuum_analyze_scale_factor', + 'autovacuum_analyze_threshold', + 'autovacuum_freeze_max_age', + 'autovacuum_max_workers', + 'autovacuum_multixact_freeze_max_age', + 'autovacuum_naptime', + 'autovacuum_vacuum_cost_delay', + 'autovacuum_vacuum_cost_limit', + 'autovacuum_vacuum_insert_scale_factor', + 'autovacuum_vacuum_scale_factor', + 'autovacuum_vacuum_threshold', + 'autovacuum_work_mem', + 'vacuum_cost_delay', + 'vacuum_cost_limit', + 'vacuum_cost_page_dirty', + 'vacuum_cost_page_hit', + 'vacuum_cost_page_miss', + 'vacuum_freeze_min_age', + 'vacuum_freeze_table_age', + 'vacuum_multixact_freeze_min_age', + 'vacuum_multixact_freeze_table_age' + ] + + G001_SETTINGS = [ + 'shared_buffers', + 'work_mem', + 'maintenance_work_mem', + 'effective_cache_size', + 'autovacuum_work_mem', + 'max_wal_size', + 'min_wal_size', + 'wal_buffers', + 'checkpoint_completion_target', + 'max_connections', + 'max_prepared_transactions', + 'max_locks_per_transaction', + 'max_pred_locks_per_transaction', + 'max_pred_locks_per_relation', + 'max_pred_locks_per_page', + 'logical_decoding_work_mem', + 'hash_mem_multiplier', + 'temp_buffers', + 'shared_preload_libraries', + 'dynamic_shared_memory_type', + 'huge_pages', + 'max_files_per_process', + 'max_stack_depth' + ] + def __init__(self, prometheus_url: str = "http://sink-prometheus:9090", postgres_sink_url: str = "postgresql://pgwatch@sink-postgres:5432/measurements", excluded_databases: Optional[List[str]] = None): @@ -795,6 +876,20 @@ def generate_h004_redundant_indexes_report(self, cluster: str = "local", node_na {}).get( 'result') else False + # Build redundant_to array from the reason field + # The reason field contains comma-separated index names + # (the indexes that make this index redundant) + # Note: In full mode, index sizes for redundant_to are not available + # (would require additional Prometheus queries). Use express mode for sizes. + redundant_to = [] + for idx_name in [r.strip() for r in reason.split(',') if r.strip()]: + redundant_to.append({ + "index_name": idx_name, + "index_definition": index_definitions.get(idx_name, 'Definition not available'), + "index_size_bytes": 0, + "index_size_pretty": "N/A" + }) + redundant_index = { "schema_name": schema_name, "table_name": table_name, @@ -808,7 +903,8 @@ def generate_h004_redundant_indexes_report(self, cluster: str = "local", node_na "supports_fk": supports_fk, "index_definition": index_definitions.get(index_name, 'Definition not available'), "index_size_pretty": self.format_bytes(index_size_bytes), - "table_size_pretty": self.format_bytes(table_size_bytes) + "table_size_pretty": self.format_bytes(table_size_bytes), + "redundant_to": redundant_to } redundant_indexes.append(redundant_index) @@ -3395,6 +3491,7 @@ def format_report_data(self, check_id: str, data: Dict[str, Any], host: str = "t template_data = { "version": self._build_metadata.get("version"), "build_ts": self._build_metadata.get("build_ts"), + "generation_mode": "full", "checkId": check_id, "checkTitle": self.get_check_title(check_id), "timestamptz": now.isoformat(), @@ -3404,13 +3501,183 @@ def format_report_data(self, check_id: str, data: Dict[str, Any], host: str = "t return template_data + def filter_a003_settings(self, a003_report: Dict[str, Any], setting_names: List[str]) -> Dict[str, Any]: + """ + Filter A003 settings data to include only specified settings. + + Args: + a003_report: Full A003 report containing all settings + setting_names: List of setting names to include + + Returns: + Filtered settings dictionary + """ + filtered = {} + # Handle both single-node and multi-node A003 report structures + results = a003_report.get('results', {}) + for node_name, node_data in results.items(): + data = node_data.get('data', {}) + for setting_name, setting_info in data.items(): + if setting_name in setting_names: + filtered[setting_name] = setting_info + return filtered + + def extract_postgres_version_from_a003(self, a003_report: Dict[str, Any], node_name: str = None) -> Dict[str, str]: + """ + Extract PostgreSQL version info from A003 report settings data. + + Derives version from server_version and server_version_num settings + which are part of the A003 settings data. + + Args: + a003_report: Full A003 report + node_name: Optional specific node name. If None, uses first available node. + + Returns: + Dictionary with postgres version info (version, server_version_num, server_major_ver, server_minor_ver) + """ + results = a003_report.get('results', {}) + if not results: + return {} + + # Get the node data + if node_name and node_name in results: + node_data = results[node_name] + else: + node_data = next(iter(results.values()), {}) + + # First check if postgres_version is already in the node result + if node_data.get('postgres_version'): + return node_data['postgres_version'] + + # Otherwise, extract from settings data (server_version, server_version_num) + data = node_data.get('data', {}) + version_str = None + version_num = None + + # Look for server_version and server_version_num in settings + if 'server_version' in data: + version_str = data['server_version'].get('setting', '') + if 'server_version_num' in data: + version_num = data['server_version_num'].get('setting', '') + + if not version_str and not version_num: + return {} + + # Parse version numbers + major_ver = "" + minor_ver = "" + if version_num and len(version_num) >= 6: + try: + num = int(version_num) + major_ver = str(num // 10000) + minor_ver = str(num % 10000) + except ValueError: + pass + + return { + "version": version_str or "", + "server_version_num": version_num or "", + "server_major_ver": major_ver, + "server_minor_ver": minor_ver + } + + def generate_d004_from_a003(self, a003_report: Dict[str, Any], cluster: str = "local", + node_name: str = "node-01") -> Dict[str, Any]: + """ + Generate D004 report by filtering A003 data for pg_stat_statements settings. + + Args: + a003_report: Full A003 report containing all settings + cluster: Cluster name (for status checks) + node_name: Node name + + Returns: + D004 report dictionary + """ + print("Generating D004 from A003 data...") + + # Filter A003 settings for D004-relevant settings + pgstat_data = self.filter_a003_settings(a003_report, self.D004_SETTINGS) + + # Check extension status (still needs direct queries) + kcache_status = self._check_pg_stat_kcache_status(cluster, node_name) + pgss_status = self._check_pg_stat_statements_status(cluster, node_name) + + # Extract postgres version from A003 + postgres_version = self.extract_postgres_version_from_a003(a003_report, node_name) + + return self.format_report_data( + "D004", + { + "settings": pgstat_data, + "pg_stat_statements_status": pgss_status, + "pg_stat_kcache_status": kcache_status, + }, + node_name, + postgres_version=postgres_version, + ) + + def generate_f001_from_a003(self, a003_report: Dict[str, Any], node_name: str = "node-01") -> Dict[str, Any]: + """ + Generate F001 report by filtering A003 data for autovacuum settings. + + Args: + a003_report: Full A003 report containing all settings + node_name: Node name + + Returns: + F001 report dictionary + """ + print("Generating F001 from A003 data...") + + # Filter A003 settings for F001-relevant settings + autovacuum_data = self.filter_a003_settings(a003_report, self.F001_SETTINGS) + + # Extract postgres version from A003 + postgres_version = self.extract_postgres_version_from_a003(a003_report, node_name) + + return self.format_report_data("F001", autovacuum_data, node_name, postgres_version=postgres_version) + + def generate_g001_from_a003(self, a003_report: Dict[str, Any], node_name: str = "node-01") -> Dict[str, Any]: + """ + Generate G001 report by filtering A003 data for memory settings. + + Args: + a003_report: Full A003 report containing all settings + node_name: Node name + + Returns: + G001 report dictionary with memory analysis + """ + print("Generating G001 from A003 data...") + + # Filter A003 settings for G001-relevant settings + memory_data = self.filter_a003_settings(a003_report, self.G001_SETTINGS) + + # Calculate memory analysis + memory_analysis = self._analyze_memory_settings(memory_data) + + # Extract postgres version from A003 + postgres_version = self.extract_postgres_version_from_a003(a003_report, node_name) + + return self.format_report_data( + "G001", + { + "settings": memory_data, + "analysis": memory_analysis, + }, + node_name, + postgres_version=postgres_version, + ) + def get_check_title(self, check_id: str) -> str: """ Get the human-readable title for a check ID. - + Args: check_id: The check identifier (e.g., "H004") - + Returns: Human-readable title for the check """ @@ -3688,17 +3955,14 @@ def generate_all_reports(self, cluster: str = "local", node_name: str = None, co nodes_to_process = [node_name] all_nodes = {"primary": node_name, "standbys": []} - # Generate each report type - report_types = [ + # Reports that don't depend on A003 (generate first) + independent_report_types = [ ('A002', self.generate_a002_version_report), ('A003', self.generate_a003_settings_report), ('A004', self.generate_a004_cluster_report), ('A007', self.generate_a007_altered_settings_report), - ('D004', self.generate_d004_pgstat_settings_report), - ('F001', self.generate_f001_autovacuum_settings_report), ('F004', self.generate_f004_heap_bloat_report), ('F005', self.generate_f005_btree_bloat_report), - ('G001', self.generate_g001_memory_settings_report), ('H001', self.generate_h001_invalid_indexes_report), ('H002', self.generate_h002_unused_indexes_report), ('H004', self.generate_h004_redundant_indexes_report), @@ -3715,7 +3979,7 @@ def generate_all_reports(self, cluster: str = "local", node_name: str = None, co ('N001', self.generate_n001_wait_events_report), ] - for check_id, report_func in report_types: + for check_id, report_func in independent_report_types: # Determine if this report needs hourly parameters pgss_hourly_reports = ['K001', 'K003', 'K004', 'K005', 'K006', 'K007', 'K008', 'M001', 'M002', 'M003'] wait_events_reports = ['N001'] @@ -3743,8 +4007,8 @@ def generate_all_reports(self, cluster: str = "local", node_name: str = None, co # Create combined report with all nodes reports[check_id] = self.format_report_data( - check_id, - combined_results, + check_id, + combined_results, all_nodes["primary"] if all_nodes["primary"] else nodes_to_process[0], all_nodes ) @@ -3756,6 +4020,61 @@ def generate_all_reports(self, cluster: str = "local", node_name: str = None, co if len(reports) % 5 == 0: gc.collect() + # Generate D004, F001, G001 from A003 data (if A003 was generated successfully) + a003_report = reports.get('A003') + if a003_report: + # Reports derived from A003 + a003_derived_reports = [ + ('D004', lambda c, n: self.generate_d004_from_a003(a003_report, c, n)), + ('F001', lambda c, n: self.generate_f001_from_a003(a003_report, n)), + ('G001', lambda c, n: self.generate_g001_from_a003(a003_report, n)), + ] + + for check_id, report_func in a003_derived_reports: + if len(nodes_to_process) == 1: + reports[check_id] = report_func(cluster, nodes_to_process[0]) + else: + # For multi-node, use the first node as reference + # (A003 data already contains all nodes) + combined_results = {} + for node in nodes_to_process: + print(f"Generating {check_id} report for node {node} from A003...") + node_report = report_func(cluster, node) + if 'results' in node_report and node in node_report['results']: + combined_results[node] = node_report['results'][node] + + reports[check_id] = self.format_report_data( + check_id, + combined_results, + all_nodes["primary"] if all_nodes["primary"] else nodes_to_process[0], + all_nodes + ) + else: + # Fallback to direct generation if A003 failed + print("Warning: A003 report not available, generating D004/F001/G001 directly") + fallback_report_types = [ + ('D004', self.generate_d004_pgstat_settings_report), + ('F001', self.generate_f001_autovacuum_settings_report), + ('G001', self.generate_g001_memory_settings_report), + ] + for check_id, report_func in fallback_report_types: + if len(nodes_to_process) == 1: + reports[check_id] = report_func(cluster, nodes_to_process[0]) + else: + combined_results = {} + for node in nodes_to_process: + print(f"Generating {check_id} report for node {node}...") + node_report = report_func(cluster, node) + if 'results' in node_report and node in node_report['results']: + combined_results[node] = node_report['results'][node] + + reports[check_id] = self.format_report_data( + check_id, + combined_results, + all_nodes["primary"] if all_nodes["primary"] else nodes_to_process[0], + all_nodes + ) + return reports def generate_queries_json(self, query_text_limit: int = 1000) -> Dict[str, List[str]]: @@ -4601,7 +4920,13 @@ def main(): # Generate specific report - use node_name or default if args.node_name is None: args.node_name = "node-01" - + + # For D004, F001, G001 - generate A003 first and derive from it + a003_report = None + if args.check_id in ('D004', 'F001', 'G001'): + print(f"Generating A003 first for {args.check_id}...") + a003_report = generator.generate_a003_settings_report(cluster, args.node_name) + if args.check_id == 'A002': report = generator.generate_a002_version_report(cluster, args.node_name) elif args.check_id == 'A003': @@ -4611,15 +4936,24 @@ def main(): elif args.check_id == 'A007': report = generator.generate_a007_altered_settings_report(cluster, args.node_name) elif args.check_id == 'D004': - report = generator.generate_d004_pgstat_settings_report(cluster, args.node_name) + if a003_report: + report = generator.generate_d004_from_a003(a003_report, cluster, args.node_name) + else: + report = generator.generate_d004_pgstat_settings_report(cluster, args.node_name) elif args.check_id == 'F001': - report = generator.generate_f001_autovacuum_settings_report(cluster, args.node_name) + if a003_report: + report = generator.generate_f001_from_a003(a003_report, args.node_name) + else: + report = generator.generate_f001_autovacuum_settings_report(cluster, args.node_name) elif args.check_id == 'F004': report = generator.generate_f004_heap_bloat_report(cluster, args.node_name) elif args.check_id == 'F005': report = generator.generate_f005_btree_bloat_report(cluster, args.node_name) elif args.check_id == 'G001': - report = generator.generate_g001_memory_settings_report(cluster, args.node_name) + if a003_report: + report = generator.generate_g001_from_a003(a003_report, args.node_name) + else: + report = generator.generate_g001_memory_settings_report(cluster, args.node_name) elif args.check_id == 'H001': report = generator.generate_h001_invalid_indexes_report(cluster, args.node_name) elif args.check_id == 'H002': @@ -4649,8 +4983,11 @@ def main(): elif args.check_id == 'N001': report = generator.generate_n001_wait_events_report(cluster, args.node_name, hours=24) - output_filename = f"{cluster}_{args.check_id}.json" if len(clusters_to_process) > 1 else args.output - + # Determine output filename + base_name = f"{cluster}_{args.check_id}" if len(clusters_to_process) > 1 else args.check_id + output_filename = f"{base_name}.json" if args.output == '-' else args.output + + # Output JSON report if args.output == '-' and len(clusters_to_process) == 1: # Report payload to stdout must remain raw JSON (not prefixed with log metadata). sys.stdout.write(json.dumps(report, indent=2) + "\n") diff --git a/reporter/schemas/A002.schema.json b/reporter/schemas/A002.schema.json index fe859f3..4640618 100644 --- a/reporter/schemas/A002.schema.json +++ b/reporter/schemas/A002.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "A002" }, "checkTitle": { "const": "Postgres major version" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/A003.schema.json b/reporter/schemas/A003.schema.json index f2ac9c0..333bbf1 100644 --- a/reporter/schemas/A003.schema.json +++ b/reporter/schemas/A003.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "A003" }, "checkTitle": { "const": "Postgres settings" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/A004.schema.json b/reporter/schemas/A004.schema.json index a019d97..d23a348 100644 --- a/reporter/schemas/A004.schema.json +++ b/reporter/schemas/A004.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "A004" }, "checkTitle": { "const": "Cluster information" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/A007.schema.json b/reporter/schemas/A007.schema.json index 22be298..0647ac4 100644 --- a/reporter/schemas/A007.schema.json +++ b/reporter/schemas/A007.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "A007" }, "checkTitle": { "const": "Altered settings" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/A013.schema.json b/reporter/schemas/A013.schema.json new file mode 100644 index 0000000..b16b386 --- /dev/null +++ b/reporter/schemas/A013.schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "A013 report schema", + "type": "object", + "additionalProperties": false, + "required": ["checkId", "checkTitle", "timestamptz", "nodes", "results"], + "properties": { + "version": { "type": ["string", "null"] }, + "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, + "checkId": { "const": "A013" }, + "checkTitle": { "const": "Postgres minor version" }, + "timestamptz": { "type": "string" }, + "nodes": { "$ref": "#/$defs/nodes" }, + "results": { + "type": "object", + "minProperties": 1, + "additionalProperties": { "$ref": "#/$defs/nodeResult" } + } + }, + "$defs": { + "nodes": { + "type": "object", + "additionalProperties": false, + "required": ["primary", "standbys"], + "properties": { + "primary": { "type": "string" }, + "standbys": { "type": "array", "items": { "type": "string" } } + } + }, + "postgresVersion": { + "type": "object", + "additionalProperties": false, + "required": ["version", "server_version_num", "server_major_ver", "server_minor_ver"], + "properties": { + "version": { "type": "string" }, + "server_version_num": { "type": "string" }, + "server_major_ver": { "type": "string" }, + "server_minor_ver": { "type": "string" } + } + }, + "data": { + "type": "object", + "additionalProperties": false, + "required": ["version"], + "properties": { + "version": { "$ref": "#/$defs/postgresVersion" } + } + }, + "nodeResult": { + "type": "object", + "additionalProperties": false, + "required": ["data"], + "properties": { + "data": { "$ref": "#/$defs/data" } + } + } + } +} diff --git a/reporter/schemas/D004.schema.json b/reporter/schemas/D004.schema.json index 997fa2a..966a9b4 100644 --- a/reporter/schemas/D004.schema.json +++ b/reporter/schemas/D004.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "D004" }, "checkTitle": { "const": "pg_stat_statements and pg_stat_kcache settings" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/F001.schema.json b/reporter/schemas/F001.schema.json index 0dd9da8..63bad03 100644 --- a/reporter/schemas/F001.schema.json +++ b/reporter/schemas/F001.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "F001" }, "checkTitle": { "const": "Autovacuum: current settings" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/F004.schema.json b/reporter/schemas/F004.schema.json index ae0290d..ae3ccb3 100644 --- a/reporter/schemas/F004.schema.json +++ b/reporter/schemas/F004.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "F004" }, "checkTitle": { "const": "Autovacuum: heap bloat (estimated)" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/F005.schema.json b/reporter/schemas/F005.schema.json index 83a788c..91898e0 100644 --- a/reporter/schemas/F005.schema.json +++ b/reporter/schemas/F005.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "F005" }, "checkTitle": { "const": "Autovacuum: index bloat (estimated)" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/G001.schema.json b/reporter/schemas/G001.schema.json index a79da7a..e50ef3b 100644 --- a/reporter/schemas/G001.schema.json +++ b/reporter/schemas/G001.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "G001" }, "checkTitle": { "const": "Memory-related settings" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/H001.schema.json b/reporter/schemas/H001.schema.json index ee1ee23..ae3a658 100644 --- a/reporter/schemas/H001.schema.json +++ b/reporter/schemas/H001.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "H001" }, "checkTitle": { "const": "Invalid indexes" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/H002.schema.json b/reporter/schemas/H002.schema.json index 13188fc..2857468 100644 --- a/reporter/schemas/H002.schema.json +++ b/reporter/schemas/H002.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "H002" }, "checkTitle": { "const": "Unused indexes" }, "timestamptz": { "type": "string" }, @@ -81,7 +82,8 @@ "stats_reset_time": { "type": ["string", "null"] }, "days_since_reset": { "type": ["integer", "null"] }, "postmaster_startup_epoch": { "type": ["number", "null"] }, - "postmaster_startup_time": { "type": ["string", "null"] } + "postmaster_startup_time": { "type": ["string", "null"] }, + "postmaster_startup_error": { "type": "string" } } }, "dbEntry": { diff --git a/reporter/schemas/H004.schema.json b/reporter/schemas/H004.schema.json index ceb5e4b..2b9366f 100644 --- a/reporter/schemas/H004.schema.json +++ b/reporter/schemas/H004.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "H004" }, "checkTitle": { "const": "Redundant indexes" }, "timestamptz": { "type": "string" }, @@ -38,6 +39,17 @@ "server_minor_ver": { "type": "string" } } }, + "redundantToIndex": { + "type": "object", + "additionalProperties": false, + "required": ["index_name", "index_definition", "index_size_bytes", "index_size_pretty"], + "properties": { + "index_name": { "type": "string" }, + "index_definition": { "type": "string" }, + "index_size_bytes": { "type": "number" }, + "index_size_pretty": { "type": "string" } + } + }, "redundantIndex": { "type": "object", "additionalProperties": false, @@ -54,7 +66,8 @@ "supports_fk", "index_definition", "index_size_pretty", - "table_size_pretty" + "table_size_pretty", + "redundant_to" ], "properties": { "schema_name": { "type": "string" }, @@ -69,7 +82,9 @@ "supports_fk": { "type": "boolean" }, "index_definition": { "type": "string" }, "index_size_pretty": { "type": "string" }, - "table_size_pretty": { "type": "string" } + "table_size_pretty": { "type": "string" }, + "redundant_to": { "type": "array", "items": { "$ref": "#/$defs/redundantToIndex" } }, + "redundant_to_parse_error": { "type": "string" } } }, "dbEntry": { diff --git a/reporter/schemas/K001.schema.json b/reporter/schemas/K001.schema.json index f52fc73..593e831 100644 --- a/reporter/schemas/K001.schema.json +++ b/reporter/schemas/K001.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "K001" }, "checkTitle": { "const": "Globally aggregated query metrics" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/K003.schema.json b/reporter/schemas/K003.schema.json index e08db6f..bbbb3aa 100644 --- a/reporter/schemas/K003.schema.json +++ b/reporter/schemas/K003.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "K003" }, "checkTitle": { "const": "Top queries by total time (total_exec_time + total_plan_time)" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/K004.schema.json b/reporter/schemas/K004.schema.json index a174172..8c32a84 100644 --- a/reporter/schemas/K004.schema.json +++ b/reporter/schemas/K004.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "K004" }, "checkTitle": { "const": "Top queries by temp bytes written" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/K005.schema.json b/reporter/schemas/K005.schema.json index 21e5074..ca0d952 100644 --- a/reporter/schemas/K005.schema.json +++ b/reporter/schemas/K005.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "K005" }, "checkTitle": { "const": "Top queries by WAL generation" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/K006.schema.json b/reporter/schemas/K006.schema.json index 592062a..fb70887 100644 --- a/reporter/schemas/K006.schema.json +++ b/reporter/schemas/K006.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "K006" }, "checkTitle": { "const": "Top queries by shared blocks read" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/K007.schema.json b/reporter/schemas/K007.schema.json index 859b6ee..142d27a 100644 --- a/reporter/schemas/K007.schema.json +++ b/reporter/schemas/K007.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "K007" }, "checkTitle": { "const": "Top queries by shared blocks hit" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/K008.schema.json b/reporter/schemas/K008.schema.json index 8f025c6..65f1f3a 100644 --- a/reporter/schemas/K008.schema.json +++ b/reporter/schemas/K008.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": "string", "enum": ["full", "express"] }, "checkId": { "const": "K008" }, "checkTitle": { "const": "Top queries by shared blocks hit+read" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/M001.schema.json b/reporter/schemas/M001.schema.json index 6b781bf..d6f8612 100644 --- a/reporter/schemas/M001.schema.json +++ b/reporter/schemas/M001.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "M001" }, "checkTitle": { "const": "Top queries by mean execution time" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/M002.schema.json b/reporter/schemas/M002.schema.json index df0b96e..87c0e4b 100644 --- a/reporter/schemas/M002.schema.json +++ b/reporter/schemas/M002.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "M002" }, "checkTitle": { "const": "Top queries by rows (I/O intensity)" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/M003.schema.json b/reporter/schemas/M003.schema.json index ad67849..8b6751e 100644 --- a/reporter/schemas/M003.schema.json +++ b/reporter/schemas/M003.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "M003" }, "checkTitle": { "const": "Top queries by I/O time" }, "timestamptz": { "type": "string" }, diff --git a/reporter/schemas/N001.schema.json b/reporter/schemas/N001.schema.json index cc27226..10ec65a 100644 --- a/reporter/schemas/N001.schema.json +++ b/reporter/schemas/N001.schema.json @@ -7,6 +7,7 @@ "properties": { "version": { "type": ["string", "null"] }, "build_ts": { "type": ["string", "null"] }, + "generation_mode": { "type": ["string", "null"] }, "checkId": { "const": "N001" }, "checkTitle": { "const": "Wait events grouped by type and query" }, "timestamptz": { "type": "string" }, diff --git a/tests/reporter/test_generators_unit.py b/tests/reporter/test_generators_unit.py index 6c641c5..05894fa 100644 --- a/tests/reporter/test_generators_unit.py +++ b/tests/reporter/test_generators_unit.py @@ -865,16 +865,14 @@ def _(*args, **kwargs): return _ - builders = [ + # Independent builders (not derived from A003) + independent_builders = [ "generate_a002_version_report", "generate_a003_settings_report", "generate_a004_cluster_report", "generate_a007_altered_settings_report", - "generate_d004_pgstat_settings_report", - "generate_f001_autovacuum_settings_report", "generate_f004_heap_bloat_report", "generate_f005_btree_bloat_report", - "generate_g001_memory_settings_report", "generate_h001_invalid_indexes_report", "generate_h002_unused_indexes_report", "generate_h004_redundant_indexes_report", @@ -891,13 +889,35 @@ def _(*args, **kwargs): "generate_n001_wait_events_report", ] - for name in builders: + # Builders derived from A003 + a003_derived_builders = [ + "generate_d004_from_a003", + "generate_f001_from_a003", + "generate_g001_from_a003", + ] + + for name in independent_builders: + monkeypatch.setattr(generator, name, stub(name)) + + for name in a003_derived_builders: monkeypatch.setattr(generator, name, stub(name)) reports = generator.generate_all_reports("local", "node-1") - assert set(reports.keys()) == {code.split("_")[1].upper() for code in builders} - assert set(called) == set(builders) + # All report types should be generated + expected_report_codes = { + 'A002', 'A003', 'A004', 'A007', + 'D004', 'F001', 'F004', 'F005', 'G001', + 'H001', 'H002', 'H004', + 'K001', 'K003', 'K004', 'K005', 'K006', 'K007', 'K008', + 'M001', 'M002', 'M003', + 'N001', + } + assert set(reports.keys()) == expected_report_codes + + # All builders should be called + all_builders = independent_builders + a003_derived_builders + assert set(called) == set(all_builders) @pytest.mark.unit