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/host/_common.py
ADDED
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
"""Protocol-independent host contracts and shared implementation helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import abc as _abc
|
|
6
|
+
import dataclasses as _dc
|
|
7
|
+
import importlib.metadata as _metadata
|
|
8
|
+
import subprocess as _subprocess
|
|
9
|
+
import threading as _threading
|
|
10
|
+
import typing as _ty
|
|
11
|
+
import types as _types
|
|
12
|
+
from pathlib import PurePath as _PurePath
|
|
13
|
+
from urllib.parse import (
|
|
14
|
+
SplitResult as _SplitResult,
|
|
15
|
+
parse_qsl as _parse_qsl,
|
|
16
|
+
quote as _quote,
|
|
17
|
+
unquote as _unquote,
|
|
18
|
+
urlsplit as _urlsplit,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from pathlib_next import Path as HostPath, Pathname as _Pathname
|
|
22
|
+
|
|
23
|
+
from ..executor import (
|
|
24
|
+
CaptureOutput,
|
|
25
|
+
Environment,
|
|
26
|
+
FileHandle,
|
|
27
|
+
Input,
|
|
28
|
+
PathLike,
|
|
29
|
+
capture_streams,
|
|
30
|
+
reject_stdin_conflict,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if _ty.TYPE_CHECKING:
|
|
34
|
+
from ..executor import Executor, ExecutorCapability
|
|
35
|
+
from ..process import Process, TerminalRequest
|
|
36
|
+
from ..shell import Shell, ShellFlavour
|
|
37
|
+
|
|
38
|
+
Command = _ty.Union[str, PathLike, _ty.Sequence[object]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def starts_direct_command(
|
|
42
|
+
cmds: _ty.Sequence[Command],
|
|
43
|
+
) -> _ty.Optional[_ty.Tuple[_ty.Union[_PurePath, _Pathname], _ty.Tuple[object, ...]]]:
|
|
44
|
+
"""Split a path-led call into one executable and its argv arguments.
|
|
45
|
+
|
|
46
|
+
A leading path object is the explicit direct-execution marker. Every
|
|
47
|
+
trailing value is an argv scalar; nested command sequences are rejected so
|
|
48
|
+
callers cannot accidentally mix shell-command and argv semantics.
|
|
49
|
+
"""
|
|
50
|
+
if not cmds or not isinstance(cmds[0], (_PurePath, _Pathname)):
|
|
51
|
+
return None
|
|
52
|
+
command = cmds[0]
|
|
53
|
+
argv = tuple(cmds[1:])
|
|
54
|
+
for value in argv:
|
|
55
|
+
if isinstance(value, (tuple, list)):
|
|
56
|
+
raise TypeError("direct command arguments must be scalar values")
|
|
57
|
+
if not isinstance(value, (str, bytes, _PurePath, _Pathname)):
|
|
58
|
+
raise TypeError(
|
|
59
|
+
"direct command arguments must be str, bytes, or path values"
|
|
60
|
+
)
|
|
61
|
+
return command, argv
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@_dc.dataclass(frozen=True)
|
|
65
|
+
class HostInfo:
|
|
66
|
+
"""Normalized system information; unavailable values remain ``None``."""
|
|
67
|
+
|
|
68
|
+
hostname: _ty.Optional[str] = None
|
|
69
|
+
os_family: _ty.Optional[str] = None
|
|
70
|
+
os_name: _ty.Optional[str] = None
|
|
71
|
+
os_version: _ty.Optional[str] = None
|
|
72
|
+
architecture: _ty.Optional[str] = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_host_info(output: _ty.Union[bytes, str, None]) -> HostInfo:
|
|
76
|
+
"""Parse newline-delimited ``HostInfo`` fields from a transport response."""
|
|
77
|
+
if isinstance(output, bytes):
|
|
78
|
+
output = output.decode("utf-8", "replace")
|
|
79
|
+
values = {}
|
|
80
|
+
for line in (output or "").splitlines():
|
|
81
|
+
key, separator, value = line.partition("=")
|
|
82
|
+
key, value = key.strip(), value.strip()
|
|
83
|
+
if separator and key in HostInfo.__dataclass_fields__ and value:
|
|
84
|
+
values[key] = value
|
|
85
|
+
if "os_family" in values:
|
|
86
|
+
values["os_family"] = normalize_os_family(values["os_family"])
|
|
87
|
+
return HostInfo(**values)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def normalize_os_family(value: _ty.Optional[str]) -> _ty.Optional[str]:
|
|
91
|
+
"""Normalize a directly reported OS family without inferring one."""
|
|
92
|
+
if not value:
|
|
93
|
+
return None
|
|
94
|
+
normalized = value.casefold()
|
|
95
|
+
aliases = {
|
|
96
|
+
"win32nt": "windows",
|
|
97
|
+
"windows": "windows",
|
|
98
|
+
"linux": "linux",
|
|
99
|
+
"darwin": "macos",
|
|
100
|
+
"macos": "macos",
|
|
101
|
+
}
|
|
102
|
+
return aliases.get(normalized, normalized)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def uri_host(host: str) -> str:
|
|
106
|
+
"""Bracket an IPv6 literal for use in a URI authority."""
|
|
107
|
+
return f"[{host}]" if ":" in host and not host.startswith("[") else host
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
#: Characters `urllib.parse` deletes from a URI before parsing it (WHATWG
|
|
111
|
+
#: requires the removal, and CPython adopted it for CVE-2022-0391). They are
|
|
112
|
+
#: removed *silently*, which is what makes them dangerous unencoded.
|
|
113
|
+
_URI_STRIPPED_CHARACTERS = {"\t": "%09", "\n": "%0A", "\r": "%0D"}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _encode_stripped_characters(uri: str) -> str:
|
|
117
|
+
"""Percent-encode the characters `urlsplit` would silently delete.
|
|
118
|
+
|
|
119
|
+
A raw tab, CR, or LF never survives `urlsplit`: it is removed before
|
|
120
|
+
parsing, which changes what the URI means rather than failing. That is
|
|
121
|
+
dangerous in two different ways, and they need different answers.
|
|
122
|
+
|
|
123
|
+
In the **userinfo** it swallows data the caller meant to pass:
|
|
124
|
+
`ssh://user:pw<LF>otp:1@host` would authenticate as `pwotp:1`, losing the
|
|
125
|
+
credential extras. Writing the separator raw is the natural thing to do --
|
|
126
|
+
a password read from a file or a prompt arrives with a real newline in it
|
|
127
|
+
-- so encode it here and let it through.
|
|
128
|
+
|
|
129
|
+
In the **authority** the same deletion rewrites the target:
|
|
130
|
+
`ssh://host<LF>.other.example/` would resolve to `host.other.example`. No
|
|
131
|
+
encoding makes that safe, because the caller cannot have meant a hostname
|
|
132
|
+
containing a newline. `_reject_authority_control_characters` refuses those
|
|
133
|
+
after parsing, once the userinfo has been separated out.
|
|
134
|
+
|
|
135
|
+
Encoding happens before `urlsplit` sees the string, so the characters are
|
|
136
|
+
preserved as data and `unquote` restores them when the password is read.
|
|
137
|
+
"""
|
|
138
|
+
for character, encoded in _URI_STRIPPED_CHARACTERS.items():
|
|
139
|
+
if character in uri:
|
|
140
|
+
uri = uri.replace(character, encoded)
|
|
141
|
+
return uri
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _reject_authority_control_characters(parsed: _SplitResult) -> None:
|
|
145
|
+
"""Refuse control characters in the host portion of an authority.
|
|
146
|
+
|
|
147
|
+
These arrive percent-encoded (see `_encode_stripped_characters`), so the
|
|
148
|
+
check is against the encoded spelling -- `urlsplit` leaves `%0A` in the
|
|
149
|
+
hostname verbatim. A hostname cannot legitimately contain one, and
|
|
150
|
+
allowing it would let a URI that reads as one target resolve to another.
|
|
151
|
+
"""
|
|
152
|
+
host = parsed.hostname or ""
|
|
153
|
+
if not host:
|
|
154
|
+
return
|
|
155
|
+
upper = host.upper()
|
|
156
|
+
if any(encoded in upper for encoded in _URI_STRIPPED_CHARACTERS.values()):
|
|
157
|
+
raise ValueError(
|
|
158
|
+
"connection URI host contains a control character; only the "
|
|
159
|
+
"userinfo may carry one"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _rebuild_authority(parsed: _SplitResult, password: _ty.Optional[str]) -> str:
|
|
164
|
+
"""Rebuild a URI authority with `password` in place of the original."""
|
|
165
|
+
host = uri_host(parsed.hostname or "")
|
|
166
|
+
if parsed.port is not None:
|
|
167
|
+
host = f"{host}:{parsed.port}"
|
|
168
|
+
if not parsed.username:
|
|
169
|
+
return host
|
|
170
|
+
userinfo = _quote(parsed.username, safe="")
|
|
171
|
+
if password is not None:
|
|
172
|
+
userinfo = f"{userinfo}:{password}"
|
|
173
|
+
return f"{userinfo}@{host}"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _without_password(parsed: _SplitResult) -> _SplitResult:
|
|
177
|
+
"""Return `parsed` with any password removed from its authority."""
|
|
178
|
+
return parsed._replace(netloc=_rebuild_authority(parsed, None))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def redact_uri(uri: str) -> str:
|
|
182
|
+
"""Strip any password from `uri`, leaving a valid, reusable URI.
|
|
183
|
+
|
|
184
|
+
`scheme://user:secret@host` becomes `scheme://user@host`. The password is
|
|
185
|
+
removed rather than masked: a placeholder would make the URI round-trip
|
|
186
|
+
into a *wrong* credential if anything fed the rendered form back in, and
|
|
187
|
+
would not be a real password anyway. What comes out is the same canonical,
|
|
188
|
+
credential-free string a config renders, so it is safe to log, repr, or
|
|
189
|
+
hand back to `HostConfig`.
|
|
190
|
+
|
|
191
|
+
A URI with no password is returned unchanged.
|
|
192
|
+
|
|
193
|
+
This never raises. It is meant for error messages and log records, where
|
|
194
|
+
failing to render a diagnostic would be worse than rendering an odd one.
|
|
195
|
+
Characters `urlsplit` would delete (tab, CR, LF) are percent-encoded first,
|
|
196
|
+
the same as during dispatch, so a password written with a raw newline is
|
|
197
|
+
still recognized and removed rather than partly surviving into the output.
|
|
198
|
+
"""
|
|
199
|
+
parsed = _urlsplit(_encode_stripped_characters(uri))
|
|
200
|
+
if parsed.password is None:
|
|
201
|
+
return uri
|
|
202
|
+
return _without_password(parsed).geturl()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def parse_credentials(password: str) -> _ty.Tuple[str, _ty.Dict[str, str]]:
|
|
206
|
+
"""Split a password field into the password and any trailing extras.
|
|
207
|
+
|
|
208
|
+
A newline separates the password from additional credential values, one
|
|
209
|
+
per line, each `key:value`:
|
|
210
|
+
|
|
211
|
+
"hunter2" -> ("hunter2", {})
|
|
212
|
+
"hunter2\\notp:123456" -> ("hunter2", {"otp": "123456"})
|
|
213
|
+
"hunter2\\notp:123456\\nrealm:CORP" -> ("hunter2", {"otp": ..., "realm": ...})
|
|
214
|
+
|
|
215
|
+
A bare name with no `:` is a flag -- it maps to the empty string, exactly
|
|
216
|
+
as `name:` does:
|
|
217
|
+
|
|
218
|
+
"hunter2\\ninteractive" -> ("hunter2", {"interactive": ""})
|
|
219
|
+
|
|
220
|
+
This is how a second factor reaches a transport through a single password
|
|
221
|
+
field -- a URI's userinfo, an environment variable, a prompt -- without
|
|
222
|
+
every caller inventing its own encoding. A newline is assumed not to be a
|
|
223
|
+
valid password character, which is the same assumption pytruenas makes.
|
|
224
|
+
|
|
225
|
+
Names are casefolded and stripped of surrounding whitespace. A value is
|
|
226
|
+
everything after the first `:`, taken **verbatim** -- it may contain
|
|
227
|
+
colons, and its leading and trailing whitespace is preserved, because a
|
|
228
|
+
secret may legitimately begin or end with a space and silently trimming
|
|
229
|
+
one would fail authentication with no visible cause. Blank lines are
|
|
230
|
+
ignored.
|
|
231
|
+
"""
|
|
232
|
+
# Split on any line ending: a value pasted from a file or a Windows prompt
|
|
233
|
+
# arrives CRLF-terminated, and a trailing "\r" left on the password would
|
|
234
|
+
# fail authentication with no visible cause.
|
|
235
|
+
lines = password.splitlines()
|
|
236
|
+
if len(lines) <= 1:
|
|
237
|
+
return password, {}
|
|
238
|
+
password, remainder = lines[0], lines[1:]
|
|
239
|
+
extras: _ty.Dict[str, str] = {}
|
|
240
|
+
for line in remainder:
|
|
241
|
+
if not line.strip():
|
|
242
|
+
continue
|
|
243
|
+
# A line with no ":" is a bare flag; `partition` already yields "" for
|
|
244
|
+
# its value, so flags and `name:` need no separate branch.
|
|
245
|
+
key, _, value = line.partition(":")
|
|
246
|
+
key = key.strip().casefold()
|
|
247
|
+
if not key:
|
|
248
|
+
raise ValueError("credential extra names must not be empty")
|
|
249
|
+
extras[key] = value
|
|
250
|
+
return password, extras
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class _HostConfigMeta(_abc.ABCMeta):
|
|
254
|
+
def __call__(cls, *args: object, **options: object) -> HostConfig:
|
|
255
|
+
if cls is HostConfig:
|
|
256
|
+
if len(args) != 1 or not isinstance(args[0], str):
|
|
257
|
+
raise TypeError(
|
|
258
|
+
"HostConfig() requires one connection string positional argument"
|
|
259
|
+
)
|
|
260
|
+
return cls._from_uri(args[0], **options)
|
|
261
|
+
return super().__call__(*args, **options)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class HostConfig(_abc.ABC, metaclass=_HostConfigMeta):
|
|
265
|
+
"""Secret-safe connection configuration and extensible URI dispatch."""
|
|
266
|
+
|
|
267
|
+
#: Credential names `HostConfig(uri, **credentials)` may pass to this
|
|
268
|
+
#: config. Declaring it makes dispatch reject anything else *before*
|
|
269
|
+
#: construction, so a typo (`passwrd=`) fails loudly instead of silently
|
|
270
|
+
#: producing a config with no password. `None` (the default) skips the
|
|
271
|
+
#: check, for configs that validate credentials themselves.
|
|
272
|
+
uri_credentials: _ty.ClassVar[_ty.Optional[_ty.Tuple[str, ...]]] = None
|
|
273
|
+
|
|
274
|
+
_uri_schemes: _ty.ClassVar[_ty.Tuple[str, ...]] = ()
|
|
275
|
+
_uri_registry_cache: _ty.ClassVar[
|
|
276
|
+
_ty.Optional[_ty.Tuple[_ty.Type["HostConfig"], ...]]
|
|
277
|
+
] = None
|
|
278
|
+
_uri_entry_points: _ty.ClassVar[_ty.Optional[tuple[object, ...]]] = None
|
|
279
|
+
_uri_plugin_failures: _ty.ClassVar[dict[str, Exception]] = {}
|
|
280
|
+
_uri_registry_generation: _ty.ClassVar[int] = 0
|
|
281
|
+
_uri_registry_lock: _ty.ClassVar[_threading.RLock] = _threading.RLock()
|
|
282
|
+
|
|
283
|
+
def __init__(self) -> None:
|
|
284
|
+
self._opened_host: _ty.Optional[Host] = None
|
|
285
|
+
self._lifecycle_lock = _threading.Lock()
|
|
286
|
+
|
|
287
|
+
def __init_subclass__(
|
|
288
|
+
cls,
|
|
289
|
+
*,
|
|
290
|
+
schemes: _ty.Iterable[str] = (),
|
|
291
|
+
**kwargs: object,
|
|
292
|
+
) -> None:
|
|
293
|
+
super().__init_subclass__(**kwargs)
|
|
294
|
+
cls._uri_schemes = tuple(scheme.casefold() for scheme in schemes)
|
|
295
|
+
HostConfig._refresh_uri_registry()
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def scheme(self) -> str:
|
|
299
|
+
return _urlsplit(self.connection_uri).scheme.casefold()
|
|
300
|
+
|
|
301
|
+
def __str__(self) -> str:
|
|
302
|
+
return self.connection_uri
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
@_abc.abstractmethod
|
|
306
|
+
def connection_uri(self) -> str:
|
|
307
|
+
"""Credential-safe canonical connection URI."""
|
|
308
|
+
|
|
309
|
+
@_abc.abstractmethod
|
|
310
|
+
def _create_host(self) -> Host:
|
|
311
|
+
"""Create the operational host represented by this configuration."""
|
|
312
|
+
|
|
313
|
+
def open(self) -> Host:
|
|
314
|
+
"""Return a host context manager for this configuration."""
|
|
315
|
+
return self._create_host()
|
|
316
|
+
|
|
317
|
+
def __enter__(self) -> Host:
|
|
318
|
+
with self._lifecycle_lock:
|
|
319
|
+
if self._opened_host is not None:
|
|
320
|
+
raise RuntimeError("host configuration is already open")
|
|
321
|
+
host = self._create_host()
|
|
322
|
+
self._opened_host = host
|
|
323
|
+
try:
|
|
324
|
+
return host.__enter__()
|
|
325
|
+
except BaseException:
|
|
326
|
+
try:
|
|
327
|
+
host.close()
|
|
328
|
+
except BaseException:
|
|
329
|
+
pass
|
|
330
|
+
finally:
|
|
331
|
+
with self._lifecycle_lock:
|
|
332
|
+
if self._opened_host is host:
|
|
333
|
+
self._opened_host = None
|
|
334
|
+
raise
|
|
335
|
+
|
|
336
|
+
def __exit__(
|
|
337
|
+
self,
|
|
338
|
+
exc_type: _ty.Optional[_ty.Type[BaseException]],
|
|
339
|
+
exc_value: _ty.Optional[BaseException],
|
|
340
|
+
traceback: _ty.Optional[_types.TracebackType],
|
|
341
|
+
) -> _ty.Optional[bool]:
|
|
342
|
+
with self._lifecycle_lock:
|
|
343
|
+
host, self._opened_host = self._opened_host, None
|
|
344
|
+
if host is not None:
|
|
345
|
+
return host.__exit__(exc_type, exc_value, traceback)
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
@classmethod
|
|
349
|
+
def _from_uri(
|
|
350
|
+
cls,
|
|
351
|
+
uri: str,
|
|
352
|
+
**credentials: object,
|
|
353
|
+
) -> HostConfig:
|
|
354
|
+
"""Dispatch a connection URI to a registered configuration.
|
|
355
|
+
|
|
356
|
+
A `scheme://user:secret@host` URI is accepted: the password is
|
|
357
|
+
extracted into the credential arguments and stripped from the parsed
|
|
358
|
+
authority, so it is never stored where `connection_uri` or `repr()`
|
|
359
|
+
would render it. Use :func:`redact_uri` before showing a URI that may
|
|
360
|
+
still carry one.
|
|
361
|
+
|
|
362
|
+
The password field is parsed by :func:`parse_credentials`, so a newline
|
|
363
|
+
separates trailing `key:value` extras -- an OTP or other second factor
|
|
364
|
+
travels through the same field. The newline may be written raw: it is
|
|
365
|
+
percent-encoded before parsing (`urlsplit` would otherwise delete it),
|
|
366
|
+
and decoded again when the password is read. A control character in the
|
|
367
|
+
*host* is still refused, because no encoding makes that meaningful.
|
|
368
|
+
"""
|
|
369
|
+
parsed = _urlsplit(_encode_stripped_characters(uri))
|
|
370
|
+
_reject_authority_control_characters(parsed)
|
|
371
|
+
if parsed.fragment:
|
|
372
|
+
raise ValueError("connection URI fragments are not supported")
|
|
373
|
+
if parsed.password is not None:
|
|
374
|
+
# `scheme://user:secret@host` is a valid URI, so accept it: extract
|
|
375
|
+
# the password into the credential arguments and strip it from the
|
|
376
|
+
# parsed authority. The password therefore never reaches a config
|
|
377
|
+
# field that `connection_uri`/`repr` render, which is what keeps
|
|
378
|
+
# the canonical form credential-free.
|
|
379
|
+
if credentials.get("password") is not None:
|
|
380
|
+
raise ValueError(
|
|
381
|
+
"password given both in the connection URI and as an argument"
|
|
382
|
+
)
|
|
383
|
+
password, extras = parse_credentials(_unquote(parsed.password))
|
|
384
|
+
credentials["password"] = password
|
|
385
|
+
for key, value in extras.items():
|
|
386
|
+
if key in credentials:
|
|
387
|
+
raise ValueError(
|
|
388
|
+
f"credential {key!r} given both in the connection URI "
|
|
389
|
+
"and as an argument"
|
|
390
|
+
)
|
|
391
|
+
credentials[key] = value
|
|
392
|
+
parsed = _without_password(parsed)
|
|
393
|
+
matches = [
|
|
394
|
+
implementation
|
|
395
|
+
for implementation in cls._uri_implementations(parsed.scheme)
|
|
396
|
+
if implementation._matches_uri(parsed)
|
|
397
|
+
]
|
|
398
|
+
if not matches:
|
|
399
|
+
raise ValueError(
|
|
400
|
+
f"unsupported host scheme: {parsed.scheme.casefold() or '<missing>'}"
|
|
401
|
+
)
|
|
402
|
+
if len(matches) > 1:
|
|
403
|
+
names = ", ".join(item.__name__ for item in matches)
|
|
404
|
+
raise ValueError(f"ambiguous host URI matched: {names}")
|
|
405
|
+
implementation = matches[0]
|
|
406
|
+
# Fail closed on an unknown credential *here*, so a config gets the
|
|
407
|
+
# safe behaviour by declaring `uri_credentials` rather than by
|
|
408
|
+
# remembering to call a helper. A typo like `passwrd=` would otherwise
|
|
409
|
+
# build a config with no password and no complaint, surfacing much
|
|
410
|
+
# later as an unexplained authentication failure.
|
|
411
|
+
if implementation.uri_credentials is not None:
|
|
412
|
+
strict_uri_credentials(credentials, implementation.uri_credentials)
|
|
413
|
+
return implementation._from_parsed_uri(parsed, **credentials)
|
|
414
|
+
|
|
415
|
+
@classmethod
|
|
416
|
+
def _matches_uri(cls, parsed: _SplitResult) -> bool:
|
|
417
|
+
"""Whether this implementation accepts a parsed URI."""
|
|
418
|
+
return parsed.scheme.casefold() in cls._uri_schemes
|
|
419
|
+
|
|
420
|
+
@classmethod
|
|
421
|
+
def _from_parsed_uri(
|
|
422
|
+
cls, parsed: _SplitResult, **credentials: object
|
|
423
|
+
) -> HostConfig:
|
|
424
|
+
"""Construct from a URI selected by :meth:`_matches_uri`."""
|
|
425
|
+
raise NotImplementedError(f"{cls.__name__} does not implement URI construction")
|
|
426
|
+
|
|
427
|
+
@classmethod
|
|
428
|
+
def _refresh_uri_registry(cls) -> None:
|
|
429
|
+
"""Clear URI implementation discovery for tests and newly loaded plugins."""
|
|
430
|
+
with HostConfig._uri_registry_lock:
|
|
431
|
+
HostConfig._uri_registry_generation += 1
|
|
432
|
+
HostConfig._uri_registry_cache = None
|
|
433
|
+
HostConfig._uri_entry_points = None
|
|
434
|
+
HostConfig._uri_plugin_failures = {}
|
|
435
|
+
|
|
436
|
+
@classmethod
|
|
437
|
+
def _uri_implementations(
|
|
438
|
+
cls,
|
|
439
|
+
requested_scheme: _ty.Optional[str] = None,
|
|
440
|
+
) -> _ty.Tuple[_ty.Type[HostConfig], ...]:
|
|
441
|
+
scheme = requested_scheme.casefold() if requested_scheme else None
|
|
442
|
+
with HostConfig._uri_registry_lock:
|
|
443
|
+
if HostConfig._uri_registry_cache is None:
|
|
444
|
+
from . import (
|
|
445
|
+
container as _container,
|
|
446
|
+
_local,
|
|
447
|
+
qemu as _qemu,
|
|
448
|
+
serial as _serial,
|
|
449
|
+
_ssh,
|
|
450
|
+
_winrm,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
del _container, _local, _qemu, _serial, _ssh, _winrm
|
|
454
|
+
while HostConfig._uri_registry_cache is None:
|
|
455
|
+
generation = HostConfig._uri_registry_generation
|
|
456
|
+
discovered = list(_recursive_subclasses(HostConfig))
|
|
457
|
+
if generation != HostConfig._uri_registry_generation:
|
|
458
|
+
continue
|
|
459
|
+
HostConfig._uri_registry_cache = tuple(
|
|
460
|
+
item for item in discovered if item._uri_schemes
|
|
461
|
+
)
|
|
462
|
+
if HostConfig._uri_entry_points is None:
|
|
463
|
+
points = _metadata.entry_points()
|
|
464
|
+
if hasattr(points, "select"):
|
|
465
|
+
points = points.select(group="hostctl.configs")
|
|
466
|
+
elif hasattr(points, "get"):
|
|
467
|
+
points = points.get("hostctl.configs", ())
|
|
468
|
+
else:
|
|
469
|
+
points = tuple(
|
|
470
|
+
item
|
|
471
|
+
for item in points
|
|
472
|
+
if getattr(item, "group", None) == "hostctl.configs"
|
|
473
|
+
)
|
|
474
|
+
HostConfig._uri_entry_points = tuple(points)
|
|
475
|
+
candidates = list(HostConfig._uri_registry_cache)
|
|
476
|
+
if scheme is not None:
|
|
477
|
+
for entry_point in HostConfig._uri_entry_points:
|
|
478
|
+
name = str(getattr(entry_point, "name", "")).casefold()
|
|
479
|
+
if name != scheme:
|
|
480
|
+
continue
|
|
481
|
+
failure = HostConfig._uri_plugin_failures.get(name)
|
|
482
|
+
if failure is not None:
|
|
483
|
+
raise failure
|
|
484
|
+
try:
|
|
485
|
+
implementation = entry_point.load()
|
|
486
|
+
if not isinstance(implementation, type) or not issubclass(
|
|
487
|
+
implementation, HostConfig
|
|
488
|
+
):
|
|
489
|
+
raise TypeError(
|
|
490
|
+
f"hostctl.configs entry point {name!r} "
|
|
491
|
+
"must load a HostConfig subclass"
|
|
492
|
+
)
|
|
493
|
+
except Exception as exc:
|
|
494
|
+
HostConfig._uri_plugin_failures[name] = exc
|
|
495
|
+
import warnings
|
|
496
|
+
|
|
497
|
+
warnings.warn(
|
|
498
|
+
f"unable to load hostctl.configs entry point {name!r}: {exc}",
|
|
499
|
+
RuntimeWarning,
|
|
500
|
+
stacklevel=2,
|
|
501
|
+
)
|
|
502
|
+
raise
|
|
503
|
+
candidates.append(implementation)
|
|
504
|
+
break
|
|
505
|
+
return tuple(
|
|
506
|
+
item
|
|
507
|
+
for item in dict.fromkeys(candidates)
|
|
508
|
+
if issubclass(item, cls) and item._uri_schemes
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
class _HostMeta(_abc.ABCMeta):
|
|
513
|
+
def __call__(cls, *args: object, **options: object) -> Host:
|
|
514
|
+
if cls is Host:
|
|
515
|
+
if len(args) != 1 or not isinstance(args[0], str):
|
|
516
|
+
raise TypeError(
|
|
517
|
+
"Host() requires one connection string positional argument"
|
|
518
|
+
)
|
|
519
|
+
config = HostConfig._from_uri(args[0], **options)
|
|
520
|
+
return config._create_host()
|
|
521
|
+
return super().__call__(*args, **options)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
class _ShellAccessor:
|
|
525
|
+
"""Expose `host.shell` as both the shell itself and a configuring call.
|
|
526
|
+
|
|
527
|
+
`host.shell` must keep working as the bound `Shell` -- `host.shell.run()`,
|
|
528
|
+
`host.shell.session()`, `with host.shell as session:` all predate this.
|
|
529
|
+
`host.shell(cwd=...)` must additionally return a shell carrying defaults.
|
|
530
|
+
|
|
531
|
+
A plain property cannot do both, and overloading `Shell.__call__` is not an
|
|
532
|
+
option: that is the `Executor` protocol's execute entry point, so making it
|
|
533
|
+
mean "configure" when called without a command would make every executor
|
|
534
|
+
call site ambiguous. This descriptor instead returns the built shell for
|
|
535
|
+
attribute access and, because a `Shell` is itself callable, routes a
|
|
536
|
+
keyword-only call through `Shell.configure`.
|
|
537
|
+
"""
|
|
538
|
+
|
|
539
|
+
def __init__(self, build):
|
|
540
|
+
self._build = build
|
|
541
|
+
self.__doc__ = build.__doc__
|
|
542
|
+
|
|
543
|
+
def __set_name__(self, owner, name):
|
|
544
|
+
self._name = name
|
|
545
|
+
|
|
546
|
+
def __get__(self, instance, owner=None):
|
|
547
|
+
if instance is None:
|
|
548
|
+
return self
|
|
549
|
+
return _ConfigurableShell(self._build(instance))
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
class _ConfigurableShell:
|
|
553
|
+
"""A `Shell` proxy whose keyword-only call returns a configured shell."""
|
|
554
|
+
|
|
555
|
+
__slots__ = ("_shell",)
|
|
556
|
+
|
|
557
|
+
def __init__(self, shell):
|
|
558
|
+
object.__setattr__(self, "_shell", shell)
|
|
559
|
+
|
|
560
|
+
def __call__(self, *args, **options):
|
|
561
|
+
if args:
|
|
562
|
+
# A positional argument means the caller is using the `Executor`
|
|
563
|
+
# protocol (`shell(command, ...)`); defer to the real shell.
|
|
564
|
+
return self._shell(*args, **options)
|
|
565
|
+
return self._shell.configure(**options)
|
|
566
|
+
|
|
567
|
+
def __getattr__(self, name):
|
|
568
|
+
return getattr(self._shell, name)
|
|
569
|
+
|
|
570
|
+
def __setattr__(self, name, value):
|
|
571
|
+
setattr(self._shell, name, value)
|
|
572
|
+
|
|
573
|
+
def __enter__(self):
|
|
574
|
+
return self._shell.__enter__()
|
|
575
|
+
|
|
576
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
577
|
+
return self._shell.__exit__(exc_type, exc_value, traceback)
|
|
578
|
+
|
|
579
|
+
def __repr__(self):
|
|
580
|
+
return repr(self._shell)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
class Host(_abc.ABC, metaclass=_HostMeta):
|
|
584
|
+
"""Protocol-independent operational interface to a machine."""
|
|
585
|
+
|
|
586
|
+
config: HostConfig
|
|
587
|
+
|
|
588
|
+
@property
|
|
589
|
+
def scheme(self) -> str:
|
|
590
|
+
return self.config.scheme
|
|
591
|
+
|
|
592
|
+
@property
|
|
593
|
+
def connection_uri(self) -> str:
|
|
594
|
+
return self.config.connection_uri
|
|
595
|
+
|
|
596
|
+
@property
|
|
597
|
+
def shell_flavour(self) -> ShellFlavour:
|
|
598
|
+
"""The explicitly known shell language used by this host."""
|
|
599
|
+
raise NotImplementedError(
|
|
600
|
+
f"{type(self).__name__} does not identify a shell flavour"
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
@property
|
|
604
|
+
def executor(self) -> Executor[_subprocess.CompletedProcess]:
|
|
605
|
+
"""The command executor used when binding this host's shell."""
|
|
606
|
+
host = self
|
|
607
|
+
|
|
608
|
+
class _HostExecutor:
|
|
609
|
+
executor_capabilities = host.executor_capabilities
|
|
610
|
+
|
|
611
|
+
def __call__(self, command, *args, **options):
|
|
612
|
+
return host.run(command, *args, **options)
|
|
613
|
+
|
|
614
|
+
return _HostExecutor()
|
|
615
|
+
|
|
616
|
+
@property
|
|
617
|
+
def executor_capabilities(self) -> _ty.FrozenSet[ExecutorCapability]:
|
|
618
|
+
"""Native context/argument features of the underlying executor.
|
|
619
|
+
|
|
620
|
+
Derived from the host's own executor so a host that does not compose
|
|
621
|
+
providers still reports truthfully. `Host.executor`'s default wrapper
|
|
622
|
+
reads this property, so it is skipped here to avoid recursing; a host
|
|
623
|
+
that overrides `executor` with a real executor reports that executor's
|
|
624
|
+
capabilities.
|
|
625
|
+
"""
|
|
626
|
+
executor = type(self).executor
|
|
627
|
+
if executor is Host.executor:
|
|
628
|
+
return frozenset()
|
|
629
|
+
return frozenset(getattr(self.executor, "executor_capabilities", ()))
|
|
630
|
+
|
|
631
|
+
@_ShellAccessor
|
|
632
|
+
def shell(self) -> Shell[_subprocess.CompletedProcess]:
|
|
633
|
+
"""A shell bound to this host's executor.
|
|
634
|
+
|
|
635
|
+
Used directly -- `host.shell.run(...)`, `host.shell.session(...)`, or
|
|
636
|
+
`with host.shell as session:` -- it carries no defaults.
|
|
637
|
+
|
|
638
|
+
Called with keywords -- `host.shell(cwd="/srv/app", env={"TZ": "UTC"})`
|
|
639
|
+
-- it returns a shell carrying those defaults, applied to every later
|
|
640
|
+
`run`, `execute`, and `session` that does not pass its own value.
|
|
641
|
+
`env` merges per key; `cwd`, `encoding`, and `errors` override.
|
|
642
|
+
"""
|
|
643
|
+
from ..shell import Shell
|
|
644
|
+
|
|
645
|
+
return Shell(self.shell_flavour, self)
|
|
646
|
+
|
|
647
|
+
def _run_selector(self) -> _ty.Optional[object]:
|
|
648
|
+
"""The :class:`~hostctl.provider.ProviderSelector` backing :meth:`run`.
|
|
649
|
+
|
|
650
|
+
``None`` for a host that does not select a command provider at all.
|
|
651
|
+
The default finds the attribute used by the provider-composed system
|
|
652
|
+
hosts; assemblies that name theirs differently override this.
|
|
653
|
+
"""
|
|
654
|
+
return getattr(self, "_executor_selector", None)
|
|
655
|
+
|
|
656
|
+
@property
|
|
657
|
+
def last_selection(self) -> _ty.Tuple[_ty.Dict[str, object], ...]:
|
|
658
|
+
"""The redacted provider trace for the most recent :meth:`run`.
|
|
659
|
+
|
|
660
|
+
The run-side counterpart of ``CompositePosixPath.selection_trace``.
|
|
661
|
+
Entries appear in provider precedence order and carry ``provider``,
|
|
662
|
+
``availability``, ``reason``, ``capabilities``, ``chosen``,
|
|
663
|
+
``generation``, ``policy``, and ``pin``.
|
|
664
|
+
|
|
665
|
+
The trace accumulates across the failover attempts of a single
|
|
666
|
+
``run()``, so a call that fell through from one provider to another
|
|
667
|
+
reports every provider tried and every refusal reason -- including on
|
|
668
|
+
the very call that suffered them.
|
|
669
|
+
|
|
670
|
+
Empty for a host whose ``run()`` does not select between providers
|
|
671
|
+
(``QemuHost``, ``SerialHost``), and empty before the first ``run()``.
|
|
672
|
+
Values are redacted on the way in; see
|
|
673
|
+
:meth:`~hostctl.provider.ProviderSelector.redact` for the limits of
|
|
674
|
+
that guarantee.
|
|
675
|
+
"""
|
|
676
|
+
selector = self._run_selector()
|
|
677
|
+
if selector is None:
|
|
678
|
+
return ()
|
|
679
|
+
return getattr(selector, "trace", ())
|
|
680
|
+
|
|
681
|
+
@property
|
|
682
|
+
@_abc.abstractmethod
|
|
683
|
+
def capabilities(self) -> _ty.FrozenSet[str]:
|
|
684
|
+
"""Operations supported by this host."""
|
|
685
|
+
|
|
686
|
+
@_abc.abstractmethod
|
|
687
|
+
def info(self) -> HostInfo:
|
|
688
|
+
"""Return normalized system information without inferred values."""
|
|
689
|
+
|
|
690
|
+
def connect(self) -> None:
|
|
691
|
+
"""Open transport resources; local/default implementations are no-op."""
|
|
692
|
+
|
|
693
|
+
def close(self) -> None:
|
|
694
|
+
"""Close transport resources; must be safe to call repeatedly."""
|
|
695
|
+
|
|
696
|
+
def __enter__(self) -> Host:
|
|
697
|
+
try:
|
|
698
|
+
self.connect()
|
|
699
|
+
except BaseException:
|
|
700
|
+
try:
|
|
701
|
+
self.close()
|
|
702
|
+
except BaseException:
|
|
703
|
+
pass
|
|
704
|
+
raise
|
|
705
|
+
return self
|
|
706
|
+
|
|
707
|
+
def __exit__(
|
|
708
|
+
self,
|
|
709
|
+
exc_type: _ty.Optional[_ty.Type[BaseException]],
|
|
710
|
+
exc_value: _ty.Optional[BaseException],
|
|
711
|
+
traceback: _ty.Optional[_types.TracebackType],
|
|
712
|
+
) -> bool:
|
|
713
|
+
self.close()
|
|
714
|
+
return False
|
|
715
|
+
|
|
716
|
+
def path(self, *segments: PathLike, backend: _ty.Optional[str] = None) -> HostPath:
|
|
717
|
+
"""Return a pathlib-compatible path for this host."""
|
|
718
|
+
raise NotImplementedError(
|
|
719
|
+
f"{type(self).__name__} does not provide the 'path' capability"
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
def spawn(
|
|
723
|
+
self,
|
|
724
|
+
*cmds: Command,
|
|
725
|
+
executable: _ty.Optional[str] = None,
|
|
726
|
+
cwd: _ty.Optional[PathLike] = None,
|
|
727
|
+
env: _ty.Optional[Environment] = None,
|
|
728
|
+
terminal: TerminalRequest = None,
|
|
729
|
+
encoding: _ty.Optional[str] = None,
|
|
730
|
+
errors: _ty.Optional[str] = None,
|
|
731
|
+
) -> Process:
|
|
732
|
+
"""Start a persistent process controlled through a synchronous facade."""
|
|
733
|
+
raise NotImplementedError(
|
|
734
|
+
f"{type(self).__name__} does not provide the 'spawn' capability"
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
def run(
|
|
738
|
+
self,
|
|
739
|
+
*cmds: Command,
|
|
740
|
+
bufsize: int = -1,
|
|
741
|
+
executable: _ty.Optional[str] = None,
|
|
742
|
+
stdin: _ty.Optional[FileHandle] = None,
|
|
743
|
+
stdout: _ty.Optional[FileHandle] = None,
|
|
744
|
+
stderr: _ty.Optional[FileHandle] = None,
|
|
745
|
+
cwd: _ty.Optional[PathLike] = None,
|
|
746
|
+
env: _ty.Optional[Environment] = None,
|
|
747
|
+
capture_output: CaptureOutput = True,
|
|
748
|
+
check: bool = True,
|
|
749
|
+
encoding: _ty.Optional[str] = None,
|
|
750
|
+
errors: _ty.Optional[str] = None,
|
|
751
|
+
input: Input = None,
|
|
752
|
+
timeout: _ty.Optional[float] = None,
|
|
753
|
+
text: _ty.Optional[bool] = None,
|
|
754
|
+
) -> _subprocess.CompletedProcess:
|
|
755
|
+
"""Run commands and return a subprocess-compatible result.
|
|
756
|
+
|
|
757
|
+
A string is verbatim shell text. A tuple/list is one quoted argv
|
|
758
|
+
command. A leading :class:`pathlib.PurePath`/``pathlib_next`` path is
|
|
759
|
+
a direct executable and all trailing values are its argv arguments.
|
|
760
|
+
Otherwise multiple top-level commands are joined by the selected
|
|
761
|
+
shell's command separator.
|
|
762
|
+
"""
|
|
763
|
+
raise NotImplementedError(
|
|
764
|
+
f"{type(self).__name__} does not provide the 'run' capability"
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def strict_uri_query(
|
|
769
|
+
parsed: _SplitResult, allowed: _ty.Iterable[str]
|
|
770
|
+
) -> _ty.Dict[str, str]:
|
|
771
|
+
"""Parse one selected implementation's query without ambiguity."""
|
|
772
|
+
query = {}
|
|
773
|
+
for key, value in _parse_qsl(parsed.query, keep_blank_values=True):
|
|
774
|
+
if key in query:
|
|
775
|
+
raise ValueError(f"duplicate connection parameter: {key}")
|
|
776
|
+
query[key] = value
|
|
777
|
+
unknown = set(query) - set(allowed)
|
|
778
|
+
if unknown:
|
|
779
|
+
raise ValueError(f"unknown connection parameter: {sorted(unknown)[0]}")
|
|
780
|
+
return query
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def strict_uri_credentials(
|
|
784
|
+
credentials: _ty.Mapping[str, object], allowed: _ty.Iterable[str]
|
|
785
|
+
) -> None:
|
|
786
|
+
"""Reject credentials which the selected implementation does not accept."""
|
|
787
|
+
unknown = set(credentials) - set(allowed)
|
|
788
|
+
if unknown:
|
|
789
|
+
raise ValueError(f"unknown credential argument: {sorted(unknown)[0]}")
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _query_int(query: _ty.Mapping[str, str], name: str, default: int) -> int:
|
|
793
|
+
try:
|
|
794
|
+
return int(query.get(name, default))
|
|
795
|
+
except ValueError as exc:
|
|
796
|
+
raise ValueError(f"{name} must be an integer") from exc
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _recursive_subclasses(
|
|
800
|
+
base: _ty.Type[HostConfig],
|
|
801
|
+
) -> _ty.Iterator[_ty.Type[HostConfig]]:
|
|
802
|
+
for subclass in base.__subclasses__():
|
|
803
|
+
yield subclass
|
|
804
|
+
yield from _recursive_subclasses(subclass)
|