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.
@@ -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