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.
Files changed (51) hide show
  1. hostctl/AGENTS.md +292 -0
  2. hostctl/README.md +248 -0
  3. hostctl/__init__.py +194 -0
  4. hostctl/__main__.py +6 -0
  5. hostctl/_async.py +169 -0
  6. hostctl/_cli.py +232 -0
  7. hostctl/executor/__init__.py +82 -0
  8. hostctl/executor/_common.py +234 -0
  9. hostctl/executor/_qga.py +646 -0
  10. hostctl/executor/container.py +171 -0
  11. hostctl/executor/local.py +91 -0
  12. hostctl/executor/psrp.py +145 -0
  13. hostctl/executor/qemu.py +305 -0
  14. hostctl/executor/serial.py +312 -0
  15. hostctl/executor/ssh.py +226 -0
  16. hostctl/executor/winrm.py +255 -0
  17. hostctl/host/__init__.py +93 -0
  18. hostctl/host/_common.py +804 -0
  19. hostctl/host/_local.py +258 -0
  20. hostctl/host/_ssh.py +587 -0
  21. hostctl/host/_winrm.py +1002 -0
  22. hostctl/host/composite_path.py +567 -0
  23. hostctl/host/container.py +606 -0
  24. hostctl/host/container_path.py +664 -0
  25. hostctl/host/qemu.py +1078 -0
  26. hostctl/host/serial.py +401 -0
  27. hostctl/host/system.py +809 -0
  28. hostctl/process/__init__.py +49 -0
  29. hostctl/process/_common.py +113 -0
  30. hostctl/process/container.py +265 -0
  31. hostctl/process/psrp.py +208 -0
  32. hostctl/process/qemu_serial.py +247 -0
  33. hostctl/process/serial.py +257 -0
  34. hostctl/process/ssh.py +165 -0
  35. hostctl/provider/__init__.py +33 -0
  36. hostctl/provider/_common.py +452 -0
  37. hostctl/provider/transports.py +303 -0
  38. hostctl/py.typed +0 -0
  39. hostctl/serial/__init__.py +285 -0
  40. hostctl/shell/__init__.py +123 -0
  41. hostctl/shell/_common.py +616 -0
  42. hostctl/shell/cmd.py +167 -0
  43. hostctl/shell/fish.py +63 -0
  44. hostctl/shell/posix.py +83 -0
  45. hostctl/shell/powershell.py +120 -0
  46. hostctl/sync.py +245 -0
  47. hostctl-0.1.0.dist-info/METADATA +302 -0
  48. hostctl-0.1.0.dist-info/RECORD +51 -0
  49. hostctl-0.1.0.dist-info/WHEEL +4 -0
  50. hostctl-0.1.0.dist-info/entry_points.txt +2 -0
  51. hostctl-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,226 @@
1
+ """SSH command executor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import inspect
7
+ import os
8
+ import subprocess
9
+ import typing
10
+
11
+ from ._common import (
12
+ CaptureOutput,
13
+ CommandArgument,
14
+ Environment,
15
+ Executor,
16
+ ExecutorCommand,
17
+ ExecutorCapability,
18
+ FileHandle,
19
+ Input,
20
+ dispatch_output,
21
+ normalize_environment,
22
+ normalize_input,
23
+ capture_streams,
24
+ reject_stdin_conflict,
25
+ )
26
+
27
+
28
+ class _SshResult(typing.Protocol):
29
+ returncode: int
30
+ stdout: typing.Optional[typing.Union[str, bytes]]
31
+ stderr: typing.Optional[typing.Union[str, bytes]]
32
+
33
+
34
+ class SshConnection(typing.Protocol):
35
+ def is_closed(self) -> bool: ...
36
+
37
+ def close(self) -> None: ...
38
+
39
+ def wait_closed(self) -> typing.Awaitable[None]: ...
40
+
41
+ def run(self, command: str, **options: object) -> typing.Awaitable[_SshResult]: ...
42
+
43
+ def create_process(
44
+ self, command: typing.Optional[str] = None, **options: object
45
+ ) -> typing.Awaitable[object]: ...
46
+
47
+
48
+ def _input_buffer(
49
+ stream: FileHandle,
50
+ *,
51
+ encoding: typing.Optional[str],
52
+ errors: typing.Optional[str],
53
+ ) -> io.BytesIO:
54
+ if stream == subprocess.DEVNULL:
55
+ return io.BytesIO()
56
+ if stream == subprocess.PIPE:
57
+ raise ValueError("stdin=subprocess.PIPE requires input")
58
+ if isinstance(stream, int):
59
+ with os.fdopen(os.dup(stream), "rb") as duplicate:
60
+ value = duplicate.read()
61
+ else:
62
+ value = stream.read()
63
+ if isinstance(value, str):
64
+ value = value.encode(encoding or "utf-8", errors or "strict")
65
+ return io.BytesIO(value)
66
+
67
+
68
+ class SshExecutor(Executor[subprocess.CompletedProcess]):
69
+ """Execute finalized command strings through an AsyncSSH connection."""
70
+
71
+ executor_capabilities: typing.FrozenSet[ExecutorCapability] = frozenset()
72
+
73
+ def __init__(self, connection: typing.Callable[[], SshConnection]) -> None:
74
+ self._connection = connection
75
+
76
+ @staticmethod
77
+ def _terminate_timeout(connection: SshConnection, error: BaseException) -> bool:
78
+ """Best-effort termination for a timed-out AsyncSSH process.
79
+
80
+ ``SSHClientConnection.run()`` does not expose the process it creates,
81
+ so AsyncSSH versions and test doubles may expose it on the exception or
82
+ connection. Probe those documented/duck-typed hooks and report
83
+ whether termination was attempted; the timeout remains marked
84
+ ``orphaned`` when no channel can be recovered.
85
+ """
86
+ targets = [
87
+ getattr(error, "process", None),
88
+ getattr(error, "channel", None),
89
+ getattr(connection, "process", None),
90
+ getattr(connection, "last_process", None),
91
+ ]
92
+ target = next((item for item in targets if item is not None), None)
93
+ if target is None:
94
+ return False
95
+
96
+ from .. import _async
97
+
98
+ async def terminate() -> None:
99
+ for name in ("terminate", "close"):
100
+ method = getattr(target, name, None)
101
+ if method is None:
102
+ continue
103
+ result = method()
104
+ if inspect.isawaitable(result):
105
+ await result
106
+ return
107
+
108
+ try:
109
+ _async.async_to_sync(terminate())
110
+ except Exception:
111
+ return False
112
+ return True
113
+
114
+ def __call__(
115
+ self,
116
+ command: ExecutorCommand,
117
+ *args: CommandArgument,
118
+ bufsize: int = -1,
119
+ stdin: typing.Optional[FileHandle] = None,
120
+ stdout: typing.Optional[FileHandle] = None,
121
+ stderr: typing.Optional[FileHandle] = None,
122
+ env: typing.Optional[Environment] = None,
123
+ capture_output: CaptureOutput = True,
124
+ check: bool = True,
125
+ encoding: typing.Optional[str] = None,
126
+ errors: typing.Optional[str] = None,
127
+ input: Input = None,
128
+ timeout: typing.Optional[float] = None,
129
+ text: typing.Optional[bool] = None,
130
+ **options: object,
131
+ ) -> subprocess.CompletedProcess:
132
+ if options:
133
+ raise TypeError(f"unsupported SSH executor option: {sorted(options)[0]}")
134
+ if args:
135
+ raise NotImplementedError("SshExecutor does not support native arguments")
136
+ if bufsize == 0:
137
+ raise ValueError("bufsize=0 is unsupported by the buffered SSH executor")
138
+ command = str(command)
139
+ reject_stdin_conflict(input, stdin)
140
+ if text and encoding is None:
141
+ encoding = "utf-8"
142
+ env = normalize_environment(env)
143
+
144
+ from .. import _async
145
+
146
+ stdout, stderr = capture_streams(capture_output, stdout, stderr)
147
+ if input is not None:
148
+ # The sink is a BytesIO, so this leg is always binary regardless of
149
+ # the command's text encoding -- `text_mode=False` encodes str and
150
+ # passes bytes through.
151
+ value = normalize_input(
152
+ input, text_mode=False, encoding=encoding, errors=errors
153
+ )
154
+ stdin = io.BytesIO(typing.cast(bytes, value))
155
+ elif stdin is None:
156
+ # AsyncSSH leaves a command's stdin open when no stream is passed.
157
+ # Supplying an empty in-memory stream sends EOF and matches
158
+ # subprocess.run()'s non-interactive default.
159
+ stdin = _input_buffer(
160
+ subprocess.DEVNULL,
161
+ encoding=encoding,
162
+ errors=errors,
163
+ )
164
+ else:
165
+ stdin = _input_buffer(
166
+ stdin,
167
+ encoding=encoding,
168
+ errors=errors,
169
+ )
170
+
171
+ stdout_target, stderr_target = stdout, stderr
172
+ try:
173
+ result = _async.async_to_sync(
174
+ self._connection().run(
175
+ command,
176
+ bufsize=bufsize,
177
+ stdin=stdin,
178
+ stdout=None,
179
+ stderr=(
180
+ subprocess.STDOUT
181
+ if stderr_target == subprocess.STDOUT
182
+ else None
183
+ ),
184
+ env=env,
185
+ check=False,
186
+ encoding=encoding,
187
+ errors=errors,
188
+ timeout=timeout,
189
+ )
190
+ )
191
+ except Exception as exc:
192
+ normalized = _async.normalize_asyncssh_error(
193
+ exc,
194
+ command=command,
195
+ timeout=timeout,
196
+ )
197
+ if isinstance(normalized, subprocess.TimeoutExpired):
198
+ terminated = self._terminate_timeout(self._connection(), exc)
199
+ normalized.orphaned = not terminated
200
+ if normalized is exc:
201
+ raise
202
+ raise normalized from exc
203
+
204
+ result_stdout = result.stdout
205
+ result_stderr = None if stderr_target == subprocess.STDOUT else result.stderr
206
+ result_stdout, result_stderr = dispatch_output(
207
+ stdout_target,
208
+ stderr_target,
209
+ result_stdout,
210
+ result_stderr,
211
+ encoding=encoding,
212
+ errors=errors,
213
+ )
214
+
215
+ returncode = result.returncode
216
+ if returncode is None:
217
+ returncode = -1
218
+ completed = subprocess.CompletedProcess(
219
+ args=command,
220
+ returncode=returncode,
221
+ stdout=result_stdout,
222
+ stderr=result_stderr,
223
+ )
224
+ if check:
225
+ completed.check_returncode()
226
+ return completed
@@ -0,0 +1,255 @@
1
+ """WinRM PowerShell script executor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import dataclasses
7
+ import subprocess
8
+ import typing
9
+
10
+ from ._common import (
11
+ CaptureOutput,
12
+ CommandArgument,
13
+ Executor,
14
+ ExecutorCommand,
15
+ ExecutorCapability,
16
+ FileHandle,
17
+ Input,
18
+ dispatch_output,
19
+ capture_streams,
20
+ )
21
+
22
+
23
+ class _WinRMResponse(typing.Protocol):
24
+ status_code: int
25
+ std_out: bytes
26
+ std_err: bytes
27
+
28
+
29
+ class WinRMSession(typing.Protocol):
30
+ def run_ps(self, script: str) -> _WinRMResponse: ...
31
+
32
+
33
+ @dataclasses.dataclass
34
+ class _NativeResponse:
35
+ status_code: int
36
+ std_out: bytes
37
+ std_err: bytes
38
+
39
+
40
+ class NativeWinRMSession:
41
+ """Current-context Windows PowerShell remoting session adapter."""
42
+
43
+ def __init__(
44
+ self,
45
+ host: str,
46
+ *,
47
+ ssl: bool = False,
48
+ port: typing.Optional[int] = None,
49
+ timeout: typing.Optional[float] = None,
50
+ transport: str = "ntlm",
51
+ server_cert_validation: str = "validate",
52
+ message_encryption: str = "auto",
53
+ ) -> None:
54
+ self.host = host
55
+ self.ssl = ssl
56
+ self.port = port
57
+ self.timeout = timeout
58
+ if transport not in {"ntlm", "kerberos"}:
59
+ raise NotImplementedError(
60
+ "native WinRM supports only current-context Negotiate (ntlm/kerberos)"
61
+ )
62
+ if message_encryption not in {"auto", "always", "never"}:
63
+ raise ValueError("invalid message_encryption")
64
+ if message_encryption != "auto":
65
+ raise NotImplementedError(
66
+ "native WinRM cannot override WS-Man message encryption; "
67
+ "use message_encryption='auto' or the pywinrm provider"
68
+ )
69
+ if server_cert_validation not in {"validate", "ignore"}:
70
+ raise ValueError("invalid server_cert_validation")
71
+ if ssl and server_cert_validation == "ignore":
72
+ # PowerShell remoting can skip certificate checks, but only when
73
+ # explicitly requested; keep this visible in the generated options.
74
+ self._skip_ca_check = True
75
+ else:
76
+ self._skip_ca_check = False
77
+
78
+ def run_ps(self, script: str) -> _NativeResponse:
79
+ host = base64.b64encode(self.host.encode("utf-8")).decode("ascii")
80
+ payload = base64.b64encode(script.encode("utf-8")).decode("ascii")
81
+ wrapper = (
82
+ "$OutputEncoding=[Console]::OutputEncoding="
83
+ "[Text.UTF8Encoding]::new($false);"
84
+ "$ErrorActionPreference='Stop';"
85
+ "$h=[Text.Encoding]::UTF8.GetString("
86
+ f"[Convert]::FromBase64String('{host}'));"
87
+ "$s=[Text.Encoding]::UTF8.GetString("
88
+ f"[Convert]::FromBase64String('{payload}'));"
89
+ "$o=@{ComputerName=$h;Authentication='Negotiate';"
90
+ f"UseSSL=${str(self.ssl).lower()}"
91
+ + (f";Port={self.port}" if self.port is not None else "")
92
+ + "};"
93
+ "try{$global:LASTEXITCODE=0;"
94
+ "$r=Invoke-Command @o -ScriptBlock ([ScriptBlock]::Create($s));"
95
+ "$r;exit ([int]$global:LASTEXITCODE)}"
96
+ "catch{$c=[string]$_.CategoryInfo.Category;"
97
+ "$m=[Convert]::ToBase64String("
98
+ "[Text.Encoding]::UTF8.GetBytes($_.Exception.Message));"
99
+ "[Console]::Error.Write('HOSTCTL_NATIVE_ERROR:'+$c+':'+$m);exit 1}"
100
+ )
101
+ if self._skip_ca_check:
102
+ wrapper = wrapper.replace(
103
+ ";Port=" + str(self.port) if self.port is not None else ";};",
104
+ (
105
+ ";SkipCACheck=$true;Port=" + str(self.port)
106
+ if self.port is not None
107
+ else ";SkipCACheck=$true;};"
108
+ ),
109
+ )
110
+ try:
111
+ result = subprocess.run(
112
+ (
113
+ "powershell.exe",
114
+ "-NoProfile",
115
+ "-NonInteractive",
116
+ "-Command",
117
+ "-",
118
+ ),
119
+ input=wrapper.encode("utf-8"),
120
+ stdout=subprocess.PIPE,
121
+ stderr=subprocess.PIPE,
122
+ check=False,
123
+ timeout=self.timeout,
124
+ )
125
+ except subprocess.TimeoutExpired as exc:
126
+ # Never expose the local powershell argv as the remote command.
127
+ raise subprocess.TimeoutExpired(self.host, self.timeout) from exc
128
+ return _NativeResponse(result.returncode, result.stdout, result.stderr)
129
+
130
+ def close(self) -> None:
131
+ return None
132
+
133
+
134
+ class WinRMExecutor(Executor[subprocess.CompletedProcess]):
135
+ """Execute finalized PowerShell scripts through a pywinrm session."""
136
+
137
+ executor_capabilities = frozenset((ExecutorCapability.SCRIPT,))
138
+
139
+ def __init__(
140
+ self,
141
+ session: typing.Callable[[], WinRMSession],
142
+ transport_timeout: typing.Optional[typing.Callable[[], float]] = None,
143
+ ) -> None:
144
+ self._session = session
145
+ self._transport_timeout = transport_timeout
146
+
147
+ def __call__(
148
+ self,
149
+ command: ExecutorCommand,
150
+ *args: CommandArgument,
151
+ bufsize: int = -1,
152
+ stdin: typing.Optional[FileHandle] = None,
153
+ stdout: typing.Optional[FileHandle] = None,
154
+ stderr: typing.Optional[FileHandle] = None,
155
+ capture_output: CaptureOutput = True,
156
+ check: bool = True,
157
+ encoding: typing.Optional[str] = None,
158
+ errors: typing.Optional[str] = None,
159
+ input: Input = None,
160
+ timeout: typing.Optional[float] = None,
161
+ text: typing.Optional[bool] = None,
162
+ **options: object,
163
+ ) -> subprocess.CompletedProcess:
164
+ if options:
165
+ raise TypeError(f"unsupported WinRM executor option: {sorted(options)[0]}")
166
+ if args:
167
+ raise NotImplementedError("WinRMExecutor does not support native arguments")
168
+ command = str(command)
169
+ if timeout is not None:
170
+ raise NotImplementedError(
171
+ "WinRMExecutor timeout is unsupported; configure transport timeouts"
172
+ )
173
+ if stdin is not None or input is not None:
174
+ raise NotImplementedError("WinRMExecutor does not support stdin/input")
175
+ stdout, stderr = capture_streams(capture_output, stdout, stderr)
176
+ try:
177
+ result = self._session().run_ps(command)
178
+ except Exception as exc:
179
+ normalized = self._normalize_error(exc, command)
180
+ if normalized is exc:
181
+ raise
182
+ raise normalized from exc
183
+ out = result.std_out
184
+ err = result.std_err
185
+ marker = b"HOSTCTL_NATIVE_ERROR:"
186
+ if result.status_code and err.startswith(marker):
187
+ try:
188
+ _, category, encoded = err.decode("ascii").split(":", 2)
189
+ detail = base64.b64decode(encoded).decode("utf-8", "replace")
190
+ except Exception:
191
+ category, detail = "transport", err.decode("utf-8", "replace")
192
+ if category in ("AuthenticationError", "PermissionDenied"):
193
+ raise PermissionError(detail)
194
+ if category not in ("RemoteError", "Remote"):
195
+ raise ConnectionError(detail)
196
+ # Remote errors are represented as a normal non-zero completion;
197
+ # check=False must be able to inspect them.
198
+ err = detail.encode(encoding or "utf-8", errors or "replace")
199
+ if text or encoding is not None or errors is not None:
200
+ codec = encoding or "utf-8"
201
+ out = out.decode(codec, errors or "strict")
202
+ err = err.decode(codec, errors or "strict")
203
+
204
+ if stderr is subprocess.STDOUT:
205
+ if out is not None:
206
+ out += err
207
+ err = None
208
+ out, err = dispatch_output(
209
+ stdout,
210
+ stderr,
211
+ out,
212
+ err,
213
+ encoding=encoding,
214
+ errors=errors,
215
+ )
216
+
217
+ completed = subprocess.CompletedProcess(
218
+ args=command,
219
+ returncode=result.status_code,
220
+ stdout=out,
221
+ stderr=err,
222
+ )
223
+ if check:
224
+ completed.check_returncode()
225
+ return completed
226
+
227
+ def _normalize_error(self, exc: Exception, command: str) -> Exception:
228
+ try:
229
+ import requests
230
+ import winrm.exceptions
231
+ except ImportError:
232
+ return exc
233
+ if isinstance(exc, winrm.exceptions.AuthenticationError):
234
+ return PermissionError(str(exc))
235
+ if isinstance(
236
+ exc,
237
+ (
238
+ TimeoutError,
239
+ subprocess.TimeoutExpired,
240
+ requests.exceptions.Timeout,
241
+ winrm.exceptions.WinRMOperationTimeoutError,
242
+ ),
243
+ ):
244
+ timeout = self._transport_timeout() if self._transport_timeout else None
245
+ return subprocess.TimeoutExpired(command, timeout)
246
+ if isinstance(
247
+ exc,
248
+ (
249
+ requests.exceptions.ConnectionError,
250
+ winrm.exceptions.WinRMTransportError,
251
+ winrm.exceptions.WSManFaultError,
252
+ ),
253
+ ):
254
+ return ConnectionError(str(exc))
255
+ return exc
@@ -0,0 +1,93 @@
1
+ """Host contracts and built-in host implementations."""
2
+
3
+ from ._common import (
4
+ Host as Host,
5
+ HostConfig as HostConfig,
6
+ HostInfo as HostInfo,
7
+ HostPath as HostPath,
8
+ parse_credentials as parse_credentials,
9
+ redact_uri as redact_uri,
10
+ strict_uri_credentials as strict_uri_credentials,
11
+ strict_uri_query as strict_uri_query,
12
+ uri_host as uri_host,
13
+ )
14
+ from ._ssh import ssh_providers as ssh_providers
15
+ from ._winrm import winrm_providers as winrm_providers
16
+ from ._local import LocalConfig as LocalConfig, LocalHost as LocalHost
17
+ from .container import (
18
+ ContainerConfig as ContainerConfig,
19
+ ContainerHost as ContainerHost,
20
+ )
21
+ from .container_path import (
22
+ ContainerPathBackend as ContainerPathBackend,
23
+ PosixContainerPath as PosixContainerPath,
24
+ WindowsContainerPath as WindowsContainerPath,
25
+ )
26
+ from .qemu import (
27
+ QemuConfig as QemuConfig,
28
+ QemuHost as QemuHost,
29
+ PosixQemuPath as PosixQemuPath,
30
+ QgaPathBackend as QgaPathBackend,
31
+ WindowsQemuPath as WindowsQemuPath,
32
+ )
33
+ from ._ssh import (
34
+ PathnameConstructor as PathnameConstructor,
35
+ SshConfig as SshConfig,
36
+ )
37
+ from ._winrm import (
38
+ WinRMConfig as WinRMConfig,
39
+ WinRMPath as WinRMPath,
40
+ WinRMPathBackend as WinRMPathBackend,
41
+ )
42
+ from .system import (
43
+ IosConfig as IosConfig,
44
+ IosHost as IosHost,
45
+ PosixConfig as PosixConfig,
46
+ PosixHost as PosixHost,
47
+ SystemConfig as SystemConfig,
48
+ SystemHost as SystemHost,
49
+ WindowsConfig as WindowsConfig,
50
+ WindowsHost as WindowsHost,
51
+ register_system_provider as register_system_provider,
52
+ )
53
+ from .serial import SerialConfig as SerialConfig, SerialHost as SerialHost
54
+ from .composite_path import (
55
+ CompositePosixPath as CompositePosixPath,
56
+ CompositeWindowsPath as CompositeWindowsPath,
57
+ )
58
+
59
+ __all__ = [
60
+ "Host",
61
+ "HostConfig",
62
+ "HostInfo",
63
+ "parse_credentials",
64
+ "redact_uri",
65
+ "ssh_providers",
66
+ "strict_uri_credentials",
67
+ "strict_uri_query",
68
+ "uri_host",
69
+ "winrm_providers",
70
+ "HostPath",
71
+ "ContainerConfig",
72
+ "ContainerHost",
73
+ "LocalConfig",
74
+ "LocalHost",
75
+ "QemuConfig",
76
+ "QemuHost",
77
+ "SshConfig",
78
+ "SerialConfig",
79
+ "SerialHost",
80
+ "WinRMConfig",
81
+ "WinRMPath",
82
+ "SystemConfig",
83
+ "SystemHost",
84
+ "PosixConfig",
85
+ "PosixHost",
86
+ "WindowsConfig",
87
+ "WindowsHost",
88
+ "register_system_provider",
89
+ "IosConfig",
90
+ "IosHost",
91
+ "CompositePosixPath",
92
+ "CompositeWindowsPath",
93
+ ]