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 +210 -6
- clonebox/cloner.py +345 -2
- clonebox/models.py +6 -2
- clonebox/validator.py +361 -29
- {clonebox-0.1.20.dist-info → clonebox-0.1.22.dist-info}/METADATA +196 -6
- clonebox-0.1.22.dist-info/RECORD +17 -0
- clonebox-0.1.20.dist-info/RECORD +0 -17
- {clonebox-0.1.20.dist-info → clonebox-0.1.22.dist-info}/WHEEL +0 -0
- {clonebox-0.1.20.dist-info → clonebox-0.1.22.dist-info}/entry_points.txt +0 -0
- {clonebox-0.1.20.dist-info → clonebox-0.1.22.dist-info}/licenses/LICENSE +0 -0
- {clonebox-0.1.20.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()
|
|
@@ -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(
|
|
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()
|