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,350 @@
|
|
|
1
|
+
"""Security auditing commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Vulnerability scan, secret detection, CORS audit, headers check.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import pathlib
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
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="security", help="Security — audit, secrets, CORS, headers, deps.", no_args_is_help=True)
|
|
19
|
+
|
|
20
|
+
# Patterns for secret detection
|
|
21
|
+
_SECRET_PATTERNS: list[tuple[str, str]] = [
|
|
22
|
+
(r"(?i)(password|passwd|pwd)\s*=\s*['\"][^'\"]{6,}", "password assignment"),
|
|
23
|
+
(r"(?i)(secret|token|api[_-]?key|apikey)\s*=\s*['\"][^'\"]{8,}", "secret/token assignment"),
|
|
24
|
+
(r"(?i)authorization:\s*bearer\s+[a-zA-Z0-9._-]{20,}", "bearer token in code"),
|
|
25
|
+
(r"sk-[a-zA-Z0-9]{20,}", "OpenAI API key"),
|
|
26
|
+
(r"ghp_[a-zA-Z0-9]{36}", "GitHub personal token"),
|
|
27
|
+
(r"(?i)aws_secret_access_key\s*=\s*[a-zA-Z0-9/+]{40}", "AWS secret key"),
|
|
28
|
+
(r"-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----", "private key"),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# audit
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
@app.command()
|
|
36
|
+
def audit(ctx: typer.Context) -> None:
|
|
37
|
+
"""Run a combined security report: deps, secrets, and CORS."""
|
|
38
|
+
actx: AppContext = ctx.obj
|
|
39
|
+
out = actx.output
|
|
40
|
+
|
|
41
|
+
root = find_project_root()
|
|
42
|
+
out.header("Security Audit Report")
|
|
43
|
+
out.text("")
|
|
44
|
+
|
|
45
|
+
issues: list[dict] = []
|
|
46
|
+
|
|
47
|
+
# 1. Dependency vulnerabilities
|
|
48
|
+
import shutil
|
|
49
|
+
|
|
50
|
+
out.info("[1/3] Checking dependency vulnerabilities ...")
|
|
51
|
+
if shutil.which("pip-audit"):
|
|
52
|
+
result = subprocess.run(["pip-audit", "--format", "json"], capture_output=True, text=True, cwd=str(root))
|
|
53
|
+
import json
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(result.stdout)
|
|
57
|
+
vulns = data.get("vulnerabilities", [])
|
|
58
|
+
for v in vulns:
|
|
59
|
+
issues.append(
|
|
60
|
+
{
|
|
61
|
+
"category": "dependency",
|
|
62
|
+
"severity": "HIGH",
|
|
63
|
+
"detail": f"{v.get('name')} {v.get('version')}: {v.get('id')}",
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
if not vulns:
|
|
67
|
+
out.success(" No dependency vulnerabilities.")
|
|
68
|
+
else:
|
|
69
|
+
out.warn(f" {len(vulns)} vulnerabilities found.")
|
|
70
|
+
except Exception:
|
|
71
|
+
out.info(" pip-audit output could not be parsed.")
|
|
72
|
+
else:
|
|
73
|
+
out.info(" pip-audit not installed — skipping. Run: uv add --dev pip-audit")
|
|
74
|
+
|
|
75
|
+
# 2. Secret detection (simplified scan)
|
|
76
|
+
out.info("[2/3] Scanning for secrets in source ...")
|
|
77
|
+
secret_issues = _scan_secrets(root)
|
|
78
|
+
issues.extend(secret_issues)
|
|
79
|
+
if not secret_issues:
|
|
80
|
+
out.success(" No secrets detected in source code.")
|
|
81
|
+
else:
|
|
82
|
+
out.warn(f" {len(secret_issues)} potential secrets detected.")
|
|
83
|
+
|
|
84
|
+
# 3. CORS config
|
|
85
|
+
out.info("[3/3] Checking CORS configuration ...")
|
|
86
|
+
cors_issues = _check_cors_config(root)
|
|
87
|
+
issues.extend(cors_issues)
|
|
88
|
+
if not cors_issues:
|
|
89
|
+
out.success(" CORS configuration looks safe.")
|
|
90
|
+
else:
|
|
91
|
+
out.warn(f" {len(cors_issues)} CORS concern(s).")
|
|
92
|
+
|
|
93
|
+
out.text("")
|
|
94
|
+
if not issues:
|
|
95
|
+
out.success("Security audit complete — no issues found.")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
rows = [[i["category"], i["severity"], i["detail"][:80]] for i in issues]
|
|
99
|
+
out.table(
|
|
100
|
+
title=f"Security Issues ({len(issues)})",
|
|
101
|
+
columns=[("Category", "bold"), ("Severity", "red"), ("Detail", "")],
|
|
102
|
+
rows=rows,
|
|
103
|
+
data_for_json=issues,
|
|
104
|
+
)
|
|
105
|
+
raise typer.Exit(1)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _scan_secrets(root: pathlib.Path) -> list[dict]:
|
|
109
|
+
"""Scan Python source files for secret patterns."""
|
|
110
|
+
|
|
111
|
+
issues: list[dict] = []
|
|
112
|
+
skip_dirs = {".venv", "__pycache__", ".git", "node_modules", "dist", ".mypy_cache"}
|
|
113
|
+
|
|
114
|
+
for py_file in root.rglob("*.py"):
|
|
115
|
+
if any(part in skip_dirs for part in py_file.parts):
|
|
116
|
+
continue
|
|
117
|
+
try:
|
|
118
|
+
content = py_file.read_text(encoding="utf-8", errors="ignore")
|
|
119
|
+
except Exception:
|
|
120
|
+
continue
|
|
121
|
+
for pattern, label in _SECRET_PATTERNS:
|
|
122
|
+
if re.search(pattern, content):
|
|
123
|
+
issues.append(
|
|
124
|
+
{
|
|
125
|
+
"category": "secret",
|
|
126
|
+
"severity": "CRITICAL",
|
|
127
|
+
"detail": f"{py_file.relative_to(root)}: possible {label}",
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
break # One issue per file
|
|
131
|
+
|
|
132
|
+
return issues
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _check_cors_config(root: pathlib.Path) -> list[dict]:
|
|
136
|
+
"""Check for overly permissive CORS settings."""
|
|
137
|
+
|
|
138
|
+
issues: list[dict] = []
|
|
139
|
+
skip_dirs = {".venv", "__pycache__", ".git", "node_modules"}
|
|
140
|
+
|
|
141
|
+
wildcard_patterns = [
|
|
142
|
+
(r'allow_origins\s*=\s*\[.*["\']?\*["\']?.*\]', "allow_origins = ['*']"),
|
|
143
|
+
(r"CORSMiddleware.*origins.*\*", "CORS wildcard origin"),
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
for py_file in root.rglob("*.py"):
|
|
147
|
+
if any(part in skip_dirs for part in py_file.parts):
|
|
148
|
+
continue
|
|
149
|
+
try:
|
|
150
|
+
content = py_file.read_text(encoding="utf-8", errors="ignore")
|
|
151
|
+
except Exception:
|
|
152
|
+
continue
|
|
153
|
+
for pattern, label in wildcard_patterns:
|
|
154
|
+
if re.search(pattern, content):
|
|
155
|
+
issues.append(
|
|
156
|
+
{
|
|
157
|
+
"category": "cors",
|
|
158
|
+
"severity": "MEDIUM",
|
|
159
|
+
"detail": f"{py_file.relative_to(root)}: {label}",
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return issues
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# secrets
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
@app.command()
|
|
170
|
+
def secrets(
|
|
171
|
+
ctx: typer.Context,
|
|
172
|
+
path: Annotated[str | None, typer.Argument(help="Path to scan (default: project root).")] = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Scan source code for potential secrets and credentials."""
|
|
175
|
+
actx: AppContext = ctx.obj
|
|
176
|
+
out = actx.output
|
|
177
|
+
|
|
178
|
+
import pathlib
|
|
179
|
+
|
|
180
|
+
root = pathlib.Path(path) if path else find_project_root()
|
|
181
|
+
out.info(f"Scanning {root} for secrets ...")
|
|
182
|
+
|
|
183
|
+
# Try gitleaks if available
|
|
184
|
+
import shutil
|
|
185
|
+
|
|
186
|
+
if shutil.which("gitleaks"):
|
|
187
|
+
result = subprocess.run(
|
|
188
|
+
["gitleaks", "detect", "--source", str(root), "--report-format", "json"], capture_output=True, text=True
|
|
189
|
+
)
|
|
190
|
+
if result.returncode == 0:
|
|
191
|
+
out.success("No secrets detected (gitleaks).")
|
|
192
|
+
return
|
|
193
|
+
import json
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
findings = json.loads(result.stdout)
|
|
197
|
+
rows = [[f.get("RuleID", ""), f.get("File", ""), str(f.get("StartLine", ""))] for f in findings]
|
|
198
|
+
out.table(
|
|
199
|
+
title=f"Secret Findings ({len(findings)})",
|
|
200
|
+
columns=[("Rule", "bold"), ("File", ""), ("Line", "")],
|
|
201
|
+
rows=rows,
|
|
202
|
+
data_for_json=findings,
|
|
203
|
+
)
|
|
204
|
+
raise typer.Exit(1)
|
|
205
|
+
except Exception:
|
|
206
|
+
out.text(result.stdout[:2000])
|
|
207
|
+
raise typer.Exit(1) from None
|
|
208
|
+
|
|
209
|
+
# Fall back to regex scan
|
|
210
|
+
issues = _scan_secrets(root)
|
|
211
|
+
if not issues:
|
|
212
|
+
out.success("No secrets detected in Python source files.")
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
rows = [[i["severity"], i["detail"]] for i in issues]
|
|
216
|
+
out.table(
|
|
217
|
+
title=f"Potential Secrets ({len(issues)})",
|
|
218
|
+
columns=[("Severity", "bold"), ("Detail", "")],
|
|
219
|
+
rows=rows,
|
|
220
|
+
data_for_json=issues,
|
|
221
|
+
)
|
|
222
|
+
out.info("Install gitleaks for comprehensive scanning: https://github.com/gitleaks/gitleaks")
|
|
223
|
+
raise typer.Exit(1)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# cors
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
@app.command()
|
|
230
|
+
def cors(ctx: typer.Context) -> None:
|
|
231
|
+
"""Audit CORS configuration in the codebase."""
|
|
232
|
+
actx: AppContext = ctx.obj
|
|
233
|
+
out = actx.output
|
|
234
|
+
|
|
235
|
+
root = find_project_root()
|
|
236
|
+
out.info("Auditing CORS configuration ...")
|
|
237
|
+
|
|
238
|
+
issues = _check_cors_config(root)
|
|
239
|
+
|
|
240
|
+
# Also check env files for CORS_ORIGINS
|
|
241
|
+
|
|
242
|
+
env_file = root / ".env"
|
|
243
|
+
if env_file.exists():
|
|
244
|
+
content = env_file.read_text()
|
|
245
|
+
if "CORS_ORIGINS=*" in content or 'CORS_ORIGINS="*"' in content:
|
|
246
|
+
issues.append(
|
|
247
|
+
{
|
|
248
|
+
"category": "cors",
|
|
249
|
+
"severity": "HIGH",
|
|
250
|
+
"detail": ".env: CORS_ORIGINS=* (wildcard in env var)",
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
if not issues:
|
|
255
|
+
out.success("CORS configuration looks safe — no wildcard origins found.")
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
rows = [[i["severity"], i["detail"]] for i in issues]
|
|
259
|
+
out.table(
|
|
260
|
+
title=f"CORS Issues ({len(issues)})",
|
|
261
|
+
columns=[("Severity", "bold"), ("Detail", "red")],
|
|
262
|
+
rows=rows,
|
|
263
|
+
data_for_json=issues,
|
|
264
|
+
)
|
|
265
|
+
out.warn("Review CORS config. Wildcard origins allow any site to call your API.")
|
|
266
|
+
raise typer.Exit(1)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# headers
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
@app.command()
|
|
273
|
+
def headers(
|
|
274
|
+
ctx: typer.Context,
|
|
275
|
+
url: Annotated[str | None, typer.Argument(help="URL to check (default: configured API URL).")] = None,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Check security headers on a URL."""
|
|
278
|
+
actx: AppContext = ctx.obj
|
|
279
|
+
out = actx.output
|
|
280
|
+
|
|
281
|
+
import httpx
|
|
282
|
+
|
|
283
|
+
target_url = url or f"{actx.client.base_url.rstrip('/')}/api/v1/health"
|
|
284
|
+
out.info(f"Checking security headers: {target_url}")
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
response = httpx.get(target_url, timeout=10, follow_redirects=True)
|
|
288
|
+
except Exception as e:
|
|
289
|
+
out.error(f"Request failed: {e}")
|
|
290
|
+
raise typer.Exit(1) from None
|
|
291
|
+
|
|
292
|
+
security_headers = {
|
|
293
|
+
"Strict-Transport-Security": {"required": True, "note": "HSTS — forces HTTPS"},
|
|
294
|
+
"X-Content-Type-Options": {"required": True, "note": "Prevents MIME sniffing"},
|
|
295
|
+
"X-Frame-Options": {"required": False, "note": "Clickjacking protection"},
|
|
296
|
+
"Content-Security-Policy": {"required": False, "note": "XSS protection"},
|
|
297
|
+
"X-XSS-Protection": {"required": False, "note": "Browser XSS filter"},
|
|
298
|
+
"Referrer-Policy": {"required": False, "note": "Controls referrer info"},
|
|
299
|
+
"Permissions-Policy": {"required": False, "note": "Controls browser features"},
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
results: list[dict] = []
|
|
303
|
+
for header, meta in security_headers.items():
|
|
304
|
+
value = response.headers.get(header, "")
|
|
305
|
+
present = bool(value)
|
|
306
|
+
results.append(
|
|
307
|
+
{
|
|
308
|
+
"header": header,
|
|
309
|
+
"present": present,
|
|
310
|
+
"value": value[:60] if value else "",
|
|
311
|
+
"required": meta["required"],
|
|
312
|
+
"note": meta["note"],
|
|
313
|
+
}
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
rows = [
|
|
317
|
+
[
|
|
318
|
+
r["header"],
|
|
319
|
+
"[green]yes[/green]" if r["present"] else ("[red]NO[/red]" if r["required"] else "[yellow]no[/yellow]"),
|
|
320
|
+
r["value"] or r["note"],
|
|
321
|
+
]
|
|
322
|
+
for r in results
|
|
323
|
+
]
|
|
324
|
+
out.table(
|
|
325
|
+
title=f"Security Headers: {target_url}",
|
|
326
|
+
columns=[("Header", "bold"), ("Present", ""), ("Value / Note", "")],
|
|
327
|
+
rows=rows,
|
|
328
|
+
data_for_json={"url": target_url, "status_code": response.status_code, "headers": results},
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
missing_required = [r for r in results if r["required"] and not r["present"]]
|
|
332
|
+
if missing_required:
|
|
333
|
+
out.warn(f"{len(missing_required)} required security header(s) missing.")
|
|
334
|
+
raise typer.Exit(1)
|
|
335
|
+
else:
|
|
336
|
+
out.success("All required security headers present.")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
# deps
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
@app.command()
|
|
343
|
+
def deps(ctx: typer.Context) -> None:
|
|
344
|
+
"""Scan dependencies for known vulnerabilities (alias for: kctl-api deps audit)."""
|
|
345
|
+
_actx: AppContext = ctx.obj
|
|
346
|
+
|
|
347
|
+
# Delegate to deps audit
|
|
348
|
+
from kctl_api.commands.deps import audit as deps_audit
|
|
349
|
+
|
|
350
|
+
deps_audit(ctx)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Docker Compose service management commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
List, start, stop, restart, and view logs for services.
|
|
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="services", help="Docker Compose services — list, status, restart, logs.", no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _compose_cmd(args: list[str]) -> list[str]:
|
|
20
|
+
"""Build a docker compose command rooted at the project."""
|
|
21
|
+
root = find_project_root()
|
|
22
|
+
return ["docker", "compose", "-f", str(root / "docker-compose.yml"), *args]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# list
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
@app.command(name="list")
|
|
29
|
+
def list_services(ctx: typer.Context) -> None:
|
|
30
|
+
"""List all docker compose services and their status."""
|
|
31
|
+
actx: AppContext = ctx.obj
|
|
32
|
+
out = actx.output
|
|
33
|
+
|
|
34
|
+
result = subprocess.run(
|
|
35
|
+
_compose_cmd(["ps", "--format", "json"]),
|
|
36
|
+
capture_output=True,
|
|
37
|
+
text=True,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if result.returncode != 0:
|
|
41
|
+
out.error(f"docker compose ps failed: {result.stderr.strip()}")
|
|
42
|
+
raise typer.Exit(1)
|
|
43
|
+
|
|
44
|
+
import json
|
|
45
|
+
|
|
46
|
+
services: list[dict] = []
|
|
47
|
+
for line in result.stdout.strip().splitlines():
|
|
48
|
+
if line.strip():
|
|
49
|
+
try:
|
|
50
|
+
services.append(json.loads(line))
|
|
51
|
+
except json.JSONDecodeError:
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
if actx.json_mode:
|
|
55
|
+
out.raw_json(services)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
rows: list[list[str]] = []
|
|
59
|
+
for svc in services:
|
|
60
|
+
name = svc.get("Name", svc.get("Service", ""))
|
|
61
|
+
state = svc.get("State", svc.get("Status", ""))
|
|
62
|
+
health = svc.get("Health", "")
|
|
63
|
+
ports = svc.get("Publishers", svc.get("Ports", ""))
|
|
64
|
+
if isinstance(ports, list):
|
|
65
|
+
ports = ", ".join(
|
|
66
|
+
f"{p.get('PublishedPort', '')}:{p.get('TargetPort', '')}" for p in ports if p.get("PublishedPort")
|
|
67
|
+
)
|
|
68
|
+
rows.append([name, str(state), str(health), str(ports)])
|
|
69
|
+
|
|
70
|
+
out.table(
|
|
71
|
+
title="Docker Compose Services",
|
|
72
|
+
columns=[
|
|
73
|
+
("Name", "bold"),
|
|
74
|
+
("State", ""),
|
|
75
|
+
("Health", ""),
|
|
76
|
+
("Ports", "dim"),
|
|
77
|
+
],
|
|
78
|
+
rows=rows,
|
|
79
|
+
data_for_json=services,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# status
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
@app.command()
|
|
87
|
+
def status(
|
|
88
|
+
ctx: typer.Context,
|
|
89
|
+
service: Annotated[str, typer.Argument(help="Service name.")],
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Show status of a specific docker compose service."""
|
|
92
|
+
actx: AppContext = ctx.obj
|
|
93
|
+
result = subprocess.run(
|
|
94
|
+
_compose_cmd(["ps", service]),
|
|
95
|
+
capture_output=False,
|
|
96
|
+
)
|
|
97
|
+
if result.returncode != 0:
|
|
98
|
+
actx.output.error(f"Service '{service}' not found or not running.")
|
|
99
|
+
raise typer.Exit(result.returncode)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# restart
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
@app.command()
|
|
106
|
+
def restart(
|
|
107
|
+
ctx: typer.Context,
|
|
108
|
+
service: Annotated[str, typer.Argument(help="Service name to restart.")],
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Restart a docker compose service."""
|
|
111
|
+
actx: AppContext = ctx.obj
|
|
112
|
+
out = actx.output
|
|
113
|
+
|
|
114
|
+
out.info(f"Restarting {service} ...")
|
|
115
|
+
result = subprocess.run(_compose_cmd(["restart", service]), capture_output=False)
|
|
116
|
+
if result.returncode != 0:
|
|
117
|
+
out.error(f"Failed to restart {service}.")
|
|
118
|
+
raise typer.Exit(result.returncode)
|
|
119
|
+
out.success(f"Service {service} restarted.")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# logs
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
@app.command()
|
|
126
|
+
def logs(
|
|
127
|
+
ctx: typer.Context,
|
|
128
|
+
service: Annotated[str, typer.Argument(help="Service name.")],
|
|
129
|
+
follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow log output.")] = False,
|
|
130
|
+
tail: Annotated[int, typer.Option("--tail", "-n", help="Number of lines to show.")] = 100,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""View logs for a docker compose service."""
|
|
133
|
+
cmd = _compose_cmd(["logs", "--tail", str(tail)])
|
|
134
|
+
if follow:
|
|
135
|
+
cmd.append("--follow")
|
|
136
|
+
cmd.append(service)
|
|
137
|
+
subprocess.run(cmd, capture_output=False)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# up
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
@app.command()
|
|
144
|
+
def up(
|
|
145
|
+
ctx: typer.Context,
|
|
146
|
+
service: Annotated[str | None, typer.Argument(help="Service name (all if omitted).")] = None,
|
|
147
|
+
build: Annotated[bool, typer.Option("--build", help="Rebuild images.")] = False,
|
|
148
|
+
detach: Annotated[bool, typer.Option("--detach", "-d", help="Detached mode.")] = True,
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Start docker compose services."""
|
|
151
|
+
actx: AppContext = ctx.obj
|
|
152
|
+
out = actx.output
|
|
153
|
+
|
|
154
|
+
cmd = _compose_cmd(["up"])
|
|
155
|
+
if build:
|
|
156
|
+
cmd.append("--build")
|
|
157
|
+
if detach:
|
|
158
|
+
cmd.append("-d")
|
|
159
|
+
if service:
|
|
160
|
+
cmd.append(service)
|
|
161
|
+
|
|
162
|
+
out.info("Starting services ...")
|
|
163
|
+
result = subprocess.run(cmd, capture_output=False)
|
|
164
|
+
if result.returncode != 0:
|
|
165
|
+
out.error("Failed to start services.")
|
|
166
|
+
raise typer.Exit(result.returncode)
|
|
167
|
+
out.success("Services started.")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# down
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
@app.command()
|
|
174
|
+
def down(
|
|
175
|
+
ctx: typer.Context,
|
|
176
|
+
volumes: Annotated[bool, typer.Option("--volumes", "-v", help="Remove volumes too.")] = False,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Stop and remove docker compose services."""
|
|
179
|
+
actx: AppContext = ctx.obj
|
|
180
|
+
out = actx.output
|
|
181
|
+
|
|
182
|
+
cmd = _compose_cmd(["down"])
|
|
183
|
+
if volumes:
|
|
184
|
+
cmd.append("--volumes")
|
|
185
|
+
|
|
186
|
+
out.info("Stopping services ...")
|
|
187
|
+
result = subprocess.run(cmd, capture_output=False)
|
|
188
|
+
if result.returncode != 0:
|
|
189
|
+
out.error("Failed to stop services.")
|
|
190
|
+
raise typer.Exit(result.returncode)
|
|
191
|
+
out.success("Services stopped.")
|