clonebox 1.1.20__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 +42 -7
- clonebox/cloner.py +100 -7
- 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 -1349
- {clonebox-1.1.20.dist-info → clonebox-1.1.23.dist-info}/METADATA +1 -1
- {clonebox-1.1.20.dist-info → clonebox-1.1.23.dist-info}/RECORD +19 -9
- {clonebox-1.1.20.dist-info → clonebox-1.1.23.dist-info}/WHEEL +0 -0
- {clonebox-1.1.20.dist-info → clonebox-1.1.23.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.20.dist-info → clonebox-1.1.23.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.20.dist-info → clonebox-1.1.23.dist-info}/top_level.txt +0 -0
clonebox/validator.py
CHANGED
|
@@ -1,1350 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
VM validation module - validates VM state against YAML configuration.
|
|
3
|
-
"""
|
|
1
|
+
from clonebox.validation import VMValidator
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
import json
|
|
7
|
-
import base64
|
|
8
|
-
import time
|
|
9
|
-
from typing import Dict, List, Tuple, Optional
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
from rich.console import Console
|
|
12
|
-
from rich.panel import Panel
|
|
13
|
-
from rich.table import Table
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class VMValidator:
|
|
17
|
-
"""Validates VM configuration against expected state from YAML."""
|
|
18
|
-
|
|
19
|
-
def __init__(
|
|
20
|
-
self,
|
|
21
|
-
config: dict,
|
|
22
|
-
vm_name: str,
|
|
23
|
-
conn_uri: str,
|
|
24
|
-
console: Console = None,
|
|
25
|
-
require_running_apps: bool = False,
|
|
26
|
-
smoke_test: bool = False,
|
|
27
|
-
):
|
|
28
|
-
self.config = config
|
|
29
|
-
self.vm_name = vm_name
|
|
30
|
-
self.conn_uri = conn_uri
|
|
31
|
-
self.console = console or Console()
|
|
32
|
-
self.require_running_apps = require_running_apps
|
|
33
|
-
self.smoke_test = smoke_test
|
|
34
|
-
self.results = {
|
|
35
|
-
"mounts": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
36
|
-
"packages": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
37
|
-
"snap_packages": {
|
|
38
|
-
"passed": 0,
|
|
39
|
-
"failed": 0,
|
|
40
|
-
"skipped": 0,
|
|
41
|
-
"total": 0,
|
|
42
|
-
"details": [],
|
|
43
|
-
},
|
|
44
|
-
"services": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
45
|
-
"disk": {"usage_pct": 0, "avail": "0", "total": "0"},
|
|
46
|
-
"apps": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
47
|
-
"smoke": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
48
|
-
"overall": "unknown",
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
self._setup_in_progress_cache: Optional[bool] = None
|
|
52
|
-
|
|
53
|
-
def _exec_in_vm(self, command: str, timeout: int = 10) -> Optional[str]:
|
|
54
|
-
"""Execute command in VM using QEMU guest agent."""
|
|
55
|
-
try:
|
|
56
|
-
# Execute command
|
|
57
|
-
result = subprocess.run(
|
|
58
|
-
[
|
|
59
|
-
"virsh",
|
|
60
|
-
"--connect",
|
|
61
|
-
self.conn_uri,
|
|
62
|
-
"qemu-agent-command",
|
|
63
|
-
self.vm_name,
|
|
64
|
-
f'{{"execute":"guest-exec","arguments":{{"path":"/bin/sh","arg":["-c","{command}"],"capture-output":true}}}}',
|
|
65
|
-
],
|
|
66
|
-
capture_output=True,
|
|
67
|
-
text=True,
|
|
68
|
-
timeout=timeout,
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
if result.returncode != 0:
|
|
72
|
-
return None
|
|
73
|
-
|
|
74
|
-
response = json.loads(result.stdout)
|
|
75
|
-
if "return" not in response or "pid" not in response["return"]:
|
|
76
|
-
return None
|
|
77
|
-
|
|
78
|
-
pid = response["return"]["pid"]
|
|
79
|
-
|
|
80
|
-
deadline = time.time() + timeout
|
|
81
|
-
while time.time() < deadline:
|
|
82
|
-
status_result = subprocess.run(
|
|
83
|
-
[
|
|
84
|
-
"virsh",
|
|
85
|
-
"--connect",
|
|
86
|
-
self.conn_uri,
|
|
87
|
-
"qemu-agent-command",
|
|
88
|
-
self.vm_name,
|
|
89
|
-
f'{{"execute":"guest-exec-status","arguments":{{"pid":{pid}}}}}',
|
|
90
|
-
],
|
|
91
|
-
capture_output=True,
|
|
92
|
-
text=True,
|
|
93
|
-
timeout=5,
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
if status_result.returncode != 0:
|
|
97
|
-
return None
|
|
98
|
-
|
|
99
|
-
status_resp = json.loads(status_result.stdout)
|
|
100
|
-
if "return" not in status_resp:
|
|
101
|
-
return None
|
|
102
|
-
|
|
103
|
-
ret = status_resp["return"]
|
|
104
|
-
if ret.get("exited", False):
|
|
105
|
-
if "out-data" in ret:
|
|
106
|
-
return base64.b64decode(ret["out-data"]).decode().strip()
|
|
107
|
-
return ""
|
|
108
|
-
|
|
109
|
-
time.sleep(0.2)
|
|
110
|
-
|
|
111
|
-
return None
|
|
112
|
-
|
|
113
|
-
except Exception:
|
|
114
|
-
return None
|
|
115
|
-
|
|
116
|
-
def _setup_in_progress(self) -> Optional[bool]:
|
|
117
|
-
if self._setup_in_progress_cache is not None:
|
|
118
|
-
return self._setup_in_progress_cache
|
|
119
|
-
|
|
120
|
-
out = self._exec_in_vm(
|
|
121
|
-
"test -f /var/lib/cloud/instance/boot-finished && echo no || echo yes",
|
|
122
|
-
timeout=10,
|
|
123
|
-
)
|
|
124
|
-
if out is None:
|
|
125
|
-
self._setup_in_progress_cache = None
|
|
126
|
-
return None
|
|
127
|
-
|
|
128
|
-
self._setup_in_progress_cache = out.strip() == "yes"
|
|
129
|
-
return self._setup_in_progress_cache
|
|
130
|
-
|
|
131
|
-
def validate_mounts(self) -> Dict:
|
|
132
|
-
"""Validate all mount points and copied data paths."""
|
|
133
|
-
setup_in_progress = self._setup_in_progress_cache is True
|
|
134
|
-
self.console.print("\n[bold]💾 Validating Mounts & Data...[/]")
|
|
135
|
-
|
|
136
|
-
paths = self.config.get("paths", {})
|
|
137
|
-
# Support both v1 (app_data_paths) and v2 (copy_paths) config formats
|
|
138
|
-
copy_paths = self.config.get("copy_paths", None)
|
|
139
|
-
if not isinstance(copy_paths, dict) or not copy_paths:
|
|
140
|
-
copy_paths = self.config.get("app_data_paths", {})
|
|
141
|
-
|
|
142
|
-
if not paths and not copy_paths:
|
|
143
|
-
self.console.print("[dim]No mounts or data paths configured[/]")
|
|
144
|
-
return self.results["mounts"]
|
|
145
|
-
|
|
146
|
-
# Get mounted filesystems
|
|
147
|
-
mount_output = self._exec_in_vm("mount | grep 9p")
|
|
148
|
-
mounted_paths = []
|
|
149
|
-
if mount_output:
|
|
150
|
-
for line in mount_output.split("\n"):
|
|
151
|
-
line = line.strip()
|
|
152
|
-
if not line:
|
|
153
|
-
continue
|
|
154
|
-
parts = line.split()
|
|
155
|
-
if len(parts) >= 3:
|
|
156
|
-
mounted_paths.append(parts[2])
|
|
157
|
-
|
|
158
|
-
mount_table = Table(title="Data Validation", border_style="cyan")
|
|
159
|
-
mount_table.add_column("Guest Path", style="bold")
|
|
160
|
-
mount_table.add_column("Type", justify="center")
|
|
161
|
-
mount_table.add_column("Status", justify="center")
|
|
162
|
-
mount_table.add_column("Files", justify="right")
|
|
163
|
-
|
|
164
|
-
# Validate bind mounts (paths)
|
|
165
|
-
for host_path, guest_path in paths.items():
|
|
166
|
-
self.results["mounts"]["total"] += 1
|
|
167
|
-
|
|
168
|
-
# Check if mounted
|
|
169
|
-
is_mounted = any(guest_path in mp for mp in mounted_paths)
|
|
170
|
-
|
|
171
|
-
# Check if accessible
|
|
172
|
-
accessible = False
|
|
173
|
-
file_count = "?"
|
|
174
|
-
|
|
175
|
-
if is_mounted:
|
|
176
|
-
test_result = self._exec_in_vm(f"test -d {guest_path} && echo 'yes' || echo 'no'")
|
|
177
|
-
accessible = test_result == "yes"
|
|
178
|
-
|
|
179
|
-
if accessible:
|
|
180
|
-
count_str = self._exec_in_vm(f"ls -A {guest_path} 2>/dev/null | wc -l")
|
|
181
|
-
if count_str and count_str.isdigit():
|
|
182
|
-
file_count = count_str
|
|
183
|
-
|
|
184
|
-
if is_mounted and accessible:
|
|
185
|
-
status_icon = "[green]✅ Mounted[/]"
|
|
186
|
-
self.results["mounts"]["passed"] += 1
|
|
187
|
-
status = "pass"
|
|
188
|
-
elif is_mounted:
|
|
189
|
-
status_icon = "[red]❌ Inaccessible[/]"
|
|
190
|
-
self.results["mounts"]["failed"] += 1
|
|
191
|
-
status = "mounted_but_inaccessible"
|
|
192
|
-
elif setup_in_progress:
|
|
193
|
-
status_icon = "[yellow]⏳ Pending[/]"
|
|
194
|
-
status = "pending"
|
|
195
|
-
self.results["mounts"]["skipped"] += 1
|
|
196
|
-
else:
|
|
197
|
-
status_icon = "[red]❌ Not Mounted[/]"
|
|
198
|
-
self.results["mounts"]["failed"] += 1
|
|
199
|
-
status = "not_mounted"
|
|
200
|
-
|
|
201
|
-
mount_table.add_row(guest_path, "Bind Mount", status_icon, str(file_count))
|
|
202
|
-
self.results["mounts"]["details"].append({
|
|
203
|
-
"path": guest_path,
|
|
204
|
-
"type": "mount",
|
|
205
|
-
"mounted": is_mounted,
|
|
206
|
-
"accessible": accessible,
|
|
207
|
-
"files": file_count,
|
|
208
|
-
"status": status
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
# Validate copied paths (copy_paths / app_data_paths)
|
|
212
|
-
for host_path, guest_path in copy_paths.items():
|
|
213
|
-
self.results["mounts"]["total"] += 1
|
|
214
|
-
|
|
215
|
-
# Check if exists and has content
|
|
216
|
-
exists = False
|
|
217
|
-
file_count = "?"
|
|
218
|
-
|
|
219
|
-
test_result = self._exec_in_vm(f"test -d {guest_path} && echo 'yes' || echo 'no'")
|
|
220
|
-
exists = test_result == "yes"
|
|
221
|
-
|
|
222
|
-
if exists:
|
|
223
|
-
count_str = self._exec_in_vm(f"ls -A {guest_path} 2>/dev/null | wc -l")
|
|
224
|
-
if count_str and count_str.isdigit():
|
|
225
|
-
file_count = count_str
|
|
226
|
-
|
|
227
|
-
# For copied paths, we just check existence and content
|
|
228
|
-
if exists:
|
|
229
|
-
# Warning if empty? Maybe, but strictly it passed existence check
|
|
230
|
-
status_icon = "[green]✅ Copied[/]"
|
|
231
|
-
self.results["mounts"]["passed"] += 1
|
|
232
|
-
status = "pass"
|
|
233
|
-
elif setup_in_progress:
|
|
234
|
-
status_icon = "[yellow]⏳ Pending[/]"
|
|
235
|
-
status = "pending"
|
|
236
|
-
self.results["mounts"]["skipped"] += 1
|
|
237
|
-
else:
|
|
238
|
-
status_icon = "[red]❌ Missing[/]"
|
|
239
|
-
self.results["mounts"]["failed"] += 1
|
|
240
|
-
status = "missing"
|
|
241
|
-
|
|
242
|
-
mount_table.add_row(guest_path, "Imported", status_icon, str(file_count))
|
|
243
|
-
self.results["mounts"]["details"].append({
|
|
244
|
-
"path": guest_path,
|
|
245
|
-
"type": "copy",
|
|
246
|
-
"mounted": False, # Expected false for copies
|
|
247
|
-
"accessible": exists,
|
|
248
|
-
"files": file_count,
|
|
249
|
-
"status": status
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
self.console.print(mount_table)
|
|
253
|
-
self.console.print(
|
|
254
|
-
f"[dim]{self.results['mounts']['passed']}/{self.results['mounts']['total']} paths valid[/]"
|
|
255
|
-
)
|
|
256
|
-
|
|
257
|
-
return self.results["mounts"]
|
|
258
|
-
|
|
259
|
-
def validate_packages(self) -> Dict:
|
|
260
|
-
"""Validate APT packages are installed."""
|
|
261
|
-
setup_in_progress = self._setup_in_progress() is True
|
|
262
|
-
self.console.print("\n[bold]📦 Validating APT Packages...[/]")
|
|
263
|
-
|
|
264
|
-
packages = self.config.get("packages", [])
|
|
265
|
-
if not packages:
|
|
266
|
-
self.console.print("[dim]No APT packages configured[/]")
|
|
267
|
-
return self.results["packages"]
|
|
268
|
-
|
|
269
|
-
total_pkgs = len(packages)
|
|
270
|
-
self.console.print(f"[dim]Checking {total_pkgs} packages via QGA...[/]")
|
|
271
|
-
|
|
272
|
-
pkg_table = Table(title="Package Validation", border_style="cyan")
|
|
273
|
-
pkg_table.add_column("Package", style="bold")
|
|
274
|
-
pkg_table.add_column("Status", justify="center")
|
|
275
|
-
pkg_table.add_column("Version", style="dim")
|
|
276
|
-
|
|
277
|
-
for idx, package in enumerate(packages, 1):
|
|
278
|
-
if idx == 1 or idx % 25 == 0 or idx == total_pkgs:
|
|
279
|
-
self.console.print(f"[dim] ...packages progress: {idx}/{total_pkgs}[/]")
|
|
280
|
-
self.results["packages"]["total"] += 1
|
|
281
|
-
|
|
282
|
-
# Check if installed
|
|
283
|
-
check_cmd = f"dpkg -l | grep -E '^ii {package}' | awk '{{print $3}}'"
|
|
284
|
-
version = self._exec_in_vm(check_cmd)
|
|
285
|
-
|
|
286
|
-
if version:
|
|
287
|
-
pkg_table.add_row(package, "[green]✅ Installed[/]", version[:40])
|
|
288
|
-
self.results["packages"]["passed"] += 1
|
|
289
|
-
self.results["packages"]["details"].append(
|
|
290
|
-
{"package": package, "installed": True, "version": version}
|
|
291
|
-
)
|
|
292
|
-
else:
|
|
293
|
-
if setup_in_progress:
|
|
294
|
-
pkg_table.add_row(package, "[yellow]⏳ Pending[/]", "")
|
|
295
|
-
self.results["packages"]["skipped"] += 1
|
|
296
|
-
self.results["packages"]["details"].append(
|
|
297
|
-
{"package": package, "installed": False, "version": None, "pending": True}
|
|
298
|
-
)
|
|
299
|
-
else:
|
|
300
|
-
pkg_table.add_row(package, "[red]❌ Missing[/]", "")
|
|
301
|
-
self.results["packages"]["failed"] += 1
|
|
302
|
-
self.results["packages"]["details"].append(
|
|
303
|
-
{"package": package, "installed": False, "version": None}
|
|
304
|
-
)
|
|
305
|
-
|
|
306
|
-
self.console.print(pkg_table)
|
|
307
|
-
self.console.print(
|
|
308
|
-
f"[dim]{self.results['packages']['passed']}/{self.results['packages']['total']} packages installed[/]"
|
|
309
|
-
)
|
|
310
|
-
|
|
311
|
-
return self.results["packages"]
|
|
312
|
-
|
|
313
|
-
def validate_snap_packages(self) -> Dict:
|
|
314
|
-
"""Validate snap packages are installed."""
|
|
315
|
-
setup_in_progress = self._setup_in_progress() is True
|
|
316
|
-
self.console.print("\n[bold]📦 Validating Snap Packages...[/]")
|
|
317
|
-
|
|
318
|
-
snap_packages = self.config.get("snap_packages", [])
|
|
319
|
-
if not snap_packages:
|
|
320
|
-
self.console.print("[dim]No snap packages configured[/]")
|
|
321
|
-
return self.results["snap_packages"]
|
|
322
|
-
|
|
323
|
-
total_snaps = len(snap_packages)
|
|
324
|
-
self.console.print(f"[dim]Checking {total_snaps} snap packages via QGA...[/]")
|
|
325
|
-
|
|
326
|
-
snap_table = Table(title="Snap Package Validation", border_style="cyan")
|
|
327
|
-
snap_table.add_column("Package", style="bold")
|
|
328
|
-
snap_table.add_column("Status", justify="center")
|
|
329
|
-
snap_table.add_column("Version", style="dim")
|
|
330
|
-
|
|
331
|
-
for idx, package in enumerate(snap_packages, 1):
|
|
332
|
-
if idx == 1 or idx % 25 == 0 or idx == total_snaps:
|
|
333
|
-
self.console.print(f"[dim] ...snap progress: {idx}/{total_snaps}[/]")
|
|
334
|
-
self.results["snap_packages"]["total"] += 1
|
|
335
|
-
|
|
336
|
-
# Check if installed
|
|
337
|
-
check_cmd = f"snap list | grep '^{package}' | awk '{{print $2}}'"
|
|
338
|
-
version = self._exec_in_vm(check_cmd)
|
|
339
|
-
|
|
340
|
-
if version:
|
|
341
|
-
snap_table.add_row(package, "[green]✅ Installed[/]", version[:40])
|
|
342
|
-
self.results["snap_packages"]["passed"] += 1
|
|
343
|
-
self.results["snap_packages"]["details"].append(
|
|
344
|
-
{"package": package, "installed": True, "version": version}
|
|
345
|
-
)
|
|
346
|
-
else:
|
|
347
|
-
if setup_in_progress:
|
|
348
|
-
snap_table.add_row(package, "[yellow]⏳ Pending[/]", "")
|
|
349
|
-
self.results["snap_packages"]["skipped"] += 1
|
|
350
|
-
self.results["snap_packages"]["details"].append(
|
|
351
|
-
{
|
|
352
|
-
"package": package,
|
|
353
|
-
"installed": False,
|
|
354
|
-
"version": None,
|
|
355
|
-
"pending": True,
|
|
356
|
-
}
|
|
357
|
-
)
|
|
358
|
-
else:
|
|
359
|
-
snap_table.add_row(package, "[red]❌ Missing[/]", "")
|
|
360
|
-
self.results["snap_packages"]["failed"] += 1
|
|
361
|
-
self.results["snap_packages"]["details"].append(
|
|
362
|
-
{"package": package, "installed": False, "version": None}
|
|
363
|
-
)
|
|
364
|
-
|
|
365
|
-
self.console.print(snap_table)
|
|
366
|
-
msg = f"{self.results['snap_packages']['passed']}/{self.results['snap_packages']['total']} snap packages installed"
|
|
367
|
-
if self.results["snap_packages"].get("skipped", 0) > 0:
|
|
368
|
-
msg += f" ({self.results['snap_packages']['skipped']} pending)"
|
|
369
|
-
self.console.print(f"[dim]{msg}[/]")
|
|
370
|
-
|
|
371
|
-
return self.results["snap_packages"]
|
|
372
|
-
|
|
373
|
-
# Services that should NOT be validated in VM (host-specific)
|
|
374
|
-
VM_EXCLUDED_SERVICES = {
|
|
375
|
-
"libvirtd",
|
|
376
|
-
"virtlogd",
|
|
377
|
-
"libvirt-guests",
|
|
378
|
-
"qemu-guest-agent",
|
|
379
|
-
"bluetooth",
|
|
380
|
-
"bluez",
|
|
381
|
-
"upower",
|
|
382
|
-
"thermald",
|
|
383
|
-
"tlp",
|
|
384
|
-
"power-profiles-daemon",
|
|
385
|
-
"gdm",
|
|
386
|
-
"gdm3",
|
|
387
|
-
"sddm",
|
|
388
|
-
"lightdm",
|
|
389
|
-
"snap.cups.cups-browsed",
|
|
390
|
-
"snap.cups.cupsd",
|
|
391
|
-
"ModemManager",
|
|
392
|
-
"wpa_supplicant",
|
|
393
|
-
"accounts-daemon",
|
|
394
|
-
"colord",
|
|
395
|
-
"switcheroo-control",
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
def validate_services(self) -> Dict:
|
|
399
|
-
"""Validate services are enabled and running."""
|
|
400
|
-
setup_in_progress = self._setup_in_progress() is True
|
|
401
|
-
self.console.print("\n[bold]⚙️ Validating Services...[/]")
|
|
402
|
-
|
|
403
|
-
services = self.config.get("services", [])
|
|
404
|
-
if not services:
|
|
405
|
-
self.console.print("[dim]No services configured[/]")
|
|
406
|
-
return self.results["services"]
|
|
407
|
-
|
|
408
|
-
total_svcs = len(services)
|
|
409
|
-
self.console.print(f"[dim]Checking {total_svcs} services via QGA...[/]")
|
|
410
|
-
|
|
411
|
-
if "skipped" not in self.results["services"]:
|
|
412
|
-
self.results["services"]["skipped"] = 0
|
|
413
|
-
|
|
414
|
-
svc_table = Table(title="Service Validation", border_style="cyan")
|
|
415
|
-
svc_table.add_column("Service", style="bold")
|
|
416
|
-
svc_table.add_column("Enabled", justify="center")
|
|
417
|
-
svc_table.add_column("Running", justify="center")
|
|
418
|
-
svc_table.add_column("PID", justify="right", style="dim")
|
|
419
|
-
svc_table.add_column("Note", style="dim")
|
|
420
|
-
|
|
421
|
-
for idx, service in enumerate(services, 1):
|
|
422
|
-
if idx == 1 or idx % 25 == 0 or idx == total_svcs:
|
|
423
|
-
self.console.print(f"[dim] ...services progress: {idx}/{total_svcs}[/]")
|
|
424
|
-
if service in self.VM_EXCLUDED_SERVICES:
|
|
425
|
-
svc_table.add_row(service, "[dim]—[/]", "[dim]—[/]", "[dim]—[/]", "host-only")
|
|
426
|
-
self.results["services"]["skipped"] += 1
|
|
427
|
-
self.results["services"]["details"].append(
|
|
428
|
-
{
|
|
429
|
-
"service": service,
|
|
430
|
-
"enabled": None,
|
|
431
|
-
"running": None,
|
|
432
|
-
"skipped": True,
|
|
433
|
-
"reason": "host-specific service",
|
|
434
|
-
}
|
|
435
|
-
)
|
|
436
|
-
continue
|
|
437
|
-
|
|
438
|
-
self.results["services"]["total"] += 1
|
|
439
|
-
|
|
440
|
-
enabled_cmd = f"systemctl is-enabled {service} 2>/dev/null"
|
|
441
|
-
enabled_status = self._exec_in_vm(enabled_cmd)
|
|
442
|
-
is_enabled = enabled_status == "enabled"
|
|
443
|
-
|
|
444
|
-
running_cmd = f"systemctl is-active {service} 2>/dev/null"
|
|
445
|
-
running_status = self._exec_in_vm(running_cmd)
|
|
446
|
-
is_running = running_status == "active"
|
|
447
|
-
|
|
448
|
-
pid_value = ""
|
|
449
|
-
if is_running:
|
|
450
|
-
pid_out = self._exec_in_vm(
|
|
451
|
-
f"systemctl show -p MainPID --value {service} 2>/dev/null"
|
|
452
|
-
)
|
|
453
|
-
if pid_out is None:
|
|
454
|
-
pid_value = "?"
|
|
455
|
-
else:
|
|
456
|
-
pid_value = pid_out.strip() or "?"
|
|
457
|
-
else:
|
|
458
|
-
pid_value = "—"
|
|
459
|
-
|
|
460
|
-
enabled_icon = "[green]✅[/]" if is_enabled else ("[yellow]⏳[/]" if setup_in_progress else "[yellow]⚠️[/]")
|
|
461
|
-
running_icon = "[green]✅[/]" if is_running else ("[yellow]⏳[/]" if setup_in_progress else "[red]❌[/]")
|
|
462
|
-
|
|
463
|
-
svc_table.add_row(service, enabled_icon, running_icon, pid_value, "")
|
|
464
|
-
|
|
465
|
-
if is_enabled and is_running:
|
|
466
|
-
self.results["services"]["passed"] += 1
|
|
467
|
-
elif setup_in_progress:
|
|
468
|
-
self.results["services"]["skipped"] += 1
|
|
469
|
-
else:
|
|
470
|
-
self.results["services"]["failed"] += 1
|
|
471
|
-
|
|
472
|
-
self.results["services"]["details"].append(
|
|
473
|
-
{
|
|
474
|
-
"service": service,
|
|
475
|
-
"enabled": is_enabled,
|
|
476
|
-
"running": is_running,
|
|
477
|
-
"pid": None if pid_value in ("", "—", "?") else pid_value,
|
|
478
|
-
"skipped": False,
|
|
479
|
-
}
|
|
480
|
-
)
|
|
481
|
-
|
|
482
|
-
self.console.print(svc_table)
|
|
483
|
-
skipped = self.results["services"].get("skipped", 0)
|
|
484
|
-
msg = f"{self.results['services']['passed']}/{self.results['services']['total']} services active"
|
|
485
|
-
if skipped > 0:
|
|
486
|
-
msg += f" ({skipped} host-only skipped)"
|
|
487
|
-
self.console.print(f"[dim]{msg}[/]")
|
|
488
|
-
|
|
489
|
-
return self.results["services"]
|
|
490
|
-
|
|
491
|
-
def validate_apps(self) -> Dict:
|
|
492
|
-
setup_in_progress = self._setup_in_progress() is True
|
|
493
|
-
packages = self.config.get("packages", [])
|
|
494
|
-
snap_packages = self.config.get("snap_packages", [])
|
|
495
|
-
# Support both v1 (app_data_paths) and v2 (copy_paths) config formats
|
|
496
|
-
copy_paths = self.config.get("copy_paths", None)
|
|
497
|
-
if not isinstance(copy_paths, dict) or not copy_paths:
|
|
498
|
-
copy_paths = self.config.get("app_data_paths", {})
|
|
499
|
-
vm_user = self.config.get("vm", {}).get("username", "ubuntu")
|
|
500
|
-
|
|
501
|
-
snap_app_specs = {
|
|
502
|
-
"pycharm-community": {
|
|
503
|
-
"process_patterns": ["pycharm-community", "pycharm", "jetbrains"],
|
|
504
|
-
"required_interfaces": [
|
|
505
|
-
"desktop",
|
|
506
|
-
"desktop-legacy",
|
|
507
|
-
"x11",
|
|
508
|
-
"wayland",
|
|
509
|
-
"home",
|
|
510
|
-
"network",
|
|
511
|
-
],
|
|
512
|
-
},
|
|
513
|
-
"chromium": {
|
|
514
|
-
"process_patterns": ["chromium", "chromium-browser"],
|
|
515
|
-
"required_interfaces": [
|
|
516
|
-
"desktop",
|
|
517
|
-
"desktop-legacy",
|
|
518
|
-
"x11",
|
|
519
|
-
"wayland",
|
|
520
|
-
"home",
|
|
521
|
-
"network",
|
|
522
|
-
],
|
|
523
|
-
},
|
|
524
|
-
"firefox": {
|
|
525
|
-
"process_patterns": ["firefox"],
|
|
526
|
-
"required_interfaces": [
|
|
527
|
-
"desktop",
|
|
528
|
-
"desktop-legacy",
|
|
529
|
-
"x11",
|
|
530
|
-
"wayland",
|
|
531
|
-
"home",
|
|
532
|
-
"network",
|
|
533
|
-
],
|
|
534
|
-
},
|
|
535
|
-
"code": {
|
|
536
|
-
"process_patterns": ["code"],
|
|
537
|
-
"required_interfaces": [
|
|
538
|
-
"desktop",
|
|
539
|
-
"desktop-legacy",
|
|
540
|
-
"x11",
|
|
541
|
-
"wayland",
|
|
542
|
-
"home",
|
|
543
|
-
"network",
|
|
544
|
-
],
|
|
545
|
-
},
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
expected = []
|
|
549
|
-
|
|
550
|
-
if "firefox" in packages:
|
|
551
|
-
expected.append("firefox")
|
|
552
|
-
|
|
553
|
-
for snap_pkg in snap_packages:
|
|
554
|
-
if snap_pkg in snap_app_specs:
|
|
555
|
-
expected.append(snap_pkg)
|
|
556
|
-
|
|
557
|
-
for _, guest_path in copy_paths.items():
|
|
558
|
-
if guest_path == "/home/ubuntu/.config/google-chrome":
|
|
559
|
-
expected.append("google-chrome")
|
|
560
|
-
break
|
|
561
|
-
|
|
562
|
-
expected = sorted(set(expected))
|
|
563
|
-
if not expected:
|
|
564
|
-
return self.results["apps"]
|
|
565
|
-
|
|
566
|
-
self.console.print("\n[bold]🧩 Validating Apps...[/]")
|
|
567
|
-
table = Table(title="App Validation", border_style="cyan")
|
|
568
|
-
table.add_column("App", style="bold")
|
|
569
|
-
table.add_column("Installed", justify="center")
|
|
570
|
-
table.add_column("Profile", justify="center")
|
|
571
|
-
table.add_column("Running", justify="center")
|
|
572
|
-
table.add_column("PID", justify="right", style="dim")
|
|
573
|
-
table.add_column("Note", style="dim")
|
|
574
|
-
|
|
575
|
-
def _pgrep_pattern(pattern: str) -> str:
|
|
576
|
-
if not pattern:
|
|
577
|
-
return pattern
|
|
578
|
-
return f"[{pattern[0]}]{pattern[1:]}"
|
|
579
|
-
|
|
580
|
-
def _check_any_process_running(patterns: List[str]) -> Optional[bool]:
|
|
581
|
-
for pattern in patterns:
|
|
582
|
-
p = _pgrep_pattern(pattern)
|
|
583
|
-
out = self._exec_in_vm(
|
|
584
|
-
f"pgrep -u {vm_user} -f '{p}' >/dev/null 2>&1 && echo yes || echo no",
|
|
585
|
-
timeout=10,
|
|
586
|
-
)
|
|
587
|
-
if out is None:
|
|
588
|
-
return None
|
|
589
|
-
if out == "yes":
|
|
590
|
-
return True
|
|
591
|
-
return False
|
|
592
|
-
|
|
593
|
-
def _find_first_pid(patterns: List[str]) -> Optional[str]:
|
|
594
|
-
for pattern in patterns:
|
|
595
|
-
p = _pgrep_pattern(pattern)
|
|
596
|
-
out = self._exec_in_vm(
|
|
597
|
-
f"pgrep -u {vm_user} -f '{p}' 2>/dev/null | head -n 1 || true",
|
|
598
|
-
timeout=10,
|
|
599
|
-
)
|
|
600
|
-
if out is None:
|
|
601
|
-
return None
|
|
602
|
-
pid = out.strip()
|
|
603
|
-
if pid:
|
|
604
|
-
return pid
|
|
605
|
-
return ""
|
|
606
|
-
|
|
607
|
-
def _collect_app_logs(app_name: str) -> str:
|
|
608
|
-
chunks: List[str] = []
|
|
609
|
-
|
|
610
|
-
def add(cmd: str, title: str, timeout: int = 20):
|
|
611
|
-
out = self._exec_in_vm(cmd, timeout=timeout)
|
|
612
|
-
if out is None:
|
|
613
|
-
return
|
|
614
|
-
out = out.strip()
|
|
615
|
-
if not out:
|
|
616
|
-
return
|
|
617
|
-
chunks.append(f"{title}\n$ {cmd}\n{out}")
|
|
618
|
-
|
|
619
|
-
if app_name in snap_app_specs:
|
|
620
|
-
add(f"snap connections {app_name} 2>/dev/null | head -n 40", "Snap connections")
|
|
621
|
-
add(f"snap logs {app_name} -n 80 2>/dev/null | tail -n 60", "Snap logs")
|
|
622
|
-
|
|
623
|
-
if app_name == "pycharm-community":
|
|
624
|
-
add(
|
|
625
|
-
"tail -n 80 /home/ubuntu/snap/pycharm-community/common/.config/JetBrains/*/log/idea.log 2>/dev/null || true",
|
|
626
|
-
"idea.log",
|
|
627
|
-
)
|
|
628
|
-
|
|
629
|
-
if app_name == "google-chrome":
|
|
630
|
-
add(
|
|
631
|
-
"journalctl -n 200 --no-pager 2>/dev/null | grep -i chrome | tail -n 60 || true",
|
|
632
|
-
"Journal (chrome)",
|
|
633
|
-
)
|
|
634
|
-
if app_name == "firefox":
|
|
635
|
-
add(
|
|
636
|
-
"journalctl -n 200 --no-pager 2>/dev/null | grep -i firefox | tail -n 60 || true",
|
|
637
|
-
"Journal (firefox)",
|
|
638
|
-
)
|
|
639
|
-
|
|
640
|
-
return "\n\n".join(chunks)
|
|
641
|
-
|
|
642
|
-
def _snap_missing_interfaces(snap_name: str, required: List[str]) -> Optional[List[str]]:
|
|
643
|
-
out = self._exec_in_vm(
|
|
644
|
-
f"snap connections {snap_name} 2>/dev/null | awk 'NR>1{{print $1, $3}}'",
|
|
645
|
-
timeout=15,
|
|
646
|
-
)
|
|
647
|
-
if out is None:
|
|
648
|
-
return None
|
|
649
|
-
|
|
650
|
-
connected = set()
|
|
651
|
-
for line in out.splitlines():
|
|
652
|
-
parts = line.split()
|
|
653
|
-
if len(parts) < 2:
|
|
654
|
-
continue
|
|
655
|
-
iface, slot = parts[0], parts[1]
|
|
656
|
-
if slot != "-":
|
|
657
|
-
connected.add(iface)
|
|
658
|
-
|
|
659
|
-
missing = [i for i in required if i not in connected]
|
|
660
|
-
return missing
|
|
661
|
-
|
|
662
|
-
def _check_dir_nonempty(path: str) -> bool:
|
|
663
|
-
out = self._exec_in_vm(
|
|
664
|
-
f"test -d {path} && [ $(ls -A {path} 2>/dev/null | wc -l) -gt 0 ] && echo yes || echo no",
|
|
665
|
-
timeout=10,
|
|
666
|
-
)
|
|
667
|
-
return out == "yes"
|
|
668
|
-
|
|
669
|
-
for app in expected:
|
|
670
|
-
self.results["apps"]["total"] += 1
|
|
671
|
-
installed = False
|
|
672
|
-
profile_ok = False
|
|
673
|
-
running: Optional[bool] = None
|
|
674
|
-
pid: Optional[str] = None
|
|
675
|
-
note = ""
|
|
676
|
-
pending = False
|
|
677
|
-
|
|
678
|
-
if app == "firefox":
|
|
679
|
-
installed = (
|
|
680
|
-
self._exec_in_vm("command -v firefox >/dev/null 2>&1 && echo yes || echo no")
|
|
681
|
-
== "yes"
|
|
682
|
-
)
|
|
683
|
-
if _check_dir_nonempty("/home/ubuntu/snap/firefox/common/.mozilla/firefox"):
|
|
684
|
-
profile_ok = True
|
|
685
|
-
elif _check_dir_nonempty("/home/ubuntu/.mozilla/firefox"):
|
|
686
|
-
profile_ok = True
|
|
687
|
-
|
|
688
|
-
if installed:
|
|
689
|
-
running = _check_any_process_running(["firefox"])
|
|
690
|
-
pid = _find_first_pid(["firefox"]) if running else ""
|
|
691
|
-
|
|
692
|
-
elif app in snap_app_specs:
|
|
693
|
-
installed = (
|
|
694
|
-
self._exec_in_vm(f"snap list {app} >/dev/null 2>&1 && echo yes || echo no")
|
|
695
|
-
== "yes"
|
|
696
|
-
)
|
|
697
|
-
if app == "pycharm-community":
|
|
698
|
-
profile_ok = _check_dir_nonempty(
|
|
699
|
-
"/home/ubuntu/snap/pycharm-community/common/.config/JetBrains"
|
|
700
|
-
)
|
|
701
|
-
else:
|
|
702
|
-
profile_ok = True
|
|
703
|
-
|
|
704
|
-
if installed:
|
|
705
|
-
patterns = snap_app_specs[app]["process_patterns"]
|
|
706
|
-
running = _check_any_process_running(patterns)
|
|
707
|
-
pid = _find_first_pid(patterns) if running else ""
|
|
708
|
-
if running is False:
|
|
709
|
-
missing_ifaces = _snap_missing_interfaces(
|
|
710
|
-
app,
|
|
711
|
-
snap_app_specs[app]["required_interfaces"],
|
|
712
|
-
)
|
|
713
|
-
if missing_ifaces:
|
|
714
|
-
note = f"missing interfaces: {', '.join(missing_ifaces)}"
|
|
715
|
-
elif missing_ifaces == []:
|
|
716
|
-
note = "not running"
|
|
717
|
-
else:
|
|
718
|
-
note = "interfaces unknown"
|
|
719
|
-
|
|
720
|
-
elif app == "google-chrome":
|
|
721
|
-
installed = (
|
|
722
|
-
self._exec_in_vm(
|
|
723
|
-
"(command -v google-chrome >/dev/null 2>&1 || command -v google-chrome-stable >/dev/null 2>&1) && echo yes || echo no"
|
|
724
|
-
)
|
|
725
|
-
== "yes"
|
|
726
|
-
)
|
|
727
|
-
profile_ok = _check_dir_nonempty("/home/ubuntu/.config/google-chrome")
|
|
728
|
-
|
|
729
|
-
if installed:
|
|
730
|
-
running = _check_any_process_running(["google-chrome", "google-chrome-stable"])
|
|
731
|
-
pid = (
|
|
732
|
-
_find_first_pid(["google-chrome", "google-chrome-stable"])
|
|
733
|
-
if running
|
|
734
|
-
else ""
|
|
735
|
-
)
|
|
736
|
-
|
|
737
|
-
if self.require_running_apps and installed and profile_ok and running is None:
|
|
738
|
-
note = note or "running unknown"
|
|
739
|
-
|
|
740
|
-
if setup_in_progress and not installed:
|
|
741
|
-
pending = True
|
|
742
|
-
note = note or "setup in progress"
|
|
743
|
-
elif setup_in_progress and not profile_ok:
|
|
744
|
-
pending = True
|
|
745
|
-
note = note or "profile import in progress"
|
|
746
|
-
|
|
747
|
-
running_icon = (
|
|
748
|
-
"[dim]—[/]"
|
|
749
|
-
if not installed
|
|
750
|
-
else (
|
|
751
|
-
"[green]✅[/]"
|
|
752
|
-
if running is True
|
|
753
|
-
else "[yellow]⚠️[/]" if running is False else "[dim]?[/]"
|
|
754
|
-
)
|
|
755
|
-
)
|
|
756
|
-
|
|
757
|
-
pid_value = "—" if not installed else ("?" if pid is None else (pid or "—"))
|
|
758
|
-
|
|
759
|
-
installed_icon = "[green]✅[/]" if installed else ("[yellow]⏳[/]" if pending else "[red]❌[/]")
|
|
760
|
-
profile_icon = "[green]✅[/]" if profile_ok else ("[yellow]⏳[/]" if pending else "[red]❌[/]")
|
|
761
|
-
|
|
762
|
-
table.add_row(app, installed_icon, profile_icon, running_icon, pid_value, note)
|
|
763
|
-
|
|
764
|
-
should_pass = installed and profile_ok
|
|
765
|
-
if self.require_running_apps and installed and profile_ok:
|
|
766
|
-
should_pass = running is True
|
|
767
|
-
|
|
768
|
-
if pending:
|
|
769
|
-
self.results["apps"]["skipped"] += 1
|
|
770
|
-
elif should_pass:
|
|
771
|
-
self.results["apps"]["passed"] += 1
|
|
772
|
-
else:
|
|
773
|
-
self.results["apps"]["failed"] += 1
|
|
774
|
-
|
|
775
|
-
self.results["apps"]["details"].append(
|
|
776
|
-
{
|
|
777
|
-
"app": app,
|
|
778
|
-
"installed": installed,
|
|
779
|
-
"profile": profile_ok,
|
|
780
|
-
"running": running,
|
|
781
|
-
"pid": pid,
|
|
782
|
-
"note": note,
|
|
783
|
-
"pending": pending,
|
|
784
|
-
}
|
|
785
|
-
)
|
|
786
|
-
|
|
787
|
-
if installed and profile_ok and running in (False, None):
|
|
788
|
-
logs = _collect_app_logs(app)
|
|
789
|
-
if logs:
|
|
790
|
-
self.console.print(Panel(logs, title=f"Logs: {app}", border_style="yellow"))
|
|
791
|
-
|
|
792
|
-
self.console.print(table)
|
|
793
|
-
return self.results["apps"]
|
|
794
|
-
|
|
795
|
-
def validate_smoke_tests(self) -> Dict:
|
|
796
|
-
setup_in_progress = self._setup_in_progress() is True
|
|
797
|
-
packages = self.config.get("packages", [])
|
|
798
|
-
snap_packages = self.config.get("snap_packages", [])
|
|
799
|
-
# Support both v1 (app_data_paths) and v2 (copy_paths) config formats
|
|
800
|
-
copy_paths = self.config.get("copy_paths", None)
|
|
801
|
-
if not isinstance(copy_paths, dict) or not copy_paths:
|
|
802
|
-
copy_paths = self.config.get("app_data_paths", {})
|
|
803
|
-
vm_user = self.config.get("vm", {}).get("username", "ubuntu")
|
|
804
|
-
|
|
805
|
-
expected = []
|
|
806
|
-
|
|
807
|
-
if "firefox" in packages:
|
|
808
|
-
expected.append("firefox")
|
|
809
|
-
|
|
810
|
-
for snap_pkg in snap_packages:
|
|
811
|
-
if snap_pkg in {"pycharm-community", "chromium", "firefox", "code"}:
|
|
812
|
-
expected.append(snap_pkg)
|
|
813
|
-
|
|
814
|
-
for _, guest_path in copy_paths.items():
|
|
815
|
-
if guest_path == "/home/ubuntu/.config/google-chrome":
|
|
816
|
-
expected.append("google-chrome")
|
|
817
|
-
break
|
|
818
|
-
|
|
819
|
-
if "docker" in (self.config.get("services", []) or []) or "docker.io" in packages:
|
|
820
|
-
expected.append("docker")
|
|
821
|
-
|
|
822
|
-
expected = sorted(set(expected))
|
|
823
|
-
if not expected:
|
|
824
|
-
return self.results["smoke"]
|
|
825
|
-
|
|
826
|
-
def _installed(app: str) -> Optional[bool]:
|
|
827
|
-
if app in {"pycharm-community", "chromium", "firefox", "code"}:
|
|
828
|
-
out = self._exec_in_vm(
|
|
829
|
-
f"snap list {app} >/dev/null 2>&1 && echo yes || echo no", timeout=10
|
|
830
|
-
)
|
|
831
|
-
return None if out is None else out.strip() == "yes"
|
|
832
|
-
|
|
833
|
-
if app == "google-chrome":
|
|
834
|
-
out = self._exec_in_vm(
|
|
835
|
-
"(command -v google-chrome >/dev/null 2>&1 || command -v google-chrome-stable >/dev/null 2>&1) && echo yes || echo no",
|
|
836
|
-
timeout=10,
|
|
837
|
-
)
|
|
838
|
-
return None if out is None else out.strip() == "yes"
|
|
839
|
-
|
|
840
|
-
if app == "docker":
|
|
841
|
-
out = self._exec_in_vm(
|
|
842
|
-
"command -v docker >/dev/null 2>&1 && echo yes || echo no", timeout=10
|
|
843
|
-
)
|
|
844
|
-
return None if out is None else out.strip() == "yes"
|
|
845
|
-
|
|
846
|
-
if app == "firefox":
|
|
847
|
-
out = self._exec_in_vm(
|
|
848
|
-
"command -v firefox >/dev/null 2>&1 && echo yes || echo no", timeout=10
|
|
849
|
-
)
|
|
850
|
-
return None if out is None else out.strip() == "yes"
|
|
851
|
-
|
|
852
|
-
out = self._exec_in_vm(
|
|
853
|
-
f"command -v {app} >/dev/null 2>&1 && echo yes || echo no", timeout=10
|
|
854
|
-
)
|
|
855
|
-
return None if out is None else out.strip() == "yes"
|
|
856
|
-
|
|
857
|
-
def _run_test(app: str) -> Optional[bool]:
|
|
858
|
-
uid_out = self._exec_in_vm(f"id -u {vm_user} 2>/dev/null || true", timeout=10)
|
|
859
|
-
vm_uid = (uid_out or "").strip()
|
|
860
|
-
if not vm_uid.isdigit():
|
|
861
|
-
vm_uid = "1000"
|
|
862
|
-
|
|
863
|
-
runtime_dir = f"/run/user/{vm_uid}"
|
|
864
|
-
self._exec_in_vm(
|
|
865
|
-
f"mkdir -p {runtime_dir} && chown {vm_uid}:{vm_uid} {runtime_dir} && chmod 700 {runtime_dir}",
|
|
866
|
-
timeout=10,
|
|
867
|
-
)
|
|
868
|
-
|
|
869
|
-
user_env = (
|
|
870
|
-
f"sudo -u {vm_user} env HOME=/home/{vm_user} USER={vm_user} LOGNAME={vm_user} XDG_RUNTIME_DIR={runtime_dir}"
|
|
871
|
-
)
|
|
872
|
-
|
|
873
|
-
if app == "pycharm-community":
|
|
874
|
-
out = self._exec_in_vm(
|
|
875
|
-
"/snap/pycharm-community/current/jbr/bin/java -version >/dev/null 2>&1 && echo yes || echo no",
|
|
876
|
-
timeout=20,
|
|
877
|
-
)
|
|
878
|
-
return None if out is None else out.strip() == "yes"
|
|
879
|
-
|
|
880
|
-
if app == "chromium":
|
|
881
|
-
out = self._exec_in_vm(
|
|
882
|
-
f"{user_env} timeout 20 chromium --headless=new --no-sandbox --disable-gpu --dump-dom about:blank >/dev/null 2>&1 && echo yes || echo no",
|
|
883
|
-
timeout=30,
|
|
884
|
-
)
|
|
885
|
-
return None if out is None else out.strip() == "yes"
|
|
886
|
-
|
|
887
|
-
if app == "firefox":
|
|
888
|
-
out = self._exec_in_vm(
|
|
889
|
-
f"{user_env} timeout 20 firefox --headless --version >/dev/null 2>&1 && echo yes || echo no",
|
|
890
|
-
timeout=30,
|
|
891
|
-
)
|
|
892
|
-
return None if out is None else out.strip() == "yes"
|
|
893
|
-
|
|
894
|
-
if app == "google-chrome":
|
|
895
|
-
out = self._exec_in_vm(
|
|
896
|
-
f"{user_env} timeout 20 google-chrome --headless=new --no-sandbox --disable-gpu --dump-dom about:blank >/dev/null 2>&1 && echo yes || echo no",
|
|
897
|
-
timeout=30,
|
|
898
|
-
)
|
|
899
|
-
return None if out is None else out.strip() == "yes"
|
|
900
|
-
|
|
901
|
-
if app == "docker":
|
|
902
|
-
out = self._exec_in_vm(
|
|
903
|
-
"timeout 20 docker info >/dev/null 2>&1 && echo yes || echo no", timeout=30
|
|
904
|
-
)
|
|
905
|
-
return None if out is None else out.strip() == "yes"
|
|
906
|
-
|
|
907
|
-
out = self._exec_in_vm(
|
|
908
|
-
f"timeout 20 {app} --version >/dev/null 2>&1 && echo yes || echo no", timeout=30
|
|
909
|
-
)
|
|
910
|
-
return None if out is None else out.strip() == "yes"
|
|
911
|
-
|
|
912
|
-
self.console.print("\n[bold]🧪 Smoke Tests (installed ≠ works)...[/]")
|
|
913
|
-
table = Table(title="Smoke Tests", border_style="cyan")
|
|
914
|
-
table.add_column("App", style="bold")
|
|
915
|
-
table.add_column("Installed", justify="center")
|
|
916
|
-
table.add_column("Launch", justify="center")
|
|
917
|
-
table.add_column("Note", style="dim")
|
|
918
|
-
|
|
919
|
-
for app in expected:
|
|
920
|
-
self.results["smoke"]["total"] += 1
|
|
921
|
-
installed = _installed(app)
|
|
922
|
-
launched: Optional[bool] = None
|
|
923
|
-
note = ""
|
|
924
|
-
pending = False
|
|
925
|
-
|
|
926
|
-
if installed is True:
|
|
927
|
-
launched = _run_test(app)
|
|
928
|
-
if launched is None:
|
|
929
|
-
note = "test failed to execute"
|
|
930
|
-
elif launched is False and setup_in_progress:
|
|
931
|
-
pending = True
|
|
932
|
-
note = note or "setup in progress"
|
|
933
|
-
elif installed is False:
|
|
934
|
-
if setup_in_progress:
|
|
935
|
-
pending = True
|
|
936
|
-
note = "setup in progress"
|
|
937
|
-
else:
|
|
938
|
-
note = "not installed"
|
|
939
|
-
else:
|
|
940
|
-
note = "install status unknown"
|
|
941
|
-
|
|
942
|
-
installed_icon = (
|
|
943
|
-
"[green]✅[/]"
|
|
944
|
-
if installed is True
|
|
945
|
-
else ("[yellow]⏳[/]" if pending else "[red]❌[/]")
|
|
946
|
-
if installed is False
|
|
947
|
-
else "[dim]?[/]"
|
|
948
|
-
)
|
|
949
|
-
launch_icon = (
|
|
950
|
-
"[green]✅[/]"
|
|
951
|
-
if launched is True
|
|
952
|
-
else ("[yellow]⏳[/]" if pending else "[red]❌[/]")
|
|
953
|
-
if launched is False
|
|
954
|
-
else ("[dim]—[/]" if installed is not True else "[dim]?[/]")
|
|
955
|
-
)
|
|
956
|
-
|
|
957
|
-
table.add_row(app, installed_icon, launch_icon, note)
|
|
958
|
-
|
|
959
|
-
passed = installed is True and launched is True
|
|
960
|
-
if pending:
|
|
961
|
-
self.results["smoke"]["skipped"] += 1
|
|
962
|
-
elif passed:
|
|
963
|
-
self.results["smoke"]["passed"] += 1
|
|
964
|
-
else:
|
|
965
|
-
self.results["smoke"]["failed"] += 1
|
|
966
|
-
|
|
967
|
-
self.results["smoke"]["details"].append(
|
|
968
|
-
{
|
|
969
|
-
"app": app,
|
|
970
|
-
"installed": installed,
|
|
971
|
-
"launched": launched,
|
|
972
|
-
"note": note,
|
|
973
|
-
"pending": pending,
|
|
974
|
-
}
|
|
975
|
-
)
|
|
976
|
-
|
|
977
|
-
self.console.print(table)
|
|
978
|
-
return self.results["smoke"]
|
|
979
|
-
|
|
980
|
-
def _check_qga_ready(self) -> bool:
|
|
981
|
-
"""Check if QEMU guest agent is responding."""
|
|
982
|
-
try:
|
|
983
|
-
result = subprocess.run(
|
|
984
|
-
[
|
|
985
|
-
"virsh",
|
|
986
|
-
"--connect",
|
|
987
|
-
self.conn_uri,
|
|
988
|
-
"qemu-agent-command",
|
|
989
|
-
self.vm_name,
|
|
990
|
-
'{"execute":"guest-ping"}',
|
|
991
|
-
],
|
|
992
|
-
capture_output=True,
|
|
993
|
-
text=True,
|
|
994
|
-
timeout=5,
|
|
995
|
-
)
|
|
996
|
-
return result.returncode == 0
|
|
997
|
-
except Exception:
|
|
998
|
-
return False
|
|
999
|
-
|
|
1000
|
-
def validate_disk_space(self) -> Dict:
|
|
1001
|
-
"""Validate disk space on root filesystem."""
|
|
1002
|
-
setup_in_progress = self._setup_in_progress() is True
|
|
1003
|
-
self.console.print("\n[bold]💾 Validating Disk Space...[/]")
|
|
1004
|
-
|
|
1005
|
-
df_output = self._exec_in_vm("df -h / --output=pcent,avail,size | tail -n 1", timeout=20)
|
|
1006
|
-
if not df_output:
|
|
1007
|
-
self.console.print("[red]❌ Could not check disk space[/]")
|
|
1008
|
-
return {"status": "error"}
|
|
1009
|
-
|
|
1010
|
-
try:
|
|
1011
|
-
# Format: pcent avail size
|
|
1012
|
-
# Example: 98% 100M 30G
|
|
1013
|
-
parts = df_output.split()
|
|
1014
|
-
usage_pct = int(parts[0].replace('%', ''))
|
|
1015
|
-
avail = parts[1]
|
|
1016
|
-
total = parts[2]
|
|
1017
|
-
|
|
1018
|
-
self.results["disk"] = {
|
|
1019
|
-
"usage_pct": usage_pct,
|
|
1020
|
-
"avail": avail,
|
|
1021
|
-
"total": total
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
if usage_pct > 90:
|
|
1025
|
-
self.console.print(f"[red]❌ Disk nearly full: {usage_pct}% used ({avail} available of {total})[/]")
|
|
1026
|
-
status = "fail"
|
|
1027
|
-
elif usage_pct > 85:
|
|
1028
|
-
self.console.print(f"[yellow]⚠️ Disk usage high: {usage_pct}% used ({avail} available of {total})[/]")
|
|
1029
|
-
status = "warning"
|
|
1030
|
-
else:
|
|
1031
|
-
self.console.print(f"[green]✅ Disk space OK: {usage_pct}% used ({avail} available of {total})[/]")
|
|
1032
|
-
status = "pass"
|
|
1033
|
-
|
|
1034
|
-
if usage_pct > 80:
|
|
1035
|
-
self._print_disk_usage_breakdown()
|
|
1036
|
-
|
|
1037
|
-
return self.results["disk"]
|
|
1038
|
-
except Exception as e:
|
|
1039
|
-
self.console.print(f"[red]❌ Error parsing df output: {e}[/]")
|
|
1040
|
-
return {"status": "error"}
|
|
1041
|
-
|
|
1042
|
-
def _print_disk_usage_breakdown(self) -> None:
|
|
1043
|
-
def _parse_du_lines(out: Optional[str]) -> List[Tuple[str, str]]:
|
|
1044
|
-
if not out:
|
|
1045
|
-
return []
|
|
1046
|
-
rows: List[Tuple[str, str]] = []
|
|
1047
|
-
for line in out.splitlines():
|
|
1048
|
-
line = line.strip()
|
|
1049
|
-
if not line:
|
|
1050
|
-
continue
|
|
1051
|
-
parts = line.split(maxsplit=1)
|
|
1052
|
-
if len(parts) != 2:
|
|
1053
|
-
continue
|
|
1054
|
-
size, path = parts
|
|
1055
|
-
rows.append((path, size))
|
|
1056
|
-
return rows
|
|
1057
|
-
|
|
1058
|
-
def _dir_size(path: str, timeout: int = 30) -> Optional[str]:
|
|
1059
|
-
out = self._exec_in_vm(f"du -x -s -h {path} 2>/dev/null | head -n 1 | cut -f1", timeout=timeout)
|
|
1060
|
-
return out.strip() if out else None
|
|
1061
|
-
|
|
1062
|
-
self.console.print("\n[bold]📁 Disk usage breakdown (largest directories)[/]")
|
|
1063
|
-
|
|
1064
|
-
top_level = self._exec_in_vm(
|
|
1065
|
-
"du -x -h --max-depth=1 / 2>/dev/null | sort -hr | head -n 15",
|
|
1066
|
-
timeout=60,
|
|
1067
|
-
)
|
|
1068
|
-
top_rows = _parse_du_lines(top_level)
|
|
1069
|
-
|
|
1070
|
-
if top_rows:
|
|
1071
|
-
table = Table(title="Disk Usage: / (Top 15)", border_style="cyan")
|
|
1072
|
-
table.add_column("Path", style="bold")
|
|
1073
|
-
table.add_column("Size", justify="right")
|
|
1074
|
-
for path, size in top_rows:
|
|
1075
|
-
table.add_row(path, size)
|
|
1076
|
-
self.console.print(table)
|
|
1077
|
-
else:
|
|
1078
|
-
self.console.print("[dim]Could not compute top-level directory sizes (du may be busy)[/]")
|
|
1079
|
-
|
|
1080
|
-
var_sz = _dir_size("/var")
|
|
1081
|
-
home_sz = _dir_size("/home")
|
|
1082
|
-
if var_sz or home_sz:
|
|
1083
|
-
sum_table = Table(title="Disk Usage: Key Directories", border_style="cyan")
|
|
1084
|
-
sum_table.add_column("Path", style="bold")
|
|
1085
|
-
sum_table.add_column("Size", justify="right")
|
|
1086
|
-
for p in ["/var", "/var/lib", "/var/log", "/var/cache", "/var/lib/snapd", "/home", "/home/ubuntu", "/tmp"]:
|
|
1087
|
-
sz = _dir_size(p, timeout=30)
|
|
1088
|
-
if sz:
|
|
1089
|
-
sum_table.add_row(p, sz)
|
|
1090
|
-
self.console.print(sum_table)
|
|
1091
|
-
|
|
1092
|
-
var_breakdown = self._exec_in_vm(
|
|
1093
|
-
"du -x -h --max-depth=1 /var 2>/dev/null | sort -hr | head -n 12",
|
|
1094
|
-
timeout=60,
|
|
1095
|
-
)
|
|
1096
|
-
var_rows = _parse_du_lines(var_breakdown)
|
|
1097
|
-
if var_rows:
|
|
1098
|
-
vtable = Table(title="Disk Usage: /var (Top 12)", border_style="cyan")
|
|
1099
|
-
vtable.add_column("Path", style="bold")
|
|
1100
|
-
vtable.add_column("Size", justify="right")
|
|
1101
|
-
for path, size in var_rows:
|
|
1102
|
-
vtable.add_row(path, size)
|
|
1103
|
-
self.console.print(vtable)
|
|
1104
|
-
|
|
1105
|
-
home_breakdown = self._exec_in_vm(
|
|
1106
|
-
"du -x -h --max-depth=2 /home/ubuntu 2>/dev/null | sort -hr | head -n 12",
|
|
1107
|
-
timeout=60,
|
|
1108
|
-
)
|
|
1109
|
-
home_rows = _parse_du_lines(home_breakdown)
|
|
1110
|
-
if home_rows:
|
|
1111
|
-
htable = Table(title="Disk Usage: /home/ubuntu (Top 12)", border_style="cyan")
|
|
1112
|
-
htable.add_column("Path", style="bold")
|
|
1113
|
-
htable.add_column("Size", justify="right")
|
|
1114
|
-
for path, size in home_rows:
|
|
1115
|
-
htable.add_row(path, size)
|
|
1116
|
-
self.console.print(htable)
|
|
1117
|
-
|
|
1118
|
-
copy_paths = self.config.get("copy_paths", None)
|
|
1119
|
-
if not isinstance(copy_paths, dict) or not copy_paths:
|
|
1120
|
-
copy_paths = self.config.get("app_data_paths", {})
|
|
1121
|
-
if copy_paths:
|
|
1122
|
-
ctable = Table(title="Disk Usage: Configured Imported Paths", border_style="cyan")
|
|
1123
|
-
ctable.add_column("Guest Path", style="bold")
|
|
1124
|
-
ctable.add_column("Size", justify="right")
|
|
1125
|
-
for _, guest_path in copy_paths.items():
|
|
1126
|
-
sz = _dir_size(guest_path, timeout=30)
|
|
1127
|
-
if sz:
|
|
1128
|
-
ctable.add_row(str(guest_path), sz)
|
|
1129
|
-
else:
|
|
1130
|
-
ctable.add_row(str(guest_path), "—")
|
|
1131
|
-
self.console.print(ctable)
|
|
1132
|
-
|
|
1133
|
-
def validate_all(self) -> Dict:
|
|
1134
|
-
"""Run all validations and return comprehensive results."""
|
|
1135
|
-
setup_in_progress = self._setup_in_progress() is True
|
|
1136
|
-
self.console.print("[bold cyan]🔍 Running Full Validation...[/]")
|
|
1137
|
-
|
|
1138
|
-
# Check if VM is running
|
|
1139
|
-
try:
|
|
1140
|
-
result = subprocess.run(
|
|
1141
|
-
["virsh", "--connect", self.conn_uri, "domstate", self.vm_name],
|
|
1142
|
-
capture_output=True,
|
|
1143
|
-
text=True,
|
|
1144
|
-
timeout=5,
|
|
1145
|
-
)
|
|
1146
|
-
vm_state = result.stdout.strip()
|
|
1147
|
-
|
|
1148
|
-
if "running" not in vm_state.lower():
|
|
1149
|
-
self.console.print(f"[yellow]⚠️ VM is not running (state: {vm_state})[/]")
|
|
1150
|
-
self.console.print("[dim]Start VM with: clonebox start .[/]")
|
|
1151
|
-
self.results["overall"] = "vm_not_running"
|
|
1152
|
-
return self.results
|
|
1153
|
-
except Exception as e:
|
|
1154
|
-
self.console.print(f"[red]❌ Cannot check VM state: {e}[/]")
|
|
1155
|
-
self.results["overall"] = "error"
|
|
1156
|
-
return self.results
|
|
1157
|
-
|
|
1158
|
-
# Check QEMU Guest Agent
|
|
1159
|
-
if not self._check_qga_ready():
|
|
1160
|
-
wait_deadline = time.time() + 180
|
|
1161
|
-
self.console.print("[yellow]⏳ Waiting for QEMU Guest Agent (up to 180s)...[/]")
|
|
1162
|
-
last_log = 0
|
|
1163
|
-
while time.time() < wait_deadline:
|
|
1164
|
-
time.sleep(5)
|
|
1165
|
-
if self._check_qga_ready():
|
|
1166
|
-
break
|
|
1167
|
-
elapsed = int(180 - (wait_deadline - time.time()))
|
|
1168
|
-
if elapsed - last_log >= 15:
|
|
1169
|
-
self.console.print(f"[dim] ...still waiting for QGA ({elapsed}s elapsed)[/]")
|
|
1170
|
-
last_log = elapsed
|
|
1171
|
-
|
|
1172
|
-
if not self._check_qga_ready():
|
|
1173
|
-
self.console.print("[red]❌ QEMU Guest Agent not responding[/]")
|
|
1174
|
-
self.console.print("\n[bold]🔧 Troubleshooting QGA:[/]")
|
|
1175
|
-
self.console.print(" 1. The VM might still be booting. Wait 30-60 seconds.")
|
|
1176
|
-
self.console.print(" 2. Ensure the agent is installed and running inside the VM:")
|
|
1177
|
-
self.console.print(" [dim]virsh console " + self.vm_name + "[/]")
|
|
1178
|
-
self.console.print(" [dim]sudo systemctl status qemu-guest-agent[/]")
|
|
1179
|
-
self.console.print(" 3. If newly created, cloud-init might still be running.")
|
|
1180
|
-
self.console.print(" 4. Check VM logs: [dim]clonebox logs " + self.vm_name + "[/]")
|
|
1181
|
-
self.console.print(f"\n[yellow]⚠️ Skipping deep validation as it requires a working Guest Agent.[/]")
|
|
1182
|
-
self.results["overall"] = "qga_not_ready"
|
|
1183
|
-
return self.results
|
|
1184
|
-
|
|
1185
|
-
ci_status = self._exec_in_vm("cloud-init status --long 2>/dev/null || cloud-init status 2>/dev/null || true", timeout=20)
|
|
1186
|
-
if ci_status:
|
|
1187
|
-
ci_lower = ci_status.lower()
|
|
1188
|
-
if "running" in ci_lower:
|
|
1189
|
-
self.console.print("[yellow]⏳ Cloud-init still running - deep validation will show pending states[/]")
|
|
1190
|
-
setup_in_progress = True
|
|
1191
|
-
|
|
1192
|
-
ready_msg = self._exec_in_vm(
|
|
1193
|
-
"cat /var/log/clonebox-ready 2>/dev/null || true",
|
|
1194
|
-
timeout=10,
|
|
1195
|
-
)
|
|
1196
|
-
if not setup_in_progress and not (ready_msg and "clonebox vm ready" in ready_msg.lower()):
|
|
1197
|
-
self.console.print(
|
|
1198
|
-
"[yellow]⚠️ CloneBox ready marker not found - provisioning may not have completed[/]"
|
|
1199
|
-
)
|
|
1200
|
-
|
|
1201
|
-
# Run all validations
|
|
1202
|
-
self.validate_disk_space()
|
|
1203
|
-
self.validate_mounts()
|
|
1204
|
-
self.validate_packages()
|
|
1205
|
-
self.validate_snap_packages()
|
|
1206
|
-
self.validate_services()
|
|
1207
|
-
self.validate_apps()
|
|
1208
|
-
if self.smoke_test:
|
|
1209
|
-
self.validate_smoke_tests()
|
|
1210
|
-
|
|
1211
|
-
recent_err = self._exec_in_vm(
|
|
1212
|
-
"journalctl -p err -n 30 --no-pager 2>/dev/null || true", timeout=20
|
|
1213
|
-
)
|
|
1214
|
-
if recent_err:
|
|
1215
|
-
recent_err = recent_err.strip()
|
|
1216
|
-
if recent_err:
|
|
1217
|
-
self.console.print(
|
|
1218
|
-
Panel(recent_err, title="Recent system errors", border_style="red")
|
|
1219
|
-
)
|
|
1220
|
-
|
|
1221
|
-
# Calculate overall status
|
|
1222
|
-
disk_failed = 1 if self.results.get("disk", {}).get("usage_pct", 0) > 90 else 0
|
|
1223
|
-
total_checks = (
|
|
1224
|
-
1 # Disk space check
|
|
1225
|
-
+ self.results["mounts"]["total"]
|
|
1226
|
-
+ self.results["packages"]["total"]
|
|
1227
|
-
+ self.results["snap_packages"]["total"]
|
|
1228
|
-
+ self.results["services"]["total"]
|
|
1229
|
-
+ self.results["apps"]["total"]
|
|
1230
|
-
+ (self.results["smoke"]["total"] if self.smoke_test else 0)
|
|
1231
|
-
)
|
|
1232
|
-
|
|
1233
|
-
total_passed = (
|
|
1234
|
-
(1 - disk_failed)
|
|
1235
|
-
+ self.results["mounts"]["passed"]
|
|
1236
|
-
+ self.results["packages"]["passed"]
|
|
1237
|
-
+ self.results["snap_packages"]["passed"]
|
|
1238
|
-
+ self.results["services"]["passed"]
|
|
1239
|
-
+ self.results["apps"]["passed"]
|
|
1240
|
-
+ (self.results["smoke"]["passed"] if self.smoke_test else 0)
|
|
1241
|
-
)
|
|
1242
|
-
|
|
1243
|
-
total_failed = (
|
|
1244
|
-
disk_failed
|
|
1245
|
-
+ self.results["mounts"]["failed"]
|
|
1246
|
-
+ self.results["packages"]["failed"]
|
|
1247
|
-
+ self.results["snap_packages"]["failed"]
|
|
1248
|
-
+ self.results["services"]["failed"]
|
|
1249
|
-
+ self.results["apps"]["failed"]
|
|
1250
|
-
+ (self.results["smoke"]["failed"] if self.smoke_test else 0)
|
|
1251
|
-
)
|
|
1252
|
-
|
|
1253
|
-
# Get skipped counts
|
|
1254
|
-
skipped_mounts = self.results["mounts"].get("skipped", 0)
|
|
1255
|
-
skipped_packages = self.results["packages"].get("skipped", 0)
|
|
1256
|
-
skipped_services = self.results["services"].get("skipped", 0)
|
|
1257
|
-
skipped_snaps = self.results["snap_packages"].get("skipped", 0)
|
|
1258
|
-
skipped_apps = self.results["apps"].get("skipped", 0)
|
|
1259
|
-
skipped_smoke = self.results["smoke"].get("skipped", 0) if self.smoke_test else 0
|
|
1260
|
-
total_skipped = skipped_mounts + skipped_packages + skipped_services + skipped_snaps + skipped_apps + skipped_smoke
|
|
1261
|
-
|
|
1262
|
-
# Print summary
|
|
1263
|
-
self.console.print("\n[bold]📊 Validation Summary[/]")
|
|
1264
|
-
summary_table = Table(border_style="cyan")
|
|
1265
|
-
summary_table.add_column("Category", style="bold")
|
|
1266
|
-
summary_table.add_column("Passed", justify="right", style="green")
|
|
1267
|
-
summary_table.add_column("Failed", justify="right", style="red")
|
|
1268
|
-
summary_table.add_column("Skipped/Pending", justify="right", style="dim")
|
|
1269
|
-
summary_table.add_column("Total", justify="right")
|
|
1270
|
-
|
|
1271
|
-
# Add Disk Space row
|
|
1272
|
-
disk_usage_pct = self.results.get("disk", {}).get("usage_pct", 0)
|
|
1273
|
-
disk_avail = self.results.get("disk", {}).get("avail", "?")
|
|
1274
|
-
disk_total = self.results.get("disk", {}).get("total", "?")
|
|
1275
|
-
|
|
1276
|
-
# Calculate used space if possible
|
|
1277
|
-
disk_status_passed = "[green]OK[/]" if disk_usage_pct <= 90 else "—"
|
|
1278
|
-
disk_status_failed = "—" if disk_usage_pct <= 90 else f"[red]FULL ({disk_usage_pct}%)[/]"
|
|
1279
|
-
|
|
1280
|
-
summary_table.add_row(
|
|
1281
|
-
"Disk Space",
|
|
1282
|
-
disk_status_passed,
|
|
1283
|
-
disk_status_failed,
|
|
1284
|
-
"—",
|
|
1285
|
-
f"{disk_usage_pct}% of {disk_total} ({disk_avail} free)",
|
|
1286
|
-
)
|
|
1287
|
-
|
|
1288
|
-
summary_table.add_row(
|
|
1289
|
-
"Mounts",
|
|
1290
|
-
str(self.results["mounts"]["passed"]),
|
|
1291
|
-
str(self.results["mounts"]["failed"]),
|
|
1292
|
-
str(skipped_mounts) if skipped_mounts else "—",
|
|
1293
|
-
str(self.results["mounts"]["total"]),
|
|
1294
|
-
)
|
|
1295
|
-
summary_table.add_row(
|
|
1296
|
-
"APT Packages",
|
|
1297
|
-
str(self.results["packages"]["passed"]),
|
|
1298
|
-
str(self.results["packages"]["failed"]),
|
|
1299
|
-
str(skipped_packages) if skipped_packages else "—",
|
|
1300
|
-
str(self.results["packages"]["total"]),
|
|
1301
|
-
)
|
|
1302
|
-
summary_table.add_row(
|
|
1303
|
-
"Snap Packages",
|
|
1304
|
-
str(self.results["snap_packages"]["passed"]),
|
|
1305
|
-
str(self.results["snap_packages"]["failed"]),
|
|
1306
|
-
str(skipped_snaps) if skipped_snaps else "—",
|
|
1307
|
-
str(self.results["snap_packages"]["total"]),
|
|
1308
|
-
)
|
|
1309
|
-
summary_table.add_row(
|
|
1310
|
-
"Services",
|
|
1311
|
-
str(self.results["services"]["passed"]),
|
|
1312
|
-
str(self.results["services"]["failed"]),
|
|
1313
|
-
str(skipped_services) if skipped_services else "—",
|
|
1314
|
-
str(self.results["services"]["total"]),
|
|
1315
|
-
)
|
|
1316
|
-
summary_table.add_row(
|
|
1317
|
-
"Apps",
|
|
1318
|
-
str(self.results["apps"]["passed"]),
|
|
1319
|
-
str(self.results["apps"]["failed"]),
|
|
1320
|
-
str(skipped_apps) if skipped_apps else "—",
|
|
1321
|
-
str(self.results["apps"]["total"]),
|
|
1322
|
-
)
|
|
1323
|
-
summary_table.add_row(
|
|
1324
|
-
"[bold]TOTAL",
|
|
1325
|
-
f"[bold green]{total_passed}",
|
|
1326
|
-
f"[bold red]{total_failed}",
|
|
1327
|
-
f"[dim]{total_skipped}[/]" if total_skipped else "[dim]0[/]",
|
|
1328
|
-
f"[bold]{total_checks}",
|
|
1329
|
-
)
|
|
1330
|
-
|
|
1331
|
-
self.console.print(summary_table)
|
|
1332
|
-
|
|
1333
|
-
# Determine overall status
|
|
1334
|
-
if total_failed == 0 and total_checks > 0 and total_skipped > 0:
|
|
1335
|
-
self.results["overall"] = "pending"
|
|
1336
|
-
self.console.print("\n[bold yellow]⏳ Setup in progress - some checks are pending[/]")
|
|
1337
|
-
elif total_failed == 0 and total_checks > 0:
|
|
1338
|
-
self.results["overall"] = "pass"
|
|
1339
|
-
self.console.print("\n[bold green]✅ All validations passed![/]")
|
|
1340
|
-
elif total_failed > 0:
|
|
1341
|
-
self.results["overall"] = "partial"
|
|
1342
|
-
self.console.print(f"\n[bold yellow]⚠️ {total_failed}/{total_checks} checks failed[/]")
|
|
1343
|
-
self.console.print(
|
|
1344
|
-
"[dim]Consider rebuilding VM: clonebox clone . --user --run --replace[/]"
|
|
1345
|
-
)
|
|
1346
|
-
else:
|
|
1347
|
-
self.results["overall"] = "no_checks"
|
|
1348
|
-
self.console.print("\n[dim]No validation checks configured[/]")
|
|
1349
|
-
|
|
1350
|
-
return self.results
|
|
3
|
+
__all__ = ["VMValidator"]
|