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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from typing import Dict, List, Optional, Tuple
|
|
2
|
+
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DiskValidationMixin:
|
|
7
|
+
def validate_disk_space(self) -> Dict:
|
|
8
|
+
"""Validate disk space on root filesystem."""
|
|
9
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
10
|
+
self.console.print("\n[bold]💾 Validating Disk Space...[/]")
|
|
11
|
+
|
|
12
|
+
df_output = self._exec_in_vm("df -h / --output=pcent,avail,size | tail -n 1", timeout=20)
|
|
13
|
+
if not df_output:
|
|
14
|
+
self.console.print("[red]❌ Could not check disk space[/]")
|
|
15
|
+
return {"status": "error"}
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
parts = df_output.split()
|
|
19
|
+
usage_pct = int(parts[0].replace("%", ""))
|
|
20
|
+
avail = parts[1]
|
|
21
|
+
total = parts[2]
|
|
22
|
+
|
|
23
|
+
self.results["disk"] = {"usage_pct": usage_pct, "avail": avail, "total": total}
|
|
24
|
+
|
|
25
|
+
if usage_pct > 90:
|
|
26
|
+
self.console.print(
|
|
27
|
+
f"[red]❌ Disk nearly full: {usage_pct}% used ({avail} available of {total})[/]"
|
|
28
|
+
)
|
|
29
|
+
status = "fail"
|
|
30
|
+
elif usage_pct > 85:
|
|
31
|
+
self.console.print(
|
|
32
|
+
f"[yellow]⚠️ Disk usage high: {usage_pct}% used ({avail} available of {total})[/]"
|
|
33
|
+
)
|
|
34
|
+
status = "warning"
|
|
35
|
+
else:
|
|
36
|
+
self.console.print(
|
|
37
|
+
f"[green]✅ Disk space OK: {usage_pct}% used ({avail} available of {total})[/]"
|
|
38
|
+
)
|
|
39
|
+
status = "pass"
|
|
40
|
+
|
|
41
|
+
if usage_pct > 80:
|
|
42
|
+
self._print_disk_usage_breakdown()
|
|
43
|
+
|
|
44
|
+
return self.results["disk"]
|
|
45
|
+
except Exception as e:
|
|
46
|
+
self.console.print(f"[red]❌ Error parsing df output: {e}[/]")
|
|
47
|
+
return {"status": "error"}
|
|
48
|
+
|
|
49
|
+
def _print_disk_usage_breakdown(self) -> None:
|
|
50
|
+
def _parse_du_lines(out: Optional[str]) -> List[Tuple[str, str]]:
|
|
51
|
+
if not out:
|
|
52
|
+
return []
|
|
53
|
+
rows: List[Tuple[str, str]] = []
|
|
54
|
+
for line in out.splitlines():
|
|
55
|
+
line = line.strip()
|
|
56
|
+
if not line:
|
|
57
|
+
continue
|
|
58
|
+
parts = line.split(maxsplit=1)
|
|
59
|
+
if len(parts) != 2:
|
|
60
|
+
continue
|
|
61
|
+
size, path = parts
|
|
62
|
+
rows.append((path, size))
|
|
63
|
+
return rows
|
|
64
|
+
|
|
65
|
+
def _dir_size(path: str, timeout: int = 30) -> Optional[str]:
|
|
66
|
+
out = self._exec_in_vm(
|
|
67
|
+
f"du -x -s -h {path} 2>/dev/null | head -n 1 | cut -f1", timeout=timeout
|
|
68
|
+
)
|
|
69
|
+
return out.strip() if out else None
|
|
70
|
+
|
|
71
|
+
self.console.print("\n[bold]📁 Disk usage breakdown (largest directories)[/]")
|
|
72
|
+
|
|
73
|
+
top_level = self._exec_in_vm(
|
|
74
|
+
"du -x -h --max-depth=1 / 2>/dev/null | sort -hr | head -n 15",
|
|
75
|
+
timeout=60,
|
|
76
|
+
)
|
|
77
|
+
top_rows = _parse_du_lines(top_level)
|
|
78
|
+
|
|
79
|
+
if top_rows:
|
|
80
|
+
table = Table(title="Disk Usage: / (Top 15)", border_style="cyan")
|
|
81
|
+
table.add_column("Path", style="bold")
|
|
82
|
+
table.add_column("Size", justify="right")
|
|
83
|
+
for path, size in top_rows:
|
|
84
|
+
table.add_row(path, size)
|
|
85
|
+
self.console.print(table)
|
|
86
|
+
else:
|
|
87
|
+
self.console.print("[dim]Could not compute top-level directory sizes (du may be busy)[/]")
|
|
88
|
+
|
|
89
|
+
var_sz = _dir_size("/var")
|
|
90
|
+
home_sz = _dir_size("/home")
|
|
91
|
+
if var_sz or home_sz:
|
|
92
|
+
sum_table = Table(title="Disk Usage: Key Directories", border_style="cyan")
|
|
93
|
+
sum_table.add_column("Path", style="bold")
|
|
94
|
+
sum_table.add_column("Size", justify="right")
|
|
95
|
+
for p in [
|
|
96
|
+
"/var",
|
|
97
|
+
"/var/lib",
|
|
98
|
+
"/var/log",
|
|
99
|
+
"/var/cache",
|
|
100
|
+
"/var/lib/snapd",
|
|
101
|
+
"/home",
|
|
102
|
+
"/home/ubuntu",
|
|
103
|
+
"/tmp",
|
|
104
|
+
]:
|
|
105
|
+
sz = _dir_size(p, timeout=30)
|
|
106
|
+
if sz:
|
|
107
|
+
sum_table.add_row(p, sz)
|
|
108
|
+
self.console.print(sum_table)
|
|
109
|
+
|
|
110
|
+
var_breakdown = self._exec_in_vm(
|
|
111
|
+
"du -x -h --max-depth=1 /var 2>/dev/null | sort -hr | head -n 12",
|
|
112
|
+
timeout=60,
|
|
113
|
+
)
|
|
114
|
+
var_rows = _parse_du_lines(var_breakdown)
|
|
115
|
+
if var_rows:
|
|
116
|
+
vtable = Table(title="Disk Usage: /var (Top 12)", border_style="cyan")
|
|
117
|
+
vtable.add_column("Path", style="bold")
|
|
118
|
+
vtable.add_column("Size", justify="right")
|
|
119
|
+
for path, size in var_rows:
|
|
120
|
+
vtable.add_row(path, size)
|
|
121
|
+
self.console.print(vtable)
|
|
122
|
+
|
|
123
|
+
home_breakdown = self._exec_in_vm(
|
|
124
|
+
"du -x -h --max-depth=2 /home/ubuntu 2>/dev/null | sort -hr | head -n 12",
|
|
125
|
+
timeout=60,
|
|
126
|
+
)
|
|
127
|
+
home_rows = _parse_du_lines(home_breakdown)
|
|
128
|
+
if home_rows:
|
|
129
|
+
htable = Table(title="Disk Usage: /home/ubuntu (Top 12)", border_style="cyan")
|
|
130
|
+
htable.add_column("Path", style="bold")
|
|
131
|
+
htable.add_column("Size", justify="right")
|
|
132
|
+
for path, size in home_rows:
|
|
133
|
+
htable.add_row(path, size)
|
|
134
|
+
self.console.print(htable)
|
|
135
|
+
|
|
136
|
+
copy_paths = self.config.get("copy_paths", None)
|
|
137
|
+
if not isinstance(copy_paths, dict) or not copy_paths:
|
|
138
|
+
copy_paths = self.config.get("app_data_paths", {})
|
|
139
|
+
if copy_paths:
|
|
140
|
+
ctable = Table(title="Disk Usage: Configured Imported Paths", border_style="cyan")
|
|
141
|
+
ctable.add_column("Guest Path", style="bold")
|
|
142
|
+
ctable.add_column("Size", justify="right")
|
|
143
|
+
for _, guest_path in copy_paths.items():
|
|
144
|
+
sz = _dir_size(guest_path, timeout=30)
|
|
145
|
+
if sz:
|
|
146
|
+
ctable.add_row(str(guest_path), sz)
|
|
147
|
+
else:
|
|
148
|
+
ctable.add_row(str(guest_path), "—")
|
|
149
|
+
self.console.print(ctable)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from typing import Dict
|
|
2
|
+
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MountValidationMixin:
|
|
7
|
+
def validate_mounts(self) -> Dict:
|
|
8
|
+
"""Validate all mount points and copied data paths."""
|
|
9
|
+
setup_in_progress = self._setup_in_progress_cache is True
|
|
10
|
+
self.console.print("\n[bold]💾 Validating Mounts & Data...[/]")
|
|
11
|
+
|
|
12
|
+
paths = self.config.get("paths", {})
|
|
13
|
+
copy_paths = self.config.get("copy_paths", None)
|
|
14
|
+
if not isinstance(copy_paths, dict) or not copy_paths:
|
|
15
|
+
copy_paths = self.config.get("app_data_paths", {})
|
|
16
|
+
|
|
17
|
+
if not paths and not copy_paths:
|
|
18
|
+
self.console.print("[dim]No mounts or data paths configured[/]")
|
|
19
|
+
return self.results["mounts"]
|
|
20
|
+
|
|
21
|
+
mount_output = self._exec_in_vm("mount | grep 9p")
|
|
22
|
+
mounted_paths = []
|
|
23
|
+
if mount_output:
|
|
24
|
+
for line in mount_output.split("\n"):
|
|
25
|
+
line = line.strip()
|
|
26
|
+
if not line:
|
|
27
|
+
continue
|
|
28
|
+
parts = line.split()
|
|
29
|
+
if len(parts) >= 3:
|
|
30
|
+
mounted_paths.append(parts[2])
|
|
31
|
+
|
|
32
|
+
mount_table = Table(title="Data Validation", border_style="cyan")
|
|
33
|
+
mount_table.add_column("Guest Path", style="bold")
|
|
34
|
+
mount_table.add_column("Type", justify="center")
|
|
35
|
+
mount_table.add_column("Status", justify="center")
|
|
36
|
+
mount_table.add_column("Files", justify="right")
|
|
37
|
+
|
|
38
|
+
for host_path, guest_path in paths.items():
|
|
39
|
+
self.results["mounts"]["total"] += 1
|
|
40
|
+
|
|
41
|
+
is_mounted = any(guest_path in mp for mp in mounted_paths)
|
|
42
|
+
|
|
43
|
+
accessible = False
|
|
44
|
+
file_count = "?"
|
|
45
|
+
|
|
46
|
+
if is_mounted:
|
|
47
|
+
test_result = self._exec_in_vm(f"test -d {guest_path} && echo 'yes' || echo 'no'")
|
|
48
|
+
accessible = test_result == "yes"
|
|
49
|
+
|
|
50
|
+
if accessible:
|
|
51
|
+
count_str = self._exec_in_vm(f"ls -A {guest_path} 2>/dev/null | wc -l")
|
|
52
|
+
if count_str and count_str.isdigit():
|
|
53
|
+
file_count = count_str
|
|
54
|
+
|
|
55
|
+
if is_mounted and accessible:
|
|
56
|
+
status_icon = "[green]✅ Mounted[/]"
|
|
57
|
+
self.results["mounts"]["passed"] += 1
|
|
58
|
+
status = "pass"
|
|
59
|
+
elif is_mounted:
|
|
60
|
+
status_icon = "[red]❌ Inaccessible[/]"
|
|
61
|
+
self.results["mounts"]["failed"] += 1
|
|
62
|
+
status = "mounted_but_inaccessible"
|
|
63
|
+
elif setup_in_progress:
|
|
64
|
+
status_icon = "[yellow]⏳ Pending[/]"
|
|
65
|
+
status = "pending"
|
|
66
|
+
self.results["mounts"]["skipped"] += 1
|
|
67
|
+
else:
|
|
68
|
+
status_icon = "[red]❌ Not Mounted[/]"
|
|
69
|
+
self.results["mounts"]["failed"] += 1
|
|
70
|
+
status = "not_mounted"
|
|
71
|
+
|
|
72
|
+
mount_table.add_row(guest_path, "Bind Mount", status_icon, str(file_count))
|
|
73
|
+
self.results["mounts"]["details"].append(
|
|
74
|
+
{
|
|
75
|
+
"path": guest_path,
|
|
76
|
+
"type": "mount",
|
|
77
|
+
"mounted": is_mounted,
|
|
78
|
+
"accessible": accessible,
|
|
79
|
+
"files": file_count,
|
|
80
|
+
"status": status,
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
for host_path, guest_path in copy_paths.items():
|
|
85
|
+
self.results["mounts"]["total"] += 1
|
|
86
|
+
|
|
87
|
+
exists = False
|
|
88
|
+
file_count = "?"
|
|
89
|
+
|
|
90
|
+
test_result = self._exec_in_vm(f"test -d {guest_path} && echo 'yes' || echo 'no'")
|
|
91
|
+
exists = test_result == "yes"
|
|
92
|
+
|
|
93
|
+
if exists:
|
|
94
|
+
count_str = self._exec_in_vm(f"ls -A {guest_path} 2>/dev/null | wc -l")
|
|
95
|
+
if count_str and count_str.isdigit():
|
|
96
|
+
file_count = count_str
|
|
97
|
+
|
|
98
|
+
if exists:
|
|
99
|
+
status_icon = "[green]✅ Copied[/]"
|
|
100
|
+
self.results["mounts"]["passed"] += 1
|
|
101
|
+
status = "pass"
|
|
102
|
+
elif setup_in_progress:
|
|
103
|
+
status_icon = "[yellow]⏳ Pending[/]"
|
|
104
|
+
status = "pending"
|
|
105
|
+
self.results["mounts"]["skipped"] += 1
|
|
106
|
+
else:
|
|
107
|
+
status_icon = "[red]❌ Missing[/]"
|
|
108
|
+
self.results["mounts"]["failed"] += 1
|
|
109
|
+
status = "missing"
|
|
110
|
+
|
|
111
|
+
mount_table.add_row(guest_path, "Imported", status_icon, str(file_count))
|
|
112
|
+
self.results["mounts"]["details"].append(
|
|
113
|
+
{
|
|
114
|
+
"path": guest_path,
|
|
115
|
+
"type": "copy",
|
|
116
|
+
"mounted": False,
|
|
117
|
+
"accessible": exists,
|
|
118
|
+
"files": file_count,
|
|
119
|
+
"status": status,
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
self.console.print(mount_table)
|
|
124
|
+
self.console.print(
|
|
125
|
+
f"[dim]{self.results['mounts']['passed']}/{self.results['mounts']['total']} paths valid[/]"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return self.results["mounts"]
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import time
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OverallValidationMixin:
|
|
10
|
+
def validate_all(self) -> Dict:
|
|
11
|
+
"""Run all validations and return comprehensive results."""
|
|
12
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
13
|
+
self.console.print("[bold cyan]🔍 Running Full Validation...[/]")
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
result = subprocess.run(
|
|
17
|
+
["virsh", "--connect", self.conn_uri, "domstate", self.vm_name],
|
|
18
|
+
capture_output=True,
|
|
19
|
+
text=True,
|
|
20
|
+
timeout=5,
|
|
21
|
+
)
|
|
22
|
+
vm_state = result.stdout.strip()
|
|
23
|
+
|
|
24
|
+
if "running" not in vm_state.lower():
|
|
25
|
+
self.console.print(f"[yellow]⚠️ VM is not running (state: {vm_state})[/]")
|
|
26
|
+
self.console.print("[dim]Start VM with: clonebox start .[/]")
|
|
27
|
+
self.results["overall"] = "vm_not_running"
|
|
28
|
+
return self.results
|
|
29
|
+
except Exception as e:
|
|
30
|
+
self.console.print(f"[red]❌ Cannot check VM state: {e}[/]")
|
|
31
|
+
self.results["overall"] = "error"
|
|
32
|
+
return self.results
|
|
33
|
+
|
|
34
|
+
if not self._check_qga_ready():
|
|
35
|
+
wait_deadline = time.time() + 180
|
|
36
|
+
self.console.print("[yellow]⏳ Waiting for QEMU Guest Agent (up to 180s)...[/]")
|
|
37
|
+
last_log = 0
|
|
38
|
+
while time.time() < wait_deadline:
|
|
39
|
+
time.sleep(5)
|
|
40
|
+
if self._check_qga_ready():
|
|
41
|
+
break
|
|
42
|
+
elapsed = int(180 - (wait_deadline - time.time()))
|
|
43
|
+
if elapsed - last_log >= 15:
|
|
44
|
+
self.console.print(f"[dim] ...still waiting for QGA ({elapsed}s elapsed)[/]")
|
|
45
|
+
last_log = elapsed
|
|
46
|
+
|
|
47
|
+
if not self._check_qga_ready():
|
|
48
|
+
self.console.print("[yellow]⚠️ QEMU Guest Agent not responding - trying SSH fallback...[/]")
|
|
49
|
+
self._exec_transport = "ssh"
|
|
50
|
+
smoke = self._ssh_exec("echo ok", timeout=10)
|
|
51
|
+
if smoke != "ok":
|
|
52
|
+
self.console.print("[red]❌ SSH fallback failed[/]")
|
|
53
|
+
try:
|
|
54
|
+
key_path = self._get_ssh_key_path()
|
|
55
|
+
ssh_port = self._get_ssh_port()
|
|
56
|
+
if key_path is None:
|
|
57
|
+
self.console.print(
|
|
58
|
+
"[dim]SSH key not found for this VM (expected: <images_dir>/<vm_name>/ssh_key)[/]"
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
self.console.print(f"[dim]Expected SSH key: {key_path}[/]")
|
|
62
|
+
self.console.print(
|
|
63
|
+
f"[dim]Expected SSH port (passt forward): 127.0.0.1:{ssh_port} -> guest:22[/]"
|
|
64
|
+
)
|
|
65
|
+
self.console.print(
|
|
66
|
+
"[dim]SSH fallback requires libvirt user networking with passt + <portForward> in VM XML.[/]"
|
|
67
|
+
)
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
self.console.print("\n[bold]🔧 Troubleshooting QGA:[/]")
|
|
71
|
+
self.console.print(" 1. The VM might still be booting. Wait 30-60 seconds.")
|
|
72
|
+
self.console.print(" 2. Ensure the agent is installed and running inside the VM:")
|
|
73
|
+
self.console.print(" [dim]virsh console " + self.vm_name + "[/]")
|
|
74
|
+
self.console.print(" [dim]sudo systemctl status qemu-guest-agent[/]")
|
|
75
|
+
self.console.print(" 3. If newly created, cloud-init might still be running.")
|
|
76
|
+
self.console.print(" 4. Check VM logs: [dim]clonebox logs " + self.vm_name + "[/]")
|
|
77
|
+
self.console.print(
|
|
78
|
+
"\n[yellow]⚠️ Skipping deep validation as it requires a working Guest Agent or SSH access.[/]"
|
|
79
|
+
)
|
|
80
|
+
self.results["overall"] = "qga_not_ready"
|
|
81
|
+
return self.results
|
|
82
|
+
|
|
83
|
+
self.console.print("[green]✅ SSH fallback connected (executing validations over SSH)[/]")
|
|
84
|
+
|
|
85
|
+
ci_status = self._exec_in_vm(
|
|
86
|
+
"cloud-init status --long 2>/dev/null || cloud-init status 2>/dev/null || true", timeout=20
|
|
87
|
+
)
|
|
88
|
+
if ci_status:
|
|
89
|
+
ci_lower = ci_status.lower()
|
|
90
|
+
if "running" in ci_lower:
|
|
91
|
+
self.console.print(
|
|
92
|
+
"[yellow]⏳ Cloud-init still running - deep validation will show pending states[/]"
|
|
93
|
+
)
|
|
94
|
+
setup_in_progress = True
|
|
95
|
+
|
|
96
|
+
ready_msg = self._exec_in_vm("cat /var/log/clonebox-ready 2>/dev/null || true", timeout=10)
|
|
97
|
+
if not setup_in_progress and not (ready_msg and "clonebox vm ready" in ready_msg.lower()):
|
|
98
|
+
self.console.print(
|
|
99
|
+
"[yellow]⚠️ CloneBox ready marker not found - provisioning may not have completed[/]"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
self.validate_disk_space()
|
|
103
|
+
self.validate_mounts()
|
|
104
|
+
self.validate_packages()
|
|
105
|
+
self.validate_snap_packages()
|
|
106
|
+
self.validate_services()
|
|
107
|
+
self.validate_apps()
|
|
108
|
+
if self.smoke_test:
|
|
109
|
+
self.validate_smoke_tests()
|
|
110
|
+
|
|
111
|
+
recent_err = self._exec_in_vm("journalctl -p err -n 30 --no-pager 2>/dev/null || true", timeout=20)
|
|
112
|
+
if recent_err:
|
|
113
|
+
recent_err = recent_err.strip()
|
|
114
|
+
if recent_err:
|
|
115
|
+
self.console.print(Panel(recent_err, title="Recent system errors", border_style="red"))
|
|
116
|
+
|
|
117
|
+
disk_failed = 1 if self.results.get("disk", {}).get("usage_pct", 0) > 90 else 0
|
|
118
|
+
total_checks = (
|
|
119
|
+
1
|
|
120
|
+
+ self.results["mounts"]["total"]
|
|
121
|
+
+ self.results["packages"]["total"]
|
|
122
|
+
+ self.results["snap_packages"]["total"]
|
|
123
|
+
+ self.results["services"]["total"]
|
|
124
|
+
+ self.results["apps"]["total"]
|
|
125
|
+
+ (self.results["smoke"]["total"] if self.smoke_test else 0)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
total_passed = (
|
|
129
|
+
(1 - disk_failed)
|
|
130
|
+
+ self.results["mounts"]["passed"]
|
|
131
|
+
+ self.results["packages"]["passed"]
|
|
132
|
+
+ self.results["snap_packages"]["passed"]
|
|
133
|
+
+ self.results["services"]["passed"]
|
|
134
|
+
+ self.results["apps"]["passed"]
|
|
135
|
+
+ (self.results["smoke"]["passed"] if self.smoke_test else 0)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
total_failed = (
|
|
139
|
+
disk_failed
|
|
140
|
+
+ self.results["mounts"]["failed"]
|
|
141
|
+
+ self.results["packages"]["failed"]
|
|
142
|
+
+ self.results["snap_packages"]["failed"]
|
|
143
|
+
+ self.results["services"]["failed"]
|
|
144
|
+
+ self.results["apps"]["failed"]
|
|
145
|
+
+ (self.results["smoke"]["failed"] if self.smoke_test else 0)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
skipped_mounts = self.results["mounts"].get("skipped", 0)
|
|
149
|
+
skipped_packages = self.results["packages"].get("skipped", 0)
|
|
150
|
+
skipped_services = self.results["services"].get("skipped", 0)
|
|
151
|
+
skipped_snaps = self.results["snap_packages"].get("skipped", 0)
|
|
152
|
+
skipped_apps = self.results["apps"].get("skipped", 0)
|
|
153
|
+
skipped_smoke = self.results["smoke"].get("skipped", 0) if self.smoke_test else 0
|
|
154
|
+
total_skipped = (
|
|
155
|
+
skipped_mounts + skipped_packages + skipped_services + skipped_snaps + skipped_apps + skipped_smoke
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
self.console.print("\n[bold]📊 Validation Summary[/]")
|
|
159
|
+
summary_table = Table(border_style="cyan")
|
|
160
|
+
summary_table.add_column("Category", style="bold")
|
|
161
|
+
summary_table.add_column("Passed", justify="right", style="green")
|
|
162
|
+
summary_table.add_column("Failed", justify="right", style="red")
|
|
163
|
+
summary_table.add_column("Skipped/Pending", justify="right", style="dim")
|
|
164
|
+
summary_table.add_column("Total", justify="right")
|
|
165
|
+
|
|
166
|
+
disk_usage_pct = self.results.get("disk", {}).get("usage_pct", 0)
|
|
167
|
+
disk_avail = self.results.get("disk", {}).get("avail", "?")
|
|
168
|
+
disk_total = self.results.get("disk", {}).get("total", "?")
|
|
169
|
+
|
|
170
|
+
disk_status_passed = "[green]OK[/]" if disk_usage_pct <= 90 else "—"
|
|
171
|
+
disk_status_failed = "—" if disk_usage_pct <= 90 else f"[red]FULL ({disk_usage_pct}%)[/]"
|
|
172
|
+
|
|
173
|
+
summary_table.add_row(
|
|
174
|
+
"Disk Space",
|
|
175
|
+
disk_status_passed,
|
|
176
|
+
disk_status_failed,
|
|
177
|
+
"—",
|
|
178
|
+
f"{disk_usage_pct}% of {disk_total} ({disk_avail} free)",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
summary_table.add_row(
|
|
182
|
+
"Mounts",
|
|
183
|
+
str(self.results["mounts"]["passed"]),
|
|
184
|
+
str(self.results["mounts"]["failed"]),
|
|
185
|
+
str(skipped_mounts) if skipped_mounts else "—",
|
|
186
|
+
str(self.results["mounts"]["total"]),
|
|
187
|
+
)
|
|
188
|
+
summary_table.add_row(
|
|
189
|
+
"APT Packages",
|
|
190
|
+
str(self.results["packages"]["passed"]),
|
|
191
|
+
str(self.results["packages"]["failed"]),
|
|
192
|
+
str(skipped_packages) if skipped_packages else "—",
|
|
193
|
+
str(self.results["packages"]["total"]),
|
|
194
|
+
)
|
|
195
|
+
summary_table.add_row(
|
|
196
|
+
"Snap Packages",
|
|
197
|
+
str(self.results["snap_packages"]["passed"]),
|
|
198
|
+
str(self.results["snap_packages"]["failed"]),
|
|
199
|
+
str(skipped_snaps) if skipped_snaps else "—",
|
|
200
|
+
str(self.results["snap_packages"]["total"]),
|
|
201
|
+
)
|
|
202
|
+
summary_table.add_row(
|
|
203
|
+
"Services",
|
|
204
|
+
str(self.results["services"]["passed"]),
|
|
205
|
+
str(self.results["services"]["failed"]),
|
|
206
|
+
str(skipped_services) if skipped_services else "—",
|
|
207
|
+
str(self.results["services"]["total"]),
|
|
208
|
+
)
|
|
209
|
+
summary_table.add_row(
|
|
210
|
+
"Apps",
|
|
211
|
+
str(self.results["apps"]["passed"]),
|
|
212
|
+
str(self.results["apps"]["failed"]),
|
|
213
|
+
str(skipped_apps) if skipped_apps else "—",
|
|
214
|
+
str(self.results["apps"]["total"]),
|
|
215
|
+
)
|
|
216
|
+
summary_table.add_row(
|
|
217
|
+
"[bold]TOTAL",
|
|
218
|
+
f"[bold green]{total_passed}",
|
|
219
|
+
f"[bold red]{total_failed}",
|
|
220
|
+
f"[dim]{total_skipped}[/]" if total_skipped else "[dim]0[/]",
|
|
221
|
+
f"[bold]{total_checks}",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
self.console.print(summary_table)
|
|
225
|
+
|
|
226
|
+
if total_failed == 0 and total_checks > 0 and total_skipped > 0:
|
|
227
|
+
self.results["overall"] = "pending"
|
|
228
|
+
self.console.print("\n[bold yellow]⏳ Setup in progress - some checks are pending[/]")
|
|
229
|
+
elif total_failed == 0 and total_checks > 0:
|
|
230
|
+
self.results["overall"] = "pass"
|
|
231
|
+
self.console.print("\n[bold green]✅ All validations passed![/]")
|
|
232
|
+
elif total_failed > 0:
|
|
233
|
+
self.results["overall"] = "partial"
|
|
234
|
+
self.console.print(f"\n[bold yellow]⚠️ {total_failed}/{total_checks} checks failed[/]")
|
|
235
|
+
self.console.print("[dim]Consider rebuilding VM: clonebox clone . --user --run --replace[/]")
|
|
236
|
+
else:
|
|
237
|
+
self.results["overall"] = "no_checks"
|
|
238
|
+
self.console.print("\n[dim]No validation checks configured[/]")
|
|
239
|
+
|
|
240
|
+
return self.results
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from typing import Dict
|
|
2
|
+
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PackageValidationMixin:
|
|
7
|
+
def validate_packages(self) -> Dict:
|
|
8
|
+
"""Validate APT packages are installed."""
|
|
9
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
10
|
+
self.console.print("\n[bold]📦 Validating APT Packages...[/]")
|
|
11
|
+
|
|
12
|
+
packages = self.config.get("packages", [])
|
|
13
|
+
if not packages:
|
|
14
|
+
self.console.print("[dim]No APT packages configured[/]")
|
|
15
|
+
return self.results["packages"]
|
|
16
|
+
|
|
17
|
+
total_pkgs = len(packages)
|
|
18
|
+
self.console.print(f"[dim]Checking {total_pkgs} packages via QGA...[/]")
|
|
19
|
+
|
|
20
|
+
pkg_table = Table(title="Package Validation", border_style="cyan")
|
|
21
|
+
pkg_table.add_column("Package", style="bold")
|
|
22
|
+
pkg_table.add_column("Status", justify="center")
|
|
23
|
+
pkg_table.add_column("Version", style="dim")
|
|
24
|
+
|
|
25
|
+
for idx, package in enumerate(packages, 1):
|
|
26
|
+
if idx == 1 or idx % 25 == 0 or idx == total_pkgs:
|
|
27
|
+
self.console.print(f"[dim] ...packages progress: {idx}/{total_pkgs}[/]")
|
|
28
|
+
self.results["packages"]["total"] += 1
|
|
29
|
+
|
|
30
|
+
check_cmd = f"dpkg -l | grep -E '^ii {package}' | awk '{{print $3}}'"
|
|
31
|
+
version = self._exec_in_vm(check_cmd)
|
|
32
|
+
|
|
33
|
+
if version:
|
|
34
|
+
pkg_table.add_row(package, "[green]✅ Installed[/]", version[:40])
|
|
35
|
+
self.results["packages"]["passed"] += 1
|
|
36
|
+
self.results["packages"]["details"].append(
|
|
37
|
+
{"package": package, "installed": True, "version": version}
|
|
38
|
+
)
|
|
39
|
+
else:
|
|
40
|
+
if setup_in_progress:
|
|
41
|
+
pkg_table.add_row(package, "[yellow]⏳ Pending[/]", "")
|
|
42
|
+
self.results["packages"]["skipped"] += 1
|
|
43
|
+
self.results["packages"]["details"].append(
|
|
44
|
+
{"package": package, "installed": False, "version": None, "pending": True}
|
|
45
|
+
)
|
|
46
|
+
else:
|
|
47
|
+
pkg_table.add_row(package, "[red]❌ Missing[/]", "")
|
|
48
|
+
self.results["packages"]["failed"] += 1
|
|
49
|
+
self.results["packages"]["details"].append(
|
|
50
|
+
{"package": package, "installed": False, "version": None}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
self.console.print(pkg_table)
|
|
54
|
+
self.console.print(
|
|
55
|
+
f"[dim]{self.results['packages']['passed']}/{self.results['packages']['total']} packages installed[/]"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return self.results["packages"]
|
|
59
|
+
|
|
60
|
+
def validate_snap_packages(self) -> Dict:
|
|
61
|
+
"""Validate snap packages are installed."""
|
|
62
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
63
|
+
self.console.print("\n[bold]📦 Validating Snap Packages...[/]")
|
|
64
|
+
|
|
65
|
+
snap_packages = self.config.get("snap_packages", [])
|
|
66
|
+
if not snap_packages:
|
|
67
|
+
self.console.print("[dim]No snap packages configured[/]")
|
|
68
|
+
return self.results["snap_packages"]
|
|
69
|
+
|
|
70
|
+
total_snaps = len(snap_packages)
|
|
71
|
+
self.console.print(f"[dim]Checking {total_snaps} snap packages via QGA...[/]")
|
|
72
|
+
|
|
73
|
+
snap_table = Table(title="Snap Package Validation", border_style="cyan")
|
|
74
|
+
snap_table.add_column("Package", style="bold")
|
|
75
|
+
snap_table.add_column("Status", justify="center")
|
|
76
|
+
snap_table.add_column("Version", style="dim")
|
|
77
|
+
|
|
78
|
+
for idx, package in enumerate(snap_packages, 1):
|
|
79
|
+
if idx == 1 or idx % 25 == 0 or idx == total_snaps:
|
|
80
|
+
self.console.print(f"[dim] ...snap progress: {idx}/{total_snaps}[/]")
|
|
81
|
+
self.results["snap_packages"]["total"] += 1
|
|
82
|
+
|
|
83
|
+
check_cmd = f"snap list | grep '^{package}' | awk '{{print $2}}'"
|
|
84
|
+
version = self._exec_in_vm(check_cmd)
|
|
85
|
+
|
|
86
|
+
if version:
|
|
87
|
+
snap_table.add_row(package, "[green]✅ Installed[/]", version[:40])
|
|
88
|
+
self.results["snap_packages"]["passed"] += 1
|
|
89
|
+
self.results["snap_packages"]["details"].append(
|
|
90
|
+
{"package": package, "installed": True, "version": version}
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
if setup_in_progress:
|
|
94
|
+
snap_table.add_row(package, "[yellow]⏳ Pending[/]", "")
|
|
95
|
+
self.results["snap_packages"]["skipped"] += 1
|
|
96
|
+
self.results["snap_packages"]["details"].append(
|
|
97
|
+
{
|
|
98
|
+
"package": package,
|
|
99
|
+
"installed": False,
|
|
100
|
+
"version": None,
|
|
101
|
+
"pending": True,
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
snap_table.add_row(package, "[red]❌ Missing[/]", "")
|
|
106
|
+
self.results["snap_packages"]["failed"] += 1
|
|
107
|
+
self.results["snap_packages"]["details"].append(
|
|
108
|
+
{"package": package, "installed": False, "version": None}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
self.console.print(snap_table)
|
|
112
|
+
msg = f"{self.results['snap_packages']['passed']}/{self.results['snap_packages']['total']} snap packages installed"
|
|
113
|
+
if self.results["snap_packages"].get("skipped", 0) > 0:
|
|
114
|
+
msg += f" ({self.results['snap_packages']['skipped']} pending)"
|
|
115
|
+
self.console.print(f"[dim]{msg}[/]")
|
|
116
|
+
|
|
117
|
+
return self.results["snap_packages"]
|