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.
- clonebox/backends/libvirt_backend.py +217 -0
- clonebox/backends/qemu_disk.py +52 -0
- clonebox/backends/subprocess_runner.py +56 -0
- clonebox/cli.py +209 -30
- clonebox/cloner.py +327 -189
- clonebox/di.py +176 -0
- clonebox/health/__init__.py +2 -1
- clonebox/health/manager.py +328 -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 +1 -3
- 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/manager.py +3 -9
- clonebox/snapshots/models.py +2 -6
- {clonebox-1.1.4.dist-info → clonebox-1.1.5.dist-info}/METADATA +51 -2
- clonebox-1.1.5.dist-info/RECORD +42 -0
- clonebox-1.1.4.dist-info/RECORD +0 -27
- {clonebox-1.1.4.dist-info → clonebox-1.1.5.dist-info}/WHEEL +0 -0
- {clonebox-1.1.4.dist-info → clonebox-1.1.5.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.4.dist-info → clonebox-1.1.5.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.4.dist-info → clonebox-1.1.5.dist-info}/top_level.txt +0 -0
|
@@ -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",
|
|
18
|
-
"
|
|
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:
|