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.
- mcp_server/__init__.py +1 -0
- mcp_server/_shared.py +101 -0
- mcp_server/server.py +37 -0
- mcp_server/tools/__init__.py +1 -0
- mcp_server/tools/activity.py +52 -0
- mcp_server/tools/analysis.py +89 -0
- mcp_server/tools/indexes.py +65 -0
- mcp_server/tools/queries.py +60 -0
- mcp_server/tools/remediation.py +281 -0
- mcp_server/tools/replication.py +43 -0
- mcp_server/tools/server.py +86 -0
- mcp_server/tools/tables.py +46 -0
- postgres_aiops/__init__.py +9 -0
- postgres_aiops/cli/__init__.py +9 -0
- postgres_aiops/cli/_common.py +78 -0
- postgres_aiops/cli/_root.py +68 -0
- postgres_aiops/cli/activity.py +52 -0
- postgres_aiops/cli/analyze.py +53 -0
- postgres_aiops/cli/doctor.py +21 -0
- postgres_aiops/cli/index.py +55 -0
- postgres_aiops/cli/init.py +112 -0
- postgres_aiops/cli/overview.py +16 -0
- postgres_aiops/cli/query.py +70 -0
- postgres_aiops/cli/remediate.py +186 -0
- postgres_aiops/cli/replication.py +45 -0
- postgres_aiops/cli/secret.py +103 -0
- postgres_aiops/cli/server.py +69 -0
- postgres_aiops/cli/table.py +45 -0
- postgres_aiops/config.py +154 -0
- postgres_aiops/connection.py +178 -0
- postgres_aiops/doctor.py +84 -0
- postgres_aiops/governance/__init__.py +40 -0
- postgres_aiops/governance/audit.py +377 -0
- postgres_aiops/governance/budget.py +225 -0
- postgres_aiops/governance/decorators.py +474 -0
- postgres_aiops/governance/paths.py +23 -0
- postgres_aiops/governance/patterns.py +378 -0
- postgres_aiops/governance/policy.py +411 -0
- postgres_aiops/governance/sanitize.py +39 -0
- postgres_aiops/governance/undo.py +218 -0
- postgres_aiops/ops/__init__.py +1 -0
- postgres_aiops/ops/_util.py +102 -0
- postgres_aiops/ops/activity.py +193 -0
- postgres_aiops/ops/analysis.py +263 -0
- postgres_aiops/ops/indexes.py +211 -0
- postgres_aiops/ops/overview.py +51 -0
- postgres_aiops/ops/queries.py +123 -0
- postgres_aiops/ops/remediation.py +237 -0
- postgres_aiops/ops/replication.py +144 -0
- postgres_aiops/ops/server.py +151 -0
- postgres_aiops/ops/tables.py +146 -0
- postgres_aiops/secretstore.py +302 -0
- postgres_aiops-0.1.0.dist-info/METADATA +119 -0
- postgres_aiops-0.1.0.dist-info/RECORD +57 -0
- postgres_aiops-0.1.0.dist-info/WHEEL +4 -0
- postgres_aiops-0.1.0.dist-info/entry_points.txt +3 -0
- 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,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)))
|