clonebox 1.1.13__py3-none-any.whl → 1.1.15__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.
clonebox/cli.py CHANGED
@@ -34,6 +34,10 @@ from clonebox.monitor import ResourceMonitor, format_bytes
34
34
  from clonebox.p2p import P2PManager
35
35
  from clonebox.snapshots import SnapshotManager, SnapshotType
36
36
  from clonebox.health import HealthCheckManager, ProbeConfig, ProbeType
37
+ from clonebox.audit import get_audit_logger, AuditQuery, AuditEventType, AuditOutcome
38
+ from clonebox.orchestrator import Orchestrator, OrchestrationResult
39
+ from clonebox.plugins import get_plugin_manager, PluginHook, PluginContext
40
+ from clonebox.remote import RemoteCloner, RemoteConnection
37
41
 
38
42
  # Custom questionary style
39
43
  custom_style = Style(
@@ -458,6 +462,9 @@ def run_vm_diagnostics(
458
462
  if health_status and "HEALTH_STATUS=OK" in health_status:
459
463
  result["health"]["status"] = "ok"
460
464
  console.print("[green]✅ Health: All checks passed[/]")
465
+ elif health_status and "HEALTH_STATUS=PENDING" in health_status:
466
+ result["health"]["status"] = "pending"
467
+ console.print("[yellow]⏳ Health: Setup in progress[/]")
461
468
  elif health_status and "HEALTH_STATUS=FAILED" in health_status:
462
469
  result["health"]["status"] = "failed"
463
470
  console.print("[red]❌ Health: Some checks failed[/]")
@@ -595,6 +602,78 @@ def cmd_repair(args):
595
602
  )
596
603
 
597
604
 
605
+ def cmd_logs(args):
606
+ """View logs from VM."""
607
+ import subprocess
608
+ import sys
609
+
610
+ name = args.name
611
+ user_session = getattr(args, "user", False)
612
+ show_all = getattr(args, "all", False)
613
+
614
+ try:
615
+ vm_name, _ = _resolve_vm_name_and_config_file(name)
616
+ except FileNotFoundError as e:
617
+ console.print(f"[red]❌ {e}[/]")
618
+ return
619
+
620
+ # Path to the logs script
621
+ script_dir = Path(__file__).parent.parent.parent / "scripts"
622
+ logs_script = script_dir / "clonebox-logs.sh"
623
+
624
+ if not logs_script.exists():
625
+ console.print(f"[red]❌ Logs script not found: {logs_script}[/]")
626
+ return
627
+
628
+ # Run the logs script
629
+ try:
630
+ console.print(f"[cyan]📋 Opening logs for VM: {vm_name}[/]")
631
+ subprocess.run(
632
+ [str(logs_script), vm_name, "true" if user_session else "false", "true" if show_all else "false"],
633
+ check=True
634
+ )
635
+ except subprocess.CalledProcessError as e:
636
+ console.print(f"[red]❌ Failed to view logs: {e}[/]")
637
+ sys.exit(1)
638
+ except KeyboardInterrupt:
639
+ console.print("\n[yellow]Interrupted.[/]")
640
+
641
+
642
+ def cmd_set_password(args):
643
+ """Set password for VM user."""
644
+ import subprocess
645
+ import sys
646
+
647
+ name = args.name
648
+ user_session = getattr(args, "user", False)
649
+
650
+ try:
651
+ vm_name, _ = _resolve_vm_name_and_config_file(name)
652
+ except FileNotFoundError as e:
653
+ console.print(f"[red]❌ {e}[/]")
654
+ return
655
+
656
+ # Path to the set-password script
657
+ script_dir = Path(__file__).parent.parent.parent / "scripts"
658
+ set_password_script = script_dir / "set-vm-password.sh"
659
+
660
+ if not set_password_script.exists():
661
+ console.print(f"[red]❌ Set password script not found: {set_password_script}[/]")
662
+ return
663
+
664
+ # Run the set-password script interactively
665
+ try:
666
+ console.print(f"[cyan]🔐 Setting password for VM: {vm_name}[/]")
667
+ subprocess.run(
668
+ [str(set_password_script), vm_name, "true" if user_session else "false"]
669
+ )
670
+ except subprocess.CalledProcessError as e:
671
+ console.print(f"[red]❌ Failed to set password: {e}[/]")
672
+ sys.exit(1)
673
+ except KeyboardInterrupt:
674
+ console.print("\n[yellow]Interrupted.[/]")
675
+
676
+
598
677
  def interactive_mode():
599
678
  """Run the interactive VM creation wizard."""
600
679
  print_banner()
@@ -1800,6 +1879,8 @@ def cmd_test(args):
1800
1879
  console.print()
1801
1880
 
1802
1881
  # Test 3: Check cloud-init status (if running)
1882
+ cloud_init_complete: Optional[bool] = None
1883
+ cloud_init_running: bool = False
1803
1884
  if not quick and state == "running":
1804
1885
  console.print("[bold]3. Cloud-init Status[/]")
1805
1886
  try:
@@ -1809,16 +1890,23 @@ def cmd_test(args):
1809
1890
  status = _qga_exec(vm_name, conn_uri, "cloud-init status 2>/dev/null || true", timeout=15)
1810
1891
  if status is None:
1811
1892
  console.print("[yellow]⚠️ Could not check cloud-init (QGA command failed)[/]")
1893
+ cloud_init_complete = None
1812
1894
  elif "done" in status.lower():
1813
1895
  console.print("[green]✅ Cloud-init completed[/]")
1896
+ cloud_init_complete = True
1814
1897
  elif "running" in status.lower():
1815
1898
  console.print("[yellow]⚠️ Cloud-init still running[/]")
1899
+ cloud_init_complete = False
1900
+ cloud_init_running = True
1816
1901
  elif status.strip():
1817
1902
  console.print(f"[yellow]⚠️ Cloud-init status: {status.strip()}[/]")
1903
+ cloud_init_complete = None
1818
1904
  else:
1819
1905
  console.print("[yellow]⚠️ Cloud-init status: unknown[/]")
1906
+ cloud_init_complete = None
1820
1907
  except Exception:
1821
1908
  console.print("[yellow]⚠️ Could not check cloud-init (QEMU agent may not be running)[/]")
1909
+ cloud_init_complete = None
1822
1910
 
1823
1911
  console.print()
1824
1912
 
@@ -1877,17 +1965,33 @@ def cmd_test(args):
1877
1965
  timeout=10,
1878
1966
  )
1879
1967
  if exists and exists.strip() == "yes":
1880
- out = _qga_exec(
1968
+ _qga_exec(
1881
1969
  vm_name,
1882
1970
  conn_uri,
1883
- "/usr/local/bin/clonebox-health >/dev/null 2>&1 && echo yes || echo no",
1971
+ "/usr/local/bin/clonebox-health >/dev/null 2>&1 || true",
1884
1972
  timeout=60,
1885
1973
  )
1886
- if out and out.strip() == "yes":
1887
- console.print("[green]✅ Health check ran successfully[/]")
1974
+ health_status = _qga_exec(
1975
+ vm_name,
1976
+ conn_uri,
1977
+ "cat /var/log/clonebox-health-status 2>/dev/null || true",
1978
+ timeout=10,
1979
+ )
1980
+ if health_status and "HEALTH_STATUS=OK" in health_status:
1981
+ console.print("[green]✅ Health check passed[/]")
1888
1982
  console.print(" View results in VM: cat /var/log/clonebox-health.log")
1983
+ elif health_status and "HEALTH_STATUS=PENDING" in health_status:
1984
+ console.print("[yellow]⚠️ Health check pending (setup in progress)[/]")
1985
+ if cloud_init_running:
1986
+ console.print(" Cloud-init is still running; re-check after it completes")
1987
+ console.print(" View logs in VM: cat /var/log/clonebox-health.log")
1988
+ elif health_status and "HEALTH_STATUS=FAILED" in health_status:
1989
+ console.print("[yellow]⚠️ Health check reports failures[/]")
1990
+ if cloud_init_running:
1991
+ console.print(" Cloud-init is still running; some failures may be transient")
1992
+ console.print(" View logs in VM: cat /var/log/clonebox-health.log")
1889
1993
  else:
1890
- console.print("[yellow]⚠️ Health check did not report success[/]")
1994
+ console.print("[yellow]⚠️ Health check status not available yet[/]")
1891
1995
  console.print(" View logs in VM: cat /var/log/clonebox-health.log")
1892
1996
  else:
1893
1997
  console.print("[yellow]⚠️ Health check script not found[/]")
@@ -1941,7 +2045,7 @@ def load_env_file(env_path: Path) -> dict:
1941
2045
  continue
1942
2046
  if "=" in line:
1943
2047
  key, value = line.split("=", 1)
1944
- env_vars[key.strip()] = value.strip()
2048
+ env_vars[key.strip()] = value.strip().strip("'\"")
1945
2049
 
1946
2050
  return env_vars
1947
2051
 
@@ -1962,6 +2066,22 @@ def expand_env_vars(value, env_vars: dict):
1962
2066
  return value
1963
2067
 
1964
2068
 
2069
+ def _find_unexpanded_env_placeholders(value) -> set:
2070
+ if isinstance(value, str):
2071
+ return set(re.findall(r"\$\{([^}]+)\}", value))
2072
+ if isinstance(value, dict):
2073
+ found = set()
2074
+ for v in value.values():
2075
+ found |= _find_unexpanded_env_placeholders(v)
2076
+ return found
2077
+ if isinstance(value, list):
2078
+ found = set()
2079
+ for item in value:
2080
+ found |= _find_unexpanded_env_placeholders(item)
2081
+ return found
2082
+ return set()
2083
+
2084
+
1965
2085
  def deduplicate_list(items: list, key=None) -> list:
1966
2086
  """Remove duplicates from list, preserving order."""
1967
2087
  seen = set()
@@ -2218,6 +2338,14 @@ def load_clonebox_config(path: Path) -> dict:
2218
2338
  # Expand environment variables in config
2219
2339
  config = expand_env_vars(config, env_vars)
2220
2340
 
2341
+ unresolved = _find_unexpanded_env_placeholders(config)
2342
+ if unresolved:
2343
+ unresolved_sorted = ", ".join(sorted(unresolved))
2344
+ raise ValueError(
2345
+ f"Unresolved environment variables in config: {unresolved_sorted}. "
2346
+ f"Set them in {env_file} or in the process environment."
2347
+ )
2348
+
2221
2349
  return config
2222
2350
 
2223
2351
 
@@ -2327,9 +2455,32 @@ def create_vm_from_config(
2327
2455
  replace: bool = False,
2328
2456
  ) -> str:
2329
2457
  """Create VM from YAML config dict."""
2330
- # Merge paths and app_data_paths
2331
- all_paths = config.get("paths", {}).copy()
2332
- all_paths.update(config.get("app_data_paths", {}))
2458
+ paths = config.get("paths", {})
2459
+ # Backwards compatible: v1 uses app_data_paths, newer configs may use copy_paths
2460
+ copy_paths = config.get("copy_paths", None)
2461
+ if not isinstance(copy_paths, dict) or not copy_paths:
2462
+ copy_paths = config.get("app_data_paths", {})
2463
+
2464
+ vm_section = config.get("vm") or {}
2465
+
2466
+ # Support both v1 (auth_method) and v2 (auth.method) config formats
2467
+ auth_section = vm_section.get("auth") or {}
2468
+ auth_method = auth_section.get("method") or vm_section.get("auth_method") or "ssh_key"
2469
+
2470
+ # v2 config: secrets provider
2471
+ secrets_section = config.get("secrets") or {}
2472
+ secrets_provider = secrets_section.get("provider", "auto")
2473
+
2474
+ # v2 config: resource limits
2475
+ limits_section = config.get("limits") or {}
2476
+ resources = {
2477
+ "memory_limit": limits_section.get("memory_limit"),
2478
+ "cpu_shares": limits_section.get("cpu_shares"),
2479
+ "disk_limit": limits_section.get("disk_limit"),
2480
+ "network_limit": limits_section.get("network_limit"),
2481
+ }
2482
+ # Remove None values
2483
+ resources = {k: v for k, v in resources.items() if v is not None}
2333
2484
 
2334
2485
  vm_config = VMConfig(
2335
2486
  name=config["vm"]["name"],
@@ -2338,7 +2489,8 @@ def create_vm_from_config(
2338
2489
  disk_size_gb=config["vm"].get("disk_size_gb", 10),
2339
2490
  gui=config["vm"].get("gui", True),
2340
2491
  base_image=config["vm"].get("base_image"),
2341
- paths=all_paths,
2492
+ paths=paths,
2493
+ copy_paths=copy_paths,
2342
2494
  packages=config.get("packages", []),
2343
2495
  snap_packages=config.get("snap_packages", []),
2344
2496
  services=config.get("services", []),
@@ -2347,6 +2499,9 @@ def create_vm_from_config(
2347
2499
  network_mode=config["vm"].get("network_mode", "auto"),
2348
2500
  username=config["vm"].get("username", "ubuntu"),
2349
2501
  password=config["vm"].get("password", "ubuntu"),
2502
+ auth_method=auth_method,
2503
+ ssh_public_key=vm_section.get("ssh_public_key") or auth_section.get("ssh_public_key"),
2504
+ resources=resources if resources else config["vm"].get("resources", {}),
2350
2505
  )
2351
2506
 
2352
2507
  cloner = SelectiveVMCloner(user_session=user_session)
@@ -3098,6 +3253,634 @@ def cmd_list_remote(args) -> None:
3098
3253
  console.print("[yellow]No VMs found on remote host.[/]")
3099
3254
 
3100
3255
 
3256
+ # === Audit Commands ===
3257
+
3258
+
3259
+ def cmd_audit_list(args) -> None:
3260
+ """List audit events."""
3261
+ query = AuditQuery()
3262
+
3263
+ # Build filters
3264
+ event_type = None
3265
+ if hasattr(args, "type") and args.type:
3266
+ try:
3267
+ event_type = AuditEventType(args.type)
3268
+ except ValueError:
3269
+ console.print(f"[red]Unknown event type: {args.type}[/]")
3270
+ return
3271
+
3272
+ outcome = None
3273
+ if hasattr(args, "outcome") and args.outcome:
3274
+ try:
3275
+ outcome = AuditOutcome(args.outcome)
3276
+ except ValueError:
3277
+ console.print(f"[red]Unknown outcome: {args.outcome}[/]")
3278
+ return
3279
+
3280
+ limit = getattr(args, "limit", 50)
3281
+ target = getattr(args, "target", None)
3282
+
3283
+ events = query.query(
3284
+ event_type=event_type,
3285
+ target_name=target,
3286
+ outcome=outcome,
3287
+ limit=limit,
3288
+ )
3289
+
3290
+ if not events:
3291
+ console.print("[yellow]No audit events found.[/]")
3292
+ return
3293
+
3294
+ if getattr(args, "json", False):
3295
+ console.print_json(json.dumps([e.to_dict() for e in events], default=str))
3296
+ return
3297
+
3298
+ table = Table(title="Audit Events", border_style="cyan")
3299
+ table.add_column("Time", style="dim")
3300
+ table.add_column("Event")
3301
+ table.add_column("Target")
3302
+ table.add_column("Outcome")
3303
+ table.add_column("User")
3304
+
3305
+ for event in reversed(events[-limit:]):
3306
+ outcome_style = {
3307
+ "success": "green",
3308
+ "failure": "red",
3309
+ "partial": "yellow",
3310
+ "denied": "red bold",
3311
+ "skipped": "dim",
3312
+ }.get(event.outcome.value, "white")
3313
+
3314
+ target_str = event.target_name or "-"
3315
+ table.add_row(
3316
+ event.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
3317
+ event.event_type.value,
3318
+ target_str,
3319
+ f"[{outcome_style}]{event.outcome.value}[/]",
3320
+ event.user,
3321
+ )
3322
+
3323
+ console.print(table)
3324
+
3325
+
3326
+ def cmd_audit_show(args) -> None:
3327
+ """Show audit event details."""
3328
+ query = AuditQuery()
3329
+ events = query.query(limit=1000)
3330
+
3331
+ for event in events:
3332
+ if event.event_id == args.event_id:
3333
+ console.print_json(json.dumps(event.to_dict(), indent=2, default=str))
3334
+ return
3335
+
3336
+ console.print(f"[red]Event not found: {args.event_id}[/]")
3337
+
3338
+
3339
+ def cmd_audit_failures(args) -> None:
3340
+ """Show recent failures."""
3341
+ query = AuditQuery()
3342
+ events = query.get_failures(limit=getattr(args, "limit", 20))
3343
+
3344
+ if not events:
3345
+ console.print("[green]No failures recorded.[/]")
3346
+ return
3347
+
3348
+ table = Table(title="Recent Failures", border_style="red")
3349
+ table.add_column("Time", style="dim")
3350
+ table.add_column("Event")
3351
+ table.add_column("Target")
3352
+ table.add_column("Error")
3353
+
3354
+ for event in reversed(events):
3355
+ table.add_row(
3356
+ event.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
3357
+ event.event_type.value,
3358
+ event.target_name or "-",
3359
+ (event.error_message or "-")[:50],
3360
+ )
3361
+
3362
+ console.print(table)
3363
+
3364
+
3365
+ def cmd_audit_search(args) -> None:
3366
+ """Search audit events."""
3367
+ from datetime import datetime, timedelta
3368
+
3369
+ query = AuditQuery()
3370
+
3371
+ # Parse event type
3372
+ event_type = None
3373
+ if hasattr(args, "event") and args.event:
3374
+ try:
3375
+ event_type = AuditEventType(args.event)
3376
+ except ValueError:
3377
+ console.print(f"[red]Unknown event type: {args.event}[/]")
3378
+ return
3379
+
3380
+ # Parse time range
3381
+ start_time = None
3382
+ if hasattr(args, "since") and args.since:
3383
+ since = args.since.lower()
3384
+ now = datetime.now()
3385
+ if "hour" in since:
3386
+ hours = int(since.split()[0]) if since[0].isdigit() else 1
3387
+ start_time = now - timedelta(hours=hours)
3388
+ elif "day" in since:
3389
+ days = int(since.split()[0]) if since[0].isdigit() else 1
3390
+ start_time = now - timedelta(days=days)
3391
+ elif "week" in since:
3392
+ weeks = int(since.split()[0]) if since[0].isdigit() else 1
3393
+ start_time = now - timedelta(weeks=weeks)
3394
+
3395
+ user = getattr(args, "user_filter", None)
3396
+ target = getattr(args, "target", None)
3397
+ limit = getattr(args, "limit", 100)
3398
+
3399
+ events = query.query(
3400
+ event_type=event_type,
3401
+ target_name=target,
3402
+ user=user,
3403
+ start_time=start_time,
3404
+ limit=limit,
3405
+ )
3406
+
3407
+ if not events:
3408
+ console.print("[yellow]No matching audit events found.[/]")
3409
+ return
3410
+
3411
+ console.print(f"[bold]Found {len(events)} events:[/]")
3412
+
3413
+ for event in events:
3414
+ outcome_color = "green" if event.outcome.value == "success" else "red"
3415
+ console.print(
3416
+ f" [{outcome_color}]{event.outcome.value}[/] "
3417
+ f"{event.timestamp.strftime('%Y-%m-%d %H:%M')} "
3418
+ f"[cyan]{event.event_type.value}[/] "
3419
+ f"{event.target_name or '-'}"
3420
+ )
3421
+
3422
+
3423
+ def cmd_audit_export(args) -> None:
3424
+ """Export audit events to file."""
3425
+ query = AuditQuery()
3426
+ events = query.query(limit=getattr(args, "limit", 10000))
3427
+
3428
+ if not events:
3429
+ console.print("[yellow]No audit events to export.[/]")
3430
+ return
3431
+
3432
+ output_format = getattr(args, "format", "json")
3433
+ output_file = getattr(args, "output", None)
3434
+
3435
+ if output_format == "json":
3436
+ data = [e.to_dict() for e in events]
3437
+ content = json.dumps(data, indent=2, default=str)
3438
+ else:
3439
+ # CSV format
3440
+ import csv
3441
+ import io
3442
+ output = io.StringIO()
3443
+ writer = csv.writer(output)
3444
+ writer.writerow(["timestamp", "event_type", "outcome", "target", "user", "error"])
3445
+ for e in events:
3446
+ writer.writerow([
3447
+ e.timestamp.isoformat(),
3448
+ e.event_type.value,
3449
+ e.outcome.value,
3450
+ e.target_name or "",
3451
+ e.user,
3452
+ e.error_message or "",
3453
+ ])
3454
+ content = output.getvalue()
3455
+
3456
+ if output_file:
3457
+ Path(output_file).write_text(content)
3458
+ console.print(f"[green]✅ Exported {len(events)} events to {output_file}[/]")
3459
+ else:
3460
+ console.print(content)
3461
+
3462
+
3463
+ # === Orchestration Commands ===
3464
+
3465
+
3466
+ def cmd_compose_up(args) -> None:
3467
+ """Start VMs from compose file."""
3468
+ compose_file = Path(args.file) if hasattr(args, "file") and args.file else Path("clonebox-compose.yaml")
3469
+
3470
+ if not compose_file.exists():
3471
+ console.print(f"[red]Compose file not found: {compose_file}[/]")
3472
+ return
3473
+
3474
+ user_session = getattr(args, "user", False)
3475
+ services = args.services if hasattr(args, "services") and args.services else None
3476
+
3477
+ console.print(f"[cyan]🚀 Starting VMs from: {compose_file}[/]")
3478
+
3479
+ try:
3480
+ orch = Orchestrator.from_file(compose_file, user_session=user_session)
3481
+ result = orch.up(services=services, console=console)
3482
+
3483
+ if result.success:
3484
+ console.print("[green]✅ All VMs started successfully[/]")
3485
+ else:
3486
+ console.print("[yellow]⚠️ Some VMs failed to start:[/]")
3487
+ for name, error in result.errors.items():
3488
+ console.print(f" [red]{name}:[/] {error}")
3489
+
3490
+ console.print(f"[dim]Duration: {result.duration_seconds:.1f}s[/]")
3491
+
3492
+ except Exception as e:
3493
+ console.print(f"[red]❌ Orchestration failed: {e}[/]")
3494
+
3495
+
3496
+ def cmd_compose_down(args) -> None:
3497
+ """Stop VMs from compose file."""
3498
+ compose_file = Path(args.file) if hasattr(args, "file") and args.file else Path("clonebox-compose.yaml")
3499
+
3500
+ if not compose_file.exists():
3501
+ console.print(f"[red]Compose file not found: {compose_file}[/]")
3502
+ return
3503
+
3504
+ user_session = getattr(args, "user", False)
3505
+ services = args.services if hasattr(args, "services") and args.services else None
3506
+ force = getattr(args, "force", False)
3507
+
3508
+ console.print(f"[cyan]🛑 Stopping VMs from: {compose_file}[/]")
3509
+
3510
+ try:
3511
+ orch = Orchestrator.from_file(compose_file, user_session=user_session)
3512
+ result = orch.down(services=services, force=force, console=console)
3513
+
3514
+ if result.success:
3515
+ console.print("[green]✅ All VMs stopped successfully[/]")
3516
+ else:
3517
+ console.print("[yellow]⚠️ Some VMs failed to stop:[/]")
3518
+ for name, error in result.errors.items():
3519
+ console.print(f" [red]{name}:[/] {error}")
3520
+
3521
+ except Exception as e:
3522
+ console.print(f"[red]❌ Stop failed: {e}[/]")
3523
+
3524
+
3525
+ def cmd_compose_status(args) -> None:
3526
+ """Show status of VMs from compose file."""
3527
+ compose_file = Path(args.file) if hasattr(args, "file") and args.file else Path("clonebox-compose.yaml")
3528
+
3529
+ if not compose_file.exists():
3530
+ console.print(f"[red]Compose file not found: {compose_file}[/]")
3531
+ return
3532
+
3533
+ user_session = getattr(args, "user", False)
3534
+
3535
+ try:
3536
+ orch = Orchestrator.from_file(compose_file, user_session=user_session)
3537
+ status = orch.status()
3538
+
3539
+ if getattr(args, "json", False):
3540
+ console.print_json(json.dumps(status, default=str))
3541
+ return
3542
+
3543
+ table = Table(title=f"Compose Status: {compose_file.name}", border_style="cyan")
3544
+ table.add_column("VM")
3545
+ table.add_column("State")
3546
+ table.add_column("Actual")
3547
+ table.add_column("Health")
3548
+ table.add_column("Depends On")
3549
+
3550
+ for name, info in status.items():
3551
+ state = info["orchestration_state"]
3552
+ actual = info["actual_state"]
3553
+ health = "✅" if info["health_check_passed"] else "⏳"
3554
+ deps = ", ".join(info["depends_on"]) or "-"
3555
+
3556
+ state_style = {
3557
+ "running": "green",
3558
+ "healthy": "green bold",
3559
+ "stopped": "dim",
3560
+ "failed": "red",
3561
+ "pending": "yellow",
3562
+ }.get(state, "white")
3563
+
3564
+ table.add_row(
3565
+ name,
3566
+ f"[{state_style}]{state}[/]",
3567
+ actual,
3568
+ health,
3569
+ deps,
3570
+ )
3571
+
3572
+ console.print(table)
3573
+
3574
+ except Exception as e:
3575
+ console.print(f"[red]❌ Failed to get status: {e}[/]")
3576
+
3577
+
3578
+ def cmd_compose_logs(args) -> None:
3579
+ """Show aggregated logs from VMs."""
3580
+ compose_file = Path(args.file) if hasattr(args, "file") and args.file else Path("clonebox-compose.yaml")
3581
+
3582
+ if not compose_file.exists():
3583
+ console.print(f"[red]Compose file not found: {compose_file}[/]")
3584
+ return
3585
+
3586
+ user_session = getattr(args, "user", False)
3587
+ follow = getattr(args, "follow", False)
3588
+ lines = getattr(args, "lines", 50)
3589
+ service = getattr(args, "service", None)
3590
+
3591
+ try:
3592
+ orch = Orchestrator.from_file(compose_file, user_session=user_session)
3593
+
3594
+ if service:
3595
+ # Logs for specific service
3596
+ logs = orch.logs(service, follow=follow, lines=lines)
3597
+ if logs:
3598
+ console.print(f"[bold]Logs for {service}:[/]")
3599
+ console.print(logs)
3600
+ else:
3601
+ console.print(f"[yellow]No logs available for {service}[/]")
3602
+ else:
3603
+ # Logs for all services
3604
+ for name in orch.plan.vms.keys():
3605
+ logs = orch.logs(name, follow=False, lines=lines)
3606
+ if logs:
3607
+ console.print(f"\n[bold cyan]━━━ {name} ━━━[/]")
3608
+ console.print(logs)
3609
+
3610
+ except Exception as e:
3611
+ console.print(f"[red]❌ Failed to get logs: {e}[/]")
3612
+
3613
+
3614
+ # === Plugin Commands ===
3615
+
3616
+
3617
+ def cmd_plugin_list(args) -> None:
3618
+ """List installed plugins."""
3619
+ manager = get_plugin_manager()
3620
+
3621
+ # Load plugins if not already loaded
3622
+ if not manager.list_plugins():
3623
+ manager.load_all()
3624
+
3625
+ plugins = manager.list_plugins()
3626
+
3627
+ if not plugins:
3628
+ console.print("[yellow]No plugins installed.[/]")
3629
+ console.print("[dim]Plugin directories:[/]")
3630
+ for d in manager.plugin_dirs:
3631
+ console.print(f" {d}")
3632
+ return
3633
+
3634
+ table = Table(title="Installed Plugins", border_style="cyan")
3635
+ table.add_column("Name")
3636
+ table.add_column("Version")
3637
+ table.add_column("Enabled")
3638
+ table.add_column("Description")
3639
+
3640
+ for plugin in plugins:
3641
+ enabled = "[green]✅[/]" if plugin["enabled"] else "[red]❌[/]"
3642
+ table.add_row(
3643
+ plugin["name"],
3644
+ plugin["version"],
3645
+ enabled,
3646
+ (plugin.get("description", "") or "")[:40],
3647
+ )
3648
+
3649
+ console.print(table)
3650
+
3651
+
3652
+ def cmd_plugin_enable(args) -> None:
3653
+ """Enable a plugin."""
3654
+ manager = get_plugin_manager()
3655
+ manager.load_all()
3656
+
3657
+ if manager.enable(args.name):
3658
+ console.print(f"[green]✅ Plugin '{args.name}' enabled[/]")
3659
+ else:
3660
+ console.print(f"[red]Plugin '{args.name}' not found[/]")
3661
+
3662
+
3663
+ def cmd_plugin_disable(args) -> None:
3664
+ """Disable a plugin."""
3665
+ manager = get_plugin_manager()
3666
+ manager.load_all()
3667
+
3668
+ if manager.disable(args.name):
3669
+ console.print(f"[yellow]⚠️ Plugin '{args.name}' disabled[/]")
3670
+ else:
3671
+ console.print(f"[red]Plugin '{args.name}' not found[/]")
3672
+
3673
+
3674
+ def cmd_plugin_discover(args) -> None:
3675
+ """Discover available plugins."""
3676
+ manager = get_plugin_manager()
3677
+ discovered = manager.discover()
3678
+
3679
+ if not discovered:
3680
+ console.print("[yellow]No plugins discovered.[/]")
3681
+ console.print("[dim]Plugin directories:[/]")
3682
+ for d in manager.plugin_dirs:
3683
+ console.print(f" {d}")
3684
+ return
3685
+
3686
+ console.print("[bold]Discovered plugins:[/]")
3687
+ for name in discovered:
3688
+ console.print(f" • {name}")
3689
+
3690
+
3691
+ def cmd_plugin_install(args) -> None:
3692
+ """Install a plugin."""
3693
+ manager = get_plugin_manager()
3694
+ source = args.source
3695
+
3696
+ console.print(f"[cyan]📦 Installing plugin from: {source}[/]")
3697
+
3698
+ if manager.install(source):
3699
+ console.print("[green]✅ Plugin installed successfully[/]")
3700
+ console.print("[dim]Run 'clonebox plugin discover' to see available plugins[/]")
3701
+ else:
3702
+ console.print(f"[red]❌ Failed to install plugin from: {source}[/]")
3703
+
3704
+
3705
+ def cmd_plugin_uninstall(args) -> None:
3706
+ """Uninstall a plugin."""
3707
+ manager = get_plugin_manager()
3708
+ name = args.name
3709
+
3710
+ console.print(f"[cyan]🗑️ Uninstalling plugin: {name}[/]")
3711
+
3712
+ if manager.uninstall(name):
3713
+ console.print(f"[green]✅ Plugin '{name}' uninstalled successfully[/]")
3714
+ else:
3715
+ console.print(f"[red]❌ Failed to uninstall plugin: {name}[/]")
3716
+
3717
+
3718
+ # === Remote Management Commands ===
3719
+
3720
+
3721
+ def cmd_remote_list(args) -> None:
3722
+ """List VMs on remote host."""
3723
+ host = args.host
3724
+ user_session = getattr(args, "user", False)
3725
+
3726
+ console.print(f"[cyan]🔍 Connecting to: {host}[/]")
3727
+
3728
+ try:
3729
+ remote = RemoteCloner(host, verify=True)
3730
+
3731
+ if not remote.is_clonebox_installed():
3732
+ console.print("[red]❌ CloneBox is not installed on remote host[/]")
3733
+ return
3734
+
3735
+ vms = remote.list_vms(user_session=user_session)
3736
+
3737
+ if not vms:
3738
+ console.print("[yellow]No VMs found on remote host.[/]")
3739
+ return
3740
+
3741
+ table = Table(title=f"VMs on {host}", border_style="cyan")
3742
+ table.add_column("Name")
3743
+ table.add_column("Status")
3744
+
3745
+ for vm in vms:
3746
+ name = vm.get("name", str(vm))
3747
+ status = vm.get("state", vm.get("status", "-"))
3748
+ table.add_row(name, status)
3749
+
3750
+ console.print(table)
3751
+
3752
+ except ConnectionError as e:
3753
+ console.print(f"[red]❌ Connection failed: {e}[/]")
3754
+ except Exception as e:
3755
+ console.print(f"[red]❌ Error: {e}[/]")
3756
+
3757
+
3758
+ def cmd_remote_status(args) -> None:
3759
+ """Get VM status on remote host."""
3760
+ host = args.host
3761
+ vm_name = args.vm_name
3762
+ user_session = getattr(args, "user", False)
3763
+
3764
+ console.print(f"[cyan]🔍 Getting status of {vm_name} on {host}[/]")
3765
+
3766
+ try:
3767
+ remote = RemoteCloner(host, verify=True)
3768
+ status = remote.get_status(vm_name, user_session=user_session)
3769
+
3770
+ if getattr(args, "json", False):
3771
+ console.print_json(json.dumps(status, default=str))
3772
+ else:
3773
+ for key, value in status.items():
3774
+ console.print(f" [bold]{key}:[/] {value}")
3775
+
3776
+ except Exception as e:
3777
+ console.print(f"[red]❌ Error: {e}[/]")
3778
+
3779
+
3780
+ def cmd_remote_start(args) -> None:
3781
+ """Start VM on remote host."""
3782
+ host = args.host
3783
+ vm_name = args.vm_name
3784
+ user_session = getattr(args, "user", False)
3785
+
3786
+ console.print(f"[cyan]🚀 Starting {vm_name} on {host}[/]")
3787
+
3788
+ try:
3789
+ remote = RemoteCloner(host, verify=True)
3790
+ remote.start_vm(vm_name, user_session=user_session)
3791
+ console.print(f"[green]✅ VM {vm_name} started[/]")
3792
+
3793
+ except Exception as e:
3794
+ console.print(f"[red]❌ Error: {e}[/]")
3795
+
3796
+
3797
+ def cmd_remote_stop(args) -> None:
3798
+ """Stop VM on remote host."""
3799
+ host = args.host
3800
+ vm_name = args.vm_name
3801
+ user_session = getattr(args, "user", False)
3802
+ force = getattr(args, "force", False)
3803
+
3804
+ console.print(f"[cyan]🛑 Stopping {vm_name} on {host}[/]")
3805
+
3806
+ try:
3807
+ remote = RemoteCloner(host, verify=True)
3808
+ remote.stop_vm(vm_name, force=force, user_session=user_session)
3809
+ console.print(f"[green]✅ VM {vm_name} stopped[/]")
3810
+
3811
+ except Exception as e:
3812
+ console.print(f"[red]❌ Error: {e}[/]")
3813
+
3814
+
3815
+ def cmd_remote_delete(args) -> None:
3816
+ """Delete VM on remote host."""
3817
+ host = args.host
3818
+ vm_name = args.vm_name
3819
+ user_session = getattr(args, "user", False)
3820
+ keep_storage = getattr(args, "keep_storage", False)
3821
+
3822
+ if not getattr(args, "yes", False):
3823
+ confirm = questionary.confirm(
3824
+ f"Delete VM '{vm_name}' on {host}?",
3825
+ default=False,
3826
+ style=custom_style,
3827
+ ).ask()
3828
+ if not confirm:
3829
+ console.print("[yellow]Aborted.[/]")
3830
+ return
3831
+
3832
+ console.print(f"[cyan]🗑️ Deleting {vm_name} on {host}[/]")
3833
+
3834
+ try:
3835
+ remote = RemoteCloner(host, verify=True)
3836
+ remote.delete_vm(vm_name, keep_storage=keep_storage, user_session=user_session)
3837
+ console.print(f"[green]✅ VM {vm_name} deleted[/]")
3838
+
3839
+ except Exception as e:
3840
+ console.print(f"[red]❌ Error: {e}[/]")
3841
+
3842
+
3843
+ def cmd_remote_exec(args) -> None:
3844
+ """Execute command in VM on remote host."""
3845
+ host = args.host
3846
+ vm_name = args.vm_name
3847
+ command = " ".join(args.command) if args.command else "echo ok"
3848
+ user_session = getattr(args, "user", False)
3849
+ timeout = getattr(args, "timeout", 30)
3850
+
3851
+ try:
3852
+ remote = RemoteCloner(host, verify=True)
3853
+ output = remote.exec_in_vm(vm_name, command, timeout=timeout, user_session=user_session)
3854
+ console.print(output)
3855
+
3856
+ except Exception as e:
3857
+ console.print(f"[red]❌ Error: {e}[/]")
3858
+
3859
+
3860
+ def cmd_remote_health(args) -> None:
3861
+ """Run health check on remote VM."""
3862
+ host = args.host
3863
+ vm_name = args.vm_name
3864
+ user_session = getattr(args, "user", False)
3865
+
3866
+ console.print(f"[cyan]🏥 Running health check on {vm_name}@{host}[/]")
3867
+
3868
+ try:
3869
+ remote = RemoteCloner(host, verify=True)
3870
+ result = remote.health_check(vm_name, user_session=user_session)
3871
+
3872
+ if result["success"]:
3873
+ console.print("[green]✅ Health check passed[/]")
3874
+ else:
3875
+ console.print("[red]❌ Health check failed[/]")
3876
+
3877
+ if result.get("output"):
3878
+ console.print(result["output"])
3879
+
3880
+ except Exception as e:
3881
+ console.print(f"[red]❌ Error: {e}[/]")
3882
+
3883
+
3101
3884
  def main():
3102
3885
  """Main entry point."""
3103
3886
  parser = argparse.ArgumentParser(
@@ -3480,6 +4263,37 @@ def main():
3480
4263
  )
3481
4264
  repair_parser.set_defaults(func=cmd_repair)
3482
4265
 
4266
+ # Logs command - view VM logs
4267
+ logs_parser = subparsers.add_parser("logs", help="View logs from VM")
4268
+ logs_parser.add_argument(
4269
+ "name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml"
4270
+ )
4271
+ logs_parser.add_argument(
4272
+ "-u",
4273
+ "--user",
4274
+ action="store_true",
4275
+ help="Use user session (qemu:///session)",
4276
+ )
4277
+ logs_parser.add_argument(
4278
+ "--all",
4279
+ action="store_true",
4280
+ help="Show all logs at once without interactive menu",
4281
+ )
4282
+ logs_parser.set_defaults(func=cmd_logs)
4283
+
4284
+ # Set-password command - set password for VM user
4285
+ set_password_parser = subparsers.add_parser("set-password", help="Set password for VM user")
4286
+ set_password_parser.add_argument(
4287
+ "name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml"
4288
+ )
4289
+ set_password_parser.add_argument(
4290
+ "-u",
4291
+ "--user",
4292
+ action="store_true",
4293
+ help="Use user session (qemu:///session)",
4294
+ )
4295
+ set_password_parser.set_defaults(func=cmd_set_password)
4296
+
3483
4297
  # Export command - package VM for migration
3484
4298
  export_parser = subparsers.add_parser("export", help="Export VM and data for migration")
3485
4299
  export_parser.add_argument(
@@ -3684,6 +4498,148 @@ def main():
3684
4498
  list_remote_parser.add_argument("host", help="Remote host (user@hostname)")
3685
4499
  list_remote_parser.set_defaults(func=cmd_list_remote)
3686
4500
 
4501
+ # === Audit Commands ===
4502
+ audit_parser = subparsers.add_parser("audit", help="View audit logs")
4503
+ audit_sub = audit_parser.add_subparsers(dest="audit_command", help="Audit commands")
4504
+
4505
+ audit_list = audit_sub.add_parser("list", aliases=["ls"], help="List audit events")
4506
+ audit_list.add_argument("--type", "-t", help="Filter by event type (e.g., vm.create)")
4507
+ audit_list.add_argument("--target", help="Filter by target name")
4508
+ audit_list.add_argument("--outcome", "-o", choices=["success", "failure", "partial"], help="Filter by outcome")
4509
+ audit_list.add_argument("--limit", "-n", type=int, default=50, help="Max events to show")
4510
+ audit_list.add_argument("--json", action="store_true", help="Output as JSON")
4511
+ audit_list.set_defaults(func=cmd_audit_list)
4512
+
4513
+ audit_show = audit_sub.add_parser("show", help="Show audit event details")
4514
+ audit_show.add_argument("event_id", help="Event ID to show")
4515
+ audit_show.set_defaults(func=cmd_audit_show)
4516
+
4517
+ audit_failures = audit_sub.add_parser("failures", help="Show recent failures")
4518
+ audit_failures.add_argument("--limit", "-n", type=int, default=20, help="Max events to show")
4519
+ audit_failures.set_defaults(func=cmd_audit_failures)
4520
+
4521
+ audit_search = audit_sub.add_parser("search", help="Search audit events")
4522
+ audit_search.add_argument("--event", "-e", help="Event type (e.g., vm.create)")
4523
+ audit_search.add_argument("--since", "-s", help="Time range (e.g., '1 hour ago', '7 days')")
4524
+ audit_search.add_argument("--user-filter", help="Filter by user")
4525
+ audit_search.add_argument("--target", help="Filter by target name")
4526
+ audit_search.add_argument("--limit", "-n", type=int, default=100, help="Max events to show")
4527
+ audit_search.set_defaults(func=cmd_audit_search)
4528
+
4529
+ audit_export = audit_sub.add_parser("export", help="Export audit events to file")
4530
+ audit_export.add_argument("--format", "-f", choices=["json", "csv"], default="json", help="Output format")
4531
+ audit_export.add_argument("--output", "-o", help="Output file (stdout if not specified)")
4532
+ audit_export.add_argument("--limit", "-n", type=int, default=10000, help="Max events to export")
4533
+ audit_export.set_defaults(func=cmd_audit_export)
4534
+
4535
+ # === Compose/Orchestration Commands ===
4536
+ compose_parser = subparsers.add_parser("compose", help="Multi-VM orchestration")
4537
+ compose_sub = compose_parser.add_subparsers(dest="compose_command", help="Compose commands")
4538
+
4539
+ compose_up = compose_sub.add_parser("up", help="Start VMs from compose file")
4540
+ compose_up.add_argument("-f", "--file", default="clonebox-compose.yaml", help="Compose file")
4541
+ compose_up.add_argument("-u", "--user", action="store_true", help="Use user session")
4542
+ compose_up.add_argument("services", nargs="*", help="Specific services to start")
4543
+ compose_up.set_defaults(func=cmd_compose_up)
4544
+
4545
+ compose_down = compose_sub.add_parser("down", help="Stop VMs from compose file")
4546
+ compose_down.add_argument("-f", "--file", default="clonebox-compose.yaml", help="Compose file")
4547
+ compose_down.add_argument("-u", "--user", action="store_true", help="Use user session")
4548
+ compose_down.add_argument("--force", action="store_true", help="Force stop")
4549
+ compose_down.add_argument("services", nargs="*", help="Specific services to stop")
4550
+ compose_down.set_defaults(func=cmd_compose_down)
4551
+
4552
+ compose_status = compose_sub.add_parser("status", aliases=["ps"], help="Show compose status")
4553
+ compose_status.add_argument("-f", "--file", default="clonebox-compose.yaml", help="Compose file")
4554
+ compose_status.add_argument("-u", "--user", action="store_true", help="Use user session")
4555
+ compose_status.add_argument("--json", action="store_true", help="Output as JSON")
4556
+ compose_status.set_defaults(func=cmd_compose_status)
4557
+
4558
+ compose_logs = compose_sub.add_parser("logs", help="Show aggregated logs from VMs")
4559
+ compose_logs.add_argument("-f", "--file", default="clonebox-compose.yaml", help="Compose file")
4560
+ compose_logs.add_argument("-u", "--user", action="store_true", help="Use user session")
4561
+ compose_logs.add_argument("--follow", action="store_true", help="Follow log output")
4562
+ compose_logs.add_argument("--lines", "-n", type=int, default=50, help="Number of lines to show")
4563
+ compose_logs.add_argument("service", nargs="?", help="Specific service to show logs for")
4564
+ compose_logs.set_defaults(func=cmd_compose_logs)
4565
+
4566
+ # === Plugin Commands ===
4567
+ plugin_parser = subparsers.add_parser("plugin", help="Manage plugins")
4568
+ plugin_sub = plugin_parser.add_subparsers(dest="plugin_command", help="Plugin commands")
4569
+
4570
+ plugin_list = plugin_sub.add_parser("list", aliases=["ls"], help="List plugins")
4571
+ plugin_list.set_defaults(func=cmd_plugin_list)
4572
+
4573
+ plugin_enable = plugin_sub.add_parser("enable", help="Enable a plugin")
4574
+ plugin_enable.add_argument("name", help="Plugin name")
4575
+ plugin_enable.set_defaults(func=cmd_plugin_enable)
4576
+
4577
+ plugin_disable = plugin_sub.add_parser("disable", help="Disable a plugin")
4578
+ plugin_disable.add_argument("name", help="Plugin name")
4579
+ plugin_disable.set_defaults(func=cmd_plugin_disable)
4580
+
4581
+ plugin_discover = plugin_sub.add_parser("discover", help="Discover available plugins")
4582
+ plugin_discover.set_defaults(func=cmd_plugin_discover)
4583
+
4584
+ plugin_install = plugin_sub.add_parser("install", help="Install a plugin")
4585
+ plugin_install.add_argument("source", help="Plugin source (PyPI package, git URL, or local path)")
4586
+ plugin_install.set_defaults(func=cmd_plugin_install)
4587
+
4588
+ plugin_uninstall = plugin_sub.add_parser("uninstall", aliases=["remove"], help="Uninstall a plugin")
4589
+ plugin_uninstall.add_argument("name", help="Plugin name")
4590
+ plugin_uninstall.set_defaults(func=cmd_plugin_uninstall)
4591
+
4592
+ # === Remote Management Commands ===
4593
+ remote_parser = subparsers.add_parser("remote", help="Manage VMs on remote hosts")
4594
+ remote_sub = remote_parser.add_subparsers(dest="remote_command", help="Remote commands")
4595
+
4596
+ remote_list = remote_sub.add_parser("list", aliases=["ls"], help="List VMs on remote host")
4597
+ remote_list.add_argument("host", help="Remote host (user@hostname)")
4598
+ remote_list.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
4599
+ remote_list.set_defaults(func=cmd_remote_list)
4600
+
4601
+ remote_status = remote_sub.add_parser("status", help="Get VM status on remote host")
4602
+ remote_status.add_argument("host", help="Remote host (user@hostname)")
4603
+ remote_status.add_argument("vm_name", help="VM name")
4604
+ remote_status.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
4605
+ remote_status.add_argument("--json", action="store_true", help="Output as JSON")
4606
+ remote_status.set_defaults(func=cmd_remote_status)
4607
+
4608
+ remote_start = remote_sub.add_parser("start", help="Start VM on remote host")
4609
+ remote_start.add_argument("host", help="Remote host (user@hostname)")
4610
+ remote_start.add_argument("vm_name", help="VM name")
4611
+ remote_start.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
4612
+ remote_start.set_defaults(func=cmd_remote_start)
4613
+
4614
+ remote_stop = remote_sub.add_parser("stop", help="Stop VM on remote host")
4615
+ remote_stop.add_argument("host", help="Remote host (user@hostname)")
4616
+ remote_stop.add_argument("vm_name", help="VM name")
4617
+ remote_stop.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
4618
+ remote_stop.add_argument("-f", "--force", action="store_true", help="Force stop")
4619
+ remote_stop.set_defaults(func=cmd_remote_stop)
4620
+
4621
+ remote_delete = remote_sub.add_parser("delete", aliases=["rm"], help="Delete VM on remote host")
4622
+ remote_delete.add_argument("host", help="Remote host (user@hostname)")
4623
+ remote_delete.add_argument("vm_name", help="VM name")
4624
+ remote_delete.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
4625
+ remote_delete.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
4626
+ remote_delete.add_argument("--keep-storage", action="store_true", help="Keep disk images")
4627
+ remote_delete.set_defaults(func=cmd_remote_delete)
4628
+
4629
+ remote_exec = remote_sub.add_parser("exec", help="Execute command in VM on remote host")
4630
+ remote_exec.add_argument("host", help="Remote host (user@hostname)")
4631
+ remote_exec.add_argument("vm_name", help="VM name")
4632
+ remote_exec.add_argument("command", nargs=argparse.REMAINDER, help="Command to execute")
4633
+ remote_exec.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
4634
+ remote_exec.add_argument("-t", "--timeout", type=int, default=30, help="Command timeout")
4635
+ remote_exec.set_defaults(func=cmd_remote_exec)
4636
+
4637
+ remote_health = remote_sub.add_parser("health", help="Run health check on remote VM")
4638
+ remote_health.add_argument("host", help="Remote host (user@hostname)")
4639
+ remote_health.add_argument("vm_name", help="VM name")
4640
+ remote_health.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
4641
+ remote_health.set_defaults(func=cmd_remote_health)
4642
+
3687
4643
  args = parser.parse_args()
3688
4644
 
3689
4645
  if hasattr(args, "func"):