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.
Files changed (85) hide show
  1. postgres_aiops-0.3.0/.github/workflows/mcp-publish.yml +55 -0
  2. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/.gitignore +1 -0
  3. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/CHANGELOG.md +19 -0
  4. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/PKG-INFO +1 -1
  5. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/server.py +1 -0
  6. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/tools/remediation.py +19 -5
  7. postgres_aiops-0.3.0/mcp_server/tools/undo.py +121 -0
  8. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/_root.py +2 -0
  9. postgres_aiops-0.3.0/postgres_aiops/cli/undo.py +62 -0
  10. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/governance/decorators.py +8 -0
  11. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/remediation.py +30 -0
  12. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/secretstore.py +3 -1
  13. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/pyproject.toml +1 -1
  14. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/server.json +2 -2
  15. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/skills/postgres-aiops/SKILL.md +1 -0
  16. postgres_aiops-0.3.0/tests/test_doctor.py +184 -0
  17. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/tests/test_governance_persistence.py +22 -0
  18. postgres_aiops-0.3.0/tests/test_init.py +122 -0
  19. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/tests/test_smoke.py +3 -0
  20. postgres_aiops-0.3.0/tests/test_undo_executor.py +138 -0
  21. postgres_aiops-0.3.0/tests/test_undo_replay.py +66 -0
  22. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/uv.lock +1 -1
  23. postgres_aiops-0.2.0/.coverage +0 -0
  24. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/.github/workflows/publish.yml +0 -0
  25. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/LICENSE +0 -0
  26. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/README.md +0 -0
  27. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/RELEASE_NOTES.md +0 -0
  28. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/SECURITY.md +0 -0
  29. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/__init__.py +0 -0
  30. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/_shared.py +0 -0
  31. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/tools/__init__.py +0 -0
  32. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/tools/activity.py +0 -0
  33. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/tools/analysis.py +0 -0
  34. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/tools/indexes.py +0 -0
  35. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/tools/queries.py +0 -0
  36. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/tools/replication.py +0 -0
  37. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/tools/server.py +0 -0
  38. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/mcp_server/tools/tables.py +0 -0
  39. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/__init__.py +0 -0
  40. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/__init__.py +0 -0
  41. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/_common.py +0 -0
  42. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/activity.py +0 -0
  43. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/analyze.py +0 -0
  44. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/doctor.py +0 -0
  45. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/index.py +0 -0
  46. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/init.py +0 -0
  47. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/overview.py +0 -0
  48. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/query.py +0 -0
  49. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/remediate.py +0 -0
  50. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/replication.py +0 -0
  51. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/secret.py +0 -0
  52. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/server.py +0 -0
  53. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/cli/table.py +0 -0
  54. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/config.py +0 -0
  55. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/connection.py +0 -0
  56. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/doctor.py +0 -0
  57. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/governance/__init__.py +0 -0
  58. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/governance/audit.py +0 -0
  59. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/governance/budget.py +0 -0
  60. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/governance/paths.py +0 -0
  61. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/governance/patterns.py +0 -0
  62. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/governance/policy.py +0 -0
  63. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/governance/sanitize.py +0 -0
  64. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/governance/undo.py +0 -0
  65. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/__init__.py +0 -0
  66. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/_util.py +0 -0
  67. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/activity.py +0 -0
  68. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/analysis.py +0 -0
  69. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/indexes.py +0 -0
  70. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/overview.py +0 -0
  71. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/queries.py +0 -0
  72. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/replication.py +0 -0
  73. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/server.py +0 -0
  74. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/postgres_aiops/ops/tables.py +0 -0
  75. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/skills/postgres-aiops/references/capabilities.md +0 -0
  76. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/skills/postgres-aiops/references/cli-reference.md +0 -0
  77. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/skills/postgres-aiops/references/setup-guide.md +0 -0
  78. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/smithery.yaml +0 -0
  79. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/tests/conftest.py +0 -0
  80. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/tests/test_analysis.py +0 -0
  81. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/tests/test_cli_writes.py +0 -0
  82. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/tests/test_connection.py +0 -0
  83. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/tests/test_reads.py +0 -0
  84. {postgres_aiops-0.2.0 → postgres_aiops-0.3.0}/tests/test_secretstore.py +0 -0
  85. {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
@@ -5,3 +5,4 @@ __pycache__/
5
5
  .pytest_cache/
6
6
  .ruff_cache/
7
7
  *.egg-info/
8
+ .coverage
@@ -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.2.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
@@ -26,6 +26,7 @@ from mcp_server.tools import ( # noqa: F401 — side effects
26
26
  replication,
27
27
  server,
28
28
  tables,
29
+ undo,
29
30
  )
30
31
 
31
32
  __all__ = ["mcp", "main", "_safe_error", "tool_errors"]
@@ -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
- columns: Column names to index.
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 = Path.home() / ".postgres-aiops"
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.2.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.2.0",
10
+ "version": "0.3.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "postgres-aiops",
15
- "version": "0.2.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