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/cli.py
CHANGED
|
@@ -945,10 +945,28 @@ def interactive_mode():
|
|
|
945
945
|
cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
|
|
946
946
|
|
|
947
947
|
# Check prerequisites
|
|
948
|
-
checks = cloner.check_prerequisites()
|
|
949
|
-
|
|
948
|
+
checks = cloner.check_prerequisites(config)
|
|
949
|
+
required_keys = [
|
|
950
|
+
"libvirt_connected",
|
|
951
|
+
"kvm_available",
|
|
952
|
+
"images_dir_writable",
|
|
953
|
+
"genisoimage_installed",
|
|
954
|
+
"qemu_img_installed",
|
|
955
|
+
]
|
|
956
|
+
if checks.get("default_network_required", True):
|
|
957
|
+
required_keys.append("default_network")
|
|
958
|
+
if getattr(config, "gui", False):
|
|
959
|
+
required_keys.append("virt_viewer_installed")
|
|
960
|
+
|
|
961
|
+
required_checks = {
|
|
962
|
+
k: checks.get(k)
|
|
963
|
+
for k in required_keys
|
|
964
|
+
if isinstance(checks.get(k), bool)
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if required_checks and not all(required_checks.values()):
|
|
950
968
|
console.print("[yellow]⚠️ Prerequisites check:[/]")
|
|
951
|
-
for check, passed in
|
|
969
|
+
for check, passed in required_checks.items():
|
|
952
970
|
icon = "✅" if passed else "❌"
|
|
953
971
|
console.print(f" {icon} {check}")
|
|
954
972
|
|
|
@@ -1898,7 +1916,10 @@ def cmd_test(args):
|
|
|
1898
1916
|
|
|
1899
1917
|
if not qga_ready:
|
|
1900
1918
|
console.print("[yellow]⚠️ QEMU Guest Agent still not connected[/]")
|
|
1901
|
-
|
|
1919
|
+
console.print(
|
|
1920
|
+
f"[dim]Tip: you can watch live cloud-init output via serial console: virsh --connect {conn_uri} console {vm_name}[/]"
|
|
1921
|
+
)
|
|
1922
|
+
|
|
1902
1923
|
# Check cloud-init status immediately if QGA is ready
|
|
1903
1924
|
if qga_ready:
|
|
1904
1925
|
console.print("[dim] Checking cloud-init status via QGA...[/]")
|
|
@@ -2624,10 +2645,24 @@ def create_vm_from_config(config, start=False, user_session=False, replace=False
|
|
|
2624
2645
|
cloner = SelectiveVMCloner(user_session=user_session)
|
|
2625
2646
|
|
|
2626
2647
|
# Check prerequisites
|
|
2627
|
-
checks = cloner.check_prerequisites()
|
|
2628
|
-
|
|
2648
|
+
checks = cloner.check_prerequisites(vm_config)
|
|
2649
|
+
required_keys = [
|
|
2650
|
+
"libvirt_connected",
|
|
2651
|
+
"kvm_available",
|
|
2652
|
+
"images_dir_writable",
|
|
2653
|
+
"genisoimage_installed",
|
|
2654
|
+
"qemu_img_installed",
|
|
2655
|
+
]
|
|
2656
|
+
if checks.get("default_network_required", True):
|
|
2657
|
+
required_keys.append("default_network")
|
|
2658
|
+
if getattr(vm_config, "gui", False):
|
|
2659
|
+
required_keys.append("virt_viewer_installed")
|
|
2660
|
+
|
|
2661
|
+
required_checks = {k: checks.get(k) for k in required_keys if isinstance(checks.get(k), bool)}
|
|
2662
|
+
|
|
2663
|
+
if required_checks and not all(required_checks.values()):
|
|
2629
2664
|
console.print("[yellow]⚠️ Prerequisites check:[/]")
|
|
2630
|
-
for check, passed in
|
|
2665
|
+
for check, passed in required_checks.items():
|
|
2631
2666
|
icon = "✅" if passed else "❌"
|
|
2632
2667
|
console.print(f" {icon} {check}")
|
|
2633
2668
|
|
clonebox/cloner.py
CHANGED
|
@@ -15,6 +15,7 @@ import tempfile
|
|
|
15
15
|
import time
|
|
16
16
|
import urllib.request
|
|
17
17
|
import uuid
|
|
18
|
+
import zlib
|
|
18
19
|
import xml.etree.ElementTree as ET
|
|
19
20
|
from dataclasses import dataclass, field
|
|
20
21
|
from datetime import datetime
|
|
@@ -288,6 +289,17 @@ class SelectiveVMCloner:
|
|
|
288
289
|
"""Check if libvirt default network is active."""
|
|
289
290
|
return self._default_network_state() == "active"
|
|
290
291
|
|
|
292
|
+
def _passt_supported(self) -> bool:
|
|
293
|
+
try:
|
|
294
|
+
if self.conn is None:
|
|
295
|
+
return False
|
|
296
|
+
if not hasattr(self.conn, "getLibVersion"):
|
|
297
|
+
return False
|
|
298
|
+
# libvirt 9.0.0 introduced the passt backend and <portForward> support
|
|
299
|
+
return int(self.conn.getLibVersion()) >= 9_000_000
|
|
300
|
+
except Exception:
|
|
301
|
+
return False
|
|
302
|
+
|
|
291
303
|
def resolve_network_mode(self, config: VMConfig) -> str:
|
|
292
304
|
"""Resolve network mode based on config and session type."""
|
|
293
305
|
mode = (config.network_mode or "auto").lower()
|
|
@@ -299,22 +311,34 @@ class SelectiveVMCloner:
|
|
|
299
311
|
return mode
|
|
300
312
|
return "default"
|
|
301
313
|
|
|
302
|
-
def check_prerequisites(self) -> dict:
|
|
314
|
+
def check_prerequisites(self, config: Optional[VMConfig] = None) -> dict:
|
|
303
315
|
"""Check system prerequisites for VM creation."""
|
|
304
316
|
images_dir = self.get_images_dir()
|
|
305
317
|
|
|
318
|
+
resolved_network_mode: Optional[str] = None
|
|
319
|
+
if config is not None:
|
|
320
|
+
try:
|
|
321
|
+
resolved_network_mode = self.resolve_network_mode(config)
|
|
322
|
+
except Exception:
|
|
323
|
+
resolved_network_mode = None
|
|
324
|
+
|
|
306
325
|
checks = {
|
|
307
326
|
"libvirt_connected": False,
|
|
308
327
|
"kvm_available": False,
|
|
309
328
|
"default_network": False,
|
|
329
|
+
"default_network_required": True,
|
|
310
330
|
"images_dir_writable": False,
|
|
311
331
|
"images_dir": str(images_dir),
|
|
312
332
|
"session_type": "user" if self.user_session else "system",
|
|
313
333
|
"genisoimage_installed": False,
|
|
314
334
|
"virt_viewer_installed": False,
|
|
315
335
|
"qemu_img_installed": False,
|
|
336
|
+
"passt_installed": shutil.which("passt") is not None,
|
|
337
|
+
"passt_available": False,
|
|
316
338
|
}
|
|
317
339
|
|
|
340
|
+
checks["passt_available"] = checks["passt_installed"] and self._passt_supported()
|
|
341
|
+
|
|
318
342
|
# Check for genisoimage
|
|
319
343
|
checks["genisoimage_installed"] = shutil.which("genisoimage") is not None
|
|
320
344
|
|
|
@@ -340,8 +364,14 @@ class SelectiveVMCloner:
|
|
|
340
364
|
|
|
341
365
|
# Check default network
|
|
342
366
|
default_net_state = self._default_network_state()
|
|
343
|
-
checks["
|
|
344
|
-
if
|
|
367
|
+
checks["default_network_required"] = (resolved_network_mode or "default") != "user"
|
|
368
|
+
if checks["default_network_required"]:
|
|
369
|
+
checks["default_network"] = default_net_state == "active"
|
|
370
|
+
else:
|
|
371
|
+
# For user-mode networking (slirp/passt), libvirt's default network is not required.
|
|
372
|
+
checks["default_network"] = True
|
|
373
|
+
|
|
374
|
+
if checks["default_network_required"] and default_net_state in {"inactive", "missing", "unknown"}:
|
|
345
375
|
checks["network_error"] = (
|
|
346
376
|
"Default network not found or inactive.\n"
|
|
347
377
|
" For user session, CloneBox can use user-mode networking (slirp) automatically.\n"
|
|
@@ -351,6 +381,9 @@ class SelectiveVMCloner:
|
|
|
351
381
|
" Or use system session: clonebox clone . (without --user)\n"
|
|
352
382
|
)
|
|
353
383
|
|
|
384
|
+
if resolved_network_mode is not None:
|
|
385
|
+
checks["network_mode"] = resolved_network_mode
|
|
386
|
+
|
|
354
387
|
# Check images directory
|
|
355
388
|
if images_dir.exists():
|
|
356
389
|
checks["images_dir_writable"] = os.access(images_dir, os.W_OK)
|
|
@@ -524,6 +557,36 @@ class SelectiveVMCloner:
|
|
|
524
557
|
f"If the VM already exists, try: clonebox clone . --name {config.name} --replace\n"
|
|
525
558
|
) from e
|
|
526
559
|
|
|
560
|
+
try:
|
|
561
|
+
if console and self.resolve_network_mode(config) == "user":
|
|
562
|
+
if shutil.which("passt") and self._passt_supported():
|
|
563
|
+
ssh_key_path = vm_dir / "ssh_key"
|
|
564
|
+
if ssh_key_path.exists():
|
|
565
|
+
ssh_port = 22000 + (zlib.crc32(config.name.encode("utf-8")) % 1000)
|
|
566
|
+
console.print(
|
|
567
|
+
f"[dim]SSH access (passthrough): ssh -i {ssh_key_path} -p {ssh_port} {config.username}@127.0.0.1[/]"
|
|
568
|
+
)
|
|
569
|
+
else:
|
|
570
|
+
console.print(
|
|
571
|
+
"[yellow]⚠️ passt not available - SSH port-forward will NOT be configured in --user mode[/]"
|
|
572
|
+
)
|
|
573
|
+
console.print(
|
|
574
|
+
"[dim]Install passt (and recreate VM) to enable SSH fallback, or use virsh console for live logs.[/]"
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
if self.user_session:
|
|
578
|
+
console.print(
|
|
579
|
+
f"[dim]Host serial log (when enabled): {vm_dir / 'serial.log'}[/]"
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
if console:
|
|
583
|
+
conn_uri = "qemu:///session" if self.user_session else "qemu:///system"
|
|
584
|
+
console.print(
|
|
585
|
+
f"[dim]Live install logs: virsh --connect {conn_uri} console {config.name}[/]"
|
|
586
|
+
)
|
|
587
|
+
except Exception:
|
|
588
|
+
pass
|
|
589
|
+
|
|
527
590
|
# Start if autostart requested
|
|
528
591
|
if getattr(config, "autostart", False):
|
|
529
592
|
self.start_vm(config.name, open_viewer=True)
|
|
@@ -1358,6 +1421,13 @@ fi
|
|
|
1358
1421
|
if network_mode == "user":
|
|
1359
1422
|
iface = ET.SubElement(devices, "interface", type="user")
|
|
1360
1423
|
ET.SubElement(iface, "model", type="virtio")
|
|
1424
|
+
|
|
1425
|
+
if shutil.which("passt") and self._passt_supported():
|
|
1426
|
+
ET.SubElement(iface, "backend", type="passt")
|
|
1427
|
+
|
|
1428
|
+
ssh_port = 22000 + (zlib.crc32(config.name.encode("utf-8")) % 1000)
|
|
1429
|
+
pf = ET.SubElement(iface, "portForward", proto="tcp", address="127.0.0.1")
|
|
1430
|
+
ET.SubElement(pf, "range", start=str(ssh_port), to="22")
|
|
1361
1431
|
else:
|
|
1362
1432
|
iface = ET.SubElement(devices, "interface", type="network")
|
|
1363
1433
|
ET.SubElement(iface, "source", network="default")
|
|
@@ -1376,6 +1446,13 @@ fi
|
|
|
1376
1446
|
serial = ET.SubElement(devices, "serial", type="pty")
|
|
1377
1447
|
ET.SubElement(serial, "target", port="0")
|
|
1378
1448
|
|
|
1449
|
+
if self.user_session:
|
|
1450
|
+
vm_dir = Path(str(root_disk)).parent
|
|
1451
|
+
serial_log_path = str(vm_dir / "serial.log")
|
|
1452
|
+
serial_file = ET.SubElement(devices, "serial", type="file")
|
|
1453
|
+
ET.SubElement(serial_file, "source", path=serial_log_path)
|
|
1454
|
+
ET.SubElement(serial_file, "target", port="1")
|
|
1455
|
+
|
|
1379
1456
|
console_elem = ET.SubElement(devices, "console", type="pty")
|
|
1380
1457
|
ET.SubElement(console_elem, "target", type="serial", port="0")
|
|
1381
1458
|
|
|
@@ -1398,6 +1475,7 @@ fi
|
|
|
1398
1475
|
|
|
1399
1476
|
# Channel for guest agent
|
|
1400
1477
|
channel = ET.SubElement(devices, "channel", type="unix")
|
|
1478
|
+
# For both user and system sessions, let libvirt handle the path
|
|
1401
1479
|
ET.SubElement(channel, "source", mode="bind")
|
|
1402
1480
|
ET.SubElement(channel, "target", type="virtio", name="org.qemu.guest_agent.0")
|
|
1403
1481
|
|
|
@@ -1563,7 +1641,7 @@ fi
|
|
|
1563
1641
|
|
|
1564
1642
|
# User-data
|
|
1565
1643
|
# Add desktop environment if GUI is enabled
|
|
1566
|
-
base_packages = ["qemu-guest-agent", "cloud-guest-utils"]
|
|
1644
|
+
base_packages = ["qemu-guest-agent", "cloud-guest-utils", "openssh-server"]
|
|
1567
1645
|
if config.gui:
|
|
1568
1646
|
base_packages.extend(
|
|
1569
1647
|
[
|
|
@@ -1619,10 +1697,13 @@ fi
|
|
|
1619
1697
|
|
|
1620
1698
|
# Phase 3: Core services
|
|
1621
1699
|
runcmd_lines.append(" - echo '[3/10] 🔧 Enabling core services...'")
|
|
1622
|
-
runcmd_lines.append(" - echo ' → [1/
|
|
1700
|
+
runcmd_lines.append(" - echo ' → [1/3] Enabling qemu-guest-agent'")
|
|
1623
1701
|
runcmd_lines.append(" - systemctl enable --now qemu-guest-agent || echo ' → ❌ Failed to enable qemu-guest-agent'")
|
|
1624
|
-
runcmd_lines.append(" - echo ' → [2/
|
|
1702
|
+
runcmd_lines.append(" - echo ' → [2/3] Enabling SSH server'")
|
|
1703
|
+
runcmd_lines.append(" - systemctl enable --now ssh || systemctl enable --now sshd || echo ' → ❌ Failed to enable ssh'")
|
|
1704
|
+
runcmd_lines.append(" - echo ' → [3/3] Enabling snapd'")
|
|
1625
1705
|
runcmd_lines.append(" - systemctl enable --now snapd || echo ' → ❌ Failed to enable snapd'")
|
|
1706
|
+
runcmd_lines.append(" - systemctl enable --now serial-getty@ttyS0.service >/dev/null 2>&1 || true")
|
|
1626
1707
|
runcmd_lines.append(" - echo ' → Waiting for snap system seed...'")
|
|
1627
1708
|
runcmd_lines.append(" - timeout 300 snap wait system seed.loaded || true")
|
|
1628
1709
|
runcmd_lines.append(" - echo ' → ✓ [3/10] Core services enabled'")
|
|
@@ -2544,7 +2625,12 @@ if __name__ == "__main__":
|
|
|
2544
2625
|
runcmd_yaml = "\n".join(runcmd_lines) if runcmd_lines else ""
|
|
2545
2626
|
|
|
2546
2627
|
# Build bootcmd combining mount commands and extra security bootcmds
|
|
2547
|
-
bootcmd_lines =
|
|
2628
|
+
bootcmd_lines = [
|
|
2629
|
+
' - sh -c "echo \"[clonebox] bootcmd: enabling serial console (ttyS0)\" > /dev/ttyS0 || true"',
|
|
2630
|
+
' - systemctl enable --now serial-getty@ttyS0.service >/dev/null 2>&1 || true',
|
|
2631
|
+
]
|
|
2632
|
+
if bootcmd_extra:
|
|
2633
|
+
bootcmd_lines.extend(list(bootcmd_extra))
|
|
2548
2634
|
|
|
2549
2635
|
bootcmd_block = ""
|
|
2550
2636
|
if bootcmd_lines:
|
|
@@ -2572,6 +2658,10 @@ users:
|
|
|
2572
2658
|
|
|
2573
2659
|
user_data_header += f"ssh_pwauth: {ssh_pwauth}\n"
|
|
2574
2660
|
|
|
2661
|
+
output_targets = "/dev/ttyS0"
|
|
2662
|
+
if user_session:
|
|
2663
|
+
output_targets += " /dev/ttyS1"
|
|
2664
|
+
|
|
2575
2665
|
# Assemble final user-data
|
|
2576
2666
|
user_data = f"""{user_data_header}
|
|
2577
2667
|
# Make sure root partition + filesystem grows to fill the qcow2 disk size
|
|
@@ -2584,6 +2674,9 @@ resize_rootfs: true
|
|
|
2584
2674
|
# Update package cache and upgrade
|
|
2585
2675
|
package_update: true
|
|
2586
2676
|
package_upgrade: false
|
|
2677
|
+
|
|
2678
|
+
output:
|
|
2679
|
+
all: "| tee -a /var/log/cloud-init-output.log {output_targets}"
|
|
2587
2680
|
{bootcmd_block}
|
|
2588
2681
|
|
|
2589
2682
|
# Install packages moved to runcmd for better logging
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .validator import VMValidator
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
from typing import Dict, List, Optional
|
|
2
|
+
|
|
3
|
+
from rich.panel import Panel
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AppValidationMixin:
|
|
8
|
+
def validate_apps(self) -> Dict:
|
|
9
|
+
setup_in_progress = self._setup_in_progress() is True
|
|
10
|
+
packages = self.config.get("packages", [])
|
|
11
|
+
snap_packages = self.config.get("snap_packages", [])
|
|
12
|
+
copy_paths = self.config.get("copy_paths", None)
|
|
13
|
+
if not isinstance(copy_paths, dict) or not copy_paths:
|
|
14
|
+
copy_paths = self.config.get("app_data_paths", {})
|
|
15
|
+
vm_user = self.config.get("vm", {}).get("username", "ubuntu")
|
|
16
|
+
|
|
17
|
+
snap_app_specs = {
|
|
18
|
+
"pycharm-community": {
|
|
19
|
+
"process_patterns": ["pycharm-community", "pycharm", "jetbrains"],
|
|
20
|
+
"required_interfaces": [
|
|
21
|
+
"desktop",
|
|
22
|
+
"desktop-legacy",
|
|
23
|
+
"x11",
|
|
24
|
+
"wayland",
|
|
25
|
+
"home",
|
|
26
|
+
"network",
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
"chromium": {
|
|
30
|
+
"process_patterns": ["chromium", "chromium-browser"],
|
|
31
|
+
"required_interfaces": [
|
|
32
|
+
"desktop",
|
|
33
|
+
"desktop-legacy",
|
|
34
|
+
"x11",
|
|
35
|
+
"wayland",
|
|
36
|
+
"home",
|
|
37
|
+
"network",
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
"firefox": {
|
|
41
|
+
"process_patterns": ["firefox"],
|
|
42
|
+
"required_interfaces": [
|
|
43
|
+
"desktop",
|
|
44
|
+
"desktop-legacy",
|
|
45
|
+
"x11",
|
|
46
|
+
"wayland",
|
|
47
|
+
"home",
|
|
48
|
+
"network",
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
"code": {
|
|
52
|
+
"process_patterns": ["code"],
|
|
53
|
+
"required_interfaces": [
|
|
54
|
+
"desktop",
|
|
55
|
+
"desktop-legacy",
|
|
56
|
+
"x11",
|
|
57
|
+
"wayland",
|
|
58
|
+
"home",
|
|
59
|
+
"network",
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
expected = []
|
|
65
|
+
|
|
66
|
+
if "firefox" in packages:
|
|
67
|
+
expected.append("firefox")
|
|
68
|
+
|
|
69
|
+
for snap_pkg in snap_packages:
|
|
70
|
+
if snap_pkg in snap_app_specs:
|
|
71
|
+
expected.append(snap_pkg)
|
|
72
|
+
|
|
73
|
+
for _, guest_path in copy_paths.items():
|
|
74
|
+
if guest_path == "/home/ubuntu/.config/google-chrome":
|
|
75
|
+
expected.append("google-chrome")
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
expected = sorted(set(expected))
|
|
79
|
+
if not expected:
|
|
80
|
+
return self.results["apps"]
|
|
81
|
+
|
|
82
|
+
self.console.print("\n[bold]🧩 Validating Apps...[/]")
|
|
83
|
+
table = Table(title="App Validation", border_style="cyan")
|
|
84
|
+
table.add_column("App", style="bold")
|
|
85
|
+
table.add_column("Installed", justify="center")
|
|
86
|
+
table.add_column("Profile", justify="center")
|
|
87
|
+
table.add_column("Running", justify="center")
|
|
88
|
+
table.add_column("PID", justify="right", style="dim")
|
|
89
|
+
table.add_column("Note", style="dim")
|
|
90
|
+
|
|
91
|
+
def _pgrep_pattern(pattern: str) -> str:
|
|
92
|
+
if not pattern:
|
|
93
|
+
return pattern
|
|
94
|
+
return f"[{pattern[0]}]{pattern[1:]}"
|
|
95
|
+
|
|
96
|
+
def _check_any_process_running(patterns: List[str]) -> Optional[bool]:
|
|
97
|
+
for pattern in patterns:
|
|
98
|
+
p = _pgrep_pattern(pattern)
|
|
99
|
+
out = self._exec_in_vm(
|
|
100
|
+
f"pgrep -u {vm_user} -f '{p}' >/dev/null 2>&1 && echo yes || echo no",
|
|
101
|
+
timeout=10,
|
|
102
|
+
)
|
|
103
|
+
if out is None:
|
|
104
|
+
return None
|
|
105
|
+
if out == "yes":
|
|
106
|
+
return True
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
def _find_first_pid(patterns: List[str]) -> Optional[str]:
|
|
110
|
+
for pattern in patterns:
|
|
111
|
+
p = _pgrep_pattern(pattern)
|
|
112
|
+
out = self._exec_in_vm(
|
|
113
|
+
f"pgrep -u {vm_user} -f '{p}' 2>/dev/null | head -n 1 || true",
|
|
114
|
+
timeout=10,
|
|
115
|
+
)
|
|
116
|
+
if out is None:
|
|
117
|
+
return None
|
|
118
|
+
pid = out.strip()
|
|
119
|
+
if pid:
|
|
120
|
+
return pid
|
|
121
|
+
return ""
|
|
122
|
+
|
|
123
|
+
def _collect_app_logs(app_name: str) -> str:
|
|
124
|
+
chunks: List[str] = []
|
|
125
|
+
|
|
126
|
+
def add(cmd: str, title: str, timeout: int = 20):
|
|
127
|
+
out = self._exec_in_vm(cmd, timeout=timeout)
|
|
128
|
+
if out is None:
|
|
129
|
+
return
|
|
130
|
+
out = out.strip()
|
|
131
|
+
if not out:
|
|
132
|
+
return
|
|
133
|
+
chunks.append(f"{title}\n$ {cmd}\n{out}")
|
|
134
|
+
|
|
135
|
+
if app_name in snap_app_specs:
|
|
136
|
+
add(f"snap connections {app_name} 2>/dev/null | head -n 40", "Snap connections")
|
|
137
|
+
add(f"snap logs {app_name} -n 80 2>/dev/null | tail -n 60", "Snap logs")
|
|
138
|
+
|
|
139
|
+
if app_name == "pycharm-community":
|
|
140
|
+
add(
|
|
141
|
+
"tail -n 80 /home/ubuntu/snap/pycharm-community/common/.config/JetBrains/*/log/idea.log 2>/dev/null || true",
|
|
142
|
+
"idea.log",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if app_name == "google-chrome":
|
|
146
|
+
add(
|
|
147
|
+
"journalctl -n 200 --no-pager 2>/dev/null | grep -i chrome | tail -n 60 || true",
|
|
148
|
+
"Journal (chrome)",
|
|
149
|
+
)
|
|
150
|
+
if app_name == "firefox":
|
|
151
|
+
add(
|
|
152
|
+
"journalctl -n 200 --no-pager 2>/dev/null | grep -i firefox | tail -n 60 || true",
|
|
153
|
+
"Journal (firefox)",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return "\n\n".join(chunks)
|
|
157
|
+
|
|
158
|
+
def _snap_missing_interfaces(snap_name: str, required: List[str]) -> Optional[List[str]]:
|
|
159
|
+
out = self._exec_in_vm(
|
|
160
|
+
f"snap connections {snap_name} 2>/dev/null | awk 'NR>1{{print $1, $3}}'",
|
|
161
|
+
timeout=15,
|
|
162
|
+
)
|
|
163
|
+
if out is None:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
connected = set()
|
|
167
|
+
for line in out.splitlines():
|
|
168
|
+
parts = line.split()
|
|
169
|
+
if len(parts) < 2:
|
|
170
|
+
continue
|
|
171
|
+
iface, slot = parts[0], parts[1]
|
|
172
|
+
if slot != "-":
|
|
173
|
+
connected.add(iface)
|
|
174
|
+
|
|
175
|
+
missing = [i for i in required if i not in connected]
|
|
176
|
+
return missing
|
|
177
|
+
|
|
178
|
+
def _check_dir_nonempty(path: str) -> bool:
|
|
179
|
+
out = self._exec_in_vm(
|
|
180
|
+
f"test -d {path} && [ $(ls -A {path} 2>/dev/null | wc -l) -gt 0 ] && echo yes || echo no",
|
|
181
|
+
timeout=10,
|
|
182
|
+
)
|
|
183
|
+
return out == "yes"
|
|
184
|
+
|
|
185
|
+
for app in expected:
|
|
186
|
+
self.results["apps"]["total"] += 1
|
|
187
|
+
installed = False
|
|
188
|
+
profile_ok = False
|
|
189
|
+
running: Optional[bool] = None
|
|
190
|
+
pid: Optional[str] = None
|
|
191
|
+
note = ""
|
|
192
|
+
pending = False
|
|
193
|
+
|
|
194
|
+
if app == "firefox":
|
|
195
|
+
installed = (
|
|
196
|
+
self._exec_in_vm("command -v firefox >/dev/null 2>&1 && echo yes || echo no") == "yes"
|
|
197
|
+
)
|
|
198
|
+
if _check_dir_nonempty("/home/ubuntu/snap/firefox/common/.mozilla/firefox"):
|
|
199
|
+
profile_ok = True
|
|
200
|
+
elif _check_dir_nonempty("/home/ubuntu/.mozilla/firefox"):
|
|
201
|
+
profile_ok = True
|
|
202
|
+
|
|
203
|
+
if installed:
|
|
204
|
+
running = _check_any_process_running(["firefox"])
|
|
205
|
+
pid = _find_first_pid(["firefox"]) if running else ""
|
|
206
|
+
|
|
207
|
+
elif app in snap_app_specs:
|
|
208
|
+
installed = (
|
|
209
|
+
self._exec_in_vm(f"snap list {app} >/dev/null 2>&1 && echo yes || echo no") == "yes"
|
|
210
|
+
)
|
|
211
|
+
if app == "pycharm-community":
|
|
212
|
+
profile_ok = _check_dir_nonempty(
|
|
213
|
+
"/home/ubuntu/snap/pycharm-community/common/.config/JetBrains"
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
profile_ok = True
|
|
217
|
+
|
|
218
|
+
if installed:
|
|
219
|
+
patterns = snap_app_specs[app]["process_patterns"]
|
|
220
|
+
running = _check_any_process_running(patterns)
|
|
221
|
+
pid = _find_first_pid(patterns) if running else ""
|
|
222
|
+
if running is False:
|
|
223
|
+
missing_ifaces = _snap_missing_interfaces(
|
|
224
|
+
app,
|
|
225
|
+
snap_app_specs[app]["required_interfaces"],
|
|
226
|
+
)
|
|
227
|
+
if missing_ifaces:
|
|
228
|
+
note = f"missing interfaces: {', '.join(missing_ifaces)}"
|
|
229
|
+
elif missing_ifaces == []:
|
|
230
|
+
note = "not running"
|
|
231
|
+
else:
|
|
232
|
+
note = "interfaces unknown"
|
|
233
|
+
|
|
234
|
+
elif app == "google-chrome":
|
|
235
|
+
installed = (
|
|
236
|
+
self._exec_in_vm(
|
|
237
|
+
"(command -v google-chrome >/dev/null 2>&1 || command -v google-chrome-stable >/dev/null 2>&1) && echo yes || echo no"
|
|
238
|
+
)
|
|
239
|
+
== "yes"
|
|
240
|
+
)
|
|
241
|
+
profile_ok = _check_dir_nonempty("/home/ubuntu/.config/google-chrome")
|
|
242
|
+
|
|
243
|
+
if installed:
|
|
244
|
+
running = _check_any_process_running(["google-chrome", "google-chrome-stable"])
|
|
245
|
+
pid = (
|
|
246
|
+
_find_first_pid(["google-chrome", "google-chrome-stable"]) if running else ""
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if self.require_running_apps and installed and profile_ok and running is None:
|
|
250
|
+
note = note or "running unknown"
|
|
251
|
+
|
|
252
|
+
if setup_in_progress and not installed:
|
|
253
|
+
pending = True
|
|
254
|
+
note = note or "setup in progress"
|
|
255
|
+
elif setup_in_progress and not profile_ok:
|
|
256
|
+
pending = True
|
|
257
|
+
note = note or "profile import in progress"
|
|
258
|
+
|
|
259
|
+
running_icon = (
|
|
260
|
+
"[dim]—[/]"
|
|
261
|
+
if not installed
|
|
262
|
+
else (
|
|
263
|
+
"[green]✅[/]" if running is True else "[yellow]⚠️[/]" if running is False else "[dim]?[/]"
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
pid_value = "—" if not installed else ("?" if pid is None else (pid or "—"))
|
|
268
|
+
|
|
269
|
+
installed_icon = "[green]✅[/]" if installed else ("[yellow]⏳[/]" if pending else "[red]❌[/]")
|
|
270
|
+
profile_icon = "[green]✅[/]" if profile_ok else ("[yellow]⏳[/]" if pending else "[red]❌[/]")
|
|
271
|
+
|
|
272
|
+
table.add_row(app, installed_icon, profile_icon, running_icon, pid_value, note)
|
|
273
|
+
|
|
274
|
+
should_pass = installed and profile_ok
|
|
275
|
+
if self.require_running_apps and installed and profile_ok:
|
|
276
|
+
should_pass = running is True
|
|
277
|
+
|
|
278
|
+
if pending:
|
|
279
|
+
self.results["apps"]["skipped"] += 1
|
|
280
|
+
elif should_pass:
|
|
281
|
+
self.results["apps"]["passed"] += 1
|
|
282
|
+
else:
|
|
283
|
+
self.results["apps"]["failed"] += 1
|
|
284
|
+
|
|
285
|
+
self.results["apps"]["details"].append(
|
|
286
|
+
{
|
|
287
|
+
"app": app,
|
|
288
|
+
"installed": installed,
|
|
289
|
+
"profile": profile_ok,
|
|
290
|
+
"running": running,
|
|
291
|
+
"pid": pid,
|
|
292
|
+
"note": note,
|
|
293
|
+
"pending": pending,
|
|
294
|
+
}
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if installed and profile_ok and running in (False, None):
|
|
298
|
+
logs = _collect_app_logs(app)
|
|
299
|
+
if logs:
|
|
300
|
+
self.console.print(Panel(logs, title=f"Logs: {app}", border_style="yellow"))
|
|
301
|
+
|
|
302
|
+
self.console.print(table)
|
|
303
|
+
return self.results["apps"]
|