clonebox 1.1.18__py3-none-any.whl → 1.1.20__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 +604 -557
- 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 +93 -31
- {clonebox-1.1.18.dist-info → clonebox-1.1.20.dist-info}/METADATA +1 -1
- {clonebox-1.1.18.dist-info → clonebox-1.1.20.dist-info}/RECORD +15 -11
- {clonebox-1.1.18.dist-info → clonebox-1.1.20.dist-info}/WHEEL +0 -0
- {clonebox-1.1.18.dist-info → clonebox-1.1.20.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.18.dist-info → clonebox-1.1.20.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.18.dist-info → clonebox-1.1.20.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", [])
|
|
@@ -256,14 +266,17 @@ class VMValidator:
|
|
|
256
266
|
self.console.print("[dim]No APT packages configured[/]")
|
|
257
267
|
return self.results["packages"]
|
|
258
268
|
|
|
269
|
+
total_pkgs = len(packages)
|
|
270
|
+
self.console.print(f"[dim]Checking {total_pkgs} packages via QGA...[/]")
|
|
271
|
+
|
|
259
272
|
pkg_table = Table(title="Package Validation", border_style="cyan")
|
|
260
273
|
pkg_table.add_column("Package", style="bold")
|
|
261
274
|
pkg_table.add_column("Status", justify="center")
|
|
262
275
|
pkg_table.add_column("Version", style="dim")
|
|
263
276
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
277
|
+
for idx, package in enumerate(packages, 1):
|
|
278
|
+
if idx == 1 or idx % 25 == 0 or idx == total_pkgs:
|
|
279
|
+
self.console.print(f"[dim] ...packages progress: {idx}/{total_pkgs}[/]")
|
|
267
280
|
self.results["packages"]["total"] += 1
|
|
268
281
|
|
|
269
282
|
# Check if installed
|
|
@@ -279,6 +292,7 @@ class VMValidator:
|
|
|
279
292
|
else:
|
|
280
293
|
if setup_in_progress:
|
|
281
294
|
pkg_table.add_row(package, "[yellow]⏳ Pending[/]", "")
|
|
295
|
+
self.results["packages"]["skipped"] += 1
|
|
282
296
|
self.results["packages"]["details"].append(
|
|
283
297
|
{"package": package, "installed": False, "version": None, "pending": True}
|
|
284
298
|
)
|
|
@@ -298,6 +312,7 @@ class VMValidator:
|
|
|
298
312
|
|
|
299
313
|
def validate_snap_packages(self) -> Dict:
|
|
300
314
|
"""Validate snap packages are installed."""
|
|
315
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
301
316
|
self.console.print("\n[bold]📦 Validating Snap Packages...[/]")
|
|
302
317
|
|
|
303
318
|
snap_packages = self.config.get("snap_packages", [])
|
|
@@ -305,14 +320,17 @@ class VMValidator:
|
|
|
305
320
|
self.console.print("[dim]No snap packages configured[/]")
|
|
306
321
|
return self.results["snap_packages"]
|
|
307
322
|
|
|
323
|
+
total_snaps = len(snap_packages)
|
|
324
|
+
self.console.print(f"[dim]Checking {total_snaps} snap packages via QGA...[/]")
|
|
325
|
+
|
|
308
326
|
snap_table = Table(title="Snap Package Validation", border_style="cyan")
|
|
309
327
|
snap_table.add_column("Package", style="bold")
|
|
310
328
|
snap_table.add_column("Status", justify="center")
|
|
311
329
|
snap_table.add_column("Version", style="dim")
|
|
312
330
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
331
|
+
for idx, package in enumerate(snap_packages, 1):
|
|
332
|
+
if idx == 1 or idx % 25 == 0 or idx == total_snaps:
|
|
333
|
+
self.console.print(f"[dim] ...snap progress: {idx}/{total_snaps}[/]")
|
|
316
334
|
self.results["snap_packages"]["total"] += 1
|
|
317
335
|
|
|
318
336
|
# Check if installed
|
|
@@ -379,6 +397,7 @@ class VMValidator:
|
|
|
379
397
|
|
|
380
398
|
def validate_services(self) -> Dict:
|
|
381
399
|
"""Validate services are enabled and running."""
|
|
400
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
382
401
|
self.console.print("\n[bold]⚙️ Validating Services...[/]")
|
|
383
402
|
|
|
384
403
|
services = self.config.get("services", [])
|
|
@@ -386,6 +405,9 @@ class VMValidator:
|
|
|
386
405
|
self.console.print("[dim]No services configured[/]")
|
|
387
406
|
return self.results["services"]
|
|
388
407
|
|
|
408
|
+
total_svcs = len(services)
|
|
409
|
+
self.console.print(f"[dim]Checking {total_svcs} services via QGA...[/]")
|
|
410
|
+
|
|
389
411
|
if "skipped" not in self.results["services"]:
|
|
390
412
|
self.results["services"]["skipped"] = 0
|
|
391
413
|
|
|
@@ -396,9 +418,9 @@ class VMValidator:
|
|
|
396
418
|
svc_table.add_column("PID", justify="right", style="dim")
|
|
397
419
|
svc_table.add_column("Note", style="dim")
|
|
398
420
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
421
|
+
for idx, service in enumerate(services, 1):
|
|
422
|
+
if idx == 1 or idx % 25 == 0 or idx == total_svcs:
|
|
423
|
+
self.console.print(f"[dim] ...services progress: {idx}/{total_svcs}[/]")
|
|
402
424
|
if service in self.VM_EXCLUDED_SERVICES:
|
|
403
425
|
svc_table.add_row(service, "[dim]—[/]", "[dim]—[/]", "[dim]—[/]", "host-only")
|
|
404
426
|
self.results["services"]["skipped"] += 1
|
|
@@ -414,7 +436,6 @@ class VMValidator:
|
|
|
414
436
|
continue
|
|
415
437
|
|
|
416
438
|
self.results["services"]["total"] += 1
|
|
417
|
-
setup_in_progress = self._setup_in_progress() is True
|
|
418
439
|
|
|
419
440
|
enabled_cmd = f"systemctl is-enabled {service} 2>/dev/null"
|
|
420
441
|
enabled_status = self._exec_in_vm(enabled_cmd)
|
|
@@ -443,7 +464,9 @@ class VMValidator:
|
|
|
443
464
|
|
|
444
465
|
if is_enabled and is_running:
|
|
445
466
|
self.results["services"]["passed"] += 1
|
|
446
|
-
elif
|
|
467
|
+
elif setup_in_progress:
|
|
468
|
+
self.results["services"]["skipped"] += 1
|
|
469
|
+
else:
|
|
447
470
|
self.results["services"]["failed"] += 1
|
|
448
471
|
|
|
449
472
|
self.results["services"]["details"].append(
|
|
@@ -466,6 +489,7 @@ class VMValidator:
|
|
|
466
489
|
return self.results["services"]
|
|
467
490
|
|
|
468
491
|
def validate_apps(self) -> Dict:
|
|
492
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
469
493
|
packages = self.config.get("packages", [])
|
|
470
494
|
snap_packages = self.config.get("snap_packages", [])
|
|
471
495
|
# Support both v1 (app_data_paths) and v2 (copy_paths) config formats
|
|
@@ -651,8 +675,6 @@ class VMValidator:
|
|
|
651
675
|
note = ""
|
|
652
676
|
pending = False
|
|
653
677
|
|
|
654
|
-
setup_in_progress = self._setup_in_progress() is True
|
|
655
|
-
|
|
656
678
|
if app == "firefox":
|
|
657
679
|
installed = (
|
|
658
680
|
self._exec_in_vm("command -v firefox >/dev/null 2>&1 && echo yes || echo no")
|
|
@@ -718,6 +740,9 @@ class VMValidator:
|
|
|
718
740
|
if setup_in_progress and not installed:
|
|
719
741
|
pending = True
|
|
720
742
|
note = note or "setup in progress"
|
|
743
|
+
elif setup_in_progress and not profile_ok:
|
|
744
|
+
pending = True
|
|
745
|
+
note = note or "profile import in progress"
|
|
721
746
|
|
|
722
747
|
running_icon = (
|
|
723
748
|
"[dim]—[/]"
|
|
@@ -768,6 +793,7 @@ class VMValidator:
|
|
|
768
793
|
return self.results["apps"]
|
|
769
794
|
|
|
770
795
|
def validate_smoke_tests(self) -> Dict:
|
|
796
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
771
797
|
packages = self.config.get("packages", [])
|
|
772
798
|
snap_packages = self.config.get("snap_packages", [])
|
|
773
799
|
# Support both v1 (app_data_paths) and v2 (copy_paths) config formats
|
|
@@ -890,7 +916,6 @@ class VMValidator:
|
|
|
890
916
|
table.add_column("Launch", justify="center")
|
|
891
917
|
table.add_column("Note", style="dim")
|
|
892
918
|
|
|
893
|
-
setup_in_progress = self._setup_in_progress() is True
|
|
894
919
|
for app in expected:
|
|
895
920
|
self.results["smoke"]["total"] += 1
|
|
896
921
|
installed = _installed(app)
|
|
@@ -974,6 +999,7 @@ class VMValidator:
|
|
|
974
999
|
|
|
975
1000
|
def validate_disk_space(self) -> Dict:
|
|
976
1001
|
"""Validate disk space on root filesystem."""
|
|
1002
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
977
1003
|
self.console.print("\n[bold]💾 Validating Disk Space...[/]")
|
|
978
1004
|
|
|
979
1005
|
df_output = self._exec_in_vm("df -h / --output=pcent,avail,size | tail -n 1", timeout=20)
|
|
@@ -995,10 +1021,10 @@ class VMValidator:
|
|
|
995
1021
|
"total": total
|
|
996
1022
|
}
|
|
997
1023
|
|
|
998
|
-
if usage_pct >
|
|
1024
|
+
if usage_pct > 90:
|
|
999
1025
|
self.console.print(f"[red]❌ Disk nearly full: {usage_pct}% used ({avail} available of {total})[/]")
|
|
1000
1026
|
status = "fail"
|
|
1001
|
-
elif usage_pct >
|
|
1027
|
+
elif usage_pct > 85:
|
|
1002
1028
|
self.console.print(f"[yellow]⚠️ Disk usage high: {usage_pct}% used ({avail} available of {total})[/]")
|
|
1003
1029
|
status = "warning"
|
|
1004
1030
|
else:
|
|
@@ -1106,6 +1132,7 @@ class VMValidator:
|
|
|
1106
1132
|
|
|
1107
1133
|
def validate_all(self) -> Dict:
|
|
1108
1134
|
"""Run all validations and return comprehensive results."""
|
|
1135
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
1109
1136
|
self.console.print("[bold cyan]🔍 Running Full Validation...[/]")
|
|
1110
1137
|
|
|
1111
1138
|
# Check if VM is running
|
|
@@ -1129,6 +1156,19 @@ class VMValidator:
|
|
|
1129
1156
|
return self.results
|
|
1130
1157
|
|
|
1131
1158
|
# Check QEMU Guest Agent
|
|
1159
|
+
if not self._check_qga_ready():
|
|
1160
|
+
wait_deadline = time.time() + 180
|
|
1161
|
+
self.console.print("[yellow]⏳ Waiting for QEMU Guest Agent (up to 180s)...[/]")
|
|
1162
|
+
last_log = 0
|
|
1163
|
+
while time.time() < wait_deadline:
|
|
1164
|
+
time.sleep(5)
|
|
1165
|
+
if self._check_qga_ready():
|
|
1166
|
+
break
|
|
1167
|
+
elapsed = int(180 - (wait_deadline - time.time()))
|
|
1168
|
+
if elapsed - last_log >= 15:
|
|
1169
|
+
self.console.print(f"[dim] ...still waiting for QGA ({elapsed}s elapsed)[/]")
|
|
1170
|
+
last_log = elapsed
|
|
1171
|
+
|
|
1132
1172
|
if not self._check_qga_ready():
|
|
1133
1173
|
self.console.print("[red]❌ QEMU Guest Agent not responding[/]")
|
|
1134
1174
|
self.console.print("\n[bold]🔧 Troubleshooting QGA:[/]")
|
|
@@ -1143,7 +1183,6 @@ class VMValidator:
|
|
|
1143
1183
|
return self.results
|
|
1144
1184
|
|
|
1145
1185
|
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
1186
|
if ci_status:
|
|
1148
1187
|
ci_lower = ci_status.lower()
|
|
1149
1188
|
if "running" in ci_lower:
|
|
@@ -1180,8 +1219,10 @@ class VMValidator:
|
|
|
1180
1219
|
)
|
|
1181
1220
|
|
|
1182
1221
|
# Calculate overall status
|
|
1222
|
+
disk_failed = 1 if self.results.get("disk", {}).get("usage_pct", 0) > 90 else 0
|
|
1183
1223
|
total_checks = (
|
|
1184
|
-
|
|
1224
|
+
1 # Disk space check
|
|
1225
|
+
+ self.results["mounts"]["total"]
|
|
1185
1226
|
+ self.results["packages"]["total"]
|
|
1186
1227
|
+ self.results["snap_packages"]["total"]
|
|
1187
1228
|
+ self.results["services"]["total"]
|
|
@@ -1190,7 +1231,8 @@ class VMValidator:
|
|
|
1190
1231
|
)
|
|
1191
1232
|
|
|
1192
1233
|
total_passed = (
|
|
1193
|
-
|
|
1234
|
+
(1 - disk_failed)
|
|
1235
|
+
+ self.results["mounts"]["passed"]
|
|
1194
1236
|
+ self.results["packages"]["passed"]
|
|
1195
1237
|
+ self.results["snap_packages"]["passed"]
|
|
1196
1238
|
+ self.results["services"]["passed"]
|
|
@@ -1199,7 +1241,8 @@ class VMValidator:
|
|
|
1199
1241
|
)
|
|
1200
1242
|
|
|
1201
1243
|
total_failed = (
|
|
1202
|
-
|
|
1244
|
+
disk_failed
|
|
1245
|
+
+ self.results["mounts"]["failed"]
|
|
1203
1246
|
+ self.results["packages"]["failed"]
|
|
1204
1247
|
+ self.results["snap_packages"]["failed"]
|
|
1205
1248
|
+ self.results["services"]["failed"]
|
|
@@ -1207,12 +1250,14 @@ class VMValidator:
|
|
|
1207
1250
|
+ (self.results["smoke"]["failed"] if self.smoke_test else 0)
|
|
1208
1251
|
)
|
|
1209
1252
|
|
|
1210
|
-
# Get skipped
|
|
1253
|
+
# Get skipped counts
|
|
1254
|
+
skipped_mounts = self.results["mounts"].get("skipped", 0)
|
|
1255
|
+
skipped_packages = self.results["packages"].get("skipped", 0)
|
|
1211
1256
|
skipped_services = self.results["services"].get("skipped", 0)
|
|
1212
1257
|
skipped_snaps = self.results["snap_packages"].get("skipped", 0)
|
|
1213
1258
|
skipped_apps = self.results["apps"].get("skipped", 0)
|
|
1214
1259
|
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
|
|
1260
|
+
total_skipped = skipped_mounts + skipped_packages + skipped_services + skipped_snaps + skipped_apps + skipped_smoke
|
|
1216
1261
|
|
|
1217
1262
|
# Print summary
|
|
1218
1263
|
self.console.print("\n[bold]📊 Validation Summary[/]")
|
|
@@ -1220,21 +1265,38 @@ class VMValidator:
|
|
|
1220
1265
|
summary_table.add_column("Category", style="bold")
|
|
1221
1266
|
summary_table.add_column("Passed", justify="right", style="green")
|
|
1222
1267
|
summary_table.add_column("Failed", justify="right", style="red")
|
|
1223
|
-
summary_table.add_column("Skipped", justify="right", style="dim")
|
|
1268
|
+
summary_table.add_column("Skipped/Pending", justify="right", style="dim")
|
|
1224
1269
|
summary_table.add_column("Total", justify="right")
|
|
1225
1270
|
|
|
1271
|
+
# Add Disk Space row
|
|
1272
|
+
disk_usage_pct = self.results.get("disk", {}).get("usage_pct", 0)
|
|
1273
|
+
disk_avail = self.results.get("disk", {}).get("avail", "?")
|
|
1274
|
+
disk_total = self.results.get("disk", {}).get("total", "?")
|
|
1275
|
+
|
|
1276
|
+
# Calculate used space if possible
|
|
1277
|
+
disk_status_passed = "[green]OK[/]" if disk_usage_pct <= 90 else "—"
|
|
1278
|
+
disk_status_failed = "—" if disk_usage_pct <= 90 else f"[red]FULL ({disk_usage_pct}%)[/]"
|
|
1279
|
+
|
|
1280
|
+
summary_table.add_row(
|
|
1281
|
+
"Disk Space",
|
|
1282
|
+
disk_status_passed,
|
|
1283
|
+
disk_status_failed,
|
|
1284
|
+
"—",
|
|
1285
|
+
f"{disk_usage_pct}% of {disk_total} ({disk_avail} free)",
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1226
1288
|
summary_table.add_row(
|
|
1227
1289
|
"Mounts",
|
|
1228
1290
|
str(self.results["mounts"]["passed"]),
|
|
1229
1291
|
str(self.results["mounts"]["failed"]),
|
|
1230
|
-
"—",
|
|
1292
|
+
str(skipped_mounts) if skipped_mounts else "—",
|
|
1231
1293
|
str(self.results["mounts"]["total"]),
|
|
1232
1294
|
)
|
|
1233
1295
|
summary_table.add_row(
|
|
1234
1296
|
"APT Packages",
|
|
1235
1297
|
str(self.results["packages"]["passed"]),
|
|
1236
1298
|
str(self.results["packages"]["failed"]),
|
|
1237
|
-
"—",
|
|
1299
|
+
str(skipped_packages) if skipped_packages else "—",
|
|
1238
1300
|
str(self.results["packages"]["total"]),
|
|
1239
1301
|
)
|
|
1240
1302
|
summary_table.add_row(
|
|
@@ -1248,7 +1310,7 @@ class VMValidator:
|
|
|
1248
1310
|
"Services",
|
|
1249
1311
|
str(self.results["services"]["passed"]),
|
|
1250
1312
|
str(self.results["services"]["failed"]),
|
|
1251
|
-
str(skipped_services),
|
|
1313
|
+
str(skipped_services) if skipped_services else "—",
|
|
1252
1314
|
str(self.results["services"]["total"]),
|
|
1253
1315
|
)
|
|
1254
1316
|
summary_table.add_row(
|