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.
- mcp_server/__init__.py +1 -0
- mcp_server/_shared.py +101 -0
- mcp_server/server.py +37 -0
- mcp_server/tools/__init__.py +1 -0
- mcp_server/tools/activity.py +52 -0
- mcp_server/tools/analysis.py +89 -0
- mcp_server/tools/indexes.py +65 -0
- mcp_server/tools/queries.py +60 -0
- mcp_server/tools/remediation.py +281 -0
- mcp_server/tools/replication.py +43 -0
- mcp_server/tools/server.py +86 -0
- mcp_server/tools/tables.py +46 -0
- postgres_aiops/__init__.py +9 -0
- postgres_aiops/cli/__init__.py +9 -0
- postgres_aiops/cli/_common.py +78 -0
- postgres_aiops/cli/_root.py +68 -0
- postgres_aiops/cli/activity.py +52 -0
- postgres_aiops/cli/analyze.py +53 -0
- postgres_aiops/cli/doctor.py +21 -0
- postgres_aiops/cli/index.py +55 -0
- postgres_aiops/cli/init.py +112 -0
- postgres_aiops/cli/overview.py +16 -0
- postgres_aiops/cli/query.py +70 -0
- postgres_aiops/cli/remediate.py +186 -0
- postgres_aiops/cli/replication.py +45 -0
- postgres_aiops/cli/secret.py +103 -0
- postgres_aiops/cli/server.py +69 -0
- postgres_aiops/cli/table.py +45 -0
- postgres_aiops/config.py +154 -0
- postgres_aiops/connection.py +178 -0
- postgres_aiops/doctor.py +84 -0
- postgres_aiops/governance/__init__.py +40 -0
- postgres_aiops/governance/audit.py +377 -0
- postgres_aiops/governance/budget.py +225 -0
- postgres_aiops/governance/decorators.py +474 -0
- postgres_aiops/governance/paths.py +23 -0
- postgres_aiops/governance/patterns.py +378 -0
- postgres_aiops/governance/policy.py +411 -0
- postgres_aiops/governance/sanitize.py +39 -0
- postgres_aiops/governance/undo.py +218 -0
- postgres_aiops/ops/__init__.py +1 -0
- postgres_aiops/ops/_util.py +102 -0
- postgres_aiops/ops/activity.py +193 -0
- postgres_aiops/ops/analysis.py +263 -0
- postgres_aiops/ops/indexes.py +211 -0
- postgres_aiops/ops/overview.py +51 -0
- postgres_aiops/ops/queries.py +123 -0
- postgres_aiops/ops/remediation.py +237 -0
- postgres_aiops/ops/replication.py +144 -0
- postgres_aiops/ops/server.py +151 -0
- postgres_aiops/ops/tables.py +146 -0
- postgres_aiops/secretstore.py +302 -0
- postgres_aiops-0.1.0.dist-info/METADATA +119 -0
- postgres_aiops-0.1.0.dist-info/RECORD +57 -0
- postgres_aiops-0.1.0.dist-info/WHEEL +4 -0
- postgres_aiops-0.1.0.dist-info/entry_points.txt +3 -0
- postgres_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""``postgres-aiops server`` — server-level reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
11
|
+
|
|
12
|
+
server_app = typer.Typer(
|
|
13
|
+
name="server",
|
|
14
|
+
help="Server reads: version, settings, extensions, databases, roles.",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@server_app.command("version")
|
|
20
|
+
@cli_errors
|
|
21
|
+
def server_version(target: TargetOption = None) -> None:
|
|
22
|
+
"""Server version, uptime and recovery state."""
|
|
23
|
+
from postgres_aiops.ops import server as ops
|
|
24
|
+
|
|
25
|
+
conn, _ = get_connection(target)
|
|
26
|
+
console.print_json(json.dumps(ops.server_version(conn)))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@server_app.command("settings")
|
|
30
|
+
@cli_errors
|
|
31
|
+
def server_settings(
|
|
32
|
+
pattern: Annotated[str | None, typer.Argument(help="Name substring filter")] = None,
|
|
33
|
+
target: TargetOption = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Configuration parameters (pg_settings), optionally filtered by name."""
|
|
36
|
+
from postgres_aiops.ops import server as ops
|
|
37
|
+
|
|
38
|
+
conn, _ = get_connection(target)
|
|
39
|
+
console.print_json(json.dumps(ops.show_settings(conn, pattern)))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@server_app.command("extensions")
|
|
43
|
+
@cli_errors
|
|
44
|
+
def server_extensions(target: TargetOption = None) -> None:
|
|
45
|
+
"""Installed extensions."""
|
|
46
|
+
from postgres_aiops.ops import server as ops
|
|
47
|
+
|
|
48
|
+
conn, _ = get_connection(target)
|
|
49
|
+
console.print_json(json.dumps(ops.list_extensions(conn)))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@server_app.command("databases")
|
|
53
|
+
@cli_errors
|
|
54
|
+
def server_databases(target: TargetOption = None) -> None:
|
|
55
|
+
"""Databases with owner, encoding and size."""
|
|
56
|
+
from postgres_aiops.ops import server as ops
|
|
57
|
+
|
|
58
|
+
conn, _ = get_connection(target)
|
|
59
|
+
console.print_json(json.dumps(ops.list_databases(conn)))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@server_app.command("roles")
|
|
63
|
+
@cli_errors
|
|
64
|
+
def server_roles(target: TargetOption = None) -> None:
|
|
65
|
+
"""Roles and their attributes."""
|
|
66
|
+
from postgres_aiops.ops import server as ops
|
|
67
|
+
|
|
68
|
+
conn, _ = get_connection(target)
|
|
69
|
+
console.print_json(json.dumps(ops.list_roles(conn)))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""``postgres-aiops table`` — table-health reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
10
|
+
|
|
11
|
+
table_app = typer.Typer(
|
|
12
|
+
name="table",
|
|
13
|
+
help="Table health: sizes, bloat, autovacuum.",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@table_app.command("sizes")
|
|
19
|
+
@cli_errors
|
|
20
|
+
def table_sizes(target: TargetOption = None) -> None:
|
|
21
|
+
"""Largest tables by total relation size."""
|
|
22
|
+
from postgres_aiops.ops import tables as ops
|
|
23
|
+
|
|
24
|
+
conn, _ = get_connection(target)
|
|
25
|
+
console.print_json(json.dumps(ops.table_sizes(conn)))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@table_app.command("bloat")
|
|
29
|
+
@cli_errors
|
|
30
|
+
def table_bloat(target: TargetOption = None) -> None:
|
|
31
|
+
"""Dead-tuple bloat proxy per table."""
|
|
32
|
+
from postgres_aiops.ops import tables as ops
|
|
33
|
+
|
|
34
|
+
conn, _ = get_connection(target)
|
|
35
|
+
console.print_json(json.dumps(ops.table_bloat(conn)))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@table_app.command("autovacuum")
|
|
39
|
+
@cli_errors
|
|
40
|
+
def table_autovacuum(target: TargetOption = None) -> None:
|
|
41
|
+
"""Per-table dead tuples and last (auto)vacuum times."""
|
|
42
|
+
from postgres_aiops.ops import tables as ops
|
|
43
|
+
|
|
44
|
+
conn, _ = get_connection(target)
|
|
45
|
+
console.print_json(json.dumps(ops.autovacuum_status(conn)))
|
postgres_aiops/config.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Configuration management for Postgres AIops.
|
|
2
|
+
|
|
3
|
+
Loads connection targets from a YAML config file. The secret (the PostgreSQL
|
|
4
|
+
role **password**) is NEVER stored in the config file and never on disk in
|
|
5
|
+
plaintext: it lives in the encrypted store ``~/.postgres-aiops/secrets.enc``
|
|
6
|
+
(see :mod:`postgres_aiops.secretstore`). For backward compatibility a legacy
|
|
7
|
+
plaintext env var (``PG_<TARGET>_PASSWORD``) is still honoured as a fallback,
|
|
8
|
+
with a warning nudging migration to the encrypted store.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
from postgres_aiops.secretstore import SecretStoreError, get_secret, has_store
|
|
21
|
+
|
|
22
|
+
CONFIG_DIR = Path.home() / ".postgres-aiops"
|
|
23
|
+
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
24
|
+
ENV_FILE = CONFIG_DIR / ".env"
|
|
25
|
+
|
|
26
|
+
DEFAULT_PORT = 5432
|
|
27
|
+
DEFAULT_DBNAME = "postgres"
|
|
28
|
+
DEFAULT_USER = "postgres"
|
|
29
|
+
DEFAULT_SSLMODE = "prefer"
|
|
30
|
+
APPLICATION_NAME = "postgres-aiops"
|
|
31
|
+
|
|
32
|
+
# Legacy env-var prefix/suffix; also used by the migration helper.
|
|
33
|
+
SECRET_ENV_PREFIX = "PG_" # nosec B105 — env-var name, not a secret
|
|
34
|
+
SECRET_ENV_SUFFIX = "_PASSWORD" # nosec B105 — env-var name, not a secret
|
|
35
|
+
|
|
36
|
+
_log = logging.getLogger("postgres-aiops.config")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _secret_env_key(name: str) -> str:
|
|
40
|
+
"""Legacy per-target password env var name, e.g. PG_PRIMARY_PASSWORD."""
|
|
41
|
+
return f"{SECRET_ENV_PREFIX}{name.upper().replace('-', '_')}{SECRET_ENV_SUFFIX}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_secret(name: str) -> str:
|
|
45
|
+
"""Return a target's password: encrypted store first, then legacy env var."""
|
|
46
|
+
if has_store():
|
|
47
|
+
try:
|
|
48
|
+
return get_secret(name)
|
|
49
|
+
except SecretStoreError:
|
|
50
|
+
pass # fall through to legacy env var
|
|
51
|
+
legacy = os.environ.get(_secret_env_key(name))
|
|
52
|
+
if legacy:
|
|
53
|
+
_log.warning(
|
|
54
|
+
"Using plaintext env var %s. Migrate to the encrypted store with "
|
|
55
|
+
"'postgres-aiops secret migrate'.",
|
|
56
|
+
_secret_env_key(name),
|
|
57
|
+
)
|
|
58
|
+
return legacy
|
|
59
|
+
raise OSError(
|
|
60
|
+
f"No password for target '{name}'. Add one with "
|
|
61
|
+
f"'postgres-aiops secret set {name}' (stored encrypted), or run "
|
|
62
|
+
f"'postgres-aiops init'."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class TargetConfig:
|
|
68
|
+
"""A connection target for a PostgreSQL server/cluster.
|
|
69
|
+
|
|
70
|
+
The password is sourced from the encrypted secret store (see ``password``),
|
|
71
|
+
never the config file. ``host``/``port`` locate the server; ``dbname`` is the
|
|
72
|
+
database to connect to; ``sslmode`` follows libpq semantics
|
|
73
|
+
(disable/allow/prefer/require/verify-ca/verify-full).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
name: str
|
|
77
|
+
host: str
|
|
78
|
+
port: int = DEFAULT_PORT
|
|
79
|
+
dbname: str = DEFAULT_DBNAME
|
|
80
|
+
user: str = DEFAULT_USER
|
|
81
|
+
sslmode: str = DEFAULT_SSLMODE
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def password(self) -> str:
|
|
85
|
+
return _resolve_secret(self.name)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def conn_kwargs(self) -> dict:
|
|
89
|
+
"""libpq connection keyword args for ``psycopg.connect`` (incl. password)."""
|
|
90
|
+
return {
|
|
91
|
+
"host": self.host,
|
|
92
|
+
"port": self.port,
|
|
93
|
+
"dbname": self.dbname,
|
|
94
|
+
"user": self.user,
|
|
95
|
+
"password": self.password,
|
|
96
|
+
"sslmode": self.sslmode,
|
|
97
|
+
"application_name": APPLICATION_NAME,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def dsn_redacted(self) -> str:
|
|
102
|
+
"""A human-readable DSN with the password redacted (for logs/doctor)."""
|
|
103
|
+
return (
|
|
104
|
+
f"postgresql://{self.user}:***@{self.host}:{self.port}/"
|
|
105
|
+
f"{self.dbname}?sslmode={self.sslmode}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class AppConfig:
|
|
111
|
+
"""Top-level application config."""
|
|
112
|
+
|
|
113
|
+
targets: tuple[TargetConfig, ...] = ()
|
|
114
|
+
|
|
115
|
+
def get_target(self, name: str) -> TargetConfig:
|
|
116
|
+
for t in self.targets:
|
|
117
|
+
if t.name == name:
|
|
118
|
+
return t
|
|
119
|
+
available = ", ".join(t.name for t in self.targets) or "(none)"
|
|
120
|
+
raise KeyError(f"Target '{name}' not found. Available: {available}")
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def default_target(self) -> TargetConfig:
|
|
124
|
+
if not self.targets:
|
|
125
|
+
raise ValueError("No targets configured. Check config.yaml")
|
|
126
|
+
return self.targets[0]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_config(config_path: Path | None = None) -> AppConfig:
|
|
130
|
+
"""Load config from YAML; the password comes from the encrypted store."""
|
|
131
|
+
path = config_path or CONFIG_FILE
|
|
132
|
+
if not path.exists():
|
|
133
|
+
raise FileNotFoundError(
|
|
134
|
+
f"Config file not found: {path}\n"
|
|
135
|
+
f"Run 'postgres-aiops init' to set up a target and store its password "
|
|
136
|
+
f"encrypted, or create {CONFIG_FILE} with a 'targets' list."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
with open(path) as f:
|
|
140
|
+
raw = yaml.safe_load(f) or {}
|
|
141
|
+
|
|
142
|
+
targets = tuple(
|
|
143
|
+
TargetConfig(
|
|
144
|
+
name=t["name"],
|
|
145
|
+
host=t["host"],
|
|
146
|
+
port=t.get("port", DEFAULT_PORT),
|
|
147
|
+
dbname=t.get("dbname", DEFAULT_DBNAME),
|
|
148
|
+
user=t.get("user", DEFAULT_USER),
|
|
149
|
+
sslmode=t.get("sslmode", DEFAULT_SSLMODE),
|
|
150
|
+
)
|
|
151
|
+
for t in raw.get("targets", [])
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return AppConfig(targets=targets)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Connection management for PostgreSQL via psycopg 3.
|
|
2
|
+
|
|
3
|
+
A thin wrapper over a live libpq connection with per-target session reuse:
|
|
4
|
+
|
|
5
|
+
* Non-secret connection details (host / port / dbname / user / sslmode) come
|
|
6
|
+
from ``config.yaml``; the **password** is read from the encrypted secret
|
|
7
|
+
store (``~/.postgres-aiops/secrets.enc``) at connect time, never from disk in
|
|
8
|
+
plaintext.
|
|
9
|
+
* Reads run parameterised SQL against the system catalogs and ``pg_stat_*``
|
|
10
|
+
views; the connection is opened ``autocommit=True`` so maintenance commands
|
|
11
|
+
that cannot run inside a transaction block (``VACUUM``, ``CREATE INDEX
|
|
12
|
+
CONCURRENTLY``, ``REINDEX CONCURRENTLY``) work directly.
|
|
13
|
+
* Rows come back as dicts (``dict_row`` row factory), so the ops layer never
|
|
14
|
+
has to index columns positionally.
|
|
15
|
+
|
|
16
|
+
All ``psycopg`` errors are translated centrally into ``PgError`` with a teaching
|
|
17
|
+
message rather than leaking a raw traceback to an agent.
|
|
18
|
+
|
|
19
|
+
The underlying connection is injectable for tests: pass ``connection=`` to
|
|
20
|
+
``PgConnection`` to substitute a fake that implements ``cursor()`` / ``close()``
|
|
21
|
+
— **no live database is required** to exercise the ops layer.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import psycopg
|
|
29
|
+
from psycopg.rows import dict_row
|
|
30
|
+
|
|
31
|
+
from postgres_aiops.config import AppConfig, TargetConfig, load_config
|
|
32
|
+
|
|
33
|
+
_CONNECT_TIMEOUT = 10
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PgError(Exception):
|
|
37
|
+
"""A PostgreSQL operation failed; carries a teaching message + sqlstate."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, message: str, *, sqlstate: str | None = None) -> None:
|
|
40
|
+
self.sqlstate = sqlstate
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _teaching_message(exc: psycopg.Error, target: TargetConfig) -> str:
|
|
45
|
+
"""Map a psycopg error to an actionable, teaching message."""
|
|
46
|
+
sqlstate = getattr(exc, "sqlstate", None)
|
|
47
|
+
detail = str(exc).strip().splitlines()[0][:200] if str(exc) else ""
|
|
48
|
+
if isinstance(exc, psycopg.OperationalError):
|
|
49
|
+
return (
|
|
50
|
+
f"Could not connect to PostgreSQL at {target.host}:{target.port}/"
|
|
51
|
+
f"{target.dbname} as '{target.user}'. Check the host/port are reachable, "
|
|
52
|
+
f"the role/password are correct, and pg_hba.conf permits this client "
|
|
53
|
+
f"(sslmode={target.sslmode}). {detail}"
|
|
54
|
+
)
|
|
55
|
+
if sqlstate == "42P01": # undefined_table
|
|
56
|
+
return (
|
|
57
|
+
f"Relation not found ({sqlstate}). A required catalog/view is missing — "
|
|
58
|
+
f"pg_stat_statements must be installed (CREATE EXTENSION pg_stat_statements) "
|
|
59
|
+
f"for query stats. {detail}"
|
|
60
|
+
)
|
|
61
|
+
if sqlstate == "42501": # insufficient_privilege
|
|
62
|
+
return (
|
|
63
|
+
f"Insufficient privilege ({sqlstate}). This role lacks rights for the "
|
|
64
|
+
f"operation; a monitoring role needs pg_monitor, and maintenance "
|
|
65
|
+
f"commands need ownership. {detail}"
|
|
66
|
+
)
|
|
67
|
+
prefix = f" [{sqlstate}]" if sqlstate else ""
|
|
68
|
+
return f"PostgreSQL error{prefix} on {target.name}: {detail}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class PgConnection:
|
|
72
|
+
"""A single authenticated session against one PostgreSQL target."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, target: TargetConfig, connection: Any | None = None) -> None:
|
|
75
|
+
self._target = target
|
|
76
|
+
self._conn = connection if connection is not None else self._open(target)
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _open(target: TargetConfig) -> Any:
|
|
80
|
+
try:
|
|
81
|
+
return psycopg.connect(
|
|
82
|
+
**target.conn_kwargs,
|
|
83
|
+
row_factory=dict_row,
|
|
84
|
+
autocommit=True,
|
|
85
|
+
connect_timeout=_CONNECT_TIMEOUT,
|
|
86
|
+
)
|
|
87
|
+
except psycopg.Error as exc:
|
|
88
|
+
raise PgError(
|
|
89
|
+
_teaching_message(exc, target), sqlstate=getattr(exc, "sqlstate", None)
|
|
90
|
+
) from exc
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def target(self) -> TargetConfig:
|
|
94
|
+
return self._target
|
|
95
|
+
|
|
96
|
+
def query(self, sql: str, params: Any | None = None) -> list[dict]:
|
|
97
|
+
"""Run a read query and return rows as a list of dicts."""
|
|
98
|
+
try:
|
|
99
|
+
with self._conn.cursor() as cur:
|
|
100
|
+
cur.execute(sql, params)
|
|
101
|
+
return [dict(r) for r in cur.fetchall()]
|
|
102
|
+
except psycopg.Error as exc:
|
|
103
|
+
raise PgError(
|
|
104
|
+
_teaching_message(exc, self._target),
|
|
105
|
+
sqlstate=getattr(exc, "sqlstate", None),
|
|
106
|
+
) from exc
|
|
107
|
+
|
|
108
|
+
def query_one(self, sql: str, params: Any | None = None) -> dict | None:
|
|
109
|
+
"""Run a read query expected to return at most one row."""
|
|
110
|
+
rows = self.query(sql, params)
|
|
111
|
+
return rows[0] if rows else None
|
|
112
|
+
|
|
113
|
+
def scalar(self, sql: str, params: Any | None = None) -> Any:
|
|
114
|
+
"""Run a read query and return the first column of the first row (or None)."""
|
|
115
|
+
row = self.query_one(sql, params)
|
|
116
|
+
if not row:
|
|
117
|
+
return None
|
|
118
|
+
return next(iter(row.values()), None)
|
|
119
|
+
|
|
120
|
+
def execute(self, sql: str, params: Any | None = None) -> str:
|
|
121
|
+
"""Run a write/DDL/maintenance statement; return the libpq status message."""
|
|
122
|
+
try:
|
|
123
|
+
with self._conn.cursor() as cur:
|
|
124
|
+
cur.execute(sql, params)
|
|
125
|
+
return getattr(cur, "statusmessage", "") or "OK"
|
|
126
|
+
except psycopg.Error as exc:
|
|
127
|
+
raise PgError(
|
|
128
|
+
_teaching_message(exc, self._target),
|
|
129
|
+
sqlstate=getattr(exc, "sqlstate", None),
|
|
130
|
+
) from exc
|
|
131
|
+
|
|
132
|
+
def close(self) -> None:
|
|
133
|
+
try:
|
|
134
|
+
self._conn.close()
|
|
135
|
+
except psycopg.Error:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ConnectionManager:
|
|
140
|
+
"""Manages connections to multiple PostgreSQL targets with session reuse."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, config: AppConfig) -> None:
|
|
143
|
+
self._config = config
|
|
144
|
+
self._connections: dict[str, PgConnection] = {}
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_config(cls, config: AppConfig | None = None) -> ConnectionManager:
|
|
148
|
+
cfg = config or load_config()
|
|
149
|
+
return cls(cfg)
|
|
150
|
+
|
|
151
|
+
def connect(self, target_name: str | None = None) -> PgConnection:
|
|
152
|
+
"""Connect to a target by name, or the default target."""
|
|
153
|
+
target = (
|
|
154
|
+
self._config.get_target(target_name)
|
|
155
|
+
if target_name
|
|
156
|
+
else self._config.default_target
|
|
157
|
+
)
|
|
158
|
+
cached = self._connections.get(target.name)
|
|
159
|
+
if cached is not None:
|
|
160
|
+
return cached
|
|
161
|
+
conn = PgConnection(target)
|
|
162
|
+
self._connections[target.name] = conn
|
|
163
|
+
return conn
|
|
164
|
+
|
|
165
|
+
def disconnect(self, target_name: str) -> None:
|
|
166
|
+
conn = self._connections.pop(target_name, None)
|
|
167
|
+
if conn is not None:
|
|
168
|
+
conn.close()
|
|
169
|
+
|
|
170
|
+
def disconnect_all(self) -> None:
|
|
171
|
+
for name in list(self._connections):
|
|
172
|
+
self.disconnect(name)
|
|
173
|
+
|
|
174
|
+
def list_targets(self) -> list[str]:
|
|
175
|
+
return [t.name for t in self._config.targets]
|
|
176
|
+
|
|
177
|
+
def list_connected(self) -> list[str]:
|
|
178
|
+
return list(self._connections.keys())
|
postgres_aiops/doctor.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Environment and connectivity diagnostics for Postgres AIops."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
from postgres_aiops.config import CONFIG_FILE, ENV_FILE, load_config
|
|
8
|
+
from postgres_aiops.secretstore import SECRETS_FILE, check_permissions, has_store
|
|
9
|
+
|
|
10
|
+
_console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_doctor(skip_auth: bool = False) -> int:
|
|
14
|
+
"""Check config, secrets, and (optionally) connectivity.
|
|
15
|
+
|
|
16
|
+
Returns a process exit code: 0 healthy, 1 problems found. Connectivity
|
|
17
|
+
failures are reported as status, never raised as tracebacks (a doctor must
|
|
18
|
+
survive the thing it diagnoses being unhealthy).
|
|
19
|
+
"""
|
|
20
|
+
problems = 0
|
|
21
|
+
|
|
22
|
+
if not CONFIG_FILE.exists():
|
|
23
|
+
_console.print(f"[red]✗ Config file missing: {CONFIG_FILE}[/]")
|
|
24
|
+
_console.print("[yellow] Run 'postgres-aiops init' to set up your first target.[/]")
|
|
25
|
+
return 1
|
|
26
|
+
_console.print(f"[green]✓ Config file present: {CONFIG_FILE}[/]")
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
config = load_config()
|
|
30
|
+
except Exception as exc: # noqa: BLE001 — report, do not crash
|
|
31
|
+
_console.print(f"[red]✗ Config load failed: {exc}[/]")
|
|
32
|
+
return 1
|
|
33
|
+
|
|
34
|
+
if not config.targets:
|
|
35
|
+
_console.print("[red]✗ No targets configured[/]")
|
|
36
|
+
return 1
|
|
37
|
+
_console.print(f"[green]✓ {len(config.targets)} target(s) configured[/]")
|
|
38
|
+
|
|
39
|
+
if has_store():
|
|
40
|
+
_console.print(f"[green]✓ Encrypted secret store present: {SECRETS_FILE}[/]")
|
|
41
|
+
perm_warning = check_permissions()
|
|
42
|
+
if perm_warning:
|
|
43
|
+
_console.print(f"[yellow]! {perm_warning}[/]")
|
|
44
|
+
elif ENV_FILE.exists():
|
|
45
|
+
_console.print(
|
|
46
|
+
f"[yellow]! Using legacy plaintext .env ({ENV_FILE}). Migrate with "
|
|
47
|
+
f"'postgres-aiops secret migrate'.[/]"
|
|
48
|
+
)
|
|
49
|
+
else:
|
|
50
|
+
_console.print(
|
|
51
|
+
"[yellow]! No secret store yet. Run 'postgres-aiops init' to set up "
|
|
52
|
+
"credentials (stored encrypted).[/]"
|
|
53
|
+
)
|
|
54
|
+
problems += 1
|
|
55
|
+
|
|
56
|
+
for target in config.targets:
|
|
57
|
+
try:
|
|
58
|
+
_ = target.password
|
|
59
|
+
_console.print(f"[green]✓ password present for '{target.name}'[/]")
|
|
60
|
+
except OSError as exc:
|
|
61
|
+
_console.print(f"[red]✗ {exc}[/]")
|
|
62
|
+
problems += 1
|
|
63
|
+
|
|
64
|
+
if skip_auth:
|
|
65
|
+
_console.print("[dim]Skipping connectivity check (--skip-auth).[/]")
|
|
66
|
+
return 1 if problems else 0
|
|
67
|
+
|
|
68
|
+
from postgres_aiops.connection import ConnectionManager
|
|
69
|
+
|
|
70
|
+
mgr = ConnectionManager(config)
|
|
71
|
+
for target in config.targets:
|
|
72
|
+
try:
|
|
73
|
+
conn = mgr.connect(target.name)
|
|
74
|
+
row = conn.query_one("SELECT version() AS version") or {}
|
|
75
|
+
version = str(row.get("version", "?"))[:80]
|
|
76
|
+
_console.print(
|
|
77
|
+
f"[green]✓ Connected to '{target.name}' ({target.host}:{target.port}) "
|
|
78
|
+
f"— {version}[/]"
|
|
79
|
+
)
|
|
80
|
+
except Exception as exc: # noqa: BLE001 — connectivity is a status, not a crash
|
|
81
|
+
_console.print(f"[red]✗ Connect to '{target.name}' failed: {exc}[/]")
|
|
82
|
+
problems += 1
|
|
83
|
+
|
|
84
|
+
return 1 if problems else 0
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Governance harness for postgres-aiops — audit, policy, budget, undo, sanitize.
|
|
2
|
+
|
|
3
|
+
A self-contained, vendored governance layer. postgres-aiops has NO dependency on
|
|
4
|
+
any external skill family — this package is its own copy of the harness:
|
|
5
|
+
|
|
6
|
+
- ``@governed_tool`` — mandatory decorator on every MCP tool: policy pre-check,
|
|
7
|
+
token/runaway budget guard, graduated-autonomy risk-tier gate, audit logging,
|
|
8
|
+
and undo-token recording.
|
|
9
|
+
- unified SQLite audit log under ``~/.postgres-aiops/`` (override with
|
|
10
|
+
``POSTGRES_AIOPS_HOME``).
|
|
11
|
+
- ``sanitize`` — prompt-injection defense for API-returned text.
|
|
12
|
+
|
|
13
|
+
State lives under ``ops_home()`` (default ``~/.postgres-aiops``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from postgres_aiops.governance.audit import AuditEngine, get_engine
|
|
17
|
+
from postgres_aiops.governance.budget import BudgetExceeded, BudgetTracker, get_budget
|
|
18
|
+
from postgres_aiops.governance.decorators import PolicyDenied, governed_tool
|
|
19
|
+
from postgres_aiops.governance.patterns import Pattern, PatternMatch, get_pattern_engine
|
|
20
|
+
from postgres_aiops.governance.policy import TierDecision, get_policy_engine
|
|
21
|
+
from postgres_aiops.governance.sanitize import sanitize
|
|
22
|
+
from postgres_aiops.governance.undo import UndoStore, get_undo_store
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"governed_tool",
|
|
26
|
+
"sanitize",
|
|
27
|
+
"PolicyDenied",
|
|
28
|
+
"get_engine",
|
|
29
|
+
"AuditEngine",
|
|
30
|
+
"get_policy_engine",
|
|
31
|
+
"TierDecision",
|
|
32
|
+
"get_budget",
|
|
33
|
+
"BudgetTracker",
|
|
34
|
+
"BudgetExceeded",
|
|
35
|
+
"get_undo_store",
|
|
36
|
+
"UndoStore",
|
|
37
|
+
"Pattern",
|
|
38
|
+
"PatternMatch",
|
|
39
|
+
"get_pattern_engine",
|
|
40
|
+
]
|