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/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", [])
@@ -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 not setup_in_progress:
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 > 95:
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 > 80:
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
- self.results["mounts"]["total"]
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
- self.results["mounts"]["passed"]
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
- self.results["mounts"]["failed"]
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 services count
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clonebox
3
- Version: 1.1.18
3
+ Version: 1.1.19
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
@@ -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=x0VQ9WI1KZrNQGAh7FDf_036jDypdT5Q7vuTlr-dgi0,179087
5
- clonebox/cloner.py,sha256=WEMNjhHVdXwiA2zCq14fr5eVD6vhLuX4vnJ47p3SiNI,107839
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=PWPgkR9cjewTh6-uW_2YyjxuEj2wI9O7h7hk7NK8nAI,51181
24
- clonebox/backends/libvirt_backend.py,sha256=sIHFIvFO1hIOXEFR_foSkOGBgIzaJVQs-njOU8GdafA,7170
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=CkiGcayqRRysqaBJst-YpSrvUzMdwsqD4TiQSluLt3Y,11305
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.18.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
44
- clonebox-1.1.18.dist-info/METADATA,sha256=n1VJE8-Mk-zf9QgHBbvt1Z5fbJMdpD1vjZVVjEBeaxE,49052
45
- clonebox-1.1.18.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
46
- clonebox-1.1.18.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
47
- clonebox-1.1.18.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
48
- clonebox-1.1.18.dist-info/RECORD,,
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,,