Files
magnus919_agent-skills/postgres/scripts/pgdiag
T
Magnus HedemarkGitHubfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
cd14da26cc feat(skill): add PostgreSQL operational skill (#245) (#265)
Add a one-tool PostgreSQL operations skill: configuration review, index and
query-plan analysis, vacuum/bloat, WAL archiving + point-in-time recovery,
replication/failover, extensions, upgrades, and evidence-based diagnostics.
Ships the read-only pgdiag collector (stdlib, --json, --plan-for, --help
without a cluster), 9 dated references, tests, a human README, 6 eval cases,
and the top-level index + regenerated catalogs. Routes app data access to
backend-engineering and schema design to data-architect/data-engineering.

Closes #245

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-03 18:22:28 -04:00

412 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
"""pgdiag - read-only PostgreSQL operational diagnostics with bounded evidence.
Collects operational evidence from a live PostgreSQL instance through the
psql client without mutating anything: server identity and version,
configuration values, connection pressure, index usage, bloat signals, WAL
archiving health, recovery and replication state, installed extensions, and
database sizes. Every psql session opens with default_transaction_read_only=on
so the server itself rejects any write attempt; the tool only ever issues
read-only statements.
The script uses the Python standard library only, and --help works with no
PostgreSQL server and no psql binary installed. Diagnostic data is emitted as
bounded JSON with --json, or as human-readable text otherwise.
Exit codes: 0 ok, 1 runtime/collection error, 2 usage error,
127 psql binary not found, 124 timeout.
"""
import argparse
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
READ_ONLY_SQL = "SET default_transaction_read_only = on"
READ_ONLY_PREFIXES = ("SELECT", "SHOW", "WITH", "EXPLAIN")
DEFAULT_TIMEOUT = 30
CHECKS = [
{
"name": "identity",
"title": "Server identity and version",
"sql": (
"SELECT current_database(), current_user, "
"current_setting('server_version'), "
"current_setting('server_version_num'), "
"pg_is_in_recovery(), pg_postmaster_start_time()"
),
},
{
"name": "config",
"title": "Configuration values",
"sql": (
"SELECT 'max_connections', current_setting('max_connections') "
"UNION ALL SELECT 'shared_buffers', current_setting('shared_buffers') "
"UNION ALL SELECT 'effective_cache_size', current_setting('effective_cache_size') "
"UNION ALL SELECT 'work_mem', current_setting('work_mem') "
"UNION ALL SELECT 'maintenance_work_mem', current_setting('maintenance_work_mem') "
"UNION ALL SELECT 'wal_level', current_setting('wal_level') "
"UNION ALL SELECT 'max_wal_size', current_setting('max_wal_size') "
"UNION ALL SELECT 'checkpoint_timeout', current_setting('checkpoint_timeout') "
"UNION ALL SELECT 'archive_mode', current_setting('archive_mode') "
"UNION ALL SELECT 'archive_command', current_setting('archive_command') "
"UNION ALL SELECT 'archive_timeout', current_setting('archive_timeout') "
"UNION ALL SELECT 'max_wal_senders', current_setting('max_wal_senders') "
"UNION ALL SELECT 'max_replication_slots', current_setting('max_replication_slots') "
"UNION ALL SELECT 'autovacuum', current_setting('autovacuum') "
"UNION ALL SELECT 'autovacuum_max_workers', current_setting('autovacuum_max_workers') "
"UNION ALL SELECT 'autovacuum_naptime', current_setting('autovacuum_naptime') "
"UNION ALL SELECT 'log_min_duration_statement', current_setting('log_min_duration_statement') "
"UNION ALL SELECT 'log_statement', current_setting('log_statement') "
"UNION ALL SELECT 'ssl', current_setting('ssl') "
"UNION ALL SELECT 'track_io_timing', current_setting('track_io_timing') "
"UNION ALL SELECT 'random_page_cost', current_setting('random_page_cost') "
"UNION ALL SELECT 'seq_page_cost', current_setting('seq_page_cost')"
),
},
{
"name": "connections",
"title": "Connection activity by state",
"sql": (
"SELECT state, count(*) FROM pg_stat_activity "
"GROUP BY state ORDER BY count(*) DESC"
),
},
{
"name": "index_usage",
"title": "Most-used indexes",
"sql": (
"SELECT schemaname, relname, indexrelname, idx_scan, idx_tup_read, idx_tup_fetch "
"FROM pg_stat_user_indexes ORDER BY idx_scan DESC LIMIT 10"
),
},
{
"name": "unused_indexes",
"title": "Indexes with no recorded scans",
"sql": (
"SELECT schemaname, relname, indexrelname, idx_scan "
"FROM pg_stat_user_indexes WHERE idx_scan = 0 "
"ORDER BY relname, indexrelname LIMIT 10"
),
},
{
"name": "invalid_indexes",
"title": "Indexes marked unusable",
"sql": (
"SELECT c.relname AS index_name, i.indrelid::regclass AS table_name "
"FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid "
"WHERE NOT i.indisvalid"
),
},
{
"name": "seq_scan_heavy",
"title": "Tables with heavy sequential scans",
"sql": (
"SELECT schemaname, relname, seq_scan, seq_tup_read, idx_scan "
"FROM pg_stat_user_tables WHERE seq_scan > 0 "
"ORDER BY seq_tup_read DESC LIMIT 10"
),
},
{
"name": "bloat",
"title": "Dead-tuple accumulation signals",
"sql": (
"SELECT schemaname, relname, n_live_tup, n_dead_tup, "
"round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct "
"FROM pg_stat_user_tables WHERE n_dead_tup > 0 "
"ORDER BY n_dead_tup DESC LIMIT 10"
),
},
{
"name": "wal_archive",
"title": "WAL archiving health",
"sql": (
"SELECT archived_count, failed_count, last_archived_wal, "
"last_archived_time, last_failed_wal, last_failed_time "
"FROM pg_stat_archiver"
),
},
{
"name": "recovery",
"title": "Recovery state",
"sql": (
"SELECT pg_is_in_recovery(), pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn()"
),
},
{
"name": "replication",
"title": "Replication state",
"sql": (
"SELECT application_name, state, sync_state, client_addr, "
"sent_lsn, write_lsn, flush_lsn, replay_lsn "
"FROM pg_stat_replication"
),
},
{
"name": "extensions",
"title": "Installed extensions",
"sql": "SELECT extname, extversion FROM pg_extension ORDER BY extname",
},
{
"name": "databases",
"title": "Database sizes",
"sql": (
"SELECT datname, pg_size_pretty(pg_database_size(datname)) AS size "
"FROM pg_database ORDER BY pg_database_size(datname) DESC"
),
},
]
CHECKS_BY_NAME = {check["name"]: check for check in CHECKS}
def emit(payload: Dict[str, Any], as_json: bool) -> None:
"""Write a payload to stdout; JSON when --json, readable text otherwise."""
if as_json:
print(json.dumps(payload, indent=2, sort_keys=True))
return
lines: List[str] = []
for check in payload.get("checks", []):
lines.append(f"== {check['name']}: {check['title']} ==")
if check["status"] == "error":
lines.append(f" error: {check['error']}")
continue
for row in check.get("rows", []):
lines.append(" " + " | ".join(str(value) for value in row))
if "error" in payload:
lines.append(f"error: {payload['error']}")
if not lines:
lines.append("no diagnostics collected")
print("\n".join(lines))
def locate_psql(explicit: Optional[str]) -> Optional[str]:
"""Resolve the psql binary: --psql wins, otherwise search the PATH."""
if explicit:
if os.path.isfile(explicit) and os.access(explicit, os.X_OK):
return explicit
return None
for directory in os.environ.get("PATH", "").split(os.pathsep):
for name in ("psql", "psql.exe"):
candidate = os.path.join(directory, name)
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
def build_command(binary: str, args: argparse.Namespace, sql: str) -> List[str]:
"""Assemble a single-session psql command that is read-only by default."""
command = [binary, "-X", "-q", "-A", "-t", "-F", "|"]
if args.host:
command += ["-h", args.host]
if args.port:
command += ["-p", str(args.port)]
if args.user:
command += ["-U", args.user]
if args.dbname:
command += ["-d", args.dbname]
command += ["-c", READ_ONLY_SQL, "-c", sql]
return command
def validate_plan_query(query: str) -> Optional[str]:
"""Return a normalized single read-only query for EXPLAIN, or None if unsafe.
Accepts only a single statement (allowing one trailing semicolon) whose
first keyword is a read-only prefix. The read-only session setting remains
the authoritative guard: EXPLAIN only plans, it never executes, and any
server-side write rejection still applies to the session.
"""
stripped = query.strip()
if not stripped:
return None
without_semicolon = stripped[:-1] if stripped.endswith(";") else stripped
if ";" in without_semicolon:
return None
first_word = without_semicolon.lstrip().split(None, 1)[0].upper()
if first_word not in READ_ONLY_PREFIXES:
return None
return without_semicolon
def run_check(binary: str, args: argparse.Namespace, check: Dict[str, Any]) -> Dict[str, Any]:
"""Run one diagnostic check; never allow a failure to stop the others."""
result: Dict[str, Any] = {"name": check["name"], "title": check["title"]}
command = build_command(binary, args, check["sql"])
try:
proc = subprocess.run(
command,
capture_output=True,
text=True,
timeout=args.timeout,
)
except subprocess.TimeoutExpired:
result["status"] = "error"
result["error"] = f"psql timed out after {args.timeout}s"
return result
if proc.returncode != 0:
result["status"] = "error"
result["error"] = proc.stderr.strip() or f"psql exited {proc.returncode}"
return result
rows = [line.split("|") for line in proc.stdout.splitlines() if line.strip()]
result["status"] = "ok"
result["rows"] = rows
return result
def run_plan(binary: str, args: argparse.Namespace, query: str) -> Dict[str, Any]:
"""EXPLAIN (FORMAT JSON) a caller-supplied read-only query."""
result: Dict[str, Any] = {"name": "plan", "title": "Query plan", "status": "ok"}
command = build_command(binary, args, f"EXPLAIN (FORMAT JSON) {query}")
try:
proc = subprocess.run(
command,
capture_output=True,
text=True,
timeout=args.timeout,
)
except subprocess.TimeoutExpired:
result["status"] = "error"
result["error"] = f"psql timed out after {args.timeout}s"
return result
if proc.returncode != 0:
result["status"] = "error"
result["error"] = proc.stderr.strip() or f"psql exited {proc.returncode}"
return result
output = proc.stdout.strip()
try:
result["plan"] = json.loads(output)
except json.JSONDecodeError:
result["plan"] = output
return result
def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(
prog="pgdiag",
description=(
"Collect read-only PostgreSQL operational diagnostics through psql. "
"Every session runs with default_transaction_read_only=on, so the "
"server rejects any write attempt."
),
epilog=(
"Exit codes: 0 ok, 1 runtime/collection error, 2 usage error, "
"127 psql binary not found, 124 timeout."
),
)
parser.add_argument("--host", help="server host (default: local socket or PGHOST)")
parser.add_argument("--port", type=int, help="server port (default: 5432 or PGPORT)")
parser.add_argument("--user", help="role name (default: current OS user or PGUSER)")
parser.add_argument("--dbname", help="database to connect to (default: user name)")
parser.add_argument(
"--psql",
help="path to the psql binary (default: first psql on PATH)",
)
parser.add_argument(
"--json",
action="store_true",
help="emit machine-readable JSON on stdout",
)
parser.add_argument(
"--plan-for",
metavar="QUERY",
help=(
"run EXPLAIN (FORMAT JSON) on a single read-only statement "
"(SELECT, SHOW, WITH, or EXPLAIN) and add it as the plan check"
),
)
parser.add_argument(
"--check",
action="append",
default=[],
metavar="NAME",
help=(
"run only the named check; repeatable. Names: "
+ ", ".join(check["name"] for check in CHECKS)
),
)
parser.add_argument(
"--timeout",
type=int,
default=DEFAULT_TIMEOUT,
help="seconds to wait per psql invocation (default: %(default)s)",
)
parser.add_argument(
"--version",
action="version",
version="pgdiag 1.0.0",
)
args = parser.parse_args(argv)
binary = locate_psql(args.psql)
if binary is None:
emit(
{
"ok": False,
"error": (
"psql binary not found; install the PostgreSQL client or pass "
"--psql with an explicit path"
),
},
args.json,
)
return 127
if args.check:
unknown = [name for name in args.check if name not in CHECKS_BY_NAME]
if unknown:
parser.error(f"unknown check(s): {', '.join(unknown)}")
selected = [CHECKS_BY_NAME[name] for name in args.check]
else:
selected = list(CHECKS)
results = [run_check(binary, args, check) for check in selected]
if args.plan_for:
query = validate_plan_query(args.plan_for)
if query is None:
emit(
{
"ok": False,
"error": (
"--plan-for requires a single read-only statement whose first "
"keyword is SELECT, SHOW, WITH, or EXPLAIN"
),
},
args.json,
)
return 2
results.append(run_plan(binary, args, query))
server: Dict[str, Any] = {}
for result in results:
if result["name"] == "identity" and result["status"] == "ok" and result.get("rows"):
row = result["rows"][0]
server = {
"database": row[0],
"user": row[1],
"version": row[2],
"version_num": row[3],
"in_recovery": row[4],
"started_at": row[5],
}
break
ok = any(result["status"] == "ok" for result in results)
payload: Dict[str, Any] = {
"ok": ok,
"psql": binary,
"server": server,
"checks": results,
"generated_at": datetime.now(timezone.utc).isoformat(),
}
emit(payload, args.json)
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())