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,171 @@
|
|
|
1
|
+
"""Docker Engine command executor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import typing
|
|
7
|
+
|
|
8
|
+
from ._common import (
|
|
9
|
+
CaptureOutput,
|
|
10
|
+
CommandArgument,
|
|
11
|
+
Environment,
|
|
12
|
+
Executor,
|
|
13
|
+
ExecutorCapability,
|
|
14
|
+
ExecutorCommand,
|
|
15
|
+
FileHandle,
|
|
16
|
+
Input,
|
|
17
|
+
PathLike,
|
|
18
|
+
dispatch_output,
|
|
19
|
+
normalize_environment,
|
|
20
|
+
capture_streams,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ContainerLike(typing.Protocol):
|
|
25
|
+
"""Small Docker SDK container surface used by :class:`ContainerExecutor`."""
|
|
26
|
+
|
|
27
|
+
def exec_run(self, command: typing.Sequence[str], **options: object) -> object: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ContainerExecutor(Executor[subprocess.CompletedProcess]):
|
|
31
|
+
"""Execute argv directly through a Docker Engine container exec."""
|
|
32
|
+
|
|
33
|
+
executor_capabilities = frozenset(
|
|
34
|
+
(ExecutorCapability.ARGS, ExecutorCapability.CWD, ExecutorCapability.ENV)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
container: typing.Callable[[], ContainerLike],
|
|
40
|
+
*,
|
|
41
|
+
user: typing.Optional[str] = None,
|
|
42
|
+
workdir: typing.Optional[str] = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._container = container
|
|
45
|
+
self._user = user
|
|
46
|
+
self._workdir = workdir
|
|
47
|
+
|
|
48
|
+
def __call__(
|
|
49
|
+
self,
|
|
50
|
+
command: ExecutorCommand,
|
|
51
|
+
*args: CommandArgument,
|
|
52
|
+
bufsize: int = -1,
|
|
53
|
+
stdin: typing.Optional[FileHandle] = None,
|
|
54
|
+
stdout: typing.Optional[FileHandle] = None,
|
|
55
|
+
stderr: typing.Optional[FileHandle] = None,
|
|
56
|
+
cwd: typing.Optional[PathLike] = None,
|
|
57
|
+
env: typing.Optional[Environment] = None,
|
|
58
|
+
capture_output: CaptureOutput = True,
|
|
59
|
+
check: bool = True,
|
|
60
|
+
encoding: typing.Optional[str] = None,
|
|
61
|
+
errors: typing.Optional[str] = None,
|
|
62
|
+
input: Input = None,
|
|
63
|
+
timeout: typing.Optional[float] = None,
|
|
64
|
+
text: typing.Optional[bool] = None,
|
|
65
|
+
**options: object,
|
|
66
|
+
) -> subprocess.CompletedProcess:
|
|
67
|
+
del bufsize
|
|
68
|
+
if options:
|
|
69
|
+
raise TypeError(
|
|
70
|
+
f"unsupported container executor option: {sorted(options)[0]}"
|
|
71
|
+
)
|
|
72
|
+
if stdin is not None or input is not None:
|
|
73
|
+
raise NotImplementedError(
|
|
74
|
+
"buffered container execution does not yet support stdin/input"
|
|
75
|
+
)
|
|
76
|
+
if timeout is not None:
|
|
77
|
+
raise NotImplementedError(
|
|
78
|
+
"Docker Engine exec does not provide a cancellable command timeout"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
stdout, stderr = capture_streams(capture_output, stdout, stderr)
|
|
82
|
+
argv = [str(command)]
|
|
83
|
+
argv.extend(
|
|
84
|
+
value.decode() if isinstance(value, bytes) else str(value) for value in args
|
|
85
|
+
)
|
|
86
|
+
merge_stderr = stderr is subprocess.STDOUT
|
|
87
|
+
exec_options: typing.Dict[str, object] = {
|
|
88
|
+
"stdout": True,
|
|
89
|
+
"stderr": True,
|
|
90
|
+
# Docker's demux=False stream preserves stdout/stderr ordering,
|
|
91
|
+
# which is required when callers request STDERR->STDOUT.
|
|
92
|
+
"demux": not merge_stderr,
|
|
93
|
+
}
|
|
94
|
+
normalized_env = normalize_environment(env)
|
|
95
|
+
if normalized_env is not None:
|
|
96
|
+
exec_options["environment"] = normalized_env
|
|
97
|
+
selected_workdir = str(cwd) if cwd is not None else self._workdir
|
|
98
|
+
if selected_workdir is not None:
|
|
99
|
+
exec_options["workdir"] = selected_workdir
|
|
100
|
+
if self._user is not None:
|
|
101
|
+
exec_options["user"] = self._user
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
result = self._container().exec_run(argv, **exec_options)
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
if _is_not_found(exc):
|
|
107
|
+
raise ConnectionError("container not found") from exc
|
|
108
|
+
normalized = normalize_container_error(exc)
|
|
109
|
+
if normalized is exc:
|
|
110
|
+
raise
|
|
111
|
+
raise normalized from exc
|
|
112
|
+
|
|
113
|
+
returncode = typing.cast(int, getattr(result, "exit_code"))
|
|
114
|
+
output = getattr(result, "output")
|
|
115
|
+
if isinstance(output, tuple):
|
|
116
|
+
out, err = output
|
|
117
|
+
else:
|
|
118
|
+
out, err = output, None
|
|
119
|
+
if out is None:
|
|
120
|
+
out = b""
|
|
121
|
+
if err is None and not merge_stderr:
|
|
122
|
+
err = b""
|
|
123
|
+
if text or encoding is not None or errors is not None:
|
|
124
|
+
codec = encoding or "utf-8"
|
|
125
|
+
out = (
|
|
126
|
+
out.decode(codec, errors or "strict") if isinstance(out, bytes) else out
|
|
127
|
+
)
|
|
128
|
+
err = (
|
|
129
|
+
err.decode(codec, errors or "strict") if isinstance(err, bytes) else err
|
|
130
|
+
)
|
|
131
|
+
if merge_stderr:
|
|
132
|
+
if err:
|
|
133
|
+
out = out + err
|
|
134
|
+
err = None
|
|
135
|
+
out, err = dispatch_output(
|
|
136
|
+
stdout, stderr, out, err, encoding=encoding, errors=errors
|
|
137
|
+
)
|
|
138
|
+
completed = subprocess.CompletedProcess(argv, returncode, out, err)
|
|
139
|
+
if check:
|
|
140
|
+
completed.check_returncode()
|
|
141
|
+
return completed
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def normalize_container_error(exc: Exception) -> Exception:
|
|
145
|
+
"""Map Docker SDK and HTTP failures without importing the optional SDK."""
|
|
146
|
+
name = type(exc).__name__
|
|
147
|
+
status_code = getattr(exc, "status_code", None)
|
|
148
|
+
if status_code is None:
|
|
149
|
+
response = getattr(exc, "response", None)
|
|
150
|
+
status_code = getattr(response, "status_code", None)
|
|
151
|
+
if status_code == 404 or name in ("NotFound",):
|
|
152
|
+
return FileNotFoundError(str(exc))
|
|
153
|
+
if status_code in (401, 403) or name in ("AuthenticationError",):
|
|
154
|
+
return PermissionError(str(exc))
|
|
155
|
+
if name in ("ReadTimeout", "ConnectTimeout", "Timeout"):
|
|
156
|
+
return TimeoutError(str(exc))
|
|
157
|
+
if name in (
|
|
158
|
+
"APIError",
|
|
159
|
+
"ConnectionError",
|
|
160
|
+
"DockerException",
|
|
161
|
+
"MaxRetryError",
|
|
162
|
+
):
|
|
163
|
+
return ConnectionError(str(exc))
|
|
164
|
+
return exc
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _is_not_found(exc: Exception) -> bool:
|
|
168
|
+
status_code = getattr(exc, "status_code", None)
|
|
169
|
+
if status_code is None:
|
|
170
|
+
status_code = getattr(getattr(exc, "response", None), "status_code", None)
|
|
171
|
+
return status_code == 404 or type(exc).__name__ == "NotFound"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Local subprocess executor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import typing
|
|
8
|
+
|
|
9
|
+
from ._common import (
|
|
10
|
+
CaptureOutput,
|
|
11
|
+
CommandArgument,
|
|
12
|
+
Environment,
|
|
13
|
+
Executor,
|
|
14
|
+
ExecutorCapability,
|
|
15
|
+
ExecutorCommand,
|
|
16
|
+
FileHandle,
|
|
17
|
+
Input,
|
|
18
|
+
normalize_environment,
|
|
19
|
+
normalize_input,
|
|
20
|
+
capture_streams,
|
|
21
|
+
reject_stdin_conflict,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LocalExecutor(Executor[subprocess.CompletedProcess]):
|
|
26
|
+
"""Execute direct argv or finalized shell invocations locally."""
|
|
27
|
+
|
|
28
|
+
executor_capabilities = frozenset(
|
|
29
|
+
(ExecutorCapability.ARGS, ExecutorCapability.CWD, ExecutorCapability.ENV)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def __call__(
|
|
33
|
+
self,
|
|
34
|
+
command: ExecutorCommand,
|
|
35
|
+
*args: CommandArgument,
|
|
36
|
+
bufsize: int = -1,
|
|
37
|
+
stdin: typing.Optional[FileHandle] = None,
|
|
38
|
+
stdout: typing.Optional[FileHandle] = None,
|
|
39
|
+
stderr: typing.Optional[FileHandle] = None,
|
|
40
|
+
cwd: typing.Optional[typing.Union[str, os.PathLike[str]]] = None,
|
|
41
|
+
env: typing.Optional[Environment] = None,
|
|
42
|
+
capture_output: CaptureOutput = True,
|
|
43
|
+
check: bool = True,
|
|
44
|
+
encoding: typing.Optional[str] = None,
|
|
45
|
+
errors: typing.Optional[str] = None,
|
|
46
|
+
input: Input = None,
|
|
47
|
+
timeout: typing.Optional[float] = None,
|
|
48
|
+
text: typing.Optional[bool] = None,
|
|
49
|
+
**options: object,
|
|
50
|
+
) -> subprocess.CompletedProcess:
|
|
51
|
+
if options:
|
|
52
|
+
raise TypeError(f"unsupported local executor option: {sorted(options)[0]}")
|
|
53
|
+
reject_stdin_conflict(input, stdin)
|
|
54
|
+
stdout, stderr = capture_streams(capture_output, stdout, stderr)
|
|
55
|
+
argv = [
|
|
56
|
+
os.fspath(command),
|
|
57
|
+
*[
|
|
58
|
+
(
|
|
59
|
+
value.decode()
|
|
60
|
+
if isinstance(value, bytes)
|
|
61
|
+
else (
|
|
62
|
+
os.fspath(value)
|
|
63
|
+
if isinstance(value, os.PathLike)
|
|
64
|
+
else str(value)
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
for value in args
|
|
68
|
+
],
|
|
69
|
+
]
|
|
70
|
+
return subprocess.run(
|
|
71
|
+
argv,
|
|
72
|
+
bufsize=bufsize,
|
|
73
|
+
stdin=stdin,
|
|
74
|
+
stdout=stdout,
|
|
75
|
+
stderr=stderr,
|
|
76
|
+
cwd=os.fspath(cwd) if cwd is not None else None,
|
|
77
|
+
env=normalize_environment(env),
|
|
78
|
+
check=check,
|
|
79
|
+
encoding=encoding,
|
|
80
|
+
errors=errors,
|
|
81
|
+
# `encoding`/`errors`/`text` put subprocess's stdin in text mode,
|
|
82
|
+
# where bytes would kill the writer thread and hang the call.
|
|
83
|
+
input=normalize_input(
|
|
84
|
+
input,
|
|
85
|
+
text_mode=bool(encoding or errors or text),
|
|
86
|
+
encoding=encoding,
|
|
87
|
+
errors=errors,
|
|
88
|
+
),
|
|
89
|
+
timeout=timeout,
|
|
90
|
+
text=text,
|
|
91
|
+
)
|
hostctl/executor/psrp.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Optional PowerShell Remoting Protocol execution provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import subprocess
|
|
7
|
+
import typing
|
|
8
|
+
import types
|
|
9
|
+
|
|
10
|
+
from ._common import (
|
|
11
|
+
CaptureOutput,
|
|
12
|
+
CommandArgument,
|
|
13
|
+
Executor,
|
|
14
|
+
ExecutorCapability,
|
|
15
|
+
ExecutorCommand,
|
|
16
|
+
FileHandle,
|
|
17
|
+
Input,
|
|
18
|
+
capture_streams,
|
|
19
|
+
dispatch_output,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if typing.TYPE_CHECKING:
|
|
23
|
+
from ..process import RunspaceSession
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def pypsrp_available() -> bool:
|
|
27
|
+
"""Return whether the optional PSRP dependency can be imported."""
|
|
28
|
+
if __import__("sys").version_info < (3, 10):
|
|
29
|
+
return False
|
|
30
|
+
try:
|
|
31
|
+
importlib.import_module("pypsrp")
|
|
32
|
+
except ImportError:
|
|
33
|
+
return False
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def require_pypsrp() -> types.ModuleType:
|
|
38
|
+
"""Import pypsrp with an actionable, version-aware error."""
|
|
39
|
+
import sys
|
|
40
|
+
|
|
41
|
+
if sys.version_info < (3, 10):
|
|
42
|
+
raise ImportError(
|
|
43
|
+
"PSRP support requires Python 3.10 or newer; use the pywinrm "
|
|
44
|
+
"provider on Python 3.9"
|
|
45
|
+
)
|
|
46
|
+
try:
|
|
47
|
+
return importlib.import_module("pypsrp")
|
|
48
|
+
except ImportError as exc:
|
|
49
|
+
raise ImportError(
|
|
50
|
+
"PSRP support requires the 'psrp' extra: " "pip install hostctl[psrp]"
|
|
51
|
+
) from exc
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PsrpExecutor(Executor[subprocess.CompletedProcess]):
|
|
55
|
+
"""Execute finalized PowerShell scripts in fresh PSRP pipelines."""
|
|
56
|
+
|
|
57
|
+
# PSRP receives a finalized PowerShell script; cwd/env are embedded by
|
|
58
|
+
# the host shell rather than forwarded as unsupported executor options.
|
|
59
|
+
executor_capabilities = frozenset(
|
|
60
|
+
(ExecutorCapability.SCRIPT, ExecutorCapability.MANAGES_STATUS)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def __init__(self, session: typing.Callable[[], RunspaceSession]) -> None:
|
|
64
|
+
self._session = session
|
|
65
|
+
|
|
66
|
+
def __call__(
|
|
67
|
+
self,
|
|
68
|
+
command: ExecutorCommand,
|
|
69
|
+
*args: CommandArgument,
|
|
70
|
+
bufsize: int = -1,
|
|
71
|
+
stdin: typing.Optional[FileHandle] = None,
|
|
72
|
+
stdout: typing.Optional[FileHandle] = None,
|
|
73
|
+
stderr: typing.Optional[FileHandle] = None,
|
|
74
|
+
capture_output: CaptureOutput = True,
|
|
75
|
+
check: bool = True,
|
|
76
|
+
encoding: typing.Optional[str] = None,
|
|
77
|
+
errors: typing.Optional[str] = None,
|
|
78
|
+
input: Input = None,
|
|
79
|
+
timeout: typing.Optional[float] = None,
|
|
80
|
+
text: typing.Optional[bool] = None,
|
|
81
|
+
**options: object,
|
|
82
|
+
) -> subprocess.CompletedProcess:
|
|
83
|
+
del bufsize
|
|
84
|
+
if options:
|
|
85
|
+
raise TypeError(f"unsupported PSRP executor option: {sorted(options)[0]}")
|
|
86
|
+
if args:
|
|
87
|
+
raise NotImplementedError("PSRP executor receives a finalized script")
|
|
88
|
+
if stdin is not None or input is not None:
|
|
89
|
+
raise NotImplementedError("PSRP does not provide raw stdin for run()")
|
|
90
|
+
if timeout is not None:
|
|
91
|
+
raise NotImplementedError(
|
|
92
|
+
"PSRP pipeline timeout is unsupported; configure WinRM timeouts"
|
|
93
|
+
)
|
|
94
|
+
stdout, stderr = capture_streams(capture_output, stdout, stderr)
|
|
95
|
+
result = self._session().invoke(str(command), raw=False, capture_exit=True)
|
|
96
|
+
codec = encoding or "utf-8"
|
|
97
|
+
# Preserve PowerShell's object-pipeline line orientation when
|
|
98
|
+
# projecting objects to subprocess-compatible text.
|
|
99
|
+
projected_output = []
|
|
100
|
+
returncode = result.returncode
|
|
101
|
+
marker = "__HOSTCTL_LASTEXITCODE__:"
|
|
102
|
+
for item in result.output:
|
|
103
|
+
value = str(item)
|
|
104
|
+
if value.startswith(marker):
|
|
105
|
+
try:
|
|
106
|
+
parsed_returncode = int(value[len(marker) :])
|
|
107
|
+
returncode = (
|
|
108
|
+
1
|
|
109
|
+
if result.had_errors and parsed_returncode == 0
|
|
110
|
+
else parsed_returncode
|
|
111
|
+
)
|
|
112
|
+
except ValueError:
|
|
113
|
+
returncode = 1
|
|
114
|
+
continue
|
|
115
|
+
projected_output.append(item)
|
|
116
|
+
out_text = "\n".join(str(item) for item in projected_output)
|
|
117
|
+
err_text = "\n".join(str(item) for item in result.streams.error)
|
|
118
|
+
if out_text:
|
|
119
|
+
out_text += "\n"
|
|
120
|
+
out: typing.Union[str, bytes] = (
|
|
121
|
+
out_text if text or encoding else out_text.encode(codec)
|
|
122
|
+
)
|
|
123
|
+
err: typing.Union[str, bytes] = (
|
|
124
|
+
err_text if text or encoding else err_text.encode(codec)
|
|
125
|
+
)
|
|
126
|
+
if stderr is subprocess.STDOUT:
|
|
127
|
+
out = out + err
|
|
128
|
+
err = None
|
|
129
|
+
out, err = dispatch_output(
|
|
130
|
+
stdout,
|
|
131
|
+
stderr,
|
|
132
|
+
out,
|
|
133
|
+
err,
|
|
134
|
+
encoding=encoding,
|
|
135
|
+
errors=errors,
|
|
136
|
+
)
|
|
137
|
+
completed = subprocess.CompletedProcess(
|
|
138
|
+
args=str(command),
|
|
139
|
+
returncode=returncode,
|
|
140
|
+
stdout=out,
|
|
141
|
+
stderr=err,
|
|
142
|
+
)
|
|
143
|
+
if check:
|
|
144
|
+
completed.check_returncode()
|
|
145
|
+
return completed
|