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
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Explicit raw QEMU/libvirt console stream sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
import types
|
|
9
|
+
import typing
|
|
10
|
+
|
|
11
|
+
from ._common import IncrementalTextDecoder, Process, ProcessData, raise_normalized
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class QemuConsoleStream(typing.Protocol):
|
|
15
|
+
"""Minimal libvirt-stream-like console channel."""
|
|
16
|
+
|
|
17
|
+
def send(self, data: bytes) -> int: ...
|
|
18
|
+
|
|
19
|
+
def recv(self, size: int) -> bytes: ...
|
|
20
|
+
|
|
21
|
+
def finish(self) -> object: ...
|
|
22
|
+
|
|
23
|
+
def abort(self) -> object: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
ConsoleStreamFactory = typing.Callable[[], QemuConsoleStream]
|
|
27
|
+
ConsoleResize = typing.Callable[[int, int, int, int], object]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normalize_qemu_console_error(exc: Exception) -> Exception:
|
|
31
|
+
"""Normalize libvirt-like stream failures without importing libvirt."""
|
|
32
|
+
if isinstance(exc, (ConnectionError, PermissionError, TimeoutError)):
|
|
33
|
+
return exc
|
|
34
|
+
if isinstance(exc, OSError):
|
|
35
|
+
if exc.errno in (errno.EACCES, errno.EPERM):
|
|
36
|
+
return PermissionError(exc.errno, exc.strerror, exc.filename)
|
|
37
|
+
if exc.errno in (errno.ETIMEDOUT,):
|
|
38
|
+
return TimeoutError(str(exc))
|
|
39
|
+
return ConnectionError(str(exc))
|
|
40
|
+
message = str(exc)
|
|
41
|
+
folded = message.casefold()
|
|
42
|
+
if "permission denied" in folded or "access denied" in folded:
|
|
43
|
+
return PermissionError(message)
|
|
44
|
+
if "timed out" in folded or "timeout" in folded:
|
|
45
|
+
return TimeoutError(message)
|
|
46
|
+
if type(exc).__name__ in ("libvirtError", "StreamError"):
|
|
47
|
+
return ConnectionError(message)
|
|
48
|
+
return exc
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class QemuSerialConsole:
|
|
52
|
+
"""Grant one exclusive raw process lease over a console stream."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
stream_factory: typing.Optional[ConsoleStreamFactory] = None,
|
|
58
|
+
stream: typing.Optional[QemuConsoleStream] = None,
|
|
59
|
+
owns_stream: bool = False,
|
|
60
|
+
resize: typing.Optional[ConsoleResize] = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
if (stream_factory is None) == (stream is None):
|
|
63
|
+
raise ValueError("provide exactly one of stream_factory or stream")
|
|
64
|
+
self._factory = stream_factory
|
|
65
|
+
self._stream = stream
|
|
66
|
+
self._owns_stream = owns_stream if stream is not None else True
|
|
67
|
+
self._resize = resize
|
|
68
|
+
self._lease = threading.Lock()
|
|
69
|
+
self._io_lock = threading.RLock()
|
|
70
|
+
|
|
71
|
+
def open(self) -> QemuSerialProcess:
|
|
72
|
+
if not self._lease.acquire(blocking=False):
|
|
73
|
+
raise RuntimeError("QEMU console already has an active process")
|
|
74
|
+
try:
|
|
75
|
+
stream = self._stream
|
|
76
|
+
if stream is None:
|
|
77
|
+
assert self._factory is not None
|
|
78
|
+
stream = self._factory()
|
|
79
|
+
self._stream = stream
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
self._lease.release()
|
|
82
|
+
normalized = normalize_qemu_console_error(exc)
|
|
83
|
+
if normalized is exc:
|
|
84
|
+
raise
|
|
85
|
+
raise normalized from exc
|
|
86
|
+
return QemuSerialProcess(
|
|
87
|
+
stream,
|
|
88
|
+
io_lock=self._io_lock,
|
|
89
|
+
release=self._release,
|
|
90
|
+
close_stream=self._owns_stream,
|
|
91
|
+
resize=self._resize,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _release(self) -> None:
|
|
95
|
+
if self._owns_stream:
|
|
96
|
+
self._stream = None
|
|
97
|
+
self._lease.release()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class QemuSerialProcess(Process):
|
|
101
|
+
"""Raw merged byte I/O over one libvirt console/channel stream."""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
stream: QemuConsoleStream,
|
|
106
|
+
*,
|
|
107
|
+
io_lock: typing.Optional[threading.RLock] = None,
|
|
108
|
+
release: typing.Callable[[], None],
|
|
109
|
+
close_stream: bool = True,
|
|
110
|
+
resize: typing.Optional[ConsoleResize] = None,
|
|
111
|
+
encoding: typing.Optional[str] = None,
|
|
112
|
+
errors: typing.Optional[str] = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
self._stream = stream
|
|
115
|
+
self._io_lock = io_lock or threading.RLock()
|
|
116
|
+
self._release = release
|
|
117
|
+
self._close_stream = close_stream
|
|
118
|
+
self._resize = resize
|
|
119
|
+
self._encoding = encoding
|
|
120
|
+
self._errors = errors or "strict"
|
|
121
|
+
self._closed = threading.Event()
|
|
122
|
+
self._decoder = (
|
|
123
|
+
IncrementalTextDecoder(encoding, self._errors)
|
|
124
|
+
if encoding is not None
|
|
125
|
+
else None
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def returncode(self) -> typing.Optional[int]:
|
|
130
|
+
return 0 if self._closed.is_set() else None
|
|
131
|
+
|
|
132
|
+
def _require_open(self) -> None:
|
|
133
|
+
if self._closed.is_set():
|
|
134
|
+
raise ConnectionError("QEMU console process is closed")
|
|
135
|
+
|
|
136
|
+
def write(self, data: ProcessData) -> None:
|
|
137
|
+
if isinstance(data, str):
|
|
138
|
+
if self._encoding is None:
|
|
139
|
+
raise TypeError("raw QEMU console writes require bytes")
|
|
140
|
+
data = data.encode(self._encoding, self._errors)
|
|
141
|
+
self._require_open()
|
|
142
|
+
offset = 0
|
|
143
|
+
try:
|
|
144
|
+
with self._io_lock:
|
|
145
|
+
while offset < len(data):
|
|
146
|
+
sent = self._stream.send(data[offset:])
|
|
147
|
+
if sent <= 0:
|
|
148
|
+
raise ConnectionError("QEMU console write made no progress")
|
|
149
|
+
offset += sent
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
raise_normalized(exc, normalize_qemu_console_error)
|
|
152
|
+
|
|
153
|
+
def read(self, size: int = -1) -> ProcessData:
|
|
154
|
+
self._require_open()
|
|
155
|
+
if size < -1:
|
|
156
|
+
raise ValueError("read size must be -1 or non-negative")
|
|
157
|
+
if size == 0:
|
|
158
|
+
return b""
|
|
159
|
+
request_size = 64 * 1024 if size == -1 else size
|
|
160
|
+
try:
|
|
161
|
+
with self._io_lock:
|
|
162
|
+
value = self._stream.recv(request_size)
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
raise_normalized(exc, normalize_qemu_console_error)
|
|
165
|
+
if not value:
|
|
166
|
+
self._finish(closing_stream=self._close_stream)
|
|
167
|
+
if self._decoder is not None:
|
|
168
|
+
return self._decoder.decode(value, final=not value)
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
def read_stderr(self, size: int = -1) -> bytes:
|
|
172
|
+
raise NotImplementedError("QEMU serial consoles have one merged byte stream")
|
|
173
|
+
|
|
174
|
+
def send_eof(self) -> None:
|
|
175
|
+
raise NotImplementedError(
|
|
176
|
+
"QEMU console streams do not support a generic half-close"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def resize(
|
|
180
|
+
self,
|
|
181
|
+
columns: int,
|
|
182
|
+
rows: int,
|
|
183
|
+
pixel_width: int = 0,
|
|
184
|
+
pixel_height: int = 0,
|
|
185
|
+
) -> None:
|
|
186
|
+
self._require_open()
|
|
187
|
+
if self._resize is None:
|
|
188
|
+
raise NotImplementedError(
|
|
189
|
+
"QEMU console backend does not support terminal resize"
|
|
190
|
+
)
|
|
191
|
+
if columns <= 0 or rows <= 0:
|
|
192
|
+
raise ValueError("terminal columns and rows must be positive")
|
|
193
|
+
if pixel_width < 0 or pixel_height < 0:
|
|
194
|
+
raise ValueError("terminal pixel dimensions must not be negative")
|
|
195
|
+
try:
|
|
196
|
+
self._resize(columns, rows, pixel_width, pixel_height)
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
raise_normalized(exc, normalize_qemu_console_error)
|
|
199
|
+
|
|
200
|
+
def wait(self, timeout: typing.Optional[float] = None) -> int:
|
|
201
|
+
if timeout is not None and timeout < 0:
|
|
202
|
+
raise ValueError("timeout must not be negative")
|
|
203
|
+
if not self._closed.wait(timeout):
|
|
204
|
+
raise subprocess.TimeoutExpired("QEMU serial console", timeout)
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
def terminate(self) -> None:
|
|
208
|
+
raise NotImplementedError(
|
|
209
|
+
"QEMU serial consoles have no generic terminate signal"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def kill(self) -> None:
|
|
213
|
+
raise NotImplementedError("QEMU serial consoles have no generic kill signal")
|
|
214
|
+
|
|
215
|
+
def close(self) -> None:
|
|
216
|
+
self._finish(closing_stream=self._close_stream)
|
|
217
|
+
|
|
218
|
+
def _finish(self, *, closing_stream: bool) -> None:
|
|
219
|
+
if self._closed.is_set():
|
|
220
|
+
return
|
|
221
|
+
error: typing.Optional[Exception] = None
|
|
222
|
+
if closing_stream:
|
|
223
|
+
try:
|
|
224
|
+
with self._io_lock:
|
|
225
|
+
self._stream.finish()
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
error = exc
|
|
228
|
+
try:
|
|
229
|
+
self._stream.abort()
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
self._closed.set()
|
|
233
|
+
self._release()
|
|
234
|
+
if error is not None:
|
|
235
|
+
raise_normalized(error, normalize_qemu_console_error)
|
|
236
|
+
|
|
237
|
+
def __enter__(self) -> QemuSerialProcess:
|
|
238
|
+
return self
|
|
239
|
+
|
|
240
|
+
def __exit__(
|
|
241
|
+
self,
|
|
242
|
+
exc_type: typing.Optional[typing.Type[BaseException]],
|
|
243
|
+
exc_value: typing.Optional[BaseException],
|
|
244
|
+
traceback: typing.Optional[types.TracebackType],
|
|
245
|
+
) -> bool:
|
|
246
|
+
self.close()
|
|
247
|
+
return False
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Raw byte-stream process facade for a serial connection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import threading
|
|
7
|
+
import types
|
|
8
|
+
import typing
|
|
9
|
+
|
|
10
|
+
from ._common import Process, ProcessData, raise_normalized
|
|
11
|
+
from ..executor.serial import SerialLike, normalize_serial_error
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SerialProcess(Process):
|
|
15
|
+
"""Exclusive raw access to one serial connection."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
serial_port: SerialLike,
|
|
20
|
+
*,
|
|
21
|
+
io_lock: threading.RLock,
|
|
22
|
+
release: typing.Callable[[], None],
|
|
23
|
+
) -> None:
|
|
24
|
+
self._serial = serial_port
|
|
25
|
+
self._io_lock = io_lock
|
|
26
|
+
self._release = release
|
|
27
|
+
self._closed = threading.Event()
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def returncode(self) -> typing.Optional[int]:
|
|
31
|
+
return 0 if self._closed.is_set() else None
|
|
32
|
+
|
|
33
|
+
def _require_open(self) -> None:
|
|
34
|
+
if self._closed.is_set() or not self._serial.is_open:
|
|
35
|
+
raise ConnectionError("serial process is closed")
|
|
36
|
+
|
|
37
|
+
def write(self, data: ProcessData) -> None:
|
|
38
|
+
if isinstance(data, str):
|
|
39
|
+
raise TypeError("raw serial process writes require bytes")
|
|
40
|
+
self._require_open()
|
|
41
|
+
offset = 0
|
|
42
|
+
try:
|
|
43
|
+
with self._io_lock:
|
|
44
|
+
while offset < len(data):
|
|
45
|
+
written = self._serial.write(data[offset:])
|
|
46
|
+
if written <= 0:
|
|
47
|
+
raise TimeoutError("serial write made no progress")
|
|
48
|
+
offset += written
|
|
49
|
+
self._serial.flush()
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
normalized = normalize_serial_error(exc)
|
|
52
|
+
if normalized is exc:
|
|
53
|
+
raise
|
|
54
|
+
raise normalized from exc
|
|
55
|
+
|
|
56
|
+
def read(self, size: int = -1) -> bytes:
|
|
57
|
+
self._require_open()
|
|
58
|
+
if size < -1:
|
|
59
|
+
raise ValueError("read size must be -1 or non-negative")
|
|
60
|
+
if size == -1:
|
|
61
|
+
available = getattr(self._serial, "in_waiting", None)
|
|
62
|
+
if available is not None:
|
|
63
|
+
size = min(max(int(available), 0), 64 * 1024)
|
|
64
|
+
if size == 0:
|
|
65
|
+
return b""
|
|
66
|
+
else:
|
|
67
|
+
# A generic injected serial object may not expose an
|
|
68
|
+
# availability count. Read at most one byte rather than
|
|
69
|
+
# blocking for an arbitrary 64 KiB request.
|
|
70
|
+
size = 1
|
|
71
|
+
try:
|
|
72
|
+
with self._io_lock:
|
|
73
|
+
return self._serial.read(size)
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
raise_normalized(exc, normalize_serial_error)
|
|
76
|
+
|
|
77
|
+
def read_stderr(self, size: int = -1) -> bytes:
|
|
78
|
+
raise NotImplementedError("serial has one merged byte stream")
|
|
79
|
+
|
|
80
|
+
def send_eof(self) -> None:
|
|
81
|
+
raise NotImplementedError("serial connections do not support half-close")
|
|
82
|
+
|
|
83
|
+
def resize(
|
|
84
|
+
self,
|
|
85
|
+
columns: int,
|
|
86
|
+
rows: int,
|
|
87
|
+
pixel_width: int = 0,
|
|
88
|
+
pixel_height: int = 0,
|
|
89
|
+
) -> None:
|
|
90
|
+
raise NotImplementedError("serial terminals cannot be resized generically")
|
|
91
|
+
|
|
92
|
+
def wait(self, timeout: typing.Optional[float] = None) -> int:
|
|
93
|
+
if timeout is not None and timeout < 0:
|
|
94
|
+
raise ValueError("timeout must not be negative")
|
|
95
|
+
if not self._closed.wait(timeout):
|
|
96
|
+
raise subprocess.TimeoutExpired("serial session", timeout)
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
def terminate(self) -> None:
|
|
100
|
+
raise NotImplementedError("serial has no generic terminate signal")
|
|
101
|
+
|
|
102
|
+
def kill(self) -> None:
|
|
103
|
+
raise NotImplementedError("serial has no generic kill signal")
|
|
104
|
+
|
|
105
|
+
def send_break(self, duration: float = 0.25) -> None:
|
|
106
|
+
if duration < 0:
|
|
107
|
+
raise ValueError("break duration must not be negative")
|
|
108
|
+
self._require_open()
|
|
109
|
+
try:
|
|
110
|
+
with self._io_lock:
|
|
111
|
+
self._serial.send_break(duration)
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
normalized = normalize_serial_error(exc)
|
|
114
|
+
if normalized is exc:
|
|
115
|
+
raise
|
|
116
|
+
raise normalized from exc
|
|
117
|
+
|
|
118
|
+
def reset_input_buffer(self) -> None:
|
|
119
|
+
"""Discard stale bytes before a protocol negotiation or command."""
|
|
120
|
+
self._require_open()
|
|
121
|
+
reset = getattr(self._serial, "reset_input_buffer", None)
|
|
122
|
+
if not callable(reset):
|
|
123
|
+
raise NotImplementedError("serial backend cannot reset its input buffer")
|
|
124
|
+
try:
|
|
125
|
+
with self._io_lock:
|
|
126
|
+
reset()
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
raise_normalized(exc, normalize_serial_error)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def dtr(self) -> bool:
|
|
132
|
+
self._require_open()
|
|
133
|
+
return self._serial.dtr
|
|
134
|
+
|
|
135
|
+
@dtr.setter
|
|
136
|
+
def dtr(self, value: bool) -> None:
|
|
137
|
+
self._require_open()
|
|
138
|
+
self._serial.dtr = bool(value)
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def rts(self) -> bool:
|
|
142
|
+
self._require_open()
|
|
143
|
+
return self._serial.rts
|
|
144
|
+
|
|
145
|
+
@rts.setter
|
|
146
|
+
def rts(self, value: bool) -> None:
|
|
147
|
+
self._require_open()
|
|
148
|
+
self._serial.rts = bool(value)
|
|
149
|
+
|
|
150
|
+
def close(self) -> None:
|
|
151
|
+
if self._closed.is_set():
|
|
152
|
+
return
|
|
153
|
+
self._closed.set()
|
|
154
|
+
self._release()
|
|
155
|
+
|
|
156
|
+
def __enter__(self) -> SerialProcess:
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
def __exit__(
|
|
160
|
+
self,
|
|
161
|
+
exc_type: typing.Optional[typing.Type[BaseException]],
|
|
162
|
+
exc_value: typing.Optional[BaseException],
|
|
163
|
+
traceback: typing.Optional[types.TracebackType],
|
|
164
|
+
) -> bool:
|
|
165
|
+
self.close()
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class SerialConsoleProcess(Process):
|
|
170
|
+
"""Console-aware wrapper used by :class:`hostctl.host.SerialHost`.
|
|
171
|
+
|
|
172
|
+
It keeps the raw process lease while allowing shell sessions to submit
|
|
173
|
+
text. Reads remain merged bytes; framing and prompt interpretation belong
|
|
174
|
+
to the selected profile.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def __init__(
|
|
178
|
+
self, process: SerialProcess, profile: object, encoding: str = "utf-8"
|
|
179
|
+
) -> None:
|
|
180
|
+
self.process = process
|
|
181
|
+
self.profile = profile
|
|
182
|
+
self.encoding = encoding
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def returncode(self):
|
|
186
|
+
return self.process.returncode
|
|
187
|
+
|
|
188
|
+
def write(self, data: ProcessData) -> None:
|
|
189
|
+
if isinstance(data, str):
|
|
190
|
+
data = data.encode(self.encoding)
|
|
191
|
+
self.process.write(data)
|
|
192
|
+
|
|
193
|
+
def read(self, size: int = -1) -> bytes:
|
|
194
|
+
value = self.process.read(size)
|
|
195
|
+
return typing.cast(bytes, value)
|
|
196
|
+
|
|
197
|
+
def read_stderr(self, size: int = -1) -> bytes:
|
|
198
|
+
return self.process.read_stderr(size)
|
|
199
|
+
|
|
200
|
+
def send_eof(self) -> None:
|
|
201
|
+
self.process.send_eof()
|
|
202
|
+
|
|
203
|
+
def resize(
|
|
204
|
+
self, columns: int, rows: int, pixel_width: int = 0, pixel_height: int = 0
|
|
205
|
+
) -> None:
|
|
206
|
+
self.process.resize(columns, rows, pixel_width, pixel_height)
|
|
207
|
+
|
|
208
|
+
def wait(self, timeout: typing.Optional[float] = None) -> int:
|
|
209
|
+
return self.process.wait(timeout)
|
|
210
|
+
|
|
211
|
+
def terminate(self) -> None:
|
|
212
|
+
self.process.terminate()
|
|
213
|
+
|
|
214
|
+
def kill(self) -> None:
|
|
215
|
+
self.process.kill()
|
|
216
|
+
|
|
217
|
+
def close(self) -> None:
|
|
218
|
+
self.process.close()
|
|
219
|
+
|
|
220
|
+
def send_break(self, duration: float = 0.25) -> None:
|
|
221
|
+
self.process.send_break(duration)
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def dtr(self) -> bool:
|
|
225
|
+
return self.process.dtr
|
|
226
|
+
|
|
227
|
+
@dtr.setter
|
|
228
|
+
def dtr(self, value: bool) -> None:
|
|
229
|
+
self.process.dtr = value
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def rts(self) -> bool:
|
|
233
|
+
return self.process.rts
|
|
234
|
+
|
|
235
|
+
@rts.setter
|
|
236
|
+
def rts(self, value: bool) -> None:
|
|
237
|
+
self.process.rts = value
|
|
238
|
+
|
|
239
|
+
def send_command(self, command: str | bytes) -> None:
|
|
240
|
+
sender = getattr(self.profile, "send", None)
|
|
241
|
+
if not callable(sender):
|
|
242
|
+
raise NotImplementedError("console profile cannot send commands")
|
|
243
|
+
sender(self.process, command)
|
|
244
|
+
|
|
245
|
+
def send(self, *commands: str | bytes) -> None:
|
|
246
|
+
"""Submit one or more profile-framed console commands."""
|
|
247
|
+
if not commands:
|
|
248
|
+
raise ValueError("send requires at least one command")
|
|
249
|
+
for command in commands:
|
|
250
|
+
self.send_command(command)
|
|
251
|
+
|
|
252
|
+
def __enter__(self) -> "SerialConsoleProcess":
|
|
253
|
+
self.process.__enter__()
|
|
254
|
+
return self
|
|
255
|
+
|
|
256
|
+
def __exit__(self, exc_type, exc_value, traceback) -> bool:
|
|
257
|
+
return self.process.__exit__(exc_type, exc_value, traceback)
|
hostctl/process/ssh.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Persistent process adapter for AsyncSSH channels."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import types
|
|
6
|
+
import typing
|
|
7
|
+
import inspect
|
|
8
|
+
|
|
9
|
+
from ._common import Process, ProcessData
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _Reader(typing.Protocol):
|
|
13
|
+
def read(self, size: int = -1) -> typing.Awaitable[ProcessData]: ...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Writer(typing.Protocol):
|
|
17
|
+
def write(self, data: ProcessData) -> None: ...
|
|
18
|
+
|
|
19
|
+
def drain(self) -> typing.Awaitable[None]: ...
|
|
20
|
+
|
|
21
|
+
def write_eof(self) -> None: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Completed(typing.Protocol):
|
|
25
|
+
returncode: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _AsyncsshProcess(typing.Protocol):
|
|
29
|
+
returncode: typing.Optional[int]
|
|
30
|
+
stdin: _Writer
|
|
31
|
+
stdout: _Reader
|
|
32
|
+
stderr: _Reader
|
|
33
|
+
|
|
34
|
+
def change_terminal_size(
|
|
35
|
+
self, width: int, height: int, pixwidth: int, pixheight: int
|
|
36
|
+
) -> None: ...
|
|
37
|
+
|
|
38
|
+
def close(self) -> None: ...
|
|
39
|
+
|
|
40
|
+
def kill(self) -> None: ...
|
|
41
|
+
|
|
42
|
+
def terminate(self) -> None: ...
|
|
43
|
+
|
|
44
|
+
def wait(
|
|
45
|
+
self, check: bool = False, timeout: typing.Optional[float] = None
|
|
46
|
+
) -> typing.Awaitable[_Completed]: ...
|
|
47
|
+
|
|
48
|
+
def wait_closed(self) -> typing.Awaitable[None]: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SshProcess(Process):
|
|
52
|
+
"""Synchronous facade over a process owned by hostctl's AsyncSSH loop."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self, process: _AsyncsshProcess, command: typing.Optional[str]
|
|
56
|
+
) -> None:
|
|
57
|
+
self._process = process
|
|
58
|
+
self._command = command
|
|
59
|
+
self._closed = False
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def returncode(self) -> typing.Optional[int]:
|
|
63
|
+
return self._process.returncode
|
|
64
|
+
|
|
65
|
+
def _call(self, function: typing.Callable[[], object]) -> object:
|
|
66
|
+
from .. import _async
|
|
67
|
+
|
|
68
|
+
async def invoke() -> object:
|
|
69
|
+
result = function()
|
|
70
|
+
if inspect.isawaitable(result):
|
|
71
|
+
return await result
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
return _async.async_to_sync(invoke())
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
normalized = _async.normalize_asyncssh_error(exc, command=self._command)
|
|
78
|
+
if normalized is exc:
|
|
79
|
+
raise
|
|
80
|
+
raise normalized from exc
|
|
81
|
+
|
|
82
|
+
def write(self, data: ProcessData) -> None:
|
|
83
|
+
async def operation() -> None:
|
|
84
|
+
writer = self._process.stdin
|
|
85
|
+
writer.write(data)
|
|
86
|
+
await writer.drain()
|
|
87
|
+
|
|
88
|
+
self._call(operation)
|
|
89
|
+
|
|
90
|
+
def read(self, size: int = -1) -> ProcessData:
|
|
91
|
+
return self._call(lambda: self._process.stdout.read(size))
|
|
92
|
+
|
|
93
|
+
def read_stderr(self, size: int = -1) -> ProcessData:
|
|
94
|
+
return self._call(lambda: self._process.stderr.read(size))
|
|
95
|
+
|
|
96
|
+
def send_eof(self) -> None:
|
|
97
|
+
def operation() -> None:
|
|
98
|
+
self._process.stdin.write_eof()
|
|
99
|
+
|
|
100
|
+
self._call(operation)
|
|
101
|
+
|
|
102
|
+
def resize(
|
|
103
|
+
self,
|
|
104
|
+
columns: int,
|
|
105
|
+
rows: int,
|
|
106
|
+
pixel_width: int = 0,
|
|
107
|
+
pixel_height: int = 0,
|
|
108
|
+
) -> None:
|
|
109
|
+
if columns <= 0 or rows <= 0:
|
|
110
|
+
raise ValueError("terminal columns and rows must be positive")
|
|
111
|
+
if pixel_width < 0 or pixel_height < 0:
|
|
112
|
+
raise ValueError("terminal pixel dimensions must not be negative")
|
|
113
|
+
self._call(
|
|
114
|
+
lambda: self._process.change_terminal_size(
|
|
115
|
+
columns, rows, pixel_width, pixel_height
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def wait(self, timeout: typing.Optional[float] = None) -> int:
|
|
120
|
+
try:
|
|
121
|
+
from .. import _async
|
|
122
|
+
|
|
123
|
+
result = _async.async_to_sync(
|
|
124
|
+
self._process.wait(check=False, timeout=timeout)
|
|
125
|
+
)
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
normalized = _async.normalize_asyncssh_error(
|
|
128
|
+
exc, command=self._command, timeout=timeout
|
|
129
|
+
)
|
|
130
|
+
if normalized is exc:
|
|
131
|
+
raise
|
|
132
|
+
raise normalized from exc
|
|
133
|
+
return -1 if result.returncode is None else result.returncode
|
|
134
|
+
|
|
135
|
+
def terminate(self) -> None:
|
|
136
|
+
self._call(self._process.terminate)
|
|
137
|
+
|
|
138
|
+
def kill(self) -> None:
|
|
139
|
+
self._call(self._process.kill)
|
|
140
|
+
|
|
141
|
+
def close(self) -> None:
|
|
142
|
+
if self._closed:
|
|
143
|
+
return
|
|
144
|
+
from .. import _async
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
self._call(self._process.close)
|
|
148
|
+
self._call(lambda: self._process.wait_closed())
|
|
149
|
+
except Exception:
|
|
150
|
+
# A failed close remains retryable; callers must not lose the
|
|
151
|
+
# channel reference merely because the first close attempt failed.
|
|
152
|
+
raise
|
|
153
|
+
self._closed = True
|
|
154
|
+
|
|
155
|
+
def __enter__(self) -> SshProcess:
|
|
156
|
+
return self
|
|
157
|
+
|
|
158
|
+
def __exit__(
|
|
159
|
+
self,
|
|
160
|
+
exc_type: typing.Optional[typing.Type[BaseException]],
|
|
161
|
+
exc_value: typing.Optional[BaseException],
|
|
162
|
+
traceback: typing.Optional[types.TracebackType],
|
|
163
|
+
) -> bool:
|
|
164
|
+
self.close()
|
|
165
|
+
return False
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Composable execution and filesystem provider contracts.
|
|
2
|
+
|
|
3
|
+
The names exported here are the *authoring* surface: the base classes an
|
|
4
|
+
integration instantiates or subclasses to reach a new transport, the probe and
|
|
5
|
+
selection records it exchanges with a host, and the one exception that permits
|
|
6
|
+
fallback. See the "Systems and providers" guide for the authoring contract.
|
|
7
|
+
|
|
8
|
+
The built-in transport adapters in :mod:`hostctl.provider.transports` are how
|
|
9
|
+
``LocalHost``, ``ContainerHost``, and ``QemuHost`` assemble themselves. They
|
|
10
|
+
are importable from that module but deliberately absent from ``__all__``: no
|
|
11
|
+
caller constructs them, and their capability frozensets describe backends
|
|
12
|
+
hostctl owns rather than a contract a caller implements against.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ._common import (
|
|
16
|
+
ExecutorProvider,
|
|
17
|
+
OperationNotStarted,
|
|
18
|
+
PathProvider,
|
|
19
|
+
ProviderProbe,
|
|
20
|
+
ProviderSelection,
|
|
21
|
+
ProviderSelector,
|
|
22
|
+
SessionInitializer,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"ExecutorProvider",
|
|
27
|
+
"OperationNotStarted",
|
|
28
|
+
"PathProvider",
|
|
29
|
+
"ProviderProbe",
|
|
30
|
+
"ProviderSelection",
|
|
31
|
+
"ProviderSelector",
|
|
32
|
+
"SessionInitializer",
|
|
33
|
+
]
|