postgres-aiops 0.2.0__tar.gz → 0.2.1__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 (82) hide show
  1. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/.coverage +0 -0
  2. postgres_aiops-0.2.1/.github/workflows/mcp-publish.yml +23 -0
  3. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/CHANGELOG.md +14 -0
  4. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/PKG-INFO +1 -1
  5. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/tools/remediation.py +19 -5
  6. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/governance/decorators.py +8 -0
  7. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/remediation.py +30 -0
  8. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/secretstore.py +3 -1
  9. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/pyproject.toml +1 -1
  10. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/server.json +2 -2
  11. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/skills/postgres-aiops/SKILL.md +1 -0
  12. postgres_aiops-0.2.1/tests/test_doctor.py +184 -0
  13. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/tests/test_governance_persistence.py +22 -0
  14. postgres_aiops-0.2.1/tests/test_init.py +122 -0
  15. postgres_aiops-0.2.1/tests/test_undo_replay.py +66 -0
  16. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/uv.lock +1 -1
  17. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/.github/workflows/publish.yml +0 -0
  18. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/.gitignore +0 -0
  19. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/LICENSE +0 -0
  20. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/README.md +0 -0
  21. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/RELEASE_NOTES.md +0 -0
  22. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/SECURITY.md +0 -0
  23. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/__init__.py +0 -0
  24. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/_shared.py +0 -0
  25. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/server.py +0 -0
  26. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/tools/__init__.py +0 -0
  27. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/tools/activity.py +0 -0
  28. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/tools/analysis.py +0 -0
  29. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/tools/indexes.py +0 -0
  30. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/tools/queries.py +0 -0
  31. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/tools/replication.py +0 -0
  32. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/tools/server.py +0 -0
  33. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/tools/tables.py +0 -0
  34. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/__init__.py +0 -0
  35. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/__init__.py +0 -0
  36. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/_common.py +0 -0
  37. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/_root.py +0 -0
  38. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/activity.py +0 -0
  39. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/analyze.py +0 -0
  40. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/doctor.py +0 -0
  41. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/index.py +0 -0
  42. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/init.py +0 -0
  43. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/overview.py +0 -0
  44. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/query.py +0 -0
  45. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/remediate.py +0 -0
  46. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/replication.py +0 -0
  47. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/secret.py +0 -0
  48. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/server.py +0 -0
  49. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/table.py +0 -0
  50. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/config.py +0 -0
  51. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/connection.py +0 -0
  52. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/doctor.py +0 -0
  53. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/governance/__init__.py +0 -0
  54. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/governance/audit.py +0 -0
  55. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/governance/budget.py +0 -0
  56. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/governance/paths.py +0 -0
  57. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/governance/patterns.py +0 -0
  58. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/governance/policy.py +0 -0
  59. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/governance/sanitize.py +0 -0
  60. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/governance/undo.py +0 -0
  61. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/__init__.py +0 -0
  62. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/_util.py +0 -0
  63. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/activity.py +0 -0
  64. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/analysis.py +0 -0
  65. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/indexes.py +0 -0
  66. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/overview.py +0 -0
  67. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/queries.py +0 -0
  68. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/replication.py +0 -0
  69. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/server.py +0 -0
  70. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/tables.py +0 -0
  71. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/skills/postgres-aiops/references/capabilities.md +0 -0
  72. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/skills/postgres-aiops/references/cli-reference.md +0 -0
  73. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/skills/postgres-aiops/references/setup-guide.md +0 -0
  74. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/smithery.yaml +0 -0
  75. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/tests/conftest.py +0 -0
  76. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/tests/test_analysis.py +0 -0
  77. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/tests/test_cli_writes.py +0 -0
  78. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/tests/test_connection.py +0 -0
  79. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/tests/test_reads.py +0 -0
  80. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/tests/test_secretstore.py +0 -0
  81. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/tests/test_smoke.py +0 -0
  82. {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/tests/test_writes.py +0 -0
@@ -0,0 +1,23 @@
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
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.1 — 2026-07-16
4
+
5
+ ### Fixed
6
+ - **`secrets.enc` now follows `POSTGRES_AIOPS_HOME`** (secretstore hardcoded the real
7
+ home directory; config/audit/undo already relocated — found in live verification).
8
+ - **Audit fidelity**: failures sanitized into `{"error": ...}` results by the MCP error
9
+ layer are now audited as `status=error` (they previously read as `ok`, hiding failed
10
+ attempts from exception reports), and no undo is recorded for a call that failed.
11
+ - Undo replay fix: `create_index` accepts a `definition` (captured `pg_get_indexdef` statement, shape-validated), making `drop_index`'s undo descriptor replayable.
12
+
13
+ ### Tests
14
+ - `doctor` and the `init` wizard are now fully covered (previously ~10–20%); plus a
15
+ regression test for the sanitized-failure audit status.
16
+
3
17
  ## v0.2.0 — 2026-07-13
4
18
 
5
19
  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.2.1
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
- 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,
@@ -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.2.1"
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.2.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "postgres-aiops",
15
- "version": "0.2.0",
15
+ "version": "0.2.1",
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
@@ -146,3 +146,25 @@ def test_real_write_tool_persists_priorstate_undo(gov_home, monkeypatch, fake_pg
146
146
  audit = _rows(gov_home / "audit.db", "audit_log")
147
147
  assert [r["tool"] for r in audit] == ["drop_index"]
148
148
  assert audit[0]["risk_level"] == "high"
149
+
150
+ @pytest.mark.unit
151
+ def test_sanitized_error_result_is_audited_as_error_and_records_no_undo(gov_home):
152
+ """@tool_errors converts exceptions to {"error": ...} dicts before the harness
153
+ sees them — those must land in the audit log as status=error (not ok), and no
154
+ undo may be recorded for a call that changed nothing."""
155
+
156
+ @governed_tool(
157
+ risk_level="low",
158
+ undo=lambda p, r: {"tool": "never_recorded", "params": {}},
159
+ )
160
+ def _broken_widget(target: str = "") -> dict:
161
+ return {"error": "boom (sanitized upstream)"}
162
+
163
+ result = _broken_widget(target="t1")
164
+ assert result["error"]
165
+ assert "_undo_id" not in result
166
+
167
+ audit = _rows(gov_home / "audit.db", "audit_log")
168
+ assert audit[-1]["tool"] == "_broken_widget"
169
+ assert audit[-1]["status"] == "error"
170
+ assert not (gov_home / "undo.db").exists() or not _rows(gov_home / "undo.db", "undo_log")
@@ -0,0 +1,122 @@
1
+ """Tests for the ``postgres-aiops init`` onboarding wizard.
2
+
3
+ The wizard is driven end-to-end through Typer's CliRunner with every path
4
+ (config.yaml, secrets.enc, rules.yaml) isolated under tmp_path. The master
5
+ password comes from POSTGRES_AIOPS_MASTER_PASSWORD (the non-interactive path)
6
+ and the hidden target-password prompt is patched at the getpass boundary.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import getpass as getpass_mod
12
+
13
+ import pytest
14
+ import yaml
15
+ from typer.testing import CliRunner
16
+
17
+ import postgres_aiops.cli.init as init_mod
18
+ import postgres_aiops.config as config_mod
19
+ import postgres_aiops.doctor as doctor_mod
20
+ import postgres_aiops.secretstore as ss
21
+
22
+ MASTER_PW = "init-master-pw"
23
+ DB_PW = "db-role-pa55word"
24
+
25
+ # Wizard answers: name, host, then accept defaults for port/dbname/user/sslmode,
26
+ # no second target, decline the trailing doctor run.
27
+ WIZARD_INPUT = "primary\ndb1.example.com\n\n\n\n\nn\nn\n"
28
+
29
+
30
+ @pytest.fixture
31
+ def init_home(tmp_path, monkeypatch):
32
+ """Isolate config + secret store + governance home under tmp_path."""
33
+ config_file = tmp_path / "config.yaml"
34
+ secrets_file = tmp_path / "secrets.enc"
35
+ monkeypatch.setenv("POSTGRES_AIOPS_HOME", str(tmp_path))
36
+ monkeypatch.setenv("POSTGRES_AIOPS_MASTER_PASSWORD", MASTER_PW)
37
+ monkeypatch.setattr(init_mod, "CONFIG_DIR", tmp_path)
38
+ monkeypatch.setattr(init_mod, "CONFIG_FILE", config_file)
39
+ monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
40
+ monkeypatch.setattr(config_mod, "CONFIG_FILE", config_file)
41
+ monkeypatch.setattr(ss, "CONFIG_DIR", tmp_path)
42
+ monkeypatch.setattr(ss, "SECRETS_FILE", secrets_file)
43
+ monkeypatch.setattr(ss, "LEGACY_ENV_FILE", tmp_path / ".env")
44
+ monkeypatch.setattr(ss, "_cached", None)
45
+ # The hidden per-target password prompt bypasses CliRunner stdin.
46
+ monkeypatch.setattr(getpass_mod, "getpass", lambda prompt="": DB_PW)
47
+ return tmp_path
48
+
49
+
50
+ def _run_init(input_text: str = WIZARD_INPUT):
51
+ from postgres_aiops.cli import app
52
+
53
+ return CliRunner().invoke(app, ["init"], input=input_text)
54
+
55
+
56
+ @pytest.mark.unit
57
+ def test_init_writes_config_with_entered_values(init_home):
58
+ result = _run_init()
59
+ assert result.exit_code == 0, result.output
60
+ raw = yaml.safe_load((init_home / "config.yaml").read_text("utf-8"))
61
+ assert raw["targets"] == [
62
+ {
63
+ "name": "primary",
64
+ "host": "db1.example.com",
65
+ "port": 5432,
66
+ "dbname": "postgres",
67
+ "user": "postgres",
68
+ "sslmode": "prefer", # accepted default must land in config
69
+ }
70
+ ]
71
+
72
+
73
+ @pytest.mark.unit
74
+ def test_init_stores_secret_encrypted_not_in_config(init_home):
75
+ result = _run_init()
76
+ assert result.exit_code == 0, result.output
77
+ # Password is readable back through the secret store API...
78
+ assert ss.SecretStore.unlock(MASTER_PW).get("primary") == DB_PW
79
+ # ...and never lands in plaintext in config.yaml or secrets.enc.
80
+ assert DB_PW not in (init_home / "config.yaml").read_text("utf-8")
81
+ assert DB_PW not in (init_home / "secrets.enc").read_text("utf-8")
82
+
83
+
84
+ @pytest.mark.unit
85
+ def test_init_seeds_default_rules_with_dual_control_tier(init_home):
86
+ result = _run_init()
87
+ assert result.exit_code == 0, result.output
88
+ rules = yaml.safe_load((init_home / "rules.yaml").read_text("utf-8"))
89
+ tiers = {r["name"]: r for r in rules["risk_tiers"]}
90
+ assert "high-risk-requires-approver" in tiers
91
+ assert tiers["high-risk-requires-approver"]["tier"] == "dual"
92
+ assert tiers["high-risk-requires-approver"]["min_risk_level"] == "high"
93
+
94
+
95
+ @pytest.mark.unit
96
+ def test_init_rerun_does_not_clobber_existing_rules(init_home):
97
+ sentinel = "# operator-authored rules — must survive re-init\nrisk_tiers: []\n"
98
+ (init_home / "rules.yaml").write_text(sentinel, "utf-8")
99
+ result = _run_init()
100
+ assert result.exit_code == 0, result.output
101
+ assert (init_home / "rules.yaml").read_text("utf-8") == sentinel
102
+
103
+
104
+ @pytest.mark.unit
105
+ def test_init_accepting_doctor_confirm_runs_doctor(init_home, monkeypatch):
106
+ calls: list[bool] = []
107
+ monkeypatch.setattr(doctor_mod, "run_doctor", lambda: calls.append(True) or 0)
108
+ # Empty last answer accepts the confirm's default=True.
109
+ result = _run_init("primary\ndb1.example.com\n\n\n\n\nn\n\n")
110
+ assert result.exit_code == 0, result.output
111
+ assert calls == [True]
112
+
113
+
114
+ @pytest.mark.unit
115
+ def test_init_overwrite_existing_target(init_home):
116
+ result = _run_init()
117
+ assert result.exit_code == 0, result.output
118
+ # Same name again: confirm overwrite, new host, accept defaults.
119
+ result = _run_init("primary\ny\ndb2.example.com\n\n\n\n\nn\nn\n")
120
+ assert result.exit_code == 0, result.output
121
+ raw = yaml.safe_load((init_home / "config.yaml").read_text("utf-8"))
122
+ assert [t["host"] for t in raw["targets"]] == ["db2.example.com"]
@@ -0,0 +1,66 @@
1
+ """drop_index → create_index(definition=…) undo REPLAY — the descriptor recorded
2
+ by _drop_index_undo must be executable by the tool it names (found broken in the
3
+ line-wide undo-replayability sweep: create_index had no ``definition`` param)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import pytest
8
+
9
+ from mcp_server.tools import remediation as gov
10
+ from postgres_aiops.ops import remediation as ops
11
+
12
+ INDEXDEF = 'CREATE UNIQUE INDEX idx_orders_ts ON public.orders USING btree (ts)'
13
+
14
+
15
+ @pytest.mark.unit
16
+ def test_drop_index_descriptor_replays_through_create_index(fake_pg, monkeypatch):
17
+ fake = fake_pg(scalars={"pg_get_indexdef": INDEXDEF})
18
+ monkeypatch.setattr(gov, "_get_connection", lambda target=None: fake)
19
+
20
+ dropped = gov.drop_index(name="idx_orders_ts")
21
+ assert dropped["priorState"]["indexdef"] == INDEXDEF
22
+
23
+ descriptor = gov._drop_index_undo({"name": "idx_orders_ts"}, dropped)
24
+ assert descriptor["tool"] == "create_index"
25
+
26
+ replay = gov.create_index(**descriptor["params"])
27
+ assert replay["index"] == "idx_orders_ts"
28
+ assert replay["fromDefinition"] is True
29
+ assert (INDEXDEF, None) in fake.executed
30
+
31
+
32
+ @pytest.mark.unit
33
+ def test_create_index_definition_and_columns_are_mutually_exclusive(fake_pg, monkeypatch):
34
+ fake = fake_pg()
35
+ monkeypatch.setattr(gov, "_get_connection", lambda target=None: fake)
36
+ result = gov.create_index(table="t", columns=["a"], definition=INDEXDEF)
37
+ assert "not both" in result["error"]
38
+ result = gov.create_index()
39
+ assert "requires table+columns" in result["error"]
40
+ assert fake.executed == []
41
+
42
+
43
+ @pytest.mark.unit
44
+ @pytest.mark.parametrize(
45
+ "bad",
46
+ [
47
+ "DROP TABLE users",
48
+ "CREATE INDEX a ON t (c); DROP TABLE users",
49
+ "SELECT 1",
50
+ "",
51
+ ],
52
+ )
53
+ def test_create_index_from_definition_rejects_non_indexdef_shapes(fake_pg, bad):
54
+ fake = fake_pg()
55
+ with pytest.raises(ValueError):
56
+ ops.create_index_from_definition(fake, bad)
57
+ assert fake.executed == []
58
+
59
+
60
+ @pytest.mark.unit
61
+ def test_create_index_definition_dry_run_executes_nothing(fake_pg, monkeypatch):
62
+ fake = fake_pg()
63
+ monkeypatch.setattr(gov, "_get_connection", lambda target=None: fake)
64
+ result = gov.create_index(definition=INDEXDEF, dry_run=True)
65
+ assert result["dryRun"] is True and result["wouldExecute"] == INDEXDEF
66
+ assert fake.executed == []
@@ -484,7 +484,7 @@ wheels = [
484
484
 
485
485
  [[package]]
486
486
  name = "postgres-aiops"
487
- version = "0.2.0"
487
+ version = "0.2.1"
488
488
  source = { editable = "." }
489
489
  dependencies = [
490
490
  { name = "cryptography" },
File without changes
File without changes