postgres-aiops 0.10.2__tar.gz → 0.10.3__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.10.2 → postgres_aiops-0.10.3}/.claude-plugin/plugin.json +1 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/.mcp.json +1 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/CHANGELOG.md +9 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/PKG-INFO +1 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/openclaw.plugin.json +1 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/package.json +1 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/_common.py +40 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/activity.py +4 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/analyze.py +4 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/index.py +5 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/overview.py +2 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/query.py +3 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/replication.py +4 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/server.py +6 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/table.py +4 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/pyproject.toml +1 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/server.json +2 -2
- postgres_aiops-0.10.3/tests/test_cli_audited.py +131 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/uv.lock +1 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/.github/workflows/mcp-publish.yml +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/.github/workflows/publish.yml +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/.gitignore +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/Dockerfile +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/LICENSE +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/README.md +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/RELEASE_NOTES.md +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/SECURITY.md +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/docs/VERIFICATION.md +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/glama.json +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/__init__.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/_shared.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/server.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/__init__.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/activity.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/analysis.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/indexes.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/queries.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/remediation.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/replication.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/server.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/tables.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/mcp_server/tools/undo.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/__init__.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/__init__.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/_root.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/doctor.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/init.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/remediate.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/secret.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/cli/undo.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/config.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/connection.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/doctor.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/__init__.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/audit.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/budget.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/decorators.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/outcome.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/paths.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/patterns.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/policy.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/sanitize.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/governance/undo.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/__init__.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/_util.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/activity.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/analysis.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/indexes.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/overview.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/queries.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/remediation.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/replication.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/server.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/ops/tables.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/postgres_aiops/secretstore.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/skills/postgres-aiops/SKILL.md +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/skills/postgres-aiops/references/agent-guardrails.md +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/skills/postgres-aiops/references/capabilities.md +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/skills/postgres-aiops/references/cli-reference.md +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/skills/postgres-aiops/references/setup-guide.md +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/smithery.yaml +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/conftest.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_analysis.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_cli_governed_exit.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_cli_reads.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_cli_remediate.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_cli_secret.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_cli_writes.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_config.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_connection.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_connection_lost.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_connection_more.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_doctor.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_gov_audit.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_gov_decorators.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_gov_patterns.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_gov_policy.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_governance_persistence.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_init.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_mcp_tools.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_ops_more.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_optional_fields.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_reads.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_secretstore.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_secretstore_more.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_self_lockout.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_smoke.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_truncation.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_undo_executor.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_undo_replay.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/tests/test_writes.py +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "postgres-aiops",
|
|
4
4
|
"displayName": "Postgres AIops",
|
|
5
|
-
"version": "0.10.
|
|
5
|
+
"version": "0.10.3",
|
|
6
6
|
"description": "Governed PostgreSQL DBA ops: slow-query RCA, bloat/vacuum & blocking-lock analysis; 35 MCP tools.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Wei Zhou",
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.10.3 — 2026-09-15
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- CLI commands that read the engine without calling an MCP tool now write an
|
|
7
|
+
audit row, as every MCP call does: they run through the same `@governed_tool`
|
|
8
|
+
harness, budget and runaway guard included. Before, an operator's CLI reads
|
|
9
|
+
left no trace in `audit.db`, contrary to the documented guarantee that MCP
|
|
10
|
+
and CLI are audited alike. A test fails if any engine command escapes it.
|
|
11
|
+
|
|
3
12
|
## v0.10.2 — 2026-09-12
|
|
4
13
|
|
|
5
14
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: postgres-aiops
|
|
3
|
-
Version: 0.10.
|
|
3
|
+
Version: 0.10.3
|
|
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 Zhou <zhouwei008@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -180,3 +180,43 @@ def double_confirm(action: str, resource: str) -> None:
|
|
|
180
180
|
f"Confirm 2/2: really {action} '{resource}'? This may be irreversible.",
|
|
181
181
|
abort=True,
|
|
182
182
|
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def audited(fn: Callable) -> Callable:
|
|
186
|
+
"""Audit a CLI command that reaches the engine without calling an MCP tool.
|
|
187
|
+
|
|
188
|
+
MCP tools write their audit row through ``@governed_tool``; a CLI command that
|
|
189
|
+
called the ops layer directly used to write none, so an operator's CLI reads
|
|
190
|
+
left no trail. This runs the command through the same harness: one audit row per
|
|
191
|
+
invocation (tool = the command function, params = its options), plus the budget
|
|
192
|
+
and runaway guard. ``typer.Exit`` is how a command ends: exit code 0 is audited
|
|
193
|
+
``ok``, a non-zero code ``error``, and the exit is re-raised unchanged. Any other
|
|
194
|
+
exception is audited ``error`` and propagates to ``cli_errors`` as before.
|
|
195
|
+
"""
|
|
196
|
+
import functools as _functools
|
|
197
|
+
|
|
198
|
+
from postgres_aiops.governance import governed_tool
|
|
199
|
+
|
|
200
|
+
@_functools.wraps(fn)
|
|
201
|
+
def body(*args: Any, **kwargs: Any) -> Any:
|
|
202
|
+
try:
|
|
203
|
+
fn(*args, **kwargs)
|
|
204
|
+
except typer.Exit as exc:
|
|
205
|
+
code = exc.exit_code
|
|
206
|
+
outcome: dict[str, Any] = {"_exitCode": code}
|
|
207
|
+
if code:
|
|
208
|
+
outcome["error"] = f"command exited with code {code}"
|
|
209
|
+
return outcome
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
governed = governed_tool(risk_level="low")(body)
|
|
213
|
+
|
|
214
|
+
@_functools.wraps(fn)
|
|
215
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
216
|
+
result = governed(*args, **kwargs)
|
|
217
|
+
if isinstance(result, dict) and "_exitCode" in result:
|
|
218
|
+
raise typer.Exit(result["_exitCode"])
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
wrapper._is_audited_cli = True # type: ignore[attr-defined]
|
|
222
|
+
return wrapper
|
|
@@ -7,7 +7,7 @@ from typing import Annotated
|
|
|
7
7
|
|
|
8
8
|
import typer
|
|
9
9
|
|
|
10
|
-
from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
10
|
+
from postgres_aiops.cli._common import TargetOption, audited, cli_errors, console, get_connection
|
|
11
11
|
|
|
12
12
|
activity_app = typer.Typer(
|
|
13
13
|
name="activity",
|
|
@@ -18,6 +18,7 @@ activity_app = typer.Typer(
|
|
|
18
18
|
|
|
19
19
|
@activity_app.command("list")
|
|
20
20
|
@cli_errors
|
|
21
|
+
@audited
|
|
21
22
|
def activity_list(
|
|
22
23
|
state: Annotated[str | None, typer.Option("--state", help="Filter by state")] = None,
|
|
23
24
|
target: TargetOption = None,
|
|
@@ -31,6 +32,7 @@ def activity_list(
|
|
|
31
32
|
|
|
32
33
|
@activity_app.command("long")
|
|
33
34
|
@cli_errors
|
|
35
|
+
@audited
|
|
34
36
|
def activity_long(
|
|
35
37
|
min_seconds: Annotated[int, typer.Option("--min-seconds", help="Minimum age")] = 60,
|
|
36
38
|
target: TargetOption = None,
|
|
@@ -44,6 +46,7 @@ def activity_long(
|
|
|
44
46
|
|
|
45
47
|
@activity_app.command("locks")
|
|
46
48
|
@cli_errors
|
|
49
|
+
@audited
|
|
47
50
|
def activity_locks(target: TargetOption = None) -> None:
|
|
48
51
|
"""List held/awaited locks."""
|
|
49
52
|
from postgres_aiops.ops import activity as ops
|
|
@@ -8,6 +8,7 @@ import typer
|
|
|
8
8
|
|
|
9
9
|
from postgres_aiops.cli._common import (
|
|
10
10
|
TargetOption,
|
|
11
|
+
audited,
|
|
11
12
|
cli_errors,
|
|
12
13
|
get_connection,
|
|
13
14
|
print_result,
|
|
@@ -22,6 +23,7 @@ analyze_app = typer.Typer(
|
|
|
22
23
|
|
|
23
24
|
@analyze_app.command("slow-query")
|
|
24
25
|
@cli_errors
|
|
26
|
+
@audited
|
|
25
27
|
def analyze_slow_query(
|
|
26
28
|
explain_sql: Annotated[str | None, typer.Option("--explain", help="SQL to EXPLAIN")] = None,
|
|
27
29
|
limit: Annotated[int, typer.Option("--limit", help="Statements to consider")] = 20,
|
|
@@ -41,6 +43,7 @@ def analyze_slow_query(
|
|
|
41
43
|
|
|
42
44
|
@analyze_app.command("bloat-vacuum")
|
|
43
45
|
@cli_errors
|
|
46
|
+
@audited
|
|
44
47
|
def analyze_bloat_vacuum(
|
|
45
48
|
limit: Annotated[int, typer.Option("--limit", help="Tables to consider")] = 50,
|
|
46
49
|
target: TargetOption = None,
|
|
@@ -58,6 +61,7 @@ def analyze_bloat_vacuum(
|
|
|
58
61
|
|
|
59
62
|
@analyze_app.command("blocking")
|
|
60
63
|
@cli_errors
|
|
64
|
+
@audited
|
|
61
65
|
def analyze_blocking(target: TargetOption = None) -> None:
|
|
62
66
|
"""Build the blocking-lock chain and name the root blocker."""
|
|
63
67
|
from postgres_aiops.ops import activity, analysis
|
|
@@ -9,6 +9,7 @@ import typer
|
|
|
9
9
|
|
|
10
10
|
from postgres_aiops.cli._common import (
|
|
11
11
|
TargetOption,
|
|
12
|
+
audited,
|
|
12
13
|
cli_errors,
|
|
13
14
|
console,
|
|
14
15
|
get_connection,
|
|
@@ -24,6 +25,7 @@ index_app = typer.Typer(
|
|
|
24
25
|
|
|
25
26
|
@index_app.command("unused")
|
|
26
27
|
@cli_errors
|
|
28
|
+
@audited
|
|
27
29
|
def index_unused(target: TargetOption = None) -> None:
|
|
28
30
|
"""Non-unique, non-primary indexes with zero scans."""
|
|
29
31
|
from postgres_aiops.ops import indexes as ops
|
|
@@ -34,6 +36,7 @@ def index_unused(target: TargetOption = None) -> None:
|
|
|
34
36
|
|
|
35
37
|
@index_app.command("missing")
|
|
36
38
|
@cli_errors
|
|
39
|
+
@audited
|
|
37
40
|
def index_missing(target: TargetOption = None) -> None:
|
|
38
41
|
"""Tables with heavy sequential scans (missing-index hints)."""
|
|
39
42
|
from postgres_aiops.ops import indexes as ops
|
|
@@ -44,6 +47,7 @@ def index_missing(target: TargetOption = None) -> None:
|
|
|
44
47
|
|
|
45
48
|
@index_app.command("bloat")
|
|
46
49
|
@cli_errors
|
|
50
|
+
@audited
|
|
47
51
|
def index_bloat(
|
|
48
52
|
limit: Annotated[int, typer.Option("--limit", help="Indexes to inspect")] = 50,
|
|
49
53
|
target: TargetOption = None,
|
|
@@ -57,6 +61,7 @@ def index_bloat(
|
|
|
57
61
|
|
|
58
62
|
@index_app.command("invalid")
|
|
59
63
|
@cli_errors
|
|
64
|
+
@audited
|
|
60
65
|
def index_invalid(target: TargetOption = None) -> None:
|
|
61
66
|
"""Invalid and duplicate indexes."""
|
|
62
67
|
from postgres_aiops.ops import indexes as ops
|
|
@@ -4,10 +4,11 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
|
|
7
|
-
from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
7
|
+
from postgres_aiops.cli._common import TargetOption, audited, cli_errors, console, get_connection
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
@cli_errors
|
|
11
|
+
@audited
|
|
11
12
|
def overview_cmd(target: TargetOption = None) -> None:
|
|
12
13
|
"""One-shot cluster health: version, connections, long queries, bloat, replication."""
|
|
13
14
|
from postgres_aiops.ops import overview as ops
|
|
@@ -10,6 +10,7 @@ import typer
|
|
|
10
10
|
from postgres_aiops.cli._common import (
|
|
11
11
|
DryRunOption,
|
|
12
12
|
TargetOption,
|
|
13
|
+
audited,
|
|
13
14
|
checked,
|
|
14
15
|
cli_errors,
|
|
15
16
|
console,
|
|
@@ -28,6 +29,7 @@ query_app = typer.Typer(
|
|
|
28
29
|
|
|
29
30
|
@query_app.command("top")
|
|
30
31
|
@cli_errors
|
|
32
|
+
@audited
|
|
31
33
|
def query_top(
|
|
32
34
|
order_by: Annotated[
|
|
33
35
|
str, typer.Option("--order-by", help="total_time|mean_time|calls|rows|io")
|
|
@@ -44,6 +46,7 @@ def query_top(
|
|
|
44
46
|
|
|
45
47
|
@query_app.command("explain")
|
|
46
48
|
@cli_errors
|
|
49
|
+
@audited
|
|
47
50
|
def query_explain(
|
|
48
51
|
sql: Annotated[str, typer.Argument(help="A single SQL statement to EXPLAIN")],
|
|
49
52
|
analyze: Annotated[
|
|
@@ -6,7 +6,7 @@ import json
|
|
|
6
6
|
|
|
7
7
|
import typer
|
|
8
8
|
|
|
9
|
-
from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
9
|
+
from postgres_aiops.cli._common import TargetOption, audited, cli_errors, console, get_connection
|
|
10
10
|
|
|
11
11
|
repl_app = typer.Typer(
|
|
12
12
|
name="repl",
|
|
@@ -17,6 +17,7 @@ repl_app = typer.Typer(
|
|
|
17
17
|
|
|
18
18
|
@repl_app.command("status")
|
|
19
19
|
@cli_errors
|
|
20
|
+
@audited
|
|
20
21
|
def repl_status(target: TargetOption = None) -> None:
|
|
21
22
|
"""Connected standbys and replay lag."""
|
|
22
23
|
from postgres_aiops.ops import replication as ops
|
|
@@ -27,6 +28,7 @@ def repl_status(target: TargetOption = None) -> None:
|
|
|
27
28
|
|
|
28
29
|
@repl_app.command("slots")
|
|
29
30
|
@cli_errors
|
|
31
|
+
@audited
|
|
30
32
|
def repl_slots(target: TargetOption = None) -> None:
|
|
31
33
|
"""Replication slots (inactive slots flagged)."""
|
|
32
34
|
from postgres_aiops.ops import replication as ops
|
|
@@ -37,6 +39,7 @@ def repl_slots(target: TargetOption = None) -> None:
|
|
|
37
39
|
|
|
38
40
|
@repl_app.command("wal")
|
|
39
41
|
@cli_errors
|
|
42
|
+
@audited
|
|
40
43
|
def repl_wal(target: TargetOption = None) -> None:
|
|
41
44
|
"""WAL position, level and archiver health."""
|
|
42
45
|
from postgres_aiops.ops import replication as ops
|
|
@@ -7,7 +7,7 @@ from typing import Annotated
|
|
|
7
7
|
|
|
8
8
|
import typer
|
|
9
9
|
|
|
10
|
-
from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
10
|
+
from postgres_aiops.cli._common import TargetOption, audited, cli_errors, console, get_connection
|
|
11
11
|
|
|
12
12
|
server_app = typer.Typer(
|
|
13
13
|
name="server",
|
|
@@ -18,6 +18,7 @@ server_app = typer.Typer(
|
|
|
18
18
|
|
|
19
19
|
@server_app.command("version")
|
|
20
20
|
@cli_errors
|
|
21
|
+
@audited
|
|
21
22
|
def server_version(target: TargetOption = None) -> None:
|
|
22
23
|
"""Server version, uptime and recovery state."""
|
|
23
24
|
from postgres_aiops.ops import server as ops
|
|
@@ -28,6 +29,7 @@ def server_version(target: TargetOption = None) -> None:
|
|
|
28
29
|
|
|
29
30
|
@server_app.command("settings")
|
|
30
31
|
@cli_errors
|
|
32
|
+
@audited
|
|
31
33
|
def server_settings(
|
|
32
34
|
pattern: Annotated[str | None, typer.Argument(help="Name substring filter")] = None,
|
|
33
35
|
target: TargetOption = None,
|
|
@@ -41,6 +43,7 @@ def server_settings(
|
|
|
41
43
|
|
|
42
44
|
@server_app.command("extensions")
|
|
43
45
|
@cli_errors
|
|
46
|
+
@audited
|
|
44
47
|
def server_extensions(target: TargetOption = None) -> None:
|
|
45
48
|
"""Installed extensions."""
|
|
46
49
|
from postgres_aiops.ops import server as ops
|
|
@@ -51,6 +54,7 @@ def server_extensions(target: TargetOption = None) -> None:
|
|
|
51
54
|
|
|
52
55
|
@server_app.command("databases")
|
|
53
56
|
@cli_errors
|
|
57
|
+
@audited
|
|
54
58
|
def server_databases(target: TargetOption = None) -> None:
|
|
55
59
|
"""Databases with owner, encoding and size."""
|
|
56
60
|
from postgres_aiops.ops import server as ops
|
|
@@ -61,6 +65,7 @@ def server_databases(target: TargetOption = None) -> None:
|
|
|
61
65
|
|
|
62
66
|
@server_app.command("roles")
|
|
63
67
|
@cli_errors
|
|
68
|
+
@audited
|
|
64
69
|
def server_roles(target: TargetOption = None) -> None:
|
|
65
70
|
"""Roles and their attributes."""
|
|
66
71
|
from postgres_aiops.ops import server as ops
|
|
@@ -8,6 +8,7 @@ import typer
|
|
|
8
8
|
|
|
9
9
|
from postgres_aiops.cli._common import (
|
|
10
10
|
TargetOption,
|
|
11
|
+
audited,
|
|
11
12
|
cli_errors,
|
|
12
13
|
get_connection,
|
|
13
14
|
print_result,
|
|
@@ -22,6 +23,7 @@ table_app = typer.Typer(
|
|
|
22
23
|
|
|
23
24
|
@table_app.command("sizes")
|
|
24
25
|
@cli_errors
|
|
26
|
+
@audited
|
|
25
27
|
def table_sizes(
|
|
26
28
|
limit: Annotated[int, typer.Option("--limit", help="Tables to return")] = 20,
|
|
27
29
|
target: TargetOption = None,
|
|
@@ -35,6 +37,7 @@ def table_sizes(
|
|
|
35
37
|
|
|
36
38
|
@table_app.command("bloat")
|
|
37
39
|
@cli_errors
|
|
40
|
+
@audited
|
|
38
41
|
def table_bloat(
|
|
39
42
|
limit: Annotated[int, typer.Option("--limit", help="Tables to inspect")] = 50,
|
|
40
43
|
target: TargetOption = None,
|
|
@@ -48,6 +51,7 @@ def table_bloat(
|
|
|
48
51
|
|
|
49
52
|
@table_app.command("autovacuum")
|
|
50
53
|
@cli_errors
|
|
54
|
+
@audited
|
|
51
55
|
def table_autovacuum(
|
|
52
56
|
limit: Annotated[int, typer.Option("--limit", help="Tables to inspect")] = 50,
|
|
53
57
|
target: TargetOption = None,
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "postgres-aiops"
|
|
7
|
-
version = "0.10.
|
|
7
|
+
version = "0.10.3"
|
|
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.
|
|
10
|
+
"version": "0.10.3",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "postgres-aiops",
|
|
15
|
-
"version": "0.10.
|
|
15
|
+
"version": "0.10.3",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Every CLI command that reaches the engine is audited.
|
|
2
|
+
|
|
3
|
+
MCP tools write their audit row through ``@governed_tool``. CLI commands that called
|
|
4
|
+
the ops layer directly wrote none: on 2026-09-15 a read command of every tool in the
|
|
5
|
+
line, run in a scratch home, left no audit.db while the harness itself worked. Each
|
|
6
|
+
such command is now wrapped in ``audited`` (``cli/_common.py``); commands that call an
|
|
7
|
+
MCP tool are audited by that tool. This pins the helper's behaviour and that no
|
|
8
|
+
engine command escapes it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import inspect
|
|
15
|
+
import sqlite3
|
|
16
|
+
import textwrap
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
import typer
|
|
20
|
+
from typer.main import get_command
|
|
21
|
+
|
|
22
|
+
from postgres_aiops.cli import app
|
|
23
|
+
from postgres_aiops.cli._common import audited
|
|
24
|
+
|
|
25
|
+
pytestmark = pytest.mark.unit
|
|
26
|
+
|
|
27
|
+
#: Local-only commands: they never reach the engine (``undo`` goes through MCP tools).
|
|
28
|
+
EXEMPT = {"init", "doctor", "secret", "mcp", "undo"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _leaves() -> list[tuple[list[str], object]]:
|
|
32
|
+
found: list[tuple[list[str], object]] = []
|
|
33
|
+
|
|
34
|
+
def walk(cmd, path):
|
|
35
|
+
if hasattr(cmd, "list_commands"):
|
|
36
|
+
for name, sub in cmd.commands.items():
|
|
37
|
+
walk(sub, path + [name])
|
|
38
|
+
else:
|
|
39
|
+
found.append((path, cmd))
|
|
40
|
+
|
|
41
|
+
walk(get_command(app), [])
|
|
42
|
+
return found
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _chain(fn):
|
|
46
|
+
seen = []
|
|
47
|
+
while fn is not None and fn not in seen:
|
|
48
|
+
seen.append(fn)
|
|
49
|
+
fn = getattr(fn, "__wrapped__", None)
|
|
50
|
+
return seen
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _calls_an_mcp_tool(fn) -> bool:
|
|
54
|
+
source = inspect.getsource(fn)
|
|
55
|
+
if "mcp_server" in source:
|
|
56
|
+
return True
|
|
57
|
+
names = set()
|
|
58
|
+
for node in ast.parse(open(inspect.getsourcefile(fn)).read()).body:
|
|
59
|
+
if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("mcp_server"):
|
|
60
|
+
names |= {a.asname or a.name for a in node.names}
|
|
61
|
+
used = {n.id for n in ast.walk(ast.parse(textwrap.dedent(source))) if isinstance(n, ast.Name)}
|
|
62
|
+
return bool(names & used)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_every_engine_command_is_audited():
|
|
66
|
+
commands = [(path, cmd) for path, cmd in _leaves() if path and path[0] not in EXEMPT]
|
|
67
|
+
assert commands, "no commands found: the walker is broken, not the CLI clean"
|
|
68
|
+
missing = [
|
|
69
|
+
" ".join(path) for path, cmd in commands
|
|
70
|
+
if not any(getattr(f, "_is_audited_cli", False) for f in _chain(cmd.callback))
|
|
71
|
+
and not _calls_an_mcp_tool(inspect.unwrap(cmd.callback))
|
|
72
|
+
]
|
|
73
|
+
assert not missing, f"CLI commands that leave no audit row: {missing}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.fixture
|
|
77
|
+
def home(tmp_path, monkeypatch):
|
|
78
|
+
import postgres_aiops.governance.audit as audit_mod
|
|
79
|
+
import postgres_aiops.governance.budget as budget_mod
|
|
80
|
+
|
|
81
|
+
monkeypatch.setenv("POSTGRES_AIOPS_HOME", str(tmp_path))
|
|
82
|
+
audit_mod.reset_engine()
|
|
83
|
+
budget_mod.reset_budget()
|
|
84
|
+
yield tmp_path
|
|
85
|
+
audit_mod.reset_engine()
|
|
86
|
+
budget_mod.reset_budget()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _rows(home) -> list[tuple[str, str]]:
|
|
90
|
+
con = sqlite3.connect(home / "audit.db")
|
|
91
|
+
try:
|
|
92
|
+
return con.execute("SELECT tool, status FROM audit_log ORDER BY id").fetchall()
|
|
93
|
+
finally:
|
|
94
|
+
con.close()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_audited_records_success_exit_codes_and_exceptions(home):
|
|
98
|
+
@audited
|
|
99
|
+
def cmd_ok(target=None):
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
@audited
|
|
103
|
+
def cmd_exit_zero(target=None):
|
|
104
|
+
raise typer.Exit()
|
|
105
|
+
|
|
106
|
+
@audited
|
|
107
|
+
def cmd_exit_one(target=None):
|
|
108
|
+
raise typer.Exit(1)
|
|
109
|
+
|
|
110
|
+
@audited
|
|
111
|
+
def cmd_raises(target=None):
|
|
112
|
+
raise RuntimeError("engine unreachable")
|
|
113
|
+
|
|
114
|
+
cmd_ok()
|
|
115
|
+
with pytest.raises(typer.Exit) as zero:
|
|
116
|
+
cmd_exit_zero()
|
|
117
|
+
assert zero.value.exit_code == 0
|
|
118
|
+
with pytest.raises(typer.Exit) as one:
|
|
119
|
+
cmd_exit_one()
|
|
120
|
+
assert one.value.exit_code == 1
|
|
121
|
+
with pytest.raises(RuntimeError, match="engine unreachable"):
|
|
122
|
+
cmd_raises()
|
|
123
|
+
assert _rows(home) == [("cmd_ok", "ok"), ("cmd_exit_zero", "ok"),
|
|
124
|
+
("cmd_exit_one", "error"), ("cmd_raises", "error")]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_audited_keeps_the_command_signature_for_typer():
|
|
128
|
+
def cmd(limit: int = 5, target: str | None = None) -> None:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
assert list(inspect.signature(audited(cmd)).parameters) == ["limit", "target"]
|
|
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
|
|
File without changes
|
{postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/skills/postgres-aiops/references/agent-guardrails.md
RENAMED
|
File without changes
|
{postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/skills/postgres-aiops/references/capabilities.md
RENAMED
|
File without changes
|
{postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/skills/postgres-aiops/references/cli-reference.md
RENAMED
|
File without changes
|
{postgres_aiops-0.10.2 → postgres_aiops-0.10.3}/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
|
|
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
|