clonebox 0.1.14__py3-none-any.whl → 0.1.16__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/cli.py +629 -215
- clonebox/cloner.py +21 -24
- clonebox/container.py +190 -0
- clonebox/models.py +197 -0
- {clonebox-0.1.14.dist-info → clonebox-0.1.16.dist-info}/METADATA +114 -14
- clonebox-0.1.16.dist-info/RECORD +14 -0
- clonebox-0.1.14.dist-info/RECORD +0 -12
- {clonebox-0.1.14.dist-info → clonebox-0.1.16.dist-info}/WHEEL +0 -0
- {clonebox-0.1.14.dist-info → clonebox-0.1.16.dist-info}/entry_points.txt +0 -0
- {clonebox-0.1.14.dist-info → clonebox-0.1.16.dist-info}/licenses/LICENSE +0 -0
- {clonebox-0.1.14.dist-info → clonebox-0.1.16.dist-info}/top_level.txt +0 -0
clonebox/cloner.py
CHANGED
|
@@ -668,7 +668,7 @@ fi
|
|
|
668
668
|
tag = f"mount{idx}"
|
|
669
669
|
# Use uid=1000,gid=1000 to give ubuntu user access to mounts
|
|
670
670
|
# mmap allows proper file mapping
|
|
671
|
-
mount_opts = "trans=virtio,version=9p2000.L,mmap,uid=1000,gid=1000"
|
|
671
|
+
mount_opts = "trans=virtio,version=9p2000.L,mmap,uid=1000,gid=1000,users"
|
|
672
672
|
mount_commands.append(f" - mkdir -p {guest_path}")
|
|
673
673
|
mount_commands.append(f" - chown 1000:1000 {guest_path}")
|
|
674
674
|
mount_commands.append(
|
|
@@ -679,34 +679,30 @@ fi
|
|
|
679
679
|
|
|
680
680
|
# User-data
|
|
681
681
|
# Add desktop environment if GUI is enabled
|
|
682
|
-
|
|
682
|
+
runcmd_lines = []
|
|
683
|
+
base_packages = ["qemu-guest-agent"]
|
|
683
684
|
if config.gui:
|
|
684
|
-
base_packages.extend(
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
685
|
+
base_packages.extend(
|
|
686
|
+
[
|
|
687
|
+
"ubuntu-desktop-minimal",
|
|
688
|
+
"firefox",
|
|
689
|
+
]
|
|
690
|
+
)
|
|
688
691
|
|
|
689
692
|
all_packages = base_packages + list(config.packages)
|
|
690
|
-
packages_yaml = (
|
|
691
|
-
|
|
692
|
-
)
|
|
693
|
-
|
|
694
|
-
# Build runcmd - services, mounts, snaps, post_commands
|
|
695
|
-
runcmd_lines = []
|
|
696
|
-
|
|
697
|
-
# Add service enablement
|
|
698
|
-
for svc in config.services:
|
|
699
|
-
runcmd_lines.append(f" - systemctl enable --now {svc} || true")
|
|
700
|
-
|
|
693
|
+
packages_yaml = "\n".join([f" - {pkg}" for pkg in all_packages])
|
|
694
|
+
|
|
701
695
|
# Add fstab entries for persistent mounts after reboot
|
|
702
696
|
if fstab_entries:
|
|
703
|
-
runcmd_lines.append(" - echo '# CloneBox 9p mounts' >> /etc/fstab")
|
|
697
|
+
runcmd_lines.append(" - grep -q '^# CloneBox 9p mounts' /etc/fstab || echo '# CloneBox 9p mounts' >> /etc/fstab")
|
|
704
698
|
for entry in fstab_entries:
|
|
705
|
-
runcmd_lines.append(f" - echo '{entry}' >> /etc/fstab")
|
|
699
|
+
runcmd_lines.append(f" - grep -qF \"{entry}\" /etc/fstab || echo '{entry}' >> /etc/fstab")
|
|
700
|
+
runcmd_lines.append(" - mount -a || true")
|
|
706
701
|
|
|
707
|
-
#
|
|
708
|
-
|
|
709
|
-
|
|
702
|
+
# Install APT packages
|
|
703
|
+
runcmd_lines.append(" - echo 'Installing APT packages...'")
|
|
704
|
+
for pkg in config.packages:
|
|
705
|
+
runcmd_lines.append(f" - apt-get install -y {pkg} || true")
|
|
710
706
|
|
|
711
707
|
# Install snap packages
|
|
712
708
|
if config.snap_packages:
|
|
@@ -740,6 +736,8 @@ fi
|
|
|
740
736
|
runcmd_lines.append(" - sleep 10 && reboot")
|
|
741
737
|
|
|
742
738
|
runcmd_yaml = "\n".join(runcmd_lines) if runcmd_lines else ""
|
|
739
|
+
bootcmd_yaml = "\n".join(mount_commands) if mount_commands else ""
|
|
740
|
+
bootcmd_block = f"\nbootcmd:\n{bootcmd_yaml}\n" if bootcmd_yaml else ""
|
|
743
741
|
|
|
744
742
|
# Remove power_state - using shutdown -r instead
|
|
745
743
|
power_state_yaml = ""
|
|
@@ -765,6 +763,7 @@ chpasswd:
|
|
|
765
763
|
# Update package cache and upgrade
|
|
766
764
|
package_update: true
|
|
767
765
|
package_upgrade: false
|
|
766
|
+
{bootcmd_block}
|
|
768
767
|
|
|
769
768
|
# Install packages (cloud-init waits for completion before runcmd)
|
|
770
769
|
packages:
|
|
@@ -811,8 +810,6 @@ final_message: "CloneBox VM is ready after $UPTIME seconds"
|
|
|
811
810
|
try:
|
|
812
811
|
vm = self.conn.lookupByName(vm_name)
|
|
813
812
|
except libvirt.libvirtError:
|
|
814
|
-
if ignore_not_found:
|
|
815
|
-
return False
|
|
816
813
|
log(f"[red]❌ VM '{vm_name}' not found[/]")
|
|
817
814
|
return False
|
|
818
815
|
|
clonebox/container.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from clonebox.models import ContainerConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ContainerCloner:
|
|
14
|
+
def __init__(self, engine: str = "auto"):
|
|
15
|
+
self.engine = self._resolve_engine(engine)
|
|
16
|
+
|
|
17
|
+
def _resolve_engine(self, engine: str) -> str:
|
|
18
|
+
if engine == "auto":
|
|
19
|
+
return self.detect_engine()
|
|
20
|
+
if engine not in {"podman", "docker"}:
|
|
21
|
+
raise ValueError("engine must be one of: auto, podman, docker")
|
|
22
|
+
if shutil.which(engine) is None:
|
|
23
|
+
raise RuntimeError(f"Container engine not found: {engine}")
|
|
24
|
+
self._run([engine, "--version"], check=True)
|
|
25
|
+
return engine
|
|
26
|
+
|
|
27
|
+
def detect_engine(self) -> str:
|
|
28
|
+
if shutil.which("podman") is not None:
|
|
29
|
+
try:
|
|
30
|
+
self._run(["podman", "--version"], check=True)
|
|
31
|
+
return "podman"
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
if shutil.which("docker") is not None:
|
|
36
|
+
try:
|
|
37
|
+
self._run(["docker", "--version"], check=True)
|
|
38
|
+
return "docker"
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
raise RuntimeError("No container engine found (podman/docker)")
|
|
43
|
+
|
|
44
|
+
def _run(
|
|
45
|
+
self,
|
|
46
|
+
cmd: List[str],
|
|
47
|
+
check: bool = True,
|
|
48
|
+
capture_output: bool = True,
|
|
49
|
+
text: bool = True,
|
|
50
|
+
) -> subprocess.CompletedProcess:
|
|
51
|
+
return subprocess.run(cmd, check=check, capture_output=capture_output, text=text)
|
|
52
|
+
|
|
53
|
+
def build_dockerfile(self, config: ContainerConfig) -> str:
|
|
54
|
+
lines: List[str] = [f"FROM {config.image}"]
|
|
55
|
+
|
|
56
|
+
if config.packages:
|
|
57
|
+
pkgs = " ".join(config.packages)
|
|
58
|
+
lines.append(
|
|
59
|
+
"RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y "
|
|
60
|
+
+ pkgs
|
|
61
|
+
+ " && rm -rf /var/lib/apt/lists/*"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
lines.append("WORKDIR /workspace")
|
|
65
|
+
lines.append('CMD ["bash"]')
|
|
66
|
+
return "\n".join(lines) + "\n"
|
|
67
|
+
|
|
68
|
+
def build_image(self, config: ContainerConfig, tag: Optional[str] = None) -> str:
|
|
69
|
+
if tag is None:
|
|
70
|
+
tag = f"{config.name}:latest"
|
|
71
|
+
|
|
72
|
+
dockerfile = self.build_dockerfile(config)
|
|
73
|
+
workspace = Path(config.workspace).resolve()
|
|
74
|
+
|
|
75
|
+
with tempfile.NamedTemporaryFile(prefix="clonebox-dockerfile-", delete=False) as f:
|
|
76
|
+
dockerfile_path = Path(f.name)
|
|
77
|
+
f.write(dockerfile.encode())
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
self._run(
|
|
81
|
+
[
|
|
82
|
+
self.engine,
|
|
83
|
+
"build",
|
|
84
|
+
"-f",
|
|
85
|
+
str(dockerfile_path),
|
|
86
|
+
"-t",
|
|
87
|
+
tag,
|
|
88
|
+
str(workspace),
|
|
89
|
+
],
|
|
90
|
+
check=True,
|
|
91
|
+
)
|
|
92
|
+
finally:
|
|
93
|
+
try:
|
|
94
|
+
dockerfile_path.unlink()
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
return tag
|
|
99
|
+
|
|
100
|
+
def up(self, config: ContainerConfig, detach: bool = False, remove: bool = True) -> None:
|
|
101
|
+
engine = self._resolve_engine(config.engine if config.engine != "auto" else self.engine)
|
|
102
|
+
|
|
103
|
+
image = config.image
|
|
104
|
+
if config.packages:
|
|
105
|
+
image = self.build_image(config)
|
|
106
|
+
|
|
107
|
+
cmd: List[str] = [engine, "run"]
|
|
108
|
+
cmd.append("-d" if detach else "-it")
|
|
109
|
+
|
|
110
|
+
if remove:
|
|
111
|
+
cmd.append("--rm")
|
|
112
|
+
|
|
113
|
+
cmd.extend(["--name", config.name])
|
|
114
|
+
cmd.extend(["-w", "/workspace"])
|
|
115
|
+
|
|
116
|
+
env_file = Path(config.workspace) / ".env"
|
|
117
|
+
if config.env_from_dotenv and env_file.exists():
|
|
118
|
+
cmd.extend(["--env-file", str(env_file)])
|
|
119
|
+
|
|
120
|
+
for src, dst in config.mounts.items():
|
|
121
|
+
cmd.extend(["-v", f"{src}:{dst}"])
|
|
122
|
+
|
|
123
|
+
for p in config.ports:
|
|
124
|
+
cmd.extend(["-p", p])
|
|
125
|
+
|
|
126
|
+
cmd.append(image)
|
|
127
|
+
|
|
128
|
+
if detach:
|
|
129
|
+
cmd.extend(["sleep", "infinity"])
|
|
130
|
+
else:
|
|
131
|
+
cmd.append("bash")
|
|
132
|
+
|
|
133
|
+
subprocess.run(cmd, check=True)
|
|
134
|
+
|
|
135
|
+
def stop(self, name: str) -> None:
|
|
136
|
+
subprocess.run([self.engine, "stop", name], check=True)
|
|
137
|
+
|
|
138
|
+
def rm(self, name: str, force: bool = False) -> None:
|
|
139
|
+
cmd = [self.engine, "rm"]
|
|
140
|
+
if force:
|
|
141
|
+
cmd.append("-f")
|
|
142
|
+
cmd.append(name)
|
|
143
|
+
subprocess.run(cmd, check=True)
|
|
144
|
+
|
|
145
|
+
def ps(self, all: bool = False) -> List[Dict[str, Any]]:
|
|
146
|
+
if self.engine == "podman":
|
|
147
|
+
cmd = ["podman", "ps", "--format", "json"]
|
|
148
|
+
if all:
|
|
149
|
+
cmd.append("-a")
|
|
150
|
+
result = self._run(cmd, check=True)
|
|
151
|
+
try:
|
|
152
|
+
parsed = json.loads(result.stdout or "[]")
|
|
153
|
+
except json.JSONDecodeError:
|
|
154
|
+
return []
|
|
155
|
+
|
|
156
|
+
items: List[Dict[str, Any]] = []
|
|
157
|
+
for c in parsed:
|
|
158
|
+
name = ""
|
|
159
|
+
names = c.get("Names")
|
|
160
|
+
if isinstance(names, list) and names:
|
|
161
|
+
name = str(names[0])
|
|
162
|
+
elif isinstance(names, str):
|
|
163
|
+
name = names
|
|
164
|
+
|
|
165
|
+
items.append(
|
|
166
|
+
{
|
|
167
|
+
"name": name,
|
|
168
|
+
"image": c.get("Image") or c.get("ImageName") or "",
|
|
169
|
+
"status": c.get("State") or c.get("Status") or "",
|
|
170
|
+
"ports": c.get("Ports") or [],
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
return items
|
|
174
|
+
|
|
175
|
+
cmd = ["docker", "ps", "--format", "{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"]
|
|
176
|
+
if all:
|
|
177
|
+
cmd.insert(2, "-a")
|
|
178
|
+
|
|
179
|
+
result = self._run(cmd, check=True)
|
|
180
|
+
items: List[Dict[str, Any]] = []
|
|
181
|
+
for line in (result.stdout or "").splitlines():
|
|
182
|
+
if not line.strip():
|
|
183
|
+
continue
|
|
184
|
+
parts = line.split("\t")
|
|
185
|
+
name = parts[0] if len(parts) > 0 else ""
|
|
186
|
+
image = parts[1] if len(parts) > 1 else ""
|
|
187
|
+
status = parts[2] if len(parts) > 2 else ""
|
|
188
|
+
ports = parts[3] if len(parts) > 3 else ""
|
|
189
|
+
items.append({"name": name, "image": image, "status": status, "ports": ports})
|
|
190
|
+
return items
|
clonebox/models.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Pydantic models for CloneBox configuration validation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class VMSettings(BaseModel):
|
|
14
|
+
"""VM-specific settings."""
|
|
15
|
+
|
|
16
|
+
name: str = Field(default="clonebox-vm", description="VM name")
|
|
17
|
+
ram_mb: int = Field(default=4096, ge=512, le=131072, description="RAM in MB")
|
|
18
|
+
vcpus: int = Field(default=4, ge=1, le=128, description="Number of vCPUs")
|
|
19
|
+
disk_size_gb: int = Field(default=10, ge=1, le=2048, description="Disk size in GB")
|
|
20
|
+
gui: bool = Field(default=True, description="Enable SPICE graphics")
|
|
21
|
+
base_image: Optional[str] = Field(default=None, description="Path to base qcow2 image")
|
|
22
|
+
network_mode: str = Field(default="auto", description="Network mode: auto|default|user")
|
|
23
|
+
username: str = Field(default="ubuntu", description="VM default username")
|
|
24
|
+
password: str = Field(default="ubuntu", description="VM default password")
|
|
25
|
+
|
|
26
|
+
@field_validator("name")
|
|
27
|
+
@classmethod
|
|
28
|
+
def name_must_be_valid(cls, v: str) -> str:
|
|
29
|
+
if not v or not v.strip():
|
|
30
|
+
raise ValueError("VM name cannot be empty")
|
|
31
|
+
if len(v) > 64:
|
|
32
|
+
raise ValueError("VM name must be <= 64 characters")
|
|
33
|
+
return v.strip()
|
|
34
|
+
|
|
35
|
+
@field_validator("network_mode")
|
|
36
|
+
@classmethod
|
|
37
|
+
def network_mode_must_be_valid(cls, v: str) -> str:
|
|
38
|
+
valid_modes = {"auto", "default", "user"}
|
|
39
|
+
if v not in valid_modes:
|
|
40
|
+
raise ValueError(f"network_mode must be one of: {valid_modes}")
|
|
41
|
+
return v
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CloneBoxConfig(BaseModel):
|
|
45
|
+
"""Complete CloneBox configuration with validation."""
|
|
46
|
+
|
|
47
|
+
version: str = Field(default="1", description="Config version")
|
|
48
|
+
generated: Optional[str] = Field(default=None, description="Generation timestamp")
|
|
49
|
+
vm: VMSettings = Field(default_factory=VMSettings, description="VM settings")
|
|
50
|
+
paths: Dict[str, str] = Field(default_factory=dict, description="Host:Guest path mappings")
|
|
51
|
+
app_data_paths: Dict[str, str] = Field(
|
|
52
|
+
default_factory=dict, description="Application data paths"
|
|
53
|
+
)
|
|
54
|
+
packages: List[str] = Field(default_factory=list, description="APT packages to install")
|
|
55
|
+
snap_packages: List[str] = Field(default_factory=list, description="Snap packages to install")
|
|
56
|
+
services: List[str] = Field(default_factory=list, description="Services to enable")
|
|
57
|
+
post_commands: List[str] = Field(default_factory=list, description="Post-setup commands")
|
|
58
|
+
detected: Optional[Dict[str, Any]] = Field(
|
|
59
|
+
default=None, description="Auto-detected system info"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@field_validator("paths", "app_data_paths")
|
|
63
|
+
@classmethod
|
|
64
|
+
def paths_must_be_absolute(cls, v: Dict[str, str]) -> Dict[str, str]:
|
|
65
|
+
for host_path, guest_path in v.items():
|
|
66
|
+
if not host_path.startswith("/"):
|
|
67
|
+
raise ValueError(f"Host path must be absolute: {host_path}")
|
|
68
|
+
if not guest_path.startswith("/"):
|
|
69
|
+
raise ValueError(f"Guest path must be absolute: {guest_path}")
|
|
70
|
+
return v
|
|
71
|
+
|
|
72
|
+
@model_validator(mode="before")
|
|
73
|
+
@classmethod
|
|
74
|
+
def handle_nested_vm(cls, data: Any) -> Any:
|
|
75
|
+
"""Handle both dict and nested vm structures."""
|
|
76
|
+
if isinstance(data, dict):
|
|
77
|
+
if "vm" in data and isinstance(data["vm"], dict):
|
|
78
|
+
return data
|
|
79
|
+
vm_fields = {"name", "ram_mb", "vcpus", "disk_size_gb", "gui", "base_image",
|
|
80
|
+
"network_mode", "username", "password"}
|
|
81
|
+
vm_data = {k: v for k, v in data.items() if k in vm_fields}
|
|
82
|
+
if vm_data:
|
|
83
|
+
data = {k: v for k, v in data.items() if k not in vm_fields}
|
|
84
|
+
data["vm"] = vm_data
|
|
85
|
+
return data
|
|
86
|
+
|
|
87
|
+
def save(self, path: Path) -> None:
|
|
88
|
+
"""Save configuration to YAML file."""
|
|
89
|
+
import yaml
|
|
90
|
+
|
|
91
|
+
config_dict = self.model_dump(exclude_none=True)
|
|
92
|
+
path.write_text(yaml.dump(config_dict, default_flow_style=False, sort_keys=False))
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def load(cls, path: Path) -> "CloneBoxConfig":
|
|
96
|
+
"""Load configuration from YAML file."""
|
|
97
|
+
import yaml
|
|
98
|
+
|
|
99
|
+
if path.is_dir():
|
|
100
|
+
path = path / ".clonebox.yaml"
|
|
101
|
+
if not path.exists():
|
|
102
|
+
raise FileNotFoundError(f"Config file not found: {path}")
|
|
103
|
+
data = yaml.safe_load(path.read_text())
|
|
104
|
+
return cls.model_validate(data)
|
|
105
|
+
|
|
106
|
+
def to_vm_config(self) -> "VMConfigDataclass":
|
|
107
|
+
"""Convert to legacy VMConfig dataclass for compatibility."""
|
|
108
|
+
from clonebox.cloner import VMConfig as VMConfigDataclass
|
|
109
|
+
|
|
110
|
+
return VMConfigDataclass(
|
|
111
|
+
name=self.vm.name,
|
|
112
|
+
ram_mb=self.vm.ram_mb,
|
|
113
|
+
vcpus=self.vm.vcpus,
|
|
114
|
+
disk_size_gb=self.vm.disk_size_gb,
|
|
115
|
+
gui=self.vm.gui,
|
|
116
|
+
base_image=self.vm.base_image,
|
|
117
|
+
paths=self.paths,
|
|
118
|
+
packages=self.packages,
|
|
119
|
+
snap_packages=self.snap_packages,
|
|
120
|
+
services=self.services,
|
|
121
|
+
post_commands=self.post_commands,
|
|
122
|
+
network_mode=self.vm.network_mode,
|
|
123
|
+
username=self.vm.username,
|
|
124
|
+
password=self.vm.password,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ContainerConfig(BaseModel):
|
|
129
|
+
name: str = Field(default_factory=lambda: f"clonebox-{uuid4().hex[:8]}")
|
|
130
|
+
engine: Literal["auto", "podman", "docker"] = "auto"
|
|
131
|
+
image: str = "ubuntu:22.04"
|
|
132
|
+
workspace: Path = Path(".")
|
|
133
|
+
extra_mounts: Dict[str, str] = Field(default_factory=dict)
|
|
134
|
+
env_from_dotenv: bool = True
|
|
135
|
+
packages: List[str] = Field(default_factory=list)
|
|
136
|
+
ports: List[str] = Field(default_factory=list)
|
|
137
|
+
|
|
138
|
+
@field_validator("name")
|
|
139
|
+
@classmethod
|
|
140
|
+
def name_must_be_valid(cls, v: str) -> str:
|
|
141
|
+
if not v or not v.strip():
|
|
142
|
+
raise ValueError("Container name cannot be empty")
|
|
143
|
+
if len(v) > 64:
|
|
144
|
+
raise ValueError("Container name must be <= 64 characters")
|
|
145
|
+
return v.strip()
|
|
146
|
+
|
|
147
|
+
@field_validator("extra_mounts")
|
|
148
|
+
@classmethod
|
|
149
|
+
def extra_mounts_must_be_absolute(cls, v: Dict[str, str]) -> Dict[str, str]:
|
|
150
|
+
for host_path, container_path in v.items():
|
|
151
|
+
if not str(host_path).startswith("/"):
|
|
152
|
+
raise ValueError(f"Host path must be absolute: {host_path}")
|
|
153
|
+
if not str(container_path).startswith("/"):
|
|
154
|
+
raise ValueError(f"Container path must be absolute: {container_path}")
|
|
155
|
+
return v
|
|
156
|
+
|
|
157
|
+
@field_validator("ports")
|
|
158
|
+
@classmethod
|
|
159
|
+
def ports_must_be_valid(cls, v: List[str]) -> List[str]:
|
|
160
|
+
for p in v:
|
|
161
|
+
if not isinstance(p, str) or not p.strip():
|
|
162
|
+
raise ValueError("Port mapping cannot be empty")
|
|
163
|
+
if ":" in p:
|
|
164
|
+
host, container = p.split(":", 1)
|
|
165
|
+
if not host.isdigit() or not container.isdigit():
|
|
166
|
+
raise ValueError(f"Invalid port mapping: {p}")
|
|
167
|
+
else:
|
|
168
|
+
if not p.isdigit():
|
|
169
|
+
raise ValueError(f"Invalid port value: {p}")
|
|
170
|
+
return v
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def mounts(self) -> Dict[str, str]:
|
|
174
|
+
mounts: Dict[str, str] = {
|
|
175
|
+
str(self.workspace.resolve()): "/workspace",
|
|
176
|
+
}
|
|
177
|
+
mounts.update(self.extra_mounts)
|
|
178
|
+
return mounts
|
|
179
|
+
|
|
180
|
+
def to_docker_run_cmd(self) -> List[str]:
|
|
181
|
+
if self.engine == "auto":
|
|
182
|
+
raise ValueError("engine must be resolved before generating run command")
|
|
183
|
+
|
|
184
|
+
cmd: List[str] = [self.engine, "run", "-it", "--rm", "--name", self.name]
|
|
185
|
+
|
|
186
|
+
for src, dst in self.mounts.items():
|
|
187
|
+
cmd.extend(["-v", f"{src}:{dst}"])
|
|
188
|
+
|
|
189
|
+
for p in self.ports:
|
|
190
|
+
cmd.extend(["-p", p])
|
|
191
|
+
|
|
192
|
+
cmd.append(self.image)
|
|
193
|
+
return cmd
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# Backwards compatibility alias
|
|
197
|
+
VMConfigModel = CloneBoxConfig
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clonebox
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.16
|
|
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
|
|
@@ -30,14 +30,26 @@ Requires-Dist: rich>=13.0.0
|
|
|
30
30
|
Requires-Dist: questionary>=2.0.0
|
|
31
31
|
Requires-Dist: psutil>=5.9.0
|
|
32
32
|
Requires-Dist: pyyaml>=6.0
|
|
33
|
+
Requires-Dist: pydantic>=2.0.0
|
|
33
34
|
Provides-Extra: dev
|
|
34
35
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
35
36
|
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
36
37
|
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
37
38
|
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
39
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
40
|
+
Provides-Extra: test
|
|
41
|
+
Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
42
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "test"
|
|
43
|
+
Requires-Dist: pytest-timeout>=2.0.0; extra == "test"
|
|
38
44
|
Dynamic: license-file
|
|
39
45
|
|
|
40
46
|
# CloneBox 📦
|
|
47
|
+
|
|
48
|
+
[](https://github.com/wronai/clonebox/actions)
|
|
49
|
+
[](https://pypi.org/project/clonebox/)
|
|
50
|
+
[](https://www.python.org/downloads/)
|
|
51
|
+
[](LICENSE)
|
|
52
|
+
|
|
41
53
|

|
|
42
54
|
|
|
43
55
|
```commandline
|
|
@@ -51,7 +63,8 @@ Dynamic: license-file
|
|
|
51
63
|
║ Clone your workstation to an isolated VM ║
|
|
52
64
|
╚═══════════════════════════════════════════════════════╝
|
|
53
65
|
```
|
|
54
|
-
|
|
66
|
+
|
|
67
|
+
> **Clone your workstation environment to an isolated VM in 60 seconds using bind mounts instead of disk cloning.**
|
|
55
68
|
|
|
56
69
|
CloneBox lets you create isolated virtual machines with only the applications, directories and services you need - using bind mounts instead of full disk cloning. Perfect for development, testing, or creating reproducible environments.
|
|
57
70
|
|
|
@@ -168,6 +181,61 @@ clonebox stop <name> # Zatrzymaj VM
|
|
|
168
181
|
clonebox delete <name> # Usuń VM
|
|
169
182
|
```
|
|
170
183
|
|
|
184
|
+
## Development and Testing
|
|
185
|
+
|
|
186
|
+
### Running Tests
|
|
187
|
+
|
|
188
|
+
CloneBox has comprehensive test coverage with unit tests and end-to-end tests:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
# Run unit tests only (fast, no libvirt required)
|
|
192
|
+
make test
|
|
193
|
+
|
|
194
|
+
# Run fast unit tests (excludes slow tests)
|
|
195
|
+
make test-unit
|
|
196
|
+
|
|
197
|
+
# Run end-to-end tests (requires libvirt/KVM)
|
|
198
|
+
make test-e2e
|
|
199
|
+
|
|
200
|
+
# Run all tests including e2e
|
|
201
|
+
make test-all
|
|
202
|
+
|
|
203
|
+
# Run tests with coverage
|
|
204
|
+
make test-cov
|
|
205
|
+
|
|
206
|
+
# Run tests with verbose output
|
|
207
|
+
make test-verbose
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Test Categories
|
|
211
|
+
|
|
212
|
+
Tests are organized with pytest markers:
|
|
213
|
+
|
|
214
|
+
- **Unit tests**: Fast tests that mock libvirt/system calls (default)
|
|
215
|
+
- **E2E tests**: End-to-end tests requiring actual VM creation (marked with `@pytest.mark.e2e`)
|
|
216
|
+
- **Slow tests**: Tests that take longer to run (marked with `@pytest.mark.slow`)
|
|
217
|
+
|
|
218
|
+
E2E tests are automatically skipped when:
|
|
219
|
+
- libvirt is not installed
|
|
220
|
+
- `/dev/kvm` is not available
|
|
221
|
+
- Running in CI environment (`CI=true` or `GITHUB_ACTIONS=true`)
|
|
222
|
+
|
|
223
|
+
### Manual Test Execution
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
# Run only unit tests (exclude e2e)
|
|
227
|
+
pytest tests/ -m "not e2e"
|
|
228
|
+
|
|
229
|
+
# Run only e2e tests
|
|
230
|
+
pytest tests/e2e/ -m "e2e" -v
|
|
231
|
+
|
|
232
|
+
# Run specific test file
|
|
233
|
+
pytest tests/test_cloner.py -v
|
|
234
|
+
|
|
235
|
+
# Run with coverage
|
|
236
|
+
pytest tests/ -m "not e2e" --cov=clonebox --cov-report=html
|
|
237
|
+
```
|
|
238
|
+
|
|
171
239
|
## Quick Start
|
|
172
240
|
|
|
173
241
|
### Interactive Mode (Recommended)
|
|
@@ -258,22 +326,53 @@ ls ~/.mozilla/firefox # Firefox profile
|
|
|
258
326
|
ls ~/.config/JetBrains # PyCharm settings
|
|
259
327
|
```
|
|
260
328
|
|
|
261
|
-
### Testing VM Configuration
|
|
329
|
+
### Testing and Validating VM Configuration
|
|
262
330
|
|
|
263
331
|
```bash
|
|
264
332
|
# Quick test - basic checks
|
|
265
333
|
clonebox test . --user --quick
|
|
266
334
|
|
|
267
|
-
# Full
|
|
268
|
-
clonebox test . --user --
|
|
269
|
-
|
|
270
|
-
#
|
|
271
|
-
# ✅
|
|
272
|
-
# ✅
|
|
273
|
-
# ✅
|
|
274
|
-
# ✅
|
|
275
|
-
# ✅
|
|
276
|
-
# ✅
|
|
335
|
+
# Full validation - checks EVERYTHING against YAML config
|
|
336
|
+
clonebox test . --user --validate
|
|
337
|
+
|
|
338
|
+
# Validation checks:
|
|
339
|
+
# ✅ All mount points (paths + app_data_paths) are mounted and accessible
|
|
340
|
+
# ✅ All APT packages are installed
|
|
341
|
+
# ✅ All snap packages are installed
|
|
342
|
+
# ✅ All services are enabled and running
|
|
343
|
+
# ✅ Reports file counts for each mount
|
|
344
|
+
# ✅ Shows package versions
|
|
345
|
+
# ✅ Comprehensive summary table
|
|
346
|
+
|
|
347
|
+
# Example output:
|
|
348
|
+
# 💾 Validating Mount Points...
|
|
349
|
+
# ┌─────────────────────────┬─────────┬────────────┬────────┐
|
|
350
|
+
# │ Guest Path │ Mounted │ Accessible │ Files │
|
|
351
|
+
# ├─────────────────────────┼─────────┼────────────┼────────┤
|
|
352
|
+
# │ /home/ubuntu/Downloads │ ✅ │ ✅ │ 199 │
|
|
353
|
+
# │ ~/.config/JetBrains │ ✅ │ ✅ │ 45 │
|
|
354
|
+
# └─────────────────────────┴─────────┴────────────┴────────┘
|
|
355
|
+
# 12/14 mounts working
|
|
356
|
+
#
|
|
357
|
+
# 📦 Validating APT Packages...
|
|
358
|
+
# ┌─────────────────┬──────────────┬────────────┐
|
|
359
|
+
# │ Package │ Status │ Version │
|
|
360
|
+
# ├─────────────────┼──────────────┼────────────┤
|
|
361
|
+
# │ firefox │ ✅ Installed │ 122.0+b... │
|
|
362
|
+
# │ docker.io │ ✅ Installed │ 24.0.7-... │
|
|
363
|
+
# └─────────────────┴──────────────┴────────────┘
|
|
364
|
+
# 8/8 packages installed
|
|
365
|
+
#
|
|
366
|
+
# 📊 Validation Summary
|
|
367
|
+
# ┌────────────────┬────────┬────────┬───────┐
|
|
368
|
+
# │ Category │ Passed │ Failed │ Total │
|
|
369
|
+
# ├────────────────┼────────┼────────┼───────┤
|
|
370
|
+
# │ Mounts │ 12 │ 2 │ 14 │
|
|
371
|
+
# │ APT Packages │ 8 │ 0 │ 8 │
|
|
372
|
+
# │ Snap Packages │ 2 │ 0 │ 2 │
|
|
373
|
+
# │ Services │ 5 │ 1 │ 6 │
|
|
374
|
+
# │ TOTAL │ 27 │ 3 │ 30 │
|
|
375
|
+
# └────────────────┴────────┴────────┴───────┘
|
|
277
376
|
```
|
|
278
377
|
|
|
279
378
|
### VM Health Monitoring and Mount Validation
|
|
@@ -601,7 +700,8 @@ clonebox clone . --network auto
|
|
|
601
700
|
| `clonebox detect --json` | Output as JSON |
|
|
602
701
|
| `clonebox status . --user` | Check VM health, cloud-init, IP, and mount status |
|
|
603
702
|
| `clonebox status . --user --health` | Check VM status and run full health check |
|
|
604
|
-
| `clonebox test . --user` | Test VM configuration
|
|
703
|
+
| `clonebox test . --user` | Test VM configuration (basic checks) |
|
|
704
|
+
| `clonebox test . --user --validate` | Full validation: mounts, packages, services vs YAML |
|
|
605
705
|
| `clonebox export . --user` | Export VM for migration to another workstation |
|
|
606
706
|
| `clonebox export . --user --include-data` | Export VM with browser profiles and configs |
|
|
607
707
|
| `clonebox import archive.tar.gz --user` | Import VM from export archive |
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
clonebox/__init__.py,sha256=C1J7Uwrp8H9Zopo5JgrQYzXg-PWls1JdqmE_0Qp1Tro,408
|
|
2
|
+
clonebox/__main__.py,sha256=Fcoyzwwyz5-eC_sBlQk5a5RbKx8uodQz5sKJ190U0NU,135
|
|
3
|
+
clonebox/cli.py,sha256=CwJ78Em2bma6jKNjrJYmM7B3rhzNDKDMLQ-3AEx00G4,98101
|
|
4
|
+
clonebox/cloner.py,sha256=bSOOkfqvF1PaUhVke0GaFYz29-eA9CigiAILP-WDhjs,32221
|
|
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.16.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
10
|
+
clonebox-0.1.16.dist-info/METADATA,sha256=ZT06tRqwiEyYiqnrkHchd30VZgwWo0Cb6NhRrMu00_g,35220
|
|
11
|
+
clonebox-0.1.16.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
12
|
+
clonebox-0.1.16.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
|
|
13
|
+
clonebox-0.1.16.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
|
|
14
|
+
clonebox-0.1.16.dist-info/RECORD,,
|
clonebox-0.1.14.dist-info/RECORD
DELETED
|
@@ -1,12 +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=IWTJjC5o3GPDZqQVnWNa8SHq1zKaK-fLGRASTehGe3Y,84733
|
|
4
|
-
clonebox/cloner.py,sha256=fVfphsPbsqW4ASnv4bkrDIL8Ks9aPUvxx-IOO_d2FTw,32102
|
|
5
|
-
clonebox/detector.py,sha256=4fu04Ty6KC82WkcJZ5UL5TqXpWYE7Kb7R0uJ-9dtbCk,21635
|
|
6
|
-
clonebox/validator.py,sha256=8HV3ahfiLkFDOH4UOmZr7-fGfhKep1Jlw1joJeWSaQE,15858
|
|
7
|
-
clonebox-0.1.14.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
8
|
-
clonebox-0.1.14.dist-info/METADATA,sha256=RGcUEs9xELSw6zii3s6qGqoYsiaOQNVo-CB5xK9N7Vw,30824
|
|
9
|
-
clonebox-0.1.14.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
10
|
-
clonebox-0.1.14.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
|
|
11
|
-
clonebox-0.1.14.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
|
|
12
|
-
clonebox-0.1.14.dist-info/RECORD,,
|
|
File without changes
|