clonebox 1.1.18__py3-none-any.whl → 1.1.19__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 +3 -1
- clonebox/cli.py +591 -556
- clonebox/cloner.py +465 -412
- clonebox/health/probes.py +14 -0
- clonebox/policies/__init__.py +13 -0
- clonebox/policies/engine.py +112 -0
- clonebox/policies/models.py +55 -0
- clonebox/policies/validators.py +26 -0
- clonebox/validator.py +70 -28
- {clonebox-1.1.18.dist-info → clonebox-1.1.19.dist-info}/METADATA +1 -1
- {clonebox-1.1.18.dist-info → clonebox-1.1.19.dist-info}/RECORD +15 -11
- {clonebox-1.1.18.dist-info → clonebox-1.1.19.dist-info}/WHEEL +0 -0
- {clonebox-1.1.18.dist-info → clonebox-1.1.19.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.18.dist-info → clonebox-1.1.19.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.18.dist-info → clonebox-1.1.19.dist-info}/top_level.txt +0 -0
clonebox/health/probes.py
CHANGED
|
@@ -8,6 +8,7 @@ from abc import ABC, abstractmethod
|
|
|
8
8
|
from datetime import datetime
|
|
9
9
|
from typing import Optional
|
|
10
10
|
|
|
11
|
+
from clonebox.policies import PolicyEngine, PolicyViolationError
|
|
11
12
|
from .models import HealthCheckResult, HealthStatus, ProbeConfig
|
|
12
13
|
|
|
13
14
|
try:
|
|
@@ -57,6 +58,19 @@ class HTTPProbe(HealthProbe):
|
|
|
57
58
|
|
|
58
59
|
start = time.time()
|
|
59
60
|
try:
|
|
61
|
+
policy = PolicyEngine.load_effective()
|
|
62
|
+
if policy is not None:
|
|
63
|
+
try:
|
|
64
|
+
policy.assert_url_allowed(config.url)
|
|
65
|
+
except PolicyViolationError as e:
|
|
66
|
+
duration_ms = (time.time() - start) * 1000
|
|
67
|
+
return self._create_result(
|
|
68
|
+
config,
|
|
69
|
+
HealthStatus.UNHEALTHY,
|
|
70
|
+
duration_ms,
|
|
71
|
+
error=str(e),
|
|
72
|
+
)
|
|
73
|
+
|
|
60
74
|
req = urllib.request.Request(
|
|
61
75
|
config.url,
|
|
62
76
|
method=config.method,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .engine import PolicyEngine, PolicyValidationError, PolicyViolationError
|
|
2
|
+
from .models import PolicyFile, PolicySet, NetworkPolicy, OperationsPolicy, ResourcesPolicy
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"PolicyEngine",
|
|
6
|
+
"PolicyValidationError",
|
|
7
|
+
"PolicyViolationError",
|
|
8
|
+
"PolicyFile",
|
|
9
|
+
"PolicySet",
|
|
10
|
+
"NetworkPolicy",
|
|
11
|
+
"OperationsPolicy",
|
|
12
|
+
"ResourcesPolicy",
|
|
13
|
+
]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from .models import PolicyFile
|
|
11
|
+
from .validators import extract_hostname, is_host_allowed
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PolicyValidationError(ValueError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PolicyViolationError(PermissionError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
DEFAULT_PROJECT_POLICY_FILES = (".clonebox-policy.yaml", ".clonebox-policy.yml")
|
|
23
|
+
DEFAULT_GLOBAL_POLICY_FILE = Path.home() / ".clonebox.d" / "policy.yaml"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class PolicyEngine:
|
|
28
|
+
policy: PolicyFile
|
|
29
|
+
source: Path
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def load(cls, path: Path) -> "PolicyEngine":
|
|
33
|
+
try:
|
|
34
|
+
raw = yaml.safe_load(path.read_text())
|
|
35
|
+
except Exception as e:
|
|
36
|
+
raise PolicyValidationError(f"Failed to read policy file {path}: {e}")
|
|
37
|
+
|
|
38
|
+
if not isinstance(raw, dict):
|
|
39
|
+
raise PolicyValidationError("Policy file must be a YAML mapping")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
policy = PolicyFile.model_validate(raw)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
raise PolicyValidationError(str(e))
|
|
45
|
+
|
|
46
|
+
return cls(policy=policy, source=path)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def find_policy_file(cls, start: Optional[Path] = None) -> Optional[Path]:
|
|
50
|
+
start_path = (start or Path.cwd()).expanduser().resolve()
|
|
51
|
+
if start_path.is_file():
|
|
52
|
+
start_path = start_path.parent
|
|
53
|
+
|
|
54
|
+
current = start_path
|
|
55
|
+
while True:
|
|
56
|
+
for name in DEFAULT_PROJECT_POLICY_FILES:
|
|
57
|
+
candidate = current / name
|
|
58
|
+
if candidate.exists() and candidate.is_file():
|
|
59
|
+
return candidate
|
|
60
|
+
|
|
61
|
+
if current.parent == current:
|
|
62
|
+
break
|
|
63
|
+
current = current.parent
|
|
64
|
+
|
|
65
|
+
if DEFAULT_GLOBAL_POLICY_FILE.exists() and DEFAULT_GLOBAL_POLICY_FILE.is_file():
|
|
66
|
+
return DEFAULT_GLOBAL_POLICY_FILE
|
|
67
|
+
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def load_effective(cls, start: Optional[Path] = None) -> Optional["PolicyEngine"]:
|
|
72
|
+
policy_path = cls.find_policy_file(start=start)
|
|
73
|
+
if not policy_path:
|
|
74
|
+
return None
|
|
75
|
+
return cls.load(policy_path)
|
|
76
|
+
|
|
77
|
+
def assert_url_allowed(self, url: str) -> None:
|
|
78
|
+
network = self.policy.policies.network
|
|
79
|
+
if network is None:
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
hostname = extract_hostname(url)
|
|
83
|
+
if not hostname:
|
|
84
|
+
raise PolicyViolationError(f"URL has no hostname: {url}")
|
|
85
|
+
|
|
86
|
+
if not is_host_allowed(hostname, network.allowlist, network.blocklist):
|
|
87
|
+
raise PolicyViolationError(f"Network access denied by policy: {hostname}")
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _operation_matches(operation: str, pattern: str) -> bool:
|
|
91
|
+
operation = (operation or "").strip().lower()
|
|
92
|
+
pattern = (pattern or "").strip().lower()
|
|
93
|
+
if not operation or not pattern:
|
|
94
|
+
return False
|
|
95
|
+
return fnmatch.fnmatch(operation, pattern)
|
|
96
|
+
|
|
97
|
+
def requires_approval(self, operation: str) -> bool:
|
|
98
|
+
ops = self.policy.policies.operations
|
|
99
|
+
if ops is None:
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
if any(self._operation_matches(operation, p) for p in ops.auto_approve or []):
|
|
103
|
+
return False
|
|
104
|
+
return any(
|
|
105
|
+
self._operation_matches(operation, p) for p in ops.require_approval or []
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def assert_operation_approved(self, operation: str, approved: bool) -> None:
|
|
109
|
+
if self.requires_approval(operation) and not approved:
|
|
110
|
+
raise PolicyViolationError(
|
|
111
|
+
f"Operation requires approval by policy: {operation} (pass --approve)"
|
|
112
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, field_validator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NetworkPolicy(BaseModel):
|
|
9
|
+
allowlist: List[str] = Field(default_factory=list)
|
|
10
|
+
blocklist: List[str] = Field(default_factory=list)
|
|
11
|
+
|
|
12
|
+
@field_validator("allowlist", "blocklist")
|
|
13
|
+
@classmethod
|
|
14
|
+
def _validate_patterns(cls, v: List[str]) -> List[str]:
|
|
15
|
+
if v is None:
|
|
16
|
+
return []
|
|
17
|
+
if not isinstance(v, list):
|
|
18
|
+
raise TypeError("must be a list")
|
|
19
|
+
for item in v:
|
|
20
|
+
if not isinstance(item, str) or not item.strip():
|
|
21
|
+
raise ValueError("patterns must be non-empty strings")
|
|
22
|
+
return v
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OperationsPolicy(BaseModel):
|
|
26
|
+
require_approval: List[str] = Field(default_factory=list)
|
|
27
|
+
auto_approve: List[str] = Field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
@field_validator("require_approval", "auto_approve")
|
|
30
|
+
@classmethod
|
|
31
|
+
def _validate_ops(cls, v: List[str]) -> List[str]:
|
|
32
|
+
if v is None:
|
|
33
|
+
return []
|
|
34
|
+
if not isinstance(v, list):
|
|
35
|
+
raise TypeError("must be a list")
|
|
36
|
+
for item in v:
|
|
37
|
+
if not isinstance(item, str) or not item.strip():
|
|
38
|
+
raise ValueError("operation names must be non-empty strings")
|
|
39
|
+
return v
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ResourcesPolicy(BaseModel):
|
|
43
|
+
max_vms_per_user: Optional[int] = None
|
|
44
|
+
max_disk_gb: Optional[int] = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PolicySet(BaseModel):
|
|
48
|
+
network: Optional[NetworkPolicy] = None
|
|
49
|
+
operations: Optional[OperationsPolicy] = None
|
|
50
|
+
resources: Optional[ResourcesPolicy] = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PolicyFile(BaseModel):
|
|
54
|
+
version: str
|
|
55
|
+
policies: PolicySet
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
from typing import List
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def extract_hostname(url: str) -> str:
|
|
9
|
+
parsed = urlparse(url)
|
|
10
|
+
return (parsed.hostname or "").strip().lower()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def host_matches(hostname: str, pattern: str) -> bool:
|
|
14
|
+
hostname = (hostname or "").strip().lower()
|
|
15
|
+
pattern = (pattern or "").strip().lower()
|
|
16
|
+
if not hostname or not pattern:
|
|
17
|
+
return False
|
|
18
|
+
return fnmatch.fnmatch(hostname, pattern)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_host_allowed(hostname: str, allowlist: List[str], blocklist: List[str]) -> bool:
|
|
22
|
+
if any(host_matches(hostname, p) for p in blocklist or []):
|
|
23
|
+
return False
|
|
24
|
+
if allowlist:
|
|
25
|
+
return any(host_matches(hostname, p) for p in allowlist)
|
|
26
|
+
return True
|
clonebox/validator.py
CHANGED
|
@@ -32,8 +32,8 @@ class VMValidator:
|
|
|
32
32
|
self.require_running_apps = require_running_apps
|
|
33
33
|
self.smoke_test = smoke_test
|
|
34
34
|
self.results = {
|
|
35
|
-
"mounts": {"passed": 0, "failed": 0, "total": 0, "details": []},
|
|
36
|
-
"packages": {"passed": 0, "failed": 0, "total": 0, "details": []},
|
|
35
|
+
"mounts": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
36
|
+
"packages": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
37
37
|
"snap_packages": {
|
|
38
38
|
"passed": 0,
|
|
39
39
|
"failed": 0,
|
|
@@ -41,7 +41,8 @@ class VMValidator:
|
|
|
41
41
|
"total": 0,
|
|
42
42
|
"details": [],
|
|
43
43
|
},
|
|
44
|
-
"services": {"passed": 0, "failed": 0, "total": 0, "details": []},
|
|
44
|
+
"services": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
45
|
+
"disk": {"usage_pct": 0, "avail": "0", "total": "0"},
|
|
45
46
|
"apps": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
46
47
|
"smoke": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
47
48
|
"overall": "unknown",
|
|
@@ -129,6 +130,7 @@ class VMValidator:
|
|
|
129
130
|
|
|
130
131
|
def validate_mounts(self) -> Dict:
|
|
131
132
|
"""Validate all mount points and copied data paths."""
|
|
133
|
+
setup_in_progress = self._setup_in_progress_cache is True
|
|
132
134
|
self.console.print("\n[bold]💾 Validating Mounts & Data...[/]")
|
|
133
135
|
|
|
134
136
|
paths = self.config.get("paths", {})
|
|
@@ -145,7 +147,13 @@ class VMValidator:
|
|
|
145
147
|
mount_output = self._exec_in_vm("mount | grep 9p")
|
|
146
148
|
mounted_paths = []
|
|
147
149
|
if mount_output:
|
|
148
|
-
|
|
150
|
+
for line in mount_output.split("\n"):
|
|
151
|
+
line = line.strip()
|
|
152
|
+
if not line:
|
|
153
|
+
continue
|
|
154
|
+
parts = line.split()
|
|
155
|
+
if len(parts) >= 3:
|
|
156
|
+
mounted_paths.append(parts[2])
|
|
149
157
|
|
|
150
158
|
mount_table = Table(title="Data Validation", border_style="cyan")
|
|
151
159
|
mount_table.add_column("Guest Path", style="bold")
|
|
@@ -154,7 +162,6 @@ class VMValidator:
|
|
|
154
162
|
mount_table.add_column("Files", justify="right")
|
|
155
163
|
|
|
156
164
|
# Validate bind mounts (paths)
|
|
157
|
-
setup_in_progress = self._setup_in_progress() is True
|
|
158
165
|
for host_path, guest_path in paths.items():
|
|
159
166
|
self.results["mounts"]["total"] += 1
|
|
160
167
|
|
|
@@ -185,6 +192,7 @@ class VMValidator:
|
|
|
185
192
|
elif setup_in_progress:
|
|
186
193
|
status_icon = "[yellow]⏳ Pending[/]"
|
|
187
194
|
status = "pending"
|
|
195
|
+
self.results["mounts"]["skipped"] += 1
|
|
188
196
|
else:
|
|
189
197
|
status_icon = "[red]❌ Not Mounted[/]"
|
|
190
198
|
self.results["mounts"]["failed"] += 1
|
|
@@ -225,6 +233,7 @@ class VMValidator:
|
|
|
225
233
|
elif setup_in_progress:
|
|
226
234
|
status_icon = "[yellow]⏳ Pending[/]"
|
|
227
235
|
status = "pending"
|
|
236
|
+
self.results["mounts"]["skipped"] += 1
|
|
228
237
|
else:
|
|
229
238
|
status_icon = "[red]❌ Missing[/]"
|
|
230
239
|
self.results["mounts"]["failed"] += 1
|
|
@@ -249,6 +258,7 @@ class VMValidator:
|
|
|
249
258
|
|
|
250
259
|
def validate_packages(self) -> Dict:
|
|
251
260
|
"""Validate APT packages are installed."""
|
|
261
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
252
262
|
self.console.print("\n[bold]📦 Validating APT Packages...[/]")
|
|
253
263
|
|
|
254
264
|
packages = self.config.get("packages", [])
|
|
@@ -261,8 +271,6 @@ class VMValidator:
|
|
|
261
271
|
pkg_table.add_column("Status", justify="center")
|
|
262
272
|
pkg_table.add_column("Version", style="dim")
|
|
263
273
|
|
|
264
|
-
setup_in_progress = self._setup_in_progress() is True
|
|
265
|
-
|
|
266
274
|
for package in packages:
|
|
267
275
|
self.results["packages"]["total"] += 1
|
|
268
276
|
|
|
@@ -279,6 +287,7 @@ class VMValidator:
|
|
|
279
287
|
else:
|
|
280
288
|
if setup_in_progress:
|
|
281
289
|
pkg_table.add_row(package, "[yellow]⏳ Pending[/]", "")
|
|
290
|
+
self.results["packages"]["skipped"] += 1
|
|
282
291
|
self.results["packages"]["details"].append(
|
|
283
292
|
{"package": package, "installed": False, "version": None, "pending": True}
|
|
284
293
|
)
|
|
@@ -298,6 +307,7 @@ class VMValidator:
|
|
|
298
307
|
|
|
299
308
|
def validate_snap_packages(self) -> Dict:
|
|
300
309
|
"""Validate snap packages are installed."""
|
|
310
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
301
311
|
self.console.print("\n[bold]📦 Validating Snap Packages...[/]")
|
|
302
312
|
|
|
303
313
|
snap_packages = self.config.get("snap_packages", [])
|
|
@@ -310,8 +320,6 @@ class VMValidator:
|
|
|
310
320
|
snap_table.add_column("Status", justify="center")
|
|
311
321
|
snap_table.add_column("Version", style="dim")
|
|
312
322
|
|
|
313
|
-
setup_in_progress = self._setup_in_progress() is True
|
|
314
|
-
|
|
315
323
|
for package in snap_packages:
|
|
316
324
|
self.results["snap_packages"]["total"] += 1
|
|
317
325
|
|
|
@@ -379,6 +387,7 @@ class VMValidator:
|
|
|
379
387
|
|
|
380
388
|
def validate_services(self) -> Dict:
|
|
381
389
|
"""Validate services are enabled and running."""
|
|
390
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
382
391
|
self.console.print("\n[bold]⚙️ Validating Services...[/]")
|
|
383
392
|
|
|
384
393
|
services = self.config.get("services", [])
|
|
@@ -396,8 +405,6 @@ class VMValidator:
|
|
|
396
405
|
svc_table.add_column("PID", justify="right", style="dim")
|
|
397
406
|
svc_table.add_column("Note", style="dim")
|
|
398
407
|
|
|
399
|
-
setup_in_progress = self._setup_in_progress() is True
|
|
400
|
-
|
|
401
408
|
for service in services:
|
|
402
409
|
if service in self.VM_EXCLUDED_SERVICES:
|
|
403
410
|
svc_table.add_row(service, "[dim]—[/]", "[dim]—[/]", "[dim]—[/]", "host-only")
|
|
@@ -414,7 +421,6 @@ class VMValidator:
|
|
|
414
421
|
continue
|
|
415
422
|
|
|
416
423
|
self.results["services"]["total"] += 1
|
|
417
|
-
setup_in_progress = self._setup_in_progress() is True
|
|
418
424
|
|
|
419
425
|
enabled_cmd = f"systemctl is-enabled {service} 2>/dev/null"
|
|
420
426
|
enabled_status = self._exec_in_vm(enabled_cmd)
|
|
@@ -443,7 +449,9 @@ class VMValidator:
|
|
|
443
449
|
|
|
444
450
|
if is_enabled and is_running:
|
|
445
451
|
self.results["services"]["passed"] += 1
|
|
446
|
-
elif
|
|
452
|
+
elif setup_in_progress:
|
|
453
|
+
self.results["services"]["skipped"] += 1
|
|
454
|
+
else:
|
|
447
455
|
self.results["services"]["failed"] += 1
|
|
448
456
|
|
|
449
457
|
self.results["services"]["details"].append(
|
|
@@ -466,6 +474,7 @@ class VMValidator:
|
|
|
466
474
|
return self.results["services"]
|
|
467
475
|
|
|
468
476
|
def validate_apps(self) -> Dict:
|
|
477
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
469
478
|
packages = self.config.get("packages", [])
|
|
470
479
|
snap_packages = self.config.get("snap_packages", [])
|
|
471
480
|
# Support both v1 (app_data_paths) and v2 (copy_paths) config formats
|
|
@@ -651,8 +660,6 @@ class VMValidator:
|
|
|
651
660
|
note = ""
|
|
652
661
|
pending = False
|
|
653
662
|
|
|
654
|
-
setup_in_progress = self._setup_in_progress() is True
|
|
655
|
-
|
|
656
663
|
if app == "firefox":
|
|
657
664
|
installed = (
|
|
658
665
|
self._exec_in_vm("command -v firefox >/dev/null 2>&1 && echo yes || echo no")
|
|
@@ -718,6 +725,9 @@ class VMValidator:
|
|
|
718
725
|
if setup_in_progress and not installed:
|
|
719
726
|
pending = True
|
|
720
727
|
note = note or "setup in progress"
|
|
728
|
+
elif setup_in_progress and not profile_ok:
|
|
729
|
+
pending = True
|
|
730
|
+
note = note or "profile import in progress"
|
|
721
731
|
|
|
722
732
|
running_icon = (
|
|
723
733
|
"[dim]—[/]"
|
|
@@ -768,6 +778,7 @@ class VMValidator:
|
|
|
768
778
|
return self.results["apps"]
|
|
769
779
|
|
|
770
780
|
def validate_smoke_tests(self) -> Dict:
|
|
781
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
771
782
|
packages = self.config.get("packages", [])
|
|
772
783
|
snap_packages = self.config.get("snap_packages", [])
|
|
773
784
|
# Support both v1 (app_data_paths) and v2 (copy_paths) config formats
|
|
@@ -890,7 +901,6 @@ class VMValidator:
|
|
|
890
901
|
table.add_column("Launch", justify="center")
|
|
891
902
|
table.add_column("Note", style="dim")
|
|
892
903
|
|
|
893
|
-
setup_in_progress = self._setup_in_progress() is True
|
|
894
904
|
for app in expected:
|
|
895
905
|
self.results["smoke"]["total"] += 1
|
|
896
906
|
installed = _installed(app)
|
|
@@ -974,6 +984,7 @@ class VMValidator:
|
|
|
974
984
|
|
|
975
985
|
def validate_disk_space(self) -> Dict:
|
|
976
986
|
"""Validate disk space on root filesystem."""
|
|
987
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
977
988
|
self.console.print("\n[bold]💾 Validating Disk Space...[/]")
|
|
978
989
|
|
|
979
990
|
df_output = self._exec_in_vm("df -h / --output=pcent,avail,size | tail -n 1", timeout=20)
|
|
@@ -995,10 +1006,10 @@ class VMValidator:
|
|
|
995
1006
|
"total": total
|
|
996
1007
|
}
|
|
997
1008
|
|
|
998
|
-
if usage_pct >
|
|
1009
|
+
if usage_pct > 90:
|
|
999
1010
|
self.console.print(f"[red]❌ Disk nearly full: {usage_pct}% used ({avail} available of {total})[/]")
|
|
1000
1011
|
status = "fail"
|
|
1001
|
-
elif usage_pct >
|
|
1012
|
+
elif usage_pct > 85:
|
|
1002
1013
|
self.console.print(f"[yellow]⚠️ Disk usage high: {usage_pct}% used ({avail} available of {total})[/]")
|
|
1003
1014
|
status = "warning"
|
|
1004
1015
|
else:
|
|
@@ -1106,6 +1117,7 @@ class VMValidator:
|
|
|
1106
1117
|
|
|
1107
1118
|
def validate_all(self) -> Dict:
|
|
1108
1119
|
"""Run all validations and return comprehensive results."""
|
|
1120
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
1109
1121
|
self.console.print("[bold cyan]🔍 Running Full Validation...[/]")
|
|
1110
1122
|
|
|
1111
1123
|
# Check if VM is running
|
|
@@ -1129,6 +1141,14 @@ class VMValidator:
|
|
|
1129
1141
|
return self.results
|
|
1130
1142
|
|
|
1131
1143
|
# Check QEMU Guest Agent
|
|
1144
|
+
if not self._check_qga_ready():
|
|
1145
|
+
wait_deadline = time.time() + 180
|
|
1146
|
+
self.console.print("[yellow]⏳ Waiting for QEMU Guest Agent (up to 180s)...[/]")
|
|
1147
|
+
while time.time() < wait_deadline:
|
|
1148
|
+
time.sleep(5)
|
|
1149
|
+
if self._check_qga_ready():
|
|
1150
|
+
break
|
|
1151
|
+
|
|
1132
1152
|
if not self._check_qga_ready():
|
|
1133
1153
|
self.console.print("[red]❌ QEMU Guest Agent not responding[/]")
|
|
1134
1154
|
self.console.print("\n[bold]🔧 Troubleshooting QGA:[/]")
|
|
@@ -1143,7 +1163,6 @@ class VMValidator:
|
|
|
1143
1163
|
return self.results
|
|
1144
1164
|
|
|
1145
1165
|
ci_status = self._exec_in_vm("cloud-init status --long 2>/dev/null || cloud-init status 2>/dev/null || true", timeout=20)
|
|
1146
|
-
setup_in_progress = False
|
|
1147
1166
|
if ci_status:
|
|
1148
1167
|
ci_lower = ci_status.lower()
|
|
1149
1168
|
if "running" in ci_lower:
|
|
@@ -1180,8 +1199,10 @@ class VMValidator:
|
|
|
1180
1199
|
)
|
|
1181
1200
|
|
|
1182
1201
|
# Calculate overall status
|
|
1202
|
+
disk_failed = 1 if self.results.get("disk", {}).get("usage_pct", 0) > 90 else 0
|
|
1183
1203
|
total_checks = (
|
|
1184
|
-
|
|
1204
|
+
1 # Disk space check
|
|
1205
|
+
+ self.results["mounts"]["total"]
|
|
1185
1206
|
+ self.results["packages"]["total"]
|
|
1186
1207
|
+ self.results["snap_packages"]["total"]
|
|
1187
1208
|
+ self.results["services"]["total"]
|
|
@@ -1190,7 +1211,8 @@ class VMValidator:
|
|
|
1190
1211
|
)
|
|
1191
1212
|
|
|
1192
1213
|
total_passed = (
|
|
1193
|
-
|
|
1214
|
+
(1 - disk_failed)
|
|
1215
|
+
+ self.results["mounts"]["passed"]
|
|
1194
1216
|
+ self.results["packages"]["passed"]
|
|
1195
1217
|
+ self.results["snap_packages"]["passed"]
|
|
1196
1218
|
+ self.results["services"]["passed"]
|
|
@@ -1199,7 +1221,8 @@ class VMValidator:
|
|
|
1199
1221
|
)
|
|
1200
1222
|
|
|
1201
1223
|
total_failed = (
|
|
1202
|
-
|
|
1224
|
+
disk_failed
|
|
1225
|
+
+ self.results["mounts"]["failed"]
|
|
1203
1226
|
+ self.results["packages"]["failed"]
|
|
1204
1227
|
+ self.results["snap_packages"]["failed"]
|
|
1205
1228
|
+ self.results["services"]["failed"]
|
|
@@ -1207,12 +1230,14 @@ class VMValidator:
|
|
|
1207
1230
|
+ (self.results["smoke"]["failed"] if self.smoke_test else 0)
|
|
1208
1231
|
)
|
|
1209
1232
|
|
|
1210
|
-
# Get skipped
|
|
1233
|
+
# Get skipped counts
|
|
1234
|
+
skipped_mounts = self.results["mounts"].get("skipped", 0)
|
|
1235
|
+
skipped_packages = self.results["packages"].get("skipped", 0)
|
|
1211
1236
|
skipped_services = self.results["services"].get("skipped", 0)
|
|
1212
1237
|
skipped_snaps = self.results["snap_packages"].get("skipped", 0)
|
|
1213
1238
|
skipped_apps = self.results["apps"].get("skipped", 0)
|
|
1214
1239
|
skipped_smoke = self.results["smoke"].get("skipped", 0) if self.smoke_test else 0
|
|
1215
|
-
total_skipped = skipped_services + skipped_snaps + skipped_apps + skipped_smoke
|
|
1240
|
+
total_skipped = skipped_mounts + skipped_packages + skipped_services + skipped_snaps + skipped_apps + skipped_smoke
|
|
1216
1241
|
|
|
1217
1242
|
# Print summary
|
|
1218
1243
|
self.console.print("\n[bold]📊 Validation Summary[/]")
|
|
@@ -1220,21 +1245,38 @@ class VMValidator:
|
|
|
1220
1245
|
summary_table.add_column("Category", style="bold")
|
|
1221
1246
|
summary_table.add_column("Passed", justify="right", style="green")
|
|
1222
1247
|
summary_table.add_column("Failed", justify="right", style="red")
|
|
1223
|
-
summary_table.add_column("Skipped", justify="right", style="dim")
|
|
1248
|
+
summary_table.add_column("Skipped/Pending", justify="right", style="dim")
|
|
1224
1249
|
summary_table.add_column("Total", justify="right")
|
|
1225
1250
|
|
|
1251
|
+
# Add Disk Space row
|
|
1252
|
+
disk_usage_pct = self.results.get("disk", {}).get("usage_pct", 0)
|
|
1253
|
+
disk_avail = self.results.get("disk", {}).get("avail", "?")
|
|
1254
|
+
disk_total = self.results.get("disk", {}).get("total", "?")
|
|
1255
|
+
|
|
1256
|
+
# Calculate used space if possible
|
|
1257
|
+
disk_status_passed = "[green]OK[/]" if disk_usage_pct <= 90 else "—"
|
|
1258
|
+
disk_status_failed = "—" if disk_usage_pct <= 90 else f"[red]FULL ({disk_usage_pct}%)[/]"
|
|
1259
|
+
|
|
1260
|
+
summary_table.add_row(
|
|
1261
|
+
"Disk Space",
|
|
1262
|
+
disk_status_passed,
|
|
1263
|
+
disk_status_failed,
|
|
1264
|
+
"—",
|
|
1265
|
+
f"{disk_usage_pct}% of {disk_total} ({disk_avail} free)",
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1226
1268
|
summary_table.add_row(
|
|
1227
1269
|
"Mounts",
|
|
1228
1270
|
str(self.results["mounts"]["passed"]),
|
|
1229
1271
|
str(self.results["mounts"]["failed"]),
|
|
1230
|
-
"—",
|
|
1272
|
+
str(skipped_mounts) if skipped_mounts else "—",
|
|
1231
1273
|
str(self.results["mounts"]["total"]),
|
|
1232
1274
|
)
|
|
1233
1275
|
summary_table.add_row(
|
|
1234
1276
|
"APT Packages",
|
|
1235
1277
|
str(self.results["packages"]["passed"]),
|
|
1236
1278
|
str(self.results["packages"]["failed"]),
|
|
1237
|
-
"—",
|
|
1279
|
+
str(skipped_packages) if skipped_packages else "—",
|
|
1238
1280
|
str(self.results["packages"]["total"]),
|
|
1239
1281
|
)
|
|
1240
1282
|
summary_table.add_row(
|
|
@@ -1248,7 +1290,7 @@ class VMValidator:
|
|
|
1248
1290
|
"Services",
|
|
1249
1291
|
str(self.results["services"]["passed"]),
|
|
1250
1292
|
str(self.results["services"]["failed"]),
|
|
1251
|
-
str(skipped_services),
|
|
1293
|
+
str(skipped_services) if skipped_services else "—",
|
|
1252
1294
|
str(self.results["services"]["total"]),
|
|
1253
1295
|
)
|
|
1254
1296
|
summary_table.add_row(
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
clonebox/__init__.py,sha256=CyfHVVq6KqBr4CNERBpXk_O6Q5B35q03YpdQbokVvvI,408
|
|
2
2
|
clonebox/__main__.py,sha256=Fcoyzwwyz5-eC_sBlQk5a5RbKx8uodQz5sKJ190U0NU,135
|
|
3
3
|
clonebox/audit.py,sha256=1W9vaIjB0A--_p7CgE3cIP5RNckJG1RxJrL-tOb-QmU,14298
|
|
4
|
-
clonebox/cli.py,sha256=
|
|
5
|
-
clonebox/cloner.py,sha256=
|
|
4
|
+
clonebox/cli.py,sha256=yxGj733N1OUt8m4UzqxGV9jBZCdehychpV8KRi7zZJU,179262
|
|
5
|
+
clonebox/cloner.py,sha256=rU5MERLn2JwayjLM2RsMeOWdY0xJSkCVzXl7dj9uIVM,111622
|
|
6
6
|
clonebox/container.py,sha256=tiYK1ZB-DhdD6A2FuMA0h_sRNkUI7KfYcJ0tFOcdyeM,6105
|
|
7
7
|
clonebox/dashboard.py,sha256=dMY6odvPq3j6FronhRRsX7aY3qdCwznB-aCWKEmHDNw,5768
|
|
8
8
|
clonebox/detector.py,sha256=vS65cvFNPmUBCX1Y_TMTnSRljw6r1Ae9dlVtACs5XFc,23075
|
|
@@ -20,14 +20,14 @@ clonebox/resource_monitor.py,sha256=lDR9KyPbVtImeeOkOBPPVP-5yCgoL5hsVFPZ_UqsY0w,
|
|
|
20
20
|
clonebox/resources.py,sha256=IkuM4OdSDV4qhyc0eIynwbAHBTv0aVSxxW-gghsnCAs,6815
|
|
21
21
|
clonebox/rollback.py,sha256=hpwO-8Ehe1pW0wHuZvJkC_qxZ6yEo9otCJRhGIUArCo,5711
|
|
22
22
|
clonebox/secrets.py,sha256=l1jwJcEPB1qMoGNLPjyrkKKr1khh9VmftFJI9BWhgK0,10628
|
|
23
|
-
clonebox/validator.py,sha256=
|
|
24
|
-
clonebox/backends/libvirt_backend.py,sha256=
|
|
23
|
+
clonebox/validator.py,sha256=2d14DHSo7Im-fFJaaDeACN1H82728FmwcZDSPVKbG44,53368
|
|
24
|
+
clonebox/backends/libvirt_backend.py,sha256=_HxB2itduhDsXrWoNTqelUqHEXnPqbnBuFNu8XJkNFA,7269
|
|
25
25
|
clonebox/backends/qemu_disk.py,sha256=YsGjYX5sbEf35Y4yjTpNkZat73a4RGBxY-KTVzJhqIs,1687
|
|
26
26
|
clonebox/backends/subprocess_runner.py,sha256=c-IyaMxM1cmUu64h654oAvulm83K5Mu-VQxXJ_0BOds,1506
|
|
27
27
|
clonebox/health/__init__.py,sha256=aKJJPQwJLnoCY728QuKUxYx1TZyooGEnyUVOegZ58Ok,422
|
|
28
28
|
clonebox/health/manager.py,sha256=6nn0a8QtxeEuuafDbn5ZBqHQdaJ2qg7yTstyAGPJWP0,9987
|
|
29
29
|
clonebox/health/models.py,sha256=sPumwj8S-88KgzSGw1Kq9bBbPVRd2RR0R87Z8hKJ_28,6001
|
|
30
|
-
clonebox/health/probes.py,sha256=
|
|
30
|
+
clonebox/health/probes.py,sha256=1tu0wi5TZ3Nk8z_cVDUZ7DVDrt7sY0AHJHqi9qn3ChA,11874
|
|
31
31
|
clonebox/interfaces/disk.py,sha256=F7Xzj2dq5UTZ2KGCuThDM8bwTps6chFbquOUmfLREjI,985
|
|
32
32
|
clonebox/interfaces/hypervisor.py,sha256=8ms4kZLA-5Ba1e_n68mCucwP_K9mufbmTBlo7XzURn4,1991
|
|
33
33
|
clonebox/interfaces/network.py,sha256=YPIquxEB7sZHczbpuopcZpffTjWYI6cKmAu3wAEFllk,853
|
|
@@ -35,14 +35,18 @@ clonebox/interfaces/process.py,sha256=njvAIZw_TCjw01KpyVQKIDoRvhTwl0FfVGbQ6mxTRO
|
|
|
35
35
|
clonebox/plugins/__init__.py,sha256=3cxlz159nokZCOL2c017WqTwt5z00yyn-o-SemP1g6c,416
|
|
36
36
|
clonebox/plugins/base.py,sha256=A2H-2vrYUczNZCDioQ8cAtvaSob4YpXutx7FWMjksC4,10133
|
|
37
37
|
clonebox/plugins/manager.py,sha256=W2ithedEEOh9iWSq3_M5_g2SQWl85aI5qrvrjOKv02I,16842
|
|
38
|
+
clonebox/policies/__init__.py,sha256=I7mDDU_gyZKb2pdyp7WUoN53p5qLxRxBOgHSNuPfNr8,365
|
|
39
|
+
clonebox/policies/engine.py,sha256=Wf6uG3-yf2YNOjLnpvACp5fUM5elNpK68ta_2ccmk_g,3573
|
|
40
|
+
clonebox/policies/models.py,sha256=BO5rFv21YUOjgfSgcgJUhBwUo5_0z2If1c4Io3ruo0k,1648
|
|
41
|
+
clonebox/policies/validators.py,sha256=rVAsbIMxKLpU1WeRDXkY4i282e0H25kaK6WshGmPxoQ,756
|
|
38
42
|
clonebox/snapshots/__init__.py,sha256=ndlrIavPAiA8z4Ep3-D_EPhOcjNKYFnP3rIpEKaGdb8,273
|
|
39
43
|
clonebox/snapshots/manager.py,sha256=hGzM8V6ZJPXjTqj47c4Kr8idlE-c1Q3gPUvuw1HvS1A,11393
|
|
40
44
|
clonebox/snapshots/models.py,sha256=sRnn3OZE8JG9FZJlRuA3ihO-JXoPCQ3nD3SQytflAao,6206
|
|
41
45
|
clonebox/templates/profiles/ml-dev.yaml,sha256=w07MToGh31xtxpjbeXTBk9BkpAN8A3gv8HeA3ESKG9M,461
|
|
42
46
|
clonebox/templates/profiles/web-stack.yaml,sha256=EBnnGMzML5vAjXmIUbCpbTCwmRaNJiuWd3EcL43DOK8,485
|
|
43
|
-
clonebox-1.1.
|
|
44
|
-
clonebox-1.1.
|
|
45
|
-
clonebox-1.1.
|
|
46
|
-
clonebox-1.1.
|
|
47
|
-
clonebox-1.1.
|
|
48
|
-
clonebox-1.1.
|
|
47
|
+
clonebox-1.1.19.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
48
|
+
clonebox-1.1.19.dist-info/METADATA,sha256=mJT8xFHNlOOcqtT7eINj2-QmcIaB4Otz32bUdcATmic,49052
|
|
49
|
+
clonebox-1.1.19.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
50
|
+
clonebox-1.1.19.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
|
|
51
|
+
clonebox-1.1.19.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
|
|
52
|
+
clonebox-1.1.19.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|