clonebox 0.1.20__py3-none-any.whl → 0.1.22__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
clonebox/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()
@@ -653,6 +759,7 @@ def interactive_mode():
653
759
  summary_table.add_row("Name", vm_name)
654
760
  summary_table.add_row("RAM", f"{ram_mb} MB")
655
761
  summary_table.add_row("vCPUs", str(vcpus))
762
+ summary_table.add_row("Disk", f"{20 if enable_gui else 10} GB")
656
763
  summary_table.add_row("Services", ", ".join(selected_services) or "None")
657
764
  summary_table.add_row(
658
765
  "Packages",
@@ -684,6 +791,7 @@ def interactive_mode():
684
791
  name=vm_name,
685
792
  ram_mb=ram_mb,
686
793
  vcpus=vcpus,
794
+ disk_size_gb=20 if enable_gui else 10,
687
795
  gui=enable_gui,
688
796
  base_image=base_image if base_image else None,
689
797
  paths=paths_mapping,
@@ -732,6 +840,7 @@ def cmd_create(args):
732
840
  name=args.name,
733
841
  ram_mb=args.ram,
734
842
  vcpus=args.vcpus,
843
+ disk_size_gb=getattr(args, "disk_size_gb", 10),
735
844
  gui=not args.no_gui,
736
845
  base_image=args.base_image,
737
846
  paths=config_data.get("paths", {}),
@@ -1441,6 +1550,8 @@ def cmd_test(args):
1441
1550
  quick = getattr(args, "quick", False)
1442
1551
  verbose = getattr(args, "verbose", False)
1443
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)
1444
1555
  conn_uri = "qemu:///session" if user_session else "qemu:///system"
1445
1556
 
1446
1557
  # If name is a path, load config
@@ -1633,7 +1744,14 @@ def cmd_test(args):
1633
1744
 
1634
1745
  # Run full validation if requested
1635
1746
  if validate_all and state == "running":
1636
- 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
+ )
1637
1755
  results = validator.validate_all()
1638
1756
 
1639
1757
  # Exit with error code if validations failed
@@ -1708,6 +1826,7 @@ def generate_clonebox_yaml(
1708
1826
  vm_name: str = None,
1709
1827
  network_mode: str = "auto",
1710
1828
  base_image: Optional[str] = None,
1829
+ disk_size_gb: Optional[int] = None,
1711
1830
  ) -> str:
1712
1831
  """Generate YAML config from system snapshot."""
1713
1832
  sys_info = detector.get_system_info()
@@ -1813,6 +1932,9 @@ def generate_clonebox_yaml(
1813
1932
  ram_mb = min(4096, int(sys_info["memory_available_gb"] * 1024 * 0.5))
1814
1933
  vcpus = max(2, sys_info["cpu_count"] // 2)
1815
1934
 
1935
+ if disk_size_gb is None:
1936
+ disk_size_gb = 20
1937
+
1816
1938
  # Auto-detect packages from running applications and services
1817
1939
  app_packages = detector.suggest_packages_for_apps(snapshot.applications)
1818
1940
  service_packages = detector.suggest_packages_for_services(snapshot.running_services)
@@ -1871,6 +1993,7 @@ def generate_clonebox_yaml(
1871
1993
  "name": vm_name,
1872
1994
  "ram_mb": ram_mb,
1873
1995
  "vcpus": vcpus,
1996
+ "disk_size_gb": disk_size_gb,
1874
1997
  "gui": True,
1875
1998
  "base_image": base_image,
1876
1999
  "network_mode": network_mode,
@@ -2024,6 +2147,7 @@ def create_vm_from_config(
2024
2147
  name=config["vm"]["name"],
2025
2148
  ram_mb=config["vm"].get("ram_mb", 4096),
2026
2149
  vcpus=config["vm"].get("vcpus", 4),
2150
+ disk_size_gb=config["vm"].get("disk_size_gb", 10),
2027
2151
  gui=config["vm"].get("gui", True),
2028
2152
  base_image=config["vm"].get("base_image"),
2029
2153
  paths=all_paths,
@@ -2103,6 +2227,7 @@ def cmd_clone(args):
2103
2227
  vm_name=vm_name,
2104
2228
  network_mode=args.network,
2105
2229
  base_image=getattr(args, "base_image", None),
2230
+ disk_size_gb=getattr(args, "disk_size_gb", None),
2106
2231
  )
2107
2232
 
2108
2233
  profile_name = getattr(args, "profile", None)
@@ -2345,6 +2470,12 @@ def main():
2345
2470
  )
2346
2471
  create_parser.add_argument("--ram", type=int, default=4096, help="RAM in MB")
2347
2472
  create_parser.add_argument("--vcpus", type=int, default=4, help="Number of vCPUs")
2473
+ create_parser.add_argument(
2474
+ "--disk-size-gb",
2475
+ type=int,
2476
+ default=10,
2477
+ help="Root disk size in GB (default: 10)",
2478
+ )
2348
2479
  create_parser.add_argument("--base-image", help="Path to base qcow2 image")
2349
2480
  create_parser.add_argument("--no-gui", action="store_true", help="Disable SPICE graphics")
2350
2481
  create_parser.add_argument("--start", "-s", action="store_true", help="Start VM after creation")
@@ -2551,6 +2682,12 @@ def main():
2551
2682
  "--base-image",
2552
2683
  help="Path to a bootable qcow2 image to use as a base disk",
2553
2684
  )
2685
+ clone_parser.add_argument(
2686
+ "--disk-size-gb",
2687
+ type=int,
2688
+ default=None,
2689
+ help="Root disk size in GB (default: 20 for generated configs)",
2690
+ )
2554
2691
  clone_parser.add_argument(
2555
2692
  "--profile",
2556
2693
  help="Profile name (loads ~/.clonebox.d/<name>.yaml, .clonebox.d/<name>.yaml, or built-in templates)",
@@ -2607,6 +2744,63 @@ def main():
2607
2744
  )
2608
2745
  diagnose_parser.set_defaults(func=cmd_diagnose)
2609
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
+
2610
2804
  # Export command - package VM for migration
2611
2805
  export_parser = subparsers.add_parser("export", help="Export VM and data for migration")
2612
2806
  export_parser.add_argument(
@@ -2652,6 +2846,16 @@ def main():
2652
2846
  test_parser.add_argument(
2653
2847
  "--validate", action="store_true", help="Run full validation (mounts, packages, services)"
2654
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
+ )
2655
2859
  test_parser.set_defaults(func=cmd_test)
2656
2860
 
2657
2861
  args = parser.parse_args()