hostctl 0.1.0__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.
Files changed (51) hide show
  1. hostctl/AGENTS.md +292 -0
  2. hostctl/README.md +248 -0
  3. hostctl/__init__.py +194 -0
  4. hostctl/__main__.py +6 -0
  5. hostctl/_async.py +169 -0
  6. hostctl/_cli.py +232 -0
  7. hostctl/executor/__init__.py +82 -0
  8. hostctl/executor/_common.py +234 -0
  9. hostctl/executor/_qga.py +646 -0
  10. hostctl/executor/container.py +171 -0
  11. hostctl/executor/local.py +91 -0
  12. hostctl/executor/psrp.py +145 -0
  13. hostctl/executor/qemu.py +305 -0
  14. hostctl/executor/serial.py +312 -0
  15. hostctl/executor/ssh.py +226 -0
  16. hostctl/executor/winrm.py +255 -0
  17. hostctl/host/__init__.py +93 -0
  18. hostctl/host/_common.py +804 -0
  19. hostctl/host/_local.py +258 -0
  20. hostctl/host/_ssh.py +587 -0
  21. hostctl/host/_winrm.py +1002 -0
  22. hostctl/host/composite_path.py +567 -0
  23. hostctl/host/container.py +606 -0
  24. hostctl/host/container_path.py +664 -0
  25. hostctl/host/qemu.py +1078 -0
  26. hostctl/host/serial.py +401 -0
  27. hostctl/host/system.py +809 -0
  28. hostctl/process/__init__.py +49 -0
  29. hostctl/process/_common.py +113 -0
  30. hostctl/process/container.py +265 -0
  31. hostctl/process/psrp.py +208 -0
  32. hostctl/process/qemu_serial.py +247 -0
  33. hostctl/process/serial.py +257 -0
  34. hostctl/process/ssh.py +165 -0
  35. hostctl/provider/__init__.py +33 -0
  36. hostctl/provider/_common.py +452 -0
  37. hostctl/provider/transports.py +303 -0
  38. hostctl/py.typed +0 -0
  39. hostctl/serial/__init__.py +285 -0
  40. hostctl/shell/__init__.py +123 -0
  41. hostctl/shell/_common.py +616 -0
  42. hostctl/shell/cmd.py +167 -0
  43. hostctl/shell/fish.py +63 -0
  44. hostctl/shell/posix.py +83 -0
  45. hostctl/shell/powershell.py +120 -0
  46. hostctl/sync.py +245 -0
  47. hostctl-0.1.0.dist-info/METADATA +302 -0
  48. hostctl-0.1.0.dist-info/RECORD +51 -0
  49. hostctl-0.1.0.dist-info/WHEEL +4 -0
  50. hostctl-0.1.0.dist-info/entry_points.txt +2 -0
  51. hostctl-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,646 @@
1
+ """QEMU Guest Agent transport contracts and normalized errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import itertools
7
+ import json
8
+ import math
9
+ import secrets
10
+ import socket
11
+ import subprocess
12
+ import threading
13
+ import time
14
+ import typing
15
+
16
+
17
+ class QgaError(Exception):
18
+ """Base class for QEMU Guest Agent failures."""
19
+
20
+
21
+ class QgaProtocolError(QgaError, ConnectionError):
22
+ """The guest agent returned malformed or inconsistent protocol data."""
23
+
24
+
25
+ class QgaDisconnectedError(QgaError, ConnectionError):
26
+ """The guest-agent transport disconnected before producing a reply."""
27
+
28
+
29
+ class QgaTimeoutError(QgaError, TimeoutError):
30
+ """A guest-agent request did not complete before its deadline."""
31
+
32
+
33
+ class QgaCommandError(QgaError):
34
+ """A structured QGA command error returned by the guest."""
35
+
36
+ def __init__(
37
+ self,
38
+ error_class: str,
39
+ description: str,
40
+ *,
41
+ data: typing.Optional[typing.Mapping[str, object]] = None,
42
+ ) -> None:
43
+ super().__init__(f"{error_class}: {description}")
44
+ self.error_class = error_class
45
+ self.description = description
46
+ self.data = dict(data or {})
47
+
48
+
49
+ class _QgaFramedSession:
50
+ """Shared QGA JSON-line framing and request lifecycle.
51
+
52
+ Concrete transports only provide raw byte-stream operations. Keeping the
53
+ parser here ensures partial reads, synchronization, correlation, and error
54
+ unwrapping behave identically for Unix sockets and SSH tunnels.
55
+ """
56
+
57
+ def __init__(self, *, timeout: float, max_reply_size: int) -> None:
58
+ if timeout <= 0:
59
+ raise ValueError("timeout must be greater than zero")
60
+ if max_reply_size <= 0:
61
+ raise ValueError("max_reply_size must be greater than zero")
62
+ self.timeout = float(timeout)
63
+ self.max_reply_size = int(max_reply_size)
64
+ self._buffer = bytearray()
65
+ self._ids = iter(range(1, 2**63))
66
+ self._session_lock = threading.Lock()
67
+ self._connected = False
68
+
69
+ def connect(self) -> None:
70
+ with self._session_lock:
71
+ if self._connected:
72
+ return
73
+ deadline = time.monotonic() + self.timeout
74
+ try:
75
+ self._connect_raw(deadline)
76
+ self._buffer.clear()
77
+ self._connected = True
78
+ self._synchronize(deadline)
79
+ except Exception:
80
+ self._disconnect_locked()
81
+ raise
82
+
83
+ def close(self) -> None:
84
+ with self._session_lock:
85
+ self._disconnect_locked()
86
+
87
+ def execute(
88
+ self,
89
+ request: typing.Mapping[str, object],
90
+ timeout: typing.Optional[float] = None,
91
+ ) -> object:
92
+ command = request.get("execute")
93
+ if not isinstance(command, str) or not command:
94
+ raise ValueError("QGA request requires a non-empty 'execute' string")
95
+ request_timeout = self.timeout if timeout is None else float(timeout)
96
+ if request_timeout <= 0:
97
+ raise ValueError("timeout must be greater than zero")
98
+ deadline = time.monotonic() + request_timeout
99
+ with self._session_lock:
100
+ try:
101
+ if not self._connected:
102
+ self._connect_raw(deadline)
103
+ self._buffer.clear()
104
+ self._connected = True
105
+ self._synchronize(deadline)
106
+ request_id = next(self._ids)
107
+ payload = dict(request)
108
+ payload["id"] = request_id
109
+ self._send(payload, deadline)
110
+ return self._unwrap(self._correlated_reply(request_id, deadline))
111
+ except Exception:
112
+ self._disconnect_locked()
113
+ raise
114
+
115
+ def _disconnect_locked(self) -> None:
116
+ self._connected = False
117
+ self._buffer.clear()
118
+ try:
119
+ self._close_raw()
120
+ except Exception:
121
+ pass
122
+
123
+ def _synchronize(self, deadline: float) -> None:
124
+ token = secrets.randbits(52)
125
+ request_id = f"sync-{next(self._ids)}"
126
+ self._send(
127
+ {
128
+ "execute": "guest-sync-delimited",
129
+ "arguments": {"id": token},
130
+ "id": request_id,
131
+ },
132
+ deadline,
133
+ prefix=b"\xff",
134
+ )
135
+ self._discard_until_delimiter(deadline)
136
+ returned = self._unwrap(self._correlated_reply(request_id, deadline))
137
+ if returned != token:
138
+ raise QgaProtocolError("QGA synchronization token did not match")
139
+
140
+ def _send(
141
+ self,
142
+ request: typing.Mapping[str, object],
143
+ deadline: float,
144
+ *,
145
+ prefix: bytes = b"",
146
+ ) -> None:
147
+ try:
148
+ encoded = json.dumps(
149
+ request, ensure_ascii=False, separators=(",", ":")
150
+ ).encode("utf-8")
151
+ except (TypeError, ValueError) as exc:
152
+ raise ValueError("QGA request is not JSON serializable") from exc
153
+ self._send_raw(prefix + encoded + b"\n", deadline)
154
+
155
+ def _discard_until_delimiter(self, deadline: float) -> None:
156
+ while True:
157
+ position = self._buffer.find(b"\xff")
158
+ if position >= 0:
159
+ del self._buffer[: position + 1]
160
+ return
161
+ if len(self._buffer) > self.max_reply_size:
162
+ raise QgaProtocolError("QGA synchronization data exceeded size limit")
163
+ self._receive(deadline)
164
+
165
+ def _correlated_reply(
166
+ self, request_id: object, deadline: float
167
+ ) -> typing.Mapping[str, object]:
168
+ while True:
169
+ reply = self._read_reply(deadline)
170
+ if reply.get("id") == request_id:
171
+ return reply
172
+ # Some QGA versions omit id on parse errors. Treat that as the
173
+ # response for the oldest outstanding request instead of spinning.
174
+ if "id" not in reply and "error" in reply:
175
+ return reply
176
+
177
+ def _read_reply(self, deadline: float) -> typing.Mapping[str, object]:
178
+ while True:
179
+ newline = self._buffer.find(b"\n")
180
+ if newline < 0:
181
+ if len(self._buffer) > self.max_reply_size:
182
+ raise QgaProtocolError("QGA reply exceeded size limit")
183
+ self._receive(deadline)
184
+ continue
185
+ frame = bytes(self._buffer[:newline]).lstrip(b"\xff")
186
+ del self._buffer[: newline + 1]
187
+ if not frame:
188
+ continue
189
+ if len(frame) > self.max_reply_size:
190
+ raise QgaProtocolError("QGA reply exceeded size limit")
191
+ try:
192
+ reply = json.loads(frame.decode("utf-8"))
193
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
194
+ raise QgaProtocolError("QGA returned malformed JSON") from exc
195
+ if not isinstance(reply, dict):
196
+ raise QgaProtocolError("QGA reply must be a JSON object")
197
+ return typing.cast(typing.Mapping[str, object], reply)
198
+
199
+ def _receive(self, deadline: float) -> None:
200
+ chunk = self._recv_raw(min(65536, self.max_reply_size + 1), deadline)
201
+ if not isinstance(chunk, bytes):
202
+ raise QgaProtocolError("QGA transport returned non-byte data")
203
+ if not chunk:
204
+ raise QgaDisconnectedError("QGA disconnected before replying")
205
+ self._buffer.extend(chunk)
206
+
207
+ @staticmethod
208
+ def _unwrap(reply: typing.Mapping[str, object]) -> object:
209
+ if "error" in reply:
210
+ error = reply["error"]
211
+ if not isinstance(error, dict):
212
+ raise QgaProtocolError("QGA error reply must contain an object")
213
+ error_class = error.get("class", "GenericError")
214
+ description = error.get("desc", "QGA command failed")
215
+ if not isinstance(error_class, str) or not isinstance(description, str):
216
+ raise QgaProtocolError("QGA error fields must be strings")
217
+ raise QgaCommandError(
218
+ error_class,
219
+ description,
220
+ data={k: v for k, v in error.items() if k not in {"class", "desc"}},
221
+ )
222
+ if "return" not in reply:
223
+ raise QgaProtocolError("QGA reply has neither 'return' nor 'error'")
224
+ return reply["return"]
225
+
226
+ @staticmethod
227
+ def _remaining(deadline: float) -> float:
228
+ remaining = deadline - time.monotonic()
229
+ if remaining <= 0:
230
+ raise QgaTimeoutError("QGA request deadline expired")
231
+ return remaining
232
+
233
+ def _connect_raw(self, deadline: float) -> None:
234
+ raise NotImplementedError
235
+
236
+ def _send_raw(self, data: bytes, deadline: float) -> None:
237
+ raise NotImplementedError
238
+
239
+ def _recv_raw(self, size: int, deadline: float) -> bytes:
240
+ raise NotImplementedError
241
+
242
+ def _close_raw(self) -> None:
243
+ raise NotImplementedError
244
+
245
+
246
+ @typing.runtime_checkable
247
+ class GuestAgentTransport(typing.Protocol):
248
+ """Synchronous transport for one correlated QGA request."""
249
+
250
+ def execute(
251
+ self,
252
+ request: typing.Mapping[str, object],
253
+ timeout: typing.Optional[float] = None,
254
+ ) -> object:
255
+ """Return the unwrapped QGA ``return`` value."""
256
+
257
+ def close(self) -> None:
258
+ """Release transport resources."""
259
+
260
+
261
+ def normalize_libvirt_error(error: BaseException) -> BaseException:
262
+ """Normalize common libvirt/QGA failures without importing libvirt eagerly."""
263
+ if not isinstance(error, Exception):
264
+ return error
265
+ message = str(error)
266
+ folded = message.casefold()
267
+ if any(value in folded for value in ("permission denied", "access denied")):
268
+ return PermissionError(message)
269
+ if any(
270
+ value in folded
271
+ for value in ("domain not found", "no domain with matching name")
272
+ ):
273
+ return FileNotFoundError(message)
274
+ if "timed out" in folded or "timeout" in folded:
275
+ return QgaTimeoutError(message)
276
+ return ConnectionError(message)
277
+
278
+
279
+ class LibvirtGuestAgentTransport:
280
+ """Issue QGA requests through ``virDomainQemuAgentCommand``."""
281
+
282
+ def __init__(
283
+ self,
284
+ domain: str,
285
+ *,
286
+ connection_uri: typing.Optional[str] = None,
287
+ timeout: float = 10.0,
288
+ connect_factory: typing.Optional[
289
+ typing.Callable[[typing.Optional[str]], object]
290
+ ] = None,
291
+ command_factory: typing.Optional[
292
+ typing.Callable[[object, str, int, int], str]
293
+ ] = None,
294
+ ) -> None:
295
+ if not domain:
296
+ raise ValueError("libvirt domain must not be empty")
297
+ if timeout <= 0:
298
+ raise ValueError("timeout must be greater than zero")
299
+ self.domain_name = domain
300
+ self.connection_uri = connection_uri
301
+ self.timeout = float(timeout)
302
+ self._connect_factory = connect_factory
303
+ self._command_factory = command_factory
304
+ self._connection: typing.Optional[object] = None
305
+ self._domain: typing.Optional[object] = None
306
+ self._ids = itertools.count(1)
307
+
308
+ def __enter__(self) -> LibvirtGuestAgentTransport:
309
+ self.connect()
310
+ return self
311
+
312
+ def __exit__(
313
+ self,
314
+ exc_type: typing.Optional[typing.Type[BaseException]],
315
+ exc_value: typing.Optional[BaseException],
316
+ traceback: object,
317
+ ) -> bool:
318
+ self.close()
319
+ return False
320
+
321
+ def connect(self) -> None:
322
+ if self._domain is not None:
323
+ return
324
+ connect_factory = self._connect_factory
325
+ command_factory = self._command_factory
326
+ if connect_factory is None or command_factory is None:
327
+ try:
328
+ import libvirt
329
+ import libvirt_qemu
330
+ except ImportError as exc:
331
+ raise ImportError(
332
+ "libvirt QGA support requires the 'qemu-libvirt' extra"
333
+ ) from exc
334
+ connect_factory = connect_factory or libvirt.open
335
+ command_factory = command_factory or libvirt_qemu.qemuAgentCommand
336
+ connection = None
337
+ try:
338
+ connection = connect_factory(self.connection_uri)
339
+ if connection is None:
340
+ raise ConnectionError("libvirt returned no connection")
341
+ domain = connection.lookupByName(self.domain_name)
342
+ if not domain.isActive():
343
+ close = getattr(connection, "close", None)
344
+ if close is not None:
345
+ close()
346
+ raise ConnectionError(
347
+ f"libvirt domain {self.domain_name!r} is not active"
348
+ )
349
+ except (ConnectionError, FileNotFoundError, PermissionError):
350
+ if connection is not None:
351
+ try:
352
+ close = getattr(connection, "close", None)
353
+ if close is not None:
354
+ close()
355
+ except Exception:
356
+ pass
357
+ raise
358
+ except Exception as exc:
359
+ try:
360
+ if connection is not None:
361
+ close = getattr(connection, "close", None)
362
+ if close is not None:
363
+ close()
364
+ except Exception:
365
+ pass
366
+ normalized = normalize_libvirt_error(exc)
367
+ if normalized is exc:
368
+ raise
369
+ raise normalized from exc
370
+ self._connect_factory = connect_factory
371
+ self._command_factory = command_factory
372
+ self._connection = connection
373
+ self._domain = domain
374
+
375
+ def close(self) -> None:
376
+ connection, self._connection = self._connection, None
377
+ self._domain = None
378
+ if connection is not None:
379
+ close = getattr(connection, "close", None)
380
+ if close is not None:
381
+ close()
382
+
383
+ def execute(
384
+ self,
385
+ request: typing.Mapping[str, object],
386
+ timeout: typing.Optional[float] = None,
387
+ ) -> object:
388
+ command = request.get("execute")
389
+ if not isinstance(command, str) or not command:
390
+ raise ValueError("QGA request requires a non-empty 'execute' string")
391
+ request_timeout = self.timeout if timeout is None else float(timeout)
392
+ if request_timeout <= 0:
393
+ raise ValueError("timeout must be greater than zero")
394
+ self.connect()
395
+ request_id = next(self._ids)
396
+ payload = dict(request)
397
+ payload["id"] = request_id
398
+ assert self._domain is not None
399
+ assert self._command_factory is not None
400
+ try:
401
+ raw = self._command_factory(
402
+ self._domain,
403
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
404
+ max(1, math.ceil(request_timeout)),
405
+ 0,
406
+ )
407
+ except Exception as exc:
408
+ normalized = normalize_libvirt_error(exc)
409
+ if normalized is exc:
410
+ raise
411
+ raise normalized from exc
412
+ try:
413
+ reply = json.loads(raw)
414
+ except (TypeError, UnicodeDecodeError, json.JSONDecodeError) as exc:
415
+ raise QgaProtocolError("libvirt returned malformed QGA JSON") from exc
416
+ if not isinstance(reply, dict):
417
+ raise QgaProtocolError("QGA reply must be a JSON object")
418
+ if reply.get("id") not in (None, request_id):
419
+ raise QgaProtocolError("libvirt returned a mismatched QGA reply")
420
+ return _QgaFramedSession._unwrap(reply)
421
+
422
+
423
+ class UnixSocketGuestAgentTransport(_QgaFramedSession):
424
+ """Persistent QGA connection over a Unix-domain socket."""
425
+
426
+ def __init__(
427
+ self,
428
+ path: str,
429
+ *,
430
+ timeout: float = 10.0,
431
+ max_reply_size: int = 8 * 1024 * 1024,
432
+ socket_factory: typing.Callable[..., socket.socket] = socket.socket,
433
+ ) -> None:
434
+ if not path:
435
+ raise ValueError("QGA socket path must not be empty")
436
+ self.path = path
437
+ self._socket_factory = socket_factory
438
+ self._uses_system_socket = socket_factory is socket.socket
439
+ self._socket: typing.Optional[socket.socket] = None
440
+ super().__init__(timeout=timeout, max_reply_size=max_reply_size)
441
+
442
+ def __enter__(self) -> UnixSocketGuestAgentTransport:
443
+ self.connect()
444
+ return self
445
+
446
+ def __exit__(self, exc_type, exc_value, traceback) -> bool:
447
+ self.close()
448
+ return False
449
+
450
+ def _connect_raw(self, deadline: float) -> None:
451
+ if not hasattr(socket, "AF_UNIX") and self._uses_system_socket:
452
+ raise NotImplementedError(
453
+ "direct QGA sockets require Unix-domain socket support"
454
+ )
455
+ family = getattr(socket, "AF_UNIX", 1)
456
+ connection = self._socket_factory(family, socket.SOCK_STREAM)
457
+ self._socket = connection
458
+ try:
459
+ connection.settimeout(self._remaining(deadline))
460
+ connection.connect(self.path)
461
+ except Exception:
462
+ try:
463
+ connection.close()
464
+ finally:
465
+ self._socket = None
466
+ raise
467
+
468
+ def _send_raw(self, data: bytes, deadline: float) -> None:
469
+ connection = self._require_socket()
470
+ try:
471
+ connection.settimeout(self._remaining(deadline))
472
+ connection.sendall(data)
473
+ except socket.timeout as exc:
474
+ raise QgaTimeoutError("timed out sending a QGA request") from exc
475
+ except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError) as exc:
476
+ raise QgaDisconnectedError("QGA disconnected while sending") from exc
477
+
478
+ def _recv_raw(self, size: int, deadline: float) -> bytes:
479
+ connection = self._require_socket()
480
+ try:
481
+ connection.settimeout(self._remaining(deadline))
482
+ return connection.recv(size)
483
+ except socket.timeout as exc:
484
+ raise QgaTimeoutError("timed out waiting for a QGA reply") from exc
485
+ except (ConnectionResetError, ConnectionAbortedError) as exc:
486
+ raise QgaDisconnectedError("QGA disconnected while receiving") from exc
487
+
488
+ def _close_raw(self) -> None:
489
+ connection, self._socket = self._socket, None
490
+ if connection is not None:
491
+ try:
492
+ connection.close()
493
+ except OSError:
494
+ pass
495
+
496
+ def _require_socket(self) -> socket.socket:
497
+ if self._socket is None:
498
+ raise QgaDisconnectedError("QGA transport is not connected")
499
+ return self._socket
500
+
501
+
502
+ class _Reader(typing.Protocol):
503
+ def read(self, size: int) -> typing.Awaitable[bytes]: ...
504
+
505
+
506
+ class _Writer(typing.Protocol):
507
+ def write(self, value: bytes) -> None: ...
508
+
509
+ def drain(self) -> typing.Awaitable[None]: ...
510
+
511
+ def close(self) -> None: ...
512
+
513
+
514
+ class _SshConnection(typing.Protocol):
515
+ def open_unix_connection(
516
+ self, path: str, *, encoding: typing.Optional[str] = None
517
+ ) -> typing.Awaitable[typing.Tuple[_Reader, _Writer]]: ...
518
+
519
+
520
+ async def _wait(awaitable: typing.Awaitable[object], timeout: float) -> object:
521
+ return await asyncio.wait_for(awaitable, timeout)
522
+
523
+
524
+ async def _close_writer(writer: _Writer) -> None:
525
+ writer.close()
526
+ wait_closed = getattr(writer, "wait_closed", None)
527
+ if wait_closed is not None:
528
+ result = wait_closed()
529
+ if hasattr(result, "__await__"):
530
+ await result
531
+
532
+
533
+ class SshUnixGuestAgentTransport(_QgaFramedSession):
534
+ """Persistent QGA stream opened on a remote Unix socket over SSH."""
535
+
536
+ def __init__(
537
+ self,
538
+ path: str,
539
+ connection: typing.Callable[[], _SshConnection],
540
+ *,
541
+ timeout: float = 10.0,
542
+ max_reply_size: int = 8 * 1024 * 1024,
543
+ ) -> None:
544
+ if not path:
545
+ raise ValueError("remote QGA socket path must not be empty")
546
+ self.path = path
547
+ self._connection = connection
548
+ self._reader: typing.Optional[_Reader] = None
549
+ self._writer: typing.Optional[_Writer] = None
550
+ super().__init__(timeout=timeout, max_reply_size=max_reply_size)
551
+
552
+ def __enter__(self) -> SshUnixGuestAgentTransport:
553
+ self.connect()
554
+ return self
555
+
556
+ def __exit__(self, exc_type, exc_value, traceback) -> bool:
557
+ self.close()
558
+ return False
559
+
560
+ def _connect_raw(self, deadline: float) -> None:
561
+ from .. import _async
562
+
563
+ try:
564
+ opened = _async.async_to_sync(
565
+ _wait(
566
+ self._connection().open_unix_connection(self.path, encoding=None),
567
+ self._remaining(deadline),
568
+ )
569
+ )
570
+ self._reader, self._writer = typing.cast(
571
+ typing.Tuple[_Reader, _Writer], opened
572
+ )
573
+ except Exception as exc:
574
+ normalized = self._normalize_ssh_error(exc, "open QGA stream")
575
+ if normalized is exc:
576
+ raise
577
+ raise normalized from exc
578
+
579
+ def _send_raw(self, data: bytes, deadline: float) -> None:
580
+ from .. import _async
581
+
582
+ writer = self._require_writer()
583
+
584
+ async def send() -> None:
585
+ writer.write(data)
586
+ await asyncio.wait_for(writer.drain(), self._remaining(deadline))
587
+
588
+ try:
589
+ _async.async_to_sync(send())
590
+ except (asyncio.TimeoutError, TimeoutError) as exc:
591
+ raise QgaTimeoutError("timed out sending a QGA request") from exc
592
+ except Exception as exc:
593
+ normalized = self._normalize_ssh_error(exc, "send QGA request")
594
+ if normalized is exc:
595
+ raise
596
+ raise normalized from exc
597
+
598
+ def _recv_raw(self, size: int, deadline: float) -> bytes:
599
+ from .. import _async
600
+
601
+ try:
602
+ chunk = _async.async_to_sync(
603
+ _wait(self._require_reader().read(size), self._remaining(deadline))
604
+ )
605
+ except (asyncio.TimeoutError, TimeoutError) as exc:
606
+ raise QgaTimeoutError("timed out waiting for a QGA reply") from exc
607
+ except Exception as exc:
608
+ normalized = self._normalize_ssh_error(exc, "receive QGA reply")
609
+ if normalized is exc:
610
+ raise
611
+ raise normalized from exc
612
+ if not isinstance(chunk, bytes):
613
+ raise TypeError("SSH QGA stream returned non-byte data")
614
+ return chunk
615
+
616
+ def _close_raw(self) -> None:
617
+ from .. import _async
618
+
619
+ writer, self._writer = self._writer, None
620
+ self._reader = None
621
+ if writer is not None:
622
+ try:
623
+ _async.async_to_sync(_close_writer(writer))
624
+ except Exception:
625
+ pass
626
+
627
+ def _require_reader(self) -> _Reader:
628
+ if self._reader is None:
629
+ raise QgaDisconnectedError("QGA transport is not connected")
630
+ return self._reader
631
+
632
+ def _require_writer(self) -> _Writer:
633
+ if self._writer is None:
634
+ raise QgaDisconnectedError("QGA transport is not connected")
635
+ return self._writer
636
+
637
+ @staticmethod
638
+ def _normalize_ssh_error(exc: Exception, command: str) -> Exception:
639
+ from .. import _async
640
+
641
+ normalized = _async.normalize_asyncssh_error(exc, command=command)
642
+ if isinstance(normalized, subprocess.TimeoutExpired):
643
+ return QgaTimeoutError(str(normalized))
644
+ if isinstance(normalized, ConnectionError):
645
+ return QgaDisconnectedError(str(normalized))
646
+ return normalized