clonebox 0.1.20__py3-none-any.whl → 0.1.22__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 CHANGED
@@ -8,23 +8,35 @@ import time
8
8
  from typing import Dict, List, Tuple, Optional
9
9
  from pathlib import Path
10
10
  from rich.console import Console
11
+ from rich.panel import Panel
11
12
  from rich.table import Table
12
13
 
13
14
 
14
15
  class VMValidator:
15
16
  """Validates VM configuration against expected state from YAML."""
16
17
 
17
- def __init__(self, config: dict, vm_name: str, conn_uri: str, console: Console = None):
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
+ ):
18
27
  self.config = config
19
28
  self.vm_name = vm_name
20
29
  self.conn_uri = conn_uri
21
30
  self.console = console or Console()
31
+ self.require_running_apps = require_running_apps
32
+ self.smoke_test = smoke_test
22
33
  self.results = {
23
34
  "mounts": {"passed": 0, "failed": 0, "total": 0, "details": []},
24
35
  "packages": {"passed": 0, "failed": 0, "total": 0, "details": []},
25
36
  "snap_packages": {"passed": 0, "failed": 0, "total": 0, "details": []},
26
37
  "services": {"passed": 0, "failed": 0, "total": 0, "details": []},
27
38
  "apps": {"passed": 0, "failed": 0, "total": 0, "details": []},
39
+ "smoke": {"passed": 0, "failed": 0, "total": 0, "details": []},
28
40
  "overall": "unknown"
29
41
  }
30
42
 
@@ -263,11 +275,12 @@ class VMValidator:
263
275
  svc_table.add_column("Service", style="bold")
264
276
  svc_table.add_column("Enabled", justify="center")
265
277
  svc_table.add_column("Running", justify="center")
278
+ svc_table.add_column("PID", justify="right", style="dim")
266
279
  svc_table.add_column("Note", style="dim")
267
280
 
268
281
  for service in services:
269
282
  if service in self.VM_EXCLUDED_SERVICES:
270
- svc_table.add_row(service, "[dim]—[/]", "[dim]—[/]", "host-only")
283
+ svc_table.add_row(service, "[dim]—[/]", "[dim]—[/]", "[dim]—[/]", "host-only")
271
284
  self.results["services"]["skipped"] += 1
272
285
  self.results["services"]["details"].append(
273
286
  {
@@ -290,10 +303,20 @@ class VMValidator:
290
303
  running_status = self._exec_in_vm(running_cmd)
291
304
  is_running = running_status == "active"
292
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
+
293
316
  enabled_icon = "[green]✅[/]" if is_enabled else "[yellow]⚠️[/]"
294
317
  running_icon = "[green]✅[/]" if is_running else "[red]❌[/]"
295
318
 
296
- svc_table.add_row(service, enabled_icon, running_icon, "")
319
+ svc_table.add_row(service, enabled_icon, running_icon, pid_value, "")
297
320
 
298
321
  if is_enabled and is_running:
299
322
  self.results["services"]["passed"] += 1
@@ -305,6 +328,7 @@ class VMValidator:
305
328
  "service": service,
306
329
  "enabled": is_enabled,
307
330
  "running": is_running,
331
+ "pid": None if pid_value in ("", "—", "?") else pid_value,
308
332
  "skipped": False,
309
333
  }
310
334
  )
@@ -322,13 +346,35 @@ class VMValidator:
322
346
  packages = self.config.get("packages", [])
323
347
  snap_packages = self.config.get("snap_packages", [])
324
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
+ }
325
369
 
326
370
  expected = []
327
371
 
328
372
  if "firefox" in packages:
329
373
  expected.append("firefox")
330
- if "pycharm-community" in snap_packages:
331
- expected.append("pycharm-community")
374
+
375
+ for snap_pkg in snap_packages:
376
+ if snap_pkg in snap_app_specs:
377
+ expected.append(snap_pkg)
332
378
 
333
379
  for _, guest_path in app_data_paths.items():
334
380
  if guest_path == "/home/ubuntu/.config/google-chrome":
@@ -344,6 +390,90 @@ class VMValidator:
344
390
  table.add_column("App", style="bold")
345
391
  table.add_column("Installed", justify="center")
346
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
347
477
 
348
478
  def _check_dir_nonempty(path: str) -> bool:
349
479
  out = self._exec_in_vm(
@@ -356,6 +486,9 @@ class VMValidator:
356
486
  self.results["apps"]["total"] += 1
357
487
  installed = False
358
488
  profile_ok = False
489
+ running: Optional[bool] = None
490
+ pid: Optional[str] = None
491
+ note = ""
359
492
 
360
493
  if app == "firefox":
361
494
  installed = (
@@ -367,16 +500,37 @@ class VMValidator:
367
500
  elif _check_dir_nonempty("/home/ubuntu/.mozilla/firefox"):
368
501
  profile_ok = True
369
502
 
370
- elif app == "pycharm-community":
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:
371
508
  installed = (
372
- self._exec_in_vm(
373
- "snap list pycharm-community >/dev/null 2>&1 && echo yes || echo no"
374
- )
509
+ self._exec_in_vm(f"snap list {app} >/dev/null 2>&1 && echo yes || echo no")
375
510
  == "yes"
376
511
  )
377
- profile_ok = _check_dir_nonempty(
378
- "/home/ubuntu/snap/pycharm-community/common/.config/JetBrains"
379
- )
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"
380
534
 
381
535
  elif app == "google-chrome":
382
536
  installed = (
@@ -387,23 +541,190 @@ class VMValidator:
387
541
  )
388
542
  profile_ok = _check_dir_nonempty("/home/ubuntu/.config/google-chrome")
389
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
+
390
559
  table.add_row(
391
560
  app,
392
561
  "[green]✅[/]" if installed else "[red]❌[/]",
393
562
  "[green]✅[/]" if profile_ok else "[red]❌[/]",
563
+ running_icon,
564
+ pid_value,
565
+ note,
394
566
  )
395
567
 
396
- if installed and profile_ok:
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:
397
573
  self.results["apps"]["passed"] += 1
398
574
  else:
399
575
  self.results["apps"]["failed"] += 1
400
576
 
401
577
  self.results["apps"]["details"].append(
402
- {"app": app, "installed": installed, "profile": profile_ok}
578
+ {
579
+ "app": app,
580
+ "installed": installed,
581
+ "profile": profile_ok,
582
+ "running": running,
583
+ "pid": pid,
584
+ "note": note,
585
+ }
403
586
  )
404
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
+
405
593
  self.console.print(table)
406
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"]
407
728
 
408
729
  def validate_all(self) -> Dict:
409
730
  """Run all validations and return comprehensive results."""
@@ -433,30 +754,41 @@ class VMValidator:
433
754
  self.validate_snap_packages()
434
755
  self.validate_services()
435
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"))
436
765
 
437
766
  # Calculate overall status
438
767
  total_checks = (
439
- self.results["mounts"]["total"] +
440
- self.results["packages"]["total"] +
441
- self.results["snap_packages"]["total"] +
442
- self.results["services"]["total"] +
443
- self.results["apps"]["total"]
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)
444
774
  )
445
775
 
446
776
  total_passed = (
447
- self.results["mounts"]["passed"] +
448
- self.results["packages"]["passed"] +
449
- self.results["snap_packages"]["passed"] +
450
- self.results["services"]["passed"] +
451
- self.results["apps"]["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)
452
783
  )
453
784
 
454
785
  total_failed = (
455
- self.results["mounts"]["failed"] +
456
- self.results["packages"]["failed"] +
457
- self.results["snap_packages"]["failed"] +
458
- self.results["services"]["failed"] +
459
- self.results["apps"]["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)
460
792
  )
461
793
 
462
794
  # Get skipped services count