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
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
import zlib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, Optional
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class VMValidatorCore:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
config: dict,
|
|
17
|
+
vm_name: str,
|
|
18
|
+
conn_uri: str,
|
|
19
|
+
console: Console = None,
|
|
20
|
+
require_running_apps: bool = False,
|
|
21
|
+
smoke_test: bool = False,
|
|
22
|
+
):
|
|
23
|
+
self.config = config
|
|
24
|
+
self.vm_name = vm_name
|
|
25
|
+
self.conn_uri = conn_uri
|
|
26
|
+
self.console = console or Console()
|
|
27
|
+
self.require_running_apps = require_running_apps
|
|
28
|
+
self.smoke_test = smoke_test
|
|
29
|
+
self.results = {
|
|
30
|
+
"mounts": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
31
|
+
"packages": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
32
|
+
"snap_packages": {
|
|
33
|
+
"passed": 0,
|
|
34
|
+
"failed": 0,
|
|
35
|
+
"skipped": 0,
|
|
36
|
+
"total": 0,
|
|
37
|
+
"details": [],
|
|
38
|
+
},
|
|
39
|
+
"services": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
40
|
+
"disk": {"usage_pct": 0, "avail": "0", "total": "0"},
|
|
41
|
+
"apps": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
42
|
+
"smoke": {"passed": 0, "failed": 0, "skipped": 0, "total": 0, "details": []},
|
|
43
|
+
"overall": "unknown",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
self._setup_in_progress_cache: Optional[bool] = None
|
|
47
|
+
self._exec_transport: str = "qga" # qga|ssh
|
|
48
|
+
|
|
49
|
+
def _get_ssh_key_path(self) -> Optional[Path]:
|
|
50
|
+
"""Return path to the SSH key generated for this VM (if present)."""
|
|
51
|
+
try:
|
|
52
|
+
if self.conn_uri.endswith("/session"):
|
|
53
|
+
images_dir = Path.home() / ".local/share/libvirt/images"
|
|
54
|
+
else:
|
|
55
|
+
images_dir = Path("/var/lib/libvirt/images")
|
|
56
|
+
key_path = images_dir / self.vm_name / "ssh_key"
|
|
57
|
+
return key_path if key_path.exists() else None
|
|
58
|
+
except Exception:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def _get_ssh_port(self) -> int:
|
|
62
|
+
"""Deterministic host-side SSH port for passt port forwarding."""
|
|
63
|
+
return 22000 + (zlib.crc32(self.vm_name.encode("utf-8")) % 1000)
|
|
64
|
+
|
|
65
|
+
def _ssh_exec(self, command: str, timeout: int = 10) -> Optional[str]:
|
|
66
|
+
if shutil.which("ssh") is None:
|
|
67
|
+
return None
|
|
68
|
+
key_path = self._get_ssh_key_path()
|
|
69
|
+
if key_path is None:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
ssh_port = self._get_ssh_port()
|
|
73
|
+
user = (self.config.get("vm") or {}).get("username") or "ubuntu"
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
result = subprocess.run(
|
|
77
|
+
[
|
|
78
|
+
"ssh",
|
|
79
|
+
"-i",
|
|
80
|
+
str(key_path),
|
|
81
|
+
"-p",
|
|
82
|
+
str(ssh_port),
|
|
83
|
+
"-o",
|
|
84
|
+
"StrictHostKeyChecking=no",
|
|
85
|
+
"-o",
|
|
86
|
+
"UserKnownHostsFile=/dev/null",
|
|
87
|
+
"-o",
|
|
88
|
+
"ConnectTimeout=5",
|
|
89
|
+
"-o",
|
|
90
|
+
"BatchMode=yes",
|
|
91
|
+
f"{user}@127.0.0.1",
|
|
92
|
+
command,
|
|
93
|
+
],
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
timeout=timeout,
|
|
97
|
+
)
|
|
98
|
+
if result.returncode != 0:
|
|
99
|
+
return None
|
|
100
|
+
return (result.stdout or "").strip()
|
|
101
|
+
except Exception:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def _exec_in_vm(self, command: str, timeout: int = 10) -> Optional[str]:
|
|
105
|
+
"""Execute command in VM using QEMU guest agent, with SSH fallback."""
|
|
106
|
+
if self._exec_transport == "ssh":
|
|
107
|
+
return self._ssh_exec(command, timeout=timeout)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
[
|
|
112
|
+
"virsh",
|
|
113
|
+
"--connect",
|
|
114
|
+
self.conn_uri,
|
|
115
|
+
"qemu-agent-command",
|
|
116
|
+
self.vm_name,
|
|
117
|
+
f'{{"execute":"guest-exec","arguments":{{"path":"/bin/sh","arg":["-c","{command}"],"capture-output":true}}}}',
|
|
118
|
+
],
|
|
119
|
+
capture_output=True,
|
|
120
|
+
text=True,
|
|
121
|
+
timeout=timeout,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if result.returncode != 0:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
response = json.loads(result.stdout)
|
|
128
|
+
if "return" not in response or "pid" not in response["return"]:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
pid = response["return"]["pid"]
|
|
132
|
+
|
|
133
|
+
deadline = time.time() + timeout
|
|
134
|
+
while time.time() < deadline:
|
|
135
|
+
status_result = subprocess.run(
|
|
136
|
+
[
|
|
137
|
+
"virsh",
|
|
138
|
+
"--connect",
|
|
139
|
+
self.conn_uri,
|
|
140
|
+
"qemu-agent-command",
|
|
141
|
+
self.vm_name,
|
|
142
|
+
f'{{"execute":"guest-exec-status","arguments":{{"pid":{pid}}}}}',
|
|
143
|
+
],
|
|
144
|
+
capture_output=True,
|
|
145
|
+
text=True,
|
|
146
|
+
timeout=5,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if status_result.returncode != 0:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
status_resp = json.loads(status_result.stdout)
|
|
153
|
+
if "return" not in status_resp:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
ret = status_resp["return"]
|
|
157
|
+
if ret.get("exited", False):
|
|
158
|
+
if "out-data" in ret:
|
|
159
|
+
return base64.b64decode(ret["out-data"]).decode().strip()
|
|
160
|
+
return ""
|
|
161
|
+
|
|
162
|
+
time.sleep(0.2)
|
|
163
|
+
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
except Exception:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def _setup_in_progress(self) -> Optional[bool]:
|
|
170
|
+
if self._setup_in_progress_cache is not None:
|
|
171
|
+
return self._setup_in_progress_cache
|
|
172
|
+
|
|
173
|
+
out = self._exec_in_vm(
|
|
174
|
+
"test -f /var/lib/cloud/instance/boot-finished && echo no || echo yes",
|
|
175
|
+
timeout=10,
|
|
176
|
+
)
|
|
177
|
+
if out is None:
|
|
178
|
+
self._setup_in_progress_cache = None
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
self._setup_in_progress_cache = out.strip() == "yes"
|
|
182
|
+
return self._setup_in_progress_cache
|
|
183
|
+
|
|
184
|
+
def _check_qga_ready(self) -> bool:
|
|
185
|
+
"""Check if QEMU guest agent is responding."""
|
|
186
|
+
try:
|
|
187
|
+
result = subprocess.run(
|
|
188
|
+
[
|
|
189
|
+
"virsh",
|
|
190
|
+
"--connect",
|
|
191
|
+
self.conn_uri,
|
|
192
|
+
"qemu-agent-command",
|
|
193
|
+
self.vm_name,
|
|
194
|
+
'{"execute":"guest-ping"}',
|
|
195
|
+
],
|
|
196
|
+
capture_output=True,
|
|
197
|
+
text=True,
|
|
198
|
+
timeout=5,
|
|
199
|
+
)
|
|
200
|
+
return result.returncode == 0
|
|
201
|
+
except Exception:
|
|
202
|
+
return False
|
|
@@ -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"]
|