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/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 libvirt.libvirtError:
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"] = f"No access to /dev/kvm. Add user to kvm group: sudo usermod -aG kvm $USER"
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", "create", "-f", "qcow2",
222
- "-b", config.base_image, "-F", "qcow2",
223
- 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",
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
- "qemu-img", "create", "-f", "qcow2",
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("[yellow]⚠️ Using user-mode networking (slirp) because default libvirt network is unavailable[/]")
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(memballoon, "address", type="pci", domain="0x0000",
362
- bus="0x00", slot="0x08", function="0x0")
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 = "\n".join(f" - {pkg}" for pkg in config.packages) if config.packages else ""
388
- services_enable = "\n".join(
389
- f" - systemctl enable --now {svc}" for svc in config.services
390
- ) 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
+ )
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
- "genisoimage", "-output", str(iso_path),
413
- "-volid", "cidata", "-joliet", "-rock",
414
- str(cloudinit_dir / "user-data"),
415
- str(cloudinit_dir / "meta-data")
416
- ], check=True, capture_output=True)
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(f"[green]✅ VM started![/]")
441
-
462
+ log("[green]✅ VM started![/]")
463
+
442
464
  if open_viewer:
443
- log(f"[cyan]🖥️ Opening virt-viewer...[/]")
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(f"[green]✅ VM stopped![/]")
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: