clonebox 1.1.3__py3-none-any.whl → 1.1.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clonebox/backends/libvirt_backend.py +217 -0
- clonebox/backends/qemu_disk.py +52 -0
- clonebox/backends/subprocess_runner.py +56 -0
- clonebox/cli.py +343 -22
- clonebox/cloner.py +327 -189
- clonebox/di.py +176 -0
- clonebox/health/__init__.py +17 -0
- clonebox/health/manager.py +328 -0
- clonebox/health/models.py +194 -0
- clonebox/health/probes.py +337 -0
- clonebox/interfaces/disk.py +40 -0
- clonebox/interfaces/hypervisor.py +89 -0
- clonebox/interfaces/network.py +33 -0
- clonebox/interfaces/process.py +46 -0
- clonebox/logging.py +125 -0
- clonebox/monitor.py +267 -0
- clonebox/p2p.py +4 -2
- clonebox/resource_monitor.py +162 -0
- clonebox/resources.py +222 -0
- clonebox/rollback.py +172 -0
- clonebox/secrets.py +331 -0
- clonebox/snapshots/__init__.py +12 -0
- clonebox/snapshots/manager.py +349 -0
- clonebox/snapshots/models.py +183 -0
- {clonebox-1.1.3.dist-info → clonebox-1.1.5.dist-info}/METADATA +51 -2
- clonebox-1.1.5.dist-info/RECORD +42 -0
- clonebox-1.1.3.dist-info/RECORD +0 -21
- {clonebox-1.1.3.dist-info → clonebox-1.1.5.dist-info}/WHEEL +0 -0
- {clonebox-1.1.3.dist-info → clonebox-1.1.5.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.3.dist-info → clonebox-1.1.5.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.3.dist-info → clonebox-1.1.5.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
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Health check probes for different protocols."""
|
|
3
|
+
|
|
4
|
+
import socket
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from .models import HealthCheckResult, HealthStatus, ProbeConfig
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import urllib.request
|
|
15
|
+
import urllib.error
|
|
16
|
+
except ImportError:
|
|
17
|
+
urllib = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HealthProbe(ABC):
|
|
21
|
+
"""Abstract base class for health probes."""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def check(self, config: ProbeConfig) -> HealthCheckResult:
|
|
25
|
+
"""Execute health check and return result."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
def _create_result(
|
|
29
|
+
self,
|
|
30
|
+
config: ProbeConfig,
|
|
31
|
+
status: HealthStatus,
|
|
32
|
+
duration_ms: float,
|
|
33
|
+
**kwargs,
|
|
34
|
+
) -> HealthCheckResult:
|
|
35
|
+
"""Create a health check result."""
|
|
36
|
+
return HealthCheckResult(
|
|
37
|
+
probe_name=config.name,
|
|
38
|
+
status=status,
|
|
39
|
+
checked_at=datetime.now(),
|
|
40
|
+
duration_ms=duration_ms,
|
|
41
|
+
**kwargs,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class HTTPProbe(HealthProbe):
|
|
46
|
+
"""HTTP/HTTPS health probe."""
|
|
47
|
+
|
|
48
|
+
def check(self, config: ProbeConfig) -> HealthCheckResult:
|
|
49
|
+
"""Check HTTP endpoint."""
|
|
50
|
+
if not config.url:
|
|
51
|
+
return self._create_result(
|
|
52
|
+
config,
|
|
53
|
+
HealthStatus.UNHEALTHY,
|
|
54
|
+
0,
|
|
55
|
+
error="No URL configured",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
start = time.time()
|
|
59
|
+
try:
|
|
60
|
+
req = urllib.request.Request(
|
|
61
|
+
config.url,
|
|
62
|
+
method=config.method,
|
|
63
|
+
headers=config.headers or {},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
with urllib.request.urlopen(req, timeout=config.timeout_seconds) as response:
|
|
67
|
+
duration_ms = (time.time() - start) * 1000
|
|
68
|
+
status_code = response.getcode()
|
|
69
|
+
body = response.read().decode("utf-8", errors="replace")
|
|
70
|
+
|
|
71
|
+
# Check status code
|
|
72
|
+
if status_code != config.expected_status:
|
|
73
|
+
return self._create_result(
|
|
74
|
+
config,
|
|
75
|
+
HealthStatus.UNHEALTHY,
|
|
76
|
+
duration_ms,
|
|
77
|
+
message=f"Expected status {config.expected_status}, got {status_code}",
|
|
78
|
+
response_code=status_code,
|
|
79
|
+
response_body=body[:500],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Check body content
|
|
83
|
+
if config.expected_body and config.expected_body not in body:
|
|
84
|
+
return self._create_result(
|
|
85
|
+
config,
|
|
86
|
+
HealthStatus.UNHEALTHY,
|
|
87
|
+
duration_ms,
|
|
88
|
+
message=f"Expected body content not found",
|
|
89
|
+
response_code=status_code,
|
|
90
|
+
response_body=body[:500],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Check JSON response
|
|
94
|
+
if config.expected_json:
|
|
95
|
+
import json
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
json_body = json.loads(body)
|
|
99
|
+
for key, expected_value in config.expected_json.items():
|
|
100
|
+
if json_body.get(key) != expected_value:
|
|
101
|
+
return self._create_result(
|
|
102
|
+
config,
|
|
103
|
+
HealthStatus.UNHEALTHY,
|
|
104
|
+
duration_ms,
|
|
105
|
+
message=f"JSON field '{key}' mismatch",
|
|
106
|
+
response_code=status_code,
|
|
107
|
+
details={"expected": expected_value, "got": json_body.get(key)},
|
|
108
|
+
)
|
|
109
|
+
except json.JSONDecodeError as e:
|
|
110
|
+
return self._create_result(
|
|
111
|
+
config,
|
|
112
|
+
HealthStatus.UNHEALTHY,
|
|
113
|
+
duration_ms,
|
|
114
|
+
message="Invalid JSON response",
|
|
115
|
+
error=str(e),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return self._create_result(
|
|
119
|
+
config,
|
|
120
|
+
HealthStatus.HEALTHY,
|
|
121
|
+
duration_ms,
|
|
122
|
+
message="OK",
|
|
123
|
+
response_code=status_code,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
except urllib.error.HTTPError as e:
|
|
127
|
+
duration_ms = (time.time() - start) * 1000
|
|
128
|
+
return self._create_result(
|
|
129
|
+
config,
|
|
130
|
+
HealthStatus.UNHEALTHY,
|
|
131
|
+
duration_ms,
|
|
132
|
+
error=f"HTTP error: {e.code}",
|
|
133
|
+
response_code=e.code,
|
|
134
|
+
)
|
|
135
|
+
except urllib.error.URLError as e:
|
|
136
|
+
duration_ms = (time.time() - start) * 1000
|
|
137
|
+
return self._create_result(
|
|
138
|
+
config,
|
|
139
|
+
HealthStatus.UNHEALTHY,
|
|
140
|
+
duration_ms,
|
|
141
|
+
error=f"Connection error: {e.reason}",
|
|
142
|
+
)
|
|
143
|
+
except TimeoutError:
|
|
144
|
+
duration_ms = (time.time() - start) * 1000
|
|
145
|
+
return self._create_result(
|
|
146
|
+
config,
|
|
147
|
+
HealthStatus.TIMEOUT,
|
|
148
|
+
duration_ms,
|
|
149
|
+
error=f"Timeout after {config.timeout_seconds}s",
|
|
150
|
+
)
|
|
151
|
+
except Exception as e:
|
|
152
|
+
duration_ms = (time.time() - start) * 1000
|
|
153
|
+
return self._create_result(
|
|
154
|
+
config,
|
|
155
|
+
HealthStatus.UNHEALTHY,
|
|
156
|
+
duration_ms,
|
|
157
|
+
error=str(e),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TCPProbe(HealthProbe):
|
|
162
|
+
"""TCP port connectivity probe."""
|
|
163
|
+
|
|
164
|
+
def check(self, config: ProbeConfig) -> HealthCheckResult:
|
|
165
|
+
"""Check TCP port connectivity."""
|
|
166
|
+
if not config.port:
|
|
167
|
+
return self._create_result(
|
|
168
|
+
config,
|
|
169
|
+
HealthStatus.UNHEALTHY,
|
|
170
|
+
0,
|
|
171
|
+
error="No port configured",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
start = time.time()
|
|
175
|
+
try:
|
|
176
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
177
|
+
sock.settimeout(config.timeout_seconds)
|
|
178
|
+
|
|
179
|
+
result = sock.connect_ex((config.host, config.port))
|
|
180
|
+
duration_ms = (time.time() - start) * 1000
|
|
181
|
+
sock.close()
|
|
182
|
+
|
|
183
|
+
if result == 0:
|
|
184
|
+
return self._create_result(
|
|
185
|
+
config,
|
|
186
|
+
HealthStatus.HEALTHY,
|
|
187
|
+
duration_ms,
|
|
188
|
+
message=f"Port {config.port} is open",
|
|
189
|
+
details={"host": config.host, "port": config.port},
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
return self._create_result(
|
|
193
|
+
config,
|
|
194
|
+
HealthStatus.UNHEALTHY,
|
|
195
|
+
duration_ms,
|
|
196
|
+
error=f"Port {config.port} is closed (code: {result})",
|
|
197
|
+
details={"host": config.host, "port": config.port},
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
except socket.timeout:
|
|
201
|
+
duration_ms = (time.time() - start) * 1000
|
|
202
|
+
return self._create_result(
|
|
203
|
+
config,
|
|
204
|
+
HealthStatus.TIMEOUT,
|
|
205
|
+
duration_ms,
|
|
206
|
+
error=f"Connection timeout to {config.host}:{config.port}",
|
|
207
|
+
)
|
|
208
|
+
except Exception as e:
|
|
209
|
+
duration_ms = (time.time() - start) * 1000
|
|
210
|
+
return self._create_result(
|
|
211
|
+
config,
|
|
212
|
+
HealthStatus.UNHEALTHY,
|
|
213
|
+
duration_ms,
|
|
214
|
+
error=str(e),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class CommandProbe(HealthProbe):
|
|
219
|
+
"""Command execution probe."""
|
|
220
|
+
|
|
221
|
+
def check(self, config: ProbeConfig) -> HealthCheckResult:
|
|
222
|
+
"""Execute command and check result."""
|
|
223
|
+
if not config.command:
|
|
224
|
+
return self._create_result(
|
|
225
|
+
config,
|
|
226
|
+
HealthStatus.UNHEALTHY,
|
|
227
|
+
0,
|
|
228
|
+
error="No command configured",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
start = time.time()
|
|
232
|
+
try:
|
|
233
|
+
result = subprocess.run(
|
|
234
|
+
config.command,
|
|
235
|
+
shell=True,
|
|
236
|
+
capture_output=True,
|
|
237
|
+
text=True,
|
|
238
|
+
timeout=config.timeout_seconds,
|
|
239
|
+
)
|
|
240
|
+
duration_ms = (time.time() - start) * 1000
|
|
241
|
+
|
|
242
|
+
stdout = result.stdout.strip()
|
|
243
|
+
stderr = result.stderr.strip()
|
|
244
|
+
|
|
245
|
+
# Check exit code
|
|
246
|
+
if result.returncode != config.expected_exit_code:
|
|
247
|
+
return self._create_result(
|
|
248
|
+
config,
|
|
249
|
+
HealthStatus.UNHEALTHY,
|
|
250
|
+
duration_ms,
|
|
251
|
+
message=f"Exit code {result.returncode}, expected {config.expected_exit_code}",
|
|
252
|
+
exit_code=result.returncode,
|
|
253
|
+
stdout=stdout[:500],
|
|
254
|
+
stderr=stderr[:500],
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Check expected output
|
|
258
|
+
if config.expected_output and config.expected_output not in stdout:
|
|
259
|
+
return self._create_result(
|
|
260
|
+
config,
|
|
261
|
+
HealthStatus.UNHEALTHY,
|
|
262
|
+
duration_ms,
|
|
263
|
+
message=f"Expected output not found",
|
|
264
|
+
exit_code=result.returncode,
|
|
265
|
+
stdout=stdout[:500],
|
|
266
|
+
details={"expected": config.expected_output},
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return self._create_result(
|
|
270
|
+
config,
|
|
271
|
+
HealthStatus.HEALTHY,
|
|
272
|
+
duration_ms,
|
|
273
|
+
message="OK",
|
|
274
|
+
exit_code=result.returncode,
|
|
275
|
+
stdout=stdout[:500] if stdout else None,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
except subprocess.TimeoutExpired:
|
|
279
|
+
duration_ms = (time.time() - start) * 1000
|
|
280
|
+
return self._create_result(
|
|
281
|
+
config,
|
|
282
|
+
HealthStatus.TIMEOUT,
|
|
283
|
+
duration_ms,
|
|
284
|
+
error=f"Command timeout after {config.timeout_seconds}s",
|
|
285
|
+
)
|
|
286
|
+
except Exception as e:
|
|
287
|
+
duration_ms = (time.time() - start) * 1000
|
|
288
|
+
return self._create_result(
|
|
289
|
+
config,
|
|
290
|
+
HealthStatus.UNHEALTHY,
|
|
291
|
+
duration_ms,
|
|
292
|
+
error=str(e),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class ScriptProbe(HealthProbe):
|
|
297
|
+
"""Script execution probe."""
|
|
298
|
+
|
|
299
|
+
def check(self, config: ProbeConfig) -> HealthCheckResult:
|
|
300
|
+
"""Execute script and check result."""
|
|
301
|
+
if not config.script_path:
|
|
302
|
+
return self._create_result(
|
|
303
|
+
config,
|
|
304
|
+
HealthStatus.UNHEALTHY,
|
|
305
|
+
0,
|
|
306
|
+
error="No script path configured",
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Use CommandProbe with script path
|
|
310
|
+
cmd_config = ProbeConfig(
|
|
311
|
+
name=config.name,
|
|
312
|
+
probe_type=config.probe_type,
|
|
313
|
+
command=config.script_path,
|
|
314
|
+
expected_exit_code=config.expected_exit_code,
|
|
315
|
+
expected_output=config.expected_output,
|
|
316
|
+
timeout_seconds=config.timeout_seconds,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
cmd_probe = CommandProbe()
|
|
320
|
+
return cmd_probe.check(cmd_config)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def get_probe(probe_type: str) -> HealthProbe:
|
|
324
|
+
"""Get probe instance by type."""
|
|
325
|
+
from .models import ProbeType
|
|
326
|
+
|
|
327
|
+
probes = {
|
|
328
|
+
ProbeType.HTTP: HTTPProbe(),
|
|
329
|
+
ProbeType.TCP: TCPProbe(),
|
|
330
|
+
ProbeType.COMMAND: CommandProbe(),
|
|
331
|
+
ProbeType.SCRIPT: ScriptProbe(),
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if isinstance(probe_type, str):
|
|
335
|
+
probe_type = ProbeType(probe_type)
|
|
336
|
+
|
|
337
|
+
return probes.get(probe_type, CommandProbe())
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Interfaces for CloneBox disk management."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DiskManager(ABC):
|
|
9
|
+
"""Abstract interface for disk operations."""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def create_disk(
|
|
13
|
+
self,
|
|
14
|
+
path: Path,
|
|
15
|
+
size_gb: int,
|
|
16
|
+
format: str = "qcow2",
|
|
17
|
+
backing_file: Optional[Path] = None,
|
|
18
|
+
) -> Path:
|
|
19
|
+
"""Create a disk image."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def resize_disk(self, path: Path, new_size_gb: int) -> None:
|
|
24
|
+
"""Resize a disk image."""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def get_disk_info(self, path: Path) -> Dict[str, Any]:
|
|
29
|
+
"""Get disk image information."""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def create_snapshot(self, path: Path, snapshot_name: str) -> Path:
|
|
34
|
+
"""Create disk snapshot."""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def delete_disk(self, path: Path) -> None:
|
|
39
|
+
"""Delete disk image."""
|
|
40
|
+
pass
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Interfaces for CloneBox hypervisor backends."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class VMInfo:
|
|
10
|
+
"""VM information returned by hypervisor."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
state: str
|
|
14
|
+
uuid: str
|
|
15
|
+
memory_mb: int
|
|
16
|
+
vcpus: int
|
|
17
|
+
ip_addresses: List[str]
|
|
18
|
+
created_at: Optional[str] = None
|
|
19
|
+
metadata: Dict[str, Any] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HypervisorBackend(ABC):
|
|
23
|
+
"""Abstract interface for hypervisor operations."""
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def name(self) -> str:
|
|
28
|
+
"""Backend name (e.g., 'libvirt', 'podman')."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def connect(self) -> None:
|
|
33
|
+
"""Establish connection to hypervisor."""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def disconnect(self) -> None:
|
|
38
|
+
"""Close connection."""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def define_vm(self, config: Any) -> str:
|
|
43
|
+
"""Define a new VM. Returns VM UUID or name."""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def undefine_vm(self, name: str) -> None:
|
|
48
|
+
"""Remove VM definition."""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def start_vm(self, name: str) -> None:
|
|
53
|
+
"""Start a VM."""
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def stop_vm(self, name: str, force: bool = False) -> None:
|
|
58
|
+
"""Stop a VM."""
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def get_vm_info(self, name: str) -> Optional[VMInfo]:
|
|
63
|
+
"""Get VM information."""
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def list_vms(self) -> List[VMInfo]:
|
|
68
|
+
"""List all VMs."""
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def vm_exists(self, name: str) -> bool:
|
|
73
|
+
"""Check if VM exists."""
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
def is_running(self, name: str) -> bool:
|
|
78
|
+
"""Check if VM is running."""
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def execute_command(
|
|
83
|
+
self,
|
|
84
|
+
name: str,
|
|
85
|
+
command: str,
|
|
86
|
+
timeout: int = 30,
|
|
87
|
+
) -> Optional[str]:
|
|
88
|
+
"""Execute command in VM."""
|
|
89
|
+
pass
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Interfaces for CloneBox network management."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class NetworkManager(ABC):
|
|
8
|
+
"""Abstract interface for network operations."""
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def create_network(self, name: str, config: Dict[str, Any]) -> None:
|
|
12
|
+
"""Create a virtual network."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def delete_network(self, name: str) -> None:
|
|
17
|
+
"""Delete a virtual network."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def network_exists(self, name: str) -> bool:
|
|
22
|
+
"""Check if network exists."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def is_network_active(self, name: str) -> bool:
|
|
27
|
+
"""Check if network is active."""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def get_vm_ip(self, vm_name: str) -> Optional[str]:
|
|
32
|
+
"""Get VM's IP address."""
|
|
33
|
+
pass
|