coxyz-cli 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.
- coxyz/__init__.py +3 -0
- coxyz/__main__.py +4 -0
- coxyz/cli.py +611 -0
- coxyz/config.py +218 -0
- coxyz/default_config.yaml +73 -0
- coxyz/policy.py +557 -0
- coxyz/scaffold.py +90 -0
- coxyz/system.py +286 -0
- coxyz/templates/compose.yaml.tpl +39 -0
- coxyz_cli-0.2.0.dist-info/METADATA +170 -0
- coxyz_cli-0.2.0.dist-info/RECORD +14 -0
- coxyz_cli-0.2.0.dist-info/WHEEL +5 -0
- coxyz_cli-0.2.0.dist-info/entry_points.txt +2 -0
- coxyz_cli-0.2.0.dist-info/top_level.txt +1 -0
coxyz/__init__.py
ADDED
coxyz/__main__.py
ADDED
coxyz/cli.py
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
"""coxyz CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from importlib.resources import files
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Annotated, Optional
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .config import Config, load_config
|
|
22
|
+
from .policy import (
|
|
23
|
+
Finding,
|
|
24
|
+
ServiceReport,
|
|
25
|
+
Severity,
|
|
26
|
+
apply_findings,
|
|
27
|
+
audit_category,
|
|
28
|
+
audit_service,
|
|
29
|
+
list_categories,
|
|
30
|
+
list_services,
|
|
31
|
+
resolve_service,
|
|
32
|
+
)
|
|
33
|
+
from .scaffold import CreateRequest, create_service, validate_service_name
|
|
34
|
+
from .system import (
|
|
35
|
+
CommandExecutionError,
|
|
36
|
+
check_required_bins,
|
|
37
|
+
detect_acl_support,
|
|
38
|
+
principal_exists,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
app = typer.Typer(
|
|
42
|
+
name="coxyz",
|
|
43
|
+
help="CLI to manage Docker services under /srv/docker.",
|
|
44
|
+
no_args_is_help=True,
|
|
45
|
+
add_completion=True,
|
|
46
|
+
)
|
|
47
|
+
console = Console()
|
|
48
|
+
err_console = Console(stderr=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ─── Global state ─────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
class Ctx:
|
|
54
|
+
config: Config
|
|
55
|
+
config_source: Optional[Path]
|
|
56
|
+
acl_enabled: bool
|
|
57
|
+
principals_available: dict[str, bool]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
ctx = Ctx()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _version_callback(value: bool) -> None:
|
|
64
|
+
if value:
|
|
65
|
+
console.print(f"coxyz {__version__}")
|
|
66
|
+
raise typer.Exit()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.callback()
|
|
70
|
+
def main(
|
|
71
|
+
config_path: Annotated[
|
|
72
|
+
Optional[Path],
|
|
73
|
+
typer.Option("--config", "-c", help="Path to config.yaml (overrides defaults)."),
|
|
74
|
+
] = None,
|
|
75
|
+
version: Annotated[
|
|
76
|
+
Optional[bool],
|
|
77
|
+
typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version."),
|
|
78
|
+
] = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Manage Docker services under /srv/docker (coxyz rules)."""
|
|
81
|
+
missing = check_required_bins()
|
|
82
|
+
if missing:
|
|
83
|
+
err_console.print(f"[red]ERROR[/red] Missing required binaries: {', '.join(missing)}")
|
|
84
|
+
raise typer.Exit(code=2)
|
|
85
|
+
try:
|
|
86
|
+
cfg, source = load_config(config_path)
|
|
87
|
+
except (FileNotFoundError, ValueError) as e:
|
|
88
|
+
err_console.print(f"[red]ERROR[/red] Config: {e}")
|
|
89
|
+
raise typer.Exit(code=2)
|
|
90
|
+
ctx.config = cfg
|
|
91
|
+
ctx.config_source = source
|
|
92
|
+
ctx.acl_enabled = detect_acl_support(cfg.root_dir)
|
|
93
|
+
ctx.principals_available = {
|
|
94
|
+
name: principal_exists(principal.name, principal.kind)
|
|
95
|
+
for name, principal in cfg.settings.principals.items()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
def _severity_style(sev: Severity) -> str:
|
|
102
|
+
return {
|
|
103
|
+
Severity.OK: "green",
|
|
104
|
+
Severity.DRIFT: "yellow",
|
|
105
|
+
Severity.WARN: "magenta",
|
|
106
|
+
Severity.ERROR: "red",
|
|
107
|
+
}[sev]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _severity_symbol(sev: Severity) -> str:
|
|
111
|
+
return {
|
|
112
|
+
Severity.OK: "✓",
|
|
113
|
+
Severity.DRIFT: "✗",
|
|
114
|
+
Severity.WARN: "!",
|
|
115
|
+
Severity.ERROR: "✗",
|
|
116
|
+
}[sev]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _print_runtime_banner() -> None:
|
|
120
|
+
src = str(ctx.config_source) if ctx.config_source else "<bundled default>"
|
|
121
|
+
principals = ", ".join(
|
|
122
|
+
f"{p.name}({p.kind})" for p in ctx.config.settings.principals.values()
|
|
123
|
+
)
|
|
124
|
+
console.print(
|
|
125
|
+
f"[dim]root={ctx.config.root_dir} "
|
|
126
|
+
f"principals={principals} "
|
|
127
|
+
f"acl={'on' if ctx.acl_enabled else 'off'} "
|
|
128
|
+
f"config={src}[/dim]"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _print_finding(finding: Finding, *, indent: str = " ") -> None:
|
|
133
|
+
style = _severity_style(finding.severity)
|
|
134
|
+
sym = _severity_symbol(finding.severity)
|
|
135
|
+
console.print(
|
|
136
|
+
f"{indent}[{style}]{sym}[/{style}] "
|
|
137
|
+
f"[dim]{finding.rule_name:14}[/dim] {finding.path}"
|
|
138
|
+
)
|
|
139
|
+
for issue in finding.issues:
|
|
140
|
+
console.print(f"{indent} [dim]→[/dim] {issue}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _display_target_for_apply(path: Path) -> str:
|
|
144
|
+
try:
|
|
145
|
+
rel = path.resolve().relative_to(ctx.config.root_dir.resolve())
|
|
146
|
+
except ValueError:
|
|
147
|
+
return str(path)
|
|
148
|
+
parts = rel.parts
|
|
149
|
+
if len(parts) >= 2 and parts[0] in ctx.config.categories:
|
|
150
|
+
return f"{parts[0]}/{parts[1]}"
|
|
151
|
+
if len(parts) >= 1 and parts[0] in ctx.config.categories:
|
|
152
|
+
return parts[0]
|
|
153
|
+
return str(path)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _print_finding_apply(finding: Finding, *, indent: str = " ") -> None:
|
|
157
|
+
style = _severity_style(finding.severity)
|
|
158
|
+
sym = _severity_symbol(finding.severity)
|
|
159
|
+
target = _display_target_for_apply(finding.path)
|
|
160
|
+
console.print(
|
|
161
|
+
f"{indent}[{style}]{sym}[/{style}] "
|
|
162
|
+
f"[dim]{finding.rule_name:14}[/dim] {target}"
|
|
163
|
+
)
|
|
164
|
+
for issue in finding.issues:
|
|
165
|
+
console.print(f"{indent} [dim]→[/dim] {issue}")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ─── Compose parsing for `list` ───────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
_IMAGE_RE = re.compile(r"^\s*image:\s*(.+?)\s*$", re.MULTILINE)
|
|
171
|
+
_EXPOSE_RE = re.compile(r"^\s*-\s*[\"']?(\d+(?:/\w+)?)[\"']?\s*$", re.MULTILINE)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _parse_compose_summary(compose_path: Path) -> tuple[str, list[str]]:
|
|
175
|
+
"""Cheap parse of image + expose ports without pulling in a YAML lib for this view."""
|
|
176
|
+
if not compose_path.is_file():
|
|
177
|
+
return "—", []
|
|
178
|
+
try:
|
|
179
|
+
text = compose_path.read_text(encoding="utf-8")
|
|
180
|
+
except OSError:
|
|
181
|
+
return "?", []
|
|
182
|
+
image_match = _IMAGE_RE.search(text)
|
|
183
|
+
image = image_match.group(1).strip().strip("\"'") if image_match else "—"
|
|
184
|
+
# Capture ports under expose: only
|
|
185
|
+
ports: list[str] = []
|
|
186
|
+
in_expose = False
|
|
187
|
+
for line in text.splitlines():
|
|
188
|
+
stripped = line.strip()
|
|
189
|
+
if stripped.startswith("expose:"):
|
|
190
|
+
in_expose = True
|
|
191
|
+
continue
|
|
192
|
+
if in_expose:
|
|
193
|
+
if stripped.startswith("- "):
|
|
194
|
+
p = stripped[2:].strip().strip("\"'")
|
|
195
|
+
ports.append(p)
|
|
196
|
+
elif stripped and not line.startswith((" ", "\t")):
|
|
197
|
+
in_expose = False
|
|
198
|
+
elif stripped and ":" in stripped and not stripped.startswith("- "):
|
|
199
|
+
in_expose = False
|
|
200
|
+
return image, ports
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ─── Commands ─────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
@app.command("list")
|
|
206
|
+
def list_cmd(
|
|
207
|
+
category: Annotated[
|
|
208
|
+
Optional[str],
|
|
209
|
+
typer.Option("--category", "-C", help="Filter by category."),
|
|
210
|
+
] = None,
|
|
211
|
+
) -> None:
|
|
212
|
+
"""List services with image, ports, and compliance status."""
|
|
213
|
+
_print_runtime_banner()
|
|
214
|
+
|
|
215
|
+
services = list_services(ctx.config, category=category)
|
|
216
|
+
if not services:
|
|
217
|
+
console.print("[dim]No services found.[/dim]")
|
|
218
|
+
raise typer.Exit()
|
|
219
|
+
|
|
220
|
+
table = Table(show_header=True, header_style="bold")
|
|
221
|
+
table.add_column("Category")
|
|
222
|
+
table.add_column("Service")
|
|
223
|
+
table.add_column("Image")
|
|
224
|
+
table.add_column("Ports")
|
|
225
|
+
table.add_column("Status")
|
|
226
|
+
|
|
227
|
+
n_compliant = 0
|
|
228
|
+
n_drift = 0
|
|
229
|
+
n_warn = 0
|
|
230
|
+
|
|
231
|
+
for cat, svc, path in services:
|
|
232
|
+
image, ports = _parse_compose_summary(path / "compose.yaml")
|
|
233
|
+
report = audit_service(
|
|
234
|
+
ctx.config, cat, svc,
|
|
235
|
+
acl_enabled=ctx.acl_enabled,
|
|
236
|
+
principals_available=ctx.principals_available,
|
|
237
|
+
)
|
|
238
|
+
if report.compliant:
|
|
239
|
+
status = Text("✓ ok", style="green")
|
|
240
|
+
n_compliant += 1
|
|
241
|
+
elif report.drift_count > 0:
|
|
242
|
+
status = Text(f"✗ {report.drift_count} drift", style="yellow")
|
|
243
|
+
n_drift += 1
|
|
244
|
+
if report.warn_count:
|
|
245
|
+
status.append(f", {report.warn_count} warn", style="magenta")
|
|
246
|
+
else:
|
|
247
|
+
status = Text(f"! {report.warn_count} warn", style="magenta")
|
|
248
|
+
n_warn += 1
|
|
249
|
+
|
|
250
|
+
table.add_row(
|
|
251
|
+
cat, svc, image,
|
|
252
|
+
", ".join(ports) if ports else "—",
|
|
253
|
+
status,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
console.print(table)
|
|
257
|
+
console.print(
|
|
258
|
+
f"[dim]Total {len(services)} — "
|
|
259
|
+
f"[green]{n_compliant} compliant[/green], "
|
|
260
|
+
f"[yellow]{n_drift} with drift[/yellow], "
|
|
261
|
+
f"[magenta]{n_warn} warn-only[/magenta][/dim]"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@app.command("check")
|
|
266
|
+
def check_cmd(
|
|
267
|
+
service: Annotated[
|
|
268
|
+
Optional[str],
|
|
269
|
+
typer.Argument(help="Service name (e.g. 'bitwarden' or 'apps/bitwarden'). Default: all."),
|
|
270
|
+
] = None,
|
|
271
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show OK findings too.")] = False,
|
|
272
|
+
) -> None:
|
|
273
|
+
"""Audit permissions/ACL (read-only). Exits non-zero if drift detected."""
|
|
274
|
+
_print_runtime_banner()
|
|
275
|
+
|
|
276
|
+
if service:
|
|
277
|
+
try:
|
|
278
|
+
cat, svc, _ = resolve_service(ctx.config, service)
|
|
279
|
+
reports = [audit_service(
|
|
280
|
+
ctx.config, cat, svc,
|
|
281
|
+
acl_enabled=ctx.acl_enabled, principals_available=ctx.principals_available,
|
|
282
|
+
)]
|
|
283
|
+
except ValueError as e:
|
|
284
|
+
err_console.print(f"[red]ERROR[/red] {e}")
|
|
285
|
+
raise typer.Exit(code=2)
|
|
286
|
+
else:
|
|
287
|
+
reports = [
|
|
288
|
+
audit_service(
|
|
289
|
+
ctx.config, cat, svc,
|
|
290
|
+
acl_enabled=ctx.acl_enabled, principals_available=ctx.principals_available,
|
|
291
|
+
)
|
|
292
|
+
for cat, svc, _ in list_services(ctx.config)
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
# Also audit each category directory once
|
|
296
|
+
cat_findings: dict[str, Finding] = {}
|
|
297
|
+
for cat in list_categories(ctx.config):
|
|
298
|
+
cat_findings[cat] = audit_category(
|
|
299
|
+
ctx.config, cat,
|
|
300
|
+
acl_enabled=ctx.acl_enabled, principals_available=ctx.principals_available,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
total_drift = 0
|
|
304
|
+
total_warn = 0
|
|
305
|
+
for f in cat_findings.values():
|
|
306
|
+
if f.severity is Severity.DRIFT:
|
|
307
|
+
total_drift += 1
|
|
308
|
+
elif f.severity is Severity.WARN:
|
|
309
|
+
total_warn += 1
|
|
310
|
+
|
|
311
|
+
# Print category findings (only non-OK or in verbose)
|
|
312
|
+
if any(f.severity is not Severity.OK for f in cat_findings.values()) or verbose:
|
|
313
|
+
console.print("\n[bold]Categories[/bold]")
|
|
314
|
+
for f in cat_findings.values():
|
|
315
|
+
if verbose or f.severity is not Severity.OK:
|
|
316
|
+
_print_finding(f)
|
|
317
|
+
|
|
318
|
+
for report in reports:
|
|
319
|
+
if report.compliant and not verbose:
|
|
320
|
+
console.print(
|
|
321
|
+
f"\n[bold]{report.category}/{report.service}[/bold] [green]✓ compliant[/green]"
|
|
322
|
+
)
|
|
323
|
+
continue
|
|
324
|
+
marker = "[green]✓[/green]" if report.compliant else "[yellow]✗[/yellow]"
|
|
325
|
+
console.print(f"\n[bold]{report.category}/{report.service}[/bold] {marker}")
|
|
326
|
+
for finding in report.findings:
|
|
327
|
+
if verbose or finding.severity is not Severity.OK:
|
|
328
|
+
_print_finding(finding)
|
|
329
|
+
total_drift += report.drift_count
|
|
330
|
+
total_warn += report.warn_count
|
|
331
|
+
|
|
332
|
+
console.print(
|
|
333
|
+
f"\n[dim]Summary:[/dim] "
|
|
334
|
+
f"[yellow]{total_drift} drift[/yellow], "
|
|
335
|
+
f"[magenta]{total_warn} warn-only[/magenta]"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if total_drift > 0:
|
|
339
|
+
raise typer.Exit(code=1)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@app.command("apply")
|
|
343
|
+
def apply_cmd(
|
|
344
|
+
service: Annotated[
|
|
345
|
+
Optional[str],
|
|
346
|
+
typer.Argument(help="Service name. Default: all."),
|
|
347
|
+
] = None,
|
|
348
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompt.")] = False,
|
|
349
|
+
) -> None:
|
|
350
|
+
"""Apply correct permissions/ACL to services after confirmation."""
|
|
351
|
+
_print_runtime_banner()
|
|
352
|
+
|
|
353
|
+
if service:
|
|
354
|
+
try:
|
|
355
|
+
cat, svc, _ = resolve_service(ctx.config, service)
|
|
356
|
+
targets: list[tuple[str, str]] = [(cat, svc)]
|
|
357
|
+
except ValueError as e:
|
|
358
|
+
err_console.print(f"[red]ERROR[/red] {e}")
|
|
359
|
+
raise typer.Exit(code=2)
|
|
360
|
+
else:
|
|
361
|
+
targets = [(c, s) for c, s, _ in list_services(ctx.config)]
|
|
362
|
+
|
|
363
|
+
# Audit categories AND services to build the full finding list
|
|
364
|
+
all_findings: list[Finding] = []
|
|
365
|
+
if not service:
|
|
366
|
+
for cat in list_categories(ctx.config):
|
|
367
|
+
all_findings.append(audit_category(
|
|
368
|
+
ctx.config, cat,
|
|
369
|
+
acl_enabled=ctx.acl_enabled, principals_available=ctx.principals_available,
|
|
370
|
+
))
|
|
371
|
+
else:
|
|
372
|
+
# When targeting one service, also include its category
|
|
373
|
+
cat = targets[0][0]
|
|
374
|
+
all_findings.append(audit_category(
|
|
375
|
+
ctx.config, cat,
|
|
376
|
+
acl_enabled=ctx.acl_enabled, principals_available=ctx.principals_available,
|
|
377
|
+
))
|
|
378
|
+
|
|
379
|
+
for cat, svc in targets:
|
|
380
|
+
report = audit_service(
|
|
381
|
+
ctx.config, cat, svc,
|
|
382
|
+
acl_enabled=ctx.acl_enabled, principals_available=ctx.principals_available,
|
|
383
|
+
)
|
|
384
|
+
all_findings.extend(report.findings)
|
|
385
|
+
|
|
386
|
+
drifts = [f for f in all_findings if f.severity is Severity.DRIFT]
|
|
387
|
+
warns = [f for f in all_findings if f.severity is Severity.WARN]
|
|
388
|
+
errors = [f for f in all_findings if f.severity is Severity.ERROR]
|
|
389
|
+
|
|
390
|
+
if not drifts and not warns and not errors:
|
|
391
|
+
console.print("[green]✓ All compliant — nothing to do.[/green]")
|
|
392
|
+
raise typer.Exit()
|
|
393
|
+
|
|
394
|
+
if drifts:
|
|
395
|
+
console.print(f"\n[yellow]Planned changes ({len(drifts)}):[/yellow]")
|
|
396
|
+
for f in drifts:
|
|
397
|
+
_print_finding_apply(f)
|
|
398
|
+
for cmd in f.fixes:
|
|
399
|
+
console.print(f" [dim]$[/dim] {' '.join(cmd)}")
|
|
400
|
+
|
|
401
|
+
if warns:
|
|
402
|
+
console.print(f"\n[magenta]Audit-only warnings ({len(warns)}) — NOT touched:[/magenta]")
|
|
403
|
+
for f in warns:
|
|
404
|
+
_print_finding_apply(f)
|
|
405
|
+
|
|
406
|
+
if errors:
|
|
407
|
+
console.print(f"\n[red]Errors ({len(errors)}) — blocking:[/red]")
|
|
408
|
+
for f in errors:
|
|
409
|
+
_print_finding_apply(f)
|
|
410
|
+
raise typer.Exit(code=2)
|
|
411
|
+
|
|
412
|
+
if not yes and not typer.confirm(f"\nApply {len(drifts)} fix(es)?"):
|
|
413
|
+
console.print("[dim]Aborted.[/dim]")
|
|
414
|
+
raise typer.Exit(code=1)
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
result = apply_findings(drifts, dry_run=False)
|
|
418
|
+
except CommandExecutionError as e:
|
|
419
|
+
err_console.print("[red]ERROR[/red] Failed to apply fixes: a shell command failed.")
|
|
420
|
+
err_console.print(f"[red]Command[/red]: {' '.join(e.command)}")
|
|
421
|
+
if e.stdout.strip():
|
|
422
|
+
err_console.print(f"[red]stdout[/red]:\n{e.stdout.rstrip()}")
|
|
423
|
+
if e.stderr.strip():
|
|
424
|
+
err_console.print(f"[red]stderr[/red]:\n{e.stderr.rstrip()}")
|
|
425
|
+
raise typer.Exit(code=2)
|
|
426
|
+
console.print(f"\n[green]✓ Done — {len(result.commands_run)} command(s) executed.[/green]")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@app.command("create")
|
|
430
|
+
def create_cmd(
|
|
431
|
+
category: Annotated[
|
|
432
|
+
Optional[str],
|
|
433
|
+
typer.Option("--category", "-C", help="Service category."),
|
|
434
|
+
] = None,
|
|
435
|
+
name: Annotated[
|
|
436
|
+
Optional[str],
|
|
437
|
+
typer.Option("--name", "-n", help="Service name."),
|
|
438
|
+
] = None,
|
|
439
|
+
image: Annotated[
|
|
440
|
+
Optional[str],
|
|
441
|
+
typer.Option("--image", "-i", help="Docker image (e.g. nginx:1.27)."),
|
|
442
|
+
] = None,
|
|
443
|
+
port: Annotated[
|
|
444
|
+
Optional[int],
|
|
445
|
+
typer.Option("--port", "-p", help="Internal port to expose."),
|
|
446
|
+
] = None,
|
|
447
|
+
timezone: Annotated[
|
|
448
|
+
Optional[str],
|
|
449
|
+
typer.Option("--timezone", help="TZ env value."),
|
|
450
|
+
] = None,
|
|
451
|
+
apply_changes: Annotated[
|
|
452
|
+
bool,
|
|
453
|
+
typer.Option("--apply", help="Actually create the service (default: dry-run)."),
|
|
454
|
+
] = False,
|
|
455
|
+
) -> None:
|
|
456
|
+
"""Scaffold a new service (interactive prompts for missing arguments)."""
|
|
457
|
+
_print_runtime_banner()
|
|
458
|
+
|
|
459
|
+
cfg = ctx.config
|
|
460
|
+
|
|
461
|
+
# Interactive prompts for missing arguments
|
|
462
|
+
if not category:
|
|
463
|
+
cats = sorted(cfg.categories)
|
|
464
|
+
console.print("Available categories: " + ", ".join(cats))
|
|
465
|
+
category = typer.prompt("Category", default=cats[0])
|
|
466
|
+
if category not in cfg.categories:
|
|
467
|
+
err_console.print(
|
|
468
|
+
f"[red]ERROR[/red] Unknown category '{category}'. "
|
|
469
|
+
f"Authorized: {', '.join(sorted(cfg.categories))}"
|
|
470
|
+
)
|
|
471
|
+
raise typer.Exit(code=2)
|
|
472
|
+
|
|
473
|
+
if not name:
|
|
474
|
+
name = typer.prompt("Service name")
|
|
475
|
+
try:
|
|
476
|
+
validate_service_name(name)
|
|
477
|
+
except ValueError as e:
|
|
478
|
+
err_console.print(f"[red]ERROR[/red] {e}")
|
|
479
|
+
raise typer.Exit(code=2)
|
|
480
|
+
|
|
481
|
+
if not image:
|
|
482
|
+
image = typer.prompt("Docker image (with tag)", default="your-image:latest")
|
|
483
|
+
if port is None:
|
|
484
|
+
port = typer.prompt(
|
|
485
|
+
"Internal port",
|
|
486
|
+
default=cfg.compose_template.default_internal_port,
|
|
487
|
+
type=int,
|
|
488
|
+
)
|
|
489
|
+
if not timezone:
|
|
490
|
+
timezone = typer.prompt("Timezone", default=cfg.compose_template.default_timezone)
|
|
491
|
+
|
|
492
|
+
req = CreateRequest(
|
|
493
|
+
category=category, service=name, image=image,
|
|
494
|
+
port=port, timezone=timezone,
|
|
495
|
+
)
|
|
496
|
+
svc_path = cfg.root_dir / category / name
|
|
497
|
+
|
|
498
|
+
console.print()
|
|
499
|
+
console.print("[bold]Will create:[/bold]")
|
|
500
|
+
console.print(f" path : {svc_path}/")
|
|
501
|
+
console.print(f" owner : {cfg.category(category).owner_spec}")
|
|
502
|
+
console.print(f" image : {image}")
|
|
503
|
+
console.print(f" port : {port}")
|
|
504
|
+
console.print(f" timezone : {timezone}")
|
|
505
|
+
console.print()
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
executed = create_service(
|
|
509
|
+
cfg, req,
|
|
510
|
+
dry_run=not apply_changes,
|
|
511
|
+
acl_enabled=ctx.acl_enabled,
|
|
512
|
+
principals_available=ctx.principals_available,
|
|
513
|
+
)
|
|
514
|
+
except (ValueError, RuntimeError) as e:
|
|
515
|
+
err_console.print(f"[red]ERROR[/red] {e}")
|
|
516
|
+
raise typer.Exit(code=2)
|
|
517
|
+
|
|
518
|
+
for cmd in executed:
|
|
519
|
+
prefix = "[dim]DRY[/dim]" if not apply_changes else "[green]RUN[/green]"
|
|
520
|
+
console.print(f" {prefix} {' '.join(cmd)}")
|
|
521
|
+
|
|
522
|
+
if not apply_changes:
|
|
523
|
+
console.print(
|
|
524
|
+
f"\n[dim]Dry-run mode — {len(executed)} action(s) planned. "
|
|
525
|
+
"Re-run with [bold]--apply[/bold] to execute.[/dim]"
|
|
526
|
+
)
|
|
527
|
+
else:
|
|
528
|
+
console.print(f"\n[green]✓ Created {svc_path}[/green]")
|
|
529
|
+
console.print(f" Next: edit {svc_path}/compose.yaml and deploy via Komodo.")
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@app.command("show-config")
|
|
533
|
+
def show_config_cmd() -> None:
|
|
534
|
+
"""Print the resolved configuration."""
|
|
535
|
+
_print_runtime_banner()
|
|
536
|
+
cfg = ctx.config
|
|
537
|
+
|
|
538
|
+
console.print(f"\n[bold]Categories[/bold] ({len(cfg.categories)})")
|
|
539
|
+
for name, c in sorted(cfg.categories.items()):
|
|
540
|
+
console.print(f" {name:12} → {c.owner_spec}")
|
|
541
|
+
|
|
542
|
+
console.print(f"\n[bold]Exclude[/bold] ({len(cfg.exclude)})")
|
|
543
|
+
for pattern in cfg.exclude:
|
|
544
|
+
console.print(f" - {pattern}")
|
|
545
|
+
|
|
546
|
+
console.print(f"\n[bold]Rules[/bold] ({len(cfg.rules)})")
|
|
547
|
+
table = Table(show_header=True, header_style="bold dim")
|
|
548
|
+
table.add_column("Rule")
|
|
549
|
+
table.add_column("Mode")
|
|
550
|
+
table.add_column("ACL")
|
|
551
|
+
table.add_column("Audit only")
|
|
552
|
+
table.add_column("Owner override")
|
|
553
|
+
for name, r in cfg.rules.items():
|
|
554
|
+
acl = "—"
|
|
555
|
+
if r.acl:
|
|
556
|
+
acl = ", ".join(f"{principal}:{perms}" for principal, perms in r.acl.items())
|
|
557
|
+
table.add_row(
|
|
558
|
+
name, r.mode,
|
|
559
|
+
acl,
|
|
560
|
+
"yes" if r.audit_only else "no",
|
|
561
|
+
r.owner or "—",
|
|
562
|
+
)
|
|
563
|
+
console.print(table)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
@app.command("edit")
|
|
567
|
+
def edit_cmd() -> None:
|
|
568
|
+
"""Edit the main configuration file."""
|
|
569
|
+
cfg_path = Path("/etc/coxyz/config.yaml")
|
|
570
|
+
if not cfg_path.exists():
|
|
571
|
+
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
|
572
|
+
src = files("coxyz").joinpath("default_config.yaml")
|
|
573
|
+
cfg_path.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
|
|
574
|
+
console.print(f"[dim]Created {cfg_path} from bundled defaults.[/dim]")
|
|
575
|
+
editor = os.environ.get("EDITOR")
|
|
576
|
+
if editor and editor.strip():
|
|
577
|
+
parts = shlex.split(editor)
|
|
578
|
+
if not parts:
|
|
579
|
+
err_console.print("[red]ERROR[/red] EDITOR is empty.")
|
|
580
|
+
raise typer.Exit(code=2)
|
|
581
|
+
editor_cmd = parts[0]
|
|
582
|
+
if shutil.which(editor_cmd) is None and not Path(editor_cmd).is_file():
|
|
583
|
+
err_console.print(f"[red]ERROR[/red] Editor not found: {editor_cmd}")
|
|
584
|
+
raise typer.Exit(code=2)
|
|
585
|
+
command = [*parts, str(cfg_path)]
|
|
586
|
+
else:
|
|
587
|
+
command = None
|
|
588
|
+
for candidate in ("nano", "vi", "vim"):
|
|
589
|
+
if shutil.which(candidate):
|
|
590
|
+
command = [candidate, str(cfg_path)]
|
|
591
|
+
break
|
|
592
|
+
if command is None:
|
|
593
|
+
err_console.print("[red]ERROR[/red] No editor found (set $EDITOR).")
|
|
594
|
+
raise typer.Exit(code=2)
|
|
595
|
+
try:
|
|
596
|
+
subprocess.run(command, check=True)
|
|
597
|
+
except subprocess.CalledProcessError:
|
|
598
|
+
err_console.print("[red]ERROR[/red] Editor exited with an error.")
|
|
599
|
+
raise typer.Exit(code=2)
|
|
600
|
+
except FileNotFoundError:
|
|
601
|
+
err_console.print(f"[red]ERROR[/red] Editor not found: {command[0]}")
|
|
602
|
+
raise typer.Exit(code=2)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def cli_main() -> None: # pragma: no cover
|
|
606
|
+
"""Module-level entry point (also used by `python -m coxyz`)."""
|
|
607
|
+
app()
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
if __name__ == "__main__": # pragma: no cover
|
|
611
|
+
cli_main()
|