postgres-aiops 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- postgres_aiops-0.3.0/.github/workflows/mcp-publish.yml +55 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/.gitignore +1 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/CHANGELOG.md +19 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/PKG-INFO +1 -1
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/server.py +1 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/tools/remediation.py +19 -5
- postgres_aiops-0.3.0/mcp_server/tools/undo.py +121 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/_root.py +2 -0
- postgres_aiops-0.3.0/postgres_aiops/cli/undo.py +62 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/governance/decorators.py +8 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/remediation.py +30 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/secretstore.py +3 -1
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/pyproject.toml +1 -1
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/server.json +2 -2
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/skills/postgres-aiops/SKILL.md +1 -0
- postgres_aiops-0.3.0/tests/test_doctor.py +184 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/tests/test_governance_persistence.py +22 -0
- postgres_aiops-0.3.0/tests/test_init.py +122 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/tests/test_smoke.py +3 -0
- postgres_aiops-0.3.0/tests/test_undo_executor.py +138 -0
- postgres_aiops-0.3.0/tests/test_undo_replay.py +66 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/uv.lock +1 -1
- postgres_aiops-0.2.0/.coverage +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/.github/workflows/publish.yml +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/LICENSE +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/README.md +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/RELEASE_NOTES.md +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/SECURITY.md +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/__init__.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/_shared.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/tools/__init__.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/tools/activity.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/tools/analysis.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/tools/indexes.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/tools/queries.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/tools/replication.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/tools/server.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/tools/tables.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/__init__.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/__init__.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/_common.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/activity.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/analyze.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/doctor.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/index.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/init.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/overview.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/query.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/remediate.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/replication.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/secret.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/server.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/table.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/config.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/connection.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/doctor.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/governance/__init__.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/governance/audit.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/governance/budget.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/governance/paths.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/governance/patterns.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/governance/policy.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/governance/sanitize.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/governance/undo.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/__init__.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/_util.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/activity.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/analysis.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/indexes.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/overview.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/queries.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/replication.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/server.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/tables.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/skills/postgres-aiops/references/capabilities.md +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/skills/postgres-aiops/references/cli-reference.md +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/skills/postgres-aiops/references/setup-guide.md +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/smithery.yaml +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/tests/conftest.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/tests/test_analysis.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/tests/test_cli_writes.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/tests/test_connection.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/tests/test_reads.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/tests/test_secretstore.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/tests/test_writes.py +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
name: mcp-publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
release:
|
|
6
|
+
types: [published]
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
id-token: write
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
publish-mcp:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- name: Wait for PyPI
|
|
18
|
+
# The release event also triggers the PyPI publish workflow; the MCP
|
|
19
|
+
# registry validates that the package version exists on PyPI, so poll
|
|
20
|
+
# until it has propagated (every 15s, up to 10 minutes).
|
|
21
|
+
run: |
|
|
22
|
+
python3 - <<'EOF'
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.request
|
|
28
|
+
|
|
29
|
+
with open("server.json", encoding="utf-8") as f:
|
|
30
|
+
package = json.load(f)["packages"][0]
|
|
31
|
+
name, version = package["identifier"], package["version"]
|
|
32
|
+
url = f"https://pypi.org/pypi/{name}/{version}/json"
|
|
33
|
+
deadline = time.monotonic() + 600
|
|
34
|
+
while True:
|
|
35
|
+
try:
|
|
36
|
+
with urllib.request.urlopen(url, timeout=10):
|
|
37
|
+
print(f"{name}=={version} is available on PyPI.")
|
|
38
|
+
sys.exit(0)
|
|
39
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
40
|
+
print(f"{name}=={version} not on PyPI yet ({exc}); "
|
|
41
|
+
"retrying in 15s...")
|
|
42
|
+
if time.monotonic() >= deadline:
|
|
43
|
+
sys.exit(f"Timed out after 10 minutes waiting for "
|
|
44
|
+
f"{name}=={version} to appear on PyPI. "
|
|
45
|
+
"Check the PyPI publish workflow, then re-run "
|
|
46
|
+
"this workflow via workflow_dispatch.")
|
|
47
|
+
time.sleep(15)
|
|
48
|
+
EOF
|
|
49
|
+
- name: Install mcp-publisher
|
|
50
|
+
run: |
|
|
51
|
+
curl -sL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
|
|
52
|
+
- name: Login to MCP Registry (GitHub OIDC)
|
|
53
|
+
run: ./mcp-publisher login github-oidc
|
|
54
|
+
- name: Publish server.json
|
|
55
|
+
run: ./mcp-publisher publish
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.3.0 — 2026-07-17
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Undo executor**: `undo list` / `undo apply <id>` (CLI + MCP) — apply a recorded replayable inverse; the dispatched inverse is re-gated by its own risk tier; single-use, dry-run, double-confirm, both wrapper + inverse audited.
|
|
7
|
+
|
|
8
|
+
## v0.2.1 — 2026-07-16
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **`secrets.enc` now follows `POSTGRES_AIOPS_HOME`** (secretstore hardcoded the real
|
|
12
|
+
home directory; config/audit/undo already relocated — found in live verification).
|
|
13
|
+
- **Audit fidelity**: failures sanitized into `{"error": ...}` results by the MCP error
|
|
14
|
+
layer are now audited as `status=error` (they previously read as `ok`, hiding failed
|
|
15
|
+
attempts from exception reports), and no undo is recorded for a call that failed.
|
|
16
|
+
- Undo replay fix: `create_index` accepts a `definition` (captured `pg_get_indexdef` statement, shape-validated), making `drop_index`'s undo descriptor replayable.
|
|
17
|
+
|
|
18
|
+
### Tests
|
|
19
|
+
- `doctor` and the `init` wizard are now fully covered (previously ~10–20%); plus a
|
|
20
|
+
regression test for the sanitized-failure audit status.
|
|
21
|
+
|
|
3
22
|
## v0.2.0 — 2026-07-13
|
|
4
23
|
|
|
5
24
|
Security-hardening release from a line-wide code review.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: postgres-aiops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Governed AI-ops for PostgreSQL DBA operations: slow-query RCA, bloat/vacuum & blocking-lock analysis with a built-in governance harness (audit, budget, undo, risk tiers)
|
|
5
5
|
Author-email: wei <zhouwei008@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -162,12 +162,13 @@ def run_analyze(table: str, dry_run: bool = False, target: Optional[str] = None)
|
|
|
162
162
|
@governed_tool(risk_level="medium", undo=_create_index_undo)
|
|
163
163
|
@tool_errors("dict")
|
|
164
164
|
def create_index(
|
|
165
|
-
table: str,
|
|
166
|
-
columns: list[str],
|
|
165
|
+
table: Optional[str] = None,
|
|
166
|
+
columns: Optional[list[str]] = None,
|
|
167
167
|
name: Optional[str] = None,
|
|
168
168
|
unique: bool = False,
|
|
169
169
|
concurrently: bool = False,
|
|
170
170
|
method: Optional[str] = None,
|
|
171
|
+
definition: Optional[str] = None,
|
|
171
172
|
dry_run: bool = False,
|
|
172
173
|
target: Optional[str] = None,
|
|
173
174
|
) -> dict:
|
|
@@ -175,21 +176,34 @@ def create_index(
|
|
|
175
176
|
|
|
176
177
|
Supports CONCURRENTLY (non-blocking build). The created name is returned so
|
|
177
178
|
the harness records an undo that drops exactly this index. Pass dry_run=True
|
|
178
|
-
to preview.
|
|
179
|
+
to preview. Alternatively pass ``definition`` (a captured pg_get_indexdef
|
|
180
|
+
statement — this is how drop_index's undo descriptor replays) INSTEAD of
|
|
181
|
+
table/columns.
|
|
179
182
|
|
|
180
183
|
Args:
|
|
181
|
-
table: Table to index (optionally schema-qualified).
|
|
182
|
-
|
|
184
|
+
table: Table to index (optionally schema-qualified). Required unless
|
|
185
|
+
``definition`` is given.
|
|
186
|
+
columns: Column names to index. Required unless ``definition`` is given.
|
|
183
187
|
name: Index name (auto-generated from table+columns when omitted).
|
|
184
188
|
unique: Create a UNIQUE index.
|
|
185
189
|
concurrently: Build with CONCURRENTLY (no table lock).
|
|
186
190
|
method: Index method — btree/hash/gist/gin/brin/spgist (default btree).
|
|
191
|
+
definition: A full CREATE [UNIQUE] INDEX statement to execute verbatim
|
|
192
|
+
(shape-validated). Mutually exclusive with table/columns.
|
|
187
193
|
dry_run: If True, preview without creating.
|
|
188
194
|
target: Target name from config; omit for the default.
|
|
189
195
|
"""
|
|
196
|
+
if definition and (table or columns):
|
|
197
|
+
raise ValueError("Pass either definition OR table+columns, not both.")
|
|
198
|
+
if not definition and not (table and columns):
|
|
199
|
+
raise ValueError("create_index requires table+columns (or a definition).")
|
|
190
200
|
conn = _get_connection(target)
|
|
191
201
|
if dry_run:
|
|
202
|
+
if definition:
|
|
203
|
+
return {"dryRun": True, "wouldExecute": definition}
|
|
192
204
|
return {"dryRun": True, "wouldCreate": {"table": table, "columns": columns, "name": name}}
|
|
205
|
+
if definition:
|
|
206
|
+
return ops.create_index_from_definition(conn, definition)
|
|
193
207
|
return ops.create_index(
|
|
194
208
|
conn, table, columns, name=name, unique=unique,
|
|
195
209
|
concurrently=concurrently, method=method,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Undo executor — list recorded inverse descriptors and APPLY them.
|
|
2
|
+
|
|
3
|
+
Every governed write records a replayable inverse descriptor (tool + params) to
|
|
4
|
+
``undo.db``. This module closes the loop: ``undo_apply`` looks up a recorded
|
|
5
|
+
descriptor and dispatches it to the named governed tool, so the inverse runs on
|
|
6
|
+
the SAME governance path as any other call (audited, budget-checked, and — if
|
|
7
|
+
the inverse itself is destructive — re-gated by its own risk tier / approver
|
|
8
|
+
requirement). ``undo_apply`` is itself governed; the real risk is enforced by
|
|
9
|
+
the inner tool it calls.
|
|
10
|
+
|
|
11
|
+
Note: recorded ``undo_params`` are the redacted safe-params captured at record
|
|
12
|
+
time, so an inverse that would need a secret value cannot be replayed here — by
|
|
13
|
+
design, inverses in this line key off ids/names, not credentials.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
|
|
19
|
+
from mcp_server._shared import mcp, tool_errors
|
|
20
|
+
from postgres_aiops.governance import governed_tool
|
|
21
|
+
from postgres_aiops.governance.undo import get_undo_store
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _resolve_tool(name: str) -> Any:
|
|
25
|
+
"""Return the governed callable registered under ``name`` (or None)."""
|
|
26
|
+
tool = mcp._tool_manager._tools.get(name)
|
|
27
|
+
return getattr(tool, "fn", None) if tool else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@mcp.tool()
|
|
31
|
+
@governed_tool(risk_level="low")
|
|
32
|
+
@tool_errors("dict")
|
|
33
|
+
def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
|
|
34
|
+
"""[READ] List recorded, not-yet-applied undo tokens (most recent first).
|
|
35
|
+
|
|
36
|
+
Each entry names the original tool, the inverse tool that ``undo_apply``
|
|
37
|
+
would run, and a human note. Use the ``undoId`` with ``undo_apply``.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
limit: Max rows to return (default 50).
|
|
41
|
+
target: Unused (undo state is host-local); accepted for CLI uniformity.
|
|
42
|
+
"""
|
|
43
|
+
rows = get_undo_store().list(status="recorded", limit=max(1, min(limit, 500)))
|
|
44
|
+
return {
|
|
45
|
+
"count": len(rows),
|
|
46
|
+
"undos": [
|
|
47
|
+
{
|
|
48
|
+
"undoId": r["undo_id"],
|
|
49
|
+
"ts": r["ts"],
|
|
50
|
+
"originalTool": r["tool"],
|
|
51
|
+
"inverseTool": r["undo_tool"],
|
|
52
|
+
"note": r.get("note", ""),
|
|
53
|
+
}
|
|
54
|
+
for r in rows
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@mcp.tool()
|
|
60
|
+
@governed_tool(risk_level="medium")
|
|
61
|
+
@tool_errors("dict")
|
|
62
|
+
def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None) -> dict:
|
|
63
|
+
"""[WRITE][risk=medium] Apply a recorded undo by dispatching its inverse tool.
|
|
64
|
+
|
|
65
|
+
The inverse runs through its own governed tool, so its real risk tier and
|
|
66
|
+
any approver requirement are enforced there. Pass dry_run=True to preview
|
|
67
|
+
the inverse call without executing it. A token can only be applied once.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
undo_id: The undoId from undo_list (or an ``_undo_id`` in a write result).
|
|
71
|
+
dry_run: If True, preview the inverse tool + params without running it.
|
|
72
|
+
target: Passed through to the inverse tool when it accepts a target.
|
|
73
|
+
"""
|
|
74
|
+
store = get_undo_store()
|
|
75
|
+
rec = store.get(undo_id)
|
|
76
|
+
if not rec:
|
|
77
|
+
raise ValueError(f"Unknown undo id '{undo_id}'. Run undo_list to see available tokens.")
|
|
78
|
+
if rec["status"] != "recorded":
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"Undo '{undo_id}' is already '{rec['status']}' — a token can only be applied once."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
inverse_tool = rec["undo_tool"]
|
|
84
|
+
try:
|
|
85
|
+
params = json.loads(rec["undo_params"]) if rec["undo_params"] else {}
|
|
86
|
+
except (ValueError, TypeError):
|
|
87
|
+
params = {}
|
|
88
|
+
if not isinstance(params, dict):
|
|
89
|
+
params = {}
|
|
90
|
+
|
|
91
|
+
fn = _resolve_tool(inverse_tool)
|
|
92
|
+
if fn is None:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"Inverse tool '{inverse_tool}' is not registered on this server; cannot apply."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if dry_run:
|
|
98
|
+
return {
|
|
99
|
+
"dryRun": True,
|
|
100
|
+
"undoId": undo_id,
|
|
101
|
+
"wouldApply": {"tool": inverse_tool, "params": params},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
call_params = dict(params)
|
|
105
|
+
if target is not None and "target" not in call_params:
|
|
106
|
+
call_params["target"] = target
|
|
107
|
+
result = fn(**call_params)
|
|
108
|
+
|
|
109
|
+
# Only mark applied when the inverse did not itself return an error dict.
|
|
110
|
+
if not (isinstance(result, dict) and result.get("error")):
|
|
111
|
+
store.mark(undo_id, "applied")
|
|
112
|
+
applied = True
|
|
113
|
+
else:
|
|
114
|
+
applied = False
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"undoId": undo_id,
|
|
118
|
+
"applied": applied,
|
|
119
|
+
"inverseTool": inverse_tool,
|
|
120
|
+
"result": result,
|
|
121
|
+
}
|
|
@@ -17,6 +17,7 @@ from postgres_aiops.cli.replication import repl_app
|
|
|
17
17
|
from postgres_aiops.cli.secret import secret_app
|
|
18
18
|
from postgres_aiops.cli.server import server_app
|
|
19
19
|
from postgres_aiops.cli.table import table_app
|
|
20
|
+
from postgres_aiops.cli.undo import undo_app
|
|
20
21
|
|
|
21
22
|
app = typer.Typer(
|
|
22
23
|
name="postgres-aiops",
|
|
@@ -33,6 +34,7 @@ app.add_typer(repl_app, name="repl")
|
|
|
33
34
|
app.add_typer(analyze_app, name="analyze")
|
|
34
35
|
app.add_typer(remediate_app, name="remediate")
|
|
35
36
|
app.add_typer(secret_app, name="secret")
|
|
37
|
+
app.add_typer(undo_app, name="undo")
|
|
36
38
|
app.command("init")(init_cmd)
|
|
37
39
|
app.command("overview")(overview_cmd)
|
|
38
40
|
app.command("doctor")(doctor_cmd)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""``postgres-aiops undo`` — list recorded undo tokens and apply them.
|
|
2
|
+
|
|
3
|
+
Real execution is delegated to the ``@governed_tool``-wrapped functions in
|
|
4
|
+
``mcp_server.tools.undo`` so an applied undo is audited on the SAME governance
|
|
5
|
+
path as any other write (the inverse tool it dispatches is itself re-gated).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from postgres_aiops.cli._common import (
|
|
16
|
+
DryRunOption,
|
|
17
|
+
TargetOption,
|
|
18
|
+
cli_errors,
|
|
19
|
+
console,
|
|
20
|
+
double_confirm,
|
|
21
|
+
dry_run_print,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
undo_app = typer.Typer(
|
|
25
|
+
name="undo",
|
|
26
|
+
help="List recorded undo tokens and apply their inverse operations.",
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
UndoIdArg = Annotated[str, typer.Argument(help="Undo id from 'undo list'")]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@undo_app.command("list")
|
|
34
|
+
@cli_errors
|
|
35
|
+
def undo_list_cmd(
|
|
36
|
+
limit: Annotated[int, typer.Option("--limit", help="Max tokens to show")] = 50,
|
|
37
|
+
target: TargetOption = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""List recorded, not-yet-applied undo tokens."""
|
|
40
|
+
from mcp_server.tools import undo as gov
|
|
41
|
+
|
|
42
|
+
console.print_json(json.dumps(gov.undo_list(limit=limit, target=target)))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@undo_app.command("apply")
|
|
46
|
+
@cli_errors
|
|
47
|
+
def undo_apply_cmd(
|
|
48
|
+
undo_id: UndoIdArg, target: TargetOption = None, dry_run: DryRunOption = False
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Apply a recorded undo (dispatches its inverse tool; dry-run + confirm)."""
|
|
51
|
+
from mcp_server.tools import undo as gov
|
|
52
|
+
|
|
53
|
+
if dry_run:
|
|
54
|
+
preview = gov.undo_apply(undo_id=undo_id, dry_run=True, target=target)
|
|
55
|
+
dry_run_print(
|
|
56
|
+
operation="undo_apply",
|
|
57
|
+
api_call=f"inverse: {preview.get('wouldApply', {}).get('tool', '?')}",
|
|
58
|
+
parameters=preview.get("wouldApply", {}).get("params", {}),
|
|
59
|
+
)
|
|
60
|
+
return
|
|
61
|
+
double_confirm("apply undo", undo_id)
|
|
62
|
+
console.print_json(json.dumps(gov.undo_apply(undo_id=undo_id, target=target)))
|
|
@@ -317,6 +317,8 @@ def _record_undo(state: _CallState, result: Any) -> None:
|
|
|
317
317
|
"""
|
|
318
318
|
if state.undo is None:
|
|
319
319
|
return
|
|
320
|
+
if isinstance(result, dict) and result.get("error"):
|
|
321
|
+
return # sanitized failure — no change happened, so no inverse to record
|
|
320
322
|
try:
|
|
321
323
|
descriptor = state.undo(state.safe_params, result)
|
|
322
324
|
except Exception: # noqa: BLE001 — undo computation must not fail the call
|
|
@@ -356,6 +358,12 @@ def _capture_error(state: _CallState, exc: Exception) -> None:
|
|
|
356
358
|
|
|
357
359
|
def _finalize(state: _CallState) -> None:
|
|
358
360
|
"""Audit + circuit-breaker bookkeeping. Runs in the wrapper's finally."""
|
|
361
|
+
# A sanitized failure (@tool_errors converts exceptions into {"error": ...}
|
|
362
|
+
# dicts BEFORE this harness sees them) must not be audited as success —
|
|
363
|
+
# compliance exception reports are built from this status.
|
|
364
|
+
if state.status == "ok" and isinstance(state.result, dict) and state.result.get("error"):
|
|
365
|
+
state.status = "error"
|
|
366
|
+
|
|
359
367
|
duration = int((time.time() - state.start) * 1000)
|
|
360
368
|
|
|
361
369
|
# Accumulate wall-time toward the cumulative time budget (best-effort).
|
|
@@ -162,6 +162,36 @@ def create_index(
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
|
|
165
|
+
# Shape gate for replaying a captured pg_get_indexdef statement. Server-generated
|
|
166
|
+
# (never user-composed), but validated anyway: single statement, CREATE INDEX only.
|
|
167
|
+
_INDEXDEF_RE = re.compile(
|
|
168
|
+
r"^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?"
|
|
169
|
+
r'("?[A-Za-z_][A-Za-z0-9_$]*"?)\s+ON\s+',
|
|
170
|
+
re.IGNORECASE,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def create_index_from_definition(conn: Any, definition: str) -> dict:
|
|
175
|
+
"""[WRITE] Recreate an index from a captured ``pg_get_indexdef`` statement.
|
|
176
|
+
|
|
177
|
+
This is the replay path for ``drop_index``'s undo descriptor: the exact
|
|
178
|
+
definition captured before the drop is executed verbatim after a shape
|
|
179
|
+
check (single statement, must be CREATE [UNIQUE] INDEX ... ON ...).
|
|
180
|
+
"""
|
|
181
|
+
stmt = (definition or "").strip().rstrip(";").strip()
|
|
182
|
+
if not stmt or ";" in stmt:
|
|
183
|
+
raise ValueError("definition must be a single CREATE INDEX statement.")
|
|
184
|
+
m = _INDEXDEF_RE.match(stmt)
|
|
185
|
+
if not m:
|
|
186
|
+
raise ValueError("definition must start with CREATE [UNIQUE] INDEX ... ON ...")
|
|
187
|
+
conn.execute(stmt) # nosec B608 — shape-validated pg_get_indexdef output
|
|
188
|
+
return {
|
|
189
|
+
"action": "create_index",
|
|
190
|
+
"index": m.group(1).strip('"'),
|
|
191
|
+
"fromDefinition": True,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
165
195
|
def drop_index(conn: Any, name: str, concurrently: bool = False) -> dict:
|
|
166
196
|
"""[WRITE] Drop an index. Reversible: captures pg_get_indexdef first so undo recreates it."""
|
|
167
197
|
ident = qualify(name)
|
|
@@ -39,9 +39,11 @@ from cryptography.exceptions import InvalidKey
|
|
|
39
39
|
from cryptography.fernet import Fernet, InvalidToken
|
|
40
40
|
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
|
41
41
|
|
|
42
|
+
from postgres_aiops.governance.paths import ops_home
|
|
43
|
+
|
|
42
44
|
# ─── Tool-specific constants (change these three to vendor for another tool) ──
|
|
43
45
|
APP_NAME = "postgres-aiops"
|
|
44
|
-
CONFIG_DIR =
|
|
46
|
+
CONFIG_DIR = ops_home()
|
|
45
47
|
MASTER_PASSWORD_ENV = "POSTGRES_AIOPS_MASTER_PASSWORD" # nosec B105 — env-var name
|
|
46
48
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
47
49
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "postgres-aiops"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Governed AI-ops for PostgreSQL DBA operations: slow-query RCA, bloat/vacuum & blocking-lock analysis with a built-in governance harness (audit, budget, undo, risk tiers)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/AIops-tools/Postgres-AIops",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.3.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "postgres-aiops",
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.3.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
|
@@ -105,6 +105,7 @@ Pass data straight to the analysis tools — `slow_query_rca(statements=[...])`,
|
|
|
105
105
|
|
|
106
106
|
- Every tool is audited to `~/.postgres-aiops/audit.db` (relocatable via `POSTGRES_AIOPS_HOME`).
|
|
107
107
|
- High-risk ops can require a named approver: set `POSTGRES_AUDIT_APPROVED_BY` and `POSTGRES_AUDIT_RATIONALE` (the env-var names the bundled harness reads).
|
|
108
|
+
- **Secure by default (v0.2.0+)**: with no `~/.postgres-aiops/rules.yaml`, high/critical operations are denied unless `POSTGRES_AUDIT_APPROVED_BY` names an approver (set `POSTGRES_AUDIT_RATIONALE` too). `postgres-aiops init` seeds a starter rules.yaml; an operator-authored rules file is honoured as-is.
|
|
108
109
|
- Writes support `--dry-run` / `dry_run=True` and double confirmation at the CLI.
|
|
109
110
|
- Reversible writes fetch the real before-state and record an inverse descriptor; irreversible ops (terminate/cancel, vacuum/analyze, reindex, reset stats) record prior stats only.
|
|
110
111
|
- All values are bound query parameters; identifiers that cannot be parameterised are validated and quoted.
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Tests for ``run_doctor`` — environment and connectivity diagnostics.
|
|
2
|
+
|
|
3
|
+
Everything is redirected to a tmp dir (config, secret store) and the
|
|
4
|
+
connection layer is faked at the ``ConnectionManager`` boundary, so no test
|
|
5
|
+
ever touches a real PostgreSQL server or ``~/.postgres-aiops``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
import yaml
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
import postgres_aiops.config as config_mod
|
|
15
|
+
import postgres_aiops.connection as connection_mod
|
|
16
|
+
import postgres_aiops.doctor as doctor_mod
|
|
17
|
+
import postgres_aiops.secretstore as ss
|
|
18
|
+
from postgres_aiops.doctor import run_doctor
|
|
19
|
+
|
|
20
|
+
MASTER_PW = "test-master-pw"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def doctor_home(tmp_path, monkeypatch):
|
|
25
|
+
"""Isolate config + secret store paths under tmp_path."""
|
|
26
|
+
config_file = tmp_path / "config.yaml"
|
|
27
|
+
env_file = tmp_path / ".env"
|
|
28
|
+
secrets_file = tmp_path / "secrets.enc"
|
|
29
|
+
monkeypatch.setenv("POSTGRES_AIOPS_HOME", str(tmp_path))
|
|
30
|
+
monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
|
|
31
|
+
monkeypatch.setattr(config_mod, "CONFIG_FILE", config_file)
|
|
32
|
+
monkeypatch.setattr(config_mod, "ENV_FILE", env_file)
|
|
33
|
+
monkeypatch.setattr(doctor_mod, "CONFIG_FILE", config_file)
|
|
34
|
+
monkeypatch.setattr(doctor_mod, "ENV_FILE", env_file)
|
|
35
|
+
monkeypatch.setattr(doctor_mod, "SECRETS_FILE", secrets_file)
|
|
36
|
+
monkeypatch.setattr(ss, "CONFIG_DIR", tmp_path)
|
|
37
|
+
monkeypatch.setattr(ss, "SECRETS_FILE", secrets_file)
|
|
38
|
+
monkeypatch.setattr(ss, "LEGACY_ENV_FILE", env_file)
|
|
39
|
+
monkeypatch.setattr(ss, "_cached", None)
|
|
40
|
+
# Wide console so long messages don't wrap mid-assertion.
|
|
41
|
+
monkeypatch.setattr(doctor_mod, "_console", Console(width=500))
|
|
42
|
+
monkeypatch.delenv("PG_PRIMARY_PASSWORD", raising=False)
|
|
43
|
+
return tmp_path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _write_config(tmp_path, targets: list[dict]) -> None:
|
|
47
|
+
(tmp_path / "config.yaml").write_text(yaml.safe_dump({"targets": targets}), "utf-8")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _seed_secret(monkeypatch, name: str = "primary", value: str = "s3cret") -> None:
|
|
51
|
+
monkeypatch.setenv("POSTGRES_AIOPS_MASTER_PASSWORD", MASTER_PW)
|
|
52
|
+
ss.SecretStore.unlock(MASTER_PW).set(name, value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_TARGET = {"name": "primary", "host": "db1.example.com", "port": 5432}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _HealthyManager:
|
|
59
|
+
"""Stands in for ConnectionManager: every connect() succeeds."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, config) -> None:
|
|
62
|
+
self._config = config
|
|
63
|
+
|
|
64
|
+
def connect(self, name):
|
|
65
|
+
class _Conn:
|
|
66
|
+
def query_one(self, sql):
|
|
67
|
+
return {"version": "PostgreSQL 16.4 on x86_64-pc-linux-gnu"}
|
|
68
|
+
|
|
69
|
+
return _Conn()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _UnreachableManager:
|
|
73
|
+
"""Stands in for ConnectionManager: every connect() fails."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, config) -> None:
|
|
76
|
+
self._config = config
|
|
77
|
+
|
|
78
|
+
def connect(self, name):
|
|
79
|
+
raise ConnectionError("connection to server at 'db1.example.com' refused")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.unit
|
|
83
|
+
def test_doctor_missing_config_fails_with_init_hint(doctor_home, capsys):
|
|
84
|
+
assert run_doctor() == 1
|
|
85
|
+
out = capsys.readouterr().out
|
|
86
|
+
assert "Config file missing" in out
|
|
87
|
+
assert "postgres-aiops init" in out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.unit
|
|
91
|
+
def test_doctor_config_load_failure_reported_not_raised(doctor_home, capsys):
|
|
92
|
+
(doctor_home / "config.yaml").write_text("targets: [unclosed", "utf-8")
|
|
93
|
+
assert run_doctor() == 1
|
|
94
|
+
assert "Config load failed" in capsys.readouterr().out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.mark.unit
|
|
98
|
+
def test_doctor_no_targets_configured(doctor_home, capsys):
|
|
99
|
+
_write_config(doctor_home, [])
|
|
100
|
+
assert run_doctor() == 1
|
|
101
|
+
assert "No targets configured" in capsys.readouterr().out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.mark.unit
|
|
105
|
+
def test_doctor_all_healthy_exit_zero(doctor_home, monkeypatch, capsys):
|
|
106
|
+
_write_config(doctor_home, [_TARGET])
|
|
107
|
+
_seed_secret(monkeypatch)
|
|
108
|
+
monkeypatch.setattr(connection_mod, "ConnectionManager", _HealthyManager)
|
|
109
|
+
assert run_doctor() == 0
|
|
110
|
+
out = capsys.readouterr().out
|
|
111
|
+
assert "Config file present" in out
|
|
112
|
+
assert "1 target(s) configured" in out
|
|
113
|
+
assert "Encrypted secret store present" in out
|
|
114
|
+
assert "password present for 'primary'" in out
|
|
115
|
+
assert "Connected to 'primary' (db1.example.com:5432)" in out
|
|
116
|
+
assert "PostgreSQL 16.4" in out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.unit
|
|
120
|
+
def test_doctor_skip_auth_skips_connectivity(doctor_home, monkeypatch, capsys):
|
|
121
|
+
_write_config(doctor_home, [_TARGET])
|
|
122
|
+
_seed_secret(monkeypatch)
|
|
123
|
+
|
|
124
|
+
def _boom(config): # doctor must not even construct a manager
|
|
125
|
+
raise AssertionError("ConnectionManager should not be used with --skip-auth")
|
|
126
|
+
|
|
127
|
+
monkeypatch.setattr(connection_mod, "ConnectionManager", _boom)
|
|
128
|
+
assert run_doctor(skip_auth=True) == 0
|
|
129
|
+
out = capsys.readouterr().out
|
|
130
|
+
assert "Skipping connectivity check" in out
|
|
131
|
+
assert "Connected" not in out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@pytest.mark.unit
|
|
135
|
+
def test_doctor_unreachable_target_exit_one(doctor_home, monkeypatch, capsys):
|
|
136
|
+
_write_config(doctor_home, [_TARGET])
|
|
137
|
+
_seed_secret(monkeypatch)
|
|
138
|
+
monkeypatch.setattr(connection_mod, "ConnectionManager", _UnreachableManager)
|
|
139
|
+
assert run_doctor() == 1
|
|
140
|
+
out = capsys.readouterr().out
|
|
141
|
+
assert "Connect to 'primary' failed" in out
|
|
142
|
+
assert "refused" in out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@pytest.mark.unit
|
|
146
|
+
def test_doctor_no_secret_store_and_no_password(doctor_home, capsys):
|
|
147
|
+
_write_config(doctor_home, [_TARGET])
|
|
148
|
+
assert run_doctor(skip_auth=True) == 1
|
|
149
|
+
out = capsys.readouterr().out
|
|
150
|
+
assert "No secret store yet" in out
|
|
151
|
+
assert "No password for target 'primary'" in out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@pytest.mark.unit
|
|
155
|
+
def test_doctor_legacy_env_file_warns_migrate(doctor_home, monkeypatch, capsys):
|
|
156
|
+
_write_config(doctor_home, [_TARGET])
|
|
157
|
+
(doctor_home / ".env").write_text("PG_PRIMARY_PASSWORD=legacy\n", "utf-8")
|
|
158
|
+
monkeypatch.setenv("PG_PRIMARY_PASSWORD", "legacy")
|
|
159
|
+
assert run_doctor(skip_auth=True) == 0
|
|
160
|
+
out = capsys.readouterr().out
|
|
161
|
+
assert "legacy plaintext .env" in out
|
|
162
|
+
assert "secret migrate" in out
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@pytest.mark.unit
|
|
166
|
+
def test_doctor_warns_on_loose_secret_permissions(doctor_home, monkeypatch, capsys):
|
|
167
|
+
_write_config(doctor_home, [_TARGET])
|
|
168
|
+
_seed_secret(monkeypatch)
|
|
169
|
+
(doctor_home / "secrets.enc").chmod(0o644)
|
|
170
|
+
assert run_doctor(skip_auth=True) == 0
|
|
171
|
+
assert "should be 600" in capsys.readouterr().out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@pytest.mark.unit
|
|
175
|
+
def test_cli_doctor_command_exits_with_doctor_code(doctor_home, monkeypatch):
|
|
176
|
+
from typer.testing import CliRunner
|
|
177
|
+
|
|
178
|
+
from postgres_aiops.cli import app
|
|
179
|
+
|
|
180
|
+
_write_config(doctor_home, [_TARGET])
|
|
181
|
+
_seed_secret(monkeypatch)
|
|
182
|
+
result = CliRunner().invoke(app, ["doctor", "--skip-auth"])
|
|
183
|
+
assert result.exit_code == 0
|
|
184
|
+
assert "Skipping connectivity check" in result.output
|