clonebox 0.1.4__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.
- {clonebox-0.1.4 → clonebox-0.1.6}/PKG-INFO +81 -6
- {clonebox-0.1.4 → clonebox-0.1.6}/README.md +80 -5
- {clonebox-0.1.4 → clonebox-0.1.6}/pyproject.toml +1 -1
- {clonebox-0.1.4 → clonebox-0.1.6}/src/clonebox/cli.py +233 -23
- {clonebox-0.1.4 → clonebox-0.1.6}/src/clonebox/cloner.py +175 -14
- {clonebox-0.1.4 → clonebox-0.1.6}/src/clonebox/detector.py +71 -0
- {clonebox-0.1.4 → clonebox-0.1.6}/src/clonebox.egg-info/PKG-INFO +81 -6
- {clonebox-0.1.4 → clonebox-0.1.6}/tests/test_cli.py +13 -0
- {clonebox-0.1.4 → clonebox-0.1.6}/tests/test_cloner.py +1 -0
- {clonebox-0.1.4 → clonebox-0.1.6}/LICENSE +0 -0
- {clonebox-0.1.4 → clonebox-0.1.6}/setup.cfg +0 -0
- {clonebox-0.1.4 → clonebox-0.1.6}/src/clonebox/__init__.py +0 -0
- {clonebox-0.1.4 → clonebox-0.1.6}/src/clonebox/__main__.py +0 -0
- {clonebox-0.1.4 → clonebox-0.1.6}/src/clonebox.egg-info/SOURCES.txt +0 -0
- {clonebox-0.1.4 → clonebox-0.1.6}/src/clonebox.egg-info/dependency_links.txt +0 -0
- {clonebox-0.1.4 → clonebox-0.1.6}/src/clonebox.egg-info/entry_points.txt +0 -0
- {clonebox-0.1.4 → clonebox-0.1.6}/src/clonebox.egg-info/requires.txt +0 -0
- {clonebox-0.1.4 → clonebox-0.1.6}/src/clonebox.egg-info/top_level.txt +0 -0
- {clonebox-0.1.4 → clonebox-0.1.6}/tests/test_detector.py +0 -0
- {clonebox-0.1.4 → clonebox-0.1.6}/tests/test_network.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clonebox
|
|
3
|
-
Version: 0.1.
|
|
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 "
|
|
452
|
+
If you get "VM already exists" error:
|
|
381
453
|
|
|
382
454
|
```bash
|
|
383
|
-
#
|
|
384
|
-
clonebox
|
|
455
|
+
# Option 1: Use --replace flag to automatically replace it
|
|
456
|
+
clonebox clone . --replace
|
|
385
457
|
|
|
386
|
-
#
|
|
458
|
+
# Option 2: Delete manually first
|
|
387
459
|
clonebox delete <vm-name>
|
|
388
460
|
|
|
389
|
-
#
|
|
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 "
|
|
413
|
+
If you get "VM already exists" error:
|
|
342
414
|
|
|
343
415
|
```bash
|
|
344
|
-
#
|
|
345
|
-
clonebox
|
|
416
|
+
# Option 1: Use --replace flag to automatically replace it
|
|
417
|
+
clonebox clone . --replace
|
|
346
418
|
|
|
347
|
-
#
|
|
419
|
+
# Option 2: Delete manually first
|
|
348
420
|
clonebox delete <vm-name>
|
|
349
421
|
|
|
350
|
-
#
|
|
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.
|
|
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
|
|
|
@@ -405,7 +407,7 @@ def cmd_start(args):
|
|
|
405
407
|
vm_name = config["vm"]["name"]
|
|
406
408
|
|
|
407
409
|
# Check if VM already exists
|
|
408
|
-
cloner = SelectiveVMCloner()
|
|
410
|
+
cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
|
|
409
411
|
try:
|
|
410
412
|
existing_vms = [v["name"] for v in cloner.list_vms()]
|
|
411
413
|
if vm_name in existing_vms:
|
|
@@ -417,7 +419,7 @@ def cmd_start(args):
|
|
|
417
419
|
|
|
418
420
|
# Create new VM from config
|
|
419
421
|
console.print(f"[cyan]Creating VM '{vm_name}' from config...[/]\n")
|
|
420
|
-
vm_uuid = create_vm_from_config(config, start=True)
|
|
422
|
+
vm_uuid = create_vm_from_config(config, start=True, user_session=getattr(args, "user", False))
|
|
421
423
|
console.print(f"\n[bold green]🎉 VM '{vm_name}' is running![/]")
|
|
422
424
|
console.print(f"[dim]UUID: {vm_uuid}[/]")
|
|
423
425
|
|
|
@@ -442,13 +444,13 @@ def cmd_start(args):
|
|
|
442
444
|
console.print("[dim]Usage: clonebox start <vm-name> or clonebox start .[/]")
|
|
443
445
|
return
|
|
444
446
|
|
|
445
|
-
cloner = SelectiveVMCloner()
|
|
447
|
+
cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
|
|
446
448
|
cloner.start_vm(name, open_viewer=not args.no_viewer, console=console)
|
|
447
449
|
|
|
448
450
|
|
|
449
451
|
def cmd_stop(args):
|
|
450
452
|
"""Stop a VM."""
|
|
451
|
-
cloner = SelectiveVMCloner()
|
|
453
|
+
cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
|
|
452
454
|
cloner.stop_vm(args.name, force=args.force, console=console)
|
|
453
455
|
|
|
454
456
|
|
|
@@ -461,13 +463,13 @@ def cmd_delete(args):
|
|
|
461
463
|
console.print("[yellow]Cancelled.[/]")
|
|
462
464
|
return
|
|
463
465
|
|
|
464
|
-
cloner = SelectiveVMCloner()
|
|
466
|
+
cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
|
|
465
467
|
cloner.delete_vm(args.name, delete_storage=not args.keep_storage, console=console)
|
|
466
468
|
|
|
467
469
|
|
|
468
470
|
def cmd_list(args):
|
|
469
471
|
"""List all VMs."""
|
|
470
|
-
cloner = SelectiveVMCloner()
|
|
472
|
+
cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
|
|
471
473
|
vms = cloner.list_vms()
|
|
472
474
|
|
|
473
475
|
if not vms:
|
|
@@ -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":
|
|
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
|
-
|
|
615
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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:[/]")
|
|
@@ -861,12 +1038,24 @@ def main():
|
|
|
861
1038
|
"name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml"
|
|
862
1039
|
)
|
|
863
1040
|
start_parser.add_argument("--no-viewer", action="store_true", help="Don't open virt-viewer")
|
|
1041
|
+
start_parser.add_argument(
|
|
1042
|
+
"-u",
|
|
1043
|
+
"--user",
|
|
1044
|
+
action="store_true",
|
|
1045
|
+
help="Use user session (qemu:///session) - no root required",
|
|
1046
|
+
)
|
|
864
1047
|
start_parser.set_defaults(func=cmd_start)
|
|
865
1048
|
|
|
866
1049
|
# Stop command
|
|
867
1050
|
stop_parser = subparsers.add_parser("stop", help="Stop a VM")
|
|
868
1051
|
stop_parser.add_argument("name", help="VM name")
|
|
869
1052
|
stop_parser.add_argument("--force", "-f", action="store_true", help="Force stop")
|
|
1053
|
+
stop_parser.add_argument(
|
|
1054
|
+
"-u",
|
|
1055
|
+
"--user",
|
|
1056
|
+
action="store_true",
|
|
1057
|
+
help="Use user session (qemu:///session) - no root required",
|
|
1058
|
+
)
|
|
870
1059
|
stop_parser.set_defaults(func=cmd_stop)
|
|
871
1060
|
|
|
872
1061
|
# Delete command
|
|
@@ -874,10 +1063,22 @@ def main():
|
|
|
874
1063
|
delete_parser.add_argument("name", help="VM name")
|
|
875
1064
|
delete_parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation")
|
|
876
1065
|
delete_parser.add_argument("--keep-storage", action="store_true", help="Keep disk images")
|
|
1066
|
+
delete_parser.add_argument(
|
|
1067
|
+
"-u",
|
|
1068
|
+
"--user",
|
|
1069
|
+
action="store_true",
|
|
1070
|
+
help="Use user session (qemu:///session) - no root required",
|
|
1071
|
+
)
|
|
877
1072
|
delete_parser.set_defaults(func=cmd_delete)
|
|
878
1073
|
|
|
879
1074
|
# List command
|
|
880
1075
|
list_parser = subparsers.add_parser("list", aliases=["ls"], help="List VMs")
|
|
1076
|
+
list_parser.add_argument(
|
|
1077
|
+
"-u",
|
|
1078
|
+
"--user",
|
|
1079
|
+
action="store_true",
|
|
1080
|
+
help="Use user session (qemu:///session) - no root required",
|
|
1081
|
+
)
|
|
881
1082
|
list_parser.set_defaults(func=cmd_list)
|
|
882
1083
|
|
|
883
1084
|
# Detect command
|
|
@@ -915,6 +1116,15 @@ def main():
|
|
|
915
1116
|
default="auto",
|
|
916
1117
|
help="Network mode: auto (default), default (libvirt network), user (slirp)",
|
|
917
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
|
+
)
|
|
918
1128
|
clone_parser.set_defaults(func=cmd_clone)
|
|
919
1129
|
|
|
920
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
|
-
|
|
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
|
|
508
|
+
"\n".join(f" - {pkg}" for pkg in all_packages) if all_packages else ""
|
|
398
509
|
)
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
{
|
|
415
|
-
{
|
|
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(
|
|
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 =
|
|
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.
|
|
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 "
|
|
452
|
+
If you get "VM already exists" error:
|
|
381
453
|
|
|
382
454
|
```bash
|
|
383
|
-
#
|
|
384
|
-
clonebox
|
|
455
|
+
# Option 1: Use --replace flag to automatically replace it
|
|
456
|
+
clonebox clone . --replace
|
|
385
457
|
|
|
386
|
-
#
|
|
458
|
+
# Option 2: Delete manually first
|
|
387
459
|
clonebox delete <vm-name>
|
|
388
460
|
|
|
389
|
-
#
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|