clonebox 1.1.2__py3-none-any.whl → 1.1.4__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/cli.py +359 -0
- clonebox/exporter.py +189 -0
- clonebox/health/__init__.py +16 -0
- clonebox/health/models.py +194 -0
- clonebox/importer.py +220 -0
- clonebox/monitor.py +269 -0
- clonebox/p2p.py +184 -0
- clonebox/snapshots/__init__.py +12 -0
- clonebox/snapshots/manager.py +355 -0
- clonebox/snapshots/models.py +187 -0
- {clonebox-1.1.2.dist-info → clonebox-1.1.4.dist-info}/METADATA +28 -2
- clonebox-1.1.4.dist-info/RECORD +27 -0
- clonebox-1.1.2.dist-info/RECORD +0 -18
- {clonebox-1.1.2.dist-info → clonebox-1.1.4.dist-info}/WHEEL +0 -0
- {clonebox-1.1.2.dist-info → clonebox-1.1.4.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.2.dist-info → clonebox-1.1.4.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.2.dist-info → clonebox-1.1.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Data models for health check system."""
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HealthStatus(Enum):
|
|
11
|
+
"""Health check status."""
|
|
12
|
+
|
|
13
|
+
HEALTHY = "healthy"
|
|
14
|
+
UNHEALTHY = "unhealthy"
|
|
15
|
+
DEGRADED = "degraded"
|
|
16
|
+
UNKNOWN = "unknown"
|
|
17
|
+
TIMEOUT = "timeout"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProbeType(Enum):
|
|
21
|
+
"""Type of health probe."""
|
|
22
|
+
|
|
23
|
+
HTTP = "http"
|
|
24
|
+
TCP = "tcp"
|
|
25
|
+
COMMAND = "command"
|
|
26
|
+
SCRIPT = "script"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ProbeConfig:
|
|
31
|
+
"""Configuration for a health probe."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
probe_type: ProbeType
|
|
35
|
+
enabled: bool = True
|
|
36
|
+
|
|
37
|
+
# Timing
|
|
38
|
+
timeout_seconds: float = 5.0
|
|
39
|
+
interval_seconds: float = 30.0
|
|
40
|
+
retries: int = 3
|
|
41
|
+
retry_delay_seconds: float = 1.0
|
|
42
|
+
|
|
43
|
+
# HTTP probe
|
|
44
|
+
url: Optional[str] = None
|
|
45
|
+
method: str = "GET"
|
|
46
|
+
expected_status: int = 200
|
|
47
|
+
expected_body: Optional[str] = None
|
|
48
|
+
expected_json: Optional[Dict[str, Any]] = None
|
|
49
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
# TCP probe
|
|
52
|
+
host: str = "localhost"
|
|
53
|
+
port: Optional[int] = None
|
|
54
|
+
|
|
55
|
+
# Command probe
|
|
56
|
+
command: Optional[str] = None
|
|
57
|
+
expected_output: Optional[str] = None
|
|
58
|
+
expected_exit_code: int = 0
|
|
59
|
+
|
|
60
|
+
# Script probe
|
|
61
|
+
script_path: Optional[str] = None
|
|
62
|
+
|
|
63
|
+
# Thresholds
|
|
64
|
+
failure_threshold: int = 3 # Consecutive failures before unhealthy
|
|
65
|
+
success_threshold: int = 1 # Consecutive successes before healthy
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
68
|
+
"""Convert to dictionary."""
|
|
69
|
+
return {
|
|
70
|
+
"name": self.name,
|
|
71
|
+
"type": self.probe_type.value,
|
|
72
|
+
"enabled": self.enabled,
|
|
73
|
+
"timeout": self.timeout_seconds,
|
|
74
|
+
"interval": self.interval_seconds,
|
|
75
|
+
"retries": self.retries,
|
|
76
|
+
"url": self.url,
|
|
77
|
+
"method": self.method,
|
|
78
|
+
"expected_status": self.expected_status,
|
|
79
|
+
"expected_body": self.expected_body,
|
|
80
|
+
"expected_json": self.expected_json,
|
|
81
|
+
"headers": self.headers,
|
|
82
|
+
"host": self.host,
|
|
83
|
+
"port": self.port,
|
|
84
|
+
"command": self.command,
|
|
85
|
+
"expected_output": self.expected_output,
|
|
86
|
+
"expected_exit_code": self.expected_exit_code,
|
|
87
|
+
"script_path": self.script_path,
|
|
88
|
+
"failure_threshold": self.failure_threshold,
|
|
89
|
+
"success_threshold": self.success_threshold,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ProbeConfig":
|
|
94
|
+
"""Create from dictionary."""
|
|
95
|
+
return cls(
|
|
96
|
+
name=data["name"],
|
|
97
|
+
probe_type=ProbeType(data.get("type", "command")),
|
|
98
|
+
enabled=data.get("enabled", True),
|
|
99
|
+
timeout_seconds=data.get("timeout", 5.0),
|
|
100
|
+
interval_seconds=data.get("interval", 30.0),
|
|
101
|
+
retries=data.get("retries", 3),
|
|
102
|
+
url=data.get("url"),
|
|
103
|
+
method=data.get("method", "GET"),
|
|
104
|
+
expected_status=data.get("expected_status", 200),
|
|
105
|
+
expected_body=data.get("expected_body"),
|
|
106
|
+
expected_json=data.get("expected_json"),
|
|
107
|
+
headers=data.get("headers", {}),
|
|
108
|
+
host=data.get("host", "localhost"),
|
|
109
|
+
port=data.get("port"),
|
|
110
|
+
command=data.get("command") or data.get("exec"),
|
|
111
|
+
expected_output=data.get("expected_output"),
|
|
112
|
+
expected_exit_code=data.get("expected_exit_code", data.get("exit_code", 0)),
|
|
113
|
+
script_path=data.get("script_path") or data.get("path"),
|
|
114
|
+
failure_threshold=data.get("failure_threshold", 3),
|
|
115
|
+
success_threshold=data.get("success_threshold", 1),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class HealthCheckResult:
|
|
121
|
+
"""Result of a health check."""
|
|
122
|
+
|
|
123
|
+
probe_name: str
|
|
124
|
+
status: HealthStatus
|
|
125
|
+
checked_at: datetime
|
|
126
|
+
duration_ms: float
|
|
127
|
+
|
|
128
|
+
message: Optional[str] = None
|
|
129
|
+
error: Optional[str] = None
|
|
130
|
+
details: Dict[str, Any] = field(default_factory=dict)
|
|
131
|
+
|
|
132
|
+
# Response info (for HTTP)
|
|
133
|
+
response_code: Optional[int] = None
|
|
134
|
+
response_body: Optional[str] = None
|
|
135
|
+
|
|
136
|
+
# Command info
|
|
137
|
+
exit_code: Optional[int] = None
|
|
138
|
+
stdout: Optional[str] = None
|
|
139
|
+
stderr: Optional[str] = None
|
|
140
|
+
|
|
141
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
142
|
+
"""Convert to dictionary."""
|
|
143
|
+
return {
|
|
144
|
+
"probe_name": self.probe_name,
|
|
145
|
+
"status": self.status.value,
|
|
146
|
+
"checked_at": self.checked_at.isoformat(),
|
|
147
|
+
"duration_ms": self.duration_ms,
|
|
148
|
+
"message": self.message,
|
|
149
|
+
"error": self.error,
|
|
150
|
+
"details": self.details,
|
|
151
|
+
"response_code": self.response_code,
|
|
152
|
+
"exit_code": self.exit_code,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def is_healthy(self) -> bool:
|
|
157
|
+
"""Check if result indicates healthy status."""
|
|
158
|
+
return self.status == HealthStatus.HEALTHY
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class VMHealthState:
|
|
163
|
+
"""Aggregated health state for a VM."""
|
|
164
|
+
|
|
165
|
+
vm_name: str
|
|
166
|
+
overall_status: HealthStatus
|
|
167
|
+
last_check: datetime
|
|
168
|
+
check_results: List[HealthCheckResult] = field(default_factory=list)
|
|
169
|
+
|
|
170
|
+
# Counters
|
|
171
|
+
consecutive_failures: int = 0
|
|
172
|
+
consecutive_successes: int = 0
|
|
173
|
+
total_checks: int = 0
|
|
174
|
+
total_failures: int = 0
|
|
175
|
+
|
|
176
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
177
|
+
"""Convert to dictionary."""
|
|
178
|
+
return {
|
|
179
|
+
"vm_name": self.vm_name,
|
|
180
|
+
"overall_status": self.overall_status.value,
|
|
181
|
+
"last_check": self.last_check.isoformat(),
|
|
182
|
+
"check_results": [r.to_dict() for r in self.check_results],
|
|
183
|
+
"consecutive_failures": self.consecutive_failures,
|
|
184
|
+
"consecutive_successes": self.consecutive_successes,
|
|
185
|
+
"total_checks": self.total_checks,
|
|
186
|
+
"total_failures": self.total_failures,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def failure_rate(self) -> float:
|
|
191
|
+
"""Calculate failure rate percentage."""
|
|
192
|
+
if self.total_checks == 0:
|
|
193
|
+
return 0.0
|
|
194
|
+
return (self.total_failures / self.total_checks) * 100
|
clonebox/importer.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
VM Importer - Import VM with path reconfiguration and decryption.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tarfile
|
|
9
|
+
import tempfile
|
|
10
|
+
import xml.etree.ElementTree as ET
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from cryptography.fernet import Fernet
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import libvirt
|
|
18
|
+
except ImportError:
|
|
19
|
+
libvirt = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class VMImporter:
|
|
23
|
+
"""Import VM with disk path reconfiguration."""
|
|
24
|
+
|
|
25
|
+
DEFAULT_DISK_DIR = Path("/var/lib/libvirt/images")
|
|
26
|
+
|
|
27
|
+
def __init__(self, conn_uri: str = "qemu:///system"):
|
|
28
|
+
self.conn_uri = conn_uri
|
|
29
|
+
self._conn = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def conn(self):
|
|
33
|
+
if self._conn is None:
|
|
34
|
+
if libvirt is None:
|
|
35
|
+
raise RuntimeError("libvirt-python not installed")
|
|
36
|
+
self._conn = libvirt.open(self.conn_uri)
|
|
37
|
+
return self._conn
|
|
38
|
+
|
|
39
|
+
def import_vm(
|
|
40
|
+
self,
|
|
41
|
+
archive_path: Path,
|
|
42
|
+
import_user_data: bool = False,
|
|
43
|
+
import_app_data: bool = False,
|
|
44
|
+
new_name: Optional[str] = None,
|
|
45
|
+
disk_dir: Optional[Path] = None,
|
|
46
|
+
) -> str:
|
|
47
|
+
"""Import VM from archive with full path reconfiguration."""
|
|
48
|
+
disk_dir = disk_dir or self.DEFAULT_DISK_DIR
|
|
49
|
+
|
|
50
|
+
with tempfile.TemporaryDirectory(prefix="clonebox-import-") as tmp_dir:
|
|
51
|
+
tmp_path = Path(tmp_dir)
|
|
52
|
+
|
|
53
|
+
# Extract archive
|
|
54
|
+
with tarfile.open(archive_path) as tar:
|
|
55
|
+
tar.extractall(tmp_path)
|
|
56
|
+
|
|
57
|
+
# Find XML file
|
|
58
|
+
xml_files = list(tmp_path.glob("*.xml"))
|
|
59
|
+
if not xml_files:
|
|
60
|
+
raise FileNotFoundError("No XML configuration found in archive")
|
|
61
|
+
xml_file = xml_files[0]
|
|
62
|
+
vm_name = xml_file.stem
|
|
63
|
+
|
|
64
|
+
# Move disks to libvirt images directory
|
|
65
|
+
disks_dir = tmp_path / "disks"
|
|
66
|
+
disk_mapping = {}
|
|
67
|
+
if disks_dir.exists():
|
|
68
|
+
for disk_file in disks_dir.iterdir():
|
|
69
|
+
dest = disk_dir / disk_file.name
|
|
70
|
+
shutil.copy2(disk_file, dest)
|
|
71
|
+
disk_mapping[disk_file.name] = dest
|
|
72
|
+
print(f" 💾 Copied disk: {dest}")
|
|
73
|
+
|
|
74
|
+
# Reconfigure disk paths in XML
|
|
75
|
+
vm_xml = self._reconfigure_paths(xml_file, disk_mapping, new_name)
|
|
76
|
+
|
|
77
|
+
# Define and create VM
|
|
78
|
+
vm = self.conn.defineXML(vm_xml)
|
|
79
|
+
final_name = new_name or vm_name
|
|
80
|
+
print(f" ✅ VM defined: {final_name}")
|
|
81
|
+
|
|
82
|
+
# Import user/app data
|
|
83
|
+
if import_user_data:
|
|
84
|
+
self._import_user_data(tmp_path / "user-data")
|
|
85
|
+
if import_app_data:
|
|
86
|
+
self._import_app_data(tmp_path / "app-data")
|
|
87
|
+
|
|
88
|
+
# Start VM
|
|
89
|
+
vm.create()
|
|
90
|
+
print(f" 🚀 VM started: {final_name}")
|
|
91
|
+
|
|
92
|
+
return final_name
|
|
93
|
+
|
|
94
|
+
def _reconfigure_paths(
|
|
95
|
+
self,
|
|
96
|
+
xml_file: Path,
|
|
97
|
+
disk_mapping: dict,
|
|
98
|
+
new_name: Optional[str] = None,
|
|
99
|
+
) -> str:
|
|
100
|
+
"""Update disk paths and optionally rename VM."""
|
|
101
|
+
tree = ET.parse(xml_file)
|
|
102
|
+
root = tree.getroot()
|
|
103
|
+
|
|
104
|
+
# Update name if requested
|
|
105
|
+
if new_name:
|
|
106
|
+
name_elem = root.find("name")
|
|
107
|
+
if name_elem is not None:
|
|
108
|
+
name_elem.text = new_name
|
|
109
|
+
|
|
110
|
+
# Update disk paths
|
|
111
|
+
for disk in root.findall(".//disk[@type='file']"):
|
|
112
|
+
source = disk.find(".//source")
|
|
113
|
+
if source is not None:
|
|
114
|
+
old_path = source.get("file")
|
|
115
|
+
if old_path:
|
|
116
|
+
disk_name = Path(old_path).name
|
|
117
|
+
if disk_name in disk_mapping:
|
|
118
|
+
source.set("file", str(disk_mapping[disk_name]))
|
|
119
|
+
print(f" 🔄 Remapped: {disk_name} → {disk_mapping[disk_name]}")
|
|
120
|
+
|
|
121
|
+
return ET.tostring(root, encoding="unicode")
|
|
122
|
+
|
|
123
|
+
def _import_user_data(self, user_data_dir: Path) -> None:
|
|
124
|
+
"""Restore user data."""
|
|
125
|
+
if user_data_dir.exists():
|
|
126
|
+
for item in user_data_dir.iterdir():
|
|
127
|
+
dest = Path.home() / item.name
|
|
128
|
+
if item.is_dir():
|
|
129
|
+
shutil.copytree(item, dest, dirs_exist_ok=True)
|
|
130
|
+
else:
|
|
131
|
+
shutil.copy2(item, dest)
|
|
132
|
+
print(f" 👤 Restored: {dest}")
|
|
133
|
+
|
|
134
|
+
def _import_app_data(self, app_data_dir: Path) -> None:
|
|
135
|
+
"""Restore application data."""
|
|
136
|
+
if app_data_dir.exists():
|
|
137
|
+
for item in app_data_dir.iterdir():
|
|
138
|
+
# Map back to original paths
|
|
139
|
+
dest_map = {
|
|
140
|
+
"projects": Path.home() / "projects",
|
|
141
|
+
".docker": Path.home() / ".docker",
|
|
142
|
+
"myapp": Path("/opt/myapp"),
|
|
143
|
+
"www": Path("/var/www"),
|
|
144
|
+
"docker": Path("/srv/docker"),
|
|
145
|
+
}
|
|
146
|
+
dest = dest_map.get(item.name, Path.home() / item.name)
|
|
147
|
+
try:
|
|
148
|
+
if item.is_dir():
|
|
149
|
+
shutil.copytree(item, dest, dirs_exist_ok=True)
|
|
150
|
+
else:
|
|
151
|
+
shutil.copy2(item, dest)
|
|
152
|
+
print(f" 📁 Restored: {dest}")
|
|
153
|
+
except PermissionError:
|
|
154
|
+
print(f" ⚠️ Permission denied: {dest}")
|
|
155
|
+
|
|
156
|
+
def close(self) -> None:
|
|
157
|
+
if self._conn is not None:
|
|
158
|
+
self._conn.close()
|
|
159
|
+
self._conn = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class SecureImporter:
|
|
163
|
+
"""AES-256 decrypting VM importer."""
|
|
164
|
+
|
|
165
|
+
KEY_PATH = Path.home() / ".clonebox.key"
|
|
166
|
+
|
|
167
|
+
def __init__(self, conn_uri: str = "qemu:///system"):
|
|
168
|
+
self.importer = VMImporter(conn_uri)
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def load_key(cls) -> Optional[bytes]:
|
|
172
|
+
"""Load decryption key from file."""
|
|
173
|
+
if cls.KEY_PATH.exists():
|
|
174
|
+
return cls.KEY_PATH.read_bytes()
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
def import_decrypted(
|
|
178
|
+
self,
|
|
179
|
+
encrypted_path: Path,
|
|
180
|
+
import_user_data: bool = False,
|
|
181
|
+
import_app_data: bool = False,
|
|
182
|
+
new_name: Optional[str] = None,
|
|
183
|
+
) -> str:
|
|
184
|
+
"""Import VM with AES-256 decryption."""
|
|
185
|
+
key = self.load_key()
|
|
186
|
+
if key is None:
|
|
187
|
+
raise FileNotFoundError(
|
|
188
|
+
f"No decryption key found at {self.KEY_PATH}. "
|
|
189
|
+
"Copy the team key to this location."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
fernet = Fernet(key)
|
|
193
|
+
|
|
194
|
+
# Create temporary decrypted archive
|
|
195
|
+
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
|
|
196
|
+
tmp_path = Path(tmp.name)
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
# Decrypt
|
|
200
|
+
encrypted_data = encrypted_path.read_bytes()
|
|
201
|
+
decrypted = fernet.decrypt(encrypted_data)
|
|
202
|
+
tmp_path.write_bytes(decrypted)
|
|
203
|
+
|
|
204
|
+
# Import
|
|
205
|
+
vm_name = self.importer.import_vm(
|
|
206
|
+
archive_path=tmp_path,
|
|
207
|
+
import_user_data=import_user_data,
|
|
208
|
+
import_app_data=import_app_data,
|
|
209
|
+
new_name=new_name,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
finally:
|
|
213
|
+
# Cleanup
|
|
214
|
+
if tmp_path.exists():
|
|
215
|
+
tmp_path.unlink()
|
|
216
|
+
|
|
217
|
+
return vm_name
|
|
218
|
+
|
|
219
|
+
def close(self) -> None:
|
|
220
|
+
self.importer.close()
|
clonebox/monitor.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Real-time resource monitoring for CloneBox VMs and containers.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import libvirt
|
|
15
|
+
except ImportError:
|
|
16
|
+
libvirt = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class VMStats:
|
|
21
|
+
"""VM resource statistics."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
state: str
|
|
25
|
+
cpu_percent: float
|
|
26
|
+
memory_used_mb: int
|
|
27
|
+
memory_total_mb: int
|
|
28
|
+
disk_used_gb: float
|
|
29
|
+
disk_total_gb: float
|
|
30
|
+
network_rx_bytes: int
|
|
31
|
+
network_tx_bytes: int
|
|
32
|
+
uptime_seconds: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ContainerStats:
|
|
37
|
+
"""Container resource statistics."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
state: str
|
|
41
|
+
cpu_percent: float
|
|
42
|
+
memory_used_mb: int
|
|
43
|
+
memory_limit_mb: int
|
|
44
|
+
network_rx_bytes: int
|
|
45
|
+
network_tx_bytes: int
|
|
46
|
+
pids: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ResourceMonitor:
|
|
50
|
+
"""Monitor VM and container resources in real-time."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, conn_uri: str = "qemu:///session"):
|
|
53
|
+
self.conn_uri = conn_uri
|
|
54
|
+
self._conn = None
|
|
55
|
+
self._prev_cpu: Dict[str, tuple] = {}
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def conn(self):
|
|
59
|
+
if self._conn is None:
|
|
60
|
+
if libvirt is None:
|
|
61
|
+
raise RuntimeError("libvirt-python not installed")
|
|
62
|
+
self._conn = libvirt.open(self.conn_uri)
|
|
63
|
+
return self._conn
|
|
64
|
+
|
|
65
|
+
def get_vm_stats(self, vm_name: str) -> Optional[VMStats]:
|
|
66
|
+
"""Get resource statistics for a VM."""
|
|
67
|
+
try:
|
|
68
|
+
dom = self.conn.lookupByName(vm_name)
|
|
69
|
+
info = dom.info()
|
|
70
|
+
|
|
71
|
+
state_map = {
|
|
72
|
+
libvirt.VIR_DOMAIN_RUNNING: "running",
|
|
73
|
+
libvirt.VIR_DOMAIN_PAUSED: "paused",
|
|
74
|
+
libvirt.VIR_DOMAIN_SHUTDOWN: "shutdown",
|
|
75
|
+
libvirt.VIR_DOMAIN_SHUTOFF: "shutoff",
|
|
76
|
+
libvirt.VIR_DOMAIN_CRASHED: "crashed",
|
|
77
|
+
}
|
|
78
|
+
state = state_map.get(info[0], "unknown")
|
|
79
|
+
|
|
80
|
+
# Memory
|
|
81
|
+
memory_total_mb = info[1] // 1024
|
|
82
|
+
memory_used_mb = info[2] // 1024 if info[2] > 0 else memory_total_mb
|
|
83
|
+
|
|
84
|
+
# CPU percentage (requires two samples)
|
|
85
|
+
cpu_time = info[4]
|
|
86
|
+
now = time.time()
|
|
87
|
+
cpu_percent = 0.0
|
|
88
|
+
|
|
89
|
+
if vm_name in self._prev_cpu:
|
|
90
|
+
prev_time, prev_cpu = self._prev_cpu[vm_name]
|
|
91
|
+
time_delta = now - prev_time
|
|
92
|
+
if time_delta > 0:
|
|
93
|
+
cpu_delta = cpu_time - prev_cpu
|
|
94
|
+
# CPU time is in nanoseconds
|
|
95
|
+
cpu_percent = (cpu_delta / (time_delta * 1e9)) * 100
|
|
96
|
+
cpu_percent = min(cpu_percent, 100.0 * info[3]) # Cap at vcpus * 100%
|
|
97
|
+
|
|
98
|
+
self._prev_cpu[vm_name] = (now, cpu_time)
|
|
99
|
+
|
|
100
|
+
# Disk stats (from block devices)
|
|
101
|
+
disk_used_gb = 0.0
|
|
102
|
+
disk_total_gb = 0.0
|
|
103
|
+
try:
|
|
104
|
+
xml = dom.XMLDesc()
|
|
105
|
+
import xml.etree.ElementTree as ET
|
|
106
|
+
|
|
107
|
+
root = ET.fromstring(xml)
|
|
108
|
+
for disk in root.findall(".//disk[@type='file']"):
|
|
109
|
+
source = disk.find(".//source")
|
|
110
|
+
if source is not None and source.get("file"):
|
|
111
|
+
disk_path = Path(source.get("file"))
|
|
112
|
+
if disk_path.exists():
|
|
113
|
+
size_bytes = disk_path.stat().st_size
|
|
114
|
+
disk_total_gb += size_bytes / (1024**3)
|
|
115
|
+
# Actual usage requires qemu-img info
|
|
116
|
+
disk_used_gb += size_bytes / (1024**3)
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
# Network stats
|
|
121
|
+
network_rx = 0
|
|
122
|
+
network_tx = 0
|
|
123
|
+
try:
|
|
124
|
+
for iface in dom.interfaceAddresses(
|
|
125
|
+
libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT
|
|
126
|
+
).keys():
|
|
127
|
+
stats = dom.interfaceStats(iface)
|
|
128
|
+
network_rx += stats[0]
|
|
129
|
+
network_tx += stats[4]
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
return VMStats(
|
|
134
|
+
name=vm_name,
|
|
135
|
+
state=state,
|
|
136
|
+
cpu_percent=cpu_percent,
|
|
137
|
+
memory_used_mb=memory_used_mb,
|
|
138
|
+
memory_total_mb=memory_total_mb,
|
|
139
|
+
disk_used_gb=disk_used_gb,
|
|
140
|
+
disk_total_gb=disk_total_gb,
|
|
141
|
+
network_rx_bytes=network_rx,
|
|
142
|
+
network_tx_bytes=network_tx,
|
|
143
|
+
uptime_seconds=0, # Would need guest agent for accurate uptime
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
except Exception:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def get_all_vm_stats(self) -> List[VMStats]:
|
|
150
|
+
"""Get stats for all VMs."""
|
|
151
|
+
stats = []
|
|
152
|
+
try:
|
|
153
|
+
for dom in self.conn.listAllDomains():
|
|
154
|
+
vm_stats = self.get_vm_stats(dom.name())
|
|
155
|
+
if vm_stats:
|
|
156
|
+
stats.append(vm_stats)
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
return stats
|
|
160
|
+
|
|
161
|
+
def get_container_stats(self, engine: str = "auto") -> List[ContainerStats]:
|
|
162
|
+
"""Get resource statistics for containers."""
|
|
163
|
+
if engine == "auto":
|
|
164
|
+
engine = "podman" if self._check_engine("podman") else "docker"
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
result = subprocess.run(
|
|
168
|
+
[engine, "stats", "--no-stream", "--format", "json"],
|
|
169
|
+
capture_output=True,
|
|
170
|
+
text=True,
|
|
171
|
+
timeout=10,
|
|
172
|
+
)
|
|
173
|
+
if result.returncode != 0:
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
containers = json.loads(result.stdout) if result.stdout.strip() else []
|
|
177
|
+
stats = []
|
|
178
|
+
|
|
179
|
+
for c in containers:
|
|
180
|
+
# Parse CPU percentage
|
|
181
|
+
cpu_str = c.get("CPUPerc", "0%").replace("%", "")
|
|
182
|
+
try:
|
|
183
|
+
cpu_percent = float(cpu_str)
|
|
184
|
+
except ValueError:
|
|
185
|
+
cpu_percent = 0.0
|
|
186
|
+
|
|
187
|
+
# Parse memory
|
|
188
|
+
mem_usage = c.get("MemUsage", "0MiB / 0MiB")
|
|
189
|
+
mem_parts = mem_usage.split("/")
|
|
190
|
+
mem_used = self._parse_memory(mem_parts[0].strip()) if len(mem_parts) > 0 else 0
|
|
191
|
+
mem_limit = self._parse_memory(mem_parts[1].strip()) if len(mem_parts) > 1 else 0
|
|
192
|
+
|
|
193
|
+
# Parse network
|
|
194
|
+
net_io = c.get("NetIO", "0B / 0B")
|
|
195
|
+
net_parts = net_io.split("/")
|
|
196
|
+
net_rx = self._parse_bytes(net_parts[0].strip()) if len(net_parts) > 0 else 0
|
|
197
|
+
net_tx = self._parse_bytes(net_parts[1].strip()) if len(net_parts) > 1 else 0
|
|
198
|
+
|
|
199
|
+
stats.append(
|
|
200
|
+
ContainerStats(
|
|
201
|
+
name=c.get("Name", c.get("Names", "unknown")),
|
|
202
|
+
state="running",
|
|
203
|
+
cpu_percent=cpu_percent,
|
|
204
|
+
memory_used_mb=mem_used,
|
|
205
|
+
memory_limit_mb=mem_limit,
|
|
206
|
+
network_rx_bytes=net_rx,
|
|
207
|
+
network_tx_bytes=net_tx,
|
|
208
|
+
pids=int(c.get("PIDs", 0)),
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
return stats
|
|
213
|
+
|
|
214
|
+
except Exception:
|
|
215
|
+
return []
|
|
216
|
+
|
|
217
|
+
def _check_engine(self, engine: str) -> bool:
|
|
218
|
+
"""Check if container engine is available."""
|
|
219
|
+
try:
|
|
220
|
+
result = subprocess.run(
|
|
221
|
+
[engine, "--version"], capture_output=True, timeout=5
|
|
222
|
+
)
|
|
223
|
+
return result.returncode == 0
|
|
224
|
+
except Exception:
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
def _parse_memory(self, mem_str: str) -> int:
|
|
228
|
+
"""Parse memory string like '100MiB' to MB."""
|
|
229
|
+
mem_str = mem_str.upper()
|
|
230
|
+
try:
|
|
231
|
+
if "GIB" in mem_str or "GB" in mem_str:
|
|
232
|
+
return int(float(mem_str.replace("GIB", "").replace("GB", "").strip()) * 1024)
|
|
233
|
+
elif "MIB" in mem_str or "MB" in mem_str:
|
|
234
|
+
return int(float(mem_str.replace("MIB", "").replace("MB", "").strip()))
|
|
235
|
+
elif "KIB" in mem_str or "KB" in mem_str:
|
|
236
|
+
return int(float(mem_str.replace("KIB", "").replace("KB", "").strip()) / 1024)
|
|
237
|
+
else:
|
|
238
|
+
return int(float(mem_str.replace("B", "").strip()) / (1024 * 1024))
|
|
239
|
+
except ValueError:
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
def _parse_bytes(self, bytes_str: str) -> int:
|
|
243
|
+
"""Parse byte string like '1.5GB' to bytes."""
|
|
244
|
+
bytes_str = bytes_str.upper()
|
|
245
|
+
try:
|
|
246
|
+
if "GB" in bytes_str:
|
|
247
|
+
return int(float(bytes_str.replace("GB", "").strip()) * 1024**3)
|
|
248
|
+
elif "MB" in bytes_str:
|
|
249
|
+
return int(float(bytes_str.replace("MB", "").strip()) * 1024**2)
|
|
250
|
+
elif "KB" in bytes_str:
|
|
251
|
+
return int(float(bytes_str.replace("KB", "").strip()) * 1024)
|
|
252
|
+
else:
|
|
253
|
+
return int(float(bytes_str.replace("B", "").strip()))
|
|
254
|
+
except ValueError:
|
|
255
|
+
return 0
|
|
256
|
+
|
|
257
|
+
def close(self) -> None:
|
|
258
|
+
if self._conn is not None:
|
|
259
|
+
self._conn.close()
|
|
260
|
+
self._conn = None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def format_bytes(num_bytes: int) -> str:
|
|
264
|
+
"""Format bytes to human-readable string."""
|
|
265
|
+
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
|
266
|
+
if abs(num_bytes) < 1024.0:
|
|
267
|
+
return f"{num_bytes:.1f}{unit}"
|
|
268
|
+
num_bytes /= 1024.0
|
|
269
|
+
return f"{num_bytes:.1f}PB"
|