clonebox 1.1.4__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,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
@@ -0,0 +1,46 @@
1
+ """Abstract interface for process execution."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Dict, List, Optional
7
+
8
+
9
+ @dataclass
10
+ class ProcessResult:
11
+ """Result of process execution."""
12
+
13
+ returncode: int
14
+ stdout: str
15
+ stderr: str
16
+
17
+ @property
18
+ def success(self) -> bool:
19
+ return self.returncode == 0
20
+
21
+
22
+ class ProcessRunner(ABC):
23
+ """Abstract interface for process execution."""
24
+
25
+ @abstractmethod
26
+ def run(
27
+ self,
28
+ command: List[str],
29
+ capture_output: bool = True,
30
+ timeout: Optional[int] = None,
31
+ check: bool = True,
32
+ cwd: Optional[Path] = None,
33
+ env: Optional[Dict[str, str]] = None,
34
+ ) -> ProcessResult:
35
+ """Run a command."""
36
+ pass
37
+
38
+ @abstractmethod
39
+ def run_shell(
40
+ self,
41
+ command: str,
42
+ capture_output: bool = True,
43
+ timeout: Optional[int] = None,
44
+ ) -> ProcessResult:
45
+ """Run a shell command."""
46
+ pass
clonebox/logging.py ADDED
@@ -0,0 +1,125 @@
1
+ """
2
+ Structured logging for CloneBox using structlog.
3
+ """
4
+
5
+ import logging
6
+ import sys
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import structlog
12
+
13
+
14
+ def configure_logging(
15
+ level: str = "INFO",
16
+ json_output: bool = False,
17
+ log_file: Optional[Path] = None,
18
+ console_output: bool = True,
19
+ ) -> None:
20
+ """
21
+ Configure structured logging for CloneBox.
22
+
23
+ Args:
24
+ level: Log level (DEBUG, INFO, WARNING, ERROR)
25
+ json_output: If True, output JSON format (good for log aggregation)
26
+ log_file: Optional file path for log output
27
+ console_output: If True, also output to console
28
+ """
29
+
30
+ # Shared processors for all outputs
31
+ shared_processors = [
32
+ structlog.contextvars.merge_contextvars,
33
+ structlog.processors.add_log_level,
34
+ structlog.processors.TimeStamper(fmt="iso"),
35
+ structlog.stdlib.PositionalArgumentsFormatter(),
36
+ structlog.processors.StackInfoRenderer(),
37
+ structlog.processors.UnicodeDecoder(),
38
+ ]
39
+
40
+ if json_output:
41
+ # JSON output for production/aggregation
42
+ renderer = structlog.processors.JSONRenderer()
43
+ else:
44
+ # Human-readable output for development
45
+ renderer = structlog.dev.ConsoleRenderer(
46
+ colors=True,
47
+ exception_formatter=structlog.dev.plain_traceback,
48
+ )
49
+
50
+ structlog.configure(
51
+ processors=shared_processors
52
+ + [
53
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
54
+ ],
55
+ wrapper_class=structlog.stdlib.BoundLogger,
56
+ context_class=dict,
57
+ logger_factory=structlog.stdlib.LoggerFactory(),
58
+ cache_logger_on_first_use=True,
59
+ )
60
+
61
+ # Configure standard logging
62
+ handlers = []
63
+
64
+ if console_output:
65
+ console_handler = logging.StreamHandler(sys.stderr)
66
+ console_handler.setFormatter(
67
+ structlog.stdlib.ProcessorFormatter(
68
+ processor=renderer,
69
+ foreign_pre_chain=shared_processors,
70
+ )
71
+ )
72
+ handlers.append(console_handler)
73
+
74
+ if log_file:
75
+ file_handler = logging.FileHandler(log_file)
76
+ file_handler.setFormatter(
77
+ structlog.stdlib.ProcessorFormatter(
78
+ processor=structlog.processors.JSONRenderer(), # Always JSON for files
79
+ foreign_pre_chain=shared_processors,
80
+ )
81
+ )
82
+ handlers.append(file_handler)
83
+
84
+ logging.basicConfig(
85
+ format="%(message)s",
86
+ level=getattr(logging, level.upper()),
87
+ handlers=handlers,
88
+ )
89
+
90
+
91
+ def get_logger(name: str = "clonebox") -> structlog.stdlib.BoundLogger:
92
+ """Get a structured logger instance."""
93
+ return structlog.get_logger(name)
94
+
95
+
96
+ # Context managers for operation tracking
97
+ from contextlib import contextmanager
98
+
99
+
100
+ @contextmanager
101
+ def log_operation(logger: structlog.stdlib.BoundLogger, operation: str, **kwargs):
102
+ """
103
+ Context manager for logging operation start/end.
104
+
105
+ Usage:
106
+ with log_operation(log, "create_vm", vm_name="my-vm"):
107
+ # do stuff
108
+ """
109
+ log = logger.bind(operation=operation, **kwargs)
110
+ start_time = datetime.now()
111
+ log.info(f"{operation}.started")
112
+
113
+ try:
114
+ yield log
115
+ duration_ms = (datetime.now() - start_time).total_seconds() * 1000
116
+ log.info(f"{operation}.completed", duration_ms=round(duration_ms, 2))
117
+ except Exception as e:
118
+ duration_ms = (datetime.now() - start_time).total_seconds() * 1000
119
+ log.error(
120
+ f"{operation}.failed",
121
+ error=str(e),
122
+ error_type=type(e).__name__,
123
+ duration_ms=round(duration_ms, 2),
124
+ )
125
+ raise
clonebox/monitor.py CHANGED
@@ -217,9 +217,7 @@ class ResourceMonitor:
217
217
  def _check_engine(self, engine: str) -> bool:
218
218
  """Check if container engine is available."""
219
219
  try:
220
- result = subprocess.run(
221
- [engine, "--version"], capture_output=True, timeout=5
222
- )
220
+ result = subprocess.run([engine, "--version"], capture_output=True, timeout=5)
223
221
  return result.returncode == 0
224
222
  except Exception:
225
223
  return False
clonebox/p2p.py CHANGED
@@ -14,8 +14,10 @@ class P2PManager:
14
14
 
15
15
  def __init__(self, ssh_options: Optional[list] = None):
16
16
  self.ssh_options = ssh_options or [
17
- "-o", "StrictHostKeyChecking=no",
18
- "-o", "UserKnownHostsFile=/dev/null",
17
+ "-o",
18
+ "StrictHostKeyChecking=no",
19
+ "-o",
20
+ "UserKnownHostsFile=/dev/null",
19
21
  ]
20
22
 
21
23
  def _run_ssh(self, host: str, command: str) -> subprocess.CompletedProcess: