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,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"]