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
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""Health check commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Check connectivity to api-main, ai-main, database, and Redis.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import contextlib
|
|
10
|
+
import time
|
|
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 service_status_color
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(name="health", help="Health checks for API services, database, and Redis.", no_args_is_help=True)
|
|
19
|
+
|
|
20
|
+
# Default watch interval in seconds
|
|
21
|
+
_DEFAULT_INTERVAL = 5
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Helpers
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _check_api(actx: AppContext) -> dict:
|
|
30
|
+
"""Check api-main health via HTTP client."""
|
|
31
|
+
try:
|
|
32
|
+
healthy, details = actx.client.health_check()
|
|
33
|
+
return {"service": "api-main", "healthy": healthy, "status": details.get("status", "unknown"), **details}
|
|
34
|
+
except Exception as e:
|
|
35
|
+
return {"service": "api-main", "healthy": False, "status": "error", "error": str(e)}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check_ai(actx: AppContext) -> dict:
|
|
39
|
+
"""Check ai-main health via HTTP client."""
|
|
40
|
+
try:
|
|
41
|
+
data = actx.ai_client.get("/api/v1/ai/health")
|
|
42
|
+
healthy = data.get("status") == "ok" if data else False
|
|
43
|
+
return {"service": "ai-main", "healthy": healthy, "status": data.get("status", "unknown"), **(data or {})}
|
|
44
|
+
except Exception as e:
|
|
45
|
+
return {"service": "ai-main", "healthy": False, "status": "error", "error": str(e)}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _check_db_async(actx: AppContext) -> dict:
|
|
49
|
+
"""Check database connectivity with SELECT 1."""
|
|
50
|
+
from kctl_api.core.db import dispose_engine, execute_query
|
|
51
|
+
|
|
52
|
+
db_url = actx.database_url
|
|
53
|
+
if not db_url:
|
|
54
|
+
return {"service": "database", "healthy": False, "status": "error", "error": "No database_url configured."}
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
rows = await execute_query(db_url, "SELECT 1 AS ping")
|
|
58
|
+
await dispose_engine()
|
|
59
|
+
ok = len(rows) > 0 and rows[0].get("ping") == 1
|
|
60
|
+
return {"service": "database", "healthy": ok, "status": "ok" if ok else "error"}
|
|
61
|
+
except ImportError as e:
|
|
62
|
+
return {"service": "database", "healthy": False, "status": "error", "error": str(e)}
|
|
63
|
+
except Exception as e:
|
|
64
|
+
with contextlib.suppress(Exception):
|
|
65
|
+
await dispose_engine()
|
|
66
|
+
return {"service": "database", "healthy": False, "status": "error", "error": str(e)}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _check_redis_async(actx: AppContext) -> dict:
|
|
70
|
+
"""Check Redis connectivity with PING."""
|
|
71
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
72
|
+
|
|
73
|
+
redis_url = actx.redis_url
|
|
74
|
+
if not redis_url:
|
|
75
|
+
return {"service": "redis", "healthy": False, "status": "error", "error": "No redis_url configured."}
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
client = get_redis(redis_url)
|
|
79
|
+
pong = await client.ping()
|
|
80
|
+
await close_redis()
|
|
81
|
+
return {"service": "redis", "healthy": bool(pong), "status": "ok" if pong else "error"}
|
|
82
|
+
except ImportError as e:
|
|
83
|
+
return {"service": "redis", "healthy": False, "status": "error", "error": str(e)}
|
|
84
|
+
except Exception as e:
|
|
85
|
+
with contextlib.suppress(Exception):
|
|
86
|
+
await close_redis()
|
|
87
|
+
return {"service": "redis", "healthy": False, "status": "error", "error": str(e)}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _display_single_check(actx: AppContext, result: dict) -> None:
|
|
91
|
+
"""Display a single health check result."""
|
|
92
|
+
out = actx.output
|
|
93
|
+
service = result["service"]
|
|
94
|
+
healthy = result["healthy"]
|
|
95
|
+
status = result.get("status", "unknown")
|
|
96
|
+
|
|
97
|
+
if actx.json_mode:
|
|
98
|
+
out.raw_json(result)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
sections: list[tuple[str, list[tuple[str, str]]]] = [
|
|
102
|
+
(
|
|
103
|
+
service,
|
|
104
|
+
[
|
|
105
|
+
("Status", service_status_color(status)),
|
|
106
|
+
("Healthy", "[green]yes[/green]" if healthy else "[red]no[/red]"),
|
|
107
|
+
],
|
|
108
|
+
),
|
|
109
|
+
]
|
|
110
|
+
# Add extra details
|
|
111
|
+
error = result.get("error")
|
|
112
|
+
if error:
|
|
113
|
+
sections[0][1].append(("Error", f"[red]{error}[/red]"))
|
|
114
|
+
|
|
115
|
+
# Include version/uptime if present
|
|
116
|
+
for extra_key in ("version", "uptime", "database", "redis", "workers"):
|
|
117
|
+
val = result.get(extra_key)
|
|
118
|
+
if val is not None:
|
|
119
|
+
sections[0][1].append((extra_key.title(), str(val)))
|
|
120
|
+
|
|
121
|
+
out.detail(title=f"Health: {service}", sections=sections, data_for_json=result)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _display_all_results(actx: AppContext, results: list[dict]) -> None:
|
|
125
|
+
"""Display aggregated health check results as a table."""
|
|
126
|
+
out = actx.output
|
|
127
|
+
|
|
128
|
+
if actx.json_mode:
|
|
129
|
+
out.raw_json(results)
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
rows: list[list[str]] = []
|
|
133
|
+
for r in results:
|
|
134
|
+
status = r.get("status", "unknown")
|
|
135
|
+
healthy = r["healthy"]
|
|
136
|
+
error = r.get("error", "")
|
|
137
|
+
rows.append(
|
|
138
|
+
[
|
|
139
|
+
r["service"],
|
|
140
|
+
service_status_color(status),
|
|
141
|
+
"[green]yes[/green]" if healthy else "[red]no[/red]",
|
|
142
|
+
error if error else "",
|
|
143
|
+
]
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
out.table(
|
|
147
|
+
title="Health Check Summary",
|
|
148
|
+
columns=[
|
|
149
|
+
("Service", "bold"),
|
|
150
|
+
("Status", ""),
|
|
151
|
+
("Healthy", ""),
|
|
152
|
+
("Error", "red"),
|
|
153
|
+
],
|
|
154
|
+
rows=rows,
|
|
155
|
+
data_for_json=results,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Summary line
|
|
159
|
+
total = len(results)
|
|
160
|
+
healthy_count = sum(1 for r in results if r["healthy"])
|
|
161
|
+
if healthy_count == total:
|
|
162
|
+
out.success(f"All {total} services healthy.")
|
|
163
|
+
else:
|
|
164
|
+
out.warn(f"{healthy_count}/{total} services healthy.")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _run_watch(
|
|
168
|
+
check_fn: object,
|
|
169
|
+
actx: AppContext,
|
|
170
|
+
interval: int,
|
|
171
|
+
*,
|
|
172
|
+
is_async: bool = False,
|
|
173
|
+
display_fn: object | None = None,
|
|
174
|
+
) -> None:
|
|
175
|
+
"""Run a check function in a watch loop with console clearing."""
|
|
176
|
+
import datetime as dt
|
|
177
|
+
|
|
178
|
+
console = actx.output.console
|
|
179
|
+
disp = display_fn or _display_single_check
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
while True:
|
|
183
|
+
console.clear()
|
|
184
|
+
actx.output.info(f"Watch mode (every {interval}s) — {dt.datetime.now(tz=dt.UTC).strftime('%H:%M:%S UTC')}")
|
|
185
|
+
result = ( # type: ignore[operator]
|
|
186
|
+
asyncio.run(check_fn(actx)) if is_async else check_fn(actx) # type: ignore[operator]
|
|
187
|
+
)
|
|
188
|
+
disp(actx, result) # type: ignore[operator]
|
|
189
|
+
time.sleep(interval)
|
|
190
|
+
except KeyboardInterrupt:
|
|
191
|
+
actx.output.info("Watch stopped.")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# api
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
@app.command()
|
|
198
|
+
def api(
|
|
199
|
+
ctx: typer.Context,
|
|
200
|
+
watch: Annotated[bool, typer.Option("--watch", "-w", help="Continuously poll.")] = False,
|
|
201
|
+
interval: Annotated[int, typer.Option("--interval", "-i", help="Watch interval in seconds.")] = _DEFAULT_INTERVAL,
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Check api-main health (GET /api/v1/health)."""
|
|
204
|
+
actx: AppContext = ctx.obj
|
|
205
|
+
|
|
206
|
+
if watch:
|
|
207
|
+
_run_watch(_check_api, actx, interval)
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
result = _check_api(actx)
|
|
211
|
+
_display_single_check(actx, result)
|
|
212
|
+
if not result["healthy"]:
|
|
213
|
+
raise typer.Exit(1)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# db
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
@app.command()
|
|
220
|
+
def db(
|
|
221
|
+
ctx: typer.Context,
|
|
222
|
+
watch: Annotated[bool, typer.Option("--watch", "-w", help="Continuously poll.")] = False,
|
|
223
|
+
interval: Annotated[int, typer.Option("--interval", "-i", help="Watch interval in seconds.")] = _DEFAULT_INTERVAL,
|
|
224
|
+
) -> None:
|
|
225
|
+
"""Check database connectivity (async SELECT 1)."""
|
|
226
|
+
actx: AppContext = ctx.obj
|
|
227
|
+
|
|
228
|
+
if watch:
|
|
229
|
+
_run_watch(_check_db_async, actx, interval, is_async=True)
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
result = asyncio.run(_check_db_async(actx))
|
|
233
|
+
_display_single_check(actx, result)
|
|
234
|
+
if not result["healthy"]:
|
|
235
|
+
raise typer.Exit(1)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# redis
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
@app.command()
|
|
242
|
+
def redis(
|
|
243
|
+
ctx: typer.Context,
|
|
244
|
+
watch: Annotated[bool, typer.Option("--watch", "-w", help="Continuously poll.")] = False,
|
|
245
|
+
interval: Annotated[int, typer.Option("--interval", "-i", help="Watch interval in seconds.")] = _DEFAULT_INTERVAL,
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Check Redis connectivity (async PING)."""
|
|
248
|
+
actx: AppContext = ctx.obj
|
|
249
|
+
|
|
250
|
+
if watch:
|
|
251
|
+
_run_watch(_check_redis_async, actx, interval, is_async=True)
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
result = asyncio.run(_check_redis_async(actx))
|
|
255
|
+
_display_single_check(actx, result)
|
|
256
|
+
if not result["healthy"]:
|
|
257
|
+
raise typer.Exit(1)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# ai
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
@app.command()
|
|
264
|
+
def ai(
|
|
265
|
+
ctx: typer.Context,
|
|
266
|
+
watch: Annotated[bool, typer.Option("--watch", "-w", help="Continuously poll.")] = False,
|
|
267
|
+
interval: Annotated[int, typer.Option("--interval", "-i", help="Watch interval in seconds.")] = _DEFAULT_INTERVAL,
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Check ai-main health (GET /api/v1/ai/health)."""
|
|
270
|
+
actx: AppContext = ctx.obj
|
|
271
|
+
|
|
272
|
+
if watch:
|
|
273
|
+
_run_watch(_check_ai, actx, interval)
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
result = _check_ai(actx)
|
|
277
|
+
_display_single_check(actx, result)
|
|
278
|
+
if not result["healthy"]:
|
|
279
|
+
raise typer.Exit(1)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
# all
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
@app.command(name="all")
|
|
286
|
+
def all_checks(
|
|
287
|
+
ctx: typer.Context,
|
|
288
|
+
watch: Annotated[bool, typer.Option("--watch", "-w", help="Continuously poll.")] = False,
|
|
289
|
+
interval: Annotated[int, typer.Option("--interval", "-i", help="Watch interval in seconds.")] = _DEFAULT_INTERVAL,
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Run all health checks and display aggregated results."""
|
|
292
|
+
actx: AppContext = ctx.obj
|
|
293
|
+
|
|
294
|
+
def _run_all(actx: AppContext) -> list[dict]:
|
|
295
|
+
results: list[dict] = []
|
|
296
|
+
# Synchronous HTTP checks
|
|
297
|
+
results.append(_check_api(actx))
|
|
298
|
+
results.append(_check_ai(actx))
|
|
299
|
+
# Async infrastructure checks
|
|
300
|
+
results.append(asyncio.run(_check_db_async(actx)))
|
|
301
|
+
results.append(asyncio.run(_check_redis_async(actx)))
|
|
302
|
+
return results
|
|
303
|
+
|
|
304
|
+
if watch:
|
|
305
|
+
import datetime as dt
|
|
306
|
+
|
|
307
|
+
console = actx.output.console
|
|
308
|
+
try:
|
|
309
|
+
while True:
|
|
310
|
+
console.clear()
|
|
311
|
+
actx.output.info(
|
|
312
|
+
f"Watch mode (every {interval}s) — {dt.datetime.now(tz=dt.UTC).strftime('%H:%M:%S UTC')}"
|
|
313
|
+
)
|
|
314
|
+
results = _run_all(actx)
|
|
315
|
+
_display_all_results(actx, results)
|
|
316
|
+
time.sleep(interval)
|
|
317
|
+
except KeyboardInterrupt:
|
|
318
|
+
actx.output.info("Watch stopped.")
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
results = _run_all(actx)
|
|
322
|
+
_display_all_results(actx, results)
|
|
323
|
+
|
|
324
|
+
# Exit with error code if any check failed
|
|
325
|
+
if not all(r["healthy"] for r in results):
|
|
326
|
+
raise typer.Exit(1)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
# deep
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
@app.command()
|
|
333
|
+
def deep(ctx: typer.Context) -> None:
|
|
334
|
+
"""Deep health check — DB, Redis, all external service dependencies."""
|
|
335
|
+
actx: AppContext = ctx.obj
|
|
336
|
+
out = actx.output
|
|
337
|
+
|
|
338
|
+
out.info("Running deep health check ...")
|
|
339
|
+
results: list[dict] = []
|
|
340
|
+
|
|
341
|
+
# Standard checks
|
|
342
|
+
results.append(_check_api(actx))
|
|
343
|
+
results.append(_check_ai(actx))
|
|
344
|
+
results.append(asyncio.run(_check_db_async(actx)))
|
|
345
|
+
results.append(asyncio.run(_check_redis_async(actx)))
|
|
346
|
+
|
|
347
|
+
# Deep DB check — verify schema exists
|
|
348
|
+
async def _deep_db() -> dict:
|
|
349
|
+
from kctl_api.core.db import dispose_engine, execute_query
|
|
350
|
+
|
|
351
|
+
db_url = actx.database_url
|
|
352
|
+
if not db_url:
|
|
353
|
+
return {"service": "db-schema", "healthy": False, "status": "error", "error": "No database_url"}
|
|
354
|
+
try:
|
|
355
|
+
rows = await execute_query(
|
|
356
|
+
db_url,
|
|
357
|
+
"SELECT COUNT(*) AS cnt FROM information_schema.tables WHERE table_schema = 'public'",
|
|
358
|
+
)
|
|
359
|
+
await dispose_engine()
|
|
360
|
+
cnt = rows[0].get("cnt", 0) if rows else 0
|
|
361
|
+
return {
|
|
362
|
+
"service": "db-schema",
|
|
363
|
+
"healthy": cnt > 0,
|
|
364
|
+
"status": "ok" if cnt > 0 else "empty",
|
|
365
|
+
"tables": cnt,
|
|
366
|
+
}
|
|
367
|
+
except Exception as e:
|
|
368
|
+
return {"service": "db-schema", "healthy": False, "status": "error", "error": str(e)}
|
|
369
|
+
|
|
370
|
+
results.append(asyncio.run(_deep_db()))
|
|
371
|
+
|
|
372
|
+
# Deep Redis check — verify can write/read
|
|
373
|
+
async def _deep_redis() -> dict:
|
|
374
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
375
|
+
|
|
376
|
+
redis_url = actx.redis_url
|
|
377
|
+
if not redis_url:
|
|
378
|
+
return {"service": "redis-rw", "healthy": False, "status": "error", "error": "No redis_url"}
|
|
379
|
+
try:
|
|
380
|
+
import time
|
|
381
|
+
|
|
382
|
+
client = get_redis(redis_url)
|
|
383
|
+
test_key = f"kctl:healthcheck:{int(time.time())}"
|
|
384
|
+
await client.set(test_key, "1", ex=10)
|
|
385
|
+
val = await client.get(test_key)
|
|
386
|
+
await client.delete(test_key)
|
|
387
|
+
await close_redis()
|
|
388
|
+
ok = val == "1"
|
|
389
|
+
return {"service": "redis-rw", "healthy": ok, "status": "ok" if ok else "error"}
|
|
390
|
+
except Exception as e:
|
|
391
|
+
return {"service": "redis-rw", "healthy": False, "status": "error", "error": str(e)}
|
|
392
|
+
|
|
393
|
+
results.append(asyncio.run(_deep_redis()))
|
|
394
|
+
|
|
395
|
+
_display_all_results(actx, results)
|
|
396
|
+
|
|
397
|
+
if not all(r["healthy"] for r in results):
|
|
398
|
+
raise typer.Exit(1)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
# deps-check
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
@app.command(name="deps-check")
|
|
405
|
+
def deps_check(ctx: typer.Context) -> None:
|
|
406
|
+
"""Verify external service dependencies are reachable."""
|
|
407
|
+
actx: AppContext = ctx.obj
|
|
408
|
+
out = actx.output
|
|
409
|
+
|
|
410
|
+
import httpx
|
|
411
|
+
|
|
412
|
+
# Read external deps from env or config
|
|
413
|
+
deps_to_check: list[dict] = []
|
|
414
|
+
|
|
415
|
+
# API itself
|
|
416
|
+
url = actx.client.base_url.rstrip("/")
|
|
417
|
+
deps_to_check.append({"name": "api-main", "url": f"{url}/api/v1/health"})
|
|
418
|
+
|
|
419
|
+
# Check AI URL if configured
|
|
420
|
+
try:
|
|
421
|
+
ai_url = actx.ai_client.base_url.rstrip("/")
|
|
422
|
+
deps_to_check.append({"name": "ai-main", "url": f"{ai_url}/api/v1/ai/health"})
|
|
423
|
+
except Exception:
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
# Check any ODOO_URL from env
|
|
427
|
+
import os
|
|
428
|
+
|
|
429
|
+
odoo_url = os.environ.get("ODOO_URL")
|
|
430
|
+
if odoo_url:
|
|
431
|
+
deps_to_check.append({"name": "odoo", "url": f"{odoo_url.rstrip('/')}/web/dataset/call_kw"})
|
|
432
|
+
|
|
433
|
+
results: list[dict] = []
|
|
434
|
+
for dep in deps_to_check:
|
|
435
|
+
try:
|
|
436
|
+
response = httpx.get(dep["url"], timeout=5, follow_redirects=True)
|
|
437
|
+
healthy = response.status_code < 500
|
|
438
|
+
results.append(
|
|
439
|
+
{
|
|
440
|
+
"service": dep["name"],
|
|
441
|
+
"healthy": healthy,
|
|
442
|
+
"status": str(response.status_code),
|
|
443
|
+
"url": dep["url"],
|
|
444
|
+
}
|
|
445
|
+
)
|
|
446
|
+
except Exception as e:
|
|
447
|
+
results.append(
|
|
448
|
+
{
|
|
449
|
+
"service": dep["name"],
|
|
450
|
+
"healthy": False,
|
|
451
|
+
"status": "unreachable",
|
|
452
|
+
"error": str(e)[:60],
|
|
453
|
+
"url": dep["url"],
|
|
454
|
+
}
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
rows = [
|
|
458
|
+
[
|
|
459
|
+
r["service"],
|
|
460
|
+
r.get("url", ""),
|
|
461
|
+
"[green]ok[/green]" if r["healthy"] else "[red]fail[/red]",
|
|
462
|
+
r.get("status", ""),
|
|
463
|
+
r.get("error", ""),
|
|
464
|
+
]
|
|
465
|
+
for r in results
|
|
466
|
+
]
|
|
467
|
+
out.table(
|
|
468
|
+
title="External Dependency Check",
|
|
469
|
+
columns=[("Service", "bold"), ("URL", "dim"), ("Status", ""), ("Code", ""), ("Error", "red")],
|
|
470
|
+
rows=rows,
|
|
471
|
+
data_for_json=results,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
failed = [r for r in results if not r["healthy"]]
|
|
475
|
+
if failed:
|
|
476
|
+
out.warn(f"{len(failed)} dependency/ies unreachable.")
|
|
477
|
+
raise typer.Exit(1)
|
|
478
|
+
else:
|
|
479
|
+
out.success(f"All {len(results)} dependencies reachable.")
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Job queue management commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Monitor and manage ARQ background jobs.
|
|
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
|
+
from kctl_api.core.exceptions import APIError, AuthenticationError
|
|
14
|
+
from kctl_api.core.exceptions import ConnectionError as KctlConnectionError
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(name="jobs", help="Background job management — overview, status, enqueue.", no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# overview
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
@app.command()
|
|
23
|
+
def overview(ctx: typer.Context) -> None:
|
|
24
|
+
"""Show job queue overview (GET /api/v1/jobs/queues/overview)."""
|
|
25
|
+
actx: AppContext = ctx.obj
|
|
26
|
+
out = actx.output
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
data = actx.client.get("/api/v1/jobs/queues/overview")
|
|
30
|
+
except AuthenticationError as e:
|
|
31
|
+
out.error(f"Auth failed: {e}")
|
|
32
|
+
raise typer.Exit(1) from None
|
|
33
|
+
except KctlConnectionError as e:
|
|
34
|
+
out.error(f"Connection failed: {e}")
|
|
35
|
+
raise typer.Exit(1) from None
|
|
36
|
+
except APIError as e:
|
|
37
|
+
out.error(f"API error: {e.detail}")
|
|
38
|
+
raise typer.Exit(1) from None
|
|
39
|
+
|
|
40
|
+
if not data:
|
|
41
|
+
out.info("No queue data available.")
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
# Display as table if data is a list of queues, or as detail if single dict
|
|
45
|
+
if isinstance(data, list):
|
|
46
|
+
rows: list[list[str]] = []
|
|
47
|
+
for q in data:
|
|
48
|
+
rows.append(
|
|
49
|
+
[
|
|
50
|
+
q.get("name", ""),
|
|
51
|
+
str(q.get("queued", 0)),
|
|
52
|
+
str(q.get("active", 0)),
|
|
53
|
+
str(q.get("complete", 0)),
|
|
54
|
+
str(q.get("failed", 0)),
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
out.table(
|
|
58
|
+
title="Job Queues Overview",
|
|
59
|
+
columns=[
|
|
60
|
+
("Queue", "bold"),
|
|
61
|
+
("Queued", ""),
|
|
62
|
+
("Active", "yellow"),
|
|
63
|
+
("Complete", "green"),
|
|
64
|
+
("Failed", "red"),
|
|
65
|
+
],
|
|
66
|
+
rows=rows,
|
|
67
|
+
data_for_json=data,
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
sections: list[tuple[str, list[tuple[str, str]]]] = [
|
|
71
|
+
("Queue Stats", [(k, str(v)) for k, v in data.items()]),
|
|
72
|
+
]
|
|
73
|
+
out.detail(title="Job Queues Overview", sections=sections, data_for_json=data)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# status
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
@app.command()
|
|
80
|
+
def status(
|
|
81
|
+
ctx: typer.Context,
|
|
82
|
+
job_id: Annotated[str, typer.Argument(help="Job ID to check.")],
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Get status of a specific job via GET /api/v1/jobs/{id}."""
|
|
85
|
+
actx: AppContext = ctx.obj
|
|
86
|
+
out = actx.output
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
data = actx.client.get(f"/api/v1/jobs/{job_id}")
|
|
90
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
91
|
+
out.error(str(e))
|
|
92
|
+
raise typer.Exit(1) from None
|
|
93
|
+
|
|
94
|
+
if not data:
|
|
95
|
+
out.error(f"Job not found: {job_id}")
|
|
96
|
+
raise typer.Exit(1)
|
|
97
|
+
|
|
98
|
+
out.detail(
|
|
99
|
+
title=f"Job: {job_id}",
|
|
100
|
+
sections=[
|
|
101
|
+
("Details", [(k, str(v)) for k, v in data.items()]),
|
|
102
|
+
],
|
|
103
|
+
data_for_json=data,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# enqueue
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
@app.command()
|
|
111
|
+
def enqueue(
|
|
112
|
+
ctx: typer.Context,
|
|
113
|
+
function: Annotated[str, typer.Argument(help="Function name to enqueue.")],
|
|
114
|
+
data_json: Annotated[str | None, typer.Option("--data", "-d", help="JSON payload for the job.")] = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Enqueue a new background job (admin-only) via POST /api/v1/jobs/enqueue."""
|
|
117
|
+
actx: AppContext = ctx.obj
|
|
118
|
+
out = actx.output
|
|
119
|
+
|
|
120
|
+
import json
|
|
121
|
+
|
|
122
|
+
payload: dict = {"function": function}
|
|
123
|
+
if data_json:
|
|
124
|
+
try:
|
|
125
|
+
payload["data"] = json.loads(data_json)
|
|
126
|
+
except json.JSONDecodeError as e:
|
|
127
|
+
out.error(f"Invalid JSON: {e}")
|
|
128
|
+
raise typer.Exit(1) from None
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
result = actx.client.post("/api/v1/jobs/enqueue", json=payload)
|
|
132
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
133
|
+
out.error(str(e))
|
|
134
|
+
raise typer.Exit(1) from None
|
|
135
|
+
|
|
136
|
+
out.success(f"Job enqueued: {function}")
|
|
137
|
+
if actx.json_mode:
|
|
138
|
+
out.raw_json(result)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# get
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
@app.command(name="get")
|
|
145
|
+
def get_job(
|
|
146
|
+
ctx: typer.Context,
|
|
147
|
+
job_id: Annotated[str, typer.Argument(help="Job ID.")],
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Get detailed job information via GET /api/v1/jobs/{id}."""
|
|
150
|
+
actx: AppContext = ctx.obj
|
|
151
|
+
out = actx.output
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
data = actx.client.get(f"/api/v1/jobs/{job_id}")
|
|
155
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
156
|
+
out.error(str(e))
|
|
157
|
+
raise typer.Exit(1) from None
|
|
158
|
+
|
|
159
|
+
if not data:
|
|
160
|
+
out.error(f"Job not found: {job_id}")
|
|
161
|
+
raise typer.Exit(1)
|
|
162
|
+
|
|
163
|
+
out.detail(
|
|
164
|
+
title=f"Job: {job_id}",
|
|
165
|
+
sections=[
|
|
166
|
+
("Details", [(k, str(v)) for k, v in data.items()]),
|
|
167
|
+
],
|
|
168
|
+
data_for_json=data,
|
|
169
|
+
)
|