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.
- {clonebox-1.1.3/src/clonebox.egg-info → clonebox-1.1.4}/PKG-INFO +1 -1
- {clonebox-1.1.3 → clonebox-1.1.4}/pyproject.toml +1 -1
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/cli.py +142 -0
- clonebox-1.1.4/src/clonebox/health/__init__.py +16 -0
- clonebox-1.1.4/src/clonebox/health/models.py +194 -0
- clonebox-1.1.4/src/clonebox/monitor.py +269 -0
- clonebox-1.1.4/src/clonebox/snapshots/__init__.py +12 -0
- clonebox-1.1.4/src/clonebox/snapshots/manager.py +355 -0
- clonebox-1.1.4/src/clonebox/snapshots/models.py +187 -0
- {clonebox-1.1.3 → clonebox-1.1.4/src/clonebox.egg-info}/PKG-INFO +1 -1
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox.egg-info/SOURCES.txt +6 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/LICENSE +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/README.md +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/setup.cfg +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/__init__.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/__main__.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/cloner.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/container.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/dashboard.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/detector.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/exporter.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/importer.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/models.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/p2p.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/profiles.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/templates/profiles/ml-dev.yaml +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/templates/profiles/web-stack.yaml +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox/validator.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox.egg-info/dependency_links.txt +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox.egg-info/entry_points.txt +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox.egg-info/requires.txt +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/src/clonebox.egg-info/top_level.txt +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_cli.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_cloner.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_cloner_simple.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_container.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_coverage_additional.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_coverage_boost_final.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_dashboard_coverage.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_detector.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_models.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_network.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_profiles.py +0 -0
- {clonebox-1.1.3 → clonebox-1.1.4}/tests/test_validator.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "clonebox"
|
|
7
|
-
version = "1.1.
|
|
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
|
+
]
|