clonebox 0.1.18__py3-none-any.whl → 0.1.20__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 +1 -1
- clonebox/cli.py +196 -74
- clonebox/cloner.py +5 -2
- clonebox/dashboard.py +133 -0
- clonebox/detector.py +76 -20
- clonebox/profiles.py +66 -0
- clonebox/templates/profiles/ml-dev.yaml +6 -0
- clonebox/validator.py +161 -26
- {clonebox-0.1.18.dist-info → clonebox-0.1.20.dist-info}/METADATA +4 -1
- clonebox-0.1.20.dist-info/RECORD +17 -0
- clonebox-0.1.18.dist-info/RECORD +0 -14
- {clonebox-0.1.18.dist-info → clonebox-0.1.20.dist-info}/WHEEL +0 -0
- {clonebox-0.1.18.dist-info → clonebox-0.1.20.dist-info}/entry_points.txt +0 -0
- {clonebox-0.1.18.dist-info → clonebox-0.1.20.dist-info}/licenses/LICENSE +0 -0
- {clonebox-0.1.18.dist-info → clonebox-0.1.20.dist-info}/top_level.txt +0 -0
clonebox/__init__.py
CHANGED
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(
|
|
@@ -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
|
|
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
|
|
@@ -905,15 +920,16 @@ def cmd_delete(args):
|
|
|
905
920
|
console.print("[yellow]Cancelled.[/]")
|
|
906
921
|
return
|
|
907
922
|
|
|
908
|
-
cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
|
|
909
|
-
cloner.delete_vm(name, delete_storage=not args.keep_storage, console=console)
|
|
910
|
-
|
|
911
923
|
|
|
912
924
|
def cmd_list(args):
|
|
913
925
|
"""List all VMs."""
|
|
914
926
|
cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
|
|
915
927
|
vms = cloner.list_vms()
|
|
916
928
|
|
|
929
|
+
if getattr(args, "json", False):
|
|
930
|
+
print(json.dumps(vms, indent=2))
|
|
931
|
+
return
|
|
932
|
+
|
|
917
933
|
if not vms:
|
|
918
934
|
console.print("[dim]No VMs found.[/]")
|
|
919
935
|
return
|
|
@@ -932,14 +948,6 @@ def cmd_list(args):
|
|
|
932
948
|
|
|
933
949
|
def cmd_container_up(args):
|
|
934
950
|
"""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
951
|
mounts = {}
|
|
944
952
|
for m in getattr(args, "mount", []) or []:
|
|
945
953
|
if ":" not in m:
|
|
@@ -959,21 +967,21 @@ def cmd_container_up(args):
|
|
|
959
967
|
if getattr(args, "name", None):
|
|
960
968
|
cfg_kwargs["name"] = args.name
|
|
961
969
|
|
|
970
|
+
profile_name = getattr(args, "profile", None)
|
|
971
|
+
if profile_name:
|
|
972
|
+
merged = merge_with_profile({"container": cfg_kwargs}, profile_name)
|
|
973
|
+
if isinstance(merged, dict) and isinstance(merged.get("container"), dict):
|
|
974
|
+
cfg_kwargs = merged["container"]
|
|
975
|
+
|
|
962
976
|
cfg = ContainerConfig(**cfg_kwargs)
|
|
963
977
|
|
|
964
978
|
cloner = ContainerCloner(engine=cfg.engine)
|
|
965
|
-
|
|
979
|
+
detach = getattr(args, "detach", False)
|
|
980
|
+
cloner.up(cfg, detach=detach, remove=not detach)
|
|
966
981
|
|
|
967
982
|
|
|
968
983
|
def cmd_container_ps(args):
|
|
969
984
|
"""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
985
|
cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
|
|
978
986
|
items = cloner.ps(all=getattr(args, "all", False))
|
|
979
987
|
|
|
@@ -1004,44 +1012,36 @@ def cmd_container_ps(args):
|
|
|
1004
1012
|
|
|
1005
1013
|
def cmd_container_stop(args):
|
|
1006
1014
|
"""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
1015
|
cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
|
|
1015
1016
|
cloner.stop(args.name)
|
|
1016
1017
|
|
|
1017
1018
|
|
|
1018
1019
|
def cmd_container_rm(args):
|
|
1019
1020
|
"""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
1021
|
cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
|
|
1028
1022
|
cloner.rm(args.name, force=getattr(args, "force", False))
|
|
1029
1023
|
|
|
1030
1024
|
|
|
1031
1025
|
def cmd_container_down(args):
|
|
1032
1026
|
"""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
1027
|
cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
|
|
1041
1028
|
cloner.stop(args.name)
|
|
1042
1029
|
cloner.rm(args.name, force=True)
|
|
1043
1030
|
|
|
1044
1031
|
|
|
1032
|
+
def cmd_dashboard(args):
|
|
1033
|
+
"""Run the local CloneBox dashboard."""
|
|
1034
|
+
try:
|
|
1035
|
+
from clonebox.dashboard import run_dashboard
|
|
1036
|
+
except Exception as e:
|
|
1037
|
+
console.print("[red]❌ Dashboard dependencies are not installed.[/]")
|
|
1038
|
+
console.print("[dim]Install with: pip install 'clonebox[dashboard]'[/]")
|
|
1039
|
+
console.print(f"[dim]{e}[/]")
|
|
1040
|
+
return
|
|
1041
|
+
|
|
1042
|
+
run_dashboard(port=getattr(args, "port", 8080))
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
1045
|
def cmd_diagnose(args):
|
|
1046
1046
|
"""Run detailed VM diagnostics (standalone)."""
|
|
1047
1047
|
name = args.name
|
|
@@ -1521,7 +1521,23 @@ def cmd_test(args):
|
|
|
1521
1521
|
if '192.168' in line or '10.0' in line:
|
|
1522
1522
|
console.print(f" IP: {line.split()[-1]}")
|
|
1523
1523
|
else:
|
|
1524
|
-
console.print("[yellow]⚠️ No IP address detected[/]")
|
|
1524
|
+
console.print("[yellow]⚠️ No IP address detected via virsh domifaddr[/]")
|
|
1525
|
+
# Fallback: try to get IP via QEMU Guest Agent (useful for slirp/user networking)
|
|
1526
|
+
try:
|
|
1527
|
+
from .cli import _qga_ping, _qga_exec
|
|
1528
|
+
except ImportError:
|
|
1529
|
+
from clonebox.cli import _qga_ping, _qga_exec
|
|
1530
|
+
if _qga_ping(vm_name, conn_uri):
|
|
1531
|
+
try:
|
|
1532
|
+
ip_out = _qga_exec(vm_name, conn_uri, "ip -4 -o addr show scope global | awk '{print $4}'", timeout=5)
|
|
1533
|
+
if ip_out and ip_out.strip():
|
|
1534
|
+
console.print(f"[green]✅ VM has network access (IP via QGA: {ip_out.strip()})[/]")
|
|
1535
|
+
else:
|
|
1536
|
+
console.print("[yellow]⚠️ IP not available via QGA[/]")
|
|
1537
|
+
except Exception as e:
|
|
1538
|
+
console.print(f"[yellow]⚠️ Could not get IP via QGA ({e})[/]")
|
|
1539
|
+
else:
|
|
1540
|
+
console.print("[dim]IP: QEMU Guest Agent not connected[/]")
|
|
1525
1541
|
except:
|
|
1526
1542
|
console.print("[yellow]⚠️ Could not check network[/]")
|
|
1527
1543
|
else:
|
|
@@ -1582,34 +1598,13 @@ def cmd_test(args):
|
|
|
1582
1598
|
if all_paths:
|
|
1583
1599
|
for idx, (host_path, guest_path) in enumerate(all_paths.items()):
|
|
1584
1600
|
try:
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
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:
|
|
1601
|
+
# Use the same QGA helper as diagnose/status
|
|
1602
|
+
is_accessible = _qga_exec(vm_name, conn_uri, f"test -d {guest_path} && echo yes || echo no", timeout=5)
|
|
1603
|
+
if is_accessible == "yes":
|
|
1604
|
+
console.print(f"[green]✅ {guest_path}[/]")
|
|
1605
|
+
else:
|
|
1606
|
+
console.print(f"[red]❌ {guest_path} (not accessible)[/]")
|
|
1607
|
+
except Exception:
|
|
1613
1608
|
console.print(f"[yellow]⚠️ {guest_path} (could not check)[/]")
|
|
1614
1609
|
else:
|
|
1615
1610
|
console.print("[dim]No mount points configured[/]")
|
|
@@ -1717,8 +1712,18 @@ def generate_clonebox_yaml(
|
|
|
1717
1712
|
"""Generate YAML config from system snapshot."""
|
|
1718
1713
|
sys_info = detector.get_system_info()
|
|
1719
1714
|
|
|
1720
|
-
#
|
|
1721
|
-
|
|
1715
|
+
# Services that should NOT be cloned to VM (host-specific)
|
|
1716
|
+
VM_EXCLUDED_SERVICES = {
|
|
1717
|
+
"libvirtd", "virtlogd", "libvirt-guests", "qemu-guest-agent",
|
|
1718
|
+
"bluetooth", "bluez", "upower", "thermald", "tlp", "power-profiles-daemon",
|
|
1719
|
+
"gdm", "gdm3", "sddm", "lightdm",
|
|
1720
|
+
"snap.cups.cups-browsed", "snap.cups.cupsd",
|
|
1721
|
+
"ModemManager", "wpa_supplicant",
|
|
1722
|
+
"accounts-daemon", "colord", "switcheroo-control",
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
# Collect services (excluding host-specific ones)
|
|
1726
|
+
services = [s.name for s in snapshot.running_services if s.name not in VM_EXCLUDED_SERVICES]
|
|
1722
1727
|
if deduplicate:
|
|
1723
1728
|
services = deduplicate_list(services)
|
|
1724
1729
|
|
|
@@ -1782,6 +1787,21 @@ def generate_clonebox_yaml(
|
|
|
1782
1787
|
guest_path = f"/home/ubuntu/{rel_path}"
|
|
1783
1788
|
app_data_mapping[host_path] = guest_path
|
|
1784
1789
|
|
|
1790
|
+
post_commands = []
|
|
1791
|
+
|
|
1792
|
+
chrome_profile = home_dir / ".config" / "google-chrome"
|
|
1793
|
+
if chrome_profile.exists():
|
|
1794
|
+
host_path = str(chrome_profile)
|
|
1795
|
+
if host_path not in paths_mapping and host_path not in app_data_mapping:
|
|
1796
|
+
app_data_mapping[host_path] = "/home/ubuntu/.config/google-chrome"
|
|
1797
|
+
|
|
1798
|
+
post_commands.append(
|
|
1799
|
+
"command -v google-chrome >/dev/null 2>&1 || ("
|
|
1800
|
+
"curl -fsSL -o /tmp/google-chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && "
|
|
1801
|
+
"apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y /tmp/google-chrome.deb"
|
|
1802
|
+
")"
|
|
1803
|
+
)
|
|
1804
|
+
|
|
1785
1805
|
# Determine VM name
|
|
1786
1806
|
if not vm_name:
|
|
1787
1807
|
if target_path:
|
|
@@ -1815,6 +1835,34 @@ def generate_clonebox_yaml(
|
|
|
1815
1835
|
if deduplicate:
|
|
1816
1836
|
all_snap_packages = deduplicate_list(all_snap_packages)
|
|
1817
1837
|
|
|
1838
|
+
if chrome_profile.exists() and "google-chrome" not in [d.get("app", "") for d in app_data_dirs]:
|
|
1839
|
+
if "chromium" not in all_snap_packages:
|
|
1840
|
+
all_snap_packages.append("chromium")
|
|
1841
|
+
|
|
1842
|
+
if "pycharm-community" in all_snap_packages:
|
|
1843
|
+
remapped = {}
|
|
1844
|
+
for host_path, guest_path in app_data_mapping.items():
|
|
1845
|
+
if guest_path == "/home/ubuntu/.config/JetBrains":
|
|
1846
|
+
remapped[host_path] = "/home/ubuntu/snap/pycharm-community/common/.config/JetBrains"
|
|
1847
|
+
elif guest_path == "/home/ubuntu/.local/share/JetBrains":
|
|
1848
|
+
remapped[host_path] = "/home/ubuntu/snap/pycharm-community/common/.local/share/JetBrains"
|
|
1849
|
+
elif guest_path == "/home/ubuntu/.cache/JetBrains":
|
|
1850
|
+
remapped[host_path] = "/home/ubuntu/snap/pycharm-community/common/.cache/JetBrains"
|
|
1851
|
+
else:
|
|
1852
|
+
remapped[host_path] = guest_path
|
|
1853
|
+
app_data_mapping = remapped
|
|
1854
|
+
|
|
1855
|
+
if "firefox" in all_apt_packages:
|
|
1856
|
+
remapped = {}
|
|
1857
|
+
for host_path, guest_path in app_data_mapping.items():
|
|
1858
|
+
if guest_path == "/home/ubuntu/.mozilla/firefox":
|
|
1859
|
+
remapped[host_path] = "/home/ubuntu/snap/firefox/common/.mozilla/firefox"
|
|
1860
|
+
elif guest_path == "/home/ubuntu/.cache/mozilla/firefox":
|
|
1861
|
+
remapped[host_path] = "/home/ubuntu/snap/firefox/common/.cache/mozilla/firefox"
|
|
1862
|
+
else:
|
|
1863
|
+
remapped[host_path] = guest_path
|
|
1864
|
+
app_data_mapping = remapped
|
|
1865
|
+
|
|
1818
1866
|
# Build config
|
|
1819
1867
|
config = {
|
|
1820
1868
|
"version": "1",
|
|
@@ -1832,7 +1880,7 @@ def generate_clonebox_yaml(
|
|
|
1832
1880
|
"services": services,
|
|
1833
1881
|
"packages": all_apt_packages,
|
|
1834
1882
|
"snap_packages": all_snap_packages,
|
|
1835
|
-
"post_commands":
|
|
1883
|
+
"post_commands": post_commands,
|
|
1836
1884
|
"paths": paths_mapping,
|
|
1837
1885
|
"app_data_paths": app_data_mapping, # App-specific config/data directories
|
|
1838
1886
|
"detected": {
|
|
@@ -2057,6 +2105,32 @@ def cmd_clone(args):
|
|
|
2057
2105
|
base_image=getattr(args, "base_image", None),
|
|
2058
2106
|
)
|
|
2059
2107
|
|
|
2108
|
+
profile_name = getattr(args, "profile", None)
|
|
2109
|
+
if profile_name:
|
|
2110
|
+
merged_config = merge_with_profile(yaml.safe_load(yaml_content), profile_name)
|
|
2111
|
+
if isinstance(merged_config, dict):
|
|
2112
|
+
vm_section = merged_config.get("vm")
|
|
2113
|
+
if isinstance(vm_section, dict):
|
|
2114
|
+
vm_packages = vm_section.pop("packages", None)
|
|
2115
|
+
if isinstance(vm_packages, list):
|
|
2116
|
+
packages = merged_config.get("packages")
|
|
2117
|
+
if not isinstance(packages, list):
|
|
2118
|
+
packages = []
|
|
2119
|
+
for p in vm_packages:
|
|
2120
|
+
if p not in packages:
|
|
2121
|
+
packages.append(p)
|
|
2122
|
+
merged_config["packages"] = packages
|
|
2123
|
+
|
|
2124
|
+
if "container" in merged_config:
|
|
2125
|
+
merged_config.pop("container", None)
|
|
2126
|
+
|
|
2127
|
+
yaml_content = yaml.dump(
|
|
2128
|
+
merged_config,
|
|
2129
|
+
default_flow_style=False,
|
|
2130
|
+
allow_unicode=True,
|
|
2131
|
+
sort_keys=False,
|
|
2132
|
+
)
|
|
2133
|
+
|
|
2060
2134
|
# Dry run - show what would be created and exit
|
|
2061
2135
|
if dry_run:
|
|
2062
2136
|
config = yaml.safe_load(yaml_content)
|
|
@@ -2337,6 +2411,7 @@ def main():
|
|
|
2337
2411
|
action="store_true",
|
|
2338
2412
|
help="Use user session (qemu:///session) - no root required",
|
|
2339
2413
|
)
|
|
2414
|
+
list_parser.add_argument("--json", action="store_true", help="Output JSON")
|
|
2340
2415
|
list_parser.set_defaults(func=cmd_list)
|
|
2341
2416
|
|
|
2342
2417
|
# Container command
|
|
@@ -2347,13 +2422,24 @@ def main():
|
|
|
2347
2422
|
default="auto",
|
|
2348
2423
|
help="Container engine: auto (default), podman, docker",
|
|
2349
2424
|
)
|
|
2425
|
+
container_parser.set_defaults(func=lambda args, p=container_parser: p.print_help())
|
|
2350
2426
|
container_sub = container_parser.add_subparsers(dest="container_command", help="Container commands")
|
|
2351
2427
|
|
|
2352
2428
|
container_up = container_sub.add_parser("up", help="Start container")
|
|
2429
|
+
container_up.add_argument(
|
|
2430
|
+
"--engine",
|
|
2431
|
+
choices=["auto", "podman", "docker"],
|
|
2432
|
+
default=argparse.SUPPRESS,
|
|
2433
|
+
help="Container engine: auto (default), podman, docker",
|
|
2434
|
+
)
|
|
2353
2435
|
container_up.add_argument("path", nargs="?", default=".", help="Workspace path")
|
|
2354
2436
|
container_up.add_argument("--name", help="Container name")
|
|
2355
2437
|
container_up.add_argument("--image", default="ubuntu:22.04", help="Container image")
|
|
2356
2438
|
container_up.add_argument("--detach", action="store_true", help="Run container in background")
|
|
2439
|
+
container_up.add_argument(
|
|
2440
|
+
"--profile",
|
|
2441
|
+
help="Profile name (loads ~/.clonebox.d/<name>.yaml, .clonebox.d/<name>.yaml, or built-in templates)",
|
|
2442
|
+
)
|
|
2357
2443
|
container_up.add_argument(
|
|
2358
2444
|
"--mount",
|
|
2359
2445
|
action="append",
|
|
@@ -2380,23 +2466,52 @@ def main():
|
|
|
2380
2466
|
container_up.set_defaults(func=cmd_container_up)
|
|
2381
2467
|
|
|
2382
2468
|
container_ps = container_sub.add_parser("ps", aliases=["ls"], help="List containers")
|
|
2469
|
+
container_ps.add_argument(
|
|
2470
|
+
"--engine",
|
|
2471
|
+
choices=["auto", "podman", "docker"],
|
|
2472
|
+
default=argparse.SUPPRESS,
|
|
2473
|
+
help="Container engine: auto (default), podman, docker",
|
|
2474
|
+
)
|
|
2383
2475
|
container_ps.add_argument("-a", "--all", action="store_true", help="Show all containers")
|
|
2384
2476
|
container_ps.add_argument("--json", action="store_true", help="Output JSON")
|
|
2385
2477
|
container_ps.set_defaults(func=cmd_container_ps)
|
|
2386
2478
|
|
|
2387
2479
|
container_stop = container_sub.add_parser("stop", help="Stop container")
|
|
2480
|
+
container_stop.add_argument(
|
|
2481
|
+
"--engine",
|
|
2482
|
+
choices=["auto", "podman", "docker"],
|
|
2483
|
+
default=argparse.SUPPRESS,
|
|
2484
|
+
help="Container engine: auto (default), podman, docker",
|
|
2485
|
+
)
|
|
2388
2486
|
container_stop.add_argument("name", help="Container name")
|
|
2389
2487
|
container_stop.set_defaults(func=cmd_container_stop)
|
|
2390
2488
|
|
|
2391
2489
|
container_rm = container_sub.add_parser("rm", help="Remove container")
|
|
2490
|
+
container_rm.add_argument(
|
|
2491
|
+
"--engine",
|
|
2492
|
+
choices=["auto", "podman", "docker"],
|
|
2493
|
+
default=argparse.SUPPRESS,
|
|
2494
|
+
help="Container engine: auto (default), podman, docker",
|
|
2495
|
+
)
|
|
2392
2496
|
container_rm.add_argument("name", help="Container name")
|
|
2393
2497
|
container_rm.add_argument("-f", "--force", action="store_true", help="Force remove")
|
|
2394
2498
|
container_rm.set_defaults(func=cmd_container_rm)
|
|
2395
2499
|
|
|
2396
2500
|
container_down = container_sub.add_parser("down", help="Stop and remove container")
|
|
2501
|
+
container_down.add_argument(
|
|
2502
|
+
"--engine",
|
|
2503
|
+
choices=["auto", "podman", "docker"],
|
|
2504
|
+
default=argparse.SUPPRESS,
|
|
2505
|
+
help="Container engine: auto (default), podman, docker",
|
|
2506
|
+
)
|
|
2397
2507
|
container_down.add_argument("name", help="Container name")
|
|
2398
2508
|
container_down.set_defaults(func=cmd_container_down)
|
|
2399
2509
|
|
|
2510
|
+
# Dashboard command
|
|
2511
|
+
dashboard_parser = subparsers.add_parser("dashboard", help="Run local dashboard")
|
|
2512
|
+
dashboard_parser.add_argument("--port", type=int, default=8080, help="Port to bind (default: 8080)")
|
|
2513
|
+
dashboard_parser.set_defaults(func=cmd_dashboard)
|
|
2514
|
+
|
|
2400
2515
|
# Detect command
|
|
2401
2516
|
detect_parser = subparsers.add_parser("detect", help="Detect system state")
|
|
2402
2517
|
detect_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
@@ -2436,6 +2551,10 @@ def main():
|
|
|
2436
2551
|
"--base-image",
|
|
2437
2552
|
help="Path to a bootable qcow2 image to use as a base disk",
|
|
2438
2553
|
)
|
|
2554
|
+
clone_parser.add_argument(
|
|
2555
|
+
"--profile",
|
|
2556
|
+
help="Profile name (loads ~/.clonebox.d/<name>.yaml, .clonebox.d/<name>.yaml, or built-in templates)",
|
|
2557
|
+
)
|
|
2439
2558
|
clone_parser.add_argument(
|
|
2440
2559
|
"--replace",
|
|
2441
2560
|
action="store_true",
|
|
@@ -2462,6 +2581,9 @@ def main():
|
|
|
2462
2581
|
status_parser.add_argument(
|
|
2463
2582
|
"--health", "-H", action="store_true", help="Run full health check"
|
|
2464
2583
|
)
|
|
2584
|
+
status_parser.add_argument(
|
|
2585
|
+
"--verbose", "-v", action="store_true", help="Show detailed diagnostics (QGA, stderr, etc.)"
|
|
2586
|
+
)
|
|
2465
2587
|
status_parser.set_defaults(func=cmd_status)
|
|
2466
2588
|
|
|
2467
2589
|
# 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
|
-
|
|
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
|
|
@@ -695,6 +696,8 @@ fi
|
|
|
695
696
|
runcmd_lines = []
|
|
696
697
|
|
|
697
698
|
runcmd_lines.append(" - systemctl enable --now qemu-guest-agent || true")
|
|
699
|
+
runcmd_lines.append(" - systemctl enable --now snapd || true")
|
|
700
|
+
runcmd_lines.append(" - timeout 300 snap wait system seed.loaded || true")
|
|
698
701
|
|
|
699
702
|
# Add service enablement
|
|
700
703
|
for svc in config.services:
|
|
@@ -759,7 +762,7 @@ users:
|
|
|
759
762
|
sudo: ALL=(ALL) NOPASSWD:ALL
|
|
760
763
|
shell: /bin/bash
|
|
761
764
|
lock_passwd: false
|
|
762
|
-
groups: sudo,adm,dialout,cdrom,floppy,audio,dip,video,plugdev,netdev
|
|
765
|
+
groups: sudo,adm,dialout,cdrom,floppy,audio,dip,video,plugdev,netdev,docker
|
|
763
766
|
plain_text_passwd: {config.password}
|
|
764
767
|
|
|
765
768
|
# Allow password authentication
|
clonebox/dashboard.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any, List
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
8
|
+
|
|
9
|
+
app = FastAPI(title="CloneBox Dashboard")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _run_clonebox(args: List[str]) -> subprocess.CompletedProcess:
|
|
13
|
+
return subprocess.run(
|
|
14
|
+
[sys.executable, "-m", "clonebox"] + args,
|
|
15
|
+
capture_output=True,
|
|
16
|
+
text=True,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _render_table(title: str, headers: List[str], rows: List[List[str]]) -> str:
|
|
21
|
+
head_html = "".join(f"<th>{h}</th>" for h in headers)
|
|
22
|
+
body_html = "".join(
|
|
23
|
+
"<tr>" + "".join(f"<td>{c}</td>" for c in row) + "</tr>" for row in rows
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
f"<h2>{title}</h2>"
|
|
28
|
+
"<table>"
|
|
29
|
+
f"<thead><tr>{head_html}</tr></thead>"
|
|
30
|
+
f"<tbody>{body_html}</tbody>"
|
|
31
|
+
"</table>"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.get("/", response_class=HTMLResponse)
|
|
36
|
+
async def dashboard() -> str:
|
|
37
|
+
return """
|
|
38
|
+
<!DOCTYPE html>
|
|
39
|
+
<html>
|
|
40
|
+
<head>
|
|
41
|
+
<title>CloneBox Dashboard</title>
|
|
42
|
+
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
43
|
+
<style>
|
|
44
|
+
body { font-family: system-ui, -apple-system, sans-serif; margin: 20px; }
|
|
45
|
+
table { border-collapse: collapse; width: 100%; margin-bottom: 24px; }
|
|
46
|
+
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
|
47
|
+
th { background: #f6f6f6; }
|
|
48
|
+
code { background: #f6f6f6; padding: 2px 4px; border-radius: 4px; }
|
|
49
|
+
</style>
|
|
50
|
+
</head>
|
|
51
|
+
<body>
|
|
52
|
+
<h1>CloneBox Dashboard</h1>
|
|
53
|
+
<p>Auto-refresh every 5s. JSON endpoints: <code>/api/vms.json</code>, <code>/api/containers.json</code></p>
|
|
54
|
+
|
|
55
|
+
<div id="vms" hx-get="/api/vms" hx-trigger="load, every 5s">Loading VMs...</div>
|
|
56
|
+
<div id="containers" hx-get="/api/containers" hx-trigger="load, every 5s">Loading containers...</div>
|
|
57
|
+
</body>
|
|
58
|
+
</html>
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.get("/api/vms", response_class=HTMLResponse)
|
|
63
|
+
async def api_vms() -> str:
|
|
64
|
+
proc = _run_clonebox(["list", "--json"])
|
|
65
|
+
if proc.returncode != 0:
|
|
66
|
+
return f"<pre>clonebox list failed:\n{proc.stderr}</pre>"
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
items: List[dict[str, Any]] = json.loads(proc.stdout or "[]")
|
|
70
|
+
except json.JSONDecodeError:
|
|
71
|
+
return f"<pre>Invalid JSON from clonebox list:\n{proc.stdout}</pre>"
|
|
72
|
+
|
|
73
|
+
if not items:
|
|
74
|
+
return "<h2>VMs</h2><p><em>No VMs found.</em></p>"
|
|
75
|
+
|
|
76
|
+
rows = [[str(i.get("name", "")), str(i.get("state", "")), str(i.get("uuid", ""))] for i in items]
|
|
77
|
+
return _render_table("VMs", ["Name", "State", "UUID"], rows)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.get("/api/containers", response_class=HTMLResponse)
|
|
81
|
+
async def api_containers() -> str:
|
|
82
|
+
proc = _run_clonebox(["container", "ps", "--json", "-a"])
|
|
83
|
+
if proc.returncode != 0:
|
|
84
|
+
return f"<pre>clonebox container ps failed:\n{proc.stderr}</pre>"
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
items: List[dict[str, Any]] = json.loads(proc.stdout or "[]")
|
|
88
|
+
except json.JSONDecodeError:
|
|
89
|
+
return f"<pre>Invalid JSON from clonebox container ps:\n{proc.stdout}</pre>"
|
|
90
|
+
|
|
91
|
+
if not items:
|
|
92
|
+
return "<h2>Containers</h2><p><em>No containers found.</em></p>"
|
|
93
|
+
|
|
94
|
+
rows = [
|
|
95
|
+
[
|
|
96
|
+
str(i.get("name", "")),
|
|
97
|
+
str(i.get("image", "")),
|
|
98
|
+
str(i.get("status", "")),
|
|
99
|
+
str(i.get("ports", "")),
|
|
100
|
+
]
|
|
101
|
+
for i in items
|
|
102
|
+
]
|
|
103
|
+
return _render_table("Containers", ["Name", "Image", "Status", "Ports"], rows)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.get("/api/vms.json")
|
|
107
|
+
async def api_vms_json() -> JSONResponse:
|
|
108
|
+
proc = _run_clonebox(["list", "--json"])
|
|
109
|
+
if proc.returncode != 0:
|
|
110
|
+
return JSONResponse({"error": proc.stderr, "stdout": proc.stdout}, status_code=500)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
return JSONResponse(json.loads(proc.stdout or "[]"))
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
return JSONResponse({"error": "invalid_json", "stdout": proc.stdout}, status_code=500)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.get("/api/containers.json")
|
|
119
|
+
async def api_containers_json() -> JSONResponse:
|
|
120
|
+
proc = _run_clonebox(["container", "ps", "--json", "-a"])
|
|
121
|
+
if proc.returncode != 0:
|
|
122
|
+
return JSONResponse({"error": proc.stderr, "stdout": proc.stdout}, status_code=500)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
return JSONResponse(json.loads(proc.stdout or "[]"))
|
|
126
|
+
except json.JSONDecodeError:
|
|
127
|
+
return JSONResponse({"error": "invalid_json", "stdout": proc.stdout}, status_code=500)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run_dashboard(port: int = 8080) -> None:
|
|
131
|
+
import uvicorn
|
|
132
|
+
|
|
133
|
+
uvicorn.run(app, host="127.0.0.1", port=port)
|
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",
|
|
@@ -244,12 +275,24 @@ class SystemDetector:
|
|
|
244
275
|
# Browsers - profiles, extensions, bookmarks, passwords
|
|
245
276
|
"chrome": [".config/google-chrome", ".config/chromium"],
|
|
246
277
|
"chromium": [".config/chromium"],
|
|
247
|
-
"firefox": [
|
|
278
|
+
"firefox": [
|
|
279
|
+
"snap/firefox/common/.mozilla/firefox",
|
|
280
|
+
"snap/firefox/common/.cache/mozilla/firefox",
|
|
281
|
+
".mozilla/firefox",
|
|
282
|
+
".cache/mozilla/firefox",
|
|
283
|
+
],
|
|
248
284
|
|
|
249
285
|
# IDEs and editors - settings, extensions, projects history
|
|
250
286
|
"code": [".config/Code", ".vscode", ".vscode-server"],
|
|
251
287
|
"vscode": [".config/Code", ".vscode", ".vscode-server"],
|
|
252
|
-
"pycharm": [
|
|
288
|
+
"pycharm": [
|
|
289
|
+
"snap/pycharm-community/common/.config/JetBrains",
|
|
290
|
+
"snap/pycharm-community/common/.local/share/JetBrains",
|
|
291
|
+
"snap/pycharm-community/common/.cache/JetBrains",
|
|
292
|
+
".config/JetBrains",
|
|
293
|
+
".local/share/JetBrains",
|
|
294
|
+
".cache/JetBrains",
|
|
295
|
+
],
|
|
253
296
|
"idea": [".config/JetBrains", ".local/share/JetBrains"],
|
|
254
297
|
"webstorm": [".config/JetBrains", ".local/share/JetBrains"],
|
|
255
298
|
"goland": [".config/JetBrains", ".local/share/JetBrains"],
|
|
@@ -323,27 +366,40 @@ class SystemDetector:
|
|
|
323
366
|
app_data_paths = []
|
|
324
367
|
seen_paths = set()
|
|
325
368
|
|
|
369
|
+
matched_patterns = set()
|
|
370
|
+
|
|
326
371
|
for app in applications:
|
|
327
372
|
app_name = app.name.lower()
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
for pattern, dirs in self.APP_DATA_DIRS.items():
|
|
373
|
+
|
|
374
|
+
for pattern in self.APP_DATA_DIRS:
|
|
331
375
|
if pattern in app_name:
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
376
|
+
matched_patterns.add(pattern)
|
|
377
|
+
|
|
378
|
+
for pattern in ("firefox", "chrome", "chromium", "pycharm"):
|
|
379
|
+
matched_patterns.add(pattern)
|
|
380
|
+
|
|
381
|
+
for pattern in sorted(matched_patterns):
|
|
382
|
+
dirs = self.APP_DATA_DIRS.get(pattern, [])
|
|
383
|
+
if not dirs:
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
snap_dirs = [d for d in dirs if d.startswith("snap/")]
|
|
387
|
+
preferred_dirs = snap_dirs if any((self.home / d).exists() for d in snap_dirs) else dirs
|
|
388
|
+
|
|
389
|
+
for dir_name in preferred_dirs:
|
|
390
|
+
full_path = self.home / dir_name
|
|
391
|
+
if full_path.exists() and str(full_path) not in seen_paths:
|
|
392
|
+
seen_paths.add(str(full_path))
|
|
393
|
+
try:
|
|
394
|
+
size = self._get_dir_size(full_path, max_depth=2)
|
|
395
|
+
except Exception:
|
|
396
|
+
size = 0
|
|
397
|
+
app_data_paths.append({
|
|
398
|
+
"path": str(full_path),
|
|
399
|
+
"app": pattern,
|
|
400
|
+
"type": "app_data",
|
|
401
|
+
"size_mb": round(size / 1024 / 1024, 1),
|
|
402
|
+
})
|
|
347
403
|
|
|
348
404
|
return app_data_paths
|
|
349
405
|
|
clonebox/profiles.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
search_paths = search_paths or []
|
|
11
|
+
|
|
12
|
+
profile_paths = [
|
|
13
|
+
Path.home() / ".clonebox.d" / f"{profile_name}.yaml",
|
|
14
|
+
Path.cwd() / ".clonebox.d" / f"{profile_name}.yaml",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
for base in search_paths:
|
|
18
|
+
profile_paths.insert(0, base / "templates" / "profiles" / f"{profile_name}.yaml")
|
|
19
|
+
profile_paths.insert(0, base / f"{profile_name}.yaml")
|
|
20
|
+
|
|
21
|
+
for profile_path in profile_paths:
|
|
22
|
+
if profile_path.exists():
|
|
23
|
+
return yaml.safe_load(profile_path.read_text())
|
|
24
|
+
|
|
25
|
+
data = pkgutil.get_data("clonebox", f"templates/profiles/{profile_name}.yaml")
|
|
26
|
+
if data is not None:
|
|
27
|
+
return yaml.safe_load(data.decode())
|
|
28
|
+
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
|
33
|
+
merged: Dict[str, Any] = dict(base)
|
|
34
|
+
for key, value in override.items():
|
|
35
|
+
if (
|
|
36
|
+
key in merged
|
|
37
|
+
and isinstance(merged[key], dict)
|
|
38
|
+
and isinstance(value, dict)
|
|
39
|
+
):
|
|
40
|
+
merged[key] = _deep_merge(merged[key], value)
|
|
41
|
+
else:
|
|
42
|
+
merged[key] = value
|
|
43
|
+
return merged
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def merge_with_profile(
|
|
47
|
+
base_config: Dict[str, Any],
|
|
48
|
+
profile_name: Optional[str] = None,
|
|
49
|
+
*,
|
|
50
|
+
profile: Optional[Dict[str, Any]] = None,
|
|
51
|
+
search_paths: Optional[list[Path]] = None,
|
|
52
|
+
) -> Dict[str, Any]:
|
|
53
|
+
"""Merge profile OVER base config (profile wins)."""
|
|
54
|
+
if profile is not None:
|
|
55
|
+
if not isinstance(profile, dict):
|
|
56
|
+
return base_config
|
|
57
|
+
return _deep_merge(base_config, profile)
|
|
58
|
+
|
|
59
|
+
if not profile_name:
|
|
60
|
+
return base_config
|
|
61
|
+
|
|
62
|
+
loaded = load_profile(profile_name, search_paths or [])
|
|
63
|
+
if not loaded or not isinstance(loaded, dict):
|
|
64
|
+
return base_config
|
|
65
|
+
|
|
66
|
+
return _deep_merge(base_config, loaded)
|
clonebox/validator.py
CHANGED
|
@@ -24,6 +24,7 @@ class VMValidator:
|
|
|
24
24
|
"packages": {"passed": 0, "failed": 0, "total": 0, "details": []},
|
|
25
25
|
"snap_packages": {"passed": 0, "failed": 0, "total": 0, "details": []},
|
|
26
26
|
"services": {"passed": 0, "failed": 0, "total": 0, "details": []},
|
|
27
|
+
"apps": {"passed": 0, "failed": 0, "total": 0, "details": []},
|
|
27
28
|
"overall": "unknown"
|
|
28
29
|
}
|
|
29
30
|
|
|
@@ -236,6 +237,16 @@ class VMValidator:
|
|
|
236
237
|
|
|
237
238
|
return self.results["snap_packages"]
|
|
238
239
|
|
|
240
|
+
# Services that should NOT be validated in VM (host-specific)
|
|
241
|
+
VM_EXCLUDED_SERVICES = {
|
|
242
|
+
"libvirtd", "virtlogd", "libvirt-guests", "qemu-guest-agent",
|
|
243
|
+
"bluetooth", "bluez", "upower", "thermald", "tlp", "power-profiles-daemon",
|
|
244
|
+
"gdm", "gdm3", "sddm", "lightdm",
|
|
245
|
+
"snap.cups.cups-browsed", "snap.cups.cupsd",
|
|
246
|
+
"ModemManager", "wpa_supplicant",
|
|
247
|
+
"accounts-daemon", "colord", "switcheroo-control",
|
|
248
|
+
}
|
|
249
|
+
|
|
239
250
|
def validate_services(self) -> Dict:
|
|
240
251
|
"""Validate services are enabled and running."""
|
|
241
252
|
self.console.print("\n[bold]⚙️ Validating Services...[/]")
|
|
@@ -244,45 +255,155 @@ class VMValidator:
|
|
|
244
255
|
if not services:
|
|
245
256
|
self.console.print("[dim]No services configured[/]")
|
|
246
257
|
return self.results["services"]
|
|
247
|
-
|
|
258
|
+
|
|
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")
|
|
252
|
-
|
|
266
|
+
svc_table.add_column("Note", style="dim")
|
|
267
|
+
|
|
253
268
|
for service in services:
|
|
269
|
+
if service in self.VM_EXCLUDED_SERVICES:
|
|
270
|
+
svc_table.add_row(service, "[dim]—[/]", "[dim]—[/]", "host-only")
|
|
271
|
+
self.results["services"]["skipped"] += 1
|
|
272
|
+
self.results["services"]["details"].append(
|
|
273
|
+
{
|
|
274
|
+
"service": service,
|
|
275
|
+
"enabled": None,
|
|
276
|
+
"running": None,
|
|
277
|
+
"skipped": True,
|
|
278
|
+
"reason": "host-specific service",
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
continue
|
|
282
|
+
|
|
254
283
|
self.results["services"]["total"] += 1
|
|
255
|
-
|
|
256
|
-
# Check if enabled
|
|
284
|
+
|
|
257
285
|
enabled_cmd = f"systemctl is-enabled {service} 2>/dev/null"
|
|
258
286
|
enabled_status = self._exec_in_vm(enabled_cmd)
|
|
259
287
|
is_enabled = enabled_status == "enabled"
|
|
260
|
-
|
|
261
|
-
# Check if running
|
|
288
|
+
|
|
262
289
|
running_cmd = f"systemctl is-active {service} 2>/dev/null"
|
|
263
290
|
running_status = self._exec_in_vm(running_cmd)
|
|
264
291
|
is_running = running_status == "active"
|
|
265
|
-
|
|
292
|
+
|
|
266
293
|
enabled_icon = "[green]✅[/]" if is_enabled else "[yellow]⚠️[/]"
|
|
267
294
|
running_icon = "[green]✅[/]" if is_running else "[red]❌[/]"
|
|
268
|
-
|
|
269
|
-
svc_table.add_row(service, enabled_icon, running_icon)
|
|
270
|
-
|
|
295
|
+
|
|
296
|
+
svc_table.add_row(service, enabled_icon, running_icon, "")
|
|
297
|
+
|
|
271
298
|
if is_enabled and is_running:
|
|
272
299
|
self.results["services"]["passed"] += 1
|
|
273
300
|
else:
|
|
274
301
|
self.results["services"]["failed"] += 1
|
|
275
|
-
|
|
276
|
-
self.results["services"]["details"].append(
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
302
|
+
|
|
303
|
+
self.results["services"]["details"].append(
|
|
304
|
+
{
|
|
305
|
+
"service": service,
|
|
306
|
+
"enabled": is_enabled,
|
|
307
|
+
"running": is_running,
|
|
308
|
+
"skipped": False,
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
|
|
282
312
|
self.console.print(svc_table)
|
|
283
|
-
self.
|
|
284
|
-
|
|
313
|
+
skipped = self.results["services"].get("skipped", 0)
|
|
314
|
+
msg = f"{self.results['services']['passed']}/{self.results['services']['total']} services active"
|
|
315
|
+
if skipped > 0:
|
|
316
|
+
msg += f" ({skipped} host-only skipped)"
|
|
317
|
+
self.console.print(f"[dim]{msg}[/]")
|
|
318
|
+
|
|
285
319
|
return self.results["services"]
|
|
320
|
+
|
|
321
|
+
def validate_apps(self) -> Dict:
|
|
322
|
+
packages = self.config.get("packages", [])
|
|
323
|
+
snap_packages = self.config.get("snap_packages", [])
|
|
324
|
+
app_data_paths = self.config.get("app_data_paths", {})
|
|
325
|
+
|
|
326
|
+
expected = []
|
|
327
|
+
|
|
328
|
+
if "firefox" in packages:
|
|
329
|
+
expected.append("firefox")
|
|
330
|
+
if "pycharm-community" in snap_packages:
|
|
331
|
+
expected.append("pycharm-community")
|
|
332
|
+
|
|
333
|
+
for _, guest_path in app_data_paths.items():
|
|
334
|
+
if guest_path == "/home/ubuntu/.config/google-chrome":
|
|
335
|
+
expected.append("google-chrome")
|
|
336
|
+
break
|
|
337
|
+
|
|
338
|
+
expected = sorted(set(expected))
|
|
339
|
+
if not expected:
|
|
340
|
+
return self.results["apps"]
|
|
341
|
+
|
|
342
|
+
self.console.print("\n[bold]🧩 Validating Apps...[/]")
|
|
343
|
+
table = Table(title="App Validation", border_style="cyan")
|
|
344
|
+
table.add_column("App", style="bold")
|
|
345
|
+
table.add_column("Installed", justify="center")
|
|
346
|
+
table.add_column("Profile", justify="center")
|
|
347
|
+
|
|
348
|
+
def _check_dir_nonempty(path: str) -> bool:
|
|
349
|
+
out = self._exec_in_vm(
|
|
350
|
+
f"test -d {path} && [ $(ls -A {path} 2>/dev/null | wc -l) -gt 0 ] && echo yes || echo no",
|
|
351
|
+
timeout=10,
|
|
352
|
+
)
|
|
353
|
+
return out == "yes"
|
|
354
|
+
|
|
355
|
+
for app in expected:
|
|
356
|
+
self.results["apps"]["total"] += 1
|
|
357
|
+
installed = False
|
|
358
|
+
profile_ok = False
|
|
359
|
+
|
|
360
|
+
if app == "firefox":
|
|
361
|
+
installed = (
|
|
362
|
+
self._exec_in_vm("command -v firefox >/dev/null 2>&1 && echo yes || echo no")
|
|
363
|
+
== "yes"
|
|
364
|
+
)
|
|
365
|
+
if _check_dir_nonempty("/home/ubuntu/snap/firefox/common/.mozilla/firefox"):
|
|
366
|
+
profile_ok = True
|
|
367
|
+
elif _check_dir_nonempty("/home/ubuntu/.mozilla/firefox"):
|
|
368
|
+
profile_ok = True
|
|
369
|
+
|
|
370
|
+
elif app == "pycharm-community":
|
|
371
|
+
installed = (
|
|
372
|
+
self._exec_in_vm(
|
|
373
|
+
"snap list pycharm-community >/dev/null 2>&1 && echo yes || echo no"
|
|
374
|
+
)
|
|
375
|
+
== "yes"
|
|
376
|
+
)
|
|
377
|
+
profile_ok = _check_dir_nonempty(
|
|
378
|
+
"/home/ubuntu/snap/pycharm-community/common/.config/JetBrains"
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
elif app == "google-chrome":
|
|
382
|
+
installed = (
|
|
383
|
+
self._exec_in_vm(
|
|
384
|
+
"(command -v google-chrome >/dev/null 2>&1 || command -v google-chrome-stable >/dev/null 2>&1) && echo yes || echo no"
|
|
385
|
+
)
|
|
386
|
+
== "yes"
|
|
387
|
+
)
|
|
388
|
+
profile_ok = _check_dir_nonempty("/home/ubuntu/.config/google-chrome")
|
|
389
|
+
|
|
390
|
+
table.add_row(
|
|
391
|
+
app,
|
|
392
|
+
"[green]✅[/]" if installed else "[red]❌[/]",
|
|
393
|
+
"[green]✅[/]" if profile_ok else "[red]❌[/]",
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if installed and profile_ok:
|
|
397
|
+
self.results["apps"]["passed"] += 1
|
|
398
|
+
else:
|
|
399
|
+
self.results["apps"]["failed"] += 1
|
|
400
|
+
|
|
401
|
+
self.results["apps"]["details"].append(
|
|
402
|
+
{"app": app, "installed": installed, "profile": profile_ok}
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
self.console.print(table)
|
|
406
|
+
return self.results["apps"]
|
|
286
407
|
|
|
287
408
|
def validate_all(self) -> Dict:
|
|
288
409
|
"""Run all validations and return comprehensive results."""
|
|
@@ -311,51 +432,65 @@ class VMValidator:
|
|
|
311
432
|
self.validate_packages()
|
|
312
433
|
self.validate_snap_packages()
|
|
313
434
|
self.validate_services()
|
|
435
|
+
self.validate_apps()
|
|
314
436
|
|
|
315
437
|
# Calculate overall status
|
|
316
438
|
total_checks = (
|
|
317
439
|
self.results["mounts"]["total"] +
|
|
318
440
|
self.results["packages"]["total"] +
|
|
319
441
|
self.results["snap_packages"]["total"] +
|
|
320
|
-
self.results["services"]["total"]
|
|
442
|
+
self.results["services"]["total"] +
|
|
443
|
+
self.results["apps"]["total"]
|
|
321
444
|
)
|
|
322
445
|
|
|
323
446
|
total_passed = (
|
|
324
447
|
self.results["mounts"]["passed"] +
|
|
325
448
|
self.results["packages"]["passed"] +
|
|
326
449
|
self.results["snap_packages"]["passed"] +
|
|
327
|
-
self.results["services"]["passed"]
|
|
450
|
+
self.results["services"]["passed"] +
|
|
451
|
+
self.results["apps"]["passed"]
|
|
328
452
|
)
|
|
329
453
|
|
|
330
454
|
total_failed = (
|
|
331
455
|
self.results["mounts"]["failed"] +
|
|
332
456
|
self.results["packages"]["failed"] +
|
|
333
457
|
self.results["snap_packages"]["failed"] +
|
|
334
|
-
self.results["services"]["failed"]
|
|
458
|
+
self.results["services"]["failed"] +
|
|
459
|
+
self.results["apps"]["failed"]
|
|
335
460
|
)
|
|
336
461
|
|
|
462
|
+
# Get skipped services count
|
|
463
|
+
skipped_services = self.results["services"].get("skipped", 0)
|
|
464
|
+
|
|
337
465
|
# Print summary
|
|
338
466
|
self.console.print("\n[bold]📊 Validation Summary[/]")
|
|
339
467
|
summary_table = Table(border_style="cyan")
|
|
340
468
|
summary_table.add_column("Category", style="bold")
|
|
341
469
|
summary_table.add_column("Passed", justify="right", style="green")
|
|
342
470
|
summary_table.add_column("Failed", justify="right", style="red")
|
|
471
|
+
summary_table.add_column("Skipped", justify="right", style="dim")
|
|
343
472
|
summary_table.add_column("Total", justify="right")
|
|
344
473
|
|
|
345
474
|
summary_table.add_row("Mounts", str(self.results["mounts"]["passed"]),
|
|
346
|
-
str(self.results["mounts"]["failed"]),
|
|
475
|
+
str(self.results["mounts"]["failed"]), "—",
|
|
347
476
|
str(self.results["mounts"]["total"]))
|
|
348
477
|
summary_table.add_row("APT Packages", str(self.results["packages"]["passed"]),
|
|
349
|
-
str(self.results["packages"]["failed"]),
|
|
478
|
+
str(self.results["packages"]["failed"]), "—",
|
|
350
479
|
str(self.results["packages"]["total"]))
|
|
351
480
|
summary_table.add_row("Snap Packages", str(self.results["snap_packages"]["passed"]),
|
|
352
|
-
str(self.results["snap_packages"]["failed"]),
|
|
481
|
+
str(self.results["snap_packages"]["failed"]), "—",
|
|
353
482
|
str(self.results["snap_packages"]["total"]))
|
|
354
483
|
summary_table.add_row("Services", str(self.results["services"]["passed"]),
|
|
355
484
|
str(self.results["services"]["failed"]),
|
|
485
|
+
str(skipped_services),
|
|
356
486
|
str(self.results["services"]["total"]))
|
|
487
|
+
summary_table.add_row("Apps", str(self.results["apps"]["passed"]),
|
|
488
|
+
str(self.results["apps"]["failed"]), "—",
|
|
489
|
+
str(self.results["apps"]["total"]))
|
|
357
490
|
summary_table.add_row("[bold]TOTAL", f"[bold green]{total_passed}",
|
|
358
|
-
f"[bold red]{total_failed}",
|
|
491
|
+
f"[bold red]{total_failed}",
|
|
492
|
+
f"[dim]{skipped_services}[/]",
|
|
493
|
+
f"[bold]{total_checks}")
|
|
359
494
|
|
|
360
495
|
self.console.print(summary_table)
|
|
361
496
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clonebox
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.20
|
|
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,17 @@
|
|
|
1
|
+
clonebox/__init__.py,sha256=CyfHVVq6KqBr4CNERBpXk_O6Q5B35q03YpdQbokVvvI,408
|
|
2
|
+
clonebox/__main__.py,sha256=Fcoyzwwyz5-eC_sBlQk5a5RbKx8uodQz5sKJ190U0NU,135
|
|
3
|
+
clonebox/cli.py,sha256=08k0XvVdcExl6M64jqt7I_E04QVES3opXPexX9bYJec,103621
|
|
4
|
+
clonebox/cloner.py,sha256=_dJrcg4FoPzAU49J-duJdp37GF-5hE3R_ipbwaw-kaQ,32679
|
|
5
|
+
clonebox/container.py,sha256=tiYK1ZB-DhdD6A2FuMA0h_sRNkUI7KfYcJ0tFOcdyeM,6105
|
|
6
|
+
clonebox/dashboard.py,sha256=RhSPvR6kWglqXeLkCWesBZQid7wv2WpJa6w78mXbPjY,4268
|
|
7
|
+
clonebox/detector.py,sha256=aS_QlbG93-DE3hsjRt88E7O-PGC2TUBgUbP9wqT9g60,23221
|
|
8
|
+
clonebox/models.py,sha256=Uxz9eHov2epJpNYbl0ejaOX91iMSjqdHskGdC8-smVk,7789
|
|
9
|
+
clonebox/profiles.py,sha256=VaKVuxCrgyMxx-8_WOTcw7E8irwGxUPhZHVY6RxYYiE,2034
|
|
10
|
+
clonebox/validator.py,sha256=LnQSZEdJXFGcJrTPxzS2cQUmAXucGeHDKwxrX632h_s,21188
|
|
11
|
+
clonebox/templates/profiles/ml-dev.yaml,sha256=MT7Wu3xGBnYIsO5mzZ2GDI4AAEFGOroIx0eU3XjNARg,140
|
|
12
|
+
clonebox-0.1.20.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
13
|
+
clonebox-0.1.20.dist-info/METADATA,sha256=qOJSrz8MfPrZe3zO0Q16AfcdD_ybXx-YuVAs0_sij2g,35353
|
|
14
|
+
clonebox-0.1.20.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
15
|
+
clonebox-0.1.20.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
|
|
16
|
+
clonebox-0.1.20.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
|
|
17
|
+
clonebox-0.1.20.dist-info/RECORD,,
|
clonebox-0.1.18.dist-info/RECORD
DELETED
|
@@ -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=0Yjkq8EC5v8e402ouvPt3F3-8QSdKuMYZmOrIa8YARc,98076
|
|
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.18.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
10
|
-
clonebox-0.1.18.dist-info/METADATA,sha256=zdZz4r2QGeCLnFq757o0dR8T9OiLqYneys1r4LFcq58,35220
|
|
11
|
-
clonebox-0.1.18.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
12
|
-
clonebox-0.1.18.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
|
|
13
|
-
clonebox-0.1.18.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
|
|
14
|
-
clonebox-0.1.18.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|