clonebox 1.1.9__tar.gz → 1.1.11__tar.gz
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-1.1.9/src/clonebox.egg-info → clonebox-1.1.11}/PKG-INFO +1 -1
- {clonebox-1.1.9 → clonebox-1.1.11}/pyproject.toml +1 -1
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/cli.py +54 -5
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/cloner.py +141 -41
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/di.py +1 -1
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/models.py +2 -5
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/validator.py +82 -34
- {clonebox-1.1.9 → clonebox-1.1.11/src/clonebox.egg-info}/PKG-INFO +1 -1
- {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_cloner.py +18 -1
- {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_coverage_boost_final.py +4 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_network.py +14 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/LICENSE +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/README.md +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/setup.cfg +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/__init__.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/__main__.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/backends/libvirt_backend.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/backends/qemu_disk.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/backends/subprocess_runner.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/container.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/dashboard.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/detector.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/exporter.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/health/__init__.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/health/manager.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/health/models.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/health/probes.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/importer.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/interfaces/disk.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/interfaces/hypervisor.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/interfaces/network.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/interfaces/process.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/logging.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/monitor.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/p2p.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/profiles.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/resource_monitor.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/resources.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/rollback.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/secrets.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/snapshots/__init__.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/snapshots/manager.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/snapshots/models.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/templates/profiles/ml-dev.yaml +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/templates/profiles/web-stack.yaml +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox.egg-info/SOURCES.txt +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox.egg-info/dependency_links.txt +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox.egg-info/entry_points.txt +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox.egg-info/requires.txt +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox.egg-info/top_level.txt +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_cli.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_cloner_simple.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_container.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_coverage_additional.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_dashboard_coverage.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_detector.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_models.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_profiles.py +0 -0
- {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_validator.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "clonebox"
|
|
7
|
-
version = "1.1.
|
|
7
|
+
version = "1.1.11"
|
|
8
8
|
description = "Clone your workstation environment to an isolated VM with selective apps, paths and services"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "Apache-2.0"}
|
|
@@ -2724,20 +2724,69 @@ def cmd_monitor(args) -> None:
|
|
|
2724
2724
|
def cmd_exec(args) -> None:
|
|
2725
2725
|
"""Execute command in VM via QEMU Guest Agent."""
|
|
2726
2726
|
vm_name, config_file = _resolve_vm_name_and_config_file(args.name)
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2727
|
+
user_session = getattr(args, "user", False)
|
|
2728
|
+
timeout = getattr(args, "timeout", 30)
|
|
2729
|
+
|
|
2730
|
+
# When using argparse.REMAINDER for `command`, any flags placed after the VM name
|
|
2731
|
+
# may end up inside args.command. Recover common exec flags from the remainder.
|
|
2732
|
+
command_tokens = args.command
|
|
2733
|
+
if not isinstance(command_tokens, list):
|
|
2734
|
+
command_tokens = [str(command_tokens)] if command_tokens is not None else []
|
|
2735
|
+
|
|
2736
|
+
if "--" in command_tokens:
|
|
2737
|
+
sep_idx = command_tokens.index("--")
|
|
2738
|
+
pre_tokens = command_tokens[:sep_idx]
|
|
2739
|
+
post_tokens = command_tokens[sep_idx + 1 :]
|
|
2740
|
+
else:
|
|
2741
|
+
pre_tokens = command_tokens
|
|
2742
|
+
post_tokens = []
|
|
2743
|
+
|
|
2744
|
+
i = 0
|
|
2745
|
+
while i < len(pre_tokens):
|
|
2746
|
+
tok = pre_tokens[i]
|
|
2747
|
+
if tok in ("-u", "--user"):
|
|
2748
|
+
user_session = True
|
|
2749
|
+
i += 1
|
|
2750
|
+
continue
|
|
2751
|
+
if tok in ("-t", "--timeout"):
|
|
2752
|
+
if i + 1 < len(pre_tokens):
|
|
2753
|
+
try:
|
|
2754
|
+
timeout = int(pre_tokens[i + 1])
|
|
2755
|
+
except ValueError:
|
|
2756
|
+
pass
|
|
2757
|
+
i += 2
|
|
2758
|
+
continue
|
|
2759
|
+
break
|
|
2760
|
+
|
|
2761
|
+
remaining_pre = pre_tokens[i:]
|
|
2762
|
+
if post_tokens:
|
|
2763
|
+
command_tokens = remaining_pre + post_tokens
|
|
2764
|
+
else:
|
|
2765
|
+
command_tokens = remaining_pre
|
|
2766
|
+
|
|
2767
|
+
command = " ".join(command_tokens).strip()
|
|
2731
2768
|
if not command:
|
|
2732
2769
|
console.print("[red]❌ No command specified[/]")
|
|
2733
2770
|
return
|
|
2734
|
-
|
|
2771
|
+
|
|
2772
|
+
conn_uri = "qemu:///session" if user_session else "qemu:///system"
|
|
2773
|
+
other_conn_uri = "qemu:///system" if conn_uri == "qemu:///session" else "qemu:///session"
|
|
2735
2774
|
|
|
2736
2775
|
qga_ready = _qga_ping(vm_name, conn_uri)
|
|
2776
|
+
if not qga_ready:
|
|
2777
|
+
alt_ready = _qga_ping(vm_name, other_conn_uri)
|
|
2778
|
+
if alt_ready:
|
|
2779
|
+
conn_uri = other_conn_uri
|
|
2780
|
+
qga_ready = True
|
|
2737
2781
|
if not qga_ready:
|
|
2738
2782
|
for _ in range(12): # ~60s
|
|
2739
2783
|
time.sleep(5)
|
|
2740
2784
|
qga_ready = _qga_ping(vm_name, conn_uri)
|
|
2785
|
+
if not qga_ready:
|
|
2786
|
+
alt_ready = _qga_ping(vm_name, other_conn_uri)
|
|
2787
|
+
if alt_ready:
|
|
2788
|
+
conn_uri = other_conn_uri
|
|
2789
|
+
qga_ready = True
|
|
2741
2790
|
if qga_ready:
|
|
2742
2791
|
break
|
|
2743
2792
|
|
|
@@ -92,6 +92,7 @@ class VMConfig:
|
|
|
92
92
|
snap_packages: list = field(default_factory=list) # Snap packages to install
|
|
93
93
|
services: list = field(default_factory=list)
|
|
94
94
|
post_commands: list = field(default_factory=list) # Commands to run after setup
|
|
95
|
+
copy_paths: dict = field(default_factory=dict) # Paths to copy (import) instead of bind-mount
|
|
95
96
|
user_session: bool = field(
|
|
96
97
|
default_factory=lambda: os.getenv("VM_USER_SESSION", "false").lower() == "true"
|
|
97
98
|
) # Use qemu:///session instead of qemu:///system
|
|
@@ -183,7 +184,13 @@ class SelectiveVMCloner:
|
|
|
183
184
|
)
|
|
184
185
|
|
|
185
186
|
try:
|
|
186
|
-
|
|
187
|
+
# Use openAuth to avoid blocking on graphical auth dialogs (polkit)
|
|
188
|
+
# This is more robust for CLI usage
|
|
189
|
+
def auth_cb(creds, opaque):
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
auth = [[libvirt.VIR_CRED_AUTHNAME, libvirt.VIR_CRED_NOECHOPROMPT], auth_cb, None]
|
|
193
|
+
self.conn = libvirt.openAuth(self.conn_uri, auth, 0)
|
|
187
194
|
except libvirt.libvirtError as e:
|
|
188
195
|
raise ConnectionError(
|
|
189
196
|
f"Cannot connect to {self.conn_uri}\n"
|
|
@@ -523,7 +530,8 @@ class SelectiveVMCloner:
|
|
|
523
530
|
pass
|
|
524
531
|
|
|
525
532
|
# CPU tuning element
|
|
526
|
-
|
|
533
|
+
# Only available in system session (requires cgroups)
|
|
534
|
+
if not self.user_session and (limits.cpu.shares or limits.cpu.quota or limits.cpu.pin):
|
|
527
535
|
cputune = ET.SubElement(root, "cputune")
|
|
528
536
|
ET.SubElement(cputune, "shares").text = str(limits.cpu.shares)
|
|
529
537
|
if limits.cpu.quota:
|
|
@@ -534,7 +542,8 @@ class SelectiveVMCloner:
|
|
|
534
542
|
ET.SubElement(cputune, "vcpupin", vcpu=str(idx), cpuset=str(cpu))
|
|
535
543
|
|
|
536
544
|
# Memory tuning element
|
|
537
|
-
|
|
545
|
+
# Only available in system session (requires cgroups)
|
|
546
|
+
if not self.user_session and (limits.memory.soft_limit or limits.memory.swap):
|
|
538
547
|
memtune = ET.SubElement(root, "memtune")
|
|
539
548
|
ET.SubElement(memtune, "hard_limit", unit="KiB").text = str(limit_kib)
|
|
540
549
|
if limits.memory.soft_limit_bytes:
|
|
@@ -558,7 +567,8 @@ class SelectiveVMCloner:
|
|
|
558
567
|
ET.SubElement(disk, "target", dev="vda", bus="virtio")
|
|
559
568
|
|
|
560
569
|
# Disk I/O tuning
|
|
561
|
-
|
|
570
|
+
# Only available in system session (requires cgroups)
|
|
571
|
+
if not self.user_session and (limits.disk.read_bps or limits.disk.write_bps or limits.disk.read_iops or limits.disk.write_iops):
|
|
562
572
|
iotune = ET.SubElement(disk, "iotune")
|
|
563
573
|
if limits.disk.read_bps_bytes:
|
|
564
574
|
ET.SubElement(iotune, "read_bytes_sec").text = str(limits.disk.read_bps_bytes)
|
|
@@ -588,6 +598,16 @@ class SelectiveVMCloner:
|
|
|
588
598
|
tag = f"mount{idx}"
|
|
589
599
|
ET.SubElement(fs, "target", dir=tag)
|
|
590
600
|
|
|
601
|
+
# 9p filesystem mounts for COPY paths (mounted to temp location for import)
|
|
602
|
+
for idx, (host_path, guest_path) in enumerate(config.copy_paths.items()):
|
|
603
|
+
if Path(host_path).exists():
|
|
604
|
+
fs = ET.SubElement(devices, "filesystem", type="mount", accessmode="mapped")
|
|
605
|
+
ET.SubElement(fs, "driver", type="path", wrpolicy="immediate")
|
|
606
|
+
ET.SubElement(fs, "source", dir=host_path)
|
|
607
|
+
# Use import tag names for copy mounts
|
|
608
|
+
tag = f"import{idx}"
|
|
609
|
+
ET.SubElement(fs, "target", dir=tag)
|
|
610
|
+
|
|
591
611
|
# Network interface
|
|
592
612
|
network_mode = self.resolve_network_mode(config)
|
|
593
613
|
if network_mode == "user":
|
|
@@ -655,7 +675,8 @@ class SelectiveVMCloner:
|
|
|
655
675
|
import base64
|
|
656
676
|
|
|
657
677
|
wants_google_chrome = any(
|
|
658
|
-
p == "/home/ubuntu/.config/google-chrome"
|
|
678
|
+
p == "/home/ubuntu/.config/google-chrome"
|
|
679
|
+
for p in list((config.paths or {}).values()) + list((config.copy_paths or {}).values())
|
|
659
680
|
)
|
|
660
681
|
|
|
661
682
|
apt_pkg_list = list(config.packages or [])
|
|
@@ -1267,29 +1288,71 @@ fi
|
|
|
1267
1288
|
pre_chown_dirs: set[str] = set()
|
|
1268
1289
|
for idx, (host_path, guest_path) in enumerate(all_paths.items()):
|
|
1269
1290
|
if Path(host_path).exists():
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
mount_commands.append(f" -
|
|
1291
|
+
# Ensure all parent directories in /home/ubuntu are owned by user
|
|
1292
|
+
# This prevents "Permission denied" when creating config dirs (e.g. .config) as root
|
|
1293
|
+
if str(guest_path).startswith("/home/ubuntu/"):
|
|
1294
|
+
try:
|
|
1295
|
+
rel_path = Path(guest_path).relative_to("/home/ubuntu")
|
|
1296
|
+
current = Path("/home/ubuntu")
|
|
1297
|
+
# Create and chown each component in the path
|
|
1298
|
+
for part in rel_path.parts:
|
|
1299
|
+
current = current / part
|
|
1300
|
+
d_str = str(current)
|
|
1301
|
+
if d_str not in pre_chown_dirs:
|
|
1302
|
+
pre_chown_dirs.add(d_str)
|
|
1303
|
+
mount_commands.append(f" - mkdir -p {d_str}")
|
|
1304
|
+
mount_commands.append(f" - chown 1000:1000 {d_str}")
|
|
1305
|
+
except ValueError:
|
|
1306
|
+
pass
|
|
1307
|
+
|
|
1283
1308
|
tag = f"mount{idx}"
|
|
1284
1309
|
# Use uid=1000,gid=1000 to give ubuntu user access to mounts
|
|
1285
1310
|
# mmap allows proper file mapping
|
|
1286
1311
|
mount_opts = "trans=virtio,version=9p2000.L,mmap,uid=1000,gid=1000,users"
|
|
1287
|
-
|
|
1288
|
-
|
|
1312
|
+
|
|
1313
|
+
# Ensure target exists and is owned by user (if not already handled)
|
|
1314
|
+
if str(guest_path) not in pre_chown_dirs:
|
|
1315
|
+
mount_commands.append(f" - mkdir -p {guest_path}")
|
|
1316
|
+
mount_commands.append(f" - chown 1000:1000 {guest_path}")
|
|
1317
|
+
|
|
1289
1318
|
mount_commands.append(f" - mount -t 9p -o {mount_opts} {tag} {guest_path} || true")
|
|
1290
1319
|
# Add fstab entry for persistence after reboot
|
|
1291
1320
|
fstab_entries.append(f"{tag} {guest_path} 9p {mount_opts},nofail 0 0")
|
|
1292
1321
|
|
|
1322
|
+
# Handle copy_paths (import then copy)
|
|
1323
|
+
all_copy_paths = dict(config.copy_paths) if config.copy_paths else {}
|
|
1324
|
+
for idx, (host_path, guest_path) in enumerate(all_copy_paths.items()):
|
|
1325
|
+
if Path(host_path).exists():
|
|
1326
|
+
tag = f"import{idx}"
|
|
1327
|
+
temp_mount_point = f"/mnt/import{idx}"
|
|
1328
|
+
# Use regular mount options
|
|
1329
|
+
mount_opts = "trans=virtio,version=9p2000.L,mmap,uid=1000,gid=1000"
|
|
1330
|
+
|
|
1331
|
+
# 1. Create temp mount point
|
|
1332
|
+
mount_commands.append(f" - mkdir -p {temp_mount_point}")
|
|
1333
|
+
|
|
1334
|
+
# 2. Mount the 9p share
|
|
1335
|
+
mount_commands.append(f" - mount -t 9p -o {mount_opts} {tag} {temp_mount_point} || true")
|
|
1336
|
+
|
|
1337
|
+
# 3. Ensure target directory exists and permissions are prepared
|
|
1338
|
+
if str(guest_path).startswith("/home/ubuntu/"):
|
|
1339
|
+
mount_commands.append(f" - mkdir -p {guest_path}")
|
|
1340
|
+
mount_commands.append(f" - chown 1000:1000 {guest_path}")
|
|
1341
|
+
else:
|
|
1342
|
+
mount_commands.append(f" - mkdir -p {guest_path}")
|
|
1343
|
+
|
|
1344
|
+
# 4. Copy contents (cp -rT to copy contents of source to target)
|
|
1345
|
+
# We use || true to ensure boot continues even if copy fails
|
|
1346
|
+
mount_commands.append(f" - echo 'Importing {host_path} to {guest_path}...'")
|
|
1347
|
+
mount_commands.append(f" - cp -rT {temp_mount_point} {guest_path} || true")
|
|
1348
|
+
|
|
1349
|
+
# 5. Fix ownership recursively
|
|
1350
|
+
mount_commands.append(f" - chown -R 1000:1000 {guest_path}")
|
|
1351
|
+
|
|
1352
|
+
# 6. Unmount and cleanup
|
|
1353
|
+
mount_commands.append(f" - umount {temp_mount_point} || true")
|
|
1354
|
+
mount_commands.append(f" - rmdir {temp_mount_point} || true")
|
|
1355
|
+
|
|
1293
1356
|
# User-data
|
|
1294
1357
|
# Add desktop environment if GUI is enabled
|
|
1295
1358
|
base_packages = ["qemu-guest-agent", "cloud-guest-utils"]
|
|
@@ -1330,16 +1393,37 @@ fi
|
|
|
1330
1393
|
for cmd in mount_commands:
|
|
1331
1394
|
runcmd_lines.append(cmd)
|
|
1332
1395
|
|
|
1396
|
+
# Create user directories with correct permissions EARLY to avoid race conditions with GDM
|
|
1397
|
+
if config.gui:
|
|
1398
|
+
# Create directories that GNOME services need
|
|
1399
|
+
runcmd_lines.extend(
|
|
1400
|
+
[
|
|
1401
|
+
" - mkdir -p /home/ubuntu/.config/pulse /home/ubuntu/.cache/ibus /home/ubuntu/.local/share",
|
|
1402
|
+
" - mkdir -p /home/ubuntu/.config/dconf /home/ubuntu/.cache/tracker3",
|
|
1403
|
+
" - mkdir -p /home/ubuntu/.config/autostart",
|
|
1404
|
+
" - chown -R 1000:1000 /home/ubuntu/.config /home/ubuntu/.cache /home/ubuntu/.local",
|
|
1405
|
+
" - chmod 700 /home/ubuntu/.config /home/ubuntu/.cache",
|
|
1406
|
+
" - systemctl set-default graphical.target",
|
|
1407
|
+
" - systemctl enable gdm3 || systemctl enable gdm || true",
|
|
1408
|
+
]
|
|
1409
|
+
)
|
|
1410
|
+
|
|
1333
1411
|
runcmd_lines.append(" - chown -R 1000:1000 /home/ubuntu || true")
|
|
1334
1412
|
runcmd_lines.append(" - chown -R 1000:1000 /home/ubuntu/snap || true")
|
|
1335
1413
|
|
|
1336
|
-
# Install snap packages
|
|
1414
|
+
# Install snap packages (with retry logic)
|
|
1337
1415
|
if config.snap_packages:
|
|
1338
1416
|
runcmd_lines.append(" - echo 'Installing snap packages...'")
|
|
1339
1417
|
for snap_pkg in config.snap_packages:
|
|
1340
|
-
|
|
1341
|
-
|
|
1418
|
+
# Try classic first, then strict, with retries
|
|
1419
|
+
cmd = (
|
|
1420
|
+
f"for i in 1 2 3; do "
|
|
1421
|
+
f"snap install {snap_pkg} --classic && break || "
|
|
1422
|
+
f"snap install {snap_pkg} && break || "
|
|
1423
|
+
f"sleep 10; "
|
|
1424
|
+
f"done"
|
|
1342
1425
|
)
|
|
1426
|
+
runcmd_lines.append(f" - {cmd}")
|
|
1343
1427
|
|
|
1344
1428
|
# Connect snap interfaces for GUI apps (not auto-connected via cloud-init)
|
|
1345
1429
|
runcmd_lines.append(" - echo 'Connecting snap interfaces...'")
|
|
@@ -1352,22 +1436,8 @@ fi
|
|
|
1352
1436
|
|
|
1353
1437
|
runcmd_lines.append(" - systemctl restart snapd || true")
|
|
1354
1438
|
|
|
1355
|
-
# Add GUI setup if enabled
|
|
1439
|
+
# Add remaining GUI setup if enabled
|
|
1356
1440
|
if config.gui:
|
|
1357
|
-
# Create directories that GNOME services need BEFORE GUI starts
|
|
1358
|
-
# These may conflict with mounted host directories, so ensure they exist with correct perms
|
|
1359
|
-
runcmd_lines.extend(
|
|
1360
|
-
[
|
|
1361
|
-
" - mkdir -p /home/ubuntu/.config/pulse /home/ubuntu/.cache/ibus /home/ubuntu/.local/share",
|
|
1362
|
-
" - mkdir -p /home/ubuntu/.config/dconf /home/ubuntu/.cache/tracker3",
|
|
1363
|
-
" - mkdir -p /home/ubuntu/.config/autostart",
|
|
1364
|
-
" - chown -R 1000:1000 /home/ubuntu/.config /home/ubuntu/.cache /home/ubuntu/.local",
|
|
1365
|
-
" - chmod 700 /home/ubuntu/.config /home/ubuntu/.cache",
|
|
1366
|
-
" - systemctl set-default graphical.target",
|
|
1367
|
-
" - systemctl enable gdm3 || systemctl enable gdm || true",
|
|
1368
|
-
]
|
|
1369
|
-
)
|
|
1370
|
-
|
|
1371
1441
|
# Create autostart entries for GUI apps
|
|
1372
1442
|
autostart_apps = {
|
|
1373
1443
|
"pycharm-community": (
|
|
@@ -1861,8 +1931,14 @@ esac
|
|
|
1861
1931
|
}
|
|
1862
1932
|
)
|
|
1863
1933
|
|
|
1864
|
-
# Check for google-chrome from app_data_paths
|
|
1865
|
-
|
|
1934
|
+
# Check for google-chrome from app_data_paths (now in copy_paths or paths)
|
|
1935
|
+
all_paths_to_check = {}
|
|
1936
|
+
if config.paths:
|
|
1937
|
+
all_paths_to_check.update(config.paths)
|
|
1938
|
+
if config.copy_paths:
|
|
1939
|
+
all_paths_to_check.update(config.copy_paths)
|
|
1940
|
+
|
|
1941
|
+
for host_path, guest_path in all_paths_to_check.items():
|
|
1866
1942
|
if guest_path == "/home/ubuntu/.config/google-chrome":
|
|
1867
1943
|
autostart_apps.append(
|
|
1868
1944
|
{
|
|
@@ -2315,7 +2391,31 @@ final_message: "CloneBox VM is ready after $UPTIME seconds"
|
|
|
2315
2391
|
vm.destroy()
|
|
2316
2392
|
else:
|
|
2317
2393
|
log(f"[cyan]🛑 Shutting down VM '{vm_name}'...[/]")
|
|
2318
|
-
|
|
2394
|
+
try:
|
|
2395
|
+
vm.shutdown()
|
|
2396
|
+
except libvirt.libvirtError as e:
|
|
2397
|
+
log(f"[red]❌ Failed to request shutdown: {e}[/]")
|
|
2398
|
+
return False
|
|
2399
|
+
|
|
2400
|
+
# Wait for shutdown
|
|
2401
|
+
import time
|
|
2402
|
+
waiting = True
|
|
2403
|
+
for i in range(30):
|
|
2404
|
+
try:
|
|
2405
|
+
if not vm.isActive():
|
|
2406
|
+
waiting = False
|
|
2407
|
+
break
|
|
2408
|
+
except libvirt.libvirtError:
|
|
2409
|
+
# Domain might be gone
|
|
2410
|
+
waiting = False
|
|
2411
|
+
break
|
|
2412
|
+
time.sleep(1)
|
|
2413
|
+
|
|
2414
|
+
if waiting:
|
|
2415
|
+
log(f"[red]❌ Shutdown timed out. VM is still running.[/]")
|
|
2416
|
+
log(f"[dim]The guest OS is not responding to ACPI shutdown signal.[/]")
|
|
2417
|
+
log(f"[dim]Try using: clonebox stop {vm_name} --force[/]")
|
|
2418
|
+
return False
|
|
2319
2419
|
|
|
2320
2420
|
log("[green]✅ VM stopped![/]")
|
|
2321
2421
|
return True
|
|
@@ -116,10 +116,6 @@ class CloneBoxConfig(BaseModel):
|
|
|
116
116
|
"""Convert to legacy VMConfig dataclass for compatibility."""
|
|
117
117
|
from clonebox.cloner import VMConfig as VMConfigDataclass
|
|
118
118
|
|
|
119
|
-
# Merge paths and app_data_paths
|
|
120
|
-
all_paths = dict(self.paths)
|
|
121
|
-
all_paths.update(self.app_data_paths)
|
|
122
|
-
|
|
123
119
|
return VMConfigDataclass(
|
|
124
120
|
name=self.vm.name,
|
|
125
121
|
ram_mb=self.vm.ram_mb,
|
|
@@ -127,7 +123,8 @@ class CloneBoxConfig(BaseModel):
|
|
|
127
123
|
disk_size_gb=self.vm.disk_size_gb,
|
|
128
124
|
gui=self.vm.gui,
|
|
129
125
|
base_image=self.vm.base_image,
|
|
130
|
-
paths=
|
|
126
|
+
paths=self.paths,
|
|
127
|
+
copy_paths=self.app_data_paths, # Map app_data_paths to copy_paths
|
|
131
128
|
packages=self.packages,
|
|
132
129
|
snap_packages=self.snap_packages,
|
|
133
130
|
services=self.services,
|
|
@@ -106,14 +106,14 @@ class VMValidator:
|
|
|
106
106
|
return None
|
|
107
107
|
|
|
108
108
|
def validate_mounts(self) -> Dict:
|
|
109
|
-
"""Validate all mount points
|
|
110
|
-
self.console.print("\n[bold]💾 Validating
|
|
109
|
+
"""Validate all mount points and copied data paths."""
|
|
110
|
+
self.console.print("\n[bold]💾 Validating Mounts & Data...[/]")
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
paths = self.config.get("paths", {})
|
|
113
|
+
app_data_paths = self.config.get("app_data_paths", {})
|
|
114
114
|
|
|
115
|
-
if not
|
|
116
|
-
self.console.print("[dim]No
|
|
115
|
+
if not paths and not app_data_paths:
|
|
116
|
+
self.console.print("[dim]No mounts or data paths configured[/]")
|
|
117
117
|
return self.results["mounts"]
|
|
118
118
|
|
|
119
119
|
# Get mounted filesystems
|
|
@@ -122,64 +122,95 @@ class VMValidator:
|
|
|
122
122
|
if mount_output:
|
|
123
123
|
mounted_paths = [line.split()[2] for line in mount_output.split("\n") if line.strip()]
|
|
124
124
|
|
|
125
|
-
mount_table = Table(title="
|
|
125
|
+
mount_table = Table(title="Data Validation", border_style="cyan")
|
|
126
126
|
mount_table.add_column("Guest Path", style="bold")
|
|
127
|
-
mount_table.add_column("
|
|
128
|
-
mount_table.add_column("
|
|
127
|
+
mount_table.add_column("Type", justify="center")
|
|
128
|
+
mount_table.add_column("Status", justify="center")
|
|
129
129
|
mount_table.add_column("Files", justify="right")
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
# Validate bind mounts (paths)
|
|
132
|
+
for host_path, guest_path in paths.items():
|
|
132
133
|
self.results["mounts"]["total"] += 1
|
|
133
|
-
|
|
134
|
+
|
|
134
135
|
# Check if mounted
|
|
135
136
|
is_mounted = any(guest_path in mp for mp in mounted_paths)
|
|
136
|
-
|
|
137
|
+
|
|
137
138
|
# Check if accessible
|
|
138
139
|
accessible = False
|
|
139
140
|
file_count = "?"
|
|
140
|
-
|
|
141
|
+
|
|
141
142
|
if is_mounted:
|
|
142
143
|
test_result = self._exec_in_vm(f"test -d {guest_path} && echo 'yes' || echo 'no'")
|
|
143
144
|
accessible = test_result == "yes"
|
|
144
|
-
|
|
145
|
+
|
|
145
146
|
if accessible:
|
|
146
|
-
# Get file count
|
|
147
147
|
count_str = self._exec_in_vm(f"ls -A {guest_path} 2>/dev/null | wc -l")
|
|
148
148
|
if count_str and count_str.isdigit():
|
|
149
149
|
file_count = count_str
|
|
150
150
|
|
|
151
|
-
# Determine status
|
|
152
151
|
if is_mounted and accessible:
|
|
153
|
-
|
|
154
|
-
access_status = "[green]✅[/]"
|
|
152
|
+
status_icon = "[green]✅ Mounted[/]"
|
|
155
153
|
self.results["mounts"]["passed"] += 1
|
|
156
154
|
status = "pass"
|
|
157
155
|
elif is_mounted:
|
|
158
|
-
|
|
159
|
-
access_status = "[red]❌[/]"
|
|
156
|
+
status_icon = "[red]❌ Inaccessible[/]"
|
|
160
157
|
self.results["mounts"]["failed"] += 1
|
|
161
158
|
status = "mounted_but_inaccessible"
|
|
162
159
|
else:
|
|
163
|
-
|
|
164
|
-
access_status = "[dim]N/A[/]"
|
|
160
|
+
status_icon = "[red]❌ Not Mounted[/]"
|
|
165
161
|
self.results["mounts"]["failed"] += 1
|
|
166
162
|
status = "not_mounted"
|
|
167
163
|
|
|
168
|
-
mount_table.add_row(guest_path,
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
164
|
+
mount_table.add_row(guest_path, "Bind Mount", status_icon, str(file_count))
|
|
165
|
+
self.results["mounts"]["details"].append({
|
|
166
|
+
"path": guest_path,
|
|
167
|
+
"type": "mount",
|
|
168
|
+
"mounted": is_mounted,
|
|
169
|
+
"accessible": accessible,
|
|
170
|
+
"files": file_count,
|
|
171
|
+
"status": status
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
# Validate copied paths (app_data_paths)
|
|
175
|
+
for host_path, guest_path in app_data_paths.items():
|
|
176
|
+
self.results["mounts"]["total"] += 1
|
|
177
|
+
|
|
178
|
+
# Check if exists and has content
|
|
179
|
+
exists = False
|
|
180
|
+
file_count = "?"
|
|
181
|
+
|
|
182
|
+
test_result = self._exec_in_vm(f"test -d {guest_path} && echo 'yes' || echo 'no'")
|
|
183
|
+
exists = test_result == "yes"
|
|
184
|
+
|
|
185
|
+
if exists:
|
|
186
|
+
count_str = self._exec_in_vm(f"ls -A {guest_path} 2>/dev/null | wc -l")
|
|
187
|
+
if count_str and count_str.isdigit():
|
|
188
|
+
file_count = count_str
|
|
189
|
+
|
|
190
|
+
# For copied paths, we just check existence and content
|
|
191
|
+
if exists:
|
|
192
|
+
# Warning if empty? Maybe, but strictly it passed existence check
|
|
193
|
+
status_icon = "[green]✅ Copied[/]"
|
|
194
|
+
self.results["mounts"]["passed"] += 1
|
|
195
|
+
status = "pass"
|
|
196
|
+
else:
|
|
197
|
+
status_icon = "[red]❌ Missing[/]"
|
|
198
|
+
self.results["mounts"]["failed"] += 1
|
|
199
|
+
status = "missing"
|
|
200
|
+
|
|
201
|
+
mount_table.add_row(guest_path, "Imported", status_icon, str(file_count))
|
|
202
|
+
self.results["mounts"]["details"].append({
|
|
203
|
+
"path": guest_path,
|
|
204
|
+
"type": "copy",
|
|
205
|
+
"mounted": False, # Expected false for copies
|
|
206
|
+
"accessible": exists,
|
|
207
|
+
"files": file_count,
|
|
208
|
+
"status": status
|
|
209
|
+
})
|
|
179
210
|
|
|
180
211
|
self.console.print(mount_table)
|
|
181
212
|
self.console.print(
|
|
182
|
-
f"[dim]{self.results['mounts']['passed']}/{self.results['mounts']['total']}
|
|
213
|
+
f"[dim]{self.results['mounts']['passed']}/{self.results['mounts']['total']} paths valid[/]"
|
|
183
214
|
)
|
|
184
215
|
|
|
185
216
|
return self.results["mounts"]
|
|
@@ -897,6 +928,23 @@ class VMValidator:
|
|
|
897
928
|
self.results["overall"] = "qga_not_ready"
|
|
898
929
|
return self.results
|
|
899
930
|
|
|
931
|
+
ci_status = self._exec_in_vm("cloud-init status --long 2>/dev/null || cloud-init status 2>/dev/null || true", timeout=20)
|
|
932
|
+
if ci_status:
|
|
933
|
+
ci_lower = ci_status.lower()
|
|
934
|
+
if "running" in ci_lower:
|
|
935
|
+
self.console.print("[yellow]⏳ Cloud-init still running - skipping deep validation for now[/]")
|
|
936
|
+
self.results["overall"] = "cloud_init_running"
|
|
937
|
+
return self.results
|
|
938
|
+
|
|
939
|
+
ready_msg = self._exec_in_vm(
|
|
940
|
+
"cat /var/log/clonebox-ready 2>/dev/null || true",
|
|
941
|
+
timeout=10,
|
|
942
|
+
)
|
|
943
|
+
if not (ready_msg and "clonebox vm ready" in ready_msg.lower()):
|
|
944
|
+
self.console.print(
|
|
945
|
+
"[yellow]⚠️ CloneBox ready marker not found - provisioning may not have completed[/]"
|
|
946
|
+
)
|
|
947
|
+
|
|
900
948
|
# Run all validations
|
|
901
949
|
self.validate_mounts()
|
|
902
950
|
self.validate_packages()
|
|
@@ -72,12 +72,14 @@ class TestSelectiveVMClonerInit:
|
|
|
72
72
|
@patch("clonebox.cloner.libvirt")
|
|
73
73
|
def test_system_images_dir(self, mock_libvirt):
|
|
74
74
|
mock_libvirt.open.return_value = MagicMock()
|
|
75
|
+
mock_libvirt.openAuth.return_value = MagicMock()
|
|
75
76
|
cloner = SelectiveVMCloner()
|
|
76
77
|
assert cloner.SYSTEM_IMAGES_DIR == Path("/var/lib/libvirt/images")
|
|
77
78
|
|
|
78
79
|
@patch("clonebox.cloner.libvirt")
|
|
79
80
|
def test_user_images_dir(self, mock_libvirt):
|
|
80
81
|
mock_libvirt.open.return_value = MagicMock()
|
|
82
|
+
mock_libvirt.openAuth.return_value = MagicMock()
|
|
81
83
|
cloner = SelectiveVMCloner()
|
|
82
84
|
expected = Path.home() / ".local/share/libvirt/images"
|
|
83
85
|
assert cloner.USER_IMAGES_DIR == expected
|
|
@@ -93,17 +95,22 @@ class TestSelectiveVMClonerInit:
|
|
|
93
95
|
def test_init_session_type(self, mock_libvirt, user_session, expected_uri):
|
|
94
96
|
mock_conn = MagicMock()
|
|
95
97
|
mock_libvirt.open.return_value = mock_conn
|
|
98
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
96
99
|
|
|
97
100
|
cloner = SelectiveVMCloner(user_session=user_session)
|
|
98
101
|
|
|
99
102
|
assert cloner.conn_uri == expected_uri
|
|
100
103
|
assert cloner.user_session is user_session
|
|
101
|
-
|
|
104
|
+
|
|
105
|
+
# Verify openAuth was called with expected URI
|
|
106
|
+
args, _ = mock_libvirt.openAuth.call_args
|
|
107
|
+
assert args[0] == expected_uri
|
|
102
108
|
|
|
103
109
|
@patch("clonebox.cloner.libvirt")
|
|
104
110
|
def test_init_custom_uri(self, mock_libvirt):
|
|
105
111
|
mock_conn = MagicMock()
|
|
106
112
|
mock_libvirt.open.return_value = mock_conn
|
|
113
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
107
114
|
|
|
108
115
|
cloner = SelectiveVMCloner(conn_uri="qemu+ssh://host/system")
|
|
109
116
|
|
|
@@ -122,6 +129,7 @@ class TestSelectiveVMClonerInit:
|
|
|
122
129
|
|
|
123
130
|
mock_libvirt.libvirtError = real_libvirt.libvirtError
|
|
124
131
|
mock_libvirt.open.side_effect = real_libvirt.libvirtError("Connection refused")
|
|
132
|
+
mock_libvirt.openAuth.side_effect = real_libvirt.libvirtError("Connection refused")
|
|
125
133
|
except ImportError:
|
|
126
134
|
# If libvirt is not installed, create a mock exception
|
|
127
135
|
class MockLibvirtError(Exception):
|
|
@@ -129,6 +137,7 @@ class TestSelectiveVMClonerInit:
|
|
|
129
137
|
|
|
130
138
|
mock_libvirt.libvirtError = MockLibvirtError
|
|
131
139
|
mock_libvirt.open.side_effect = MockLibvirtError("Connection refused")
|
|
140
|
+
mock_libvirt.openAuth.side_effect = MockLibvirtError("Connection refused")
|
|
132
141
|
|
|
133
142
|
with pytest.raises(ConnectionError) as exc_info:
|
|
134
143
|
SelectiveVMCloner()
|
|
@@ -149,6 +158,7 @@ class TestSelectiveVMClonerMethods:
|
|
|
149
158
|
@patch("clonebox.cloner.libvirt")
|
|
150
159
|
def test_get_images_dir(self, mock_libvirt, user_session, expected_path):
|
|
151
160
|
mock_libvirt.open.return_value = MagicMock()
|
|
161
|
+
mock_libvirt.openAuth.return_value = MagicMock()
|
|
152
162
|
|
|
153
163
|
cloner = SelectiveVMCloner(user_session=user_session)
|
|
154
164
|
assert cloner.get_images_dir() == expected_path
|
|
@@ -159,6 +169,7 @@ class TestSelectiveVMClonerMethods:
|
|
|
159
169
|
mock_conn.isAlive.return_value = True
|
|
160
170
|
mock_conn.networkLookupByName.return_value.isActive.return_value = 1
|
|
161
171
|
mock_libvirt.open.return_value = mock_conn
|
|
172
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
162
173
|
|
|
163
174
|
cloner = SelectiveVMCloner(user_session=True) # Use user session to avoid permission issues
|
|
164
175
|
checks = cloner.check_prerequisites()
|
|
@@ -182,6 +193,7 @@ class TestSelectiveVMClonerMethods:
|
|
|
182
193
|
mock_conn = MagicMock()
|
|
183
194
|
mock_conn.isAlive.return_value = True
|
|
184
195
|
mock_libvirt.open.return_value = mock_conn
|
|
196
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
185
197
|
|
|
186
198
|
cloner = SelectiveVMCloner(user_session=user_session)
|
|
187
199
|
assert cloner.check_prerequisites()["session_type"] == expected_type
|
|
@@ -203,6 +215,7 @@ class TestSelectiveVMClonerMethods:
|
|
|
203
215
|
mock_conn.lookupByID.return_value = running_vm
|
|
204
216
|
mock_conn.lookupByName.return_value = stopped_vm
|
|
205
217
|
mock_libvirt.open.return_value = mock_conn
|
|
218
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
206
219
|
|
|
207
220
|
cloner = SelectiveVMCloner()
|
|
208
221
|
vms = cloner.list_vms()
|
|
@@ -217,6 +230,7 @@ class TestSelectiveVMClonerMethods:
|
|
|
217
230
|
def test_close(self, mock_libvirt):
|
|
218
231
|
mock_conn = MagicMock()
|
|
219
232
|
mock_libvirt.open.return_value = mock_conn
|
|
233
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
220
234
|
|
|
221
235
|
cloner = SelectiveVMCloner()
|
|
222
236
|
cloner.close()
|
|
@@ -230,6 +244,7 @@ class TestVMXMLGeneration:
|
|
|
230
244
|
@patch("clonebox.cloner.libvirt")
|
|
231
245
|
def test_generate_vm_xml_basic(self, mock_libvirt):
|
|
232
246
|
mock_libvirt.open.return_value = MagicMock()
|
|
247
|
+
mock_libvirt.openAuth.return_value = MagicMock()
|
|
233
248
|
|
|
234
249
|
cloner = SelectiveVMCloner()
|
|
235
250
|
config = VMConfig(name="test-vm", ram_mb=2048, vcpus=2)
|
|
@@ -245,6 +260,7 @@ class TestVMXMLGeneration:
|
|
|
245
260
|
@patch("clonebox.cloner.libvirt")
|
|
246
261
|
def test_generate_vm_xml_with_paths(self, mock_libvirt):
|
|
247
262
|
mock_libvirt.open.return_value = MagicMock()
|
|
263
|
+
mock_libvirt.openAuth.return_value = MagicMock()
|
|
248
264
|
|
|
249
265
|
cloner = SelectiveVMCloner()
|
|
250
266
|
config = VMConfig(name="test-vm", paths={"/home/user/project": "/mnt/project"})
|
|
@@ -266,6 +282,7 @@ class TestVMCreation:
|
|
|
266
282
|
mock_conn = MagicMock()
|
|
267
283
|
mock_conn.lookupByName.side_effect = Exception("not found")
|
|
268
284
|
mock_libvirt.open.return_value = mock_conn
|
|
285
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
269
286
|
|
|
270
287
|
cloner = SelectiveVMCloner()
|
|
271
288
|
config = VMConfig(name="test-vm")
|
|
@@ -137,6 +137,7 @@ def test_cloner_additional_branches():
|
|
|
137
137
|
with patch("clonebox.cloner.libvirt") as mock_libvirt:
|
|
138
138
|
mock_conn = Mock()
|
|
139
139
|
mock_libvirt.open.return_value = mock_conn
|
|
140
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
140
141
|
cloner = SelectiveVMCloner()
|
|
141
142
|
|
|
142
143
|
# Cover _get_downloads_dir
|
|
@@ -164,6 +165,7 @@ def test_cloner_create_vm_branches():
|
|
|
164
165
|
with patch("clonebox.cloner.libvirt") as mock_libvirt:
|
|
165
166
|
mock_conn = Mock()
|
|
166
167
|
mock_libvirt.open.return_value = mock_conn
|
|
168
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
167
169
|
cloner = SelectiveVMCloner()
|
|
168
170
|
|
|
169
171
|
config = VMConfig(name="test-vm", packages=["vim"])
|
|
@@ -299,6 +301,7 @@ def test_cloner_cloudinit_generation():
|
|
|
299
301
|
with patch("clonebox.cloner.libvirt") as mock_libvirt:
|
|
300
302
|
mock_conn = Mock()
|
|
301
303
|
mock_libvirt.open.return_value = mock_conn
|
|
304
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
302
305
|
cloner = SelectiveVMCloner()
|
|
303
306
|
|
|
304
307
|
config = VMConfig(
|
|
@@ -332,6 +335,7 @@ def test_cloner_delete_vm_branches():
|
|
|
332
335
|
with patch("clonebox.cloner.libvirt") as mock_libvirt:
|
|
333
336
|
mock_conn = Mock()
|
|
334
337
|
mock_libvirt.open.return_value = mock_conn
|
|
338
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
335
339
|
cloner = SelectiveVMCloner()
|
|
336
340
|
|
|
337
341
|
mock_vm = Mock()
|
|
@@ -40,6 +40,7 @@ class TestNetworkMode:
|
|
|
40
40
|
"""Test auto mode with system session uses default network."""
|
|
41
41
|
mock_conn = MagicMock()
|
|
42
42
|
mock_libvirt.open.return_value = mock_conn
|
|
43
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
43
44
|
|
|
44
45
|
cloner = SelectiveVMCloner(user_session=False)
|
|
45
46
|
config = VMConfig(network_mode="auto")
|
|
@@ -51,6 +52,8 @@ class TestNetworkMode:
|
|
|
51
52
|
def test_resolve_network_mode_auto_user_with_default(self, mock_libvirt):
|
|
52
53
|
"""Test auto mode with user session and default network available."""
|
|
53
54
|
mock_conn = MagicMock()
|
|
55
|
+
mock_libvirt.open.return_value = mock_conn
|
|
56
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
54
57
|
mock_net = MagicMock()
|
|
55
58
|
mock_net.isActive.return_value = 1
|
|
56
59
|
mock_conn.networkLookupByName.return_value = mock_net
|
|
@@ -78,6 +81,7 @@ class TestNetworkMode:
|
|
|
78
81
|
mock_conn = MagicMock()
|
|
79
82
|
mock_conn.networkLookupByName.side_effect = libvirt_error("No network")
|
|
80
83
|
mock_libvirt.open.return_value = mock_conn
|
|
84
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
81
85
|
|
|
82
86
|
cloner = SelectiveVMCloner(user_session=True)
|
|
83
87
|
config = VMConfig(network_mode="auto")
|
|
@@ -90,6 +94,7 @@ class TestNetworkMode:
|
|
|
90
94
|
"""Test explicit default mode."""
|
|
91
95
|
mock_conn = MagicMock()
|
|
92
96
|
mock_libvirt.open.return_value = mock_conn
|
|
97
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
93
98
|
|
|
94
99
|
cloner = SelectiveVMCloner(user_session=True)
|
|
95
100
|
config = VMConfig(network_mode="default")
|
|
@@ -102,6 +107,7 @@ class TestNetworkMode:
|
|
|
102
107
|
"""Test explicit user mode."""
|
|
103
108
|
mock_conn = MagicMock()
|
|
104
109
|
mock_libvirt.open.return_value = mock_conn
|
|
110
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
105
111
|
|
|
106
112
|
cloner = SelectiveVMCloner(user_session=False)
|
|
107
113
|
config = VMConfig(network_mode="user")
|
|
@@ -114,6 +120,7 @@ class TestNetworkMode:
|
|
|
114
120
|
"""Test invalid network mode falls back to default."""
|
|
115
121
|
mock_conn = MagicMock()
|
|
116
122
|
mock_libvirt.open.return_value = mock_conn
|
|
123
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
117
124
|
|
|
118
125
|
cloner = SelectiveVMCloner()
|
|
119
126
|
config = VMConfig(network_mode="invalid")
|
|
@@ -125,6 +132,8 @@ class TestNetworkMode:
|
|
|
125
132
|
def test_default_network_active_true(self, mock_libvirt):
|
|
126
133
|
"""Test _default_network_active returns True when network is active."""
|
|
127
134
|
mock_conn = MagicMock()
|
|
135
|
+
mock_libvirt.open.return_value = mock_conn
|
|
136
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
128
137
|
mock_net = MagicMock()
|
|
129
138
|
mock_net.isActive.return_value = 1
|
|
130
139
|
mock_conn.networkLookupByName.return_value = mock_net
|
|
@@ -138,6 +147,8 @@ class TestNetworkMode:
|
|
|
138
147
|
"""Test _default_network_active returns False when network is inactive."""
|
|
139
148
|
|
|
140
149
|
mock_conn = MagicMock()
|
|
150
|
+
mock_libvirt.open.return_value = mock_conn
|
|
151
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
141
152
|
mock_net = MagicMock()
|
|
142
153
|
mock_net.isActive.return_value = 0
|
|
143
154
|
mock_conn.networkLookupByName.return_value = mock_net
|
|
@@ -162,6 +173,7 @@ class TestNetworkMode:
|
|
|
162
173
|
mock_conn = MagicMock()
|
|
163
174
|
mock_conn.networkLookupByName.side_effect = libvirt_error("Not found")
|
|
164
175
|
mock_libvirt.open.return_value = mock_conn
|
|
176
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
165
177
|
|
|
166
178
|
cloner = SelectiveVMCloner()
|
|
167
179
|
assert cloner._default_network_active() is False
|
|
@@ -171,6 +183,7 @@ class TestNetworkMode:
|
|
|
171
183
|
"""Test VM XML generation with user network."""
|
|
172
184
|
mock_conn = MagicMock()
|
|
173
185
|
mock_libvirt.open.return_value = mock_conn
|
|
186
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
174
187
|
|
|
175
188
|
cloner = SelectiveVMCloner()
|
|
176
189
|
config = VMConfig(name="test-vm", network_mode="user")
|
|
@@ -185,6 +198,7 @@ class TestNetworkMode:
|
|
|
185
198
|
"""Test VM XML generation with default network."""
|
|
186
199
|
mock_conn = MagicMock()
|
|
187
200
|
mock_libvirt.open.return_value = mock_conn
|
|
201
|
+
mock_libvirt.openAuth.return_value = mock_conn
|
|
188
202
|
|
|
189
203
|
cloner = SelectiveVMCloner()
|
|
190
204
|
config = VMConfig(name="test-vm", network_mode="default")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|