clonebox 1.1.14__py3-none-any.whl → 1.1.15__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
@@ -35,6 +35,7 @@ from clonebox.logging import get_logger, log_operation
35
35
  from clonebox.resources import ResourceLimits
36
36
  from clonebox.rollback import vm_creation_transaction
37
37
  from clonebox.secrets import SecretsManager, SSHKeyPair
38
+ from clonebox.audit import get_audit_logger, AuditEventType, AuditOutcome
38
39
 
39
40
  log = get_logger(__name__)
40
41
 
@@ -365,121 +366,131 @@ class SelectiveVMCloner:
365
366
  Returns:
366
367
  UUID of created VM
367
368
  """
368
- with log_operation(
369
- log, "vm.create", vm_name=config.name, ram_mb=config.ram_mb
370
- ):
371
- with vm_creation_transaction(self, config, console) as ctx:
372
- # If VM already exists, optionally replace it
373
- existing_vm = None
374
- try:
375
- candidate_vm = self.conn.lookupByName(config.name)
376
- if candidate_vm is not None:
377
- try:
378
- if hasattr(candidate_vm, "name") and callable(candidate_vm.name):
379
- if candidate_vm.name() == config.name:
369
+ audit = get_audit_logger()
370
+ with audit.operation(
371
+ AuditEventType.VM_CREATE,
372
+ target_type="vm",
373
+ target_name=config.name,
374
+ ) as audit_ctx:
375
+ audit_ctx.add_detail("ram_mb", config.ram_mb)
376
+ audit_ctx.add_detail("vcpus", config.vcpus)
377
+ audit_ctx.add_detail("disk_size_gb", config.disk_size_gb)
378
+
379
+ with log_operation(
380
+ log, "vm.create", vm_name=config.name, ram_mb=config.ram_mb
381
+ ):
382
+ with vm_creation_transaction(self, config, console) as ctx:
383
+ # If VM already exists, optionally replace it
384
+ existing_vm = None
385
+ try:
386
+ candidate_vm = self.conn.lookupByName(config.name)
387
+ if candidate_vm is not None:
388
+ try:
389
+ if hasattr(candidate_vm, "name") and callable(candidate_vm.name):
390
+ if candidate_vm.name() == config.name:
391
+ existing_vm = candidate_vm
392
+ else:
380
393
  existing_vm = candidate_vm
381
- else:
394
+ except Exception:
382
395
  existing_vm = candidate_vm
383
- except Exception:
384
- existing_vm = candidate_vm
385
- except Exception:
386
- existing_vm = None
387
-
388
- if existing_vm is not None:
389
- if not replace:
390
- raise RuntimeError(
391
- f"VM '{config.name}' already exists.\n\n"
396
+ except Exception:
397
+ existing_vm = None
398
+
399
+ if existing_vm is not None:
400
+ if not replace:
401
+ raise RuntimeError(
402
+ f"VM '{config.name}' already exists.\n\n"
403
+ f"🔧 Solutions:\n"
404
+ f" 1. Reuse existing VM: clonebox start {config.name}\n"
405
+ f" 2. Replace it: clonebox clone . --name {config.name} --replace\n"
406
+ f" 3. Delete it: clonebox delete {config.name}\n"
407
+ )
408
+
409
+ log.info(f"VM '{config.name}' already exists - replacing...")
410
+ self.delete_vm(config.name, delete_storage=True, console=console, ignore_not_found=True)
411
+
412
+ # Determine images directory
413
+ images_dir = self.get_images_dir()
414
+ try:
415
+ vm_dir = ctx.add_directory(images_dir / config.name)
416
+ vm_dir.mkdir(parents=True, exist_ok=True)
417
+ except PermissionError as e:
418
+ raise PermissionError(
419
+ f"Cannot create VM directory: {images_dir / config.name}\n\n"
392
420
  f"🔧 Solutions:\n"
393
- f" 1. Reuse existing VM: clonebox start {config.name}\n"
394
- f" 2. Replace it: clonebox clone . --name {config.name} --replace\n"
395
- f" 3. Delete it: clonebox delete {config.name}\n"
396
- )
397
-
398
- log.info(f"VM '{config.name}' already exists - replacing...")
399
- self.delete_vm(config.name, delete_storage=True, console=console, ignore_not_found=True)
400
-
401
- # Determine images directory
402
- images_dir = self.get_images_dir()
403
- try:
404
- vm_dir = ctx.add_directory(images_dir / config.name)
405
- vm_dir.mkdir(parents=True, exist_ok=True)
406
- except PermissionError as e:
407
- raise PermissionError(
408
- f"Cannot create VM directory: {images_dir / config.name}\n\n"
409
- f"🔧 Solutions:\n"
410
- f" 1. Use --user flag to run in user session (recommended):\n"
411
- f" clonebox clone . --user\n\n"
412
- f" 2. Run with sudo (not recommended):\n"
413
- f" sudo clonebox clone .\n\n"
414
- f" 3. Fix directory permissions:\n"
415
- f" sudo mkdir -p {images_dir}\n"
416
- f" sudo chown -R $USER:libvirt {images_dir}\n\n"
417
- f"Original error: {e}"
418
- ) from e
419
-
420
- # Create root disk
421
- root_disk = ctx.add_file(vm_dir / "root.qcow2")
422
-
423
- if not config.base_image:
424
- config.base_image = str(self._ensure_default_base_image(console=console))
425
-
426
- if config.base_image and Path(config.base_image).exists():
427
- # Use backing file for faster creation
428
- log.debug(f"Creating disk with backing file: {config.base_image}")
429
- cmd = [
430
- "qemu-img",
431
- "create",
432
- "-f",
433
- "qcow2",
434
- "-b",
435
- config.base_image,
436
- "-F",
437
- "qcow2",
438
- str(root_disk),
439
- f"{config.disk_size_gb}G",
440
- ]
441
- else:
442
- # Create empty disk
443
- log.debug(f"Creating empty {config.disk_size_gb}GB disk...")
444
- cmd = ["qemu-img", "create", "-f", "qcow2", str(root_disk), f"{config.disk_size_gb}G"]
445
-
446
- subprocess.run(cmd, check=True, capture_output=True)
447
-
448
- # Create cloud-init ISO if packages/services specified
449
- cloudinit_iso = None
450
- if (
451
- config.packages
452
- or config.services
453
- or config.snap_packages
454
- or config.post_commands
455
- or config.gui
456
- ):
457
- cloudinit_iso = ctx.add_file(self._create_cloudinit_iso(vm_dir, config))
458
- log.info(f"Created cloud-init ISO with {len(config.packages)} packages")
459
-
460
- # Generate VM XML
461
- vm_xml = self._generate_vm_xml(config, root_disk, cloudinit_iso)
462
- ctx.add_libvirt_domain(self.conn, config.name)
463
-
464
- # Define VM
465
- log.info(f"Defining VM '{config.name}'...")
466
- try:
467
- vm = self.conn.defineXML(vm_xml)
468
- except Exception as e:
469
- raise RuntimeError(
470
- f"Failed to define VM '{config.name}'.\n"
471
- f"Error: {e}\n\n"
472
- f"If the VM already exists, try: clonebox clone . --name {config.name} --replace\n"
473
- ) from e
421
+ f" 1. Use --user flag to run in user session (recommended):\n"
422
+ f" clonebox clone . --user\n\n"
423
+ f" 2. Run with sudo (not recommended):\n"
424
+ f" sudo clonebox clone .\n\n"
425
+ f" 3. Fix directory permissions:\n"
426
+ f" sudo mkdir -p {images_dir}\n"
427
+ f" sudo chown -R $USER:libvirt {images_dir}\n\n"
428
+ f"Original error: {e}"
429
+ ) from e
430
+
431
+ # Create root disk
432
+ root_disk = ctx.add_file(vm_dir / "root.qcow2")
433
+
434
+ if not config.base_image:
435
+ config.base_image = str(self._ensure_default_base_image(console=console))
436
+
437
+ if config.base_image and Path(config.base_image).exists():
438
+ # Use backing file for faster creation
439
+ log.debug(f"Creating disk with backing file: {config.base_image}")
440
+ cmd = [
441
+ "qemu-img",
442
+ "create",
443
+ "-f",
444
+ "qcow2",
445
+ "-b",
446
+ config.base_image,
447
+ "-F",
448
+ "qcow2",
449
+ str(root_disk),
450
+ f"{config.disk_size_gb}G",
451
+ ]
452
+ else:
453
+ # Create empty disk
454
+ log.debug(f"Creating empty {config.disk_size_gb}GB disk...")
455
+ cmd = ["qemu-img", "create", "-f", "qcow2", str(root_disk), f"{config.disk_size_gb}G"]
456
+
457
+ subprocess.run(cmd, check=True, capture_output=True)
458
+
459
+ # Create cloud-init ISO if packages/services specified
460
+ cloudinit_iso = None
461
+ if (
462
+ config.packages
463
+ or config.services
464
+ or config.snap_packages
465
+ or config.post_commands
466
+ or config.gui
467
+ ):
468
+ cloudinit_iso = ctx.add_file(self._create_cloudinit_iso(vm_dir, config, self.user_session))
469
+ log.info(f"Created cloud-init ISO with {len(config.packages)} packages")
470
+
471
+ # Generate VM XML
472
+ vm_xml = self._generate_vm_xml(config, root_disk, cloudinit_iso)
473
+ ctx.add_libvirt_domain(self.conn, config.name)
474
+
475
+ # Define VM
476
+ log.info(f"Defining VM '{config.name}'...")
477
+ try:
478
+ vm = self.conn.defineXML(vm_xml)
479
+ except Exception as e:
480
+ raise RuntimeError(
481
+ f"Failed to define VM '{config.name}'.\n"
482
+ f"Error: {e}\n\n"
483
+ f"If the VM already exists, try: clonebox clone . --name {config.name} --replace\n"
484
+ ) from e
474
485
 
475
- # Start if autostart requested
476
- if getattr(config, "autostart", False):
477
- self.start_vm(config.name, open_viewer=True)
486
+ # Start if autostart requested
487
+ if getattr(config, "autostart", False):
488
+ self.start_vm(config.name, open_viewer=True)
478
489
 
479
- # All good - commit transaction
480
- ctx.commit()
490
+ # All good - commit transaction
491
+ ctx.commit()
481
492
 
482
- return vm.UUIDString()
493
+ return vm.UUIDString()
483
494
 
484
495
  def _generate_vm_xml(
485
496
  self, config: VMConfig = None, root_disk: Path = None, cloudinit_iso: Optional[Path] = None
@@ -706,6 +717,9 @@ class SelectiveVMCloner:
706
717
  for snap, ifaces in SNAP_INTERFACES.items()
707
718
  )
708
719
 
720
+ mount_points_bash = "\n".join(str(p) for p in (config.paths or {}).values())
721
+ copy_paths_bash = "\n".join(str(p) for p in (config.copy_paths or {}).values())
722
+
709
723
  script = f"""#!/bin/bash
710
724
  set -uo pipefail
711
725
  LOG="/var/log/clonebox-boot.log"
@@ -1000,12 +1014,23 @@ for app in "${{APPS_TO_TEST[@]}}"; do
1000
1014
  fi
1001
1015
  done
1002
1016
 
1003
- section "5/7" "Checking mount points..."
1004
- write_status "checking_mounts" "checking mount points"
1005
- while IFS= read -r line; do
1006
- tag=$(echo "$line" | awk '{{print $1}}')
1007
- mp=$(echo "$line" | awk '{{print $2}}')
1008
- if [[ "$tag" =~ ^mount[0-9]+$ ]] && [[ "$mp" == /* ]]; then
1017
+ section "5/7" "Checking mounts & imported paths..."
1018
+ write_status "checking_mounts" "checking mounts & imported paths"
1019
+
1020
+ MOUNT_POINTS=$(cat <<'EOF'
1021
+ {mount_points_bash}
1022
+ EOF
1023
+ )
1024
+
1025
+ COPIED_PATHS=$(cat <<'EOF'
1026
+ {copy_paths_bash}
1027
+ EOF
1028
+ )
1029
+
1030
+ # Bind mounts (shared live)
1031
+ if [ -n "$(echo "$MOUNT_POINTS" | tr -d '[:space:]')" ]; then
1032
+ while IFS= read -r mp; do
1033
+ [ -z "$mp" ] && continue
1009
1034
  if mountpoint -q "$mp" 2>/dev/null; then
1010
1035
  ok "$mp mounted"
1011
1036
  else
@@ -1018,8 +1043,24 @@ while IFS= read -r line; do
1018
1043
  fail "$mp mount FAILED"
1019
1044
  fi
1020
1045
  fi
1021
- fi
1022
- done < /etc/fstab
1046
+ done <<< "$MOUNT_POINTS"
1047
+ else
1048
+ log " (no bind mounts configured)"
1049
+ fi
1050
+
1051
+ # Imported/copied paths (one-time import)
1052
+ if [ -n "$(echo "$COPIED_PATHS" | tr -d '[:space:]')" ]; then
1053
+ while IFS= read -r p; do
1054
+ [ -z "$p" ] && continue
1055
+ if [ -d "$p" ]; then
1056
+ ok "$p copied"
1057
+ else
1058
+ fail "$p missing (copy)"
1059
+ fi
1060
+ done <<< "$COPIED_PATHS"
1061
+ else
1062
+ log " (no copied paths configured)"
1063
+ fi
1023
1064
 
1024
1065
  section "6/7" "Checking services..."
1025
1066
  write_status "checking_services" "checking services"
@@ -1262,7 +1303,7 @@ fi
1262
1303
  encoded = base64.b64encode(script.encode()).decode()
1263
1304
  return encoded
1264
1305
 
1265
- def _create_cloudinit_iso(self, vm_dir: Path, config: VMConfig) -> Path:
1306
+ def _create_cloudinit_iso(self, vm_dir: Path, config: VMConfig, user_session: bool = False) -> Path:
1266
1307
  """Create cloud-init ISO with secure credential handling."""
1267
1308
  secrets_mgr = SecretsManager()
1268
1309
 
@@ -1287,6 +1328,17 @@ fi
1287
1328
  ssh_authorized_keys = [key_pair.public_key]
1288
1329
  log.info(f"SSH key generated and saved to: {ssh_key_path}")
1289
1330
 
1331
+ local_password = getattr(config, "password", None)
1332
+ if getattr(config, "gui", False) and local_password:
1333
+ chpasswd_config = (
1334
+ "chpasswd:\n"
1335
+ " list: |\n"
1336
+ f" {config.username}:{local_password}\n"
1337
+ " expire: False"
1338
+ )
1339
+ lock_passwd = "false"
1340
+ ssh_pwauth = "true"
1341
+
1290
1342
  elif auth_method == "one_time_password":
1291
1343
  otp, chpasswd_raw = SecretsManager.generate_one_time_password()
1292
1344
  chpasswd_config = chpasswd_raw
@@ -2273,19 +2325,25 @@ if __name__ == "__main__":
2273
2325
  # Note: The bash monitor is already installed above, no need to install Python monitor
2274
2326
 
2275
2327
  # Create logs disk for host access
2328
+ # Use different paths based on session type
2329
+ if user_session:
2330
+ logs_disk_path = str(Path.home() / ".local/share/libvirt/images/clonebox-logs.qcow2")
2331
+ else:
2332
+ logs_disk_path = "/var/lib/libvirt/images/clonebox-logs.qcow2"
2333
+
2276
2334
  runcmd_lines.extend(
2277
2335
  [
2278
2336
  " - mkdir -p /mnt/logs",
2279
- " - truncate -s 1G /var/lib/libvirt/images/clonebox-logs.qcow2",
2280
- " - mkfs.ext4 -F /var/lib/libvirt/images/clonebox-logs.qcow2",
2281
- " - echo '/var/lib/libvirt/images/clonebox-logs.qcow2 /mnt/logs ext4 loop,defaults 0 0' >> /etc/fstab",
2337
+ f" - truncate -s 1G {logs_disk_path}",
2338
+ f" - mkfs.ext4 -F {logs_disk_path}",
2339
+ f" - echo '{logs_disk_path} /mnt/logs ext4 loop,defaults 0 0' >> /etc/fstab",
2282
2340
  " - mount -a",
2283
2341
  " - mkdir -p /mnt/logs/var/log",
2284
2342
  " - mkdir -p /mnt/logs/tmp",
2285
2343
  " - cp -r /var/log/clonebox*.log /mnt/logs/var/log/ 2>/dev/null || true",
2286
2344
  " - cp -r /tmp/*-error.log /mnt/logs/tmp/ 2>/dev/null || true",
2287
- " - echo 'Logs disk mounted at /mnt/logs - accessible from host as /var/lib/libvirt/images/clonebox-logs.qcow2'",
2288
- " - \"echo 'To view logs on host: sudo mount -o loop /var/lib/libvirt/images/clonebox-logs.qcow2 /mnt/clonebox-logs'\"",
2345
+ f" - echo 'Logs disk mounted at /mnt/logs - accessible from host as {logs_disk_path}'",
2346
+ f" - \"echo 'To view logs on host: sudo mount -o loop {logs_disk_path} /mnt/clonebox-logs'\"",
2289
2347
  ]
2290
2348
  )
2291
2349
 
@@ -388,6 +388,91 @@ class PluginManager:
388
388
  self._save_config()
389
389
  return True
390
390
 
391
+ def install(self, source: str) -> bool:
392
+ """
393
+ Install a plugin from a source.
394
+
395
+ Sources:
396
+ - PyPI package name: "clonebox-plugin-kubernetes"
397
+ - Git URL: "git+https://github.com/user/plugin.git"
398
+ - Local path: "/path/to/plugin"
399
+
400
+ Returns True if installation succeeded.
401
+ """
402
+ import subprocess
403
+
404
+ # Handle local path
405
+ if Path(source).exists():
406
+ target_dir = self.plugin_dirs[0] # User plugins dir
407
+ target_dir.mkdir(parents=True, exist_ok=True)
408
+ source_path = Path(source)
409
+
410
+ if source_path.is_file() and source_path.suffix == ".py":
411
+ # Single file plugin
412
+ import shutil
413
+ shutil.copy(source_path, target_dir / source_path.name)
414
+ return True
415
+ elif source_path.is_dir():
416
+ # Directory plugin
417
+ import shutil
418
+ target = target_dir / source_path.name
419
+ if target.exists():
420
+ shutil.rmtree(target)
421
+ shutil.copytree(source_path, target)
422
+ return True
423
+
424
+ # Handle pip installable (PyPI or git)
425
+ try:
426
+ result = subprocess.run(
427
+ [sys.executable, "-m", "pip", "install", "--user", source],
428
+ capture_output=True,
429
+ text=True,
430
+ )
431
+ return result.returncode == 0
432
+ except Exception:
433
+ return False
434
+
435
+ def uninstall(self, name: str) -> bool:
436
+ """
437
+ Uninstall a plugin.
438
+
439
+ Returns True if uninstallation succeeded.
440
+ """
441
+ import subprocess
442
+
443
+ # Check if it's a local plugin
444
+ for plugin_dir in self.plugin_dirs:
445
+ plugin_path = plugin_dir / f"{name}.py"
446
+ plugin_pkg = plugin_dir / name
447
+
448
+ if plugin_path.exists():
449
+ plugin_path.unlink()
450
+ return True
451
+ if plugin_pkg.exists() and plugin_pkg.is_dir():
452
+ import shutil
453
+ shutil.rmtree(plugin_pkg)
454
+ return True
455
+
456
+ # Try pip uninstall
457
+ try:
458
+ result = subprocess.run(
459
+ [sys.executable, "-m", "pip", "uninstall", "-y", f"clonebox-plugin-{name}"],
460
+ capture_output=True,
461
+ text=True,
462
+ )
463
+ if result.returncode == 0:
464
+ return True
465
+
466
+ # Try with original name
467
+ result = subprocess.run(
468
+ [sys.executable, "-m", "pip", "uninstall", "-y", name],
469
+ capture_output=True,
470
+ text=True,
471
+ )
472
+ return result.returncode == 0
473
+ except Exception:
474
+ return False
475
+
391
476
  def list_plugins(self) -> List[Dict[str, Any]]:
392
477
  """List all loaded plugins."""
393
478
  return [