clonebox 1.1.17__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,570 +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
2488
+ refresh = 1.0
2489
+ once = False
2490
+ monitor = ResourceMonitor(conn_uri=conn_uri)
2444
2491
 
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
- )
2452
-
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
- 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
-
2515
- if restart_detected:
2516
- progress.update(
2517
- task,
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})",
2526
- )
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)
2527
2572
  else:
2528
- progress.update(
2529
- task,
2530
- description=f"[cyan]Installing packages... ({minutes}m {seconds}s, {remaining})",
2531
- )
2573
+ console.print("[dim]No containers running.[/]")
2532
2574
 
2533
- except (subprocess.TimeoutExpired, Exception) as e:
2534
- elapsed = int(time.time() - start_time)
2535
- minutes = elapsed // 60
2536
- seconds = elapsed % 60
2537
- progress.update(
2538
- task, description=f"[cyan]Configuring VM... ({minutes}m {seconds}s)"
2539
- )
2575
+ if once:
2576
+ break
2540
2577
 
2541
- time.sleep(3)
2578
+ console.print(f"\n[dim]Refreshing every {refresh}s. Press Ctrl+C to exit.[/]")
2579
+ time.sleep(refresh)
2542
2580
 
2543
- # Final status
2544
- if time.time() - start_time >= timeout:
2545
- progress.update(
2546
- task, description="[yellow]⚠ Monitoring timeout - VM continues in background"
2547
- )
2581
+ except KeyboardInterrupt:
2582
+ console.print("\n[yellow]Monitoring stopped.[/]")
2583
+ finally:
2584
+ monitor.close()
2548
2585
 
2549
2586
 
2550
- def create_vm_from_config(
2551
- config: dict,
2552
- start: bool = False,
2553
- user_session: bool = False,
2554
- replace: bool = False,
2555
- ) -> str:
2556
- """Create VM from YAML config dict."""
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"
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", {})
2568
2590
 
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}
2583
-
2591
+ # Create VMConfig object
2584
2592
  vm_config = VMConfig(
2585
- name=config["vm"]["name"],
2586
- ram_mb=config["vm"].get("ram_mb", 8192),
2587
- vcpus=config["vm"].get("vcpus", 8),
2588
- disk_size_gb=config["vm"].get("disk_size_gb", 10),
2589
- gui=config["vm"].get("gui", True),
2590
- base_image=config["vm"].get("base_image"),
2591
- paths=paths,
2592
- 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", {}),
2593
2604
  packages=config.get("packages", []),
2594
2605
  snap_packages=config.get("snap_packages", []),
2595
2606
  services=config.get("services", []),
2596
2607
  post_commands=config.get("post_commands", []),
2597
- user_session=user_session,
2598
- network_mode=config["vm"].get("network_mode", "auto"),
2599
- username=config["vm"].get("username", "ubuntu"),
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", {}),
2608
+ copy_paths=(config.get("copy_paths") or config.get("app_data_paths") or {}),
2609
+ resources=config.get("resources", {}),
2604
2610
  )
2605
-
2611
+
2606
2612
  cloner = SelectiveVMCloner(user_session=user_session)
2607
-
2608
- # Check prerequisites and show detailed info
2613
+
2614
+ # Check prerequisites
2609
2615
  checks = cloner.check_prerequisites()
2610
-
2611
- if not checks["images_dir_writable"]:
2612
- console.print(f"[yellow]⚠️ Storage directory: {checks['images_dir']}[/]")
2613
- if "images_dir_error" in checks:
2614
- console.print(f"[red]{checks['images_dir_error']}[/]")
2615
- raise PermissionError(checks["images_dir_error"])
2616
-
2617
- console.print(f"[dim]Session: {checks['session_type']}, Storage: {checks['images_dir']}[/]")
2618
-
2619
- vm_uuid = cloner.create_vm(vm_config, console=console, replace=replace)
2620
-
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
+
2621
2630
  if start:
2622
- cloner.start_vm(vm_config.name, open_viewer=vm_config.gui, console=console)
2623
-
2624
- # Monitor cloud-init progress if GUI is enabled
2625
- if vm_config.gui:
2626
- console.print("\n[bold cyan]📊 Monitoring setup progress...[/]")
2627
- try:
2628
- monitor_cloud_init_status(vm_config.name, user_session=user_session)
2629
- except KeyboardInterrupt:
2630
- console.print("\n[yellow]Monitoring stopped. VM continues setup in background.[/]")
2631
- except Exception as e:
2632
- console.print(
2633
- f"\n[dim]Note: Could not monitor status ({e}). VM continues setup in background.[/]"
2634
- )
2635
-
2631
+ cloner.start_vm(vm_config.name, open_viewer=True, console=console)
2632
+
2636
2633
  return vm_uuid
2637
2634
 
2638
2635
 
2639
- def cmd_clone(args):
2636
+ def cmd_clone(args) -> None:
2640
2637
  """Generate clone config from path and optionally create VM."""
2641
- target_path = Path(args.path).resolve()
2642
- dry_run = getattr(args, "dry_run", False)
2643
-
2638
+ from clonebox.detector import SystemDetector
2639
+
2640
+ target_path = Path(args.path).expanduser().resolve() if args.path else Path.cwd()
2641
+
2644
2642
  if not target_path.exists():
2645
2643
  console.print(f"[red]❌ Path does not exist: {target_path}[/]")
2646
2644
  return
2647
-
2648
- if dry_run:
2649
- console.print(f"[bold cyan]🔍 DRY RUN - Analyzing: {target_path}[/]\n")
2650
- else:
2651
- console.print(f"[bold cyan]📦 Generating clone config for: {target_path}[/]\n")
2652
-
2645
+
2646
+ console.print(f"[cyan]🔍 Analyzing system for cloning...[/]")
2647
+
2653
2648
  # Detect system state
2649
+ detector = SystemDetector()
2650
+
2654
2651
  with Progress(
2655
2652
  SpinnerColumn(),
2656
2653
  TextColumn("[progress.description]{task.description}"),
2657
2654
  console=console,
2658
- transient=True,
2659
2655
  ) as progress:
2660
- progress.add_task("Scanning system...", total=None)
2661
- detector = SystemDetector()
2656
+ task = progress.add_task("Scanning system...", total=None)
2657
+
2658
+ # Take snapshot
2662
2659
  snapshot = detector.detect_all()
2663
-
2660
+
2661
+ # Detect Docker containers
2662
+ containers = detector.detect_docker_containers()
2663
+
2664
+ progress.update(task, description="Finalizing...")
2665
+
2664
2666
  # Generate config
2665
- vm_name = args.name or f"clone-{target_path.name}"
2666
2667
  yaml_content = generate_clonebox_yaml(
2667
2668
  snapshot,
2668
2669
  detector,
2669
2670
  deduplicate=args.dedupe,
2670
- target_path=str(target_path),
2671
- vm_name=vm_name,
2671
+ target_path=str(target_path) if args.path else None,
2672
+ vm_name=args.name,
2672
2673
  network_mode=args.network,
2673
- base_image=getattr(args, "base_image", None),
2674
- disk_size_gb=getattr(args, "disk_size_gb", None),
2674
+ base_image=args.base_image,
2675
+ disk_size_gb=args.disk_size_gb,
2675
2676
  )
2676
-
2677
- profile_name = getattr(args, "profile", None)
2678
- if profile_name:
2679
- merged_config = merge_with_profile(yaml.safe_load(yaml_content), profile_name)
2680
- if isinstance(merged_config, dict):
2681
- vm_section = merged_config.get("vm")
2682
- if isinstance(vm_section, dict):
2683
- vm_packages = vm_section.pop("packages", None)
2684
- if isinstance(vm_packages, list):
2685
- packages = merged_config.get("packages")
2686
- if not isinstance(packages, list):
2687
- packages = []
2688
- for p in vm_packages:
2689
- if p not in packages:
2690
- packages.append(p)
2691
- merged_config["packages"] = packages
2692
-
2693
- if "container" in merged_config:
2694
- merged_config.pop("container", None)
2695
-
2696
- yaml_content = yaml.dump(
2697
- merged_config,
2698
- default_flow_style=False,
2699
- allow_unicode=True,
2700
- sort_keys=False,
2701
- )
2702
-
2703
- # Dry run - show what would be created and exit
2704
- if dry_run:
2705
- config = yaml.safe_load(yaml_content)
2706
- console.print(
2707
- Panel(
2708
- f"[bold]VM Name:[/] {config['vm']['name']}\n"
2709
- f"[bold]RAM:[/] {config['vm'].get('ram_mb', 4096)} MB\n"
2710
- f"[bold]vCPUs:[/] {config['vm'].get('vcpus', 4)}\n"
2711
- f"[bold]Network:[/] {config['vm'].get('network_mode', 'auto')}\n"
2712
- f"[bold]Paths:[/] {len(config.get('paths', {}))} mounts\n"
2713
- f"[bold]Packages:[/] {len(config.get('packages', []))} packages\n"
2714
- f"[bold]Services:[/] {len(config.get('services', []))} services",
2715
- title="[bold cyan]Would create VM[/]",
2716
- border_style="cyan",
2717
- )
2718
- )
2719
- console.print("\n[dim]Config preview:[/]")
2720
- console.print(Panel(yaml_content, title="[bold].clonebox.yaml[/]", border_style="dim"))
2721
- console.print("\n[yellow]ℹ️ Dry run complete. No changes made.[/]")
2722
- return
2723
-
2677
+
2724
2678
  # Save config file
2725
- config_file = (
2726
- target_path / CLONEBOX_CONFIG_FILE
2727
- if target_path.is_dir()
2728
- else target_path.parent / CLONEBOX_CONFIG_FILE
2729
- )
2730
- config_file.write_text(yaml_content)
2731
- console.print(f"[green]✅ Config saved: {config_file}[/]\n")
2732
-
2733
- # Show config
2734
- console.print(Panel(yaml_content, title="[bold].clonebox.yaml[/]", border_style="cyan"))
2735
-
2736
- # 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
2737
2695
  if args.edit:
2738
2696
  editor = os.environ.get("EDITOR", "nano")
2739
- console.print(f"[cyan]Opening {editor}...[/]")
2740
2697
  os.system(f"{editor} {config_file}")
2741
- # Reload after edit
2742
- yaml_content = config_file.read_text()
2743
-
2744
- # Ask to create VM
2698
+
2699
+ # Run VM if requested
2745
2700
  if args.run:
2746
- create_now = True
2747
- else:
2748
- create_now = questionary.confirm(
2749
- "Create VM with this config?", default=True, style=custom_style
2750
- ).ask()
2751
-
2752
- if create_now:
2753
- # Load config with environment variable expansion
2754
- config = load_clonebox_config(config_file.parent)
2755
- user_session = getattr(args, "user", False)
2756
-
2757
- console.print("\n[bold cyan]🔧 Creating VM...[/]\n")
2758
- if user_session:
2759
- console.print("[cyan]Using user session (qemu:///session) - no root required[/]")
2760
-
2761
- try:
2762
- vm_uuid = create_vm_from_config(
2763
- config,
2764
- start=True,
2765
- user_session=user_session,
2766
- replace=getattr(args, "replace", False),
2767
- )
2768
- console.print(f"\n[bold green]🎉 VM '{config['vm']['name']}' is running![/]")
2769
- console.print(f"[dim]UUID: {vm_uuid}[/]")
2770
-
2771
- # Show GUI startup info if GUI is enabled
2772
- if config.get("vm", {}).get("gui", False):
2773
- username = config["vm"].get("username", "ubuntu")
2774
- password = config["vm"].get("password", "ubuntu")
2775
- console.print("\n[bold yellow]⏰ GUI Setup Process:[/]")
2776
- console.print(" [yellow]•[/] Installing desktop environment (~5-10 minutes)")
2777
- console.print(" [yellow]•[/] Running health checks on all components")
2778
- console.print(" [yellow]•[/] Automatic restart after installation")
2779
- console.print(" [yellow]•[/] GUI login screen will appear")
2780
- console.print(
2781
- f" [yellow]•[/] Login: [cyan]{username}[/] / [cyan]{'*' * len(password)}[/] (from .env)"
2782
- )
2783
- console.print("\n[dim]💡 Progress will be monitored automatically below[/]")
2784
-
2785
- # Show health check info
2786
- console.print("\n[bold]📊 Health Check (inside VM):[/]")
2787
- console.print(" [cyan]cat /var/log/clonebox-health.log[/] # View full report")
2788
- console.print(" [cyan]cat /var/log/clonebox-health-status[/] # Quick status")
2789
- console.print(" [cyan]clonebox-health[/] # Re-run health check")
2790
-
2791
- # Show mount instructions
2792
- paths = config.get("paths", {})
2793
- app_data_paths = config.get("app_data_paths", {})
2794
-
2795
- if paths:
2796
- console.print("\n[bold]📁 Mounted paths (shared live):[/]")
2797
- for idx, (host, guest) in enumerate(list(paths.items())[:5]):
2798
- console.print(f" [dim]{host}[/] → [cyan]{guest}[/]")
2799
- if len(paths) > 5:
2800
- console.print(f" [dim]... and {len(paths) - 5} more paths[/]")
2801
-
2802
- if app_data_paths:
2803
- console.print("\n[bold]📥 Copied paths (one-time import):[/]")
2804
- for idx, (host, guest) in enumerate(list(app_data_paths.items())[:5]):
2805
- console.print(f" [dim]{host}[/] → [cyan]{guest}[/]")
2806
- if len(app_data_paths) > 5:
2807
- console.print(f" [dim]... and {len(app_data_paths) - 5} more paths[/]")
2808
- except PermissionError as e:
2809
- console.print(f"[red]❌ Permission Error:[/]\n{e}")
2810
- console.print("\n[yellow]💡 Try running with --user flag:[/]")
2811
- console.print(f" [cyan]clonebox clone {target_path} --user[/]")
2812
- except Exception as e:
2813
- console.print(f"[red]❌ Error: {e}[/]")
2814
- else:
2815
- console.print("\n[dim]To create VM later, run:[/]")
2816
- 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}[/]")
2817
2707
 
2818
2708
 
2819
- def cmd_detect(args):
2709
+ def cmd_detect(args) -> None:
2820
2710
  """Detect and show system state."""
2821
- console.print("[bold cyan]🔍 Detecting system state...[/]\n")
2822
-
2823
- detector = SystemDetector()
2824
- snapshot = detector.detect_all()
2825
-
2826
- # JSON output
2827
- if args.json:
2828
- result = {
2829
- "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
+ ],
2830
2739
  "applications": [
2831
- {"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
2832
2747
  ],
2833
2748
  "paths": [
2834
- {"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
2835
2760
  ],
2836
2761
  }
2837
- print(json.dumps(result, indent=2))
2838
- return
2839
-
2840
- # YAML output
2841
- if args.yaml:
2842
- result = generate_clonebox_yaml(snapshot, detector, deduplicate=args.dedupe)
2843
-
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
2844
2779
  if args.output:
2845
- output_path = Path(args.output)
2846
- output_path.write_text(result)
2847
- 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}[/]")
2848
2783
  else:
2849
- print(result)
2850
- return
2851
-
2852
- # Services
2853
- services = detector.detect_services()
2854
- running = [s for s in services if s.status == "running"]
2855
-
2856
- if running:
2857
- table = Table(title="Running Services", border_style="green")
2858
- table.add_column("Service")
2859
- table.add_column("Status")
2860
- table.add_column("Enabled")
2861
-
2862
- for svc in running:
2863
- table.add_row(svc.name, f"[green]{svc.status}[/]", "✓" if svc.enabled else "")
2864
-
2865
- console.print(table)
2866
-
2867
- # Applications
2868
- apps = detector.detect_applications()
2869
-
2870
- if apps:
2871
- console.print()
2872
- table = Table(title="Running Applications", border_style="blue")
2873
- table.add_column("Name")
2874
- table.add_column("PID")
2875
- table.add_column("Memory")
2876
- table.add_column("Working Dir")
2877
-
2878
- for app in apps[:15]:
2879
- table.add_row(
2880
- app.name,
2881
- str(app.pid),
2882
- f"{app.memory_mb:.0f} MB",
2883
- app.working_dir[:40] if app.working_dir else "",
2884
- )
2885
-
2886
- console.print(table)
2887
-
2888
- # Paths
2889
- paths = detector.detect_paths()
2890
-
2891
- if paths:
2892
- console.print()
2893
- table = Table(title="Detected Paths", border_style="yellow")
2894
- table.add_column("Type")
2895
- table.add_column("Path")
2896
- 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()
2897
2790
 
2898
- for p in paths[:20]:
2899
- table.add_row(
2900
- f"[cyan]{p.type}[/]", p.path, f"{p.size_mb:.0f} MB" if p.size_mb > 0 else "-"
2901
- )
2902
2791
 
2903
- 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
2904
2849
 
2905
2850
 
2906
2851
  def cmd_monitor(args) -> None:
2907
- """Real-time resource monitoring for VMs and containers."""
2908
- 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)
2909
2856
  refresh = getattr(args, "refresh", 2.0)
2910
2857
  once = getattr(args, "once", False)
2911
-
2912
- monitor = ResourceMonitor(conn_uri)
2913
-
2858
+
2859
+ cloner = SelectiveVMCloner(user_session=user_session)
2860
+
2914
2861
  try:
2915
- while True:
2916
- # Clear screen for live update
2917
- if not once:
2918
- console.clear()
2919
-
2920
- console.print("[bold cyan]📊 CloneBox Resource Monitor[/]")
2921
- console.print()
2922
-
2923
- # VM Stats
2924
- vm_stats = monitor.get_all_vm_stats()
2925
- if vm_stats:
2926
- table = Table(title="🖥️ Virtual Machines", border_style="cyan")
2927
- table.add_column("Name", style="bold")
2928
- table.add_column("State")
2929
- table.add_column("CPU %")
2930
- table.add_column("Memory")
2931
- table.add_column("Disk")
2932
- table.add_column("Network I/O")
2933
-
2934
- for vm in vm_stats:
2935
- state_color = "green" if vm.state == "running" else "yellow"
2936
- cpu_color = "red" if vm.cpu_percent > 80 else "green"
2937
- mem_pct = (
2938
- (vm.memory_used_mb / vm.memory_total_mb * 100)
2939
- if vm.memory_total_mb > 0
2940
- else 0
2941
- )
2942
- mem_color = "red" if mem_pct > 80 else "green"
2943
-
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"])
2944
2883
  table.add_row(
2945
- vm.name,
2946
- f"[{state_color}]{vm.state}[/]",
2947
- f"[{cpu_color}]{vm.cpu_percent:.1f}%[/]",
2948
- f"[{mem_color}]{vm.memory_used_mb}/{vm.memory_total_mb} MB[/]",
2949
- f"{vm.disk_used_gb:.1f}/{vm.disk_total_gb:.1f} GB",
2950
- f"↓{format_bytes(vm.network_rx_bytes)} ↑{format_bytes(vm.network_tx_bytes)}",
2951
- )
2952
- console.print(table)
2953
- else:
2954
- console.print("[dim]No VMs found.[/]")
2955
-
2956
- console.print()
2957
-
2958
- # Container Stats
2959
- container_stats = monitor.get_container_stats()
2960
- if container_stats:
2961
- table = Table(title="🐳 Containers", border_style="blue")
2962
- table.add_column("Name", style="bold")
2963
- table.add_column("State")
2964
- table.add_column("CPU %")
2965
- table.add_column("Memory")
2966
- table.add_column("Network I/O")
2967
- table.add_column("PIDs")
2968
-
2969
- for c in container_stats:
2970
- cpu_color = "red" if c.cpu_percent > 80 else "green"
2971
- mem_pct = (
2972
- (c.memory_used_mb / c.memory_limit_mb * 100) if c.memory_limit_mb > 0 else 0
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",
2973
2889
  )
2974
- mem_color = "red" if mem_pct > 80 else "green"
2975
-
2976
- table.add_row(
2977
- c.name,
2978
- f"[green]{c.state}[/]",
2979
- f"[{cpu_color}]{c.cpu_percent:.1f}%[/]",
2980
- f"[{mem_color}]{c.memory_used_mb}/{c.memory_limit_mb} MB[/]",
2981
- f"↓{format_bytes(c.network_rx_bytes)} ↑{format_bytes(c.network_tx_bytes)}",
2982
- 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",
2983
2908
  )
2984
- console.print(table)
2985
- else:
2986
- console.print("[dim]No containers running.[/]")
2987
-
2988
- if once:
2989
- break
2990
-
2991
- console.print(f"\n[dim]Refreshing every {refresh}s. Press Ctrl+C to exit.[/]")
2992
- time.sleep(refresh)
2993
-
2994
- except KeyboardInterrupt:
2995
- 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
+
2996
2936
  finally:
2997
2937
  monitor.close()
2998
2938
 
@@ -3147,6 +3087,17 @@ def cmd_snapshot_restore(args) -> None:
3147
3087
  vm_name, config_file = _resolve_vm_name_and_config_file(args.vm_name)
3148
3088
  conn_uri = "qemu:///session" if getattr(args, "user", False) else "qemu:///system"
3149
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
+
3150
3101
  console.print(f"[cyan]🔄 Restoring snapshot: {args.name}[/]")
3151
3102
 
3152
3103
  try:
@@ -3352,6 +3303,46 @@ def cmd_list_remote(args) -> None:
3352
3303
  console.print("[yellow]No VMs found on remote host.[/]")
3353
3304
 
3354
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
+
3355
3346
  # === Audit Commands ===
3356
3347
 
3357
3348
 
@@ -3918,6 +3909,17 @@ def cmd_remote_delete(args) -> None:
3918
3909
  user_session = getattr(args, "user", False)
3919
3910
  keep_storage = getattr(args, "keep_storage", False)
3920
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
+
3921
3923
  if not getattr(args, "yes", False):
3922
3924
  confirm = questionary.confirm(
3923
3925
  f"Delete VM '{vm_name}' on {host}?",
@@ -4093,6 +4095,11 @@ def main():
4093
4095
  action="store_true",
4094
4096
  help="Use user session (qemu:///session) - no root required",
4095
4097
  )
4098
+ delete_parser.add_argument(
4099
+ "--approve",
4100
+ action="store_true",
4101
+ help="Approve policy-gated operation",
4102
+ )
4096
4103
  delete_parser.set_defaults(func=cmd_delete)
4097
4104
 
4098
4105
  # List command
@@ -4262,6 +4269,11 @@ def main():
4262
4269
  action="store_true",
4263
4270
  help="If VM already exists, stop+undefine it and recreate (also deletes its storage)",
4264
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
+ )
4265
4277
  clone_parser.add_argument(
4266
4278
  "--dry-run",
4267
4279
  action="store_true",
@@ -4401,14 +4413,12 @@ def main():
4401
4413
  export_parser.add_argument(
4402
4414
  "-u", "--user", action="store_true", help="Use user session (qemu:///session)"
4403
4415
  )
4404
- export_parser.add_argument(
4405
- "-o", "--output", help="Output archive filename (default: <vmname>-export.tar.gz)"
4406
- )
4416
+ export_parser.add_argument("-o", "--output", help="Output archive filename (default: <vmname>-export.tar.gz)")
4407
4417
  export_parser.add_argument(
4408
4418
  "--include-data",
4409
4419
  "-d",
4410
4420
  action="store_true",
4411
- help="Include shared data (browser profiles, configs) in export",
4421
+ help="Include shared data (browser profiles, configs)",
4412
4422
  )
4413
4423
  export_parser.set_defaults(func=cmd_export)
4414
4424
 
@@ -4421,6 +4431,11 @@ def main():
4421
4431
  import_parser.add_argument(
4422
4432
  "--replace", action="store_true", help="Replace existing VM if exists"
4423
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
+ )
4424
4439
  import_parser.set_defaults(func=cmd_import)
4425
4440
 
4426
4441
  # Test command - validate VM configuration
@@ -4500,6 +4515,11 @@ def main():
4500
4515
  snap_restore.add_argument(
4501
4516
  "-f", "--force", action="store_true", help="Force restore even if running"
4502
4517
  )
4518
+ snap_restore.add_argument(
4519
+ "--approve",
4520
+ action="store_true",
4521
+ help="Approve policy-gated operation",
4522
+ )
4503
4523
  snap_restore.set_defaults(func=cmd_snapshot_restore)
4504
4524
 
4505
4525
  snap_delete = snapshot_sub.add_parser("delete", aliases=["rm"], help="Delete snapshot")
@@ -4688,6 +4708,28 @@ def main():
4688
4708
  plugin_uninstall.add_argument("name", help="Plugin name")
4689
4709
  plugin_uninstall.set_defaults(func=cmd_plugin_uninstall)
4690
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
+
4691
4733
  # === Remote Management Commands ===
4692
4734
  remote_parser = subparsers.add_parser("remote", help="Manage VMs on remote hosts")
4693
4735
  remote_sub = remote_parser.add_subparsers(dest="remote_command", help="Remote commands")
@@ -4723,6 +4765,11 @@ def main():
4723
4765
  remote_delete.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
4724
4766
  remote_delete.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
4725
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
+ )
4726
4773
  remote_delete.set_defaults(func=cmd_remote_delete)
4727
4774
 
4728
4775
  remote_exec = remote_sub.add_parser("exec", help="Execute command in VM on remote host")