clonebox 1.1.14__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.

Potentially problematic release.


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

clonebox/audit.py CHANGED
@@ -228,7 +228,11 @@ class AuditLogger:
228
228
 
229
229
  # Ensure log directory exists
230
230
  if self.enabled:
231
- self.log_path.parent.mkdir(parents=True, exist_ok=True)
231
+ try:
232
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
233
+ except (PermissionError, OSError):
234
+ # If we can't create log directory, disable audit logging
235
+ self.enabled = False
232
236
 
233
237
  def log(
234
238
  self,
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()
@@ -1972,7 +2045,7 @@ def load_env_file(env_path: Path) -> dict:
1972
2045
  continue
1973
2046
  if "=" in line:
1974
2047
  key, value = line.split("=", 1)
1975
- env_vars[key.strip()] = value.strip()
2048
+ env_vars[key.strip()] = value.strip().strip("'\"")
1976
2049
 
1977
2050
  return env_vars
1978
2051
 
@@ -1993,6 +2066,22 @@ def expand_env_vars(value, env_vars: dict):
1993
2066
  return value
1994
2067
 
1995
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
+
1996
2085
  def deduplicate_list(items: list, key=None) -> list:
1997
2086
  """Remove duplicates from list, preserving order."""
1998
2087
  seen = set()
@@ -2249,6 +2338,14 @@ def load_clonebox_config(path: Path) -> dict:
2249
2338
  # Expand environment variables in config
2250
2339
  config = expand_env_vars(config, env_vars)
2251
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
+
2252
2349
  return config
2253
2350
 
2254
2351
 
@@ -2358,9 +2455,32 @@ def create_vm_from_config(
2358
2455
  replace: bool = False,
2359
2456
  ) -> str:
2360
2457
  """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", {}))
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}
2364
2484
 
2365
2485
  vm_config = VMConfig(
2366
2486
  name=config["vm"]["name"],
@@ -2369,7 +2489,8 @@ def create_vm_from_config(
2369
2489
  disk_size_gb=config["vm"].get("disk_size_gb", 10),
2370
2490
  gui=config["vm"].get("gui", True),
2371
2491
  base_image=config["vm"].get("base_image"),
2372
- paths=all_paths,
2492
+ paths=paths,
2493
+ copy_paths=copy_paths,
2373
2494
  packages=config.get("packages", []),
2374
2495
  snap_packages=config.get("snap_packages", []),
2375
2496
  services=config.get("services", []),
@@ -2378,6 +2499,9 @@ def create_vm_from_config(
2378
2499
  network_mode=config["vm"].get("network_mode", "auto"),
2379
2500
  username=config["vm"].get("username", "ubuntu"),
2380
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", {}),
2381
2505
  )
2382
2506
 
2383
2507
  cloner = SelectiveVMCloner(user_session=user_session)
@@ -3238,6 +3362,104 @@ def cmd_audit_failures(args) -> None:
3238
3362
  console.print(table)
3239
3363
 
3240
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
+
3241
3463
  # === Orchestration Commands ===
3242
3464
 
3243
3465
 
@@ -3353,6 +3575,42 @@ def cmd_compose_status(args) -> None:
3353
3575
  console.print(f"[red]❌ Failed to get status: {e}[/]")
3354
3576
 
3355
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
+
3356
3614
  # === Plugin Commands ===
3357
3615
 
3358
3616
 
@@ -3430,6 +3688,199 @@ def cmd_plugin_discover(args) -> None:
3430
3688
  console.print(f" • {name}")
3431
3689
 
3432
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
+
3433
3884
  def main():
3434
3885
  """Main entry point."""
3435
3886
  parser = argparse.ArgumentParser(
@@ -3812,6 +4263,37 @@ def main():
3812
4263
  )
3813
4264
  repair_parser.set_defaults(func=cmd_repair)
3814
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
+
3815
4297
  # Export command - package VM for migration
3816
4298
  export_parser = subparsers.add_parser("export", help="Export VM and data for migration")
3817
4299
  export_parser.add_argument(
@@ -4036,6 +4518,20 @@ def main():
4036
4518
  audit_failures.add_argument("--limit", "-n", type=int, default=20, help="Max events to show")
4037
4519
  audit_failures.set_defaults(func=cmd_audit_failures)
4038
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
+
4039
4535
  # === Compose/Orchestration Commands ===
4040
4536
  compose_parser = subparsers.add_parser("compose", help="Multi-VM orchestration")
4041
4537
  compose_sub = compose_parser.add_subparsers(dest="compose_command", help="Compose commands")
@@ -4059,6 +4555,14 @@ def main():
4059
4555
  compose_status.add_argument("--json", action="store_true", help="Output as JSON")
4060
4556
  compose_status.set_defaults(func=cmd_compose_status)
4061
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
+
4062
4566
  # === Plugin Commands ===
4063
4567
  plugin_parser = subparsers.add_parser("plugin", help="Manage plugins")
4064
4568
  plugin_sub = plugin_parser.add_subparsers(dest="plugin_command", help="Plugin commands")
@@ -4077,6 +4581,65 @@ def main():
4077
4581
  plugin_discover = plugin_sub.add_parser("discover", help="Discover available plugins")
4078
4582
  plugin_discover.set_defaults(func=cmd_plugin_discover)
4079
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
+
4080
4643
  args = parser.parse_args()
4081
4644
 
4082
4645
  if hasattr(args, "func"):