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/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", "StrictHostKeyChecking=no",
18
- "-o", "UserKnownHostsFile=/dev/null",
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