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 +1 -1
- clonebox/cli.py +175 -149
- clonebox/cloner.py +2 -1
- clonebox/detector.py +31 -0
- clonebox/profiles.py +49 -0
- clonebox/templates/profiles/ml-dev.yaml +6 -0
- clonebox/validator.py +47 -7
- {clonebox-0.1.17.dist-info → clonebox-0.1.19.dist-info}/METADATA +4 -1
- clonebox-0.1.19.dist-info/RECORD +16 -0
- clonebox-0.1.17.dist-info/RECORD +0 -14
- {clonebox-0.1.17.dist-info → clonebox-0.1.19.dist-info}/WHEEL +0 -0
- {clonebox-0.1.17.dist-info → clonebox-0.1.19.dist-info}/entry_points.txt +0 -0
- {clonebox-0.1.17.dist-info → clonebox-0.1.19.dist-info}/licenses/LICENSE +0 -0
- {clonebox-0.1.17.dist-info → clonebox-0.1.19.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(
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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(
|
|
152
|
+
json.dumps(status_payload),
|
|
124
153
|
],
|
|
125
154
|
capture_output=True,
|
|
126
155
|
text=True,
|
|
127
|
-
timeout=
|
|
156
|
+
timeout=5,
|
|
128
157
|
)
|
|
129
|
-
if
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
167
|
+
out_data = ret.get("out-data")
|
|
168
|
+
if out_data:
|
|
169
|
+
return base64.b64decode(out_data).decode().strip()
|
|
170
|
+
return ""
|
|
173
171
|
|
|
174
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
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
|
-
#
|
|
1724
|
-
|
|
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
|
-
|
|
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)
|
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.
|
|
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}",
|
|
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.
|
|
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,,
|
clonebox-0.1.17.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=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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|