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.
- clonebox/backends/libvirt_backend.py +217 -0
- clonebox/backends/qemu_disk.py +52 -0
- clonebox/backends/subprocess_runner.py +56 -0
- clonebox/cli.py +227 -45
- clonebox/cloner.py +327 -189
- clonebox/di.py +176 -0
- clonebox/health/__init__.py +2 -1
- clonebox/health/manager.py +328 -0
- clonebox/health/probes.py +337 -0
- clonebox/interfaces/disk.py +40 -0
- clonebox/interfaces/hypervisor.py +89 -0
- clonebox/interfaces/network.py +33 -0
- clonebox/interfaces/process.py +46 -0
- clonebox/logging.py +125 -0
- clonebox/models.py +2 -2
- clonebox/monitor.py +1 -3
- clonebox/p2p.py +4 -2
- clonebox/resource_monitor.py +162 -0
- clonebox/resources.py +222 -0
- clonebox/rollback.py +172 -0
- clonebox/secrets.py +331 -0
- clonebox/snapshots/manager.py +3 -9
- clonebox/snapshots/models.py +2 -6
- clonebox/validator.py +34 -0
- {clonebox-1.1.4.dist-info → clonebox-1.1.6.dist-info}/METADATA +52 -2
- clonebox-1.1.6.dist-info/RECORD +42 -0
- clonebox-1.1.4.dist-info/RECORD +0 -27
- {clonebox-1.1.4.dist-info → clonebox-1.1.6.dist-info}/WHEEL +0 -0
- {clonebox-1.1.4.dist-info → clonebox-1.1.6.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.4.dist-info → clonebox-1.1.6.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.4.dist-info → clonebox-1.1.6.dist-info}/top_level.txt +0 -0
|
@@ -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
|
+
)
|