clonebox 0.1.13__py3-none-any.whl → 0.1.15__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 CHANGED
@@ -672,6 +672,109 @@ def cmd_status(args):
672
672
  except Exception:
673
673
  console.print("[yellow]⏳ Cloud-init status: Unknown (QEMU agent may not be ready)[/]")
674
674
 
675
+ # Check mount status
676
+ console.print("\n[bold]💾 Checking mount status...[/]")
677
+ try:
678
+ # Load config to get expected mounts
679
+ config_file = Path.cwd() / ".clonebox.yaml"
680
+ if config_file.exists():
681
+ config = load_clonebox_config(config_file)
682
+ all_paths = config.get("paths", {}).copy()
683
+ all_paths.update(config.get("app_data_paths", {}))
684
+
685
+ if all_paths:
686
+ # Check which mounts are active
687
+ result = subprocess.run(
688
+ ["virsh", "--connect", conn_uri, "qemu-agent-command", name,
689
+ '{"execute":"guest-exec","arguments":{"path":"/bin/sh","arg":["-c","mount | grep 9p"],"capture-output":true}}'],
690
+ capture_output=True, text=True, timeout=10
691
+ )
692
+
693
+ mount_table = Table(title="Mount Points", border_style="cyan", show_header=True)
694
+ mount_table.add_column("Guest Path", style="bold")
695
+ mount_table.add_column("Status", justify="center")
696
+ mount_table.add_column("Files", justify="right")
697
+
698
+ mounted_paths = []
699
+ if result.returncode == 0 and "return" in result.stdout:
700
+ # Parse guest-exec response for mount output
701
+ import json
702
+ try:
703
+ resp = json.loads(result.stdout)
704
+ if "return" in resp and "pid" in resp["return"]:
705
+ # Get the output from guest-exec-status
706
+ pid = resp["return"]["pid"]
707
+ status_result = subprocess.run(
708
+ ["virsh", "--connect", conn_uri, "qemu-agent-command", name,
709
+ f'{{"execute":"guest-exec-status","arguments":{{"pid":{pid}}}}}'],
710
+ capture_output=True, text=True, timeout=5
711
+ )
712
+ if status_result.returncode == 0:
713
+ status_resp = json.loads(status_result.stdout)
714
+ if "return" in status_resp and "out-data" in status_resp["return"]:
715
+ import base64
716
+ mount_output = base64.b64decode(status_resp["return"]["out-data"]).decode()
717
+ mounted_paths = [line.split()[2] for line in mount_output.split('\n') if line.strip()]
718
+ except:
719
+ pass
720
+
721
+ # Check each expected mount
722
+ working_mounts = 0
723
+ total_mounts = 0
724
+ for host_path, guest_path in all_paths.items():
725
+ total_mounts += 1
726
+ is_mounted = any(guest_path in mp for mp in mounted_paths)
727
+
728
+ # Try to get file count
729
+ file_count = "?"
730
+ if is_mounted:
731
+ try:
732
+ count_result = subprocess.run(
733
+ ["virsh", "--connect", conn_uri, "qemu-agent-command", name,
734
+ f'{{"execute":"guest-exec","arguments":{{"path":"/bin/sh","arg":["-c","ls -A {guest_path} 2>/dev/null | wc -l"],"capture-output":true}}}}'],
735
+ capture_output=True, text=True, timeout=5
736
+ )
737
+ if count_result.returncode == 0:
738
+ resp = json.loads(count_result.stdout)
739
+ if "return" in resp and "pid" in resp["return"]:
740
+ pid = resp["return"]["pid"]
741
+ import time
742
+ time.sleep(0.5)
743
+ status_result = subprocess.run(
744
+ ["virsh", "--connect", conn_uri, "qemu-agent-command", name,
745
+ f'{{"execute":"guest-exec-status","arguments":{{"pid":{pid}}}}}'],
746
+ capture_output=True, text=True, timeout=5
747
+ )
748
+ if status_result.returncode == 0:
749
+ status_resp = json.loads(status_result.stdout)
750
+ if "return" in status_resp and "out-data" in status_resp["return"]:
751
+ file_count = base64.b64decode(status_resp["return"]["out-data"]).decode().strip()
752
+ except:
753
+ pass
754
+
755
+ if is_mounted:
756
+ status = "[green]✅ Mounted[/]"
757
+ working_mounts += 1
758
+ else:
759
+ status = "[red]❌ Not mounted[/]"
760
+
761
+ mount_table.add_row(guest_path, status, str(file_count))
762
+
763
+ console.print(mount_table)
764
+ console.print(f"[dim]{working_mounts}/{total_mounts} mounts active[/]")
765
+
766
+ if working_mounts < total_mounts:
767
+ console.print("[yellow]⚠️ Some mounts are missing. Try remounting in VM:[/]")
768
+ console.print("[dim] sudo mount -a[/]")
769
+ console.print("[dim]Or rebuild VM with: clonebox clone . --user --run --replace[/]")
770
+ else:
771
+ console.print("[dim]No mount points configured[/]")
772
+ else:
773
+ console.print("[dim]No .clonebox.yaml found - cannot check mounts[/]")
774
+ except Exception as e:
775
+ console.print(f"[yellow]⚠️ Cannot check mounts: {e}[/]")
776
+ console.print("[dim]QEMU guest agent may not be ready yet[/]")
777
+
675
778
  # Check health status if available
676
779
  console.print("\n[bold]🏥 Health Check Status...[/]")
677
780
  try:
@@ -1042,11 +1145,13 @@ def cmd_test(args):
1042
1145
  """Test VM configuration and health."""
1043
1146
  import subprocess
1044
1147
  import json
1148
+ from clonebox.validator import VMValidator
1045
1149
 
1046
1150
  name = args.name
1047
1151
  user_session = getattr(args, "user", False)
1048
1152
  quick = getattr(args, "quick", False)
1049
1153
  verbose = getattr(args, "verbose", False)
1154
+ validate_all = getattr(args, "validate", False)
1050
1155
  conn_uri = "qemu:///session" if user_session else "qemu:///system"
1051
1156
 
1052
1157
  # If name is a path, load config
@@ -1242,11 +1347,24 @@ def cmd_test(args):
1242
1347
 
1243
1348
  console.print()
1244
1349
 
1245
- # Summary
1246
- console.print("[bold]Test Summary[/]")
1247
- console.print("VM configuration is valid and VM is accessible.")
1248
- console.print("\n[dim]For detailed health report, run in VM:[/]")
1249
- console.print("[dim] cat /var/log/clonebox-health.log[/]")
1350
+ # Run full validation if requested
1351
+ if validate_all and state == "running":
1352
+ validator = VMValidator(config, vm_name, conn_uri, console)
1353
+ results = validator.validate_all()
1354
+
1355
+ # Exit with error code if validations failed
1356
+ if results["overall"] == "partial":
1357
+ return 1
1358
+ else:
1359
+ # Summary
1360
+ console.print("[bold]Test Summary[/]")
1361
+ console.print("VM configuration is valid and VM is accessible.")
1362
+ console.print("\n[dim]For full validation including packages, services, and mounts:[/]")
1363
+ console.print("[dim] clonebox test . --user --validate[/]")
1364
+ console.print("\n[dim]For detailed health report, run in VM:[/]")
1365
+ console.print("[dim] cat /var/log/clonebox-health.log[/]")
1366
+
1367
+ return 0
1250
1368
 
1251
1369
 
1252
1370
  CLONEBOX_CONFIG_FILE = ".clonebox.yaml"
@@ -1616,12 +1734,16 @@ def create_vm_from_config(
1616
1734
  def cmd_clone(args):
1617
1735
  """Generate clone config from path and optionally create VM."""
1618
1736
  target_path = Path(args.path).resolve()
1737
+ dry_run = getattr(args, "dry_run", False)
1619
1738
 
1620
1739
  if not target_path.exists():
1621
1740
  console.print(f"[red]❌ Path does not exist: {target_path}[/]")
1622
1741
  return
1623
1742
 
1624
- console.print(f"[bold cyan]📦 Generating clone config for: {target_path}[/]\n")
1743
+ if dry_run:
1744
+ console.print(f"[bold cyan]🔍 DRY RUN - Analyzing: {target_path}[/]\n")
1745
+ else:
1746
+ console.print(f"[bold cyan]📦 Generating clone config for: {target_path}[/]\n")
1625
1747
 
1626
1748
  # Detect system state
1627
1749
  with Progress(
@@ -1646,6 +1768,25 @@ def cmd_clone(args):
1646
1768
  base_image=getattr(args, "base_image", None),
1647
1769
  )
1648
1770
 
1771
+ # Dry run - show what would be created and exit
1772
+ if dry_run:
1773
+ config = yaml.safe_load(yaml_content)
1774
+ console.print(Panel(
1775
+ f"[bold]VM Name:[/] {config['vm']['name']}\n"
1776
+ f"[bold]RAM:[/] {config['vm'].get('ram_mb', 4096)} MB\n"
1777
+ f"[bold]vCPUs:[/] {config['vm'].get('vcpus', 4)}\n"
1778
+ f"[bold]Network:[/] {config['vm'].get('network_mode', 'auto')}\n"
1779
+ f"[bold]Paths:[/] {len(config.get('paths', {}))} mounts\n"
1780
+ f"[bold]Packages:[/] {len(config.get('packages', []))} packages\n"
1781
+ f"[bold]Services:[/] {len(config.get('services', []))} services",
1782
+ title="[bold cyan]Would create VM[/]",
1783
+ border_style="cyan",
1784
+ ))
1785
+ console.print("\n[dim]Config preview:[/]")
1786
+ console.print(Panel(yaml_content, title="[bold].clonebox.yaml[/]", border_style="dim"))
1787
+ console.print("\n[yellow]ℹ️ Dry run complete. No changes made.[/]")
1788
+ return
1789
+
1649
1790
  # Save config file
1650
1791
  config_file = (
1651
1792
  target_path / CLONEBOX_CONFIG_FILE
@@ -1953,6 +2094,11 @@ def main():
1953
2094
  action="store_true",
1954
2095
  help="If VM already exists, stop+undefine it and recreate (also deletes its storage)",
1955
2096
  )
2097
+ clone_parser.add_argument(
2098
+ "--dry-run",
2099
+ action="store_true",
2100
+ help="Show what would be created without making any changes",
2101
+ )
1956
2102
  clone_parser.set_defaults(func=cmd_clone)
1957
2103
 
1958
2104
  # Status command - check VM health from workstation
@@ -2013,6 +2159,9 @@ def main():
2013
2159
  test_parser.add_argument(
2014
2160
  "--verbose", "-v", action="store_true", help="Verbose output"
2015
2161
  )
2162
+ test_parser.add_argument(
2163
+ "--validate", action="store_true", help="Run full validation (mounts, packages, services)"
2164
+ )
2016
2165
  test_parser.set_defaults(func=cmd_test)
2017
2166
 
2018
2167
  args = parser.parse_args()
clonebox/models.py ADDED
@@ -0,0 +1,128 @@
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, Optional
8
+
9
+ from pydantic import BaseModel, Field, field_validator, model_validator
10
+
11
+
12
+ class VMSettings(BaseModel):
13
+ """VM-specific settings."""
14
+
15
+ name: str = Field(default="clonebox-vm", description="VM name")
16
+ ram_mb: int = Field(default=4096, ge=512, le=131072, description="RAM in MB")
17
+ vcpus: int = Field(default=4, ge=1, le=128, description="Number of vCPUs")
18
+ disk_size_gb: int = Field(default=10, ge=1, le=2048, description="Disk size in GB")
19
+ gui: bool = Field(default=True, description="Enable SPICE graphics")
20
+ base_image: Optional[str] = Field(default=None, description="Path to base qcow2 image")
21
+ network_mode: str = Field(default="auto", description="Network mode: auto|default|user")
22
+ username: str = Field(default="ubuntu", description="VM default username")
23
+ password: str = Field(default="ubuntu", description="VM default password")
24
+
25
+ @field_validator("name")
26
+ @classmethod
27
+ def name_must_be_valid(cls, v: str) -> str:
28
+ if not v or not v.strip():
29
+ raise ValueError("VM name cannot be empty")
30
+ if len(v) > 64:
31
+ raise ValueError("VM name must be <= 64 characters")
32
+ return v.strip()
33
+
34
+ @field_validator("network_mode")
35
+ @classmethod
36
+ def network_mode_must_be_valid(cls, v: str) -> str:
37
+ valid_modes = {"auto", "default", "user"}
38
+ if v not in valid_modes:
39
+ raise ValueError(f"network_mode must be one of: {valid_modes}")
40
+ return v
41
+
42
+
43
+ class CloneBoxConfig(BaseModel):
44
+ """Complete CloneBox configuration with validation."""
45
+
46
+ version: str = Field(default="1", description="Config version")
47
+ generated: Optional[str] = Field(default=None, description="Generation timestamp")
48
+ vm: VMSettings = Field(default_factory=VMSettings, description="VM settings")
49
+ paths: Dict[str, str] = Field(default_factory=dict, description="Host:Guest path mappings")
50
+ app_data_paths: Dict[str, str] = Field(
51
+ default_factory=dict, description="Application data paths"
52
+ )
53
+ packages: List[str] = Field(default_factory=list, description="APT packages to install")
54
+ snap_packages: List[str] = Field(default_factory=list, description="Snap packages to install")
55
+ services: List[str] = Field(default_factory=list, description="Services to enable")
56
+ post_commands: List[str] = Field(default_factory=list, description="Post-setup commands")
57
+ detected: Optional[Dict[str, Any]] = Field(
58
+ default=None, description="Auto-detected system info"
59
+ )
60
+
61
+ @field_validator("paths", "app_data_paths")
62
+ @classmethod
63
+ def paths_must_be_absolute(cls, v: Dict[str, str]) -> Dict[str, str]:
64
+ for host_path, guest_path in v.items():
65
+ if not host_path.startswith("/"):
66
+ raise ValueError(f"Host path must be absolute: {host_path}")
67
+ if not guest_path.startswith("/"):
68
+ raise ValueError(f"Guest path must be absolute: {guest_path}")
69
+ return v
70
+
71
+ @model_validator(mode="before")
72
+ @classmethod
73
+ def handle_nested_vm(cls, data: Any) -> Any:
74
+ """Handle both dict and nested vm structures."""
75
+ if isinstance(data, dict):
76
+ if "vm" in data and isinstance(data["vm"], dict):
77
+ return data
78
+ vm_fields = {"name", "ram_mb", "vcpus", "disk_size_gb", "gui", "base_image",
79
+ "network_mode", "username", "password"}
80
+ vm_data = {k: v for k, v in data.items() if k in vm_fields}
81
+ if vm_data:
82
+ data = {k: v for k, v in data.items() if k not in vm_fields}
83
+ data["vm"] = vm_data
84
+ return data
85
+
86
+ def save(self, path: Path) -> None:
87
+ """Save configuration to YAML file."""
88
+ import yaml
89
+
90
+ config_dict = self.model_dump(exclude_none=True)
91
+ path.write_text(yaml.dump(config_dict, default_flow_style=False, sort_keys=False))
92
+
93
+ @classmethod
94
+ def load(cls, path: Path) -> "CloneBoxConfig":
95
+ """Load configuration from YAML file."""
96
+ import yaml
97
+
98
+ if path.is_dir():
99
+ path = path / ".clonebox.yaml"
100
+ if not path.exists():
101
+ raise FileNotFoundError(f"Config file not found: {path}")
102
+ data = yaml.safe_load(path.read_text())
103
+ return cls.model_validate(data)
104
+
105
+ def to_vm_config(self) -> "VMConfigDataclass":
106
+ """Convert to legacy VMConfig dataclass for compatibility."""
107
+ from clonebox.cloner import VMConfig as VMConfigDataclass
108
+
109
+ return VMConfigDataclass(
110
+ name=self.vm.name,
111
+ ram_mb=self.vm.ram_mb,
112
+ vcpus=self.vm.vcpus,
113
+ disk_size_gb=self.vm.disk_size_gb,
114
+ gui=self.vm.gui,
115
+ base_image=self.vm.base_image,
116
+ paths=self.paths,
117
+ packages=self.packages,
118
+ snap_packages=self.snap_packages,
119
+ services=self.services,
120
+ post_commands=self.post_commands,
121
+ network_mode=self.vm.network_mode,
122
+ username=self.vm.username,
123
+ password=self.vm.password,
124
+ )
125
+
126
+
127
+ # Backwards compatibility alias
128
+ VMConfigModel = CloneBoxConfig
clonebox/validator.py ADDED
@@ -0,0 +1,374 @@
1
+ """
2
+ VM validation module - validates VM state against YAML configuration.
3
+ """
4
+ import subprocess
5
+ import json
6
+ import base64
7
+ import time
8
+ from typing import Dict, List, Tuple, Optional
9
+ from pathlib import Path
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+
14
+ class VMValidator:
15
+ """Validates VM configuration against expected state from YAML."""
16
+
17
+ def __init__(self, config: dict, vm_name: str, conn_uri: str, console: Console = None):
18
+ self.config = config
19
+ self.vm_name = vm_name
20
+ self.conn_uri = conn_uri
21
+ self.console = console or Console()
22
+ self.results = {
23
+ "mounts": {"passed": 0, "failed": 0, "total": 0, "details": []},
24
+ "packages": {"passed": 0, "failed": 0, "total": 0, "details": []},
25
+ "snap_packages": {"passed": 0, "failed": 0, "total": 0, "details": []},
26
+ "services": {"passed": 0, "failed": 0, "total": 0, "details": []},
27
+ "overall": "unknown"
28
+ }
29
+
30
+ def _exec_in_vm(self, command: str, timeout: int = 10) -> Optional[str]:
31
+ """Execute command in VM using QEMU guest agent."""
32
+ try:
33
+ # Execute command
34
+ result = subprocess.run(
35
+ ["virsh", "--connect", self.conn_uri, "qemu-agent-command", self.vm_name,
36
+ f'{{"execute":"guest-exec","arguments":{{"path":"/bin/sh","arg":["-c","{command}"],"capture-output":true}}}}'],
37
+ capture_output=True, text=True, timeout=timeout
38
+ )
39
+
40
+ if result.returncode != 0:
41
+ return None
42
+
43
+ response = json.loads(result.stdout)
44
+ if "return" not in response or "pid" not in response["return"]:
45
+ return None
46
+
47
+ pid = response["return"]["pid"]
48
+
49
+ # Wait a bit for command to complete
50
+ time.sleep(0.3)
51
+
52
+ # Get result
53
+ status_result = subprocess.run(
54
+ ["virsh", "--connect", self.conn_uri, "qemu-agent-command", self.vm_name,
55
+ f'{{"execute":"guest-exec-status","arguments":{{"pid":{pid}}}}}'],
56
+ capture_output=True, text=True, timeout=5
57
+ )
58
+
59
+ if status_result.returncode != 0:
60
+ return None
61
+
62
+ status_resp = json.loads(status_result.stdout)
63
+ if "return" not in status_resp:
64
+ return None
65
+
66
+ ret = status_resp["return"]
67
+ if not ret.get("exited", False):
68
+ return None
69
+
70
+ if "out-data" in ret:
71
+ return base64.b64decode(ret["out-data"]).decode().strip()
72
+
73
+ return ""
74
+
75
+ except Exception:
76
+ return None
77
+
78
+ def validate_mounts(self) -> Dict:
79
+ """Validate all mount points are accessible and contain data."""
80
+ self.console.print("\n[bold]💾 Validating Mount Points...[/]")
81
+
82
+ all_paths = self.config.get("paths", {}).copy()
83
+ all_paths.update(self.config.get("app_data_paths", {}))
84
+
85
+ if not all_paths:
86
+ self.console.print("[dim]No mount points configured[/]")
87
+ return self.results["mounts"]
88
+
89
+ # Get mounted filesystems
90
+ mount_output = self._exec_in_vm("mount | grep 9p")
91
+ mounted_paths = []
92
+ if mount_output:
93
+ mounted_paths = [line.split()[2] for line in mount_output.split('\n') if line.strip()]
94
+
95
+ mount_table = Table(title="Mount Validation", border_style="cyan")
96
+ mount_table.add_column("Guest Path", style="bold")
97
+ mount_table.add_column("Mounted", justify="center")
98
+ mount_table.add_column("Accessible", justify="center")
99
+ mount_table.add_column("Files", justify="right")
100
+
101
+ for host_path, guest_path in all_paths.items():
102
+ self.results["mounts"]["total"] += 1
103
+
104
+ # Check if mounted
105
+ is_mounted = any(guest_path in mp for mp in mounted_paths)
106
+
107
+ # Check if accessible
108
+ accessible = False
109
+ file_count = "?"
110
+
111
+ if is_mounted:
112
+ test_result = self._exec_in_vm(f"test -d {guest_path} && echo 'yes' || echo 'no'")
113
+ accessible = test_result == "yes"
114
+
115
+ if accessible:
116
+ # Get file count
117
+ count_str = self._exec_in_vm(f"ls -A {guest_path} 2>/dev/null | wc -l")
118
+ if count_str and count_str.isdigit():
119
+ file_count = count_str
120
+
121
+ # Determine status
122
+ if is_mounted and accessible:
123
+ mount_status = "[green]✅[/]"
124
+ access_status = "[green]✅[/]"
125
+ self.results["mounts"]["passed"] += 1
126
+ status = "pass"
127
+ elif is_mounted:
128
+ mount_status = "[green]✅[/]"
129
+ access_status = "[red]❌[/]"
130
+ self.results["mounts"]["failed"] += 1
131
+ status = "mounted_but_inaccessible"
132
+ else:
133
+ mount_status = "[red]❌[/]"
134
+ access_status = "[dim]N/A[/]"
135
+ self.results["mounts"]["failed"] += 1
136
+ status = "not_mounted"
137
+
138
+ mount_table.add_row(guest_path, mount_status, access_status, str(file_count))
139
+
140
+ self.results["mounts"]["details"].append({
141
+ "path": guest_path,
142
+ "mounted": is_mounted,
143
+ "accessible": accessible,
144
+ "files": file_count,
145
+ "status": status
146
+ })
147
+
148
+ self.console.print(mount_table)
149
+ self.console.print(f"[dim]{self.results['mounts']['passed']}/{self.results['mounts']['total']} mounts working[/]")
150
+
151
+ return self.results["mounts"]
152
+
153
+ def validate_packages(self) -> Dict:
154
+ """Validate APT packages are installed."""
155
+ self.console.print("\n[bold]📦 Validating APT Packages...[/]")
156
+
157
+ packages = self.config.get("packages", [])
158
+ if not packages:
159
+ self.console.print("[dim]No APT packages configured[/]")
160
+ return self.results["packages"]
161
+
162
+ pkg_table = Table(title="Package Validation", border_style="cyan")
163
+ pkg_table.add_column("Package", style="bold")
164
+ pkg_table.add_column("Status", justify="center")
165
+ pkg_table.add_column("Version", style="dim")
166
+
167
+ for package in packages:
168
+ self.results["packages"]["total"] += 1
169
+
170
+ # Check if installed
171
+ check_cmd = f"dpkg -l | grep -E '^ii {package}' | awk '{{print $3}}'"
172
+ version = self._exec_in_vm(check_cmd)
173
+
174
+ if version:
175
+ pkg_table.add_row(package, "[green]✅ Installed[/]", version[:40])
176
+ self.results["packages"]["passed"] += 1
177
+ self.results["packages"]["details"].append({
178
+ "package": package,
179
+ "installed": True,
180
+ "version": version
181
+ })
182
+ else:
183
+ pkg_table.add_row(package, "[red]❌ Missing[/]", "")
184
+ self.results["packages"]["failed"] += 1
185
+ self.results["packages"]["details"].append({
186
+ "package": package,
187
+ "installed": False,
188
+ "version": None
189
+ })
190
+
191
+ self.console.print(pkg_table)
192
+ self.console.print(f"[dim]{self.results['packages']['passed']}/{self.results['packages']['total']} packages installed[/]")
193
+
194
+ return self.results["packages"]
195
+
196
+ def validate_snap_packages(self) -> Dict:
197
+ """Validate snap packages are installed."""
198
+ self.console.print("\n[bold]📦 Validating Snap Packages...[/]")
199
+
200
+ snap_packages = self.config.get("snap_packages", [])
201
+ if not snap_packages:
202
+ self.console.print("[dim]No snap packages configured[/]")
203
+ return self.results["snap_packages"]
204
+
205
+ snap_table = Table(title="Snap Package Validation", border_style="cyan")
206
+ snap_table.add_column("Package", style="bold")
207
+ snap_table.add_column("Status", justify="center")
208
+ snap_table.add_column("Version", style="dim")
209
+
210
+ for package in snap_packages:
211
+ self.results["snap_packages"]["total"] += 1
212
+
213
+ # Check if installed
214
+ check_cmd = f"snap list | grep '^{package}' | awk '{{print $2}}'"
215
+ version = self._exec_in_vm(check_cmd)
216
+
217
+ if version:
218
+ snap_table.add_row(package, "[green]✅ Installed[/]", version[:40])
219
+ self.results["snap_packages"]["passed"] += 1
220
+ self.results["snap_packages"]["details"].append({
221
+ "package": package,
222
+ "installed": True,
223
+ "version": version
224
+ })
225
+ else:
226
+ snap_table.add_row(package, "[red]❌ Missing[/]", "")
227
+ self.results["snap_packages"]["failed"] += 1
228
+ self.results["snap_packages"]["details"].append({
229
+ "package": package,
230
+ "installed": False,
231
+ "version": None
232
+ })
233
+
234
+ self.console.print(snap_table)
235
+ self.console.print(f"[dim]{self.results['snap_packages']['passed']}/{self.results['snap_packages']['total']} snap packages installed[/]")
236
+
237
+ return self.results["snap_packages"]
238
+
239
+ def validate_services(self) -> Dict:
240
+ """Validate services are enabled and running."""
241
+ self.console.print("\n[bold]⚙️ Validating Services...[/]")
242
+
243
+ services = self.config.get("services", [])
244
+ if not services:
245
+ self.console.print("[dim]No services configured[/]")
246
+ return self.results["services"]
247
+
248
+ svc_table = Table(title="Service Validation", border_style="cyan")
249
+ svc_table.add_column("Service", style="bold")
250
+ svc_table.add_column("Enabled", justify="center")
251
+ svc_table.add_column("Running", justify="center")
252
+
253
+ for service in services:
254
+ self.results["services"]["total"] += 1
255
+
256
+ # Check if enabled
257
+ enabled_cmd = f"systemctl is-enabled {service} 2>/dev/null"
258
+ enabled_status = self._exec_in_vm(enabled_cmd)
259
+ is_enabled = enabled_status == "enabled"
260
+
261
+ # Check if running
262
+ running_cmd = f"systemctl is-active {service} 2>/dev/null"
263
+ running_status = self._exec_in_vm(running_cmd)
264
+ is_running = running_status == "active"
265
+
266
+ enabled_icon = "[green]✅[/]" if is_enabled else "[yellow]⚠️[/]"
267
+ running_icon = "[green]✅[/]" if is_running else "[red]❌[/]"
268
+
269
+ svc_table.add_row(service, enabled_icon, running_icon)
270
+
271
+ if is_enabled and is_running:
272
+ self.results["services"]["passed"] += 1
273
+ else:
274
+ self.results["services"]["failed"] += 1
275
+
276
+ self.results["services"]["details"].append({
277
+ "service": service,
278
+ "enabled": is_enabled,
279
+ "running": is_running
280
+ })
281
+
282
+ self.console.print(svc_table)
283
+ self.console.print(f"[dim]{self.results['services']['passed']}/{self.results['services']['total']} services active[/]")
284
+
285
+ return self.results["services"]
286
+
287
+ def validate_all(self) -> Dict:
288
+ """Run all validations and return comprehensive results."""
289
+ self.console.print("[bold cyan]🔍 Running Full Validation...[/]")
290
+
291
+ # Check if VM is running
292
+ try:
293
+ result = subprocess.run(
294
+ ["virsh", "--connect", self.conn_uri, "domstate", self.vm_name],
295
+ capture_output=True, text=True, timeout=5
296
+ )
297
+ vm_state = result.stdout.strip()
298
+
299
+ if "running" not in vm_state.lower():
300
+ self.console.print(f"[yellow]⚠️ VM is not running (state: {vm_state})[/]")
301
+ self.console.print("[dim]Start VM with: clonebox start .[/]")
302
+ self.results["overall"] = "vm_not_running"
303
+ return self.results
304
+ except Exception as e:
305
+ self.console.print(f"[red]❌ Cannot check VM state: {e}[/]")
306
+ self.results["overall"] = "error"
307
+ return self.results
308
+
309
+ # Run all validations
310
+ self.validate_mounts()
311
+ self.validate_packages()
312
+ self.validate_snap_packages()
313
+ self.validate_services()
314
+
315
+ # Calculate overall status
316
+ total_checks = (
317
+ self.results["mounts"]["total"] +
318
+ self.results["packages"]["total"] +
319
+ self.results["snap_packages"]["total"] +
320
+ self.results["services"]["total"]
321
+ )
322
+
323
+ total_passed = (
324
+ self.results["mounts"]["passed"] +
325
+ self.results["packages"]["passed"] +
326
+ self.results["snap_packages"]["passed"] +
327
+ self.results["services"]["passed"]
328
+ )
329
+
330
+ total_failed = (
331
+ self.results["mounts"]["failed"] +
332
+ self.results["packages"]["failed"] +
333
+ self.results["snap_packages"]["failed"] +
334
+ self.results["services"]["failed"]
335
+ )
336
+
337
+ # Print summary
338
+ self.console.print("\n[bold]📊 Validation Summary[/]")
339
+ summary_table = Table(border_style="cyan")
340
+ summary_table.add_column("Category", style="bold")
341
+ summary_table.add_column("Passed", justify="right", style="green")
342
+ summary_table.add_column("Failed", justify="right", style="red")
343
+ summary_table.add_column("Total", justify="right")
344
+
345
+ summary_table.add_row("Mounts", str(self.results["mounts"]["passed"]),
346
+ str(self.results["mounts"]["failed"]),
347
+ str(self.results["mounts"]["total"]))
348
+ summary_table.add_row("APT Packages", str(self.results["packages"]["passed"]),
349
+ str(self.results["packages"]["failed"]),
350
+ str(self.results["packages"]["total"]))
351
+ summary_table.add_row("Snap Packages", str(self.results["snap_packages"]["passed"]),
352
+ str(self.results["snap_packages"]["failed"]),
353
+ str(self.results["snap_packages"]["total"]))
354
+ summary_table.add_row("Services", str(self.results["services"]["passed"]),
355
+ str(self.results["services"]["failed"]),
356
+ str(self.results["services"]["total"]))
357
+ summary_table.add_row("[bold]TOTAL", f"[bold green]{total_passed}",
358
+ f"[bold red]{total_failed}", f"[bold]{total_checks}")
359
+
360
+ self.console.print(summary_table)
361
+
362
+ # Determine overall status
363
+ if total_failed == 0 and total_checks > 0:
364
+ self.results["overall"] = "pass"
365
+ self.console.print("\n[bold green]✅ All validations passed![/]")
366
+ elif total_failed > 0:
367
+ self.results["overall"] = "partial"
368
+ self.console.print(f"\n[bold yellow]⚠️ {total_failed}/{total_checks} checks failed[/]")
369
+ self.console.print("[dim]Consider rebuilding VM: clonebox clone . --user --run --replace[/]")
370
+ else:
371
+ self.results["overall"] = "no_checks"
372
+ self.console.print("\n[dim]No validation checks configured[/]")
373
+
374
+ return self.results
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clonebox
3
- Version: 0.1.13
3
+ Version: 0.1.15
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,42 +326,82 @@ 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
- ### VM Health Monitoring
378
+ ### VM Health Monitoring and Mount Validation
280
379
 
281
380
  ```bash
282
- # Check overall status
381
+ # Check overall status including mount validation
283
382
  clonebox status . --user
284
383
 
285
- # Output:
286
- # 📊 Checking VM status: clone-clonebox
287
- # VM State: running
288
- # VM has network access
289
- # ☁️ Cloud-init: Still running (packages installing)
290
- # 🏥 Health Check Status... ⏳ Health check not yet run
291
-
292
- # Trigger health check
384
+ # Output shows:
385
+ # 📊 VM State: running
386
+ # 🔍 Network and IP address
387
+ # ☁️ Cloud-init: Complete
388
+ # 💾 Mount Points status table:
389
+ # ┌─────────────────────────┬──────────────┬────────┐
390
+ # │ Guest Path │ Status │ Files │
391
+ # ├─────────────────────────┼──────────────┼────────┤
392
+ # │ /home/ubuntu/Downloads │ ✅ Mounted │ 199 │
393
+ # │ /home/ubuntu/Documents │ ❌ Not mounted│ ? │
394
+ # │ ~/.config/JetBrains │ ✅ Mounted │ 45 │
395
+ # └─────────────────────────┴──────────────┴────────┘
396
+ # 12/14 mounts active
397
+ # 🏥 Health Check Status: OK
398
+
399
+ # Trigger full health check
293
400
  clonebox status . --user --health
294
401
 
295
- # View detailed health report in VM:
296
- # cat /var/log/clonebox-health.log
402
+ # If mounts are missing, remount or rebuild:
403
+ # In VM: sudo mount -a
404
+ # Or rebuild: clonebox clone . --user --run --replace
297
405
  ```
298
406
 
299
407
  ### Export/Import Workflow
@@ -590,8 +698,10 @@ clonebox clone . --network auto
590
698
  | `clonebox detect --yaml` | Output as YAML config |
591
699
  | `clonebox detect --yaml --dedupe` | YAML with duplicates removed |
592
700
  | `clonebox detect --json` | Output as JSON |
593
- | `clonebox status . --user` | Check VM health, cloud-init status, and IP address |
594
- | `clonebox test . --user` | Test VM configuration and validate all settings |
701
+ | `clonebox status . --user` | Check VM health, cloud-init, IP, and mount status |
702
+ | `clonebox status . --user --health` | Check VM status and run full health check |
703
+ | `clonebox test . --user` | Test VM configuration (basic checks) |
704
+ | `clonebox test . --user --validate` | Full validation: mounts, packages, services vs YAML |
595
705
  | `clonebox export . --user` | Export VM for migration to another workstation |
596
706
  | `clonebox export . --user --include-data` | Export VM with browser profiles and configs |
597
707
  | `clonebox import archive.tar.gz --user` | Import VM from export archive |
@@ -0,0 +1,13 @@
1
+ clonebox/__init__.py,sha256=C1J7Uwrp8H9Zopo5JgrQYzXg-PWls1JdqmE_0Qp1Tro,408
2
+ clonebox/__main__.py,sha256=Fcoyzwwyz5-eC_sBlQk5a5RbKx8uodQz5sKJ190U0NU,135
3
+ clonebox/cli.py,sha256=xo7PJx9XODx9dfIbmuikDncTIGtpU3aAW3-S4iCxv-s,86697
4
+ clonebox/cloner.py,sha256=fVfphsPbsqW4ASnv4bkrDIL8Ks9aPUvxx-IOO_d2FTw,32102
5
+ clonebox/detector.py,sha256=4fu04Ty6KC82WkcJZ5UL5TqXpWYE7Kb7R0uJ-9dtbCk,21635
6
+ clonebox/models.py,sha256=l3z1gm4TAIKzikUrQQn9yfxI62vrQRuHQxV1uftY0fY,5260
7
+ clonebox/validator.py,sha256=8HV3ahfiLkFDOH4UOmZr7-fGfhKep1Jlw1joJeWSaQE,15858
8
+ clonebox-0.1.15.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
9
+ clonebox-0.1.15.dist-info/METADATA,sha256=Tg6u-MfJXaO2MrdlsnFFw584tGPrVINffw6ydx2OrH4,35220
10
+ clonebox-0.1.15.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
11
+ clonebox-0.1.15.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
12
+ clonebox-0.1.15.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
13
+ clonebox-0.1.15.dist-info/RECORD,,
@@ -1,11 +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=GWsBWL5i3DYVkwMuLja4pfXQAp5fgytr3MM5iJgtbdQ,78741
4
- clonebox/cloner.py,sha256=fVfphsPbsqW4ASnv4bkrDIL8Ks9aPUvxx-IOO_d2FTw,32102
5
- clonebox/detector.py,sha256=4fu04Ty6KC82WkcJZ5UL5TqXpWYE7Kb7R0uJ-9dtbCk,21635
6
- clonebox-0.1.13.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
7
- clonebox-0.1.13.dist-info/METADATA,sha256=IvxQ3Zd2pwvgEQ-GXI5332ogfskiGoOoEZGrA0C2rGE,29927
8
- clonebox-0.1.13.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
9
- clonebox-0.1.13.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
10
- clonebox-0.1.13.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
11
- clonebox-0.1.13.dist-info/RECORD,,