clonebox 1.1.18__py3-none-any.whl → 1.1.19__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
@@ -7,8 +7,10 @@ import argparse
7
7
  import json
8
8
  import os
9
9
  import re
10
+ import secrets
10
11
  import sys
11
12
  import time
13
+ from dataclasses import asdict
12
14
  from typing import Any, Dict, Optional, Tuple
13
15
  from datetime import datetime
14
16
  from pathlib import Path
@@ -37,6 +39,7 @@ from clonebox.health import HealthCheckManager, ProbeConfig, ProbeType
37
39
  from clonebox.audit import get_audit_logger, AuditQuery, AuditEventType, AuditOutcome
38
40
  from clonebox.orchestrator import Orchestrator, OrchestrationResult
39
41
  from clonebox.plugins import get_plugin_manager, PluginHook, PluginContext
42
+ from clonebox.policies import PolicyEngine, PolicyValidationError, PolicyViolationError
40
43
  from clonebox.remote import RemoteCloner, RemoteConnection
41
44
 
42
45
  # Custom questionary style
@@ -263,8 +266,6 @@ def run_vm_diagnostics(
263
266
  console.print(f"[dim]{domifaddr.stdout.strip()}[/]")
264
267
  else:
265
268
  console.print("[yellow]⚠️ No interface address detected via virsh domifaddr[/]")
266
- if verbose and domifaddr.stderr.strip():
267
- console.print(f"[dim]{domifaddr.stderr.strip()}[/]")
268
269
  # Fallback: try to get IP via QEMU Guest Agent (useful for slirp/user networking)
269
270
  if guest_agent_ready:
270
271
  try:
@@ -349,7 +350,7 @@ def run_vm_diagnostics(
349
350
  if not guest_agent_ready:
350
351
  result["cloud_init"] = {"status": "unknown", "reason": "qga_not_ready"}
351
352
  console.print(
352
- "[yellow]⏳ Cloud-init status: Unknown (QEMU guest agent not connected yet)[/]"
353
+ "[yellow]⏳ Cloud-init status: Unknown (QEMU Guest Agent not connected yet)[/]"
353
354
  )
354
355
  else:
355
356
  ready_msg = _qga_exec(
@@ -453,7 +454,7 @@ def run_vm_diagnostics(
453
454
  console.print("\n[bold]🏥 Health Check Status...[/]")
454
455
  if not guest_agent_ready:
455
456
  result["health"]["status"] = "unknown"
456
- console.print("[dim]Health status: Not available yet (QEMU guest agent not ready)[/]")
457
+ console.print("[dim]Health status: Not available yet (QEMU Guest Agent not ready)[/]")
457
458
  else:
458
459
  health_status = _qga_exec(
459
460
  vm_name, conn_uri, "cat /var/log/clonebox-health-status 2>/dev/null || true", timeout=10
@@ -609,7 +610,6 @@ def cmd_logs(args):
609
610
 
610
611
  name = args.name
611
612
  user_session = getattr(args, "user", False)
612
- show_all = getattr(args, "all", False)
613
613
 
614
614
  try:
615
615
  vm_name, _ = _resolve_vm_name_and_config_file(name)
@@ -629,7 +629,7 @@ def cmd_logs(args):
629
629
  try:
630
630
  console.print(f"[cyan]📋 Opening logs for VM: {vm_name}[/]")
631
631
  subprocess.run(
632
- [str(logs_script), vm_name, "true" if user_session else "false", "true" if show_all else "false"],
632
+ [str(logs_script), vm_name, "true" if user_session else "false", "true" if getattr(args, "all", False) else "false"],
633
633
  check=True
634
634
  )
635
635
  except subprocess.CalledProcessError as e:
@@ -942,7 +942,7 @@ def interactive_mode():
942
942
  )
943
943
 
944
944
  try:
945
- cloner = SelectiveVMCloner(user_session=user_session)
945
+ cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
946
946
 
947
947
  # Check prerequisites
948
948
  checks = cloner.check_prerequisites()
@@ -1192,13 +1192,42 @@ def cmd_delete(args):
1192
1192
  # If name is a path, load config
1193
1193
  if name and (name.startswith(".") or name.startswith("/") or name.startswith("~")):
1194
1194
  target_path = Path(name).expanduser().resolve()
1195
- config_file = target_path / ".clonebox.yaml" if target_path.is_dir() else target_path
1195
+
1196
+ if target_path.is_dir():
1197
+ config_file = target_path / CLONEBOX_CONFIG_FILE
1198
+ else:
1199
+ config_file = target_path
1200
+
1196
1201
  if config_file.exists():
1197
1202
  config = load_clonebox_config(config_file)
1198
1203
  name = config["vm"]["name"]
1199
1204
  else:
1200
1205
  console.print(f"[red]❌ Config not found: {config_file}[/]")
1201
1206
  return
1207
+ elif not name or name == ".":
1208
+ config_file = Path.cwd() / ".clonebox.yaml"
1209
+ if config_file.exists():
1210
+ config = load_clonebox_config(config_file)
1211
+ name = config["vm"]["name"]
1212
+ else:
1213
+ console.print("[red]❌ No .clonebox.yaml found in current directory[/]")
1214
+ console.print("[dim]Usage: clonebox delete . or clonebox delete <vm-name>[/]")
1215
+ return
1216
+
1217
+ policy_start = None
1218
+ if name and (name.startswith(".") or name.startswith("/") or name.startswith("~")):
1219
+ policy_start = Path(name).expanduser().resolve()
1220
+
1221
+ policy = PolicyEngine.load_effective(start=policy_start)
1222
+ if policy is not None:
1223
+ try:
1224
+ policy.assert_operation_approved(
1225
+ AuditEventType.VM_DELETE.value,
1226
+ approved=getattr(args, "approve", False),
1227
+ )
1228
+ except PolicyViolationError as e:
1229
+ console.print(f"[red]❌ {e}[/]")
1230
+ sys.exit(1)
1202
1231
 
1203
1232
  if not args.yes:
1204
1233
  if not questionary.confirm(
@@ -1207,6 +1236,19 @@ def cmd_delete(args):
1207
1236
  console.print("[yellow]Cancelled.[/]")
1208
1237
  return
1209
1238
 
1239
+ cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
1240
+ delete_storage = not getattr(args, "keep_storage", False)
1241
+ console.print(f"[cyan]🗑️ Deleting VM: {name}[/]")
1242
+ try:
1243
+ ok = cloner.delete_vm(
1244
+ name, delete_storage=delete_storage, console=console, approved=getattr(args, "approve", False)
1245
+ )
1246
+ if not ok:
1247
+ sys.exit(1)
1248
+ except Exception as e:
1249
+ console.print(f"[red]❌ Failed to delete VM: {e}[/]")
1250
+ sys.exit(1)
1251
+
1210
1252
 
1211
1253
  def cmd_list(args):
1212
1254
  """List all VMs."""
@@ -1418,7 +1460,12 @@ def cmd_export(args):
1418
1460
  # If name is a path, load config
1419
1461
  if name and (name.startswith(".") or name.startswith("/") or name.startswith("~")):
1420
1462
  target_path = Path(name).expanduser().resolve()
1421
- config_file = target_path / ".clonebox.yaml" if target_path.is_dir() else target_path
1463
+
1464
+ if target_path.is_dir():
1465
+ config_file = target_path / CLONEBOX_CONFIG_FILE
1466
+ else:
1467
+ config_file = target_path
1468
+
1422
1469
  if config_file.exists():
1423
1470
  config = load_clonebox_config(config_file)
1424
1471
  name = config["vm"]["name"]
@@ -1649,6 +1696,16 @@ def cmd_import(args):
1649
1696
  f"[red]❌ VM '{vm_name}' already exists. Use --replace to overwrite.[/]"
1650
1697
  )
1651
1698
  return
1699
+ policy = PolicyEngine.load_effective(start=vm_storage)
1700
+ if policy is not None:
1701
+ try:
1702
+ policy.assert_operation_approved(
1703
+ AuditEventType.VM_DELETE.value,
1704
+ approved=getattr(args, "approve", False),
1705
+ )
1706
+ except PolicyViolationError as e:
1707
+ console.print(f"[red]❌ {e}[/]")
1708
+ sys.exit(1)
1652
1709
  shutil.rmtree(vm_storage)
1653
1710
 
1654
1711
  vm_storage.mkdir(parents=True)
@@ -1811,7 +1868,7 @@ def cmd_test(args):
1811
1868
  console.print()
1812
1869
 
1813
1870
  # Test 2: Check VM state
1814
- console.print("[bold]2. VM State Check[/]")
1871
+ cloud_init_running = False
1815
1872
  try:
1816
1873
  result = subprocess.run(
1817
1874
  ["virsh", "--connect", conn_uri, "domstate", vm_name],
@@ -1820,6 +1877,7 @@ def cmd_test(args):
1820
1877
  timeout=10,
1821
1878
  )
1822
1879
  state = result.stdout.strip()
1880
+
1823
1881
  if state == "running":
1824
1882
  console.print("[green]✅ VM is running[/]")
1825
1883
 
@@ -1831,6 +1889,15 @@ def cmd_test(args):
1831
1889
  qga_ready = _qga_ping(vm_name, conn_uri)
1832
1890
  if qga_ready:
1833
1891
  break
1892
+
1893
+ # Check cloud-init status immediately if QGA is ready
1894
+ if qga_ready:
1895
+ status = _qga_exec(
1896
+ vm_name, conn_uri, "cloud-init status 2>/dev/null || true", timeout=15
1897
+ )
1898
+ if status and "running" in status.lower():
1899
+ cloud_init_running = True
1900
+ console.print("[yellow]⏳ Setup in progress (cloud-init is running)[/]")
1834
1901
 
1835
1902
  # Test network if running
1836
1903
  console.print("\n Checking network...")
@@ -1859,8 +1926,9 @@ def cmd_test(args):
1859
1926
  timeout=5,
1860
1927
  )
1861
1928
  if ip_out and ip_out.strip():
1929
+ ip_clean = ip_out.strip().replace("\n", ", ")
1862
1930
  console.print(
1863
- f"[green]✅ VM has network access (IP via QGA: {ip_out.strip()})[/]"
1931
+ f"[green]✅ VM has network access (IP via QGA: {ip_clean})[/]"
1864
1932
  )
1865
1933
  else:
1866
1934
  console.print("[yellow]⚠️ IP not available via QGA[/]")
@@ -1880,14 +1948,15 @@ def cmd_test(args):
1880
1948
 
1881
1949
  # Test 3: Check cloud-init status (if running)
1882
1950
  cloud_init_complete: Optional[bool] = None
1883
- cloud_init_running: bool = False
1884
1951
  if not quick and state == "running":
1885
1952
  console.print("[bold]3. Cloud-init Status[/]")
1886
1953
  try:
1887
1954
  if not qga_ready:
1888
1955
  console.print("[yellow]⚠️ Cloud-init status unknown (QEMU Guest Agent not connected)[/]")
1889
1956
  else:
1890
- status = _qga_exec(vm_name, conn_uri, "cloud-init status 2>/dev/null || true", timeout=15)
1957
+ status = _qga_exec(
1958
+ vm_name, conn_uri, "cloud-init status 2>/dev/null || true", timeout=15
1959
+ )
1891
1960
  if status is None:
1892
1961
  console.print("[yellow]⚠️ Could not check cloud-init (QGA command failed)[/]")
1893
1962
  cloud_init_complete = None
@@ -1914,9 +1983,11 @@ def cmd_test(args):
1914
1983
  if not quick and state == "running":
1915
1984
  console.print("[bold]4. Mount Points Check[/]")
1916
1985
  paths = config.get("paths", {})
1917
- app_data_paths = config.get("app_data_paths", {})
1986
+ copy_paths = config.get("copy_paths", None)
1987
+ if not isinstance(copy_paths, dict) or not copy_paths:
1988
+ copy_paths = config.get("app_data_paths", {})
1918
1989
 
1919
- if paths or app_data_paths:
1990
+ if paths or copy_paths:
1920
1991
  if not _qga_ping(vm_name, conn_uri):
1921
1992
  console.print("[yellow]⚠️ QEMU guest agent not connected - cannot verify mounts[/]")
1922
1993
  else:
@@ -1938,7 +2009,7 @@ def cmd_test(args):
1938
2009
  console.print(f"[yellow]⚠️ {guest_path} (could not check)[/]")
1939
2010
 
1940
2011
  # Check copied paths
1941
- for idx, (host_path, guest_path) in enumerate(app_data_paths.items()):
2012
+ for idx, (host_path, guest_path) in enumerate(copy_paths.items()):
1942
2013
  try:
1943
2014
  is_accessible = _qga_exec(
1944
2015
  vm_name, conn_uri, f"test -d {guest_path} && echo yes || echo no", timeout=5
@@ -1965,23 +2036,14 @@ def cmd_test(args):
1965
2036
  console.print("[yellow]⚠️ QEMU Guest Agent not connected - cannot run health check[/]")
1966
2037
  else:
1967
2038
  exists = _qga_exec(
1968
- vm_name,
1969
- conn_uri,
1970
- "test -x /usr/local/bin/clonebox-health && echo yes || echo no",
1971
- timeout=10,
2039
+ vm_name, conn_uri, "test -x /usr/local/bin/clonebox-health && echo yes || echo no", timeout=10
1972
2040
  )
1973
2041
  if exists and exists.strip() == "yes":
1974
2042
  _qga_exec(
1975
- vm_name,
1976
- conn_uri,
1977
- "/usr/local/bin/clonebox-health >/dev/null 2>&1 || true",
1978
- timeout=60,
2043
+ vm_name, conn_uri, "/usr/local/bin/clonebox-health >/dev/null 2>&1 || true", timeout=60
1979
2044
  )
1980
2045
  health_status = _qga_exec(
1981
- vm_name,
1982
- conn_uri,
1983
- "cat /var/log/clonebox-health-status 2>/dev/null || true",
1984
- timeout=10,
2046
+ vm_name, conn_uri, "cat /var/log/clonebox-health-status 2>/dev/null || true", timeout=10
1985
2047
  )
1986
2048
  if health_status and "HEALTH_STATUS=OK" in health_status:
1987
2049
  console.print("[green]✅ Health check passed[/]")
@@ -1999,9 +2061,6 @@ def cmd_test(args):
1999
2061
  else:
2000
2062
  console.print("[yellow]⚠️ Health check status not available yet[/]")
2001
2063
  console.print(" View logs in VM: cat /var/log/clonebox-health.log")
2002
- else:
2003
- console.print("[yellow]⚠️ Health check script not found[/]")
2004
- console.print(" This is expected until cloud-init completes")
2005
2064
  except Exception as e:
2006
2065
  console.print(f"[yellow]⚠️ Could not run health check: {e}[/]")
2007
2066
 
@@ -2147,11 +2206,11 @@ def generate_clonebox_yaml(
2147
2206
  paths_by_type = {"project": [], "config": [], "data": []}
2148
2207
  for p in snapshot.paths:
2149
2208
  if p.type in paths_by_type:
2150
- paths_by_type[p.type].append(p.path)
2209
+ paths_by_type[p.type].append(p)
2151
2210
 
2152
2211
  if deduplicate:
2153
2212
  for ptype in paths_by_type:
2154
- paths_by_type[ptype] = deduplicate_list(paths_by_type[ptype])
2213
+ paths_by_type[ptype] = deduplicate_list(paths_by_type[ptype], key=lambda x: x.path)
2155
2214
 
2156
2215
  # Collect working directories from running apps
2157
2216
  working_dirs = []
@@ -2172,7 +2231,8 @@ def generate_clonebox_yaml(
2172
2231
  # Build paths mapping
2173
2232
  paths_mapping = {}
2174
2233
  idx = 0
2175
- for host_path in paths_by_type["project"][:5]: # Limit projects
2234
+ for host_path_obj in paths_by_type["project"][:5]: # Limit projects
2235
+ host_path = host_path_obj.path if hasattr(host_path_obj, 'path') else host_path_obj
2176
2236
  paths_mapping[host_path] = f"/mnt/project{idx}"
2177
2237
  idx += 1
2178
2238
 
@@ -2254,10 +2314,6 @@ def generate_clonebox_yaml(
2254
2314
  if deduplicate:
2255
2315
  all_snap_packages = deduplicate_list(all_snap_packages)
2256
2316
 
2257
- if chrome_profile.exists() and "google-chrome" not in [d.get("app", "") for d in app_data_dirs]:
2258
- if "chromium" not in all_snap_packages:
2259
- all_snap_packages.append("chromium")
2260
-
2261
2317
  if "pycharm-community" in all_snap_packages:
2262
2318
  remapped = {}
2263
2319
  for host_path, guest_path in app_data_mapping.items():
@@ -2315,9 +2371,9 @@ def generate_clonebox_yaml(
2315
2371
  for d in app_data_dirs[:15]
2316
2372
  ],
2317
2373
  "all_paths": {
2318
- "projects": list(paths_by_type["project"]),
2319
- "configs": list(paths_by_type["config"][:5]),
2320
- "data": list(paths_by_type["data"][:5]),
2374
+ "projects": [{"path": p.path if hasattr(p, 'path') else p, "type": p.type if hasattr(p, 'type') else 'project', "size_mb": p.size_mb if hasattr(p, 'size_mb') else 0} for p in paths_by_type["project"]],
2375
+ "configs": [{"path": p.path, "type": p.type, "size_mb": p.size_mb} for p in paths_by_type["config"][:5]],
2376
+ "data": [{"path": p.path, "type": p.type, "size_mb": p.size_mb} for p in paths_by_type["data"][:5]],
2321
2377
  },
2322
2378
  },
2323
2379
  }
@@ -2417,7 +2473,7 @@ def _exec_in_vm_qga(vm_name: str, conn_uri: str, command: str) -> Optional[str]:
2417
2473
  return None
2418
2474
 
2419
2475
 
2420
- def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout: int = 900):
2476
+ def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout: int = 1800):
2421
2477
  """Monitor cloud-init status in VM and show progress."""
2422
2478
  import subprocess
2423
2479
  import time
@@ -2429,582 +2485,454 @@ def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout:
2429
2485
  last_phases = []
2430
2486
  seen_lines = set()
2431
2487
 
2432
- with Progress(
2433
- SpinnerColumn(),
2434
- TextColumn("[progress.description]{task.description}"),
2435
- console=console,
2436
- ) as progress:
2437
- task = progress.add_task("[cyan]Starting VM and initializing...", total=None)
2438
-
2439
- while time.time() - start_time < timeout:
2440
- try:
2441
- elapsed = int(time.time() - start_time)
2442
- minutes = elapsed // 60
2443
- seconds = elapsed % 60
2444
-
2445
- # Check VM state
2446
- result = subprocess.run(
2447
- ["virsh", "--connect", conn_uri, "domstate", vm_name],
2448
- capture_output=True,
2449
- text=True,
2450
- timeout=5,
2451
- )
2488
+ refresh = 1.0
2489
+ once = False
2490
+ monitor = ResourceMonitor(conn_uri=conn_uri)
2452
2491
 
2453
- vm_state = result.stdout.strip().lower()
2454
-
2455
- if "shut off" in vm_state or "shutting down" in vm_state:
2456
- # VM is shutting down - count consecutive detections
2457
- shutdown_count += 1
2458
- if shutdown_count >= 3 and not restart_detected:
2459
- # Confirmed shutdown after 3 consecutive checks
2460
- restart_detected = True
2461
- progress.update(
2462
- task,
2463
- description="[yellow]⟳ VM restarting after installation...",
2492
+ try:
2493
+ with Progress(
2494
+ SpinnerColumn(),
2495
+ TextColumn("[progress.description]{task.description}"),
2496
+ console=console,
2497
+ ) as progress:
2498
+ task = progress.add_task("[cyan]Starting VM and initializing...", total=None)
2499
+
2500
+ while time.time() - start_time < timeout:
2501
+ # Clear screen for live update
2502
+ if not progress.finished:
2503
+ console.clear()
2504
+
2505
+ console.print("[bold cyan]📊 CloneBox Resource Monitor[/]")
2506
+ console.print()
2507
+
2508
+ # VM Stats
2509
+ vm_stats = monitor.get_all_vm_stats()
2510
+ if vm_stats:
2511
+ table = Table(title="🖥️ Virtual Machines", border_style="cyan")
2512
+ table.add_column("Name", style="bold")
2513
+ table.add_column("State")
2514
+ table.add_column("CPU %")
2515
+ table.add_column("Memory")
2516
+ table.add_column("Disk")
2517
+ table.add_column("Network I/O")
2518
+
2519
+ for vm in vm_stats:
2520
+ state_color = "green" if vm.state == "running" else "yellow"
2521
+ cpu_color = "red" if vm.cpu_percent > 80 else "green"
2522
+ mem_pct = (
2523
+ (vm.memory_used_mb / vm.memory_total_mb * 100)
2524
+ if vm.memory_total_mb > 0
2525
+ else 0
2464
2526
  )
2465
- time.sleep(3)
2466
- continue
2467
- else:
2468
- # VM is running - reset shutdown counter
2469
- if shutdown_count > 0 and shutdown_count < 3:
2470
- # Was a brief glitch, not a real shutdown
2471
- shutdown_count = 0
2472
-
2473
- if restart_detected and "running" in vm_state and shutdown_count >= 3:
2474
- # VM restarted successfully - GUI should be ready
2475
- progress.update(
2476
- task, description=f"[green]✓ VM ready! Total time: {minutes}m {seconds}s"
2477
- )
2478
- time.sleep(2)
2479
- break
2480
-
2481
- # Estimate remaining time (total ~12-15 minutes for full desktop install)
2482
- if elapsed < 60:
2483
- remaining = "~12-15 minutes"
2484
- elif elapsed < 300:
2485
- remaining = f"~{12 - minutes} minutes"
2486
- elif elapsed < 600:
2487
- remaining = f"~{10 - minutes} minutes"
2488
- elif elapsed < 800:
2489
- remaining = "finishing soon..."
2527
+ mem_color = "red" if mem_pct > 80 else "green"
2528
+
2529
+ table.add_row(
2530
+ vm.name,
2531
+ f"[{state_color}]{vm.state}[/]",
2532
+ f"[{cpu_color}]{vm.cpu_percent:.1f}%[/]",
2533
+ f"[{mem_color}]{vm.memory_used_mb}/{vm.memory_total_mb} MB[/]",
2534
+ f"{vm.disk_used_gb:.1f}/{vm.disk_total_gb:.1f} GB",
2535
+ f"↓{format_bytes(vm.network_rx_bytes)} ↑{format_bytes(vm.network_tx_bytes)}",
2536
+ )
2537
+ console.print(table)
2490
2538
  else:
2491
- remaining = "almost done"
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
- # Check disk space in real-time
2502
- disk_info = _exec_in_vm_qga(
2503
- vm_name,
2504
- conn_uri,
2505
- "df / --output=pcent | tail -n 1 | tr -dc '0-9'"
2506
- )
2507
- if disk_info and disk_info.isdigit():
2508
- usage = int(disk_info)
2509
- if usage > 90:
2510
- console.print(f"[bold red]⚠️ WARNING: VM Disk is nearly full ({usage}%)![/]")
2511
- console.print("[red] Installation may fail. Consider increasing --disk-size-gb.[/]")
2512
-
2513
- if raw_info:
2514
- lines = [l.strip() for l in raw_info.strip().split('\n') if l.strip()]
2515
- for line in lines:
2516
- if line not in seen_lines:
2517
- # If it's a new phase line, we can log it to console above the progress bar
2518
- if "[" in line and "/9]" in line:
2519
- console.print(f"[dim] {line}[/]")
2520
- seen_lines.add(line)
2521
- last_phases.append(line)
2522
-
2523
- # Keep only last 2 for the progress bar description
2524
- if len(last_phases) > 2:
2525
- last_phases = last_phases[-2:]
2526
-
2527
- if restart_detected:
2528
- progress.update(
2529
- task,
2530
- description=f"[cyan]Finalizing setup... ({minutes}m {seconds}s, {remaining})",
2531
- )
2532
- elif last_phases:
2533
- # Show the actual phase from logs
2534
- current_status = last_phases[-1]
2535
- progress.update(
2536
- task,
2537
- description=f"[cyan]{current_status} ({minutes}m {seconds}s, {remaining})",
2538
- )
2539
+ console.print("[dim]No VMs found.[/]")
2540
+
2541
+ console.print()
2542
+
2543
+ # Container Stats
2544
+ container_stats = monitor.get_container_stats()
2545
+ if container_stats:
2546
+ table = Table(title="🐳 Containers", border_style="blue")
2547
+ table.add_column("Name", style="bold")
2548
+ table.add_column("State")
2549
+ table.add_column("CPU %")
2550
+ table.add_column("Memory")
2551
+ table.add_column("Network I/O")
2552
+ table.add_column("PIDs")
2553
+
2554
+ for c in container_stats:
2555
+ cpu_color = "red" if c.cpu_percent > 80 else "green"
2556
+ mem_pct = (
2557
+ (c.memory_used_mb / c.memory_limit_mb * 100)
2558
+ if c.memory_limit_mb > 0
2559
+ else 0
2560
+ )
2561
+ mem_color = "red" if mem_pct > 80 else "green"
2562
+
2563
+ table.add_row(
2564
+ c.name,
2565
+ f"[green]{c.state}[/]",
2566
+ f"[{cpu_color}]{c.cpu_percent:.1f}%[/]",
2567
+ f"[{mem_color}]{c.memory_used_mb}/{c.memory_limit_mb} MB[/]",
2568
+ f"↓{format_bytes(c.network_rx_bytes)} ↑{format_bytes(c.network_tx_bytes)}",
2569
+ str(c.pids),
2570
+ )
2571
+ console.print(table)
2539
2572
  else:
2540
- progress.update(
2541
- task,
2542
- description=f"[cyan]Installing packages... ({minutes}m {seconds}s, {remaining})",
2543
- )
2573
+ console.print("[dim]No containers running.[/]")
2544
2574
 
2545
- except (subprocess.TimeoutExpired, Exception) as e:
2546
- elapsed = int(time.time() - start_time)
2547
- minutes = elapsed // 60
2548
- seconds = elapsed % 60
2549
- progress.update(
2550
- task, description=f"[cyan]Configuring VM... ({minutes}m {seconds}s)"
2551
- )
2575
+ if once:
2576
+ break
2552
2577
 
2553
- time.sleep(3)
2578
+ console.print(f"\n[dim]Refreshing every {refresh}s. Press Ctrl+C to exit.[/]")
2579
+ time.sleep(refresh)
2554
2580
 
2555
- # Final status
2556
- if time.time() - start_time >= timeout:
2557
- progress.update(
2558
- task, description="[yellow]⚠ Monitoring timeout - VM continues in background"
2559
- )
2581
+ except KeyboardInterrupt:
2582
+ console.print("\n[yellow]Monitoring stopped.[/]")
2583
+ finally:
2584
+ monitor.close()
2560
2585
 
2561
2586
 
2562
- def create_vm_from_config(
2563
- config: dict,
2564
- start: bool = False,
2565
- user_session: bool = False,
2566
- replace: bool = False,
2567
- ) -> str:
2568
- """Create VM from YAML config dict."""
2569
- paths = config.get("paths", {})
2570
- # Backwards compatible: v1 uses app_data_paths, newer configs may use copy_paths
2571
- copy_paths = config.get("copy_paths", None)
2572
- if not isinstance(copy_paths, dict) or not copy_paths:
2573
- copy_paths = config.get("app_data_paths", {})
2574
-
2575
- vm_section = config.get("vm") or {}
2576
-
2577
- # Support both v1 (auth_method) and v2 (auth.method) config formats
2578
- auth_section = vm_section.get("auth") or {}
2579
- auth_method = auth_section.get("method") or vm_section.get("auth_method") or "ssh_key"
2587
+ def create_vm_from_config(config, start=False, user_session=False, replace=False, approved=False):
2588
+ """Create VM from configuration dict."""
2589
+ vm_config_dict = config.get("vm", {})
2580
2590
 
2581
- # v2 config: secrets provider
2582
- secrets_section = config.get("secrets") or {}
2583
- secrets_provider = secrets_section.get("provider", "auto")
2584
-
2585
- # v2 config: resource limits
2586
- limits_section = config.get("limits") or {}
2587
- resources = {
2588
- "memory_limit": limits_section.get("memory_limit"),
2589
- "cpu_shares": limits_section.get("cpu_shares"),
2590
- "disk_limit": limits_section.get("disk_limit"),
2591
- "network_limit": limits_section.get("network_limit"),
2592
- }
2593
- # Remove None values
2594
- resources = {k: v for k, v in resources.items() if v is not None}
2595
-
2591
+ # Create VMConfig object
2596
2592
  vm_config = VMConfig(
2597
- name=config["vm"]["name"],
2598
- ram_mb=config["vm"].get("ram_mb", 8192),
2599
- vcpus=config["vm"].get("vcpus", 8),
2600
- disk_size_gb=config["vm"].get("disk_size_gb", 10),
2601
- gui=config["vm"].get("gui", True),
2602
- base_image=config["vm"].get("base_image"),
2603
- paths=paths,
2604
- copy_paths=copy_paths,
2593
+ name=vm_config_dict.get("name", "clonebox-vm"),
2594
+ ram_mb=vm_config_dict.get("ram_mb", 8192),
2595
+ vcpus=vm_config_dict.get("vcpus", 4),
2596
+ disk_size_gb=vm_config_dict.get("disk_size_gb", 20),
2597
+ gui=vm_config_dict.get("gui", True),
2598
+ base_image=vm_config_dict.get("base_image"),
2599
+ network_mode=vm_config_dict.get("network_mode", "auto"),
2600
+ username=vm_config_dict.get("username", "ubuntu"),
2601
+ password=vm_config_dict.get("password", "ubuntu"),
2602
+ user_session=user_session,
2603
+ paths=config.get("paths", {}),
2605
2604
  packages=config.get("packages", []),
2606
2605
  snap_packages=config.get("snap_packages", []),
2607
2606
  services=config.get("services", []),
2608
2607
  post_commands=config.get("post_commands", []),
2609
- user_session=user_session,
2610
- network_mode=config["vm"].get("network_mode", "auto"),
2611
- username=config["vm"].get("username", "ubuntu"),
2612
- password=config["vm"].get("password", "ubuntu"),
2613
- auth_method=auth_method,
2614
- ssh_public_key=vm_section.get("ssh_public_key") or auth_section.get("ssh_public_key"),
2615
- resources=resources if resources else config["vm"].get("resources", {}),
2608
+ copy_paths=(config.get("copy_paths") or config.get("app_data_paths") or {}),
2609
+ resources=config.get("resources", {}),
2616
2610
  )
2617
-
2611
+
2618
2612
  cloner = SelectiveVMCloner(user_session=user_session)
2619
-
2620
- # Check prerequisites and show detailed info
2613
+
2614
+ # Check prerequisites
2621
2615
  checks = cloner.check_prerequisites()
2622
-
2623
- if not checks["images_dir_writable"]:
2624
- console.print(f"[yellow]⚠️ Storage directory: {checks['images_dir']}[/]")
2625
- if "images_dir_error" in checks:
2626
- console.print(f"[red]{checks['images_dir_error']}[/]")
2627
- raise PermissionError(checks["images_dir_error"])
2628
-
2629
- console.print(f"[dim]Session: {checks['session_type']}, Storage: {checks['images_dir']}[/]")
2630
-
2631
- vm_uuid = cloner.create_vm(vm_config, console=console, replace=replace)
2632
-
2616
+ if not all(checks.values()):
2617
+ console.print("[yellow]⚠️ Prerequisites check:[/]")
2618
+ for check, passed in checks.items():
2619
+ icon = "" if passed else "❌"
2620
+ console.print(f" {icon} {check}")
2621
+
2622
+ # Create VM
2623
+ vm_uuid = cloner.create_vm(
2624
+ vm_config,
2625
+ replace=replace,
2626
+ console=console,
2627
+ approved=approved,
2628
+ )
2629
+
2633
2630
  if start:
2634
- cloner.start_vm(vm_config.name, open_viewer=vm_config.gui, console=console)
2635
-
2636
- # Monitor cloud-init progress if GUI is enabled
2637
- if vm_config.gui:
2638
- console.print("\n[bold cyan]📊 Monitoring setup progress...[/]")
2639
- try:
2640
- monitor_cloud_init_status(vm_config.name, user_session=user_session)
2641
- except KeyboardInterrupt:
2642
- console.print("\n[yellow]Monitoring stopped. VM continues setup in background.[/]")
2643
- except Exception as e:
2644
- console.print(
2645
- f"\n[dim]Note: Could not monitor status ({e}). VM continues setup in background.[/]"
2646
- )
2647
-
2631
+ cloner.start_vm(vm_config.name, open_viewer=True, console=console)
2632
+
2648
2633
  return vm_uuid
2649
2634
 
2650
2635
 
2651
- def cmd_clone(args):
2636
+ def cmd_clone(args) -> None:
2652
2637
  """Generate clone config from path and optionally create VM."""
2653
- target_path = Path(args.path).resolve()
2654
- dry_run = getattr(args, "dry_run", False)
2655
-
2638
+ from clonebox.detector import SystemDetector
2639
+
2640
+ target_path = Path(args.path).expanduser().resolve() if args.path else Path.cwd()
2641
+
2656
2642
  if not target_path.exists():
2657
2643
  console.print(f"[red]❌ Path does not exist: {target_path}[/]")
2658
2644
  return
2659
-
2660
- if dry_run:
2661
- console.print(f"[bold cyan]🔍 DRY RUN - Analyzing: {target_path}[/]\n")
2662
- else:
2663
- console.print(f"[bold cyan]📦 Generating clone config for: {target_path}[/]\n")
2664
-
2645
+
2646
+ console.print(f"[cyan]🔍 Analyzing system for cloning...[/]")
2647
+
2665
2648
  # Detect system state
2649
+ detector = SystemDetector()
2650
+
2666
2651
  with Progress(
2667
2652
  SpinnerColumn(),
2668
2653
  TextColumn("[progress.description]{task.description}"),
2669
2654
  console=console,
2670
- transient=True,
2671
2655
  ) as progress:
2672
- progress.add_task("Scanning system...", total=None)
2673
- detector = SystemDetector()
2656
+ task = progress.add_task("Scanning system...", total=None)
2657
+
2658
+ # Take snapshot
2674
2659
  snapshot = detector.detect_all()
2675
-
2660
+
2661
+ # Detect Docker containers
2662
+ containers = detector.detect_docker_containers()
2663
+
2664
+ progress.update(task, description="Finalizing...")
2665
+
2676
2666
  # Generate config
2677
- vm_name = args.name or f"clone-{target_path.name}"
2678
2667
  yaml_content = generate_clonebox_yaml(
2679
2668
  snapshot,
2680
2669
  detector,
2681
2670
  deduplicate=args.dedupe,
2682
- target_path=str(target_path),
2683
- vm_name=vm_name,
2671
+ target_path=str(target_path) if args.path else None,
2672
+ vm_name=args.name,
2684
2673
  network_mode=args.network,
2685
- base_image=getattr(args, "base_image", None),
2686
- disk_size_gb=getattr(args, "disk_size_gb", None),
2674
+ base_image=args.base_image,
2675
+ disk_size_gb=args.disk_size_gb,
2687
2676
  )
2688
-
2689
- profile_name = getattr(args, "profile", None)
2690
- if profile_name:
2691
- merged_config = merge_with_profile(yaml.safe_load(yaml_content), profile_name)
2692
- if isinstance(merged_config, dict):
2693
- vm_section = merged_config.get("vm")
2694
- if isinstance(vm_section, dict):
2695
- vm_packages = vm_section.pop("packages", None)
2696
- if isinstance(vm_packages, list):
2697
- packages = merged_config.get("packages")
2698
- if not isinstance(packages, list):
2699
- packages = []
2700
- for p in vm_packages:
2701
- if p not in packages:
2702
- packages.append(p)
2703
- merged_config["packages"] = packages
2704
-
2705
- if "container" in merged_config:
2706
- merged_config.pop("container", None)
2707
-
2708
- yaml_content = yaml.dump(
2709
- merged_config,
2710
- default_flow_style=False,
2711
- allow_unicode=True,
2712
- sort_keys=False,
2713
- )
2714
-
2715
- # Dry run - show what would be created and exit
2716
- if dry_run:
2717
- config = yaml.safe_load(yaml_content)
2718
- console.print(
2719
- Panel(
2720
- f"[bold]VM Name:[/] {config['vm']['name']}\n"
2721
- f"[bold]RAM:[/] {config['vm'].get('ram_mb', 4096)} MB\n"
2722
- f"[bold]vCPUs:[/] {config['vm'].get('vcpus', 4)}\n"
2723
- f"[bold]Network:[/] {config['vm'].get('network_mode', 'auto')}\n"
2724
- f"[bold]Paths:[/] {len(config.get('paths', {}))} mounts\n"
2725
- f"[bold]Packages:[/] {len(config.get('packages', []))} packages\n"
2726
- f"[bold]Services:[/] {len(config.get('services', []))} services",
2727
- title="[bold cyan]Would create VM[/]",
2728
- border_style="cyan",
2729
- )
2730
- )
2731
- console.print("\n[dim]Config preview:[/]")
2732
- console.print(Panel(yaml_content, title="[bold].clonebox.yaml[/]", border_style="dim"))
2733
- console.print("\n[yellow]ℹ️ Dry run complete. No changes made.[/]")
2734
- return
2735
-
2677
+
2736
2678
  # Save config file
2737
- config_file = (
2738
- target_path / CLONEBOX_CONFIG_FILE
2739
- if target_path.is_dir()
2740
- else target_path.parent / CLONEBOX_CONFIG_FILE
2741
- )
2742
- config_file.write_text(yaml_content)
2743
- console.print(f"[green]✅ Config saved: {config_file}[/]\n")
2744
-
2745
- # Show config
2746
- console.print(Panel(yaml_content, title="[bold].clonebox.yaml[/]", border_style="cyan"))
2747
-
2748
- # Open in editor if requested
2679
+ config_file = target_path / CLONEBOX_CONFIG_FILE
2680
+
2681
+ if config_file.exists() and not args.replace:
2682
+ console.print(f"[yellow]⚠️ Config file already exists: {config_file}[/]")
2683
+ if not questionary.confirm(
2684
+ "Overwrite existing config?", default=False, style=custom_style
2685
+ ).ask():
2686
+ console.print("[dim]Cancelled.[/]")
2687
+ return
2688
+
2689
+ with open(config_file, "w") as f:
2690
+ f.write(yaml_content)
2691
+
2692
+ console.print(f"[green]✅ Config saved to: {config_file}[/]")
2693
+
2694
+ # Edit if requested
2749
2695
  if args.edit:
2750
2696
  editor = os.environ.get("EDITOR", "nano")
2751
- console.print(f"[cyan]Opening {editor}...[/]")
2752
2697
  os.system(f"{editor} {config_file}")
2753
- # Reload after edit
2754
- yaml_content = config_file.read_text()
2755
-
2756
- # Ask to create VM
2698
+
2699
+ # Run VM if requested
2757
2700
  if args.run:
2758
- create_now = True
2759
- else:
2760
- create_now = questionary.confirm(
2761
- "Create VM with this config?", default=True, style=custom_style
2762
- ).ask()
2763
-
2764
- if create_now:
2765
- # Load config with environment variable expansion
2766
- config = load_clonebox_config(config_file.parent)
2767
- user_session = getattr(args, "user", False)
2768
-
2769
- console.print("\n[bold cyan]🔧 Creating VM...[/]\n")
2770
- if user_session:
2771
- console.print("[cyan]Using user session (qemu:///session) - no root required[/]")
2772
-
2773
- try:
2774
- vm_uuid = create_vm_from_config(
2775
- config,
2776
- start=True,
2777
- user_session=user_session,
2778
- replace=getattr(args, "replace", False),
2779
- )
2780
- console.print(f"\n[bold green]🎉 VM '{config['vm']['name']}' is running![/]")
2781
- console.print(f"[dim]UUID: {vm_uuid}[/]")
2782
-
2783
- # Show GUI startup info if GUI is enabled
2784
- if config.get("vm", {}).get("gui", False):
2785
- username = config["vm"].get("username", "ubuntu")
2786
- password = config["vm"].get("password", "ubuntu")
2787
- console.print("\n[bold yellow]⏰ GUI Setup Process:[/]")
2788
- console.print(" [yellow]•[/] Installing desktop environment (~5-10 minutes)")
2789
- console.print(" [yellow]•[/] Running health checks on all components")
2790
- console.print(" [yellow]•[/] Automatic restart after installation")
2791
- console.print(" [yellow]•[/] GUI login screen will appear")
2792
- console.print(
2793
- f" [yellow]•[/] Login: [cyan]{username}[/] / [cyan]{'*' * len(password)}[/] (from .env)"
2794
- )
2795
- console.print("\n[dim]💡 Progress will be monitored automatically below[/]")
2796
-
2797
- # Show health check info
2798
- console.print("\n[bold]📊 Health Check (inside VM):[/]")
2799
- console.print(" [cyan]cat /var/log/clonebox-health.log[/] # View full report")
2800
- console.print(" [cyan]cat /var/log/clonebox-health-status[/] # Quick status")
2801
- console.print(" [cyan]clonebox-health[/] # Re-run health check")
2802
-
2803
- # Show mount instructions
2804
- paths = config.get("paths", {})
2805
- app_data_paths = config.get("app_data_paths", {})
2806
-
2807
- if paths:
2808
- console.print("\n[bold]📁 Mounted paths (shared live):[/]")
2809
- for idx, (host, guest) in enumerate(list(paths.items())[:5]):
2810
- console.print(f" [dim]{host}[/] → [cyan]{guest}[/]")
2811
- if len(paths) > 5:
2812
- console.print(f" [dim]... and {len(paths) - 5} more paths[/]")
2813
-
2814
- if app_data_paths:
2815
- console.print("\n[bold]📥 Copied paths (one-time import):[/]")
2816
- for idx, (host, guest) in enumerate(list(app_data_paths.items())[:5]):
2817
- console.print(f" [dim]{host}[/] → [cyan]{guest}[/]")
2818
- if len(app_data_paths) > 5:
2819
- console.print(f" [dim]... and {len(app_data_paths) - 5} more paths[/]")
2820
- except PermissionError as e:
2821
- console.print(f"[red]❌ Permission Error:[/]\n{e}")
2822
- console.print("\n[yellow]💡 Try running with --user flag:[/]")
2823
- console.print(f" [cyan]clonebox clone {target_path} --user[/]")
2824
- except Exception as e:
2825
- console.print(f"[red]❌ Error: {e}[/]")
2826
- else:
2827
- console.print("\n[dim]To create VM later, run:[/]")
2828
- console.print(f" [cyan]clonebox start {target_path}[/]")
2701
+ console.print("[cyan]🚀 Creating VM from config...[/]")
2702
+ config = load_clonebox_config(config_file)
2703
+ vm_uuid = create_vm_from_config(
2704
+ config, start=True, user_session=args.user, replace=args.replace, approved=args.approve
2705
+ )
2706
+ console.print(f"[green]✅ VM created: {vm_uuid}[/]")
2829
2707
 
2830
2708
 
2831
- def cmd_detect(args):
2709
+ def cmd_detect(args) -> None:
2832
2710
  """Detect and show system state."""
2833
- console.print("[bold cyan]🔍 Detecting system state...[/]\n")
2834
-
2835
- detector = SystemDetector()
2836
- snapshot = detector.detect_all()
2837
-
2838
- # JSON output
2839
- if args.json:
2840
- result = {
2841
- "services": [{"name": s.name, "status": s.status} for s in snapshot.running_services],
2711
+ from clonebox.detector import SystemDetector
2712
+
2713
+ console.print("[cyan]🔍 Detecting system state...[/]")
2714
+
2715
+ try:
2716
+ detector = SystemDetector()
2717
+
2718
+ # Detect system info
2719
+ sys_info = detector.get_system_info()
2720
+
2721
+ # Detect all services, apps, and paths
2722
+ snapshot = detector.detect_all()
2723
+
2724
+ # Detect Docker containers
2725
+ containers = detector.detect_docker_containers()
2726
+
2727
+ # Prepare output
2728
+ output = {
2729
+ "system": sys_info,
2730
+ "services": [
2731
+ {
2732
+ "name": s.name,
2733
+ "status": s.status,
2734
+ "enabled": s.enabled,
2735
+ "description": s.description,
2736
+ }
2737
+ for s in snapshot.running_services
2738
+ ],
2842
2739
  "applications": [
2843
- {"name": a.name, "pid": a.pid, "cwd": a.working_dir} for a in snapshot.applications
2740
+ {
2741
+ "name": a.name,
2742
+ "pid": a.pid,
2743
+ "memory_mb": round(a.memory_mb, 2),
2744
+ "working_dir": a.working_dir or "",
2745
+ }
2746
+ for a in snapshot.applications
2844
2747
  ],
2845
2748
  "paths": [
2846
- {"path": p.path, "type": p.type, "size_mb": p.size_mb} for p in snapshot.paths
2749
+ {"path": p.path, "type": p.type, "size_mb": p.size_mb}
2750
+ for p in snapshot.paths
2751
+ ],
2752
+ "docker_containers": [
2753
+ {
2754
+ "name": c["name"],
2755
+ "status": c["status"],
2756
+ "image": c["image"],
2757
+ "ports": c.get("ports", ""),
2758
+ }
2759
+ for c in containers
2847
2760
  ],
2848
2761
  }
2849
- print(json.dumps(result, indent=2))
2850
- return
2851
-
2852
- # YAML output
2853
- if args.yaml:
2854
- result = generate_clonebox_yaml(snapshot, detector, deduplicate=args.dedupe)
2855
-
2762
+
2763
+ # Apply deduplication if requested
2764
+ if args.dedupe:
2765
+ output["services"] = deduplicate_list(output["services"], key=lambda x: x["name"])
2766
+ output["applications"] = deduplicate_list(output["applications"], key=lambda x: (x["name"], x["pid"]))
2767
+ output["paths"] = deduplicate_list(output["paths"], key=lambda x: x["path"])
2768
+
2769
+ # Format output
2770
+ if args.json:
2771
+ content = json.dumps(output, indent=2)
2772
+ elif args.yaml:
2773
+ content = yaml.dump(output, default_flow_style=False, allow_unicode=True)
2774
+ else:
2775
+ # Pretty print
2776
+ content = format_detection_output(output, sys_info)
2777
+
2778
+ # Save to file or print
2856
2779
  if args.output:
2857
- output_path = Path(args.output)
2858
- output_path.write_text(result)
2859
- console.print(f"[green]✅ Config saved to: {output_path}[/]")
2780
+ with open(args.output, "w") as f:
2781
+ f.write(content)
2782
+ console.print(f"[green]✅ Output saved to: {args.output}[/]")
2860
2783
  else:
2861
- print(result)
2862
- return
2863
-
2864
- # Services
2865
- services = detector.detect_services()
2866
- running = [s for s in services if s.status == "running"]
2867
-
2868
- if running:
2869
- table = Table(title="Running Services", border_style="green")
2870
- table.add_column("Service")
2871
- table.add_column("Status")
2872
- table.add_column("Enabled")
2873
-
2874
- for svc in running:
2875
- table.add_row(svc.name, f"[green]{svc.status}[/]", "✓" if svc.enabled else "")
2876
-
2877
- console.print(table)
2878
-
2879
- # Applications
2880
- apps = detector.detect_applications()
2881
-
2882
- if apps:
2883
- console.print()
2884
- table = Table(title="Running Applications", border_style="blue")
2885
- table.add_column("Name")
2886
- table.add_column("PID")
2887
- table.add_column("Memory")
2888
- table.add_column("Working Dir")
2889
-
2890
- for app in apps[:15]:
2891
- table.add_row(
2892
- app.name,
2893
- str(app.pid),
2894
- f"{app.memory_mb:.0f} MB",
2895
- app.working_dir[:40] if app.working_dir else "",
2896
- )
2897
-
2898
- console.print(table)
2899
-
2900
- # Paths
2901
- paths = detector.detect_paths()
2902
-
2903
- if paths:
2904
- console.print()
2905
- table = Table(title="Detected Paths", border_style="yellow")
2906
- table.add_column("Type")
2907
- table.add_column("Path")
2908
- table.add_column("Size")
2784
+ console.print(content)
2785
+
2786
+ except Exception as e:
2787
+ console.print(f"[red]Error: {e}[/]")
2788
+ import traceback
2789
+ traceback.print_exc()
2909
2790
 
2910
- for p in paths[:20]:
2911
- table.add_row(
2912
- f"[cyan]{p.type}[/]", p.path, f"{p.size_mb:.0f} MB" if p.size_mb > 0 else "-"
2913
- )
2914
2791
 
2915
- console.print(table)
2792
+ def format_detection_output(output, sys_info):
2793
+ """Format detection output for console display."""
2794
+ from rich.table import Table
2795
+ from rich.text import Text
2796
+
2797
+ # System info
2798
+ system_text = Text()
2799
+ system_text.append(f"Hostname: {sys_info['hostname']}\n", style="bold")
2800
+ system_text.append(f"User: {sys_info['user']}\n")
2801
+ system_text.append(f"CPU: {sys_info['cpu_count']} cores\n")
2802
+ system_text.append(
2803
+ f"Memory: {sys_info['memory_total_gb']:.1f} GB total, {sys_info['memory_available_gb']:.1f} GB available\n"
2804
+ )
2805
+ system_text.append(
2806
+ f"Disk: {sys_info['disk_total_gb']:.1f} GB total, {sys_info['disk_free_gb']:.1f} GB free"
2807
+ )
2808
+
2809
+ # Services table
2810
+ services_table = Table(title="Services", show_header=True, header_style="bold magenta")
2811
+ services_table.add_column("Name", style="cyan")
2812
+ services_table.add_column("Status", style="green")
2813
+ services_table.add_column("Enabled", style="yellow")
2814
+ services_table.add_column("Description", style="dim")
2815
+
2816
+ for svc in output["services"]:
2817
+ status_style = "green" if svc["status"] == "running" else "red"
2818
+ enabled_text = "✓" if svc["enabled"] else "✗"
2819
+ services_table.add_row(
2820
+ svc["name"],
2821
+ Text(svc["status"], style=status_style),
2822
+ enabled_text,
2823
+ svc["description"] or "-",
2824
+ )
2825
+
2826
+ # Applications table
2827
+ apps_table = Table(title="Applications", show_header=True, header_style="bold magenta")
2828
+ apps_table.add_column("Name", style="cyan")
2829
+ apps_table.add_column("PID", justify="right")
2830
+ apps_table.add_column("Memory (MB)", justify="right")
2831
+ apps_table.add_column("Working Dir", style="dim")
2832
+
2833
+ for app in output["applications"]:
2834
+ apps_table.add_row(
2835
+ app["name"],
2836
+ str(app["pid"]),
2837
+ f"{app['memory_mb']:.1f}",
2838
+ app["working_dir"] or "-",
2839
+ )
2840
+
2841
+ # Combine output
2842
+ result = Panel(system_text, title="System Information", border_style="blue")
2843
+ result += "\n\n"
2844
+ result += services_table
2845
+ result += "\n\n"
2846
+ result += apps_table
2847
+
2848
+ return result
2916
2849
 
2917
2850
 
2918
2851
  def cmd_monitor(args) -> None:
2919
- """Real-time resource monitoring for VMs and containers."""
2920
- conn_uri = "qemu:///session" if getattr(args, "user", False) else "qemu:///system"
2852
+ """Real-time resource monitoring."""
2853
+ from clonebox.cloner import SelectiveVMCloner
2854
+
2855
+ user_session = getattr(args, "user", False)
2921
2856
  refresh = getattr(args, "refresh", 2.0)
2922
2857
  once = getattr(args, "once", False)
2923
-
2924
- monitor = ResourceMonitor(conn_uri)
2925
-
2858
+
2859
+ cloner = SelectiveVMCloner(user_session=user_session)
2860
+
2926
2861
  try:
2927
- while True:
2928
- # Clear screen for live update
2929
- if not once:
2930
- console.clear()
2931
-
2932
- console.print("[bold cyan]📊 CloneBox Resource Monitor[/]")
2933
- console.print()
2934
-
2935
- # VM Stats
2936
- vm_stats = monitor.get_all_vm_stats()
2937
- if vm_stats:
2938
- table = Table(title="🖥️ Virtual Machines", border_style="cyan")
2939
- table.add_column("Name", style="bold")
2940
- table.add_column("State")
2941
- table.add_column("CPU %")
2942
- table.add_column("Memory")
2943
- table.add_column("Disk")
2944
- table.add_column("Network I/O")
2945
-
2946
- for vm in vm_stats:
2947
- state_color = "green" if vm.state == "running" else "yellow"
2948
- cpu_color = "red" if vm.cpu_percent > 80 else "green"
2949
- mem_pct = (
2950
- (vm.memory_used_mb / vm.memory_total_mb * 100)
2951
- if vm.memory_total_mb > 0
2952
- else 0
2953
- )
2954
- mem_color = "red" if mem_pct > 80 else "green"
2955
-
2862
+ vms = cloner.list_vms()
2863
+
2864
+ if not vms:
2865
+ console.print("[dim]No VMs found.[/]")
2866
+ return
2867
+
2868
+ # Create monitor
2869
+ monitor = ResourceMonitor(conn_uri="qemu:///session" if user_session else "qemu:///system")
2870
+
2871
+ if once:
2872
+ # Show stats once
2873
+ table = Table(title="VM Resource Usage", show_header=True, header_style="bold magenta")
2874
+ table.add_column("VM Name", style="cyan")
2875
+ table.add_column("CPU %", justify="right")
2876
+ table.add_column("Memory", justify="right")
2877
+ table.add_column("Disk I/O", justify="right")
2878
+ table.add_column("Network I/O", justify="right")
2879
+
2880
+ for vm in vms:
2881
+ if vm["state"] == "running":
2882
+ stats = monitor.get_vm_stats(vm["name"])
2956
2883
  table.add_row(
2957
- vm.name,
2958
- f"[{state_color}]{vm.state}[/]",
2959
- f"[{cpu_color}]{vm.cpu_percent:.1f}%[/]",
2960
- f"[{mem_color}]{vm.memory_used_mb}/{vm.memory_total_mb} MB[/]",
2961
- f"{vm.disk_used_gb:.1f}/{vm.disk_total_gb:.1f} GB",
2962
- f"↓{format_bytes(vm.network_rx_bytes)} ↑{format_bytes(vm.network_tx_bytes)}",
2884
+ vm["name"],
2885
+ f"{stats.get('cpu_percent', 0):.1f}%",
2886
+ format_bytes(stats.get("memory_usage", 0)),
2887
+ f"{stats.get('disk_read', 0)}/{stats.get('disk_write', 0)} MB/s",
2888
+ f"{stats.get('net_rx', 0)}/{stats.get('net_tx', 0)} MB/s",
2963
2889
  )
2964
- console.print(table)
2965
- else:
2966
- console.print("[dim]No VMs found.[/]")
2967
-
2968
- console.print()
2969
-
2970
- # Container Stats
2971
- container_stats = monitor.get_container_stats()
2972
- if container_stats:
2973
- table = Table(title="🐳 Containers", border_style="blue")
2974
- table.add_column("Name", style="bold")
2975
- table.add_column("State")
2976
- table.add_column("CPU %")
2977
- table.add_column("Memory")
2978
- table.add_column("Network I/O")
2979
- table.add_column("PIDs")
2980
-
2981
- for c in container_stats:
2982
- cpu_color = "red" if c.cpu_percent > 80 else "green"
2983
- mem_pct = (
2984
- (c.memory_used_mb / c.memory_limit_mb * 100) if c.memory_limit_mb > 0 else 0
2985
- )
2986
- mem_color = "red" if mem_pct > 80 else "green"
2987
-
2988
- table.add_row(
2989
- c.name,
2990
- f"[green]{c.state}[/]",
2991
- f"[{cpu_color}]{c.cpu_percent:.1f}%[/]",
2992
- f"[{mem_color}]{c.memory_used_mb}/{c.memory_limit_mb} MB[/]",
2993
- f"↓{format_bytes(c.network_rx_bytes)} ↑{format_bytes(c.network_tx_bytes)}",
2994
- str(c.pids),
2890
+ else:
2891
+ table.add_row(vm["name"], "[dim]not running[/]", "-", "-", "-")
2892
+
2893
+ console.print(table)
2894
+ else:
2895
+ # Continuous monitoring
2896
+ console.print(f"[cyan]Monitoring VMs (refresh every {refresh}s). Press Ctrl+C to exit.[/]\n")
2897
+
2898
+ try:
2899
+ while True:
2900
+ # Clear screen
2901
+ console.clear()
2902
+
2903
+ # Create table
2904
+ table = Table(
2905
+ title=f"VM Resource Usage - {datetime.now().strftime('%H:%M:%S')}",
2906
+ show_header=True,
2907
+ header_style="bold magenta",
2995
2908
  )
2996
- console.print(table)
2997
- else:
2998
- console.print("[dim]No containers running.[/]")
2999
-
3000
- if once:
3001
- break
3002
-
3003
- console.print(f"\n[dim]Refreshing every {refresh}s. Press Ctrl+C to exit.[/]")
3004
- time.sleep(refresh)
3005
-
3006
- except KeyboardInterrupt:
3007
- console.print("\n[yellow]Monitoring stopped.[/]")
2909
+ table.add_column("VM Name", style="cyan")
2910
+ table.add_column("State", style="green")
2911
+ table.add_column("CPU %", justify="right")
2912
+ table.add_column("Memory", justify="right")
2913
+ table.add_column("Disk I/O", justify="right")
2914
+ table.add_column("Network I/O", justify="right")
2915
+
2916
+ for vm in vms:
2917
+ if vm["state"] == "running":
2918
+ stats = monitor.get_vm_stats(vm["name"])
2919
+ table.add_row(
2920
+ vm["name"],
2921
+ vm["state"],
2922
+ f"{stats.get('cpu_percent', 0):.1f}%",
2923
+ format_bytes(stats.get("memory_usage", 0)),
2924
+ f"{stats.get('disk_read', 0):.1f}/{stats.get('disk_write', 0):.1f} MB/s",
2925
+ f"{stats.get('net_rx', 0):.1f}/{stats.get('net_tx', 0):.1f} MB/s",
2926
+ )
2927
+ else:
2928
+ table.add_row(vm["name"], f"[dim]{vm['state']}[/]", "-", "-", "-", "-")
2929
+
2930
+ console.print(table)
2931
+ time.sleep(refresh)
2932
+
2933
+ except KeyboardInterrupt:
2934
+ console.print("\n[yellow]Monitoring stopped.[/]")
2935
+
3008
2936
  finally:
3009
2937
  monitor.close()
3010
2938
 
@@ -3159,6 +3087,17 @@ def cmd_snapshot_restore(args) -> None:
3159
3087
  vm_name, config_file = _resolve_vm_name_and_config_file(args.vm_name)
3160
3088
  conn_uri = "qemu:///session" if getattr(args, "user", False) else "qemu:///system"
3161
3089
 
3090
+ policy = PolicyEngine.load_effective(start=config_file)
3091
+ if policy is not None:
3092
+ try:
3093
+ policy.assert_operation_approved(
3094
+ AuditEventType.VM_SNAPSHOT_RESTORE.value,
3095
+ approved=getattr(args, "approve", False),
3096
+ )
3097
+ except PolicyViolationError as e:
3098
+ console.print(f"[red]❌ {e}[/]")
3099
+ sys.exit(1)
3100
+
3162
3101
  console.print(f"[cyan]🔄 Restoring snapshot: {args.name}[/]")
3163
3102
 
3164
3103
  try:
@@ -3364,6 +3303,46 @@ def cmd_list_remote(args) -> None:
3364
3303
  console.print("[yellow]No VMs found on remote host.[/]")
3365
3304
 
3366
3305
 
3306
+ def cmd_policy_validate(args) -> None:
3307
+ """Validate a policy file."""
3308
+ try:
3309
+ file_arg = getattr(args, "file", None)
3310
+ if file_arg:
3311
+ policy_path = Path(file_arg).expanduser().resolve()
3312
+ else:
3313
+ policy_path = PolicyEngine.find_policy_file()
3314
+
3315
+ if not policy_path:
3316
+ console.print("[red]❌ Policy file not found[/]")
3317
+ sys.exit(1)
3318
+
3319
+ PolicyEngine.load(policy_path)
3320
+ console.print(f"[green]✅ Policy valid: {policy_path}[/]")
3321
+ except (PolicyValidationError, FileNotFoundError) as e:
3322
+ console.print(f"[red]❌ Policy invalid: {e}[/]")
3323
+ sys.exit(1)
3324
+
3325
+
3326
+ def cmd_policy_apply(args) -> None:
3327
+ """Apply a policy file as project or global policy."""
3328
+ try:
3329
+ src = Path(args.file).expanduser().resolve()
3330
+ PolicyEngine.load(src)
3331
+
3332
+ scope = getattr(args, "scope", "project")
3333
+ if scope == "global":
3334
+ dest = Path.home() / ".clonebox.d" / "policy.yaml"
3335
+ dest.parent.mkdir(parents=True, exist_ok=True)
3336
+ else:
3337
+ dest = Path.cwd() / ".clonebox-policy.yaml"
3338
+
3339
+ dest.write_text(src.read_text())
3340
+ console.print(f"[green]✅ Policy applied: {dest}[/]")
3341
+ except (PolicyValidationError, FileNotFoundError) as e:
3342
+ console.print(f"[red]❌ Failed to apply policy: {e}[/]")
3343
+ sys.exit(1)
3344
+
3345
+
3367
3346
  # === Audit Commands ===
3368
3347
 
3369
3348
 
@@ -3930,6 +3909,17 @@ def cmd_remote_delete(args) -> None:
3930
3909
  user_session = getattr(args, "user", False)
3931
3910
  keep_storage = getattr(args, "keep_storage", False)
3932
3911
 
3912
+ policy = PolicyEngine.load_effective()
3913
+ if policy is not None:
3914
+ try:
3915
+ policy.assert_operation_approved(
3916
+ AuditEventType.VM_DELETE.value,
3917
+ approved=getattr(args, "approve", False),
3918
+ )
3919
+ except PolicyViolationError as e:
3920
+ console.print(f"[red]❌ {e}[/]")
3921
+ sys.exit(1)
3922
+
3933
3923
  if not getattr(args, "yes", False):
3934
3924
  confirm = questionary.confirm(
3935
3925
  f"Delete VM '{vm_name}' on {host}?",
@@ -4105,6 +4095,11 @@ def main():
4105
4095
  action="store_true",
4106
4096
  help="Use user session (qemu:///session) - no root required",
4107
4097
  )
4098
+ delete_parser.add_argument(
4099
+ "--approve",
4100
+ action="store_true",
4101
+ help="Approve policy-gated operation",
4102
+ )
4108
4103
  delete_parser.set_defaults(func=cmd_delete)
4109
4104
 
4110
4105
  # List command
@@ -4274,6 +4269,11 @@ def main():
4274
4269
  action="store_true",
4275
4270
  help="If VM already exists, stop+undefine it and recreate (also deletes its storage)",
4276
4271
  )
4272
+ clone_parser.add_argument(
4273
+ "--approve",
4274
+ action="store_true",
4275
+ help="Approve policy-gated operation (required for --replace if policy demands)",
4276
+ )
4277
4277
  clone_parser.add_argument(
4278
4278
  "--dry-run",
4279
4279
  action="store_true",
@@ -4413,14 +4413,12 @@ def main():
4413
4413
  export_parser.add_argument(
4414
4414
  "-u", "--user", action="store_true", help="Use user session (qemu:///session)"
4415
4415
  )
4416
- export_parser.add_argument(
4417
- "-o", "--output", help="Output archive filename (default: <vmname>-export.tar.gz)"
4418
- )
4416
+ export_parser.add_argument("-o", "--output", help="Output archive filename (default: <vmname>-export.tar.gz)")
4419
4417
  export_parser.add_argument(
4420
4418
  "--include-data",
4421
4419
  "-d",
4422
4420
  action="store_true",
4423
- help="Include shared data (browser profiles, configs) in export",
4421
+ help="Include shared data (browser profiles, configs)",
4424
4422
  )
4425
4423
  export_parser.set_defaults(func=cmd_export)
4426
4424
 
@@ -4433,6 +4431,11 @@ def main():
4433
4431
  import_parser.add_argument(
4434
4432
  "--replace", action="store_true", help="Replace existing VM if exists"
4435
4433
  )
4434
+ import_parser.add_argument(
4435
+ "--approve",
4436
+ action="store_true",
4437
+ help="Approve policy-gated operation (required for --replace if policy demands)",
4438
+ )
4436
4439
  import_parser.set_defaults(func=cmd_import)
4437
4440
 
4438
4441
  # Test command - validate VM configuration
@@ -4512,6 +4515,11 @@ def main():
4512
4515
  snap_restore.add_argument(
4513
4516
  "-f", "--force", action="store_true", help="Force restore even if running"
4514
4517
  )
4518
+ snap_restore.add_argument(
4519
+ "--approve",
4520
+ action="store_true",
4521
+ help="Approve policy-gated operation",
4522
+ )
4515
4523
  snap_restore.set_defaults(func=cmd_snapshot_restore)
4516
4524
 
4517
4525
  snap_delete = snapshot_sub.add_parser("delete", aliases=["rm"], help="Delete snapshot")
@@ -4700,6 +4708,28 @@ def main():
4700
4708
  plugin_uninstall.add_argument("name", help="Plugin name")
4701
4709
  plugin_uninstall.set_defaults(func=cmd_plugin_uninstall)
4702
4710
 
4711
+ policy_parser = subparsers.add_parser("policy", help="Manage security policies")
4712
+ policy_parser.set_defaults(func=lambda args, p=policy_parser: p.print_help())
4713
+ policy_sub = policy_parser.add_subparsers(dest="policy_command", help="Policy commands")
4714
+
4715
+ policy_validate = policy_sub.add_parser("validate", help="Validate policy file")
4716
+ policy_validate.add_argument(
4717
+ "--file",
4718
+ "-f",
4719
+ help="Policy file (default: auto-detect .clonebox-policy.yaml/.yml or ~/.clonebox.d/policy.yaml)",
4720
+ )
4721
+ policy_validate.set_defaults(func=cmd_policy_validate)
4722
+
4723
+ policy_apply = policy_sub.add_parser("apply", help="Apply policy file")
4724
+ policy_apply.add_argument("--file", "-f", required=True, help="Policy file to apply")
4725
+ policy_apply.add_argument(
4726
+ "--scope",
4727
+ choices=["project", "global"],
4728
+ default="project",
4729
+ help="Apply scope: project writes .clonebox-policy.yaml in CWD, global writes ~/.clonebox.d/policy.yaml",
4730
+ )
4731
+ policy_apply.set_defaults(func=cmd_policy_apply)
4732
+
4703
4733
  # === Remote Management Commands ===
4704
4734
  remote_parser = subparsers.add_parser("remote", help="Manage VMs on remote hosts")
4705
4735
  remote_sub = remote_parser.add_subparsers(dest="remote_command", help="Remote commands")
@@ -4735,6 +4765,11 @@ def main():
4735
4765
  remote_delete.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
4736
4766
  remote_delete.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
4737
4767
  remote_delete.add_argument("--keep-storage", action="store_true", help="Keep disk images")
4768
+ remote_delete.add_argument(
4769
+ "--approve",
4770
+ action="store_true",
4771
+ help="Approve policy-gated operation",
4772
+ )
4738
4773
  remote_delete.set_defaults(func=cmd_remote_delete)
4739
4774
 
4740
4775
  remote_exec = remote_sub.add_parser("exec", help="Execute command in VM on remote host")