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/AGENTS.md
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# hostctl — API header
|
|
2
|
+
|
|
3
|
+
Protocol-independent host execution and filesystem paths. Core requires
|
|
4
|
+
`pathlib_next`; transport integrations are optional extras.
|
|
5
|
+
|
|
6
|
+
The dependency-free CLI entry point is `hostctl._cli:main`. Commands are
|
|
7
|
+
`run`, `ls`, `cat`, `cp`, `info`, and `shell`; passwords come only from
|
|
8
|
+
`HOSTCTL_PASSWORD` or `--ask-password`, never argv values.
|
|
9
|
+
|
|
10
|
+
`hostctl.__all__` is the stable surface: hosts and configs you construct,
|
|
11
|
+
exceptions you catch, types you annotate with, and the provider/shell contracts
|
|
12
|
+
you implement. Concrete backends, transport adapters, and objects the library
|
|
13
|
+
only hands back (`QgaPathBackend`, `WinRMPathBackend`, `ContainerPathBackend`,
|
|
14
|
+
the `Posix*`/`Windows*` path classes, the concrete `*Executor`s, `*Process`
|
|
15
|
+
classes, and `hostctl.provider.transports`) stay importable from their defining
|
|
16
|
+
module but are **not** exported from `hostctl` and may change without notice.
|
|
17
|
+
|
|
18
|
+
Host implementations are grouped under `hostctl.host`: shared contracts are
|
|
19
|
+
re-exported from the package, with concrete implementations in private
|
|
20
|
+
modules (`hostctl.host._local`, `._ssh`, and `._winrm`) plus the other provider
|
|
21
|
+
modules. WinRM paths live with `_winrm`; QGA paths live with `qemu`.
|
|
22
|
+
The private `hostctl.executor._qga` module owns QGA framing and its Unix,
|
|
23
|
+
libvirt, and SSH transports; it is consumed by the QEMU executor and host.
|
|
24
|
+
Shell construction is transport-independent under `hostctl.shell`:
|
|
25
|
+
`_common.py` owns shared contracts, while `posix.py` and `powershell.py` own
|
|
26
|
+
their concrete flavours. Executor code follows the same layout under
|
|
27
|
+
`hostctl.executor`: `_common.py` owns contracts and option types; `ssh.py` and
|
|
28
|
+
`winrm.py` own `SshExecutor` and `WinRMExecutor`. Each package re-exports its
|
|
29
|
+
own contracts; only the stable subset above reaches top-level `hostctl`.
|
|
30
|
+
`hostctl.sync` adds `stat_checksum(entry)`, `host_checksum(*hosts,
|
|
31
|
+
algorithm="md5", chunk_size=1048576)`, and `ProgressReader`; these plug into
|
|
32
|
+
`pathlib_next.utils.sync.PathSyncer` and the existing path copy machinery.
|
|
33
|
+
|
|
34
|
+
`SystemHost`, `PosixHost`, and `WindowsHost` compose ordered executor/path
|
|
35
|
+
providers. `PosixHost.from_ssh(SshConfig(...))` and
|
|
36
|
+
`WindowsHost.from_winrm(WinRMConfig(...))` retain the original connection URI
|
|
37
|
+
and lifecycle while exposing transport operations through provider adapters;
|
|
38
|
+
transport implementations remain private.
|
|
39
|
+
`register_system_provider(name, resolver)` extends logical system URI
|
|
40
|
+
descriptors. Built-ins include `local`, `ssh`, `sftp`, and `winrm`; transport
|
|
41
|
+
descriptors require matching objects in `SystemConfig(provider_options=...)` and
|
|
42
|
+
never serialize credentials into the canonical URI.
|
|
43
|
+
|
|
44
|
+
`Executor(command, *, stdin=None, stdout=None, stderr=None, cwd=None, env=None,
|
|
45
|
+
capture_output=None, check=None, encoding=None, errors=None, input=None,
|
|
46
|
+
timeout=None, text=None, **options)` defines the shared shell-agnostic option
|
|
47
|
+
surface. `ExecutionOptions` is the corresponding total-false `TypedDict`;
|
|
48
|
+
executor-specific extensions remain keyword options.
|
|
49
|
+
`ExecutorCommand` is `str | pathlib.PurePath | pathlib_next.Pathname`; paths
|
|
50
|
+
remain path objects until the concrete executor converts them for transport.
|
|
51
|
+
`ExecutorCapability.ARGS`, `.CWD`, and `.ENV` declare native executor support.
|
|
52
|
+
`Shell.execute(path, *args)` preserves path/args for an `ARGS` executor;
|
|
53
|
+
otherwise the flavour safely renders them into one script. `cwd` and `env`
|
|
54
|
+
follow the same native-or-embed rule.
|
|
55
|
+
`Shell`, `SshExecutor`, and `WinRMExecutor` inherit the `Executor` protocol so
|
|
56
|
+
shared protocol implementations can be added once; `Shell.__call__` delegates
|
|
57
|
+
to `Shell.execute()`.
|
|
58
|
+
`Host` itself supplies the multi-command `run()` provider contract; there is no
|
|
59
|
+
duplicate host-executor protocol or unused host-options wrapper.
|
|
60
|
+
`Shell.execute(path_like)` preserves the path, while string input remains a
|
|
61
|
+
string. Executors and hosts distinguish direct commands from shell scripts by
|
|
62
|
+
that value type; no duplicate command-kind flag is carried.
|
|
63
|
+
|
|
64
|
+
## Configuration and lifecycle
|
|
65
|
+
|
|
66
|
+
- `Host(connection_string, **secrets) -> Host` dispatches a secret-safe URI
|
|
67
|
+
through config implementations and the `hostctl.configs` entry-point group.
|
|
68
|
+
- `HostConfig(connection_string, **secrets) -> HostConfig` performs the same
|
|
69
|
+
dispatch without creating a host. `str(config)` is its canonical,
|
|
70
|
+
secret-free connection string and can be passed back to `HostConfig`.
|
|
71
|
+
- A `scheme://user:password@host` URI is valid *input*: the password is
|
|
72
|
+
extracted into the credential arguments and stripped from the parsed
|
|
73
|
+
authority, so it never reaches a field that `connection_uri` or `repr()`
|
|
74
|
+
renders. Supplying a password both in the URI and as an argument raises.
|
|
75
|
+
`redact_uri(uri)` STRIPS a password and returns a valid, reusable URI (not a
|
|
76
|
+
masked one, so a rendered form can never round-trip a wrong credential), for
|
|
77
|
+
logs, reprs, and error messages.
|
|
78
|
+
- A connection URI may carry a raw tab, CR, or LF in its **userinfo**: those
|
|
79
|
+
are percent-encoded before `urlsplit` sees them, which would otherwise
|
|
80
|
+
delete them silently (`ssh://u:pw<LF>otp:1@host` would authenticate with
|
|
81
|
+
`pwotp:1`). So the credential-extras separator can be written naturally.
|
|
82
|
+
A control character in the **host** is REJECTED -- deletion there rewrites
|
|
83
|
+
the target (`ssh://host<LF>.other.example/` would resolve to
|
|
84
|
+
`host.other.example`), and no encoding makes it meaningful.
|
|
85
|
+
`redact_uri` never raises on either -- it is for diagnostics.
|
|
86
|
+
- `parse_credentials(password) -> (password, extras)` splits a password field
|
|
87
|
+
on a newline: the first line is the password, each later line is a
|
|
88
|
+
credential extra. `name:value` sets a value, a bare `name` is a flag
|
|
89
|
+
equivalent to `name:` (empty string). Names are casefolded and stripped,
|
|
90
|
+
values keep everything after the first `:`, blank lines are ignored, and
|
|
91
|
+
CRLF is handled. URI dispatch runs the password field through it, so an OTP
|
|
92
|
+
or other second factor travels in the same field; an extra a config does not
|
|
93
|
+
declare is rejected by name rather than dropped.
|
|
94
|
+
- `config.connection_uri` never includes passwords or private keys;
|
|
95
|
+
`config.scheme` matches its URI scheme.
|
|
96
|
+
- `with config as host:` and `with config.open() as host:` connect and always
|
|
97
|
+
close. Re-entering the same active config raises `RuntimeError`.
|
|
98
|
+
- External config implementations declare `schemes=(...)`; registry hooks and
|
|
99
|
+
caches are protected implementation details.
|
|
100
|
+
|
|
101
|
+
`LocalConfig`, `SshConfig`, `WinRMConfig`, and `ContainerConfig` produce their
|
|
102
|
+
corresponding hosts.
|
|
103
|
+
|
|
104
|
+
## Shell contract
|
|
105
|
+
|
|
106
|
+
- `Shell(flavour, executor)` binds a `ShellFlavour` to either a command
|
|
107
|
+
callable or an object exposing `run(command, **options)`. The command is
|
|
108
|
+
always one string. If its inspected signature accepts `cwd` and/or `env`,
|
|
109
|
+
`Shell.run()` forwards those separately; otherwise the flavour embeds that
|
|
110
|
+
context into the script. Shell-agnostic subprocess options (`stdin`,
|
|
111
|
+
`stdout`, `stderr`, `capture_output`, `check`, `encoding`, `errors`, `input`,
|
|
112
|
+
`timeout`, `text`) pass through only to the executor. `Shell.execute(command)`
|
|
113
|
+
passes strings unchanged and converts path-like commands with `str(path)`.
|
|
114
|
+
- `ShellFlavour.script(cmds, *, cwd=None, env=None) -> str` constructs a script
|
|
115
|
+
in one target shell language.
|
|
116
|
+
- `ShellFlavour.environment_script(env) -> str` is a reusable standalone
|
|
117
|
+
environment-mapping renderer. The base normalizes/validates variable names
|
|
118
|
+
and joins each flavour's `environment_assignment(key, value)` output.
|
|
119
|
+
Values remain objects so a flavour can preserve meaningful types; built-ins
|
|
120
|
+
decode bytes and stringify ordinary values.
|
|
121
|
+
- `ShellOperator.PIPE`, `.AND`, `.OR`, `.REDIRECT`, `.APPEND`, and `.SEQUENCE`
|
|
122
|
+
are explicit infix tokens between top-level commands. Flavours own their
|
|
123
|
+
spelling and may reject operators they cannot represent portably.
|
|
124
|
+
- Raw strings stay verbatim; tuple/list commands quote each item as data;
|
|
125
|
+
standalone and structured paths are quoted; top-level commands otherwise
|
|
126
|
+
use the flavour's `command_separator`.
|
|
127
|
+
- `ShellFlavour.command(cmds, *, executable=None, cwd=None, env=None) ->
|
|
128
|
+
ShellCommand` wraps that script for an SSH exec channel.
|
|
129
|
+
- Structured values are normalized through the base class (including bytes and
|
|
130
|
+
iterable argv sequences); empty structured commands and control characters
|
|
131
|
+
are rejected. Raw empty command strings are skipped when joining.
|
|
132
|
+
- Environment assignments embedded by a shell are additive to the inherited
|
|
133
|
+
remote environment. This intentionally differs from local
|
|
134
|
+
`subprocess.run(env=...)`, which replaces the environment; a clear-env mode
|
|
135
|
+
remains a separate future contract.
|
|
136
|
+
- `POSIX_SHELL` and `POWERSHELL` are the built-in strategies;
|
|
137
|
+
common built-ins also include `BASH`, `ZSH`, `FISH`, `CMD`, and PowerShell 7
|
|
138
|
+
`PWSH`. PowerShell 5 rejects `AND`/`OR`; PowerShell 7 supports them.
|
|
139
|
+
- `shell_flavour(selection)` accepts a registered string, configured
|
|
140
|
+
`ShellFlavour` instance, or no-argument `ShellFlavour` subclass.
|
|
141
|
+
`register_shell_flavour()` adds application-defined string selections.
|
|
142
|
+
- `ShellCommand.command` is transport-ready text; `.environment` is the
|
|
143
|
+
environment sent out of band, or `None` when embedded into the script.
|
|
144
|
+
- `Shell(flavour, executor, cwd=None, env=None, encoding=None, errors=None)`
|
|
145
|
+
accepts defaults applied to every `run`/`session` call that omits them.
|
|
146
|
+
`host.shell(cwd=..., env=...)` returns such a shell; bare `host.shell` has
|
|
147
|
+
none. `cwd`/`encoding`/`errors` are replaced by a per-call value, `env`
|
|
148
|
+
merges per key, and `Shell.configure(...)` returns a configured copy without
|
|
149
|
+
mutating the original.
|
|
150
|
+
- A custom `HostConfig` should declare `uri_credentials = ("password", ...)`.
|
|
151
|
+
Dispatch then rejects any other credential BEFORE construction, so a typo
|
|
152
|
+
(`passwrd=`) fails loudly instead of silently building a config with no
|
|
153
|
+
password. `None` (default) skips the check for configs that validate
|
|
154
|
+
themselves. `strict_uri_credentials`, `strict_uri_query`, and `uri_host`
|
|
155
|
+
are public for configs writing `_from_parsed_uri` by hand.
|
|
156
|
+
- `ssh_providers(SshConfig)` and `winrm_providers(WinRMConfig)` return an
|
|
157
|
+
`(executor_provider, path_provider)` pair sharing ONE transport, for
|
|
158
|
+
composing a transport into a host you assemble yourself rather than taking
|
|
159
|
+
the finished host `_create_host()` builds. They return a pair instead of
|
|
160
|
+
exposing the transport because both providers must share it -- building them
|
|
161
|
+
separately silently opens two connections, only one of which is closed.
|
|
162
|
+
- `input=` is normalized to the stream mode every executor is about to use, by
|
|
163
|
+
`executor/_common.normalize_input`. Under a text mode (`encoding`/`errors`/
|
|
164
|
+
`text`) bytes are decoded; under a binary mode str is encoded. This is not
|
|
165
|
+
cosmetic: handing bytes to a text-mode `subprocess` stdin kills its writer
|
|
166
|
+
thread, and the call then blocks forever because the child never sees EOF --
|
|
167
|
+
`timeout=` does not fire. All executors share the helper so the same call
|
|
168
|
+
behaves identically whichever provider a `SystemHost` selects.
|
|
169
|
+
- `env` on `run`/`session`/`configure` accepts `EnvironmentSelection`: a
|
|
170
|
+
mapping merges over the shell's default per key, the default empty mapping
|
|
171
|
+
merges nothing and inherits it, and `None` runs without the shell's
|
|
172
|
+
configured environment -- keeping whatever the host provides on its own,
|
|
173
|
+
since nothing is sent to override it. `None` does NOT mean an empty
|
|
174
|
+
environment. Declining `env` does not affect `cwd`.
|
|
175
|
+
- `Shell.session(*cmds, terminal=False, cwd=None, env=None, ...) -> ShellSession`
|
|
176
|
+
opens a persistent provider process. `Shell` is also a context manager:
|
|
177
|
+
`with host.shell as session:` opens a default session and closes it on exit,
|
|
178
|
+
which is the no-argument shorthand for `with host.shell.session() as ...`.
|
|
179
|
+
Re-entering a shell whose session is still open raises `RuntimeError`.
|
|
180
|
+
`ShellSession.send(*cmds, cwd=None,
|
|
181
|
+
env=None)` uses the same command grammar, writes the flavour terminator and
|
|
182
|
+
a line terminator, and mutates the live shell context. This newline is
|
|
183
|
+
required for interactive shells to submit each command.
|
|
184
|
+
TTY stderr is merged into stdout.
|
|
185
|
+
|
|
186
|
+
## Host contract
|
|
187
|
+
|
|
188
|
+
- `Host` is abstract. Base `run()` and `path()` raise `NotImplementedError`.
|
|
189
|
+
- Hosts expose delegated `scheme`/`connection_uri`, explicit `capabilities`,
|
|
190
|
+
`info() -> HostInfo`, and `connect()`/`close()` plus context management.
|
|
191
|
+
- `host.shell_flavour` is the explicitly known target-shell strategy;
|
|
192
|
+
`host.shell` builds `Shell(host.shell_flavour, host)`. SSH uses its configured
|
|
193
|
+
or positively detected flavour, WinRM uses PowerShell, and local execution
|
|
194
|
+
selects POSIX sh or Windows PowerShell from the local platform.
|
|
195
|
+
- SSH provider run renders through its shell flavour and delegates the finalized
|
|
196
|
+
command to `SshExecutor`; WinRM provider run delegates its finalized
|
|
197
|
+
PowerShell script to `WinRMExecutor`.
|
|
198
|
+
- `HostInfo` fields are optional; unknown system values remain `None`.
|
|
199
|
+
- A usable `path()` returns `HostPath` (`pathlib_next.Path`).
|
|
200
|
+
- A usable `run()` returns `subprocess.CompletedProcess`; `check=True` raises
|
|
201
|
+
`subprocess.CalledProcessError`, and command timeouts raise
|
|
202
|
+
`subprocess.TimeoutExpired`.
|
|
203
|
+
- `Host.spawn()` is the low-level persistent `Process` contract. Providers
|
|
204
|
+
advertise `spawn` and `tty` separately.
|
|
205
|
+
|
|
206
|
+
## SSH
|
|
207
|
+
|
|
208
|
+
`SshConfig(host, port=22, username="root", password=None, client_keys=None,
|
|
209
|
+
executable=None, known_hosts=(), dialect=POSIX_SHELL,
|
|
210
|
+
path_flavor=pathlib_next.PosixPathname)`.
|
|
211
|
+
|
|
212
|
+
Authentication fields are explicit and excluded from repr. `dialect` selects
|
|
213
|
+
POSIX or PowerShell command construction independently of the POSIX/Windows
|
|
214
|
+
SFTP path flavor. `dialect` is a `ShellFlavour` strategy. `path_flavor` is a concrete
|
|
215
|
+
`pathlib_next.Pathname` or `pathlib.PurePath` subclass; bare `PurePath` is
|
|
216
|
+
rejected because it would infer the local OS. SSH implies neither an OS nor a
|
|
217
|
+
shell. `dialect="auto"` performs positive cached probing and raises rather than
|
|
218
|
+
guessing. The SSH executor provider closes its AsyncSSH connection and waits for closure.
|
|
219
|
+
AsyncSSH authentication failures are exposed as `PermissionError`; SSH
|
|
220
|
+
host-key, key-exchange, disconnect, connection-loss, protocol, and channel
|
|
221
|
+
failures are exposed as `ConnectionError`. The original AsyncSSH exception is
|
|
222
|
+
retained as `__cause__`.
|
|
223
|
+
The SFTP path provider reuses one `AsyncsshSftpBackend` per host and invalidates its
|
|
224
|
+
cached sources during `close()`; each path call does not create another SFTP
|
|
225
|
+
connection. Provider close performs all AsyncSSH operations through the
|
|
226
|
+
shared bridge. Omitted `run()` stdin is an explicit EOF stream, `bufsize=0` is
|
|
227
|
+
rejected, and a missing remote exit status is reported as return code `-1`.
|
|
228
|
+
Timeouts raise `subprocess.TimeoutExpired` with an `orphaned` flag indicating
|
|
229
|
+
whether a process/channel termination hook was available. `dialect="auto"`
|
|
230
|
+
retains the executable path reported by the successful probe. Persistent SSH
|
|
231
|
+
process reads, writes, EOF, and close operations use the same transport-error
|
|
232
|
+
normalization as `wait()`.
|
|
233
|
+
|
|
234
|
+
## Containers
|
|
235
|
+
|
|
236
|
+
`ContainerConfig(container, engine_url=None, user=None, workdir=None,
|
|
237
|
+
executable=None, dialect="auto", path_flavor="auto")` uses the optional
|
|
238
|
+
`container` extra and Docker Engine API. Inspection selects Linux/POSIX or
|
|
239
|
+
Windows/PowerShell semantics. `ContainerHost` supports buffered exec,
|
|
240
|
+
persistent sessions/TTYs, and archive-backed POSIX or Windows paths. Archive
|
|
241
|
+
paths support stat/traversal/read/write/append/exclusive-create; archive-only
|
|
242
|
+
mkdir/remove/rename/chmod raise `NotImplementedError`.
|
|
243
|
+
|
|
244
|
+
## QEMU Guest Agent
|
|
245
|
+
|
|
246
|
+
`QemuConfig(domain, transport="libvirt", connection=None, socket_path=None,
|
|
247
|
+
ssh=None, agent_timeout=10, dialect="auto", path_flavor="auto")` creates a
|
|
248
|
+
`QemuHost`. Transports are local libvirt (`qemu-libvirt` extra), direct Unix
|
|
249
|
+
socket, or an AsyncSSH-tunneled remote Unix socket. Discovery positively probes
|
|
250
|
+
QGA and its enabled command list.
|
|
251
|
+
|
|
252
|
+
`QemuExecutor` uses buffered `guest-exec`/`guest-exec-status`. QGA cannot cancel
|
|
253
|
+
timed-out processes; `TimeoutExpired.orphaned` is true and `.pid` is retained
|
|
254
|
+
when known. `QgaPathBackend` uses bounded file-handle RPCs; metadata/mutations
|
|
255
|
+
without a positively available helper raise `NotImplementedError`.
|
|
256
|
+
An injected `QemuSerialConsole` adds the `serial` capability and
|
|
257
|
+
`QemuHost.open_serial()`. It is raw, exclusive, and makes no shell/status claim.
|
|
258
|
+
|
|
259
|
+
## WinRM
|
|
260
|
+
|
|
261
|
+
`WinRMConfig(host, username, password=None, transport="ntlm", port=None,
|
|
262
|
+
ssl=False, server_cert_validation="validate", message_encryption="auto",
|
|
263
|
+
operation_timeout_sec=20, read_timeout_sec=30, provider="auto")`. `auto`
|
|
264
|
+
selects PSRP when `hostctl[psrp]` is installed on Python 3.10+, otherwise
|
|
265
|
+
pywinrm; `provider="psrp"` requires the extra.
|
|
266
|
+
|
|
267
|
+
The WinRM executor provider supports PowerShell `run()` and Windows-semantic `WinRMPath`.
|
|
268
|
+
Password-free configs on Windows use current-context native PowerShell
|
|
269
|
+
remoting; explicit credentials use pywinrm. `WinRMPath.open("rb")` fetches
|
|
270
|
+
bounded ranges; writable modes stage content and transfer Base64 chunks on
|
|
271
|
+
close. WinRM stdin and command
|
|
272
|
+
deadlines remain unsupported. Transport timeouts are not a total command
|
|
273
|
+
deadline. pywinrm Session has no guaranteed close API; hostctl calls `close()`
|
|
274
|
+
only when a provided session exposes it.
|
|
275
|
+
PSRP runspaces are exposed separately through the WinRM transport provider and
|
|
276
|
+
`RunspaceSession.invoke()`. They retain typed PowerShell streams and state, and
|
|
277
|
+
are not advertised as a byte-oriented `spawn`/TTY process.
|
|
278
|
+
|
|
279
|
+
## Local and utilities
|
|
280
|
+
|
|
281
|
+
`LocalHost.path()` and `LocalHost.run()` work on POSIX and Windows.
|
|
282
|
+
`LocalExecutor` provides native argv, cwd, environment, stream, encoding,
|
|
283
|
+
check, and timeout behavior through `subprocess.run`.
|
|
284
|
+
|
|
285
|
+
`SerialConfig`/`SerialHost` provide an opaque `serial:///...` URI and one
|
|
286
|
+
exclusive byte-stream lease. `RawConsoleProfile` supports sessions only;
|
|
287
|
+
`PromptConsoleProfile` adds bounded login/prompt framing and advertises
|
|
288
|
+
`run` only when `reliable_status=True` and a completion marker is configured.
|
|
289
|
+
Streams are merged, PTY/path/status semantics are absent unless the profile
|
|
290
|
+
explicitly supplies them. Optional PySerial support is the `serial` extra and
|
|
291
|
+
injected serial objects remain caller-owned. Break, DTR, and RTS are available
|
|
292
|
+
on `SerialProcess`/`SerialConsoleProcess`; RFC 2217 URLs are not encrypted.
|
hostctl/README.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
[](https://pypi.org/project/hostctl/)
|
|
2
|
+
[](LICENSE)
|
|
3
|
+
[](https://jose-pr.github.io/hostctl/)
|
|
4
|
+
[](https://github.com/jose-pr/hostctl/actions/workflows/release.yml)
|
|
5
|
+
|
|
6
|
+
A **protocol-agnostic way to run commands and access files on a host**.
|
|
7
|
+
`Host` defines operations; `HostConfig` owns secret-safe connection identity,
|
|
8
|
+
extensible URI dispatch, and lifecycle. Local and application hosts can compose
|
|
9
|
+
SSH, WinRM, serial, container, and QEMU providers while exposing only the
|
|
10
|
+
capabilities those providers actually support.
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- **`Host.run(...)`** — subprocess-compatible results from local shells,
|
|
15
|
+
SSH (`asyncssh`), or PowerShell over WinRM (`pywinrm`). Unsupported transport
|
|
16
|
+
options raise `NotImplementedError`.
|
|
17
|
+
- **`Host.path(...)`** — a `pathlib_next.Path` filesystem view: local,
|
|
18
|
+
remote SFTP, Windows over WinRM, container archives, or QEMU Guest Agent.
|
|
19
|
+
- **Cross-host copy and sync** — use `Path.copy()`/`PathSyncer` directly;
|
|
20
|
+
`host_checksum()` computes unchanged-file digests beside remote data.
|
|
21
|
+
- **`with host.shell as session:`** — a persistent shell over SSH or a
|
|
22
|
+
container, closed on exit; `host.shell.session(...)` takes a starting
|
|
23
|
+
command, TTY, `cwd`, `env`, or encoding. `send(*cmds)` uses the same
|
|
24
|
+
structured quoting rules.
|
|
25
|
+
- **Serial console hosts.** `SerialConfig` accepts opaque native/PySerial URLs;
|
|
26
|
+
raw profiles provide exclusive sessions, while an explicitly configured
|
|
27
|
+
prompt profile can add safely framed `run()` results. Serial consoles never
|
|
28
|
+
imply a filesystem or PTY and RFC 2217 has no encryption.
|
|
29
|
+
- **Explicit or detected SSH shells.** Use a concrete dialect for deterministic
|
|
30
|
+
behavior or `dialect="auto"` for positive POSIX/Windows probing.
|
|
31
|
+
- **Extensible shell languages.** Select a registered string, a
|
|
32
|
+
`ShellFlavour` subclass, or a configured flavour instance.
|
|
33
|
+
- **Extension point, not a closed abstraction.** Override `Host.path()` in a
|
|
34
|
+
subclass to add a project-specific backend (see `docs/guide/extending.md`).
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install hostctl
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Optional features/extras:
|
|
43
|
+
|
|
44
|
+
| Extra/flag | Adds | Needed for |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| `ssh` | `asyncssh`, `pathlib_next[sftp-async]` | `run()`/`path()` over SSH |
|
|
47
|
+
| `winrm` | `pywinrm` | PowerShell `run()` over WinRM |
|
|
48
|
+
| `psrp` | `pypsrp` on Python 3.10+ | persistent typed PowerShell runspaces |
|
|
49
|
+
| `container` | Docker SDK for Python | Docker Engine `run()`/`path()`/sessions |
|
|
50
|
+
| `serial` | PySerial | raw native/RFC 2217/socket serial sessions |
|
|
51
|
+
| `qemu-libvirt` | libvirt Python bindings | local libvirt QGA transport |
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from hostctl import (
|
|
57
|
+
Host,
|
|
58
|
+
HostConfig,
|
|
59
|
+
ContainerConfig,
|
|
60
|
+
QemuConfig,
|
|
61
|
+
LocalConfig,
|
|
62
|
+
POWERSHELL,
|
|
63
|
+
SshConfig,
|
|
64
|
+
WinRMConfig,
|
|
65
|
+
)
|
|
66
|
+
from pathlib_next import WindowsPathname
|
|
67
|
+
|
|
68
|
+
# Local
|
|
69
|
+
with LocalConfig() as host:
|
|
70
|
+
result = host.run("echo hello")
|
|
71
|
+
print(result.stdout)
|
|
72
|
+
|
|
73
|
+
# SSH to a POSIX target (needs the `ssh` extra)
|
|
74
|
+
with SshConfig(host="nas.example.com", username="admin", password="secret") as host:
|
|
75
|
+
result = host.run("df -h")
|
|
76
|
+
for line in host.path("/etc").iterdir():
|
|
77
|
+
print(line)
|
|
78
|
+
|
|
79
|
+
# SSH to Windows is explicit, not inferred from the transport.
|
|
80
|
+
windows_ssh = SshConfig(
|
|
81
|
+
host="windows.example.com",
|
|
82
|
+
username="admin",
|
|
83
|
+
password="secret",
|
|
84
|
+
dialect=POWERSHELL,
|
|
85
|
+
path_flavor=WindowsPathname,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# WinRM supports PowerShell execution and Windows filesystem paths.
|
|
89
|
+
with WinRMConfig("windows.example.com", "admin", "secret", ssl=True) as windows:
|
|
90
|
+
windows.run(["Write-Output", "hello"])
|
|
91
|
+
windows.path(r"C:\Temp\hello.txt").write_text("hello", encoding="utf-8")
|
|
92
|
+
|
|
93
|
+
# An existing running container (needs the `container` extra).
|
|
94
|
+
with ContainerConfig("application") as container:
|
|
95
|
+
container.run(["printf", "%s\n", "hello"])
|
|
96
|
+
print(container.path("/etc/os-release").read_text())
|
|
97
|
+
with container.shell.session(terminal=True, encoding="utf-8") as session:
|
|
98
|
+
session.send(["printf", "%s\n", "hello from the session"])
|
|
99
|
+
print(session.read())
|
|
100
|
+
|
|
101
|
+
# A QEMU guest through its QGA Unix socket tunneled over SSH.
|
|
102
|
+
with QemuConfig(
|
|
103
|
+
"vm-id",
|
|
104
|
+
transport="ssh",
|
|
105
|
+
ssh=SshConfig("hypervisor.example", username="root"),
|
|
106
|
+
) as guest:
|
|
107
|
+
print(guest.info())
|
|
108
|
+
print(guest.run(["echo", "hello"], encoding="utf-8").stdout)
|
|
109
|
+
print(guest.path("/etc/os-release").read_text())
|
|
110
|
+
|
|
111
|
+
# A canonical connection URI carries configuration, never a password.
|
|
112
|
+
with Host(windows_ssh.connection_uri, password="secret") as same_host:
|
|
113
|
+
same_host.run(["Write-Output", "hello"])
|
|
114
|
+
|
|
115
|
+
# A `user:password@host` URI is still valid input: the password is extracted
|
|
116
|
+
# and used, and never rendered back out. Use `redact_uri` to display one.
|
|
117
|
+
with Host("ssh://admin:secret@nas.example.com") as same_host:
|
|
118
|
+
same_host.run("uptime")
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
A URI may carry `user:password@host` on the way *in* — it is a valid URI, so
|
|
122
|
+
hostctl accepts it, extracts the password into the credentials, and keeps it
|
|
123
|
+
out of every rendered form. Passing the same password both in the URI and as an
|
|
124
|
+
argument is an error rather than a silent precedence rule. `redact_uri(uri)`
|
|
125
|
+
strips the password and returns a valid, reusable URI — it is removed rather
|
|
126
|
+
than masked, so a rendered form can never round-trip a wrong credential.
|
|
127
|
+
|
|
128
|
+
Shell `env` defaults merge per key; `env=None` declines them, leaving whatever
|
|
129
|
+
environment the host itself provides.
|
|
130
|
+
|
|
131
|
+
A password field may also carry extra credentials, one per line after it:
|
|
132
|
+
`parse_credentials("hunter2\notp:123456")` yields `("hunter2", {"otp":
|
|
133
|
+
"123456"})`. A bare name is a flag meaning the same as `name:`. This lets an
|
|
134
|
+
OTP or other second factor reach a transport through a single field — a URI's
|
|
135
|
+
userinfo, an environment variable, a prompt — without each caller inventing an
|
|
136
|
+
encoding. Inside a URI the separator may be written raw — hostctl encodes it
|
|
137
|
+
before parsing, since `urlsplit` would otherwise delete it and silently merge
|
|
138
|
+
the extras into the password. A control character in the *host* is still
|
|
139
|
+
rejected: no encoding makes a hostname containing one meaningful, and allowing
|
|
140
|
+
it would let a URI that reads as one target resolve to another.
|
|
141
|
+
|
|
142
|
+
`str(config)` is the same canonical, secret-free connection string as
|
|
143
|
+
`config.connection_uri`. `HostConfig(str(config), **secrets)` reconstructs the
|
|
144
|
+
concrete configuration without creating or connecting a host.
|
|
145
|
+
|
|
146
|
+
Install `hostctl[winrm-kerberos]` or `hostctl[winrm-credssp]` when selecting
|
|
147
|
+
those WinRM authentication transports. Certificate authentication is not
|
|
148
|
+
exposed until its required certificate/key configuration is part of the API.
|
|
149
|
+
|
|
150
|
+
### Composable system hosts
|
|
151
|
+
|
|
152
|
+
Use `PosixHost`, `WindowsHost`, or `IosHost` when system semantics should be
|
|
153
|
+
independent of the transport. Providers are tried in declaration order during
|
|
154
|
+
preflight; a provider may be retried only when it raises
|
|
155
|
+
`OperationNotStarted`, which guarantees that no remote operation was sent.
|
|
156
|
+
Paths retain their selected provider and expose it through `.provider` and
|
|
157
|
+
`.via(name)`:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
from hostctl import ExecutorProvider, PathProvider, PosixHost, LocalExecutor, HostPath
|
|
161
|
+
|
|
162
|
+
host = PosixHost(
|
|
163
|
+
executor_providers=(ExecutorProvider("ssh", ssh_executor),
|
|
164
|
+
ExecutorProvider("local", LocalExecutor())),
|
|
165
|
+
path_providers=(PathProvider("sftp", sftp_path),
|
|
166
|
+
PathProvider("rpc", lambda *p: HostPath(*p))),
|
|
167
|
+
)
|
|
168
|
+
path = host.path("etc", "hosts")
|
|
169
|
+
print(path.provider.name)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Application-specific adapters can follow the SFTP/RPC/download pattern in
|
|
173
|
+
[`examples/application_provider.py`](examples/application_provider.py). The
|
|
174
|
+
[Systems and providers](https://jose-pr.github.io/hostctl/guide/providers/)
|
|
175
|
+
guide covers selection traces, backend pinning, per-operation capabilities,
|
|
176
|
+
provider authoring, and the no-replay safety rule in full.
|
|
177
|
+
|
|
178
|
+
Existing transport URIs remain compatible. To opt into system semantics while
|
|
179
|
+
retaining those URI/configuration objects, compose them explicitly:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
from hostctl import PosixHost, SshConfig, WindowsHost, WinRMConfig
|
|
183
|
+
|
|
184
|
+
posix = PosixHost.from_ssh(SshConfig("server.example", username="root"))
|
|
185
|
+
windows = WindowsHost.from_winrm(WinRMConfig("server.example", "admin"))
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
SSH and WinRM transport implementations are private providers; QEMU's SSH
|
|
189
|
+
tunnel continues to consume `SshConfig` directly.
|
|
190
|
+
|
|
191
|
+
## Command line
|
|
192
|
+
|
|
193
|
+
The installed `hostctl` command is a thin wrapper over the library:
|
|
194
|
+
|
|
195
|
+
```console
|
|
196
|
+
hostctl run local: -- python -c "print('hello')"
|
|
197
|
+
hostctl info ssh://server
|
|
198
|
+
hostctl cp ./artifact ssh://server:/tmp/artifact
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Secrets never belong in argv. Use `HOSTCTL_PASSWORD` or the subcommand's
|
|
202
|
+
`--ask-password` option. See the
|
|
203
|
+
[CLI guide](https://jose-pr.github.io/hostctl/guide/cli/) for all subcommands
|
|
204
|
+
and exit statuses.
|
|
205
|
+
|
|
206
|
+
## API overview
|
|
207
|
+
|
|
208
|
+
| Module | Purpose |
|
|
209
|
+
| --- | --- |
|
|
210
|
+
| `hostctl.host` | Shared contracts and built-in host providers |
|
|
211
|
+
| `hostctl.host.container` | `ContainerConfig`, `ContainerHost` |
|
|
212
|
+
| `hostctl.host.qemu` | `QemuConfig`, `QemuHost` |
|
|
213
|
+
| `hostctl.host.serial` | `SerialConfig`, `SerialHost` |
|
|
214
|
+
| `hostctl.provider` | Ordered executor/path provider composition |
|
|
215
|
+
| `hostctl.executor` | Transport-specific command executors |
|
|
216
|
+
| `hostctl.shell` | Shell flavours and persistent sessions |
|
|
217
|
+
| `hostctl.sync` | Remote checksum and byte-progress helpers for `pathlib_next` |
|
|
218
|
+
| `hostctl._cli` | Dependency-free command-line entry point |
|
|
219
|
+
|
|
220
|
+
## Development
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
py -3.14 -m venv .venv/3.14-nt-amd64
|
|
224
|
+
.venv/3.14-nt-amd64/Scripts/python -m pip install -e ".[dev,ssh,winrm,container,serial]"
|
|
225
|
+
.venv/3.14-nt-amd64/Scripts/python -m pytest -q
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Python 3.14 is the default development interpreter. Python 3.9 remains the
|
|
229
|
+
supported compatibility floor and should be selected explicitly with
|
|
230
|
+
`py -3.9` when running floor-specific checks.
|
|
231
|
+
|
|
232
|
+
### Releasing
|
|
233
|
+
|
|
234
|
+
This project follows [Semantic Versioning](https://semver.org/) and keeps a
|
|
235
|
+
[`CHANGELOG.md`](CHANGELOG.md). Pushing a tag matching `v*` triggers the release
|
|
236
|
+
workflow: test gate → build → publish → docs deploy.
|
|
237
|
+
|
|
238
|
+
To prepare a release, update `pyproject.toml` and move the complete
|
|
239
|
+
`[Unreleased]` section to `## [X.Y.Z] - YYYY-MM-DD` in the same commit. Keep
|
|
240
|
+
the package version PEP 440-compatible and use the corresponding SemVer tag
|
|
241
|
+
(`vX.Y.Z`, or `vX.Y.Z-rc.N` for a prerelease). Then push the commit and tag;
|
|
242
|
+
the workflow extracts that changelog section for the GitHub release and
|
|
243
|
+
publishes the built artifacts. Leave a fresh empty `[Unreleased]` section for
|
|
244
|
+
the next cycle.
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
MIT — see [LICENSE](LICENSE).
|