clonebox 1.1.14__py3-none-any.whl → 1.1.16__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.

Potentially problematic release.


This version of clonebox might be problematic. Click here for more details.

clonebox/cli.py CHANGED
@@ -37,6 +37,7 @@ from clonebox.health import HealthCheckManager, ProbeConfig, ProbeType
37
37
  from clonebox.audit import get_audit_logger, AuditQuery, AuditEventType, AuditOutcome
38
38
  from clonebox.orchestrator import Orchestrator, OrchestrationResult
39
39
  from clonebox.plugins import get_plugin_manager, PluginHook, PluginContext
40
+ from clonebox.remote import RemoteCloner, RemoteConnection
40
41
 
41
42
  # Custom questionary style
42
43
  custom_style = Style(
@@ -601,6 +602,78 @@ def cmd_repair(args):
601
602
  )
602
603
 
603
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
+
604
677
  def interactive_mode():
605
678
  """Run the interactive VM creation wizard."""
606
679
  print_banner()
@@ -1857,7 +1930,10 @@ def cmd_test(args):
1857
1930
  if is_accessible == "yes":
1858
1931
  console.print(f"[green]✅ {guest_path} (mount)[/]")
1859
1932
  else:
1860
- console.print(f"[red]❌ {guest_path} (mount inaccessible)[/]")
1933
+ if cloud_init_running:
1934
+ console.print(f"[yellow]⏳ {guest_path} (mount pending)[/]")
1935
+ else:
1936
+ console.print(f"[red]❌ {guest_path} (mount inaccessible)[/]")
1861
1937
  except Exception:
1862
1938
  console.print(f"[yellow]⚠️ {guest_path} (could not check)[/]")
1863
1939
 
@@ -1870,7 +1946,10 @@ def cmd_test(args):
1870
1946
  if is_accessible == "yes":
1871
1947
  console.print(f"[green]✅ {guest_path} (copied)[/]")
1872
1948
  else:
1873
- console.print(f"[red]❌ {guest_path} (copy missing)[/]")
1949
+ if cloud_init_running:
1950
+ console.print(f"[yellow]⏳ {guest_path} (copy pending)[/]")
1951
+ else:
1952
+ console.print(f"[red]❌ {guest_path} (copy missing)[/]")
1874
1953
  except Exception:
1875
1954
  console.print(f"[yellow]⚠️ {guest_path} (could not check)[/]")
1876
1955
  else:
@@ -1972,7 +2051,7 @@ def load_env_file(env_path: Path) -> dict:
1972
2051
  continue
1973
2052
  if "=" in line:
1974
2053
  key, value = line.split("=", 1)
1975
- env_vars[key.strip()] = value.strip()
2054
+ env_vars[key.strip()] = value.strip().strip("'\"")
1976
2055
 
1977
2056
  return env_vars
1978
2057
 
@@ -1993,6 +2072,22 @@ def expand_env_vars(value, env_vars: dict):
1993
2072
  return value
1994
2073
 
1995
2074
 
2075
+ def _find_unexpanded_env_placeholders(value) -> set:
2076
+ if isinstance(value, str):
2077
+ return set(re.findall(r"\$\{([^}]+)\}", value))
2078
+ if isinstance(value, dict):
2079
+ found = set()
2080
+ for v in value.values():
2081
+ found |= _find_unexpanded_env_placeholders(v)
2082
+ return found
2083
+ if isinstance(value, list):
2084
+ found = set()
2085
+ for item in value:
2086
+ found |= _find_unexpanded_env_placeholders(item)
2087
+ return found
2088
+ return set()
2089
+
2090
+
1996
2091
  def deduplicate_list(items: list, key=None) -> list:
1997
2092
  """Remove duplicates from list, preserving order."""
1998
2093
  seen = set()
@@ -2249,9 +2344,79 @@ def load_clonebox_config(path: Path) -> dict:
2249
2344
  # Expand environment variables in config
2250
2345
  config = expand_env_vars(config, env_vars)
2251
2346
 
2347
+ unresolved = _find_unexpanded_env_placeholders(config)
2348
+ if unresolved:
2349
+ unresolved_sorted = ", ".join(sorted(unresolved))
2350
+ raise ValueError(
2351
+ f"Unresolved environment variables in config: {unresolved_sorted}. "
2352
+ f"Set them in {env_file} or in the process environment."
2353
+ )
2354
+
2252
2355
  return config
2253
2356
 
2254
2357
 
2358
+ def _exec_in_vm_qga(vm_name: str, conn_uri: str, command: str) -> Optional[str]:
2359
+ """Internal helper to execute command via QGA and get output."""
2360
+ import subprocess
2361
+ import json
2362
+ import base64
2363
+ import time
2364
+
2365
+ try:
2366
+ # 1. Start execution
2367
+ cmd_json = {
2368
+ "execute": "guest-exec",
2369
+ "arguments": {
2370
+ "path": "/bin/sh",
2371
+ "arg": ["-c", command],
2372
+ "capture-output": True
2373
+ }
2374
+ }
2375
+
2376
+ result = subprocess.run(
2377
+ ["virsh", "--connect", conn_uri, "qemu-agent-command", vm_name, json.dumps(cmd_json)],
2378
+ capture_output=True,
2379
+ text=True,
2380
+ timeout=5
2381
+ )
2382
+
2383
+ if result.returncode != 0:
2384
+ return None
2385
+
2386
+ resp = json.loads(result.stdout)
2387
+ pid = resp.get("return", {}).get("pid")
2388
+ if not pid:
2389
+ return None
2390
+
2391
+ # 2. Wait and get status (quick check)
2392
+ status_json = {"execute": "guest-exec-status", "arguments": {"pid": pid}}
2393
+
2394
+ for _ in range(3): # Try a few times
2395
+ status_result = subprocess.run(
2396
+ ["virsh", "--connect", conn_uri, "qemu-agent-command", vm_name, json.dumps(status_json)],
2397
+ capture_output=True,
2398
+ text=True,
2399
+ timeout=5
2400
+ )
2401
+
2402
+ if status_result.returncode != 0:
2403
+ continue
2404
+
2405
+ status_resp = json.loads(status_result.stdout)
2406
+ ret = status_resp.get("return", {})
2407
+
2408
+ if ret.get("exited"):
2409
+ if "out-data" in ret:
2410
+ return base64.b64decode(ret["out-data"]).decode().strip()
2411
+ return ""
2412
+
2413
+ time.sleep(0.5)
2414
+
2415
+ return None
2416
+ except Exception:
2417
+ return None
2418
+
2419
+
2255
2420
  def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout: int = 900):
2256
2421
  """Monitor cloud-init status in VM and show progress."""
2257
2422
  import subprocess
@@ -2261,6 +2426,8 @@ def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout:
2261
2426
  start_time = time.time()
2262
2427
  shutdown_count = 0 # Count consecutive shutdown detections
2263
2428
  restart_detected = False
2429
+ last_phases = []
2430
+ seen_lines = set()
2264
2431
 
2265
2432
  with Progress(
2266
2433
  SpinnerColumn(),
@@ -2293,7 +2460,7 @@ def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout:
2293
2460
  restart_detected = True
2294
2461
  progress.update(
2295
2462
  task,
2296
- description="[yellow]⟳ VM restarting after package installation...",
2463
+ description="[yellow]⟳ VM restarting after installation...",
2297
2464
  )
2298
2465
  time.sleep(3)
2299
2466
  continue
@@ -2306,7 +2473,7 @@ def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout:
2306
2473
  if restart_detected and "running" in vm_state and shutdown_count >= 3:
2307
2474
  # VM restarted successfully - GUI should be ready
2308
2475
  progress.update(
2309
- task, description=f"[green]✓ GUI ready! Total time: {minutes}m {seconds}s"
2476
+ task, description=f"[green]✓ VM ready! Total time: {minutes}m {seconds}s"
2310
2477
  )
2311
2478
  time.sleep(2)
2312
2479
  break
@@ -2323,15 +2490,44 @@ def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout:
2323
2490
  else:
2324
2491
  remaining = "almost done"
2325
2492
 
2493
+ # Try to get real-time progress via QGA
2494
+ # Get last 3 unique progress lines
2495
+ raw_info = _exec_in_vm_qga(
2496
+ vm_name,
2497
+ conn_uri,
2498
+ "grep -E '\\[[0-9]/[0-9]\\]|→' /var/log/cloud-init-output.log 2>/dev/null | tail -n 5"
2499
+ )
2500
+
2501
+ if raw_info:
2502
+ lines = [l.strip() for l in raw_info.strip().split('\n') if l.strip()]
2503
+ for line in lines:
2504
+ if line not in seen_lines:
2505
+ # If it's a new phase line, we can log it to console above the progress bar
2506
+ if "[" in line and "/9]" in line:
2507
+ console.print(f"[dim] {line}[/]")
2508
+ seen_lines.add(line)
2509
+ last_phases.append(line)
2510
+
2511
+ # Keep only last 2 for the progress bar description
2512
+ if len(last_phases) > 2:
2513
+ last_phases = last_phases[-2:]
2514
+
2326
2515
  if restart_detected:
2327
2516
  progress.update(
2328
2517
  task,
2329
- description=f"[cyan]Starting GUI... ({minutes}m {seconds}s, {remaining})",
2518
+ description=f"[cyan]Finalizing setup... ({minutes}m {seconds}s, {remaining})",
2519
+ )
2520
+ elif last_phases:
2521
+ # Show the actual phase from logs
2522
+ current_status = last_phases[-1]
2523
+ progress.update(
2524
+ task,
2525
+ description=f"[cyan]{current_status} ({minutes}m {seconds}s, {remaining})",
2330
2526
  )
2331
2527
  else:
2332
2528
  progress.update(
2333
2529
  task,
2334
- description=f"[cyan]Installing desktop packages... ({minutes}m {seconds}s, {remaining})",
2530
+ description=f"[cyan]Installing packages... ({minutes}m {seconds}s, {remaining})",
2335
2531
  )
2336
2532
 
2337
2533
  except (subprocess.TimeoutExpired, Exception) as e:
@@ -2358,9 +2554,32 @@ def create_vm_from_config(
2358
2554
  replace: bool = False,
2359
2555
  ) -> str:
2360
2556
  """Create VM from YAML config dict."""
2361
- # Merge paths and app_data_paths
2362
- all_paths = config.get("paths", {}).copy()
2363
- all_paths.update(config.get("app_data_paths", {}))
2557
+ paths = config.get("paths", {})
2558
+ # Backwards compatible: v1 uses app_data_paths, newer configs may use copy_paths
2559
+ copy_paths = config.get("copy_paths", None)
2560
+ if not isinstance(copy_paths, dict) or not copy_paths:
2561
+ copy_paths = config.get("app_data_paths", {})
2562
+
2563
+ vm_section = config.get("vm") or {}
2564
+
2565
+ # Support both v1 (auth_method) and v2 (auth.method) config formats
2566
+ auth_section = vm_section.get("auth") or {}
2567
+ auth_method = auth_section.get("method") or vm_section.get("auth_method") or "ssh_key"
2568
+
2569
+ # v2 config: secrets provider
2570
+ secrets_section = config.get("secrets") or {}
2571
+ secrets_provider = secrets_section.get("provider", "auto")
2572
+
2573
+ # v2 config: resource limits
2574
+ limits_section = config.get("limits") or {}
2575
+ resources = {
2576
+ "memory_limit": limits_section.get("memory_limit"),
2577
+ "cpu_shares": limits_section.get("cpu_shares"),
2578
+ "disk_limit": limits_section.get("disk_limit"),
2579
+ "network_limit": limits_section.get("network_limit"),
2580
+ }
2581
+ # Remove None values
2582
+ resources = {k: v for k, v in resources.items() if v is not None}
2364
2583
 
2365
2584
  vm_config = VMConfig(
2366
2585
  name=config["vm"]["name"],
@@ -2369,7 +2588,8 @@ def create_vm_from_config(
2369
2588
  disk_size_gb=config["vm"].get("disk_size_gb", 10),
2370
2589
  gui=config["vm"].get("gui", True),
2371
2590
  base_image=config["vm"].get("base_image"),
2372
- paths=all_paths,
2591
+ paths=paths,
2592
+ copy_paths=copy_paths,
2373
2593
  packages=config.get("packages", []),
2374
2594
  snap_packages=config.get("snap_packages", []),
2375
2595
  services=config.get("services", []),
@@ -2378,6 +2598,9 @@ def create_vm_from_config(
2378
2598
  network_mode=config["vm"].get("network_mode", "auto"),
2379
2599
  username=config["vm"].get("username", "ubuntu"),
2380
2600
  password=config["vm"].get("password", "ubuntu"),
2601
+ auth_method=auth_method,
2602
+ ssh_public_key=vm_section.get("ssh_public_key") or auth_section.get("ssh_public_key"),
2603
+ resources=resources if resources else config["vm"].get("resources", {}),
2381
2604
  )
2382
2605
 
2383
2606
  cloner = SelectiveVMCloner(user_session=user_session)
@@ -3238,6 +3461,104 @@ def cmd_audit_failures(args) -> None:
3238
3461
  console.print(table)
3239
3462
 
3240
3463
 
3464
+ def cmd_audit_search(args) -> None:
3465
+ """Search audit events."""
3466
+ from datetime import datetime, timedelta
3467
+
3468
+ query = AuditQuery()
3469
+
3470
+ # Parse event type
3471
+ event_type = None
3472
+ if hasattr(args, "event") and args.event:
3473
+ try:
3474
+ event_type = AuditEventType(args.event)
3475
+ except ValueError:
3476
+ console.print(f"[red]Unknown event type: {args.event}[/]")
3477
+ return
3478
+
3479
+ # Parse time range
3480
+ start_time = None
3481
+ if hasattr(args, "since") and args.since:
3482
+ since = args.since.lower()
3483
+ now = datetime.now()
3484
+ if "hour" in since:
3485
+ hours = int(since.split()[0]) if since[0].isdigit() else 1
3486
+ start_time = now - timedelta(hours=hours)
3487
+ elif "day" in since:
3488
+ days = int(since.split()[0]) if since[0].isdigit() else 1
3489
+ start_time = now - timedelta(days=days)
3490
+ elif "week" in since:
3491
+ weeks = int(since.split()[0]) if since[0].isdigit() else 1
3492
+ start_time = now - timedelta(weeks=weeks)
3493
+
3494
+ user = getattr(args, "user_filter", None)
3495
+ target = getattr(args, "target", None)
3496
+ limit = getattr(args, "limit", 100)
3497
+
3498
+ events = query.query(
3499
+ event_type=event_type,
3500
+ target_name=target,
3501
+ user=user,
3502
+ start_time=start_time,
3503
+ limit=limit,
3504
+ )
3505
+
3506
+ if not events:
3507
+ console.print("[yellow]No matching audit events found.[/]")
3508
+ return
3509
+
3510
+ console.print(f"[bold]Found {len(events)} events:[/]")
3511
+
3512
+ for event in events:
3513
+ outcome_color = "green" if event.outcome.value == "success" else "red"
3514
+ console.print(
3515
+ f" [{outcome_color}]{event.outcome.value}[/] "
3516
+ f"{event.timestamp.strftime('%Y-%m-%d %H:%M')} "
3517
+ f"[cyan]{event.event_type.value}[/] "
3518
+ f"{event.target_name or '-'}"
3519
+ )
3520
+
3521
+
3522
+ def cmd_audit_export(args) -> None:
3523
+ """Export audit events to file."""
3524
+ query = AuditQuery()
3525
+ events = query.query(limit=getattr(args, "limit", 10000))
3526
+
3527
+ if not events:
3528
+ console.print("[yellow]No audit events to export.[/]")
3529
+ return
3530
+
3531
+ output_format = getattr(args, "format", "json")
3532
+ output_file = getattr(args, "output", None)
3533
+
3534
+ if output_format == "json":
3535
+ data = [e.to_dict() for e in events]
3536
+ content = json.dumps(data, indent=2, default=str)
3537
+ else:
3538
+ # CSV format
3539
+ import csv
3540
+ import io
3541
+ output = io.StringIO()
3542
+ writer = csv.writer(output)
3543
+ writer.writerow(["timestamp", "event_type", "outcome", "target", "user", "error"])
3544
+ for e in events:
3545
+ writer.writerow([
3546
+ e.timestamp.isoformat(),
3547
+ e.event_type.value,
3548
+ e.outcome.value,
3549
+ e.target_name or "",
3550
+ e.user,
3551
+ e.error_message or "",
3552
+ ])
3553
+ content = output.getvalue()
3554
+
3555
+ if output_file:
3556
+ Path(output_file).write_text(content)
3557
+ console.print(f"[green]✅ Exported {len(events)} events to {output_file}[/]")
3558
+ else:
3559
+ console.print(content)
3560
+
3561
+
3241
3562
  # === Orchestration Commands ===
3242
3563
 
3243
3564
 
@@ -3353,6 +3674,42 @@ def cmd_compose_status(args) -> None:
3353
3674
  console.print(f"[red]❌ Failed to get status: {e}[/]")
3354
3675
 
3355
3676
 
3677
+ def cmd_compose_logs(args) -> None:
3678
+ """Show aggregated logs from VMs."""
3679
+ compose_file = Path(args.file) if hasattr(args, "file") and args.file else Path("clonebox-compose.yaml")
3680
+
3681
+ if not compose_file.exists():
3682
+ console.print(f"[red]Compose file not found: {compose_file}[/]")
3683
+ return
3684
+
3685
+ user_session = getattr(args, "user", False)
3686
+ follow = getattr(args, "follow", False)
3687
+ lines = getattr(args, "lines", 50)
3688
+ service = getattr(args, "service", None)
3689
+
3690
+ try:
3691
+ orch = Orchestrator.from_file(compose_file, user_session=user_session)
3692
+
3693
+ if service:
3694
+ # Logs for specific service
3695
+ logs = orch.logs(service, follow=follow, lines=lines)
3696
+ if logs:
3697
+ console.print(f"[bold]Logs for {service}:[/]")
3698
+ console.print(logs)
3699
+ else:
3700
+ console.print(f"[yellow]No logs available for {service}[/]")
3701
+ else:
3702
+ # Logs for all services
3703
+ for name in orch.plan.vms.keys():
3704
+ logs = orch.logs(name, follow=False, lines=lines)
3705
+ if logs:
3706
+ console.print(f"\n[bold cyan]━━━ {name} ━━━[/]")
3707
+ console.print(logs)
3708
+
3709
+ except Exception as e:
3710
+ console.print(f"[red]❌ Failed to get logs: {e}[/]")
3711
+
3712
+
3356
3713
  # === Plugin Commands ===
3357
3714
 
3358
3715
 
@@ -3430,6 +3787,199 @@ def cmd_plugin_discover(args) -> None:
3430
3787
  console.print(f" • {name}")
3431
3788
 
3432
3789
 
3790
+ def cmd_plugin_install(args) -> None:
3791
+ """Install a plugin."""
3792
+ manager = get_plugin_manager()
3793
+ source = args.source
3794
+
3795
+ console.print(f"[cyan]📦 Installing plugin from: {source}[/]")
3796
+
3797
+ if manager.install(source):
3798
+ console.print("[green]✅ Plugin installed successfully[/]")
3799
+ console.print("[dim]Run 'clonebox plugin discover' to see available plugins[/]")
3800
+ else:
3801
+ console.print(f"[red]❌ Failed to install plugin from: {source}[/]")
3802
+
3803
+
3804
+ def cmd_plugin_uninstall(args) -> None:
3805
+ """Uninstall a plugin."""
3806
+ manager = get_plugin_manager()
3807
+ name = args.name
3808
+
3809
+ console.print(f"[cyan]🗑️ Uninstalling plugin: {name}[/]")
3810
+
3811
+ if manager.uninstall(name):
3812
+ console.print(f"[green]✅ Plugin '{name}' uninstalled successfully[/]")
3813
+ else:
3814
+ console.print(f"[red]❌ Failed to uninstall plugin: {name}[/]")
3815
+
3816
+
3817
+ # === Remote Management Commands ===
3818
+
3819
+
3820
+ def cmd_remote_list(args) -> None:
3821
+ """List VMs on remote host."""
3822
+ host = args.host
3823
+ user_session = getattr(args, "user", False)
3824
+
3825
+ console.print(f"[cyan]🔍 Connecting to: {host}[/]")
3826
+
3827
+ try:
3828
+ remote = RemoteCloner(host, verify=True)
3829
+
3830
+ if not remote.is_clonebox_installed():
3831
+ console.print("[red]❌ CloneBox is not installed on remote host[/]")
3832
+ return
3833
+
3834
+ vms = remote.list_vms(user_session=user_session)
3835
+
3836
+ if not vms:
3837
+ console.print("[yellow]No VMs found on remote host.[/]")
3838
+ return
3839
+
3840
+ table = Table(title=f"VMs on {host}", border_style="cyan")
3841
+ table.add_column("Name")
3842
+ table.add_column("Status")
3843
+
3844
+ for vm in vms:
3845
+ name = vm.get("name", str(vm))
3846
+ status = vm.get("state", vm.get("status", "-"))
3847
+ table.add_row(name, status)
3848
+
3849
+ console.print(table)
3850
+
3851
+ except ConnectionError as e:
3852
+ console.print(f"[red]❌ Connection failed: {e}[/]")
3853
+ except Exception as e:
3854
+ console.print(f"[red]❌ Error: {e}[/]")
3855
+
3856
+
3857
+ def cmd_remote_status(args) -> None:
3858
+ """Get VM status on remote host."""
3859
+ host = args.host
3860
+ vm_name = args.vm_name
3861
+ user_session = getattr(args, "user", False)
3862
+
3863
+ console.print(f"[cyan]🔍 Getting status of {vm_name} on {host}[/]")
3864
+
3865
+ try:
3866
+ remote = RemoteCloner(host, verify=True)
3867
+ status = remote.get_status(vm_name, user_session=user_session)
3868
+
3869
+ if getattr(args, "json", False):
3870
+ console.print_json(json.dumps(status, default=str))
3871
+ else:
3872
+ for key, value in status.items():
3873
+ console.print(f" [bold]{key}:[/] {value}")
3874
+
3875
+ except Exception as e:
3876
+ console.print(f"[red]❌ Error: {e}[/]")
3877
+
3878
+
3879
+ def cmd_remote_start(args) -> None:
3880
+ """Start VM on remote host."""
3881
+ host = args.host
3882
+ vm_name = args.vm_name
3883
+ user_session = getattr(args, "user", False)
3884
+
3885
+ console.print(f"[cyan]🚀 Starting {vm_name} on {host}[/]")
3886
+
3887
+ try:
3888
+ remote = RemoteCloner(host, verify=True)
3889
+ remote.start_vm(vm_name, user_session=user_session)
3890
+ console.print(f"[green]✅ VM {vm_name} started[/]")
3891
+
3892
+ except Exception as e:
3893
+ console.print(f"[red]❌ Error: {e}[/]")
3894
+
3895
+
3896
+ def cmd_remote_stop(args) -> None:
3897
+ """Stop VM on remote host."""
3898
+ host = args.host
3899
+ vm_name = args.vm_name
3900
+ user_session = getattr(args, "user", False)
3901
+ force = getattr(args, "force", False)
3902
+
3903
+ console.print(f"[cyan]🛑 Stopping {vm_name} on {host}[/]")
3904
+
3905
+ try:
3906
+ remote = RemoteCloner(host, verify=True)
3907
+ remote.stop_vm(vm_name, force=force, user_session=user_session)
3908
+ console.print(f"[green]✅ VM {vm_name} stopped[/]")
3909
+
3910
+ except Exception as e:
3911
+ console.print(f"[red]❌ Error: {e}[/]")
3912
+
3913
+
3914
+ def cmd_remote_delete(args) -> None:
3915
+ """Delete VM on remote host."""
3916
+ host = args.host
3917
+ vm_name = args.vm_name
3918
+ user_session = getattr(args, "user", False)
3919
+ keep_storage = getattr(args, "keep_storage", False)
3920
+
3921
+ if not getattr(args, "yes", False):
3922
+ confirm = questionary.confirm(
3923
+ f"Delete VM '{vm_name}' on {host}?",
3924
+ default=False,
3925
+ style=custom_style,
3926
+ ).ask()
3927
+ if not confirm:
3928
+ console.print("[yellow]Aborted.[/]")
3929
+ return
3930
+
3931
+ console.print(f"[cyan]🗑️ Deleting {vm_name} on {host}[/]")
3932
+
3933
+ try:
3934
+ remote = RemoteCloner(host, verify=True)
3935
+ remote.delete_vm(vm_name, keep_storage=keep_storage, user_session=user_session)
3936
+ console.print(f"[green]✅ VM {vm_name} deleted[/]")
3937
+
3938
+ except Exception as e:
3939
+ console.print(f"[red]❌ Error: {e}[/]")
3940
+
3941
+
3942
+ def cmd_remote_exec(args) -> None:
3943
+ """Execute command in VM on remote host."""
3944
+ host = args.host
3945
+ vm_name = args.vm_name
3946
+ command = " ".join(args.command) if args.command else "echo ok"
3947
+ user_session = getattr(args, "user", False)
3948
+ timeout = getattr(args, "timeout", 30)
3949
+
3950
+ try:
3951
+ remote = RemoteCloner(host, verify=True)
3952
+ output = remote.exec_in_vm(vm_name, command, timeout=timeout, user_session=user_session)
3953
+ console.print(output)
3954
+
3955
+ except Exception as e:
3956
+ console.print(f"[red]❌ Error: {e}[/]")
3957
+
3958
+
3959
+ def cmd_remote_health(args) -> None:
3960
+ """Run health check on remote VM."""
3961
+ host = args.host
3962
+ vm_name = args.vm_name
3963
+ user_session = getattr(args, "user", False)
3964
+
3965
+ console.print(f"[cyan]🏥 Running health check on {vm_name}@{host}[/]")
3966
+
3967
+ try:
3968
+ remote = RemoteCloner(host, verify=True)
3969
+ result = remote.health_check(vm_name, user_session=user_session)
3970
+
3971
+ if result["success"]:
3972
+ console.print("[green]✅ Health check passed[/]")
3973
+ else:
3974
+ console.print("[red]❌ Health check failed[/]")
3975
+
3976
+ if result.get("output"):
3977
+ console.print(result["output"])
3978
+
3979
+ except Exception as e:
3980
+ console.print(f"[red]❌ Error: {e}[/]")
3981
+
3982
+
3433
3983
  def main():
3434
3984
  """Main entry point."""
3435
3985
  parser = argparse.ArgumentParser(
@@ -3812,6 +4362,37 @@ def main():
3812
4362
  )
3813
4363
  repair_parser.set_defaults(func=cmd_repair)
3814
4364
 
4365
+ # Logs command - view VM logs
4366
+ logs_parser = subparsers.add_parser("logs", help="View logs from VM")
4367
+ logs_parser.add_argument(
4368
+ "name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml"
4369
+ )
4370
+ logs_parser.add_argument(
4371
+ "-u",
4372
+ "--user",
4373
+ action="store_true",
4374
+ help="Use user session (qemu:///session)",
4375
+ )
4376
+ logs_parser.add_argument(
4377
+ "--all",
4378
+ action="store_true",
4379
+ help="Show all logs at once without interactive menu",
4380
+ )
4381
+ logs_parser.set_defaults(func=cmd_logs)
4382
+
4383
+ # Set-password command - set password for VM user
4384
+ set_password_parser = subparsers.add_parser("set-password", help="Set password for VM user")
4385
+ set_password_parser.add_argument(
4386
+ "name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml"
4387
+ )
4388
+ set_password_parser.add_argument(
4389
+ "-u",
4390
+ "--user",
4391
+ action="store_true",
4392
+ help="Use user session (qemu:///session)",
4393
+ )
4394
+ set_password_parser.set_defaults(func=cmd_set_password)
4395
+
3815
4396
  # Export command - package VM for migration
3816
4397
  export_parser = subparsers.add_parser("export", help="Export VM and data for migration")
3817
4398
  export_parser.add_argument(
@@ -4036,6 +4617,20 @@ def main():
4036
4617
  audit_failures.add_argument("--limit", "-n", type=int, default=20, help="Max events to show")
4037
4618
  audit_failures.set_defaults(func=cmd_audit_failures)
4038
4619
 
4620
+ audit_search = audit_sub.add_parser("search", help="Search audit events")
4621
+ audit_search.add_argument("--event", "-e", help="Event type (e.g., vm.create)")
4622
+ audit_search.add_argument("--since", "-s", help="Time range (e.g., '1 hour ago', '7 days')")
4623
+ audit_search.add_argument("--user-filter", help="Filter by user")
4624
+ audit_search.add_argument("--target", help="Filter by target name")
4625
+ audit_search.add_argument("--limit", "-n", type=int, default=100, help="Max events to show")
4626
+ audit_search.set_defaults(func=cmd_audit_search)
4627
+
4628
+ audit_export = audit_sub.add_parser("export", help="Export audit events to file")
4629
+ audit_export.add_argument("--format", "-f", choices=["json", "csv"], default="json", help="Output format")
4630
+ audit_export.add_argument("--output", "-o", help="Output file (stdout if not specified)")
4631
+ audit_export.add_argument("--limit", "-n", type=int, default=10000, help="Max events to export")
4632
+ audit_export.set_defaults(func=cmd_audit_export)
4633
+
4039
4634
  # === Compose/Orchestration Commands ===
4040
4635
  compose_parser = subparsers.add_parser("compose", help="Multi-VM orchestration")
4041
4636
  compose_sub = compose_parser.add_subparsers(dest="compose_command", help="Compose commands")
@@ -4059,6 +4654,14 @@ def main():
4059
4654
  compose_status.add_argument("--json", action="store_true", help="Output as JSON")
4060
4655
  compose_status.set_defaults(func=cmd_compose_status)
4061
4656
 
4657
+ compose_logs = compose_sub.add_parser("logs", help="Show aggregated logs from VMs")
4658
+ compose_logs.add_argument("-f", "--file", default="clonebox-compose.yaml", help="Compose file")
4659
+ compose_logs.add_argument("-u", "--user", action="store_true", help="Use user session")
4660
+ compose_logs.add_argument("--follow", action="store_true", help="Follow log output")
4661
+ compose_logs.add_argument("--lines", "-n", type=int, default=50, help="Number of lines to show")
4662
+ compose_logs.add_argument("service", nargs="?", help="Specific service to show logs for")
4663
+ compose_logs.set_defaults(func=cmd_compose_logs)
4664
+
4062
4665
  # === Plugin Commands ===
4063
4666
  plugin_parser = subparsers.add_parser("plugin", help="Manage plugins")
4064
4667
  plugin_sub = plugin_parser.add_subparsers(dest="plugin_command", help="Plugin commands")
@@ -4077,6 +4680,65 @@ def main():
4077
4680
  plugin_discover = plugin_sub.add_parser("discover", help="Discover available plugins")
4078
4681
  plugin_discover.set_defaults(func=cmd_plugin_discover)
4079
4682
 
4683
+ plugin_install = plugin_sub.add_parser("install", help="Install a plugin")
4684
+ plugin_install.add_argument("source", help="Plugin source (PyPI package, git URL, or local path)")
4685
+ plugin_install.set_defaults(func=cmd_plugin_install)
4686
+
4687
+ plugin_uninstall = plugin_sub.add_parser("uninstall", aliases=["remove"], help="Uninstall a plugin")
4688
+ plugin_uninstall.add_argument("name", help="Plugin name")
4689
+ plugin_uninstall.set_defaults(func=cmd_plugin_uninstall)
4690
+
4691
+ # === Remote Management Commands ===
4692
+ remote_parser = subparsers.add_parser("remote", help="Manage VMs on remote hosts")
4693
+ remote_sub = remote_parser.add_subparsers(dest="remote_command", help="Remote commands")
4694
+
4695
+ remote_list = remote_sub.add_parser("list", aliases=["ls"], help="List VMs on remote host")
4696
+ remote_list.add_argument("host", help="Remote host (user@hostname)")
4697
+ remote_list.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
4698
+ remote_list.set_defaults(func=cmd_remote_list)
4699
+
4700
+ remote_status = remote_sub.add_parser("status", help="Get VM status on remote host")
4701
+ remote_status.add_argument("host", help="Remote host (user@hostname)")
4702
+ remote_status.add_argument("vm_name", help="VM name")
4703
+ remote_status.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
4704
+ remote_status.add_argument("--json", action="store_true", help="Output as JSON")
4705
+ remote_status.set_defaults(func=cmd_remote_status)
4706
+
4707
+ remote_start = remote_sub.add_parser("start", help="Start VM on remote host")
4708
+ remote_start.add_argument("host", help="Remote host (user@hostname)")
4709
+ remote_start.add_argument("vm_name", help="VM name")
4710
+ remote_start.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
4711
+ remote_start.set_defaults(func=cmd_remote_start)
4712
+
4713
+ remote_stop = remote_sub.add_parser("stop", help="Stop VM on remote host")
4714
+ remote_stop.add_argument("host", help="Remote host (user@hostname)")
4715
+ remote_stop.add_argument("vm_name", help="VM name")
4716
+ remote_stop.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
4717
+ remote_stop.add_argument("-f", "--force", action="store_true", help="Force stop")
4718
+ remote_stop.set_defaults(func=cmd_remote_stop)
4719
+
4720
+ remote_delete = remote_sub.add_parser("delete", aliases=["rm"], help="Delete VM on remote host")
4721
+ remote_delete.add_argument("host", help="Remote host (user@hostname)")
4722
+ remote_delete.add_argument("vm_name", help="VM name")
4723
+ remote_delete.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
4724
+ remote_delete.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
4725
+ remote_delete.add_argument("--keep-storage", action="store_true", help="Keep disk images")
4726
+ remote_delete.set_defaults(func=cmd_remote_delete)
4727
+
4728
+ remote_exec = remote_sub.add_parser("exec", help="Execute command in VM on remote host")
4729
+ remote_exec.add_argument("host", help="Remote host (user@hostname)")
4730
+ remote_exec.add_argument("vm_name", help="VM name")
4731
+ remote_exec.add_argument("command", nargs=argparse.REMAINDER, help="Command to execute")
4732
+ remote_exec.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
4733
+ remote_exec.add_argument("-t", "--timeout", type=int, default=30, help="Command timeout")
4734
+ remote_exec.set_defaults(func=cmd_remote_exec)
4735
+
4736
+ remote_health = remote_sub.add_parser("health", help="Run health check on remote VM")
4737
+ remote_health.add_argument("host", help="Remote host (user@hostname)")
4738
+ remote_health.add_argument("vm_name", help="VM name")
4739
+ remote_health.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
4740
+ remote_health.set_defaults(func=cmd_remote_health)
4741
+
4080
4742
  args = parser.parse_args()
4081
4743
 
4082
4744
  if hasattr(args, "func"):