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/audit.py +5 -1
- clonebox/cli.py +568 -5
- clonebox/cloner.py +181 -123
- clonebox/plugins/manager.py +85 -0
- clonebox/remote.py +511 -0
- clonebox/secrets.py +9 -6
- clonebox/validator.py +113 -41
- {clonebox-1.1.14.dist-info → clonebox-1.1.15.dist-info}/METADATA +5 -1
- {clonebox-1.1.14.dist-info → clonebox-1.1.15.dist-info}/RECORD +13 -12
- {clonebox-1.1.14.dist-info → clonebox-1.1.15.dist-info}/WHEEL +0 -0
- {clonebox-1.1.14.dist-info → clonebox-1.1.15.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.14.dist-info → clonebox-1.1.15.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.14.dist-info → clonebox-1.1.15.dist-info}/top_level.txt +0 -0
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
394
|
+
except Exception:
|
|
382
395
|
existing_vm = candidate_vm
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
existing_vm
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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.
|
|
394
|
-
f"
|
|
395
|
-
f"
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
f"
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
cmd =
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
config.
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
486
|
+
# Start if autostart requested
|
|
487
|
+
if getattr(config, "autostart", False):
|
|
488
|
+
self.start_vm(config.name, open_viewer=True)
|
|
478
489
|
|
|
479
|
-
|
|
480
|
-
|
|
490
|
+
# All good - commit transaction
|
|
491
|
+
ctx.commit()
|
|
481
492
|
|
|
482
|
-
|
|
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
|
|
1004
|
-
write_status "checking_mounts" "checking
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
1022
|
-
|
|
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
|
|
2280
|
-
" - mkfs.ext4 -F
|
|
2281
|
-
" - echo '
|
|
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
|
|
2288
|
-
" - \"echo 'To view logs on host: sudo mount -o loop
|
|
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
|
|
clonebox/plugins/manager.py
CHANGED
|
@@ -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 [
|