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 CHANGED
@@ -949,11 +949,12 @@ def interactive_mode():
949
949
  required_keys = [
950
950
  "libvirt_connected",
951
951
  "kvm_available",
952
- "default_network",
953
952
  "images_dir_writable",
954
953
  "genisoimage_installed",
955
954
  "qemu_img_installed",
956
955
  ]
956
+ if checks.get("default_network_required", True):
957
+ required_keys.append("default_network")
957
958
  if getattr(config, "gui", False):
958
959
  required_keys.append("virt_viewer_installed")
959
960
 
@@ -1915,7 +1916,10 @@ def cmd_test(args):
1915
1916
 
1916
1917
  if not qga_ready:
1917
1918
  console.print("[yellow]⚠️ QEMU Guest Agent still not connected[/]")
1918
-
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
+
1919
1923
  # Check cloud-init status immediately if QGA is ready
1920
1924
  if qga_ready:
1921
1925
  console.print("[dim] Checking cloud-init status via QGA...[/]")
@@ -2645,11 +2649,12 @@ def create_vm_from_config(config, start=False, user_session=False, replace=False
2645
2649
  required_keys = [
2646
2650
  "libvirt_connected",
2647
2651
  "kvm_available",
2648
- "default_network",
2649
2652
  "images_dir_writable",
2650
2653
  "genisoimage_installed",
2651
2654
  "qemu_img_installed",
2652
2655
  ]
2656
+ if checks.get("default_network_required", True):
2657
+ required_keys.append("default_network")
2653
2658
  if getattr(vm_config, "gui", False):
2654
2659
  required_keys.append("virt_viewer_installed")
2655
2660
 
clonebox/cloner.py CHANGED
@@ -289,6 +289,17 @@ class SelectiveVMCloner:
289
289
  """Check if libvirt default network is active."""
290
290
  return self._default_network_state() == "active"
291
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
+
292
303
  def resolve_network_mode(self, config: VMConfig) -> str:
293
304
  """Resolve network mode based on config and session type."""
294
305
  mode = (config.network_mode or "auto").lower()
@@ -323,8 +334,11 @@ class SelectiveVMCloner:
323
334
  "virt_viewer_installed": False,
324
335
  "qemu_img_installed": False,
325
336
  "passt_installed": shutil.which("passt") is not None,
337
+ "passt_available": False,
326
338
  }
327
339
 
340
+ checks["passt_available"] = checks["passt_installed"] and self._passt_supported()
341
+
328
342
  # Check for genisoimage
329
343
  checks["genisoimage_installed"] = shutil.which("genisoimage") is not None
330
344
 
@@ -543,6 +557,36 @@ class SelectiveVMCloner:
543
557
  f"If the VM already exists, try: clonebox clone . --name {config.name} --replace\n"
544
558
  ) from e
545
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
+
546
590
  # Start if autostart requested
547
591
  if getattr(config, "autostart", False):
548
592
  self.start_vm(config.name, open_viewer=True)
@@ -1378,7 +1422,7 @@ fi
1378
1422
  iface = ET.SubElement(devices, "interface", type="user")
1379
1423
  ET.SubElement(iface, "model", type="virtio")
1380
1424
 
1381
- if shutil.which("passt"):
1425
+ if shutil.which("passt") and self._passt_supported():
1382
1426
  ET.SubElement(iface, "backend", type="passt")
1383
1427
 
1384
1428
  ssh_port = 22000 + (zlib.crc32(config.name.encode("utf-8")) % 1000)
@@ -1402,6 +1446,13 @@ fi
1402
1446
  serial = ET.SubElement(devices, "serial", type="pty")
1403
1447
  ET.SubElement(serial, "target", port="0")
1404
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
+
1405
1456
  console_elem = ET.SubElement(devices, "console", type="pty")
1406
1457
  ET.SubElement(console_elem, "target", type="serial", port="0")
1407
1458
 
@@ -1652,6 +1703,7 @@ fi
1652
1703
  runcmd_lines.append(" - systemctl enable --now ssh || systemctl enable --now sshd || echo ' → ❌ Failed to enable ssh'")
1653
1704
  runcmd_lines.append(" - echo ' → [3/3] Enabling snapd'")
1654
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")
1655
1707
  runcmd_lines.append(" - echo ' → Waiting for snap system seed...'")
1656
1708
  runcmd_lines.append(" - timeout 300 snap wait system seed.loaded || true")
1657
1709
  runcmd_lines.append(" - echo ' → ✓ [3/10] Core services enabled'")
@@ -2573,7 +2625,12 @@ if __name__ == "__main__":
2573
2625
  runcmd_yaml = "\n".join(runcmd_lines) if runcmd_lines else ""
2574
2626
 
2575
2627
  # Build bootcmd combining mount commands and extra security bootcmds
2576
- bootcmd_lines = list(bootcmd_extra) if bootcmd_extra else []
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))
2577
2634
 
2578
2635
  bootcmd_block = ""
2579
2636
  if bootcmd_lines:
@@ -2601,6 +2658,10 @@ users:
2601
2658
 
2602
2659
  user_data_header += f"ssh_pwauth: {ssh_pwauth}\n"
2603
2660
 
2661
+ output_targets = "/dev/ttyS0"
2662
+ if user_session:
2663
+ output_targets += " /dev/ttyS1"
2664
+
2604
2665
  # Assemble final user-data
2605
2666
  user_data = f"""{user_data_header}
2606
2667
  # Make sure root partition + filesystem grows to fill the qcow2 disk size
@@ -2613,6 +2674,9 @@ resize_rootfs: true
2613
2674
  # Update package cache and upgrade
2614
2675
  package_update: true
2615
2676
  package_upgrade: false
2677
+
2678
+ output:
2679
+ all: "| tee -a /var/log/cloud-init-output.log {output_targets}"
2616
2680
  {bootcmd_block}
2617
2681
 
2618
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"]
@@ -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