postgres-aiops 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. mcp_server/__init__.py +1 -0
  2. mcp_server/_shared.py +101 -0
  3. mcp_server/server.py +37 -0
  4. mcp_server/tools/__init__.py +1 -0
  5. mcp_server/tools/activity.py +52 -0
  6. mcp_server/tools/analysis.py +89 -0
  7. mcp_server/tools/indexes.py +65 -0
  8. mcp_server/tools/queries.py +60 -0
  9. mcp_server/tools/remediation.py +281 -0
  10. mcp_server/tools/replication.py +43 -0
  11. mcp_server/tools/server.py +86 -0
  12. mcp_server/tools/tables.py +46 -0
  13. postgres_aiops/__init__.py +9 -0
  14. postgres_aiops/cli/__init__.py +9 -0
  15. postgres_aiops/cli/_common.py +78 -0
  16. postgres_aiops/cli/_root.py +68 -0
  17. postgres_aiops/cli/activity.py +52 -0
  18. postgres_aiops/cli/analyze.py +53 -0
  19. postgres_aiops/cli/doctor.py +21 -0
  20. postgres_aiops/cli/index.py +55 -0
  21. postgres_aiops/cli/init.py +112 -0
  22. postgres_aiops/cli/overview.py +16 -0
  23. postgres_aiops/cli/query.py +70 -0
  24. postgres_aiops/cli/remediate.py +186 -0
  25. postgres_aiops/cli/replication.py +45 -0
  26. postgres_aiops/cli/secret.py +103 -0
  27. postgres_aiops/cli/server.py +69 -0
  28. postgres_aiops/cli/table.py +45 -0
  29. postgres_aiops/config.py +154 -0
  30. postgres_aiops/connection.py +178 -0
  31. postgres_aiops/doctor.py +84 -0
  32. postgres_aiops/governance/__init__.py +40 -0
  33. postgres_aiops/governance/audit.py +377 -0
  34. postgres_aiops/governance/budget.py +225 -0
  35. postgres_aiops/governance/decorators.py +474 -0
  36. postgres_aiops/governance/paths.py +23 -0
  37. postgres_aiops/governance/patterns.py +378 -0
  38. postgres_aiops/governance/policy.py +411 -0
  39. postgres_aiops/governance/sanitize.py +39 -0
  40. postgres_aiops/governance/undo.py +218 -0
  41. postgres_aiops/ops/__init__.py +1 -0
  42. postgres_aiops/ops/_util.py +102 -0
  43. postgres_aiops/ops/activity.py +193 -0
  44. postgres_aiops/ops/analysis.py +263 -0
  45. postgres_aiops/ops/indexes.py +211 -0
  46. postgres_aiops/ops/overview.py +51 -0
  47. postgres_aiops/ops/queries.py +123 -0
  48. postgres_aiops/ops/remediation.py +237 -0
  49. postgres_aiops/ops/replication.py +144 -0
  50. postgres_aiops/ops/server.py +151 -0
  51. postgres_aiops/ops/tables.py +146 -0
  52. postgres_aiops/secretstore.py +302 -0
  53. postgres_aiops-0.1.0.dist-info/METADATA +119 -0
  54. postgres_aiops-0.1.0.dist-info/RECORD +57 -0
  55. postgres_aiops-0.1.0.dist-info/WHEEL +4 -0
  56. postgres_aiops-0.1.0.dist-info/entry_points.txt +3 -0
  57. postgres_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,474 @@
1
+ """The ``@governed_tool`` decorator — mandatory wrapper for all Endpoint MCP tool functions.
2
+
3
+ Responsibilities:
4
+ 1. Pre-check: evaluate policy rules (deny, maintenance window)
5
+ 2. Execute: run the actual tool function
6
+ 3. Post-log: write audit record to ``~/.postgres-aiops/audit.db``
7
+ 4. Metadata: attach risk_level, idempotent, timeout, sensitive_params
8
+
9
+ Usage::
10
+
11
+ from postgres_aiops.governance import governed_tool
12
+
13
+ @governed_tool(risk_level="high", sensitive_params=["password"])
14
+ def delete_segment(name: str, env: str) -> dict:
15
+ ...
16
+
17
+ Registration enforcement::
18
+
19
+ # In your MCP server startup
20
+ for tool in tools:
21
+ assert getattr(tool, "_is_governed_tool", False), f"{tool.__name__} missing @governed_tool"
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import inspect
27
+ import logging
28
+ import os
29
+ import re
30
+ import time
31
+ import traceback
32
+ from functools import wraps
33
+ from typing import Any
34
+
35
+ from postgres_aiops.governance.audit import detect_agent, get_engine
36
+ from postgres_aiops.governance.budget import BudgetExceeded, get_budget
37
+ from postgres_aiops.governance.patterns import PatternMatch, get_pattern_engine
38
+ from postgres_aiops.governance.policy import PolicyResult, get_policy_engine
39
+ from postgres_aiops.governance.sanitize import sanitize
40
+
41
+ _log = logging.getLogger("postgres-aiops.decorators")
42
+
43
+
44
+ class PolicyDenied(Exception):
45
+ """Raised when an operation is denied by policy."""
46
+
47
+ def __init__(self, result: PolicyResult) -> None:
48
+ self.result = result
49
+ super().__init__(result.reason)
50
+
51
+
52
+ def governed_tool(
53
+ fn: Any = None,
54
+ *,
55
+ risk_level: str = "low",
56
+ idempotent: bool = False,
57
+ timeout_seconds: int = 300,
58
+ sensitive_params: list[str] | None = None,
59
+ undo: Any = None,
60
+ ) -> Any:
61
+ """Decorator for all Endpoint MCP tool functions.
62
+
63
+ Can be used with or without arguments::
64
+
65
+ @governed_tool
66
+ def list_segments(...): ...
67
+
68
+ @governed_tool(risk_level="critical", sensitive_params=["password"])
69
+ def delete_vm(...): ...
70
+
71
+ Args:
72
+ risk_level: One of 'low', 'medium', 'high', 'critical'.
73
+ idempotent: Whether the operation can be safely retried on failure.
74
+ timeout_seconds: Maximum execution time before warning — exceeding it
75
+ logs a warning (no hard cancellation).
76
+ sensitive_params: Parameter names to redact in audit logs.
77
+ undo: Optional callable ``(params, result) -> dict | None`` returning an
78
+ inverse descriptor ``{"tool", "params", "skill"?, "note"?}``. On a
79
+ successful call the inverse is recorded to ~/.postgres-aiops/undo.db and the
80
+ result dict gains an ``_undo_id``. Return None for "no safe inverse".
81
+ Recording only — execution is an external orchestrator's job.
82
+ """
83
+ _sensitive = set(sensitive_params or [])
84
+
85
+ def decorator(func: Any) -> Any:
86
+ # Cache the signature at decoration time so positional args can be
87
+ # mapped to parameter names on every call (audit + env scoping).
88
+ signature = inspect.signature(func)
89
+
90
+ if inspect.iscoroutinefunction(func):
91
+ # ── Async tools get an async wrapper with identical audit /
92
+ # policy / circuit-breaker semantics (a sync wrapper would return
93
+ # an un-awaited coroutine and audit it as "ok").
94
+ @wraps(func)
95
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
96
+ state = _CallState(
97
+ func, args, kwargs, signature, _sensitive, risk_level,
98
+ timeout_seconds, undo,
99
+ )
100
+ try:
101
+ _pre_check(state)
102
+ return _annotate_result(state, await func(*args, **kwargs))
103
+ except (PolicyDenied, BudgetExceeded):
104
+ raise
105
+ except Exception as exc:
106
+ _capture_error(state, exc)
107
+ raise
108
+ finally:
109
+ _finalize(state)
110
+ else:
111
+ @wraps(func)
112
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
113
+ state = _CallState(
114
+ func, args, kwargs, signature, _sensitive, risk_level,
115
+ timeout_seconds, undo,
116
+ )
117
+ try:
118
+ _pre_check(state)
119
+ return _annotate_result(state, func(*args, **kwargs))
120
+ except (PolicyDenied, BudgetExceeded):
121
+ raise
122
+ except Exception as exc:
123
+ _capture_error(state, exc)
124
+ raise
125
+ finally:
126
+ _finalize(state)
127
+
128
+ # ── Attach metadata for harness / introspection ───────────
129
+ wrapper._is_governed_tool = True
130
+ wrapper._risk_level = risk_level
131
+ wrapper._idempotent = idempotent
132
+ wrapper._timeout_seconds = timeout_seconds
133
+ wrapper._sensitive_params = list(_sensitive)
134
+ return wrapper
135
+
136
+ # Support @governed_tool and @governed_tool(...)
137
+ if fn is not None:
138
+ return decorator(fn)
139
+ return decorator
140
+
141
+
142
+ # ── Internal helpers ──────────────────────────────────────────────────
143
+
144
+
145
+ class _CallState:
146
+ """Per-call context shared by the sync and async wrapper bodies.
147
+
148
+ Built once per invocation; the helper functions (`_pre_check`,
149
+ `_annotate_result`, `_capture_error`, `_finalize`) read and mutate it so
150
+ both wrappers keep identical audit / policy / circuit-breaker semantics.
151
+ """
152
+
153
+ __slots__ = (
154
+ "skill", "tool_name", "agent", "start", "status", "result",
155
+ "policy_result", "pattern_match", "audit", "policy",
156
+ "safe_params", "env", "risk_level", "timeout_seconds",
157
+ "rationale", "approved_by", "risk_tier", "undo",
158
+ )
159
+
160
+ def __init__(
161
+ self,
162
+ func: Any,
163
+ args: tuple[Any, ...],
164
+ kwargs: dict[str, Any],
165
+ signature: inspect.Signature,
166
+ sensitive: set[str],
167
+ risk_level: str,
168
+ timeout_seconds: int,
169
+ undo: Any = None,
170
+ ) -> None:
171
+ self.undo = undo
172
+ self.skill = _infer_skill(func)
173
+ self.tool_name = func.__name__
174
+ self.agent = detect_agent()
175
+ self.start = time.time()
176
+ self.status = "ok"
177
+ self.result: Any = None
178
+ self.policy_result: PolicyResult | None = None
179
+ self.pattern_match: PatternMatch | None = None
180
+ self.risk_level = risk_level
181
+ self.timeout_seconds = timeout_seconds
182
+ self.audit = get_engine()
183
+ self.policy = get_policy_engine()
184
+
185
+ # Map positional args to parameter names so they appear in the audit
186
+ # log and participate in env scoping (previously only kwargs did).
187
+ params = _bind_params(signature, args, kwargs)
188
+ self.safe_params = _redact(params, sensitive)
189
+ env = params.get("target", params.get("env", ""))
190
+ self.env = str(env) if env else ""
191
+
192
+ # Accountability trail (SOC2 / 等保: who authorized this, and why).
193
+ # Sourced from env so an approval gate / pilot can inject context
194
+ # without changing every tool signature. risk_tier is filled by the
195
+ # policy pre-check (graduated autonomy).
196
+ self.rationale = os.environ.get("POSTGRES_AUDIT_RATIONALE", "")
197
+ self.approved_by = os.environ.get("POSTGRES_AUDIT_APPROVED_BY", "")
198
+ self.risk_tier = ""
199
+
200
+
201
+ def _bind_params(
202
+ signature: inspect.Signature, args: tuple[Any, ...], kwargs: dict[str, Any]
203
+ ) -> dict[str, Any]:
204
+ """Build a full name→value param dict from positional + keyword args.
205
+
206
+ Falls back to kwargs-only if binding fails (the actual call will raise
207
+ the matching TypeError; audit should not mask it with its own).
208
+ """
209
+ try:
210
+ bound = signature.bind_partial(*args, **kwargs)
211
+ # Apply declared defaults so env scoping and risk-tier matching see the
212
+ # effective target/tags even when the caller relied on a default value
213
+ # (bind_partial alone only captures explicitly-passed arguments).
214
+ bound.apply_defaults()
215
+ except TypeError:
216
+ return dict(kwargs)
217
+ params: dict[str, Any] = {}
218
+ for name, value in bound.arguments.items():
219
+ kind = signature.parameters[name].kind
220
+ if kind == inspect.Parameter.VAR_KEYWORD:
221
+ params.update(value)
222
+ elif kind == inspect.Parameter.VAR_POSITIONAL:
223
+ params[name] = list(value)
224
+ else:
225
+ params[name] = value
226
+ return params
227
+
228
+
229
+ def _pre_check(state: _CallState) -> None:
230
+ """Policy pre-check + L5 auto-remediation pattern consult.
231
+
232
+ Raises PolicyDenied when policy denies the call. Pattern engine failures
233
+ never block the call (fail-open by design — a broken pattern file must
234
+ not take down every MCP tool).
235
+ """
236
+ state.policy_result = state.policy.check_allowed(
237
+ state.tool_name,
238
+ env=state.env,
239
+ risk_level=state.risk_level,
240
+ params=state.safe_params,
241
+ )
242
+ if not state.policy_result.allowed:
243
+ state.status = "denied"
244
+ state.result = {
245
+ "error": state.policy_result.reason,
246
+ "rule": state.policy_result.rule,
247
+ }
248
+ raise PolicyDenied(state.policy_result)
249
+
250
+ # Graduated autonomy — what approval tier does this op need? Record it on
251
+ # the audit trail, and enforce: tiers that require a named approver (dual /
252
+ # review) are denied when none was recorded (POSTGRES_AUDIT_APPROVED_BY).
253
+ tier = state.policy.required_approval_tier(
254
+ state.tool_name,
255
+ env=state.env,
256
+ risk_level=state.risk_level,
257
+ params=state.safe_params,
258
+ )
259
+ state.risk_tier = tier.tier
260
+ if tier.requires_approver and not state.approved_by:
261
+ reason = (
262
+ f"Operation '{state.tool_name}' on '{state.env or 'target'}' requires "
263
+ f"'{tier.tier}' approval (rule: {tier.rule}) but no approver is recorded. "
264
+ f"Set POSTGRES_AUDIT_APPROVED_BY to the authorizing human (and "
265
+ f"POSTGRES_AUDIT_RATIONALE to why) before retrying."
266
+ )
267
+ if tier.reason:
268
+ reason += f" Policy note: {tier.reason}"
269
+ denial = PolicyResult(allowed=False, rule=f"approval_tier:{tier.tier}", reason=reason)
270
+ state.policy_result = denial
271
+ state.status = "denied"
272
+ state.result = {"error": reason, "rule": denial.rule}
273
+ raise PolicyDenied(denial)
274
+
275
+ # Budget / runaway guard — only for calls policy already allowed, so denied
276
+ # calls do not count. A trip raises BudgetExceeded (a hard stop); record the
277
+ # denial on state so _finalize audits it.
278
+ try:
279
+ get_budget().check_and_record(state.tool_name, state.safe_params)
280
+ except BudgetExceeded as exc:
281
+ state.status = "budget_exceeded"
282
+ state.result = {"error": exc.reason, "rule": exc.rule}
283
+ raise
284
+
285
+ try:
286
+ state.pattern_match = get_pattern_engine().match(
287
+ skill=state.skill, tool=state.tool_name, target=state.env
288
+ )
289
+ except Exception: # noqa: BLE001 — fail-open by design
290
+ state.pattern_match = None
291
+
292
+
293
+ def _annotate_result(state: _CallState, result: Any) -> Any:
294
+ """Record the result, surface pattern context, and record an undo token.
295
+
296
+ Runs only on the success path (the wrapper calls it with the function's
297
+ return value), so a recorded undo always corresponds to a change that
298
+ actually happened.
299
+ """
300
+ state.result = result
301
+ if (
302
+ state.pattern_match
303
+ and state.pattern_match.armed
304
+ and isinstance(result, dict)
305
+ ):
306
+ result.setdefault("_pattern_id", state.pattern_match.pattern.pattern_id)
307
+ result.setdefault("_pattern_armed", True)
308
+ _record_undo(state, result)
309
+ return result
310
+
311
+
312
+ def _record_undo(state: _CallState, result: Any) -> None:
313
+ """Compute and persist the inverse descriptor for a successful write.
314
+
315
+ Best-effort: a broken undo callable or store must never fail the call.
316
+ Attaches ``_undo_id`` to dict results so the agent / pilot can reference it.
317
+ """
318
+ if state.undo is None:
319
+ return
320
+ try:
321
+ descriptor = state.undo(state.safe_params, result)
322
+ except Exception: # noqa: BLE001 — undo computation must not fail the call
323
+ _log.warning("undo callable for %s.%s raised", state.skill, state.tool_name,
324
+ exc_info=True)
325
+ return
326
+ if not descriptor:
327
+ return
328
+ try:
329
+ from postgres_aiops.governance.undo import get_undo_store
330
+
331
+ undo_id = get_undo_store().record(
332
+ skill=state.skill,
333
+ tool=state.tool_name,
334
+ undo_descriptor=descriptor,
335
+ orig_params=state.safe_params,
336
+ )
337
+ if undo_id and isinstance(result, dict):
338
+ result.setdefault("_undo_id", undo_id)
339
+ except Exception: # noqa: BLE001 — recording is best-effort
340
+ _log.warning("failed to record undo for %s.%s", state.skill, state.tool_name,
341
+ exc_info=True)
342
+
343
+
344
+ def _capture_error(state: _CallState, exc: Exception) -> None:
345
+ """Record a failed call. Exception text and tracebacks can carry
346
+ connection strings, credentials, internal paths — sanitize before
347
+ persisting to the audit row."""
348
+ state.status = "error"
349
+ state.result = {
350
+ "error": sanitize(_redact_secrets_text(str(exc)), 500),
351
+ "traceback": sanitize(
352
+ _redact_secrets_text(traceback.format_exc()[-500:]), 500
353
+ ),
354
+ }
355
+
356
+
357
+ def _finalize(state: _CallState) -> None:
358
+ """Audit + circuit-breaker bookkeeping. Runs in the wrapper's finally."""
359
+ duration = int((time.time() - state.start) * 1000)
360
+
361
+ # Accumulate wall-time toward the cumulative time budget (best-effort).
362
+ try:
363
+ get_budget().add_duration(time.time() - state.start)
364
+ except Exception: # noqa: BLE001 — bookkeeping must never fail the call
365
+ pass
366
+
367
+ # timeout_seconds is advisory: exceeding it logs a warning, no hard
368
+ # cancellation (cancelling mid-flight Endpoint calls is worse).
369
+ if state.timeout_seconds and duration > state.timeout_seconds * 1000:
370
+ _log.warning(
371
+ "%s.%s took %dms — exceeded timeout_seconds=%d (advisory, not cancelled)",
372
+ state.skill, state.tool_name, duration, state.timeout_seconds,
373
+ )
374
+
375
+ bypassed = state.policy_result and state.policy_result.rule == "policy_disabled"
376
+ final_status = f"{state.status}_bypassed" if bypassed else state.status
377
+
378
+ # Update circuit-breaker state for armed patterns
379
+ if state.pattern_match and state.pattern_match.armed:
380
+ try:
381
+ get_pattern_engine().report_outcome(
382
+ pattern_id=state.pattern_match.pattern.pattern_id,
383
+ target=state.env,
384
+ success=(state.status == "ok"),
385
+ )
386
+ except Exception: # noqa: BLE001 — never let bookkeeping fail the call
387
+ pass
388
+
389
+ pattern_id = state.pattern_match.pattern.pattern_id if state.pattern_match else ""
390
+ pattern_armed = bool(state.pattern_match and state.pattern_match.armed)
391
+
392
+ state.audit.log(
393
+ skill=state.skill,
394
+ tool=state.tool_name,
395
+ params=state.safe_params,
396
+ result=_with_pattern_context(state.result, pattern_id, pattern_armed),
397
+ status=final_status,
398
+ duration_ms=duration,
399
+ agent=state.agent,
400
+ user="",
401
+ risk_level=state.risk_level,
402
+ rationale=state.rationale,
403
+ approved_by=state.approved_by,
404
+ risk_tier=state.risk_tier,
405
+ )
406
+
407
+
408
+ def _infer_skill(func: Any) -> str:
409
+ """Infer the skill name from the function's module path.
410
+
411
+ ``postgres_aiops.ops.jobs`` → ``postgres-aiops``
412
+ ``mcp_server.server`` → ``postgres-aiops`` (the only consumer here).
413
+ """
414
+ module = getattr(func, "__module__", "") or ""
415
+ if module.startswith("postgres_aiops") or module.startswith("mcp_server"):
416
+ return "postgres-aiops"
417
+ return "unknown"
418
+
419
+
420
+ def _redact(params: dict[str, Any], sensitive: set[str]) -> dict[str, Any]:
421
+ """Return a copy of params with sensitive values replaced by '***'.
422
+
423
+ Recurses into nested dicts AND lists/tuples so credentials buried inside
424
+ collections (e.g. ``{"targets": [{"password": "x"}]}``) are redacted too.
425
+ """
426
+ if not sensitive:
427
+ return params
428
+ result: dict[str, Any] = {}
429
+ for k, v in params.items():
430
+ if k in sensitive:
431
+ result[k] = "***"
432
+ else:
433
+ result[k] = _redact_value(v, sensitive)
434
+ return result
435
+
436
+
437
+ def _redact_value(value: Any, sensitive: set[str]) -> Any:
438
+ """Recursively redact sensitive keys inside dicts, lists, and tuples."""
439
+ if isinstance(value, dict):
440
+ return _redact(value, sensitive)
441
+ if isinstance(value, (list, tuple)):
442
+ return type(value)(_redact_value(item, sensitive) for item in value)
443
+ return value
444
+
445
+
446
+ # Matches ``key=value`` / ``key: value`` / ``key"="value`` for common secret
447
+ # keys in free-form exception text. Value runs until whitespace, quote, comma,
448
+ # or '@' (to keep host:port that often follows a credential in DSNs).
449
+ _SECRET_TEXT_RE = re.compile(
450
+ r"(?i)\b(password|passwd|pwd|token|secret|api[_-]?key|authorization|bearer)"
451
+ r"(\s*[=:]\s*|\s+)"
452
+ r"['\"]?[^\s'\",@]+",
453
+ )
454
+
455
+
456
+ def _redact_secrets_text(text: str) -> str:
457
+ """Redact ``password=...`` / ``token: ...`` style secrets in free-form text."""
458
+ return _SECRET_TEXT_RE.sub(r"\1\2***", text)
459
+
460
+
461
+ def _with_pattern_context(result: Any, pattern_id: str, armed: bool) -> Any:
462
+ """Attach pattern metadata to an audit row's result field.
463
+
464
+ Only mutates dict results; non-dict results (errors, primitives) are
465
+ returned unchanged so the audit log preserves them faithfully.
466
+ """
467
+ if not pattern_id:
468
+ return result
469
+ if isinstance(result, dict):
470
+ annotated = dict(result)
471
+ annotated.setdefault("_pattern_id", pattern_id)
472
+ annotated.setdefault("_pattern_armed", armed)
473
+ return annotated
474
+ return result
@@ -0,0 +1,23 @@
1
+ """Home-directory resolution for the governance harness.
2
+
3
+ State lives under ``ops_home()`` — by default ``~/.postgres-aiops``, overridable
4
+ via the ``POSTGRES_AIOPS_HOME`` environment variable so an operator can relocate
5
+ the audit / policy / budget / undo store.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ _DEFAULT_HOME = "~/.postgres-aiops"
14
+
15
+
16
+ def ops_home() -> Path:
17
+ """Return the harness state directory, honoring ``POSTGRES_AIOPS_HOME``."""
18
+ return Path(os.environ.get("POSTGRES_AIOPS_HOME") or _DEFAULT_HOME).expanduser()
19
+
20
+
21
+ def ops_path(*parts: str) -> Path:
22
+ """Resolve a file under the harness home, e.g. ``ops_path('audit.db')``."""
23
+ return ops_home().joinpath(*parts)