clonebox 0.1.1__py3-none-any.whl → 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clonebox/cli.py +884 -0
- clonebox/cloner.py +85 -9
- clonebox/detector.py +3 -1
- clonebox-0.1.2.dist-info/METADATA +301 -0
- clonebox-0.1.2.dist-info/RECORD +10 -0
- clonebox-0.1.1.dist-info/METADATA +0 -40
- clonebox-0.1.1.dist-info/RECORD +0 -9
- {clonebox-0.1.1.dist-info → clonebox-0.1.2.dist-info}/WHEEL +0 -0
- {clonebox-0.1.1.dist-info → clonebox-0.1.2.dist-info}/entry_points.txt +0 -0
- {clonebox-0.1.1.dist-info → clonebox-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {clonebox-0.1.1.dist-info → clonebox-0.1.2.dist-info}/top_level.txt +0 -0
clonebox/cli.py
ADDED
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CloneBox CLI - Interactive command-line interface for creating VMs.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import os
|
|
8
|
+
import json
|
|
9
|
+
import argparse
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
20
|
+
from rich import print as rprint
|
|
21
|
+
import questionary
|
|
22
|
+
from questionary import Style
|
|
23
|
+
|
|
24
|
+
from clonebox import __version__
|
|
25
|
+
from clonebox.cloner import SelectiveVMCloner, VMConfig
|
|
26
|
+
from clonebox.detector import SystemDetector
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Custom questionary style
|
|
30
|
+
custom_style = Style([
|
|
31
|
+
('qmark', 'fg:cyan bold'),
|
|
32
|
+
('question', 'bold'),
|
|
33
|
+
('answer', 'fg:green'),
|
|
34
|
+
('pointer', 'fg:cyan bold'),
|
|
35
|
+
('highlighted', 'fg:cyan bold'),
|
|
36
|
+
('selected', 'fg:green'),
|
|
37
|
+
('separator', 'fg:gray'),
|
|
38
|
+
('instruction', 'fg:gray italic'),
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
console = Console()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def print_banner():
|
|
45
|
+
"""Print the CloneBox banner."""
|
|
46
|
+
banner = """
|
|
47
|
+
╔═══════════════════════════════════════════════════════════════╗
|
|
48
|
+
║ ____ _ ____ ║
|
|
49
|
+
║ / ___|| | ___ _ __ ___| _ \\ ___ __ __ ║
|
|
50
|
+
║ | | | | / _ \\ | '_ \\ / _ \\ |_) |/ _ \\\\ \\/ / ║
|
|
51
|
+
║ | |___ | || (_) || | | | __/ _ <| (_) |> < ║
|
|
52
|
+
║ \\____||_| \\___/ |_| |_|\\___|_| \\_\\\\___//_/\\_\\ ║
|
|
53
|
+
║ ║
|
|
54
|
+
║ Clone your workstation to an isolated VM ║
|
|
55
|
+
╚═══════════════════════════════════════════════════════════════╝
|
|
56
|
+
"""
|
|
57
|
+
console.print(banner, style="cyan")
|
|
58
|
+
console.print(f" Version {__version__}\n", style="dim")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def interactive_mode():
|
|
62
|
+
"""Run the interactive VM creation wizard."""
|
|
63
|
+
print_banner()
|
|
64
|
+
|
|
65
|
+
console.print("[bold cyan]🔍 Detecting system state...[/]\n")
|
|
66
|
+
|
|
67
|
+
with Progress(
|
|
68
|
+
SpinnerColumn(),
|
|
69
|
+
TextColumn("[progress.description]{task.description}"),
|
|
70
|
+
console=console,
|
|
71
|
+
transient=True
|
|
72
|
+
) as progress:
|
|
73
|
+
task = progress.add_task("Scanning services, apps, and paths...", total=None)
|
|
74
|
+
detector = SystemDetector()
|
|
75
|
+
snapshot = detector.detect_all()
|
|
76
|
+
sys_info = detector.get_system_info()
|
|
77
|
+
docker_containers = detector.detect_docker_containers()
|
|
78
|
+
|
|
79
|
+
# Show system info
|
|
80
|
+
console.print(Panel(
|
|
81
|
+
f"[bold]Hostname:[/] {sys_info['hostname']}\n"
|
|
82
|
+
f"[bold]User:[/] {sys_info['user']}\n"
|
|
83
|
+
f"[bold]CPU:[/] {sys_info['cpu_count']} cores\n"
|
|
84
|
+
f"[bold]RAM:[/] {sys_info['memory_available_gb']:.1f} / {sys_info['memory_total_gb']:.1f} GB available\n"
|
|
85
|
+
f"[bold]Disk:[/] {sys_info['disk_free_gb']:.1f} / {sys_info['disk_total_gb']:.1f} GB free",
|
|
86
|
+
title="[bold cyan]System Info[/]",
|
|
87
|
+
border_style="cyan"
|
|
88
|
+
))
|
|
89
|
+
|
|
90
|
+
console.print()
|
|
91
|
+
|
|
92
|
+
# === VM Name ===
|
|
93
|
+
vm_name = questionary.text(
|
|
94
|
+
"VM name:",
|
|
95
|
+
default="clonebox-vm",
|
|
96
|
+
style=custom_style
|
|
97
|
+
).ask()
|
|
98
|
+
|
|
99
|
+
if not vm_name:
|
|
100
|
+
console.print("[red]Cancelled.[/]")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
# === RAM ===
|
|
104
|
+
max_ram = int(sys_info['memory_available_gb'] * 1024 * 0.75) # 75% of available
|
|
105
|
+
default_ram = min(4096, max_ram)
|
|
106
|
+
|
|
107
|
+
ram_mb = questionary.text(
|
|
108
|
+
f"RAM (MB) [max recommended: {max_ram}]:",
|
|
109
|
+
default=str(default_ram),
|
|
110
|
+
style=custom_style
|
|
111
|
+
).ask()
|
|
112
|
+
ram_mb = int(ram_mb) if ram_mb else default_ram
|
|
113
|
+
|
|
114
|
+
# === vCPUs ===
|
|
115
|
+
max_vcpus = sys_info['cpu_count']
|
|
116
|
+
default_vcpus = max(2, max_vcpus // 2)
|
|
117
|
+
|
|
118
|
+
vcpus = questionary.text(
|
|
119
|
+
f"vCPUs [max: {max_vcpus}]:",
|
|
120
|
+
default=str(default_vcpus),
|
|
121
|
+
style=custom_style
|
|
122
|
+
).ask()
|
|
123
|
+
vcpus = int(vcpus) if vcpus else default_vcpus
|
|
124
|
+
|
|
125
|
+
# === Services Selection ===
|
|
126
|
+
console.print("\n[bold cyan]📦 Select services to include in VM:[/]")
|
|
127
|
+
|
|
128
|
+
service_choices = []
|
|
129
|
+
for svc in snapshot.running_services:
|
|
130
|
+
label = f"{svc.name} ({svc.status})"
|
|
131
|
+
if svc.description:
|
|
132
|
+
label += f" - {svc.description[:40]}"
|
|
133
|
+
service_choices.append(questionary.Choice(label, value=svc.name))
|
|
134
|
+
|
|
135
|
+
selected_services = []
|
|
136
|
+
if service_choices:
|
|
137
|
+
selected_services = questionary.checkbox(
|
|
138
|
+
"Services (space to select, enter to confirm):",
|
|
139
|
+
choices=service_choices,
|
|
140
|
+
style=custom_style
|
|
141
|
+
).ask() or []
|
|
142
|
+
else:
|
|
143
|
+
console.print("[dim] No interesting services detected[/]")
|
|
144
|
+
|
|
145
|
+
# === Applications/Processes Selection ===
|
|
146
|
+
console.print("\n[bold cyan]🚀 Select applications to track:[/]")
|
|
147
|
+
|
|
148
|
+
app_choices = []
|
|
149
|
+
for app in snapshot.running_apps[:20]: # Limit to top 20
|
|
150
|
+
label = f"{app.name} (PID: {app.pid}, {app.memory_mb:.0f} MB)"
|
|
151
|
+
if app.working_dir:
|
|
152
|
+
label += f" @ {app.working_dir[:30]}"
|
|
153
|
+
app_choices.append(questionary.Choice(label, value=app))
|
|
154
|
+
|
|
155
|
+
selected_apps = []
|
|
156
|
+
if app_choices:
|
|
157
|
+
selected_apps = questionary.checkbox(
|
|
158
|
+
"Applications (will add their working dirs):",
|
|
159
|
+
choices=app_choices,
|
|
160
|
+
style=custom_style
|
|
161
|
+
).ask() or []
|
|
162
|
+
else:
|
|
163
|
+
console.print("[dim] No interesting applications detected[/]")
|
|
164
|
+
|
|
165
|
+
# === Docker Containers ===
|
|
166
|
+
if docker_containers:
|
|
167
|
+
console.print("\n[bold cyan]🐳 Docker containers detected:[/]")
|
|
168
|
+
|
|
169
|
+
container_choices = [
|
|
170
|
+
questionary.Choice(
|
|
171
|
+
f"{c['name']} ({c['image']}) - {c['status']}",
|
|
172
|
+
value=c['name']
|
|
173
|
+
)
|
|
174
|
+
for c in docker_containers
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
selected_containers = questionary.checkbox(
|
|
178
|
+
"Containers (will share docker socket):",
|
|
179
|
+
choices=container_choices,
|
|
180
|
+
style=custom_style
|
|
181
|
+
).ask() or []
|
|
182
|
+
|
|
183
|
+
# If any docker selected, add docker socket
|
|
184
|
+
if selected_containers:
|
|
185
|
+
if "docker" not in selected_services:
|
|
186
|
+
selected_services.append("docker")
|
|
187
|
+
|
|
188
|
+
# === Paths Selection ===
|
|
189
|
+
console.print("\n[bold cyan]📁 Select paths to mount in VM:[/]")
|
|
190
|
+
|
|
191
|
+
# Group paths by type
|
|
192
|
+
path_groups = {}
|
|
193
|
+
for p in snapshot.paths:
|
|
194
|
+
if p.type not in path_groups:
|
|
195
|
+
path_groups[p.type] = []
|
|
196
|
+
path_groups[p.type].append(p)
|
|
197
|
+
|
|
198
|
+
path_choices = []
|
|
199
|
+
for ptype in ["project", "config", "data"]:
|
|
200
|
+
if ptype in path_groups:
|
|
201
|
+
for p in path_groups[ptype]:
|
|
202
|
+
size_str = f"{p.size_mb:.0f} MB" if p.size_mb > 0 else "?"
|
|
203
|
+
label = f"[{ptype}] {p.path} ({size_str})"
|
|
204
|
+
if p.description:
|
|
205
|
+
label += f" - {p.description}"
|
|
206
|
+
path_choices.append(questionary.Choice(label, value=p.path))
|
|
207
|
+
|
|
208
|
+
selected_paths = []
|
|
209
|
+
if path_choices:
|
|
210
|
+
selected_paths = questionary.checkbox(
|
|
211
|
+
"Paths (will be bind-mounted read-write):",
|
|
212
|
+
choices=path_choices,
|
|
213
|
+
style=custom_style
|
|
214
|
+
).ask() or []
|
|
215
|
+
|
|
216
|
+
# Add working directories from selected applications
|
|
217
|
+
for app in selected_apps:
|
|
218
|
+
if app.working_dir and app.working_dir not in selected_paths:
|
|
219
|
+
selected_paths.append(app.working_dir)
|
|
220
|
+
|
|
221
|
+
# === Additional Packages ===
|
|
222
|
+
console.print("\n[bold cyan]📦 Additional packages to install:[/]")
|
|
223
|
+
|
|
224
|
+
common_packages = [
|
|
225
|
+
"build-essential", "git", "curl", "wget", "vim", "htop",
|
|
226
|
+
"python3", "python3-pip", "python3-venv",
|
|
227
|
+
"nodejs", "npm",
|
|
228
|
+
"docker.io", "docker-compose",
|
|
229
|
+
"nginx", "postgresql", "redis",
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
pkg_choices = [questionary.Choice(pkg, value=pkg) for pkg in common_packages]
|
|
233
|
+
|
|
234
|
+
selected_packages = questionary.checkbox(
|
|
235
|
+
"Packages (space to select):",
|
|
236
|
+
choices=pkg_choices,
|
|
237
|
+
style=custom_style
|
|
238
|
+
).ask() or []
|
|
239
|
+
|
|
240
|
+
# Add custom packages
|
|
241
|
+
custom_pkgs = questionary.text(
|
|
242
|
+
"Additional packages (space-separated):",
|
|
243
|
+
default="",
|
|
244
|
+
style=custom_style
|
|
245
|
+
).ask()
|
|
246
|
+
|
|
247
|
+
if custom_pkgs:
|
|
248
|
+
selected_packages.extend(custom_pkgs.split())
|
|
249
|
+
|
|
250
|
+
# === Base Image ===
|
|
251
|
+
base_image = questionary.text(
|
|
252
|
+
"Base image path (optional, leave empty for blank disk):",
|
|
253
|
+
default="",
|
|
254
|
+
style=custom_style
|
|
255
|
+
).ask()
|
|
256
|
+
|
|
257
|
+
# === GUI ===
|
|
258
|
+
enable_gui = questionary.confirm(
|
|
259
|
+
"Enable SPICE graphics (GUI)?",
|
|
260
|
+
default=True,
|
|
261
|
+
style=custom_style
|
|
262
|
+
).ask()
|
|
263
|
+
|
|
264
|
+
# === Summary ===
|
|
265
|
+
console.print("\n")
|
|
266
|
+
|
|
267
|
+
# Build paths mapping
|
|
268
|
+
paths_mapping = {}
|
|
269
|
+
for idx, host_path in enumerate(selected_paths):
|
|
270
|
+
guest_path = f"/mnt/host{idx}"
|
|
271
|
+
paths_mapping[host_path] = guest_path
|
|
272
|
+
|
|
273
|
+
# Summary table
|
|
274
|
+
summary_table = Table(title="VM Configuration Summary", border_style="cyan")
|
|
275
|
+
summary_table.add_column("Setting", style="bold")
|
|
276
|
+
summary_table.add_column("Value")
|
|
277
|
+
|
|
278
|
+
summary_table.add_row("Name", vm_name)
|
|
279
|
+
summary_table.add_row("RAM", f"{ram_mb} MB")
|
|
280
|
+
summary_table.add_row("vCPUs", str(vcpus))
|
|
281
|
+
summary_table.add_row("Services", ", ".join(selected_services) or "None")
|
|
282
|
+
summary_table.add_row("Packages", ", ".join(selected_packages[:5]) + ("..." if len(selected_packages) > 5 else "") or "None")
|
|
283
|
+
summary_table.add_row("Paths", f"{len(paths_mapping)} bind mounts")
|
|
284
|
+
summary_table.add_row("GUI", "Yes (SPICE)" if enable_gui else "No")
|
|
285
|
+
|
|
286
|
+
console.print(summary_table)
|
|
287
|
+
|
|
288
|
+
if paths_mapping:
|
|
289
|
+
console.print("\n[bold]Bind mounts:[/]")
|
|
290
|
+
for host, guest in paths_mapping.items():
|
|
291
|
+
console.print(f" [cyan]{host}[/] → [green]{guest}[/]")
|
|
292
|
+
|
|
293
|
+
console.print()
|
|
294
|
+
|
|
295
|
+
# === Confirm ===
|
|
296
|
+
if not questionary.confirm("Create VM with these settings?", default=True, style=custom_style).ask():
|
|
297
|
+
console.print("[yellow]Cancelled.[/]")
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
# === Create VM ===
|
|
301
|
+
console.print("\n[bold cyan]🔧 Creating VM...[/]\n")
|
|
302
|
+
|
|
303
|
+
config = VMConfig(
|
|
304
|
+
name=vm_name,
|
|
305
|
+
ram_mb=ram_mb,
|
|
306
|
+
vcpus=vcpus,
|
|
307
|
+
gui=enable_gui,
|
|
308
|
+
base_image=base_image if base_image else None,
|
|
309
|
+
paths=paths_mapping,
|
|
310
|
+
packages=selected_packages,
|
|
311
|
+
services=selected_services,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
cloner = SelectiveVMCloner()
|
|
316
|
+
|
|
317
|
+
# Check prerequisites
|
|
318
|
+
checks = cloner.check_prerequisites()
|
|
319
|
+
if not all(checks.values()):
|
|
320
|
+
console.print("[yellow]⚠️ Prerequisites check:[/]")
|
|
321
|
+
for check, passed in checks.items():
|
|
322
|
+
icon = "✅" if passed else "❌"
|
|
323
|
+
console.print(f" {icon} {check}")
|
|
324
|
+
|
|
325
|
+
if not checks["libvirt_connected"]:
|
|
326
|
+
console.print("\n[red]Cannot proceed without libvirt connection.[/]")
|
|
327
|
+
console.print("Try: [cyan]sudo systemctl start libvirtd[/]")
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
vm_uuid = cloner.create_vm(config, console=console)
|
|
331
|
+
|
|
332
|
+
# Ask to start
|
|
333
|
+
if questionary.confirm("Start VM now?", default=True, style=custom_style).ask():
|
|
334
|
+
cloner.start_vm(vm_name, open_viewer=enable_gui, console=console)
|
|
335
|
+
console.print("\n[bold green]🎉 VM is running![/]")
|
|
336
|
+
|
|
337
|
+
if paths_mapping:
|
|
338
|
+
console.print("\n[bold]Inside the VM, mount shared folders with:[/]")
|
|
339
|
+
for idx, (host, guest) in enumerate(paths_mapping.items()):
|
|
340
|
+
console.print(f" [cyan]sudo mount -t 9p -o trans=virtio mount{idx} {guest}[/]")
|
|
341
|
+
|
|
342
|
+
console.print(f"\n[dim]VM UUID: {vm_uuid}[/]")
|
|
343
|
+
|
|
344
|
+
except Exception as e:
|
|
345
|
+
console.print(f"\n[red]❌ Error: {e}[/]")
|
|
346
|
+
raise
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def cmd_create(args):
|
|
350
|
+
"""Create VM from JSON config."""
|
|
351
|
+
config_data = json.loads(args.config)
|
|
352
|
+
|
|
353
|
+
config = VMConfig(
|
|
354
|
+
name=args.name,
|
|
355
|
+
ram_mb=args.ram,
|
|
356
|
+
vcpus=args.vcpus,
|
|
357
|
+
gui=not args.no_gui,
|
|
358
|
+
base_image=args.base_image,
|
|
359
|
+
paths=config_data.get("paths", {}),
|
|
360
|
+
packages=config_data.get("packages", []),
|
|
361
|
+
services=config_data.get("services", []),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
cloner = SelectiveVMCloner()
|
|
365
|
+
vm_uuid = cloner.create_vm(config, console=console)
|
|
366
|
+
|
|
367
|
+
if args.start:
|
|
368
|
+
cloner.start_vm(args.name, open_viewer=not args.no_gui, console=console)
|
|
369
|
+
|
|
370
|
+
console.print(f"[green]✅ VM created: {vm_uuid}[/]")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def cmd_start(args):
|
|
374
|
+
"""Start a VM or create from .clonebox.yaml."""
|
|
375
|
+
name = args.name
|
|
376
|
+
|
|
377
|
+
# Check if it's a path (contains / or . or ~)
|
|
378
|
+
if name and (name.startswith(".") or name.startswith("/") or name.startswith("~")):
|
|
379
|
+
# Treat as path - load .clonebox.yaml
|
|
380
|
+
target_path = Path(name).expanduser().resolve()
|
|
381
|
+
|
|
382
|
+
if target_path.is_dir():
|
|
383
|
+
config_file = target_path / CLONEBOX_CONFIG_FILE
|
|
384
|
+
else:
|
|
385
|
+
config_file = target_path
|
|
386
|
+
|
|
387
|
+
if not config_file.exists():
|
|
388
|
+
console.print(f"[red]❌ Config not found: {config_file}[/]")
|
|
389
|
+
console.print(f"[dim]Run 'clonebox clone {target_path}' first to generate config[/]")
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
console.print(f"[bold cyan]📦 Loading config: {config_file}[/]\n")
|
|
393
|
+
|
|
394
|
+
config = load_clonebox_config(config_file)
|
|
395
|
+
vm_name = config["vm"]["name"]
|
|
396
|
+
|
|
397
|
+
# Check if VM already exists
|
|
398
|
+
cloner = SelectiveVMCloner()
|
|
399
|
+
try:
|
|
400
|
+
existing_vms = [v["name"] for v in cloner.list_vms()]
|
|
401
|
+
if vm_name in existing_vms:
|
|
402
|
+
console.print(f"[cyan]VM '{vm_name}' exists, starting...[/]")
|
|
403
|
+
cloner.start_vm(vm_name, open_viewer=not args.no_viewer, console=console)
|
|
404
|
+
return
|
|
405
|
+
except:
|
|
406
|
+
pass
|
|
407
|
+
|
|
408
|
+
# Create new VM from config
|
|
409
|
+
console.print(f"[cyan]Creating VM '{vm_name}' from config...[/]\n")
|
|
410
|
+
vm_uuid = create_vm_from_config(config, start=True)
|
|
411
|
+
console.print(f"\n[bold green]🎉 VM '{vm_name}' is running![/]")
|
|
412
|
+
console.print(f"[dim]UUID: {vm_uuid}[/]")
|
|
413
|
+
|
|
414
|
+
if config.get("paths"):
|
|
415
|
+
console.print("\n[bold]Inside VM, mount paths with:[/]")
|
|
416
|
+
for idx, (host, guest) in enumerate(config["paths"].items()):
|
|
417
|
+
console.print(f" [cyan]sudo mount -t 9p -o trans=virtio mount{idx} {guest}[/]")
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
# Default: treat as VM name
|
|
421
|
+
if not name:
|
|
422
|
+
# No argument - check current directory for .clonebox.yaml
|
|
423
|
+
config_file = Path.cwd() / CLONEBOX_CONFIG_FILE
|
|
424
|
+
if config_file.exists():
|
|
425
|
+
console.print(f"[cyan]Found {CLONEBOX_CONFIG_FILE} in current directory[/]")
|
|
426
|
+
args.name = "."
|
|
427
|
+
return cmd_start(args)
|
|
428
|
+
else:
|
|
429
|
+
console.print("[red]❌ No VM name specified and no .clonebox.yaml in current directory[/]")
|
|
430
|
+
console.print("[dim]Usage: clonebox start <vm-name> or clonebox start .[/]")
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
cloner = SelectiveVMCloner()
|
|
434
|
+
cloner.start_vm(name, open_viewer=not args.no_viewer, console=console)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def cmd_stop(args):
|
|
438
|
+
"""Stop a VM."""
|
|
439
|
+
cloner = SelectiveVMCloner()
|
|
440
|
+
cloner.stop_vm(args.name, force=args.force, console=console)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def cmd_delete(args):
|
|
444
|
+
"""Delete a VM."""
|
|
445
|
+
if not args.yes:
|
|
446
|
+
if not questionary.confirm(
|
|
447
|
+
f"Delete VM '{args.name}' and its storage?",
|
|
448
|
+
default=False,
|
|
449
|
+
style=custom_style
|
|
450
|
+
).ask():
|
|
451
|
+
console.print("[yellow]Cancelled.[/]")
|
|
452
|
+
return
|
|
453
|
+
|
|
454
|
+
cloner = SelectiveVMCloner()
|
|
455
|
+
cloner.delete_vm(args.name, delete_storage=not args.keep_storage, console=console)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def cmd_list(args):
|
|
459
|
+
"""List all VMs."""
|
|
460
|
+
cloner = SelectiveVMCloner()
|
|
461
|
+
vms = cloner.list_vms()
|
|
462
|
+
|
|
463
|
+
if not vms:
|
|
464
|
+
console.print("[dim]No VMs found.[/]")
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
table = Table(title="Virtual Machines", border_style="cyan")
|
|
468
|
+
table.add_column("Name", style="bold")
|
|
469
|
+
table.add_column("State")
|
|
470
|
+
table.add_column("UUID", style="dim")
|
|
471
|
+
|
|
472
|
+
for vm in vms:
|
|
473
|
+
state_style = "green" if vm["state"] == "running" else "dim"
|
|
474
|
+
table.add_row(vm["name"], f"[{state_style}]{vm['state']}[/]", vm["uuid"][:8])
|
|
475
|
+
|
|
476
|
+
console.print(table)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
CLONEBOX_CONFIG_FILE = ".clonebox.yaml"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def deduplicate_list(items: list, key=None) -> list:
|
|
483
|
+
"""Remove duplicates from list, preserving order."""
|
|
484
|
+
seen = set()
|
|
485
|
+
result = []
|
|
486
|
+
for item in items:
|
|
487
|
+
k = key(item) if key else item
|
|
488
|
+
if k not in seen:
|
|
489
|
+
seen.add(k)
|
|
490
|
+
result.append(item)
|
|
491
|
+
return result
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def generate_clonebox_yaml(snapshot, detector, deduplicate: bool = True,
|
|
495
|
+
target_path: str = None, vm_name: str = None) -> str:
|
|
496
|
+
"""Generate YAML config from system snapshot."""
|
|
497
|
+
sys_info = detector.get_system_info()
|
|
498
|
+
|
|
499
|
+
# Collect services
|
|
500
|
+
services = [s.name for s in snapshot.running_services]
|
|
501
|
+
if deduplicate:
|
|
502
|
+
services = deduplicate_list(services)
|
|
503
|
+
|
|
504
|
+
# Collect paths with types
|
|
505
|
+
paths_by_type = {"project": [], "config": [], "data": []}
|
|
506
|
+
for p in snapshot.paths:
|
|
507
|
+
if p.type in paths_by_type:
|
|
508
|
+
paths_by_type[p.type].append(p.path)
|
|
509
|
+
|
|
510
|
+
if deduplicate:
|
|
511
|
+
for ptype in paths_by_type:
|
|
512
|
+
paths_by_type[ptype] = deduplicate_list(paths_by_type[ptype])
|
|
513
|
+
|
|
514
|
+
# Collect working directories from running apps
|
|
515
|
+
working_dirs = []
|
|
516
|
+
for app in snapshot.applications:
|
|
517
|
+
if app.working_dir and app.working_dir != "/" and app.working_dir.startswith("/home"):
|
|
518
|
+
working_dirs.append(app.working_dir)
|
|
519
|
+
|
|
520
|
+
if deduplicate:
|
|
521
|
+
working_dirs = deduplicate_list(working_dirs)
|
|
522
|
+
|
|
523
|
+
# If target_path specified, prioritize it
|
|
524
|
+
if target_path:
|
|
525
|
+
target_path = str(Path(target_path).resolve())
|
|
526
|
+
if target_path not in paths_by_type["project"]:
|
|
527
|
+
paths_by_type["project"].insert(0, target_path)
|
|
528
|
+
|
|
529
|
+
# Build paths mapping
|
|
530
|
+
paths_mapping = {}
|
|
531
|
+
idx = 0
|
|
532
|
+
for host_path in paths_by_type["project"][:5]: # Limit projects
|
|
533
|
+
paths_mapping[host_path] = f"/mnt/project{idx}"
|
|
534
|
+
idx += 1
|
|
535
|
+
|
|
536
|
+
for host_path in working_dirs[:3]: # Limit working dirs
|
|
537
|
+
if host_path not in paths_mapping:
|
|
538
|
+
paths_mapping[host_path] = f"/mnt/workdir{idx}"
|
|
539
|
+
idx += 1
|
|
540
|
+
|
|
541
|
+
# Determine VM name
|
|
542
|
+
if not vm_name:
|
|
543
|
+
if target_path:
|
|
544
|
+
vm_name = f"clone-{Path(target_path).name}"
|
|
545
|
+
else:
|
|
546
|
+
vm_name = f"clone-{sys_info['hostname']}"
|
|
547
|
+
|
|
548
|
+
# Calculate recommended resources
|
|
549
|
+
ram_mb = min(4096, int(sys_info['memory_available_gb'] * 1024 * 0.5))
|
|
550
|
+
vcpus = max(2, sys_info['cpu_count'] // 2)
|
|
551
|
+
|
|
552
|
+
# Build config
|
|
553
|
+
config = {
|
|
554
|
+
"version": "1",
|
|
555
|
+
"generated": datetime.now().isoformat(),
|
|
556
|
+
"vm": {
|
|
557
|
+
"name": vm_name,
|
|
558
|
+
"ram_mb": ram_mb,
|
|
559
|
+
"vcpus": vcpus,
|
|
560
|
+
"gui": True,
|
|
561
|
+
"base_image": None,
|
|
562
|
+
},
|
|
563
|
+
"services": services,
|
|
564
|
+
"packages": [
|
|
565
|
+
"build-essential", "git", "curl", "vim",
|
|
566
|
+
"python3", "python3-pip",
|
|
567
|
+
],
|
|
568
|
+
"paths": paths_mapping,
|
|
569
|
+
"detected": {
|
|
570
|
+
"running_apps": [
|
|
571
|
+
{"name": a.name, "cwd": a.working_dir, "memory_mb": round(a.memory_mb)}
|
|
572
|
+
for a in snapshot.applications[:10]
|
|
573
|
+
],
|
|
574
|
+
"all_paths": {
|
|
575
|
+
"projects": paths_by_type["project"],
|
|
576
|
+
"configs": paths_by_type["config"][:5],
|
|
577
|
+
"data": paths_by_type["data"][:5],
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def load_clonebox_config(path: Path) -> dict:
|
|
586
|
+
"""Load .clonebox.yaml config file."""
|
|
587
|
+
config_file = path / CLONEBOX_CONFIG_FILE if path.is_dir() else path
|
|
588
|
+
|
|
589
|
+
if not config_file.exists():
|
|
590
|
+
raise FileNotFoundError(f"Config file not found: {config_file}")
|
|
591
|
+
|
|
592
|
+
with open(config_file) as f:
|
|
593
|
+
return yaml.safe_load(f)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def create_vm_from_config(config: dict, start: bool = False, user_session: bool = False) -> str:
|
|
597
|
+
"""Create VM from YAML config dict."""
|
|
598
|
+
vm_config = VMConfig(
|
|
599
|
+
name=config["vm"]["name"],
|
|
600
|
+
ram_mb=config["vm"].get("ram_mb", 4096),
|
|
601
|
+
vcpus=config["vm"].get("vcpus", 4),
|
|
602
|
+
gui=config["vm"].get("gui", True),
|
|
603
|
+
base_image=config["vm"].get("base_image"),
|
|
604
|
+
paths=config.get("paths", {}),
|
|
605
|
+
packages=config.get("packages", []),
|
|
606
|
+
services=config.get("services", []),
|
|
607
|
+
user_session=user_session,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
cloner = SelectiveVMCloner(user_session=user_session)
|
|
611
|
+
|
|
612
|
+
# Check prerequisites and show detailed info
|
|
613
|
+
checks = cloner.check_prerequisites()
|
|
614
|
+
|
|
615
|
+
if not checks["images_dir_writable"]:
|
|
616
|
+
console.print(f"[yellow]⚠️ Storage directory: {checks['images_dir']}[/]")
|
|
617
|
+
if "images_dir_error" in checks:
|
|
618
|
+
console.print(f"[red]{checks['images_dir_error']}[/]")
|
|
619
|
+
raise PermissionError(checks["images_dir_error"])
|
|
620
|
+
|
|
621
|
+
console.print(f"[dim]Session: {checks['session_type']}, Storage: {checks['images_dir']}[/]")
|
|
622
|
+
|
|
623
|
+
vm_uuid = cloner.create_vm(vm_config, console=console)
|
|
624
|
+
|
|
625
|
+
if start:
|
|
626
|
+
cloner.start_vm(vm_config.name, open_viewer=vm_config.gui, console=console)
|
|
627
|
+
|
|
628
|
+
return vm_uuid
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def cmd_clone(args):
|
|
632
|
+
"""Generate clone config from path and optionally create VM."""
|
|
633
|
+
target_path = Path(args.path).resolve()
|
|
634
|
+
|
|
635
|
+
if not target_path.exists():
|
|
636
|
+
console.print(f"[red]❌ Path does not exist: {target_path}[/]")
|
|
637
|
+
return
|
|
638
|
+
|
|
639
|
+
console.print(f"[bold cyan]📦 Generating clone config for: {target_path}[/]\n")
|
|
640
|
+
|
|
641
|
+
# Detect system state
|
|
642
|
+
with Progress(
|
|
643
|
+
SpinnerColumn(),
|
|
644
|
+
TextColumn("[progress.description]{task.description}"),
|
|
645
|
+
console=console,
|
|
646
|
+
transient=True
|
|
647
|
+
) as progress:
|
|
648
|
+
progress.add_task("Scanning system...", total=None)
|
|
649
|
+
detector = SystemDetector()
|
|
650
|
+
snapshot = detector.detect_all()
|
|
651
|
+
|
|
652
|
+
# Generate config
|
|
653
|
+
vm_name = args.name or f"clone-{target_path.name}"
|
|
654
|
+
yaml_content = generate_clonebox_yaml(
|
|
655
|
+
snapshot, detector,
|
|
656
|
+
deduplicate=args.dedupe,
|
|
657
|
+
target_path=str(target_path),
|
|
658
|
+
vm_name=vm_name
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
# Save config file
|
|
662
|
+
config_file = target_path / CLONEBOX_CONFIG_FILE if target_path.is_dir() else target_path.parent / CLONEBOX_CONFIG_FILE
|
|
663
|
+
config_file.write_text(yaml_content)
|
|
664
|
+
console.print(f"[green]✅ Config saved: {config_file}[/]\n")
|
|
665
|
+
|
|
666
|
+
# Show config
|
|
667
|
+
console.print(Panel(yaml_content, title="[bold].clonebox.yaml[/]", border_style="cyan"))
|
|
668
|
+
|
|
669
|
+
# Open in editor if requested
|
|
670
|
+
if args.edit:
|
|
671
|
+
editor = os.environ.get("EDITOR", "nano")
|
|
672
|
+
console.print(f"[cyan]Opening {editor}...[/]")
|
|
673
|
+
os.system(f"{editor} {config_file}")
|
|
674
|
+
# Reload after edit
|
|
675
|
+
yaml_content = config_file.read_text()
|
|
676
|
+
|
|
677
|
+
# Ask to create VM
|
|
678
|
+
if args.run:
|
|
679
|
+
create_now = True
|
|
680
|
+
else:
|
|
681
|
+
create_now = questionary.confirm(
|
|
682
|
+
"Create VM with this config?",
|
|
683
|
+
default=True,
|
|
684
|
+
style=custom_style
|
|
685
|
+
).ask()
|
|
686
|
+
|
|
687
|
+
if create_now:
|
|
688
|
+
config = yaml.safe_load(yaml_content)
|
|
689
|
+
user_session = getattr(args, 'user', False)
|
|
690
|
+
|
|
691
|
+
console.print("\n[bold cyan]🔧 Creating VM...[/]\n")
|
|
692
|
+
if user_session:
|
|
693
|
+
console.print("[cyan]Using user session (qemu:///session) - no root required[/]")
|
|
694
|
+
|
|
695
|
+
try:
|
|
696
|
+
vm_uuid = create_vm_from_config(config, start=True, user_session=user_session)
|
|
697
|
+
console.print(f"\n[bold green]🎉 VM '{config['vm']['name']}' is running![/]")
|
|
698
|
+
console.print(f"[dim]UUID: {vm_uuid}[/]")
|
|
699
|
+
|
|
700
|
+
# Show mount instructions
|
|
701
|
+
if config.get("paths"):
|
|
702
|
+
console.print("\n[bold]Inside VM, mount paths with:[/]")
|
|
703
|
+
for idx, (host, guest) in enumerate(config["paths"].items()):
|
|
704
|
+
console.print(f" [cyan]sudo mount -t 9p -o trans=virtio mount{idx} {guest}[/]")
|
|
705
|
+
except PermissionError as e:
|
|
706
|
+
console.print(f"[red]❌ Permission Error:[/]\n{e}")
|
|
707
|
+
console.print(f"\n[yellow]💡 Try running with --user flag:[/]")
|
|
708
|
+
console.print(f" [cyan]clonebox clone {target_path} --user[/]")
|
|
709
|
+
except Exception as e:
|
|
710
|
+
console.print(f"[red]❌ Error: {e}[/]")
|
|
711
|
+
else:
|
|
712
|
+
console.print(f"\n[dim]To create VM later, run:[/]")
|
|
713
|
+
console.print(f" [cyan]clonebox start {target_path}[/]")
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def cmd_detect(args):
|
|
717
|
+
"""Detect and show system state."""
|
|
718
|
+
console.print("[bold cyan]🔍 Detecting system state...[/]\n")
|
|
719
|
+
|
|
720
|
+
detector = SystemDetector()
|
|
721
|
+
snapshot = detector.detect_all()
|
|
722
|
+
|
|
723
|
+
# JSON output
|
|
724
|
+
if args.json:
|
|
725
|
+
result = {
|
|
726
|
+
"services": [{"name": s.name, "status": s.status} for s in snapshot.running_services],
|
|
727
|
+
"applications": [{"name": a.name, "pid": a.pid, "cwd": a.working_dir} for a in snapshot.applications],
|
|
728
|
+
"paths": [{"path": p.path, "type": p.type, "size_mb": p.size_mb} for p in snapshot.paths],
|
|
729
|
+
}
|
|
730
|
+
print(json.dumps(result, indent=2))
|
|
731
|
+
return
|
|
732
|
+
|
|
733
|
+
# YAML output
|
|
734
|
+
if args.yaml:
|
|
735
|
+
result = generate_clonebox_yaml(snapshot, detector, deduplicate=args.dedupe)
|
|
736
|
+
|
|
737
|
+
if args.output:
|
|
738
|
+
output_path = Path(args.output)
|
|
739
|
+
output_path.write_text(result)
|
|
740
|
+
console.print(f"[green]✅ Config saved to: {output_path}[/]")
|
|
741
|
+
else:
|
|
742
|
+
print(result)
|
|
743
|
+
return
|
|
744
|
+
|
|
745
|
+
# Services
|
|
746
|
+
services = detector.detect_services()
|
|
747
|
+
running = [s for s in services if s.status == "running"]
|
|
748
|
+
|
|
749
|
+
if running:
|
|
750
|
+
table = Table(title="Running Services", border_style="green")
|
|
751
|
+
table.add_column("Service")
|
|
752
|
+
table.add_column("Status")
|
|
753
|
+
table.add_column("Enabled")
|
|
754
|
+
|
|
755
|
+
for svc in running:
|
|
756
|
+
table.add_row(svc.name, f"[green]{svc.status}[/]", "✓" if svc.enabled else "")
|
|
757
|
+
|
|
758
|
+
console.print(table)
|
|
759
|
+
|
|
760
|
+
# Applications
|
|
761
|
+
apps = detector.detect_applications()
|
|
762
|
+
|
|
763
|
+
if apps:
|
|
764
|
+
console.print()
|
|
765
|
+
table = Table(title="Running Applications", border_style="blue")
|
|
766
|
+
table.add_column("Name")
|
|
767
|
+
table.add_column("PID")
|
|
768
|
+
table.add_column("Memory")
|
|
769
|
+
table.add_column("Working Dir")
|
|
770
|
+
|
|
771
|
+
for app in apps[:15]:
|
|
772
|
+
table.add_row(
|
|
773
|
+
app.name,
|
|
774
|
+
str(app.pid),
|
|
775
|
+
f"{app.memory_mb:.0f} MB",
|
|
776
|
+
app.working_dir[:40] if app.working_dir else ""
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
console.print(table)
|
|
780
|
+
|
|
781
|
+
# Paths
|
|
782
|
+
paths = detector.detect_paths()
|
|
783
|
+
|
|
784
|
+
if paths:
|
|
785
|
+
console.print()
|
|
786
|
+
table = Table(title="Detected Paths", border_style="yellow")
|
|
787
|
+
table.add_column("Type")
|
|
788
|
+
table.add_column("Path")
|
|
789
|
+
table.add_column("Size")
|
|
790
|
+
|
|
791
|
+
for p in paths[:20]:
|
|
792
|
+
table.add_row(
|
|
793
|
+
f"[cyan]{p.type}[/]",
|
|
794
|
+
p.path,
|
|
795
|
+
f"{p.size_mb:.0f} MB" if p.size_mb > 0 else "-"
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
console.print(table)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def main():
|
|
802
|
+
"""Main entry point."""
|
|
803
|
+
parser = argparse.ArgumentParser(
|
|
804
|
+
prog="clonebox",
|
|
805
|
+
description="Clone your workstation environment to an isolated VM"
|
|
806
|
+
)
|
|
807
|
+
parser.add_argument("--version", action="version", version=f"clonebox {__version__}")
|
|
808
|
+
|
|
809
|
+
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
|
810
|
+
|
|
811
|
+
# Interactive mode (default)
|
|
812
|
+
parser.set_defaults(func=lambda args: interactive_mode())
|
|
813
|
+
|
|
814
|
+
# Create command
|
|
815
|
+
create_parser = subparsers.add_parser("create", help="Create VM from config")
|
|
816
|
+
create_parser.add_argument("--name", "-n", default="clonebox-vm", help="VM name")
|
|
817
|
+
create_parser.add_argument("--config", "-c", required=True,
|
|
818
|
+
help='JSON config: {"paths": {}, "packages": [], "services": []}')
|
|
819
|
+
create_parser.add_argument("--ram", type=int, default=4096, help="RAM in MB")
|
|
820
|
+
create_parser.add_argument("--vcpus", type=int, default=4, help="Number of vCPUs")
|
|
821
|
+
create_parser.add_argument("--base-image", help="Path to base qcow2 image")
|
|
822
|
+
create_parser.add_argument("--no-gui", action="store_true", help="Disable SPICE graphics")
|
|
823
|
+
create_parser.add_argument("--start", "-s", action="store_true", help="Start VM after creation")
|
|
824
|
+
create_parser.set_defaults(func=cmd_create)
|
|
825
|
+
|
|
826
|
+
# Start command
|
|
827
|
+
start_parser = subparsers.add_parser("start", help="Start a VM")
|
|
828
|
+
start_parser.add_argument("name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml")
|
|
829
|
+
start_parser.add_argument("--no-viewer", action="store_true", help="Don't open virt-viewer")
|
|
830
|
+
start_parser.set_defaults(func=cmd_start)
|
|
831
|
+
|
|
832
|
+
# Stop command
|
|
833
|
+
stop_parser = subparsers.add_parser("stop", help="Stop a VM")
|
|
834
|
+
stop_parser.add_argument("name", help="VM name")
|
|
835
|
+
stop_parser.add_argument("--force", "-f", action="store_true", help="Force stop")
|
|
836
|
+
stop_parser.set_defaults(func=cmd_stop)
|
|
837
|
+
|
|
838
|
+
# Delete command
|
|
839
|
+
delete_parser = subparsers.add_parser("delete", help="Delete a VM")
|
|
840
|
+
delete_parser.add_argument("name", help="VM name")
|
|
841
|
+
delete_parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation")
|
|
842
|
+
delete_parser.add_argument("--keep-storage", action="store_true", help="Keep disk images")
|
|
843
|
+
delete_parser.set_defaults(func=cmd_delete)
|
|
844
|
+
|
|
845
|
+
# List command
|
|
846
|
+
list_parser = subparsers.add_parser("list", aliases=["ls"], help="List VMs")
|
|
847
|
+
list_parser.set_defaults(func=cmd_list)
|
|
848
|
+
|
|
849
|
+
# Detect command
|
|
850
|
+
detect_parser = subparsers.add_parser("detect", help="Detect system state")
|
|
851
|
+
detect_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
852
|
+
detect_parser.add_argument("--yaml", action="store_true", help="Output as YAML config")
|
|
853
|
+
detect_parser.add_argument("--dedupe", action="store_true", help="Remove duplicate entries")
|
|
854
|
+
detect_parser.add_argument("-o", "--output", help="Save output to file")
|
|
855
|
+
detect_parser.set_defaults(func=cmd_detect)
|
|
856
|
+
|
|
857
|
+
# Clone command
|
|
858
|
+
clone_parser = subparsers.add_parser("clone", help="Generate clone config from path")
|
|
859
|
+
clone_parser.add_argument("path", nargs="?", default=".", help="Path to clone (default: current dir)")
|
|
860
|
+
clone_parser.add_argument("--name", "-n", help="VM name (default: directory name)")
|
|
861
|
+
clone_parser.add_argument("--run", "-r", action="store_true", help="Create and start VM immediately")
|
|
862
|
+
clone_parser.add_argument("--edit", "-e", action="store_true", help="Open config in editor before creating")
|
|
863
|
+
clone_parser.add_argument("--dedupe", action="store_true", default=True, help="Remove duplicate entries")
|
|
864
|
+
clone_parser.add_argument("--user", "-u", action="store_true",
|
|
865
|
+
help="Use user session (qemu:///session) - no root required, stores in ~/.local/share/libvirt/")
|
|
866
|
+
clone_parser.set_defaults(func=cmd_clone)
|
|
867
|
+
|
|
868
|
+
args = parser.parse_args()
|
|
869
|
+
|
|
870
|
+
if hasattr(args, "func"):
|
|
871
|
+
try:
|
|
872
|
+
args.func(args)
|
|
873
|
+
except KeyboardInterrupt:
|
|
874
|
+
console.print("\n[yellow]Interrupted.[/]")
|
|
875
|
+
sys.exit(1)
|
|
876
|
+
except Exception as e:
|
|
877
|
+
console.print(f"[red]Error: {e}[/]")
|
|
878
|
+
sys.exit(1)
|
|
879
|
+
else:
|
|
880
|
+
interactive_mode()
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
if __name__ == "__main__":
|
|
884
|
+
main()
|