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/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"] = f"No access to /dev/kvm. Add user to kvm group: sudo usermod -aG kvm $USER"
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
- " Start it with: sudo virsh net-start default\n"
127
- " Or create it: sudo virsh net-define /usr/share/libvirt/networks/default.xml"
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", "create", "-f", "qcow2",
199
- "-b", config.base_image, "-F", "qcow2",
200
- str(root_disk), f"{config.disk_size_gb}G"
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
- "qemu-img", "create", "-f", "qcow2",
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
- iface = ET.SubElement(devices, "interface", type="network")
292
- ET.SubElement(iface, "source", network="default")
293
- ET.SubElement(iface, "model", type="virtio")
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(memballoon, "address", type="pci", domain="0x0000",
327
- bus="0x00", slot="0x08", function="0x0")
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 = "\n".join(f" - {pkg}" for pkg in config.packages) if config.packages else ""
353
- services_enable = "\n".join(
354
- f" - systemctl enable --now {svc}" for svc in config.services
355
- ) if config.services else ""
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
- "genisoimage", "-output", str(iso_path),
378
- "-volid", "cidata", "-joliet", "-rock",
379
- str(cloudinit_dir / "user-data"),
380
- str(cloudinit_dir / "meta-data")
381
- ], check=True, capture_output=True)
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(f"[green]✅ VM started![/]")
406
-
462
+ log("[green]✅ VM started![/]")
463
+
407
464
  if open_viewer:
408
- log(f"[cyan]🖥️ Opening virt-viewer...[/]")
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(f"[green]✅ VM stopped![/]")
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: