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
clonebox/p2p.py
CHANGED
|
@@ -14,8 +14,10 @@ class P2PManager:
|
|
|
14
14
|
|
|
15
15
|
def __init__(self, ssh_options: Optional[list] = None):
|
|
16
16
|
self.ssh_options = ssh_options or [
|
|
17
|
-
"-o",
|
|
18
|
-
"
|
|
17
|
+
"-o",
|
|
18
|
+
"StrictHostKeyChecking=no",
|
|
19
|
+
"-o",
|
|
20
|
+
"UserKnownHostsFile=/dev/null",
|
|
19
21
|
]
|
|
20
22
|
|
|
21
23
|
def _run_ssh(self, host: str, command: str) -> subprocess.CompletedProcess:
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Resource monitoring system for CloneBox."""
|
|
2
|
+
|
|
3
|
+
import xml.etree.ElementTree as ET
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import libvirt
|
|
10
|
+
except ImportError:
|
|
11
|
+
libvirt = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ResourceUsage:
|
|
16
|
+
"""Current resource usage of a VM."""
|
|
17
|
+
|
|
18
|
+
timestamp: datetime
|
|
19
|
+
cpu_time_ns: int
|
|
20
|
+
cpu_percent: float
|
|
21
|
+
memory_used_bytes: int
|
|
22
|
+
memory_total_bytes: int
|
|
23
|
+
memory_percent: float
|
|
24
|
+
swap_used_bytes: int
|
|
25
|
+
disk_read_bytes: int
|
|
26
|
+
disk_write_bytes: int
|
|
27
|
+
disk_read_requests: int
|
|
28
|
+
disk_write_requests: int
|
|
29
|
+
net_rx_bytes: int
|
|
30
|
+
net_tx_bytes: int
|
|
31
|
+
net_rx_packets: int
|
|
32
|
+
net_tx_packets: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ResourceMonitor:
|
|
36
|
+
"""Monitor VM resource usage using libvirt."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, conn: Optional[Any] = None):
|
|
39
|
+
self.conn = conn
|
|
40
|
+
self._prev_stats: Dict[str, Dict] = {}
|
|
41
|
+
|
|
42
|
+
def get_usage(self, vm_name: str) -> ResourceUsage:
|
|
43
|
+
"""Get current resource usage for a VM."""
|
|
44
|
+
if not self.conn:
|
|
45
|
+
raise RuntimeError("libvirt connection not available")
|
|
46
|
+
|
|
47
|
+
domain = self.conn.lookupByName(vm_name)
|
|
48
|
+
if not domain.isActive():
|
|
49
|
+
raise RuntimeError(f"VM '{vm_name}' is not running")
|
|
50
|
+
|
|
51
|
+
# CPU stats
|
|
52
|
+
cpu_stats = domain.getCPUStats(True)[0]
|
|
53
|
+
cpu_time = cpu_stats.get("cpu_time", 0)
|
|
54
|
+
cpu_percent = self._calculate_cpu_percent(vm_name, cpu_time)
|
|
55
|
+
|
|
56
|
+
# Memory stats
|
|
57
|
+
# Need to ensure memory balloon driver is active for accurate stats
|
|
58
|
+
mem_stats = domain.memoryStats()
|
|
59
|
+
memory_used = mem_stats.get("rss", 0) * 1024 # RSS is often most accurate for host view
|
|
60
|
+
memory_total = mem_stats.get("actual", 1) * 1024
|
|
61
|
+
if "unused" in mem_stats:
|
|
62
|
+
memory_used = (mem_stats["actual"] - mem_stats["unused"]) * 1024
|
|
63
|
+
|
|
64
|
+
memory_percent = (memory_used / memory_total * 100) if memory_total else 0
|
|
65
|
+
swap_used = mem_stats.get("swap_in", 0) * 1024
|
|
66
|
+
|
|
67
|
+
# Block and Network stats
|
|
68
|
+
disk_stats = self._get_disk_stats(domain)
|
|
69
|
+
net_stats = self._get_network_stats(domain)
|
|
70
|
+
|
|
71
|
+
return ResourceUsage(
|
|
72
|
+
timestamp=datetime.now(),
|
|
73
|
+
cpu_time_ns=cpu_time,
|
|
74
|
+
cpu_percent=cpu_percent,
|
|
75
|
+
memory_used_bytes=memory_used,
|
|
76
|
+
memory_total_bytes=memory_total,
|
|
77
|
+
memory_percent=memory_percent,
|
|
78
|
+
swap_used_bytes=swap_used,
|
|
79
|
+
**disk_stats,
|
|
80
|
+
**net_stats,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def _calculate_cpu_percent(self, vm_name: str, cpu_time: int) -> float:
|
|
84
|
+
"""Calculate CPU percentage from time delta."""
|
|
85
|
+
import time
|
|
86
|
+
|
|
87
|
+
now = time.time()
|
|
88
|
+
prev = self._prev_stats.get(vm_name, {})
|
|
89
|
+
prev_time = prev.get("cpu_time", cpu_time)
|
|
90
|
+
prev_timestamp = prev.get("timestamp", now)
|
|
91
|
+
|
|
92
|
+
# Update stored stats
|
|
93
|
+
self._prev_stats[vm_name] = {
|
|
94
|
+
"cpu_time": cpu_time,
|
|
95
|
+
"timestamp": now,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
time_delta = now - prev_timestamp
|
|
99
|
+
if time_delta <= 0:
|
|
100
|
+
return 0.0
|
|
101
|
+
|
|
102
|
+
cpu_delta = cpu_time - prev_time
|
|
103
|
+
# cpu_time is in nanoseconds, time_delta in seconds
|
|
104
|
+
# (delta_ns / (delta_sec * 1e9)) * 100
|
|
105
|
+
return (cpu_delta / (time_delta * 1e9)) * 100
|
|
106
|
+
|
|
107
|
+
def _get_disk_stats(self, domain) -> Dict[str, int]:
|
|
108
|
+
"""Get aggregated disk stats."""
|
|
109
|
+
stats = {
|
|
110
|
+
"disk_read_bytes": 0,
|
|
111
|
+
"disk_write_bytes": 0,
|
|
112
|
+
"disk_read_requests": 0,
|
|
113
|
+
"disk_write_requests": 0,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
xml = domain.XMLDesc()
|
|
117
|
+
tree = ET.fromstring(xml)
|
|
118
|
+
|
|
119
|
+
for disk in tree.findall(".//disk"):
|
|
120
|
+
target = disk.find("target")
|
|
121
|
+
if target is not None:
|
|
122
|
+
dev = target.get("dev")
|
|
123
|
+
try:
|
|
124
|
+
# blockStats returns: (read_req, read_bytes, write_req, write_bytes, errs)
|
|
125
|
+
ds = domain.blockStats(dev)
|
|
126
|
+
stats["disk_read_requests"] += ds[0]
|
|
127
|
+
stats["disk_read_bytes"] += ds[1]
|
|
128
|
+
stats["disk_write_requests"] += ds[2]
|
|
129
|
+
stats["disk_write_bytes"] += ds[3]
|
|
130
|
+
except Exception:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
return stats
|
|
134
|
+
|
|
135
|
+
def _get_network_stats(self, domain) -> Dict[str, int]:
|
|
136
|
+
"""Get aggregated network stats."""
|
|
137
|
+
stats = {
|
|
138
|
+
"net_rx_bytes": 0,
|
|
139
|
+
"net_tx_bytes": 0,
|
|
140
|
+
"net_rx_packets": 0,
|
|
141
|
+
"net_tx_packets": 0,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
xml = domain.XMLDesc()
|
|
145
|
+
tree = ET.fromstring(xml)
|
|
146
|
+
|
|
147
|
+
for iface in tree.findall(".//interface"):
|
|
148
|
+
target = iface.find("target")
|
|
149
|
+
if target is not None:
|
|
150
|
+
dev = target.get("dev")
|
|
151
|
+
try:
|
|
152
|
+
# interfaceStats returns: (rx_bytes, rx_packets, rx_errs, rx_drop,
|
|
153
|
+
# tx_bytes, tx_packets, tx_errs, tx_drop)
|
|
154
|
+
ns = domain.interfaceStats(dev)
|
|
155
|
+
stats["net_rx_bytes"] += ns[0]
|
|
156
|
+
stats["net_rx_packets"] += ns[1]
|
|
157
|
+
stats["net_tx_bytes"] += ns[4]
|
|
158
|
+
stats["net_tx_packets"] += ns[5]
|
|
159
|
+
except Exception:
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
return stats
|
clonebox/resources.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Resource limits and monitoring for CloneBox VMs.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import List, Optional, Union
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_size(value: Union[str, int]) -> int:
|
|
11
|
+
"""Parse size string like '8G', '512M' to bytes."""
|
|
12
|
+
if isinstance(value, int):
|
|
13
|
+
return value
|
|
14
|
+
|
|
15
|
+
value = str(value).strip().upper()
|
|
16
|
+
multipliers = {
|
|
17
|
+
"K": 1024,
|
|
18
|
+
"M": 1024**2,
|
|
19
|
+
"G": 1024**3,
|
|
20
|
+
"T": 1024**4,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if value[-1] in multipliers:
|
|
24
|
+
try:
|
|
25
|
+
return int(float(value[:-1]) * multipliers[value[-1]])
|
|
26
|
+
except ValueError:
|
|
27
|
+
return 0
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
return int(value)
|
|
31
|
+
except ValueError:
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_bandwidth(value: Union[str, int]) -> int:
|
|
36
|
+
"""Parse bandwidth like '100Mbps' to bits/sec."""
|
|
37
|
+
if isinstance(value, int):
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
value = str(value).strip().lower()
|
|
41
|
+
|
|
42
|
+
if value.endswith("gbps"):
|
|
43
|
+
return int(float(value[:-4]) * 1_000_000_000)
|
|
44
|
+
elif value.endswith("mbps"):
|
|
45
|
+
return int(float(value[:-4]) * 1_000_000)
|
|
46
|
+
elif value.endswith("kbps"):
|
|
47
|
+
return int(float(value[:-4]) * 1_000)
|
|
48
|
+
elif value.endswith("bps"):
|
|
49
|
+
return int(float(value[:-3]))
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
return int(value)
|
|
53
|
+
except ValueError:
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class CPULimits:
|
|
59
|
+
"""CPU resource limits."""
|
|
60
|
+
|
|
61
|
+
vcpus: int = 2
|
|
62
|
+
shares: int = 1024 # CFS shares (weight)
|
|
63
|
+
period: int = 100000 # CFS period (microseconds)
|
|
64
|
+
quota: Optional[int] = None # CFS quota (microseconds)
|
|
65
|
+
pin: Optional[List[int]] = None # CPU pinning
|
|
66
|
+
|
|
67
|
+
def get_max_percent(self) -> float:
|
|
68
|
+
"""Get max CPU percentage."""
|
|
69
|
+
if self.quota:
|
|
70
|
+
return (self.quota / self.period) * 100
|
|
71
|
+
return self.vcpus * 100
|
|
72
|
+
|
|
73
|
+
def to_libvirt_xml(self) -> str:
|
|
74
|
+
"""Generate libvirt cputune XML."""
|
|
75
|
+
elements = []
|
|
76
|
+
elements.append(f" <shares>{self.shares}</shares>")
|
|
77
|
+
|
|
78
|
+
if self.quota:
|
|
79
|
+
elements.append(f" <period>{self.period}</period>")
|
|
80
|
+
elements.append(f" <quota>{self.quota}</quota>")
|
|
81
|
+
|
|
82
|
+
if self.pin:
|
|
83
|
+
for idx, cpu in enumerate(self.pin):
|
|
84
|
+
elements.append(f' <vcpupin vcpu="{idx}" cpuset="{cpu}"/>')
|
|
85
|
+
|
|
86
|
+
if not elements:
|
|
87
|
+
return ""
|
|
88
|
+
|
|
89
|
+
return f" <cputune>\n{chr(10).join(elements)}\n </cputune>"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class MemoryLimits:
|
|
94
|
+
"""Memory resource limits."""
|
|
95
|
+
|
|
96
|
+
limit: str = "4G" # Hard limit
|
|
97
|
+
soft_limit: Optional[str] = None # Soft limit
|
|
98
|
+
swap: Optional[str] = None # Swap limit
|
|
99
|
+
hugepages: bool = False # Use hugepages
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def limit_bytes(self) -> int:
|
|
103
|
+
return parse_size(self.limit)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def soft_limit_bytes(self) -> Optional[int]:
|
|
107
|
+
return parse_size(self.soft_limit) if self.soft_limit else None
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def swap_bytes(self) -> Optional[int]:
|
|
111
|
+
return parse_size(self.swap) if self.swap else None
|
|
112
|
+
|
|
113
|
+
def to_libvirt_xml(self) -> str:
|
|
114
|
+
"""Generate libvirt memtune XML."""
|
|
115
|
+
elements = []
|
|
116
|
+
|
|
117
|
+
# Convert to KiB for libvirt
|
|
118
|
+
limit_kib = self.limit_bytes // 1024
|
|
119
|
+
elements.append(f" <hard_limit unit='KiB'>{limit_kib}</hard_limit>")
|
|
120
|
+
|
|
121
|
+
if self.soft_limit_bytes:
|
|
122
|
+
soft_kib = self.soft_limit_bytes // 1024
|
|
123
|
+
elements.append(f" <soft_limit unit='KiB'>{soft_kib}</soft_limit>")
|
|
124
|
+
|
|
125
|
+
if self.swap_bytes:
|
|
126
|
+
swap_kib = self.swap_bytes // 1024
|
|
127
|
+
elements.append(f" <swap_hard_limit unit='KiB'>{swap_kib}</swap_hard_limit>")
|
|
128
|
+
|
|
129
|
+
return f" <memtune>\n{chr(10).join(elements)}\n </memtune>"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class DiskLimits:
|
|
134
|
+
"""Disk I/O limits."""
|
|
135
|
+
|
|
136
|
+
read_bps: Optional[str] = None # Read bytes/sec
|
|
137
|
+
write_bps: Optional[str] = None # Write bytes/sec
|
|
138
|
+
read_iops: Optional[int] = None # Read IOPS
|
|
139
|
+
write_iops: Optional[int] = None # Write IOPS
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def read_bps_bytes(self) -> Optional[int]:
|
|
143
|
+
return parse_size(self.read_bps) if self.read_bps else None
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def write_bps_bytes(self) -> Optional[int]:
|
|
147
|
+
return parse_size(self.write_bps) if self.write_bps else None
|
|
148
|
+
|
|
149
|
+
def to_libvirt_xml(self) -> str:
|
|
150
|
+
"""Generate libvirt iotune XML for disk device."""
|
|
151
|
+
elements = []
|
|
152
|
+
|
|
153
|
+
if self.read_bps_bytes:
|
|
154
|
+
elements.append(f" <read_bytes_sec>{self.read_bps_bytes}</read_bytes_sec>")
|
|
155
|
+
if self.write_bps_bytes:
|
|
156
|
+
elements.append(f" <write_bytes_sec>{self.write_bps_bytes}</write_bytes_sec>")
|
|
157
|
+
if self.read_iops:
|
|
158
|
+
elements.append(f" <read_iops_sec>{self.read_iops}</read_iops_sec>")
|
|
159
|
+
if self.write_iops:
|
|
160
|
+
elements.append(f" <write_iops_sec>{self.write_iops}</write_iops_sec>")
|
|
161
|
+
|
|
162
|
+
if elements:
|
|
163
|
+
return f" <iotune>\n{chr(10).join(elements)}\n </iotune>"
|
|
164
|
+
return ""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass
|
|
168
|
+
class NetworkLimits:
|
|
169
|
+
"""Network bandwidth limits."""
|
|
170
|
+
|
|
171
|
+
inbound: Optional[str] = None # Inbound bandwidth
|
|
172
|
+
outbound: Optional[str] = None # Outbound bandwidth
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def inbound_kbps(self) -> Optional[int]:
|
|
176
|
+
if self.inbound:
|
|
177
|
+
return parse_bandwidth(self.inbound) // 1000 # Convert to kbps
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def outbound_kbps(self) -> Optional[int]:
|
|
182
|
+
if self.outbound:
|
|
183
|
+
return parse_bandwidth(self.outbound) // 1000
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
def to_libvirt_xml(self) -> str:
|
|
187
|
+
"""Generate libvirt bandwidth XML for interface."""
|
|
188
|
+
elements = []
|
|
189
|
+
|
|
190
|
+
if self.inbound_kbps:
|
|
191
|
+
# average in KB/s (libvirt uses kbps but sometimes expects KB/s depending on version,
|
|
192
|
+
# usually it's average in kbytes/s)
|
|
193
|
+
avg_kbs = self.inbound_kbps // 8
|
|
194
|
+
elements.append(f' <inbound average="{avg_kbs}"/>')
|
|
195
|
+
|
|
196
|
+
if self.outbound_kbps:
|
|
197
|
+
avg_kbs = self.outbound_kbps // 8
|
|
198
|
+
elements.append(f' <outbound average="{avg_kbs}"/>')
|
|
199
|
+
|
|
200
|
+
if elements:
|
|
201
|
+
return f" <bandwidth>\n{chr(10).join(elements)}\n </bandwidth>"
|
|
202
|
+
return ""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass
|
|
206
|
+
class ResourceLimits:
|
|
207
|
+
"""Combined resource limits for a VM."""
|
|
208
|
+
|
|
209
|
+
cpu: CPULimits = field(default_factory=CPULimits)
|
|
210
|
+
memory: MemoryLimits = field(default_factory=MemoryLimits)
|
|
211
|
+
disk: DiskLimits = field(default_factory=DiskLimits)
|
|
212
|
+
network: NetworkLimits = field(default_factory=NetworkLimits)
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def from_dict(cls, data: dict) -> "ResourceLimits":
|
|
216
|
+
"""Create from config dict."""
|
|
217
|
+
return cls(
|
|
218
|
+
cpu=CPULimits(**data.get("cpu", {})),
|
|
219
|
+
memory=MemoryLimits(**data.get("memory", {})),
|
|
220
|
+
disk=DiskLimits(**data.get("disk", {})),
|
|
221
|
+
network=NetworkLimits(**data.get("network", {})),
|
|
222
|
+
)
|
clonebox/rollback.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Transactional rollback support for CloneBox operations.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import shutil
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable, List, Optional
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RollbackAction:
|
|
17
|
+
"""A single rollback action."""
|
|
18
|
+
|
|
19
|
+
description: str
|
|
20
|
+
action: Callable[[], None]
|
|
21
|
+
critical: bool = True # If True, failure stops rollback chain
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class RollbackContext:
|
|
26
|
+
"""
|
|
27
|
+
Context manager for transactional operations with automatic rollback.
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
with RollbackContext("create VM") as ctx:
|
|
31
|
+
ctx.add_file(disk_path) # Will be deleted on error
|
|
32
|
+
ctx.add_directory(vm_dir) # Will be deleted on error
|
|
33
|
+
ctx.add_action("stop VM", lambda: cloner.stop_vm(name))
|
|
34
|
+
|
|
35
|
+
# If any exception occurs, all registered items are cleaned up
|
|
36
|
+
do_risky_operation()
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
operation_name: str
|
|
40
|
+
_files: List[Path] = field(default_factory=list)
|
|
41
|
+
_directories: List[Path] = field(default_factory=list)
|
|
42
|
+
_actions: List[RollbackAction] = field(default_factory=list)
|
|
43
|
+
_committed: bool = False
|
|
44
|
+
_console: Optional[Any] = None
|
|
45
|
+
|
|
46
|
+
def add_file(self, path: Path, description: Optional[str] = None) -> Path:
|
|
47
|
+
"""Register a file for cleanup on rollback."""
|
|
48
|
+
self._files.append(path)
|
|
49
|
+
log.debug(f"Registered file for rollback: {path}")
|
|
50
|
+
return path
|
|
51
|
+
|
|
52
|
+
def add_directory(self, path: Path, description: Optional[str] = None) -> Path:
|
|
53
|
+
"""Register a directory for cleanup on rollback."""
|
|
54
|
+
self._directories.append(path)
|
|
55
|
+
log.debug(f"Registered directory for rollback: {path}")
|
|
56
|
+
return path
|
|
57
|
+
|
|
58
|
+
def add_action(
|
|
59
|
+
self, description: str, action: Callable[[], None], critical: bool = False
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Register a custom rollback action."""
|
|
62
|
+
self._actions.append(
|
|
63
|
+
RollbackAction(description=description, action=action, critical=critical)
|
|
64
|
+
)
|
|
65
|
+
log.debug(f"Registered action for rollback: {description}")
|
|
66
|
+
|
|
67
|
+
def add_libvirt_domain(self, conn, domain_name: str) -> None:
|
|
68
|
+
"""Register a libvirt domain for cleanup."""
|
|
69
|
+
|
|
70
|
+
def cleanup_domain():
|
|
71
|
+
try:
|
|
72
|
+
dom = conn.lookupByName(domain_name)
|
|
73
|
+
if dom.isActive():
|
|
74
|
+
dom.destroy()
|
|
75
|
+
dom.undefine()
|
|
76
|
+
except Exception as e:
|
|
77
|
+
log.warning(f"Failed to cleanup domain {domain_name}: {e}")
|
|
78
|
+
|
|
79
|
+
self._actions.append(
|
|
80
|
+
RollbackAction(
|
|
81
|
+
description=f"undefine domain {domain_name}",
|
|
82
|
+
action=cleanup_domain,
|
|
83
|
+
critical=False,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def commit(self) -> None:
|
|
88
|
+
"""Mark operation as successful, preventing rollback."""
|
|
89
|
+
self._committed = True
|
|
90
|
+
log.info(f"Operation '{self.operation_name}' committed successfully")
|
|
91
|
+
|
|
92
|
+
def rollback(self) -> List[str]:
|
|
93
|
+
"""Execute rollback actions. Returns list of errors."""
|
|
94
|
+
errors = []
|
|
95
|
+
|
|
96
|
+
if self._console:
|
|
97
|
+
self._console.print(
|
|
98
|
+
f"[yellow]Rolling back '{self.operation_name}'...[/yellow]"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Execute custom actions first (in reverse order)
|
|
102
|
+
for action in reversed(self._actions):
|
|
103
|
+
try:
|
|
104
|
+
log.info(f"Rollback action: {action.description}")
|
|
105
|
+
action.action()
|
|
106
|
+
except Exception as e:
|
|
107
|
+
error_msg = f"Rollback action '{action.description}' failed: {e}"
|
|
108
|
+
errors.append(error_msg)
|
|
109
|
+
log.error(error_msg)
|
|
110
|
+
if action.critical:
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
# Delete files
|
|
114
|
+
for path in reversed(self._files):
|
|
115
|
+
try:
|
|
116
|
+
if path.exists():
|
|
117
|
+
path.unlink()
|
|
118
|
+
log.info(f"Deleted file: {path}")
|
|
119
|
+
except Exception as e:
|
|
120
|
+
errors.append(f"Failed to delete {path}: {e}")
|
|
121
|
+
|
|
122
|
+
# Delete directories
|
|
123
|
+
for path in reversed(self._directories):
|
|
124
|
+
try:
|
|
125
|
+
if path.exists():
|
|
126
|
+
shutil.rmtree(path)
|
|
127
|
+
log.info(f"Deleted directory: {path}")
|
|
128
|
+
except Exception as e:
|
|
129
|
+
errors.append(f"Failed to delete {path}: {e}")
|
|
130
|
+
|
|
131
|
+
return errors
|
|
132
|
+
|
|
133
|
+
def __enter__(self) -> "RollbackContext":
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
|
|
137
|
+
if exc_type is not None and not self._committed:
|
|
138
|
+
errors = self.rollback()
|
|
139
|
+
if errors and self._console:
|
|
140
|
+
self._console.print("[red]Rollback completed with errors:[/red]")
|
|
141
|
+
for error in errors:
|
|
142
|
+
self._console.print(f" [dim]- {error}[/dim]")
|
|
143
|
+
return False # Don't suppress the exception
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@contextmanager
|
|
147
|
+
def vm_creation_transaction(cloner: Any, config: Any, console: Optional[Any] = None):
|
|
148
|
+
"""
|
|
149
|
+
Context manager for VM creation with automatic rollback.
|
|
150
|
+
|
|
151
|
+
Usage:
|
|
152
|
+
with vm_creation_transaction(cloner, config, console) as ctx:
|
|
153
|
+
vm_dir = ctx.add_directory(images_dir / config.name)
|
|
154
|
+
vm_dir.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
|
|
156
|
+
disk_path = ctx.add_file(vm_dir / "root.qcow2")
|
|
157
|
+
create_disk(disk_path)
|
|
158
|
+
|
|
159
|
+
ctx.add_libvirt_domain(cloner.conn, config.name)
|
|
160
|
+
cloner.conn.defineXML(xml)
|
|
161
|
+
|
|
162
|
+
ctx.commit() # Success!
|
|
163
|
+
"""
|
|
164
|
+
ctx = RollbackContext(
|
|
165
|
+
operation_name=f"create VM '{config.name}'", _console=console
|
|
166
|
+
)
|
|
167
|
+
try:
|
|
168
|
+
yield ctx
|
|
169
|
+
except Exception:
|
|
170
|
+
if not ctx._committed:
|
|
171
|
+
ctx.rollback()
|
|
172
|
+
raise
|