clonebox 0.1.5__py3-none-any.whl → 0.1.7__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 +222 -17
- clonebox/cloner.py +194 -14
- clonebox/detector.py +245 -0
- {clonebox-0.1.5.dist-info → clonebox-0.1.7.dist-info}/METADATA +82 -6
- clonebox-0.1.7.dist-info/RECORD +11 -0
- clonebox-0.1.5.dist-info/RECORD +0 -11
- {clonebox-0.1.5.dist-info → clonebox-0.1.7.dist-info}/WHEEL +0 -0
- {clonebox-0.1.5.dist-info → clonebox-0.1.7.dist-info}/entry_points.txt +0 -0
- {clonebox-0.1.5.dist-info → clonebox-0.1.7.dist-info}/licenses/LICENSE +0 -0
- {clonebox-0.1.5.dist-info → clonebox-0.1.7.dist-info}/top_level.txt +0 -0
clonebox/cli.py
CHANGED
|
@@ -6,7 +6,9 @@ CloneBox CLI - Interactive command-line interface for creating VMs.
|
|
|
6
6
|
import argparse
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
|
+
import re
|
|
9
10
|
import sys
|
|
11
|
+
from typing import Optional
|
|
10
12
|
from datetime import datetime
|
|
11
13
|
from pathlib import Path
|
|
12
14
|
|
|
@@ -487,6 +489,40 @@ def cmd_list(args):
|
|
|
487
489
|
|
|
488
490
|
|
|
489
491
|
CLONEBOX_CONFIG_FILE = ".clonebox.yaml"
|
|
492
|
+
CLONEBOX_ENV_FILE = ".env"
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def load_env_file(env_path: Path) -> dict:
|
|
496
|
+
"""Load environment variables from .env file."""
|
|
497
|
+
env_vars = {}
|
|
498
|
+
if not env_path.exists():
|
|
499
|
+
return env_vars
|
|
500
|
+
|
|
501
|
+
with open(env_path) as f:
|
|
502
|
+
for line in f:
|
|
503
|
+
line = line.strip()
|
|
504
|
+
if not line or line.startswith('#'):
|
|
505
|
+
continue
|
|
506
|
+
if '=' in line:
|
|
507
|
+
key, value = line.split('=', 1)
|
|
508
|
+
env_vars[key.strip()] = value.strip()
|
|
509
|
+
|
|
510
|
+
return env_vars
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def expand_env_vars(value, env_vars: dict):
|
|
514
|
+
"""Expand environment variables in string values like ${VAR_NAME}."""
|
|
515
|
+
if isinstance(value, str):
|
|
516
|
+
# Replace ${VAR_NAME} with value from env_vars or os.environ
|
|
517
|
+
def replacer(match):
|
|
518
|
+
var_name = match.group(1)
|
|
519
|
+
return env_vars.get(var_name, os.environ.get(var_name, match.group(0)))
|
|
520
|
+
return re.sub(r'\$\{([^}]+)\}', replacer, value)
|
|
521
|
+
elif isinstance(value, dict):
|
|
522
|
+
return {k: expand_env_vars(v, env_vars) for k, v in value.items()}
|
|
523
|
+
elif isinstance(value, list):
|
|
524
|
+
return [expand_env_vars(item, env_vars) for item in value]
|
|
525
|
+
return value
|
|
490
526
|
|
|
491
527
|
|
|
492
528
|
def deduplicate_list(items: list, key=None) -> list:
|
|
@@ -508,6 +544,7 @@ def generate_clonebox_yaml(
|
|
|
508
544
|
target_path: str = None,
|
|
509
545
|
vm_name: str = None,
|
|
510
546
|
network_mode: str = "auto",
|
|
547
|
+
base_image: Optional[str] = None,
|
|
511
548
|
) -> str:
|
|
512
549
|
"""Generate YAML config from system snapshot."""
|
|
513
550
|
sys_info = detector.get_system_info()
|
|
@@ -555,6 +592,16 @@ def generate_clonebox_yaml(
|
|
|
555
592
|
paths_mapping[host_path] = f"/mnt/workdir{idx}"
|
|
556
593
|
idx += 1
|
|
557
594
|
|
|
595
|
+
# Add default user folders (Downloads, Documents)
|
|
596
|
+
home_dir = Path.home()
|
|
597
|
+
default_folders = [
|
|
598
|
+
(home_dir / "Downloads", "/home/ubuntu/Downloads"),
|
|
599
|
+
(home_dir / "Documents", "/home/ubuntu/Documents"),
|
|
600
|
+
]
|
|
601
|
+
for host_folder, guest_folder in default_folders:
|
|
602
|
+
if host_folder.exists() and str(host_folder) not in paths_mapping:
|
|
603
|
+
paths_mapping[str(host_folder)] = guest_folder
|
|
604
|
+
|
|
558
605
|
# Determine VM name
|
|
559
606
|
if not vm_name:
|
|
560
607
|
if target_path:
|
|
@@ -566,6 +613,28 @@ def generate_clonebox_yaml(
|
|
|
566
613
|
ram_mb = min(4096, int(sys_info["memory_available_gb"] * 1024 * 0.5))
|
|
567
614
|
vcpus = max(2, sys_info["cpu_count"] // 2)
|
|
568
615
|
|
|
616
|
+
# Auto-detect packages from running applications and services
|
|
617
|
+
app_packages = detector.suggest_packages_for_apps(snapshot.applications)
|
|
618
|
+
service_packages = detector.suggest_packages_for_services(snapshot.running_services)
|
|
619
|
+
|
|
620
|
+
# Combine with base packages (apt only)
|
|
621
|
+
base_packages = [
|
|
622
|
+
"build-essential",
|
|
623
|
+
"git",
|
|
624
|
+
"curl",
|
|
625
|
+
"vim",
|
|
626
|
+
]
|
|
627
|
+
|
|
628
|
+
# Merge apt packages and deduplicate
|
|
629
|
+
all_apt_packages = base_packages + app_packages["apt"] + service_packages["apt"]
|
|
630
|
+
if deduplicate:
|
|
631
|
+
all_apt_packages = deduplicate_list(all_apt_packages)
|
|
632
|
+
|
|
633
|
+
# Merge snap packages and deduplicate
|
|
634
|
+
all_snap_packages = app_packages["snap"] + service_packages["snap"]
|
|
635
|
+
if deduplicate:
|
|
636
|
+
all_snap_packages = deduplicate_list(all_snap_packages)
|
|
637
|
+
|
|
569
638
|
# Build config
|
|
570
639
|
config = {
|
|
571
640
|
"version": "1",
|
|
@@ -575,18 +644,15 @@ def generate_clonebox_yaml(
|
|
|
575
644
|
"ram_mb": ram_mb,
|
|
576
645
|
"vcpus": vcpus,
|
|
577
646
|
"gui": True,
|
|
578
|
-
"base_image":
|
|
647
|
+
"base_image": base_image,
|
|
579
648
|
"network_mode": network_mode,
|
|
649
|
+
"username": "ubuntu",
|
|
650
|
+
"password": "${VM_PASSWORD}",
|
|
580
651
|
},
|
|
581
652
|
"services": services,
|
|
582
|
-
"packages":
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
"curl",
|
|
586
|
-
"vim",
|
|
587
|
-
"python3",
|
|
588
|
-
"python3-pip",
|
|
589
|
-
],
|
|
653
|
+
"packages": all_apt_packages,
|
|
654
|
+
"snap_packages": all_snap_packages,
|
|
655
|
+
"post_commands": [], # User can add custom commands to run after setup
|
|
590
656
|
"paths": paths_mapping,
|
|
591
657
|
"detected": {
|
|
592
658
|
"running_apps": [
|
|
@@ -605,17 +671,115 @@ def generate_clonebox_yaml(
|
|
|
605
671
|
|
|
606
672
|
|
|
607
673
|
def load_clonebox_config(path: Path) -> dict:
|
|
608
|
-
"""Load .clonebox.yaml config file."""
|
|
674
|
+
"""Load .clonebox.yaml config file and expand environment variables from .env."""
|
|
609
675
|
config_file = path / CLONEBOX_CONFIG_FILE if path.is_dir() else path
|
|
610
676
|
|
|
611
677
|
if not config_file.exists():
|
|
612
678
|
raise FileNotFoundError(f"Config file not found: {config_file}")
|
|
613
679
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
680
|
+
# Load .env file from same directory
|
|
681
|
+
config_dir = config_file.parent
|
|
682
|
+
env_file = config_dir / CLONEBOX_ENV_FILE
|
|
683
|
+
env_vars = load_env_file(env_file)
|
|
617
684
|
|
|
618
|
-
|
|
685
|
+
# Load YAML config
|
|
686
|
+
with open(config_file) as f:
|
|
687
|
+
config = yaml.safe_load(f)
|
|
688
|
+
|
|
689
|
+
# Expand environment variables in config
|
|
690
|
+
config = expand_env_vars(config, env_vars)
|
|
691
|
+
|
|
692
|
+
return config
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout: int = 600):
|
|
696
|
+
"""Monitor cloud-init status in VM and show progress."""
|
|
697
|
+
import subprocess
|
|
698
|
+
import time
|
|
699
|
+
|
|
700
|
+
conn_uri = "qemu:///session" if user_session else "qemu:///system"
|
|
701
|
+
start_time = time.time()
|
|
702
|
+
shutdown_count = 0 # Count consecutive shutdown detections
|
|
703
|
+
restart_detected = False
|
|
704
|
+
|
|
705
|
+
with Progress(
|
|
706
|
+
SpinnerColumn(),
|
|
707
|
+
TextColumn("[progress.description]{task.description}"),
|
|
708
|
+
console=console,
|
|
709
|
+
) as progress:
|
|
710
|
+
task = progress.add_task("[cyan]Starting VM and initializing...", total=None)
|
|
711
|
+
|
|
712
|
+
while time.time() - start_time < timeout:
|
|
713
|
+
try:
|
|
714
|
+
elapsed = int(time.time() - start_time)
|
|
715
|
+
minutes = elapsed // 60
|
|
716
|
+
seconds = elapsed % 60
|
|
717
|
+
|
|
718
|
+
# Check VM state
|
|
719
|
+
result = subprocess.run(
|
|
720
|
+
["virsh", "--connect", conn_uri, "domstate", vm_name],
|
|
721
|
+
capture_output=True,
|
|
722
|
+
text=True,
|
|
723
|
+
timeout=5
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
vm_state = result.stdout.strip().lower()
|
|
727
|
+
|
|
728
|
+
if "shut off" in vm_state or "shutting down" in vm_state:
|
|
729
|
+
# VM is shutting down - count consecutive detections
|
|
730
|
+
shutdown_count += 1
|
|
731
|
+
if shutdown_count >= 3 and not restart_detected:
|
|
732
|
+
# Confirmed shutdown after 3 consecutive checks
|
|
733
|
+
restart_detected = True
|
|
734
|
+
progress.update(task, description="[yellow]⟳ VM restarting after package installation...")
|
|
735
|
+
time.sleep(3)
|
|
736
|
+
continue
|
|
737
|
+
else:
|
|
738
|
+
# VM is running - reset shutdown counter
|
|
739
|
+
if shutdown_count > 0 and shutdown_count < 3:
|
|
740
|
+
# Was a brief glitch, not a real shutdown
|
|
741
|
+
shutdown_count = 0
|
|
742
|
+
|
|
743
|
+
if restart_detected and "running" in vm_state and shutdown_count >= 3:
|
|
744
|
+
# VM restarted successfully - GUI should be ready
|
|
745
|
+
progress.update(task, description=f"[green]✓ GUI ready! Total time: {minutes}m {seconds}s")
|
|
746
|
+
time.sleep(2)
|
|
747
|
+
break
|
|
748
|
+
|
|
749
|
+
# Estimate remaining time
|
|
750
|
+
if elapsed < 60:
|
|
751
|
+
remaining = "~9-10 minutes"
|
|
752
|
+
elif elapsed < 180:
|
|
753
|
+
remaining = f"~{8 - minutes} minutes"
|
|
754
|
+
elif elapsed < 300:
|
|
755
|
+
remaining = f"~{6 - minutes} minutes"
|
|
756
|
+
else:
|
|
757
|
+
remaining = "finishing soon"
|
|
758
|
+
|
|
759
|
+
if restart_detected:
|
|
760
|
+
progress.update(task, description=f"[cyan]Starting GUI... ({minutes}m {seconds}s, {remaining})")
|
|
761
|
+
else:
|
|
762
|
+
progress.update(task, description=f"[cyan]Installing desktop packages... ({minutes}m {seconds}s, {remaining})")
|
|
763
|
+
|
|
764
|
+
except (subprocess.TimeoutExpired, Exception) as e:
|
|
765
|
+
elapsed = int(time.time() - start_time)
|
|
766
|
+
minutes = elapsed // 60
|
|
767
|
+
seconds = elapsed % 60
|
|
768
|
+
progress.update(task, description=f"[cyan]Configuring VM... ({minutes}m {seconds}s)")
|
|
769
|
+
|
|
770
|
+
time.sleep(3)
|
|
771
|
+
|
|
772
|
+
# Final status
|
|
773
|
+
if time.time() - start_time >= timeout:
|
|
774
|
+
progress.update(task, description="[yellow]⚠ Monitoring timeout - VM continues in background")
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def create_vm_from_config(
|
|
778
|
+
config: dict,
|
|
779
|
+
start: bool = False,
|
|
780
|
+
user_session: bool = False,
|
|
781
|
+
replace: bool = False,
|
|
782
|
+
) -> str:
|
|
619
783
|
"""Create VM from YAML config dict."""
|
|
620
784
|
vm_config = VMConfig(
|
|
621
785
|
name=config["vm"]["name"],
|
|
@@ -625,9 +789,13 @@ def create_vm_from_config(config: dict, start: bool = False, user_session: bool
|
|
|
625
789
|
base_image=config["vm"].get("base_image"),
|
|
626
790
|
paths=config.get("paths", {}),
|
|
627
791
|
packages=config.get("packages", []),
|
|
792
|
+
snap_packages=config.get("snap_packages", []),
|
|
628
793
|
services=config.get("services", []),
|
|
794
|
+
post_commands=config.get("post_commands", []),
|
|
629
795
|
user_session=user_session,
|
|
630
796
|
network_mode=config["vm"].get("network_mode", "auto"),
|
|
797
|
+
username=config["vm"].get("username", "ubuntu"),
|
|
798
|
+
password=config["vm"].get("password", "ubuntu"),
|
|
631
799
|
)
|
|
632
800
|
|
|
633
801
|
cloner = SelectiveVMCloner(user_session=user_session)
|
|
@@ -643,10 +811,20 @@ def create_vm_from_config(config: dict, start: bool = False, user_session: bool
|
|
|
643
811
|
|
|
644
812
|
console.print(f"[dim]Session: {checks['session_type']}, Storage: {checks['images_dir']}[/]")
|
|
645
813
|
|
|
646
|
-
vm_uuid = cloner.create_vm(vm_config, console=console)
|
|
814
|
+
vm_uuid = cloner.create_vm(vm_config, console=console, replace=replace)
|
|
647
815
|
|
|
648
816
|
if start:
|
|
649
817
|
cloner.start_vm(vm_config.name, open_viewer=vm_config.gui, console=console)
|
|
818
|
+
|
|
819
|
+
# Monitor cloud-init progress if GUI is enabled
|
|
820
|
+
if vm_config.gui:
|
|
821
|
+
console.print("\n[bold cyan]📊 Monitoring setup progress...[/]")
|
|
822
|
+
try:
|
|
823
|
+
monitor_cloud_init_status(vm_config.name, user_session=user_session)
|
|
824
|
+
except KeyboardInterrupt:
|
|
825
|
+
console.print("\n[yellow]Monitoring stopped. VM continues setup in background.[/]")
|
|
826
|
+
except Exception as e:
|
|
827
|
+
console.print(f"\n[dim]Note: Could not monitor status ({e}). VM continues setup in background.[/]")
|
|
650
828
|
|
|
651
829
|
return vm_uuid
|
|
652
830
|
|
|
@@ -681,6 +859,7 @@ def cmd_clone(args):
|
|
|
681
859
|
target_path=str(target_path),
|
|
682
860
|
vm_name=vm_name,
|
|
683
861
|
network_mode=args.network,
|
|
862
|
+
base_image=getattr(args, "base_image", None),
|
|
684
863
|
)
|
|
685
864
|
|
|
686
865
|
# Save config file
|
|
@@ -712,7 +891,8 @@ def cmd_clone(args):
|
|
|
712
891
|
).ask()
|
|
713
892
|
|
|
714
893
|
if create_now:
|
|
715
|
-
config
|
|
894
|
+
# Load config with environment variable expansion
|
|
895
|
+
config = load_clonebox_config(config_file.parent)
|
|
716
896
|
user_session = getattr(args, "user", False)
|
|
717
897
|
|
|
718
898
|
console.print("\n[bold cyan]🔧 Creating VM...[/]\n")
|
|
@@ -720,10 +900,26 @@ def cmd_clone(args):
|
|
|
720
900
|
console.print("[cyan]Using user session (qemu:///session) - no root required[/]")
|
|
721
901
|
|
|
722
902
|
try:
|
|
723
|
-
vm_uuid = create_vm_from_config(
|
|
903
|
+
vm_uuid = create_vm_from_config(
|
|
904
|
+
config,
|
|
905
|
+
start=True,
|
|
906
|
+
user_session=user_session,
|
|
907
|
+
replace=getattr(args, "replace", False),
|
|
908
|
+
)
|
|
724
909
|
console.print(f"\n[bold green]🎉 VM '{config['vm']['name']}' is running![/]")
|
|
725
910
|
console.print(f"[dim]UUID: {vm_uuid}[/]")
|
|
726
911
|
|
|
912
|
+
# Show GUI startup info if GUI is enabled
|
|
913
|
+
if config.get("vm", {}).get("gui", False):
|
|
914
|
+
username = config['vm'].get('username', 'ubuntu')
|
|
915
|
+
password = config['vm'].get('password', 'ubuntu')
|
|
916
|
+
console.print("\n[bold yellow]⏰ GUI Setup Process:[/]")
|
|
917
|
+
console.print(" [yellow]•[/] Installing desktop environment (~5-10 minutes)")
|
|
918
|
+
console.print(" [yellow]•[/] Automatic restart after installation")
|
|
919
|
+
console.print(" [yellow]•[/] GUI login screen will appear")
|
|
920
|
+
console.print(f" [yellow]•[/] Login: [cyan]{username}[/] / [cyan]{'*' * len(password)}[/] (from .env)")
|
|
921
|
+
console.print("\n[dim]💡 Progress will be monitored automatically below[/]")
|
|
922
|
+
|
|
727
923
|
# Show mount instructions
|
|
728
924
|
if config.get("paths"):
|
|
729
925
|
console.print("\n[bold]Inside VM, mount paths with:[/]")
|
|
@@ -939,6 +1135,15 @@ def main():
|
|
|
939
1135
|
default="auto",
|
|
940
1136
|
help="Network mode: auto (default), default (libvirt network), user (slirp)",
|
|
941
1137
|
)
|
|
1138
|
+
clone_parser.add_argument(
|
|
1139
|
+
"--base-image",
|
|
1140
|
+
help="Path to a bootable qcow2 image to use as a base disk",
|
|
1141
|
+
)
|
|
1142
|
+
clone_parser.add_argument(
|
|
1143
|
+
"--replace",
|
|
1144
|
+
action="store_true",
|
|
1145
|
+
help="If VM already exists, stop+undefine it and recreate (also deletes its storage)",
|
|
1146
|
+
)
|
|
942
1147
|
clone_parser.set_defaults(func=cmd_clone)
|
|
943
1148
|
|
|
944
1149
|
args = parser.parse_args()
|
clonebox/cloner.py
CHANGED
|
@@ -5,6 +5,8 @@ SelectiveVMCloner - Creates isolated VMs with only selected apps/paths/services.
|
|
|
5
5
|
|
|
6
6
|
import os
|
|
7
7
|
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import urllib.request
|
|
8
10
|
import uuid
|
|
9
11
|
import xml.etree.ElementTree as ET
|
|
10
12
|
from dataclasses import dataclass, field
|
|
@@ -29,9 +31,13 @@ class VMConfig:
|
|
|
29
31
|
base_image: Optional[str] = None
|
|
30
32
|
paths: dict = field(default_factory=dict)
|
|
31
33
|
packages: list = field(default_factory=list)
|
|
34
|
+
snap_packages: list = field(default_factory=list) # Snap packages to install
|
|
32
35
|
services: list = field(default_factory=list)
|
|
36
|
+
post_commands: list = field(default_factory=list) # Commands to run after setup
|
|
33
37
|
user_session: bool = False # Use qemu:///session instead of qemu:///system
|
|
34
38
|
network_mode: str = "auto" # auto|default|user
|
|
39
|
+
username: str = "ubuntu" # VM default username
|
|
40
|
+
password: str = "ubuntu" # VM default password
|
|
35
41
|
|
|
36
42
|
def to_dict(self) -> dict:
|
|
37
43
|
return {
|
|
@@ -51,6 +57,11 @@ class SelectiveVMCloner:
|
|
|
51
57
|
SYSTEM_IMAGES_DIR = Path("/var/lib/libvirt/images")
|
|
52
58
|
USER_IMAGES_DIR = Path.home() / ".local/share/libvirt/images"
|
|
53
59
|
|
|
60
|
+
DEFAULT_BASE_IMAGE_URL = (
|
|
61
|
+
"https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img"
|
|
62
|
+
)
|
|
63
|
+
DEFAULT_BASE_IMAGE_FILENAME = "clonebox-ubuntu-jammy-amd64.qcow2"
|
|
64
|
+
|
|
54
65
|
def __init__(self, conn_uri: str = None, user_session: bool = False):
|
|
55
66
|
self.user_session = user_session
|
|
56
67
|
if conn_uri:
|
|
@@ -91,6 +102,57 @@ class SelectiveVMCloner:
|
|
|
91
102
|
return self.USER_IMAGES_DIR
|
|
92
103
|
return self.SYSTEM_IMAGES_DIR
|
|
93
104
|
|
|
105
|
+
def _get_downloads_dir(self) -> Path:
|
|
106
|
+
return Path.home() / "Downloads"
|
|
107
|
+
|
|
108
|
+
def _ensure_default_base_image(self, console=None) -> Path:
|
|
109
|
+
def log(msg):
|
|
110
|
+
if console:
|
|
111
|
+
console.print(msg)
|
|
112
|
+
else:
|
|
113
|
+
print(msg)
|
|
114
|
+
|
|
115
|
+
downloads_dir = self._get_downloads_dir()
|
|
116
|
+
downloads_dir.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
cached_path = downloads_dir / self.DEFAULT_BASE_IMAGE_FILENAME
|
|
118
|
+
|
|
119
|
+
if cached_path.exists() and cached_path.stat().st_size > 0:
|
|
120
|
+
return cached_path
|
|
121
|
+
|
|
122
|
+
log(
|
|
123
|
+
"[cyan]⬇️ Downloading base image (first run only). This will be cached in ~/Downloads...[/]"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
with tempfile.NamedTemporaryFile(
|
|
128
|
+
prefix=f"{self.DEFAULT_BASE_IMAGE_FILENAME}.",
|
|
129
|
+
dir=str(downloads_dir),
|
|
130
|
+
delete=False,
|
|
131
|
+
) as tmp:
|
|
132
|
+
tmp_path = Path(tmp.name)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
urllib.request.urlretrieve(self.DEFAULT_BASE_IMAGE_URL, tmp_path)
|
|
136
|
+
tmp_path.replace(cached_path)
|
|
137
|
+
finally:
|
|
138
|
+
if tmp_path.exists() and tmp_path != cached_path:
|
|
139
|
+
try:
|
|
140
|
+
tmp_path.unlink()
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
except Exception as e:
|
|
144
|
+
raise RuntimeError(
|
|
145
|
+
"Failed to download a default base image.\n\n"
|
|
146
|
+
"🔧 Solutions:\n"
|
|
147
|
+
" 1. Provide a base image explicitly:\n"
|
|
148
|
+
" clonebox clone . --base-image /path/to/image.qcow2\n"
|
|
149
|
+
" 2. Download it manually and reuse it:\n"
|
|
150
|
+
f" wget -O {cached_path} {self.DEFAULT_BASE_IMAGE_URL}\n\n"
|
|
151
|
+
f"Original error: {e}"
|
|
152
|
+
) from e
|
|
153
|
+
|
|
154
|
+
return cached_path
|
|
155
|
+
|
|
94
156
|
def _default_network_active(self) -> bool:
|
|
95
157
|
"""Check if libvirt default network is active."""
|
|
96
158
|
try:
|
|
@@ -175,7 +237,7 @@ class SelectiveVMCloner:
|
|
|
175
237
|
|
|
176
238
|
return checks
|
|
177
239
|
|
|
178
|
-
def create_vm(self, config: VMConfig, console=None) -> str:
|
|
240
|
+
def create_vm(self, config: VMConfig, console=None, replace: bool = False) -> str:
|
|
179
241
|
"""
|
|
180
242
|
Create a VM with only selected applications/paths.
|
|
181
243
|
|
|
@@ -193,6 +255,38 @@ class SelectiveVMCloner:
|
|
|
193
255
|
else:
|
|
194
256
|
print(msg)
|
|
195
257
|
|
|
258
|
+
# If VM already exists, optionally replace it
|
|
259
|
+
existing_vm = None
|
|
260
|
+
try:
|
|
261
|
+
candidate_vm = self.conn.lookupByName(config.name)
|
|
262
|
+
if candidate_vm is not None:
|
|
263
|
+
# libvirt returns a domain object whose .name() should match the requested name.
|
|
264
|
+
# In tests, an unconfigured MagicMock may be returned here; avoid treating that as
|
|
265
|
+
# a real existing domain unless we can confirm the name matches.
|
|
266
|
+
try:
|
|
267
|
+
if hasattr(candidate_vm, "name") and callable(candidate_vm.name):
|
|
268
|
+
if candidate_vm.name() == config.name:
|
|
269
|
+
existing_vm = candidate_vm
|
|
270
|
+
else:
|
|
271
|
+
existing_vm = candidate_vm
|
|
272
|
+
except Exception:
|
|
273
|
+
existing_vm = candidate_vm
|
|
274
|
+
except Exception:
|
|
275
|
+
existing_vm = None
|
|
276
|
+
|
|
277
|
+
if existing_vm is not None:
|
|
278
|
+
if not replace:
|
|
279
|
+
raise RuntimeError(
|
|
280
|
+
f"VM '{config.name}' already exists.\n\n"
|
|
281
|
+
f"🔧 Solutions:\n"
|
|
282
|
+
f" 1. Reuse existing VM: clonebox start {config.name}\n"
|
|
283
|
+
f" 2. Replace it: clonebox clone . --name {config.name} --replace\n"
|
|
284
|
+
f" 3. Delete it: clonebox delete {config.name}\n"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
log(f"[yellow]⚠️ VM '{config.name}' already exists - replacing...[/]")
|
|
288
|
+
self.delete_vm(config.name, delete_storage=True, console=console, ignore_not_found=True)
|
|
289
|
+
|
|
196
290
|
# Determine images directory
|
|
197
291
|
images_dir = self.get_images_dir()
|
|
198
292
|
vm_dir = images_dir / config.name
|
|
@@ -216,6 +310,9 @@ class SelectiveVMCloner:
|
|
|
216
310
|
# Create root disk
|
|
217
311
|
root_disk = vm_dir / "root.qcow2"
|
|
218
312
|
|
|
313
|
+
if not config.base_image:
|
|
314
|
+
config.base_image = str(self._ensure_default_base_image(console=console))
|
|
315
|
+
|
|
219
316
|
if config.base_image and Path(config.base_image).exists():
|
|
220
317
|
# Use backing file for faster creation
|
|
221
318
|
log(f"[cyan]📀 Creating disk with backing file: {config.base_image}[/]")
|
|
@@ -258,7 +355,14 @@ class SelectiveVMCloner:
|
|
|
258
355
|
|
|
259
356
|
# Define and create VM
|
|
260
357
|
log(f"[cyan]🔧 Defining VM '{config.name}'...[/]")
|
|
261
|
-
|
|
358
|
+
try:
|
|
359
|
+
vm = self.conn.defineXML(vm_xml)
|
|
360
|
+
except Exception as e:
|
|
361
|
+
raise RuntimeError(
|
|
362
|
+
f"Failed to define VM '{config.name}'.\n"
|
|
363
|
+
f"Error: {e}\n\n"
|
|
364
|
+
f"If the VM already exists, try: clonebox clone . --name {config.name} --replace\n"
|
|
365
|
+
) from e
|
|
262
366
|
|
|
263
367
|
log(f"[green]✅ VM '{config.name}' created successfully![/]")
|
|
264
368
|
log(f"[dim] UUID: {vm.UUIDString()}[/]")
|
|
@@ -393,27 +497,95 @@ class SelectiveVMCloner:
|
|
|
393
497
|
)
|
|
394
498
|
|
|
395
499
|
# User-data
|
|
500
|
+
# Add desktop environment if GUI is enabled
|
|
501
|
+
base_packages = []
|
|
502
|
+
if config.gui:
|
|
503
|
+
base_packages.extend([
|
|
504
|
+
"ubuntu-desktop-minimal",
|
|
505
|
+
"firefox",
|
|
506
|
+
])
|
|
507
|
+
|
|
508
|
+
all_packages = base_packages + list(config.packages)
|
|
396
509
|
packages_yaml = (
|
|
397
|
-
"\n".join(f" - {pkg}" for pkg in
|
|
510
|
+
"\n".join(f" - {pkg}" for pkg in all_packages) if all_packages else ""
|
|
398
511
|
)
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
512
|
+
|
|
513
|
+
# Build runcmd - services, mounts, snaps, post_commands
|
|
514
|
+
runcmd_lines = []
|
|
515
|
+
|
|
516
|
+
# Add service enablement
|
|
517
|
+
for svc in config.services:
|
|
518
|
+
runcmd_lines.append(f" - systemctl enable --now {svc} || true")
|
|
519
|
+
|
|
520
|
+
# Add mounts
|
|
521
|
+
for cmd in mount_commands:
|
|
522
|
+
runcmd_lines.append(cmd)
|
|
523
|
+
|
|
524
|
+
# Install snap packages
|
|
525
|
+
if config.snap_packages:
|
|
526
|
+
runcmd_lines.append(" - echo 'Installing snap packages...'")
|
|
527
|
+
for snap_pkg in config.snap_packages:
|
|
528
|
+
runcmd_lines.append(f" - snap install {snap_pkg} --classic || snap install {snap_pkg} || true")
|
|
529
|
+
|
|
530
|
+
# Add GUI setup if enabled - runs AFTER package installation completes
|
|
531
|
+
if config.gui:
|
|
532
|
+
runcmd_lines.extend([
|
|
533
|
+
" - systemctl set-default graphical.target",
|
|
534
|
+
" - systemctl enable gdm3 || systemctl enable gdm || true",
|
|
535
|
+
])
|
|
536
|
+
|
|
537
|
+
# Run user-defined post commands
|
|
538
|
+
if config.post_commands:
|
|
539
|
+
runcmd_lines.append(" - echo 'Running post-setup commands...'")
|
|
540
|
+
for cmd in config.post_commands:
|
|
541
|
+
runcmd_lines.append(f" - {cmd}")
|
|
542
|
+
|
|
543
|
+
# Validation - check installed packages and log results
|
|
544
|
+
runcmd_lines.append(" - echo '=== CloneBox Setup Validation ===' >> /var/log/clonebox-setup.log")
|
|
545
|
+
runcmd_lines.append(" - dpkg -l | grep -E 'ii' | wc -l >> /var/log/clonebox-setup.log")
|
|
546
|
+
runcmd_lines.append(" - snap list >> /var/log/clonebox-setup.log 2>/dev/null || true")
|
|
547
|
+
runcmd_lines.append(" - echo 'CloneBox VM ready!' > /var/log/clonebox-ready")
|
|
548
|
+
runcmd_lines.append(" - echo 'Setup completed at:' $(date) >> /var/log/clonebox-setup.log")
|
|
549
|
+
|
|
550
|
+
# Add reboot command at the end if GUI is enabled
|
|
551
|
+
if config.gui:
|
|
552
|
+
runcmd_lines.append(" - shutdown -r +1 'Rebooting to start GUI' || reboot")
|
|
553
|
+
|
|
554
|
+
runcmd_yaml = "\n".join(runcmd_lines) if runcmd_lines else ""
|
|
555
|
+
|
|
556
|
+
# Remove power_state - using shutdown -r instead
|
|
557
|
+
power_state_yaml = ""
|
|
405
558
|
|
|
406
559
|
user_data = f"""#cloud-config
|
|
407
560
|
hostname: {config.name}
|
|
408
561
|
manage_etc_hosts: true
|
|
409
562
|
|
|
563
|
+
# Default user
|
|
564
|
+
users:
|
|
565
|
+
- name: {config.username}
|
|
566
|
+
sudo: ALL=(ALL) NOPASSWD:ALL
|
|
567
|
+
shell: /bin/bash
|
|
568
|
+
lock_passwd: false
|
|
569
|
+
groups: sudo,adm,dialout,cdrom,floppy,audio,dip,video,plugdev,netdev
|
|
570
|
+
plain_text_passwd: {config.password}
|
|
571
|
+
|
|
572
|
+
# Allow password authentication
|
|
573
|
+
ssh_pwauth: true
|
|
574
|
+
chpasswd:
|
|
575
|
+
expire: false
|
|
576
|
+
|
|
577
|
+
# Update package cache and upgrade
|
|
578
|
+
package_update: true
|
|
579
|
+
package_upgrade: false
|
|
580
|
+
|
|
581
|
+
# Install packages (cloud-init waits for completion before runcmd)
|
|
410
582
|
packages:
|
|
411
583
|
{packages_yaml}
|
|
412
584
|
|
|
585
|
+
# Run after packages are installed
|
|
413
586
|
runcmd:
|
|
414
|
-
{
|
|
415
|
-
{
|
|
416
|
-
- echo "CloneBox VM ready!" > /var/log/clonebox-ready
|
|
587
|
+
{runcmd_yaml}
|
|
588
|
+
{power_state_yaml}
|
|
417
589
|
|
|
418
590
|
final_message: "CloneBox VM is ready after $UPTIME seconds"
|
|
419
591
|
"""
|
|
@@ -451,6 +623,8 @@ final_message: "CloneBox VM is ready after $UPTIME seconds"
|
|
|
451
623
|
try:
|
|
452
624
|
vm = self.conn.lookupByName(vm_name)
|
|
453
625
|
except libvirt.libvirtError:
|
|
626
|
+
if ignore_not_found:
|
|
627
|
+
return False
|
|
454
628
|
log(f"[red]❌ VM '{vm_name}' not found[/]")
|
|
455
629
|
return False
|
|
456
630
|
|
|
@@ -500,7 +674,13 @@ final_message: "CloneBox VM is ready after $UPTIME seconds"
|
|
|
500
674
|
log("[green]✅ VM stopped![/]")
|
|
501
675
|
return True
|
|
502
676
|
|
|
503
|
-
def delete_vm(
|
|
677
|
+
def delete_vm(
|
|
678
|
+
self,
|
|
679
|
+
vm_name: str,
|
|
680
|
+
delete_storage: bool = True,
|
|
681
|
+
console=None,
|
|
682
|
+
ignore_not_found: bool = False,
|
|
683
|
+
) -> bool:
|
|
504
684
|
"""Delete a VM and optionally its storage."""
|
|
505
685
|
|
|
506
686
|
def log(msg):
|
|
@@ -525,7 +705,7 @@ final_message: "CloneBox VM is ready after $UPTIME seconds"
|
|
|
525
705
|
|
|
526
706
|
# Delete storage
|
|
527
707
|
if delete_storage:
|
|
528
|
-
vm_dir =
|
|
708
|
+
vm_dir = self.get_images_dir() / vm_name
|
|
529
709
|
if vm_dir.exists():
|
|
530
710
|
import shutil
|
|
531
711
|
|
clonebox/detector.py
CHANGED
|
@@ -144,12 +144,209 @@ class SystemDetector:
|
|
|
144
144
|
"esbuild",
|
|
145
145
|
"tmux",
|
|
146
146
|
"screen",
|
|
147
|
+
# IDEs and desktop apps
|
|
148
|
+
"pycharm",
|
|
149
|
+
"idea",
|
|
150
|
+
"webstorm",
|
|
151
|
+
"phpstorm",
|
|
152
|
+
"goland",
|
|
153
|
+
"clion",
|
|
154
|
+
"rider",
|
|
155
|
+
"datagrip",
|
|
156
|
+
"sublime",
|
|
157
|
+
"atom",
|
|
158
|
+
"slack",
|
|
159
|
+
"discord",
|
|
160
|
+
"telegram",
|
|
161
|
+
"spotify",
|
|
162
|
+
"vlc",
|
|
163
|
+
"gimp",
|
|
164
|
+
"inkscape",
|
|
165
|
+
"blender",
|
|
166
|
+
"obs",
|
|
167
|
+
"postman",
|
|
168
|
+
"insomnia",
|
|
169
|
+
"dbeaver",
|
|
147
170
|
]
|
|
148
171
|
|
|
172
|
+
# Map process/service names to Ubuntu packages or snap packages
|
|
173
|
+
# Format: "process_name": ("package_name", "install_type") where install_type is "apt" or "snap"
|
|
174
|
+
APP_TO_PACKAGE_MAP = {
|
|
175
|
+
"python": ("python3", "apt"),
|
|
176
|
+
"python3": ("python3", "apt"),
|
|
177
|
+
"pip": ("python3-pip", "apt"),
|
|
178
|
+
"node": ("nodejs", "apt"),
|
|
179
|
+
"npm": ("npm", "apt"),
|
|
180
|
+
"yarn": ("yarnpkg", "apt"),
|
|
181
|
+
"docker": ("docker.io", "apt"),
|
|
182
|
+
"dockerd": ("docker.io", "apt"),
|
|
183
|
+
"docker-compose": ("docker-compose", "apt"),
|
|
184
|
+
"podman": ("podman", "apt"),
|
|
185
|
+
"nginx": ("nginx", "apt"),
|
|
186
|
+
"apache2": ("apache2", "apt"),
|
|
187
|
+
"httpd": ("apache2", "apt"),
|
|
188
|
+
"postgres": ("postgresql", "apt"),
|
|
189
|
+
"postgresql": ("postgresql", "apt"),
|
|
190
|
+
"mysql": ("mysql-server", "apt"),
|
|
191
|
+
"mysqld": ("mysql-server", "apt"),
|
|
192
|
+
"mongod": ("mongodb", "apt"),
|
|
193
|
+
"mongodb": ("mongodb", "apt"),
|
|
194
|
+
"redis-server": ("redis-server", "apt"),
|
|
195
|
+
"redis": ("redis-server", "apt"),
|
|
196
|
+
"vim": ("vim", "apt"),
|
|
197
|
+
"nvim": ("neovim", "apt"),
|
|
198
|
+
"emacs": ("emacs", "apt"),
|
|
199
|
+
"firefox": ("firefox", "apt"),
|
|
200
|
+
"chromium": ("chromium-browser", "apt"),
|
|
201
|
+
"jupyter": ("jupyter-notebook", "apt"),
|
|
202
|
+
"jupyter-lab": ("jupyterlab", "apt"),
|
|
203
|
+
"gunicorn": ("gunicorn", "apt"),
|
|
204
|
+
"uvicorn": ("uvicorn", "apt"),
|
|
205
|
+
"tmux": ("tmux", "apt"),
|
|
206
|
+
"screen": ("screen", "apt"),
|
|
207
|
+
"git": ("git", "apt"),
|
|
208
|
+
"curl": ("curl", "apt"),
|
|
209
|
+
"wget": ("wget", "apt"),
|
|
210
|
+
"ssh": ("openssh-client", "apt"),
|
|
211
|
+
"sshd": ("openssh-server", "apt"),
|
|
212
|
+
"go": ("golang", "apt"),
|
|
213
|
+
"cargo": ("cargo", "apt"),
|
|
214
|
+
"rustc": ("rustc", "apt"),
|
|
215
|
+
"java": ("default-jdk", "apt"),
|
|
216
|
+
"gradle": ("gradle", "apt"),
|
|
217
|
+
"mvn": ("maven", "apt"),
|
|
218
|
+
# Popular desktop apps (snap packages)
|
|
219
|
+
"chrome": ("chromium", "snap"),
|
|
220
|
+
"google-chrome": ("chromium", "snap"),
|
|
221
|
+
"pycharm": ("pycharm-community", "snap"),
|
|
222
|
+
"idea": ("intellij-idea-community", "snap"),
|
|
223
|
+
"code": ("code", "snap"),
|
|
224
|
+
"vscode": ("code", "snap"),
|
|
225
|
+
"slack": ("slack", "snap"),
|
|
226
|
+
"discord": ("discord", "snap"),
|
|
227
|
+
"spotify": ("spotify", "snap"),
|
|
228
|
+
"vlc": ("vlc", "apt"),
|
|
229
|
+
"gimp": ("gimp", "apt"),
|
|
230
|
+
"inkscape": ("inkscape", "apt"),
|
|
231
|
+
"blender": ("blender", "apt"),
|
|
232
|
+
"obs": ("obs-studio", "apt"),
|
|
233
|
+
"telegram": ("telegram-desktop", "snap"),
|
|
234
|
+
"postman": ("postman", "snap"),
|
|
235
|
+
"insomnia": ("insomnia", "snap"),
|
|
236
|
+
"dbeaver": ("dbeaver-ce", "snap"),
|
|
237
|
+
"sublime": ("sublime-text", "snap"),
|
|
238
|
+
"atom": ("atom", "snap"),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
# Map applications to their config/data directories for complete cloning
|
|
242
|
+
# These directories contain user settings, extensions, profiles, credentials
|
|
243
|
+
APP_DATA_DIRS = {
|
|
244
|
+
# Browsers - profiles, extensions, bookmarks, passwords
|
|
245
|
+
"chrome": [".config/google-chrome", ".config/chromium"],
|
|
246
|
+
"chromium": [".config/chromium"],
|
|
247
|
+
"firefox": [".mozilla/firefox", ".cache/mozilla/firefox"],
|
|
248
|
+
|
|
249
|
+
# IDEs and editors - settings, extensions, projects history
|
|
250
|
+
"code": [".config/Code", ".vscode", ".vscode-server"],
|
|
251
|
+
"vscode": [".config/Code", ".vscode", ".vscode-server"],
|
|
252
|
+
"pycharm": [".config/JetBrains", ".local/share/JetBrains", ".cache/JetBrains"],
|
|
253
|
+
"idea": [".config/JetBrains", ".local/share/JetBrains"],
|
|
254
|
+
"webstorm": [".config/JetBrains", ".local/share/JetBrains"],
|
|
255
|
+
"goland": [".config/JetBrains", ".local/share/JetBrains"],
|
|
256
|
+
"sublime": [".config/sublime-text", ".config/sublime-text-3"],
|
|
257
|
+
"atom": [".atom"],
|
|
258
|
+
"vim": [".vim", ".vimrc", ".config/nvim"],
|
|
259
|
+
"nvim": [".config/nvim", ".local/share/nvim"],
|
|
260
|
+
"emacs": [".emacs.d", ".emacs"],
|
|
261
|
+
"cursor": [".config/Cursor", ".cursor"],
|
|
262
|
+
|
|
263
|
+
# Development tools
|
|
264
|
+
"docker": [".docker"],
|
|
265
|
+
"git": [".gitconfig", ".git-credentials", ".config/git"],
|
|
266
|
+
"npm": [".npm", ".npmrc"],
|
|
267
|
+
"yarn": [".yarn", ".yarnrc"],
|
|
268
|
+
"pip": [".pip", ".config/pip"],
|
|
269
|
+
"cargo": [".cargo"],
|
|
270
|
+
"rustup": [".rustup"],
|
|
271
|
+
"go": [".go", "go"],
|
|
272
|
+
"gradle": [".gradle"],
|
|
273
|
+
"maven": [".m2"],
|
|
274
|
+
|
|
275
|
+
# Python environments
|
|
276
|
+
"python": [".pyenv", ".virtualenvs", ".local/share/virtualenvs"],
|
|
277
|
+
"python3": [".pyenv", ".virtualenvs", ".local/share/virtualenvs"],
|
|
278
|
+
"conda": [".conda", "anaconda3", "miniconda3"],
|
|
279
|
+
|
|
280
|
+
# Node.js
|
|
281
|
+
"node": [".nvm", ".node", ".npm"],
|
|
282
|
+
|
|
283
|
+
# Databases
|
|
284
|
+
"postgres": [".pgpass", ".psqlrc", ".psql_history"],
|
|
285
|
+
"mysql": [".my.cnf", ".mysql_history"],
|
|
286
|
+
"mongodb": [".mongorc.js", ".dbshell"],
|
|
287
|
+
"redis": [".rediscli_history"],
|
|
288
|
+
|
|
289
|
+
# Communication apps
|
|
290
|
+
"slack": [".config/Slack"],
|
|
291
|
+
"discord": [".config/discord"],
|
|
292
|
+
"telegram": [".local/share/TelegramDesktop"],
|
|
293
|
+
"teams": [".config/Microsoft/Microsoft Teams"],
|
|
294
|
+
|
|
295
|
+
# Other tools
|
|
296
|
+
"postman": [".config/Postman"],
|
|
297
|
+
"insomnia": [".config/Insomnia"],
|
|
298
|
+
"dbeaver": [".local/share/DBeaverData"],
|
|
299
|
+
"ssh": [".ssh"],
|
|
300
|
+
"gpg": [".gnupg"],
|
|
301
|
+
"aws": [".aws"],
|
|
302
|
+
"gcloud": [".config/gcloud"],
|
|
303
|
+
"kubectl": [".kube"],
|
|
304
|
+
"terraform": [".terraform.d"],
|
|
305
|
+
"ansible": [".ansible"],
|
|
306
|
+
|
|
307
|
+
# General app data
|
|
308
|
+
"spotify": [".config/spotify"],
|
|
309
|
+
"vlc": [".config/vlc"],
|
|
310
|
+
"gimp": [".config/GIMP", ".gimp-2.10"],
|
|
311
|
+
"obs": [".config/obs-studio"],
|
|
312
|
+
}
|
|
313
|
+
|
|
149
314
|
def __init__(self):
|
|
150
315
|
self.user = pwd.getpwuid(os.getuid()).pw_name
|
|
151
316
|
self.home = Path.home()
|
|
152
317
|
|
|
318
|
+
def detect_app_data_dirs(self, applications: list) -> list:
|
|
319
|
+
"""Detect config/data directories for running applications.
|
|
320
|
+
|
|
321
|
+
Returns list of paths that contain user data needed by running apps.
|
|
322
|
+
"""
|
|
323
|
+
app_data_paths = []
|
|
324
|
+
seen_paths = set()
|
|
325
|
+
|
|
326
|
+
for app in applications:
|
|
327
|
+
app_name = app.name.lower()
|
|
328
|
+
|
|
329
|
+
# Check each known app pattern
|
|
330
|
+
for pattern, dirs in self.APP_DATA_DIRS.items():
|
|
331
|
+
if pattern in app_name:
|
|
332
|
+
for dir_name in dirs:
|
|
333
|
+
full_path = self.home / dir_name
|
|
334
|
+
if full_path.exists() and str(full_path) not in seen_paths:
|
|
335
|
+
seen_paths.add(str(full_path))
|
|
336
|
+
# Calculate size
|
|
337
|
+
try:
|
|
338
|
+
size = self._get_dir_size(full_path, max_depth=2)
|
|
339
|
+
except:
|
|
340
|
+
size = 0
|
|
341
|
+
app_data_paths.append({
|
|
342
|
+
"path": str(full_path),
|
|
343
|
+
"app": app.name,
|
|
344
|
+
"type": "app_data",
|
|
345
|
+
"size_mb": round(size / 1024 / 1024, 1)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
return app_data_paths
|
|
349
|
+
|
|
153
350
|
def detect_all(self) -> SystemSnapshot:
|
|
154
351
|
"""Detect all services, applications and paths."""
|
|
155
352
|
return SystemSnapshot(
|
|
@@ -390,6 +587,54 @@ class SystemDetector:
|
|
|
390
587
|
pass
|
|
391
588
|
return containers
|
|
392
589
|
|
|
590
|
+
def suggest_packages_for_apps(self, applications: list) -> dict:
|
|
591
|
+
"""Suggest packages based on detected applications.
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
dict with 'apt' and 'snap' keys containing lists of packages
|
|
595
|
+
"""
|
|
596
|
+
apt_packages = set()
|
|
597
|
+
snap_packages = set()
|
|
598
|
+
|
|
599
|
+
for app in applications:
|
|
600
|
+
app_name = app.name.lower()
|
|
601
|
+
for key, (package, install_type) in self.APP_TO_PACKAGE_MAP.items():
|
|
602
|
+
if key in app_name:
|
|
603
|
+
if install_type == "snap":
|
|
604
|
+
snap_packages.add(package)
|
|
605
|
+
else:
|
|
606
|
+
apt_packages.add(package)
|
|
607
|
+
break
|
|
608
|
+
|
|
609
|
+
return {
|
|
610
|
+
"apt": sorted(list(apt_packages)),
|
|
611
|
+
"snap": sorted(list(snap_packages))
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
def suggest_packages_for_services(self, services: list) -> dict:
|
|
615
|
+
"""Suggest packages based on detected services.
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
dict with 'apt' and 'snap' keys containing lists of packages
|
|
619
|
+
"""
|
|
620
|
+
apt_packages = set()
|
|
621
|
+
snap_packages = set()
|
|
622
|
+
|
|
623
|
+
for service in services:
|
|
624
|
+
service_name = service.name.lower()
|
|
625
|
+
for key, (package, install_type) in self.APP_TO_PACKAGE_MAP.items():
|
|
626
|
+
if key in service_name:
|
|
627
|
+
if install_type == "snap":
|
|
628
|
+
snap_packages.add(package)
|
|
629
|
+
else:
|
|
630
|
+
apt_packages.add(package)
|
|
631
|
+
break
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
"apt": sorted(list(apt_packages)),
|
|
635
|
+
"snap": sorted(list(snap_packages))
|
|
636
|
+
}
|
|
637
|
+
|
|
393
638
|
def get_system_info(self) -> dict:
|
|
394
639
|
"""Get basic system information."""
|
|
395
640
|
return {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clonebox
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
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
|
|
@@ -38,6 +38,7 @@ Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
|
38
38
|
Dynamic: license-file
|
|
39
39
|
|
|
40
40
|
# CloneBox 📦
|
|
41
|
+

|
|
41
42
|
|
|
42
43
|
```commandline
|
|
43
44
|
╔═══════════════════════════════════════════════════════╗
|
|
@@ -62,6 +63,7 @@ CloneBox lets you create isolated virtual machines with only the applications, d
|
|
|
62
63
|
- ☁️ **Cloud-init** - Automatic package installation and service setup
|
|
63
64
|
- 🖥️ **GUI support** - SPICE graphics with virt-viewer integration
|
|
64
65
|
- ⚡ **Fast creation** - No full disk cloning, VMs are ready in seconds
|
|
66
|
+
- 📥 **Auto-download** - Automatically downloads and caches Ubuntu cloud images (stored in ~/Downloads)
|
|
65
67
|
|
|
66
68
|
## Installation
|
|
67
69
|
|
|
@@ -139,6 +141,8 @@ Simply run `clonebox` to start the interactive wizard:
|
|
|
139
141
|
|
|
140
142
|
```bash
|
|
141
143
|
clonebox
|
|
144
|
+
clonebox clone . --user --run --replace --base-image ~/ubuntu-22.04-cloud.qcow2
|
|
145
|
+
|
|
142
146
|
```
|
|
143
147
|
|
|
144
148
|
The wizard will:
|
|
@@ -259,6 +263,7 @@ The fastest way to clone your current working directory:
|
|
|
259
263
|
|
|
260
264
|
```bash
|
|
261
265
|
# Clone current directory - generates .clonebox.yaml and asks to create VM
|
|
266
|
+
# Base OS image is automatically downloaded to ~/Downloads on first run
|
|
262
267
|
clonebox clone .
|
|
263
268
|
|
|
264
269
|
# Clone specific path
|
|
@@ -269,6 +274,15 @@ clonebox clone ~/projects/my-app --name my-dev-vm --run
|
|
|
269
274
|
|
|
270
275
|
# Clone and edit config before creating
|
|
271
276
|
clonebox clone . --edit
|
|
277
|
+
|
|
278
|
+
# Replace existing VM (stops, deletes, and recreates)
|
|
279
|
+
clonebox clone . --replace
|
|
280
|
+
|
|
281
|
+
# Use custom base image instead of auto-download
|
|
282
|
+
clonebox clone . --base-image ~/ubuntu-22.04-cloud.qcow2
|
|
283
|
+
|
|
284
|
+
# User session mode (no root required)
|
|
285
|
+
clonebox clone . --user
|
|
272
286
|
```
|
|
273
287
|
|
|
274
288
|
Later, start the VM from any directory with `.clonebox.yaml`:
|
|
@@ -291,6 +305,63 @@ clonebox detect --yaml --dedupe
|
|
|
291
305
|
clonebox detect --yaml --dedupe -o my-config.yaml
|
|
292
306
|
```
|
|
293
307
|
|
|
308
|
+
### Base Images
|
|
309
|
+
|
|
310
|
+
CloneBox automatically downloads a bootable Ubuntu cloud image on first run:
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
# Auto-download (default) - downloads Ubuntu 22.04 to ~/Downloads on first run
|
|
314
|
+
clonebox clone .
|
|
315
|
+
|
|
316
|
+
# Use custom base image
|
|
317
|
+
clonebox clone . --base-image ~/my-custom-image.qcow2
|
|
318
|
+
|
|
319
|
+
# Manual download (optional - clonebox does this automatically)
|
|
320
|
+
wget -O ~/Downloads/clonebox-ubuntu-jammy-amd64.qcow2 \
|
|
321
|
+
https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
**Base image behavior:**
|
|
325
|
+
- If no `--base-image` is specified, Ubuntu 22.04 cloud image is auto-downloaded
|
|
326
|
+
- Downloaded images are cached in `~/Downloads/clonebox-ubuntu-jammy-amd64.qcow2`
|
|
327
|
+
- Subsequent VMs reuse the cached image (no re-download)
|
|
328
|
+
- Each VM gets its own disk using the base image as a backing file (copy-on-write)
|
|
329
|
+
|
|
330
|
+
### VM Login Credentials
|
|
331
|
+
|
|
332
|
+
VM credentials are managed through `.env` file for security:
|
|
333
|
+
|
|
334
|
+
**Setup:**
|
|
335
|
+
1. Copy `.env.example` to `.env`:
|
|
336
|
+
```bash
|
|
337
|
+
cp .env.example .env
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
2. Edit `.env` and set your password:
|
|
341
|
+
```bash
|
|
342
|
+
# .env file
|
|
343
|
+
VM_PASSWORD=your_secure_password
|
|
344
|
+
VM_USERNAME=ubuntu
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
3. The `.clonebox.yaml` file references the password from `.env`:
|
|
348
|
+
```yaml
|
|
349
|
+
vm:
|
|
350
|
+
username: ubuntu
|
|
351
|
+
password: ${VM_PASSWORD} # Loaded from .env
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
**Default credentials (if .env not configured):**
|
|
355
|
+
- **Username:** `ubuntu`
|
|
356
|
+
- **Password:** `ubuntu`
|
|
357
|
+
|
|
358
|
+
**Security notes:**
|
|
359
|
+
- `.env` is automatically gitignored (never committed)
|
|
360
|
+
- Username is stored in YAML (not sensitive)
|
|
361
|
+
- Password is stored in `.env` (sensitive, not committed)
|
|
362
|
+
- Change password after first login: `passwd`
|
|
363
|
+
- User has passwordless sudo access
|
|
364
|
+
|
|
294
365
|
### User Session & Networking
|
|
295
366
|
|
|
296
367
|
CloneBox supports creating VMs in user session (no root required) with automatic network fallback:
|
|
@@ -322,7 +393,9 @@ clonebox clone . --network auto
|
|
|
322
393
|
| `clonebox clone <path>` | Generate `.clonebox.yaml` from path + running processes |
|
|
323
394
|
| `clonebox clone . --run` | Clone and immediately start VM |
|
|
324
395
|
| `clonebox clone . --edit` | Clone, edit config, then create |
|
|
396
|
+
| `clonebox clone . --replace` | Replace existing VM (stop, delete, recreate) |
|
|
325
397
|
| `clonebox clone . --user` | Clone in user session (no root) |
|
|
398
|
+
| `clonebox clone . --base-image <path>` | Use custom base image |
|
|
326
399
|
| `clonebox clone . --network user` | Use user-mode networking (slirp) |
|
|
327
400
|
| `clonebox clone . --network auto` | Auto-detect network mode (default) |
|
|
328
401
|
| `clonebox start .` | Start VM from `.clonebox.yaml` in current dir |
|
|
@@ -377,18 +450,21 @@ sudo usermod -aG kvm $USER
|
|
|
377
450
|
|
|
378
451
|
### VM Already Exists
|
|
379
452
|
|
|
380
|
-
If you get "
|
|
453
|
+
If you get "VM already exists" error:
|
|
381
454
|
|
|
382
455
|
```bash
|
|
383
|
-
#
|
|
384
|
-
clonebox
|
|
456
|
+
# Option 1: Use --replace flag to automatically replace it
|
|
457
|
+
clonebox clone . --replace
|
|
385
458
|
|
|
386
|
-
#
|
|
459
|
+
# Option 2: Delete manually first
|
|
387
460
|
clonebox delete <vm-name>
|
|
388
461
|
|
|
389
|
-
#
|
|
462
|
+
# Option 3: Use virsh directly
|
|
390
463
|
virsh --connect qemu:///session destroy <vm-name>
|
|
391
464
|
virsh --connect qemu:///session undefine <vm-name>
|
|
465
|
+
|
|
466
|
+
# Option 4: Start the existing VM instead
|
|
467
|
+
clonebox start <vm-name>
|
|
392
468
|
```
|
|
393
469
|
|
|
394
470
|
### virt-viewer not found
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
clonebox/__init__.py,sha256=IOk7G0DiSQ33EGbFC0xbnnFB9aou_6yuyFxvycQEvA0,407
|
|
2
|
+
clonebox/__main__.py,sha256=Fcoyzwwyz5-eC_sBlQk5a5RbKx8uodQz5sKJ190U0NU,135
|
|
3
|
+
clonebox/cli.py,sha256=ngC6Pwbfr1brFkf_0ODlFzDikojTOkA3Yw-3iY4vrmY,41441
|
|
4
|
+
clonebox/cloner.py,sha256=eDIxORCtnqG9mFKJl4OmW9F6tkEJp7dENYX-2x1Favg,26453
|
|
5
|
+
clonebox/detector.py,sha256=4fu04Ty6KC82WkcJZ5UL5TqXpWYE7Kb7R0uJ-9dtbCk,21635
|
|
6
|
+
clonebox-0.1.7.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
7
|
+
clonebox-0.1.7.dist-info/METADATA,sha256=wsaV7GyZ6zfLCxLuNoSopr4XUHdyTlrgxxJMiHuFnKU,15582
|
|
8
|
+
clonebox-0.1.7.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
9
|
+
clonebox-0.1.7.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
|
|
10
|
+
clonebox-0.1.7.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
|
|
11
|
+
clonebox-0.1.7.dist-info/RECORD,,
|
clonebox-0.1.5.dist-info/RECORD
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
clonebox/__init__.py,sha256=IOk7G0DiSQ33EGbFC0xbnnFB9aou_6yuyFxvycQEvA0,407
|
|
2
|
-
clonebox/__main__.py,sha256=Fcoyzwwyz5-eC_sBlQk5a5RbKx8uodQz5sKJ190U0NU,135
|
|
3
|
-
clonebox/cli.py,sha256=TROvvv2BtUqKp5osXVUBvmPAdYTeTf0M-e69YaYQp78,32700
|
|
4
|
-
clonebox/cloner.py,sha256=Uoh9mCUX-3p2tFL_3qlf2R2232JCXO5YhWrgKTpEr0s,19369
|
|
5
|
-
clonebox/detector.py,sha256=jkzENmi4720n5e04k6gM7MNvXbQdYX-z1_O3Id0WK9w,12505
|
|
6
|
-
clonebox-0.1.5.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
7
|
-
clonebox-0.1.5.dist-info/METADATA,sha256=WWS_bye_xkDuWeJSfOMk7BDaDcL1rfE4RtZiKNlO1nA,13126
|
|
8
|
-
clonebox-0.1.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
9
|
-
clonebox-0.1.5.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
|
|
10
|
-
clonebox-0.1.5.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
|
|
11
|
-
clonebox-0.1.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|