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/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
- base_packages = []
682
+ runcmd_lines = []
683
+ base_packages = ["qemu-guest-agent"]
683
684
  if config.gui:
684
- base_packages.extend([
685
- "ubuntu-desktop-minimal",
686
- "firefox",
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
- "\n".join(f" - {pkg}" for pkg in all_packages) if all_packages else ""
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
- # Add mounts (immediate, before reboot)
708
- for cmd in mount_commands:
709
- runcmd_lines.append(cmd)
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.14
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
+ [![CI](https://github.com/wronai/clonebox/workflows/CI/badge.svg)](https://github.com/wronai/clonebox/actions)
49
+ [![PyPI version](https://badge.fury.io/py/clonebox.svg)](https://pypi.org/project/clonebox/)
50
+ [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
51
+ [![License](https://img.shields.io/badge/license-Apache%202.0-green.svg)](LICENSE)
52
+
41
53
  ![img.png](img.png)
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
- **Clone your workstation environment to an isolated VM with selective apps, paths and services.**
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 test with verbose output
268
- clonebox test . --user --verbose
269
-
270
- # Test output shows:
271
- # ✅ VM is defined in libvirt
272
- # ✅ VM is running
273
- # ✅ VM has network access (IP: 192.168.122.89)
274
- # ✅ Cloud-init completed
275
- # ✅ All mount points accessible
276
- # ✅ Health check triggered
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 and validate all settings |
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,,
@@ -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,,