clonebox 1.1.4__py3-none-any.whl → 1.1.6__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.

Potentially problematic release.


This version of clonebox might be problematic. Click here for more details.

@@ -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
+ )