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