clonebox 0.1.5__tar.gz → 0.1.6__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clonebox
3
- Version: 0.1.5
3
+ Version: 0.1.6
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
@@ -62,6 +62,7 @@ CloneBox lets you create isolated virtual machines with only the applications, d
62
62
  - ☁️ **Cloud-init** - Automatic package installation and service setup
63
63
  - 🖥️ **GUI support** - SPICE graphics with virt-viewer integration
64
64
  - ⚡ **Fast creation** - No full disk cloning, VMs are ready in seconds
65
+ - 📥 **Auto-download** - Automatically downloads and caches Ubuntu cloud images (stored in ~/Downloads)
65
66
 
66
67
  ## Installation
67
68
 
@@ -139,6 +140,8 @@ Simply run `clonebox` to start the interactive wizard:
139
140
 
140
141
  ```bash
141
142
  clonebox
143
+ clonebox clone . --user --run --replace --base-image ~/ubuntu-22.04-cloud.qcow2
144
+
142
145
  ```
143
146
 
144
147
  The wizard will:
@@ -259,6 +262,7 @@ The fastest way to clone your current working directory:
259
262
 
260
263
  ```bash
261
264
  # Clone current directory - generates .clonebox.yaml and asks to create VM
265
+ # Base OS image is automatically downloaded to ~/Downloads on first run
262
266
  clonebox clone .
263
267
 
264
268
  # Clone specific path
@@ -269,6 +273,15 @@ clonebox clone ~/projects/my-app --name my-dev-vm --run
269
273
 
270
274
  # Clone and edit config before creating
271
275
  clonebox clone . --edit
276
+
277
+ # Replace existing VM (stops, deletes, and recreates)
278
+ clonebox clone . --replace
279
+
280
+ # Use custom base image instead of auto-download
281
+ clonebox clone . --base-image ~/ubuntu-22.04-cloud.qcow2
282
+
283
+ # User session mode (no root required)
284
+ clonebox clone . --user
272
285
  ```
273
286
 
274
287
  Later, start the VM from any directory with `.clonebox.yaml`:
@@ -291,6 +304,63 @@ clonebox detect --yaml --dedupe
291
304
  clonebox detect --yaml --dedupe -o my-config.yaml
292
305
  ```
293
306
 
307
+ ### Base Images
308
+
309
+ CloneBox automatically downloads a bootable Ubuntu cloud image on first run:
310
+
311
+ ```bash
312
+ # Auto-download (default) - downloads Ubuntu 22.04 to ~/Downloads on first run
313
+ clonebox clone .
314
+
315
+ # Use custom base image
316
+ clonebox clone . --base-image ~/my-custom-image.qcow2
317
+
318
+ # Manual download (optional - clonebox does this automatically)
319
+ wget -O ~/Downloads/clonebox-ubuntu-jammy-amd64.qcow2 \
320
+ https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img
321
+ ```
322
+
323
+ **Base image behavior:**
324
+ - If no `--base-image` is specified, Ubuntu 22.04 cloud image is auto-downloaded
325
+ - Downloaded images are cached in `~/Downloads/clonebox-ubuntu-jammy-amd64.qcow2`
326
+ - Subsequent VMs reuse the cached image (no re-download)
327
+ - Each VM gets its own disk using the base image as a backing file (copy-on-write)
328
+
329
+ ### VM Login Credentials
330
+
331
+ VM credentials are managed through `.env` file for security:
332
+
333
+ **Setup:**
334
+ 1. Copy `.env.example` to `.env`:
335
+ ```bash
336
+ cp .env.example .env
337
+ ```
338
+
339
+ 2. Edit `.env` and set your password:
340
+ ```bash
341
+ # .env file
342
+ VM_PASSWORD=your_secure_password
343
+ VM_USERNAME=ubuntu
344
+ ```
345
+
346
+ 3. The `.clonebox.yaml` file references the password from `.env`:
347
+ ```yaml
348
+ vm:
349
+ username: ubuntu
350
+ password: ${VM_PASSWORD} # Loaded from .env
351
+ ```
352
+
353
+ **Default credentials (if .env not configured):**
354
+ - **Username:** `ubuntu`
355
+ - **Password:** `ubuntu`
356
+
357
+ **Security notes:**
358
+ - `.env` is automatically gitignored (never committed)
359
+ - Username is stored in YAML (not sensitive)
360
+ - Password is stored in `.env` (sensitive, not committed)
361
+ - Change password after first login: `passwd`
362
+ - User has passwordless sudo access
363
+
294
364
  ### User Session & Networking
295
365
 
296
366
  CloneBox supports creating VMs in user session (no root required) with automatic network fallback:
@@ -322,7 +392,9 @@ clonebox clone . --network auto
322
392
  | `clonebox clone <path>` | Generate `.clonebox.yaml` from path + running processes |
323
393
  | `clonebox clone . --run` | Clone and immediately start VM |
324
394
  | `clonebox clone . --edit` | Clone, edit config, then create |
395
+ | `clonebox clone . --replace` | Replace existing VM (stop, delete, recreate) |
325
396
  | `clonebox clone . --user` | Clone in user session (no root) |
397
+ | `clonebox clone . --base-image <path>` | Use custom base image |
326
398
  | `clonebox clone . --network user` | Use user-mode networking (slirp) |
327
399
  | `clonebox clone . --network auto` | Auto-detect network mode (default) |
328
400
  | `clonebox start .` | Start VM from `.clonebox.yaml` in current dir |
@@ -377,18 +449,21 @@ sudo usermod -aG kvm $USER
377
449
 
378
450
  ### VM Already Exists
379
451
 
380
- If you get "domain already exists" error:
452
+ If you get "VM already exists" error:
381
453
 
382
454
  ```bash
383
- # List VMs
384
- clonebox list
455
+ # Option 1: Use --replace flag to automatically replace it
456
+ clonebox clone . --replace
385
457
 
386
- # Stop and delete the existing VM
458
+ # Option 2: Delete manually first
387
459
  clonebox delete <vm-name>
388
460
 
389
- # Or use virsh directly
461
+ # Option 3: Use virsh directly
390
462
  virsh --connect qemu:///session destroy <vm-name>
391
463
  virsh --connect qemu:///session undefine <vm-name>
464
+
465
+ # Option 4: Start the existing VM instead
466
+ clonebox start <vm-name>
392
467
  ```
393
468
 
394
469
  ### virt-viewer not found
@@ -23,6 +23,7 @@ CloneBox lets you create isolated virtual machines with only the applications, d
23
23
  - ☁️ **Cloud-init** - Automatic package installation and service setup
24
24
  - 🖥️ **GUI support** - SPICE graphics with virt-viewer integration
25
25
  - ⚡ **Fast creation** - No full disk cloning, VMs are ready in seconds
26
+ - 📥 **Auto-download** - Automatically downloads and caches Ubuntu cloud images (stored in ~/Downloads)
26
27
 
27
28
  ## Installation
28
29
 
@@ -100,6 +101,8 @@ Simply run `clonebox` to start the interactive wizard:
100
101
 
101
102
  ```bash
102
103
  clonebox
104
+ clonebox clone . --user --run --replace --base-image ~/ubuntu-22.04-cloud.qcow2
105
+
103
106
  ```
104
107
 
105
108
  The wizard will:
@@ -220,6 +223,7 @@ The fastest way to clone your current working directory:
220
223
 
221
224
  ```bash
222
225
  # Clone current directory - generates .clonebox.yaml and asks to create VM
226
+ # Base OS image is automatically downloaded to ~/Downloads on first run
223
227
  clonebox clone .
224
228
 
225
229
  # Clone specific path
@@ -230,6 +234,15 @@ clonebox clone ~/projects/my-app --name my-dev-vm --run
230
234
 
231
235
  # Clone and edit config before creating
232
236
  clonebox clone . --edit
237
+
238
+ # Replace existing VM (stops, deletes, and recreates)
239
+ clonebox clone . --replace
240
+
241
+ # Use custom base image instead of auto-download
242
+ clonebox clone . --base-image ~/ubuntu-22.04-cloud.qcow2
243
+
244
+ # User session mode (no root required)
245
+ clonebox clone . --user
233
246
  ```
234
247
 
235
248
  Later, start the VM from any directory with `.clonebox.yaml`:
@@ -252,6 +265,63 @@ clonebox detect --yaml --dedupe
252
265
  clonebox detect --yaml --dedupe -o my-config.yaml
253
266
  ```
254
267
 
268
+ ### Base Images
269
+
270
+ CloneBox automatically downloads a bootable Ubuntu cloud image on first run:
271
+
272
+ ```bash
273
+ # Auto-download (default) - downloads Ubuntu 22.04 to ~/Downloads on first run
274
+ clonebox clone .
275
+
276
+ # Use custom base image
277
+ clonebox clone . --base-image ~/my-custom-image.qcow2
278
+
279
+ # Manual download (optional - clonebox does this automatically)
280
+ wget -O ~/Downloads/clonebox-ubuntu-jammy-amd64.qcow2 \
281
+ https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img
282
+ ```
283
+
284
+ **Base image behavior:**
285
+ - If no `--base-image` is specified, Ubuntu 22.04 cloud image is auto-downloaded
286
+ - Downloaded images are cached in `~/Downloads/clonebox-ubuntu-jammy-amd64.qcow2`
287
+ - Subsequent VMs reuse the cached image (no re-download)
288
+ - Each VM gets its own disk using the base image as a backing file (copy-on-write)
289
+
290
+ ### VM Login Credentials
291
+
292
+ VM credentials are managed through `.env` file for security:
293
+
294
+ **Setup:**
295
+ 1. Copy `.env.example` to `.env`:
296
+ ```bash
297
+ cp .env.example .env
298
+ ```
299
+
300
+ 2. Edit `.env` and set your password:
301
+ ```bash
302
+ # .env file
303
+ VM_PASSWORD=your_secure_password
304
+ VM_USERNAME=ubuntu
305
+ ```
306
+
307
+ 3. The `.clonebox.yaml` file references the password from `.env`:
308
+ ```yaml
309
+ vm:
310
+ username: ubuntu
311
+ password: ${VM_PASSWORD} # Loaded from .env
312
+ ```
313
+
314
+ **Default credentials (if .env not configured):**
315
+ - **Username:** `ubuntu`
316
+ - **Password:** `ubuntu`
317
+
318
+ **Security notes:**
319
+ - `.env` is automatically gitignored (never committed)
320
+ - Username is stored in YAML (not sensitive)
321
+ - Password is stored in `.env` (sensitive, not committed)
322
+ - Change password after first login: `passwd`
323
+ - User has passwordless sudo access
324
+
255
325
  ### User Session & Networking
256
326
 
257
327
  CloneBox supports creating VMs in user session (no root required) with automatic network fallback:
@@ -283,7 +353,9 @@ clonebox clone . --network auto
283
353
  | `clonebox clone <path>` | Generate `.clonebox.yaml` from path + running processes |
284
354
  | `clonebox clone . --run` | Clone and immediately start VM |
285
355
  | `clonebox clone . --edit` | Clone, edit config, then create |
356
+ | `clonebox clone . --replace` | Replace existing VM (stop, delete, recreate) |
286
357
  | `clonebox clone . --user` | Clone in user session (no root) |
358
+ | `clonebox clone . --base-image <path>` | Use custom base image |
287
359
  | `clonebox clone . --network user` | Use user-mode networking (slirp) |
288
360
  | `clonebox clone . --network auto` | Auto-detect network mode (default) |
289
361
  | `clonebox start .` | Start VM from `.clonebox.yaml` in current dir |
@@ -338,18 +410,21 @@ sudo usermod -aG kvm $USER
338
410
 
339
411
  ### VM Already Exists
340
412
 
341
- If you get "domain already exists" error:
413
+ If you get "VM already exists" error:
342
414
 
343
415
  ```bash
344
- # List VMs
345
- clonebox list
416
+ # Option 1: Use --replace flag to automatically replace it
417
+ clonebox clone . --replace
346
418
 
347
- # Stop and delete the existing VM
419
+ # Option 2: Delete manually first
348
420
  clonebox delete <vm-name>
349
421
 
350
- # Or use virsh directly
422
+ # Option 3: Use virsh directly
351
423
  virsh --connect qemu:///session destroy <vm-name>
352
424
  virsh --connect qemu:///session undefine <vm-name>
425
+
426
+ # Option 4: Start the existing VM instead
427
+ clonebox start <vm-name>
353
428
  ```
354
429
 
355
430
  ### virt-viewer not found
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "clonebox"
7
- version = "0.1.5"
7
+ version = "0.1.6"
8
8
  description = "Clone your workstation environment to an isolated VM with selective apps, paths and services"
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -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()
@@ -566,6 +603,23 @@ def generate_clonebox_yaml(
566
603
  ram_mb = min(4096, int(sys_info["memory_available_gb"] * 1024 * 0.5))
567
604
  vcpus = max(2, sys_info["cpu_count"] // 2)
568
605
 
606
+ # Auto-detect packages from running applications and services
607
+ suggested_app_packages = detector.suggest_packages_for_apps(snapshot.applications)
608
+ suggested_service_packages = detector.suggest_packages_for_services(snapshot.running_services)
609
+
610
+ # Combine with base packages
611
+ base_packages = [
612
+ "build-essential",
613
+ "git",
614
+ "curl",
615
+ "vim",
616
+ ]
617
+
618
+ # Merge all packages and deduplicate
619
+ all_packages = base_packages + suggested_app_packages + suggested_service_packages
620
+ if deduplicate:
621
+ all_packages = deduplicate_list(all_packages)
622
+
569
623
  # Build config
570
624
  config = {
571
625
  "version": "1",
@@ -575,18 +629,13 @@ def generate_clonebox_yaml(
575
629
  "ram_mb": ram_mb,
576
630
  "vcpus": vcpus,
577
631
  "gui": True,
578
- "base_image": None,
632
+ "base_image": base_image,
579
633
  "network_mode": network_mode,
634
+ "username": "ubuntu",
635
+ "password": "${VM_PASSWORD}",
580
636
  },
581
637
  "services": services,
582
- "packages": [
583
- "build-essential",
584
- "git",
585
- "curl",
586
- "vim",
587
- "python3",
588
- "python3-pip",
589
- ],
638
+ "packages": all_packages,
590
639
  "paths": paths_mapping,
591
640
  "detected": {
592
641
  "running_apps": [
@@ -605,17 +654,115 @@ def generate_clonebox_yaml(
605
654
 
606
655
 
607
656
  def load_clonebox_config(path: Path) -> dict:
608
- """Load .clonebox.yaml config file."""
657
+ """Load .clonebox.yaml config file and expand environment variables from .env."""
609
658
  config_file = path / CLONEBOX_CONFIG_FILE if path.is_dir() else path
610
659
 
611
660
  if not config_file.exists():
612
661
  raise FileNotFoundError(f"Config file not found: {config_file}")
613
662
 
614
- with open(config_file) as f:
615
- return yaml.safe_load(f)
616
-
663
+ # Load .env file from same directory
664
+ config_dir = config_file.parent
665
+ env_file = config_dir / CLONEBOX_ENV_FILE
666
+ env_vars = load_env_file(env_file)
617
667
 
618
- def create_vm_from_config(config: dict, start: bool = False, user_session: bool = False) -> str:
668
+ # Load YAML config
669
+ with open(config_file) as f:
670
+ config = yaml.safe_load(f)
671
+
672
+ # Expand environment variables in config
673
+ config = expand_env_vars(config, env_vars)
674
+
675
+ return config
676
+
677
+
678
+ def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout: int = 600):
679
+ """Monitor cloud-init status in VM and show progress."""
680
+ import subprocess
681
+ import time
682
+
683
+ conn_uri = "qemu:///session" if user_session else "qemu:///system"
684
+ start_time = time.time()
685
+ shutdown_count = 0 # Count consecutive shutdown detections
686
+ restart_detected = False
687
+
688
+ with Progress(
689
+ SpinnerColumn(),
690
+ TextColumn("[progress.description]{task.description}"),
691
+ console=console,
692
+ ) as progress:
693
+ task = progress.add_task("[cyan]Starting VM and initializing...", total=None)
694
+
695
+ while time.time() - start_time < timeout:
696
+ try:
697
+ elapsed = int(time.time() - start_time)
698
+ minutes = elapsed // 60
699
+ seconds = elapsed % 60
700
+
701
+ # Check VM state
702
+ result = subprocess.run(
703
+ ["virsh", "--connect", conn_uri, "domstate", vm_name],
704
+ capture_output=True,
705
+ text=True,
706
+ timeout=5
707
+ )
708
+
709
+ vm_state = result.stdout.strip().lower()
710
+
711
+ if "shut off" in vm_state or "shutting down" in vm_state:
712
+ # VM is shutting down - count consecutive detections
713
+ shutdown_count += 1
714
+ if shutdown_count >= 3 and not restart_detected:
715
+ # Confirmed shutdown after 3 consecutive checks
716
+ restart_detected = True
717
+ progress.update(task, description="[yellow]⟳ VM restarting after package installation...")
718
+ time.sleep(3)
719
+ continue
720
+ else:
721
+ # VM is running - reset shutdown counter
722
+ if shutdown_count > 0 and shutdown_count < 3:
723
+ # Was a brief glitch, not a real shutdown
724
+ shutdown_count = 0
725
+
726
+ if restart_detected and "running" in vm_state and shutdown_count >= 3:
727
+ # VM restarted successfully - GUI should be ready
728
+ progress.update(task, description=f"[green]✓ GUI ready! Total time: {minutes}m {seconds}s")
729
+ time.sleep(2)
730
+ break
731
+
732
+ # Estimate remaining time
733
+ if elapsed < 60:
734
+ remaining = "~9-10 minutes"
735
+ elif elapsed < 180:
736
+ remaining = f"~{8 - minutes} minutes"
737
+ elif elapsed < 300:
738
+ remaining = f"~{6 - minutes} minutes"
739
+ else:
740
+ remaining = "finishing soon"
741
+
742
+ if restart_detected:
743
+ progress.update(task, description=f"[cyan]Starting GUI... ({minutes}m {seconds}s, {remaining})")
744
+ else:
745
+ progress.update(task, description=f"[cyan]Installing desktop packages... ({minutes}m {seconds}s, {remaining})")
746
+
747
+ except (subprocess.TimeoutExpired, Exception) as e:
748
+ elapsed = int(time.time() - start_time)
749
+ minutes = elapsed // 60
750
+ seconds = elapsed % 60
751
+ progress.update(task, description=f"[cyan]Configuring VM... ({minutes}m {seconds}s)")
752
+
753
+ time.sleep(3)
754
+
755
+ # Final status
756
+ if time.time() - start_time >= timeout:
757
+ progress.update(task, description="[yellow]⚠ Monitoring timeout - VM continues in background")
758
+
759
+
760
+ def create_vm_from_config(
761
+ config: dict,
762
+ start: bool = False,
763
+ user_session: bool = False,
764
+ replace: bool = False,
765
+ ) -> str:
619
766
  """Create VM from YAML config dict."""
620
767
  vm_config = VMConfig(
621
768
  name=config["vm"]["name"],
@@ -628,6 +775,8 @@ def create_vm_from_config(config: dict, start: bool = False, user_session: bool
628
775
  services=config.get("services", []),
629
776
  user_session=user_session,
630
777
  network_mode=config["vm"].get("network_mode", "auto"),
778
+ username=config["vm"].get("username", "ubuntu"),
779
+ password=config["vm"].get("password", "ubuntu"),
631
780
  )
632
781
 
633
782
  cloner = SelectiveVMCloner(user_session=user_session)
@@ -643,10 +792,20 @@ def create_vm_from_config(config: dict, start: bool = False, user_session: bool
643
792
 
644
793
  console.print(f"[dim]Session: {checks['session_type']}, Storage: {checks['images_dir']}[/]")
645
794
 
646
- vm_uuid = cloner.create_vm(vm_config, console=console)
795
+ vm_uuid = cloner.create_vm(vm_config, console=console, replace=replace)
647
796
 
648
797
  if start:
649
798
  cloner.start_vm(vm_config.name, open_viewer=vm_config.gui, console=console)
799
+
800
+ # Monitor cloud-init progress if GUI is enabled
801
+ if vm_config.gui:
802
+ console.print("\n[bold cyan]📊 Monitoring setup progress...[/]")
803
+ try:
804
+ monitor_cloud_init_status(vm_config.name, user_session=user_session)
805
+ except KeyboardInterrupt:
806
+ console.print("\n[yellow]Monitoring stopped. VM continues setup in background.[/]")
807
+ except Exception as e:
808
+ console.print(f"\n[dim]Note: Could not monitor status ({e}). VM continues setup in background.[/]")
650
809
 
651
810
  return vm_uuid
652
811
 
@@ -681,6 +840,7 @@ def cmd_clone(args):
681
840
  target_path=str(target_path),
682
841
  vm_name=vm_name,
683
842
  network_mode=args.network,
843
+ base_image=getattr(args, "base_image", None),
684
844
  )
685
845
 
686
846
  # Save config file
@@ -712,7 +872,8 @@ def cmd_clone(args):
712
872
  ).ask()
713
873
 
714
874
  if create_now:
715
- config = yaml.safe_load(yaml_content)
875
+ # Load config with environment variable expansion
876
+ config = load_clonebox_config(config_file.parent)
716
877
  user_session = getattr(args, "user", False)
717
878
 
718
879
  console.print("\n[bold cyan]🔧 Creating VM...[/]\n")
@@ -720,10 +881,26 @@ def cmd_clone(args):
720
881
  console.print("[cyan]Using user session (qemu:///session) - no root required[/]")
721
882
 
722
883
  try:
723
- vm_uuid = create_vm_from_config(config, start=True, user_session=user_session)
884
+ vm_uuid = create_vm_from_config(
885
+ config,
886
+ start=True,
887
+ user_session=user_session,
888
+ replace=getattr(args, "replace", False),
889
+ )
724
890
  console.print(f"\n[bold green]🎉 VM '{config['vm']['name']}' is running![/]")
725
891
  console.print(f"[dim]UUID: {vm_uuid}[/]")
726
892
 
893
+ # Show GUI startup info if GUI is enabled
894
+ if config.get("vm", {}).get("gui", False):
895
+ username = config['vm'].get('username', 'ubuntu')
896
+ password = config['vm'].get('password', 'ubuntu')
897
+ console.print("\n[bold yellow]⏰ GUI Setup Process:[/]")
898
+ console.print(" [yellow]•[/] Installing desktop environment (~5-10 minutes)")
899
+ console.print(" [yellow]•[/] Automatic restart after installation")
900
+ console.print(" [yellow]•[/] GUI login screen will appear")
901
+ console.print(f" [yellow]•[/] Login: [cyan]{username}[/] / [cyan]{'*' * len(password)}[/] (from .env)")
902
+ console.print("\n[dim]💡 Progress will be monitored automatically below[/]")
903
+
727
904
  # Show mount instructions
728
905
  if config.get("paths"):
729
906
  console.print("\n[bold]Inside VM, mount paths with:[/]")
@@ -939,6 +1116,15 @@ def main():
939
1116
  default="auto",
940
1117
  help="Network mode: auto (default), default (libvirt network), user (slirp)",
941
1118
  )
1119
+ clone_parser.add_argument(
1120
+ "--base-image",
1121
+ help="Path to a bootable qcow2 image to use as a base disk",
1122
+ )
1123
+ clone_parser.add_argument(
1124
+ "--replace",
1125
+ action="store_true",
1126
+ help="If VM already exists, stop+undefine it and recreate (also deletes its storage)",
1127
+ )
942
1128
  clone_parser.set_defaults(func=cmd_clone)
943
1129
 
944
1130
  args = parser.parse_args()
@@ -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
@@ -32,6 +34,8 @@ class VMConfig:
32
34
  services: list = field(default_factory=list)
33
35
  user_session: bool = False # Use qemu:///session instead of qemu:///system
34
36
  network_mode: str = "auto" # auto|default|user
37
+ username: str = "ubuntu" # VM default username
38
+ password: str = "ubuntu" # VM default password
35
39
 
36
40
  def to_dict(self) -> dict:
37
41
  return {
@@ -51,6 +55,11 @@ class SelectiveVMCloner:
51
55
  SYSTEM_IMAGES_DIR = Path("/var/lib/libvirt/images")
52
56
  USER_IMAGES_DIR = Path.home() / ".local/share/libvirt/images"
53
57
 
58
+ DEFAULT_BASE_IMAGE_URL = (
59
+ "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img"
60
+ )
61
+ DEFAULT_BASE_IMAGE_FILENAME = "clonebox-ubuntu-jammy-amd64.qcow2"
62
+
54
63
  def __init__(self, conn_uri: str = None, user_session: bool = False):
55
64
  self.user_session = user_session
56
65
  if conn_uri:
@@ -91,6 +100,57 @@ class SelectiveVMCloner:
91
100
  return self.USER_IMAGES_DIR
92
101
  return self.SYSTEM_IMAGES_DIR
93
102
 
103
+ def _get_downloads_dir(self) -> Path:
104
+ return Path.home() / "Downloads"
105
+
106
+ def _ensure_default_base_image(self, console=None) -> Path:
107
+ def log(msg):
108
+ if console:
109
+ console.print(msg)
110
+ else:
111
+ print(msg)
112
+
113
+ downloads_dir = self._get_downloads_dir()
114
+ downloads_dir.mkdir(parents=True, exist_ok=True)
115
+ cached_path = downloads_dir / self.DEFAULT_BASE_IMAGE_FILENAME
116
+
117
+ if cached_path.exists() and cached_path.stat().st_size > 0:
118
+ return cached_path
119
+
120
+ log(
121
+ "[cyan]⬇️ Downloading base image (first run only). This will be cached in ~/Downloads...[/]"
122
+ )
123
+
124
+ try:
125
+ with tempfile.NamedTemporaryFile(
126
+ prefix=f"{self.DEFAULT_BASE_IMAGE_FILENAME}.",
127
+ dir=str(downloads_dir),
128
+ delete=False,
129
+ ) as tmp:
130
+ tmp_path = Path(tmp.name)
131
+
132
+ try:
133
+ urllib.request.urlretrieve(self.DEFAULT_BASE_IMAGE_URL, tmp_path)
134
+ tmp_path.replace(cached_path)
135
+ finally:
136
+ if tmp_path.exists() and tmp_path != cached_path:
137
+ try:
138
+ tmp_path.unlink()
139
+ except Exception:
140
+ pass
141
+ except Exception as e:
142
+ raise RuntimeError(
143
+ "Failed to download a default base image.\n\n"
144
+ "🔧 Solutions:\n"
145
+ " 1. Provide a base image explicitly:\n"
146
+ " clonebox clone . --base-image /path/to/image.qcow2\n"
147
+ " 2. Download it manually and reuse it:\n"
148
+ f" wget -O {cached_path} {self.DEFAULT_BASE_IMAGE_URL}\n\n"
149
+ f"Original error: {e}"
150
+ ) from e
151
+
152
+ return cached_path
153
+
94
154
  def _default_network_active(self) -> bool:
95
155
  """Check if libvirt default network is active."""
96
156
  try:
@@ -175,7 +235,7 @@ class SelectiveVMCloner:
175
235
 
176
236
  return checks
177
237
 
178
- def create_vm(self, config: VMConfig, console=None) -> str:
238
+ def create_vm(self, config: VMConfig, console=None, replace: bool = False) -> str:
179
239
  """
180
240
  Create a VM with only selected applications/paths.
181
241
 
@@ -193,6 +253,38 @@ class SelectiveVMCloner:
193
253
  else:
194
254
  print(msg)
195
255
 
256
+ # If VM already exists, optionally replace it
257
+ existing_vm = None
258
+ try:
259
+ candidate_vm = self.conn.lookupByName(config.name)
260
+ if candidate_vm is not None:
261
+ # libvirt returns a domain object whose .name() should match the requested name.
262
+ # In tests, an unconfigured MagicMock may be returned here; avoid treating that as
263
+ # a real existing domain unless we can confirm the name matches.
264
+ try:
265
+ if hasattr(candidate_vm, "name") and callable(candidate_vm.name):
266
+ if candidate_vm.name() == config.name:
267
+ existing_vm = candidate_vm
268
+ else:
269
+ existing_vm = candidate_vm
270
+ except Exception:
271
+ existing_vm = candidate_vm
272
+ except Exception:
273
+ existing_vm = None
274
+
275
+ if existing_vm is not None:
276
+ if not replace:
277
+ raise RuntimeError(
278
+ f"VM '{config.name}' already exists.\n\n"
279
+ f"🔧 Solutions:\n"
280
+ f" 1. Reuse existing VM: clonebox start {config.name}\n"
281
+ f" 2. Replace it: clonebox clone . --name {config.name} --replace\n"
282
+ f" 3. Delete it: clonebox delete {config.name}\n"
283
+ )
284
+
285
+ log(f"[yellow]⚠️ VM '{config.name}' already exists - replacing...[/]")
286
+ self.delete_vm(config.name, delete_storage=True, console=console, ignore_not_found=True)
287
+
196
288
  # Determine images directory
197
289
  images_dir = self.get_images_dir()
198
290
  vm_dir = images_dir / config.name
@@ -216,6 +308,9 @@ class SelectiveVMCloner:
216
308
  # Create root disk
217
309
  root_disk = vm_dir / "root.qcow2"
218
310
 
311
+ if not config.base_image:
312
+ config.base_image = str(self._ensure_default_base_image(console=console))
313
+
219
314
  if config.base_image and Path(config.base_image).exists():
220
315
  # Use backing file for faster creation
221
316
  log(f"[cyan]📀 Creating disk with backing file: {config.base_image}[/]")
@@ -258,7 +353,14 @@ class SelectiveVMCloner:
258
353
 
259
354
  # Define and create VM
260
355
  log(f"[cyan]🔧 Defining VM '{config.name}'...[/]")
261
- vm = self.conn.defineXML(vm_xml)
356
+ try:
357
+ vm = self.conn.defineXML(vm_xml)
358
+ except Exception as e:
359
+ raise RuntimeError(
360
+ f"Failed to define VM '{config.name}'.\n"
361
+ f"Error: {e}\n\n"
362
+ f"If the VM already exists, try: clonebox clone . --name {config.name} --replace\n"
363
+ ) from e
262
364
 
263
365
  log(f"[green]✅ VM '{config.name}' created successfully![/]")
264
366
  log(f"[dim] UUID: {vm.UUIDString()}[/]")
@@ -393,27 +495,78 @@ class SelectiveVMCloner:
393
495
  )
394
496
 
395
497
  # User-data
498
+ # Add desktop environment if GUI is enabled
499
+ base_packages = []
500
+ if config.gui:
501
+ base_packages.extend([
502
+ "ubuntu-desktop-minimal",
503
+ "firefox",
504
+ ])
505
+
506
+ all_packages = base_packages + list(config.packages)
396
507
  packages_yaml = (
397
- "\n".join(f" - {pkg}" for pkg in config.packages) if config.packages else ""
508
+ "\n".join(f" - {pkg}" for pkg in all_packages) if all_packages else ""
398
509
  )
399
- services_enable = (
400
- "\n".join(f" - systemctl enable --now {svc}" for svc in config.services)
401
- if config.services
402
- else ""
403
- )
404
- mounts_yaml = "\n".join(mount_commands) if mount_commands else ""
510
+
511
+ # Build runcmd - services and mounts
512
+ runcmd_lines = []
513
+
514
+ # Add service enablement
515
+ for svc in config.services:
516
+ runcmd_lines.append(f" - systemctl enable --now {svc} || true")
517
+
518
+ # Add mounts
519
+ for cmd in mount_commands:
520
+ runcmd_lines.append(cmd)
521
+
522
+ # Add GUI setup if enabled - runs AFTER package installation completes
523
+ if config.gui:
524
+ runcmd_lines.extend([
525
+ " - systemctl set-default graphical.target",
526
+ " - systemctl enable gdm3 || systemctl enable gdm || true",
527
+ ])
528
+
529
+ runcmd_lines.append(" - echo 'CloneBox VM ready!' > /var/log/clonebox-ready")
530
+
531
+ # Add reboot command at the end if GUI is enabled
532
+ if config.gui:
533
+ runcmd_lines.append(" - shutdown -r +1 'Rebooting to start GUI' || reboot")
534
+
535
+ runcmd_yaml = "\n".join(runcmd_lines) if runcmd_lines else ""
536
+
537
+ # Remove power_state - using shutdown -r instead
538
+ power_state_yaml = ""
405
539
 
406
540
  user_data = f"""#cloud-config
407
541
  hostname: {config.name}
408
542
  manage_etc_hosts: true
409
543
 
544
+ # Default user
545
+ users:
546
+ - name: {config.username}
547
+ sudo: ALL=(ALL) NOPASSWD:ALL
548
+ shell: /bin/bash
549
+ lock_passwd: false
550
+ groups: sudo,adm,dialout,cdrom,floppy,audio,dip,video,plugdev,netdev
551
+ plain_text_passwd: {config.password}
552
+
553
+ # Allow password authentication
554
+ ssh_pwauth: true
555
+ chpasswd:
556
+ expire: false
557
+
558
+ # Update package cache and upgrade
559
+ package_update: true
560
+ package_upgrade: false
561
+
562
+ # Install packages (cloud-init waits for completion before runcmd)
410
563
  packages:
411
564
  {packages_yaml}
412
565
 
566
+ # Run after packages are installed
413
567
  runcmd:
414
- {services_enable}
415
- {mounts_yaml}
416
- - echo "CloneBox VM ready!" > /var/log/clonebox-ready
568
+ {runcmd_yaml}
569
+ {power_state_yaml}
417
570
 
418
571
  final_message: "CloneBox VM is ready after $UPTIME seconds"
419
572
  """
@@ -451,6 +604,8 @@ final_message: "CloneBox VM is ready after $UPTIME seconds"
451
604
  try:
452
605
  vm = self.conn.lookupByName(vm_name)
453
606
  except libvirt.libvirtError:
607
+ if ignore_not_found:
608
+ return False
454
609
  log(f"[red]❌ VM '{vm_name}' not found[/]")
455
610
  return False
456
611
 
@@ -500,7 +655,13 @@ final_message: "CloneBox VM is ready after $UPTIME seconds"
500
655
  log("[green]✅ VM stopped![/]")
501
656
  return True
502
657
 
503
- def delete_vm(self, vm_name: str, delete_storage: bool = True, console=None) -> bool:
658
+ def delete_vm(
659
+ self,
660
+ vm_name: str,
661
+ delete_storage: bool = True,
662
+ console=None,
663
+ ignore_not_found: bool = False,
664
+ ) -> bool:
504
665
  """Delete a VM and optionally its storage."""
505
666
 
506
667
  def log(msg):
@@ -525,7 +686,7 @@ final_message: "CloneBox VM is ready after $UPTIME seconds"
525
686
 
526
687
  # Delete storage
527
688
  if delete_storage:
528
- vm_dir = Path(f"/var/lib/libvirt/images/{vm_name}")
689
+ vm_dir = self.get_images_dir() / vm_name
529
690
  if vm_dir.exists():
530
691
  import shutil
531
692
 
@@ -146,6 +146,53 @@ class SystemDetector:
146
146
  "screen",
147
147
  ]
148
148
 
149
+ # Map process/service names to Ubuntu packages
150
+ APP_TO_PACKAGE_MAP = {
151
+ "python": "python3",
152
+ "python3": "python3",
153
+ "pip": "python3-pip",
154
+ "node": "nodejs",
155
+ "npm": "npm",
156
+ "yarn": "yarnpkg",
157
+ "docker": "docker.io",
158
+ "dockerd": "docker.io",
159
+ "docker-compose": "docker-compose",
160
+ "podman": "podman",
161
+ "nginx": "nginx",
162
+ "apache2": "apache2",
163
+ "httpd": "apache2",
164
+ "postgres": "postgresql",
165
+ "postgresql": "postgresql",
166
+ "mysql": "mysql-server",
167
+ "mysqld": "mysql-server",
168
+ "mongod": "mongodb",
169
+ "mongodb": "mongodb",
170
+ "redis-server": "redis-server",
171
+ "redis": "redis-server",
172
+ "vim": "vim",
173
+ "nvim": "neovim",
174
+ "emacs": "emacs",
175
+ "firefox": "firefox",
176
+ "chromium": "chromium-browser",
177
+ "jupyter": "jupyter-notebook",
178
+ "jupyter-lab": "jupyterlab",
179
+ "gunicorn": "gunicorn",
180
+ "uvicorn": "uvicorn",
181
+ "tmux": "tmux",
182
+ "screen": "screen",
183
+ "git": "git",
184
+ "curl": "curl",
185
+ "wget": "wget",
186
+ "ssh": "openssh-client",
187
+ "sshd": "openssh-server",
188
+ "go": "golang",
189
+ "cargo": "cargo",
190
+ "rustc": "rustc",
191
+ "java": "default-jdk",
192
+ "gradle": "gradle",
193
+ "mvn": "maven",
194
+ }
195
+
149
196
  def __init__(self):
150
197
  self.user = pwd.getpwuid(os.getuid()).pw_name
151
198
  self.home = Path.home()
@@ -390,6 +437,30 @@ class SystemDetector:
390
437
  pass
391
438
  return containers
392
439
 
440
+ def suggest_packages_for_apps(self, applications: list) -> list:
441
+ """Suggest Ubuntu packages based on detected applications."""
442
+ packages = set()
443
+ for app in applications:
444
+ app_name = app.name.lower()
445
+ # Check if app name matches any known mapping
446
+ for key, package in self.APP_TO_PACKAGE_MAP.items():
447
+ if key in app_name:
448
+ packages.add(package)
449
+ break
450
+ return sorted(list(packages))
451
+
452
+ def suggest_packages_for_services(self, services: list) -> list:
453
+ """Suggest Ubuntu packages based on detected services."""
454
+ packages = set()
455
+ for service in services:
456
+ service_name = service.name.lower()
457
+ # Check if service name matches any known mapping
458
+ for key, package in self.APP_TO_PACKAGE_MAP.items():
459
+ if key in service_name:
460
+ packages.add(package)
461
+ break
462
+ return sorted(list(packages))
463
+
393
464
  def get_system_info(self) -> dict:
394
465
  """Get basic system information."""
395
466
  return {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clonebox
3
- Version: 0.1.5
3
+ Version: 0.1.6
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
@@ -62,6 +62,7 @@ CloneBox lets you create isolated virtual machines with only the applications, d
62
62
  - ☁️ **Cloud-init** - Automatic package installation and service setup
63
63
  - 🖥️ **GUI support** - SPICE graphics with virt-viewer integration
64
64
  - ⚡ **Fast creation** - No full disk cloning, VMs are ready in seconds
65
+ - 📥 **Auto-download** - Automatically downloads and caches Ubuntu cloud images (stored in ~/Downloads)
65
66
 
66
67
  ## Installation
67
68
 
@@ -139,6 +140,8 @@ Simply run `clonebox` to start the interactive wizard:
139
140
 
140
141
  ```bash
141
142
  clonebox
143
+ clonebox clone . --user --run --replace --base-image ~/ubuntu-22.04-cloud.qcow2
144
+
142
145
  ```
143
146
 
144
147
  The wizard will:
@@ -259,6 +262,7 @@ The fastest way to clone your current working directory:
259
262
 
260
263
  ```bash
261
264
  # Clone current directory - generates .clonebox.yaml and asks to create VM
265
+ # Base OS image is automatically downloaded to ~/Downloads on first run
262
266
  clonebox clone .
263
267
 
264
268
  # Clone specific path
@@ -269,6 +273,15 @@ clonebox clone ~/projects/my-app --name my-dev-vm --run
269
273
 
270
274
  # Clone and edit config before creating
271
275
  clonebox clone . --edit
276
+
277
+ # Replace existing VM (stops, deletes, and recreates)
278
+ clonebox clone . --replace
279
+
280
+ # Use custom base image instead of auto-download
281
+ clonebox clone . --base-image ~/ubuntu-22.04-cloud.qcow2
282
+
283
+ # User session mode (no root required)
284
+ clonebox clone . --user
272
285
  ```
273
286
 
274
287
  Later, start the VM from any directory with `.clonebox.yaml`:
@@ -291,6 +304,63 @@ clonebox detect --yaml --dedupe
291
304
  clonebox detect --yaml --dedupe -o my-config.yaml
292
305
  ```
293
306
 
307
+ ### Base Images
308
+
309
+ CloneBox automatically downloads a bootable Ubuntu cloud image on first run:
310
+
311
+ ```bash
312
+ # Auto-download (default) - downloads Ubuntu 22.04 to ~/Downloads on first run
313
+ clonebox clone .
314
+
315
+ # Use custom base image
316
+ clonebox clone . --base-image ~/my-custom-image.qcow2
317
+
318
+ # Manual download (optional - clonebox does this automatically)
319
+ wget -O ~/Downloads/clonebox-ubuntu-jammy-amd64.qcow2 \
320
+ https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img
321
+ ```
322
+
323
+ **Base image behavior:**
324
+ - If no `--base-image` is specified, Ubuntu 22.04 cloud image is auto-downloaded
325
+ - Downloaded images are cached in `~/Downloads/clonebox-ubuntu-jammy-amd64.qcow2`
326
+ - Subsequent VMs reuse the cached image (no re-download)
327
+ - Each VM gets its own disk using the base image as a backing file (copy-on-write)
328
+
329
+ ### VM Login Credentials
330
+
331
+ VM credentials are managed through `.env` file for security:
332
+
333
+ **Setup:**
334
+ 1. Copy `.env.example` to `.env`:
335
+ ```bash
336
+ cp .env.example .env
337
+ ```
338
+
339
+ 2. Edit `.env` and set your password:
340
+ ```bash
341
+ # .env file
342
+ VM_PASSWORD=your_secure_password
343
+ VM_USERNAME=ubuntu
344
+ ```
345
+
346
+ 3. The `.clonebox.yaml` file references the password from `.env`:
347
+ ```yaml
348
+ vm:
349
+ username: ubuntu
350
+ password: ${VM_PASSWORD} # Loaded from .env
351
+ ```
352
+
353
+ **Default credentials (if .env not configured):**
354
+ - **Username:** `ubuntu`
355
+ - **Password:** `ubuntu`
356
+
357
+ **Security notes:**
358
+ - `.env` is automatically gitignored (never committed)
359
+ - Username is stored in YAML (not sensitive)
360
+ - Password is stored in `.env` (sensitive, not committed)
361
+ - Change password after first login: `passwd`
362
+ - User has passwordless sudo access
363
+
294
364
  ### User Session & Networking
295
365
 
296
366
  CloneBox supports creating VMs in user session (no root required) with automatic network fallback:
@@ -322,7 +392,9 @@ clonebox clone . --network auto
322
392
  | `clonebox clone <path>` | Generate `.clonebox.yaml` from path + running processes |
323
393
  | `clonebox clone . --run` | Clone and immediately start VM |
324
394
  | `clonebox clone . --edit` | Clone, edit config, then create |
395
+ | `clonebox clone . --replace` | Replace existing VM (stop, delete, recreate) |
325
396
  | `clonebox clone . --user` | Clone in user session (no root) |
397
+ | `clonebox clone . --base-image <path>` | Use custom base image |
326
398
  | `clonebox clone . --network user` | Use user-mode networking (slirp) |
327
399
  | `clonebox clone . --network auto` | Auto-detect network mode (default) |
328
400
  | `clonebox start .` | Start VM from `.clonebox.yaml` in current dir |
@@ -377,18 +449,21 @@ sudo usermod -aG kvm $USER
377
449
 
378
450
  ### VM Already Exists
379
451
 
380
- If you get "domain already exists" error:
452
+ If you get "VM already exists" error:
381
453
 
382
454
  ```bash
383
- # List VMs
384
- clonebox list
455
+ # Option 1: Use --replace flag to automatically replace it
456
+ clonebox clone . --replace
385
457
 
386
- # Stop and delete the existing VM
458
+ # Option 2: Delete manually first
387
459
  clonebox delete <vm-name>
388
460
 
389
- # Or use virsh directly
461
+ # Option 3: Use virsh directly
390
462
  virsh --connect qemu:///session destroy <vm-name>
391
463
  virsh --connect qemu:///session undefine <vm-name>
464
+
465
+ # Option 4: Start the existing VM instead
466
+ clonebox start <vm-name>
392
467
  ```
393
468
 
394
469
  ### virt-viewer not found
@@ -151,6 +151,19 @@ class TestGenerateCloneboxYaml:
151
151
  assert "running_apps" in config["detected"]
152
152
  assert "all_paths" in config["detected"]
153
153
 
154
+ def test_generate_yaml_base_image(self):
155
+ snapshot = self.create_mock_snapshot()
156
+ detector = self.create_mock_detector()
157
+
158
+ yaml_str = generate_clonebox_yaml(
159
+ snapshot,
160
+ detector,
161
+ base_image="/images/base.qcow2",
162
+ )
163
+ config = yaml.safe_load(yaml_str)
164
+
165
+ assert config["vm"]["base_image"] == "/images/base.qcow2"
166
+
154
167
 
155
168
  class TestLoadCloneboxConfig:
156
169
  """Test loading .clonebox.yaml configs."""
@@ -241,6 +241,7 @@ class TestVMCreation:
241
241
  @patch("clonebox.cloner.libvirt")
242
242
  def test_create_vm_permission_error(self, mock_libvirt, mock_run):
243
243
  mock_conn = MagicMock()
244
+ mock_conn.lookupByName.side_effect = Exception("not found")
244
245
  mock_libvirt.open.return_value = mock_conn
245
246
 
246
247
  cloner = SelectiveVMCloner()
File without changes
File without changes
File without changes