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,289 @@
|
|
|
1
|
+
"""Dependency management commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Audit, outdated, licenses, graph, and size analysis.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from kctl_api.core.callbacks import AppContext
|
|
14
|
+
from kctl_api.core.utils import find_project_root
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="deps", help="Dependency management — audit, outdated, licenses, graph, size.", no_args_is_help=True
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run_uv(args: list[str], cwd: str | None = None) -> subprocess.CompletedProcess:
|
|
22
|
+
return subprocess.run(["uv", *args], capture_output=True, text=True, cwd=cwd)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# audit
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
@app.command()
|
|
29
|
+
def audit(
|
|
30
|
+
ctx: typer.Context,
|
|
31
|
+
fix: Annotated[bool, typer.Option("--fix", help="Attempt to upgrade vulnerable packages.")] = False,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Run security audit via uv or pip-audit."""
|
|
34
|
+
actx: AppContext = ctx.obj
|
|
35
|
+
out = actx.output
|
|
36
|
+
|
|
37
|
+
root = find_project_root()
|
|
38
|
+
out.info("Running dependency security audit ...")
|
|
39
|
+
|
|
40
|
+
# Try pip-audit first (more detailed), fall back to uv
|
|
41
|
+
import shutil
|
|
42
|
+
|
|
43
|
+
if shutil.which("pip-audit"):
|
|
44
|
+
cmd = ["pip-audit", "--format", "json"]
|
|
45
|
+
result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(root))
|
|
46
|
+
|
|
47
|
+
if result.returncode == 0:
|
|
48
|
+
import json
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
data = json.loads(result.stdout)
|
|
52
|
+
vulns = data.get("vulnerabilities", [])
|
|
53
|
+
if not vulns:
|
|
54
|
+
out.success("No vulnerabilities found (pip-audit).")
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
rows = [
|
|
58
|
+
[v.get("name", ""), v.get("version", ""), v.get("id", ""), v.get("fix_versions", [""])[0]]
|
|
59
|
+
for v in vulns
|
|
60
|
+
]
|
|
61
|
+
out.table(
|
|
62
|
+
title=f"Vulnerabilities ({len(vulns)})",
|
|
63
|
+
columns=[("Package", "bold"), ("Version", ""), ("ID", "red"), ("Fix Version", "green")],
|
|
64
|
+
rows=rows,
|
|
65
|
+
data_for_json=vulns,
|
|
66
|
+
)
|
|
67
|
+
raise typer.Exit(1)
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
out.text(result.stdout)
|
|
72
|
+
if result.returncode != 0:
|
|
73
|
+
out.text(result.stderr)
|
|
74
|
+
else:
|
|
75
|
+
# Fall back to uv audit (uv >= 0.4)
|
|
76
|
+
result = _run_uv(["audit"], cwd=str(root))
|
|
77
|
+
if result.returncode == 0 and not result.stdout.strip():
|
|
78
|
+
out.success("No vulnerabilities found (uv audit).")
|
|
79
|
+
else:
|
|
80
|
+
out.text(result.stdout)
|
|
81
|
+
if result.stderr:
|
|
82
|
+
out.text(result.stderr)
|
|
83
|
+
out.info("For detailed audit: uv add --dev pip-audit && pip-audit")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# outdated
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
@app.command()
|
|
90
|
+
def outdated(ctx: typer.Context) -> None:
|
|
91
|
+
"""Show outdated packages across all workspace members."""
|
|
92
|
+
actx: AppContext = ctx.obj
|
|
93
|
+
out = actx.output
|
|
94
|
+
|
|
95
|
+
root = find_project_root()
|
|
96
|
+
out.info("Checking for outdated packages ...")
|
|
97
|
+
|
|
98
|
+
result = _run_uv(["pip", "list", "--outdated", "--format", "json"], cwd=str(root))
|
|
99
|
+
|
|
100
|
+
if result.returncode != 0:
|
|
101
|
+
# Try without --format json
|
|
102
|
+
result = _run_uv(["pip", "list", "--outdated"], cwd=str(root))
|
|
103
|
+
out.text(result.stdout or "(no output)")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
import json
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
packages = json.loads(result.stdout)
|
|
110
|
+
except Exception:
|
|
111
|
+
out.text(result.stdout or "(no output)")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
if not packages:
|
|
115
|
+
out.success("All packages are up to date.")
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
rows = [[p.get("name", ""), p.get("version", ""), p.get("latest_version", "")] for p in packages]
|
|
119
|
+
out.table(
|
|
120
|
+
title=f"Outdated Packages ({len(packages)})",
|
|
121
|
+
columns=[("Package", "bold"), ("Current", "yellow"), ("Latest", "green")],
|
|
122
|
+
rows=rows,
|
|
123
|
+
data_for_json=packages,
|
|
124
|
+
)
|
|
125
|
+
out.info("Upgrade: uv sync --upgrade")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# licenses
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
@app.command()
|
|
132
|
+
def licenses(
|
|
133
|
+
ctx: typer.Context,
|
|
134
|
+
allow: Annotated[str | None, typer.Option("--allow", help="Comma-separated allowed license patterns.")] = None,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Generate a license report for all installed packages."""
|
|
137
|
+
actx: AppContext = ctx.obj
|
|
138
|
+
out = actx.output
|
|
139
|
+
|
|
140
|
+
root = find_project_root()
|
|
141
|
+
|
|
142
|
+
# Try pip-licenses
|
|
143
|
+
import shutil
|
|
144
|
+
|
|
145
|
+
if shutil.which("pip-licenses"):
|
|
146
|
+
result = subprocess.run(["pip-licenses", "--format", "json"], capture_output=True, text=True, cwd=str(root))
|
|
147
|
+
if result.returncode == 0:
|
|
148
|
+
import json
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
data = json.loads(result.stdout)
|
|
152
|
+
allowed_list = [a.strip().upper() for a in (allow or "").split(",")] if allow else []
|
|
153
|
+
|
|
154
|
+
flagged = []
|
|
155
|
+
for pkg in data:
|
|
156
|
+
lic = pkg.get("License", "UNKNOWN").upper()
|
|
157
|
+
if allowed_list and not any(a in lic for a in allowed_list):
|
|
158
|
+
flagged.append(pkg)
|
|
159
|
+
|
|
160
|
+
display = flagged if (allowed_list and flagged) else data
|
|
161
|
+
rows = [[p.get("Name", ""), p.get("Version", ""), p.get("License", "")] for p in display[:100]]
|
|
162
|
+
out.table(
|
|
163
|
+
title=f"Package Licenses ({len(display)} of {len(data)})",
|
|
164
|
+
columns=[("Package", "bold"), ("Version", ""), ("License", "")],
|
|
165
|
+
rows=rows,
|
|
166
|
+
data_for_json=display,
|
|
167
|
+
)
|
|
168
|
+
if allowed_list and flagged:
|
|
169
|
+
out.warn(f"{len(flagged)} packages with non-allowed licenses.")
|
|
170
|
+
raise typer.Exit(1)
|
|
171
|
+
return
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
# Fall back: parse from uv pip list
|
|
176
|
+
out.info("pip-licenses not installed. Install: uv add --dev pip-licenses")
|
|
177
|
+
result = _run_uv(["pip", "show", "--format", "columns"], cwd=str(root))
|
|
178
|
+
if result.stdout:
|
|
179
|
+
out.text(result.stdout[:3000])
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# graph
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
@app.command()
|
|
186
|
+
def graph(ctx: typer.Context) -> None:
|
|
187
|
+
"""Show the dependency graph via uv tree."""
|
|
188
|
+
actx: AppContext = ctx.obj
|
|
189
|
+
out = actx.output
|
|
190
|
+
|
|
191
|
+
root = find_project_root()
|
|
192
|
+
out.info("Generating dependency graph ...")
|
|
193
|
+
|
|
194
|
+
result = _run_uv(["tree", "--all-packages"], cwd=str(root))
|
|
195
|
+
if result.returncode != 0:
|
|
196
|
+
# Try without --all-packages
|
|
197
|
+
result = _run_uv(["tree"], cwd=str(root))
|
|
198
|
+
|
|
199
|
+
if result.stdout:
|
|
200
|
+
typer.echo(result.stdout)
|
|
201
|
+
if result.stderr and result.returncode != 0:
|
|
202
|
+
out.error(result.stderr.strip())
|
|
203
|
+
raise typer.Exit(result.returncode)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# size
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
@app.command()
|
|
210
|
+
def size(
|
|
211
|
+
ctx: typer.Context,
|
|
212
|
+
top: Annotated[int, typer.Option("--top", "-n", help="Show top N packages by size.")] = 20,
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Show installed package sizes."""
|
|
215
|
+
actx: AppContext = ctx.obj
|
|
216
|
+
out = actx.output
|
|
217
|
+
|
|
218
|
+
import os
|
|
219
|
+
|
|
220
|
+
out.info(f"Analyzing package sizes (top {top}) ...")
|
|
221
|
+
|
|
222
|
+
# Get site-packages directory
|
|
223
|
+
try:
|
|
224
|
+
import site
|
|
225
|
+
|
|
226
|
+
site_dirs = site.getsitepackages()
|
|
227
|
+
except Exception:
|
|
228
|
+
site_dirs = []
|
|
229
|
+
|
|
230
|
+
result = _run_uv(["pip", "list", "--format", "json"])
|
|
231
|
+
packages: list[dict] = []
|
|
232
|
+
if result.returncode == 0:
|
|
233
|
+
import json
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
pkg_list = json.loads(result.stdout)
|
|
237
|
+
except Exception:
|
|
238
|
+
pkg_list = []
|
|
239
|
+
|
|
240
|
+
for site_dir in site_dirs:
|
|
241
|
+
if not os.path.isdir(site_dir):
|
|
242
|
+
continue
|
|
243
|
+
for pkg_info in pkg_list:
|
|
244
|
+
pkg_name = pkg_info.get("name", "")
|
|
245
|
+
# Estimate size from dist-info directory
|
|
246
|
+
dist_name = pkg_name.replace("-", "_")
|
|
247
|
+
size_bytes = 0
|
|
248
|
+
for entry in os.scandir(site_dir):
|
|
249
|
+
if entry.name.lower().startswith(dist_name.lower()):
|
|
250
|
+
try:
|
|
251
|
+
for root_dir, _dirs, files in os.walk(entry.path):
|
|
252
|
+
size_bytes += sum(
|
|
253
|
+
os.path.getsize(os.path.join(root_dir, f))
|
|
254
|
+
for f in files
|
|
255
|
+
if not os.path.islink(os.path.join(root_dir, f))
|
|
256
|
+
)
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
if size_bytes > 0:
|
|
260
|
+
packages.append(
|
|
261
|
+
{
|
|
262
|
+
"name": pkg_name,
|
|
263
|
+
"version": pkg_info.get("version", ""),
|
|
264
|
+
"size_bytes": size_bytes,
|
|
265
|
+
"size_kb": round(size_bytes / 1024),
|
|
266
|
+
}
|
|
267
|
+
)
|
|
268
|
+
break # Only use first site dir
|
|
269
|
+
|
|
270
|
+
if not packages:
|
|
271
|
+
out.warn(
|
|
272
|
+
"Could not determine package sizes. "
|
|
273
|
+
"Try: du -sh $(python -c 'import site; print(site.getsitepackages()[0])')"
|
|
274
|
+
)
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
packages.sort(key=lambda x: -x["size_bytes"])
|
|
278
|
+
display = packages[:top]
|
|
279
|
+
|
|
280
|
+
rows = [[p["name"], p["version"], f"{p['size_kb']} KB"] for p in display]
|
|
281
|
+
out.table(
|
|
282
|
+
title=f"Top {top} Packages by Size",
|
|
283
|
+
columns=[("Package", "bold"), ("Version", ""), ("Size", "")],
|
|
284
|
+
rows=rows,
|
|
285
|
+
data_for_json=display,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
total_mb = round(sum(p["size_bytes"] for p in packages) / 1024 / 1024, 1)
|
|
289
|
+
out.info(f"Total installed: ~{total_mb} MB ({len(packages)} packages)")
|
kctl_api/commands/dev.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Development server commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Start, stop, rebuild, and manage the dev environment.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from kctl_api.core.callbacks import AppContext
|
|
14
|
+
from kctl_api.core.utils import find_project_root
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(name="dev", help="Development environment — up, down, rebuild, logs.", no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# up
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
@app.command()
|
|
23
|
+
def up(
|
|
24
|
+
ctx: typer.Context,
|
|
25
|
+
build: Annotated[bool, typer.Option("--build", help="Rebuild images before starting.")] = False,
|
|
26
|
+
odoo_mm: Annotated[bool, typer.Option("--odoo-mm", help="Include Odoo-MM.")] = False,
|
|
27
|
+
plane_mm: Annotated[bool, typer.Option("--plane-mm", help="Include Plane-MM.")] = False,
|
|
28
|
+
all_services: Annotated[bool, typer.Option("--all", help="Include all optional services.")] = False,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Start the development stack via scripts/dev."""
|
|
31
|
+
actx: AppContext = ctx.obj
|
|
32
|
+
out = actx.output
|
|
33
|
+
|
|
34
|
+
root = find_project_root()
|
|
35
|
+
cmd = [str(root / "scripts" / "dev")]
|
|
36
|
+
if build:
|
|
37
|
+
cmd.append("--build")
|
|
38
|
+
if odoo_mm:
|
|
39
|
+
cmd.append("--odoo-mm")
|
|
40
|
+
if plane_mm:
|
|
41
|
+
cmd.append("--plane-mm")
|
|
42
|
+
if all_services:
|
|
43
|
+
cmd.append("--all")
|
|
44
|
+
|
|
45
|
+
out.info("Starting dev environment ...")
|
|
46
|
+
result = subprocess.run(cmd, cwd=str(root), capture_output=False)
|
|
47
|
+
if result.returncode != 0:
|
|
48
|
+
out.error("Failed to start dev environment.")
|
|
49
|
+
raise typer.Exit(result.returncode)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# down
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
@app.command()
|
|
56
|
+
def down(ctx: typer.Context) -> None:
|
|
57
|
+
"""Stop the development stack."""
|
|
58
|
+
actx: AppContext = ctx.obj
|
|
59
|
+
out = actx.output
|
|
60
|
+
|
|
61
|
+
root = find_project_root()
|
|
62
|
+
cmd = ["docker", "compose", "-f", str(root / "docker-compose.yml"), "down"]
|
|
63
|
+
|
|
64
|
+
out.info("Stopping dev environment ...")
|
|
65
|
+
result = subprocess.run(cmd, capture_output=False)
|
|
66
|
+
if result.returncode != 0:
|
|
67
|
+
out.error("Failed to stop dev environment.")
|
|
68
|
+
raise typer.Exit(result.returncode)
|
|
69
|
+
out.success("Dev environment stopped.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# rebuild
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
@app.command()
|
|
76
|
+
def rebuild(
|
|
77
|
+
ctx: typer.Context,
|
|
78
|
+
service: Annotated[str | None, typer.Argument(help="Service name (all if omitted).")] = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Rebuild and restart docker compose services."""
|
|
81
|
+
actx: AppContext = ctx.obj
|
|
82
|
+
out = actx.output
|
|
83
|
+
|
|
84
|
+
root = find_project_root()
|
|
85
|
+
cmd = ["docker", "compose", "-f", str(root / "docker-compose.yml"), "up", "--build", "-d"]
|
|
86
|
+
if service:
|
|
87
|
+
cmd.append(service)
|
|
88
|
+
|
|
89
|
+
out.info(f"Rebuilding {service if service else 'all services'} ...")
|
|
90
|
+
result = subprocess.run(cmd, capture_output=False)
|
|
91
|
+
if result.returncode != 0:
|
|
92
|
+
out.error("Rebuild failed.")
|
|
93
|
+
raise typer.Exit(result.returncode)
|
|
94
|
+
out.success("Rebuild complete.")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# logs
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
@app.command()
|
|
101
|
+
def logs(
|
|
102
|
+
ctx: typer.Context,
|
|
103
|
+
service: Annotated[str | None, typer.Argument(help="Service name (all if omitted).")] = None,
|
|
104
|
+
follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow log output.")] = False,
|
|
105
|
+
tail: Annotated[int, typer.Option("--tail", "-n", help="Number of lines.")] = 100,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""View docker compose logs."""
|
|
108
|
+
root = find_project_root()
|
|
109
|
+
cmd = ["docker", "compose", "-f", str(root / "docker-compose.yml"), "logs", "--tail", str(tail)]
|
|
110
|
+
if follow:
|
|
111
|
+
cmd.append("--follow")
|
|
112
|
+
if service:
|
|
113
|
+
cmd.append(service)
|
|
114
|
+
subprocess.run(cmd, capture_output=False)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# attach
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
@app.command()
|
|
121
|
+
def attach(
|
|
122
|
+
ctx: typer.Context,
|
|
123
|
+
service: Annotated[str, typer.Argument(help="Service name to attach to.")],
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Attach to a running container's shell."""
|
|
126
|
+
actx: AppContext = ctx.obj
|
|
127
|
+
out = actx.output
|
|
128
|
+
|
|
129
|
+
root = find_project_root()
|
|
130
|
+
cmd = ["docker", "compose", "-f", str(root / "docker-compose.yml"), "exec", service, "/bin/sh"]
|
|
131
|
+
|
|
132
|
+
out.info(f"Attaching to {service} ...")
|
|
133
|
+
result = subprocess.run(cmd, capture_output=False)
|
|
134
|
+
if result.returncode != 0:
|
|
135
|
+
out.error(f"Failed to attach to {service}.")
|
|
136
|
+
raise typer.Exit(result.returncode)
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Docker management commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Image management, container inspection, network, volumes, and system info.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from kctl_api.core.callbacks import AppContext
|
|
14
|
+
from kctl_api.core.utils import find_project_root
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="docker", help="Docker management — images, containers, network, volumes, system.", no_args_is_help=True
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _compose_cmd(args: list[str], compose_file: str | None = None) -> list[str]:
|
|
22
|
+
root = find_project_root()
|
|
23
|
+
cf = compose_file or str(root / "docker-compose.yml")
|
|
24
|
+
return ["docker", "compose", "-f", cf, *args]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# images
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
@app.command()
|
|
31
|
+
def images(ctx: typer.Context) -> None:
|
|
32
|
+
"""List Docker images for the project."""
|
|
33
|
+
actx: AppContext = ctx.obj
|
|
34
|
+
out = actx.output
|
|
35
|
+
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
["docker", "images", "--format", "json"],
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
)
|
|
41
|
+
if result.returncode != 0:
|
|
42
|
+
out.error(f"docker images failed: {result.stderr.strip()}")
|
|
43
|
+
raise typer.Exit(1)
|
|
44
|
+
|
|
45
|
+
import json
|
|
46
|
+
|
|
47
|
+
image_list: list[dict] = []
|
|
48
|
+
for line in result.stdout.strip().splitlines():
|
|
49
|
+
if line.strip():
|
|
50
|
+
try:
|
|
51
|
+
image_list.append(json.loads(line))
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
# Filter to project images
|
|
56
|
+
root = find_project_root()
|
|
57
|
+
project_name = root.name.replace("-", "")
|
|
58
|
+
|
|
59
|
+
project_images = [
|
|
60
|
+
img
|
|
61
|
+
for img in image_list
|
|
62
|
+
if project_name in (img.get("Repository") or "").lower() or "kodemeio" in (img.get("Repository") or "").lower()
|
|
63
|
+
] or image_list[:20]
|
|
64
|
+
|
|
65
|
+
rows = [
|
|
66
|
+
[
|
|
67
|
+
img.get("Repository", ""),
|
|
68
|
+
img.get("Tag", ""),
|
|
69
|
+
img.get("Size", ""),
|
|
70
|
+
img.get("CreatedSince", img.get("CreatedAt", "")),
|
|
71
|
+
]
|
|
72
|
+
for img in project_images
|
|
73
|
+
]
|
|
74
|
+
out.table(
|
|
75
|
+
title=f"Docker Images ({len(project_images)})",
|
|
76
|
+
columns=[("Repository", "bold"), ("Tag", ""), ("Size", ""), ("Created", "")],
|
|
77
|
+
rows=rows,
|
|
78
|
+
data_for_json=project_images,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# containers
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
@app.command()
|
|
86
|
+
def containers(
|
|
87
|
+
ctx: typer.Context,
|
|
88
|
+
all_containers: Annotated[bool, typer.Option("--all", "-a", help="Show stopped containers too.")] = False,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""List running (or all) Docker containers."""
|
|
91
|
+
actx: AppContext = ctx.obj
|
|
92
|
+
out = actx.output
|
|
93
|
+
|
|
94
|
+
cmd = ["docker", "ps", "--format", "json"]
|
|
95
|
+
if all_containers:
|
|
96
|
+
cmd.append("--all")
|
|
97
|
+
|
|
98
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
99
|
+
if result.returncode != 0:
|
|
100
|
+
out.error(f"docker ps failed: {result.stderr.strip()}")
|
|
101
|
+
raise typer.Exit(1)
|
|
102
|
+
|
|
103
|
+
import json
|
|
104
|
+
|
|
105
|
+
container_list: list[dict] = []
|
|
106
|
+
for line in result.stdout.strip().splitlines():
|
|
107
|
+
if line.strip():
|
|
108
|
+
try:
|
|
109
|
+
container_list.append(json.loads(line))
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
rows = [
|
|
114
|
+
[
|
|
115
|
+
c.get("Names", c.get("Name", "")),
|
|
116
|
+
c.get("Image", ""),
|
|
117
|
+
c.get("Status", ""),
|
|
118
|
+
c.get("Ports", ""),
|
|
119
|
+
]
|
|
120
|
+
for c in container_list
|
|
121
|
+
]
|
|
122
|
+
out.table(
|
|
123
|
+
title=f"Containers ({len(container_list)})",
|
|
124
|
+
columns=[("Name", "bold"), ("Image", ""), ("Status", ""), ("Ports", "")],
|
|
125
|
+
rows=rows,
|
|
126
|
+
data_for_json=container_list,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# network
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
@app.command()
|
|
134
|
+
def network(ctx: typer.Context) -> None:
|
|
135
|
+
"""List Docker networks and show which containers are attached."""
|
|
136
|
+
actx: AppContext = ctx.obj
|
|
137
|
+
out = actx.output
|
|
138
|
+
|
|
139
|
+
result = subprocess.run(
|
|
140
|
+
["docker", "network", "ls", "--format", "json"],
|
|
141
|
+
capture_output=True,
|
|
142
|
+
text=True,
|
|
143
|
+
)
|
|
144
|
+
if result.returncode != 0:
|
|
145
|
+
out.error(f"docker network ls failed: {result.stderr.strip()}")
|
|
146
|
+
raise typer.Exit(1)
|
|
147
|
+
|
|
148
|
+
import json
|
|
149
|
+
|
|
150
|
+
networks: list[dict] = []
|
|
151
|
+
for line in result.stdout.strip().splitlines():
|
|
152
|
+
if line.strip():
|
|
153
|
+
try:
|
|
154
|
+
networks.append(json.loads(line))
|
|
155
|
+
except json.JSONDecodeError:
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
rows = [
|
|
159
|
+
[
|
|
160
|
+
n.get("Name", ""),
|
|
161
|
+
n.get("Driver", ""),
|
|
162
|
+
n.get("Scope", ""),
|
|
163
|
+
"[green]yes[/green]" if n.get("Name") == "dokploy-network" else "",
|
|
164
|
+
]
|
|
165
|
+
for n in networks
|
|
166
|
+
]
|
|
167
|
+
out.table(
|
|
168
|
+
title=f"Docker Networks ({len(networks)})",
|
|
169
|
+
columns=[("Name", "bold"), ("Driver", ""), ("Scope", ""), ("Dokploy", "")],
|
|
170
|
+
rows=rows,
|
|
171
|
+
data_for_json=networks,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Check dokploy-network
|
|
175
|
+
has_dokploy = any(n.get("Name") == "dokploy-network" for n in networks)
|
|
176
|
+
if not has_dokploy:
|
|
177
|
+
out.warn("dokploy-network not found. Create it: docker network create dokploy-network")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# volumes
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
@app.command()
|
|
184
|
+
def volumes(ctx: typer.Context) -> None:
|
|
185
|
+
"""List Docker volumes used by the project."""
|
|
186
|
+
actx: AppContext = ctx.obj
|
|
187
|
+
out = actx.output
|
|
188
|
+
|
|
189
|
+
result = subprocess.run(
|
|
190
|
+
["docker", "volume", "ls", "--format", "json"],
|
|
191
|
+
capture_output=True,
|
|
192
|
+
text=True,
|
|
193
|
+
)
|
|
194
|
+
if result.returncode != 0:
|
|
195
|
+
out.error(f"docker volume ls failed: {result.stderr.strip()}")
|
|
196
|
+
raise typer.Exit(1)
|
|
197
|
+
|
|
198
|
+
import json
|
|
199
|
+
|
|
200
|
+
volume_list: list[dict] = []
|
|
201
|
+
for line in result.stdout.strip().splitlines():
|
|
202
|
+
if line.strip():
|
|
203
|
+
try:
|
|
204
|
+
volume_list.append(json.loads(line))
|
|
205
|
+
except json.JSONDecodeError:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
rows = [[v.get("Name", ""), v.get("Driver", ""), v.get("Labels", "")] for v in volume_list]
|
|
209
|
+
out.table(
|
|
210
|
+
title=f"Docker Volumes ({len(volume_list)})",
|
|
211
|
+
columns=[("Name", "bold"), ("Driver", ""), ("Labels", "")],
|
|
212
|
+
rows=rows,
|
|
213
|
+
data_for_json=volume_list,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# system
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
@app.command()
|
|
221
|
+
def system(ctx: typer.Context) -> None:
|
|
222
|
+
"""Show Docker system disk usage and resource summary."""
|
|
223
|
+
actx: AppContext = ctx.obj
|
|
224
|
+
out = actx.output
|
|
225
|
+
|
|
226
|
+
result = subprocess.run(
|
|
227
|
+
["docker", "system", "df"],
|
|
228
|
+
capture_output=True,
|
|
229
|
+
text=True,
|
|
230
|
+
)
|
|
231
|
+
if result.returncode != 0:
|
|
232
|
+
out.error(f"docker system df failed: {result.stderr.strip()}")
|
|
233
|
+
raise typer.Exit(1)
|
|
234
|
+
|
|
235
|
+
out.text(result.stdout)
|
|
236
|
+
|
|
237
|
+
# Also show info summary
|
|
238
|
+
info_result = subprocess.run(
|
|
239
|
+
[
|
|
240
|
+
"docker",
|
|
241
|
+
"info",
|
|
242
|
+
"--format",
|
|
243
|
+
"Containers: {{.Containers}} running / Images: {{.Images}} / Memory: {{.MemTotal}}",
|
|
244
|
+
],
|
|
245
|
+
capture_output=True,
|
|
246
|
+
text=True,
|
|
247
|
+
)
|
|
248
|
+
if info_result.returncode == 0:
|
|
249
|
+
out.text(info_result.stdout.strip())
|
|
250
|
+
|
|
251
|
+
if actx.json_mode:
|
|
252
|
+
out.raw_json({"output": result.stdout})
|