clonebox 0.1.17__py3-none-any.whl → 0.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/__init__.py CHANGED
@@ -5,7 +5,7 @@ Selectively clone applications, paths and services to a new virtual machine
5
5
  with bind mounts instead of full disk cloning.
6
6
  """
7
7
 
8
- __version__ = "0.1.13"
8
+ __version__ = "0.1.18"
9
9
  __author__ = "CloneBox Team"
10
10
 
11
11
  from clonebox.cloner import SelectiveVMCloner
clonebox/cli.py CHANGED
@@ -22,7 +22,10 @@ from rich.table import Table
22
22
 
23
23
  from clonebox import __version__
24
24
  from clonebox.cloner import SelectiveVMCloner, VMConfig
25
+ from clonebox.container import ContainerCloner
25
26
  from clonebox.detector import SystemDetector
27
+ from clonebox.models import ContainerConfig
28
+ from clonebox.profiles import merge_with_profile
26
29
 
27
30
  # Custom questionary style
28
31
  custom_style = Style(
@@ -80,98 +83,95 @@ def _resolve_vm_name_and_config_file(name: Optional[str]) -> tuple[str, Optional
80
83
 
81
84
 
82
85
  def _qga_ping(vm_name: str, conn_uri: str) -> bool:
83
- def _qga_ping() -> bool:
84
- try:
85
- result = subprocess.run(
86
- [
87
- "virsh",
88
- "--connect",
89
- conn_uri,
90
- "qemu-agent-command",
91
- vm_name,
92
- json.dumps({"execute": "guest-ping"}),
93
- ],
94
- capture_output=True,
95
- text=True,
96
- timeout=5,
97
- )
98
- return result.returncode == 0
99
- except Exception:
100
- return False
86
+ import subprocess
101
87
 
102
- return _qga_ping()
88
+ try:
89
+ result = subprocess.run(
90
+ [
91
+ "virsh",
92
+ "--connect",
93
+ conn_uri,
94
+ "qemu-agent-command",
95
+ vm_name,
96
+ json.dumps({"execute": "guest-ping"}),
97
+ ],
98
+ capture_output=True,
99
+ text=True,
100
+ timeout=5,
101
+ )
102
+ return result.returncode == 0
103
+ except Exception:
104
+ return False
103
105
 
104
106
 
105
107
  def _qga_exec(vm_name: str, conn_uri: str, command: str, timeout: int = 10) -> Optional[str]:
106
- def _qga_exec() -> Optional[str]:
107
- try:
108
- payload = {
109
- "execute": "guest-exec",
110
- "arguments": {
111
- "path": "/bin/sh",
112
- "arg": ["-c", command],
113
- "capture-output": True,
114
- },
115
- }
116
- exec_result = subprocess.run(
108
+ import subprocess
109
+ import base64
110
+ import time
111
+
112
+ try:
113
+ payload = {
114
+ "execute": "guest-exec",
115
+ "arguments": {
116
+ "path": "/bin/sh",
117
+ "arg": ["-c", command],
118
+ "capture-output": True,
119
+ },
120
+ }
121
+ exec_result = subprocess.run(
122
+ [
123
+ "virsh",
124
+ "--connect",
125
+ conn_uri,
126
+ "qemu-agent-command",
127
+ vm_name,
128
+ json.dumps(payload),
129
+ ],
130
+ capture_output=True,
131
+ text=True,
132
+ timeout=timeout,
133
+ )
134
+ if exec_result.returncode != 0:
135
+ return None
136
+
137
+ resp = json.loads(exec_result.stdout)
138
+ pid = resp.get("return", {}).get("pid")
139
+ if not pid:
140
+ return None
141
+
142
+ deadline = time.time() + timeout
143
+ while time.time() < deadline:
144
+ status_payload = {"execute": "guest-exec-status", "arguments": {"pid": pid}}
145
+ status_result = subprocess.run(
117
146
  [
118
147
  "virsh",
119
148
  "--connect",
120
149
  conn_uri,
121
150
  "qemu-agent-command",
122
151
  vm_name,
123
- json.dumps(payload),
152
+ json.dumps(status_payload),
124
153
  ],
125
154
  capture_output=True,
126
155
  text=True,
127
- timeout=timeout,
156
+ timeout=5,
128
157
  )
129
- if exec_result.returncode != 0:
130
- return None
131
-
132
- resp = json.loads(exec_result.stdout)
133
- pid = resp.get("return", {}).get("pid")
134
- if not pid:
158
+ if status_result.returncode != 0:
135
159
  return None
136
160
 
137
- import base64
138
- import time
139
-
140
- deadline = time.time() + timeout
141
- while time.time() < deadline:
142
- status_payload = {"execute": "guest-exec-status", "arguments": {"pid": pid}}
143
- status_result = subprocess.run(
144
- [
145
- "virsh",
146
- "--connect",
147
- conn_uri,
148
- "qemu-agent-command",
149
- vm_name,
150
- json.dumps(status_payload),
151
- ],
152
- capture_output=True,
153
- text=True,
154
- timeout=5,
155
- )
156
- if status_result.returncode != 0:
157
- return None
158
-
159
- status_resp = json.loads(status_result.stdout)
160
- ret = status_resp.get("return", {})
161
- if not ret.get("exited", False):
162
- time.sleep(0.3)
163
- continue
164
-
165
- out_data = ret.get("out-data")
166
- if out_data:
167
- return base64.b64decode(out_data).decode().strip()
168
- return ""
161
+ status_resp = json.loads(status_result.stdout)
162
+ ret = status_resp.get("return", {})
163
+ if not ret.get("exited", False):
164
+ time.sleep(0.3)
165
+ continue
169
166
 
170
- return None
171
- except Exception:
172
- return None
167
+ out_data = ret.get("out-data")
168
+ if out_data:
169
+ return base64.b64decode(out_data).decode().strip()
170
+ return ""
173
171
 
174
- return _qga_exec()
172
+ return None
173
+ except Exception:
174
+ return None
175
175
 
176
176
 
177
177
  def run_vm_diagnostics(
@@ -237,9 +237,22 @@ def run_vm_diagnostics(
237
237
  if domifaddr.stdout.strip():
238
238
  console.print(f"[dim]{domifaddr.stdout.strip()}[/]")
239
239
  else:
240
- console.print("[yellow]⚠️ No interface address detected yet[/]")
240
+ console.print("[yellow]⚠️ No interface address detected via virsh domifaddr[/]")
241
241
  if verbose and domifaddr.stderr.strip():
242
242
  console.print(f"[dim]{domifaddr.stderr.strip()}[/]")
243
+ # Fallback: try to get IP via QEMU Guest Agent (useful for slirp/user networking)
244
+ if guest_agent_ready:
245
+ try:
246
+ ip_out = _qga_exec(vm_name, conn_uri, "ip -4 -o addr show scope global | awk '{print $4}'", timeout=5)
247
+ if ip_out and ip_out.strip():
248
+ console.print(f"[green]IP (via QGA): {ip_out.strip()}[/]")
249
+ result["network"]["qga_ip"] = ip_out.strip()
250
+ else:
251
+ console.print("[dim]IP: not available via QGA[/]")
252
+ except Exception as e:
253
+ console.print(f"[dim]IP: QGA query failed ({e})[/]")
254
+ else:
255
+ console.print("[dim]IP: QEMU Guest Agent not connected[/]")
243
256
  except Exception as e:
244
257
  result["network"] = {"error": str(e)}
245
258
  console.print(f"[yellow]⚠️ Cannot get IP: {e}[/]")
@@ -706,7 +719,6 @@ def interactive_mode():
706
719
  console.print("\n[bold]Inside the VM, mount shared folders with:[/]")
707
720
  for idx, (host, guest) in enumerate(paths_mapping.items()):
708
721
  console.print(f" [cyan]sudo mount -t 9p -o trans=virtio mount{idx} {guest}[/]")
709
-
710
722
  except Exception as e:
711
723
  console.print(f"\n[red]❌ Error: {e}[/]")
712
724
  raise
@@ -907,16 +919,15 @@ def cmd_delete(args):
907
919
  ).ask():
908
920
  console.print("[yellow]Cancelled.[/]")
909
921
  return
910
-
911
- cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
912
- cloner.delete_vm(name, delete_storage=not args.keep_storage, console=console)
913
-
914
-
915
922
  def cmd_list(args):
916
923
  """List all VMs."""
917
924
  cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
918
925
  vms = cloner.list_vms()
919
926
 
927
+ if getattr(args, "json", False):
928
+ print(json.dumps(vms, indent=2))
929
+ return
930
+
920
931
  if not vms:
921
932
  console.print("[dim]No VMs found.[/]")
922
933
  return
@@ -935,14 +946,6 @@ def cmd_list(args):
935
946
 
936
947
  def cmd_container_up(args):
937
948
  """Start a container sandbox."""
938
- try:
939
- from clonebox.container import ContainerCloner
940
- from clonebox.models import ContainerConfig
941
- except ModuleNotFoundError as e:
942
- raise ModuleNotFoundError(
943
- "Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
944
- ) from e
945
-
946
949
  mounts = {}
947
950
  for m in getattr(args, "mount", []) or []:
948
951
  if ":" not in m:
@@ -962,21 +965,21 @@ def cmd_container_up(args):
962
965
  if getattr(args, "name", None):
963
966
  cfg_kwargs["name"] = args.name
964
967
 
968
+ profile_name = getattr(args, "profile", None)
969
+ if profile_name:
970
+ merged = merge_with_profile({"container": cfg_kwargs}, profile_name)
971
+ if isinstance(merged, dict) and isinstance(merged.get("container"), dict):
972
+ cfg_kwargs = merged["container"]
973
+
965
974
  cfg = ContainerConfig(**cfg_kwargs)
966
975
 
967
976
  cloner = ContainerCloner(engine=cfg.engine)
968
- cloner.up(cfg, detach=getattr(args, "detach", False))
977
+ detach = getattr(args, "detach", False)
978
+ cloner.up(cfg, detach=detach, remove=not detach)
969
979
 
970
980
 
971
981
  def cmd_container_ps(args):
972
982
  """List containers."""
973
- try:
974
- from clonebox.container import ContainerCloner
975
- except ModuleNotFoundError as e:
976
- raise ModuleNotFoundError(
977
- "Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
978
- ) from e
979
-
980
983
  cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
981
984
  items = cloner.ps(all=getattr(args, "all", False))
982
985
 
@@ -1007,39 +1010,18 @@ def cmd_container_ps(args):
1007
1010
 
1008
1011
  def cmd_container_stop(args):
1009
1012
  """Stop a container."""
1010
- try:
1011
- from clonebox.container import ContainerCloner
1012
- except ModuleNotFoundError as e:
1013
- raise ModuleNotFoundError(
1014
- "Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
1015
- ) from e
1016
-
1017
1013
  cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
1018
1014
  cloner.stop(args.name)
1019
1015
 
1020
1016
 
1021
1017
  def cmd_container_rm(args):
1022
1018
  """Remove a container."""
1023
- try:
1024
- from clonebox.container import ContainerCloner
1025
- except ModuleNotFoundError as e:
1026
- raise ModuleNotFoundError(
1027
- "Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
1028
- ) from e
1029
-
1030
1019
  cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
1031
1020
  cloner.rm(args.name, force=getattr(args, "force", False))
1032
1021
 
1033
1022
 
1034
1023
  def cmd_container_down(args):
1035
1024
  """Stop and remove a container."""
1036
- try:
1037
- from clonebox.container import ContainerCloner
1038
- except ModuleNotFoundError as e:
1039
- raise ModuleNotFoundError(
1040
- "Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
1041
- ) from e
1042
-
1043
1025
  cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
1044
1026
  cloner.stop(args.name)
1045
1027
  cloner.rm(args.name, force=True)
@@ -1524,7 +1506,23 @@ def cmd_test(args):
1524
1506
  if '192.168' in line or '10.0' in line:
1525
1507
  console.print(f" IP: {line.split()[-1]}")
1526
1508
  else:
1527
- console.print("[yellow]⚠️ No IP address detected[/]")
1509
+ console.print("[yellow]⚠️ No IP address detected via virsh domifaddr[/]")
1510
+ # Fallback: try to get IP via QEMU Guest Agent (useful for slirp/user networking)
1511
+ try:
1512
+ from .cli import _qga_ping, _qga_exec
1513
+ except ImportError:
1514
+ from clonebox.cli import _qga_ping, _qga_exec
1515
+ if _qga_ping(vm_name, conn_uri):
1516
+ try:
1517
+ ip_out = _qga_exec(vm_name, conn_uri, "ip -4 -o addr show scope global | awk '{print $4}'", timeout=5)
1518
+ if ip_out and ip_out.strip():
1519
+ console.print(f"[green]✅ VM has network access (IP via QGA: {ip_out.strip()})[/]")
1520
+ else:
1521
+ console.print("[yellow]⚠️ IP not available via QGA[/]")
1522
+ except Exception as e:
1523
+ console.print(f"[yellow]⚠️ Could not get IP via QGA ({e})[/]")
1524
+ else:
1525
+ console.print("[dim]IP: QEMU Guest Agent not connected[/]")
1528
1526
  except:
1529
1527
  console.print("[yellow]⚠️ Could not check network[/]")
1530
1528
  else:
@@ -1585,34 +1583,13 @@ def cmd_test(args):
1585
1583
  if all_paths:
1586
1584
  for idx, (host_path, guest_path) in enumerate(all_paths.items()):
1587
1585
  try:
1588
- result = subprocess.run(
1589
- ["virsh", "--connect", conn_uri, "qemu-agent-command", vm_name,
1590
- f'{{"execute":"guest-exec","arguments":{{"path":"test","arg":["-d","{guest_path}"],"capture-output":true}}}}'],
1591
- capture_output=True, text=True, timeout=10
1592
- )
1593
- if result.returncode == 0:
1594
- try:
1595
- response = json.loads(result.stdout)
1596
- if "return" in response:
1597
- pid = response["return"]["pid"]
1598
- result2 = subprocess.run(
1599
- ["virsh", "--connect", conn_uri, "qemu-agent-command", vm_name,
1600
- f'{{"execute":"guest-exec-status","arguments":{"pid":{pid}}}}'],
1601
- capture_output=True, text=True, timeout=10
1602
- )
1603
- if result2.returncode == 0:
1604
- resp2 = json.loads(result2.stdout)
1605
- if "return" in resp2 and resp2["return"]["exited"]:
1606
- exit_code = resp2["return"]["exitcode"]
1607
- if exit_code == 0:
1608
- console.print(f"[green]✅ {guest_path}[/]")
1609
- else:
1610
- console.print(f"[red]❌ {guest_path} (not accessible)[/]")
1611
- continue
1612
- except:
1613
- pass
1614
- console.print(f"[yellow]⚠️ {guest_path} (unknown)[/]")
1615
- except:
1586
+ # Use the same QGA helper as diagnose/status
1587
+ is_accessible = _qga_exec(vm_name, conn_uri, f"test -d {guest_path} && echo yes || echo no", timeout=5)
1588
+ if is_accessible == "yes":
1589
+ console.print(f"[green]✅ {guest_path}[/]")
1590
+ else:
1591
+ console.print(f"[red]❌ {guest_path} (not accessible)[/]")
1592
+ except Exception:
1616
1593
  console.print(f"[yellow]⚠️ {guest_path} (could not check)[/]")
1617
1594
  else:
1618
1595
  console.print("[dim]No mount points configured[/]")
@@ -1720,8 +1697,18 @@ def generate_clonebox_yaml(
1720
1697
  """Generate YAML config from system snapshot."""
1721
1698
  sys_info = detector.get_system_info()
1722
1699
 
1723
- # Collect services
1724
- services = [s.name for s in snapshot.running_services]
1700
+ # Services that should NOT be cloned to VM (host-specific)
1701
+ VM_EXCLUDED_SERVICES = {
1702
+ "libvirtd", "virtlogd", "libvirt-guests", "qemu-guest-agent",
1703
+ "bluetooth", "bluez", "upower", "thermald", "tlp", "power-profiles-daemon",
1704
+ "gdm", "gdm3", "sddm", "lightdm",
1705
+ "snap.cups.cups-browsed", "snap.cups.cupsd",
1706
+ "ModemManager", "wpa_supplicant",
1707
+ "accounts-daemon", "colord", "switcheroo-control",
1708
+ }
1709
+
1710
+ # Collect services (excluding host-specific ones)
1711
+ services = [s.name for s in snapshot.running_services if s.name not in VM_EXCLUDED_SERVICES]
1725
1712
  if deduplicate:
1726
1713
  services = deduplicate_list(services)
1727
1714
 
@@ -2060,6 +2047,32 @@ def cmd_clone(args):
2060
2047
  base_image=getattr(args, "base_image", None),
2061
2048
  )
2062
2049
 
2050
+ profile_name = getattr(args, "profile", None)
2051
+ if profile_name:
2052
+ merged_config = merge_with_profile(yaml.safe_load(yaml_content), profile_name)
2053
+ if isinstance(merged_config, dict):
2054
+ vm_section = merged_config.get("vm")
2055
+ if isinstance(vm_section, dict):
2056
+ vm_packages = vm_section.pop("packages", None)
2057
+ if isinstance(vm_packages, list):
2058
+ packages = merged_config.get("packages")
2059
+ if not isinstance(packages, list):
2060
+ packages = []
2061
+ for p in vm_packages:
2062
+ if p not in packages:
2063
+ packages.append(p)
2064
+ merged_config["packages"] = packages
2065
+
2066
+ if "container" in merged_config:
2067
+ merged_config.pop("container", None)
2068
+
2069
+ yaml_content = yaml.dump(
2070
+ merged_config,
2071
+ default_flow_style=False,
2072
+ allow_unicode=True,
2073
+ sort_keys=False,
2074
+ )
2075
+
2063
2076
  # Dry run - show what would be created and exit
2064
2077
  if dry_run:
2065
2078
  config = yaml.safe_load(yaml_content)
@@ -2340,6 +2353,7 @@ def main():
2340
2353
  action="store_true",
2341
2354
  help="Use user session (qemu:///session) - no root required",
2342
2355
  )
2356
+ list_parser.add_argument("--json", action="store_true", help="Output JSON")
2343
2357
  list_parser.set_defaults(func=cmd_list)
2344
2358
 
2345
2359
  # Container command
@@ -2350,6 +2364,7 @@ def main():
2350
2364
  default="auto",
2351
2365
  help="Container engine: auto (default), podman, docker",
2352
2366
  )
2367
+ container_parser.set_defaults(func=lambda args, p=container_parser: p.print_help())
2353
2368
  container_sub = container_parser.add_subparsers(dest="container_command", help="Container commands")
2354
2369
 
2355
2370
  container_up = container_sub.add_parser("up", help="Start container")
@@ -2357,6 +2372,10 @@ def main():
2357
2372
  container_up.add_argument("--name", help="Container name")
2358
2373
  container_up.add_argument("--image", default="ubuntu:22.04", help="Container image")
2359
2374
  container_up.add_argument("--detach", action="store_true", help="Run container in background")
2375
+ container_up.add_argument(
2376
+ "--profile",
2377
+ help="Profile name (loads ~/.clonebox.d/<name>.yaml, .clonebox.d/<name>.yaml, or built-in templates)",
2378
+ )
2360
2379
  container_up.add_argument(
2361
2380
  "--mount",
2362
2381
  action="append",
@@ -2439,6 +2458,10 @@ def main():
2439
2458
  "--base-image",
2440
2459
  help="Path to a bootable qcow2 image to use as a base disk",
2441
2460
  )
2461
+ clone_parser.add_argument(
2462
+ "--profile",
2463
+ help="Profile name (loads ~/.clonebox.d/<name>.yaml, .clonebox.d/<name>.yaml, or built-in templates)",
2464
+ )
2442
2465
  clone_parser.add_argument(
2443
2466
  "--replace",
2444
2467
  action="store_true",
@@ -2465,6 +2488,9 @@ def main():
2465
2488
  status_parser.add_argument(
2466
2489
  "--health", "-H", action="store_true", help="Run full health check"
2467
2490
  )
2491
+ status_parser.add_argument(
2492
+ "--verbose", "-v", action="store_true", help="Show detailed diagnostics (QGA, stderr, etc.)"
2493
+ )
2468
2494
  status_parser.set_defaults(func=cmd_status)
2469
2495
 
2470
2496
  # Diagnose command - detailed diagnostics from workstation
clonebox/cloner.py CHANGED
@@ -663,7 +663,8 @@ fi
663
663
  # Generate mount commands and fstab entries for 9p filesystems
664
664
  mount_commands = []
665
665
  fstab_entries = []
666
- for idx, (host_path, guest_path) in enumerate(config.paths.items()):
666
+ all_paths = dict(config.paths) if config.paths else {}
667
+ for idx, (host_path, guest_path) in enumerate(all_paths.items()):
667
668
  if Path(host_path).exists():
668
669
  tag = f"mount{idx}"
669
670
  # Use uid=1000,gid=1000 to give ubuntu user access to mounts
clonebox/detector.py CHANGED
@@ -64,6 +64,37 @@ class SystemSnapshot:
64
64
  class SystemDetector:
65
65
  """Detects running services, applications and important paths on the system."""
66
66
 
67
+ # Services that should NOT be cloned to VM (host-specific, hardware-dependent, or hypervisor services)
68
+ VM_EXCLUDED_SERVICES = {
69
+ # Hypervisor/virtualization - no nested virt needed
70
+ "libvirtd",
71
+ "virtlogd",
72
+ "libvirt-guests",
73
+ "qemu-guest-agent", # Host-side, VM has its own
74
+ # Hardware-specific
75
+ "bluetooth",
76
+ "bluez",
77
+ "upower",
78
+ "thermald",
79
+ "tlp",
80
+ "power-profiles-daemon",
81
+ # Display manager (VM has its own)
82
+ "gdm",
83
+ "gdm3",
84
+ "sddm",
85
+ "lightdm",
86
+ # Snap-based duplicates (prefer APT versions)
87
+ "snap.cups.cups-browsed",
88
+ "snap.cups.cupsd",
89
+ # Network hardware
90
+ "ModemManager",
91
+ "wpa_supplicant",
92
+ # Host-specific desktop
93
+ "accounts-daemon",
94
+ "colord",
95
+ "switcheroo-control",
96
+ }
97
+
67
98
  # Common development/server services to look for
68
99
  INTERESTING_SERVICES = [
69
100
  "docker",
clonebox/profiles.py ADDED
@@ -0,0 +1,49 @@
1
+ import pkgutil
2
+ from pathlib import Path
3
+ from typing import Any, Dict, Optional
4
+
5
+ import yaml
6
+
7
+
8
+ def load_profile(profile_name: str, search_paths: list[Path]) -> Optional[Dict[str, Any]]:
9
+ """Load profile YAML from ~/.clonebox.d/, .clonebox.d/, templates/profiles/"""
10
+ profile_paths = [
11
+ Path.home() / ".clonebox.d" / f"{profile_name}.yaml",
12
+ Path.cwd() / ".clonebox.d" / f"{profile_name}.yaml",
13
+ ]
14
+
15
+ for profile_path in profile_paths:
16
+ if profile_path.exists():
17
+ return yaml.safe_load(profile_path.read_text())
18
+
19
+ data = pkgutil.get_data("clonebox", f"templates/profiles/{profile_name}.yaml")
20
+ if data is not None:
21
+ return yaml.safe_load(data.decode())
22
+
23
+ return None
24
+
25
+
26
+ def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
27
+ merged: Dict[str, Any] = dict(base)
28
+ for key, value in override.items():
29
+ if (
30
+ key in merged
31
+ and isinstance(merged[key], dict)
32
+ and isinstance(value, dict)
33
+ ):
34
+ merged[key] = _deep_merge(merged[key], value)
35
+ else:
36
+ merged[key] = value
37
+ return merged
38
+
39
+
40
+ def merge_with_profile(base_config: Dict[str, Any], profile_name: Optional[str] = None) -> Dict[str, Any]:
41
+ """Merge profile OVER base config (profile wins)."""
42
+ if not profile_name:
43
+ return base_config
44
+
45
+ profile = load_profile(profile_name, [])
46
+ if not profile or not isinstance(profile, dict):
47
+ return base_config
48
+
49
+ return _deep_merge(base_config, profile)
@@ -0,0 +1,6 @@
1
+ container:
2
+ image: python:3.11-slim
3
+ packages: ["pip", "jupyterlab"]
4
+ ports: ["8888:8888"]
5
+ vm:
6
+ packages: ["python3-pip", "jupyterlab"]
clonebox/validator.py CHANGED
@@ -236,6 +236,16 @@ class VMValidator:
236
236
 
237
237
  return self.results["snap_packages"]
238
238
 
239
+ # Services that should NOT be validated in VM (host-specific)
240
+ VM_EXCLUDED_SERVICES = {
241
+ "libvirtd", "virtlogd", "libvirt-guests", "qemu-guest-agent",
242
+ "bluetooth", "bluez", "upower", "thermald", "tlp", "power-profiles-daemon",
243
+ "gdm", "gdm3", "sddm", "lightdm",
244
+ "snap.cups.cups-browsed", "snap.cups.cupsd",
245
+ "ModemManager", "wpa_supplicant",
246
+ "accounts-daemon", "colord", "switcheroo-control",
247
+ }
248
+
239
249
  def validate_services(self) -> Dict:
240
250
  """Validate services are enabled and running."""
241
251
  self.console.print("\n[bold]⚙️ Validating Services...[/]")
@@ -245,12 +255,30 @@ class VMValidator:
245
255
  self.console.print("[dim]No services configured[/]")
246
256
  return self.results["services"]
247
257
 
258
+ # Initialize skipped counter
259
+ if "skipped" not in self.results["services"]:
260
+ self.results["services"]["skipped"] = 0
261
+
248
262
  svc_table = Table(title="Service Validation", border_style="cyan")
249
263
  svc_table.add_column("Service", style="bold")
250
264
  svc_table.add_column("Enabled", justify="center")
251
265
  svc_table.add_column("Running", justify="center")
266
+ svc_table.add_column("Note", style="dim")
252
267
 
253
268
  for service in services:
269
+ # Check if service should be skipped (host-specific)
270
+ if service in self.VM_EXCLUDED_SERVICES:
271
+ svc_table.add_row(service, "[dim]—[/]", "[dim]—[/]", "host-only")
272
+ self.results["services"]["skipped"] += 1
273
+ self.results["services"]["details"].append({
274
+ "service": service,
275
+ "enabled": None,
276
+ "running": None,
277
+ "skipped": True,
278
+ "reason": "host-specific service"
279
+ })
280
+ continue
281
+
254
282
  self.results["services"]["total"] += 1
255
283
 
256
284
  # Check if enabled
@@ -266,7 +294,7 @@ class VMValidator:
266
294
  enabled_icon = "[green]✅[/]" if is_enabled else "[yellow]⚠️[/]"
267
295
  running_icon = "[green]✅[/]" if is_running else "[red]❌[/]"
268
296
 
269
- svc_table.add_row(service, enabled_icon, running_icon)
297
+ svc_table.add_row(service, enabled_icon, running_icon, "")
270
298
 
271
299
  if is_enabled and is_running:
272
300
  self.results["services"]["passed"] += 1
@@ -276,11 +304,16 @@ class VMValidator:
276
304
  self.results["services"]["details"].append({
277
305
  "service": service,
278
306
  "enabled": is_enabled,
279
- "running": is_running
307
+ "running": is_running,
308
+ "skipped": False
280
309
  })
281
310
 
282
311
  self.console.print(svc_table)
283
- self.console.print(f"[dim]{self.results['services']['passed']}/{self.results['services']['total']} services active[/]")
312
+ skipped = self.results["services"].get("skipped", 0)
313
+ msg = f"{self.results['services']['passed']}/{self.results['services']['total']} services active"
314
+ if skipped > 0:
315
+ msg += f" ({skipped} host-only skipped)"
316
+ self.console.print(f"[dim]{msg}[/]")
284
317
 
285
318
  return self.results["services"]
286
319
 
@@ -334,28 +367,35 @@ class VMValidator:
334
367
  self.results["services"]["failed"]
335
368
  )
336
369
 
370
+ # Get skipped services count
371
+ skipped_services = self.results["services"].get("skipped", 0)
372
+
337
373
  # Print summary
338
374
  self.console.print("\n[bold]📊 Validation Summary[/]")
339
375
  summary_table = Table(border_style="cyan")
340
376
  summary_table.add_column("Category", style="bold")
341
377
  summary_table.add_column("Passed", justify="right", style="green")
342
378
  summary_table.add_column("Failed", justify="right", style="red")
379
+ summary_table.add_column("Skipped", justify="right", style="dim")
343
380
  summary_table.add_column("Total", justify="right")
344
381
 
345
382
  summary_table.add_row("Mounts", str(self.results["mounts"]["passed"]),
346
- str(self.results["mounts"]["failed"]),
383
+ str(self.results["mounts"]["failed"]), "—",
347
384
  str(self.results["mounts"]["total"]))
348
385
  summary_table.add_row("APT Packages", str(self.results["packages"]["passed"]),
349
- str(self.results["packages"]["failed"]),
386
+ str(self.results["packages"]["failed"]), "—",
350
387
  str(self.results["packages"]["total"]))
351
388
  summary_table.add_row("Snap Packages", str(self.results["snap_packages"]["passed"]),
352
- str(self.results["snap_packages"]["failed"]),
389
+ str(self.results["snap_packages"]["failed"]), "—",
353
390
  str(self.results["snap_packages"]["total"]))
354
391
  summary_table.add_row("Services", str(self.results["services"]["passed"]),
355
392
  str(self.results["services"]["failed"]),
393
+ str(skipped_services),
356
394
  str(self.results["services"]["total"]))
357
395
  summary_table.add_row("[bold]TOTAL", f"[bold green]{total_passed}",
358
- f"[bold red]{total_failed}", f"[bold]{total_checks}")
396
+ f"[bold red]{total_failed}",
397
+ f"[dim]{skipped_services}[/]",
398
+ f"[bold]{total_checks}")
359
399
 
360
400
  self.console.print(summary_table)
361
401
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clonebox
3
- Version: 0.1.17
3
+ Version: 0.1.19
4
4
  Summary: Clone your workstation environment to an isolated VM with selective apps, paths and services
5
5
  Author: CloneBox Team
6
6
  License: Apache-2.0
@@ -41,6 +41,9 @@ Provides-Extra: test
41
41
  Requires-Dist: pytest>=7.0.0; extra == "test"
42
42
  Requires-Dist: pytest-cov>=4.0.0; extra == "test"
43
43
  Requires-Dist: pytest-timeout>=2.0.0; extra == "test"
44
+ Provides-Extra: dashboard
45
+ Requires-Dist: fastapi>=0.100.0; extra == "dashboard"
46
+ Requires-Dist: uvicorn>=0.22.0; extra == "dashboard"
44
47
  Dynamic: license-file
45
48
 
46
49
  # CloneBox 📦
@@ -0,0 +1,16 @@
1
+ clonebox/__init__.py,sha256=CyfHVVq6KqBr4CNERBpXk_O6Q5B35q03YpdQbokVvvI,408
2
+ clonebox/__main__.py,sha256=Fcoyzwwyz5-eC_sBlQk5a5RbKx8uodQz5sKJ190U0NU,135
3
+ clonebox/cli.py,sha256=WnAlgR3h0gIMwkc0uHAr44E4eS25GTBsNbKbuumrUug,99790
4
+ clonebox/cloner.py,sha256=OXytYy4K4sr-ytP55HZK9frt1vU5Kf5EOCIKYhpdJtQ,32516
5
+ clonebox/container.py,sha256=tiYK1ZB-DhdD6A2FuMA0h_sRNkUI7KfYcJ0tFOcdyeM,6105
6
+ clonebox/detector.py,sha256=FM8QJ6RTO0jXyUJIcAJivLwUh82xm287ayFD_VIuvgs,22521
7
+ clonebox/models.py,sha256=Uxz9eHov2epJpNYbl0ejaOX91iMSjqdHskGdC8-smVk,7789
8
+ clonebox/profiles.py,sha256=ZyuPct9Jg5m2ImnSEbHvBMp3x7Ufj2420D84rXt2T1Y,1537
9
+ clonebox/validator.py,sha256=qIkFMkWmpx4k2-vPkLigZ6pK5wtl748CmpuNLB6yOIM,17645
10
+ clonebox/templates/profiles/ml-dev.yaml,sha256=MT7Wu3xGBnYIsO5mzZ2GDI4AAEFGOroIx0eU3XjNARg,140
11
+ clonebox-0.1.19.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
12
+ clonebox-0.1.19.dist-info/METADATA,sha256=HXLOv0aaY7OlXGJKtk8LSzlbqeQwgHp9Bax29KzQLV4,35353
13
+ clonebox-0.1.19.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
14
+ clonebox-0.1.19.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
15
+ clonebox-0.1.19.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
16
+ clonebox-0.1.19.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- clonebox/__init__.py,sha256=C1J7Uwrp8H9Zopo5JgrQYzXg-PWls1JdqmE_0Qp1Tro,408
2
- clonebox/__main__.py,sha256=Fcoyzwwyz5-eC_sBlQk5a5RbKx8uodQz5sKJ190U0NU,135
3
- clonebox/cli.py,sha256=mB2Xoz9llkHW9yIR-xRuZ94F_PmV833gdiKVKKcJQrc,98462
4
- clonebox/cloner.py,sha256=tgN51yeNGesolO1wfuVh-CAGkAZew7oMoCYYz_bXgBk,32456
5
- clonebox/container.py,sha256=tiYK1ZB-DhdD6A2FuMA0h_sRNkUI7KfYcJ0tFOcdyeM,6105
6
- clonebox/detector.py,sha256=4fu04Ty6KC82WkcJZ5UL5TqXpWYE7Kb7R0uJ-9dtbCk,21635
7
- clonebox/models.py,sha256=Uxz9eHov2epJpNYbl0ejaOX91iMSjqdHskGdC8-smVk,7789
8
- clonebox/validator.py,sha256=8HV3ahfiLkFDOH4UOmZr7-fGfhKep1Jlw1joJeWSaQE,15858
9
- clonebox-0.1.17.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
10
- clonebox-0.1.17.dist-info/METADATA,sha256=YHJ3k1qzqhkB7hsuZVesQv5ANF6bbABLGTYSCl29p34,35220
11
- clonebox-0.1.17.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
12
- clonebox-0.1.17.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
13
- clonebox-0.1.17.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
14
- clonebox-0.1.17.dist-info/RECORD,,