hostctl 0.2.4__tar.gz → 0.2.6__tar.gz
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-0.2.4 → hostctl-0.2.6}/.gitignore +4 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/CHANGELOG.md +117 -1
- {hostctl-0.2.4 → hostctl-0.2.6}/PKG-INFO +2 -2
- {hostctl-0.2.4 → hostctl-0.2.6}/docs/guide/cli.md +4 -1
- {hostctl-0.2.4 → hostctl-0.2.6}/docs/guide/contracts.md +3 -3
- {hostctl-0.2.4 → hostctl-0.2.6}/pyproject.toml +1 -1
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/AGENTS.md +12 -1
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/_cli.py +29 -2
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/__init__.py +8 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/_common.py +20 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/container.py +2 -1
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/local.py +2 -1
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/psrp.py +10 -24
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/qemu.py +2 -1
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/ssh.py +4 -1
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/winrm.py +62 -21
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/_common.py +4 -3
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/_ssh.py +14 -1
- hostctl-0.2.6/src/hostctl/host/_staged_io.py +132 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/_winrm.py +3 -74
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/composite_path.py +71 -56
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/container.py +1 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/container_path.py +3 -57
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/qemu.py +3 -57
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/serial.py +16 -11
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/system.py +9 -16
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/process/psrp.py +8 -1
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/process/ssh.py +6 -9
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/provider/transports.py +5 -20
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/shell/cmd.py +0 -1
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/shell/fish.py +0 -1
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/shell/posix.py +0 -1
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/shell/powershell.py +0 -1
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/conformance/providers.py +58 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/conformance/test_live.py +29 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/conformance/test_path_contract.py +34 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/conformance/test_run_contract.py +53 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/conformance/test_sync_contract.py +12 -6
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_cli.py +44 -0
- hostctl-0.2.6/tests/test_executor_exports.py +104 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_host_container.py +38 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_host_remote.py +33 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_host_winrm.py +94 -1
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_psrp.py +111 -2
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_serial_host.py +49 -0
- hostctl-0.2.6/tests/test_staged_io.py +143 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_system_hosts.py +29 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/LICENSE +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/README.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/docs/api/reference.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/docs/changelog.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/docs/guide/extending.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/docs/guide/path.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/docs/guide/providers.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/docs/guide/run.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/docs/guide/transfer.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/docs/index.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/examples/application_provider.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/examples/copy_between_hosts.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/examples/local_run.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/examples/remote_run.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/mkdocs.yml +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/__init__.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/__main__.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/_async.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/_qga.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/serial.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/__init__.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/_connection.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/_local.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/process/__init__.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/process/_common.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/process/container.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/process/qemu_serial.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/process/serial.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/provider/__init__.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/provider/_common.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/py.typed +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/serial/__init__.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/shell/__init__.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/shell/_common.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/sync.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/conformance/__init__.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/conformance/path_fakes.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/conformance/test_composite_path_routing.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/conformance/test_process_contract.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_application_provider.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_asyncssh_errors.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_composite_path_backend_kwargs.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_composite_path_inherited_operations.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_composite_path_propagation.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_connection_string.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_container_path.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_container_process.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_exec_command.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_host_api.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_host_local.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_host_module_layout.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_host_qemu.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_observability.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_process.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_provider_fault_injection.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_qemu_executor.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_qemu_path.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_qemu_serial.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_qemu_serial_process.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_qga_ssh_transport.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_qga_transport.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_serial_executor.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_serial_live.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_shell.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_ssh_process.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_sync_helpers.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_system_host_fidelity.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_winrm_path.py +0 -0
|
@@ -7,6 +7,120 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.6] - 2026-08-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `wants_text(text, encoding, errors)` is exported from `hostctl.executor`,
|
|
15
|
+
joining the four stream helpers made public in 0.2.5 for the same reason:
|
|
16
|
+
an executor implemented outside hostctl must reach the same conclusions as
|
|
17
|
+
the built-in ones, because a `SystemHost` chooses the provider.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Direct execution no longer raises `NameError` on two dispatch paths.
|
|
22
|
+
`ContainerHost.spawn(Exec(...))` raised on *every* direct spawn, and
|
|
23
|
+
`SystemHost.run(Exec(program))` raised whenever the selected executor
|
|
24
|
+
provider advertised neither `args` nor `script` — the shape a bare
|
|
25
|
+
`ExecutorProvider("name", callable)` has, which is the documented minimal
|
|
26
|
+
form of that public authoring contract. Both modules used `command_text`
|
|
27
|
+
without importing it.
|
|
28
|
+
|
|
29
|
+
- `hostctl cp` no longer mistakes a URI port colon for the `URI:PATH`
|
|
30
|
+
separator. `ssh://host:2222/tmp/x` split into `ssh://host` plus the relative
|
|
31
|
+
path `2222/tmp/x` — the default port and the wrong file, with no message. A
|
|
32
|
+
split that lands on the authority's port colon is now rejected with the
|
|
33
|
+
grammar error, so the required spelling `ssh://host:2222:/tmp/x` is the only
|
|
34
|
+
one that runs. Userinfo colons and IPv6 literals are excluded from the check.
|
|
35
|
+
|
|
36
|
+
- Native current-context WinRM now honours `server_cert_validation="ignore"`.
|
|
37
|
+
It was spliced into the rendered wrapper with `str.replace`, which matched
|
|
38
|
+
nothing without a port (the setting vanished) and produced an
|
|
39
|
+
`Invoke-Command` parameter-binding error with one, since `SkipCACheck`
|
|
40
|
+
belongs to `New-PSSessionOption`. The option is now built explicitly and
|
|
41
|
+
passed as `-SessionOption`, skipping both the CA and CN checks to match
|
|
42
|
+
pywinrm's `ignore`.
|
|
43
|
+
|
|
44
|
+
- Native current-context WinRM now reports remote exit codes.
|
|
45
|
+
`Invoke-Command` does not copy the remote `$LASTEXITCODE` into the calling
|
|
46
|
+
session, so a command that failed only by status — a native executable
|
|
47
|
+
exiting non-zero without throwing — returned 0 locally and `check=True`
|
|
48
|
+
never fired. The remote script block now emits the code as a
|
|
49
|
+
`__HOSTCTL_LASTEXITCODE__` marker line, which the local wrapper consumes
|
|
50
|
+
and exits with. **Behaviour change**: calls that silently succeeded against
|
|
51
|
+
a failing remote command now raise `CalledProcessError` under the default
|
|
52
|
+
`check=True`.
|
|
53
|
+
|
|
54
|
+
- Every provider now agrees on when output is text. `errors=` alone selected
|
|
55
|
+
text mode on the local, WinRM, container, and QEMU executors and binary
|
|
56
|
+
mode on SSH, PSRP, and the serial host, so the identical call returned
|
|
57
|
+
`str` or `bytes` depending on which provider a `SystemHost` selected — the
|
|
58
|
+
exact divergence the shared stream helpers exist to prevent. All seven
|
|
59
|
+
sites now call `wants_text`. **Behaviour change**: `run(cmd,
|
|
60
|
+
errors="replace")` returns `str` from SSH, PSRP, and serial where it
|
|
61
|
+
previously returned `bytes`.
|
|
62
|
+
|
|
63
|
+
- SFTP paths percent-encode the remote path before embedding it in the
|
|
64
|
+
`sftp://` URI. `pathlib_next` parses that URI and uridecodes its parts, so
|
|
65
|
+
a filename containing `?` or `#` was truncated into a query or fragment and
|
|
66
|
+
a literal `%xx` was decoded into a different name — reading and writing the
|
|
67
|
+
wrong file with no error. Encoding is minimal (RFC 3986 `pchar`), so a
|
|
68
|
+
Windows-flavoured remote path still reads as `sftp://host:22/C:/Temp`.
|
|
69
|
+
|
|
70
|
+
- `RunspaceSession` no longer closes a pool it was given. `_owns_pool` was
|
|
71
|
+
recorded at construction and never read, so a pool injected to be shared
|
|
72
|
+
across sessions was closed by whichever session finished first. An injected
|
|
73
|
+
pool is now left open and retained, which also leaves the session
|
|
74
|
+
reopenable; a pool the session created is still closed.
|
|
75
|
+
|
|
76
|
+
- An abandoned container or QGA write stream no longer uploads from the
|
|
77
|
+
garbage collector. The staged write-back stream existed as three
|
|
78
|
+
byte-identical copies of which only the WinRM one had a `__del__` guard;
|
|
79
|
+
`io.IOBase.__del__` calls `close()`, and `close()` is what commits, so a
|
|
80
|
+
write stream that went out of scope unclosed performed its network
|
|
81
|
+
transfer at an arbitrary GC point with any error printed and swallowed by
|
|
82
|
+
the interpreter. One `hostctl.host._staged_io` now serves all three, and
|
|
83
|
+
the abandonment case warns instead of uploading. The `open()` mode
|
|
84
|
+
validators were deduplicated with it, so `"rt"` is accepted on the
|
|
85
|
+
container and QGA backends as it always was on WinRM.
|
|
86
|
+
|
|
87
|
+
- `SerialHost.run(capture_output=False)` no longer discards the console
|
|
88
|
+
transcript. It reimplemented the output contract and treated a `None`
|
|
89
|
+
stdout target as "discard"; the shared `dispatch_output` — and every other
|
|
90
|
+
transport, and `subprocess` — treats it as `sys.stdout`. Serial now routes
|
|
91
|
+
through the shared helper. `stdout=subprocess.DEVNULL` remains the way to
|
|
92
|
+
discard deliberately.
|
|
93
|
+
|
|
94
|
+
## [0.2.5] - 2026-08-05
|
|
95
|
+
|
|
96
|
+
### Added
|
|
97
|
+
|
|
98
|
+
- `write_output`, `normalize_input`, and `dispatch_output` are exported from
|
|
99
|
+
`hostctl.executor`, joining `capture_streams`. An executor implemented
|
|
100
|
+
outside hostctl previously had to import `hostctl.executor._common` to
|
|
101
|
+
reproduce hostctl's own stdout/stderr and stdin semantics.
|
|
102
|
+
|
|
103
|
+
Sharing these is a correctness requirement rather than a convenience: a
|
|
104
|
+
`SystemHost` can dispatch the same call through different providers on
|
|
105
|
+
different attempts, so providers that disagree about output handling return
|
|
106
|
+
results that differ by which transport won. `normalize_input` is the one
|
|
107
|
+
worth not reimplementing — a mismatch there does not raise, it deadlocks,
|
|
108
|
+
because bytes handed to a text-mode stdin kill `subprocess`'s writer thread
|
|
109
|
+
without closing the pipe, so the child never sees EOF and `timeout=` never
|
|
110
|
+
fires.
|
|
111
|
+
|
|
112
|
+
No behaviour change; these are the same objects `_common` defines.
|
|
113
|
+
|
|
114
|
+
### Fixed
|
|
115
|
+
|
|
116
|
+
- Four conformance tests covering timestamp handling were skipping for a
|
|
117
|
+
reason that was not true, so the contract they check went unverified. The
|
|
118
|
+
checks called `os.utime()` on paths belonging to fake *remote* providers,
|
|
119
|
+
which map into a private sandbox root and have no local existence; the
|
|
120
|
+
resulting `FileNotFoundError` was reported as "this provider cannot set
|
|
121
|
+
timestamps". Timestamps are now set through the sandbox that actually stores
|
|
122
|
+
the file. Test-only change.
|
|
123
|
+
|
|
10
124
|
## [0.2.4] - 2026-08-05
|
|
11
125
|
|
|
12
126
|
### Fixed
|
|
@@ -397,7 +511,9 @@ test suite on Python 3.9 through 3.14.
|
|
|
397
511
|
assigned to it; a config-less host now builds its own family configuration
|
|
398
512
|
instead.
|
|
399
513
|
|
|
400
|
-
[Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.
|
|
514
|
+
[Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.6...HEAD
|
|
515
|
+
[0.2.6]: https://github.com/jose-pr/hostctl/compare/v0.2.5...v0.2.6
|
|
516
|
+
[0.2.5]: https://github.com/jose-pr/hostctl/compare/v0.2.4...v0.2.5
|
|
401
517
|
[0.2.4]: https://github.com/jose-pr/hostctl/compare/v0.2.3...v0.2.4
|
|
402
518
|
[0.2.3]: https://github.com/jose-pr/hostctl/compare/v0.2.2...v0.2.3
|
|
403
519
|
[0.2.2]: https://github.com/jose-pr/hostctl/compare/v0.2.1...v0.2.2
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: hostctl
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: Run commands and access files on a host, local or remote, protocol-agnostic
|
|
5
5
|
Project-URL: Homepage, https://github.com/jose-pr/hostctl/
|
|
6
6
|
Project-URL: Documentation, https://jose-pr.github.io/hostctl/
|
|
@@ -22,7 +22,10 @@ interactive hidden prompt. Transport extras remain optional and produce their
|
|
|
22
22
|
normal actionable import error when absent.
|
|
23
23
|
|
|
24
24
|
`cp` accepts local paths or `URI:PATH` operands and delegates to
|
|
25
|
-
`pathlib_next.Path.copy()`.
|
|
25
|
+
`pathlib_next.Path.copy()`. The colon before the path is required even when the
|
|
26
|
+
URI carries a port — `ssh://host:2222:/tmp/x`, never `ssh://host:2222/tmp/x`,
|
|
27
|
+
which is rejected rather than read as port-less host plus a relative path.
|
|
28
|
+
Use `--overwrite` and `--recursive` explicitly.
|
|
26
29
|
The copy is therefore only as streaming and atomic as the two selected path
|
|
27
30
|
backends; see the filesystem and transfer guides for provider-specific limits.
|
|
28
31
|
|
|
@@ -14,9 +14,9 @@ stderr by default. Captured streams are bytes unless `text=True` or an
|
|
|
14
14
|
- A string is shell source and remains verbatim (operators such as `&&`, pipes,
|
|
15
15
|
and redirects are not quoted).
|
|
16
16
|
- A tuple/list is argv data and each item is quoted by the selected shell.
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
- `Exec(program, *args)` is the only direct-execution spelling: one program
|
|
18
|
+
with an argv, never interpreted by a shell, and never combined with other
|
|
19
|
+
commands. A path anywhere else is an ordinary value that stringifies.
|
|
20
20
|
- Multiple top-level commands are joined with the shell's sequence separator.
|
|
21
21
|
- `env` is additive to the provider's environment. `cwd` is the process working
|
|
22
22
|
directory. `check=True` raises `CalledProcessError` for every non-zero status;
|
|
@@ -177,6 +177,13 @@ corresponding hosts.
|
|
|
177
177
|
thread, and the call then blocks forever because the child never sees EOF --
|
|
178
178
|
`timeout=` does not fire. All executors share the helper so the same call
|
|
179
179
|
behaves identically whichever provider a `SystemHost` selects.
|
|
180
|
+
- Output is `str` when ANY of `text`, `encoding`, or `errors` is given, and
|
|
181
|
+
`bytes` otherwise -- `subprocess.run`'s rule, decided by the exported
|
|
182
|
+
`wants_text(text, encoding, errors)`. `text=False` does not veto an
|
|
183
|
+
`encoding`. An executor implemented outside hostctl must use the same
|
|
184
|
+
helper, for the same reason as `normalize_input`: a `SystemHost` picks the
|
|
185
|
+
provider, so a caller cannot write one correct invocation if providers
|
|
186
|
+
disagree about the result type.
|
|
180
187
|
- `env` on `run`/`session`/`configure` accepts `EnvironmentSelection`: a
|
|
181
188
|
mapping merges over the shell's default per key, the default empty mapping
|
|
182
189
|
merges nothing and inherits it, and `None` runs without the shell's
|
|
@@ -277,7 +284,11 @@ pywinrm; `provider="psrp"` requires the extra.
|
|
|
277
284
|
|
|
278
285
|
The WinRM executor provider supports PowerShell `run()` and Windows-semantic `WinRMPath`.
|
|
279
286
|
Password-free configs on Windows use current-context native PowerShell
|
|
280
|
-
remoting; explicit credentials use pywinrm.
|
|
287
|
+
remoting; explicit credentials use pywinrm. On that native path
|
|
288
|
+
`server_cert_validation="ignore"` becomes `New-PSSessionOption -SkipCACheck
|
|
289
|
+
-SkipCNCheck` (both checks, matching pywinrm), and the remote exit code is
|
|
290
|
+
carried back explicitly, so a native command failing only by status is
|
|
291
|
+
reported as a failure rather than as success. `WinRMPath.open("rb")` fetches
|
|
281
292
|
bounded ranges; writable modes stage content and transfer Base64 chunks on
|
|
282
293
|
close. WinRM stdin and command
|
|
283
294
|
deadlines remain unsupported. Transport timeouts are not a total command
|
|
@@ -49,6 +49,22 @@ def _open_host(stack: contextlib.ExitStack, uri: str, credentials: dict[str, obj
|
|
|
49
49
|
return stack.enter_context(Host(uri, **credentials))
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
def _port_colon(value: str, scheme_end: int, authority_end: int) -> int:
|
|
53
|
+
"""Index of the authority's port colon, or ``-1`` when it has none.
|
|
54
|
+
|
|
55
|
+
The port colon is the first colon of the host part: userinfo colons sit
|
|
56
|
+
before the ``@``, and an IPv6 literal keeps its colons inside brackets.
|
|
57
|
+
"""
|
|
58
|
+
userinfo_end = value.rfind("@", scheme_end, authority_end)
|
|
59
|
+
host_start = scheme_end if userinfo_end < 0 else userinfo_end + 1
|
|
60
|
+
if value[host_start : host_start + 1] == "[":
|
|
61
|
+
bracket_end = value.find("]", host_start, authority_end)
|
|
62
|
+
if bracket_end < 0:
|
|
63
|
+
return -1
|
|
64
|
+
host_start = bracket_end + 1
|
|
65
|
+
return value.find(":", host_start, authority_end)
|
|
66
|
+
|
|
67
|
+
|
|
52
68
|
def _path_operand(
|
|
53
69
|
stack: contextlib.ExitStack,
|
|
54
70
|
value: str,
|
|
@@ -58,10 +74,21 @@ def _path_operand(
|
|
|
58
74
|
if not _URI_OPERAND.match(value) or re.match(r"^[A-Za-z]:[\\/]", value):
|
|
59
75
|
return HostPath(value)
|
|
60
76
|
if "://" in value:
|
|
61
|
-
|
|
77
|
+
scheme_end = value.find("://") + 3
|
|
78
|
+
authority_end = value.find("/", scheme_end)
|
|
62
79
|
search_end = len(value) if authority_end < 0 else authority_end
|
|
63
80
|
separator = value.rfind(":", 0, search_end)
|
|
64
|
-
if separator
|
|
81
|
+
if separator < scheme_end:
|
|
82
|
+
raise ValueError("remote path operand must be URI:PATH")
|
|
83
|
+
if (
|
|
84
|
+
separator == _port_colon(value, scheme_end, search_end)
|
|
85
|
+
and value[separator + 1 : search_end].isdigit()
|
|
86
|
+
):
|
|
87
|
+
# `ssh://host:2222/x` has no path colon, so the last colon before
|
|
88
|
+
# the authority's slash is the port's. Splitting there yields
|
|
89
|
+
# `ssh://host` plus the relative path `2222/x` -- the wrong host
|
|
90
|
+
# and the wrong file, with nothing said. The grammar wants
|
|
91
|
+
# `ssh://host:2222:/x`.
|
|
65
92
|
raise ValueError("remote path operand must be URI:PATH")
|
|
66
93
|
else:
|
|
67
94
|
separator = value.find(":", value.find(":") + 1)
|
|
@@ -13,7 +13,11 @@ from ._common import (
|
|
|
13
13
|
PathLike as PathLike,
|
|
14
14
|
normalize_environment as normalize_environment,
|
|
15
15
|
capture_streams as capture_streams,
|
|
16
|
+
dispatch_output as dispatch_output,
|
|
17
|
+
normalize_input as normalize_input,
|
|
16
18
|
reject_stdin_conflict as reject_stdin_conflict,
|
|
19
|
+
wants_text as wants_text,
|
|
20
|
+
write_output as write_output,
|
|
17
21
|
)
|
|
18
22
|
from .container import (
|
|
19
23
|
ContainerExecutor as ContainerExecutor,
|
|
@@ -65,7 +69,11 @@ __all__ = [
|
|
|
65
69
|
"normalize_container_error",
|
|
66
70
|
"normalize_environment",
|
|
67
71
|
"capture_streams",
|
|
72
|
+
"dispatch_output",
|
|
73
|
+
"normalize_input",
|
|
68
74
|
"reject_stdin_conflict",
|
|
75
|
+
"wants_text",
|
|
76
|
+
"write_output",
|
|
69
77
|
"normalize_serial_error",
|
|
70
78
|
"SerialExecutor",
|
|
71
79
|
"SerialFactory",
|
|
@@ -94,6 +94,26 @@ def normalize_input(
|
|
|
94
94
|
return input
|
|
95
95
|
|
|
96
96
|
|
|
97
|
+
def wants_text(
|
|
98
|
+
text: typing.Optional[bool],
|
|
99
|
+
encoding: typing.Optional[str],
|
|
100
|
+
errors: typing.Optional[str],
|
|
101
|
+
) -> bool:
|
|
102
|
+
"""Decide whether one call's output is `str` rather than `bytes`.
|
|
103
|
+
|
|
104
|
+
`subprocess.run`'s rule, and the only one hostctl has: any of `text`,
|
|
105
|
+
`encoding`, or `errors` selects text mode. `text=False` does not veto an
|
|
106
|
+
`encoding` -- `subprocess` does not treat it as one either.
|
|
107
|
+
|
|
108
|
+
This exists as a shared function because the answer must not depend on
|
|
109
|
+
which provider a `SystemHost` happened to select. Four executors inferred
|
|
110
|
+
it as `bool(encoding or errors or text)` while SSH, PSRP, and the serial
|
|
111
|
+
host each ignored `errors`, so `run(cmd, errors="replace")` returned
|
|
112
|
+
`str` or `bytes` depending on which transport won the fallback.
|
|
113
|
+
"""
|
|
114
|
+
return bool(text or encoding or errors)
|
|
115
|
+
|
|
116
|
+
|
|
97
117
|
def command_text(
|
|
98
118
|
value: object, *, encoding: str = "utf-8", errors: str = "strict"
|
|
99
119
|
) -> str:
|
|
@@ -19,6 +19,7 @@ from ._common import (
|
|
|
19
19
|
dispatch_output,
|
|
20
20
|
normalize_environment,
|
|
21
21
|
capture_streams,
|
|
22
|
+
wants_text,
|
|
22
23
|
)
|
|
23
24
|
|
|
24
25
|
|
|
@@ -119,7 +120,7 @@ class ContainerExecutor(Executor[subprocess.CompletedProcess]):
|
|
|
119
120
|
out = b""
|
|
120
121
|
if err is None and not merge_stderr:
|
|
121
122
|
err = b""
|
|
122
|
-
if text
|
|
123
|
+
if wants_text(text, encoding, errors):
|
|
123
124
|
codec = encoding or "utf-8"
|
|
124
125
|
out = (
|
|
125
126
|
out.decode(codec, errors or "strict") if isinstance(out, bytes) else out
|
|
@@ -20,6 +20,7 @@ from ._common import (
|
|
|
20
20
|
normalize_input,
|
|
21
21
|
capture_streams,
|
|
22
22
|
reject_stdin_conflict,
|
|
23
|
+
wants_text,
|
|
23
24
|
)
|
|
24
25
|
|
|
25
26
|
|
|
@@ -69,7 +70,7 @@ class LocalExecutor(Executor[subprocess.CompletedProcess]):
|
|
|
69
70
|
# where bytes would kill the writer thread and hang the call.
|
|
70
71
|
input=normalize_input(
|
|
71
72
|
input,
|
|
72
|
-
text_mode=
|
|
73
|
+
text_mode=wants_text(text, encoding, errors),
|
|
73
74
|
encoding=encoding,
|
|
74
75
|
errors=errors,
|
|
75
76
|
),
|
|
@@ -18,6 +18,7 @@ from ._common import (
|
|
|
18
18
|
Input,
|
|
19
19
|
capture_streams,
|
|
20
20
|
dispatch_output,
|
|
21
|
+
wants_text,
|
|
21
22
|
)
|
|
22
23
|
|
|
23
24
|
if typing.TYPE_CHECKING:
|
|
@@ -97,35 +98,20 @@ class PsrpExecutor(Executor[subprocess.CompletedProcess]):
|
|
|
97
98
|
command_text(command), raw=False, capture_exit=True
|
|
98
99
|
)
|
|
99
100
|
codec = encoding or "utf-8"
|
|
101
|
+
# `invoke(capture_exit=True)` already consumed the
|
|
102
|
+
# `__HOSTCTL_LASTEXITCODE__` line and folded it into `returncode`;
|
|
103
|
+
# this used to re-parse the same marker out of output that no longer
|
|
104
|
+
# contained it, which is two implementations of one convention.
|
|
105
|
+
returncode = result.returncode
|
|
100
106
|
# Preserve PowerShell's object-pipeline line orientation when
|
|
101
107
|
# projecting objects to subprocess-compatible text.
|
|
102
|
-
|
|
103
|
-
returncode = result.returncode
|
|
104
|
-
marker = "__HOSTCTL_LASTEXITCODE__:"
|
|
105
|
-
for item in result.output:
|
|
106
|
-
value = str(item)
|
|
107
|
-
if value.startswith(marker):
|
|
108
|
-
try:
|
|
109
|
-
parsed_returncode = int(value[len(marker) :])
|
|
110
|
-
returncode = (
|
|
111
|
-
1
|
|
112
|
-
if result.had_errors and parsed_returncode == 0
|
|
113
|
-
else parsed_returncode
|
|
114
|
-
)
|
|
115
|
-
except ValueError:
|
|
116
|
-
returncode = 1
|
|
117
|
-
continue
|
|
118
|
-
projected_output.append(item)
|
|
119
|
-
out_text = "\n".join(str(item) for item in projected_output)
|
|
108
|
+
out_text = "\n".join(str(item) for item in result.output)
|
|
120
109
|
err_text = "\n".join(str(item) for item in result.streams.error)
|
|
121
110
|
if out_text:
|
|
122
111
|
out_text += "\n"
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
)
|
|
126
|
-
err: typing.Union[str, bytes] = (
|
|
127
|
-
err_text if text or encoding else err_text.encode(codec)
|
|
128
|
-
)
|
|
112
|
+
as_text = wants_text(text, encoding, errors)
|
|
113
|
+
out: typing.Union[str, bytes] = out_text if as_text else out_text.encode(codec)
|
|
114
|
+
err: typing.Union[str, bytes] = err_text if as_text else err_text.encode(codec)
|
|
129
115
|
if stderr is subprocess.STDOUT:
|
|
130
116
|
out = out + err
|
|
131
117
|
err = None
|
|
@@ -25,6 +25,7 @@ from ._common import (
|
|
|
25
25
|
normalize_input,
|
|
26
26
|
capture_streams,
|
|
27
27
|
reject_stdin_conflict,
|
|
28
|
+
wants_text,
|
|
28
29
|
)
|
|
29
30
|
|
|
30
31
|
|
|
@@ -180,7 +181,7 @@ class QemuExecutor(Executor[subprocess.CompletedProcess]):
|
|
|
180
181
|
"completed guest-exec status has no integer exit code or signal"
|
|
181
182
|
)
|
|
182
183
|
|
|
183
|
-
if text
|
|
184
|
+
if wants_text(text, encoding, errors):
|
|
184
185
|
codec = encoding or "utf-8"
|
|
185
186
|
out = out.decode(codec, errors or "strict")
|
|
186
187
|
err = err.decode(codec, errors or "strict")
|
|
@@ -23,6 +23,7 @@ from ._common import (
|
|
|
23
23
|
normalize_input,
|
|
24
24
|
capture_streams,
|
|
25
25
|
reject_stdin_conflict,
|
|
26
|
+
wants_text,
|
|
26
27
|
)
|
|
27
28
|
|
|
28
29
|
|
|
@@ -138,7 +139,9 @@ class SshExecutor(Executor[subprocess.CompletedProcess]):
|
|
|
138
139
|
raise ValueError("bufsize=0 is unsupported by the buffered SSH executor")
|
|
139
140
|
command = command_text(command)
|
|
140
141
|
reject_stdin_conflict(input, stdin)
|
|
141
|
-
if text and encoding is None:
|
|
142
|
+
if wants_text(text, encoding, errors) and encoding is None:
|
|
143
|
+
# AsyncSSH returns `bytes` unless it is given an encoding, so
|
|
144
|
+
# text mode has to be expressed as one.
|
|
142
145
|
encoding = "utf-8"
|
|
143
146
|
env = normalize_environment(env)
|
|
144
147
|
|
|
@@ -18,6 +18,7 @@ from ._common import (
|
|
|
18
18
|
Input,
|
|
19
19
|
dispatch_output,
|
|
20
20
|
capture_streams,
|
|
21
|
+
wants_text,
|
|
21
22
|
)
|
|
22
23
|
|
|
23
24
|
|
|
@@ -38,6 +39,24 @@ class _NativeResponse:
|
|
|
38
39
|
std_err: bytes
|
|
39
40
|
|
|
40
41
|
|
|
42
|
+
#: Emitted by the remote script block and consumed by the local wrapper.
|
|
43
|
+
#: `Invoke-Command` does not copy the remote `$LASTEXITCODE` into the calling
|
|
44
|
+
#: session, so a native command that fails only by exit code would otherwise
|
|
45
|
+
#: report success locally. Same convention as the PSRP runspace session.
|
|
46
|
+
NATIVE_EXIT_MARKER = "__HOSTCTL_LASTEXITCODE__"
|
|
47
|
+
|
|
48
|
+
#: Runs *inside* the remote script block, appended on its own line so that the
|
|
49
|
+
#: payload's last statement -- comment, `}` or bare expression alike -- cannot
|
|
50
|
+
#: swallow it.
|
|
51
|
+
_NATIVE_EXIT_EPILOGUE = (
|
|
52
|
+
f"Write-Output ('{NATIVE_EXIT_MARKER}:' + [string]([int]$LASTEXITCODE))"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _b64(value: str) -> str:
|
|
57
|
+
return base64.b64encode(value.encode("utf-8")).decode("ascii")
|
|
58
|
+
|
|
59
|
+
|
|
41
60
|
class NativeWinRMSession:
|
|
42
61
|
"""Current-context Windows PowerShell remoting session adapter."""
|
|
43
62
|
|
|
@@ -76,38 +95,60 @@ class NativeWinRMSession:
|
|
|
76
95
|
else:
|
|
77
96
|
self._skip_ca_check = False
|
|
78
97
|
|
|
79
|
-
def
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
98
|
+
def _wrapper(self, script: str) -> str:
|
|
99
|
+
"""Build the local PowerShell program that performs one remote call.
|
|
100
|
+
|
|
101
|
+
Everything variable is base64 -- the host, the payload, and the exit
|
|
102
|
+
epilogue -- so no caller-supplied text is ever spliced into PowerShell
|
|
103
|
+
source. The connection options are assembled as a list rather than
|
|
104
|
+
patched into rendered text afterwards: the previous `str.replace`
|
|
105
|
+
approach silently did nothing without a port and produced a hashtable
|
|
106
|
+
`Invoke-Command` rejects with one.
|
|
107
|
+
"""
|
|
108
|
+
options = [
|
|
109
|
+
"ComputerName=$h",
|
|
110
|
+
"Authentication='Negotiate'",
|
|
111
|
+
f"UseSSL=${str(self.ssl).lower()}",
|
|
112
|
+
]
|
|
113
|
+
if self.port is not None:
|
|
114
|
+
options.append(f"Port={self.port}")
|
|
115
|
+
prelude = ""
|
|
116
|
+
if self._skip_ca_check:
|
|
117
|
+
# `SkipCACheck` is a `New-PSSessionOption` parameter, not an
|
|
118
|
+
# `Invoke-Command` one. Both checks are skipped together to match
|
|
119
|
+
# pywinrm's `server_cert_validation="ignore"`.
|
|
120
|
+
prelude = "$so=New-PSSessionOption -SkipCACheck -SkipCNCheck;"
|
|
121
|
+
options.append("SessionOption=$so")
|
|
122
|
+
return (
|
|
83
123
|
"$OutputEncoding=[Console]::OutputEncoding="
|
|
84
124
|
"[Text.UTF8Encoding]::new($false);"
|
|
85
125
|
"$ErrorActionPreference='Stop';"
|
|
86
126
|
"$h=[Text.Encoding]::UTF8.GetString("
|
|
87
|
-
f"[Convert]::FromBase64String('{host}'));"
|
|
127
|
+
f"[Convert]::FromBase64String('{_b64(self.host)}'));"
|
|
88
128
|
"$s=[Text.Encoding]::UTF8.GetString("
|
|
89
|
-
f"[Convert]::FromBase64String('{
|
|
90
|
-
"$
|
|
91
|
-
f"
|
|
92
|
-
+
|
|
129
|
+
f"[Convert]::FromBase64String('{_b64(script)}'));"
|
|
130
|
+
"$e=[Text.Encoding]::UTF8.GetString("
|
|
131
|
+
f"[Convert]::FromBase64String('{_b64(_NATIVE_EXIT_EPILOGUE)}'));"
|
|
132
|
+
+ prelude
|
|
133
|
+
+ "$o=@{"
|
|
134
|
+
+ ";".join(options)
|
|
93
135
|
+ "};"
|
|
94
136
|
"try{$global:LASTEXITCODE=0;"
|
|
95
|
-
"$
|
|
96
|
-
"$r
|
|
137
|
+
"$b=[ScriptBlock]::Create($s+[Environment]::NewLine+$e);"
|
|
138
|
+
"$r=Invoke-Command @o -ScriptBlock $b;"
|
|
139
|
+
"$c=[int]$global:LASTEXITCODE;$out=@();"
|
|
140
|
+
"foreach($v in $r){$t=[string]$v;"
|
|
141
|
+
f"if($t.StartsWith('{NATIVE_EXIT_MARKER}:'))"
|
|
142
|
+
"{$c=[int]($t.Split(':')[1])}else{$out+=$v}};"
|
|
143
|
+
"$out;exit $c}"
|
|
97
144
|
"catch{$c=[string]$_.CategoryInfo.Category;"
|
|
98
145
|
"$m=[Convert]::ToBase64String("
|
|
99
146
|
"[Text.Encoding]::UTF8.GetBytes($_.Exception.Message));"
|
|
100
147
|
"[Console]::Error.Write('HOSTCTL_NATIVE_ERROR:'+$c+':'+$m);exit 1}"
|
|
101
148
|
)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
(
|
|
106
|
-
";SkipCACheck=$true;Port=" + str(self.port)
|
|
107
|
-
if self.port is not None
|
|
108
|
-
else ";SkipCACheck=$true;};"
|
|
109
|
-
),
|
|
110
|
-
)
|
|
149
|
+
|
|
150
|
+
def run_ps(self, script: str) -> _NativeResponse:
|
|
151
|
+
wrapper = self._wrapper(script)
|
|
111
152
|
try:
|
|
112
153
|
result = subprocess.run(
|
|
113
154
|
(
|
|
@@ -197,7 +238,7 @@ class WinRMExecutor(Executor[subprocess.CompletedProcess]):
|
|
|
197
238
|
# Remote errors are represented as a normal non-zero completion;
|
|
198
239
|
# check=False must be able to inspect them.
|
|
199
240
|
err = detail.encode(encoding or "utf-8", errors or "replace")
|
|
200
|
-
if text
|
|
241
|
+
if wants_text(text, encoding, errors):
|
|
201
242
|
codec = encoding or "utf-8"
|
|
202
243
|
out = out.decode(codec, errors or "strict")
|
|
203
244
|
err = err.decode(codec, errors or "strict")
|
|
@@ -834,9 +834,10 @@ class Host(_abc.ABC, metaclass=_HostMeta):
|
|
|
834
834
|
"""Run commands and return a subprocess-compatible result.
|
|
835
835
|
|
|
836
836
|
A string is verbatim shell text. A tuple/list is one quoted argv
|
|
837
|
-
command.
|
|
838
|
-
|
|
839
|
-
|
|
837
|
+
command. :class:`Exec` is the only direct-execution spelling: it runs
|
|
838
|
+
one program with an argv and no shell layer, and it cannot be combined
|
|
839
|
+
with other commands. A path anywhere else is an ordinary value that
|
|
840
|
+
stringifies. Multiple top-level commands are joined by the selected
|
|
840
841
|
shell's command separator.
|
|
841
842
|
"""
|
|
842
843
|
raise NotImplementedError(
|
|
@@ -24,6 +24,13 @@ from ..provider import (
|
|
|
24
24
|
)
|
|
25
25
|
|
|
26
26
|
log = logging.getLogger("hostctl.host.ssh")
|
|
27
|
+
|
|
28
|
+
#: RFC 3986 `pchar`, plus `/`. Everything legal in a URI path segment is
|
|
29
|
+
#: left as written -- including `:` so a Windows-flavoured remote path still
|
|
30
|
+
#: reads as `sftp://host:22/C:/Temp` -- while `%`, `?`, `#`, space, and
|
|
31
|
+
#: non-ASCII are percent-encoded, because those are what a URI parser would
|
|
32
|
+
#: otherwise take for syntax.
|
|
33
|
+
_URI_PATH_SAFE = "/:@-._~!$&'()*+,;="
|
|
27
34
|
from ..process import Process, SshProcess, TerminalRequest
|
|
28
35
|
from ._common import (
|
|
29
36
|
CaptureOutput,
|
|
@@ -392,8 +399,14 @@ class _SshTransport:
|
|
|
392
399
|
self._sftp_backend = AsyncsshSftpBackend(
|
|
393
400
|
connect_opts=self.config.connect_opts()
|
|
394
401
|
)
|
|
402
|
+
# The remote path becomes part of a URI, so it has to be encoded
|
|
403
|
+
# as one. `pathlib_next` parses with `uritools.urisplit` and then
|
|
404
|
+
# uridecodes the components: a raw `?` or `#` in a filename was
|
|
405
|
+
# taken as the start of the query or fragment and truncated the
|
|
406
|
+
# path, and a genuine `%xx` was decoded into a different name.
|
|
395
407
|
path = SftpPath(
|
|
396
|
-
f"sftp://{uri_host(self.config.host)}:{self.config.port or 22}
|
|
408
|
+
f"sftp://{uri_host(self.config.host)}:{self.config.port or 22}"
|
|
409
|
+
f"{quote(remote_path, safe=_URI_PATH_SAFE)}",
|
|
397
410
|
backend=self._sftp_backend,
|
|
398
411
|
)
|
|
399
412
|
self._sftp_sources.add(path.source)
|