clonebox 1.1.3__tar.gz → 1.1.4__tar.gz

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.
Files changed (44) hide show
  1. {clonebox-1.1.3/src/clonebox.egg-info → clonebox-1.1.4}/PKG-INFO +1 -1
  2. {clonebox-1.1.3 → clonebox-1.1.4}/pyproject.toml +1 -1
  3. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/cli.py +142 -0
  4. clonebox-1.1.4/src/clonebox/health/__init__.py +16 -0
  5. clonebox-1.1.4/src/clonebox/health/models.py +194 -0
  6. clonebox-1.1.4/src/clonebox/monitor.py +269 -0
  7. clonebox-1.1.4/src/clonebox/snapshots/__init__.py +12 -0
  8. clonebox-1.1.4/src/clonebox/snapshots/manager.py +355 -0
  9. clonebox-1.1.4/src/clonebox/snapshots/models.py +187 -0
  10. {clonebox-1.1.3 → clonebox-1.1.4/src/clonebox.egg-info}/PKG-INFO +1 -1
  11. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox.egg-info/SOURCES.txt +6 -0
  12. {clonebox-1.1.3 → clonebox-1.1.4}/LICENSE +0 -0
  13. {clonebox-1.1.3 → clonebox-1.1.4}/README.md +0 -0
  14. {clonebox-1.1.3 → clonebox-1.1.4}/setup.cfg +0 -0
  15. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/__init__.py +0 -0
  16. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/__main__.py +0 -0
  17. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/cloner.py +0 -0
  18. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/container.py +0 -0
  19. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/dashboard.py +0 -0
  20. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/detector.py +0 -0
  21. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/exporter.py +0 -0
  22. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/importer.py +0 -0
  23. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/models.py +0 -0
  24. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/p2p.py +0 -0
  25. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/profiles.py +0 -0
  26. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/templates/profiles/ml-dev.yaml +0 -0
  27. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/templates/profiles/web-stack.yaml +0 -0
  28. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/validator.py +0 -0
  29. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox.egg-info/dependency_links.txt +0 -0
  30. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox.egg-info/entry_points.txt +0 -0
  31. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox.egg-info/requires.txt +0 -0
  32. {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox.egg-info/top_level.txt +0 -0
  33. {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_cli.py +0 -0
  34. {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_cloner.py +0 -0
  35. {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_cloner_simple.py +0 -0
  36. {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_container.py +0 -0
  37. {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_coverage_additional.py +0 -0
  38. {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_coverage_boost_final.py +0 -0
  39. {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_dashboard_coverage.py +0 -0
  40. {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_detector.py +0 -0
  41. {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_models.py +0 -0
  42. {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_network.py +0 -0
  43. {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_profiles.py +0 -0
  44. {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clonebox
3
- Version: 1.1.3
3
+ Version: 1.1.4
4
4
  Summary: Clone your workstation environment to an isolated VM with selective apps, paths and services
5
5
  Author: CloneBox Team
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "clonebox"
7
- version = "1.1.3"
7
+ version = "1.1.4"
8
8
  description = "Clone your workstation environment to an isolated VM with selective apps, paths and services"
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -30,6 +30,7 @@ from clonebox.models import ContainerConfig
30
30
  from clonebox.profiles import merge_with_profile
31
31
  from clonebox.exporter import SecureExporter, VMExporter
32
32
  from clonebox.importer import SecureImporter, VMImporter
33
+ from clonebox.monitor import ResourceMonitor, format_bytes
33
34
  from clonebox.p2p import P2PManager
34
35
 
35
36
  # Custom questionary style
@@ -2638,6 +2639,118 @@ def cmd_detect(args):
2638
2639
  console.print(table)
2639
2640
 
2640
2641
 
2642
+ def cmd_monitor(args) -> None:
2643
+ """Real-time resource monitoring for VMs and containers."""
2644
+ conn_uri = "qemu:///session" if getattr(args, "user", False) else "qemu:///system"
2645
+ refresh = getattr(args, "refresh", 2.0)
2646
+ once = getattr(args, "once", False)
2647
+
2648
+ monitor = ResourceMonitor(conn_uri)
2649
+
2650
+ try:
2651
+ while True:
2652
+ # Clear screen for live update
2653
+ if not once:
2654
+ console.clear()
2655
+
2656
+ console.print("[bold cyan]📊 CloneBox Resource Monitor[/]")
2657
+ console.print()
2658
+
2659
+ # VM Stats
2660
+ vm_stats = monitor.get_all_vm_stats()
2661
+ if vm_stats:
2662
+ table = Table(title="🖥️ Virtual Machines", border_style="cyan")
2663
+ table.add_column("Name", style="bold")
2664
+ table.add_column("State")
2665
+ table.add_column("CPU %")
2666
+ table.add_column("Memory")
2667
+ table.add_column("Disk")
2668
+ table.add_column("Network I/O")
2669
+
2670
+ for vm in vm_stats:
2671
+ state_color = "green" if vm.state == "running" else "yellow"
2672
+ cpu_color = "red" if vm.cpu_percent > 80 else "green"
2673
+ mem_pct = (vm.memory_used_mb / vm.memory_total_mb * 100) if vm.memory_total_mb > 0 else 0
2674
+ mem_color = "red" if mem_pct > 80 else "green"
2675
+
2676
+ table.add_row(
2677
+ vm.name,
2678
+ f"[{state_color}]{vm.state}[/]",
2679
+ f"[{cpu_color}]{vm.cpu_percent:.1f}%[/]",
2680
+ f"[{mem_color}]{vm.memory_used_mb}/{vm.memory_total_mb} MB[/]",
2681
+ f"{vm.disk_used_gb:.1f}/{vm.disk_total_gb:.1f} GB",
2682
+ f"↓{format_bytes(vm.network_rx_bytes)} ↑{format_bytes(vm.network_tx_bytes)}",
2683
+ )
2684
+ console.print(table)
2685
+ else:
2686
+ console.print("[dim]No VMs found.[/]")
2687
+
2688
+ console.print()
2689
+
2690
+ # Container Stats
2691
+ container_stats = monitor.get_container_stats()
2692
+ if container_stats:
2693
+ table = Table(title="🐳 Containers", border_style="blue")
2694
+ table.add_column("Name", style="bold")
2695
+ table.add_column("State")
2696
+ table.add_column("CPU %")
2697
+ table.add_column("Memory")
2698
+ table.add_column("Network I/O")
2699
+ table.add_column("PIDs")
2700
+
2701
+ for c in container_stats:
2702
+ cpu_color = "red" if c.cpu_percent > 80 else "green"
2703
+ mem_pct = (c.memory_used_mb / c.memory_limit_mb * 100) if c.memory_limit_mb > 0 else 0
2704
+ mem_color = "red" if mem_pct > 80 else "green"
2705
+
2706
+ table.add_row(
2707
+ c.name,
2708
+ f"[green]{c.state}[/]",
2709
+ f"[{cpu_color}]{c.cpu_percent:.1f}%[/]",
2710
+ f"[{mem_color}]{c.memory_used_mb}/{c.memory_limit_mb} MB[/]",
2711
+ f"↓{format_bytes(c.network_rx_bytes)} ↑{format_bytes(c.network_tx_bytes)}",
2712
+ str(c.pids),
2713
+ )
2714
+ console.print(table)
2715
+ else:
2716
+ console.print("[dim]No containers running.[/]")
2717
+
2718
+ if once:
2719
+ break
2720
+
2721
+ console.print(f"\n[dim]Refreshing every {refresh}s. Press Ctrl+C to exit.[/]")
2722
+ time.sleep(refresh)
2723
+
2724
+ except KeyboardInterrupt:
2725
+ console.print("\n[yellow]Monitoring stopped.[/]")
2726
+ finally:
2727
+ monitor.close()
2728
+
2729
+
2730
+ def cmd_exec(args) -> None:
2731
+ """Execute command in VM via QEMU Guest Agent."""
2732
+ vm_name, config_file = _resolve_vm_name_and_config_file(args.name)
2733
+ conn_uri = "qemu:///session" if getattr(args, "user", False) else "qemu:///system"
2734
+ command = args.command
2735
+ timeout = getattr(args, "timeout", 30)
2736
+
2737
+ if not _qga_ping(vm_name, conn_uri):
2738
+ console.print(f"[red]❌ Cannot connect to VM '{vm_name}' via QEMU Guest Agent[/]")
2739
+ console.print("[dim]Make sure the VM is running and qemu-guest-agent is installed.[/]")
2740
+ return
2741
+
2742
+ console.print(f"[cyan]▶ Executing in {vm_name}:[/] {command}")
2743
+
2744
+ result = _qga_exec(vm_name, conn_uri, command, timeout=timeout)
2745
+
2746
+ if result is None:
2747
+ console.print("[red]❌ Command execution failed or timed out[/]")
2748
+ elif result == "":
2749
+ console.print("[dim](no output)[/]")
2750
+ else:
2751
+ console.print(result)
2752
+
2753
+
2641
2754
  def cmd_keygen(args) -> None:
2642
2755
  """Generate encryption key for secure P2P transfers."""
2643
2756
  key_path = SecureExporter.generate_key()
@@ -3198,6 +3311,35 @@ def main():
3198
3311
  )
3199
3312
  test_parser.set_defaults(func=cmd_test)
3200
3313
 
3314
+ # Monitor command - real-time resource monitoring
3315
+ monitor_parser = subparsers.add_parser("monitor", help="Real-time resource monitoring")
3316
+ monitor_parser.add_argument(
3317
+ "-u", "--user", action="store_true", help="Use user session (qemu:///session)"
3318
+ )
3319
+ monitor_parser.add_argument(
3320
+ "--refresh", "-r", type=float, default=2.0, help="Refresh interval in seconds (default: 2)"
3321
+ )
3322
+ monitor_parser.add_argument(
3323
+ "--once", action="store_true", help="Show stats once and exit"
3324
+ )
3325
+ monitor_parser.set_defaults(func=cmd_monitor)
3326
+
3327
+ # Exec command - execute command in VM
3328
+ exec_parser = subparsers.add_parser("exec", help="Execute command in VM via QEMU Guest Agent")
3329
+ exec_parser.add_argument(
3330
+ "name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml"
3331
+ )
3332
+ exec_parser.add_argument(
3333
+ "command", help="Command to execute in VM"
3334
+ )
3335
+ exec_parser.add_argument(
3336
+ "-u", "--user", action="store_true", help="Use user session (qemu:///session)"
3337
+ )
3338
+ exec_parser.add_argument(
3339
+ "--timeout", "-t", type=int, default=30, help="Command timeout in seconds (default: 30)"
3340
+ )
3341
+ exec_parser.set_defaults(func=cmd_exec)
3342
+
3201
3343
  # === P2P Secure Transfer Commands ===
3202
3344
 
3203
3345
  # Keygen command - generate encryption key
@@ -0,0 +1,16 @@
1
+ """Health check system for CloneBox VMs."""
2
+
3
+ from .models import HealthCheckResult, HealthStatus, ProbeConfig
4
+ from .probes import HTTPProbe, TCPProbe, CommandProbe, ScriptProbe
5
+ from .manager import HealthCheckManager
6
+
7
+ __all__ = [
8
+ "HealthCheckResult",
9
+ "HealthStatus",
10
+ "ProbeConfig",
11
+ "HTTPProbe",
12
+ "TCPProbe",
13
+ "CommandProbe",
14
+ "ScriptProbe",
15
+ "HealthCheckManager",
16
+ ]
@@ -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
@@ -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"
@@ -0,0 +1,12 @@
1
+ """Snapshot management for CloneBox VMs."""
2
+
3
+ from .models import Snapshot, SnapshotType, SnapshotState, SnapshotPolicy
4
+ from .manager import SnapshotManager
5
+
6
+ __all__ = [
7
+ "Snapshot",
8
+ "SnapshotType",
9
+ "SnapshotState",
10
+ "SnapshotPolicy",
11
+ "SnapshotManager",
12
+ ]