clonebox 0.1.25__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/validator.py ADDED
@@ -0,0 +1,841 @@
1
+ """
2
+ VM validation module - validates VM state against YAML configuration.
3
+ """
4
+ import subprocess
5
+ import json
6
+ import base64
7
+ import time
8
+ from typing import Dict, List, Tuple, Optional
9
+ from pathlib import Path
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+
14
+
15
+ class VMValidator:
16
+ """Validates VM configuration against expected state from YAML."""
17
+
18
+ def __init__(
19
+ self,
20
+ config: dict,
21
+ vm_name: str,
22
+ conn_uri: str,
23
+ console: Console = None,
24
+ require_running_apps: bool = False,
25
+ smoke_test: bool = False,
26
+ ):
27
+ self.config = config
28
+ self.vm_name = vm_name
29
+ self.conn_uri = conn_uri
30
+ self.console = console or Console()
31
+ self.require_running_apps = require_running_apps
32
+ self.smoke_test = smoke_test
33
+ self.results = {
34
+ "mounts": {"passed": 0, "failed": 0, "total": 0, "details": []},
35
+ "packages": {"passed": 0, "failed": 0, "total": 0, "details": []},
36
+ "snap_packages": {"passed": 0, "failed": 0, "total": 0, "details": []},
37
+ "services": {"passed": 0, "failed": 0, "total": 0, "details": []},
38
+ "apps": {"passed": 0, "failed": 0, "total": 0, "details": []},
39
+ "smoke": {"passed": 0, "failed": 0, "total": 0, "details": []},
40
+ "overall": "unknown"
41
+ }
42
+
43
+ def _exec_in_vm(self, command: str, timeout: int = 10) -> Optional[str]:
44
+ """Execute command in VM using QEMU guest agent."""
45
+ try:
46
+ # Execute command
47
+ result = subprocess.run(
48
+ ["virsh", "--connect", self.conn_uri, "qemu-agent-command", self.vm_name,
49
+ f'{{"execute":"guest-exec","arguments":{{"path":"/bin/sh","arg":["-c","{command}"],"capture-output":true}}}}'],
50
+ capture_output=True, text=True, timeout=timeout
51
+ )
52
+
53
+ if result.returncode != 0:
54
+ return None
55
+
56
+ response = json.loads(result.stdout)
57
+ if "return" not in response or "pid" not in response["return"]:
58
+ return None
59
+
60
+ pid = response["return"]["pid"]
61
+
62
+ # Wait a bit for command to complete
63
+ time.sleep(0.3)
64
+
65
+ # Get result
66
+ status_result = subprocess.run(
67
+ ["virsh", "--connect", self.conn_uri, "qemu-agent-command", self.vm_name,
68
+ f'{{"execute":"guest-exec-status","arguments":{{"pid":{pid}}}}}'],
69
+ capture_output=True, text=True, timeout=5
70
+ )
71
+
72
+ if status_result.returncode != 0:
73
+ return None
74
+
75
+ status_resp = json.loads(status_result.stdout)
76
+ if "return" not in status_resp:
77
+ return None
78
+
79
+ ret = status_resp["return"]
80
+ if not ret.get("exited", False):
81
+ return None
82
+
83
+ if "out-data" in ret:
84
+ return base64.b64decode(ret["out-data"]).decode().strip()
85
+
86
+ return ""
87
+
88
+ except Exception:
89
+ return None
90
+
91
+ def validate_mounts(self) -> Dict:
92
+ """Validate all mount points are accessible and contain data."""
93
+ self.console.print("\n[bold]💾 Validating Mount Points...[/]")
94
+
95
+ all_paths = self.config.get("paths", {}).copy()
96
+ all_paths.update(self.config.get("app_data_paths", {}))
97
+
98
+ if not all_paths:
99
+ self.console.print("[dim]No mount points configured[/]")
100
+ return self.results["mounts"]
101
+
102
+ # Get mounted filesystems
103
+ mount_output = self._exec_in_vm("mount | grep 9p")
104
+ mounted_paths = []
105
+ if mount_output:
106
+ mounted_paths = [line.split()[2] for line in mount_output.split('\n') if line.strip()]
107
+
108
+ mount_table = Table(title="Mount Validation", border_style="cyan")
109
+ mount_table.add_column("Guest Path", style="bold")
110
+ mount_table.add_column("Mounted", justify="center")
111
+ mount_table.add_column("Accessible", justify="center")
112
+ mount_table.add_column("Files", justify="right")
113
+
114
+ for host_path, guest_path in all_paths.items():
115
+ self.results["mounts"]["total"] += 1
116
+
117
+ # Check if mounted
118
+ is_mounted = any(guest_path in mp for mp in mounted_paths)
119
+
120
+ # Check if accessible
121
+ accessible = False
122
+ file_count = "?"
123
+
124
+ if is_mounted:
125
+ test_result = self._exec_in_vm(f"test -d {guest_path} && echo 'yes' || echo 'no'")
126
+ accessible = test_result == "yes"
127
+
128
+ if accessible:
129
+ # Get file count
130
+ count_str = self._exec_in_vm(f"ls -A {guest_path} 2>/dev/null | wc -l")
131
+ if count_str and count_str.isdigit():
132
+ file_count = count_str
133
+
134
+ # Determine status
135
+ if is_mounted and accessible:
136
+ mount_status = "[green]✅[/]"
137
+ access_status = "[green]✅[/]"
138
+ self.results["mounts"]["passed"] += 1
139
+ status = "pass"
140
+ elif is_mounted:
141
+ mount_status = "[green]✅[/]"
142
+ access_status = "[red]❌[/]"
143
+ self.results["mounts"]["failed"] += 1
144
+ status = "mounted_but_inaccessible"
145
+ else:
146
+ mount_status = "[red]❌[/]"
147
+ access_status = "[dim]N/A[/]"
148
+ self.results["mounts"]["failed"] += 1
149
+ status = "not_mounted"
150
+
151
+ mount_table.add_row(guest_path, mount_status, access_status, str(file_count))
152
+
153
+ self.results["mounts"]["details"].append({
154
+ "path": guest_path,
155
+ "mounted": is_mounted,
156
+ "accessible": accessible,
157
+ "files": file_count,
158
+ "status": status
159
+ })
160
+
161
+ self.console.print(mount_table)
162
+ self.console.print(f"[dim]{self.results['mounts']['passed']}/{self.results['mounts']['total']} mounts working[/]")
163
+
164
+ return self.results["mounts"]
165
+
166
+ def validate_packages(self) -> Dict:
167
+ """Validate APT packages are installed."""
168
+ self.console.print("\n[bold]📦 Validating APT Packages...[/]")
169
+
170
+ packages = self.config.get("packages", [])
171
+ if not packages:
172
+ self.console.print("[dim]No APT packages configured[/]")
173
+ return self.results["packages"]
174
+
175
+ pkg_table = Table(title="Package Validation", border_style="cyan")
176
+ pkg_table.add_column("Package", style="bold")
177
+ pkg_table.add_column("Status", justify="center")
178
+ pkg_table.add_column("Version", style="dim")
179
+
180
+ for package in packages:
181
+ self.results["packages"]["total"] += 1
182
+
183
+ # Check if installed
184
+ check_cmd = f"dpkg -l | grep -E '^ii {package}' | awk '{{print $3}}'"
185
+ version = self._exec_in_vm(check_cmd)
186
+
187
+ if version:
188
+ pkg_table.add_row(package, "[green]✅ Installed[/]", version[:40])
189
+ self.results["packages"]["passed"] += 1
190
+ self.results["packages"]["details"].append({
191
+ "package": package,
192
+ "installed": True,
193
+ "version": version
194
+ })
195
+ else:
196
+ pkg_table.add_row(package, "[red]❌ Missing[/]", "")
197
+ self.results["packages"]["failed"] += 1
198
+ self.results["packages"]["details"].append({
199
+ "package": package,
200
+ "installed": False,
201
+ "version": None
202
+ })
203
+
204
+ self.console.print(pkg_table)
205
+ self.console.print(f"[dim]{self.results['packages']['passed']}/{self.results['packages']['total']} packages installed[/]")
206
+
207
+ return self.results["packages"]
208
+
209
+ def validate_snap_packages(self) -> Dict:
210
+ """Validate snap packages are installed."""
211
+ self.console.print("\n[bold]📦 Validating Snap Packages...[/]")
212
+
213
+ snap_packages = self.config.get("snap_packages", [])
214
+ if not snap_packages:
215
+ self.console.print("[dim]No snap packages configured[/]")
216
+ return self.results["snap_packages"]
217
+
218
+ snap_table = Table(title="Snap Package Validation", border_style="cyan")
219
+ snap_table.add_column("Package", style="bold")
220
+ snap_table.add_column("Status", justify="center")
221
+ snap_table.add_column("Version", style="dim")
222
+
223
+ for package in snap_packages:
224
+ self.results["snap_packages"]["total"] += 1
225
+
226
+ # Check if installed
227
+ check_cmd = f"snap list | grep '^{package}' | awk '{{print $2}}'"
228
+ version = self._exec_in_vm(check_cmd)
229
+
230
+ if version:
231
+ snap_table.add_row(package, "[green]✅ Installed[/]", version[:40])
232
+ self.results["snap_packages"]["passed"] += 1
233
+ self.results["snap_packages"]["details"].append({
234
+ "package": package,
235
+ "installed": True,
236
+ "version": version
237
+ })
238
+ else:
239
+ snap_table.add_row(package, "[red]❌ Missing[/]", "")
240
+ self.results["snap_packages"]["failed"] += 1
241
+ self.results["snap_packages"]["details"].append({
242
+ "package": package,
243
+ "installed": False,
244
+ "version": None
245
+ })
246
+
247
+ self.console.print(snap_table)
248
+ self.console.print(f"[dim]{self.results['snap_packages']['passed']}/{self.results['snap_packages']['total']} snap packages installed[/]")
249
+
250
+ return self.results["snap_packages"]
251
+
252
+ # Services that should NOT be validated in VM (host-specific)
253
+ VM_EXCLUDED_SERVICES = {
254
+ "libvirtd", "virtlogd", "libvirt-guests", "qemu-guest-agent",
255
+ "bluetooth", "bluez", "upower", "thermald", "tlp", "power-profiles-daemon",
256
+ "gdm", "gdm3", "sddm", "lightdm",
257
+ "snap.cups.cups-browsed", "snap.cups.cupsd",
258
+ "ModemManager", "wpa_supplicant",
259
+ "accounts-daemon", "colord", "switcheroo-control",
260
+ }
261
+
262
+ def validate_services(self) -> Dict:
263
+ """Validate services are enabled and running."""
264
+ self.console.print("\n[bold]⚙️ Validating Services...[/]")
265
+
266
+ services = self.config.get("services", [])
267
+ if not services:
268
+ self.console.print("[dim]No services configured[/]")
269
+ return self.results["services"]
270
+
271
+ if "skipped" not in self.results["services"]:
272
+ self.results["services"]["skipped"] = 0
273
+
274
+ svc_table = Table(title="Service Validation", border_style="cyan")
275
+ svc_table.add_column("Service", style="bold")
276
+ svc_table.add_column("Enabled", justify="center")
277
+ svc_table.add_column("Running", justify="center")
278
+ svc_table.add_column("PID", justify="right", style="dim")
279
+ svc_table.add_column("Note", style="dim")
280
+
281
+ for service in services:
282
+ if service in self.VM_EXCLUDED_SERVICES:
283
+ svc_table.add_row(service, "[dim]—[/]", "[dim]—[/]", "[dim]—[/]", "host-only")
284
+ self.results["services"]["skipped"] += 1
285
+ self.results["services"]["details"].append(
286
+ {
287
+ "service": service,
288
+ "enabled": None,
289
+ "running": None,
290
+ "skipped": True,
291
+ "reason": "host-specific service",
292
+ }
293
+ )
294
+ continue
295
+
296
+ self.results["services"]["total"] += 1
297
+
298
+ enabled_cmd = f"systemctl is-enabled {service} 2>/dev/null"
299
+ enabled_status = self._exec_in_vm(enabled_cmd)
300
+ is_enabled = enabled_status == "enabled"
301
+
302
+ running_cmd = f"systemctl is-active {service} 2>/dev/null"
303
+ running_status = self._exec_in_vm(running_cmd)
304
+ is_running = running_status == "active"
305
+
306
+ pid_value = ""
307
+ if is_running:
308
+ pid_out = self._exec_in_vm(f"systemctl show -p MainPID --value {service} 2>/dev/null")
309
+ if pid_out is None:
310
+ pid_value = "?"
311
+ else:
312
+ pid_value = pid_out.strip() or "?"
313
+ else:
314
+ pid_value = "—"
315
+
316
+ enabled_icon = "[green]✅[/]" if is_enabled else "[yellow]⚠️[/]"
317
+ running_icon = "[green]✅[/]" if is_running else "[red]❌[/]"
318
+
319
+ svc_table.add_row(service, enabled_icon, running_icon, pid_value, "")
320
+
321
+ if is_enabled and is_running:
322
+ self.results["services"]["passed"] += 1
323
+ else:
324
+ self.results["services"]["failed"] += 1
325
+
326
+ self.results["services"]["details"].append(
327
+ {
328
+ "service": service,
329
+ "enabled": is_enabled,
330
+ "running": is_running,
331
+ "pid": None if pid_value in ("", "—", "?") else pid_value,
332
+ "skipped": False,
333
+ }
334
+ )
335
+
336
+ self.console.print(svc_table)
337
+ skipped = self.results["services"].get("skipped", 0)
338
+ msg = f"{self.results['services']['passed']}/{self.results['services']['total']} services active"
339
+ if skipped > 0:
340
+ msg += f" ({skipped} host-only skipped)"
341
+ self.console.print(f"[dim]{msg}[/]")
342
+
343
+ return self.results["services"]
344
+
345
+ def validate_apps(self) -> Dict:
346
+ packages = self.config.get("packages", [])
347
+ snap_packages = self.config.get("snap_packages", [])
348
+ app_data_paths = self.config.get("app_data_paths", {})
349
+ vm_user = self.config.get("vm", {}).get("username", "ubuntu")
350
+
351
+ snap_app_specs = {
352
+ "pycharm-community": {
353
+ "process_patterns": ["pycharm-community", "pycharm", "jetbrains"],
354
+ "required_interfaces": ["desktop", "desktop-legacy", "x11", "wayland", "home", "network"],
355
+ },
356
+ "chromium": {
357
+ "process_patterns": ["chromium", "chromium-browser"],
358
+ "required_interfaces": ["desktop", "desktop-legacy", "x11", "wayland", "home", "network"],
359
+ },
360
+ "firefox": {
361
+ "process_patterns": ["firefox"],
362
+ "required_interfaces": ["desktop", "desktop-legacy", "x11", "wayland", "home", "network"],
363
+ },
364
+ "code": {
365
+ "process_patterns": ["code"],
366
+ "required_interfaces": ["desktop", "desktop-legacy", "x11", "wayland", "home", "network"],
367
+ },
368
+ }
369
+
370
+ expected = []
371
+
372
+ if "firefox" in packages:
373
+ expected.append("firefox")
374
+
375
+ for snap_pkg in snap_packages:
376
+ if snap_pkg in snap_app_specs:
377
+ expected.append(snap_pkg)
378
+
379
+ for _, guest_path in app_data_paths.items():
380
+ if guest_path == "/home/ubuntu/.config/google-chrome":
381
+ expected.append("google-chrome")
382
+ break
383
+
384
+ expected = sorted(set(expected))
385
+ if not expected:
386
+ return self.results["apps"]
387
+
388
+ self.console.print("\n[bold]🧩 Validating Apps...[/]")
389
+ table = Table(title="App Validation", border_style="cyan")
390
+ table.add_column("App", style="bold")
391
+ table.add_column("Installed", justify="center")
392
+ table.add_column("Profile", justify="center")
393
+ table.add_column("Running", justify="center")
394
+ table.add_column("PID", justify="right", style="dim")
395
+ table.add_column("Note", style="dim")
396
+
397
+ def _pgrep_pattern(pattern: str) -> str:
398
+ if not pattern:
399
+ return pattern
400
+ return f"[{pattern[0]}]{pattern[1:]}"
401
+
402
+ def _check_any_process_running(patterns: List[str]) -> Optional[bool]:
403
+ for pattern in patterns:
404
+ p = _pgrep_pattern(pattern)
405
+ out = self._exec_in_vm(
406
+ f"pgrep -u {vm_user} -f '{p}' >/dev/null 2>&1 && echo yes || echo no",
407
+ timeout=10,
408
+ )
409
+ if out is None:
410
+ return None
411
+ if out == "yes":
412
+ return True
413
+ return False
414
+
415
+ def _find_first_pid(patterns: List[str]) -> Optional[str]:
416
+ for pattern in patterns:
417
+ p = _pgrep_pattern(pattern)
418
+ out = self._exec_in_vm(
419
+ f"pgrep -u {vm_user} -f '{p}' 2>/dev/null | head -n 1 || true",
420
+ timeout=10,
421
+ )
422
+ if out is None:
423
+ return None
424
+ pid = out.strip()
425
+ if pid:
426
+ return pid
427
+ return ""
428
+
429
+ def _collect_app_logs(app_name: str) -> str:
430
+ chunks: List[str] = []
431
+
432
+ def add(cmd: str, title: str, timeout: int = 20):
433
+ out = self._exec_in_vm(cmd, timeout=timeout)
434
+ if out is None:
435
+ return
436
+ out = out.strip()
437
+ if not out:
438
+ return
439
+ chunks.append(f"{title}\n$ {cmd}\n{out}")
440
+
441
+ if app_name in snap_app_specs:
442
+ add(f"snap connections {app_name} 2>/dev/null | head -n 40", "Snap connections")
443
+ add(f"snap logs {app_name} -n 80 2>/dev/null | tail -n 60", "Snap logs")
444
+
445
+ if app_name == "pycharm-community":
446
+ add(
447
+ "tail -n 80 /home/ubuntu/snap/pycharm-community/common/.config/JetBrains/*/log/idea.log 2>/dev/null || true",
448
+ "idea.log",
449
+ )
450
+
451
+ if app_name == "google-chrome":
452
+ add("journalctl -n 200 --no-pager 2>/dev/null | grep -i chrome | tail -n 60 || true", "Journal (chrome)")
453
+ if app_name == "firefox":
454
+ add("journalctl -n 200 --no-pager 2>/dev/null | grep -i firefox | tail -n 60 || true", "Journal (firefox)")
455
+
456
+ return "\n\n".join(chunks)
457
+
458
+ def _snap_missing_interfaces(snap_name: str, required: List[str]) -> Optional[List[str]]:
459
+ out = self._exec_in_vm(
460
+ f"snap connections {snap_name} 2>/dev/null | awk 'NR>1{{print $1, $3}}'",
461
+ timeout=15,
462
+ )
463
+ if out is None:
464
+ return None
465
+
466
+ connected = set()
467
+ for line in out.splitlines():
468
+ parts = line.split()
469
+ if len(parts) < 2:
470
+ continue
471
+ iface, slot = parts[0], parts[1]
472
+ if slot != "-":
473
+ connected.add(iface)
474
+
475
+ missing = [i for i in required if i not in connected]
476
+ return missing
477
+
478
+ def _check_dir_nonempty(path: str) -> bool:
479
+ out = self._exec_in_vm(
480
+ f"test -d {path} && [ $(ls -A {path} 2>/dev/null | wc -l) -gt 0 ] && echo yes || echo no",
481
+ timeout=10,
482
+ )
483
+ return out == "yes"
484
+
485
+ for app in expected:
486
+ self.results["apps"]["total"] += 1
487
+ installed = False
488
+ profile_ok = False
489
+ running: Optional[bool] = None
490
+ pid: Optional[str] = None
491
+ note = ""
492
+
493
+ if app == "firefox":
494
+ installed = (
495
+ self._exec_in_vm("command -v firefox >/dev/null 2>&1 && echo yes || echo no")
496
+ == "yes"
497
+ )
498
+ if _check_dir_nonempty("/home/ubuntu/snap/firefox/common/.mozilla/firefox"):
499
+ profile_ok = True
500
+ elif _check_dir_nonempty("/home/ubuntu/.mozilla/firefox"):
501
+ profile_ok = True
502
+
503
+ if installed:
504
+ running = _check_any_process_running(["firefox"])
505
+ pid = _find_first_pid(["firefox"]) if running else ""
506
+
507
+ elif app in snap_app_specs:
508
+ installed = (
509
+ self._exec_in_vm(f"snap list {app} >/dev/null 2>&1 && echo yes || echo no")
510
+ == "yes"
511
+ )
512
+ if app == "pycharm-community":
513
+ profile_ok = _check_dir_nonempty(
514
+ "/home/ubuntu/snap/pycharm-community/common/.config/JetBrains"
515
+ )
516
+ else:
517
+ profile_ok = True
518
+
519
+ if installed:
520
+ patterns = snap_app_specs[app]["process_patterns"]
521
+ running = _check_any_process_running(patterns)
522
+ pid = _find_first_pid(patterns) if running else ""
523
+ if running is False:
524
+ missing_ifaces = _snap_missing_interfaces(
525
+ app,
526
+ snap_app_specs[app]["required_interfaces"],
527
+ )
528
+ if missing_ifaces:
529
+ note = f"missing interfaces: {', '.join(missing_ifaces)}"
530
+ elif missing_ifaces == []:
531
+ note = "not running"
532
+ else:
533
+ note = "interfaces unknown"
534
+
535
+ elif app == "google-chrome":
536
+ installed = (
537
+ self._exec_in_vm(
538
+ "(command -v google-chrome >/dev/null 2>&1 || command -v google-chrome-stable >/dev/null 2>&1) && echo yes || echo no"
539
+ )
540
+ == "yes"
541
+ )
542
+ profile_ok = _check_dir_nonempty("/home/ubuntu/.config/google-chrome")
543
+
544
+ if installed:
545
+ running = _check_any_process_running(["google-chrome", "google-chrome-stable"])
546
+ pid = _find_first_pid(["google-chrome", "google-chrome-stable"]) if running else ""
547
+
548
+ if self.require_running_apps and installed and profile_ok and running is None:
549
+ note = note or "running unknown"
550
+
551
+ running_icon = (
552
+ "[dim]—[/]"
553
+ if not installed
554
+ else "[green]✅[/]" if running is True else "[yellow]⚠️[/]" if running is False else "[dim]?[/]"
555
+ )
556
+
557
+ pid_value = "—" if not installed else ("?" if pid is None else (pid or "—"))
558
+
559
+ table.add_row(
560
+ app,
561
+ "[green]✅[/]" if installed else "[red]❌[/]",
562
+ "[green]✅[/]" if profile_ok else "[red]❌[/]",
563
+ running_icon,
564
+ pid_value,
565
+ note,
566
+ )
567
+
568
+ should_pass = installed and profile_ok
569
+ if self.require_running_apps and installed and profile_ok:
570
+ should_pass = running is True
571
+
572
+ if should_pass:
573
+ self.results["apps"]["passed"] += 1
574
+ else:
575
+ self.results["apps"]["failed"] += 1
576
+
577
+ self.results["apps"]["details"].append(
578
+ {
579
+ "app": app,
580
+ "installed": installed,
581
+ "profile": profile_ok,
582
+ "running": running,
583
+ "pid": pid,
584
+ "note": note,
585
+ }
586
+ )
587
+
588
+ if installed and profile_ok and running in (False, None):
589
+ logs = _collect_app_logs(app)
590
+ if logs:
591
+ self.console.print(Panel(logs, title=f"Logs: {app}", border_style="yellow"))
592
+
593
+ self.console.print(table)
594
+ return self.results["apps"]
595
+
596
+ def validate_smoke_tests(self) -> Dict:
597
+ packages = self.config.get("packages", [])
598
+ snap_packages = self.config.get("snap_packages", [])
599
+ app_data_paths = self.config.get("app_data_paths", {})
600
+ vm_user = self.config.get("vm", {}).get("username", "ubuntu")
601
+
602
+ expected = []
603
+
604
+ if "firefox" in packages:
605
+ expected.append("firefox")
606
+
607
+ for snap_pkg in snap_packages:
608
+ if snap_pkg in {"pycharm-community", "chromium", "firefox", "code"}:
609
+ expected.append(snap_pkg)
610
+
611
+ for _, guest_path in app_data_paths.items():
612
+ if guest_path == "/home/ubuntu/.config/google-chrome":
613
+ expected.append("google-chrome")
614
+ break
615
+
616
+ if "docker" in (self.config.get("services", []) or []) or "docker.io" in packages:
617
+ expected.append("docker")
618
+
619
+ expected = sorted(set(expected))
620
+ if not expected:
621
+ return self.results["smoke"]
622
+
623
+ def _installed(app: str) -> Optional[bool]:
624
+ if app in {"pycharm-community", "chromium", "firefox", "code"}:
625
+ out = self._exec_in_vm(f"snap list {app} >/dev/null 2>&1 && echo yes || echo no", timeout=10)
626
+ return None if out is None else out.strip() == "yes"
627
+
628
+ if app == "google-chrome":
629
+ out = self._exec_in_vm(
630
+ "(command -v google-chrome >/dev/null 2>&1 || command -v google-chrome-stable >/dev/null 2>&1) && echo yes || echo no",
631
+ timeout=10,
632
+ )
633
+ return None if out is None else out.strip() == "yes"
634
+
635
+ if app == "docker":
636
+ out = self._exec_in_vm("command -v docker >/dev/null 2>&1 && echo yes || echo no", timeout=10)
637
+ return None if out is None else out.strip() == "yes"
638
+
639
+ if app == "firefox":
640
+ out = self._exec_in_vm("command -v firefox >/dev/null 2>&1 && echo yes || echo no", timeout=10)
641
+ return None if out is None else out.strip() == "yes"
642
+
643
+ out = self._exec_in_vm(f"command -v {app} >/dev/null 2>&1 && echo yes || echo no", timeout=10)
644
+ return None if out is None else out.strip() == "yes"
645
+
646
+ def _run_test(app: str) -> Optional[bool]:
647
+ user_env = f"sudo -u {vm_user} env HOME=/home/{vm_user} XDG_RUNTIME_DIR=/run/user/1000"
648
+
649
+ if app == "pycharm-community":
650
+ out = self._exec_in_vm(
651
+ "/snap/pycharm-community/current/jbr/bin/java -version >/dev/null 2>&1 && echo yes || echo no",
652
+ timeout=20,
653
+ )
654
+ return None if out is None else out.strip() == "yes"
655
+
656
+ if app == "chromium":
657
+ out = self._exec_in_vm(
658
+ f"{user_env} timeout 20 chromium --headless=new --no-sandbox --disable-gpu --dump-dom about:blank >/dev/null 2>&1 && echo yes || echo no",
659
+ timeout=30,
660
+ )
661
+ return None if out is None else out.strip() == "yes"
662
+
663
+ if app == "firefox":
664
+ out = self._exec_in_vm(
665
+ f"{user_env} timeout 20 firefox --headless --screenshot /tmp/clonebox-firefox.png about:blank >/dev/null 2>&1 && rm -f /tmp/clonebox-firefox.png && echo yes || echo no",
666
+ timeout=30,
667
+ )
668
+ return None if out is None else out.strip() == "yes"
669
+
670
+ if app == "google-chrome":
671
+ out = self._exec_in_vm(
672
+ f"{user_env} timeout 20 google-chrome --headless=new --no-sandbox --disable-gpu --dump-dom about:blank >/dev/null 2>&1 && echo yes || echo no",
673
+ timeout=30,
674
+ )
675
+ return None if out is None else out.strip() == "yes"
676
+
677
+ if app == "docker":
678
+ out = self._exec_in_vm("timeout 20 docker info >/dev/null 2>&1 && echo yes || echo no", timeout=30)
679
+ return None if out is None else out.strip() == "yes"
680
+
681
+ out = self._exec_in_vm(f"timeout 20 {app} --version >/dev/null 2>&1 && echo yes || echo no", timeout=30)
682
+ return None if out is None else out.strip() == "yes"
683
+
684
+ self.console.print("\n[bold]🧪 Smoke Tests (installed ≠ works)...[/]")
685
+ table = Table(title="Smoke Tests", border_style="cyan")
686
+ table.add_column("App", style="bold")
687
+ table.add_column("Installed", justify="center")
688
+ table.add_column("Launch", justify="center")
689
+ table.add_column("Note", style="dim")
690
+
691
+ for app in expected:
692
+ self.results["smoke"]["total"] += 1
693
+ installed = _installed(app)
694
+ launched: Optional[bool] = None
695
+ note = ""
696
+
697
+ if installed is True:
698
+ launched = _run_test(app)
699
+ if launched is None:
700
+ note = "test failed to execute"
701
+ elif installed is False:
702
+ note = "not installed"
703
+ else:
704
+ note = "install status unknown"
705
+
706
+ installed_icon = "[green]✅[/]" if installed is True else "[red]❌[/]" if installed is False else "[dim]?[/]"
707
+ launch_icon = "[green]✅[/]" if launched is True else "[red]❌[/]" if launched is False else ("[dim]—[/]" if installed is not True else "[dim]?[/]")
708
+
709
+ table.add_row(app, installed_icon, launch_icon, note)
710
+
711
+ passed = installed is True and launched is True
712
+ if passed:
713
+ self.results["smoke"]["passed"] += 1
714
+ else:
715
+ self.results["smoke"]["failed"] += 1
716
+
717
+ self.results["smoke"]["details"].append(
718
+ {
719
+ "app": app,
720
+ "installed": installed,
721
+ "launched": launched,
722
+ "note": note,
723
+ }
724
+ )
725
+
726
+ self.console.print(table)
727
+ return self.results["smoke"]
728
+
729
+ def validate_all(self) -> Dict:
730
+ """Run all validations and return comprehensive results."""
731
+ self.console.print("[bold cyan]🔍 Running Full Validation...[/]")
732
+
733
+ # Check if VM is running
734
+ try:
735
+ result = subprocess.run(
736
+ ["virsh", "--connect", self.conn_uri, "domstate", self.vm_name],
737
+ capture_output=True, text=True, timeout=5
738
+ )
739
+ vm_state = result.stdout.strip()
740
+
741
+ if "running" not in vm_state.lower():
742
+ self.console.print(f"[yellow]⚠️ VM is not running (state: {vm_state})[/]")
743
+ self.console.print("[dim]Start VM with: clonebox start .[/]")
744
+ self.results["overall"] = "vm_not_running"
745
+ return self.results
746
+ except Exception as e:
747
+ self.console.print(f"[red]❌ Cannot check VM state: {e}[/]")
748
+ self.results["overall"] = "error"
749
+ return self.results
750
+
751
+ # Run all validations
752
+ self.validate_mounts()
753
+ self.validate_packages()
754
+ self.validate_snap_packages()
755
+ self.validate_services()
756
+ self.validate_apps()
757
+ if self.smoke_test:
758
+ self.validate_smoke_tests()
759
+
760
+ recent_err = self._exec_in_vm("journalctl -p err -n 30 --no-pager 2>/dev/null || true", timeout=20)
761
+ if recent_err:
762
+ recent_err = recent_err.strip()
763
+ if recent_err:
764
+ self.console.print(Panel(recent_err, title="Recent system errors", border_style="red"))
765
+
766
+ # Calculate overall status
767
+ total_checks = (
768
+ self.results["mounts"]["total"]
769
+ + self.results["packages"]["total"]
770
+ + self.results["snap_packages"]["total"]
771
+ + self.results["services"]["total"]
772
+ + self.results["apps"]["total"]
773
+ + (self.results["smoke"]["total"] if self.smoke_test else 0)
774
+ )
775
+
776
+ total_passed = (
777
+ self.results["mounts"]["passed"]
778
+ + self.results["packages"]["passed"]
779
+ + self.results["snap_packages"]["passed"]
780
+ + self.results["services"]["passed"]
781
+ + self.results["apps"]["passed"]
782
+ + (self.results["smoke"]["passed"] if self.smoke_test else 0)
783
+ )
784
+
785
+ total_failed = (
786
+ self.results["mounts"]["failed"]
787
+ + self.results["packages"]["failed"]
788
+ + self.results["snap_packages"]["failed"]
789
+ + self.results["services"]["failed"]
790
+ + self.results["apps"]["failed"]
791
+ + (self.results["smoke"]["failed"] if self.smoke_test else 0)
792
+ )
793
+
794
+ # Get skipped services count
795
+ skipped_services = self.results["services"].get("skipped", 0)
796
+
797
+ # Print summary
798
+ self.console.print("\n[bold]📊 Validation Summary[/]")
799
+ summary_table = Table(border_style="cyan")
800
+ summary_table.add_column("Category", style="bold")
801
+ summary_table.add_column("Passed", justify="right", style="green")
802
+ summary_table.add_column("Failed", justify="right", style="red")
803
+ summary_table.add_column("Skipped", justify="right", style="dim")
804
+ summary_table.add_column("Total", justify="right")
805
+
806
+ summary_table.add_row("Mounts", str(self.results["mounts"]["passed"]),
807
+ str(self.results["mounts"]["failed"]), "—",
808
+ str(self.results["mounts"]["total"]))
809
+ summary_table.add_row("APT Packages", str(self.results["packages"]["passed"]),
810
+ str(self.results["packages"]["failed"]), "—",
811
+ str(self.results["packages"]["total"]))
812
+ summary_table.add_row("Snap Packages", str(self.results["snap_packages"]["passed"]),
813
+ str(self.results["snap_packages"]["failed"]), "—",
814
+ str(self.results["snap_packages"]["total"]))
815
+ summary_table.add_row("Services", str(self.results["services"]["passed"]),
816
+ str(self.results["services"]["failed"]),
817
+ str(skipped_services),
818
+ str(self.results["services"]["total"]))
819
+ summary_table.add_row("Apps", str(self.results["apps"]["passed"]),
820
+ str(self.results["apps"]["failed"]), "—",
821
+ str(self.results["apps"]["total"]))
822
+ summary_table.add_row("[bold]TOTAL", f"[bold green]{total_passed}",
823
+ f"[bold red]{total_failed}",
824
+ f"[dim]{skipped_services}[/]",
825
+ f"[bold]{total_checks}")
826
+
827
+ self.console.print(summary_table)
828
+
829
+ # Determine overall status
830
+ if total_failed == 0 and total_checks > 0:
831
+ self.results["overall"] = "pass"
832
+ self.console.print("\n[bold green]✅ All validations passed![/]")
833
+ elif total_failed > 0:
834
+ self.results["overall"] = "partial"
835
+ self.console.print(f"\n[bold yellow]⚠️ {total_failed}/{total_checks} checks failed[/]")
836
+ self.console.print("[dim]Consider rebuilding VM: clonebox clone . --user --run --replace[/]")
837
+ else:
838
+ self.results["overall"] = "no_checks"
839
+ self.console.print("\n[dim]No validation checks configured[/]")
840
+
841
+ return self.results