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