clonebox 1.1.19__tar.gz → 1.1.21__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.
Potentially problematic release.
This version of clonebox might be problematic. Click here for more details.
- {clonebox-1.1.19/src/clonebox.egg-info → clonebox-1.1.21}/PKG-INFO +1 -1
- {clonebox-1.1.19 → clonebox-1.1.21}/pyproject.toml +1 -1
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/cli.py +49 -7
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/cloner.py +35 -6
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/validator.py +100 -15
- {clonebox-1.1.19 → clonebox-1.1.21/src/clonebox.egg-info}/PKG-INFO +1 -1
- {clonebox-1.1.19 → clonebox-1.1.21}/LICENSE +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/README.md +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/setup.cfg +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/__init__.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/__main__.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/audit.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/backends/libvirt_backend.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/backends/qemu_disk.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/backends/subprocess_runner.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/container.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/dashboard.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/detector.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/di.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/exporter.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/health/__init__.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/health/manager.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/health/models.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/health/probes.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/importer.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/interfaces/disk.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/interfaces/hypervisor.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/interfaces/network.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/interfaces/process.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/logging.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/models.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/monitor.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/orchestrator.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/p2p.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/plugins/__init__.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/plugins/base.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/plugins/manager.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/policies/__init__.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/policies/engine.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/policies/models.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/policies/validators.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/profiles.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/remote.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/resource_monitor.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/resources.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/rollback.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/secrets.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/snapshots/__init__.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/snapshots/manager.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/snapshots/models.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/templates/profiles/ml-dev.yaml +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox/templates/profiles/web-stack.yaml +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox.egg-info/SOURCES.txt +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox.egg-info/dependency_links.txt +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox.egg-info/entry_points.txt +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox.egg-info/requires.txt +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/src/clonebox.egg-info/top_level.txt +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/tests/test_audit.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/tests/test_cli.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/tests/test_cli_new_commands.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/tests/test_cloner.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/tests/test_cloner_simple.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/tests/test_container.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/tests/test_coverage_additional.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/tests/test_coverage_boost_final.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/tests/test_dashboard_coverage.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/tests/test_detector.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/tests/test_models.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/tests/test_network.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/tests/test_orchestrator.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/tests/test_plugins.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/tests/test_profiles.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/tests/test_remote.py +0 -0
- {clonebox-1.1.19 → clonebox-1.1.21}/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.21"
|
|
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"}
|
|
@@ -945,10 +945,27 @@ def interactive_mode():
|
|
|
945
945
|
cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
|
|
946
946
|
|
|
947
947
|
# Check prerequisites
|
|
948
|
-
checks = cloner.check_prerequisites()
|
|
949
|
-
|
|
948
|
+
checks = cloner.check_prerequisites(config)
|
|
949
|
+
required_keys = [
|
|
950
|
+
"libvirt_connected",
|
|
951
|
+
"kvm_available",
|
|
952
|
+
"default_network",
|
|
953
|
+
"images_dir_writable",
|
|
954
|
+
"genisoimage_installed",
|
|
955
|
+
"qemu_img_installed",
|
|
956
|
+
]
|
|
957
|
+
if getattr(config, "gui", False):
|
|
958
|
+
required_keys.append("virt_viewer_installed")
|
|
959
|
+
|
|
960
|
+
required_checks = {
|
|
961
|
+
k: checks.get(k)
|
|
962
|
+
for k in required_keys
|
|
963
|
+
if isinstance(checks.get(k), bool)
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
if required_checks and not all(required_checks.values()):
|
|
950
967
|
console.print("[yellow]⚠️ Prerequisites check:[/]")
|
|
951
|
-
for check, passed in
|
|
968
|
+
for check, passed in required_checks.items():
|
|
952
969
|
icon = "✅" if passed else "❌"
|
|
953
970
|
console.print(f" {icon} {check}")
|
|
954
971
|
|
|
@@ -1884,14 +1901,24 @@ def cmd_test(args):
|
|
|
1884
1901
|
# Give QEMU Guest Agent some time to come up (common during early boot)
|
|
1885
1902
|
qga_ready = _qga_ping(vm_name, conn_uri)
|
|
1886
1903
|
if not qga_ready:
|
|
1887
|
-
for
|
|
1904
|
+
console.print("[yellow]⏳ Waiting for QEMU Guest Agent (up to 60s)...[/]")
|
|
1905
|
+
qga_wait_start = time.time()
|
|
1906
|
+
for attempt in range(12): # ~60s
|
|
1888
1907
|
time.sleep(5)
|
|
1889
1908
|
qga_ready = _qga_ping(vm_name, conn_uri)
|
|
1909
|
+
elapsed = int(time.time() - qga_wait_start)
|
|
1890
1910
|
if qga_ready:
|
|
1911
|
+
console.print(f"[green]✅ QEMU Guest Agent connected after {elapsed}s[/]")
|
|
1891
1912
|
break
|
|
1913
|
+
if attempt % 2 == 1:
|
|
1914
|
+
console.print(f"[dim] ...still waiting ({elapsed}s elapsed)[/]")
|
|
1915
|
+
|
|
1916
|
+
if not qga_ready:
|
|
1917
|
+
console.print("[yellow]⚠️ QEMU Guest Agent still not connected[/]")
|
|
1892
1918
|
|
|
1893
1919
|
# Check cloud-init status immediately if QGA is ready
|
|
1894
1920
|
if qga_ready:
|
|
1921
|
+
console.print("[dim] Checking cloud-init status via QGA...[/]")
|
|
1895
1922
|
status = _qga_exec(
|
|
1896
1923
|
vm_name, conn_uri, "cloud-init status 2>/dev/null || true", timeout=15
|
|
1897
1924
|
)
|
|
@@ -2068,6 +2095,8 @@ def cmd_test(args):
|
|
|
2068
2095
|
|
|
2069
2096
|
# Run full validation if requested
|
|
2070
2097
|
if validate_all and state == "running":
|
|
2098
|
+
console.print("[bold cyan]🔎 Starting deep validation (--validate)[/]")
|
|
2099
|
+
console.print("[dim]This can take a few minutes on first boot (waiting for QGA/cloud-init, checking packages/services).[/]")
|
|
2071
2100
|
validator = VMValidator(
|
|
2072
2101
|
config,
|
|
2073
2102
|
vm_name,
|
|
@@ -2612,10 +2641,23 @@ def create_vm_from_config(config, start=False, user_session=False, replace=False
|
|
|
2612
2641
|
cloner = SelectiveVMCloner(user_session=user_session)
|
|
2613
2642
|
|
|
2614
2643
|
# Check prerequisites
|
|
2615
|
-
checks = cloner.check_prerequisites()
|
|
2616
|
-
|
|
2644
|
+
checks = cloner.check_prerequisites(vm_config)
|
|
2645
|
+
required_keys = [
|
|
2646
|
+
"libvirt_connected",
|
|
2647
|
+
"kvm_available",
|
|
2648
|
+
"default_network",
|
|
2649
|
+
"images_dir_writable",
|
|
2650
|
+
"genisoimage_installed",
|
|
2651
|
+
"qemu_img_installed",
|
|
2652
|
+
]
|
|
2653
|
+
if getattr(vm_config, "gui", False):
|
|
2654
|
+
required_keys.append("virt_viewer_installed")
|
|
2655
|
+
|
|
2656
|
+
required_checks = {k: checks.get(k) for k in required_keys if isinstance(checks.get(k), bool)}
|
|
2657
|
+
|
|
2658
|
+
if required_checks and not all(required_checks.values()):
|
|
2617
2659
|
console.print("[yellow]⚠️ Prerequisites check:[/]")
|
|
2618
|
-
for check, passed in
|
|
2660
|
+
for check, passed in required_checks.items():
|
|
2619
2661
|
icon = "✅" if passed else "❌"
|
|
2620
2662
|
console.print(f" {icon} {check}")
|
|
2621
2663
|
|
|
@@ -15,6 +15,7 @@ import tempfile
|
|
|
15
15
|
import time
|
|
16
16
|
import urllib.request
|
|
17
17
|
import uuid
|
|
18
|
+
import zlib
|
|
18
19
|
import xml.etree.ElementTree as ET
|
|
19
20
|
from dataclasses import dataclass, field
|
|
20
21
|
from datetime import datetime
|
|
@@ -299,20 +300,29 @@ class SelectiveVMCloner:
|
|
|
299
300
|
return mode
|
|
300
301
|
return "default"
|
|
301
302
|
|
|
302
|
-
def check_prerequisites(self) -> dict:
|
|
303
|
+
def check_prerequisites(self, config: Optional[VMConfig] = None) -> dict:
|
|
303
304
|
"""Check system prerequisites for VM creation."""
|
|
304
305
|
images_dir = self.get_images_dir()
|
|
305
306
|
|
|
307
|
+
resolved_network_mode: Optional[str] = None
|
|
308
|
+
if config is not None:
|
|
309
|
+
try:
|
|
310
|
+
resolved_network_mode = self.resolve_network_mode(config)
|
|
311
|
+
except Exception:
|
|
312
|
+
resolved_network_mode = None
|
|
313
|
+
|
|
306
314
|
checks = {
|
|
307
315
|
"libvirt_connected": False,
|
|
308
316
|
"kvm_available": False,
|
|
309
317
|
"default_network": False,
|
|
318
|
+
"default_network_required": True,
|
|
310
319
|
"images_dir_writable": False,
|
|
311
320
|
"images_dir": str(images_dir),
|
|
312
321
|
"session_type": "user" if self.user_session else "system",
|
|
313
322
|
"genisoimage_installed": False,
|
|
314
323
|
"virt_viewer_installed": False,
|
|
315
324
|
"qemu_img_installed": False,
|
|
325
|
+
"passt_installed": shutil.which("passt") is not None,
|
|
316
326
|
}
|
|
317
327
|
|
|
318
328
|
# Check for genisoimage
|
|
@@ -340,8 +350,14 @@ class SelectiveVMCloner:
|
|
|
340
350
|
|
|
341
351
|
# Check default network
|
|
342
352
|
default_net_state = self._default_network_state()
|
|
343
|
-
checks["
|
|
344
|
-
if
|
|
353
|
+
checks["default_network_required"] = (resolved_network_mode or "default") != "user"
|
|
354
|
+
if checks["default_network_required"]:
|
|
355
|
+
checks["default_network"] = default_net_state == "active"
|
|
356
|
+
else:
|
|
357
|
+
# For user-mode networking (slirp/passt), libvirt's default network is not required.
|
|
358
|
+
checks["default_network"] = True
|
|
359
|
+
|
|
360
|
+
if checks["default_network_required"] and default_net_state in {"inactive", "missing", "unknown"}:
|
|
345
361
|
checks["network_error"] = (
|
|
346
362
|
"Default network not found or inactive.\n"
|
|
347
363
|
" For user session, CloneBox can use user-mode networking (slirp) automatically.\n"
|
|
@@ -351,6 +367,9 @@ class SelectiveVMCloner:
|
|
|
351
367
|
" Or use system session: clonebox clone . (without --user)\n"
|
|
352
368
|
)
|
|
353
369
|
|
|
370
|
+
if resolved_network_mode is not None:
|
|
371
|
+
checks["network_mode"] = resolved_network_mode
|
|
372
|
+
|
|
354
373
|
# Check images directory
|
|
355
374
|
if images_dir.exists():
|
|
356
375
|
checks["images_dir_writable"] = os.access(images_dir, os.W_OK)
|
|
@@ -1358,6 +1377,13 @@ fi
|
|
|
1358
1377
|
if network_mode == "user":
|
|
1359
1378
|
iface = ET.SubElement(devices, "interface", type="user")
|
|
1360
1379
|
ET.SubElement(iface, "model", type="virtio")
|
|
1380
|
+
|
|
1381
|
+
if shutil.which("passt"):
|
|
1382
|
+
ET.SubElement(iface, "backend", type="passt")
|
|
1383
|
+
|
|
1384
|
+
ssh_port = 22000 + (zlib.crc32(config.name.encode("utf-8")) % 1000)
|
|
1385
|
+
pf = ET.SubElement(iface, "portForward", proto="tcp", address="127.0.0.1")
|
|
1386
|
+
ET.SubElement(pf, "range", start=str(ssh_port), to="22")
|
|
1361
1387
|
else:
|
|
1362
1388
|
iface = ET.SubElement(devices, "interface", type="network")
|
|
1363
1389
|
ET.SubElement(iface, "source", network="default")
|
|
@@ -1398,6 +1424,7 @@ fi
|
|
|
1398
1424
|
|
|
1399
1425
|
# Channel for guest agent
|
|
1400
1426
|
channel = ET.SubElement(devices, "channel", type="unix")
|
|
1427
|
+
# For both user and system sessions, let libvirt handle the path
|
|
1401
1428
|
ET.SubElement(channel, "source", mode="bind")
|
|
1402
1429
|
ET.SubElement(channel, "target", type="virtio", name="org.qemu.guest_agent.0")
|
|
1403
1430
|
|
|
@@ -1563,7 +1590,7 @@ fi
|
|
|
1563
1590
|
|
|
1564
1591
|
# User-data
|
|
1565
1592
|
# Add desktop environment if GUI is enabled
|
|
1566
|
-
base_packages = ["qemu-guest-agent", "cloud-guest-utils"]
|
|
1593
|
+
base_packages = ["qemu-guest-agent", "cloud-guest-utils", "openssh-server"]
|
|
1567
1594
|
if config.gui:
|
|
1568
1595
|
base_packages.extend(
|
|
1569
1596
|
[
|
|
@@ -1619,9 +1646,11 @@ fi
|
|
|
1619
1646
|
|
|
1620
1647
|
# Phase 3: Core services
|
|
1621
1648
|
runcmd_lines.append(" - echo '[3/10] 🔧 Enabling core services...'")
|
|
1622
|
-
runcmd_lines.append(" - echo ' → [1/
|
|
1649
|
+
runcmd_lines.append(" - echo ' → [1/3] Enabling qemu-guest-agent'")
|
|
1623
1650
|
runcmd_lines.append(" - systemctl enable --now qemu-guest-agent || echo ' → ❌ Failed to enable qemu-guest-agent'")
|
|
1624
|
-
runcmd_lines.append(" - echo ' → [2/
|
|
1651
|
+
runcmd_lines.append(" - echo ' → [2/3] Enabling SSH server'")
|
|
1652
|
+
runcmd_lines.append(" - systemctl enable --now ssh || systemctl enable --now sshd || echo ' → ❌ Failed to enable ssh'")
|
|
1653
|
+
runcmd_lines.append(" - echo ' → [3/3] Enabling snapd'")
|
|
1625
1654
|
runcmd_lines.append(" - systemctl enable --now snapd || echo ' → ❌ Failed to enable snapd'")
|
|
1626
1655
|
runcmd_lines.append(" - echo ' → Waiting for snap system seed...'")
|
|
1627
1656
|
runcmd_lines.append(" - timeout 300 snap wait system seed.loaded || true")
|
|
@@ -6,6 +6,7 @@ import subprocess
|
|
|
6
6
|
import json
|
|
7
7
|
import base64
|
|
8
8
|
import time
|
|
9
|
+
import zlib
|
|
9
10
|
from typing import Dict, List, Tuple, Optional
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from rich.console import Console
|
|
@@ -49,9 +50,66 @@ class VMValidator:
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
self._setup_in_progress_cache: Optional[bool] = None
|
|
53
|
+
self._exec_transport: str = "qga" # qga|ssh
|
|
54
|
+
|
|
55
|
+
def _get_ssh_key_path(self) -> Optional[Path]:
|
|
56
|
+
"""Return path to the SSH key generated for this VM (if present)."""
|
|
57
|
+
try:
|
|
58
|
+
if self.conn_uri.endswith("/session"):
|
|
59
|
+
images_dir = Path.home() / ".local/share/libvirt/images"
|
|
60
|
+
else:
|
|
61
|
+
images_dir = Path("/var/lib/libvirt/images")
|
|
62
|
+
key_path = images_dir / self.vm_name / "ssh_key"
|
|
63
|
+
return key_path if key_path.exists() else None
|
|
64
|
+
except Exception:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
def _get_ssh_port(self) -> int:
|
|
68
|
+
"""Deterministic host-side SSH port for passt port forwarding."""
|
|
69
|
+
return 22000 + (zlib.crc32(self.vm_name.encode("utf-8")) % 1000)
|
|
70
|
+
|
|
71
|
+
def _ssh_exec(self, command: str, timeout: int = 10) -> Optional[str]:
|
|
72
|
+
key_path = self._get_ssh_key_path()
|
|
73
|
+
if key_path is None:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
ssh_port = self._get_ssh_port()
|
|
77
|
+
user = (self.config.get("vm") or {}).get("username") or "ubuntu"
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
result = subprocess.run(
|
|
81
|
+
[
|
|
82
|
+
"ssh",
|
|
83
|
+
"-i",
|
|
84
|
+
str(key_path),
|
|
85
|
+
"-p",
|
|
86
|
+
str(ssh_port),
|
|
87
|
+
"-o",
|
|
88
|
+
"StrictHostKeyChecking=no",
|
|
89
|
+
"-o",
|
|
90
|
+
"UserKnownHostsFile=/dev/null",
|
|
91
|
+
"-o",
|
|
92
|
+
"ConnectTimeout=5",
|
|
93
|
+
"-o",
|
|
94
|
+
"BatchMode=yes",
|
|
95
|
+
f"{user}@127.0.0.1",
|
|
96
|
+
command,
|
|
97
|
+
],
|
|
98
|
+
capture_output=True,
|
|
99
|
+
text=True,
|
|
100
|
+
timeout=timeout,
|
|
101
|
+
)
|
|
102
|
+
if result.returncode != 0:
|
|
103
|
+
return None
|
|
104
|
+
return (result.stdout or "").strip()
|
|
105
|
+
except Exception:
|
|
106
|
+
return None
|
|
52
107
|
|
|
53
108
|
def _exec_in_vm(self, command: str, timeout: int = 10) -> Optional[str]:
|
|
54
|
-
"""Execute command in VM using QEMU guest agent."""
|
|
109
|
+
"""Execute command in VM using QEMU guest agent, with SSH fallback."""
|
|
110
|
+
if self._exec_transport == "ssh":
|
|
111
|
+
return self._ssh_exec(command, timeout=timeout)
|
|
112
|
+
|
|
55
113
|
try:
|
|
56
114
|
# Execute command
|
|
57
115
|
result = subprocess.run(
|
|
@@ -266,12 +324,17 @@ class VMValidator:
|
|
|
266
324
|
self.console.print("[dim]No APT packages configured[/]")
|
|
267
325
|
return self.results["packages"]
|
|
268
326
|
|
|
327
|
+
total_pkgs = len(packages)
|
|
328
|
+
self.console.print(f"[dim]Checking {total_pkgs} packages via QGA...[/]")
|
|
329
|
+
|
|
269
330
|
pkg_table = Table(title="Package Validation", border_style="cyan")
|
|
270
331
|
pkg_table.add_column("Package", style="bold")
|
|
271
332
|
pkg_table.add_column("Status", justify="center")
|
|
272
333
|
pkg_table.add_column("Version", style="dim")
|
|
273
334
|
|
|
274
|
-
for package in packages:
|
|
335
|
+
for idx, package in enumerate(packages, 1):
|
|
336
|
+
if idx == 1 or idx % 25 == 0 or idx == total_pkgs:
|
|
337
|
+
self.console.print(f"[dim] ...packages progress: {idx}/{total_pkgs}[/]")
|
|
275
338
|
self.results["packages"]["total"] += 1
|
|
276
339
|
|
|
277
340
|
# Check if installed
|
|
@@ -315,12 +378,17 @@ class VMValidator:
|
|
|
315
378
|
self.console.print("[dim]No snap packages configured[/]")
|
|
316
379
|
return self.results["snap_packages"]
|
|
317
380
|
|
|
381
|
+
total_snaps = len(snap_packages)
|
|
382
|
+
self.console.print(f"[dim]Checking {total_snaps} snap packages via QGA...[/]")
|
|
383
|
+
|
|
318
384
|
snap_table = Table(title="Snap Package Validation", border_style="cyan")
|
|
319
385
|
snap_table.add_column("Package", style="bold")
|
|
320
386
|
snap_table.add_column("Status", justify="center")
|
|
321
387
|
snap_table.add_column("Version", style="dim")
|
|
322
388
|
|
|
323
|
-
for package in snap_packages:
|
|
389
|
+
for idx, package in enumerate(snap_packages, 1):
|
|
390
|
+
if idx == 1 or idx % 25 == 0 or idx == total_snaps:
|
|
391
|
+
self.console.print(f"[dim] ...snap progress: {idx}/{total_snaps}[/]")
|
|
324
392
|
self.results["snap_packages"]["total"] += 1
|
|
325
393
|
|
|
326
394
|
# Check if installed
|
|
@@ -395,6 +463,9 @@ class VMValidator:
|
|
|
395
463
|
self.console.print("[dim]No services configured[/]")
|
|
396
464
|
return self.results["services"]
|
|
397
465
|
|
|
466
|
+
total_svcs = len(services)
|
|
467
|
+
self.console.print(f"[dim]Checking {total_svcs} services via QGA...[/]")
|
|
468
|
+
|
|
398
469
|
if "skipped" not in self.results["services"]:
|
|
399
470
|
self.results["services"]["skipped"] = 0
|
|
400
471
|
|
|
@@ -405,7 +476,9 @@ class VMValidator:
|
|
|
405
476
|
svc_table.add_column("PID", justify="right", style="dim")
|
|
406
477
|
svc_table.add_column("Note", style="dim")
|
|
407
478
|
|
|
408
|
-
for service in services:
|
|
479
|
+
for idx, service in enumerate(services, 1):
|
|
480
|
+
if idx == 1 or idx % 25 == 0 or idx == total_svcs:
|
|
481
|
+
self.console.print(f"[dim] ...services progress: {idx}/{total_svcs}[/]")
|
|
409
482
|
if service in self.VM_EXCLUDED_SERVICES:
|
|
410
483
|
svc_table.add_row(service, "[dim]—[/]", "[dim]—[/]", "[dim]—[/]", "host-only")
|
|
411
484
|
self.results["services"]["skipped"] += 1
|
|
@@ -1144,23 +1217,35 @@ class VMValidator:
|
|
|
1144
1217
|
if not self._check_qga_ready():
|
|
1145
1218
|
wait_deadline = time.time() + 180
|
|
1146
1219
|
self.console.print("[yellow]⏳ Waiting for QEMU Guest Agent (up to 180s)...[/]")
|
|
1220
|
+
last_log = 0
|
|
1147
1221
|
while time.time() < wait_deadline:
|
|
1148
1222
|
time.sleep(5)
|
|
1149
1223
|
if self._check_qga_ready():
|
|
1150
1224
|
break
|
|
1225
|
+
elapsed = int(180 - (wait_deadline - time.time()))
|
|
1226
|
+
if elapsed - last_log >= 15:
|
|
1227
|
+
self.console.print(f"[dim] ...still waiting for QGA ({elapsed}s elapsed)[/]")
|
|
1228
|
+
last_log = elapsed
|
|
1151
1229
|
|
|
1152
1230
|
if not self._check_qga_ready():
|
|
1153
|
-
|
|
1154
|
-
self.console.print("
|
|
1155
|
-
self.
|
|
1156
|
-
self.
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1231
|
+
# SSH fallback (primarily for --user networking, where passt can forward ports)
|
|
1232
|
+
self.console.print("[yellow]⚠️ QEMU Guest Agent not responding - trying SSH fallback...[/]")
|
|
1233
|
+
self._exec_transport = "ssh"
|
|
1234
|
+
smoke = self._ssh_exec("echo ok", timeout=10)
|
|
1235
|
+
if smoke != "ok":
|
|
1236
|
+
self.console.print("[red]❌ SSH fallback failed[/]")
|
|
1237
|
+
self.console.print("\n[bold]🔧 Troubleshooting QGA:[/]")
|
|
1238
|
+
self.console.print(" 1. The VM might still be booting. Wait 30-60 seconds.")
|
|
1239
|
+
self.console.print(" 2. Ensure the agent is installed and running inside the VM:")
|
|
1240
|
+
self.console.print(" [dim]virsh console " + self.vm_name + "[/]")
|
|
1241
|
+
self.console.print(" [dim]sudo systemctl status qemu-guest-agent[/]")
|
|
1242
|
+
self.console.print(" 3. If newly created, cloud-init might still be running.")
|
|
1243
|
+
self.console.print(" 4. Check VM logs: [dim]clonebox logs " + self.vm_name + "[/]")
|
|
1244
|
+
self.console.print(f"\n[yellow]⚠️ Skipping deep validation as it requires a working Guest Agent or SSH access.[/]")
|
|
1245
|
+
self.results["overall"] = "qga_not_ready"
|
|
1246
|
+
return self.results
|
|
1247
|
+
|
|
1248
|
+
self.console.print("[green]✅ SSH fallback connected (executing validations over SSH)[/]")
|
|
1164
1249
|
|
|
1165
1250
|
ci_status = self._exec_in_vm("cloud-init status --long 2>/dev/null || cloud-init status 2>/dev/null || true", timeout=20)
|
|
1166
1251
|
if ci_status:
|
|
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
|
|
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
|