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/_ssh.py
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"""SSH host implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import importlib
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import typing
|
|
12
|
+
from pathlib import PurePath, PurePosixPath, PureWindowsPath
|
|
13
|
+
from urllib.parse import quote, unquote, urlencode
|
|
14
|
+
|
|
15
|
+
from pathlib_next import Pathname, PosixPathname, WindowsPathname
|
|
16
|
+
|
|
17
|
+
from ..executor import SshConnection, SshExecutor
|
|
18
|
+
from ..provider import (
|
|
19
|
+
ExecutorProvider,
|
|
20
|
+
OperationNotStarted,
|
|
21
|
+
PathProvider,
|
|
22
|
+
ProviderProbe,
|
|
23
|
+
ProviderSelector,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger("hostctl.host.ssh")
|
|
27
|
+
from ..process import Process, SshProcess, TerminalRequest
|
|
28
|
+
from ._common import (
|
|
29
|
+
CaptureOutput,
|
|
30
|
+
Command,
|
|
31
|
+
Environment,
|
|
32
|
+
FileHandle,
|
|
33
|
+
HostConfig,
|
|
34
|
+
HostInfo,
|
|
35
|
+
HostPath,
|
|
36
|
+
Input,
|
|
37
|
+
PathLike,
|
|
38
|
+
parse_host_info,
|
|
39
|
+
starts_direct_command,
|
|
40
|
+
strict_uri_credentials,
|
|
41
|
+
strict_uri_query,
|
|
42
|
+
uri_host,
|
|
43
|
+
reject_stdin_conflict,
|
|
44
|
+
)
|
|
45
|
+
from ..shell import (
|
|
46
|
+
BASH,
|
|
47
|
+
CMD,
|
|
48
|
+
FISH,
|
|
49
|
+
POWERSHELL,
|
|
50
|
+
POSIX_SHELL,
|
|
51
|
+
PWSH,
|
|
52
|
+
ZSH,
|
|
53
|
+
ShellFlavour,
|
|
54
|
+
ShellFlavourSelection,
|
|
55
|
+
shell_flavour,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
SshKeySource = typing.Union[str, bytes, os.PathLike[str]]
|
|
59
|
+
SshClientKeys = typing.Optional[
|
|
60
|
+
typing.Union[SshKeySource, typing.Sequence[SshKeySource]]
|
|
61
|
+
]
|
|
62
|
+
SshKnownHosts = typing.Optional[
|
|
63
|
+
typing.Union[SshKeySource, typing.Sequence[SshKeySource]]
|
|
64
|
+
]
|
|
65
|
+
PathnameConstructor = typing.Type[typing.Union[PurePath, Pathname]]
|
|
66
|
+
SshShellSelection = typing.Union[ShellFlavourSelection, typing.Literal["auto"]]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _path_flavor_from_connection_string(value: str) -> PathnameConstructor:
|
|
70
|
+
try:
|
|
71
|
+
return {
|
|
72
|
+
"posix": PosixPathname,
|
|
73
|
+
"windows": WindowsPathname,
|
|
74
|
+
}[value]
|
|
75
|
+
except KeyError as exc:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
"path_flavor must be 'posix' or 'windows' in a connection string"
|
|
78
|
+
) from exc
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclasses.dataclass
|
|
82
|
+
class SshConfig(HostConfig, schemes=("ssh",)):
|
|
83
|
+
"""Explicit SSH transport, authentication, and target-shell settings."""
|
|
84
|
+
|
|
85
|
+
host: str
|
|
86
|
+
port: int = 22
|
|
87
|
+
username: str = "root"
|
|
88
|
+
password: typing.Optional[str] = dataclasses.field(default=None, repr=False)
|
|
89
|
+
client_keys: SshClientKeys = dataclasses.field(default=None, repr=False)
|
|
90
|
+
executable: typing.Optional[str] = None
|
|
91
|
+
known_hosts: SshKnownHosts = ()
|
|
92
|
+
dialect: SshShellSelection = POSIX_SHELL
|
|
93
|
+
path_flavor: PathnameConstructor = PosixPathname
|
|
94
|
+
|
|
95
|
+
def __post_init__(self) -> None:
|
|
96
|
+
HostConfig.__init__(self)
|
|
97
|
+
if not self.host:
|
|
98
|
+
raise ValueError("host must not be empty")
|
|
99
|
+
if not 1 <= self.port <= 65535:
|
|
100
|
+
raise ValueError("port must be between 1 and 65535")
|
|
101
|
+
if self.dialect != "auto":
|
|
102
|
+
self.dialect = shell_flavour(self.dialect)
|
|
103
|
+
elif self.executable is not None:
|
|
104
|
+
raise ValueError("executable cannot be combined with dialect='auto'")
|
|
105
|
+
if not isinstance(self.path_flavor, type) or not issubclass(
|
|
106
|
+
self.path_flavor, (Pathname, PurePath)
|
|
107
|
+
):
|
|
108
|
+
raise TypeError("path_flavor must be a pure-path class")
|
|
109
|
+
if self.path_flavor is PurePath:
|
|
110
|
+
raise TypeError(
|
|
111
|
+
"bare PurePath uses the local OS; choose a concrete path flavor"
|
|
112
|
+
)
|
|
113
|
+
if not issubclass(self.path_flavor, (PurePosixPath, PureWindowsPath)):
|
|
114
|
+
raise TypeError("path_flavor must use POSIX or Windows path semantics")
|
|
115
|
+
|
|
116
|
+
def connect_opts(self) -> typing.Dict[str, object]:
|
|
117
|
+
opts: typing.Dict[str, object] = {"username": self.username or "root"}
|
|
118
|
+
if self.password is not None:
|
|
119
|
+
opts["password"] = self.password
|
|
120
|
+
if self.client_keys is not None:
|
|
121
|
+
keys = self.client_keys
|
|
122
|
+
if isinstance(keys, (str, bytes, os.PathLike)):
|
|
123
|
+
keys = [keys]
|
|
124
|
+
opts["client_keys"] = keys
|
|
125
|
+
if self.known_hosts != ():
|
|
126
|
+
opts["known_hosts"] = self.known_hosts
|
|
127
|
+
return opts
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def connection_uri(self) -> str:
|
|
131
|
+
user = f"{quote(self.username, safe='')}@" if self.username else ""
|
|
132
|
+
path_flavor = (
|
|
133
|
+
"windows" if issubclass(self.path_flavor, PureWindowsPath) else "posix"
|
|
134
|
+
)
|
|
135
|
+
query = {"dialect": self.dialect, "path_flavor": path_flavor}
|
|
136
|
+
if self.executable:
|
|
137
|
+
query["executable"] = self.executable
|
|
138
|
+
return (
|
|
139
|
+
f"ssh://{user}{uri_host(self.host)}:{self.port or 22}"
|
|
140
|
+
f"?{urlencode(query)}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def _from_parsed_uri(cls, parsed, **credentials: object) -> SshConfig:
|
|
145
|
+
strict_uri_credentials(credentials, ("password", "client_keys", "known_hosts"))
|
|
146
|
+
query = strict_uri_query(parsed, {"dialect", "path_flavor", "executable"})
|
|
147
|
+
if not parsed.hostname or parsed.path not in ("", "/"):
|
|
148
|
+
raise ValueError("SSH URI requires a host and no path")
|
|
149
|
+
return cls(
|
|
150
|
+
host=parsed.hostname,
|
|
151
|
+
port=parsed.port or 22,
|
|
152
|
+
username=unquote(parsed.username or "") or "root",
|
|
153
|
+
password=typing.cast(typing.Optional[str], credentials.get("password")),
|
|
154
|
+
client_keys=typing.cast(SshClientKeys, credentials.get("client_keys")),
|
|
155
|
+
executable=query.get("executable") or None,
|
|
156
|
+
known_hosts=typing.cast(SshKnownHosts, credentials.get("known_hosts", ())),
|
|
157
|
+
dialect=(
|
|
158
|
+
"auto"
|
|
159
|
+
if query.get("dialect") == "auto"
|
|
160
|
+
else shell_flavour(query.get("dialect", POSIX_SHELL.name))
|
|
161
|
+
),
|
|
162
|
+
path_flavor=_path_flavor_from_connection_string(
|
|
163
|
+
query.get("path_flavor", "posix")
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _create_host(self):
|
|
168
|
+
from .system import PosixHost, WindowsHost
|
|
169
|
+
|
|
170
|
+
transport = self._create_transport()
|
|
171
|
+
host_type = (
|
|
172
|
+
WindowsHost if issubclass(self.path_flavor, PureWindowsPath) else PosixHost
|
|
173
|
+
)
|
|
174
|
+
return host_type(
|
|
175
|
+
self,
|
|
176
|
+
executor_providers=(SshExecutorProvider(transport),),
|
|
177
|
+
path_providers=(SftpPathProvider(transport),),
|
|
178
|
+
shell=(
|
|
179
|
+
self.dialect
|
|
180
|
+
if self.dialect != "auto"
|
|
181
|
+
else (lambda: transport.shell_flavour)
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def _create_transport(self) -> _SshTransport:
|
|
186
|
+
"""Create the private SSH service shared by executor/path providers."""
|
|
187
|
+
return _SshTransport(self)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class _SshTransport:
|
|
191
|
+
"""A host reached over SSH, with an explicitly configured command dialect."""
|
|
192
|
+
|
|
193
|
+
def __init__(self, config: SshConfig) -> None:
|
|
194
|
+
self.config = config
|
|
195
|
+
self._ssh: typing.Optional[SshConnection] = None
|
|
196
|
+
self._ssh_lock = threading.RLock()
|
|
197
|
+
self._executor = SshExecutor(lambda: self.ssh)
|
|
198
|
+
self._resolved_dialect: typing.Optional[
|
|
199
|
+
typing.Tuple[typing.Tuple[object, ...], ShellFlavour, typing.Optional[str]]
|
|
200
|
+
] = None
|
|
201
|
+
self._sftp_backend: typing.Optional[object] = None
|
|
202
|
+
self._sftp_sources: typing.Set[object] = set()
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def capabilities(self) -> typing.FrozenSet[str]:
|
|
206
|
+
return frozenset(("run", "path", "spawn", "tty"))
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def shell_flavour(self) -> ShellFlavour:
|
|
210
|
+
selection = self.config.dialect
|
|
211
|
+
if selection != "auto":
|
|
212
|
+
return typing.cast(ShellFlavour, selection)
|
|
213
|
+
key = (selection, self.config.path_flavor, self.config.executable)
|
|
214
|
+
with self._ssh_lock:
|
|
215
|
+
if self._resolved_dialect is not None and self._resolved_dialect[0] == key:
|
|
216
|
+
return self._resolved_dialect[1]
|
|
217
|
+
resolved, executable = (
|
|
218
|
+
self._detect_windows_shell()
|
|
219
|
+
if issubclass(self.config.path_flavor, PureWindowsPath)
|
|
220
|
+
else self._detect_posix_shell()
|
|
221
|
+
)
|
|
222
|
+
self._resolved_dialect = (key, resolved, executable)
|
|
223
|
+
return resolved
|
|
224
|
+
|
|
225
|
+
def _detect_posix_shell(self) -> typing.Tuple[ShellFlavour, str]:
|
|
226
|
+
result = self.executor(
|
|
227
|
+
"printf '%s\\n' \"$SHELL\"",
|
|
228
|
+
check=False,
|
|
229
|
+
text=True,
|
|
230
|
+
timeout=5,
|
|
231
|
+
)
|
|
232
|
+
if result.returncode:
|
|
233
|
+
raise RuntimeError("unable to detect the remote POSIX login shell")
|
|
234
|
+
name = (result.stdout or "").strip().replace("\\", "/").rsplit("/", 1)[-1]
|
|
235
|
+
executable = (result.stdout or "").strip()
|
|
236
|
+
try:
|
|
237
|
+
return {
|
|
238
|
+
"sh": POSIX_SHELL,
|
|
239
|
+
"dash": POSIX_SHELL,
|
|
240
|
+
"bash": BASH,
|
|
241
|
+
"zsh": ZSH,
|
|
242
|
+
"fish": FISH,
|
|
243
|
+
}[name.casefold()], executable
|
|
244
|
+
except KeyError as exc:
|
|
245
|
+
raise RuntimeError(
|
|
246
|
+
f"unsupported or undetected remote POSIX shell: {name or '<empty>'}"
|
|
247
|
+
) from exc
|
|
248
|
+
|
|
249
|
+
def _detect_windows_shell(self) -> typing.Tuple[ShellFlavour, str]:
|
|
250
|
+
probes = (
|
|
251
|
+
(
|
|
252
|
+
PWSH,
|
|
253
|
+
"pwsh -NoProfile -NonInteractive -Command "
|
|
254
|
+
'"Write-Output HOSTCTL_PWSH_7"',
|
|
255
|
+
"HOSTCTL_PWSH_7",
|
|
256
|
+
),
|
|
257
|
+
(
|
|
258
|
+
POWERSHELL,
|
|
259
|
+
"powershell.exe -NoProfile -NonInteractive -Command "
|
|
260
|
+
'"Write-Output HOSTCTL_POWERSHELL_5"',
|
|
261
|
+
"HOSTCTL_POWERSHELL_5",
|
|
262
|
+
),
|
|
263
|
+
(CMD, 'cmd.exe /d /s /c "echo HOSTCTL_CMD"', "HOSTCTL_CMD"),
|
|
264
|
+
)
|
|
265
|
+
for flavour, command, marker in probes:
|
|
266
|
+
try:
|
|
267
|
+
result = self.executor(
|
|
268
|
+
command,
|
|
269
|
+
check=False,
|
|
270
|
+
text=True,
|
|
271
|
+
timeout=5,
|
|
272
|
+
)
|
|
273
|
+
except subprocess.TimeoutExpired:
|
|
274
|
+
continue
|
|
275
|
+
if result.returncode == 0 and (result.stdout or "").strip() == marker:
|
|
276
|
+
executable = {
|
|
277
|
+
PWSH: "pwsh",
|
|
278
|
+
POWERSHELL: "powershell.exe",
|
|
279
|
+
CMD: "cmd.exe",
|
|
280
|
+
}[flavour]
|
|
281
|
+
return flavour, executable
|
|
282
|
+
raise RuntimeError("unable to detect a supported remote Windows shell")
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def executor(self) -> SshExecutor:
|
|
286
|
+
return self._executor
|
|
287
|
+
|
|
288
|
+
def info(self) -> HostInfo:
|
|
289
|
+
flavour = self.shell_flavour
|
|
290
|
+
result = self.run(
|
|
291
|
+
flavour.info_script,
|
|
292
|
+
check=False,
|
|
293
|
+
encoding="utf-8",
|
|
294
|
+
)
|
|
295
|
+
return parse_host_info(result.stdout)
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def ssh(self) -> SshConnection:
|
|
299
|
+
"""The lazily opened and reused asyncssh connection."""
|
|
300
|
+
with self._ssh_lock:
|
|
301
|
+
if self._ssh is None or self._ssh.is_closed():
|
|
302
|
+
from .. import _async
|
|
303
|
+
|
|
304
|
+
log.debug(
|
|
305
|
+
"opening SSH connection to %s",
|
|
306
|
+
ProviderSelector.redact(self.config.connection_uri),
|
|
307
|
+
)
|
|
308
|
+
try:
|
|
309
|
+
self._ssh = _async.async_to_sync(
|
|
310
|
+
_async.asyncssh().connect(
|
|
311
|
+
self.config.host,
|
|
312
|
+
port=self.config.port or 22,
|
|
313
|
+
**self.config.connect_opts(),
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
except Exception as exc:
|
|
317
|
+
log.debug(
|
|
318
|
+
"SSH connection to %s failed: %s",
|
|
319
|
+
ProviderSelector.redact(self.config.connection_uri),
|
|
320
|
+
type(exc).__name__,
|
|
321
|
+
)
|
|
322
|
+
normalized = _async.normalize_asyncssh_error(exc)
|
|
323
|
+
if normalized is exc:
|
|
324
|
+
raise
|
|
325
|
+
raise normalized from exc
|
|
326
|
+
log.debug(
|
|
327
|
+
"SSH connection to %s established",
|
|
328
|
+
ProviderSelector.redact(self.config.connection_uri),
|
|
329
|
+
)
|
|
330
|
+
return self._ssh
|
|
331
|
+
|
|
332
|
+
def connect(self) -> None:
|
|
333
|
+
_ = self.ssh
|
|
334
|
+
|
|
335
|
+
def close(self) -> None:
|
|
336
|
+
with self._ssh_lock:
|
|
337
|
+
self._invalidate_sftp()
|
|
338
|
+
if self._ssh is None:
|
|
339
|
+
return
|
|
340
|
+
connection, self._ssh = self._ssh, None
|
|
341
|
+
log.debug(
|
|
342
|
+
"closing SSH connection to %s",
|
|
343
|
+
ProviderSelector.redact(self.config.connection_uri),
|
|
344
|
+
)
|
|
345
|
+
from .. import _async
|
|
346
|
+
|
|
347
|
+
async def close_connection() -> None:
|
|
348
|
+
connection.close()
|
|
349
|
+
await connection.wait_closed()
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
_async.async_to_sync(close_connection())
|
|
353
|
+
except Exception as exc:
|
|
354
|
+
normalized = _async.normalize_asyncssh_error(exc)
|
|
355
|
+
if normalized is exc:
|
|
356
|
+
raise
|
|
357
|
+
raise normalized from exc
|
|
358
|
+
|
|
359
|
+
def _invalidate_sftp(self) -> None:
|
|
360
|
+
backend, sources = self._sftp_backend, tuple(self._sftp_sources)
|
|
361
|
+
self._sftp_backend = None
|
|
362
|
+
self._sftp_sources.clear()
|
|
363
|
+
if backend is None:
|
|
364
|
+
return
|
|
365
|
+
invalidate = getattr(backend, "invalidate", None)
|
|
366
|
+
if callable(invalidate):
|
|
367
|
+
for source in sources:
|
|
368
|
+
invalidate(source)
|
|
369
|
+
return
|
|
370
|
+
# pathlib_next 0.8.x exposes cache invalidation internally while its
|
|
371
|
+
# public backend API remains intentionally small. Use that hook when
|
|
372
|
+
# available; dropping our backend reference is the safe fallback.
|
|
373
|
+
try:
|
|
374
|
+
module = importlib.import_module("pathlib_next.uri.schemes.sftp._asyncssh")
|
|
375
|
+
cache = getattr(module, "_CACHE", None)
|
|
376
|
+
invalidate_cache = getattr(cache, "invalidate", None)
|
|
377
|
+
if callable(invalidate_cache):
|
|
378
|
+
for source in sources:
|
|
379
|
+
invalidate_cache((backend, source))
|
|
380
|
+
except (ImportError, AttributeError):
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
def path(self, *segments: PathLike) -> HostPath:
|
|
384
|
+
from pathlib_next.uri.schemes.sftp import AsyncsshSftpBackend, SftpPath
|
|
385
|
+
|
|
386
|
+
remote_path = self.config.path_flavor(*segments).as_posix()
|
|
387
|
+
if not remote_path.startswith("/"):
|
|
388
|
+
remote_path = "/" + remote_path
|
|
389
|
+
with self._ssh_lock:
|
|
390
|
+
if self._sftp_backend is None:
|
|
391
|
+
self._sftp_backend = AsyncsshSftpBackend(
|
|
392
|
+
connect_opts=self.config.connect_opts()
|
|
393
|
+
)
|
|
394
|
+
path = SftpPath(
|
|
395
|
+
f"sftp://{uri_host(self.config.host)}:{self.config.port or 22}{remote_path}",
|
|
396
|
+
backend=self._sftp_backend,
|
|
397
|
+
)
|
|
398
|
+
self._sftp_sources.add(path.source)
|
|
399
|
+
return path
|
|
400
|
+
|
|
401
|
+
def run(
|
|
402
|
+
self,
|
|
403
|
+
*cmds: Command,
|
|
404
|
+
bufsize: int = -1,
|
|
405
|
+
executable: typing.Optional[str] = None,
|
|
406
|
+
stdin: typing.Optional[FileHandle] = None,
|
|
407
|
+
stdout: typing.Optional[FileHandle] = None,
|
|
408
|
+
stderr: typing.Optional[FileHandle] = None,
|
|
409
|
+
cwd: typing.Optional[PathLike] = None,
|
|
410
|
+
env: typing.Optional[Environment] = None,
|
|
411
|
+
capture_output: CaptureOutput = True,
|
|
412
|
+
check: bool = True,
|
|
413
|
+
encoding: typing.Optional[str] = None,
|
|
414
|
+
errors: typing.Optional[str] = None,
|
|
415
|
+
input: Input = None,
|
|
416
|
+
timeout: typing.Optional[float] = None,
|
|
417
|
+
text: typing.Optional[bool] = None,
|
|
418
|
+
) -> subprocess.CompletedProcess:
|
|
419
|
+
reject_stdin_conflict(input, stdin)
|
|
420
|
+
direct = starts_direct_command(cmds)
|
|
421
|
+
if direct is not None:
|
|
422
|
+
command, args = direct
|
|
423
|
+
cmds = ((command, *args),)
|
|
424
|
+
if text and encoding is None:
|
|
425
|
+
encoding = "utf-8"
|
|
426
|
+
selected_flavour = self.shell_flavour
|
|
427
|
+
selected_executable = executable or self.config.executable
|
|
428
|
+
if self.config.dialect == "auto" and selected_executable is None:
|
|
429
|
+
assert self._resolved_dialect is not None
|
|
430
|
+
selected_executable = self._resolved_dialect[2]
|
|
431
|
+
shell_command = selected_flavour.command(
|
|
432
|
+
cmds,
|
|
433
|
+
executable=selected_executable,
|
|
434
|
+
cwd=cwd,
|
|
435
|
+
env=env,
|
|
436
|
+
)
|
|
437
|
+
remote_command = shell_command.command
|
|
438
|
+
remote_env = shell_command.environment
|
|
439
|
+
|
|
440
|
+
return self.executor(
|
|
441
|
+
remote_command,
|
|
442
|
+
bufsize=bufsize,
|
|
443
|
+
stdin=stdin,
|
|
444
|
+
stdout=stdout,
|
|
445
|
+
stderr=stderr,
|
|
446
|
+
env=remote_env,
|
|
447
|
+
capture_output=capture_output,
|
|
448
|
+
check=check,
|
|
449
|
+
encoding=encoding,
|
|
450
|
+
errors=errors,
|
|
451
|
+
input=input,
|
|
452
|
+
timeout=timeout,
|
|
453
|
+
text=text,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def spawn(
|
|
457
|
+
self,
|
|
458
|
+
*cmds: Command,
|
|
459
|
+
executable: typing.Optional[str] = None,
|
|
460
|
+
cwd: typing.Optional[PathLike] = None,
|
|
461
|
+
env: typing.Optional[Environment] = None,
|
|
462
|
+
terminal: TerminalRequest = None,
|
|
463
|
+
encoding: typing.Optional[str] = None,
|
|
464
|
+
errors: typing.Optional[str] = None,
|
|
465
|
+
) -> Process:
|
|
466
|
+
"""Start a persistent SSH process, optionally allocating a PTY."""
|
|
467
|
+
if cmds:
|
|
468
|
+
selected_flavour = self.shell_flavour
|
|
469
|
+
selected_executable = executable or self.config.executable
|
|
470
|
+
if self.config.dialect == "auto" and selected_executable is None:
|
|
471
|
+
assert self._resolved_dialect is not None
|
|
472
|
+
selected_executable = self._resolved_dialect[2]
|
|
473
|
+
shell_command = selected_flavour.command(
|
|
474
|
+
cmds,
|
|
475
|
+
executable=selected_executable,
|
|
476
|
+
cwd=cwd,
|
|
477
|
+
env=env,
|
|
478
|
+
)
|
|
479
|
+
command: typing.Optional[str] = shell_command.command
|
|
480
|
+
remote_env = shell_command.environment
|
|
481
|
+
else:
|
|
482
|
+
if cwd is not None or env is not None:
|
|
483
|
+
raise ValueError("cwd and env require a command when spawning")
|
|
484
|
+
command = executable
|
|
485
|
+
remote_env = None
|
|
486
|
+
|
|
487
|
+
from ..process import terminal_options
|
|
488
|
+
|
|
489
|
+
selected_terminal = terminal_options(terminal)
|
|
490
|
+
options: typing.Dict[str, object] = {"env": remote_env, "encoding": encoding}
|
|
491
|
+
if encoding is not None or errors is not None:
|
|
492
|
+
options["encoding"] = encoding or "utf-8"
|
|
493
|
+
if errors is not None:
|
|
494
|
+
options["errors"] = errors
|
|
495
|
+
if selected_terminal is not None:
|
|
496
|
+
options.update(
|
|
497
|
+
request_pty=True,
|
|
498
|
+
term_type=selected_terminal.term_type,
|
|
499
|
+
term_size=selected_terminal.size,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
from .. import _async
|
|
503
|
+
|
|
504
|
+
try:
|
|
505
|
+
process = _async.async_to_sync(self.ssh.create_process(command, **options))
|
|
506
|
+
except Exception as exc:
|
|
507
|
+
normalized = _async.normalize_asyncssh_error(exc, command=command)
|
|
508
|
+
if normalized is exc:
|
|
509
|
+
raise
|
|
510
|
+
raise normalized from exc
|
|
511
|
+
return SshProcess(typing.cast(typing.Any, process), command)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
class SshExecutorProvider(ExecutorProvider):
|
|
515
|
+
"""Lifecycle-owning SSH command provider used by :class:`PosixHost`."""
|
|
516
|
+
|
|
517
|
+
def __init__(self, transport: _SshTransport):
|
|
518
|
+
self.transport = transport
|
|
519
|
+
# SSH receives one finalized command string; argv arguments are
|
|
520
|
+
# rendered by the host shell before dispatch.
|
|
521
|
+
super().__init__("ssh", transport.executor)
|
|
522
|
+
|
|
523
|
+
def probe(self):
|
|
524
|
+
return ProviderProbe("available", capabilities=self.capabilities)
|
|
525
|
+
|
|
526
|
+
def connect(self):
|
|
527
|
+
try:
|
|
528
|
+
self.transport.connect()
|
|
529
|
+
except (ConnectionError, TimeoutError) as exc:
|
|
530
|
+
log.debug(
|
|
531
|
+
"SSH provider declining before dispatch: %s: %s",
|
|
532
|
+
type(exc).__name__,
|
|
533
|
+
ProviderSelector.redact(exc),
|
|
534
|
+
)
|
|
535
|
+
raise OperationNotStarted(
|
|
536
|
+
"SSH connection failed before dispatch", cause=exc
|
|
537
|
+
) from exc
|
|
538
|
+
|
|
539
|
+
def close(self):
|
|
540
|
+
self.transport.close()
|
|
541
|
+
|
|
542
|
+
def info(self):
|
|
543
|
+
return self.transport.info()
|
|
544
|
+
|
|
545
|
+
@property
|
|
546
|
+
def shell_executable(self):
|
|
547
|
+
resolved = self.transport._resolved_dialect
|
|
548
|
+
return resolved[2] if resolved is not None else None
|
|
549
|
+
|
|
550
|
+
def spawn(self, *args, **options):
|
|
551
|
+
return self.transport.spawn(*args, **options)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
class SftpPathProvider(PathProvider):
|
|
555
|
+
"""SFTP path provider sharing the SSH transport lifecycle."""
|
|
556
|
+
|
|
557
|
+
def __init__(self, transport: _SshTransport):
|
|
558
|
+
self.transport = transport
|
|
559
|
+
super().__init__(
|
|
560
|
+
"sftp", lambda *segments: transport.path(*segments), capabilities=("path",)
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
# Lifecycle is owned by SshExecutorProvider for the shared transport.
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def ssh_providers(
|
|
567
|
+
config: SshConfig,
|
|
568
|
+
) -> typing.Tuple[SshExecutorProvider, SftpPathProvider]:
|
|
569
|
+
"""Build an executor and path provider sharing one SSH transport.
|
|
570
|
+
|
|
571
|
+
This is the supported way to compose SSH into a host you assemble
|
|
572
|
+
yourself, rather than taking the finished `PosixHost` that
|
|
573
|
+
`SshConfig._create_host()` returns:
|
|
574
|
+
|
|
575
|
+
executors, paths = [], []
|
|
576
|
+
run_provider, path_provider = ssh_providers(config.ssh)
|
|
577
|
+
executors.append(run_provider)
|
|
578
|
+
paths.append(path_provider)
|
|
579
|
+
|
|
580
|
+
Both providers **must** share one transport: that is what makes them share
|
|
581
|
+
a connection and a lifecycle, and `SshExecutorProvider` is the one that
|
|
582
|
+
owns close. Assembling them by hand from two transports type-checks and
|
|
583
|
+
silently opens two connections, only one of which is ever closed -- which
|
|
584
|
+
is precisely why this returns a pair instead of exposing the transport.
|
|
585
|
+
"""
|
|
586
|
+
transport = _SshTransport(config)
|
|
587
|
+
return SshExecutorProvider(transport), SftpPathProvider(transport)
|