clonebox 0.1.2__py3-none-any.whl → 0.1.4__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 +317 -265
- clonebox/cloner.py +177 -119
- clonebox/detector.py +186 -108
- {clonebox-0.1.2.dist-info → clonebox-0.1.4.dist-info}/METADATA +110 -3
- clonebox-0.1.4.dist-info/RECORD +11 -0
- clonebox-0.1.2.dist-info/RECORD +0 -10
- {clonebox-0.1.2.dist-info → clonebox-0.1.4.dist-info}/WHEEL +0 -0
- {clonebox-0.1.2.dist-info → clonebox-0.1.4.dist-info}/entry_points.txt +0 -0
- {clonebox-0.1.2.dist-info → clonebox-0.1.4.dist-info}/licenses/LICENSE +0 -0
- {clonebox-0.1.2.dist-info → clonebox-0.1.4.dist-info}/top_level.txt +0 -0
clonebox/cloner.py
CHANGED
|
@@ -4,13 +4,12 @@ SelectiveVMCloner - Creates isolated VMs with only selected apps/paths/services.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import os
|
|
7
|
-
import uuid
|
|
8
|
-
import time
|
|
9
7
|
import subprocess
|
|
8
|
+
import uuid
|
|
10
9
|
import xml.etree.ElementTree as ET
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Optional
|
|
13
|
-
from dataclasses import dataclass, field
|
|
14
13
|
|
|
15
14
|
try:
|
|
16
15
|
import libvirt
|
|
@@ -21,7 +20,7 @@ except ImportError:
|
|
|
21
20
|
@dataclass
|
|
22
21
|
class VMConfig:
|
|
23
22
|
"""Configuration for the VM to create."""
|
|
24
|
-
|
|
23
|
+
|
|
25
24
|
name: str = "clonebox-vm"
|
|
26
25
|
ram_mb: int = 4096
|
|
27
26
|
vcpus: int = 4
|
|
@@ -32,7 +31,8 @@ class VMConfig:
|
|
|
32
31
|
packages: list = field(default_factory=list)
|
|
33
32
|
services: list = field(default_factory=list)
|
|
34
33
|
user_session: bool = False # Use qemu:///session instead of qemu:///system
|
|
35
|
-
|
|
34
|
+
network_mode: str = "auto" # auto|default|user
|
|
35
|
+
|
|
36
36
|
def to_dict(self) -> dict:
|
|
37
37
|
return {
|
|
38
38
|
"paths": self.paths,
|
|
@@ -46,11 +46,11 @@ class SelectiveVMCloner:
|
|
|
46
46
|
Creates VMs with only selected applications, paths and services.
|
|
47
47
|
Uses bind mounts instead of full disk cloning.
|
|
48
48
|
"""
|
|
49
|
-
|
|
49
|
+
|
|
50
50
|
# Default images directories
|
|
51
51
|
SYSTEM_IMAGES_DIR = Path("/var/lib/libvirt/images")
|
|
52
52
|
USER_IMAGES_DIR = Path.home() / ".local/share/libvirt/images"
|
|
53
|
-
|
|
53
|
+
|
|
54
54
|
def __init__(self, conn_uri: str = None, user_session: bool = False):
|
|
55
55
|
self.user_session = user_session
|
|
56
56
|
if conn_uri:
|
|
@@ -59,7 +59,7 @@ class SelectiveVMCloner:
|
|
|
59
59
|
self.conn_uri = "qemu:///session" if user_session else "qemu:///system"
|
|
60
60
|
self.conn = None
|
|
61
61
|
self._connect()
|
|
62
|
-
|
|
62
|
+
|
|
63
63
|
def _connect(self):
|
|
64
64
|
"""Connect to libvirt."""
|
|
65
65
|
if libvirt is None:
|
|
@@ -67,7 +67,7 @@ class SelectiveVMCloner:
|
|
|
67
67
|
"libvirt-python is required. Install with: pip install libvirt-python\n"
|
|
68
68
|
"Also ensure libvirt is installed: sudo apt install libvirt-daemon-system"
|
|
69
69
|
)
|
|
70
|
-
|
|
70
|
+
|
|
71
71
|
try:
|
|
72
72
|
self.conn = libvirt.open(self.conn_uri)
|
|
73
73
|
except libvirt.libvirtError as e:
|
|
@@ -81,20 +81,39 @@ class SelectiveVMCloner:
|
|
|
81
81
|
f" 4. Re-login or run: newgrp libvirt\n"
|
|
82
82
|
f" 5. For user session (no sudo): use --user flag"
|
|
83
83
|
)
|
|
84
|
-
|
|
84
|
+
|
|
85
85
|
if self.conn is None:
|
|
86
86
|
raise ConnectionError(f"Cannot connect to {self.conn_uri}")
|
|
87
|
-
|
|
87
|
+
|
|
88
88
|
def get_images_dir(self) -> Path:
|
|
89
89
|
"""Get the appropriate images directory based on session type."""
|
|
90
90
|
if self.user_session:
|
|
91
91
|
return self.USER_IMAGES_DIR
|
|
92
92
|
return self.SYSTEM_IMAGES_DIR
|
|
93
|
-
|
|
93
|
+
|
|
94
|
+
def _default_network_active(self) -> bool:
|
|
95
|
+
"""Check if libvirt default network is active."""
|
|
96
|
+
try:
|
|
97
|
+
net = self.conn.networkLookupByName("default")
|
|
98
|
+
return net.isActive() == 1
|
|
99
|
+
except Exception:
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
def resolve_network_mode(self, config: VMConfig) -> str:
|
|
103
|
+
"""Resolve network mode based on config and session type."""
|
|
104
|
+
mode = (config.network_mode or "auto").lower()
|
|
105
|
+
if mode == "auto":
|
|
106
|
+
if self.user_session and not self._default_network_active():
|
|
107
|
+
return "user"
|
|
108
|
+
return "default"
|
|
109
|
+
if mode in {"default", "user"}:
|
|
110
|
+
return mode
|
|
111
|
+
return "default"
|
|
112
|
+
|
|
94
113
|
def check_prerequisites(self) -> dict:
|
|
95
114
|
"""Check system prerequisites for VM creation."""
|
|
96
115
|
images_dir = self.get_images_dir()
|
|
97
|
-
|
|
116
|
+
|
|
98
117
|
checks = {
|
|
99
118
|
"libvirt_connected": False,
|
|
100
119
|
"kvm_available": False,
|
|
@@ -103,19 +122,21 @@ class SelectiveVMCloner:
|
|
|
103
122
|
"images_dir": str(images_dir),
|
|
104
123
|
"session_type": "user" if self.user_session else "system",
|
|
105
124
|
}
|
|
106
|
-
|
|
125
|
+
|
|
107
126
|
# Check libvirt connection
|
|
108
127
|
if self.conn and self.conn.isAlive():
|
|
109
128
|
checks["libvirt_connected"] = True
|
|
110
|
-
|
|
129
|
+
|
|
111
130
|
# Check KVM
|
|
112
131
|
kvm_path = Path("/dev/kvm")
|
|
113
132
|
checks["kvm_available"] = kvm_path.exists()
|
|
114
133
|
if not checks["kvm_available"]:
|
|
115
134
|
checks["kvm_error"] = "KVM not available. Enable virtualization in BIOS."
|
|
116
135
|
elif not os.access(kvm_path, os.R_OK | os.W_OK):
|
|
117
|
-
checks["kvm_error"] =
|
|
118
|
-
|
|
136
|
+
checks["kvm_error"] = (
|
|
137
|
+
"No access to /dev/kvm. Add user to kvm group: sudo usermod -aG kvm $USER"
|
|
138
|
+
)
|
|
139
|
+
|
|
119
140
|
# Check default network
|
|
120
141
|
try:
|
|
121
142
|
net = self.conn.networkLookupByName("default")
|
|
@@ -123,10 +144,13 @@ class SelectiveVMCloner:
|
|
|
123
144
|
except libvirt.libvirtError:
|
|
124
145
|
checks["network_error"] = (
|
|
125
146
|
"Default network not found or inactive.\n"
|
|
126
|
-
"
|
|
127
|
-
" Or create
|
|
147
|
+
" For user session, CloneBox can use user-mode networking (slirp) automatically.\n"
|
|
148
|
+
" Or create a user network:\n"
|
|
149
|
+
" virsh --connect qemu:///session net-define /tmp/default-network.xml\n"
|
|
150
|
+
" virsh --connect qemu:///session net-start default\n"
|
|
151
|
+
" Or use system session: clonebox clone . (without --user)\n"
|
|
128
152
|
)
|
|
129
|
-
|
|
153
|
+
|
|
130
154
|
# Check images directory
|
|
131
155
|
if images_dir.exists():
|
|
132
156
|
checks["images_dir_writable"] = os.access(images_dir, os.W_OK)
|
|
@@ -148,30 +172,31 @@ class SelectiveVMCloner:
|
|
|
148
172
|
f"Cannot create {images_dir}\n"
|
|
149
173
|
f" Use --user flag for user session (stores in ~/.local/share/libvirt/images/)"
|
|
150
174
|
)
|
|
151
|
-
|
|
175
|
+
|
|
152
176
|
return checks
|
|
153
|
-
|
|
177
|
+
|
|
154
178
|
def create_vm(self, config: VMConfig, console=None) -> str:
|
|
155
179
|
"""
|
|
156
180
|
Create a VM with only selected applications/paths.
|
|
157
|
-
|
|
181
|
+
|
|
158
182
|
Args:
|
|
159
183
|
config: VMConfig with paths, packages, services
|
|
160
184
|
console: Rich console for output (optional)
|
|
161
|
-
|
|
185
|
+
|
|
162
186
|
Returns:
|
|
163
187
|
UUID of created VM
|
|
164
188
|
"""
|
|
189
|
+
|
|
165
190
|
def log(msg):
|
|
166
191
|
if console:
|
|
167
192
|
console.print(msg)
|
|
168
193
|
else:
|
|
169
194
|
print(msg)
|
|
170
|
-
|
|
195
|
+
|
|
171
196
|
# Determine images directory
|
|
172
197
|
images_dir = self.get_images_dir()
|
|
173
198
|
vm_dir = images_dir / config.name
|
|
174
|
-
|
|
199
|
+
|
|
175
200
|
try:
|
|
176
201
|
vm_dir.mkdir(parents=True, exist_ok=True)
|
|
177
202
|
except PermissionError as e:
|
|
@@ -187,88 +212,98 @@ class SelectiveVMCloner:
|
|
|
187
212
|
f" sudo chown -R $USER:libvirt {images_dir}\n\n"
|
|
188
213
|
f"Original error: {e}"
|
|
189
214
|
) from e
|
|
190
|
-
|
|
215
|
+
|
|
191
216
|
# Create root disk
|
|
192
217
|
root_disk = vm_dir / "root.qcow2"
|
|
193
|
-
|
|
218
|
+
|
|
194
219
|
if config.base_image and Path(config.base_image).exists():
|
|
195
220
|
# Use backing file for faster creation
|
|
196
221
|
log(f"[cyan]📀 Creating disk with backing file: {config.base_image}[/]")
|
|
197
222
|
cmd = [
|
|
198
|
-
"qemu-img",
|
|
199
|
-
"
|
|
200
|
-
|
|
223
|
+
"qemu-img",
|
|
224
|
+
"create",
|
|
225
|
+
"-f",
|
|
226
|
+
"qcow2",
|
|
227
|
+
"-b",
|
|
228
|
+
config.base_image,
|
|
229
|
+
"-F",
|
|
230
|
+
"qcow2",
|
|
231
|
+
str(root_disk),
|
|
232
|
+
f"{config.disk_size_gb}G",
|
|
201
233
|
]
|
|
202
234
|
else:
|
|
203
235
|
# Create empty disk
|
|
204
236
|
log(f"[cyan]📀 Creating empty {config.disk_size_gb}GB disk...[/]")
|
|
205
|
-
cmd = [
|
|
206
|
-
|
|
207
|
-
str(root_disk), f"{config.disk_size_gb}G"
|
|
208
|
-
]
|
|
209
|
-
|
|
237
|
+
cmd = ["qemu-img", "create", "-f", "qcow2", str(root_disk), f"{config.disk_size_gb}G"]
|
|
238
|
+
|
|
210
239
|
subprocess.run(cmd, check=True, capture_output=True)
|
|
211
|
-
|
|
240
|
+
|
|
212
241
|
# Create cloud-init ISO if packages/services specified
|
|
213
242
|
cloudinit_iso = None
|
|
214
243
|
if config.packages or config.services:
|
|
215
244
|
cloudinit_iso = self._create_cloudinit_iso(vm_dir, config)
|
|
216
245
|
log(f"[cyan]☁️ Created cloud-init ISO with {len(config.packages)} packages[/]")
|
|
217
|
-
|
|
246
|
+
|
|
247
|
+
# Resolve network mode
|
|
248
|
+
network_mode = self.resolve_network_mode(config)
|
|
249
|
+
if network_mode == "user":
|
|
250
|
+
log(
|
|
251
|
+
"[yellow]⚠️ Using user-mode networking (slirp) because default libvirt network is unavailable[/]"
|
|
252
|
+
)
|
|
253
|
+
else:
|
|
254
|
+
log(f"[dim]Network mode: {network_mode}[/]")
|
|
255
|
+
|
|
218
256
|
# Generate VM XML
|
|
219
257
|
vm_xml = self._generate_vm_xml(config, root_disk, cloudinit_iso)
|
|
220
|
-
|
|
258
|
+
|
|
221
259
|
# Define and create VM
|
|
222
260
|
log(f"[cyan]🔧 Defining VM '{config.name}'...[/]")
|
|
223
261
|
vm = self.conn.defineXML(vm_xml)
|
|
224
|
-
|
|
262
|
+
|
|
225
263
|
log(f"[green]✅ VM '{config.name}' created successfully![/]")
|
|
226
264
|
log(f"[dim] UUID: {vm.UUIDString()}[/]")
|
|
227
|
-
|
|
265
|
+
|
|
228
266
|
return vm.UUIDString()
|
|
229
|
-
|
|
267
|
+
|
|
230
268
|
def _generate_vm_xml(
|
|
231
|
-
self,
|
|
232
|
-
config: VMConfig,
|
|
233
|
-
root_disk: Path,
|
|
234
|
-
cloudinit_iso: Optional[Path]
|
|
269
|
+
self, config: VMConfig, root_disk: Path, cloudinit_iso: Optional[Path]
|
|
235
270
|
) -> str:
|
|
236
271
|
"""Generate libvirt XML for the VM."""
|
|
237
|
-
|
|
272
|
+
|
|
238
273
|
root = ET.Element("domain", type="kvm")
|
|
239
|
-
|
|
274
|
+
|
|
240
275
|
# Basic metadata
|
|
241
276
|
ET.SubElement(root, "name").text = config.name
|
|
242
277
|
ET.SubElement(root, "uuid").text = str(uuid.uuid4())
|
|
243
278
|
ET.SubElement(root, "memory", unit="MiB").text = str(config.ram_mb)
|
|
244
279
|
ET.SubElement(root, "currentMemory", unit="MiB").text = str(config.ram_mb)
|
|
245
280
|
ET.SubElement(root, "vcpu", placement="static").text = str(config.vcpus)
|
|
246
|
-
|
|
281
|
+
|
|
247
282
|
# OS configuration
|
|
248
283
|
os_elem = ET.SubElement(root, "os")
|
|
249
284
|
ET.SubElement(os_elem, "type", arch="x86_64", machine="q35").text = "hvm"
|
|
250
285
|
ET.SubElement(os_elem, "boot", dev="hd")
|
|
251
|
-
|
|
286
|
+
|
|
252
287
|
# Features
|
|
253
288
|
features = ET.SubElement(root, "features")
|
|
254
289
|
ET.SubElement(features, "acpi")
|
|
255
290
|
ET.SubElement(features, "apic")
|
|
256
|
-
|
|
291
|
+
|
|
257
292
|
# CPU
|
|
258
293
|
ET.SubElement(root, "cpu", mode="host-passthrough", check="none")
|
|
259
|
-
|
|
294
|
+
|
|
260
295
|
# Devices
|
|
261
296
|
devices = ET.SubElement(root, "devices")
|
|
262
|
-
|
|
297
|
+
|
|
263
298
|
# Emulator
|
|
264
299
|
ET.SubElement(devices, "emulator").text = "/usr/bin/qemu-system-x86_64"
|
|
265
|
-
|
|
300
|
+
|
|
266
301
|
# Root disk
|
|
267
302
|
disk = ET.SubElement(devices, "disk", type="file", device="disk")
|
|
268
303
|
ET.SubElement(disk, "driver", name="qemu", type="qcow2", cache="writeback")
|
|
269
304
|
ET.SubElement(disk, "source", file=str(root_disk))
|
|
270
305
|
ET.SubElement(disk, "target", dev="vda", bus="virtio")
|
|
271
|
-
|
|
306
|
+
|
|
272
307
|
# Cloud-init ISO
|
|
273
308
|
if cloudinit_iso:
|
|
274
309
|
cdrom = ET.SubElement(devices, "disk", type="file", device="cdrom")
|
|
@@ -276,7 +311,7 @@ class SelectiveVMCloner:
|
|
|
276
311
|
ET.SubElement(cdrom, "source", file=str(cloudinit_iso))
|
|
277
312
|
ET.SubElement(cdrom, "target", dev="sda", bus="sata")
|
|
278
313
|
ET.SubElement(cdrom, "readonly")
|
|
279
|
-
|
|
314
|
+
|
|
280
315
|
# 9p filesystem mounts (bind mounts from host)
|
|
281
316
|
for idx, (host_path, guest_tag) in enumerate(config.paths.items()):
|
|
282
317
|
if Path(host_path).exists():
|
|
@@ -286,58 +321,67 @@ class SelectiveVMCloner:
|
|
|
286
321
|
# Use simple tag names for 9p mounts
|
|
287
322
|
tag = f"mount{idx}"
|
|
288
323
|
ET.SubElement(fs, "target", dir=tag)
|
|
289
|
-
|
|
324
|
+
|
|
290
325
|
# Network interface
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
326
|
+
network_mode = self.resolve_network_mode(config)
|
|
327
|
+
if network_mode == "user":
|
|
328
|
+
iface = ET.SubElement(devices, "interface", type="user")
|
|
329
|
+
ET.SubElement(iface, "model", type="virtio")
|
|
330
|
+
else:
|
|
331
|
+
iface = ET.SubElement(devices, "interface", type="network")
|
|
332
|
+
ET.SubElement(iface, "source", network="default")
|
|
333
|
+
ET.SubElement(iface, "model", type="virtio")
|
|
334
|
+
|
|
295
335
|
# Serial console
|
|
296
336
|
serial = ET.SubElement(devices, "serial", type="pty")
|
|
297
337
|
ET.SubElement(serial, "target", port="0")
|
|
298
|
-
|
|
338
|
+
|
|
299
339
|
console_elem = ET.SubElement(devices, "console", type="pty")
|
|
300
340
|
ET.SubElement(console_elem, "target", type="serial", port="0")
|
|
301
|
-
|
|
341
|
+
|
|
302
342
|
# Graphics (SPICE)
|
|
303
343
|
if config.gui:
|
|
304
344
|
graphics = ET.SubElement(
|
|
305
|
-
devices, "graphics",
|
|
306
|
-
type="spice",
|
|
307
|
-
autoport="yes",
|
|
308
|
-
listen="127.0.0.1"
|
|
345
|
+
devices, "graphics", type="spice", autoport="yes", listen="127.0.0.1"
|
|
309
346
|
)
|
|
310
347
|
ET.SubElement(graphics, "listen", type="address", address="127.0.0.1")
|
|
311
|
-
|
|
348
|
+
|
|
312
349
|
# Video
|
|
313
350
|
video = ET.SubElement(devices, "video")
|
|
314
351
|
ET.SubElement(video, "model", type="virtio", heads="1", primary="yes")
|
|
315
|
-
|
|
352
|
+
|
|
316
353
|
# Input devices
|
|
317
354
|
ET.SubElement(devices, "input", type="tablet", bus="usb")
|
|
318
355
|
ET.SubElement(devices, "input", type="keyboard", bus="usb")
|
|
319
|
-
|
|
356
|
+
|
|
320
357
|
# Channel for guest agent
|
|
321
358
|
channel = ET.SubElement(devices, "channel", type="unix")
|
|
322
359
|
ET.SubElement(channel, "target", type="virtio", name="org.qemu.guest_agent.0")
|
|
323
|
-
|
|
360
|
+
|
|
324
361
|
# Memory balloon
|
|
325
362
|
memballoon = ET.SubElement(devices, "memballoon", model="virtio")
|
|
326
|
-
ET.SubElement(
|
|
327
|
-
|
|
328
|
-
|
|
363
|
+
ET.SubElement(
|
|
364
|
+
memballoon,
|
|
365
|
+
"address",
|
|
366
|
+
type="pci",
|
|
367
|
+
domain="0x0000",
|
|
368
|
+
bus="0x00",
|
|
369
|
+
slot="0x08",
|
|
370
|
+
function="0x0",
|
|
371
|
+
)
|
|
372
|
+
|
|
329
373
|
return ET.tostring(root, encoding="unicode")
|
|
330
|
-
|
|
374
|
+
|
|
331
375
|
def _create_cloudinit_iso(self, vm_dir: Path, config: VMConfig) -> Path:
|
|
332
376
|
"""Create cloud-init ISO with user-data and meta-data."""
|
|
333
|
-
|
|
377
|
+
|
|
334
378
|
cloudinit_dir = vm_dir / "cloud-init"
|
|
335
379
|
cloudinit_dir.mkdir(exist_ok=True)
|
|
336
|
-
|
|
380
|
+
|
|
337
381
|
# Meta-data
|
|
338
382
|
meta_data = f"instance-id: {config.name}\nlocal-hostname: {config.name}\n"
|
|
339
383
|
(cloudinit_dir / "meta-data").write_text(meta_data)
|
|
340
|
-
|
|
384
|
+
|
|
341
385
|
# Generate mount commands for 9p filesystems
|
|
342
386
|
mount_commands = []
|
|
343
387
|
for idx, (host_path, guest_path) in enumerate(config.paths.items()):
|
|
@@ -347,14 +391,18 @@ class SelectiveVMCloner:
|
|
|
347
391
|
mount_commands.append(
|
|
348
392
|
f" - mount -t 9p -o trans=virtio,version=9p2000.L {tag} {guest_path}"
|
|
349
393
|
)
|
|
350
|
-
|
|
394
|
+
|
|
351
395
|
# User-data
|
|
352
|
-
packages_yaml =
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
396
|
+
packages_yaml = (
|
|
397
|
+
"\n".join(f" - {pkg}" for pkg in config.packages) if config.packages else ""
|
|
398
|
+
)
|
|
399
|
+
services_enable = (
|
|
400
|
+
"\n".join(f" - systemctl enable --now {svc}" for svc in config.services)
|
|
401
|
+
if config.services
|
|
402
|
+
else ""
|
|
403
|
+
)
|
|
356
404
|
mounts_yaml = "\n".join(mount_commands) if mount_commands else ""
|
|
357
|
-
|
|
405
|
+
|
|
358
406
|
user_data = f"""#cloud-config
|
|
359
407
|
hostname: {config.name}
|
|
360
408
|
manage_etc_hosts: true
|
|
@@ -370,125 +418,135 @@ runcmd:
|
|
|
370
418
|
final_message: "CloneBox VM is ready after $UPTIME seconds"
|
|
371
419
|
"""
|
|
372
420
|
(cloudinit_dir / "user-data").write_text(user_data)
|
|
373
|
-
|
|
421
|
+
|
|
374
422
|
# Create ISO
|
|
375
423
|
iso_path = vm_dir / "cloud-init.iso"
|
|
376
|
-
subprocess.run(
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
424
|
+
subprocess.run(
|
|
425
|
+
[
|
|
426
|
+
"genisoimage",
|
|
427
|
+
"-output",
|
|
428
|
+
str(iso_path),
|
|
429
|
+
"-volid",
|
|
430
|
+
"cidata",
|
|
431
|
+
"-joliet",
|
|
432
|
+
"-rock",
|
|
433
|
+
str(cloudinit_dir / "user-data"),
|
|
434
|
+
str(cloudinit_dir / "meta-data"),
|
|
435
|
+
],
|
|
436
|
+
check=True,
|
|
437
|
+
capture_output=True,
|
|
438
|
+
)
|
|
439
|
+
|
|
383
440
|
return iso_path
|
|
384
|
-
|
|
441
|
+
|
|
385
442
|
def start_vm(self, vm_name: str, open_viewer: bool = True, console=None) -> bool:
|
|
386
443
|
"""Start a VM and optionally open virt-viewer."""
|
|
387
|
-
|
|
444
|
+
|
|
388
445
|
def log(msg):
|
|
389
446
|
if console:
|
|
390
447
|
console.print(msg)
|
|
391
448
|
else:
|
|
392
449
|
print(msg)
|
|
393
|
-
|
|
450
|
+
|
|
394
451
|
try:
|
|
395
452
|
vm = self.conn.lookupByName(vm_name)
|
|
396
453
|
except libvirt.libvirtError:
|
|
397
454
|
log(f"[red]❌ VM '{vm_name}' not found[/]")
|
|
398
455
|
return False
|
|
399
|
-
|
|
456
|
+
|
|
400
457
|
if vm.isActive():
|
|
401
458
|
log(f"[yellow]⚠️ VM '{vm_name}' is already running[/]")
|
|
402
459
|
else:
|
|
403
460
|
log(f"[cyan]🚀 Starting VM '{vm_name}'...[/]")
|
|
404
461
|
vm.create()
|
|
405
|
-
log(
|
|
406
|
-
|
|
462
|
+
log("[green]✅ VM started![/]")
|
|
463
|
+
|
|
407
464
|
if open_viewer:
|
|
408
|
-
log(
|
|
465
|
+
log("[cyan]🖥️ Opening virt-viewer...[/]")
|
|
409
466
|
subprocess.Popen(
|
|
410
467
|
["virt-viewer", "-c", self.conn_uri, vm_name],
|
|
411
468
|
stdout=subprocess.DEVNULL,
|
|
412
|
-
stderr=subprocess.DEVNULL
|
|
469
|
+
stderr=subprocess.DEVNULL,
|
|
413
470
|
)
|
|
414
|
-
|
|
471
|
+
|
|
415
472
|
return True
|
|
416
|
-
|
|
473
|
+
|
|
417
474
|
def stop_vm(self, vm_name: str, force: bool = False, console=None) -> bool:
|
|
418
475
|
"""Stop a VM."""
|
|
419
|
-
|
|
476
|
+
|
|
420
477
|
def log(msg):
|
|
421
478
|
if console:
|
|
422
479
|
console.print(msg)
|
|
423
480
|
else:
|
|
424
481
|
print(msg)
|
|
425
|
-
|
|
482
|
+
|
|
426
483
|
try:
|
|
427
484
|
vm = self.conn.lookupByName(vm_name)
|
|
428
485
|
except libvirt.libvirtError:
|
|
429
486
|
log(f"[red]❌ VM '{vm_name}' not found[/]")
|
|
430
487
|
return False
|
|
431
|
-
|
|
488
|
+
|
|
432
489
|
if not vm.isActive():
|
|
433
490
|
log(f"[yellow]⚠️ VM '{vm_name}' is not running[/]")
|
|
434
491
|
return True
|
|
435
|
-
|
|
492
|
+
|
|
436
493
|
if force:
|
|
437
494
|
log(f"[yellow]⚡ Force stopping VM '{vm_name}'...[/]")
|
|
438
495
|
vm.destroy()
|
|
439
496
|
else:
|
|
440
497
|
log(f"[cyan]🛑 Shutting down VM '{vm_name}'...[/]")
|
|
441
498
|
vm.shutdown()
|
|
442
|
-
|
|
443
|
-
log(
|
|
499
|
+
|
|
500
|
+
log("[green]✅ VM stopped![/]")
|
|
444
501
|
return True
|
|
445
|
-
|
|
502
|
+
|
|
446
503
|
def delete_vm(self, vm_name: str, delete_storage: bool = True, console=None) -> bool:
|
|
447
504
|
"""Delete a VM and optionally its storage."""
|
|
448
|
-
|
|
505
|
+
|
|
449
506
|
def log(msg):
|
|
450
507
|
if console:
|
|
451
508
|
console.print(msg)
|
|
452
509
|
else:
|
|
453
510
|
print(msg)
|
|
454
|
-
|
|
511
|
+
|
|
455
512
|
try:
|
|
456
513
|
vm = self.conn.lookupByName(vm_name)
|
|
457
514
|
except libvirt.libvirtError:
|
|
458
515
|
log(f"[red]❌ VM '{vm_name}' not found[/]")
|
|
459
516
|
return False
|
|
460
|
-
|
|
517
|
+
|
|
461
518
|
# Stop if running
|
|
462
519
|
if vm.isActive():
|
|
463
520
|
vm.destroy()
|
|
464
|
-
|
|
521
|
+
|
|
465
522
|
# Undefine
|
|
466
523
|
vm.undefine()
|
|
467
524
|
log(f"[green]✅ VM '{vm_name}' undefined[/]")
|
|
468
|
-
|
|
525
|
+
|
|
469
526
|
# Delete storage
|
|
470
527
|
if delete_storage:
|
|
471
528
|
vm_dir = Path(f"/var/lib/libvirt/images/{vm_name}")
|
|
472
529
|
if vm_dir.exists():
|
|
473
530
|
import shutil
|
|
531
|
+
|
|
474
532
|
shutil.rmtree(vm_dir)
|
|
475
533
|
log(f"[green]🗑️ Storage deleted: {vm_dir}[/]")
|
|
476
|
-
|
|
534
|
+
|
|
477
535
|
return True
|
|
478
|
-
|
|
536
|
+
|
|
479
537
|
def list_vms(self) -> list:
|
|
480
538
|
"""List all VMs."""
|
|
481
539
|
vms = []
|
|
482
540
|
for vm_id in self.conn.listDomainsID():
|
|
483
541
|
vm = self.conn.lookupByID(vm_id)
|
|
484
542
|
vms.append({"name": vm.name(), "state": "running", "uuid": vm.UUIDString()})
|
|
485
|
-
|
|
543
|
+
|
|
486
544
|
for name in self.conn.listDefinedDomains():
|
|
487
545
|
vm = self.conn.lookupByName(name)
|
|
488
546
|
vms.append({"name": name, "state": "stopped", "uuid": vm.UUIDString()})
|
|
489
|
-
|
|
547
|
+
|
|
490
548
|
return vms
|
|
491
|
-
|
|
549
|
+
|
|
492
550
|
def close(self):
|
|
493
551
|
"""Close libvirt connection."""
|
|
494
552
|
if self.conn:
|