clonebox 0.1.18__tar.gz → 0.1.19__tar.gz

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.
Files changed (28) hide show
  1. {clonebox-0.1.18/src/clonebox.egg-info → clonebox-0.1.19}/PKG-INFO +4 -1
  2. {clonebox-0.1.18 → clonebox-0.1.19}/pyproject.toml +15 -1
  3. {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox/__init__.py +1 -1
  4. {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox/cli.py +104 -75
  5. {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox/cloner.py +2 -1
  6. {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox/detector.py +31 -0
  7. clonebox-0.1.19/src/clonebox/profiles.py +49 -0
  8. clonebox-0.1.19/src/clonebox/templates/profiles/ml-dev.yaml +6 -0
  9. {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox/validator.py +47 -7
  10. {clonebox-0.1.18 → clonebox-0.1.19/src/clonebox.egg-info}/PKG-INFO +4 -1
  11. {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox.egg-info/SOURCES.txt +3 -0
  12. {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox.egg-info/requires.txt +4 -0
  13. {clonebox-0.1.18 → clonebox-0.1.19}/tests/test_cli.py +3 -0
  14. clonebox-0.1.19/tests/test_container.py +235 -0
  15. {clonebox-0.1.18 → clonebox-0.1.19}/LICENSE +0 -0
  16. {clonebox-0.1.18 → clonebox-0.1.19}/README.md +0 -0
  17. {clonebox-0.1.18 → clonebox-0.1.19}/setup.cfg +0 -0
  18. {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox/__main__.py +0 -0
  19. {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox/container.py +0 -0
  20. {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox/models.py +0 -0
  21. {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox.egg-info/dependency_links.txt +0 -0
  22. {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox.egg-info/entry_points.txt +0 -0
  23. {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox.egg-info/top_level.txt +0 -0
  24. {clonebox-0.1.18 → clonebox-0.1.19}/tests/test_cloner.py +0 -0
  25. {clonebox-0.1.18 → clonebox-0.1.19}/tests/test_detector.py +0 -0
  26. {clonebox-0.1.18 → clonebox-0.1.19}/tests/test_models.py +0 -0
  27. {clonebox-0.1.18 → clonebox-0.1.19}/tests/test_network.py +0 -0
  28. {clonebox-0.1.18 → clonebox-0.1.19}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clonebox
3
- Version: 0.1.18
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 📦
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "clonebox"
7
- version = "0.1.18"
7
+ version = "0.1.19"
8
8
  description = "Clone your workstation environment to an isolated VM with selective apps, paths and services"
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -52,6 +52,10 @@ test = [
52
52
  "pytest-cov>=4.0.0",
53
53
  "pytest-timeout>=2.0.0",
54
54
  ]
55
+ dashboard = [
56
+ "fastapi>=0.100.0",
57
+ "uvicorn>=0.22.0",
58
+ ]
55
59
 
56
60
  [project.scripts]
57
61
  clonebox = "clonebox.cli:main"
@@ -64,6 +68,9 @@ Issues = "https://github.com/wronai/clonebox/issues"
64
68
  [tool.setuptools.packages.find]
65
69
  where = ["src"]
66
70
 
71
+ [tool.setuptools.package-data]
72
+ clonebox = ["templates/**/*.yaml"]
73
+
67
74
  [tool.black]
68
75
  line-length = 100
69
76
  target-version = ["py38", "py39", "py310", "py311", "py312"]
@@ -82,6 +89,7 @@ markers = [
82
89
  "e2e: end-to-end tests requiring libvirt/KVM (deselect with '-m \"not e2e\"')",
83
90
  "slow: slow tests (deselect with '-m \"not slow\"')",
84
91
  "integration: integration tests",
92
+ "container: container workflow tests requiring podman/docker",
85
93
  "requires_kvm: tests requiring /dev/kvm access",
86
94
  ]
87
95
  addopts = [
@@ -99,3 +107,9 @@ filterwarnings = [
99
107
  "ignore::PendingDeprecationWarning",
100
108
  "ignore::UserWarning",
101
109
  ]
110
+
111
+ [tool.coverage.run]
112
+ omit = [
113
+ "src/clonebox/container.py",
114
+ "src/clonebox/cli.py",
115
+ ]
@@ -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
@@ -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(
@@ -234,9 +237,22 @@ def run_vm_diagnostics(
234
237
  if domifaddr.stdout.strip():
235
238
  console.print(f"[dim]{domifaddr.stdout.strip()}[/]")
236
239
  else:
237
- console.print("[yellow]⚠️ No interface address detected yet[/]")
240
+ console.print("[yellow]⚠️ No interface address detected via virsh domifaddr[/]")
238
241
  if verbose and domifaddr.stderr.strip():
239
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[/]")
240
256
  except Exception as e:
241
257
  result["network"] = {"error": str(e)}
242
258
  console.print(f"[yellow]⚠️ Cannot get IP: {e}[/]")
@@ -703,7 +719,6 @@ def interactive_mode():
703
719
  console.print("\n[bold]Inside the VM, mount shared folders with:[/]")
704
720
  for idx, (host, guest) in enumerate(paths_mapping.items()):
705
721
  console.print(f" [cyan]sudo mount -t 9p -o trans=virtio mount{idx} {guest}[/]")
706
-
707
722
  except Exception as e:
708
723
  console.print(f"\n[red]❌ Error: {e}[/]")
709
724
  raise
@@ -904,16 +919,15 @@ def cmd_delete(args):
904
919
  ).ask():
905
920
  console.print("[yellow]Cancelled.[/]")
906
921
  return
907
-
908
- cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
909
- cloner.delete_vm(name, delete_storage=not args.keep_storage, console=console)
910
-
911
-
912
922
  def cmd_list(args):
913
923
  """List all VMs."""
914
924
  cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
915
925
  vms = cloner.list_vms()
916
926
 
927
+ if getattr(args, "json", False):
928
+ print(json.dumps(vms, indent=2))
929
+ return
930
+
917
931
  if not vms:
918
932
  console.print("[dim]No VMs found.[/]")
919
933
  return
@@ -932,14 +946,6 @@ def cmd_list(args):
932
946
 
933
947
  def cmd_container_up(args):
934
948
  """Start a container sandbox."""
935
- try:
936
- from clonebox.container import ContainerCloner
937
- from clonebox.models import ContainerConfig
938
- except ModuleNotFoundError as e:
939
- raise ModuleNotFoundError(
940
- "Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
941
- ) from e
942
-
943
949
  mounts = {}
944
950
  for m in getattr(args, "mount", []) or []:
945
951
  if ":" not in m:
@@ -959,21 +965,21 @@ def cmd_container_up(args):
959
965
  if getattr(args, "name", None):
960
966
  cfg_kwargs["name"] = args.name
961
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
+
962
974
  cfg = ContainerConfig(**cfg_kwargs)
963
975
 
964
976
  cloner = ContainerCloner(engine=cfg.engine)
965
- cloner.up(cfg, detach=getattr(args, "detach", False))
977
+ detach = getattr(args, "detach", False)
978
+ cloner.up(cfg, detach=detach, remove=not detach)
966
979
 
967
980
 
968
981
  def cmd_container_ps(args):
969
982
  """List containers."""
970
- try:
971
- from clonebox.container import ContainerCloner
972
- except ModuleNotFoundError as e:
973
- raise ModuleNotFoundError(
974
- "Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
975
- ) from e
976
-
977
983
  cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
978
984
  items = cloner.ps(all=getattr(args, "all", False))
979
985
 
@@ -1004,39 +1010,18 @@ def cmd_container_ps(args):
1004
1010
 
1005
1011
  def cmd_container_stop(args):
1006
1012
  """Stop a container."""
1007
- try:
1008
- from clonebox.container import ContainerCloner
1009
- except ModuleNotFoundError as e:
1010
- raise ModuleNotFoundError(
1011
- "Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
1012
- ) from e
1013
-
1014
1013
  cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
1015
1014
  cloner.stop(args.name)
1016
1015
 
1017
1016
 
1018
1017
  def cmd_container_rm(args):
1019
1018
  """Remove a container."""
1020
- try:
1021
- from clonebox.container import ContainerCloner
1022
- except ModuleNotFoundError as e:
1023
- raise ModuleNotFoundError(
1024
- "Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
1025
- ) from e
1026
-
1027
1019
  cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
1028
1020
  cloner.rm(args.name, force=getattr(args, "force", False))
1029
1021
 
1030
1022
 
1031
1023
  def cmd_container_down(args):
1032
1024
  """Stop and remove a container."""
1033
- try:
1034
- from clonebox.container import ContainerCloner
1035
- except ModuleNotFoundError as e:
1036
- raise ModuleNotFoundError(
1037
- "Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
1038
- ) from e
1039
-
1040
1025
  cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
1041
1026
  cloner.stop(args.name)
1042
1027
  cloner.rm(args.name, force=True)
@@ -1521,7 +1506,23 @@ def cmd_test(args):
1521
1506
  if '192.168' in line or '10.0' in line:
1522
1507
  console.print(f" IP: {line.split()[-1]}")
1523
1508
  else:
1524
- 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[/]")
1525
1526
  except:
1526
1527
  console.print("[yellow]⚠️ Could not check network[/]")
1527
1528
  else:
@@ -1582,34 +1583,13 @@ def cmd_test(args):
1582
1583
  if all_paths:
1583
1584
  for idx, (host_path, guest_path) in enumerate(all_paths.items()):
1584
1585
  try:
1585
- result = subprocess.run(
1586
- ["virsh", "--connect", conn_uri, "qemu-agent-command", vm_name,
1587
- f'{{"execute":"guest-exec","arguments":{{"path":"test","arg":["-d","{guest_path}"],"capture-output":true}}}}'],
1588
- capture_output=True, text=True, timeout=10
1589
- )
1590
- if result.returncode == 0:
1591
- try:
1592
- response = json.loads(result.stdout)
1593
- if "return" in response:
1594
- pid = response["return"]["pid"]
1595
- result2 = subprocess.run(
1596
- ["virsh", "--connect", conn_uri, "qemu-agent-command", vm_name,
1597
- f'{{"execute":"guest-exec-status","arguments":{"pid":{pid}}}}'],
1598
- capture_output=True, text=True, timeout=10
1599
- )
1600
- if result2.returncode == 0:
1601
- resp2 = json.loads(result2.stdout)
1602
- if "return" in resp2 and resp2["return"]["exited"]:
1603
- exit_code = resp2["return"]["exitcode"]
1604
- if exit_code == 0:
1605
- console.print(f"[green]✅ {guest_path}[/]")
1606
- else:
1607
- console.print(f"[red]❌ {guest_path} (not accessible)[/]")
1608
- continue
1609
- except:
1610
- pass
1611
- console.print(f"[yellow]⚠️ {guest_path} (unknown)[/]")
1612
- 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:
1613
1593
  console.print(f"[yellow]⚠️ {guest_path} (could not check)[/]")
1614
1594
  else:
1615
1595
  console.print("[dim]No mount points configured[/]")
@@ -1717,8 +1697,18 @@ def generate_clonebox_yaml(
1717
1697
  """Generate YAML config from system snapshot."""
1718
1698
  sys_info = detector.get_system_info()
1719
1699
 
1720
- # Collect services
1721
- 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]
1722
1712
  if deduplicate:
1723
1713
  services = deduplicate_list(services)
1724
1714
 
@@ -2057,6 +2047,32 @@ def cmd_clone(args):
2057
2047
  base_image=getattr(args, "base_image", None),
2058
2048
  )
2059
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
+
2060
2076
  # Dry run - show what would be created and exit
2061
2077
  if dry_run:
2062
2078
  config = yaml.safe_load(yaml_content)
@@ -2337,6 +2353,7 @@ def main():
2337
2353
  action="store_true",
2338
2354
  help="Use user session (qemu:///session) - no root required",
2339
2355
  )
2356
+ list_parser.add_argument("--json", action="store_true", help="Output JSON")
2340
2357
  list_parser.set_defaults(func=cmd_list)
2341
2358
 
2342
2359
  # Container command
@@ -2347,6 +2364,7 @@ def main():
2347
2364
  default="auto",
2348
2365
  help="Container engine: auto (default), podman, docker",
2349
2366
  )
2367
+ container_parser.set_defaults(func=lambda args, p=container_parser: p.print_help())
2350
2368
  container_sub = container_parser.add_subparsers(dest="container_command", help="Container commands")
2351
2369
 
2352
2370
  container_up = container_sub.add_parser("up", help="Start container")
@@ -2354,6 +2372,10 @@ def main():
2354
2372
  container_up.add_argument("--name", help="Container name")
2355
2373
  container_up.add_argument("--image", default="ubuntu:22.04", help="Container image")
2356
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
+ )
2357
2379
  container_up.add_argument(
2358
2380
  "--mount",
2359
2381
  action="append",
@@ -2436,6 +2458,10 @@ def main():
2436
2458
  "--base-image",
2437
2459
  help="Path to a bootable qcow2 image to use as a base disk",
2438
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
+ )
2439
2465
  clone_parser.add_argument(
2440
2466
  "--replace",
2441
2467
  action="store_true",
@@ -2462,6 +2488,9 @@ def main():
2462
2488
  status_parser.add_argument(
2463
2489
  "--health", "-H", action="store_true", help="Run full health check"
2464
2490
  )
2491
+ status_parser.add_argument(
2492
+ "--verbose", "-v", action="store_true", help="Show detailed diagnostics (QGA, stderr, etc.)"
2493
+ )
2465
2494
  status_parser.set_defaults(func=cmd_status)
2466
2495
 
2467
2496
  # Diagnose command - detailed diagnostics from workstation
@@ -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
@@ -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",
@@ -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"]
@@ -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.18
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 📦
@@ -8,6 +8,7 @@ src/clonebox/cloner.py
8
8
  src/clonebox/container.py
9
9
  src/clonebox/detector.py
10
10
  src/clonebox/models.py
11
+ src/clonebox/profiles.py
11
12
  src/clonebox/validator.py
12
13
  src/clonebox.egg-info/PKG-INFO
13
14
  src/clonebox.egg-info/SOURCES.txt
@@ -15,8 +16,10 @@ src/clonebox.egg-info/dependency_links.txt
15
16
  src/clonebox.egg-info/entry_points.txt
16
17
  src/clonebox.egg-info/requires.txt
17
18
  src/clonebox.egg-info/top_level.txt
19
+ src/clonebox/templates/profiles/ml-dev.yaml
18
20
  tests/test_cli.py
19
21
  tests/test_cloner.py
22
+ tests/test_container.py
20
23
  tests/test_detector.py
21
24
  tests/test_models.py
22
25
  tests/test_network.py
@@ -5,6 +5,10 @@ psutil>=5.9.0
5
5
  pyyaml>=6.0
6
6
  pydantic>=2.0.0
7
7
 
8
+ [dashboard]
9
+ fastapi>=0.100.0
10
+ uvicorn>=0.22.0
11
+
8
12
  [dev]
9
13
  pytest>=7.0.0
10
14
  pytest-cov>=4.0.0
@@ -222,6 +222,9 @@ class TestCLIIntegration:
222
222
  (["detect", "--help"], 0),
223
223
  (["clone", "--help"], 0),
224
224
  (["list", "--help"], 0),
225
+ (["container", "--help"], 0),
226
+ (["container", "ps", "--help"], 0),
227
+ (["container", "up", "--help"], 0),
225
228
  ])
226
229
  def test_cli_help_commands(self, command, expected_exit):
227
230
  """Test CLI help and version commands."""
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import MagicMock
5
+
6
+ import pytest
7
+
8
+ from clonebox.container import ContainerCloner
9
+ from clonebox.models import ContainerConfig
10
+
11
+
12
+ def _which_side_effect(mapping):
13
+ def which(name):
14
+ return mapping.get(name)
15
+
16
+ return which
17
+
18
+
19
+ class TestContainerClonerEngineDetection:
20
+ def test_detect_engine_prefers_podman(self, monkeypatch):
21
+ monkeypatch.setattr(
22
+ "clonebox.container.shutil.which",
23
+ _which_side_effect({"podman": "/usr/bin/podman", "docker": "/usr/bin/docker"}),
24
+ )
25
+
26
+ def fake_run(self, cmd, check=True, capture_output=True, text=True):
27
+ return MagicMock(returncode=0, stdout="podman version", stderr="")
28
+
29
+ monkeypatch.setattr("clonebox.container.ContainerCloner._run", fake_run)
30
+
31
+ c = ContainerCloner(engine="auto")
32
+ assert c.engine == "podman"
33
+
34
+ def test_detect_engine_fallbacks_to_docker(self, monkeypatch):
35
+ monkeypatch.setattr(
36
+ "clonebox.container.shutil.which",
37
+ _which_side_effect({"podman": "/usr/bin/podman", "docker": "/usr/bin/docker"}),
38
+ )
39
+
40
+ def fake_run(self, cmd, check=True, capture_output=True, text=True):
41
+ if cmd[0] == "podman":
42
+ raise RuntimeError("podman broken")
43
+ return MagicMock(returncode=0, stdout="docker version", stderr="")
44
+
45
+ monkeypatch.setattr("clonebox.container.ContainerCloner._run", fake_run)
46
+
47
+ c = ContainerCloner(engine="auto")
48
+ assert c.engine == "docker"
49
+
50
+ def test_detect_engine_errors_when_none_found(self, monkeypatch):
51
+ monkeypatch.setattr(
52
+ "clonebox.container.shutil.which",
53
+ _which_side_effect({"podman": None, "docker": None}),
54
+ )
55
+
56
+ c = ContainerCloner.__new__(ContainerCloner)
57
+ c.engine = "auto"
58
+ with pytest.raises(RuntimeError, match="No container engine found"):
59
+ c.detect_engine()
60
+
61
+
62
+ class TestContainerClonerBuild:
63
+ def test_build_dockerfile_includes_packages(self, tmp_path):
64
+ cfg = ContainerConfig(
65
+ engine="podman",
66
+ image="ubuntu:22.04",
67
+ workspace=tmp_path,
68
+ packages=["curl", "git"],
69
+ )
70
+ c = ContainerCloner.__new__(ContainerCloner)
71
+ c.engine = "podman"
72
+
73
+ dockerfile = c.build_dockerfile(cfg)
74
+ assert "FROM ubuntu:22.04" in dockerfile
75
+ assert "apt-get install -y curl git" in dockerfile
76
+ assert "WORKDIR /workspace" in dockerfile
77
+
78
+ def test_build_image_calls_engine_build(self, monkeypatch, tmp_path):
79
+ (tmp_path / "file.txt").write_text("x")
80
+ cfg = ContainerConfig(engine="podman", workspace=tmp_path, packages=["curl"])
81
+
82
+ calls = []
83
+
84
+ def fake_run(self, cmd, check=True, capture_output=True, text=True):
85
+ calls.append(cmd)
86
+ return MagicMock(returncode=0, stdout="ok", stderr="")
87
+
88
+ monkeypatch.setattr("clonebox.container.ContainerCloner._run", fake_run)
89
+
90
+ c = ContainerCloner.__new__(ContainerCloner)
91
+ c.engine = "podman"
92
+
93
+ tag = c.build_image(cfg, tag="myimg:latest")
94
+ assert tag == "myimg:latest"
95
+ assert calls
96
+ cmd = calls[0]
97
+ assert cmd[0] == "podman"
98
+ assert cmd[1] == "build"
99
+ assert "-t" in cmd and "myimg:latest" in cmd
100
+ assert str(tmp_path.resolve()) == cmd[-1]
101
+
102
+ dockerfile_path = Path(cmd[cmd.index("-f") + 1])
103
+ assert not dockerfile_path.exists()
104
+
105
+
106
+ class TestContainerClonerUpAndPs:
107
+ def test_up_runs_interactive_with_env_file(self, monkeypatch, tmp_path):
108
+ (tmp_path / ".env").write_text("FOO=bar\n")
109
+
110
+ cfg = ContainerConfig(engine="podman", name="test", workspace=tmp_path)
111
+
112
+ monkeypatch.setattr(
113
+ "clonebox.container.shutil.which", _which_side_effect({"podman": "/usr/bin/podman"})
114
+ )
115
+
116
+ def fake_run_version(self, cmd, check=True, capture_output=True, text=True):
117
+ return MagicMock(returncode=0, stdout="podman version", stderr="")
118
+
119
+ monkeypatch.setattr("clonebox.container.ContainerCloner._run", fake_run_version)
120
+
121
+ called = {}
122
+
123
+ def fake_subprocess_run(cmd, check=True):
124
+ called["cmd"] = cmd
125
+ return MagicMock(returncode=0)
126
+
127
+ monkeypatch.setattr("clonebox.container.subprocess.run", fake_subprocess_run)
128
+
129
+ c = ContainerCloner(engine="podman")
130
+ c.up(cfg, detach=False)
131
+
132
+ cmd = called["cmd"]
133
+ assert cmd[0] == "podman"
134
+ assert cmd[1] == "run"
135
+ assert "--env-file" in cmd
136
+ assert str(tmp_path / ".env") in cmd
137
+ assert "-v" in cmd
138
+ assert "bash" in cmd
139
+
140
+ def test_up_detach_runs_sleep_infinity(self, monkeypatch, tmp_path):
141
+ cfg = ContainerConfig(engine="podman", name="test", workspace=tmp_path)
142
+
143
+ monkeypatch.setattr(
144
+ "clonebox.container.shutil.which", _which_side_effect({"podman": "/usr/bin/podman"})
145
+ )
146
+
147
+ def fake_run_version(self, cmd, check=True, capture_output=True, text=True):
148
+ return MagicMock(returncode=0, stdout="podman version", stderr="")
149
+
150
+ monkeypatch.setattr("clonebox.container.ContainerCloner._run", fake_run_version)
151
+
152
+ called = {}
153
+
154
+ def fake_subprocess_run(cmd, check=True):
155
+ called["cmd"] = cmd
156
+ return MagicMock(returncode=0)
157
+
158
+ monkeypatch.setattr("clonebox.container.subprocess.run", fake_subprocess_run)
159
+
160
+ c = ContainerCloner(engine="podman")
161
+ c.up(cfg, detach=True)
162
+
163
+ cmd = called["cmd"]
164
+ assert "-d" in cmd
165
+ assert cmd[-2:] == ["sleep", "infinity"]
166
+
167
+ def test_ps_docker_parses_tab_format(self, monkeypatch):
168
+ def fake_run(self, cmd, check=True, capture_output=True, text=True):
169
+ return MagicMock(
170
+ returncode=0,
171
+ stdout="c1\tubuntu:22.04\tUp 2 seconds\t0.0.0.0:8080->80/tcp\n",
172
+ stderr="",
173
+ )
174
+
175
+ monkeypatch.setattr("clonebox.container.ContainerCloner._run", fake_run)
176
+
177
+ c = ContainerCloner.__new__(ContainerCloner)
178
+ c.engine = "docker"
179
+
180
+ items = c.ps(all=False)
181
+ assert len(items) == 1
182
+ assert items[0]["name"] == "c1"
183
+ assert items[0]["image"] == "ubuntu:22.04"
184
+
185
+ def test_ps_podman_json_uses_a_when_all(self, monkeypatch):
186
+ captured = {}
187
+
188
+ def fake_run(self, cmd, check=True, capture_output=True, text=True):
189
+ captured["cmd"] = cmd
190
+ return MagicMock(
191
+ returncode=0,
192
+ stdout='[{"Names":["c1"],"Image":"ubuntu:22.04","State":"running","Ports":[]}]',
193
+ stderr="",
194
+ )
195
+
196
+ monkeypatch.setattr("clonebox.container.ContainerCloner._run", fake_run)
197
+
198
+ c = ContainerCloner.__new__(ContainerCloner)
199
+ c.engine = "podman"
200
+
201
+ items = c.ps(all=True)
202
+ # Command is: podman ps --format json -a
203
+ assert captured["cmd"][:2] == ["podman", "ps"]
204
+ assert "-a" in captured["cmd"]
205
+ assert items[0]["name"] == "c1"
206
+
207
+
208
+ class TestContainerClonerStopRm:
209
+ def test_stop_calls_engine(self, monkeypatch):
210
+ called = {}
211
+
212
+ def fake_subprocess_run(cmd, check=True):
213
+ called["cmd"] = cmd
214
+ return MagicMock(returncode=0)
215
+
216
+ monkeypatch.setattr("clonebox.container.subprocess.run", fake_subprocess_run)
217
+
218
+ c = ContainerCloner.__new__(ContainerCloner)
219
+ c.engine = "podman"
220
+ c.stop("c1")
221
+ assert called["cmd"] == ["podman", "stop", "c1"]
222
+
223
+ def test_rm_force_adds_f(self, monkeypatch):
224
+ called = {}
225
+
226
+ def fake_subprocess_run(cmd, check=True):
227
+ called["cmd"] = cmd
228
+ return MagicMock(returncode=0)
229
+
230
+ monkeypatch.setattr("clonebox.container.subprocess.run", fake_subprocess_run)
231
+
232
+ c = ContainerCloner.__new__(ContainerCloner)
233
+ c.engine = "docker"
234
+ c.rm("c1", force=True)
235
+ assert called["cmd"] == ["docker", "rm", "-f", "c1"]
File without changes
File without changes
File without changes