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.
- hostctl/AGENTS.md +292 -0
- hostctl/README.md +248 -0
- hostctl/__init__.py +194 -0
- hostctl/__main__.py +6 -0
- hostctl/_async.py +169 -0
- hostctl/_cli.py +232 -0
- hostctl/executor/__init__.py +82 -0
- hostctl/executor/_common.py +234 -0
- hostctl/executor/_qga.py +646 -0
- hostctl/executor/container.py +171 -0
- hostctl/executor/local.py +91 -0
- hostctl/executor/psrp.py +145 -0
- hostctl/executor/qemu.py +305 -0
- hostctl/executor/serial.py +312 -0
- hostctl/executor/ssh.py +226 -0
- hostctl/executor/winrm.py +255 -0
- hostctl/host/__init__.py +93 -0
- hostctl/host/_common.py +804 -0
- hostctl/host/_local.py +258 -0
- hostctl/host/_ssh.py +587 -0
- hostctl/host/_winrm.py +1002 -0
- hostctl/host/composite_path.py +567 -0
- hostctl/host/container.py +606 -0
- hostctl/host/container_path.py +664 -0
- hostctl/host/qemu.py +1078 -0
- hostctl/host/serial.py +401 -0
- hostctl/host/system.py +809 -0
- hostctl/process/__init__.py +49 -0
- hostctl/process/_common.py +113 -0
- hostctl/process/container.py +265 -0
- hostctl/process/psrp.py +208 -0
- hostctl/process/qemu_serial.py +247 -0
- hostctl/process/serial.py +257 -0
- hostctl/process/ssh.py +165 -0
- hostctl/provider/__init__.py +33 -0
- hostctl/provider/_common.py +452 -0
- hostctl/provider/transports.py +303 -0
- hostctl/py.typed +0 -0
- hostctl/serial/__init__.py +285 -0
- hostctl/shell/__init__.py +123 -0
- hostctl/shell/_common.py +616 -0
- hostctl/shell/cmd.py +167 -0
- hostctl/shell/fish.py +63 -0
- hostctl/shell/posix.py +83 -0
- hostctl/shell/powershell.py +120 -0
- hostctl/sync.py +245 -0
- hostctl-0.1.0.dist-info/METADATA +302 -0
- hostctl-0.1.0.dist-info/RECORD +51 -0
- hostctl-0.1.0.dist-info/WHEEL +4 -0
- hostctl-0.1.0.dist-info/entry_points.txt +2 -0
- hostctl-0.1.0.dist-info/licenses/LICENSE +21 -0
hostctl/executor/_qga.py
ADDED
|
@@ -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
|