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.
@@ -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"]
@@ -0,0 +1,125 @@
1
+ from typing import Dict
2
+
3
+ from rich.table import Table
4
+
5
+
6
+ class ServiceValidationMixin:
7
+ # Services that should NOT be validated in VM (host-specific)
8
+ VM_EXCLUDED_SERVICES = {
9
+ "libvirtd",
10
+ "virtlogd",
11
+ "libvirt-guests",
12
+ "qemu-guest-agent",
13
+ "bluetooth",
14
+ "bluez",
15
+ "upower",
16
+ "thermald",
17
+ "tlp",
18
+ "power-profiles-daemon",
19
+ "gdm",
20
+ "gdm3",
21
+ "sddm",
22
+ "lightdm",
23
+ "snap.cups.cups-browsed",
24
+ "snap.cups.cupsd",
25
+ "ModemManager",
26
+ "wpa_supplicant",
27
+ "accounts-daemon",
28
+ "colord",
29
+ "switcheroo-control",
30
+ }
31
+
32
+ def validate_services(self) -> Dict:
33
+ """Validate services are enabled and running."""
34
+ setup_in_progress = self._setup_in_progress() is True
35
+ self.console.print("\n[bold]⚙️ Validating Services...[/]")
36
+
37
+ services = self.config.get("services", [])
38
+ if not services:
39
+ self.console.print("[dim]No services configured[/]")
40
+ return self.results["services"]
41
+
42
+ total_svcs = len(services)
43
+ self.console.print(f"[dim]Checking {total_svcs} services via QGA...[/]")
44
+
45
+ if "skipped" not in self.results["services"]:
46
+ self.results["services"]["skipped"] = 0
47
+
48
+ svc_table = Table(title="Service Validation", border_style="cyan")
49
+ svc_table.add_column("Service", style="bold")
50
+ svc_table.add_column("Enabled", justify="center")
51
+ svc_table.add_column("Running", justify="center")
52
+ svc_table.add_column("PID", justify="right", style="dim")
53
+ svc_table.add_column("Note", style="dim")
54
+
55
+ for idx, service in enumerate(services, 1):
56
+ if idx == 1 or idx % 25 == 0 or idx == total_svcs:
57
+ self.console.print(f"[dim] ...services progress: {idx}/{total_svcs}[/]")
58
+ if service in self.VM_EXCLUDED_SERVICES:
59
+ svc_table.add_row(service, "[dim]—[/]", "[dim]—[/]", "[dim]—[/]", "host-only")
60
+ self.results["services"]["skipped"] += 1
61
+ self.results["services"]["details"].append(
62
+ {
63
+ "service": service,
64
+ "enabled": None,
65
+ "running": None,
66
+ "skipped": True,
67
+ "reason": "host-specific service",
68
+ }
69
+ )
70
+ continue
71
+
72
+ self.results["services"]["total"] += 1
73
+
74
+ enabled_cmd = f"systemctl is-enabled {service} 2>/dev/null"
75
+ enabled_status = self._exec_in_vm(enabled_cmd)
76
+ is_enabled = enabled_status == "enabled"
77
+
78
+ running_cmd = f"systemctl is-active {service} 2>/dev/null"
79
+ running_status = self._exec_in_vm(running_cmd)
80
+ is_running = running_status == "active"
81
+
82
+ pid_value = ""
83
+ if is_running:
84
+ pid_out = self._exec_in_vm(f"systemctl show -p MainPID --value {service} 2>/dev/null")
85
+ if pid_out is None:
86
+ pid_value = "?"
87
+ else:
88
+ pid_value = pid_out.strip() or "?"
89
+ else:
90
+ pid_value = "—"
91
+
92
+ enabled_icon = (
93
+ "[green]✅[/]" if is_enabled else ("[yellow]⏳[/]" if setup_in_progress else "[yellow]⚠️[/]")
94
+ )
95
+ running_icon = (
96
+ "[green]✅[/]" if is_running else ("[yellow]⏳[/]" if setup_in_progress else "[red]❌[/]")
97
+ )
98
+
99
+ svc_table.add_row(service, enabled_icon, running_icon, pid_value, "")
100
+
101
+ if is_enabled and is_running:
102
+ self.results["services"]["passed"] += 1
103
+ elif setup_in_progress:
104
+ self.results["services"]["skipped"] += 1
105
+ else:
106
+ self.results["services"]["failed"] += 1
107
+
108
+ self.results["services"]["details"].append(
109
+ {
110
+ "service": service,
111
+ "enabled": is_enabled,
112
+ "running": is_running,
113
+ "pid": None if pid_value in ("", "—", "?") else pid_value,
114
+ "skipped": False,
115
+ }
116
+ )
117
+
118
+ self.console.print(svc_table)
119
+ skipped = self.results["services"].get("skipped", 0)
120
+ msg = f"{self.results['services']['passed']}/{self.results['services']['total']} services active"
121
+ if skipped > 0:
122
+ msg += f" ({skipped} host-only skipped)"
123
+ self.console.print(f"[dim]{msg}[/]")
124
+
125
+ return self.results["services"]
@@ -0,0 +1,177 @@
1
+ from typing import Dict, Optional
2
+
3
+ from rich.table import Table
4
+
5
+
6
+ class SmokeValidationMixin:
7
+ def validate_smoke_tests(self) -> Dict:
8
+ setup_in_progress = self._setup_in_progress() is True
9
+ packages = self.config.get("packages", [])
10
+ snap_packages = self.config.get("snap_packages", [])
11
+ copy_paths = self.config.get("copy_paths", None)
12
+ if not isinstance(copy_paths, dict) or not copy_paths:
13
+ copy_paths = self.config.get("app_data_paths", {})
14
+ vm_user = self.config.get("vm", {}).get("username", "ubuntu")
15
+
16
+ expected = []
17
+
18
+ if "firefox" in packages:
19
+ expected.append("firefox")
20
+
21
+ for snap_pkg in snap_packages:
22
+ if snap_pkg in {"pycharm-community", "chromium", "firefox", "code"}:
23
+ expected.append(snap_pkg)
24
+
25
+ for _, guest_path in copy_paths.items():
26
+ if guest_path == "/home/ubuntu/.config/google-chrome":
27
+ expected.append("google-chrome")
28
+ break
29
+
30
+ if "docker" in (self.config.get("services", []) or []) or "docker.io" in packages:
31
+ expected.append("docker")
32
+
33
+ expected = sorted(set(expected))
34
+ if not expected:
35
+ return self.results["smoke"]
36
+
37
+ def _installed(app: str) -> Optional[bool]:
38
+ if app in {"pycharm-community", "chromium", "firefox", "code"}:
39
+ out = self._exec_in_vm(f"snap list {app} >/dev/null 2>&1 && echo yes || echo no", timeout=10)
40
+ return None if out is None else out.strip() == "yes"
41
+
42
+ if app == "google-chrome":
43
+ out = self._exec_in_vm(
44
+ "(command -v google-chrome >/dev/null 2>&1 || command -v google-chrome-stable >/dev/null 2>&1) && echo yes || echo no",
45
+ timeout=10,
46
+ )
47
+ return None if out is None else out.strip() == "yes"
48
+
49
+ if app == "docker":
50
+ out = self._exec_in_vm("command -v docker >/dev/null 2>&1 && echo yes || echo no", timeout=10)
51
+ return None if out is None else out.strip() == "yes"
52
+
53
+ if app == "firefox":
54
+ out = self._exec_in_vm("command -v firefox >/dev/null 2>&1 && echo yes || echo no", timeout=10)
55
+ return None if out is None else out.strip() == "yes"
56
+
57
+ out = self._exec_in_vm(f"command -v {app} >/dev/null 2>&1 && echo yes || echo no", timeout=10)
58
+ return None if out is None else out.strip() == "yes"
59
+
60
+ def _run_test(app: str) -> Optional[bool]:
61
+ uid_out = self._exec_in_vm(f"id -u {vm_user} 2>/dev/null || true", timeout=10)
62
+ vm_uid = (uid_out or "").strip()
63
+ if not vm_uid.isdigit():
64
+ vm_uid = "1000"
65
+
66
+ runtime_dir = f"/run/user/{vm_uid}"
67
+ self._exec_in_vm(
68
+ f"mkdir -p {runtime_dir} && chown {vm_uid}:{vm_uid} {runtime_dir} && chmod 700 {runtime_dir}",
69
+ timeout=10,
70
+ )
71
+
72
+ user_env = (
73
+ f"sudo -u {vm_user} env HOME=/home/{vm_user} USER={vm_user} LOGNAME={vm_user} XDG_RUNTIME_DIR={runtime_dir}"
74
+ )
75
+
76
+ if app == "pycharm-community":
77
+ out = self._exec_in_vm(
78
+ "/snap/pycharm-community/current/jbr/bin/java -version >/dev/null 2>&1 && echo yes || echo no",
79
+ timeout=20,
80
+ )
81
+ return None if out is None else out.strip() == "yes"
82
+
83
+ if app == "chromium":
84
+ out = self._exec_in_vm(
85
+ f"{user_env} timeout 20 chromium --headless=new --no-sandbox --disable-gpu --dump-dom about:blank >/dev/null 2>&1 && echo yes || echo no",
86
+ timeout=30,
87
+ )
88
+ return None if out is None else out.strip() == "yes"
89
+
90
+ if app == "firefox":
91
+ out = self._exec_in_vm(
92
+ f"{user_env} timeout 20 firefox --headless --version >/dev/null 2>&1 && echo yes || echo no",
93
+ timeout=30,
94
+ )
95
+ return None if out is None else out.strip() == "yes"
96
+
97
+ if app == "google-chrome":
98
+ out = self._exec_in_vm(
99
+ 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",
100
+ timeout=30,
101
+ )
102
+ return None if out is None else out.strip() == "yes"
103
+
104
+ if app == "docker":
105
+ out = self._exec_in_vm("timeout 20 docker info >/dev/null 2>&1 && echo yes || echo no", timeout=30)
106
+ return None if out is None else out.strip() == "yes"
107
+
108
+ out = self._exec_in_vm(f"timeout 20 {app} --version >/dev/null 2>&1 && echo yes || echo no", timeout=30)
109
+ return None if out is None else out.strip() == "yes"
110
+
111
+ self.console.print("\n[bold]🧪 Smoke Tests (installed ≠ works)...[/]")
112
+ table = Table(title="Smoke Tests", border_style="cyan")
113
+ table.add_column("App", style="bold")
114
+ table.add_column("Installed", justify="center")
115
+ table.add_column("Launch", justify="center")
116
+ table.add_column("Note", style="dim")
117
+
118
+ for app in expected:
119
+ self.results["smoke"]["total"] += 1
120
+ installed = _installed(app)
121
+ launched: Optional[bool] = None
122
+ note = ""
123
+ pending = False
124
+
125
+ if installed is True:
126
+ launched = _run_test(app)
127
+ if launched is None:
128
+ note = "test failed to execute"
129
+ elif launched is False and setup_in_progress:
130
+ pending = True
131
+ note = note or "setup in progress"
132
+ elif installed is False:
133
+ if setup_in_progress:
134
+ pending = True
135
+ note = "setup in progress"
136
+ else:
137
+ note = "not installed"
138
+ else:
139
+ note = "install status unknown"
140
+
141
+ installed_icon = (
142
+ "[green]✅[/]"
143
+ if installed is True
144
+ else ("[yellow]⏳[/]" if pending else "[red]❌[/]")
145
+ if installed is False
146
+ else "[dim]?[/]"
147
+ )
148
+ launch_icon = (
149
+ "[green]✅[/]"
150
+ if launched is True
151
+ else ("[yellow]⏳[/]" if pending else "[red]❌[/]")
152
+ if launched is False
153
+ else ("[dim]—[/]" if installed is not True else "[dim]?[/]")
154
+ )
155
+
156
+ table.add_row(app, installed_icon, launch_icon, note)
157
+
158
+ passed = installed is True and launched is True
159
+ if pending:
160
+ self.results["smoke"]["skipped"] += 1
161
+ elif passed:
162
+ self.results["smoke"]["passed"] += 1
163
+ else:
164
+ self.results["smoke"]["failed"] += 1
165
+
166
+ self.results["smoke"]["details"].append(
167
+ {
168
+ "app": app,
169
+ "installed": installed,
170
+ "launched": launched,
171
+ "note": note,
172
+ "pending": pending,
173
+ }
174
+ )
175
+
176
+ self.console.print(table)
177
+ return self.results["smoke"]