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,49 @@
|
|
|
1
|
+
"""Persistent process contracts and provider adapters."""
|
|
2
|
+
|
|
3
|
+
from ._common import (
|
|
4
|
+
Process as Process,
|
|
5
|
+
ProcessData as ProcessData,
|
|
6
|
+
TerminalOptions as TerminalOptions,
|
|
7
|
+
TerminalRequest as TerminalRequest,
|
|
8
|
+
terminal_options as terminal_options,
|
|
9
|
+
)
|
|
10
|
+
from .ssh import SshProcess as SshProcess
|
|
11
|
+
from .container import ContainerProcess as ContainerProcess
|
|
12
|
+
from .serial import (
|
|
13
|
+
SerialConsoleProcess as SerialConsoleProcess,
|
|
14
|
+
SerialProcess as SerialProcess,
|
|
15
|
+
)
|
|
16
|
+
from .qemu_serial import (
|
|
17
|
+
ConsoleResize as ConsoleResize,
|
|
18
|
+
ConsoleStreamFactory as ConsoleStreamFactory,
|
|
19
|
+
QemuConsoleStream as QemuConsoleStream,
|
|
20
|
+
QemuSerialConsole as QemuSerialConsole,
|
|
21
|
+
QemuSerialProcess as QemuSerialProcess,
|
|
22
|
+
normalize_qemu_console_error as normalize_qemu_console_error,
|
|
23
|
+
)
|
|
24
|
+
from .psrp import (
|
|
25
|
+
PipelineResult as PipelineResult,
|
|
26
|
+
PipelineStreams as PipelineStreams,
|
|
27
|
+
RunspaceSession as RunspaceSession,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = (
|
|
31
|
+
"Process",
|
|
32
|
+
"ProcessData",
|
|
33
|
+
"ConsoleResize",
|
|
34
|
+
"ConsoleStreamFactory",
|
|
35
|
+
"QemuConsoleStream",
|
|
36
|
+
"QemuSerialConsole",
|
|
37
|
+
"QemuSerialProcess",
|
|
38
|
+
"ContainerProcess",
|
|
39
|
+
"SshProcess",
|
|
40
|
+
"SerialProcess",
|
|
41
|
+
"SerialConsoleProcess",
|
|
42
|
+
"TerminalOptions",
|
|
43
|
+
"TerminalRequest",
|
|
44
|
+
"terminal_options",
|
|
45
|
+
"normalize_qemu_console_error",
|
|
46
|
+
"PipelineResult",
|
|
47
|
+
"PipelineStreams",
|
|
48
|
+
"RunspaceSession",
|
|
49
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Contracts for persistent child processes and terminal allocation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import typing
|
|
7
|
+
import types
|
|
8
|
+
import codecs
|
|
9
|
+
|
|
10
|
+
ProcessData = typing.Union[str, bytes]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def raise_normalized(
|
|
14
|
+
exc: Exception, normalizer: typing.Callable[[Exception], Exception]
|
|
15
|
+
) -> typing.NoReturn:
|
|
16
|
+
"""Raise a normalized transport error while preserving its cause."""
|
|
17
|
+
normalized = normalizer(exc)
|
|
18
|
+
if normalized is exc:
|
|
19
|
+
raise exc
|
|
20
|
+
raise normalized from exc
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class IncrementalTextDecoder:
|
|
24
|
+
"""Decode arbitrary byte chunks without splitting multibyte characters."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, encoding: str, errors: str = "strict") -> None:
|
|
27
|
+
self._decoder = codecs.getincrementaldecoder(encoding)(errors)
|
|
28
|
+
|
|
29
|
+
def decode(self, data: bytes, *, final: bool = False) -> str:
|
|
30
|
+
return self._decoder.decode(data, final=final)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
TerminalRequest = typing.Optional[typing.Union[bool, "TerminalOptions"]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclasses.dataclass(frozen=True)
|
|
37
|
+
class TerminalOptions:
|
|
38
|
+
"""Requested pseudo-terminal type and initial dimensions."""
|
|
39
|
+
|
|
40
|
+
term_type: str = "xterm-256color"
|
|
41
|
+
columns: int = 80
|
|
42
|
+
rows: int = 24
|
|
43
|
+
pixel_width: int = 0
|
|
44
|
+
pixel_height: int = 0
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
if not self.term_type:
|
|
48
|
+
raise ValueError("term_type must not be empty")
|
|
49
|
+
if self.columns <= 0 or self.rows <= 0:
|
|
50
|
+
raise ValueError("terminal columns and rows must be positive")
|
|
51
|
+
if self.pixel_width < 0 or self.pixel_height < 0:
|
|
52
|
+
raise ValueError("terminal pixel dimensions must not be negative")
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def size(self) -> typing.Tuple[int, int, int, int]:
|
|
56
|
+
return (
|
|
57
|
+
self.columns,
|
|
58
|
+
self.rows,
|
|
59
|
+
self.pixel_width,
|
|
60
|
+
self.pixel_height,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def terminal_options(value: TerminalRequest) -> typing.Optional[TerminalOptions]:
|
|
65
|
+
"""Normalize a convenient boolean terminal request to concrete options."""
|
|
66
|
+
if value is True:
|
|
67
|
+
return TerminalOptions()
|
|
68
|
+
if value in (False, None):
|
|
69
|
+
return None
|
|
70
|
+
if not isinstance(value, TerminalOptions):
|
|
71
|
+
raise TypeError("terminal must be bool, TerminalOptions, or None")
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@typing.runtime_checkable
|
|
76
|
+
class Process(typing.Protocol):
|
|
77
|
+
"""Synchronous control surface for a persistent child process."""
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def returncode(self) -> typing.Optional[int]: ...
|
|
81
|
+
|
|
82
|
+
def write(self, data: ProcessData) -> None: ...
|
|
83
|
+
|
|
84
|
+
def read(self, size: int = -1) -> ProcessData: ...
|
|
85
|
+
|
|
86
|
+
def read_stderr(self, size: int = -1) -> ProcessData: ...
|
|
87
|
+
|
|
88
|
+
def send_eof(self) -> None: ...
|
|
89
|
+
|
|
90
|
+
def resize(
|
|
91
|
+
self,
|
|
92
|
+
columns: int,
|
|
93
|
+
rows: int,
|
|
94
|
+
pixel_width: int = 0,
|
|
95
|
+
pixel_height: int = 0,
|
|
96
|
+
) -> None: ...
|
|
97
|
+
|
|
98
|
+
def wait(self, timeout: typing.Optional[float] = None) -> int: ...
|
|
99
|
+
|
|
100
|
+
def terminate(self) -> None: ...
|
|
101
|
+
|
|
102
|
+
def kill(self) -> None: ...
|
|
103
|
+
|
|
104
|
+
def close(self) -> None: ...
|
|
105
|
+
|
|
106
|
+
def __enter__(self) -> Process: ...
|
|
107
|
+
|
|
108
|
+
def __exit__(
|
|
109
|
+
self,
|
|
110
|
+
exc_type: typing.Optional[typing.Type[BaseException]],
|
|
111
|
+
exc_value: typing.Optional[BaseException],
|
|
112
|
+
traceback: typing.Optional[types.TracebackType],
|
|
113
|
+
) -> bool: ...
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Persistent process adapter for Docker Engine exec sockets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import collections
|
|
6
|
+
import codecs
|
|
7
|
+
import socket
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
import types
|
|
11
|
+
import typing
|
|
12
|
+
|
|
13
|
+
from ._common import Process, ProcessData
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ContainerExecApi(typing.Protocol):
|
|
17
|
+
def exec_inspect(self, exec_id: str) -> typing.Mapping[str, object]: ...
|
|
18
|
+
|
|
19
|
+
def exec_resize(self, exec_id: str, *, height: int, width: int) -> object: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _Socket(typing.Protocol):
|
|
23
|
+
def recv(self, size: int) -> bytes: ...
|
|
24
|
+
|
|
25
|
+
def sendall(self, value: bytes) -> None: ...
|
|
26
|
+
|
|
27
|
+
def shutdown(self, how: int) -> None: ...
|
|
28
|
+
|
|
29
|
+
def close(self) -> None: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ContainerProcess(Process):
|
|
33
|
+
"""Synchronous Docker exec channel, with framed non-TTY output."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
api: ContainerExecApi,
|
|
38
|
+
exec_id: str,
|
|
39
|
+
stream: object,
|
|
40
|
+
*,
|
|
41
|
+
tty: bool,
|
|
42
|
+
command: typing.Sequence[str],
|
|
43
|
+
encoding: typing.Optional[str] = None,
|
|
44
|
+
errors: typing.Optional[str] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._api = api
|
|
47
|
+
self._exec_id = exec_id
|
|
48
|
+
self._socket = typing.cast(_Socket, getattr(stream, "_sock", stream))
|
|
49
|
+
self._tty = tty
|
|
50
|
+
self._command = list(command)
|
|
51
|
+
self._encoding = encoding
|
|
52
|
+
self._errors = errors or "strict"
|
|
53
|
+
self._buffers = {
|
|
54
|
+
1: collections.deque(), # type: typing.Deque[bytes]
|
|
55
|
+
2: collections.deque(), # type: typing.Deque[bytes]
|
|
56
|
+
}
|
|
57
|
+
self._eof = False
|
|
58
|
+
self._closed = False
|
|
59
|
+
self._returncode: typing.Optional[int] = None
|
|
60
|
+
self._wire_buffer = bytearray()
|
|
61
|
+
self._decoders = {
|
|
62
|
+
1: (
|
|
63
|
+
codecs.getincrementaldecoder(encoding)(self._errors)
|
|
64
|
+
if encoding
|
|
65
|
+
else None
|
|
66
|
+
),
|
|
67
|
+
2: (
|
|
68
|
+
codecs.getincrementaldecoder(encoding)(self._errors)
|
|
69
|
+
if encoding
|
|
70
|
+
else None
|
|
71
|
+
),
|
|
72
|
+
}
|
|
73
|
+
settimeout = getattr(self._socket, "settimeout", None)
|
|
74
|
+
if settimeout is not None:
|
|
75
|
+
settimeout(None)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def returncode(self) -> typing.Optional[int]:
|
|
79
|
+
if self._returncode is not None:
|
|
80
|
+
return self._returncode
|
|
81
|
+
state = self._api.exec_inspect(self._exec_id)
|
|
82
|
+
if state.get("Running"):
|
|
83
|
+
return None
|
|
84
|
+
value = state.get("ExitCode")
|
|
85
|
+
if value is None:
|
|
86
|
+
return None
|
|
87
|
+
self._returncode = int(value)
|
|
88
|
+
return self._returncode
|
|
89
|
+
|
|
90
|
+
def _encode(self, value: ProcessData) -> bytes:
|
|
91
|
+
if isinstance(value, bytes):
|
|
92
|
+
return value
|
|
93
|
+
return value.encode(self._encoding or "utf-8", self._errors)
|
|
94
|
+
|
|
95
|
+
def _decode(
|
|
96
|
+
self, value: bytes, stream: int = 1, *, final: bool = False
|
|
97
|
+
) -> ProcessData:
|
|
98
|
+
if self._encoding is None:
|
|
99
|
+
return value
|
|
100
|
+
decoder = self._decoders[stream]
|
|
101
|
+
return typing.cast(codecs.IncrementalDecoder, decoder).decode(
|
|
102
|
+
value, final=final
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def write(self, data: ProcessData) -> None:
|
|
106
|
+
if self._closed:
|
|
107
|
+
raise ValueError("process is closed")
|
|
108
|
+
self._socket.sendall(self._encode(data))
|
|
109
|
+
|
|
110
|
+
def _receive(self) -> None:
|
|
111
|
+
if self._eof:
|
|
112
|
+
return
|
|
113
|
+
if self._tty:
|
|
114
|
+
value = self._socket.recv(64 * 1024)
|
|
115
|
+
if value:
|
|
116
|
+
self._buffers[1].append(value)
|
|
117
|
+
else:
|
|
118
|
+
self._eof = True
|
|
119
|
+
return
|
|
120
|
+
while not self._consume_frames():
|
|
121
|
+
chunk = self._socket.recv(64 * 1024)
|
|
122
|
+
if not chunk:
|
|
123
|
+
self._finish_wire()
|
|
124
|
+
return
|
|
125
|
+
self._wire_buffer.extend(chunk)
|
|
126
|
+
|
|
127
|
+
def _consume_frames(self) -> bool:
|
|
128
|
+
"""Consume every complete Docker multiplex frame already buffered."""
|
|
129
|
+
consumed = False
|
|
130
|
+
while len(self._wire_buffer) >= 8:
|
|
131
|
+
length = int.from_bytes(self._wire_buffer[4:8], "big")
|
|
132
|
+
frame_size = 8 + length
|
|
133
|
+
if len(self._wire_buffer) < frame_size:
|
|
134
|
+
break
|
|
135
|
+
stream = self._wire_buffer[0]
|
|
136
|
+
payload = bytes(self._wire_buffer[8:frame_size])
|
|
137
|
+
del self._wire_buffer[:frame_size]
|
|
138
|
+
if stream in self._buffers and payload:
|
|
139
|
+
self._buffers[stream].append(payload)
|
|
140
|
+
consumed = True
|
|
141
|
+
return consumed
|
|
142
|
+
|
|
143
|
+
def _finish_wire(self) -> None:
|
|
144
|
+
self._eof = True
|
|
145
|
+
if self._wire_buffer:
|
|
146
|
+
raise ConnectionError("connection dropped mid-frame")
|
|
147
|
+
|
|
148
|
+
def _read_stream(self, stream: int, size: int) -> ProcessData:
|
|
149
|
+
if self._tty and stream == 2:
|
|
150
|
+
raise NotImplementedError("TTY sessions combine stdout and stderr")
|
|
151
|
+
if size == 0:
|
|
152
|
+
return self._decode(b"")
|
|
153
|
+
output = bytearray()
|
|
154
|
+
while size < 0 or len(output) < size:
|
|
155
|
+
while not self._buffers[stream] and not self._eof:
|
|
156
|
+
self._receive()
|
|
157
|
+
if not self._buffers[stream]:
|
|
158
|
+
break
|
|
159
|
+
chunk = self._buffers[stream].popleft()
|
|
160
|
+
if size >= 0 and len(output) + len(chunk) > size:
|
|
161
|
+
take = size - len(output)
|
|
162
|
+
output.extend(chunk[:take])
|
|
163
|
+
self._buffers[stream].appendleft(chunk[take:])
|
|
164
|
+
else:
|
|
165
|
+
output.extend(chunk)
|
|
166
|
+
# read(n) returns as soon as data is available, like SSH and
|
|
167
|
+
# socket streams; callers needing exactly n bytes can loop.
|
|
168
|
+
if size >= 0 and output:
|
|
169
|
+
break
|
|
170
|
+
return self._decode(bytes(output), stream)
|
|
171
|
+
|
|
172
|
+
def read(self, size: int = -1) -> ProcessData:
|
|
173
|
+
return self._read_stream(1, size)
|
|
174
|
+
|
|
175
|
+
def read_stderr(self, size: int = -1) -> ProcessData:
|
|
176
|
+
return self._read_stream(2, size)
|
|
177
|
+
|
|
178
|
+
def send_eof(self) -> None:
|
|
179
|
+
shutdown = getattr(self._socket, "shutdown", None)
|
|
180
|
+
if shutdown is None:
|
|
181
|
+
raise NotImplementedError("exec transport cannot half-close stdin")
|
|
182
|
+
try:
|
|
183
|
+
shutdown(socket.SHUT_WR)
|
|
184
|
+
except (OSError, ValueError) as exc:
|
|
185
|
+
raise NotImplementedError("exec transport cannot half-close stdin") from exc
|
|
186
|
+
|
|
187
|
+
def resize(
|
|
188
|
+
self,
|
|
189
|
+
columns: int,
|
|
190
|
+
rows: int,
|
|
191
|
+
pixel_width: int = 0,
|
|
192
|
+
pixel_height: int = 0,
|
|
193
|
+
) -> None:
|
|
194
|
+
if not self._tty:
|
|
195
|
+
raise NotImplementedError("process does not have a terminal")
|
|
196
|
+
if columns <= 0 or rows <= 0:
|
|
197
|
+
raise ValueError("terminal columns and rows must be positive")
|
|
198
|
+
if pixel_width < 0 or pixel_height < 0:
|
|
199
|
+
raise ValueError("terminal pixel dimensions must not be negative")
|
|
200
|
+
self._api.exec_resize(self._exec_id, height=rows, width=columns)
|
|
201
|
+
|
|
202
|
+
def wait(self, timeout: typing.Optional[float] = None) -> int:
|
|
203
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
204
|
+
while True:
|
|
205
|
+
# Drain one available frame before polling. Non-blocking Docker
|
|
206
|
+
# sockets raise timeout/BlockingIOError when no data is ready.
|
|
207
|
+
self._receive_available()
|
|
208
|
+
returncode = self.returncode
|
|
209
|
+
if returncode is not None:
|
|
210
|
+
return returncode
|
|
211
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
212
|
+
raise subprocess.TimeoutExpired(self._command, timeout)
|
|
213
|
+
time.sleep(0.01)
|
|
214
|
+
|
|
215
|
+
def _receive_available(self) -> None:
|
|
216
|
+
settimeout = getattr(self._socket, "settimeout", None)
|
|
217
|
+
gettimeout = getattr(self._socket, "gettimeout", None)
|
|
218
|
+
if settimeout is None:
|
|
219
|
+
# There is no portable way to prove that recv() will not block.
|
|
220
|
+
# wait() must keep polling process state rather than deadlock.
|
|
221
|
+
return
|
|
222
|
+
previous = gettimeout() if gettimeout is not None else None
|
|
223
|
+
try:
|
|
224
|
+
settimeout(0.0)
|
|
225
|
+
while not self._eof:
|
|
226
|
+
try:
|
|
227
|
+
value = self._socket.recv(64 * 1024)
|
|
228
|
+
except (socket.timeout, BlockingIOError):
|
|
229
|
+
break
|
|
230
|
+
if not value:
|
|
231
|
+
if self._tty:
|
|
232
|
+
self._eof = True
|
|
233
|
+
else:
|
|
234
|
+
self._finish_wire()
|
|
235
|
+
break
|
|
236
|
+
if self._tty:
|
|
237
|
+
self._buffers[1].append(value)
|
|
238
|
+
else:
|
|
239
|
+
self._wire_buffer.extend(value)
|
|
240
|
+
self._consume_frames()
|
|
241
|
+
finally:
|
|
242
|
+
settimeout(previous)
|
|
243
|
+
|
|
244
|
+
def terminate(self) -> None:
|
|
245
|
+
raise NotImplementedError("Docker Engine cannot signal one exec process")
|
|
246
|
+
|
|
247
|
+
def kill(self) -> None:
|
|
248
|
+
raise NotImplementedError("Docker Engine cannot signal one exec process")
|
|
249
|
+
|
|
250
|
+
def close(self) -> None:
|
|
251
|
+
if not self._closed:
|
|
252
|
+
self._closed = True
|
|
253
|
+
self._socket.close()
|
|
254
|
+
|
|
255
|
+
def __enter__(self) -> ContainerProcess:
|
|
256
|
+
return self
|
|
257
|
+
|
|
258
|
+
def __exit__(
|
|
259
|
+
self,
|
|
260
|
+
exc_type: typing.Optional[typing.Type[BaseException]],
|
|
261
|
+
exc_value: typing.Optional[BaseException],
|
|
262
|
+
traceback: typing.Optional[types.TracebackType],
|
|
263
|
+
) -> bool:
|
|
264
|
+
self.close()
|
|
265
|
+
return False
|
hostctl/process/psrp.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Persistent PowerShell Remoting Protocol runspace sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import types
|
|
7
|
+
import typing
|
|
8
|
+
|
|
9
|
+
from ..executor.psrp import require_pypsrp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclasses.dataclass(frozen=True)
|
|
13
|
+
class PipelineStreams:
|
|
14
|
+
"""Typed PowerShell streams returned by one pipeline invocation."""
|
|
15
|
+
|
|
16
|
+
error: tuple[object, ...] = ()
|
|
17
|
+
warning: tuple[object, ...] = ()
|
|
18
|
+
verbose: tuple[object, ...] = ()
|
|
19
|
+
debug: tuple[object, ...] = ()
|
|
20
|
+
information: tuple[object, ...] = ()
|
|
21
|
+
progress: tuple[object, ...] = ()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclasses.dataclass(frozen=True)
|
|
25
|
+
class PipelineResult:
|
|
26
|
+
"""Result of a PSRP pipeline, retaining objects and stream records."""
|
|
27
|
+
|
|
28
|
+
output: tuple[object, ...]
|
|
29
|
+
streams: PipelineStreams
|
|
30
|
+
state: str
|
|
31
|
+
had_errors: bool
|
|
32
|
+
returncode: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _stream_values(streams: object, name: str) -> tuple[object, ...]:
|
|
36
|
+
value = getattr(streams, name, ())
|
|
37
|
+
try:
|
|
38
|
+
return tuple(value)
|
|
39
|
+
except TypeError:
|
|
40
|
+
return (value,) if value is not None else ()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _pipeline_state_name(value: object) -> str:
|
|
44
|
+
"""Normalize pypsrp's integer states and compatible enum-like doubles."""
|
|
45
|
+
value = getattr(value, "name", getattr(value, "state", value))
|
|
46
|
+
if isinstance(value, int):
|
|
47
|
+
return {
|
|
48
|
+
0: "NotStarted",
|
|
49
|
+
1: "Running",
|
|
50
|
+
2: "Stopping",
|
|
51
|
+
3: "Stopped",
|
|
52
|
+
4: "Completed",
|
|
53
|
+
5: "Failed",
|
|
54
|
+
6: "Disconnected",
|
|
55
|
+
}.get(value, f"Unknown({value})")
|
|
56
|
+
return str(value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RunspaceSession:
|
|
60
|
+
"""A persistent PSRP runspace with typed PowerShell pipeline streams.
|
|
61
|
+
|
|
62
|
+
``RunspaceSession`` intentionally does not implement the byte-oriented
|
|
63
|
+
``Process``/``ShellSession`` contract: a runspace is a PowerShell object
|
|
64
|
+
pipeline, not a TTY and has no raw stdin or resize operations.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self, config: object | None = None, *, pool: object | None = None
|
|
69
|
+
) -> None:
|
|
70
|
+
self._config = config
|
|
71
|
+
self._pool = pool
|
|
72
|
+
self._owns_pool = pool is None
|
|
73
|
+
self._open = False
|
|
74
|
+
|
|
75
|
+
def _make_pool(self) -> object:
|
|
76
|
+
if self._pool is not None:
|
|
77
|
+
return self._pool
|
|
78
|
+
require_pypsrp()
|
|
79
|
+
from pypsrp.powershell import RunspacePool
|
|
80
|
+
from pypsrp.wsman import WSMan
|
|
81
|
+
|
|
82
|
+
config = self._config
|
|
83
|
+
if config is None:
|
|
84
|
+
raise ValueError("a WinRMConfig is required when pool is not supplied")
|
|
85
|
+
ssl = bool(getattr(config, "ssl", False))
|
|
86
|
+
endpoint_port = getattr(config, "port", None)
|
|
87
|
+
kwargs = {
|
|
88
|
+
"server": getattr(config, "host"),
|
|
89
|
+
"port": endpoint_port or (5986 if ssl else 5985),
|
|
90
|
+
"username": getattr(config, "username"),
|
|
91
|
+
"password": getattr(config, "password", None),
|
|
92
|
+
"ssl": ssl,
|
|
93
|
+
"auth": "negotiate",
|
|
94
|
+
"cert_validation": getattr(config, "server_cert_validation", "validate")
|
|
95
|
+
== "validate",
|
|
96
|
+
"encryption": getattr(config, "message_encryption", "auto"),
|
|
97
|
+
"connection_timeout": getattr(config, "read_timeout_sec", 30),
|
|
98
|
+
"operation_timeout": getattr(config, "operation_timeout_sec", 20),
|
|
99
|
+
"read_timeout": getattr(config, "read_timeout_sec", 30),
|
|
100
|
+
}
|
|
101
|
+
try:
|
|
102
|
+
wsman = WSMan(**kwargs)
|
|
103
|
+
except TypeError:
|
|
104
|
+
# pypsrp releases have renamed timeout parameters; retry with the
|
|
105
|
+
# stable connection options and let transport errors surface.
|
|
106
|
+
kwargs.pop("operation_timeout")
|
|
107
|
+
kwargs.pop("read_timeout")
|
|
108
|
+
wsman = WSMan(**kwargs)
|
|
109
|
+
return RunspacePool(wsman)
|
|
110
|
+
|
|
111
|
+
def connect(self) -> None:
|
|
112
|
+
if self._open:
|
|
113
|
+
return
|
|
114
|
+
self._pool = self._make_pool()
|
|
115
|
+
open_method = getattr(self._pool, "open", None)
|
|
116
|
+
if open_method is not None:
|
|
117
|
+
open_method()
|
|
118
|
+
self._open = True
|
|
119
|
+
|
|
120
|
+
def close(self) -> None:
|
|
121
|
+
if not self._open and self._pool is None:
|
|
122
|
+
return
|
|
123
|
+
pool, self._pool = self._pool, None
|
|
124
|
+
self._open = False
|
|
125
|
+
if pool is not None:
|
|
126
|
+
close = getattr(pool, "close", None)
|
|
127
|
+
if close is not None:
|
|
128
|
+
close()
|
|
129
|
+
|
|
130
|
+
def __enter__(self) -> RunspaceSession:
|
|
131
|
+
self.connect()
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def __exit__(
|
|
135
|
+
self,
|
|
136
|
+
exc_type: typing.Optional[typing.Type[BaseException]],
|
|
137
|
+
exc_value: typing.Optional[BaseException],
|
|
138
|
+
traceback: typing.Optional[types.TracebackType],
|
|
139
|
+
) -> bool:
|
|
140
|
+
self.close()
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
def invoke(
|
|
144
|
+
self,
|
|
145
|
+
script: object,
|
|
146
|
+
*args: object,
|
|
147
|
+
raw: bool = False,
|
|
148
|
+
capture_exit: bool = False,
|
|
149
|
+
) -> PipelineResult:
|
|
150
|
+
"""Invoke a script or executable with persistent runspace state."""
|
|
151
|
+
self.connect()
|
|
152
|
+
from ..shell import POWERSHELL
|
|
153
|
+
|
|
154
|
+
if args:
|
|
155
|
+
# PowerShell's call operator preserves executable semantics while
|
|
156
|
+
# still allowing arguments to be represented as object literals.
|
|
157
|
+
command = POWERSHELL.quote(script)
|
|
158
|
+
values = " ".join(POWERSHELL.quote(value) for value in args)
|
|
159
|
+
script_text = "& {} {}".format(command, values)
|
|
160
|
+
else:
|
|
161
|
+
script_text = str(script)
|
|
162
|
+
marker = "__HOSTCTL_LASTEXITCODE__"
|
|
163
|
+
if capture_exit:
|
|
164
|
+
script_text = (
|
|
165
|
+
f"{script_text}; Write-Output ('{marker}:' + "
|
|
166
|
+
"[string]([int]$LASTEXITCODE))"
|
|
167
|
+
)
|
|
168
|
+
from pypsrp.powershell import PowerShell
|
|
169
|
+
|
|
170
|
+
pipeline = PowerShell(self._pool)
|
|
171
|
+
pipeline.add_script(script_text)
|
|
172
|
+
output = pipeline.invoke()
|
|
173
|
+
streams_obj = getattr(pipeline, "streams", object())
|
|
174
|
+
streams = PipelineStreams(
|
|
175
|
+
error=_stream_values(streams_obj, "error"),
|
|
176
|
+
warning=_stream_values(streams_obj, "warning"),
|
|
177
|
+
verbose=_stream_values(streams_obj, "verbose"),
|
|
178
|
+
debug=_stream_values(streams_obj, "debug"),
|
|
179
|
+
information=_stream_values(streams_obj, "information"),
|
|
180
|
+
progress=_stream_values(streams_obj, "progress"),
|
|
181
|
+
)
|
|
182
|
+
state = _pipeline_state_name(getattr(pipeline, "state", "Completed"))
|
|
183
|
+
had_errors = bool(getattr(pipeline, "had_errors", bool(streams.error)))
|
|
184
|
+
values = tuple(output or ())
|
|
185
|
+
returncode = 0 if state.casefold() == "completed" and not had_errors else 1
|
|
186
|
+
if capture_exit:
|
|
187
|
+
retained = []
|
|
188
|
+
for value in values:
|
|
189
|
+
text_value = str(value)
|
|
190
|
+
if text_value.startswith(marker + ":"):
|
|
191
|
+
try:
|
|
192
|
+
parsed_returncode = int(text_value.split(":", 1)[1])
|
|
193
|
+
returncode = (
|
|
194
|
+
1
|
|
195
|
+
if had_errors and parsed_returncode == 0
|
|
196
|
+
else parsed_returncode
|
|
197
|
+
)
|
|
198
|
+
except ValueError:
|
|
199
|
+
had_errors = True
|
|
200
|
+
returncode = 1
|
|
201
|
+
else:
|
|
202
|
+
retained.append(value)
|
|
203
|
+
values = tuple(retained)
|
|
204
|
+
if raw:
|
|
205
|
+
values = tuple(values)
|
|
206
|
+
else:
|
|
207
|
+
values = tuple(str(value) for value in values)
|
|
208
|
+
return PipelineResult(values, streams, state, had_errors, returncode)
|