postgres-aiops 0.2.1__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.1 → postgres_aiops-0.3.0}/.gitignore +1 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/CHANGELOG.md +5 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/PKG-INFO +1 -1
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/server.py +1 -0
- postgres_aiops-0.3.0/mcp_server/tools/undo.py +121 -0
- {postgres_aiops-0.2.1 → 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.1 → postgres_aiops-0.3.0}/pyproject.toml +1 -1
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/server.json +2 -2
- {postgres_aiops-0.2.1 → 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.2.1 → postgres_aiops-0.3.0}/uv.lock +1 -1
- postgres_aiops-0.2.1/.coverage +0 -0
- postgres_aiops-0.2.1/.github/workflows/mcp-publish.yml +0 -23
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/.github/workflows/publish.yml +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/LICENSE +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/README.md +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/RELEASE_NOTES.md +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/SECURITY.md +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/__init__.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/_shared.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/tools/__init__.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/tools/activity.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/tools/analysis.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/tools/indexes.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/tools/queries.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/tools/remediation.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/tools/replication.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/tools/server.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/tools/tables.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/__init__.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/__init__.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/_common.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/activity.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/analyze.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/doctor.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/index.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/init.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/overview.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/query.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/remediate.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/replication.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/secret.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/server.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/table.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/config.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/connection.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/doctor.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/governance/__init__.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/governance/audit.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/governance/budget.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/governance/decorators.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/governance/paths.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/governance/patterns.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/governance/policy.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/governance/sanitize.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/governance/undo.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/__init__.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/_util.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/activity.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/analysis.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/indexes.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/overview.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/queries.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/remediation.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/replication.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/server.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/tables.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/secretstore.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/skills/postgres-aiops/SKILL.md +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/skills/postgres-aiops/references/capabilities.md +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/skills/postgres-aiops/references/cli-reference.md +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/skills/postgres-aiops/references/setup-guide.md +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/smithery.yaml +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/conftest.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_analysis.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_cli_writes.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_connection.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_doctor.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_governance_persistence.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_init.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_reads.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_secretstore.py +0 -0
- {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_undo_replay.py +0 -0
- {postgres_aiops-0.2.1 → 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,10 @@
|
|
|
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
|
+
|
|
3
8
|
## v0.2.1 — 2026-07-16
|
|
4
9
|
|
|
5
10
|
### Fixed
|
|
@@ -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
|
|
@@ -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)))
|
|
@@ -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
|
}
|
|
@@ -34,6 +34,8 @@ EXPECTED_TOOLS = {
|
|
|
34
34
|
# writes
|
|
35
35
|
"reset_query_stats", "terminate_backend", "cancel_query", "run_vacuum",
|
|
36
36
|
"run_analyze", "create_index", "drop_index", "reindex", "update_setting",
|
|
37
|
+
# undo executor
|
|
38
|
+
"undo_list", "undo_apply",
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
WRITE_RISK = {
|
|
@@ -64,6 +66,7 @@ def test_all_modules_import():
|
|
|
64
66
|
"mcp_server.server", "mcp_server._shared",
|
|
65
67
|
"mcp_server.tools.server", "mcp_server.tools.activity",
|
|
66
68
|
"mcp_server.tools.queries", "mcp_server.tools.indexes",
|
|
69
|
+
"postgres_aiops.cli.undo", "mcp_server.tools.undo",
|
|
67
70
|
"mcp_server.tools.tables", "mcp_server.tools.replication",
|
|
68
71
|
"mcp_server.tools.analysis", "mcp_server.tools.remediation",
|
|
69
72
|
):
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Undo EXECUTOR — undo_apply dispatches a recorded inverse through its governed
|
|
2
|
+
tool, on a real undo.db in an isolated home. Closes the loop from "undo recorded"
|
|
3
|
+
to "undo actually executed".
|
|
4
|
+
|
|
5
|
+
Portable across the line: the dispatched inverse is a synthetic governed tool
|
|
6
|
+
registered on the real MCP instance, so this file is identical everywhere except
|
|
7
|
+
the package import path.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sqlite3
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
import postgres_aiops.governance.audit as audit_mod
|
|
17
|
+
import postgres_aiops.governance.policy as policy_mod
|
|
18
|
+
import postgres_aiops.governance.undo as undo_mod
|
|
19
|
+
from mcp_server._shared import mcp
|
|
20
|
+
from mcp_server.tools import undo as gov
|
|
21
|
+
from postgres_aiops.governance import governed_tool
|
|
22
|
+
|
|
23
|
+
_CALLS: list[dict] = []
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@governed_tool(risk_level="low")
|
|
27
|
+
def _undo_probe(value: str = "", target=None) -> dict:
|
|
28
|
+
"""Synthetic inverse target used only by the undo-executor tests."""
|
|
29
|
+
_CALLS.append({"value": value})
|
|
30
|
+
return {"ok": True, "value": value}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def gov_home(tmp_path, monkeypatch):
|
|
35
|
+
_CALLS.clear()
|
|
36
|
+
# Register the probe only for the duration of these tests so it never
|
|
37
|
+
# pollutes the real tool registry (which exact-count smoke tests assert on).
|
|
38
|
+
mcp.add_tool(_undo_probe, name="_undo_probe")
|
|
39
|
+
monkeypatch.setenv("POSTGRES_AIOPS_HOME", str(tmp_path))
|
|
40
|
+
monkeypatch.setenv("POSTGRES_AUDIT_APPROVED_BY", "pytest")
|
|
41
|
+
audit_mod.reset_engine()
|
|
42
|
+
policy_mod.reset_policy_engine()
|
|
43
|
+
undo_mod.reset_undo_store()
|
|
44
|
+
yield tmp_path
|
|
45
|
+
mcp._tool_manager._tools.pop("_undo_probe", None)
|
|
46
|
+
audit_mod.reset_engine()
|
|
47
|
+
policy_mod.reset_policy_engine()
|
|
48
|
+
undo_mod.reset_undo_store()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _record(undo_tool="_undo_probe", params=None):
|
|
52
|
+
descriptor = {"tool": undo_tool, "params": params if params is not None else {"value": "v1"}}
|
|
53
|
+
return undo_mod.get_undo_store().record(
|
|
54
|
+
skill="probe", tool="orig_op", undo_descriptor=descriptor,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.mark.unit
|
|
59
|
+
def test_undo_list_returns_recorded_tokens(gov_home):
|
|
60
|
+
uid = _record()
|
|
61
|
+
out = gov.undo_list()
|
|
62
|
+
assert out["count"] == 1
|
|
63
|
+
assert out["undos"][0]["undoId"] == uid
|
|
64
|
+
assert out["undos"][0]["inverseTool"] == "_undo_probe"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.unit
|
|
68
|
+
def test_undo_apply_dispatches_inverse_and_marks_applied(gov_home):
|
|
69
|
+
uid = _record(params={"value": "restore-me"})
|
|
70
|
+
result = gov.undo_apply(undo_id=uid)
|
|
71
|
+
assert result["applied"] is True
|
|
72
|
+
assert result["inverseTool"] == "_undo_probe"
|
|
73
|
+
# the inverse governed tool actually ran with the recorded params
|
|
74
|
+
assert _CALLS == [{"value": "restore-me"}]
|
|
75
|
+
# the token is consumed (single-use)
|
|
76
|
+
assert undo_mod.get_undo_store().get(uid)["status"] == "applied"
|
|
77
|
+
assert uid not in {u["undoId"] for u in gov.undo_list()["undos"]}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.mark.unit
|
|
81
|
+
def test_undo_apply_dry_run_previews_without_running(gov_home):
|
|
82
|
+
uid = _record()
|
|
83
|
+
out = gov.undo_apply(undo_id=uid, dry_run=True)
|
|
84
|
+
assert out["dryRun"] is True
|
|
85
|
+
assert out["wouldApply"]["tool"] == "_undo_probe"
|
|
86
|
+
assert _CALLS == []
|
|
87
|
+
assert undo_mod.get_undo_store().get(uid)["status"] == "recorded"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.unit
|
|
91
|
+
def test_undo_apply_is_single_use(gov_home):
|
|
92
|
+
uid = _record()
|
|
93
|
+
gov.undo_apply(undo_id=uid)
|
|
94
|
+
second = gov.undo_apply(undo_id=uid)
|
|
95
|
+
assert "already 'applied'" in second["error"]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.mark.unit
|
|
99
|
+
def test_undo_apply_unknown_id_errors(gov_home):
|
|
100
|
+
out = gov.undo_apply(undo_id="deadbeef")
|
|
101
|
+
assert "Unknown undo id" in out["error"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.mark.unit
|
|
105
|
+
def test_undo_apply_unregistered_inverse_errors(gov_home):
|
|
106
|
+
uid = _record(undo_tool="no_such_tool_xyz")
|
|
107
|
+
out = gov.undo_apply(undo_id=uid)
|
|
108
|
+
assert "not registered" in out["error"]
|
|
109
|
+
assert undo_mod.get_undo_store().get(uid)["status"] == "recorded"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.mark.unit
|
|
113
|
+
def test_cli_undo_apply_dry_run_renders(gov_home):
|
|
114
|
+
"""The `undo apply --dry-run` CLI path must render without error — guards
|
|
115
|
+
against dry_run_print signature drift across tools (api_call vs detail)."""
|
|
116
|
+
from typer.testing import CliRunner
|
|
117
|
+
|
|
118
|
+
from postgres_aiops.cli import app
|
|
119
|
+
|
|
120
|
+
uid = _record()
|
|
121
|
+
result = CliRunner().invoke(app, ["undo", "apply", uid, "--dry-run"])
|
|
122
|
+
assert result.exit_code == 0, result.output
|
|
123
|
+
assert "DRY-RUN" in result.output
|
|
124
|
+
assert _CALLS == []
|
|
125
|
+
assert undo_mod.get_undo_store().get(uid)["status"] == "recorded"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@pytest.mark.unit
|
|
129
|
+
def test_undo_apply_audits_both_wrapper_and_inverse(gov_home):
|
|
130
|
+
uid = _record()
|
|
131
|
+
gov.undo_apply(undo_id=uid)
|
|
132
|
+
conn = sqlite3.connect(gov_home / "audit.db")
|
|
133
|
+
try:
|
|
134
|
+
tools = [r[0] for r in conn.execute("SELECT tool FROM audit_log ORDER BY id")]
|
|
135
|
+
finally:
|
|
136
|
+
conn.close()
|
|
137
|
+
assert "undo_apply" in tools
|
|
138
|
+
assert "_undo_probe" in tools
|
postgres_aiops-0.2.1/.coverage
DELETED
|
Binary file
|
|
@@ -1,23 +0,0 @@
|
|
|
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: Install mcp-publisher
|
|
18
|
-
run: |
|
|
19
|
-
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
|
|
20
|
-
- name: Login to MCP Registry (GitHub OIDC)
|
|
21
|
-
run: ./mcp-publisher login github-oidc
|
|
22
|
-
- name: Publish server.json
|
|
23
|
-
run: ./mcp-publisher publish
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/skills/postgres-aiops/references/capabilities.md
RENAMED
|
File without changes
|
{postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/skills/postgres-aiops/references/cli-reference.md
RENAMED
|
File without changes
|
{postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/skills/postgres-aiops/references/setup-guide.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|