clonebox 1.1.21__py3-none-any.whl → 1.1.23__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clonebox/cli.py +8 -3
- clonebox/cloner.py +66 -2
- clonebox/validation/__init__.py +1 -0
- clonebox/validation/apps.py +303 -0
- clonebox/validation/core.py +202 -0
- clonebox/validation/disk.py +149 -0
- clonebox/validation/mounts.py +128 -0
- clonebox/validation/overall.py +240 -0
- clonebox/validation/packages.py +117 -0
- clonebox/validation/services.py +125 -0
- clonebox/validation/smoke.py +177 -0
- clonebox/validation/validator.py +24 -0
- clonebox/validator.py +2 -1414
- {clonebox-1.1.21.dist-info → clonebox-1.1.23.dist-info}/METADATA +1 -1
- {clonebox-1.1.21.dist-info → clonebox-1.1.23.dist-info}/RECORD +19 -9
- {clonebox-1.1.21.dist-info → clonebox-1.1.23.dist-info}/WHEEL +0 -0
- {clonebox-1.1.21.dist-info → clonebox-1.1.23.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.21.dist-info → clonebox-1.1.23.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.21.dist-info → clonebox-1.1.23.dist-info}/top_level.txt +0 -0
clonebox/cli.py
CHANGED
|
@@ -949,11 +949,12 @@ def interactive_mode():
|
|
|
949
949
|
required_keys = [
|
|
950
950
|
"libvirt_connected",
|
|
951
951
|
"kvm_available",
|
|
952
|
-
"default_network",
|
|
953
952
|
"images_dir_writable",
|
|
954
953
|
"genisoimage_installed",
|
|
955
954
|
"qemu_img_installed",
|
|
956
955
|
]
|
|
956
|
+
if checks.get("default_network_required", True):
|
|
957
|
+
required_keys.append("default_network")
|
|
957
958
|
if getattr(config, "gui", False):
|
|
958
959
|
required_keys.append("virt_viewer_installed")
|
|
959
960
|
|
|
@@ -1915,7 +1916,10 @@ def cmd_test(args):
|
|
|
1915
1916
|
|
|
1916
1917
|
if not qga_ready:
|
|
1917
1918
|
console.print("[yellow]⚠️ QEMU Guest Agent still not connected[/]")
|
|
1918
|
-
|
|
1919
|
+
console.print(
|
|
1920
|
+
f"[dim]Tip: you can watch live cloud-init output via serial console: virsh --connect {conn_uri} console {vm_name}[/]"
|
|
1921
|
+
)
|
|
1922
|
+
|
|
1919
1923
|
# Check cloud-init status immediately if QGA is ready
|
|
1920
1924
|
if qga_ready:
|
|
1921
1925
|
console.print("[dim] Checking cloud-init status via QGA...[/]")
|
|
@@ -2645,11 +2649,12 @@ def create_vm_from_config(config, start=False, user_session=False, replace=False
|
|
|
2645
2649
|
required_keys = [
|
|
2646
2650
|
"libvirt_connected",
|
|
2647
2651
|
"kvm_available",
|
|
2648
|
-
"default_network",
|
|
2649
2652
|
"images_dir_writable",
|
|
2650
2653
|
"genisoimage_installed",
|
|
2651
2654
|
"qemu_img_installed",
|
|
2652
2655
|
]
|
|
2656
|
+
if checks.get("default_network_required", True):
|
|
2657
|
+
required_keys.append("default_network")
|
|
2653
2658
|
if getattr(vm_config, "gui", False):
|
|
2654
2659
|
required_keys.append("virt_viewer_installed")
|
|
2655
2660
|
|
clonebox/cloner.py
CHANGED
|
@@ -289,6 +289,17 @@ class SelectiveVMCloner:
|
|
|
289
289
|
"""Check if libvirt default network is active."""
|
|
290
290
|
return self._default_network_state() == "active"
|
|
291
291
|
|
|
292
|
+
def _passt_supported(self) -> bool:
|
|
293
|
+
try:
|
|
294
|
+
if self.conn is None:
|
|
295
|
+
return False
|
|
296
|
+
if not hasattr(self.conn, "getLibVersion"):
|
|
297
|
+
return False
|
|
298
|
+
# libvirt 9.0.0 introduced the passt backend and <portForward> support
|
|
299
|
+
return int(self.conn.getLibVersion()) >= 9_000_000
|
|
300
|
+
except Exception:
|
|
301
|
+
return False
|
|
302
|
+
|
|
292
303
|
def resolve_network_mode(self, config: VMConfig) -> str:
|
|
293
304
|
"""Resolve network mode based on config and session type."""
|
|
294
305
|
mode = (config.network_mode or "auto").lower()
|
|
@@ -323,8 +334,11 @@ class SelectiveVMCloner:
|
|
|
323
334
|
"virt_viewer_installed": False,
|
|
324
335
|
"qemu_img_installed": False,
|
|
325
336
|
"passt_installed": shutil.which("passt") is not None,
|
|
337
|
+
"passt_available": False,
|
|
326
338
|
}
|
|
327
339
|
|
|
340
|
+
checks["passt_available"] = checks["passt_installed"] and self._passt_supported()
|
|
341
|
+
|
|
328
342
|
# Check for genisoimage
|
|
329
343
|
checks["genisoimage_installed"] = shutil.which("genisoimage") is not None
|
|
330
344
|
|
|
@@ -543,6 +557,36 @@ class SelectiveVMCloner:
|
|
|
543
557
|
f"If the VM already exists, try: clonebox clone . --name {config.name} --replace\n"
|
|
544
558
|
) from e
|
|
545
559
|
|
|
560
|
+
try:
|
|
561
|
+
if console and self.resolve_network_mode(config) == "user":
|
|
562
|
+
if shutil.which("passt") and self._passt_supported():
|
|
563
|
+
ssh_key_path = vm_dir / "ssh_key"
|
|
564
|
+
if ssh_key_path.exists():
|
|
565
|
+
ssh_port = 22000 + (zlib.crc32(config.name.encode("utf-8")) % 1000)
|
|
566
|
+
console.print(
|
|
567
|
+
f"[dim]SSH access (passthrough): ssh -i {ssh_key_path} -p {ssh_port} {config.username}@127.0.0.1[/]"
|
|
568
|
+
)
|
|
569
|
+
else:
|
|
570
|
+
console.print(
|
|
571
|
+
"[yellow]⚠️ passt not available - SSH port-forward will NOT be configured in --user mode[/]"
|
|
572
|
+
)
|
|
573
|
+
console.print(
|
|
574
|
+
"[dim]Install passt (and recreate VM) to enable SSH fallback, or use virsh console for live logs.[/]"
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
if self.user_session:
|
|
578
|
+
console.print(
|
|
579
|
+
f"[dim]Host serial log (when enabled): {vm_dir / 'serial.log'}[/]"
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
if console:
|
|
583
|
+
conn_uri = "qemu:///session" if self.user_session else "qemu:///system"
|
|
584
|
+
console.print(
|
|
585
|
+
f"[dim]Live install logs: virsh --connect {conn_uri} console {config.name}[/]"
|
|
586
|
+
)
|
|
587
|
+
except Exception:
|
|
588
|
+
pass
|
|
589
|
+
|
|
546
590
|
# Start if autostart requested
|
|
547
591
|
if getattr(config, "autostart", False):
|
|
548
592
|
self.start_vm(config.name, open_viewer=True)
|
|
@@ -1378,7 +1422,7 @@ fi
|
|
|
1378
1422
|
iface = ET.SubElement(devices, "interface", type="user")
|
|
1379
1423
|
ET.SubElement(iface, "model", type="virtio")
|
|
1380
1424
|
|
|
1381
|
-
if shutil.which("passt"):
|
|
1425
|
+
if shutil.which("passt") and self._passt_supported():
|
|
1382
1426
|
ET.SubElement(iface, "backend", type="passt")
|
|
1383
1427
|
|
|
1384
1428
|
ssh_port = 22000 + (zlib.crc32(config.name.encode("utf-8")) % 1000)
|
|
@@ -1402,6 +1446,13 @@ fi
|
|
|
1402
1446
|
serial = ET.SubElement(devices, "serial", type="pty")
|
|
1403
1447
|
ET.SubElement(serial, "target", port="0")
|
|
1404
1448
|
|
|
1449
|
+
if self.user_session:
|
|
1450
|
+
vm_dir = Path(str(root_disk)).parent
|
|
1451
|
+
serial_log_path = str(vm_dir / "serial.log")
|
|
1452
|
+
serial_file = ET.SubElement(devices, "serial", type="file")
|
|
1453
|
+
ET.SubElement(serial_file, "source", path=serial_log_path)
|
|
1454
|
+
ET.SubElement(serial_file, "target", port="1")
|
|
1455
|
+
|
|
1405
1456
|
console_elem = ET.SubElement(devices, "console", type="pty")
|
|
1406
1457
|
ET.SubElement(console_elem, "target", type="serial", port="0")
|
|
1407
1458
|
|
|
@@ -1652,6 +1703,7 @@ fi
|
|
|
1652
1703
|
runcmd_lines.append(" - systemctl enable --now ssh || systemctl enable --now sshd || echo ' → ❌ Failed to enable ssh'")
|
|
1653
1704
|
runcmd_lines.append(" - echo ' → [3/3] Enabling snapd'")
|
|
1654
1705
|
runcmd_lines.append(" - systemctl enable --now snapd || echo ' → ❌ Failed to enable snapd'")
|
|
1706
|
+
runcmd_lines.append(" - systemctl enable --now serial-getty@ttyS0.service >/dev/null 2>&1 || true")
|
|
1655
1707
|
runcmd_lines.append(" - echo ' → Waiting for snap system seed...'")
|
|
1656
1708
|
runcmd_lines.append(" - timeout 300 snap wait system seed.loaded || true")
|
|
1657
1709
|
runcmd_lines.append(" - echo ' → ✓ [3/10] Core services enabled'")
|
|
@@ -2573,7 +2625,12 @@ if __name__ == "__main__":
|
|
|
2573
2625
|
runcmd_yaml = "\n".join(runcmd_lines) if runcmd_lines else ""
|
|
2574
2626
|
|
|
2575
2627
|
# Build bootcmd combining mount commands and extra security bootcmds
|
|
2576
|
-
bootcmd_lines =
|
|
2628
|
+
bootcmd_lines = [
|
|
2629
|
+
' - sh -c "echo \"[clonebox] bootcmd: enabling serial console (ttyS0)\" > /dev/ttyS0 || true"',
|
|
2630
|
+
' - systemctl enable --now serial-getty@ttyS0.service >/dev/null 2>&1 || true',
|
|
2631
|
+
]
|
|
2632
|
+
if bootcmd_extra:
|
|
2633
|
+
bootcmd_lines.extend(list(bootcmd_extra))
|
|
2577
2634
|
|
|
2578
2635
|
bootcmd_block = ""
|
|
2579
2636
|
if bootcmd_lines:
|
|
@@ -2601,6 +2658,10 @@ users:
|
|
|
2601
2658
|
|
|
2602
2659
|
user_data_header += f"ssh_pwauth: {ssh_pwauth}\n"
|
|
2603
2660
|
|
|
2661
|
+
output_targets = "/dev/ttyS0"
|
|
2662
|
+
if user_session:
|
|
2663
|
+
output_targets += " /dev/ttyS1"
|
|
2664
|
+
|
|
2604
2665
|
# Assemble final user-data
|
|
2605
2666
|
user_data = f"""{user_data_header}
|
|
2606
2667
|
# Make sure root partition + filesystem grows to fill the qcow2 disk size
|
|
@@ -2613,6 +2674,9 @@ resize_rootfs: true
|
|
|
2613
2674
|
# Update package cache and upgrade
|
|
2614
2675
|
package_update: true
|
|
2615
2676
|
package_upgrade: false
|
|
2677
|
+
|
|
2678
|
+
output:
|
|
2679
|
+
all: "| tee -a /var/log/cloud-init-output.log {output_targets}"
|
|
2616
2680
|
{bootcmd_block}
|
|
2617
2681
|
|
|
2618
2682
|
# Install packages moved to runcmd for better logging
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .validator import VMValidator
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
from typing import Dict, List, Optional
|
|
2
|
+
|
|
3
|
+
from rich.panel import Panel
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AppValidationMixin:
|
|
8
|
+
def validate_apps(self) -> Dict:
|
|
9
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
10
|
+
packages = self.config.get("packages", [])
|
|
11
|
+
snap_packages = self.config.get("snap_packages", [])
|
|
12
|
+
copy_paths = self.config.get("copy_paths", None)
|
|
13
|
+
if not isinstance(copy_paths, dict) or not copy_paths:
|
|
14
|
+
copy_paths = self.config.get("app_data_paths", {})
|
|
15
|
+
vm_user = self.config.get("vm", {}).get("username", "ubuntu")
|
|
16
|
+
|
|
17
|
+
snap_app_specs = {
|
|
18
|
+
"pycharm-community": {
|
|
19
|
+
"process_patterns": ["pycharm-community", "pycharm", "jetbrains"],
|
|
20
|
+
"required_interfaces": [
|
|
21
|
+
"desktop",
|
|
22
|
+
"desktop-legacy",
|
|
23
|
+
"x11",
|
|
24
|
+
"wayland",
|
|
25
|
+
"home",
|
|
26
|
+
"network",
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
"chromium": {
|
|
30
|
+
"process_patterns": ["chromium", "chromium-browser"],
|
|
31
|
+
"required_interfaces": [
|
|
32
|
+
"desktop",
|
|
33
|
+
"desktop-legacy",
|
|
34
|
+
"x11",
|
|
35
|
+
"wayland",
|
|
36
|
+
"home",
|
|
37
|
+
"network",
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
"firefox": {
|
|
41
|
+
"process_patterns": ["firefox"],
|
|
42
|
+
"required_interfaces": [
|
|
43
|
+
"desktop",
|
|
44
|
+
"desktop-legacy",
|
|
45
|
+
"x11",
|
|
46
|
+
"wayland",
|
|
47
|
+
"home",
|
|
48
|
+
"network",
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
"code": {
|
|
52
|
+
"process_patterns": ["code"],
|
|
53
|
+
"required_interfaces": [
|
|
54
|
+
"desktop",
|
|
55
|
+
"desktop-legacy",
|
|
56
|
+
"x11",
|
|
57
|
+
"wayland",
|
|
58
|
+
"home",
|
|
59
|
+
"network",
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
expected = []
|
|
65
|
+
|
|
66
|
+
if "firefox" in packages:
|
|
67
|
+
expected.append("firefox")
|
|
68
|
+
|
|
69
|
+
for snap_pkg in snap_packages:
|
|
70
|
+
if snap_pkg in snap_app_specs:
|
|
71
|
+
expected.append(snap_pkg)
|
|
72
|
+
|
|
73
|
+
for _, guest_path in copy_paths.items():
|
|
74
|
+
if guest_path == "/home/ubuntu/.config/google-chrome":
|
|
75
|
+
expected.append("google-chrome")
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
expected = sorted(set(expected))
|
|
79
|
+
if not expected:
|
|
80
|
+
return self.results["apps"]
|
|
81
|
+
|
|
82
|
+
self.console.print("\n[bold]🧩 Validating Apps...[/]")
|
|
83
|
+
table = Table(title="App Validation", border_style="cyan")
|
|
84
|
+
table.add_column("App", style="bold")
|
|
85
|
+
table.add_column("Installed", justify="center")
|
|
86
|
+
table.add_column("Profile", justify="center")
|
|
87
|
+
table.add_column("Running", justify="center")
|
|
88
|
+
table.add_column("PID", justify="right", style="dim")
|
|
89
|
+
table.add_column("Note", style="dim")
|
|
90
|
+
|
|
91
|
+
def _pgrep_pattern(pattern: str) -> str:
|
|
92
|
+
if not pattern:
|
|
93
|
+
return pattern
|
|
94
|
+
return f"[{pattern[0]}]{pattern[1:]}"
|
|
95
|
+
|
|
96
|
+
def _check_any_process_running(patterns: List[str]) -> Optional[bool]:
|
|
97
|
+
for pattern in patterns:
|
|
98
|
+
p = _pgrep_pattern(pattern)
|
|
99
|
+
out = self._exec_in_vm(
|
|
100
|
+
f"pgrep -u {vm_user} -f '{p}' >/dev/null 2>&1 && echo yes || echo no",
|
|
101
|
+
timeout=10,
|
|
102
|
+
)
|
|
103
|
+
if out is None:
|
|
104
|
+
return None
|
|
105
|
+
if out == "yes":
|
|
106
|
+
return True
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
def _find_first_pid(patterns: List[str]) -> Optional[str]:
|
|
110
|
+
for pattern in patterns:
|
|
111
|
+
p = _pgrep_pattern(pattern)
|
|
112
|
+
out = self._exec_in_vm(
|
|
113
|
+
f"pgrep -u {vm_user} -f '{p}' 2>/dev/null | head -n 1 || true",
|
|
114
|
+
timeout=10,
|
|
115
|
+
)
|
|
116
|
+
if out is None:
|
|
117
|
+
return None
|
|
118
|
+
pid = out.strip()
|
|
119
|
+
if pid:
|
|
120
|
+
return pid
|
|
121
|
+
return ""
|
|
122
|
+
|
|
123
|
+
def _collect_app_logs(app_name: str) -> str:
|
|
124
|
+
chunks: List[str] = []
|
|
125
|
+
|
|
126
|
+
def add(cmd: str, title: str, timeout: int = 20):
|
|
127
|
+
out = self._exec_in_vm(cmd, timeout=timeout)
|
|
128
|
+
if out is None:
|
|
129
|
+
return
|
|
130
|
+
out = out.strip()
|
|
131
|
+
if not out:
|
|
132
|
+
return
|
|
133
|
+
chunks.append(f"{title}\n$ {cmd}\n{out}")
|
|
134
|
+
|
|
135
|
+
if app_name in snap_app_specs:
|
|
136
|
+
add(f"snap connections {app_name} 2>/dev/null | head -n 40", "Snap connections")
|
|
137
|
+
add(f"snap logs {app_name} -n 80 2>/dev/null | tail -n 60", "Snap logs")
|
|
138
|
+
|
|
139
|
+
if app_name == "pycharm-community":
|
|
140
|
+
add(
|
|
141
|
+
"tail -n 80 /home/ubuntu/snap/pycharm-community/common/.config/JetBrains/*/log/idea.log 2>/dev/null || true",
|
|
142
|
+
"idea.log",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if app_name == "google-chrome":
|
|
146
|
+
add(
|
|
147
|
+
"journalctl -n 200 --no-pager 2>/dev/null | grep -i chrome | tail -n 60 || true",
|
|
148
|
+
"Journal (chrome)",
|
|
149
|
+
)
|
|
150
|
+
if app_name == "firefox":
|
|
151
|
+
add(
|
|
152
|
+
"journalctl -n 200 --no-pager 2>/dev/null | grep -i firefox | tail -n 60 || true",
|
|
153
|
+
"Journal (firefox)",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return "\n\n".join(chunks)
|
|
157
|
+
|
|
158
|
+
def _snap_missing_interfaces(snap_name: str, required: List[str]) -> Optional[List[str]]:
|
|
159
|
+
out = self._exec_in_vm(
|
|
160
|
+
f"snap connections {snap_name} 2>/dev/null | awk 'NR>1{{print $1, $3}}'",
|
|
161
|
+
timeout=15,
|
|
162
|
+
)
|
|
163
|
+
if out is None:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
connected = set()
|
|
167
|
+
for line in out.splitlines():
|
|
168
|
+
parts = line.split()
|
|
169
|
+
if len(parts) < 2:
|
|
170
|
+
continue
|
|
171
|
+
iface, slot = parts[0], parts[1]
|
|
172
|
+
if slot != "-":
|
|
173
|
+
connected.add(iface)
|
|
174
|
+
|
|
175
|
+
missing = [i for i in required if i not in connected]
|
|
176
|
+
return missing
|
|
177
|
+
|
|
178
|
+
def _check_dir_nonempty(path: str) -> bool:
|
|
179
|
+
out = self._exec_in_vm(
|
|
180
|
+
f"test -d {path} && [ $(ls -A {path} 2>/dev/null | wc -l) -gt 0 ] && echo yes || echo no",
|
|
181
|
+
timeout=10,
|
|
182
|
+
)
|
|
183
|
+
return out == "yes"
|
|
184
|
+
|
|
185
|
+
for app in expected:
|
|
186
|
+
self.results["apps"]["total"] += 1
|
|
187
|
+
installed = False
|
|
188
|
+
profile_ok = False
|
|
189
|
+
running: Optional[bool] = None
|
|
190
|
+
pid: Optional[str] = None
|
|
191
|
+
note = ""
|
|
192
|
+
pending = False
|
|
193
|
+
|
|
194
|
+
if app == "firefox":
|
|
195
|
+
installed = (
|
|
196
|
+
self._exec_in_vm("command -v firefox >/dev/null 2>&1 && echo yes || echo no") == "yes"
|
|
197
|
+
)
|
|
198
|
+
if _check_dir_nonempty("/home/ubuntu/snap/firefox/common/.mozilla/firefox"):
|
|
199
|
+
profile_ok = True
|
|
200
|
+
elif _check_dir_nonempty("/home/ubuntu/.mozilla/firefox"):
|
|
201
|
+
profile_ok = True
|
|
202
|
+
|
|
203
|
+
if installed:
|
|
204
|
+
running = _check_any_process_running(["firefox"])
|
|
205
|
+
pid = _find_first_pid(["firefox"]) if running else ""
|
|
206
|
+
|
|
207
|
+
elif app in snap_app_specs:
|
|
208
|
+
installed = (
|
|
209
|
+
self._exec_in_vm(f"snap list {app} >/dev/null 2>&1 && echo yes || echo no") == "yes"
|
|
210
|
+
)
|
|
211
|
+
if app == "pycharm-community":
|
|
212
|
+
profile_ok = _check_dir_nonempty(
|
|
213
|
+
"/home/ubuntu/snap/pycharm-community/common/.config/JetBrains"
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
profile_ok = True
|
|
217
|
+
|
|
218
|
+
if installed:
|
|
219
|
+
patterns = snap_app_specs[app]["process_patterns"]
|
|
220
|
+
running = _check_any_process_running(patterns)
|
|
221
|
+
pid = _find_first_pid(patterns) if running else ""
|
|
222
|
+
if running is False:
|
|
223
|
+
missing_ifaces = _snap_missing_interfaces(
|
|
224
|
+
app,
|
|
225
|
+
snap_app_specs[app]["required_interfaces"],
|
|
226
|
+
)
|
|
227
|
+
if missing_ifaces:
|
|
228
|
+
note = f"missing interfaces: {', '.join(missing_ifaces)}"
|
|
229
|
+
elif missing_ifaces == []:
|
|
230
|
+
note = "not running"
|
|
231
|
+
else:
|
|
232
|
+
note = "interfaces unknown"
|
|
233
|
+
|
|
234
|
+
elif app == "google-chrome":
|
|
235
|
+
installed = (
|
|
236
|
+
self._exec_in_vm(
|
|
237
|
+
"(command -v google-chrome >/dev/null 2>&1 || command -v google-chrome-stable >/dev/null 2>&1) && echo yes || echo no"
|
|
238
|
+
)
|
|
239
|
+
== "yes"
|
|
240
|
+
)
|
|
241
|
+
profile_ok = _check_dir_nonempty("/home/ubuntu/.config/google-chrome")
|
|
242
|
+
|
|
243
|
+
if installed:
|
|
244
|
+
running = _check_any_process_running(["google-chrome", "google-chrome-stable"])
|
|
245
|
+
pid = (
|
|
246
|
+
_find_first_pid(["google-chrome", "google-chrome-stable"]) if running else ""
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if self.require_running_apps and installed and profile_ok and running is None:
|
|
250
|
+
note = note or "running unknown"
|
|
251
|
+
|
|
252
|
+
if setup_in_progress and not installed:
|
|
253
|
+
pending = True
|
|
254
|
+
note = note or "setup in progress"
|
|
255
|
+
elif setup_in_progress and not profile_ok:
|
|
256
|
+
pending = True
|
|
257
|
+
note = note or "profile import in progress"
|
|
258
|
+
|
|
259
|
+
running_icon = (
|
|
260
|
+
"[dim]—[/]"
|
|
261
|
+
if not installed
|
|
262
|
+
else (
|
|
263
|
+
"[green]✅[/]" if running is True else "[yellow]⚠️[/]" if running is False else "[dim]?[/]"
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
pid_value = "—" if not installed else ("?" if pid is None else (pid or "—"))
|
|
268
|
+
|
|
269
|
+
installed_icon = "[green]✅[/]" if installed else ("[yellow]⏳[/]" if pending else "[red]❌[/]")
|
|
270
|
+
profile_icon = "[green]✅[/]" if profile_ok else ("[yellow]⏳[/]" if pending else "[red]❌[/]")
|
|
271
|
+
|
|
272
|
+
table.add_row(app, installed_icon, profile_icon, running_icon, pid_value, note)
|
|
273
|
+
|
|
274
|
+
should_pass = installed and profile_ok
|
|
275
|
+
if self.require_running_apps and installed and profile_ok:
|
|
276
|
+
should_pass = running is True
|
|
277
|
+
|
|
278
|
+
if pending:
|
|
279
|
+
self.results["apps"]["skipped"] += 1
|
|
280
|
+
elif should_pass:
|
|
281
|
+
self.results["apps"]["passed"] += 1
|
|
282
|
+
else:
|
|
283
|
+
self.results["apps"]["failed"] += 1
|
|
284
|
+
|
|
285
|
+
self.results["apps"]["details"].append(
|
|
286
|
+
{
|
|
287
|
+
"app": app,
|
|
288
|
+
"installed": installed,
|
|
289
|
+
"profile": profile_ok,
|
|
290
|
+
"running": running,
|
|
291
|
+
"pid": pid,
|
|
292
|
+
"note": note,
|
|
293
|
+
"pending": pending,
|
|
294
|
+
}
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if installed and profile_ok and running in (False, None):
|
|
298
|
+
logs = _collect_app_logs(app)
|
|
299
|
+
if logs:
|
|
300
|
+
self.console.print(Panel(logs, title=f"Logs: {app}", border_style="yellow"))
|
|
301
|
+
|
|
302
|
+
self.console.print(table)
|
|
303
|
+
return self.results["apps"]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
import zlib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, Optional
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class VMValidatorCore:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
config: dict,
|
|
17
|
+
vm_name: str,
|
|
18
|
+
conn_uri: str,
|
|
19
|
+
console: Console = None,
|
|
20
|
+
require_running_apps: bool = False,
|
|
21
|
+
smoke_test: bool = False,
|
|
22
|
+
):
|
|
23
|
+
self.config = config
|
|
24
|
+
self.vm_name = vm_name
|
|
25
|
+
self.conn_uri = conn_uri
|
|
26
|
+
self.console = console or Console()
|
|
27
|
+
self.require_running_apps = require_running_apps
|
|
28
|
+
self.smoke_test = smoke_test
|
|
29
|
+
self.results = {
|
|
30
|
+
"mounts": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
31
|
+
"packages": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
32
|
+
"snap_packages": {
|
|
33
|
+
"passed": 0,
|
|
34
|
+
"failed": 0,
|
|
35
|
+
"skipped": 0,
|
|
36
|
+
"total": 0,
|
|
37
|
+
"details": [],
|
|
38
|
+
},
|
|
39
|
+
"services": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
40
|
+
"disk": {"usage_pct": 0, "avail": "0", "total": "0"},
|
|
41
|
+
"apps": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
42
|
+
"smoke": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
43
|
+
"overall": "unknown",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
self._setup_in_progress_cache: Optional[bool] = None
|
|
47
|
+
self._exec_transport: str = "qga" # qga|ssh
|
|
48
|
+
|
|
49
|
+
def _get_ssh_key_path(self) -> Optional[Path]:
|
|
50
|
+
"""Return path to the SSH key generated for this VM (if present)."""
|
|
51
|
+
try:
|
|
52
|
+
if self.conn_uri.endswith("/session"):
|
|
53
|
+
images_dir = Path.home() / ".local/share/libvirt/images"
|
|
54
|
+
else:
|
|
55
|
+
images_dir = Path("/var/lib/libvirt/images")
|
|
56
|
+
key_path = images_dir / self.vm_name / "ssh_key"
|
|
57
|
+
return key_path if key_path.exists() else None
|
|
58
|
+
except Exception:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def _get_ssh_port(self) -> int:
|
|
62
|
+
"""Deterministic host-side SSH port for passt port forwarding."""
|
|
63
|
+
return 22000 + (zlib.crc32(self.vm_name.encode("utf-8")) % 1000)
|
|
64
|
+
|
|
65
|
+
def _ssh_exec(self, command: str, timeout: int = 10) -> Optional[str]:
|
|
66
|
+
if shutil.which("ssh") is None:
|
|
67
|
+
return None
|
|
68
|
+
key_path = self._get_ssh_key_path()
|
|
69
|
+
if key_path is None:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
ssh_port = self._get_ssh_port()
|
|
73
|
+
user = (self.config.get("vm") or {}).get("username") or "ubuntu"
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
result = subprocess.run(
|
|
77
|
+
[
|
|
78
|
+
"ssh",
|
|
79
|
+
"-i",
|
|
80
|
+
str(key_path),
|
|
81
|
+
"-p",
|
|
82
|
+
str(ssh_port),
|
|
83
|
+
"-o",
|
|
84
|
+
"StrictHostKeyChecking=no",
|
|
85
|
+
"-o",
|
|
86
|
+
"UserKnownHostsFile=/dev/null",
|
|
87
|
+
"-o",
|
|
88
|
+
"ConnectTimeout=5",
|
|
89
|
+
"-o",
|
|
90
|
+
"BatchMode=yes",
|
|
91
|
+
f"{user}@127.0.0.1",
|
|
92
|
+
command,
|
|
93
|
+
],
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
timeout=timeout,
|
|
97
|
+
)
|
|
98
|
+
if result.returncode != 0:
|
|
99
|
+
return None
|
|
100
|
+
return (result.stdout or "").strip()
|
|
101
|
+
except Exception:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def _exec_in_vm(self, command: str, timeout: int = 10) -> Optional[str]:
|
|
105
|
+
"""Execute command in VM using QEMU guest agent, with SSH fallback."""
|
|
106
|
+
if self._exec_transport == "ssh":
|
|
107
|
+
return self._ssh_exec(command, timeout=timeout)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
[
|
|
112
|
+
"virsh",
|
|
113
|
+
"--connect",
|
|
114
|
+
self.conn_uri,
|
|
115
|
+
"qemu-agent-command",
|
|
116
|
+
self.vm_name,
|
|
117
|
+
f'{{"execute":"guest-exec","arguments":{{"path":"/bin/sh","arg":["-c","{command}"],"capture-output":true}}}}',
|
|
118
|
+
],
|
|
119
|
+
capture_output=True,
|
|
120
|
+
text=True,
|
|
121
|
+
timeout=timeout,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if result.returncode != 0:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
response = json.loads(result.stdout)
|
|
128
|
+
if "return" not in response or "pid" not in response["return"]:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
pid = response["return"]["pid"]
|
|
132
|
+
|
|
133
|
+
deadline = time.time() + timeout
|
|
134
|
+
while time.time() < deadline:
|
|
135
|
+
status_result = subprocess.run(
|
|
136
|
+
[
|
|
137
|
+
"virsh",
|
|
138
|
+
"--connect",
|
|
139
|
+
self.conn_uri,
|
|
140
|
+
"qemu-agent-command",
|
|
141
|
+
self.vm_name,
|
|
142
|
+
f'{{"execute":"guest-exec-status","arguments":{{"pid":{pid}}}}}',
|
|
143
|
+
],
|
|
144
|
+
capture_output=True,
|
|
145
|
+
text=True,
|
|
146
|
+
timeout=5,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if status_result.returncode != 0:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
status_resp = json.loads(status_result.stdout)
|
|
153
|
+
if "return" not in status_resp:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
ret = status_resp["return"]
|
|
157
|
+
if ret.get("exited", False):
|
|
158
|
+
if "out-data" in ret:
|
|
159
|
+
return base64.b64decode(ret["out-data"]).decode().strip()
|
|
160
|
+
return ""
|
|
161
|
+
|
|
162
|
+
time.sleep(0.2)
|
|
163
|
+
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
except Exception:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def _setup_in_progress(self) -> Optional[bool]:
|
|
170
|
+
if self._setup_in_progress_cache is not None:
|
|
171
|
+
return self._setup_in_progress_cache
|
|
172
|
+
|
|
173
|
+
out = self._exec_in_vm(
|
|
174
|
+
"test -f /var/lib/cloud/instance/boot-finished && echo no || echo yes",
|
|
175
|
+
timeout=10,
|
|
176
|
+
)
|
|
177
|
+
if out is None:
|
|
178
|
+
self._setup_in_progress_cache = None
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
self._setup_in_progress_cache = out.strip() == "yes"
|
|
182
|
+
return self._setup_in_progress_cache
|
|
183
|
+
|
|
184
|
+
def _check_qga_ready(self) -> bool:
|
|
185
|
+
"""Check if QEMU guest agent is responding."""
|
|
186
|
+
try:
|
|
187
|
+
result = subprocess.run(
|
|
188
|
+
[
|
|
189
|
+
"virsh",
|
|
190
|
+
"--connect",
|
|
191
|
+
self.conn_uri,
|
|
192
|
+
"qemu-agent-command",
|
|
193
|
+
self.vm_name,
|
|
194
|
+
'{"execute":"guest-ping"}',
|
|
195
|
+
],
|
|
196
|
+
capture_output=True,
|
|
197
|
+
text=True,
|
|
198
|
+
timeout=5,
|
|
199
|
+
)
|
|
200
|
+
return result.returncode == 0
|
|
201
|
+
except Exception:
|
|
202
|
+
return False
|