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 +188 -6
- clonebox/cloner.py +317 -0
- clonebox/validator.py +361 -29
- {clonebox-0.1.21.dist-info → clonebox-0.1.22.dist-info}/METADATA +2 -1
- {clonebox-0.1.21.dist-info → clonebox-0.1.22.dist-info}/RECORD +9 -9
- {clonebox-0.1.21.dist-info → clonebox-0.1.22.dist-info}/WHEEL +0 -0
- {clonebox-0.1.21.dist-info → clonebox-0.1.22.dist-info}/entry_points.txt +0 -0
- {clonebox-0.1.21.dist-info → clonebox-0.1.22.dist-info}/licenses/LICENSE +0 -0
- {clonebox-0.1.21.dist-info → clonebox-0.1.22.dist-info}/top_level.txt +0 -0
clonebox/cli.py
CHANGED
|
@@ -8,7 +8,8 @@ import json
|
|
|
8
8
|
import os
|
|
9
9
|
import re
|
|
10
10
|
import sys
|
|
11
|
-
|
|
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]) ->
|
|
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
|
|
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(
|
|
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__(
|
|
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
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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.
|
|
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=
|
|
4
|
-
clonebox/cloner.py,sha256=
|
|
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=
|
|
10
|
+
clonebox/validator.py,sha256=z4YuIgVnX6ZqfIdJtjKIFwZ-iWlRUnpX7gmWwq-Jr88,35352
|
|
11
11
|
clonebox/templates/profiles/ml-dev.yaml,sha256=MT7Wu3xGBnYIsO5mzZ2GDI4AAEFGOroIx0eU3XjNARg,140
|
|
12
|
-
clonebox-0.1.
|
|
13
|
-
clonebox-0.1.
|
|
14
|
-
clonebox-0.1.
|
|
15
|
-
clonebox-0.1.
|
|
16
|
-
clonebox-0.1.
|
|
17
|
-
clonebox-0.1.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|