clonebox 0.1.21__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/cli.py CHANGED
@@ -8,7 +8,8 @@ import json
8
8
  import os
9
9
  import re
10
10
  import sys
11
- from typing import Optional
11
+ import time
12
+ from typing import Any, Dict, Optional, Tuple
12
13
  from datetime import datetime
13
14
  from pathlib import Path
14
15
 
@@ -16,6 +17,7 @@ import questionary
16
17
  import yaml
17
18
  from questionary import Style
18
19
  from rich.console import Console
20
+ from rich.live import Live
19
21
  from rich.panel import Panel
20
22
  from rich.progress import Progress, SpinnerColumn, TextColumn
21
23
  from rich.table import Table
@@ -61,7 +63,7 @@ def print_banner():
61
63
  console.print(f" Version {__version__}\n", style="dim")
62
64
 
63
65
 
64
- def _resolve_vm_name_and_config_file(name: Optional[str]) -> tuple[str, Optional[Path]]:
66
+ def _resolve_vm_name_and_config_file(name: Optional[str]) -> Tuple[str, Optional[Path]]:
65
67
  config_file: Optional[Path] = None
66
68
 
67
69
  if name and (name.startswith(".") or name.startswith("/") or name.startswith("~")):
@@ -196,6 +198,9 @@ def run_vm_diagnostics(
196
198
 
197
199
  console.print(f"[bold cyan]🧪 Diagnostics: {vm_name}[/]\n")
198
200
 
201
+ guest_agent_ready = _qga_ping(vm_name, conn_uri)
202
+ result["qga"]["ready"] = guest_agent_ready
203
+
199
204
  try:
200
205
  domstate = subprocess.run(
201
206
  ["virsh", "--connect", conn_uri, "domstate", vm_name],
@@ -257,8 +262,6 @@ def run_vm_diagnostics(
257
262
  result["network"] = {"error": str(e)}
258
263
  console.print(f"[yellow]⚠️ Cannot get IP: {e}[/]")
259
264
 
260
- guest_agent_ready = _qga_ping(vm_name, conn_uri)
261
- result["qga"]["ready"] = guest_agent_ready
262
265
  if verbose:
263
266
  console.print("\n[bold]🤖 QEMU Guest Agent...[/]")
264
267
  console.print(f"{'[green]✅' if guest_agent_ready else '[red]❌'} QGA connected")
@@ -334,7 +337,7 @@ def run_vm_diagnostics(
334
337
  if not cloud_init_complete:
335
338
  console.print("[dim]Mounts may not be ready until cloud-init completes.[/]")
336
339
 
337
- mounts_detail: list[dict] = []
340
+ mounts_detail: list = []
338
341
  result["mounts"]["details"] = mounts_detail
339
342
  if not guest_agent_ready:
340
343
  console.print("[yellow]⏳ QEMU guest agent not connected yet - cannot verify mounts.[/]")
@@ -426,6 +429,109 @@ def run_vm_diagnostics(
426
429
  return result
427
430
 
428
431
 
432
+ def cmd_watch(args):
433
+ name = args.name
434
+ user_session = getattr(args, "user", False)
435
+ conn_uri = "qemu:///session" if user_session else "qemu:///system"
436
+ refresh = getattr(args, "refresh", 1.0)
437
+ max_wait = getattr(args, "timeout", 600)
438
+
439
+ try:
440
+ vm_name, _ = _resolve_vm_name_and_config_file(name)
441
+ except FileNotFoundError as e:
442
+ console.print(f"[red]❌ {e}[/]")
443
+ return
444
+
445
+ console.print(f"[bold cyan]👀 Watching boot diagnostics: {vm_name}[/]")
446
+ console.print("[dim]Waiting for QEMU Guest Agent...[/]")
447
+
448
+ start = time.time()
449
+ while time.time() - start < max_wait:
450
+ if _qga_ping(vm_name, conn_uri):
451
+ break
452
+ time.sleep(min(refresh, 2.0))
453
+
454
+ if not _qga_ping(vm_name, conn_uri):
455
+ console.print("[yellow]⚠️ QEMU Guest Agent not connected - cannot watch diagnostic status yet[/]")
456
+ console.print(f"[dim]Try: clonebox status {name or vm_name} {'--user' if user_session else ''} --verbose[/]")
457
+ return
458
+
459
+ def _read_status() -> Tuple[Optional[Dict[str, Any]], str]:
460
+ status_raw = _qga_exec(vm_name, conn_uri, "cat /var/run/clonebox-status.json 2>/dev/null || true", timeout=10)
461
+ log_tail = _qga_exec(vm_name, conn_uri, "tail -n 40 /var/log/clonebox-boot.log 2>/dev/null || true", timeout=10) or ""
462
+
463
+ status_obj: Optional[Dict[str, Any]] = None
464
+ if status_raw:
465
+ try:
466
+ status_obj = json.loads(status_raw)
467
+ except Exception:
468
+ status_obj = None
469
+ return status_obj, log_tail
470
+
471
+ with Live(refresh_per_second=max(1, int(1 / max(refresh, 0.2))), console=console) as live:
472
+ while True:
473
+ status_obj, log_tail = _read_status()
474
+ phase = (status_obj or {}).get("phase") if status_obj else None
475
+ current_task = (status_obj or {}).get("current_task") if status_obj else None
476
+
477
+ header = f"phase={phase or 'unknown'}"
478
+ if current_task:
479
+ header += f" | {current_task}"
480
+
481
+ stats = ""
482
+ if status_obj:
483
+ stats = (
484
+ f"passed={status_obj.get('passed', 0)} failed={status_obj.get('failed', 0)} repaired={status_obj.get('repaired', 0)} total={status_obj.get('total', 0)}"
485
+ )
486
+
487
+ body = "\n".join([s for s in [header, stats, "", log_tail.strip()] if s])
488
+ live.update(Panel(body or "(no output yet)", title="CloneBox boot diagnostic", border_style="cyan"))
489
+
490
+ if phase == "complete":
491
+ break
492
+
493
+ if time.time() - start >= max_wait:
494
+ break
495
+
496
+ time.sleep(refresh)
497
+
498
+
499
+ def cmd_repair(args):
500
+ name = args.name
501
+ user_session = getattr(args, "user", False)
502
+ conn_uri = "qemu:///session" if user_session else "qemu:///system"
503
+ timeout = getattr(args, "timeout", 600)
504
+ follow = getattr(args, "watch", False)
505
+
506
+ try:
507
+ vm_name, _ = _resolve_vm_name_and_config_file(name)
508
+ except FileNotFoundError as e:
509
+ console.print(f"[red]❌ {e}[/]")
510
+ return
511
+
512
+ if not _qga_ping(vm_name, conn_uri):
513
+ console.print("[yellow]⚠️ QEMU Guest Agent not connected - cannot trigger repair[/]")
514
+ console.print("[dim]Inside VM you can run: sudo /usr/local/bin/clonebox-boot-diagnostic[/]")
515
+ return
516
+
517
+ console.print(f"[cyan]🔧 Running boot diagnostic/repair in VM: {vm_name}[/]")
518
+ out = _qga_exec(vm_name, conn_uri, "/usr/local/bin/clonebox-boot-diagnostic || true", timeout=timeout)
519
+ if out is None:
520
+ console.print("[yellow]⚠️ Repair triggered but output not available via QGA (check VM console/log)[/]")
521
+ elif out.strip():
522
+ console.print(Panel(out.strip()[-3000:], title="Command output", border_style="cyan"))
523
+
524
+ if follow:
525
+ cmd_watch(
526
+ argparse.Namespace(
527
+ name=name,
528
+ user=user_session,
529
+ refresh=getattr(args, "refresh", 1.0),
530
+ timeout=timeout,
531
+ )
532
+ )
533
+
534
+
429
535
  def interactive_mode():
430
536
  """Run the interactive VM creation wizard."""
431
537
  print_banner()
@@ -1444,6 +1550,8 @@ def cmd_test(args):
1444
1550
  quick = getattr(args, "quick", False)
1445
1551
  verbose = getattr(args, "verbose", False)
1446
1552
  validate_all = getattr(args, "validate", False)
1553
+ require_running_apps = getattr(args, "require_running_apps", False)
1554
+ smoke_test = getattr(args, "smoke_test", False)
1447
1555
  conn_uri = "qemu:///session" if user_session else "qemu:///system"
1448
1556
 
1449
1557
  # If name is a path, load config
@@ -1636,7 +1744,14 @@ def cmd_test(args):
1636
1744
 
1637
1745
  # Run full validation if requested
1638
1746
  if validate_all and state == "running":
1639
- validator = VMValidator(config, vm_name, conn_uri, console)
1747
+ validator = VMValidator(
1748
+ config,
1749
+ vm_name,
1750
+ conn_uri,
1751
+ console,
1752
+ require_running_apps=require_running_apps,
1753
+ smoke_test=smoke_test,
1754
+ )
1640
1755
  results = validator.validate_all()
1641
1756
 
1642
1757
  # Exit with error code if validations failed
@@ -2629,6 +2744,63 @@ def main():
2629
2744
  )
2630
2745
  diagnose_parser.set_defaults(func=cmd_diagnose)
2631
2746
 
2747
+ watch_parser = subparsers.add_parser(
2748
+ "watch", help="Watch boot diagnostic output from VM (via QEMU Guest Agent)"
2749
+ )
2750
+ watch_parser.add_argument(
2751
+ "name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml"
2752
+ )
2753
+ watch_parser.add_argument(
2754
+ "-u",
2755
+ "--user",
2756
+ action="store_true",
2757
+ help="Use user session (qemu:///session)",
2758
+ )
2759
+ watch_parser.add_argument(
2760
+ "--refresh",
2761
+ type=float,
2762
+ default=1.0,
2763
+ help="Refresh interval in seconds (default: 1.0)",
2764
+ )
2765
+ watch_parser.add_argument(
2766
+ "--timeout",
2767
+ type=int,
2768
+ default=600,
2769
+ help="Max seconds to wait (default: 600)",
2770
+ )
2771
+ watch_parser.set_defaults(func=cmd_watch)
2772
+
2773
+ repair_parser = subparsers.add_parser(
2774
+ "repair", help="Trigger boot diagnostic/repair inside VM (via QEMU Guest Agent)"
2775
+ )
2776
+ repair_parser.add_argument(
2777
+ "name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml"
2778
+ )
2779
+ repair_parser.add_argument(
2780
+ "-u",
2781
+ "--user",
2782
+ action="store_true",
2783
+ help="Use user session (qemu:///session)",
2784
+ )
2785
+ repair_parser.add_argument(
2786
+ "--timeout",
2787
+ type=int,
2788
+ default=600,
2789
+ help="Max seconds to wait for repair (default: 600)",
2790
+ )
2791
+ repair_parser.add_argument(
2792
+ "--watch",
2793
+ action="store_true",
2794
+ help="After triggering repair, watch status/log output",
2795
+ )
2796
+ repair_parser.add_argument(
2797
+ "--refresh",
2798
+ type=float,
2799
+ default=1.0,
2800
+ help="Refresh interval for --watch (default: 1.0)",
2801
+ )
2802
+ repair_parser.set_defaults(func=cmd_repair)
2803
+
2632
2804
  # Export command - package VM for migration
2633
2805
  export_parser = subparsers.add_parser("export", help="Export VM and data for migration")
2634
2806
  export_parser.add_argument(
@@ -2674,6 +2846,16 @@ def main():
2674
2846
  test_parser.add_argument(
2675
2847
  "--validate", action="store_true", help="Run full validation (mounts, packages, services)"
2676
2848
  )
2849
+ test_parser.add_argument(
2850
+ "--require-running-apps",
2851
+ action="store_true",
2852
+ help="Fail validation if expected apps are installed but not currently running",
2853
+ )
2854
+ test_parser.add_argument(
2855
+ "--smoke-test",
2856
+ action="store_true",
2857
+ help="Run smoke tests (installed ≠ works): headless launch checks for key apps",
2858
+ )
2677
2859
  test_parser.set_defaults(func=cmd_test)
2678
2860
 
2679
2861
  args = parser.parse_args()
clonebox/cloner.py CHANGED
@@ -487,6 +487,268 @@ class SelectiveVMCloner:
487
487
 
488
488
  return ET.tostring(root, encoding="unicode")
489
489
 
490
+ def _generate_boot_diagnostic_script(self, config: VMConfig) -> str:
491
+ """Generate boot diagnostic script with self-healing capabilities."""
492
+ import base64
493
+
494
+ wants_google_chrome = any(
495
+ p == "/home/ubuntu/.config/google-chrome" for p in (config.paths or {}).values()
496
+ )
497
+
498
+ apt_pkg_list = list(config.packages or [])
499
+ for base_pkg in ["qemu-guest-agent", "cloud-guest-utils"]:
500
+ if base_pkg not in apt_pkg_list:
501
+ apt_pkg_list.insert(0, base_pkg)
502
+ if config.gui:
503
+ for gui_pkg in ["ubuntu-desktop-minimal", "firefox"]:
504
+ if gui_pkg not in apt_pkg_list:
505
+ apt_pkg_list.append(gui_pkg)
506
+
507
+ apt_packages = " ".join(f'"{p}"' for p in apt_pkg_list) if apt_pkg_list else ""
508
+ snap_packages = " ".join(f'"{p}"' for p in config.snap_packages) if config.snap_packages else ""
509
+ services = " ".join(f'"{s}"' for s in config.services) if config.services else ""
510
+
511
+ snap_ifaces_bash = "\n".join(
512
+ f'SNAP_INTERFACES["{snap}"]="{" ".join(ifaces)}"'
513
+ for snap, ifaces in SNAP_INTERFACES.items()
514
+ )
515
+
516
+ script = f'''#!/bin/bash
517
+ set -uo pipefail
518
+ LOG="/var/log/clonebox-boot.log"
519
+ STATUS_KV="/var/run/clonebox-status"
520
+ STATUS_JSON="/var/run/clonebox-status.json"
521
+ MAX_RETRIES=3
522
+ PASSED=0 FAILED=0 REPAIRED=0 TOTAL=0
523
+
524
+ RED='\\033[0;31m' GREEN='\\033[0;32m' YELLOW='\\033[1;33m' CYAN='\\033[0;36m' NC='\\033[0m' BOLD='\\033[1m'
525
+
526
+ log() {{ echo -e "[$(date +%H:%M:%S)] $1" | tee -a "$LOG"; }}
527
+ ok() {{ log "${{GREEN}}✅ $1${{NC}}"; ((PASSED++)); ((TOTAL++)); }}
528
+ fail() {{ log "${{RED}}❌ $1${{NC}}"; ((FAILED++)); ((TOTAL++)); }}
529
+ repair() {{ log "${{YELLOW}}🔧 $1${{NC}}"; }}
530
+ section() {{ log ""; log "${{BOLD}}[$1] $2${{NC}}"; }}
531
+
532
+ write_status() {{
533
+ local phase="$1"
534
+ local current_task="${{2:-}}"
535
+ printf 'passed=%s failed=%s repaired=%s\n' "$PASSED" "$FAILED" "$REPAIRED" > "$STATUS_KV" 2>/dev/null || true
536
+ cat > "$STATUS_JSON" <<EOF
537
+ {{"phase":"$phase","current_task":"$current_task","total":$TOTAL,"passed":$PASSED,"failed":$FAILED,"repaired":$REPAIRED,"timestamp":"$(date -Iseconds)"}}
538
+ EOF
539
+ }}
540
+
541
+ header() {{
542
+ log ""
543
+ log "${{BOLD}}${{CYAN}}═══════════════════════════════════════════════════════════${{NC}}"
544
+ log "${{BOLD}}${{CYAN}} $1${{NC}}"
545
+ log "${{BOLD}}${{CYAN}}═══════════════════════════════════════════════════════════${{NC}}"
546
+ }}
547
+
548
+ declare -A SNAP_INTERFACES
549
+ {snap_ifaces_bash}
550
+ DEFAULT_IFACES="desktop desktop-legacy x11 home network"
551
+
552
+ check_apt() {{
553
+ dpkg -l "$1" 2>/dev/null | grep -q "^ii"
554
+ }}
555
+
556
+ install_apt() {{
557
+ for i in $(seq 1 $MAX_RETRIES); do
558
+ DEBIAN_FRONTEND=noninteractive apt-get install -y "$1" &>>"$LOG" && return 0
559
+ sleep 3
560
+ done
561
+ return 1
562
+ }}
563
+
564
+ check_snap() {{
565
+ snap list "$1" &>/dev/null
566
+ }}
567
+
568
+ install_snap() {{
569
+ timeout 60 snap wait system seed.loaded 2>/dev/null || true
570
+ for i in $(seq 1 $MAX_RETRIES); do
571
+ snap install "$1" --classic &>>"$LOG" && return 0
572
+ snap install "$1" &>>"$LOG" && return 0
573
+ sleep 5
574
+ done
575
+ return 1
576
+ }}
577
+
578
+ connect_interfaces() {{
579
+ local snap="$1"
580
+ local ifaces="${{SNAP_INTERFACES[$snap]:-$DEFAULT_IFACES}}"
581
+ for iface in $ifaces; do
582
+ snap connect "$snap:$iface" ":$iface" 2>/dev/null && log " ${{GREEN}}✓${{NC}} $snap:$iface" || true
583
+ done
584
+ }}
585
+
586
+ test_launch() {{
587
+ case "$1" in
588
+ pycharm-community) /snap/pycharm-community/current/jbr/bin/java -version &>/dev/null ;;
589
+ chromium) timeout 10 chromium --headless=new --dump-dom about:blank &>/dev/null ;;
590
+ firefox) timeout 10 firefox --headless --screenshot /tmp/ff-test.png about:blank &>/dev/null; rm -f /tmp/ff-test.png ;;
591
+ docker) docker info &>/dev/null ;;
592
+ *) command -v "$1" &>/dev/null ;;
593
+ esac
594
+ }}
595
+
596
+ header "CloneBox VM Boot Diagnostic"
597
+ write_status "starting" "boot diagnostic starting"
598
+
599
+ APT_PACKAGES=({apt_packages})
600
+ SNAP_PACKAGES=({snap_packages})
601
+ SERVICES=({services})
602
+
603
+ section "1/5" "Checking APT packages..."
604
+ write_status "checking_apt" "checking APT packages"
605
+ for pkg in "${{APT_PACKAGES[@]}}"; do
606
+ [ -z "$pkg" ] && continue
607
+ if check_apt "$pkg"; then
608
+ ok "$pkg"
609
+ else
610
+ repair "Installing $pkg..."
611
+ if install_apt "$pkg"; then
612
+ ok "$pkg installed"
613
+ ((REPAIRED++))
614
+ else
615
+ fail "$pkg FAILED"
616
+ fi
617
+ fi
618
+ done
619
+
620
+ section "2/5" "Checking Snap packages..."
621
+ write_status "checking_snaps" "checking snap packages"
622
+ timeout 120 snap wait system seed.loaded 2>/dev/null || true
623
+ for pkg in "${{SNAP_PACKAGES[@]}}"; do
624
+ [ -z "$pkg" ] && continue
625
+ if check_snap "$pkg"; then
626
+ ok "$pkg (snap)"
627
+ else
628
+ repair "Installing $pkg..."
629
+ if install_snap "$pkg"; then
630
+ ok "$pkg installed"
631
+ ((REPAIRED++))
632
+ else
633
+ fail "$pkg FAILED"
634
+ fi
635
+ fi
636
+ done
637
+
638
+ section "3/5" "Connecting Snap interfaces..."
639
+ write_status "connecting_interfaces" "connecting snap interfaces"
640
+ for pkg in "${{SNAP_PACKAGES[@]}}"; do
641
+ [ -z "$pkg" ] && continue
642
+ check_snap "$pkg" && connect_interfaces "$pkg"
643
+ done
644
+ systemctl restart snapd 2>/dev/null || true
645
+
646
+ section "4/5" "Testing application launch..."
647
+ write_status "testing_launch" "testing application launch"
648
+ APPS_TO_TEST=()
649
+ for pkg in "${{SNAP_PACKAGES[@]}}"; do
650
+ [ -z "$pkg" ] && continue
651
+ APPS_TO_TEST+=("$pkg")
652
+ done
653
+ if [ "{str(wants_google_chrome).lower()}" = "true" ]; then
654
+ APPS_TO_TEST+=("google-chrome")
655
+ fi
656
+ if printf '%s\n' "${{APT_PACKAGES[@]}}" | grep -qx "docker.io"; then
657
+ APPS_TO_TEST+=("docker")
658
+ fi
659
+
660
+ for app in "${{APPS_TO_TEST[@]}}"; do
661
+ [ -z "$app" ] && continue
662
+ case "$app" in
663
+ google-chrome)
664
+ if ! command -v google-chrome >/dev/null 2>&1 && ! command -v google-chrome-stable >/dev/null 2>&1; then
665
+ repair "Installing google-chrome..."
666
+ tmp_deb="/tmp/google-chrome-stable_current_amd64.deb"
667
+ if curl -fsSL -o "$tmp_deb" "https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb" \
668
+ && DEBIAN_FRONTEND=noninteractive apt-get install -y "$tmp_deb" &>>"$LOG"; then
669
+ rm -f "$tmp_deb"
670
+ ((REPAIRED++))
671
+ else
672
+ rm -f "$tmp_deb" 2>/dev/null || true
673
+ fi
674
+ fi
675
+ ;;
676
+ docker)
677
+ check_apt "docker.io" || continue
678
+ ;;
679
+ *)
680
+ if check_snap "$app"; then
681
+ :
682
+ else
683
+ continue
684
+ fi
685
+ ;;
686
+ esac
687
+
688
+ if test_launch "$app"; then
689
+ ok "$app launches OK"
690
+ else
691
+ fail "$app launch test FAILED"
692
+ fi
693
+ done
694
+
695
+ section "5/6" "Checking mount points..."
696
+ write_status "checking_mounts" "checking mount points"
697
+ while IFS= read -r line; do
698
+ tag=$(echo "$line" | awk '{{print $1}}')
699
+ mp=$(echo "$line" | awk '{{print $2}}')
700
+ if [[ "$tag" =~ ^mount[0-9]+$ ]] && [[ "$mp" == /* ]]; then
701
+ if mountpoint -q "$mp" 2>/dev/null; then
702
+ ok "$mp mounted"
703
+ else
704
+ repair "Mounting $mp..."
705
+ mkdir -p "$mp" 2>/dev/null || true
706
+ if mount "$mp" &>>"$LOG"; then
707
+ ok "$mp mounted"
708
+ ((REPAIRED++))
709
+ else
710
+ fail "$mp mount FAILED"
711
+ fi
712
+ fi
713
+ fi
714
+ done < /etc/fstab
715
+
716
+ section "6/6" "Checking services..."
717
+ write_status "checking_services" "checking services"
718
+ for svc in "${{SERVICES[@]}}"; do
719
+ [ -z "$svc" ] && continue
720
+ if systemctl is-active "$svc" &>/dev/null; then
721
+ ok "$svc running"
722
+ else
723
+ repair "Starting $svc..."
724
+ systemctl enable --now "$svc" &>/dev/null && ok "$svc started" && ((REPAIRED++)) || fail "$svc FAILED"
725
+ fi
726
+ done
727
+
728
+ header "Diagnostic Summary"
729
+ log ""
730
+ log " Total: $TOTAL"
731
+ log " ${{GREEN}}Passed:${{NC}} $PASSED"
732
+ log " ${{YELLOW}}Repaired:${{NC}} $REPAIRED"
733
+ log " ${{RED}}Failed:${{NC}} $FAILED"
734
+ log ""
735
+
736
+ write_status "complete" "complete"
737
+
738
+ if [ $FAILED -eq 0 ]; then
739
+ log "${{GREEN}}${{BOLD}}═══════════════════════════════════════════════════════════${{NC}}"
740
+ log "${{GREEN}}${{BOLD}} ✅ All checks passed! CloneBox VM is ready.${{NC}}"
741
+ log "${{GREEN}}${{BOLD}}═══════════════════════════════════════════════════════════${{NC}}"
742
+ exit 0
743
+ else
744
+ log "${{RED}}${{BOLD}}═══════════════════════════════════════════════════════════${{NC}}"
745
+ log "${{RED}}${{BOLD}} ⚠️ $FAILED checks failed. See /var/log/clonebox-boot.log${{NC}}"
746
+ log "${{RED}}${{BOLD}}═══════════════════════════════════════════════════════════${{NC}}"
747
+ exit 1
748
+ fi
749
+ '''
750
+ return base64.b64encode(script.encode()).decode()
751
+
490
752
  def _generate_health_check_script(self, config: VMConfig) -> str:
491
753
  """Generate a health check script that validates all installed components."""
492
754
  import base64
@@ -759,6 +1021,61 @@ fi
759
1021
  runcmd_lines.append(" - /usr/local/bin/clonebox-health >> /var/log/clonebox-health.log 2>&1")
760
1022
  runcmd_lines.append(" - echo 'CloneBox VM ready!' > /var/log/clonebox-ready")
761
1023
 
1024
+ # Generate boot diagnostic script (self-healing)
1025
+ boot_diag_script = self._generate_boot_diagnostic_script(config)
1026
+ runcmd_lines.append(f" - echo '{boot_diag_script}' | base64 -d > /usr/local/bin/clonebox-boot-diagnostic")
1027
+ runcmd_lines.append(" - chmod +x /usr/local/bin/clonebox-boot-diagnostic")
1028
+
1029
+ # Create systemd service for boot diagnostic (runs before GDM on subsequent boots)
1030
+ systemd_service = '''[Unit]
1031
+ Description=CloneBox Boot Diagnostic
1032
+ After=network-online.target snapd.service
1033
+ Before=gdm.service display-manager.service
1034
+ Wants=network-online.target
1035
+
1036
+ [Service]
1037
+ Type=oneshot
1038
+ ExecStart=/usr/local/bin/clonebox-boot-diagnostic
1039
+ StandardOutput=journal+console
1040
+ StandardError=journal+console
1041
+ TTYPath=/dev/tty1
1042
+ TTYReset=yes
1043
+ TTYVHangup=yes
1044
+ RemainAfterExit=yes
1045
+ TimeoutStartSec=600
1046
+
1047
+ [Install]
1048
+ WantedBy=multi-user.target'''
1049
+ import base64
1050
+ systemd_b64 = base64.b64encode(systemd_service.encode()).decode()
1051
+ runcmd_lines.append(f" - echo '{systemd_b64}' | base64 -d > /etc/systemd/system/clonebox-diagnostic.service")
1052
+ runcmd_lines.append(" - systemctl daemon-reload")
1053
+ runcmd_lines.append(" - systemctl enable clonebox-diagnostic.service")
1054
+ runcmd_lines.append(" - systemctl start clonebox-diagnostic.service || true")
1055
+
1056
+ # Create MOTD banner
1057
+ motd_banner = '''#!/bin/bash
1058
+ S="/var/run/clonebox-status"
1059
+ echo ""
1060
+ echo -e "\\033[1;34m═══════════════════════════════════════════════════════════\\033[0m"
1061
+ echo -e "\\033[1;34m CloneBox VM Status\\033[0m"
1062
+ echo -e "\\033[1;34m═══════════════════════════════════════════════════════════\\033[0m"
1063
+ if [ -f "$S" ]; then
1064
+ source "$S"
1065
+ if [ "${failed:-0}" -eq 0 ]; then
1066
+ echo -e " \\033[0;32m✅ All systems operational\\033[0m"
1067
+ else
1068
+ echo -e " \\033[0;31m⚠️ $failed checks failed\\033[0m"
1069
+ fi
1070
+ echo -e " Passed: ${passed:-0} | Repaired: ${repaired:-0} | Failed: ${failed:-0}"
1071
+ fi
1072
+ echo -e " Log: /var/log/clonebox-boot.log"
1073
+ echo -e "\\033[1;34m═══════════════════════════════════════════════════════════\\033[0m"
1074
+ echo ""'''
1075
+ motd_b64 = base64.b64encode(motd_banner.encode()).decode()
1076
+ runcmd_lines.append(f" - echo '{motd_b64}' | base64 -d > /etc/update-motd.d/99-clonebox")
1077
+ runcmd_lines.append(" - chmod +x /etc/update-motd.d/99-clonebox")
1078
+
762
1079
  # Add reboot command at the end if GUI is enabled
763
1080
  if config.gui:
764
1081
  runcmd_lines.append(" - echo 'Rebooting in 10 seconds to start GUI...'")
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clonebox
3
- Version: 0.1.21
3
+ Version: 0.1.22
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
@@ -274,6 +274,7 @@ clonebox
274
274
  clonebox clone . --user --run --replace --base-image ~/ubuntu-22.04-cloud.qcow2 --disk-size-gb 30
275
275
 
276
276
  clonebox test . --user --validate
277
+ clonebox test . --user --validate --require-running-apps
277
278
  ```
278
279
 
279
280
  ### Profiles (Reusable presets)
@@ -1,17 +1,17 @@
1
1
  clonebox/__init__.py,sha256=CyfHVVq6KqBr4CNERBpXk_O6Q5B35q03YpdQbokVvvI,408
2
2
  clonebox/__main__.py,sha256=Fcoyzwwyz5-eC_sBlQk5a5RbKx8uodQz5sKJ190U0NU,135
3
- clonebox/cli.py,sha256=ItXPNJmQeWbv-Bf9IxYi4e6AsX04pJwRk8fBXP0MglA,104376
4
- clonebox/cloner.py,sha256=jR-pIBewc8zNE_h_sTcID1uZYKeQjNc6VInhE3aikfQ,34156
3
+ clonebox/cli.py,sha256=vbJ65ShdXG1nGkQteCaFtDTas0L2RNV--aay2Qx-6F0,110765
4
+ clonebox/cloner.py,sha256=dX6K56goT3qZD3GOYjZBuAPMrAI0PriyFJWsJpQvyKc,46320
5
5
  clonebox/container.py,sha256=tiYK1ZB-DhdD6A2FuMA0h_sRNkUI7KfYcJ0tFOcdyeM,6105
6
6
  clonebox/dashboard.py,sha256=RhSPvR6kWglqXeLkCWesBZQid7wv2WpJa6w78mXbPjY,4268
7
7
  clonebox/detector.py,sha256=aS_QlbG93-DE3hsjRt88E7O-PGC2TUBgUbP9wqT9g60,23221
8
8
  clonebox/models.py,sha256=yBRUlJejpeJHZjvCYMGq1nXPFcmhLFxN-LqkEyveWsA,7913
9
9
  clonebox/profiles.py,sha256=VaKVuxCrgyMxx-8_WOTcw7E8irwGxUPhZHVY6RxYYiE,2034
10
- clonebox/validator.py,sha256=LnQSZEdJXFGcJrTPxzS2cQUmAXucGeHDKwxrX632h_s,21188
10
+ clonebox/validator.py,sha256=z4YuIgVnX6ZqfIdJtjKIFwZ-iWlRUnpX7gmWwq-Jr88,35352
11
11
  clonebox/templates/profiles/ml-dev.yaml,sha256=MT7Wu3xGBnYIsO5mzZ2GDI4AAEFGOroIx0eU3XjNARg,140
12
- clonebox-0.1.21.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
13
- clonebox-0.1.21.dist-info/METADATA,sha256=xvEQjhq84KYnf5oveMK8GRcfZXoBB54sZ9CrcRfTRpg,41534
14
- clonebox-0.1.21.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
15
- clonebox-0.1.21.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
16
- clonebox-0.1.21.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
17
- clonebox-0.1.21.dist-info/RECORD,,
12
+ clonebox-0.1.22.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
13
+ clonebox-0.1.22.dist-info/METADATA,sha256=MuI44ArtnU0ql1rF99Hf_4frTRHe7_AikJK9w2jk6tI,41591
14
+ clonebox-0.1.22.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
15
+ clonebox-0.1.22.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
16
+ clonebox-0.1.22.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
17
+ clonebox-0.1.22.dist-info/RECORD,,