kctl-api 0.2.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.
- kctl_api/__init__.py +3 -0
- kctl_api/__main__.py +5 -0
- kctl_api/cli.py +238 -0
- kctl_api/commands/__init__.py +1 -0
- kctl_api/commands/ai.py +250 -0
- kctl_api/commands/aliases.py +84 -0
- kctl_api/commands/apps.py +172 -0
- kctl_api/commands/auth.py +313 -0
- kctl_api/commands/automation.py +242 -0
- kctl_api/commands/build_cmd.py +87 -0
- kctl_api/commands/clean.py +182 -0
- kctl_api/commands/config_cmd.py +443 -0
- kctl_api/commands/dashboard.py +139 -0
- kctl_api/commands/db.py +599 -0
- kctl_api/commands/deploy.py +84 -0
- kctl_api/commands/deps.py +289 -0
- kctl_api/commands/dev.py +136 -0
- kctl_api/commands/docker_cmd.py +252 -0
- kctl_api/commands/doctor_cmd.py +286 -0
- kctl_api/commands/env.py +289 -0
- kctl_api/commands/files.py +250 -0
- kctl_api/commands/fmt_cmd.py +58 -0
- kctl_api/commands/health.py +479 -0
- kctl_api/commands/jobs.py +169 -0
- kctl_api/commands/lint_cmd.py +81 -0
- kctl_api/commands/logs.py +258 -0
- kctl_api/commands/marketplace.py +316 -0
- kctl_api/commands/monitor_cmd.py +243 -0
- kctl_api/commands/notifications.py +132 -0
- kctl_api/commands/odoo_proxy.py +182 -0
- kctl_api/commands/openapi.py +299 -0
- kctl_api/commands/perf.py +307 -0
- kctl_api/commands/rate_limit.py +223 -0
- kctl_api/commands/realtime.py +100 -0
- kctl_api/commands/redis_cmd.py +609 -0
- kctl_api/commands/routes_cmd.py +277 -0
- kctl_api/commands/saas.py +145 -0
- kctl_api/commands/scaffold.py +362 -0
- kctl_api/commands/security_cmd.py +350 -0
- kctl_api/commands/services.py +191 -0
- kctl_api/commands/shell.py +197 -0
- kctl_api/commands/skill_cmd.py +58 -0
- kctl_api/commands/streams.py +309 -0
- kctl_api/commands/stripe_cmd.py +105 -0
- kctl_api/commands/tenant_ai.py +169 -0
- kctl_api/commands/test_cmd.py +95 -0
- kctl_api/commands/users.py +302 -0
- kctl_api/commands/webhooks.py +56 -0
- kctl_api/commands/workflows.py +127 -0
- kctl_api/commands/ws.py +323 -0
- kctl_api/core/__init__.py +1 -0
- kctl_api/core/async_client.py +120 -0
- kctl_api/core/callbacks.py +88 -0
- kctl_api/core/client.py +190 -0
- kctl_api/core/config.py +260 -0
- kctl_api/core/db.py +65 -0
- kctl_api/core/exceptions.py +43 -0
- kctl_api/core/output.py +5 -0
- kctl_api/core/plugins.py +26 -0
- kctl_api/core/redis.py +35 -0
- kctl_api/core/resolve.py +47 -0
- kctl_api/core/utils.py +109 -0
- kctl_api-0.2.0.dist-info/METADATA +34 -0
- kctl_api-0.2.0.dist-info/RECORD +66 -0
- kctl_api-0.2.0.dist-info/WHEEL +4 -0
- kctl_api-0.2.0.dist-info/entry_points.txt +2 -0
kctl_api/commands/db.py
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
"""Database management commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Direct DB queries, Alembic migrations, psql shell, seeding, and analysis.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from kctl_api.core.callbacks import AppContext
|
|
16
|
+
from kctl_api.core.utils import find_project_root
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(name="db", help="Database management — tables, migrations, queries, shell.", no_args_is_help=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# tables
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
@app.command()
|
|
25
|
+
def tables(ctx: typer.Context) -> None:
|
|
26
|
+
"""List database tables via direct async query."""
|
|
27
|
+
actx: AppContext = ctx.obj
|
|
28
|
+
out = actx.output
|
|
29
|
+
|
|
30
|
+
db_url = actx.database_url
|
|
31
|
+
if not db_url:
|
|
32
|
+
out.error("No database_url configured.")
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
|
|
35
|
+
async def _run() -> list[dict]:
|
|
36
|
+
from kctl_api.core.db import dispose_engine, execute_query
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
rows = await execute_query(
|
|
40
|
+
db_url,
|
|
41
|
+
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name",
|
|
42
|
+
)
|
|
43
|
+
return rows
|
|
44
|
+
finally:
|
|
45
|
+
await dispose_engine()
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
rows = asyncio.run(_run())
|
|
49
|
+
except ImportError as e:
|
|
50
|
+
out.error(str(e))
|
|
51
|
+
raise typer.Exit(1) from None
|
|
52
|
+
except Exception as e:
|
|
53
|
+
out.error(f"Query failed: {e}")
|
|
54
|
+
raise typer.Exit(1) from None
|
|
55
|
+
|
|
56
|
+
table_rows: list[list[str]] = [[r.get("table_name", "")] for r in rows]
|
|
57
|
+
|
|
58
|
+
out.table(
|
|
59
|
+
title=f"Database Tables ({len(rows)})",
|
|
60
|
+
columns=[("Table", "bold")],
|
|
61
|
+
rows=table_rows,
|
|
62
|
+
data_for_json=rows,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# count
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
@app.command()
|
|
70
|
+
def count(
|
|
71
|
+
ctx: typer.Context,
|
|
72
|
+
table: Annotated[str, typer.Argument(help="Table name.")],
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Count rows in a table via direct async query."""
|
|
75
|
+
actx: AppContext = ctx.obj
|
|
76
|
+
out = actx.output
|
|
77
|
+
|
|
78
|
+
db_url = actx.database_url
|
|
79
|
+
if not db_url:
|
|
80
|
+
out.error("No database_url configured.")
|
|
81
|
+
raise typer.Exit(1)
|
|
82
|
+
|
|
83
|
+
async def _run() -> list[dict]:
|
|
84
|
+
from kctl_api.core.db import dispose_engine, execute_query, validate_identifier
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
safe_table = validate_identifier(table)
|
|
88
|
+
return await execute_query(db_url, f"SELECT COUNT(*) AS cnt FROM {safe_table}")
|
|
89
|
+
finally:
|
|
90
|
+
await dispose_engine()
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
rows = asyncio.run(_run())
|
|
94
|
+
except Exception as e:
|
|
95
|
+
out.error(f"Query failed: {e}")
|
|
96
|
+
raise typer.Exit(1) from None
|
|
97
|
+
|
|
98
|
+
cnt = rows[0].get("cnt", 0) if rows else 0
|
|
99
|
+
out.success(f"{table}: {cnt} rows")
|
|
100
|
+
if actx.json_mode:
|
|
101
|
+
out.raw_json({"table": table, "count": cnt})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# migrate
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
@app.command()
|
|
108
|
+
def migrate(ctx: typer.Context) -> None:
|
|
109
|
+
"""Run Alembic upgrade head via scripts/db migrate."""
|
|
110
|
+
actx: AppContext = ctx.obj
|
|
111
|
+
out = actx.output
|
|
112
|
+
|
|
113
|
+
root = find_project_root()
|
|
114
|
+
script = root / "scripts" / "db"
|
|
115
|
+
|
|
116
|
+
out.info("Running database migration ...")
|
|
117
|
+
result = subprocess.run([str(script), "migrate"], cwd=str(root), capture_output=False)
|
|
118
|
+
if result.returncode != 0:
|
|
119
|
+
out.error("Migration failed.")
|
|
120
|
+
raise typer.Exit(result.returncode)
|
|
121
|
+
out.success("Migration complete.")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# generate
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
@app.command()
|
|
128
|
+
def generate(
|
|
129
|
+
ctx: typer.Context,
|
|
130
|
+
message: Annotated[str, typer.Argument(help="Migration revision message.")],
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Generate a new Alembic migration via scripts/db generate."""
|
|
133
|
+
actx: AppContext = ctx.obj
|
|
134
|
+
out = actx.output
|
|
135
|
+
|
|
136
|
+
root = find_project_root()
|
|
137
|
+
script = root / "scripts" / "db"
|
|
138
|
+
|
|
139
|
+
out.info(f"Generating migration: {message}")
|
|
140
|
+
result = subprocess.run([str(script), "generate", message], cwd=str(root), capture_output=False)
|
|
141
|
+
if result.returncode != 0:
|
|
142
|
+
out.error("Generation failed.")
|
|
143
|
+
raise typer.Exit(result.returncode)
|
|
144
|
+
out.success("Migration generated.")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# history
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
@app.command()
|
|
151
|
+
def history(ctx: typer.Context) -> None:
|
|
152
|
+
"""Show Alembic migration history via scripts/db history."""
|
|
153
|
+
root = find_project_root()
|
|
154
|
+
script = root / "scripts" / "db"
|
|
155
|
+
subprocess.run([str(script), "history"], cwd=str(root), capture_output=False)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# downgrade
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
@app.command()
|
|
162
|
+
def downgrade(
|
|
163
|
+
ctx: typer.Context,
|
|
164
|
+
revision: Annotated[str, typer.Argument(help="Target revision (or '-1' for one step back).")] = "-1",
|
|
165
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation.")] = False,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Downgrade database to a previous migration revision."""
|
|
168
|
+
actx: AppContext = ctx.obj
|
|
169
|
+
out = actx.output
|
|
170
|
+
|
|
171
|
+
if not force:
|
|
172
|
+
confirm = typer.confirm(f"Downgrade to revision '{revision}'?", default=False)
|
|
173
|
+
if not confirm:
|
|
174
|
+
out.info("Cancelled.")
|
|
175
|
+
raise typer.Exit(0)
|
|
176
|
+
|
|
177
|
+
root = find_project_root()
|
|
178
|
+
script = root / "scripts" / "db"
|
|
179
|
+
|
|
180
|
+
out.info(f"Downgrading to {revision} ...")
|
|
181
|
+
result = subprocess.run([str(script), "downgrade", revision], cwd=str(root), capture_output=False)
|
|
182
|
+
if result.returncode != 0:
|
|
183
|
+
out.error("Downgrade failed.")
|
|
184
|
+
raise typer.Exit(result.returncode)
|
|
185
|
+
out.success("Downgrade complete.")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# shell
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
@app.command()
|
|
192
|
+
def shell(ctx: typer.Context) -> None:
|
|
193
|
+
"""Open an interactive psql shell to the configured database."""
|
|
194
|
+
actx: AppContext = ctx.obj
|
|
195
|
+
out = actx.output
|
|
196
|
+
|
|
197
|
+
db_url = actx.database_url
|
|
198
|
+
if not db_url:
|
|
199
|
+
out.error("No database_url configured.")
|
|
200
|
+
raise typer.Exit(1)
|
|
201
|
+
|
|
202
|
+
# Convert async URL to sync for psql
|
|
203
|
+
psql_url = db_url.replace("postgresql+asyncpg://", "postgresql://")
|
|
204
|
+
|
|
205
|
+
out.info("Opening psql shell ...")
|
|
206
|
+
result = subprocess.run(["psql", psql_url], capture_output=False)
|
|
207
|
+
sys.exit(result.returncode)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# query
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
@app.command()
|
|
214
|
+
def query(
|
|
215
|
+
ctx: typer.Context,
|
|
216
|
+
sql: Annotated[str, typer.Argument(help="SQL query to execute.")],
|
|
217
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation for write queries.")] = False,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Execute a raw SQL query via async engine."""
|
|
220
|
+
actx: AppContext = ctx.obj
|
|
221
|
+
out = actx.output
|
|
222
|
+
|
|
223
|
+
db_url = actx.database_url
|
|
224
|
+
if not db_url:
|
|
225
|
+
out.error("No database_url configured.")
|
|
226
|
+
raise typer.Exit(1)
|
|
227
|
+
|
|
228
|
+
# Warn on write queries
|
|
229
|
+
sql_upper = sql.strip().upper()
|
|
230
|
+
is_write = any(sql_upper.startswith(kw) for kw in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE"))
|
|
231
|
+
if is_write and not force:
|
|
232
|
+
confirm = typer.confirm("This looks like a write query. Proceed?", default=False)
|
|
233
|
+
if not confirm:
|
|
234
|
+
out.info("Cancelled.")
|
|
235
|
+
raise typer.Exit(0)
|
|
236
|
+
|
|
237
|
+
async def _run() -> list[dict]:
|
|
238
|
+
from kctl_api.core.db import dispose_engine, execute_query
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
return await execute_query(db_url, sql)
|
|
242
|
+
finally:
|
|
243
|
+
await dispose_engine()
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
rows = asyncio.run(_run())
|
|
247
|
+
except Exception as e:
|
|
248
|
+
out.error(f"Query failed: {e}")
|
|
249
|
+
raise typer.Exit(1) from None
|
|
250
|
+
|
|
251
|
+
if not rows:
|
|
252
|
+
out.info("Query returned no rows.")
|
|
253
|
+
if actx.json_mode:
|
|
254
|
+
out.raw_json([])
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
# Auto-detect columns from first row
|
|
258
|
+
cols = list(rows[0].keys())
|
|
259
|
+
table_rows = [[str(r.get(c, "")) for c in cols] for r in rows]
|
|
260
|
+
|
|
261
|
+
out.table(
|
|
262
|
+
title=f"Query Results ({len(rows)} rows)",
|
|
263
|
+
columns=[(c, "") for c in cols],
|
|
264
|
+
rows=table_rows,
|
|
265
|
+
data_for_json=rows,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# seed
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
@app.command()
|
|
273
|
+
def seed(
|
|
274
|
+
ctx: typer.Context,
|
|
275
|
+
app_name: Annotated[str | None, typer.Argument(help="App name to seed (default: api-main).")] = None,
|
|
276
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation.")] = False,
|
|
277
|
+
) -> None:
|
|
278
|
+
"""Seed the database with test/dev data via scripts/db seed."""
|
|
279
|
+
actx: AppContext = ctx.obj
|
|
280
|
+
out = actx.output
|
|
281
|
+
|
|
282
|
+
if not force:
|
|
283
|
+
confirm = typer.confirm("Seed the database with test data?", default=False)
|
|
284
|
+
if not confirm:
|
|
285
|
+
out.info("Cancelled.")
|
|
286
|
+
raise typer.Exit(0)
|
|
287
|
+
|
|
288
|
+
root = find_project_root()
|
|
289
|
+
script = root / "scripts" / "db"
|
|
290
|
+
cmd = [str(script), "seed"]
|
|
291
|
+
if app_name:
|
|
292
|
+
cmd.append(app_name)
|
|
293
|
+
|
|
294
|
+
out.info(f"Seeding database{f' ({app_name})' if app_name else ''} ...")
|
|
295
|
+
result = subprocess.run(cmd, cwd=str(root), capture_output=False)
|
|
296
|
+
if result.returncode != 0:
|
|
297
|
+
out.error("Seeding failed.")
|
|
298
|
+
raise typer.Exit(result.returncode)
|
|
299
|
+
out.success("Database seeded.")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
# reset
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
@app.command()
|
|
306
|
+
def reset(
|
|
307
|
+
ctx: typer.Context,
|
|
308
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation.")] = False,
|
|
309
|
+
skip_seed: Annotated[bool, typer.Option("--no-seed", help="Skip seeding after reset.")] = False,
|
|
310
|
+
) -> None:
|
|
311
|
+
"""Drop + recreate + migrate + seed the database."""
|
|
312
|
+
actx: AppContext = ctx.obj
|
|
313
|
+
out = actx.output
|
|
314
|
+
|
|
315
|
+
if not force:
|
|
316
|
+
confirm = typer.confirm(
|
|
317
|
+
"[red]WARNING[/red]: This will DROP and recreate the database. Proceed?",
|
|
318
|
+
default=False,
|
|
319
|
+
)
|
|
320
|
+
if not confirm:
|
|
321
|
+
out.info("Cancelled.")
|
|
322
|
+
raise typer.Exit(0)
|
|
323
|
+
|
|
324
|
+
root = find_project_root()
|
|
325
|
+
script = root / "scripts" / "db"
|
|
326
|
+
|
|
327
|
+
# Drop and recreate
|
|
328
|
+
out.info("Dropping and recreating database ...")
|
|
329
|
+
result = subprocess.run([str(script), "reset"], cwd=str(root), capture_output=False)
|
|
330
|
+
if result.returncode != 0:
|
|
331
|
+
# Try migrate as fallback
|
|
332
|
+
out.warn("Reset script not found, running migrate ...")
|
|
333
|
+
result = subprocess.run([str(script), "migrate"], cwd=str(root), capture_output=False)
|
|
334
|
+
|
|
335
|
+
if result.returncode != 0:
|
|
336
|
+
out.error("Reset failed.")
|
|
337
|
+
raise typer.Exit(result.returncode)
|
|
338
|
+
|
|
339
|
+
if not skip_seed:
|
|
340
|
+
out.info("Seeding database ...")
|
|
341
|
+
subprocess.run([str(script), "seed"], cwd=str(root), capture_output=False)
|
|
342
|
+
|
|
343
|
+
out.success("Database reset complete.")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ---------------------------------------------------------------------------
|
|
347
|
+
# check-safety
|
|
348
|
+
# ---------------------------------------------------------------------------
|
|
349
|
+
@app.command(name="check-safety")
|
|
350
|
+
def check_safety(ctx: typer.Context) -> None:
|
|
351
|
+
"""Analyze pending Alembic migrations for destructive operations."""
|
|
352
|
+
actx: AppContext = ctx.obj
|
|
353
|
+
out = actx.output
|
|
354
|
+
|
|
355
|
+
root = find_project_root()
|
|
356
|
+
|
|
357
|
+
# Find migration files
|
|
358
|
+
|
|
359
|
+
migration_dirs = list(root.rglob("versions"))
|
|
360
|
+
if not migration_dirs:
|
|
361
|
+
out.warn("No Alembic versions directory found.")
|
|
362
|
+
raise typer.Exit(1)
|
|
363
|
+
|
|
364
|
+
destructive_patterns = [
|
|
365
|
+
("drop_table", "Table deletion"),
|
|
366
|
+
("drop_column", "Column deletion"),
|
|
367
|
+
("drop_index", "Index deletion"),
|
|
368
|
+
("drop_constraint", "Constraint deletion"),
|
|
369
|
+
("execute.*DROP", "Raw DROP statement"),
|
|
370
|
+
("execute.*TRUNCATE", "TRUNCATE statement"),
|
|
371
|
+
("execute.*DELETE", "DELETE without WHERE"),
|
|
372
|
+
]
|
|
373
|
+
|
|
374
|
+
import re
|
|
375
|
+
|
|
376
|
+
issues: list[dict] = []
|
|
377
|
+
for versions_dir in migration_dirs:
|
|
378
|
+
for migration_file in sorted(versions_dir.glob("*.py")):
|
|
379
|
+
content = migration_file.read_text(encoding="utf-8", errors="ignore")
|
|
380
|
+
# Only check upgrade() function
|
|
381
|
+
upgrade_match = re.search(r"def upgrade\(\):(.*?)(?=def downgrade|\Z)", content, re.DOTALL)
|
|
382
|
+
if not upgrade_match:
|
|
383
|
+
continue
|
|
384
|
+
upgrade_body = upgrade_match.group(1)
|
|
385
|
+
|
|
386
|
+
for pattern, label in destructive_patterns:
|
|
387
|
+
if re.search(pattern, upgrade_body, re.IGNORECASE):
|
|
388
|
+
issues.append(
|
|
389
|
+
{
|
|
390
|
+
"file": migration_file.name,
|
|
391
|
+
"operation": label,
|
|
392
|
+
"severity": "WARNING",
|
|
393
|
+
}
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if not issues:
|
|
397
|
+
out.success("No destructive operations found in pending migrations.")
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
rows = [[i["file"], i["operation"], i["severity"]] for i in issues]
|
|
401
|
+
out.table(
|
|
402
|
+
title=f"Destructive Migration Operations ({len(issues)})",
|
|
403
|
+
columns=[("File", "bold"), ("Operation", "red"), ("Severity", "yellow")],
|
|
404
|
+
rows=rows,
|
|
405
|
+
data_for_json=issues,
|
|
406
|
+
)
|
|
407
|
+
out.warn("Review these migrations carefully before applying to production.")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# ---------------------------------------------------------------------------
|
|
411
|
+
# size
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
@app.command()
|
|
414
|
+
def size(ctx: typer.Context) -> None:
|
|
415
|
+
"""Show database size per table."""
|
|
416
|
+
actx: AppContext = ctx.obj
|
|
417
|
+
out = actx.output
|
|
418
|
+
|
|
419
|
+
db_url = actx.database_url
|
|
420
|
+
if not db_url:
|
|
421
|
+
out.error("No database_url configured.")
|
|
422
|
+
raise typer.Exit(1)
|
|
423
|
+
|
|
424
|
+
async def _run() -> list[dict]:
|
|
425
|
+
from kctl_api.core.db import dispose_engine, execute_query
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
return await execute_query(
|
|
429
|
+
db_url,
|
|
430
|
+
"""
|
|
431
|
+
SELECT
|
|
432
|
+
table_name,
|
|
433
|
+
pg_size_pretty(pg_total_relation_size(quote_ident(table_name))) AS total_size,
|
|
434
|
+
pg_size_pretty(pg_relation_size(quote_ident(table_name))) AS table_size,
|
|
435
|
+
pg_size_pretty(
|
|
436
|
+
pg_total_relation_size(quote_ident(table_name))
|
|
437
|
+
- pg_relation_size(quote_ident(table_name))
|
|
438
|
+
) AS index_size,
|
|
439
|
+
(SELECT COUNT(*) FROM information_schema.columns
|
|
440
|
+
WHERE table_name = t.table_name
|
|
441
|
+
AND table_schema = 'public') AS columns
|
|
442
|
+
FROM information_schema.tables t
|
|
443
|
+
WHERE table_schema = 'public'
|
|
444
|
+
ORDER BY pg_total_relation_size(quote_ident(table_name)) DESC
|
|
445
|
+
""",
|
|
446
|
+
)
|
|
447
|
+
finally:
|
|
448
|
+
await dispose_engine()
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
rows_data = asyncio.run(_run())
|
|
452
|
+
except Exception as e:
|
|
453
|
+
out.error(f"Query failed: {e}")
|
|
454
|
+
raise typer.Exit(1) from None
|
|
455
|
+
|
|
456
|
+
rows = [
|
|
457
|
+
[r.get("table_name", ""), r.get("total_size", ""), r.get("table_size", ""), r.get("index_size", "")]
|
|
458
|
+
for r in rows_data
|
|
459
|
+
]
|
|
460
|
+
out.table(
|
|
461
|
+
title="Database Table Sizes",
|
|
462
|
+
columns=[("Table", "bold"), ("Total", ""), ("Data", ""), ("Indexes", "")],
|
|
463
|
+
rows=rows,
|
|
464
|
+
data_for_json=rows_data,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# ---------------------------------------------------------------------------
|
|
469
|
+
# connections
|
|
470
|
+
# ---------------------------------------------------------------------------
|
|
471
|
+
@app.command()
|
|
472
|
+
def connections(ctx: typer.Context) -> None:
|
|
473
|
+
"""Show active database connections."""
|
|
474
|
+
actx: AppContext = ctx.obj
|
|
475
|
+
out = actx.output
|
|
476
|
+
|
|
477
|
+
db_url = actx.database_url
|
|
478
|
+
if not db_url:
|
|
479
|
+
out.error("No database_url configured.")
|
|
480
|
+
raise typer.Exit(1)
|
|
481
|
+
|
|
482
|
+
async def _run() -> list[dict]:
|
|
483
|
+
from kctl_api.core.db import dispose_engine, execute_query
|
|
484
|
+
|
|
485
|
+
try:
|
|
486
|
+
return await execute_query(
|
|
487
|
+
db_url,
|
|
488
|
+
"""
|
|
489
|
+
SELECT
|
|
490
|
+
pid,
|
|
491
|
+
usename AS username,
|
|
492
|
+
application_name,
|
|
493
|
+
client_addr,
|
|
494
|
+
state,
|
|
495
|
+
wait_event_type,
|
|
496
|
+
wait_event,
|
|
497
|
+
LEFT(query, 80) AS query_preview
|
|
498
|
+
FROM pg_stat_activity
|
|
499
|
+
WHERE datname = current_database()
|
|
500
|
+
AND pid <> pg_backend_pid()
|
|
501
|
+
ORDER BY state, pid
|
|
502
|
+
""",
|
|
503
|
+
)
|
|
504
|
+
finally:
|
|
505
|
+
await dispose_engine()
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
rows_data = asyncio.run(_run())
|
|
509
|
+
except Exception as e:
|
|
510
|
+
out.error(f"Query failed: {e}")
|
|
511
|
+
raise typer.Exit(1) from None
|
|
512
|
+
|
|
513
|
+
rows = [
|
|
514
|
+
[
|
|
515
|
+
str(r.get("pid", "")),
|
|
516
|
+
r.get("username", ""),
|
|
517
|
+
r.get("application_name", ""),
|
|
518
|
+
r.get("state", ""),
|
|
519
|
+
r.get("query_preview", "")[:60],
|
|
520
|
+
]
|
|
521
|
+
for r in rows_data
|
|
522
|
+
]
|
|
523
|
+
out.table(
|
|
524
|
+
title=f"Active DB Connections ({len(rows_data)})",
|
|
525
|
+
columns=[("PID", ""), ("User", "bold"), ("App", ""), ("State", ""), ("Query", "dim")],
|
|
526
|
+
rows=rows,
|
|
527
|
+
data_for_json=rows_data,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# ---------------------------------------------------------------------------
|
|
532
|
+
# slow-queries
|
|
533
|
+
# ---------------------------------------------------------------------------
|
|
534
|
+
@app.command(name="slow-queries")
|
|
535
|
+
def slow_queries(
|
|
536
|
+
ctx: typer.Context,
|
|
537
|
+
min_ms: Annotated[int, typer.Option("--min-ms", help="Minimum mean time in ms to include.")] = 100,
|
|
538
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max queries to show.")] = 20,
|
|
539
|
+
) -> None:
|
|
540
|
+
"""Show slow queries from pg_stat_statements (requires pg_stat_statements extension)."""
|
|
541
|
+
actx: AppContext = ctx.obj
|
|
542
|
+
out = actx.output
|
|
543
|
+
|
|
544
|
+
db_url = actx.database_url
|
|
545
|
+
if not db_url:
|
|
546
|
+
out.error("No database_url configured.")
|
|
547
|
+
raise typer.Exit(1)
|
|
548
|
+
|
|
549
|
+
async def _run() -> list[dict]:
|
|
550
|
+
from kctl_api.core.db import dispose_engine, execute_query
|
|
551
|
+
|
|
552
|
+
try:
|
|
553
|
+
return await execute_query(
|
|
554
|
+
db_url,
|
|
555
|
+
f"""
|
|
556
|
+
SELECT
|
|
557
|
+
ROUND(mean_exec_time::numeric, 2) AS mean_ms,
|
|
558
|
+
calls,
|
|
559
|
+
ROUND(total_exec_time::numeric, 2) AS total_ms,
|
|
560
|
+
LEFT(query, 100) AS query_preview
|
|
561
|
+
FROM pg_stat_statements
|
|
562
|
+
WHERE mean_exec_time >= {min_ms}
|
|
563
|
+
ORDER BY mean_exec_time DESC
|
|
564
|
+
LIMIT {limit}
|
|
565
|
+
""",
|
|
566
|
+
)
|
|
567
|
+
finally:
|
|
568
|
+
await dispose_engine()
|
|
569
|
+
|
|
570
|
+
try:
|
|
571
|
+
rows_data = asyncio.run(_run())
|
|
572
|
+
except Exception as e:
|
|
573
|
+
if "pg_stat_statements" in str(e):
|
|
574
|
+
out.warn("pg_stat_statements extension not enabled.")
|
|
575
|
+
out.info("Enable it: CREATE EXTENSION IF NOT EXISTS pg_stat_statements;")
|
|
576
|
+
out.info("Also add to postgresql.conf: shared_preload_libraries = 'pg_stat_statements'")
|
|
577
|
+
raise typer.Exit(1) from None
|
|
578
|
+
out.error(f"Query failed: {e}")
|
|
579
|
+
raise typer.Exit(1) from None
|
|
580
|
+
|
|
581
|
+
if not rows_data:
|
|
582
|
+
out.success(f"No queries with mean time >= {min_ms}ms found.")
|
|
583
|
+
return
|
|
584
|
+
|
|
585
|
+
rows = [
|
|
586
|
+
[
|
|
587
|
+
f"{r.get('mean_ms', 0)}ms",
|
|
588
|
+
str(r.get("calls", "")),
|
|
589
|
+
f"{r.get('total_ms', 0)}ms",
|
|
590
|
+
r.get("query_preview", "")[:80],
|
|
591
|
+
]
|
|
592
|
+
for r in rows_data
|
|
593
|
+
]
|
|
594
|
+
out.table(
|
|
595
|
+
title=f"Slow Queries (mean >= {min_ms}ms)",
|
|
596
|
+
columns=[("Mean", "bold"), ("Calls", ""), ("Total", ""), ("Query", "dim")],
|
|
597
|
+
rows=rows,
|
|
598
|
+
data_for_json=rows_data,
|
|
599
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Deployment management commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Status, logs, restart, and rollback via Dokploy API (stubs).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from kctl_api.core.callbacks import AppContext
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(name="deploy", help="Deployment management — status, logs, restart, rollback.", no_args_is_help=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# status
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
@app.command()
|
|
21
|
+
def status(
|
|
22
|
+
ctx: typer.Context,
|
|
23
|
+
app_name: Annotated[str | None, typer.Argument(help="App name (all if omitted).")] = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Show deployment status (will use Dokploy API)."""
|
|
26
|
+
actx: AppContext = ctx.obj
|
|
27
|
+
out = actx.output
|
|
28
|
+
out.info(f"Not yet implemented. Will query Dokploy API for: {app_name or 'all apps'}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# logs
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
@app.command()
|
|
35
|
+
def logs(
|
|
36
|
+
ctx: typer.Context,
|
|
37
|
+
app_name: Annotated[str, typer.Argument(help="App name.")],
|
|
38
|
+
tail: Annotated[int, typer.Option("--tail", "-n", help="Number of lines.")] = 100,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""View deployment logs (will use Dokploy API)."""
|
|
41
|
+
actx: AppContext = ctx.obj
|
|
42
|
+
out = actx.output
|
|
43
|
+
out.info(f"Not yet implemented. Will fetch last {tail} log lines for: {app_name}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# restart
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
@app.command()
|
|
50
|
+
def restart(
|
|
51
|
+
ctx: typer.Context,
|
|
52
|
+
app_name: Annotated[str, typer.Argument(help="App name to restart.")],
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Restart a deployed app (will use Dokploy API)."""
|
|
55
|
+
actx: AppContext = ctx.obj
|
|
56
|
+
out = actx.output
|
|
57
|
+
out.info(f"Not yet implemented. Will restart deployment: {app_name}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# rollback
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
@app.command()
|
|
64
|
+
def rollback(
|
|
65
|
+
ctx: typer.Context,
|
|
66
|
+
app_name: Annotated[str, typer.Argument(help="App name to rollback.")],
|
|
67
|
+
revision: Annotated[str | None, typer.Option("--revision", "-r", help="Target revision.")] = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Rollback a deployment to a previous revision (will use Dokploy API)."""
|
|
70
|
+
actx: AppContext = ctx.obj
|
|
71
|
+
out = actx.output
|
|
72
|
+
rev_msg = f" to revision {revision}" if revision else " to previous revision"
|
|
73
|
+
out.info(f"Not yet implemented. Will rollback {app_name}{rev_msg}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# list
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
@app.command(name="list")
|
|
80
|
+
def list_deployments(ctx: typer.Context) -> None:
|
|
81
|
+
"""List all deployments (will use Dokploy API)."""
|
|
82
|
+
actx: AppContext = ctx.obj
|
|
83
|
+
out = actx.output
|
|
84
|
+
out.info("Not yet implemented. Will list all Dokploy deployments.")
|