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 ADDED
@@ -0,0 +1,3 @@
1
+ """coxyz — CLI to manage Docker services under /srv/docker."""
2
+
3
+ __version__ = "0.2.0"
coxyz/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import cli_main
2
+
3
+ if __name__ == "__main__":
4
+ cli_main()
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()