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/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
- mounted_paths = [line.split()[2] for line in mount_output.split("\n") if line.strip()]
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
- setup_in_progress = self._setup_in_progress() is True
265
-
266
- for package in packages:
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
- setup_in_progress = self._setup_in_progress() is True
314
-
315
- for package in snap_packages:
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
- setup_in_progress = self._setup_in_progress() is True
400
-
401
- for service in services:
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 not setup_in_progress:
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 > 95:
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 > 80:
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
- self.results["mounts"]["total"]
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
- self.results["mounts"]["passed"]
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
- self.results["mounts"]["failed"]
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 services count
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(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clonebox
3
- Version: 1.1.18
3
+ Version: 1.1.20
4
4
  Summary: Clone your workstation environment to an isolated VM with selective apps, paths and services
5
5
  Author: CloneBox Team
6
6
  License: Apache-2.0