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 +155 -6
- clonebox/models.py +128 -0
- clonebox/validator.py +374 -0
- {clonebox-0.1.13.dist-info → clonebox-0.1.15.dist-info}/METADATA +137 -27
- clonebox-0.1.15.dist-info/RECORD +13 -0
- clonebox-0.1.13.dist-info/RECORD +0 -11
- {clonebox-0.1.13.dist-info → clonebox-0.1.15.dist-info}/WHEEL +0 -0
- {clonebox-0.1.13.dist-info → clonebox-0.1.15.dist-info}/entry_points.txt +0 -0
- {clonebox-0.1.13.dist-info → clonebox-0.1.15.dist-info}/licenses/LICENSE +0 -0
- {clonebox-0.1.13.dist-info → clonebox-0.1.15.dist-info}/top_level.txt +0 -0
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
|
-
#
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
[](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,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
|
|
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
|
-
### 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
|
-
# 📊
|
|
287
|
-
#
|
|
288
|
-
#
|
|
289
|
-
#
|
|
290
|
-
#
|
|
291
|
-
|
|
292
|
-
#
|
|
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
|
-
#
|
|
296
|
-
#
|
|
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
|
|
594
|
-
| `clonebox
|
|
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,,
|
clonebox-0.1.13.dist-info/RECORD
DELETED
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|