clonebox 0.1.18__tar.gz → 0.1.19__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {clonebox-0.1.18/src/clonebox.egg-info → clonebox-0.1.19}/PKG-INFO +4 -1
- {clonebox-0.1.18 → clonebox-0.1.19}/pyproject.toml +15 -1
- {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox/__init__.py +1 -1
- {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox/cli.py +104 -75
- {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox/cloner.py +2 -1
- {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox/detector.py +31 -0
- clonebox-0.1.19/src/clonebox/profiles.py +49 -0
- clonebox-0.1.19/src/clonebox/templates/profiles/ml-dev.yaml +6 -0
- {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox/validator.py +47 -7
- {clonebox-0.1.18 → clonebox-0.1.19/src/clonebox.egg-info}/PKG-INFO +4 -1
- {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox.egg-info/SOURCES.txt +3 -0
- {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox.egg-info/requires.txt +4 -0
- {clonebox-0.1.18 → clonebox-0.1.19}/tests/test_cli.py +3 -0
- clonebox-0.1.19/tests/test_container.py +235 -0
- {clonebox-0.1.18 → clonebox-0.1.19}/LICENSE +0 -0
- {clonebox-0.1.18 → clonebox-0.1.19}/README.md +0 -0
- {clonebox-0.1.18 → clonebox-0.1.19}/setup.cfg +0 -0
- {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox/__main__.py +0 -0
- {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox/container.py +0 -0
- {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox/models.py +0 -0
- {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox.egg-info/dependency_links.txt +0 -0
- {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox.egg-info/entry_points.txt +0 -0
- {clonebox-0.1.18 → clonebox-0.1.19}/src/clonebox.egg-info/top_level.txt +0 -0
- {clonebox-0.1.18 → clonebox-0.1.19}/tests/test_cloner.py +0 -0
- {clonebox-0.1.18 → clonebox-0.1.19}/tests/test_detector.py +0 -0
- {clonebox-0.1.18 → clonebox-0.1.19}/tests/test_models.py +0 -0
- {clonebox-0.1.18 → clonebox-0.1.19}/tests/test_network.py +0 -0
- {clonebox-0.1.18 → clonebox-0.1.19}/tests/test_validator.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clonebox
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.19
|
|
4
4
|
Summary: Clone your workstation environment to an isolated VM with selective apps, paths and services
|
|
5
5
|
Author: CloneBox Team
|
|
6
6
|
License: Apache-2.0
|
|
@@ -41,6 +41,9 @@ Provides-Extra: test
|
|
|
41
41
|
Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
42
42
|
Requires-Dist: pytest-cov>=4.0.0; extra == "test"
|
|
43
43
|
Requires-Dist: pytest-timeout>=2.0.0; extra == "test"
|
|
44
|
+
Provides-Extra: dashboard
|
|
45
|
+
Requires-Dist: fastapi>=0.100.0; extra == "dashboard"
|
|
46
|
+
Requires-Dist: uvicorn>=0.22.0; extra == "dashboard"
|
|
44
47
|
Dynamic: license-file
|
|
45
48
|
|
|
46
49
|
# CloneBox 📦
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "clonebox"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.19"
|
|
8
8
|
description = "Clone your workstation environment to an isolated VM with selective apps, paths and services"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "Apache-2.0"}
|
|
@@ -52,6 +52,10 @@ test = [
|
|
|
52
52
|
"pytest-cov>=4.0.0",
|
|
53
53
|
"pytest-timeout>=2.0.0",
|
|
54
54
|
]
|
|
55
|
+
dashboard = [
|
|
56
|
+
"fastapi>=0.100.0",
|
|
57
|
+
"uvicorn>=0.22.0",
|
|
58
|
+
]
|
|
55
59
|
|
|
56
60
|
[project.scripts]
|
|
57
61
|
clonebox = "clonebox.cli:main"
|
|
@@ -64,6 +68,9 @@ Issues = "https://github.com/wronai/clonebox/issues"
|
|
|
64
68
|
[tool.setuptools.packages.find]
|
|
65
69
|
where = ["src"]
|
|
66
70
|
|
|
71
|
+
[tool.setuptools.package-data]
|
|
72
|
+
clonebox = ["templates/**/*.yaml"]
|
|
73
|
+
|
|
67
74
|
[tool.black]
|
|
68
75
|
line-length = 100
|
|
69
76
|
target-version = ["py38", "py39", "py310", "py311", "py312"]
|
|
@@ -82,6 +89,7 @@ markers = [
|
|
|
82
89
|
"e2e: end-to-end tests requiring libvirt/KVM (deselect with '-m \"not e2e\"')",
|
|
83
90
|
"slow: slow tests (deselect with '-m \"not slow\"')",
|
|
84
91
|
"integration: integration tests",
|
|
92
|
+
"container: container workflow tests requiring podman/docker",
|
|
85
93
|
"requires_kvm: tests requiring /dev/kvm access",
|
|
86
94
|
]
|
|
87
95
|
addopts = [
|
|
@@ -99,3 +107,9 @@ filterwarnings = [
|
|
|
99
107
|
"ignore::PendingDeprecationWarning",
|
|
100
108
|
"ignore::UserWarning",
|
|
101
109
|
]
|
|
110
|
+
|
|
111
|
+
[tool.coverage.run]
|
|
112
|
+
omit = [
|
|
113
|
+
"src/clonebox/container.py",
|
|
114
|
+
"src/clonebox/cli.py",
|
|
115
|
+
]
|
|
@@ -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
|
|
@@ -904,16 +919,15 @@ def cmd_delete(args):
|
|
|
904
919
|
).ask():
|
|
905
920
|
console.print("[yellow]Cancelled.[/]")
|
|
906
921
|
return
|
|
907
|
-
|
|
908
|
-
cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
|
|
909
|
-
cloner.delete_vm(name, delete_storage=not args.keep_storage, console=console)
|
|
910
|
-
|
|
911
|
-
|
|
912
922
|
def cmd_list(args):
|
|
913
923
|
"""List all VMs."""
|
|
914
924
|
cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
|
|
915
925
|
vms = cloner.list_vms()
|
|
916
926
|
|
|
927
|
+
if getattr(args, "json", False):
|
|
928
|
+
print(json.dumps(vms, indent=2))
|
|
929
|
+
return
|
|
930
|
+
|
|
917
931
|
if not vms:
|
|
918
932
|
console.print("[dim]No VMs found.[/]")
|
|
919
933
|
return
|
|
@@ -932,14 +946,6 @@ def cmd_list(args):
|
|
|
932
946
|
|
|
933
947
|
def cmd_container_up(args):
|
|
934
948
|
"""Start a container sandbox."""
|
|
935
|
-
try:
|
|
936
|
-
from clonebox.container import ContainerCloner
|
|
937
|
-
from clonebox.models import ContainerConfig
|
|
938
|
-
except ModuleNotFoundError as e:
|
|
939
|
-
raise ModuleNotFoundError(
|
|
940
|
-
"Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
|
|
941
|
-
) from e
|
|
942
|
-
|
|
943
949
|
mounts = {}
|
|
944
950
|
for m in getattr(args, "mount", []) or []:
|
|
945
951
|
if ":" not in m:
|
|
@@ -959,21 +965,21 @@ def cmd_container_up(args):
|
|
|
959
965
|
if getattr(args, "name", None):
|
|
960
966
|
cfg_kwargs["name"] = args.name
|
|
961
967
|
|
|
968
|
+
profile_name = getattr(args, "profile", None)
|
|
969
|
+
if profile_name:
|
|
970
|
+
merged = merge_with_profile({"container": cfg_kwargs}, profile_name)
|
|
971
|
+
if isinstance(merged, dict) and isinstance(merged.get("container"), dict):
|
|
972
|
+
cfg_kwargs = merged["container"]
|
|
973
|
+
|
|
962
974
|
cfg = ContainerConfig(**cfg_kwargs)
|
|
963
975
|
|
|
964
976
|
cloner = ContainerCloner(engine=cfg.engine)
|
|
965
|
-
|
|
977
|
+
detach = getattr(args, "detach", False)
|
|
978
|
+
cloner.up(cfg, detach=detach, remove=not detach)
|
|
966
979
|
|
|
967
980
|
|
|
968
981
|
def cmd_container_ps(args):
|
|
969
982
|
"""List containers."""
|
|
970
|
-
try:
|
|
971
|
-
from clonebox.container import ContainerCloner
|
|
972
|
-
except ModuleNotFoundError as e:
|
|
973
|
-
raise ModuleNotFoundError(
|
|
974
|
-
"Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
|
|
975
|
-
) from e
|
|
976
|
-
|
|
977
983
|
cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
|
|
978
984
|
items = cloner.ps(all=getattr(args, "all", False))
|
|
979
985
|
|
|
@@ -1004,39 +1010,18 @@ def cmd_container_ps(args):
|
|
|
1004
1010
|
|
|
1005
1011
|
def cmd_container_stop(args):
|
|
1006
1012
|
"""Stop a container."""
|
|
1007
|
-
try:
|
|
1008
|
-
from clonebox.container import ContainerCloner
|
|
1009
|
-
except ModuleNotFoundError as e:
|
|
1010
|
-
raise ModuleNotFoundError(
|
|
1011
|
-
"Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
|
|
1012
|
-
) from e
|
|
1013
|
-
|
|
1014
1013
|
cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
|
|
1015
1014
|
cloner.stop(args.name)
|
|
1016
1015
|
|
|
1017
1016
|
|
|
1018
1017
|
def cmd_container_rm(args):
|
|
1019
1018
|
"""Remove a container."""
|
|
1020
|
-
try:
|
|
1021
|
-
from clonebox.container import ContainerCloner
|
|
1022
|
-
except ModuleNotFoundError as e:
|
|
1023
|
-
raise ModuleNotFoundError(
|
|
1024
|
-
"Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
|
|
1025
|
-
) from e
|
|
1026
|
-
|
|
1027
1019
|
cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
|
|
1028
1020
|
cloner.rm(args.name, force=getattr(args, "force", False))
|
|
1029
1021
|
|
|
1030
1022
|
|
|
1031
1023
|
def cmd_container_down(args):
|
|
1032
1024
|
"""Stop and remove a container."""
|
|
1033
|
-
try:
|
|
1034
|
-
from clonebox.container import ContainerCloner
|
|
1035
|
-
except ModuleNotFoundError as e:
|
|
1036
|
-
raise ModuleNotFoundError(
|
|
1037
|
-
"Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
|
|
1038
|
-
) from e
|
|
1039
|
-
|
|
1040
1025
|
cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
|
|
1041
1026
|
cloner.stop(args.name)
|
|
1042
1027
|
cloner.rm(args.name, force=True)
|
|
@@ -1521,7 +1506,23 @@ def cmd_test(args):
|
|
|
1521
1506
|
if '192.168' in line or '10.0' in line:
|
|
1522
1507
|
console.print(f" IP: {line.split()[-1]}")
|
|
1523
1508
|
else:
|
|
1524
|
-
console.print("[yellow]⚠️ No IP address detected[/]")
|
|
1509
|
+
console.print("[yellow]⚠️ No IP address detected via virsh domifaddr[/]")
|
|
1510
|
+
# Fallback: try to get IP via QEMU Guest Agent (useful for slirp/user networking)
|
|
1511
|
+
try:
|
|
1512
|
+
from .cli import _qga_ping, _qga_exec
|
|
1513
|
+
except ImportError:
|
|
1514
|
+
from clonebox.cli import _qga_ping, _qga_exec
|
|
1515
|
+
if _qga_ping(vm_name, conn_uri):
|
|
1516
|
+
try:
|
|
1517
|
+
ip_out = _qga_exec(vm_name, conn_uri, "ip -4 -o addr show scope global | awk '{print $4}'", timeout=5)
|
|
1518
|
+
if ip_out and ip_out.strip():
|
|
1519
|
+
console.print(f"[green]✅ VM has network access (IP via QGA: {ip_out.strip()})[/]")
|
|
1520
|
+
else:
|
|
1521
|
+
console.print("[yellow]⚠️ IP not available via QGA[/]")
|
|
1522
|
+
except Exception as e:
|
|
1523
|
+
console.print(f"[yellow]⚠️ Could not get IP via QGA ({e})[/]")
|
|
1524
|
+
else:
|
|
1525
|
+
console.print("[dim]IP: QEMU Guest Agent not connected[/]")
|
|
1525
1526
|
except:
|
|
1526
1527
|
console.print("[yellow]⚠️ Could not check network[/]")
|
|
1527
1528
|
else:
|
|
@@ -1582,34 +1583,13 @@ def cmd_test(args):
|
|
|
1582
1583
|
if all_paths:
|
|
1583
1584
|
for idx, (host_path, guest_path) in enumerate(all_paths.items()):
|
|
1584
1585
|
try:
|
|
1585
|
-
|
|
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:
|
|
1586
|
+
# Use the same QGA helper as diagnose/status
|
|
1587
|
+
is_accessible = _qga_exec(vm_name, conn_uri, f"test -d {guest_path} && echo yes || echo no", timeout=5)
|
|
1588
|
+
if is_accessible == "yes":
|
|
1589
|
+
console.print(f"[green]✅ {guest_path}[/]")
|
|
1590
|
+
else:
|
|
1591
|
+
console.print(f"[red]❌ {guest_path} (not accessible)[/]")
|
|
1592
|
+
except Exception:
|
|
1613
1593
|
console.print(f"[yellow]⚠️ {guest_path} (could not check)[/]")
|
|
1614
1594
|
else:
|
|
1615
1595
|
console.print("[dim]No mount points configured[/]")
|
|
@@ -1717,8 +1697,18 @@ def generate_clonebox_yaml(
|
|
|
1717
1697
|
"""Generate YAML config from system snapshot."""
|
|
1718
1698
|
sys_info = detector.get_system_info()
|
|
1719
1699
|
|
|
1720
|
-
#
|
|
1721
|
-
|
|
1700
|
+
# Services that should NOT be cloned to VM (host-specific)
|
|
1701
|
+
VM_EXCLUDED_SERVICES = {
|
|
1702
|
+
"libvirtd", "virtlogd", "libvirt-guests", "qemu-guest-agent",
|
|
1703
|
+
"bluetooth", "bluez", "upower", "thermald", "tlp", "power-profiles-daemon",
|
|
1704
|
+
"gdm", "gdm3", "sddm", "lightdm",
|
|
1705
|
+
"snap.cups.cups-browsed", "snap.cups.cupsd",
|
|
1706
|
+
"ModemManager", "wpa_supplicant",
|
|
1707
|
+
"accounts-daemon", "colord", "switcheroo-control",
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
# Collect services (excluding host-specific ones)
|
|
1711
|
+
services = [s.name for s in snapshot.running_services if s.name not in VM_EXCLUDED_SERVICES]
|
|
1722
1712
|
if deduplicate:
|
|
1723
1713
|
services = deduplicate_list(services)
|
|
1724
1714
|
|
|
@@ -2057,6 +2047,32 @@ def cmd_clone(args):
|
|
|
2057
2047
|
base_image=getattr(args, "base_image", None),
|
|
2058
2048
|
)
|
|
2059
2049
|
|
|
2050
|
+
profile_name = getattr(args, "profile", None)
|
|
2051
|
+
if profile_name:
|
|
2052
|
+
merged_config = merge_with_profile(yaml.safe_load(yaml_content), profile_name)
|
|
2053
|
+
if isinstance(merged_config, dict):
|
|
2054
|
+
vm_section = merged_config.get("vm")
|
|
2055
|
+
if isinstance(vm_section, dict):
|
|
2056
|
+
vm_packages = vm_section.pop("packages", None)
|
|
2057
|
+
if isinstance(vm_packages, list):
|
|
2058
|
+
packages = merged_config.get("packages")
|
|
2059
|
+
if not isinstance(packages, list):
|
|
2060
|
+
packages = []
|
|
2061
|
+
for p in vm_packages:
|
|
2062
|
+
if p not in packages:
|
|
2063
|
+
packages.append(p)
|
|
2064
|
+
merged_config["packages"] = packages
|
|
2065
|
+
|
|
2066
|
+
if "container" in merged_config:
|
|
2067
|
+
merged_config.pop("container", None)
|
|
2068
|
+
|
|
2069
|
+
yaml_content = yaml.dump(
|
|
2070
|
+
merged_config,
|
|
2071
|
+
default_flow_style=False,
|
|
2072
|
+
allow_unicode=True,
|
|
2073
|
+
sort_keys=False,
|
|
2074
|
+
)
|
|
2075
|
+
|
|
2060
2076
|
# Dry run - show what would be created and exit
|
|
2061
2077
|
if dry_run:
|
|
2062
2078
|
config = yaml.safe_load(yaml_content)
|
|
@@ -2337,6 +2353,7 @@ def main():
|
|
|
2337
2353
|
action="store_true",
|
|
2338
2354
|
help="Use user session (qemu:///session) - no root required",
|
|
2339
2355
|
)
|
|
2356
|
+
list_parser.add_argument("--json", action="store_true", help="Output JSON")
|
|
2340
2357
|
list_parser.set_defaults(func=cmd_list)
|
|
2341
2358
|
|
|
2342
2359
|
# Container command
|
|
@@ -2347,6 +2364,7 @@ def main():
|
|
|
2347
2364
|
default="auto",
|
|
2348
2365
|
help="Container engine: auto (default), podman, docker",
|
|
2349
2366
|
)
|
|
2367
|
+
container_parser.set_defaults(func=lambda args, p=container_parser: p.print_help())
|
|
2350
2368
|
container_sub = container_parser.add_subparsers(dest="container_command", help="Container commands")
|
|
2351
2369
|
|
|
2352
2370
|
container_up = container_sub.add_parser("up", help="Start container")
|
|
@@ -2354,6 +2372,10 @@ def main():
|
|
|
2354
2372
|
container_up.add_argument("--name", help="Container name")
|
|
2355
2373
|
container_up.add_argument("--image", default="ubuntu:22.04", help="Container image")
|
|
2356
2374
|
container_up.add_argument("--detach", action="store_true", help="Run container in background")
|
|
2375
|
+
container_up.add_argument(
|
|
2376
|
+
"--profile",
|
|
2377
|
+
help="Profile name (loads ~/.clonebox.d/<name>.yaml, .clonebox.d/<name>.yaml, or built-in templates)",
|
|
2378
|
+
)
|
|
2357
2379
|
container_up.add_argument(
|
|
2358
2380
|
"--mount",
|
|
2359
2381
|
action="append",
|
|
@@ -2436,6 +2458,10 @@ def main():
|
|
|
2436
2458
|
"--base-image",
|
|
2437
2459
|
help="Path to a bootable qcow2 image to use as a base disk",
|
|
2438
2460
|
)
|
|
2461
|
+
clone_parser.add_argument(
|
|
2462
|
+
"--profile",
|
|
2463
|
+
help="Profile name (loads ~/.clonebox.d/<name>.yaml, .clonebox.d/<name>.yaml, or built-in templates)",
|
|
2464
|
+
)
|
|
2439
2465
|
clone_parser.add_argument(
|
|
2440
2466
|
"--replace",
|
|
2441
2467
|
action="store_true",
|
|
@@ -2462,6 +2488,9 @@ def main():
|
|
|
2462
2488
|
status_parser.add_argument(
|
|
2463
2489
|
"--health", "-H", action="store_true", help="Run full health check"
|
|
2464
2490
|
)
|
|
2491
|
+
status_parser.add_argument(
|
|
2492
|
+
"--verbose", "-v", action="store_true", help="Show detailed diagnostics (QGA, stderr, etc.)"
|
|
2493
|
+
)
|
|
2465
2494
|
status_parser.set_defaults(func=cmd_status)
|
|
2466
2495
|
|
|
2467
2496
|
# Diagnose command - detailed diagnostics from workstation
|
|
@@ -663,7 +663,8 @@ fi
|
|
|
663
663
|
# Generate mount commands and fstab entries for 9p filesystems
|
|
664
664
|
mount_commands = []
|
|
665
665
|
fstab_entries = []
|
|
666
|
-
|
|
666
|
+
all_paths = dict(config.paths) if config.paths else {}
|
|
667
|
+
for idx, (host_path, guest_path) in enumerate(all_paths.items()):
|
|
667
668
|
if Path(host_path).exists():
|
|
668
669
|
tag = f"mount{idx}"
|
|
669
670
|
# Use uid=1000,gid=1000 to give ubuntu user access to mounts
|
|
@@ -64,6 +64,37 @@ class SystemSnapshot:
|
|
|
64
64
|
class SystemDetector:
|
|
65
65
|
"""Detects running services, applications and important paths on the system."""
|
|
66
66
|
|
|
67
|
+
# Services that should NOT be cloned to VM (host-specific, hardware-dependent, or hypervisor services)
|
|
68
|
+
VM_EXCLUDED_SERVICES = {
|
|
69
|
+
# Hypervisor/virtualization - no nested virt needed
|
|
70
|
+
"libvirtd",
|
|
71
|
+
"virtlogd",
|
|
72
|
+
"libvirt-guests",
|
|
73
|
+
"qemu-guest-agent", # Host-side, VM has its own
|
|
74
|
+
# Hardware-specific
|
|
75
|
+
"bluetooth",
|
|
76
|
+
"bluez",
|
|
77
|
+
"upower",
|
|
78
|
+
"thermald",
|
|
79
|
+
"tlp",
|
|
80
|
+
"power-profiles-daemon",
|
|
81
|
+
# Display manager (VM has its own)
|
|
82
|
+
"gdm",
|
|
83
|
+
"gdm3",
|
|
84
|
+
"sddm",
|
|
85
|
+
"lightdm",
|
|
86
|
+
# Snap-based duplicates (prefer APT versions)
|
|
87
|
+
"snap.cups.cups-browsed",
|
|
88
|
+
"snap.cups.cupsd",
|
|
89
|
+
# Network hardware
|
|
90
|
+
"ModemManager",
|
|
91
|
+
"wpa_supplicant",
|
|
92
|
+
# Host-specific desktop
|
|
93
|
+
"accounts-daemon",
|
|
94
|
+
"colord",
|
|
95
|
+
"switcheroo-control",
|
|
96
|
+
}
|
|
97
|
+
|
|
67
98
|
# Common development/server services to look for
|
|
68
99
|
INTERESTING_SERVICES = [
|
|
69
100
|
"docker",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import pkgutil
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_profile(profile_name: str, search_paths: list[Path]) -> Optional[Dict[str, Any]]:
|
|
9
|
+
"""Load profile YAML from ~/.clonebox.d/, .clonebox.d/, templates/profiles/"""
|
|
10
|
+
profile_paths = [
|
|
11
|
+
Path.home() / ".clonebox.d" / f"{profile_name}.yaml",
|
|
12
|
+
Path.cwd() / ".clonebox.d" / f"{profile_name}.yaml",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
for profile_path in profile_paths:
|
|
16
|
+
if profile_path.exists():
|
|
17
|
+
return yaml.safe_load(profile_path.read_text())
|
|
18
|
+
|
|
19
|
+
data = pkgutil.get_data("clonebox", f"templates/profiles/{profile_name}.yaml")
|
|
20
|
+
if data is not None:
|
|
21
|
+
return yaml.safe_load(data.decode())
|
|
22
|
+
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
|
27
|
+
merged: Dict[str, Any] = dict(base)
|
|
28
|
+
for key, value in override.items():
|
|
29
|
+
if (
|
|
30
|
+
key in merged
|
|
31
|
+
and isinstance(merged[key], dict)
|
|
32
|
+
and isinstance(value, dict)
|
|
33
|
+
):
|
|
34
|
+
merged[key] = _deep_merge(merged[key], value)
|
|
35
|
+
else:
|
|
36
|
+
merged[key] = value
|
|
37
|
+
return merged
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def merge_with_profile(base_config: Dict[str, Any], profile_name: Optional[str] = None) -> Dict[str, Any]:
|
|
41
|
+
"""Merge profile OVER base config (profile wins)."""
|
|
42
|
+
if not profile_name:
|
|
43
|
+
return base_config
|
|
44
|
+
|
|
45
|
+
profile = load_profile(profile_name, [])
|
|
46
|
+
if not profile or not isinstance(profile, dict):
|
|
47
|
+
return base_config
|
|
48
|
+
|
|
49
|
+
return _deep_merge(base_config, profile)
|
|
@@ -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 📦
|
|
@@ -8,6 +8,7 @@ src/clonebox/cloner.py
|
|
|
8
8
|
src/clonebox/container.py
|
|
9
9
|
src/clonebox/detector.py
|
|
10
10
|
src/clonebox/models.py
|
|
11
|
+
src/clonebox/profiles.py
|
|
11
12
|
src/clonebox/validator.py
|
|
12
13
|
src/clonebox.egg-info/PKG-INFO
|
|
13
14
|
src/clonebox.egg-info/SOURCES.txt
|
|
@@ -15,8 +16,10 @@ src/clonebox.egg-info/dependency_links.txt
|
|
|
15
16
|
src/clonebox.egg-info/entry_points.txt
|
|
16
17
|
src/clonebox.egg-info/requires.txt
|
|
17
18
|
src/clonebox.egg-info/top_level.txt
|
|
19
|
+
src/clonebox/templates/profiles/ml-dev.yaml
|
|
18
20
|
tests/test_cli.py
|
|
19
21
|
tests/test_cloner.py
|
|
22
|
+
tests/test_container.py
|
|
20
23
|
tests/test_detector.py
|
|
21
24
|
tests/test_models.py
|
|
22
25
|
tests/test_network.py
|
|
@@ -222,6 +222,9 @@ class TestCLIIntegration:
|
|
|
222
222
|
(["detect", "--help"], 0),
|
|
223
223
|
(["clone", "--help"], 0),
|
|
224
224
|
(["list", "--help"], 0),
|
|
225
|
+
(["container", "--help"], 0),
|
|
226
|
+
(["container", "ps", "--help"], 0),
|
|
227
|
+
(["container", "up", "--help"], 0),
|
|
225
228
|
])
|
|
226
229
|
def test_cli_help_commands(self, command, expected_exit):
|
|
227
230
|
"""Test CLI help and version commands."""
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import MagicMock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from clonebox.container import ContainerCloner
|
|
9
|
+
from clonebox.models import ContainerConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _which_side_effect(mapping):
|
|
13
|
+
def which(name):
|
|
14
|
+
return mapping.get(name)
|
|
15
|
+
|
|
16
|
+
return which
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestContainerClonerEngineDetection:
|
|
20
|
+
def test_detect_engine_prefers_podman(self, monkeypatch):
|
|
21
|
+
monkeypatch.setattr(
|
|
22
|
+
"clonebox.container.shutil.which",
|
|
23
|
+
_which_side_effect({"podman": "/usr/bin/podman", "docker": "/usr/bin/docker"}),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def fake_run(self, cmd, check=True, capture_output=True, text=True):
|
|
27
|
+
return MagicMock(returncode=0, stdout="podman version", stderr="")
|
|
28
|
+
|
|
29
|
+
monkeypatch.setattr("clonebox.container.ContainerCloner._run", fake_run)
|
|
30
|
+
|
|
31
|
+
c = ContainerCloner(engine="auto")
|
|
32
|
+
assert c.engine == "podman"
|
|
33
|
+
|
|
34
|
+
def test_detect_engine_fallbacks_to_docker(self, monkeypatch):
|
|
35
|
+
monkeypatch.setattr(
|
|
36
|
+
"clonebox.container.shutil.which",
|
|
37
|
+
_which_side_effect({"podman": "/usr/bin/podman", "docker": "/usr/bin/docker"}),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def fake_run(self, cmd, check=True, capture_output=True, text=True):
|
|
41
|
+
if cmd[0] == "podman":
|
|
42
|
+
raise RuntimeError("podman broken")
|
|
43
|
+
return MagicMock(returncode=0, stdout="docker version", stderr="")
|
|
44
|
+
|
|
45
|
+
monkeypatch.setattr("clonebox.container.ContainerCloner._run", fake_run)
|
|
46
|
+
|
|
47
|
+
c = ContainerCloner(engine="auto")
|
|
48
|
+
assert c.engine == "docker"
|
|
49
|
+
|
|
50
|
+
def test_detect_engine_errors_when_none_found(self, monkeypatch):
|
|
51
|
+
monkeypatch.setattr(
|
|
52
|
+
"clonebox.container.shutil.which",
|
|
53
|
+
_which_side_effect({"podman": None, "docker": None}),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
c = ContainerCloner.__new__(ContainerCloner)
|
|
57
|
+
c.engine = "auto"
|
|
58
|
+
with pytest.raises(RuntimeError, match="No container engine found"):
|
|
59
|
+
c.detect_engine()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestContainerClonerBuild:
|
|
63
|
+
def test_build_dockerfile_includes_packages(self, tmp_path):
|
|
64
|
+
cfg = ContainerConfig(
|
|
65
|
+
engine="podman",
|
|
66
|
+
image="ubuntu:22.04",
|
|
67
|
+
workspace=tmp_path,
|
|
68
|
+
packages=["curl", "git"],
|
|
69
|
+
)
|
|
70
|
+
c = ContainerCloner.__new__(ContainerCloner)
|
|
71
|
+
c.engine = "podman"
|
|
72
|
+
|
|
73
|
+
dockerfile = c.build_dockerfile(cfg)
|
|
74
|
+
assert "FROM ubuntu:22.04" in dockerfile
|
|
75
|
+
assert "apt-get install -y curl git" in dockerfile
|
|
76
|
+
assert "WORKDIR /workspace" in dockerfile
|
|
77
|
+
|
|
78
|
+
def test_build_image_calls_engine_build(self, monkeypatch, tmp_path):
|
|
79
|
+
(tmp_path / "file.txt").write_text("x")
|
|
80
|
+
cfg = ContainerConfig(engine="podman", workspace=tmp_path, packages=["curl"])
|
|
81
|
+
|
|
82
|
+
calls = []
|
|
83
|
+
|
|
84
|
+
def fake_run(self, cmd, check=True, capture_output=True, text=True):
|
|
85
|
+
calls.append(cmd)
|
|
86
|
+
return MagicMock(returncode=0, stdout="ok", stderr="")
|
|
87
|
+
|
|
88
|
+
monkeypatch.setattr("clonebox.container.ContainerCloner._run", fake_run)
|
|
89
|
+
|
|
90
|
+
c = ContainerCloner.__new__(ContainerCloner)
|
|
91
|
+
c.engine = "podman"
|
|
92
|
+
|
|
93
|
+
tag = c.build_image(cfg, tag="myimg:latest")
|
|
94
|
+
assert tag == "myimg:latest"
|
|
95
|
+
assert calls
|
|
96
|
+
cmd = calls[0]
|
|
97
|
+
assert cmd[0] == "podman"
|
|
98
|
+
assert cmd[1] == "build"
|
|
99
|
+
assert "-t" in cmd and "myimg:latest" in cmd
|
|
100
|
+
assert str(tmp_path.resolve()) == cmd[-1]
|
|
101
|
+
|
|
102
|
+
dockerfile_path = Path(cmd[cmd.index("-f") + 1])
|
|
103
|
+
assert not dockerfile_path.exists()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TestContainerClonerUpAndPs:
|
|
107
|
+
def test_up_runs_interactive_with_env_file(self, monkeypatch, tmp_path):
|
|
108
|
+
(tmp_path / ".env").write_text("FOO=bar\n")
|
|
109
|
+
|
|
110
|
+
cfg = ContainerConfig(engine="podman", name="test", workspace=tmp_path)
|
|
111
|
+
|
|
112
|
+
monkeypatch.setattr(
|
|
113
|
+
"clonebox.container.shutil.which", _which_side_effect({"podman": "/usr/bin/podman"})
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def fake_run_version(self, cmd, check=True, capture_output=True, text=True):
|
|
117
|
+
return MagicMock(returncode=0, stdout="podman version", stderr="")
|
|
118
|
+
|
|
119
|
+
monkeypatch.setattr("clonebox.container.ContainerCloner._run", fake_run_version)
|
|
120
|
+
|
|
121
|
+
called = {}
|
|
122
|
+
|
|
123
|
+
def fake_subprocess_run(cmd, check=True):
|
|
124
|
+
called["cmd"] = cmd
|
|
125
|
+
return MagicMock(returncode=0)
|
|
126
|
+
|
|
127
|
+
monkeypatch.setattr("clonebox.container.subprocess.run", fake_subprocess_run)
|
|
128
|
+
|
|
129
|
+
c = ContainerCloner(engine="podman")
|
|
130
|
+
c.up(cfg, detach=False)
|
|
131
|
+
|
|
132
|
+
cmd = called["cmd"]
|
|
133
|
+
assert cmd[0] == "podman"
|
|
134
|
+
assert cmd[1] == "run"
|
|
135
|
+
assert "--env-file" in cmd
|
|
136
|
+
assert str(tmp_path / ".env") in cmd
|
|
137
|
+
assert "-v" in cmd
|
|
138
|
+
assert "bash" in cmd
|
|
139
|
+
|
|
140
|
+
def test_up_detach_runs_sleep_infinity(self, monkeypatch, tmp_path):
|
|
141
|
+
cfg = ContainerConfig(engine="podman", name="test", workspace=tmp_path)
|
|
142
|
+
|
|
143
|
+
monkeypatch.setattr(
|
|
144
|
+
"clonebox.container.shutil.which", _which_side_effect({"podman": "/usr/bin/podman"})
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def fake_run_version(self, cmd, check=True, capture_output=True, text=True):
|
|
148
|
+
return MagicMock(returncode=0, stdout="podman version", stderr="")
|
|
149
|
+
|
|
150
|
+
monkeypatch.setattr("clonebox.container.ContainerCloner._run", fake_run_version)
|
|
151
|
+
|
|
152
|
+
called = {}
|
|
153
|
+
|
|
154
|
+
def fake_subprocess_run(cmd, check=True):
|
|
155
|
+
called["cmd"] = cmd
|
|
156
|
+
return MagicMock(returncode=0)
|
|
157
|
+
|
|
158
|
+
monkeypatch.setattr("clonebox.container.subprocess.run", fake_subprocess_run)
|
|
159
|
+
|
|
160
|
+
c = ContainerCloner(engine="podman")
|
|
161
|
+
c.up(cfg, detach=True)
|
|
162
|
+
|
|
163
|
+
cmd = called["cmd"]
|
|
164
|
+
assert "-d" in cmd
|
|
165
|
+
assert cmd[-2:] == ["sleep", "infinity"]
|
|
166
|
+
|
|
167
|
+
def test_ps_docker_parses_tab_format(self, monkeypatch):
|
|
168
|
+
def fake_run(self, cmd, check=True, capture_output=True, text=True):
|
|
169
|
+
return MagicMock(
|
|
170
|
+
returncode=0,
|
|
171
|
+
stdout="c1\tubuntu:22.04\tUp 2 seconds\t0.0.0.0:8080->80/tcp\n",
|
|
172
|
+
stderr="",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
monkeypatch.setattr("clonebox.container.ContainerCloner._run", fake_run)
|
|
176
|
+
|
|
177
|
+
c = ContainerCloner.__new__(ContainerCloner)
|
|
178
|
+
c.engine = "docker"
|
|
179
|
+
|
|
180
|
+
items = c.ps(all=False)
|
|
181
|
+
assert len(items) == 1
|
|
182
|
+
assert items[0]["name"] == "c1"
|
|
183
|
+
assert items[0]["image"] == "ubuntu:22.04"
|
|
184
|
+
|
|
185
|
+
def test_ps_podman_json_uses_a_when_all(self, monkeypatch):
|
|
186
|
+
captured = {}
|
|
187
|
+
|
|
188
|
+
def fake_run(self, cmd, check=True, capture_output=True, text=True):
|
|
189
|
+
captured["cmd"] = cmd
|
|
190
|
+
return MagicMock(
|
|
191
|
+
returncode=0,
|
|
192
|
+
stdout='[{"Names":["c1"],"Image":"ubuntu:22.04","State":"running","Ports":[]}]',
|
|
193
|
+
stderr="",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
monkeypatch.setattr("clonebox.container.ContainerCloner._run", fake_run)
|
|
197
|
+
|
|
198
|
+
c = ContainerCloner.__new__(ContainerCloner)
|
|
199
|
+
c.engine = "podman"
|
|
200
|
+
|
|
201
|
+
items = c.ps(all=True)
|
|
202
|
+
# Command is: podman ps --format json -a
|
|
203
|
+
assert captured["cmd"][:2] == ["podman", "ps"]
|
|
204
|
+
assert "-a" in captured["cmd"]
|
|
205
|
+
assert items[0]["name"] == "c1"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class TestContainerClonerStopRm:
|
|
209
|
+
def test_stop_calls_engine(self, monkeypatch):
|
|
210
|
+
called = {}
|
|
211
|
+
|
|
212
|
+
def fake_subprocess_run(cmd, check=True):
|
|
213
|
+
called["cmd"] = cmd
|
|
214
|
+
return MagicMock(returncode=0)
|
|
215
|
+
|
|
216
|
+
monkeypatch.setattr("clonebox.container.subprocess.run", fake_subprocess_run)
|
|
217
|
+
|
|
218
|
+
c = ContainerCloner.__new__(ContainerCloner)
|
|
219
|
+
c.engine = "podman"
|
|
220
|
+
c.stop("c1")
|
|
221
|
+
assert called["cmd"] == ["podman", "stop", "c1"]
|
|
222
|
+
|
|
223
|
+
def test_rm_force_adds_f(self, monkeypatch):
|
|
224
|
+
called = {}
|
|
225
|
+
|
|
226
|
+
def fake_subprocess_run(cmd, check=True):
|
|
227
|
+
called["cmd"] = cmd
|
|
228
|
+
return MagicMock(returncode=0)
|
|
229
|
+
|
|
230
|
+
monkeypatch.setattr("clonebox.container.subprocess.run", fake_subprocess_run)
|
|
231
|
+
|
|
232
|
+
c = ContainerCloner.__new__(ContainerCloner)
|
|
233
|
+
c.engine = "docker"
|
|
234
|
+
c.rm("c1", force=True)
|
|
235
|
+
assert called["cmd"] == ["docker", "rm", "-f", "c1"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|