clonebox 0.1.21__py3-none-any.whl → 0.1.23__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 +1012 -19
- clonebox/validator.py +361 -29
- {clonebox-0.1.21.dist-info → clonebox-0.1.23.dist-info}/METADATA +3 -1
- {clonebox-0.1.21.dist-info → clonebox-0.1.23.dist-info}/RECORD +9 -9
- {clonebox-0.1.21.dist-info → clonebox-0.1.23.dist-info}/WHEEL +0 -0
- {clonebox-0.1.21.dist-info → clonebox-0.1.23.dist-info}/entry_points.txt +0 -0
- {clonebox-0.1.21.dist-info → clonebox-0.1.23.dist-info}/licenses/LICENSE +0 -0
- {clonebox-0.1.21.dist-info → clonebox-0.1.23.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()
|