kctl-pg 0.7.2__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.
- kctl_pg/__init__.py +3 -0
- kctl_pg/__main__.py +5 -0
- kctl_pg/cli.py +162 -0
- kctl_pg/commands/__init__.py +0 -0
- kctl_pg/commands/activity.py +176 -0
- kctl_pg/commands/automation.py +666 -0
- kctl_pg/commands/backup.py +177 -0
- kctl_pg/commands/config_cmd.py +545 -0
- kctl_pg/commands/dashboard.py +104 -0
- kctl_pg/commands/db.py +354 -0
- kctl_pg/commands/doctor_cmd.py +87 -0
- kctl_pg/commands/dr.py +821 -0
- kctl_pg/commands/extensions.py +126 -0
- kctl_pg/commands/health.py +91 -0
- kctl_pg/commands/indexes.py +578 -0
- kctl_pg/commands/lint.py +590 -0
- kctl_pg/commands/maintenance.py +379 -0
- kctl_pg/commands/performance.py +727 -0
- kctl_pg/commands/pg_config.py +235 -0
- kctl_pg/commands/pgbouncer.py +486 -0
- kctl_pg/commands/pipeline.py +601 -0
- kctl_pg/commands/query.py +60 -0
- kctl_pg/commands/replication.py +727 -0
- kctl_pg/commands/schemas.py +216 -0
- kctl_pg/commands/security.py +762 -0
- kctl_pg/commands/skill_cmd.py +76 -0
- kctl_pg/commands/stats.py +537 -0
- kctl_pg/commands/tables.py +996 -0
- kctl_pg/commands/users.py +493 -0
- kctl_pg/core/__init__.py +0 -0
- kctl_pg/core/callbacks.py +58 -0
- kctl_pg/core/client.py +152 -0
- kctl_pg/core/config.py +165 -0
- kctl_pg/core/exceptions.py +42 -0
- kctl_pg/core/output.py +5 -0
- kctl_pg-0.7.2.dist-info/METADATA +18 -0
- kctl_pg-0.7.2.dist-info/RECORD +39 -0
- kctl_pg-0.7.2.dist-info/WHEEL +4 -0
- kctl_pg-0.7.2.dist-info/entry_points.txt +2 -0
kctl_pg/__init__.py
ADDED
kctl_pg/__main__.py
ADDED
kctl_pg/cli.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Main CLI entry point for kctl-pg."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from kctl_lib import handle_cli_error
|
|
10
|
+
|
|
11
|
+
from kctl_pg import __version__
|
|
12
|
+
from kctl_pg.commands.activity import app as activity_app
|
|
13
|
+
from kctl_pg.commands.automation import app as automation_app
|
|
14
|
+
from kctl_pg.commands.backup import app as backup_app
|
|
15
|
+
from kctl_pg.commands.config_cmd import app as config_app
|
|
16
|
+
from kctl_pg.commands.dashboard import app as dashboard_app
|
|
17
|
+
from kctl_pg.commands.db import app as db_app
|
|
18
|
+
from kctl_pg.commands.dr import app as dr_app
|
|
19
|
+
from kctl_pg.commands.extensions import app as extensions_app
|
|
20
|
+
from kctl_pg.commands.health import app as health_app
|
|
21
|
+
from kctl_pg.commands.indexes import app as indexes_app
|
|
22
|
+
from kctl_pg.commands.lint import app as lint_app
|
|
23
|
+
from kctl_pg.commands.maintenance import app as maintenance_app
|
|
24
|
+
from kctl_pg.commands.performance import app as performance_app
|
|
25
|
+
from kctl_pg.commands.pg_config import app as pg_config_app
|
|
26
|
+
from kctl_pg.commands.pgbouncer import app as pgbouncer_app
|
|
27
|
+
from kctl_pg.commands.pipeline import app as pipeline_app
|
|
28
|
+
from kctl_pg.commands.query import app as query_app
|
|
29
|
+
from kctl_pg.commands.replication import app as replication_app
|
|
30
|
+
from kctl_pg.commands.schemas import app as schemas_app
|
|
31
|
+
from kctl_pg.commands.security import app as security_app
|
|
32
|
+
from kctl_pg.commands.stats import app as stats_app
|
|
33
|
+
from kctl_pg.commands.tables import app as tables_app
|
|
34
|
+
from kctl_pg.commands.users import app as users_app
|
|
35
|
+
from kctl_pg.core.callbacks import AppContext
|
|
36
|
+
from kctl_pg.core.exceptions import KctlError
|
|
37
|
+
from kctl_pg.commands.skill_cmd import app as skill_app
|
|
38
|
+
from kctl_pg.commands.doctor_cmd import app as doctor_app
|
|
39
|
+
from kctl_lib.self_update import notify_if_outdated
|
|
40
|
+
from kctl_lib.tui import add_tui_command
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def version_callback(value: bool) -> None:
|
|
44
|
+
if value:
|
|
45
|
+
typer.echo(f"kctl-pg {__version__}")
|
|
46
|
+
raise typer.Exit()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
app = typer.Typer(
|
|
50
|
+
name="kctl-pg",
|
|
51
|
+
help="Kodemeio PostgreSQL CLI - manage PostgreSQL servers via SSH tunnel.",
|
|
52
|
+
no_args_is_help=True,
|
|
53
|
+
rich_markup_mode="rich",
|
|
54
|
+
pretty_exceptions_enable=False,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.callback()
|
|
59
|
+
def main(
|
|
60
|
+
ctx: typer.Context,
|
|
61
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
62
|
+
quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
|
|
63
|
+
profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile name")] = None,
|
|
64
|
+
host: Annotated[str | None, typer.Option("--host", "-H", help="PostgreSQL host override")] = None,
|
|
65
|
+
port: Annotated[int | None, typer.Option("--port", help="PostgreSQL port override")] = None,
|
|
66
|
+
user: Annotated[str | None, typer.Option("--user", "-U", help="PostgreSQL user override")] = None,
|
|
67
|
+
password: Annotated[str | None, typer.Option("--password", help="PostgreSQL password override")] = None,
|
|
68
|
+
version: Annotated[
|
|
69
|
+
bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version")
|
|
70
|
+
] = False,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Kodemeio PostgreSQL CLI."""
|
|
73
|
+
ctx.ensure_object(dict)
|
|
74
|
+
ctx.obj = AppContext(
|
|
75
|
+
json_mode=json_output,
|
|
76
|
+
quiet=quiet,
|
|
77
|
+
profile=profile,
|
|
78
|
+
host_override=host,
|
|
79
|
+
port_override=port,
|
|
80
|
+
user_override=user,
|
|
81
|
+
password_override=password,
|
|
82
|
+
)
|
|
83
|
+
notify_if_outdated(ctx.obj.output, "kctl-pg", __version__)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Register all command groups
|
|
87
|
+
app.add_typer(config_app, name="config")
|
|
88
|
+
app.add_typer(db_app, name="db")
|
|
89
|
+
app.add_typer(users_app, name="users")
|
|
90
|
+
app.add_typer(health_app, name="health")
|
|
91
|
+
app.add_typer(dashboard_app, name="dashboard")
|
|
92
|
+
app.add_typer(query_app, name="query")
|
|
93
|
+
app.add_typer(activity_app, name="activity")
|
|
94
|
+
app.add_typer(backup_app, name="backup")
|
|
95
|
+
app.add_typer(extensions_app, name="extensions")
|
|
96
|
+
app.add_typer(maintenance_app, name="maintenance")
|
|
97
|
+
app.add_typer(performance_app, name="performance")
|
|
98
|
+
app.add_typer(pg_config_app, name="pg-config")
|
|
99
|
+
app.add_typer(replication_app, name="replication")
|
|
100
|
+
app.add_typer(security_app, name="security")
|
|
101
|
+
app.add_typer(stats_app, name="stats")
|
|
102
|
+
app.add_typer(tables_app, name="tables")
|
|
103
|
+
app.add_typer(indexes_app, name="indexes")
|
|
104
|
+
app.add_typer(schemas_app, name="schemas")
|
|
105
|
+
app.add_typer(pgbouncer_app, name="pgbouncer")
|
|
106
|
+
app.add_typer(lint_app, name="lint")
|
|
107
|
+
app.add_typer(automation_app, name="automation")
|
|
108
|
+
app.add_typer(dr_app, name="dr")
|
|
109
|
+
app.add_typer(pipeline_app, name="pipeline")
|
|
110
|
+
app.add_typer(skill_app, name="skill", hidden=True)
|
|
111
|
+
app.add_typer(doctor_app, name="doctor")
|
|
112
|
+
add_tui_command(app, service_key="pg", version=__version__)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command("self-update")
|
|
116
|
+
def self_update_cmd(ctx: typer.Context) -> None:
|
|
117
|
+
"""Check for updates and upgrade kctl-pg."""
|
|
118
|
+
actx = ctx.obj
|
|
119
|
+
out = actx.output
|
|
120
|
+
|
|
121
|
+
from kctl_lib.self_update import check_update
|
|
122
|
+
from kctl_lib.self_update import update as do_update
|
|
123
|
+
|
|
124
|
+
latest = check_update("kctl-pg", __version__)
|
|
125
|
+
if latest:
|
|
126
|
+
out.info(f"Updating to {latest}...")
|
|
127
|
+
do_update("kctl-pg")
|
|
128
|
+
out.success(f"Updated to {latest}")
|
|
129
|
+
else:
|
|
130
|
+
out.success("Already up to date")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.command()
|
|
134
|
+
def completions(
|
|
135
|
+
shell: Annotated[str, typer.Argument(help="Shell type: zsh, bash, fish")] = "zsh",
|
|
136
|
+
install: Annotated[bool, typer.Option("--install", help="Install completions")] = False,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Generate or install shell completions."""
|
|
139
|
+
from kctl_lib.completions import get_completion_script, install_completions
|
|
140
|
+
|
|
141
|
+
if install:
|
|
142
|
+
path = install_completions("kctl-pg", shell)
|
|
143
|
+
if path:
|
|
144
|
+
typer.echo(f"Completions installed to {path}")
|
|
145
|
+
else:
|
|
146
|
+
typer.echo(f"Could not install completions for {shell}", err=True)
|
|
147
|
+
raise typer.Exit(code=1)
|
|
148
|
+
else:
|
|
149
|
+
script = get_completion_script("kctl-pg", shell)
|
|
150
|
+
typer.echo(script)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _run() -> None:
|
|
154
|
+
"""Entry point with error handling."""
|
|
155
|
+
try:
|
|
156
|
+
app()
|
|
157
|
+
except KctlError as e:
|
|
158
|
+
handle_cli_error(e)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
_run()
|
|
File without changes
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Connection and activity monitoring commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kctl_pg.core.callbacks import AppContext
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Monitor connections and activity.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("list")
|
|
15
|
+
def list_(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
database: Annotated[str | None, typer.Option("--db", "-d", help="Filter by database")] = None,
|
|
18
|
+
state: Annotated[
|
|
19
|
+
str | None, typer.Option("--state", help="Filter by state (active, idle, idle in transaction)")
|
|
20
|
+
] = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""List active connections."""
|
|
23
|
+
actx: AppContext = ctx.obj
|
|
24
|
+
out = actx.output
|
|
25
|
+
c = actx.client
|
|
26
|
+
|
|
27
|
+
sql = """
|
|
28
|
+
SELECT
|
|
29
|
+
pid,
|
|
30
|
+
datname AS database,
|
|
31
|
+
usename AS user,
|
|
32
|
+
client_addr,
|
|
33
|
+
state,
|
|
34
|
+
now() - backend_start AS backend_age,
|
|
35
|
+
now() - query_start AS query_duration,
|
|
36
|
+
left(query, 100) AS query
|
|
37
|
+
FROM pg_stat_activity
|
|
38
|
+
WHERE backend_type = 'client backend'
|
|
39
|
+
"""
|
|
40
|
+
params: list = []
|
|
41
|
+
|
|
42
|
+
if database:
|
|
43
|
+
sql += " AND datname = %s"
|
|
44
|
+
params.append(database)
|
|
45
|
+
if state:
|
|
46
|
+
sql += " AND state = %s"
|
|
47
|
+
params.append(state)
|
|
48
|
+
|
|
49
|
+
sql += " ORDER BY backend_start"
|
|
50
|
+
|
|
51
|
+
rows_data = c.fetchall(sql, tuple(params) if params else None)
|
|
52
|
+
|
|
53
|
+
rows = [
|
|
54
|
+
[
|
|
55
|
+
str(r["pid"]),
|
|
56
|
+
r["database"] or "-",
|
|
57
|
+
r["user"] or "-",
|
|
58
|
+
str(r["client_addr"]) if r["client_addr"] else "-",
|
|
59
|
+
r["state"] or "-",
|
|
60
|
+
str(r["query_duration"]).split(".")[0] if r["query_duration"] else "-",
|
|
61
|
+
r["query"][:60] if r["query"] else "-",
|
|
62
|
+
]
|
|
63
|
+
for r in rows_data
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
out.table(
|
|
67
|
+
f"Connections ({len(rows_data)})",
|
|
68
|
+
[
|
|
69
|
+
("PID", ""),
|
|
70
|
+
("Database", "cyan"),
|
|
71
|
+
("User", ""),
|
|
72
|
+
("Client", ""),
|
|
73
|
+
("State", ""),
|
|
74
|
+
("Duration", "yellow"),
|
|
75
|
+
("Query", "dim"),
|
|
76
|
+
],
|
|
77
|
+
rows,
|
|
78
|
+
data_for_json=rows_data,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def kill(
|
|
84
|
+
ctx: typer.Context,
|
|
85
|
+
pid: Annotated[int, typer.Argument(help="Backend PID to terminate")],
|
|
86
|
+
force: Annotated[
|
|
87
|
+
bool, typer.Option("--force", help="Use pg_terminate_backend (instead of pg_cancel_backend)")
|
|
88
|
+
] = False,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Cancel or terminate a backend process."""
|
|
91
|
+
actx: AppContext = ctx.obj
|
|
92
|
+
out = actx.output
|
|
93
|
+
c = actx.client
|
|
94
|
+
|
|
95
|
+
if force:
|
|
96
|
+
result = c.fetchval("SELECT pg_terminate_backend(%s)", (pid,))
|
|
97
|
+
action = "Terminated"
|
|
98
|
+
else:
|
|
99
|
+
result = c.fetchval("SELECT pg_cancel_backend(%s)", (pid,))
|
|
100
|
+
action = "Cancelled"
|
|
101
|
+
|
|
102
|
+
if result:
|
|
103
|
+
out.success(f"{action} backend PID {pid}")
|
|
104
|
+
else:
|
|
105
|
+
out.error(f"Failed to {action.lower()} PID {pid} (process may not exist)")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command()
|
|
109
|
+
def locks(
|
|
110
|
+
ctx: typer.Context,
|
|
111
|
+
database: Annotated[str | None, typer.Option("--db", "-d", help="Filter by database")] = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Show lock contention (blocked queries waiting for locks)."""
|
|
114
|
+
actx: AppContext = ctx.obj
|
|
115
|
+
out = actx.output
|
|
116
|
+
c = actx.client
|
|
117
|
+
|
|
118
|
+
rows_data = c.fetchall("""
|
|
119
|
+
SELECT
|
|
120
|
+
blocked.pid AS blocked_pid,
|
|
121
|
+
blocked.usename AS blocked_user,
|
|
122
|
+
blocked_activity.query AS blocked_query,
|
|
123
|
+
blocking.pid AS blocking_pid,
|
|
124
|
+
blocking.usename AS blocking_user,
|
|
125
|
+
blocking_activity.query AS blocking_query,
|
|
126
|
+
blocked.datname AS database
|
|
127
|
+
FROM pg_catalog.pg_locks blocked
|
|
128
|
+
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked.pid
|
|
129
|
+
JOIN pg_catalog.pg_locks blocking ON blocking.locktype = blocked.locktype
|
|
130
|
+
AND blocking.database IS NOT DISTINCT FROM blocked.database
|
|
131
|
+
AND blocking.relation IS NOT DISTINCT FROM blocked.relation
|
|
132
|
+
AND blocking.page IS NOT DISTINCT FROM blocked.page
|
|
133
|
+
AND blocking.tuple IS NOT DISTINCT FROM blocked.tuple
|
|
134
|
+
AND blocking.virtualxid IS NOT DISTINCT FROM blocked.virtualxid
|
|
135
|
+
AND blocking.transactionid IS NOT DISTINCT FROM blocked.transactionid
|
|
136
|
+
AND blocking.classid IS NOT DISTINCT FROM blocked.classid
|
|
137
|
+
AND blocking.objid IS NOT DISTINCT FROM blocked.objid
|
|
138
|
+
AND blocking.objsubid IS NOT DISTINCT FROM blocked.objsubid
|
|
139
|
+
AND blocking.pid != blocked.pid
|
|
140
|
+
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking.pid
|
|
141
|
+
JOIN pg_stat_activity blocked_sa ON blocked_sa.pid = blocked.pid
|
|
142
|
+
WHERE NOT blocked.granted
|
|
143
|
+
""")
|
|
144
|
+
|
|
145
|
+
if database:
|
|
146
|
+
rows_data = [r for r in rows_data if r["database"] == database]
|
|
147
|
+
|
|
148
|
+
if not rows_data:
|
|
149
|
+
out.success("No lock contention detected")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
rows = [
|
|
153
|
+
[
|
|
154
|
+
str(r["blocked_pid"]),
|
|
155
|
+
r["blocked_user"] or "-",
|
|
156
|
+
(r["blocked_query"] or "-")[:50],
|
|
157
|
+
str(r["blocking_pid"]),
|
|
158
|
+
r["blocking_user"] or "-",
|
|
159
|
+
(r["blocking_query"] or "-")[:50],
|
|
160
|
+
]
|
|
161
|
+
for r in rows_data
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
out.table(
|
|
165
|
+
f"Lock Contention ({len(rows_data)})",
|
|
166
|
+
[
|
|
167
|
+
("Blocked PID", "red"),
|
|
168
|
+
("User", ""),
|
|
169
|
+
("Blocked Query", "dim"),
|
|
170
|
+
("Blocking PID", "yellow"),
|
|
171
|
+
("User", ""),
|
|
172
|
+
("Blocking Query", "dim"),
|
|
173
|
+
],
|
|
174
|
+
rows,
|
|
175
|
+
data_for_json=rows_data,
|
|
176
|
+
)
|