postgres-aiops 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. mcp_server/__init__.py +1 -0
  2. mcp_server/_shared.py +101 -0
  3. mcp_server/server.py +37 -0
  4. mcp_server/tools/__init__.py +1 -0
  5. mcp_server/tools/activity.py +52 -0
  6. mcp_server/tools/analysis.py +89 -0
  7. mcp_server/tools/indexes.py +65 -0
  8. mcp_server/tools/queries.py +60 -0
  9. mcp_server/tools/remediation.py +281 -0
  10. mcp_server/tools/replication.py +43 -0
  11. mcp_server/tools/server.py +86 -0
  12. mcp_server/tools/tables.py +46 -0
  13. postgres_aiops/__init__.py +9 -0
  14. postgres_aiops/cli/__init__.py +9 -0
  15. postgres_aiops/cli/_common.py +78 -0
  16. postgres_aiops/cli/_root.py +68 -0
  17. postgres_aiops/cli/activity.py +52 -0
  18. postgres_aiops/cli/analyze.py +53 -0
  19. postgres_aiops/cli/doctor.py +21 -0
  20. postgres_aiops/cli/index.py +55 -0
  21. postgres_aiops/cli/init.py +112 -0
  22. postgres_aiops/cli/overview.py +16 -0
  23. postgres_aiops/cli/query.py +70 -0
  24. postgres_aiops/cli/remediate.py +186 -0
  25. postgres_aiops/cli/replication.py +45 -0
  26. postgres_aiops/cli/secret.py +103 -0
  27. postgres_aiops/cli/server.py +69 -0
  28. postgres_aiops/cli/table.py +45 -0
  29. postgres_aiops/config.py +154 -0
  30. postgres_aiops/connection.py +178 -0
  31. postgres_aiops/doctor.py +84 -0
  32. postgres_aiops/governance/__init__.py +40 -0
  33. postgres_aiops/governance/audit.py +377 -0
  34. postgres_aiops/governance/budget.py +225 -0
  35. postgres_aiops/governance/decorators.py +474 -0
  36. postgres_aiops/governance/paths.py +23 -0
  37. postgres_aiops/governance/patterns.py +378 -0
  38. postgres_aiops/governance/policy.py +411 -0
  39. postgres_aiops/governance/sanitize.py +39 -0
  40. postgres_aiops/governance/undo.py +218 -0
  41. postgres_aiops/ops/__init__.py +1 -0
  42. postgres_aiops/ops/_util.py +102 -0
  43. postgres_aiops/ops/activity.py +193 -0
  44. postgres_aiops/ops/analysis.py +263 -0
  45. postgres_aiops/ops/indexes.py +211 -0
  46. postgres_aiops/ops/overview.py +51 -0
  47. postgres_aiops/ops/queries.py +123 -0
  48. postgres_aiops/ops/remediation.py +237 -0
  49. postgres_aiops/ops/replication.py +144 -0
  50. postgres_aiops/ops/server.py +151 -0
  51. postgres_aiops/ops/tables.py +146 -0
  52. postgres_aiops/secretstore.py +302 -0
  53. postgres_aiops-0.1.0.dist-info/METADATA +119 -0
  54. postgres_aiops-0.1.0.dist-info/RECORD +57 -0
  55. postgres_aiops-0.1.0.dist-info/WHEEL +4 -0
  56. postgres_aiops-0.1.0.dist-info/entry_points.txt +3 -0
  57. postgres_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,281 @@
1
+ """PostgreSQL maintenance MCP tools (guarded writes).
2
+
3
+ The state-changing tools. Every one is wrapped with the governance harness
4
+ (audit + graduated approval tier) and takes a ``dry_run`` preview. Reversible
5
+ writes pass an ``undo=`` callback that turns the fetched before-state into an
6
+ inverse descriptor the harness records; irreversible ones record none.
7
+
8
+ Risk tiers:
9
+ * terminate_backend / cancel_query / drop_index = high (destructive / irreversible)
10
+ * run_vacuum / run_analyze / create_index / reindex / update_setting = medium
11
+ """
12
+
13
+ from typing import Any, Optional
14
+
15
+ from mcp_server._shared import _get_connection, mcp, tool_errors
16
+ from postgres_aiops.governance import governed_tool
17
+ from postgres_aiops.ops import remediation as ops
18
+
19
+ # ── undo descriptors (built from the fetched before-state) ──────────────────
20
+
21
+
22
+ def _create_index_undo(params: dict[str, Any], result: Any) -> Optional[dict]:
23
+ if not isinstance(result, dict) or not result.get("index"):
24
+ return None
25
+ return {
26
+ "tool": "drop_index",
27
+ "params": {"name": result["index"]},
28
+ "skill": "postgres-aiops",
29
+ "note": "Inverse of create_index: drop the index that was just created.",
30
+ }
31
+
32
+
33
+ def _drop_index_undo(params: dict[str, Any], result: Any) -> Optional[dict]:
34
+ if not isinstance(result, dict):
35
+ return None
36
+ indexdef = (result.get("priorState") or {}).get("indexdef")
37
+ if not indexdef:
38
+ return None
39
+ return {
40
+ "tool": "create_index",
41
+ "params": {"definition": indexdef},
42
+ "skill": "postgres-aiops",
43
+ "note": (
44
+ "Inverse of drop_index: recreate the index from its captured "
45
+ "definition (replay this CREATE INDEX statement)."
46
+ ),
47
+ }
48
+
49
+
50
+ def _update_setting_undo(params: dict[str, Any], result: Any) -> Optional[dict]:
51
+ if not isinstance(result, dict):
52
+ return None
53
+ prior = (result.get("priorState") or {}).get("value")
54
+ if prior is None or prior == "":
55
+ return None
56
+ return {
57
+ "tool": "update_setting",
58
+ "params": {"name": params.get("name"), "value": prior},
59
+ "skill": "postgres-aiops",
60
+ "note": "Inverse of update_setting: ALTER SYSTEM SET back to the prior value.",
61
+ }
62
+
63
+
64
+ # ── activity control (high; irreversible) ───────────────────────────────────
65
+
66
+
67
+ @mcp.tool()
68
+ @governed_tool(risk_level="high")
69
+ @tool_errors("dict")
70
+ def terminate_backend(pid: int, dry_run: bool = False, target: Optional[str] = None) -> dict:
71
+ """[WRITE][risk=high] Terminate a backend (pg_terminate_backend). No safe inverse.
72
+
73
+ Captures the backend's pid + query for the audit trail; a terminate cannot be
74
+ undone, so no undo is offered. Pass dry_run=True to preview.
75
+
76
+ Args:
77
+ pid: Backend process id (from list_activity).
78
+ dry_run: If True, preview without terminating.
79
+ target: Target name from config; omit for the default.
80
+ """
81
+ conn = _get_connection(target)
82
+ if dry_run:
83
+ return {"dryRun": True, "wouldTerminate": {"pid": pid}}
84
+ return ops.terminate_backend(conn, pid)
85
+
86
+
87
+ @mcp.tool()
88
+ @governed_tool(risk_level="high")
89
+ @tool_errors("dict")
90
+ def cancel_query(pid: int, dry_run: bool = False, target: Optional[str] = None) -> dict:
91
+ """[WRITE][risk=high] Cancel a backend's running query (pg_cancel_backend). No inverse.
92
+
93
+ Captures the backend's pid + query for audit; a cancel has no undo. Pass
94
+ dry_run=True to preview.
95
+
96
+ Args:
97
+ pid: Backend process id (from list_activity).
98
+ dry_run: If True, preview without cancelling.
99
+ target: Target name from config; omit for the default.
100
+ """
101
+ conn = _get_connection(target)
102
+ if dry_run:
103
+ return {"dryRun": True, "wouldCancel": {"pid": pid}}
104
+ return ops.cancel_query(conn, pid)
105
+
106
+
107
+ # ── vacuum / analyze (medium; irreversible, record prior stats) ─────────────
108
+
109
+
110
+ @mcp.tool()
111
+ @governed_tool(risk_level="medium")
112
+ @tool_errors("dict")
113
+ def run_vacuum(
114
+ table: str,
115
+ full: bool = False,
116
+ analyze: bool = False,
117
+ dry_run: bool = False,
118
+ target: Optional[str] = None,
119
+ ) -> dict:
120
+ """[WRITE][risk=medium] VACUUM a table (optionally FULL/ANALYZE). Records prior stats.
121
+
122
+ No undo (a vacuum has no inverse); the prior dead-tuple/last-vacuum stats are
123
+ captured for the audit trail. VACUUM FULL takes an exclusive lock. Pass
124
+ dry_run=True to preview.
125
+
126
+ Args:
127
+ table: Table name (optionally schema-qualified, e.g. public.orders).
128
+ full: Run VACUUM FULL (rewrites the table, exclusive lock).
129
+ analyze: Also refresh planner statistics (VACUUM ANALYZE).
130
+ dry_run: If True, preview without running.
131
+ target: Target name from config; omit for the default.
132
+ """
133
+ conn = _get_connection(target)
134
+ if dry_run:
135
+ return {"dryRun": True, "wouldVacuum": {"table": table, "full": full, "analyze": analyze}}
136
+ return ops.run_vacuum(conn, table, full=full, analyze=analyze)
137
+
138
+
139
+ @mcp.tool()
140
+ @governed_tool(risk_level="medium")
141
+ @tool_errors("dict")
142
+ def run_analyze(table: str, dry_run: bool = False, target: Optional[str] = None) -> dict:
143
+ """[WRITE][risk=medium] ANALYZE a table to refresh planner statistics.
144
+
145
+ No undo; captures prior stats for audit. Pass dry_run=True to preview.
146
+
147
+ Args:
148
+ table: Table name (optionally schema-qualified).
149
+ dry_run: If True, preview without running.
150
+ target: Target name from config; omit for the default.
151
+ """
152
+ conn = _get_connection(target)
153
+ if dry_run:
154
+ return {"dryRun": True, "wouldAnalyze": {"table": table}}
155
+ return ops.run_analyze(conn, table)
156
+
157
+
158
+ # ── index create/drop/reindex ───────────────────────────────────────────────
159
+
160
+
161
+ @mcp.tool()
162
+ @governed_tool(risk_level="medium", undo=_create_index_undo)
163
+ @tool_errors("dict")
164
+ def create_index(
165
+ table: str,
166
+ columns: list[str],
167
+ name: Optional[str] = None,
168
+ unique: bool = False,
169
+ concurrently: bool = False,
170
+ method: Optional[str] = None,
171
+ dry_run: bool = False,
172
+ target: Optional[str] = None,
173
+ ) -> dict:
174
+ """[WRITE][risk=medium] Create an index. Reversible: undo drops the created name.
175
+
176
+ Supports CONCURRENTLY (non-blocking build). The created name is returned so
177
+ the harness records an undo that drops exactly this index. Pass dry_run=True
178
+ to preview.
179
+
180
+ Args:
181
+ table: Table to index (optionally schema-qualified).
182
+ columns: Column names to index.
183
+ name: Index name (auto-generated from table+columns when omitted).
184
+ unique: Create a UNIQUE index.
185
+ concurrently: Build with CONCURRENTLY (no table lock).
186
+ method: Index method — btree/hash/gist/gin/brin/spgist (default btree).
187
+ dry_run: If True, preview without creating.
188
+ target: Target name from config; omit for the default.
189
+ """
190
+ conn = _get_connection(target)
191
+ if dry_run:
192
+ return {"dryRun": True, "wouldCreate": {"table": table, "columns": columns, "name": name}}
193
+ return ops.create_index(
194
+ conn, table, columns, name=name, unique=unique,
195
+ concurrently=concurrently, method=method,
196
+ )
197
+
198
+
199
+ @mcp.tool()
200
+ @governed_tool(risk_level="high", undo=_drop_index_undo)
201
+ @tool_errors("dict")
202
+ def drop_index(
203
+ name: str,
204
+ concurrently: bool = False,
205
+ dry_run: bool = False,
206
+ target: Optional[str] = None,
207
+ ) -> dict:
208
+ """[WRITE][risk=high] Drop an index. Reversible: captures pg_get_indexdef first.
209
+
210
+ Before dropping, the exact index definition is captured so the harness records
211
+ an undo that recreates it. Pass dry_run=True to preview.
212
+
213
+ Args:
214
+ name: Index name (optionally schema-qualified).
215
+ concurrently: Drop with CONCURRENTLY (no table lock).
216
+ dry_run: If True, preview without dropping.
217
+ target: Target name from config; omit for the default.
218
+ """
219
+ conn = _get_connection(target)
220
+ if dry_run:
221
+ return {"dryRun": True, "wouldDrop": {"name": name}}
222
+ return ops.drop_index(conn, name, concurrently=concurrently)
223
+
224
+
225
+ @mcp.tool()
226
+ @governed_tool(risk_level="medium")
227
+ @tool_errors("dict")
228
+ def reindex(
229
+ target_name: str,
230
+ kind: str = "INDEX",
231
+ concurrently: bool = False,
232
+ dry_run: bool = False,
233
+ target: Optional[str] = None,
234
+ ) -> dict:
235
+ """[WRITE][risk=medium] REINDEX an index/table/schema (rebuild in place, no undo).
236
+
237
+ Rebuilds physical index storage; there is no inverse. Pass dry_run=True to
238
+ preview.
239
+
240
+ Args:
241
+ target_name: The index/table/schema name to reindex.
242
+ kind: INDEX, TABLE, or SCHEMA (default INDEX).
243
+ concurrently: Rebuild with CONCURRENTLY.
244
+ dry_run: If True, preview without rebuilding.
245
+ target: Target name from config; omit for the default.
246
+ """
247
+ conn = _get_connection(target)
248
+ if dry_run:
249
+ return {"dryRun": True, "wouldReindex": {"kind": kind, "target": target_name}}
250
+ return ops.reindex(conn, target_name, kind=kind, concurrently=concurrently)
251
+
252
+
253
+ # ── server settings (medium; reversible via ALTER SYSTEM) ───────────────────
254
+
255
+
256
+ @mcp.tool()
257
+ @governed_tool(risk_level="medium", undo=_update_setting_undo)
258
+ @tool_errors("dict")
259
+ def update_setting(
260
+ name: str,
261
+ value: str,
262
+ dry_run: bool = False,
263
+ target: Optional[str] = None,
264
+ ) -> dict:
265
+ """[WRITE][risk=medium] ALTER SYSTEM SET a parameter. Reversible: captures prior value.
266
+
267
+ Writes postgresql.auto.conf; most parameters need SELECT pg_reload_conf() (or
268
+ a restart for postmaster-context settings) to take effect — reported but NOT
269
+ performed automatically. The prior value is captured so the harness records an
270
+ undo that sets it back. Pass dry_run=True to preview.
271
+
272
+ Args:
273
+ name: The configuration parameter name (e.g. work_mem).
274
+ value: The new value (as a string).
275
+ dry_run: If True, preview without changing.
276
+ target: Target name from config; omit for the default.
277
+ """
278
+ conn = _get_connection(target)
279
+ if dry_run:
280
+ return {"dryRun": True, "wouldSet": {"name": name, "value": value}}
281
+ return ops.update_setting(conn, name, value)
@@ -0,0 +1,43 @@
1
+ """Replication PostgreSQL MCP tools (read-only)."""
2
+
3
+ from typing import Optional
4
+
5
+ from mcp_server._shared import _get_connection, mcp, tool_errors
6
+ from postgres_aiops.governance import governed_tool
7
+ from postgres_aiops.ops import replication as ops
8
+
9
+
10
+ @mcp.tool()
11
+ @governed_tool(risk_level="low")
12
+ @tool_errors("dict")
13
+ def replication_status(target: Optional[str] = None) -> dict:
14
+ """[READ] Connected standbys and their replay lag (pg_stat_replication).
15
+
16
+ Args:
17
+ target: Target name from config; omit for the default.
18
+ """
19
+ return ops.replication_status(_get_connection(target))
20
+
21
+
22
+ @mcp.tool()
23
+ @governed_tool(risk_level="low")
24
+ @tool_errors("dict")
25
+ def replication_slots(target: Optional[str] = None) -> dict:
26
+ """[READ] Replication slots, flagging inactive slots that retain WAL.
27
+
28
+ Args:
29
+ target: Target name from config; omit for the default.
30
+ """
31
+ return ops.replication_slots(_get_connection(target))
32
+
33
+
34
+ @mcp.tool()
35
+ @governed_tool(risk_level="low")
36
+ @tool_errors("dict")
37
+ def wal_status(target: Optional[str] = None) -> dict:
38
+ """[READ] WAL position, level, size settings and archiver health.
39
+
40
+ Args:
41
+ target: Target name from config; omit for the default.
42
+ """
43
+ return ops.wal_status(_get_connection(target))
@@ -0,0 +1,86 @@
1
+ """Server-level PostgreSQL MCP tools (read-only): overview + catalog reads."""
2
+
3
+ from typing import Optional
4
+
5
+ from mcp_server._shared import _get_connection, mcp, tool_errors
6
+ from postgres_aiops.governance import governed_tool
7
+ from postgres_aiops.ops import overview as overview_ops
8
+ from postgres_aiops.ops import server as ops
9
+
10
+
11
+ @mcp.tool()
12
+ @governed_tool(risk_level="low")
13
+ @tool_errors("dict")
14
+ def overview(target: Optional[str] = None) -> dict:
15
+ """[READ] One-shot cluster health snapshot.
16
+
17
+ Version + uptime, connections by state, idle-in-transaction count, the
18
+ longest-running query, the worst dead-tuple table, and standby replay lag —
19
+ each section captured defensively so one failing probe does not sink the rest.
20
+
21
+ Args:
22
+ target: Target name from config; omit for the default.
23
+ """
24
+ return overview_ops.snapshot(_get_connection(target))
25
+
26
+
27
+ @mcp.tool()
28
+ @governed_tool(risk_level="low")
29
+ @tool_errors("dict")
30
+ def server_version(target: Optional[str] = None) -> dict:
31
+ """[READ] Server version, uptime, recovery state, and data directory.
32
+
33
+ Args:
34
+ target: Target name from config; omit for the default.
35
+ """
36
+ return ops.server_version(_get_connection(target))
37
+
38
+
39
+ @mcp.tool()
40
+ @governed_tool(risk_level="low")
41
+ @tool_errors("list")
42
+ def show_settings(pattern: Optional[str] = None, target: Optional[str] = None) -> list:
43
+ """[READ] Configuration parameters from pg_settings.
44
+
45
+ Args:
46
+ pattern: Optional case-insensitive substring to filter setting names
47
+ (e.g. 'work_mem', 'autovacuum').
48
+ target: Target name from config; omit for the default.
49
+ """
50
+ return ops.show_settings(_get_connection(target), pattern)
51
+
52
+
53
+ @mcp.tool()
54
+ @governed_tool(risk_level="low")
55
+ @tool_errors("list")
56
+ def list_extensions(target: Optional[str] = None) -> list:
57
+ """[READ] Installed extensions and whether a newer version is available.
58
+
59
+ Args:
60
+ target: Target name from config; omit for the default.
61
+ """
62
+ return ops.list_extensions(_get_connection(target))
63
+
64
+
65
+ @mcp.tool()
66
+ @governed_tool(risk_level="low")
67
+ @tool_errors("list")
68
+ def list_databases(target: Optional[str] = None) -> list:
69
+ """[READ] Databases with owner, encoding and on-disk size (largest first).
70
+
71
+ Args:
72
+ target: Target name from config; omit for the default.
73
+ """
74
+ return ops.list_databases(_get_connection(target))
75
+
76
+
77
+ @mcp.tool()
78
+ @governed_tool(risk_level="low")
79
+ @tool_errors("list")
80
+ def list_roles(target: Optional[str] = None) -> list:
81
+ """[READ] Roles and their attributes (superuser/login/replication/…).
82
+
83
+ Args:
84
+ target: Target name from config; omit for the default.
85
+ """
86
+ return ops.list_roles(_get_connection(target))
@@ -0,0 +1,46 @@
1
+ """Table-health PostgreSQL MCP tools (read-only)."""
2
+
3
+ from typing import Optional
4
+
5
+ from mcp_server._shared import _get_connection, mcp, tool_errors
6
+ from postgres_aiops.governance import governed_tool
7
+ from postgres_aiops.ops import tables as ops
8
+
9
+
10
+ @mcp.tool()
11
+ @governed_tool(risk_level="low")
12
+ @tool_errors("dict")
13
+ def table_sizes(limit: int = 20, target: Optional[str] = None) -> dict:
14
+ """[READ] Largest tables by total relation size (table + indexes + TOAST).
15
+
16
+ Args:
17
+ limit: Number of tables to return, largest first (default 20).
18
+ target: Target name from config; omit for the default.
19
+ """
20
+ return ops.table_sizes(_get_connection(target), limit=limit)
21
+
22
+
23
+ @mcp.tool()
24
+ @governed_tool(risk_level="low")
25
+ @tool_errors("dict")
26
+ def table_bloat(limit: int = 50, target: Optional[str] = None) -> dict:
27
+ """[READ] Dead-tuple bloat proxy per table (dead / (live + dead)), worst first.
28
+
29
+ Args:
30
+ limit: Number of tables to inspect (default 50).
31
+ target: Target name from config; omit for the default.
32
+ """
33
+ return ops.table_bloat(_get_connection(target), limit=limit)
34
+
35
+
36
+ @mcp.tool()
37
+ @governed_tool(risk_level="low")
38
+ @tool_errors("dict")
39
+ def autovacuum_status(limit: int = 50, target: Optional[str] = None) -> dict:
40
+ """[READ] Per-table dead tuples, mods-since-analyze, and last (auto)vacuum times.
41
+
42
+ Args:
43
+ limit: Number of tables to inspect (default 50).
44
+ target: Target name from config; omit for the default.
45
+ """
46
+ return ops.autovacuum_status(_get_connection(target), limit=limit)
@@ -0,0 +1,9 @@
1
+ """postgres-aiops — governed PostgreSQL DBA operations for AI agents.
2
+
3
+ Standalone and self-contained: the governance harness (audit, token budget,
4
+ undo-token recording, graduated risk tiers, prompt-injection sanitize) is
5
+ bundled under ``postgres_aiops.governance`` — this package has no external
6
+ skill-family dependency. Preview: not yet full-coverage.
7
+ """
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,9 @@
1
+ """CLI package for postgres-aiops.
2
+
3
+ Re-exports ``app`` so the pyproject entry point
4
+ ``postgres-aiops = "postgres_aiops.cli:app"`` works unchanged.
5
+ """
6
+
7
+ from postgres_aiops.cli._root import app
8
+
9
+ __all__ = ["app"]
@@ -0,0 +1,78 @@
1
+ """Shared helpers for postgres-aiops CLI sub-modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+ from typing import Annotated, Any
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ console = Console()
14
+
15
+ # ─── Shared Option types ───────────────────────────────────────────────────
16
+
17
+ TargetOption = Annotated[
18
+ str | None, typer.Option("--target", "-t", help="Target name from config")
19
+ ]
20
+ DryRunOption = Annotated[
21
+ bool, typer.Option("--dry-run", help="Print the API call without executing")
22
+ ]
23
+
24
+
25
+ def _cli_error_types() -> tuple[type[BaseException], ...]:
26
+ """Exceptions translated to a one-line teaching error instead of a traceback."""
27
+ from postgres_aiops.connection import PgError
28
+
29
+ return (PgError, KeyError, OSError, ValueError)
30
+
31
+
32
+ def cli_errors(fn: Callable) -> Callable:
33
+ """Translate known exceptions into one red line + exit code 1."""
34
+
35
+ @functools.wraps(fn)
36
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
37
+ try:
38
+ return fn(*args, **kwargs)
39
+ except (typer.Exit, typer.Abort):
40
+ raise
41
+ except _cli_error_types() as e:
42
+ message = str(e)
43
+ if isinstance(e, KeyError):
44
+ message = f"Missing required key or environment variable: {message}"
45
+ console.print(f"[red]Error: {message}[/]")
46
+ raise typer.Exit(1) from e
47
+
48
+ return wrapper
49
+
50
+
51
+ def get_connection(target: str | None, config_path: Path | None = None):
52
+ """Return a (conn, config) tuple for the given target."""
53
+ from postgres_aiops.config import load_config
54
+ from postgres_aiops.connection import ConnectionManager
55
+
56
+ cfg = load_config(config_path)
57
+ mgr = ConnectionManager(cfg)
58
+ return mgr.connect(target), cfg
59
+
60
+
61
+ def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = None) -> None:
62
+ """Print a dry-run preview of the API call that would be made."""
63
+ console.print("\n[bold magenta][DRY-RUN] No changes will be made.[/]")
64
+ console.print(f"[magenta] Operation: {operation}[/]")
65
+ console.print(f"[magenta] API Call: {api_call}[/]")
66
+ for k, v in (parameters or {}).items():
67
+ console.print(f"[magenta] Param: {k} = {v}[/]")
68
+ console.print("[magenta] Run without --dry-run to execute.[/]\n")
69
+
70
+
71
+ def double_confirm(action: str, resource: str) -> None:
72
+ """Require two confirmations for a destructive operation."""
73
+ console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
74
+ typer.confirm(f"Confirm 1/2: {action} '{resource}'?", abort=True)
75
+ typer.confirm(
76
+ f"Confirm 2/2: really {action} '{resource}'? This may be irreversible.",
77
+ abort=True,
78
+ )
@@ -0,0 +1,68 @@
1
+ """Top-level Typer app: assembles sub-apps and top-level commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from postgres_aiops.cli._common import cli_errors
8
+ from postgres_aiops.cli.activity import activity_app
9
+ from postgres_aiops.cli.analyze import analyze_app
10
+ from postgres_aiops.cli.doctor import doctor_cmd
11
+ from postgres_aiops.cli.index import index_app
12
+ from postgres_aiops.cli.init import init_cmd
13
+ from postgres_aiops.cli.overview import overview_cmd
14
+ from postgres_aiops.cli.query import query_app
15
+ from postgres_aiops.cli.remediate import remediate_app
16
+ from postgres_aiops.cli.replication import repl_app
17
+ from postgres_aiops.cli.secret import secret_app
18
+ from postgres_aiops.cli.server import server_app
19
+ from postgres_aiops.cli.table import table_app
20
+
21
+ app = typer.Typer(
22
+ name="postgres-aiops",
23
+ help="Governed AI-ops for PostgreSQL DBA operations.",
24
+ no_args_is_help=True,
25
+ )
26
+
27
+ app.add_typer(server_app, name="server")
28
+ app.add_typer(activity_app, name="activity")
29
+ app.add_typer(query_app, name="query")
30
+ app.add_typer(index_app, name="index")
31
+ app.add_typer(table_app, name="table")
32
+ app.add_typer(repl_app, name="repl")
33
+ app.add_typer(analyze_app, name="analyze")
34
+ app.add_typer(remediate_app, name="remediate")
35
+ app.add_typer(secret_app, name="secret")
36
+ app.command("init")(init_cmd)
37
+ app.command("overview")(overview_cmd)
38
+ app.command("doctor")(doctor_cmd)
39
+
40
+
41
+ @app.command("mcp")
42
+ @cli_errors
43
+ def mcp_cmd() -> None:
44
+ """Start the MCP server (stdio transport).
45
+
46
+ Single-command entry point for MCP clients (does not go through uvx/PyPI
47
+ resolution at launch):
48
+ postgres-aiops mcp
49
+ """
50
+ import sys
51
+
52
+ if sys.version_info < (3, 11):
53
+ typer.echo(
54
+ f"ERROR: postgres-aiops requires Python >= 3.11 "
55
+ f"(got {sys.version_info.major}.{sys.version_info.minor}).\n"
56
+ f"Fix: uv python install 3.12 && "
57
+ f"uv tool install --python 3.12 --force postgres-aiops",
58
+ err=True,
59
+ )
60
+ raise typer.Exit(2)
61
+
62
+ from mcp_server.server import main as _mcp_main
63
+
64
+ _mcp_main()
65
+
66
+
67
+ if __name__ == "__main__":
68
+ app()
@@ -0,0 +1,52 @@
1
+ """``postgres-aiops activity`` — sessions, long-running queries, locks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
11
+
12
+ activity_app = typer.Typer(
13
+ name="activity",
14
+ help="Activity: sessions, long-running queries, locks.",
15
+ no_args_is_help=True,
16
+ )
17
+
18
+
19
+ @activity_app.command("list")
20
+ @cli_errors
21
+ def activity_list(
22
+ state: Annotated[str | None, typer.Option("--state", help="Filter by state")] = None,
23
+ target: TargetOption = None,
24
+ ) -> None:
25
+ """List current sessions (pg_stat_activity) with per-state counts."""
26
+ from postgres_aiops.ops import activity as ops
27
+
28
+ conn, _ = get_connection(target)
29
+ console.print_json(json.dumps(ops.list_activity(conn, state=state)))
30
+
31
+
32
+ @activity_app.command("long")
33
+ @cli_errors
34
+ def activity_long(
35
+ min_seconds: Annotated[int, typer.Option("--min-seconds", help="Minimum age")] = 60,
36
+ target: TargetOption = None,
37
+ ) -> None:
38
+ """List active queries running at least --min-seconds."""
39
+ from postgres_aiops.ops import activity as ops
40
+
41
+ conn, _ = get_connection(target)
42
+ console.print_json(json.dumps(ops.long_running_queries(conn, min_seconds=min_seconds)))
43
+
44
+
45
+ @activity_app.command("locks")
46
+ @cli_errors
47
+ def activity_locks(target: TargetOption = None) -> None:
48
+ """List held/awaited locks."""
49
+ from postgres_aiops.ops import activity as ops
50
+
51
+ conn, _ = get_connection(target)
52
+ console.print_json(json.dumps(ops.list_locks(conn)))