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/__init__.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""hostctl -- run commands and access files on a host, local or remote.
|
|
2
|
+
|
|
3
|
+
Protocol-agnostic host management: concrete hosts expose only the operations
|
|
4
|
+
their providers support. Shell dialect and path flavor remain explicit where
|
|
5
|
+
the transport cannot identify them.
|
|
6
|
+
|
|
7
|
+
``__all__`` is the package's promise: every name here is one a caller must be
|
|
8
|
+
able to *write* -- to construct a host or config, to catch an exception, to
|
|
9
|
+
annotate its own code, to pass as an argument, or to implement at a documented
|
|
10
|
+
extension point. Concrete backends, transport adapters, and result types the
|
|
11
|
+
library only ever hands back are reachable from their defining submodules
|
|
12
|
+
(``hostctl.host.qemu``, ``hostctl.provider.transports``, ``hostctl.process``,
|
|
13
|
+
and so on) but are not part of the stable surface.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
20
|
+
|
|
21
|
+
__version__ = _version("hostctl")
|
|
22
|
+
except PackageNotFoundError: # not installed (e.g. running from a bare checkout)
|
|
23
|
+
__version__ = "0.0.0.dev0"
|
|
24
|
+
|
|
25
|
+
from .executor import (
|
|
26
|
+
Executor as Executor,
|
|
27
|
+
ExecutorCapability as ExecutorCapability,
|
|
28
|
+
ExecutorCommand as ExecutorCommand,
|
|
29
|
+
ExecutionOptions as ExecutionOptions,
|
|
30
|
+
LocalExecutor as LocalExecutor,
|
|
31
|
+
pypsrp_available as pypsrp_available,
|
|
32
|
+
require_pypsrp as require_pypsrp,
|
|
33
|
+
)
|
|
34
|
+
from .host import (
|
|
35
|
+
ContainerConfig as ContainerConfig,
|
|
36
|
+
ContainerHost as ContainerHost,
|
|
37
|
+
Host as Host,
|
|
38
|
+
HostConfig as HostConfig,
|
|
39
|
+
HostInfo as HostInfo,
|
|
40
|
+
HostPath as HostPath,
|
|
41
|
+
LocalConfig as LocalConfig,
|
|
42
|
+
LocalHost as LocalHost,
|
|
43
|
+
parse_credentials as parse_credentials,
|
|
44
|
+
redact_uri as redact_uri,
|
|
45
|
+
ssh_providers as ssh_providers,
|
|
46
|
+
strict_uri_credentials as strict_uri_credentials,
|
|
47
|
+
strict_uri_query as strict_uri_query,
|
|
48
|
+
uri_host as uri_host,
|
|
49
|
+
winrm_providers as winrm_providers,
|
|
50
|
+
QemuConfig as QemuConfig,
|
|
51
|
+
QemuHost as QemuHost,
|
|
52
|
+
SshConfig as SshConfig,
|
|
53
|
+
SerialConfig as SerialConfig,
|
|
54
|
+
SerialHost as SerialHost,
|
|
55
|
+
WinRMConfig as WinRMConfig,
|
|
56
|
+
WinRMPath as WinRMPath,
|
|
57
|
+
SystemConfig as SystemConfig,
|
|
58
|
+
SystemHost as SystemHost,
|
|
59
|
+
PosixConfig as PosixConfig,
|
|
60
|
+
PosixHost as PosixHost,
|
|
61
|
+
WindowsConfig as WindowsConfig,
|
|
62
|
+
WindowsHost as WindowsHost,
|
|
63
|
+
register_system_provider as register_system_provider,
|
|
64
|
+
IosConfig as IosConfig,
|
|
65
|
+
IosHost as IosHost,
|
|
66
|
+
CompositePosixPath as CompositePosixPath,
|
|
67
|
+
CompositeWindowsPath as CompositeWindowsPath,
|
|
68
|
+
)
|
|
69
|
+
from .provider import (
|
|
70
|
+
ExecutorProvider as ExecutorProvider,
|
|
71
|
+
OperationNotStarted as OperationNotStarted,
|
|
72
|
+
PathProvider as PathProvider,
|
|
73
|
+
ProviderProbe as ProviderProbe,
|
|
74
|
+
ProviderSelection as ProviderSelection,
|
|
75
|
+
ProviderSelector as ProviderSelector,
|
|
76
|
+
SessionInitializer as SessionInitializer,
|
|
77
|
+
)
|
|
78
|
+
from .executor._qga import (
|
|
79
|
+
QgaCommandError as QgaCommandError,
|
|
80
|
+
QgaProtocolError as QgaProtocolError,
|
|
81
|
+
)
|
|
82
|
+
from .process import (
|
|
83
|
+
Process as Process,
|
|
84
|
+
TerminalOptions as TerminalOptions,
|
|
85
|
+
)
|
|
86
|
+
from .serial import (
|
|
87
|
+
ConsoleProtocolError as ConsoleProtocolError,
|
|
88
|
+
LoginStep as LoginStep,
|
|
89
|
+
PromptConsoleProfile as PromptConsoleProfile,
|
|
90
|
+
RawConsoleProfile as RawConsoleProfile,
|
|
91
|
+
SerialConsoleProtocol as SerialConsoleProtocol,
|
|
92
|
+
)
|
|
93
|
+
from .sync import (
|
|
94
|
+
ProgressReader as ProgressReader,
|
|
95
|
+
host_checksum as host_checksum,
|
|
96
|
+
stat_checksum as stat_checksum,
|
|
97
|
+
)
|
|
98
|
+
from .shell import (
|
|
99
|
+
BASH as BASH,
|
|
100
|
+
CMD as CMD,
|
|
101
|
+
FISH as FISH,
|
|
102
|
+
POWERSHELL as POWERSHELL,
|
|
103
|
+
POSIX_SHELL as POSIX_SHELL,
|
|
104
|
+
PWSH as PWSH,
|
|
105
|
+
ZSH as ZSH,
|
|
106
|
+
Shell as Shell,
|
|
107
|
+
ShellFlavour as ShellFlavour,
|
|
108
|
+
ShellOperator as ShellOperator,
|
|
109
|
+
ShellSession as ShellSession,
|
|
110
|
+
register_shell_flavour as register_shell_flavour,
|
|
111
|
+
shell_flavour as shell_flavour,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
__all__ = [
|
|
115
|
+
# Hosts and configuration
|
|
116
|
+
"Host",
|
|
117
|
+
"HostConfig",
|
|
118
|
+
"HostInfo",
|
|
119
|
+
"HostPath",
|
|
120
|
+
"LocalConfig",
|
|
121
|
+
"LocalHost",
|
|
122
|
+
"parse_credentials",
|
|
123
|
+
"redact_uri",
|
|
124
|
+
"ssh_providers",
|
|
125
|
+
"strict_uri_credentials",
|
|
126
|
+
"strict_uri_query",
|
|
127
|
+
"uri_host",
|
|
128
|
+
"winrm_providers",
|
|
129
|
+
"SshConfig",
|
|
130
|
+
"WinRMConfig",
|
|
131
|
+
"WinRMPath",
|
|
132
|
+
"ContainerConfig",
|
|
133
|
+
"ContainerHost",
|
|
134
|
+
"QemuConfig",
|
|
135
|
+
"QemuHost",
|
|
136
|
+
"SerialConfig",
|
|
137
|
+
"SerialHost",
|
|
138
|
+
"SystemConfig",
|
|
139
|
+
"SystemHost",
|
|
140
|
+
"PosixConfig",
|
|
141
|
+
"PosixHost",
|
|
142
|
+
"WindowsConfig",
|
|
143
|
+
"WindowsHost",
|
|
144
|
+
"IosConfig",
|
|
145
|
+
"IosHost",
|
|
146
|
+
# Executors and processes
|
|
147
|
+
"Executor",
|
|
148
|
+
"ExecutionOptions",
|
|
149
|
+
"ExecutorCapability",
|
|
150
|
+
"ExecutorCommand",
|
|
151
|
+
"LocalExecutor",
|
|
152
|
+
"pypsrp_available",
|
|
153
|
+
"require_pypsrp",
|
|
154
|
+
"Process",
|
|
155
|
+
"TerminalOptions",
|
|
156
|
+
"QgaCommandError",
|
|
157
|
+
"QgaProtocolError",
|
|
158
|
+
# Shells and sessions
|
|
159
|
+
"Shell",
|
|
160
|
+
"ShellFlavour",
|
|
161
|
+
"ShellOperator",
|
|
162
|
+
"ShellSession",
|
|
163
|
+
"register_shell_flavour",
|
|
164
|
+
"shell_flavour",
|
|
165
|
+
"BASH",
|
|
166
|
+
"CMD",
|
|
167
|
+
"FISH",
|
|
168
|
+
"POWERSHELL",
|
|
169
|
+
"POSIX_SHELL",
|
|
170
|
+
"PWSH",
|
|
171
|
+
"ZSH",
|
|
172
|
+
# Serial consoles
|
|
173
|
+
"SerialConsoleProtocol",
|
|
174
|
+
"RawConsoleProfile",
|
|
175
|
+
"PromptConsoleProfile",
|
|
176
|
+
"LoginStep",
|
|
177
|
+
"ConsoleProtocolError",
|
|
178
|
+
# Provider composition
|
|
179
|
+
"ExecutorProvider",
|
|
180
|
+
"PathProvider",
|
|
181
|
+
"ProviderProbe",
|
|
182
|
+
"ProviderSelection",
|
|
183
|
+
"ProviderSelector",
|
|
184
|
+
"OperationNotStarted",
|
|
185
|
+
"SessionInitializer",
|
|
186
|
+
"register_system_provider",
|
|
187
|
+
"CompositePosixPath",
|
|
188
|
+
"CompositeWindowsPath",
|
|
189
|
+
# Transfer helpers
|
|
190
|
+
"ProgressReader",
|
|
191
|
+
"host_checksum",
|
|
192
|
+
"stat_checksum",
|
|
193
|
+
"__version__",
|
|
194
|
+
]
|
hostctl/__main__.py
ADDED
hostctl/_async.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Run an awaitable to completion from synchronous code.
|
|
2
|
+
|
|
3
|
+
``asyncssh`` is fully async, but :class:`~hostctl.host.Host` presents a
|
|
4
|
+
synchronous API, so its direct-asyncssh operations (opening the connection,
|
|
5
|
+
running a remote command) are driven to completion here. SFTP is NOT among
|
|
6
|
+
them: :meth:`~hostctl.host.Host.path` uses `pathlib_next`'s own ``SftpPath``
|
|
7
|
+
(with its own async bridge), not this one.
|
|
8
|
+
|
|
9
|
+
The bridge is a single **shared background event loop** running in a daemon
|
|
10
|
+
thread; each call submits its coroutine with ``run_coroutine_threadsafe`` and
|
|
11
|
+
blocks on the result. This works whether or not the *calling* thread already
|
|
12
|
+
has a running loop (the coroutine runs on the dedicated background loop, not
|
|
13
|
+
the caller's), so no ``nest_asyncio`` re-entrancy hack is needed -- and it
|
|
14
|
+
avoids the deprecated ``asyncio.get_event_loop()``/policy APIs (slated for
|
|
15
|
+
removal in 3.16). asyncssh objects created through this bridge are bound to
|
|
16
|
+
this loop, so all subsequent operations on them must also go through here.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio as _asyncio
|
|
22
|
+
import os as _os
|
|
23
|
+
import subprocess as _subprocess
|
|
24
|
+
import sys as _sys
|
|
25
|
+
import threading as _threading
|
|
26
|
+
import typing as _ty
|
|
27
|
+
|
|
28
|
+
_T = _ty.TypeVar("_T")
|
|
29
|
+
|
|
30
|
+
_loop: _ty.Optional[_asyncio.AbstractEventLoop] = None
|
|
31
|
+
_loop_pid: _ty.Optional[int] = None
|
|
32
|
+
_loop_thread: _ty.Optional[_threading.Thread] = None
|
|
33
|
+
_loop_lock = _threading.Lock()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def asyncssh():
|
|
37
|
+
"""Import ``asyncssh`` on demand (the ``ssh`` extra)."""
|
|
38
|
+
try:
|
|
39
|
+
import asyncssh
|
|
40
|
+
except ImportError as exc: # pragma: no cover - only without the extra
|
|
41
|
+
raise ImportError(
|
|
42
|
+
"SSH support requires the 'ssh' extra: pip install hostctl[ssh]"
|
|
43
|
+
) from exc
|
|
44
|
+
return asyncssh
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalize_asyncssh_error(
|
|
48
|
+
exc: Exception,
|
|
49
|
+
*,
|
|
50
|
+
command: _ty.Optional[str] = None,
|
|
51
|
+
timeout: _ty.Optional[float] = None,
|
|
52
|
+
) -> Exception:
|
|
53
|
+
"""Map AsyncSSH transport/process errors to hostctl's standard contract."""
|
|
54
|
+
module = asyncssh()
|
|
55
|
+
if isinstance(exc, (TimeoutError, module.TimeoutError)):
|
|
56
|
+
if command is not None:
|
|
57
|
+
return _subprocess.TimeoutExpired(
|
|
58
|
+
command,
|
|
59
|
+
timeout,
|
|
60
|
+
output=getattr(exc, "stdout", None),
|
|
61
|
+
stderr=getattr(exc, "stderr", None),
|
|
62
|
+
)
|
|
63
|
+
# Connection/open timeouts have no subprocess command to attach, but
|
|
64
|
+
# must still cross the public boundary as a builtin timeout rather
|
|
65
|
+
# than leaking an AsyncSSH-specific exception.
|
|
66
|
+
return TimeoutError(str(exc))
|
|
67
|
+
if isinstance(exc, module.PermissionDenied):
|
|
68
|
+
return PermissionError(str(exc))
|
|
69
|
+
if isinstance(exc, module.ProcessError):
|
|
70
|
+
returncode = getattr(exc, "returncode", None)
|
|
71
|
+
if returncode is None:
|
|
72
|
+
returncode = -1
|
|
73
|
+
return _subprocess.CalledProcessError(
|
|
74
|
+
returncode,
|
|
75
|
+
command or getattr(exc, "command", None),
|
|
76
|
+
output=getattr(exc, "stdout", None),
|
|
77
|
+
stderr=getattr(exc, "stderr", None),
|
|
78
|
+
)
|
|
79
|
+
connection_errors = (
|
|
80
|
+
module.ChannelOpenError,
|
|
81
|
+
module.DisconnectError,
|
|
82
|
+
module.HostKeyNotVerifiable,
|
|
83
|
+
module.KeyExchangeFailed,
|
|
84
|
+
module.ProtocolError,
|
|
85
|
+
)
|
|
86
|
+
if isinstance(exc, connection_errors):
|
|
87
|
+
return ConnectionError(str(exc))
|
|
88
|
+
return exc
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _new_loop() -> _asyncio.AbstractEventLoop:
|
|
92
|
+
if _sys.platform == "win32":
|
|
93
|
+
# SelectorEventLoop, not the Windows-default Proactor loop: this
|
|
94
|
+
# bridge only makes plain-TCP SSH client connections (no subprocess
|
|
95
|
+
# pipes), and Proactor's pipe transports emit a benign-but-noisy
|
|
96
|
+
# "Exception ignored in _ProactorBasePipeTransport.__del__" on GC.
|
|
97
|
+
return _asyncio.SelectorEventLoop()
|
|
98
|
+
return _asyncio.new_event_loop()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _ensure_loop() -> _asyncio.AbstractEventLoop:
|
|
102
|
+
global _loop, _loop_pid, _loop_thread
|
|
103
|
+
pid = _os.getpid()
|
|
104
|
+
with _loop_lock:
|
|
105
|
+
if (
|
|
106
|
+
_loop is not None
|
|
107
|
+
and not _loop.is_closed()
|
|
108
|
+
and _loop_pid == pid
|
|
109
|
+
and _loop_thread is not None
|
|
110
|
+
and _loop_thread.is_alive()
|
|
111
|
+
):
|
|
112
|
+
return _loop
|
|
113
|
+
# First call, or a fork()'d child that inherited a now-dead loop thread.
|
|
114
|
+
if _loop is not None and (
|
|
115
|
+
_loop_pid != pid or _loop_thread is None or not _loop_thread.is_alive()
|
|
116
|
+
):
|
|
117
|
+
try:
|
|
118
|
+
_loop.close()
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
loop = _new_loop()
|
|
122
|
+
thread = _threading.Thread(
|
|
123
|
+
target=loop.run_forever,
|
|
124
|
+
name="hostctl-asyncssh-loop",
|
|
125
|
+
daemon=True,
|
|
126
|
+
)
|
|
127
|
+
thread.start()
|
|
128
|
+
_loop, _loop_pid, _loop_thread = loop, pid, thread
|
|
129
|
+
return loop
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def async_to_sync(
|
|
133
|
+
awaitable: _ty.Awaitable[_T],
|
|
134
|
+
) -> _T:
|
|
135
|
+
"""Drive ``awaitable`` to completion on the shared background loop.
|
|
136
|
+
|
|
137
|
+
Safe to call from any thread and from inside an unrelated running event
|
|
138
|
+
loop. Calling from the bridge loop itself is rejected to avoid deadlock.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
running = _asyncio.get_running_loop()
|
|
142
|
+
except RuntimeError:
|
|
143
|
+
running = None
|
|
144
|
+
if running is not None and running is _loop:
|
|
145
|
+
if _asyncio.iscoroutine(awaitable):
|
|
146
|
+
awaitable.close()
|
|
147
|
+
raise RuntimeError("async_to_sync called from the bridge loop thread")
|
|
148
|
+
loop = _ensure_loop()
|
|
149
|
+
if _loop_thread is None or not _loop_thread.is_alive():
|
|
150
|
+
if _asyncio.iscoroutine(awaitable):
|
|
151
|
+
awaitable.close()
|
|
152
|
+
raise RuntimeError("hostctl async bridge thread is not alive")
|
|
153
|
+
future = _asyncio.run_coroutine_threadsafe(_ensure_coro(awaitable), loop)
|
|
154
|
+
return future.result()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _ensure_coro(
|
|
158
|
+
awaitable: _ty.Awaitable[_T],
|
|
159
|
+
) -> _ty.Coroutine[_ty.Any, _ty.Any, _T]:
|
|
160
|
+
"""Adapt any awaitable to a coroutine (``run_coroutine_threadsafe`` requires
|
|
161
|
+
a genuine coroutine object; asyncssh's ``@async_context_manager`` awaitables
|
|
162
|
+
and futures are not)."""
|
|
163
|
+
if _asyncio.iscoroutine(awaitable):
|
|
164
|
+
return awaitable
|
|
165
|
+
|
|
166
|
+
async def _wrap() -> _T:
|
|
167
|
+
return await awaitable
|
|
168
|
+
|
|
169
|
+
return _wrap()
|
hostctl/_cli.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Small stdlib command-line adapter over the public hostctl API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
import dataclasses
|
|
8
|
+
import getpass
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import PurePath
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import typing
|
|
17
|
+
|
|
18
|
+
from pathlib_next import Path as NextPath
|
|
19
|
+
|
|
20
|
+
from .host import Host, HostPath
|
|
21
|
+
|
|
22
|
+
_URI_OPERAND = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_OutputStream = typing.Union[typing.TextIO, typing.BinaryIO]
|
|
26
|
+
_OutputValue = typing.Union[str, bytes]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _write(stream: _OutputStream, value: _OutputValue) -> None:
|
|
30
|
+
if value is None:
|
|
31
|
+
return
|
|
32
|
+
if isinstance(value, bytes):
|
|
33
|
+
binary = getattr(stream, "buffer", stream)
|
|
34
|
+
binary.write(value)
|
|
35
|
+
else:
|
|
36
|
+
stream.write(str(value))
|
|
37
|
+
flush = getattr(stream, "flush", None)
|
|
38
|
+
if flush:
|
|
39
|
+
flush()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _credentials(args: argparse.Namespace) -> dict[str, object]:
|
|
43
|
+
password = os.environ.get("HOSTCTL_PASSWORD")
|
|
44
|
+
if args.ask_password:
|
|
45
|
+
password = getpass.getpass("Password: ")
|
|
46
|
+
return {"password": password} if password is not None else {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _open_host(stack: contextlib.ExitStack, uri: str, credentials: dict[str, object]):
|
|
50
|
+
return stack.enter_context(Host(uri, **credentials))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _path_operand(
|
|
54
|
+
stack: contextlib.ExitStack,
|
|
55
|
+
value: str,
|
|
56
|
+
credentials: dict[str, object],
|
|
57
|
+
):
|
|
58
|
+
"""Resolve ``URI:PATH`` or an ordinary local filesystem path."""
|
|
59
|
+
if not _URI_OPERAND.match(value) or re.match(r"^[A-Za-z]:[\\/]", value):
|
|
60
|
+
return HostPath(value)
|
|
61
|
+
if "://" in value:
|
|
62
|
+
authority_end = value.find("/", value.find("://") + 3)
|
|
63
|
+
search_end = len(value) if authority_end < 0 else authority_end
|
|
64
|
+
separator = value.rfind(":", 0, search_end)
|
|
65
|
+
if separator <= value.find("://") + 2:
|
|
66
|
+
raise ValueError("remote path operand must be URI:PATH")
|
|
67
|
+
else:
|
|
68
|
+
separator = value.find(":", value.find(":") + 1)
|
|
69
|
+
if separator < 0:
|
|
70
|
+
raise ValueError("remote path operand must be URI:PATH")
|
|
71
|
+
uri, path = value[:separator], value[separator + 1 :]
|
|
72
|
+
if not path:
|
|
73
|
+
raise ValueError("remote path operand requires a path")
|
|
74
|
+
return _open_host(stack, uri, credentials).path(path)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _command_run(args: argparse.Namespace, stdout, stderr) -> int:
|
|
78
|
+
command = list(args.command)
|
|
79
|
+
if command[:1] == ["--"]:
|
|
80
|
+
command.pop(0)
|
|
81
|
+
if not command:
|
|
82
|
+
raise ValueError("run requires a command after --")
|
|
83
|
+
with Host(args.uri, **_credentials(args)) as host:
|
|
84
|
+
try:
|
|
85
|
+
command_path = host.shell_flavour.command_path(command[0])
|
|
86
|
+
except NotImplementedError:
|
|
87
|
+
command_path = PurePath(command[0])
|
|
88
|
+
result = host.run(
|
|
89
|
+
command_path,
|
|
90
|
+
*command[1:],
|
|
91
|
+
check=False,
|
|
92
|
+
capture_output=True,
|
|
93
|
+
)
|
|
94
|
+
_write(stdout, result.stdout)
|
|
95
|
+
_write(stderr, result.stderr)
|
|
96
|
+
return int(result.returncode)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _command_ls(args: argparse.Namespace, stdout, stderr) -> int:
|
|
100
|
+
with Host(args.uri, **_credentials(args)) as host:
|
|
101
|
+
for child in host.path(args.path).iterdir():
|
|
102
|
+
_write(stdout, f"{child.name}\n")
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _command_cat(args: argparse.Namespace, stdout, stderr) -> int:
|
|
107
|
+
with Host(args.uri, **_credentials(args)) as host:
|
|
108
|
+
_write(stdout, host.path(args.path).read_bytes())
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _command_cp(args: argparse.Namespace, stdout, stderr) -> int:
|
|
113
|
+
with contextlib.ExitStack() as stack:
|
|
114
|
+
credentials = _credentials(args)
|
|
115
|
+
source = _path_operand(stack, args.source, credentials)
|
|
116
|
+
target = _path_operand(stack, args.target, credentials)
|
|
117
|
+
# Python 3.14 added stdlib pathlib.Path.copy(), which currently wins
|
|
118
|
+
# LocalPath's mixed MRO. Route explicitly through pathlib_next so local
|
|
119
|
+
# and remote operands retain one copy contract on every supported floor.
|
|
120
|
+
NextPath.copy(
|
|
121
|
+
source,
|
|
122
|
+
target,
|
|
123
|
+
overwrite=args.overwrite,
|
|
124
|
+
recursive=args.recursive,
|
|
125
|
+
)
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _command_info(args: argparse.Namespace, stdout, stderr) -> int:
|
|
130
|
+
with Host(args.uri, **_credentials(args)) as host:
|
|
131
|
+
info = host.info()
|
|
132
|
+
value = dataclasses.asdict(info) if dataclasses.is_dataclass(info) else vars(info)
|
|
133
|
+
_write(stdout, json.dumps(value, sort_keys=True) + "\n")
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _command_shell(args: argparse.Namespace, stdout, stderr) -> int:
|
|
138
|
+
with Host(args.uri, **_credentials(args)) as host:
|
|
139
|
+
session = host.shell.session(terminal=True)
|
|
140
|
+
stopped = threading.Event()
|
|
141
|
+
|
|
142
|
+
def pump() -> None:
|
|
143
|
+
try:
|
|
144
|
+
while not stopped.is_set():
|
|
145
|
+
data = session.read(65536)
|
|
146
|
+
if not data:
|
|
147
|
+
return
|
|
148
|
+
_write(stdout, data)
|
|
149
|
+
except (OSError, ValueError):
|
|
150
|
+
if not stopped.is_set():
|
|
151
|
+
raise
|
|
152
|
+
|
|
153
|
+
reader = threading.Thread(target=pump, name="hostctl-shell-output", daemon=True)
|
|
154
|
+
reader.start()
|
|
155
|
+
try:
|
|
156
|
+
for line in sys.stdin:
|
|
157
|
+
session.send(line.rstrip("\r\n"))
|
|
158
|
+
except KeyboardInterrupt:
|
|
159
|
+
return 130
|
|
160
|
+
finally:
|
|
161
|
+
try:
|
|
162
|
+
session.send_eof()
|
|
163
|
+
except NotImplementedError:
|
|
164
|
+
pass
|
|
165
|
+
stopped.set()
|
|
166
|
+
session.close()
|
|
167
|
+
reader.join(timeout=1)
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _parser() -> argparse.ArgumentParser:
|
|
172
|
+
parser = argparse.ArgumentParser(prog="hostctl")
|
|
173
|
+
subcommands = parser.add_subparsers(dest="subcommand", required=True)
|
|
174
|
+
|
|
175
|
+
def host_command(name: str, handler):
|
|
176
|
+
command = subcommands.add_parser(name)
|
|
177
|
+
command.add_argument("--ask-password", action="store_true")
|
|
178
|
+
command.add_argument("uri")
|
|
179
|
+
command.set_defaults(handler=handler)
|
|
180
|
+
return command
|
|
181
|
+
|
|
182
|
+
run = host_command("run", _command_run)
|
|
183
|
+
run.add_argument("command", nargs=argparse.REMAINDER)
|
|
184
|
+
|
|
185
|
+
ls = host_command("ls", _command_ls)
|
|
186
|
+
ls.add_argument("path")
|
|
187
|
+
|
|
188
|
+
cat = host_command("cat", _command_cat)
|
|
189
|
+
cat.add_argument("path")
|
|
190
|
+
|
|
191
|
+
info = host_command("info", _command_info)
|
|
192
|
+
|
|
193
|
+
shell = host_command("shell", _command_shell)
|
|
194
|
+
|
|
195
|
+
cp = subcommands.add_parser("cp")
|
|
196
|
+
cp.add_argument("--ask-password", action="store_true")
|
|
197
|
+
cp.add_argument("--overwrite", action="store_true")
|
|
198
|
+
cp.add_argument("--recursive", action="store_true")
|
|
199
|
+
cp.add_argument("source")
|
|
200
|
+
cp.add_argument("target")
|
|
201
|
+
cp.set_defaults(handler=_command_cp)
|
|
202
|
+
return parser
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def main(
|
|
206
|
+
argv: typing.Optional[typing.Sequence[str]] = None,
|
|
207
|
+
*,
|
|
208
|
+
stdout: typing.Optional[_OutputStream] = None,
|
|
209
|
+
stderr: typing.Optional[_OutputStream] = None,
|
|
210
|
+
) -> int:
|
|
211
|
+
"""Run the CLI and return its process exit status."""
|
|
212
|
+
stdout = sys.stdout if stdout is None else stdout
|
|
213
|
+
stderr = sys.stderr if stderr is None else stderr
|
|
214
|
+
parser = _parser()
|
|
215
|
+
args = parser.parse_args(argv)
|
|
216
|
+
try:
|
|
217
|
+
return int(args.handler(args, stdout, stderr))
|
|
218
|
+
except subprocess.TimeoutExpired as exc:
|
|
219
|
+
_write(stderr, f"hostctl: timed out: {exc}\n")
|
|
220
|
+
return 125
|
|
221
|
+
except PermissionError as exc:
|
|
222
|
+
_write(stderr, f"hostctl: permission denied: {exc}\n")
|
|
223
|
+
return 126
|
|
224
|
+
except FileNotFoundError as exc:
|
|
225
|
+
_write(stderr, f"hostctl: not found: {exc}\n")
|
|
226
|
+
return 127
|
|
227
|
+
except (ConnectionError, OSError, ValueError, NotImplementedError) as exc:
|
|
228
|
+
_write(stderr, f"hostctl: {exc}\n")
|
|
229
|
+
return 125
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
__all__ = ["main"]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Public executor contracts."""
|
|
2
|
+
|
|
3
|
+
from ._common import (
|
|
4
|
+
CaptureOutput as CaptureOutput,
|
|
5
|
+
CommandArgument as CommandArgument,
|
|
6
|
+
Environment as Environment,
|
|
7
|
+
ExecutionOptions as ExecutionOptions,
|
|
8
|
+
Executor as Executor,
|
|
9
|
+
ExecutorCapability as ExecutorCapability,
|
|
10
|
+
ExecutorCommand as ExecutorCommand,
|
|
11
|
+
FileHandle as FileHandle,
|
|
12
|
+
Input as Input,
|
|
13
|
+
PathLike as PathLike,
|
|
14
|
+
normalize_environment as normalize_environment,
|
|
15
|
+
capture_streams as capture_streams,
|
|
16
|
+
reject_stdin_conflict as reject_stdin_conflict,
|
|
17
|
+
)
|
|
18
|
+
from .container import (
|
|
19
|
+
ContainerExecutor as ContainerExecutor,
|
|
20
|
+
ContainerLike as ContainerLike,
|
|
21
|
+
normalize_container_error as normalize_container_error,
|
|
22
|
+
)
|
|
23
|
+
from .ssh import SshConnection as SshConnection, SshExecutor as SshExecutor
|
|
24
|
+
from .local import LocalExecutor as LocalExecutor
|
|
25
|
+
from .serial import (
|
|
26
|
+
SerialExecutor as SerialExecutor,
|
|
27
|
+
SerialFactory as SerialFactory,
|
|
28
|
+
SerialLike as SerialLike,
|
|
29
|
+
SerialSettings as SerialSettings,
|
|
30
|
+
SerialTransport as SerialTransport,
|
|
31
|
+
normalize_serial_error as normalize_serial_error,
|
|
32
|
+
)
|
|
33
|
+
from .qemu import (
|
|
34
|
+
GuestAgentProtocolError as GuestAgentProtocolError,
|
|
35
|
+
QemuExecutor as QemuExecutor,
|
|
36
|
+
)
|
|
37
|
+
from .winrm import (
|
|
38
|
+
NativeWinRMSession as NativeWinRMSession,
|
|
39
|
+
WinRMExecutor as WinRMExecutor,
|
|
40
|
+
WinRMSession as WinRMSession,
|
|
41
|
+
)
|
|
42
|
+
from .psrp import (
|
|
43
|
+
PsrpExecutor as PsrpExecutor,
|
|
44
|
+
pypsrp_available as pypsrp_available,
|
|
45
|
+
require_pypsrp as require_pypsrp,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"CaptureOutput",
|
|
50
|
+
"ContainerExecutor",
|
|
51
|
+
"ContainerLike",
|
|
52
|
+
"CommandArgument",
|
|
53
|
+
"Environment",
|
|
54
|
+
"ExecutionOptions",
|
|
55
|
+
"Executor",
|
|
56
|
+
"ExecutorCapability",
|
|
57
|
+
"ExecutorCommand",
|
|
58
|
+
"FileHandle",
|
|
59
|
+
"Input",
|
|
60
|
+
"LocalExecutor",
|
|
61
|
+
"PathLike",
|
|
62
|
+
"QemuExecutor",
|
|
63
|
+
"GuestAgentProtocolError",
|
|
64
|
+
"NativeWinRMSession",
|
|
65
|
+
"normalize_container_error",
|
|
66
|
+
"normalize_environment",
|
|
67
|
+
"capture_streams",
|
|
68
|
+
"reject_stdin_conflict",
|
|
69
|
+
"normalize_serial_error",
|
|
70
|
+
"SerialExecutor",
|
|
71
|
+
"SerialFactory",
|
|
72
|
+
"SerialLike",
|
|
73
|
+
"SerialSettings",
|
|
74
|
+
"SerialTransport",
|
|
75
|
+
"SshConnection",
|
|
76
|
+
"SshExecutor",
|
|
77
|
+
"WinRMExecutor",
|
|
78
|
+
"WinRMSession",
|
|
79
|
+
"PsrpExecutor",
|
|
80
|
+
"pypsrp_available",
|
|
81
|
+
"require_pypsrp",
|
|
82
|
+
]
|