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,53 @@
|
|
|
1
|
+
"""``postgres-aiops analyze`` — the three flagship analyses (pull live)."""
|
|
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
|
+
analyze_app = typer.Typer(
|
|
13
|
+
name="analyze",
|
|
14
|
+
help="Flagship analyses: slow-query RCA, bloat/vacuum, blocking chains.",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@analyze_app.command("slow-query")
|
|
20
|
+
@cli_errors
|
|
21
|
+
def analyze_slow_query(
|
|
22
|
+
explain_sql: Annotated[str | None, typer.Option("--explain", help="SQL to EXPLAIN")] = None,
|
|
23
|
+
target: TargetOption = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Root-cause the worst pg_stat_statements entry."""
|
|
26
|
+
from postgres_aiops.ops import analysis, queries
|
|
27
|
+
|
|
28
|
+
conn, _ = get_connection(target)
|
|
29
|
+
statements = queries.top_queries(conn, order_by="total_time")["statements"]
|
|
30
|
+
explain = queries.explain_query(conn, explain_sql) if explain_sql else None
|
|
31
|
+
console.print_json(json.dumps(analysis.slow_query_rca(statements, explain=explain)))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@analyze_app.command("bloat-vacuum")
|
|
35
|
+
@cli_errors
|
|
36
|
+
def analyze_bloat_vacuum(target: TargetOption = None) -> None:
|
|
37
|
+
"""Rank tables needing vacuum from dead-tuple ratio + autovacuum recency."""
|
|
38
|
+
from postgres_aiops.ops import analysis, tables
|
|
39
|
+
|
|
40
|
+
conn, _ = get_connection(target)
|
|
41
|
+
rows = tables.table_bloat(conn)["tables"]
|
|
42
|
+
console.print_json(json.dumps(analysis.bloat_and_vacuum_analysis(rows)))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@analyze_app.command("blocking")
|
|
46
|
+
@cli_errors
|
|
47
|
+
def analyze_blocking(target: TargetOption = None) -> None:
|
|
48
|
+
"""Build the blocking-lock chain and name the root blocker."""
|
|
49
|
+
from postgres_aiops.ops import activity, analysis
|
|
50
|
+
|
|
51
|
+
conn, _ = get_connection(target)
|
|
52
|
+
pairs = activity.blocking_pairs(conn)
|
|
53
|
+
console.print_json(json.dumps(analysis.blocking_lock_chain_rca(pairs)))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Doctor top-level command: environment and connectivity check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from postgres_aiops.cli._common import cli_errors
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@cli_errors
|
|
13
|
+
def doctor_cmd(
|
|
14
|
+
skip_auth: Annotated[
|
|
15
|
+
bool, typer.Option("--skip-auth", help="Skip connectivity check (faster)")
|
|
16
|
+
] = False,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Check environment, config, secrets, and connectivity."""
|
|
19
|
+
from postgres_aiops.doctor import run_doctor
|
|
20
|
+
|
|
21
|
+
raise typer.Exit(run_doctor(skip_auth=skip_auth))
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""``postgres-aiops index`` — index-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
|
+
index_app = typer.Typer(
|
|
12
|
+
name="index",
|
|
13
|
+
help="Index health: unused, missing, bloat, invalid.",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@index_app.command("unused")
|
|
19
|
+
@cli_errors
|
|
20
|
+
def index_unused(target: TargetOption = None) -> None:
|
|
21
|
+
"""Non-unique, non-primary indexes with zero scans."""
|
|
22
|
+
from postgres_aiops.ops import indexes as ops
|
|
23
|
+
|
|
24
|
+
conn, _ = get_connection(target)
|
|
25
|
+
console.print_json(json.dumps(ops.unused_indexes(conn)))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@index_app.command("missing")
|
|
29
|
+
@cli_errors
|
|
30
|
+
def index_missing(target: TargetOption = None) -> None:
|
|
31
|
+
"""Tables with heavy sequential scans (missing-index hints)."""
|
|
32
|
+
from postgres_aiops.ops import indexes as ops
|
|
33
|
+
|
|
34
|
+
conn, _ = get_connection(target)
|
|
35
|
+
console.print_json(json.dumps(ops.missing_index_hints(conn)))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@index_app.command("bloat")
|
|
39
|
+
@cli_errors
|
|
40
|
+
def index_bloat(target: TargetOption = None) -> None:
|
|
41
|
+
"""Coarse index-bloat estimate."""
|
|
42
|
+
from postgres_aiops.ops import indexes as ops
|
|
43
|
+
|
|
44
|
+
conn, _ = get_connection(target)
|
|
45
|
+
console.print_json(json.dumps(ops.index_bloat(conn)))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@index_app.command("invalid")
|
|
49
|
+
@cli_errors
|
|
50
|
+
def index_invalid(target: TargetOption = None) -> None:
|
|
51
|
+
"""Invalid and duplicate indexes."""
|
|
52
|
+
from postgres_aiops.ops import indexes as ops
|
|
53
|
+
|
|
54
|
+
conn, _ = get_connection(target)
|
|
55
|
+
console.print_json(json.dumps(ops.invalid_indexes(conn)))
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""``postgres-aiops init`` — a friendly, interactive onboarding wizard.
|
|
2
|
+
|
|
3
|
+
Walks a new user through connecting their first PostgreSQL target: collects the
|
|
4
|
+
non-secret connection details into ``config.yaml`` and the role password into the
|
|
5
|
+
*encrypted* store (never plaintext on disk). Designed to be run on a terminal;
|
|
6
|
+
everything it needs is prompted with sensible defaults.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import getpass
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from postgres_aiops.cli._common import cli_errors, console
|
|
17
|
+
from postgres_aiops.config import (
|
|
18
|
+
CONFIG_DIR,
|
|
19
|
+
CONFIG_FILE,
|
|
20
|
+
DEFAULT_DBNAME,
|
|
21
|
+
DEFAULT_PORT,
|
|
22
|
+
DEFAULT_SSLMODE,
|
|
23
|
+
DEFAULT_USER,
|
|
24
|
+
)
|
|
25
|
+
from postgres_aiops.secretstore import SecretStore, resolve_master_password
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_existing_targets() -> list[dict]:
|
|
29
|
+
if not CONFIG_FILE.exists():
|
|
30
|
+
return []
|
|
31
|
+
raw = yaml.safe_load(CONFIG_FILE.read_text("utf-8")) or {}
|
|
32
|
+
return list(raw.get("targets", []))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _write_targets(targets: list[dict]) -> None:
|
|
36
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
try:
|
|
38
|
+
CONFIG_DIR.chmod(0o700)
|
|
39
|
+
except OSError:
|
|
40
|
+
pass
|
|
41
|
+
CONFIG_FILE.write_text(yaml.safe_dump({"targets": targets}, sort_keys=False), "utf-8")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@cli_errors
|
|
45
|
+
def init_cmd() -> None:
|
|
46
|
+
"""Interactively set up your first PostgreSQL connection."""
|
|
47
|
+
console.print("[bold cyan]Postgres AIops — setup wizard[/]")
|
|
48
|
+
console.print(
|
|
49
|
+
"This collects connection details (saved to config.yaml) and your "
|
|
50
|
+
"PostgreSQL password (saved [bold]encrypted[/] to secrets.enc).\n"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
console.print("[bold]Step 1 — master password[/]")
|
|
54
|
+
console.print(
|
|
55
|
+
"[dim]Encrypts secrets.enc. You'll set it via the "
|
|
56
|
+
"POSTGRES_AIOPS_MASTER_PASSWORD env var for non-interactive/MCP use.[/]"
|
|
57
|
+
)
|
|
58
|
+
password = resolve_master_password(confirm_if_new=True)
|
|
59
|
+
store = SecretStore.unlock(password)
|
|
60
|
+
|
|
61
|
+
targets = _load_existing_targets()
|
|
62
|
+
existing_names = {t.get("name") for t in targets}
|
|
63
|
+
|
|
64
|
+
while True:
|
|
65
|
+
console.print("\n[bold]Step 2 — add a target[/]")
|
|
66
|
+
name = typer.prompt("Target name (e.g. primary)").strip()
|
|
67
|
+
if name in existing_names:
|
|
68
|
+
if not typer.confirm(f"'{name}' already exists — overwrite?", default=False):
|
|
69
|
+
continue
|
|
70
|
+
targets = [t for t in targets if t.get("name") != name]
|
|
71
|
+
|
|
72
|
+
host = typer.prompt("Host (IP or FQDN of the PostgreSQL server)").strip()
|
|
73
|
+
port = typer.prompt("Port", default=DEFAULT_PORT, type=int)
|
|
74
|
+
dbname = typer.prompt("Database name", default=DEFAULT_DBNAME).strip()
|
|
75
|
+
user = typer.prompt("Role/user", default=DEFAULT_USER).strip()
|
|
76
|
+
sslmode = typer.prompt(
|
|
77
|
+
"sslmode (disable/allow/prefer/require/verify-ca/verify-full)",
|
|
78
|
+
default=DEFAULT_SSLMODE,
|
|
79
|
+
).strip()
|
|
80
|
+
|
|
81
|
+
console.print(
|
|
82
|
+
"[dim]The role should have pg_monitor for read visibility. "
|
|
83
|
+
"Enter its password below (input hidden).[/]"
|
|
84
|
+
)
|
|
85
|
+
secret = getpass.getpass(f"Password for '{user}'@'{name}' (hidden): ")
|
|
86
|
+
store = store.set(name, secret)
|
|
87
|
+
|
|
88
|
+
entry = {
|
|
89
|
+
"name": name,
|
|
90
|
+
"host": host,
|
|
91
|
+
"port": port,
|
|
92
|
+
"dbname": dbname,
|
|
93
|
+
"user": user,
|
|
94
|
+
"sslmode": sslmode,
|
|
95
|
+
}
|
|
96
|
+
targets.append(entry)
|
|
97
|
+
existing_names.add(name)
|
|
98
|
+
_write_targets(targets)
|
|
99
|
+
console.print(f"[green]✓ Saved target '{name}' (password stored encrypted).[/]")
|
|
100
|
+
|
|
101
|
+
if not typer.confirm("\nAdd another target?", default=False):
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
console.print(f"\n[green]✓ Setup complete.[/] Config: {CONFIG_FILE}")
|
|
105
|
+
console.print(
|
|
106
|
+
"[dim]Tip: export POSTGRES_AIOPS_MASTER_PASSWORD=... in your shell profile "
|
|
107
|
+
"so the MCP server and CLI can unlock secrets non-interactively.[/]"
|
|
108
|
+
)
|
|
109
|
+
if typer.confirm("Run a connectivity check now (postgres-aiops doctor)?", default=True):
|
|
110
|
+
from postgres_aiops.doctor import run_doctor
|
|
111
|
+
|
|
112
|
+
raise typer.Exit(run_doctor())
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""``postgres-aiops overview`` — one-shot cluster health snapshot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@cli_errors
|
|
11
|
+
def overview_cmd(target: TargetOption = None) -> None:
|
|
12
|
+
"""One-shot cluster health: version, connections, long queries, bloat, replication."""
|
|
13
|
+
from postgres_aiops.ops import overview as ops
|
|
14
|
+
|
|
15
|
+
conn, _ = get_connection(target)
|
|
16
|
+
console.print_json(json.dumps(ops.snapshot(conn)))
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""``postgres-aiops query`` — pg_stat_statements top-N, EXPLAIN, stats reset."""
|
|
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 (
|
|
11
|
+
DryRunOption,
|
|
12
|
+
TargetOption,
|
|
13
|
+
cli_errors,
|
|
14
|
+
console,
|
|
15
|
+
double_confirm,
|
|
16
|
+
dry_run_print,
|
|
17
|
+
get_connection,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
query_app = typer.Typer(
|
|
21
|
+
name="query",
|
|
22
|
+
help="Query stats: top-N, EXPLAIN, reset.",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@query_app.command("top")
|
|
28
|
+
@cli_errors
|
|
29
|
+
def query_top(
|
|
30
|
+
order_by: Annotated[
|
|
31
|
+
str, typer.Option("--order-by", help="total_time|mean_time|calls|rows|io")
|
|
32
|
+
] = "total_time",
|
|
33
|
+
limit: Annotated[int, typer.Option("--limit", help="Rows to return")] = 20,
|
|
34
|
+
target: TargetOption = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Top statements from pg_stat_statements."""
|
|
37
|
+
from postgres_aiops.ops import queries as ops
|
|
38
|
+
|
|
39
|
+
conn, _ = get_connection(target)
|
|
40
|
+
console.print_json(json.dumps(ops.top_queries(conn, order_by=order_by, limit=limit)))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@query_app.command("explain")
|
|
44
|
+
@cli_errors
|
|
45
|
+
def query_explain(
|
|
46
|
+
sql: Annotated[str, typer.Argument(help="A single SQL statement to EXPLAIN")],
|
|
47
|
+
analyze: Annotated[
|
|
48
|
+
bool, typer.Option("--analyze", help="Execute to gather real timing")
|
|
49
|
+
] = False,
|
|
50
|
+
target: TargetOption = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Return the JSON execution plan for a statement."""
|
|
53
|
+
from postgres_aiops.ops import queries as ops
|
|
54
|
+
|
|
55
|
+
conn, _ = get_connection(target)
|
|
56
|
+
console.print_json(json.dumps(ops.explain_query(conn, sql, analyze=analyze)))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@query_app.command("reset")
|
|
60
|
+
@cli_errors
|
|
61
|
+
def query_reset(target: TargetOption = None, dry_run: DryRunOption = False) -> None:
|
|
62
|
+
"""Reset pg_stat_statements accumulators (irreversible; dry-run + confirm)."""
|
|
63
|
+
from postgres_aiops.ops import queries as ops
|
|
64
|
+
|
|
65
|
+
if dry_run:
|
|
66
|
+
dry_run_print(operation="reset_query_stats", api_call="SELECT pg_stat_statements_reset()")
|
|
67
|
+
return
|
|
68
|
+
double_confirm("reset pg_stat_statements on", "this target")
|
|
69
|
+
conn, _ = get_connection(target)
|
|
70
|
+
console.print_json(json.dumps(ops.reset_query_stats(conn)))
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""``postgres-aiops remediate`` — guarded maintenance writes (dry-run + confirm)."""
|
|
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 (
|
|
11
|
+
DryRunOption,
|
|
12
|
+
TargetOption,
|
|
13
|
+
cli_errors,
|
|
14
|
+
console,
|
|
15
|
+
double_confirm,
|
|
16
|
+
dry_run_print,
|
|
17
|
+
get_connection,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
remediate_app = typer.Typer(
|
|
21
|
+
name="remediate",
|
|
22
|
+
help="Guarded writes: terminate/cancel, vacuum/analyze, index ops, ALTER SYSTEM.",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@remediate_app.command("terminate")
|
|
28
|
+
@cli_errors
|
|
29
|
+
def remediate_terminate(
|
|
30
|
+
pid: Annotated[int, typer.Argument(help="Backend pid to terminate")],
|
|
31
|
+
target: TargetOption = None,
|
|
32
|
+
dry_run: DryRunOption = False,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Terminate a backend (no undo; dry-run + confirm)."""
|
|
35
|
+
from postgres_aiops.ops import remediation as ops
|
|
36
|
+
|
|
37
|
+
if dry_run:
|
|
38
|
+
dry_run_print(operation="terminate_backend",
|
|
39
|
+
api_call="SELECT pg_terminate_backend(pid)", parameters={"pid": pid})
|
|
40
|
+
return
|
|
41
|
+
double_confirm("terminate backend", str(pid))
|
|
42
|
+
conn, _ = get_connection(target)
|
|
43
|
+
console.print_json(json.dumps(ops.terminate_backend(conn, pid)))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@remediate_app.command("cancel")
|
|
47
|
+
@cli_errors
|
|
48
|
+
def remediate_cancel(
|
|
49
|
+
pid: Annotated[int, typer.Argument(help="Backend pid whose query to cancel")],
|
|
50
|
+
target: TargetOption = None,
|
|
51
|
+
dry_run: DryRunOption = False,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Cancel a backend's running query (no undo; dry-run + confirm)."""
|
|
54
|
+
from postgres_aiops.ops import remediation as ops
|
|
55
|
+
|
|
56
|
+
if dry_run:
|
|
57
|
+
dry_run_print(operation="cancel_query",
|
|
58
|
+
api_call="SELECT pg_cancel_backend(pid)", parameters={"pid": pid})
|
|
59
|
+
return
|
|
60
|
+
double_confirm("cancel query on backend", str(pid))
|
|
61
|
+
conn, _ = get_connection(target)
|
|
62
|
+
console.print_json(json.dumps(ops.cancel_query(conn, pid)))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@remediate_app.command("vacuum")
|
|
66
|
+
@cli_errors
|
|
67
|
+
def remediate_vacuum(
|
|
68
|
+
table: Annotated[str, typer.Argument(help="Table (optionally schema-qualified)")],
|
|
69
|
+
full: Annotated[bool, typer.Option("--full", help="VACUUM FULL (exclusive lock)")] = False,
|
|
70
|
+
analyze: Annotated[bool, typer.Option("--analyze", help="Also ANALYZE")] = False,
|
|
71
|
+
target: TargetOption = None,
|
|
72
|
+
dry_run: DryRunOption = False,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""VACUUM a table (dry-run + confirm)."""
|
|
75
|
+
from postgres_aiops.ops import remediation as ops
|
|
76
|
+
|
|
77
|
+
if dry_run:
|
|
78
|
+
dry_run_print(operation="run_vacuum", api_call=f"VACUUM {table}",
|
|
79
|
+
parameters={"full": full, "analyze": analyze})
|
|
80
|
+
return
|
|
81
|
+
double_confirm("VACUUM", table)
|
|
82
|
+
conn, _ = get_connection(target)
|
|
83
|
+
console.print_json(json.dumps(ops.run_vacuum(conn, table, full=full, analyze=analyze)))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@remediate_app.command("analyze-table")
|
|
87
|
+
@cli_errors
|
|
88
|
+
def remediate_analyze(
|
|
89
|
+
table: Annotated[str, typer.Argument(help="Table (optionally schema-qualified)")],
|
|
90
|
+
target: TargetOption = None,
|
|
91
|
+
dry_run: DryRunOption = False,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""ANALYZE a table (dry-run + confirm)."""
|
|
94
|
+
from postgres_aiops.ops import remediation as ops
|
|
95
|
+
|
|
96
|
+
if dry_run:
|
|
97
|
+
dry_run_print(operation="run_analyze", api_call=f"ANALYZE {table}")
|
|
98
|
+
return
|
|
99
|
+
double_confirm("ANALYZE", table)
|
|
100
|
+
conn, _ = get_connection(target)
|
|
101
|
+
console.print_json(json.dumps(ops.run_analyze(conn, table)))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@remediate_app.command("create-index")
|
|
105
|
+
@cli_errors
|
|
106
|
+
def remediate_create_index(
|
|
107
|
+
table: Annotated[str, typer.Argument(help="Table to index")],
|
|
108
|
+
columns: Annotated[list[str], typer.Argument(help="Column(s) to index")],
|
|
109
|
+
name: Annotated[str | None, typer.Option("--name", help="Index name")] = None,
|
|
110
|
+
unique: Annotated[bool, typer.Option("--unique", help="UNIQUE index")] = False,
|
|
111
|
+
concurrently: Annotated[bool, typer.Option("--concurrently", help="CONCURRENTLY")] = False,
|
|
112
|
+
target: TargetOption = None,
|
|
113
|
+
dry_run: DryRunOption = False,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Create an index (reversible; dry-run + confirm)."""
|
|
116
|
+
from postgres_aiops.ops import remediation as ops
|
|
117
|
+
|
|
118
|
+
if dry_run:
|
|
119
|
+
dry_run_print(operation="create_index", api_call=f"CREATE INDEX ON {table}",
|
|
120
|
+
parameters={"columns": columns, "name": name, "unique": unique})
|
|
121
|
+
return
|
|
122
|
+
double_confirm("create index on", table)
|
|
123
|
+
conn, _ = get_connection(target)
|
|
124
|
+
result = ops.create_index(conn, table, columns, name=name, unique=unique,
|
|
125
|
+
concurrently=concurrently)
|
|
126
|
+
console.print_json(json.dumps(result))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@remediate_app.command("drop-index")
|
|
130
|
+
@cli_errors
|
|
131
|
+
def remediate_drop_index(
|
|
132
|
+
name: Annotated[str, typer.Argument(help="Index name to drop")],
|
|
133
|
+
concurrently: Annotated[bool, typer.Option("--concurrently", help="CONCURRENTLY")] = False,
|
|
134
|
+
target: TargetOption = None,
|
|
135
|
+
dry_run: DryRunOption = False,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Drop an index (reversible; captures the definition first; dry-run + confirm)."""
|
|
138
|
+
from postgres_aiops.ops import remediation as ops
|
|
139
|
+
|
|
140
|
+
if dry_run:
|
|
141
|
+
dry_run_print(operation="drop_index", api_call=f"DROP INDEX {name}")
|
|
142
|
+
return
|
|
143
|
+
double_confirm("drop index", name)
|
|
144
|
+
conn, _ = get_connection(target)
|
|
145
|
+
console.print_json(json.dumps(ops.drop_index(conn, name, concurrently=concurrently)))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@remediate_app.command("reindex")
|
|
149
|
+
@cli_errors
|
|
150
|
+
def remediate_reindex(
|
|
151
|
+
target_name: Annotated[str, typer.Argument(help="Index/table/schema name")],
|
|
152
|
+
kind: Annotated[str, typer.Option("--kind", help="INDEX|TABLE|SCHEMA")] = "INDEX",
|
|
153
|
+
concurrently: Annotated[bool, typer.Option("--concurrently", help="CONCURRENTLY")] = False,
|
|
154
|
+
target: TargetOption = None,
|
|
155
|
+
dry_run: DryRunOption = False,
|
|
156
|
+
) -> None:
|
|
157
|
+
"""REINDEX an index/table/schema (dry-run + confirm)."""
|
|
158
|
+
from postgres_aiops.ops import remediation as ops
|
|
159
|
+
|
|
160
|
+
if dry_run:
|
|
161
|
+
dry_run_print(operation="reindex", api_call=f"REINDEX {kind} {target_name}")
|
|
162
|
+
return
|
|
163
|
+
double_confirm(f"REINDEX {kind}", target_name)
|
|
164
|
+
conn, _ = get_connection(target)
|
|
165
|
+
console.print_json(json.dumps(ops.reindex(conn, target_name, kind=kind,
|
|
166
|
+
concurrently=concurrently)))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@remediate_app.command("set")
|
|
170
|
+
@cli_errors
|
|
171
|
+
def remediate_set(
|
|
172
|
+
name: Annotated[str, typer.Argument(help="Parameter name (e.g. work_mem)")],
|
|
173
|
+
value: Annotated[str, typer.Argument(help="New value")],
|
|
174
|
+
target: TargetOption = None,
|
|
175
|
+
dry_run: DryRunOption = False,
|
|
176
|
+
) -> None:
|
|
177
|
+
"""ALTER SYSTEM SET a parameter (reversible; dry-run + confirm)."""
|
|
178
|
+
from postgres_aiops.ops import remediation as ops
|
|
179
|
+
|
|
180
|
+
if dry_run:
|
|
181
|
+
dry_run_print(operation="update_setting",
|
|
182
|
+
api_call=f"ALTER SYSTEM SET {name} = ...", parameters={"value": value})
|
|
183
|
+
return
|
|
184
|
+
double_confirm(f"ALTER SYSTEM SET {name} =", value)
|
|
185
|
+
conn, _ = get_connection(target)
|
|
186
|
+
console.print_json(json.dumps(ops.update_setting(conn, name, value)))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""``postgres-aiops repl`` — replication 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
|
+
repl_app = typer.Typer(
|
|
12
|
+
name="repl",
|
|
13
|
+
help="Replication: status, slots, WAL.",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@repl_app.command("status")
|
|
19
|
+
@cli_errors
|
|
20
|
+
def repl_status(target: TargetOption = None) -> None:
|
|
21
|
+
"""Connected standbys and replay lag."""
|
|
22
|
+
from postgres_aiops.ops import replication as ops
|
|
23
|
+
|
|
24
|
+
conn, _ = get_connection(target)
|
|
25
|
+
console.print_json(json.dumps(ops.replication_status(conn)))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@repl_app.command("slots")
|
|
29
|
+
@cli_errors
|
|
30
|
+
def repl_slots(target: TargetOption = None) -> None:
|
|
31
|
+
"""Replication slots (inactive slots flagged)."""
|
|
32
|
+
from postgres_aiops.ops import replication as ops
|
|
33
|
+
|
|
34
|
+
conn, _ = get_connection(target)
|
|
35
|
+
console.print_json(json.dumps(ops.replication_slots(conn)))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@repl_app.command("wal")
|
|
39
|
+
@cli_errors
|
|
40
|
+
def repl_wal(target: TargetOption = None) -> None:
|
|
41
|
+
"""WAL position, level and archiver health."""
|
|
42
|
+
from postgres_aiops.ops import replication as ops
|
|
43
|
+
|
|
44
|
+
conn, _ = get_connection(target)
|
|
45
|
+
console.print_json(json.dumps(ops.wal_status(conn)))
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""``postgres-aiops secret`` — manage the encrypted credential store.
|
|
2
|
+
|
|
3
|
+
Secrets (PostgreSQL role passwords) are stored in ``~/.postgres-aiops/secrets.enc``
|
|
4
|
+
(Fernet, key derived from a master password via scrypt). Nothing here ever
|
|
5
|
+
prints a secret value.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import getpass
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from postgres_aiops.cli._common import cli_errors, console
|
|
16
|
+
from postgres_aiops.config import SECRET_ENV_PREFIX, SECRET_ENV_SUFFIX
|
|
17
|
+
from postgres_aiops.secretstore import (
|
|
18
|
+
SECRETS_FILE,
|
|
19
|
+
SecretStore,
|
|
20
|
+
check_permissions,
|
|
21
|
+
migrate_legacy_env,
|
|
22
|
+
resolve_master_password,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
secret_app = typer.Typer(
|
|
26
|
+
name="secret",
|
|
27
|
+
help="Manage the encrypted credential store (secrets.enc).",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
NameArg = Annotated[str, typer.Argument(help="Target name the password belongs to")]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@secret_app.command("set")
|
|
35
|
+
@cli_errors
|
|
36
|
+
def secret_set(
|
|
37
|
+
name: NameArg,
|
|
38
|
+
value: Annotated[
|
|
39
|
+
str | None,
|
|
40
|
+
typer.Option("--value", help="Password value (omit to be prompted, hidden)"),
|
|
41
|
+
] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Store (or replace) the password for a target — value is read hidden."""
|
|
44
|
+
password = resolve_master_password(confirm_if_new=True)
|
|
45
|
+
if value is None:
|
|
46
|
+
value = getpass.getpass(f"Password for '{name}' (hidden): ")
|
|
47
|
+
store = SecretStore.unlock(password)
|
|
48
|
+
store.set(name, value)
|
|
49
|
+
console.print(f"[green]✓ Stored encrypted password for '{name}' in {SECRETS_FILE}[/]")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@secret_app.command("list")
|
|
53
|
+
@cli_errors
|
|
54
|
+
def secret_list() -> None:
|
|
55
|
+
"""List target names that have a stored password (values never shown)."""
|
|
56
|
+
store = SecretStore.unlock()
|
|
57
|
+
names = store.names()
|
|
58
|
+
if not names:
|
|
59
|
+
console.print("[yellow]No secrets stored yet. Add one: postgres-aiops secret set <name>[/]")
|
|
60
|
+
return
|
|
61
|
+
console.print("[bold]Stored secrets:[/]")
|
|
62
|
+
for n in names:
|
|
63
|
+
console.print(f" • {n}")
|
|
64
|
+
warning = check_permissions()
|
|
65
|
+
if warning:
|
|
66
|
+
console.print(f"[yellow]! {warning}[/]")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@secret_app.command("rm")
|
|
70
|
+
@cli_errors
|
|
71
|
+
def secret_rm(name: NameArg) -> None:
|
|
72
|
+
"""Delete a stored password."""
|
|
73
|
+
store = SecretStore.unlock()
|
|
74
|
+
store.delete(name)
|
|
75
|
+
console.print(f"[green]✓ Deleted password for '{name}'[/]")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@secret_app.command("migrate")
|
|
79
|
+
@cli_errors
|
|
80
|
+
def secret_migrate() -> None:
|
|
81
|
+
"""Import passwords from a legacy plaintext .env into the encrypted store."""
|
|
82
|
+
password = resolve_master_password(confirm_if_new=True)
|
|
83
|
+
imported = migrate_legacy_env(SECRET_ENV_PREFIX, SECRET_ENV_SUFFIX, password)
|
|
84
|
+
if not imported:
|
|
85
|
+
console.print("[yellow]Nothing to migrate (no legacy .env secrets found).[/]")
|
|
86
|
+
return
|
|
87
|
+
console.print(f"[green]✓ Imported {len(imported)} secret(s): {', '.join(imported)}[/]")
|
|
88
|
+
console.print("[dim]The old .env was renamed to .env.migrated — delete it once verified.[/]")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@secret_app.command("rotate-password")
|
|
92
|
+
@cli_errors
|
|
93
|
+
def secret_rotate_password() -> None:
|
|
94
|
+
"""Re-encrypt the whole store under a new master password."""
|
|
95
|
+
console.print("[bold]Unlock with the current master password:[/]")
|
|
96
|
+
store = SecretStore.unlock()
|
|
97
|
+
new_pw = getpass.getpass("New master password: ")
|
|
98
|
+
confirm = getpass.getpass("Confirm new master password: ")
|
|
99
|
+
if new_pw != confirm:
|
|
100
|
+
console.print("[red]Passwords did not match. Aborted.[/]")
|
|
101
|
+
raise typer.Exit(1)
|
|
102
|
+
store.with_password(new_pw)
|
|
103
|
+
console.print("[green]✓ Master password rotated. Update POSTGRES_AIOPS_MASTER_PASSWORD.[/]")
|