clonebox 0.1.18__tar.gz → 0.1.20__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.
- {clonebox-0.1.18/src/clonebox.egg-info → clonebox-0.1.20}/PKG-INFO +4 -1
- {clonebox-0.1.18 → clonebox-0.1.20}/pyproject.toml +15 -1
- {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox/__init__.py +1 -1
- {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox/cli.py +196 -74
- {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox/cloner.py +5 -2
- clonebox-0.1.20/src/clonebox/dashboard.py +133 -0
- {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox/detector.py +76 -20
- clonebox-0.1.20/src/clonebox/profiles.py +66 -0
- clonebox-0.1.20/src/clonebox/templates/profiles/ml-dev.yaml +6 -0
- {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox/validator.py +161 -26
- {clonebox-0.1.18 → clonebox-0.1.20/src/clonebox.egg-info}/PKG-INFO +4 -1
- {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox.egg-info/SOURCES.txt +5 -0
- {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox.egg-info/requires.txt +4 -0
- {clonebox-0.1.18 → clonebox-0.1.20}/tests/test_cli.py +3 -0
- clonebox-0.1.20/tests/test_container.py +235 -0
- {clonebox-0.1.18 → clonebox-0.1.20}/tests/test_detector.py +24 -0
- clonebox-0.1.20/tests/test_profiles.py +26 -0
- {clonebox-0.1.18 → clonebox-0.1.20}/tests/test_validator.py +99 -1
- {clonebox-0.1.18 → clonebox-0.1.20}/LICENSE +0 -0
- {clonebox-0.1.18 → clonebox-0.1.20}/README.md +0 -0
- {clonebox-0.1.18 → clonebox-0.1.20}/setup.cfg +0 -0
- {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox/__main__.py +0 -0
- {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox/container.py +0 -0
- {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox/models.py +0 -0
- {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox.egg-info/dependency_links.txt +0 -0
- {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox.egg-info/entry_points.txt +0 -0
- {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox.egg-info/top_level.txt +0 -0
- {clonebox-0.1.18 → clonebox-0.1.20}/tests/test_cloner.py +0 -0
- {clonebox-0.1.18 → clonebox-0.1.20}/tests/test_models.py +0 -0
- {clonebox-0.1.18 → clonebox-0.1.20}/tests/test_network.py +0 -0
|
@@ -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 📦
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "clonebox"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.20"
|
|
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
|
+
]
|
|
@@ -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
|
|
@@ -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
|