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.
Files changed (59) hide show
  1. {clonebox-1.1.9/src/clonebox.egg-info → clonebox-1.1.11}/PKG-INFO +1 -1
  2. {clonebox-1.1.9 → clonebox-1.1.11}/pyproject.toml +1 -1
  3. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/cli.py +54 -5
  4. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/cloner.py +141 -41
  5. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/di.py +1 -1
  6. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/models.py +2 -5
  7. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/validator.py +82 -34
  8. {clonebox-1.1.9 → clonebox-1.1.11/src/clonebox.egg-info}/PKG-INFO +1 -1
  9. {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_cloner.py +18 -1
  10. {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_coverage_boost_final.py +4 -0
  11. {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_network.py +14 -0
  12. {clonebox-1.1.9 → clonebox-1.1.11}/LICENSE +0 -0
  13. {clonebox-1.1.9 → clonebox-1.1.11}/README.md +0 -0
  14. {clonebox-1.1.9 → clonebox-1.1.11}/setup.cfg +0 -0
  15. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/__init__.py +0 -0
  16. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/__main__.py +0 -0
  17. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/backends/libvirt_backend.py +0 -0
  18. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/backends/qemu_disk.py +0 -0
  19. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/backends/subprocess_runner.py +0 -0
  20. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/container.py +0 -0
  21. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/dashboard.py +0 -0
  22. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/detector.py +0 -0
  23. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/exporter.py +0 -0
  24. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/health/__init__.py +0 -0
  25. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/health/manager.py +0 -0
  26. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/health/models.py +0 -0
  27. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/health/probes.py +0 -0
  28. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/importer.py +0 -0
  29. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/interfaces/disk.py +0 -0
  30. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/interfaces/hypervisor.py +0 -0
  31. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/interfaces/network.py +0 -0
  32. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/interfaces/process.py +0 -0
  33. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/logging.py +0 -0
  34. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/monitor.py +0 -0
  35. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/p2p.py +0 -0
  36. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/profiles.py +0 -0
  37. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/resource_monitor.py +0 -0
  38. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/resources.py +0 -0
  39. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/rollback.py +0 -0
  40. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/secrets.py +0 -0
  41. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/snapshots/__init__.py +0 -0
  42. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/snapshots/manager.py +0 -0
  43. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/snapshots/models.py +0 -0
  44. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/templates/profiles/ml-dev.yaml +0 -0
  45. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox/templates/profiles/web-stack.yaml +0 -0
  46. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox.egg-info/SOURCES.txt +0 -0
  47. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox.egg-info/dependency_links.txt +0 -0
  48. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox.egg-info/entry_points.txt +0 -0
  49. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox.egg-info/requires.txt +0 -0
  50. {clonebox-1.1.9 → clonebox-1.1.11}/src/clonebox.egg-info/top_level.txt +0 -0
  51. {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_cli.py +0 -0
  52. {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_cloner_simple.py +0 -0
  53. {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_container.py +0 -0
  54. {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_coverage_additional.py +0 -0
  55. {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_dashboard_coverage.py +0 -0
  56. {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_detector.py +0 -0
  57. {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_models.py +0 -0
  58. {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_profiles.py +0 -0
  59. {clonebox-1.1.9 → clonebox-1.1.11}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clonebox
3
- Version: 1.1.9
3
+ Version: 1.1.11
4
4
  Summary: Clone your workstation environment to an isolated VM with selective apps, paths and services
5
5
  Author: CloneBox Team
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "clonebox"
7
- version = "1.1.9"
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
- conn_uri = "qemu:///session" if getattr(args, "user", False) else "qemu:///system"
2728
- command = args.command
2729
- if isinstance(command, list):
2730
- command = " ".join(command).strip()
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
- timeout = getattr(args, "timeout", 30)
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
- self.conn = libvirt.open(self.conn_uri)
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
- if limits.cpu.shares or limits.cpu.quota or limits.cpu.pin:
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
- if limits.memory.soft_limit or limits.memory.swap:
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
- if limits.disk.read_bps or limits.disk.write_bps or limits.disk.read_iops or limits.disk.write_iops:
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" for p in (config.paths or {}).values()
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
- if str(guest_path).startswith("/home/ubuntu/snap/"):
1271
- guest_parts = Path(guest_path).parts
1272
- if len(guest_parts) > 4:
1273
- snap_name = guest_parts[4]
1274
- for d in (
1275
- "/home/ubuntu",
1276
- "/home/ubuntu/snap",
1277
- f"/home/ubuntu/snap/{snap_name}",
1278
- ):
1279
- if d not in pre_chown_dirs:
1280
- pre_chown_dirs.add(d)
1281
- mount_commands.append(f" - mkdir -p {d}")
1282
- mount_commands.append(f" - chown 1000:1000 {d}")
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
- mount_commands.append(f" - mkdir -p {guest_path}")
1288
- mount_commands.append(f" - chown 1000:1000 {guest_path}")
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
- runcmd_lines.append(
1341
- f" - snap install {snap_pkg} --classic || snap install {snap_pkg} || true"
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 - runs AFTER package installation completes
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
- for host_path, guest_path in (config.paths or {}).items():
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
- vm.shutdown()
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
@@ -34,7 +34,7 @@ class DependencyContainer:
34
34
 
35
35
  def __init__(self):
36
36
  self._registrations: Dict[Type, ServiceRegistration] = {}
37
- self._lock = threading.Lock()
37
+ self._lock = threading.RLock()
38
38
 
39
39
  def register(
40
40
  self,
@@ -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=all_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 are accessible and contain data."""
110
- self.console.print("\n[bold]💾 Validating Mount Points...[/]")
109
+ """Validate all mount points and copied data paths."""
110
+ self.console.print("\n[bold]💾 Validating Mounts & Data...[/]")
111
111
 
112
- all_paths = self.config.get("paths", {}).copy()
113
- all_paths.update(self.config.get("app_data_paths", {}))
112
+ paths = self.config.get("paths", {})
113
+ app_data_paths = self.config.get("app_data_paths", {})
114
114
 
115
- if not all_paths:
116
- self.console.print("[dim]No mount points configured[/]")
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="Mount Validation", border_style="cyan")
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("Mounted", justify="center")
128
- mount_table.add_column("Accessible", justify="center")
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
- for host_path, guest_path in all_paths.items():
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
- mount_status = "[green]✅[/]"
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
- mount_status = "[green][/]"
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
- mount_status = "[red]❌[/]"
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, mount_status, access_status, str(file_count))
169
-
170
- self.results["mounts"]["details"].append(
171
- {
172
- "path": guest_path,
173
- "mounted": is_mounted,
174
- "accessible": accessible,
175
- "files": file_count,
176
- "status": status,
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']} mounts working[/]"
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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clonebox
3
- Version: 1.1.9
3
+ Version: 1.1.11
4
4
  Summary: Clone your workstation environment to an isolated VM with selective apps, paths and services
5
5
  Author: CloneBox Team
6
6
  License: Apache-2.0
@@ -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
- mock_libvirt.open.assert_called_with(expected_uri)
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