clonebox 1.1.3__tar.gz → 1.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {clonebox-1.1.3/src/clonebox.egg-info → clonebox-1.1.5}/PKG-INFO +51 -2
  2. {clonebox-1.1.3 → clonebox-1.1.5}/README.md +49 -1
  3. {clonebox-1.1.3 → clonebox-1.1.5}/pyproject.toml +2 -1
  4. clonebox-1.1.5/src/clonebox/backends/libvirt_backend.py +217 -0
  5. clonebox-1.1.5/src/clonebox/backends/qemu_disk.py +52 -0
  6. clonebox-1.1.5/src/clonebox/backends/subprocess_runner.py +56 -0
  7. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox/cli.py +343 -22
  8. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox/cloner.py +327 -189
  9. clonebox-1.1.5/src/clonebox/di.py +176 -0
  10. clonebox-1.1.5/src/clonebox/health/__init__.py +17 -0
  11. clonebox-1.1.5/src/clonebox/health/manager.py +328 -0
  12. clonebox-1.1.5/src/clonebox/health/models.py +194 -0
  13. clonebox-1.1.5/src/clonebox/health/probes.py +337 -0
  14. clonebox-1.1.5/src/clonebox/interfaces/disk.py +40 -0
  15. clonebox-1.1.5/src/clonebox/interfaces/hypervisor.py +89 -0
  16. clonebox-1.1.5/src/clonebox/interfaces/network.py +33 -0
  17. clonebox-1.1.5/src/clonebox/interfaces/process.py +46 -0
  18. clonebox-1.1.5/src/clonebox/logging.py +125 -0
  19. clonebox-1.1.5/src/clonebox/monitor.py +267 -0
  20. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox/p2p.py +4 -2
  21. clonebox-1.1.5/src/clonebox/resource_monitor.py +162 -0
  22. clonebox-1.1.5/src/clonebox/resources.py +222 -0
  23. clonebox-1.1.5/src/clonebox/rollback.py +172 -0
  24. clonebox-1.1.5/src/clonebox/secrets.py +331 -0
  25. clonebox-1.1.5/src/clonebox/snapshots/__init__.py +12 -0
  26. clonebox-1.1.5/src/clonebox/snapshots/manager.py +349 -0
  27. clonebox-1.1.5/src/clonebox/snapshots/models.py +183 -0
  28. {clonebox-1.1.3 → clonebox-1.1.5/src/clonebox.egg-info}/PKG-INFO +51 -2
  29. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox.egg-info/SOURCES.txt +21 -0
  30. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox.egg-info/requires.txt +1 -0
  31. {clonebox-1.1.3 → clonebox-1.1.5}/tests/test_cloner.py +24 -7
  32. {clonebox-1.1.3 → clonebox-1.1.5}/tests/test_cloner_simple.py +7 -4
  33. {clonebox-1.1.3 → clonebox-1.1.5}/tests/test_coverage_boost_final.py +61 -21
  34. {clonebox-1.1.3 → clonebox-1.1.5}/tests/test_network.py +17 -2
  35. {clonebox-1.1.3 → clonebox-1.1.5}/LICENSE +0 -0
  36. {clonebox-1.1.3 → clonebox-1.1.5}/setup.cfg +0 -0
  37. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox/__init__.py +0 -0
  38. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox/__main__.py +0 -0
  39. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox/container.py +0 -0
  40. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox/dashboard.py +0 -0
  41. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox/detector.py +0 -0
  42. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox/exporter.py +0 -0
  43. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox/importer.py +0 -0
  44. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox/models.py +0 -0
  45. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox/profiles.py +0 -0
  46. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox/templates/profiles/ml-dev.yaml +0 -0
  47. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox/templates/profiles/web-stack.yaml +0 -0
  48. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox/validator.py +0 -0
  49. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox.egg-info/dependency_links.txt +0 -0
  50. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox.egg-info/entry_points.txt +0 -0
  51. {clonebox-1.1.3 → clonebox-1.1.5}/src/clonebox.egg-info/top_level.txt +0 -0
  52. {clonebox-1.1.3 → clonebox-1.1.5}/tests/test_cli.py +0 -0
  53. {clonebox-1.1.3 → clonebox-1.1.5}/tests/test_container.py +0 -0
  54. {clonebox-1.1.3 → clonebox-1.1.5}/tests/test_coverage_additional.py +0 -0
  55. {clonebox-1.1.3 → clonebox-1.1.5}/tests/test_dashboard_coverage.py +0 -0
  56. {clonebox-1.1.3 → clonebox-1.1.5}/tests/test_detector.py +0 -0
  57. {clonebox-1.1.3 → clonebox-1.1.5}/tests/test_models.py +0 -0
  58. {clonebox-1.1.3 → clonebox-1.1.5}/tests/test_profiles.py +0 -0
  59. {clonebox-1.1.3 → clonebox-1.1.5}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clonebox
3
- Version: 1.1.3
3
+ Version: 1.1.5
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
@@ -32,6 +32,7 @@ Requires-Dist: psutil>=5.9.0
32
32
  Requires-Dist: pyyaml>=6.0
33
33
  Requires-Dist: pydantic>=2.0.0
34
34
  Requires-Dist: python-dotenv>=1.0.0
35
+ Requires-Dist: cryptography>=42.0.0
35
36
  Provides-Extra: dev
36
37
  Requires-Dist: pytest>=7.0.0; extra == "dev"
37
38
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
@@ -114,6 +115,8 @@ CloneBox excels in scenarios where developers need:
114
115
  | 🎛️ Profiles System (`ml-dev`, `web-stack`) | ✅ Stable |
115
116
  | 🔍 Auto-detection (services, apps, paths) | ✅ Stable |
116
117
  | 🔒 P2P Secure Transfer (AES-256) | ✅ **NEW** |
118
+ | 📸 Snapshot Management | ✅ **NEW** |
119
+ | 🏥 Health Check System | ✅ **NEW** |
117
120
  | 🧪 95%+ Test Coverage | ✅ |
118
121
 
119
122
  ### P2P Secure VM Sharing
@@ -141,9 +144,55 @@ clonebox sync-key user@hostB # Sync encryption key
141
144
  clonebox list-remote user@hostB # List remote VMs
142
145
  ```
143
146
 
147
+ ### Snapshot Management
148
+
149
+ Save and restore VM states:
150
+
151
+ ```bash
152
+ # Create snapshot before risky operation
153
+ clonebox snapshot create my-vm --name "before-upgrade" --user
154
+
155
+ # List all snapshots
156
+ clonebox snapshot list my-vm --user
157
+
158
+ # Restore to previous state
159
+ clonebox snapshot restore my-vm --name "before-upgrade" --user
160
+
161
+ # Delete old snapshot
162
+ clonebox snapshot delete my-vm --name "before-upgrade" --user
163
+ ```
164
+
165
+ ### Health Checks
166
+
167
+ Configure health probes in `.clonebox.yaml`:
168
+
169
+ ```yaml
170
+ health_checks:
171
+ - name: nginx
172
+ type: http
173
+ url: http://localhost:80/health
174
+ expected_status: 200
175
+
176
+ - name: postgres
177
+ type: tcp
178
+ host: localhost
179
+ port: 5432
180
+
181
+ - name: redis
182
+ type: command
183
+ exec: "redis-cli ping"
184
+ expected_output: "PONG"
185
+ ```
186
+
187
+ Run health checks:
188
+
189
+ ```bash
190
+ clonebox health my-vm --user
191
+ ```
192
+
144
193
  ### Roadmap
145
194
 
146
- - **v1.2.0**: `clonebox exec` command, VM snapshots, snapshot restore
195
+ - **v1.2.0**: Resource limits, progress bars, secrets isolation
147
196
  - **v1.3.0**: Multi-VM orchestration, cluster mode
148
197
  - **v2.0.0**: Cloud provider support (AWS, GCP, Azure), Windows WSL2 support
149
198
 
@@ -63,6 +63,8 @@ CloneBox excels in scenarios where developers need:
63
63
  | 🎛️ Profiles System (`ml-dev`, `web-stack`) | ✅ Stable |
64
64
  | 🔍 Auto-detection (services, apps, paths) | ✅ Stable |
65
65
  | 🔒 P2P Secure Transfer (AES-256) | ✅ **NEW** |
66
+ | 📸 Snapshot Management | ✅ **NEW** |
67
+ | 🏥 Health Check System | ✅ **NEW** |
66
68
  | 🧪 95%+ Test Coverage | ✅ |
67
69
 
68
70
  ### P2P Secure VM Sharing
@@ -90,9 +92,55 @@ clonebox sync-key user@hostB # Sync encryption key
90
92
  clonebox list-remote user@hostB # List remote VMs
91
93
  ```
92
94
 
95
+ ### Snapshot Management
96
+
97
+ Save and restore VM states:
98
+
99
+ ```bash
100
+ # Create snapshot before risky operation
101
+ clonebox snapshot create my-vm --name "before-upgrade" --user
102
+
103
+ # List all snapshots
104
+ clonebox snapshot list my-vm --user
105
+
106
+ # Restore to previous state
107
+ clonebox snapshot restore my-vm --name "before-upgrade" --user
108
+
109
+ # Delete old snapshot
110
+ clonebox snapshot delete my-vm --name "before-upgrade" --user
111
+ ```
112
+
113
+ ### Health Checks
114
+
115
+ Configure health probes in `.clonebox.yaml`:
116
+
117
+ ```yaml
118
+ health_checks:
119
+ - name: nginx
120
+ type: http
121
+ url: http://localhost:80/health
122
+ expected_status: 200
123
+
124
+ - name: postgres
125
+ type: tcp
126
+ host: localhost
127
+ port: 5432
128
+
129
+ - name: redis
130
+ type: command
131
+ exec: "redis-cli ping"
132
+ expected_output: "PONG"
133
+ ```
134
+
135
+ Run health checks:
136
+
137
+ ```bash
138
+ clonebox health my-vm --user
139
+ ```
140
+
93
141
  ### Roadmap
94
142
 
95
- - **v1.2.0**: `clonebox exec` command, VM snapshots, snapshot restore
143
+ - **v1.2.0**: Resource limits, progress bars, secrets isolation
96
144
  - **v1.3.0**: Multi-VM orchestration, cluster mode
97
145
  - **v2.0.0**: Cloud provider support (AWS, GCP, Azure), Windows WSL2 support
98
146
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "clonebox"
7
- version = "1.1.3"
7
+ version = "1.1.5"
8
8
  description = "Clone your workstation environment to an isolated VM with selective apps, paths and services"
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -38,6 +38,7 @@ dependencies = [
38
38
  "pyyaml>=6.0",
39
39
  "pydantic>=2.0.0",
40
40
  "python-dotenv>=1.0.0",
41
+ "cryptography>=42.0.0",
41
42
  ]
42
43
 
43
44
  [project.optional-dependencies]
@@ -0,0 +1,217 @@
1
+ """libvirt hypervisor backend implementation."""
2
+
3
+ import base64
4
+ import json
5
+ import time
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ try:
9
+ import libvirt
10
+ except ImportError:
11
+ libvirt = None
12
+
13
+ from ..interfaces.hypervisor import HypervisorBackend, VMInfo
14
+
15
+
16
+ class LibvirtBackend(HypervisorBackend):
17
+ """libvirt hypervisor backend."""
18
+
19
+ name = "libvirt"
20
+
21
+ def __init__(
22
+ self,
23
+ uri: Optional[str] = None,
24
+ user_session: bool = False,
25
+ ):
26
+ self.uri = uri or ("qemu:///session" if user_session else "qemu:///system")
27
+ self._conn: Optional[libvirt.virConnect] = None
28
+
29
+ def connect(self) -> None:
30
+ """Establish connection to libvirt."""
31
+ if self._conn is not None:
32
+ try:
33
+ if self._conn.isAlive():
34
+ return
35
+ except libvirt.libvirtError:
36
+ pass
37
+
38
+ try:
39
+ self._conn = libvirt.open(self.uri)
40
+ except libvirt.libvirtError as e:
41
+ raise ConnectionError(f"Failed to connect to libvirt at {self.uri}: {e}")
42
+
43
+ def disconnect(self) -> None:
44
+ """Close connection."""
45
+ if self._conn:
46
+ try:
47
+ self._conn.close()
48
+ except libvirt.libvirtError:
49
+ pass
50
+ self._conn = None
51
+
52
+ @property
53
+ def conn(self) -> libvirt.virConnect:
54
+ """Get active libvirt connection."""
55
+ if self._conn is None:
56
+ self.connect()
57
+ return self._conn
58
+
59
+ def define_vm(self, config: Any) -> str:
60
+ """Define a new VM. Returns VM name."""
61
+ # This normally takes XML, but for DI we might want to pass config
62
+ # and let the backend handle XML generation if it's backend-specific.
63
+ # For now, we assume the caller provides the XML via config.xml or similar
64
+ # if they want to use this generic interface, or we refactor cloner to use backend.
65
+ if hasattr(config, "xml"):
66
+ domain = self.conn.defineXML(config.xml)
67
+ return domain.name()
68
+ raise ValueError("Config must provide XML for libvirt backend")
69
+
70
+ def undefine_vm(self, name: str) -> None:
71
+ """Remove VM definition."""
72
+ try:
73
+ domain = self.conn.lookupByName(name)
74
+ if domain.isActive():
75
+ domain.destroy()
76
+ domain.undefine()
77
+ except libvirt.libvirtError as e:
78
+ if "not found" not in str(e).lower():
79
+ raise
80
+
81
+ def start_vm(self, name: str) -> None:
82
+ """Start a VM."""
83
+ domain = self.conn.lookupByName(name)
84
+ if not domain.isActive():
85
+ domain.create()
86
+
87
+ def stop_vm(self, name: str, force: bool = False) -> None:
88
+ """Stop a VM."""
89
+ domain = self.conn.lookupByName(name)
90
+ if domain.isActive():
91
+ if force:
92
+ domain.destroy()
93
+ else:
94
+ domain.shutdown()
95
+
96
+ def get_vm_info(self, name: str) -> Optional[VMInfo]:
97
+ """Get VM information."""
98
+ try:
99
+ domain = self.conn.lookupByName(name)
100
+ except libvirt.libvirtError:
101
+ return None
102
+
103
+ info = domain.info()
104
+ state_map = {
105
+ libvirt.VIR_DOMAIN_RUNNING: "running",
106
+ libvirt.VIR_DOMAIN_BLOCKED: "blocked",
107
+ libvirt.VIR_DOMAIN_PAUSED: "paused",
108
+ libvirt.VIR_DOMAIN_SHUTDOWN: "shutdown",
109
+ libvirt.VIR_DOMAIN_SHUTOFF: "shutoff",
110
+ libvirt.VIR_DOMAIN_CRASHED: "crashed",
111
+ libvirt.VIR_DOMAIN_PMSUSPENDED: "pmsuspended",
112
+ }
113
+
114
+ return VMInfo(
115
+ name=domain.name(),
116
+ state=state_map.get(info[0], "unknown"),
117
+ uuid=domain.UUIDString(),
118
+ memory_mb=info[1] // 1024,
119
+ vcpus=info[3],
120
+ ip_addresses=self._get_ip_addresses(domain),
121
+ )
122
+
123
+ def list_vms(self) -> List[VMInfo]:
124
+ """List all VMs."""
125
+ vms = []
126
+ try:
127
+ # List all domains (running and defined)
128
+ for domain in self.conn.listAllDomains():
129
+ info = self.get_vm_info(domain.name())
130
+ if info:
131
+ vms.append(info)
132
+ except libvirt.libvirtError:
133
+ pass
134
+ return vms
135
+
136
+ def vm_exists(self, name: str) -> bool:
137
+ """Check if VM exists."""
138
+ try:
139
+ self.conn.lookupByName(name)
140
+ return True
141
+ except libvirt.libvirtError:
142
+ return False
143
+
144
+ def is_running(self, name: str) -> bool:
145
+ """Check if VM is running."""
146
+ try:
147
+ domain = self.conn.lookupByName(name)
148
+ return domain.isActive() == 1
149
+ except libvirt.libvirtError:
150
+ return False
151
+
152
+ def execute_command(
153
+ self,
154
+ name: str,
155
+ command: str,
156
+ timeout: int = 30,
157
+ ) -> Optional[str]:
158
+ """Execute command in VM via QEMU Guest Agent."""
159
+ domain = self.conn.lookupByName(name)
160
+
161
+ # Build QGA guest-exec command
162
+ qga_cmd = {
163
+ "execute": "guest-exec",
164
+ "arguments": {
165
+ "path": "/bin/bash",
166
+ "arg": ["-c", command],
167
+ "capture-output": True,
168
+ },
169
+ }
170
+
171
+ try:
172
+ result_json = domain.qemuAgentCommand(json.dumps(qga_cmd), timeout)
173
+ result_data = json.loads(result_json)
174
+ pid = result_data["return"]["pid"]
175
+
176
+ # Poll for completion
177
+ status_cmd = {"execute": "guest-exec-status", "arguments": {"pid": pid}}
178
+ start_time = time.time()
179
+ while time.time() - start_time < timeout:
180
+ status_json = domain.qemuAgentCommand(json.dumps(status_cmd), timeout)
181
+ status_data = json.loads(status_json)
182
+
183
+ if status_data["return"]["exited"]:
184
+ if "out-data" in status_data["return"]:
185
+ return base64.b64decode(status_data["return"]["out-data"]).decode(
186
+ "utf-8", errors="replace"
187
+ )
188
+ return ""
189
+
190
+ time.sleep(0.5)
191
+
192
+ return None # Timeout
193
+
194
+ except Exception:
195
+ return None
196
+
197
+ def _get_ip_addresses(self, domain) -> List[str]:
198
+ """Get IP addresses from domain via guest agent or lease."""
199
+ ips = []
200
+ try:
201
+ # Try guest agent first (more accurate)
202
+ ifaces = domain.interfaceAddresses(libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT)
203
+ for iface_data in ifaces.values():
204
+ for addr in iface_data.get("addrs", []):
205
+ if addr["type"] == 0: # IPv4
206
+ ips.append(addr["addr"])
207
+ except libvirt.libvirtError:
208
+ # Fallback to network leases
209
+ try:
210
+ ifaces = domain.interfaceAddresses(libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE)
211
+ for iface_data in ifaces.values():
212
+ for addr in iface_data.get("addrs", []):
213
+ if addr["type"] == 0:
214
+ ips.append(addr["addr"])
215
+ except libvirt.libvirtError:
216
+ pass
217
+ return ips
@@ -0,0 +1,52 @@
1
+ """QEMU disk manager implementation."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+ from typing import Any, Dict, Optional
6
+
7
+ from ..interfaces.disk import DiskManager
8
+
9
+
10
+ class QemuDiskManager(DiskManager):
11
+ """Manage VM disks using qemu-img."""
12
+
13
+ def create_disk(
14
+ self,
15
+ path: Path,
16
+ size_gb: int,
17
+ format: str = "qcow2",
18
+ backing_file: Optional[Path] = None,
19
+ ) -> Path:
20
+ """Create a disk image."""
21
+ cmd = ["qemu-img", "create", "-f", format]
22
+
23
+ if backing_file:
24
+ cmd.extend(["-b", str(backing_file), "-F", format])
25
+
26
+ cmd.extend([str(path), f"{size_gb}G"])
27
+
28
+ subprocess.run(cmd, check=True, capture_output=True)
29
+ return path
30
+
31
+ def resize_disk(self, path: Path, new_size_gb: int) -> None:
32
+ """Resize a disk image."""
33
+ cmd = ["qemu-img", "resize", str(path), f"{new_size_gb}G"]
34
+ subprocess.run(cmd, check=True, capture_output=True)
35
+
36
+ def get_disk_info(self, path: Path) -> Dict[str, Any]:
37
+ """Get disk image information."""
38
+ import json
39
+ cmd = ["qemu-img", "info", "--output=json", str(path)]
40
+ result = subprocess.run(cmd, check=True, capture_output=True, text=True)
41
+ return json.loads(result.stdout)
42
+
43
+ def create_snapshot(self, path: Path, snapshot_name: str) -> Path:
44
+ """Create internal disk snapshot."""
45
+ cmd = ["qemu-img", "snapshot", "-c", snapshot_name, str(path)]
46
+ subprocess.run(cmd, check=True, capture_output=True)
47
+ return path
48
+
49
+ def delete_disk(self, path: Path) -> None:
50
+ """Delete disk image."""
51
+ if path.exists():
52
+ path.unlink()
@@ -0,0 +1,56 @@
1
+ """Subprocess process runner implementation."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+ from typing import Dict, List, Optional
6
+
7
+ from ..interfaces.process import ProcessResult, ProcessRunner
8
+
9
+
10
+ class SubprocessRunner(ProcessRunner):
11
+ """Run processes using the subprocess module."""
12
+
13
+ def run(
14
+ self,
15
+ command: List[str],
16
+ capture_output: bool = True,
17
+ timeout: Optional[int] = None,
18
+ check: bool = True,
19
+ cwd: Optional[Path] = None,
20
+ env: Optional[Dict[str, str]] = None,
21
+ ) -> ProcessResult:
22
+ """Run a command."""
23
+ result = subprocess.run(
24
+ command,
25
+ capture_output=capture_output,
26
+ timeout=timeout,
27
+ check=check,
28
+ cwd=str(cwd) if cwd else None,
29
+ env=env,
30
+ text=True,
31
+ )
32
+ return ProcessResult(
33
+ returncode=result.returncode,
34
+ stdout=result.stdout,
35
+ stderr=result.stderr,
36
+ )
37
+
38
+ def run_shell(
39
+ self,
40
+ command: str,
41
+ capture_output: bool = True,
42
+ timeout: Optional[int] = None,
43
+ ) -> ProcessResult:
44
+ """Run a shell command."""
45
+ result = subprocess.run(
46
+ command,
47
+ shell=True,
48
+ capture_output=capture_output,
49
+ timeout=timeout,
50
+ text=True,
51
+ )
52
+ return ProcessResult(
53
+ returncode=result.returncode,
54
+ stdout=result.stdout,
55
+ stderr=result.stderr,
56
+ )