hostctl 0.3.0__tar.gz → 0.3.2__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.3.0 → hostctl-0.3.2}/CHANGELOG.md +50 -1
- {hostctl-0.3.0 → hostctl-0.3.2}/PKG-INFO +1 -1
- {hostctl-0.3.0 → hostctl-0.3.2}/docs/guide/providers.md +9 -1
- {hostctl-0.3.0 → hostctl-0.3.2}/pyproject.toml +1 -1
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/AGENTS.md +24 -5
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/_common.py +35 -23
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/_connection.py +17 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/_ssh.py +15 -1
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/composite_path.py +21 -1
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/system.py +5 -5
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/provider/_common.py +40 -6
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_connection_string.py +36 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_host_api.py +26 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_provider_fault_injection.py +131 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/.gitignore +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/AGENTS.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/LICENSE +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/README.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/benchmarks/README.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/benchmarks/results/0.2.7-py3.14.json +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/benchmarks/results/0.2.7-py3.9.json +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/benchmarks/results/0.3.0-py3.14.json +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/benchmarks/results/0.3.0-py3.9.json +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/benchmarks/run.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/docs/api/reference.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/docs/changelog.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/docs/guide/cli.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/docs/guide/contracts.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/docs/guide/extending.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/docs/guide/path.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/docs/guide/run.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/docs/guide/transfer.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/docs/index.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/examples/application_provider.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/examples/copy_between_hosts.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/examples/local_run.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/examples/remote_run.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/mkdocs.yml +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/__init__.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/__main__.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/_async.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/_cli.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/__init__.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/_common.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/_qga.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/container.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/local.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/psrp.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/qemu.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/serial.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/ssh.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/executor/winrm.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/__init__.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/_local.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/_qga_helper.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/_staged_io.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/_winrm.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/container.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/container_path.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/qemu.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/host/serial.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/process/__init__.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/process/_common.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/process/container.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/process/psrp.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/process/qemu_serial.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/process/serial.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/process/ssh.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/provider/__init__.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/provider/transports.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/py.typed +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/serial/__init__.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/shell/__init__.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/shell/_common.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/shell/_grammar.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/shell/cmd.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/shell/fish.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/shell/posix.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/shell/powershell.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/src/hostctl/sync.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/__init__.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/path_fakes.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/providers.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/test_composite_path_routing.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/test_live.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/test_path_contract.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/test_process_contract.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/test_registry.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/test_run_contract.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/conformance/test_sync_contract.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/conftest.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_application_provider.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_asyncssh_errors.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_cli.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_cli_console.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_cmd_live_effects.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_composite_path_backend_kwargs.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_composite_path_inherited_operations.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_composite_path_propagation.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_container_path.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_container_process.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_docs_snippets.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_exec_command.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_executor_exports.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_host_container.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_host_local.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_host_module_layout.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_host_qemu.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_host_remote.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_host_winrm.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_login_shell.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_module_hygiene.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_observability.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_packaging.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_powershell_status_live.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_process.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_psrp.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qemu_executor.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qemu_guest_semantics.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qemu_lifecycle.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qemu_path.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qemu_serial.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qemu_serial_process.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qga_errors.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qga_path_helper.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qga_ssh_transport.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_qga_transport.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_serial_contract.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_serial_executor.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_serial_framing.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_serial_host.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_serial_live.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_shell.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_shell_rendering.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_ssh_process.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_staged_io.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_sync_helpers.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_system_host_fidelity.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_system_hosts.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_uri_hygiene.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_winrm_path.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.2}/tests/test_winrm_powershell_live.py +0 -0
|
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.2] - 2026-09-23
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`redact_uri` no longer over-redacts a well-formed URI.** The textual
|
|
15
|
+
fallback -- which reads the last `@` in the whole string as the userinfo
|
|
16
|
+
delimiter -- ran whenever `urlsplit` reported no password, including for
|
|
17
|
+
URIs that simply do not have one. `ssh://nas:22/mail/user@example.com`
|
|
18
|
+
carries no credential at all and came back as `ssh://nas@example.com`: a
|
|
19
|
+
diagnostic quietly naming a different host. Guessing is now confined to
|
|
20
|
+
input that does not parse -- when the URI is well formed, `urlsplit`'s
|
|
21
|
+
authority is the authority, so an `@` in a path is left alone. A password
|
|
22
|
+
hidden by an unencoded `/`, `?` or `#` is still redacted: that input is
|
|
23
|
+
malformed (the separator ends the authority, leaving `user:secret` read as
|
|
24
|
+
host:port), and detecting exactly that is what now gates the fallback.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- `ConnectionString` now documents the parsing contract it already enforced:
|
|
29
|
+
the input must be a valid URI, and reserved characters in a password must
|
|
30
|
+
be percent-encoded. There is no lenient mode and none is planned -- a
|
|
31
|
+
password may itself contain `@` and `:`, so nothing can recover where an
|
|
32
|
+
unescaped one ended, and a guess in the parser would be a guess about what
|
|
33
|
+
to connect to. Documentation and tests only; behaviour is unchanged.
|
|
34
|
+
|
|
35
|
+
## [0.3.1] - 2026-09-22
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **A composite path falls back when SFTP cannot connect.** Nothing on the
|
|
40
|
+
path route connected the provider first: pathlib_next dialled SFTP inside
|
|
41
|
+
the operation, so an untrusted host key (`HostKeyNotVerifiable`) or a
|
|
42
|
+
refused port escaped raw and the next path provider was never tried --
|
|
43
|
+
while `run()` on the same host fell back. A composite path now calls the
|
|
44
|
+
provider's `connect()` before dispatch, and SFTP maps asyncssh's connect
|
|
45
|
+
errors the way the SSH leg already did. Most likely to fire since 0.3.0
|
|
46
|
+
started verifying the SFTP host key: any host not yet in `known_hosts`.
|
|
47
|
+
- **`run()` no longer re-dials a declined provider on every call.** 0.3.0
|
|
48
|
+
cleared every decline per call, so a dead primary cost a full connect
|
|
49
|
+
attempt (~9 s against a refused port) on each `run()` while the fallback
|
|
50
|
+
served. A decline now lasts for the generation again, and is re-admitted
|
|
51
|
+
only when no other provider can serve -- which keeps a single-provider
|
|
52
|
+
host from bricking on one transient refusal, for paths as well as `run()`.
|
|
53
|
+
- A path refusal with nothing left to try names why ("no path provider
|
|
54
|
+
completed read (...: Host key is not trusted ...)") and keeps the cause.
|
|
55
|
+
|
|
10
56
|
## [0.3.0] - 2026-09-22
|
|
11
57
|
|
|
12
58
|
### Added
|
|
@@ -741,7 +787,10 @@ test suite on Python 3.9 through 3.14.
|
|
|
741
787
|
assigned to it; a config-less host now builds its own family configuration
|
|
742
788
|
instead.
|
|
743
789
|
|
|
744
|
-
[Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2
|
|
790
|
+
[Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.3.2...HEAD
|
|
791
|
+
[0.3.2]: https://github.com/jose-pr/hostctl/compare/v0.3.1...v0.3.2
|
|
792
|
+
[0.3.1]: https://github.com/jose-pr/hostctl/compare/v0.3.0...v0.3.1
|
|
793
|
+
[0.3.0]: https://github.com/jose-pr/hostctl/compare/v0.2.7...v0.3.0
|
|
745
794
|
[0.2.7]: https://github.com/jose-pr/hostctl/compare/v0.2.6...v0.2.7
|
|
746
795
|
[0.2.6]: https://github.com/jose-pr/hostctl/compare/v0.2.5...v0.2.6
|
|
747
796
|
[0.2.5]: https://github.com/jose-pr/hostctl/compare/v0.2.4...v0.2.5
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hostctl
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
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/
|
|
@@ -100,7 +100,15 @@ the same conservative default.
|
|
|
100
100
|
|
|
101
101
|
A provider that declines is remembered for the current connection generation, so
|
|
102
102
|
later operations skip it instead of re-attempting it every time. Reconnecting or
|
|
103
|
-
replacing a provider starts a new generation and re-probes everything.
|
|
103
|
+
replacing a provider starts a new generation and re-probes everything. The one
|
|
104
|
+
exception is a host with nothing else left: when every other candidate is
|
|
105
|
+
unavailable, an earlier refusal is tried again rather than failing without
|
|
106
|
+
dialling, so one transient refusal cannot brick a single-provider host.
|
|
107
|
+
|
|
108
|
+
Paths and commands connect the same way: a composite path calls the provider's
|
|
109
|
+
`connect()` before each dispatch, which is where a refused port or an untrusted
|
|
110
|
+
host key becomes `OperationNotStarted`. A path provider's `connect()` must
|
|
111
|
+
therefore be idempotent and cheap once connected.
|
|
104
112
|
|
|
105
113
|
## Selection traces
|
|
106
114
|
|
|
@@ -156,7 +156,11 @@ and dispatches it with no args, and that script is shell text, not a program.
|
|
|
156
156
|
renders. Supplying a password both in the URI and as an argument raises.
|
|
157
157
|
`redact_uri(uri)` STRIPS a password and returns a valid, reusable URI (not a
|
|
158
158
|
masked one, so a rendered form can never round-trip a wrong credential), for
|
|
159
|
-
logs, reprs, and error messages.
|
|
159
|
+
logs, reprs, and error messages. A WELL-FORMED URI IS NEVER OVER-REDACTED:
|
|
160
|
+
when it parses, `urlsplit`'s authority is the authority, so an `@` elsewhere
|
|
161
|
+
(`ssh://nas:22/mail/user@example.com`) is left alone. Only input that does
|
|
162
|
+
not parse gets the textual last-`@` guess, which may over-redact -- by then
|
|
163
|
+
the text has already failed to say where its authority ended.
|
|
160
164
|
- A connection URI may carry a raw tab, CR, or LF in its **userinfo**: those
|
|
161
165
|
are percent-encoded before `urlsplit` sees them, which would otherwise
|
|
162
166
|
delete them silently (`ssh://u:pw<LF>otp:1@host` would authenticate with
|
|
@@ -189,9 +193,19 @@ path grammar, so `path()` raises.
|
|
|
189
193
|
`ConnectionString(value, *, scheme=None, port=None, ...)` is the parsed form
|
|
190
194
|
of a target -- host, scheme, port, user, password -- with `is_local` answered
|
|
191
195
|
by `netimps` rather than guessed. It exists so a caller never reimplements
|
|
192
|
-
connection-string parsing.
|
|
193
|
-
|
|
194
|
-
|
|
196
|
+
connection-string parsing. A bare host parses (a missing scheme is not an
|
|
197
|
+
error), but otherwise the value MUST be a valid URI: an unclosed IPv6 literal,
|
|
198
|
+
a non-numeric port, or an unescaped `/`, `?` or `#` in a password RAISES.
|
|
199
|
+
**Percent-encode reserved characters in a password** -- there is no lenient
|
|
200
|
+
mode and none will be added, because a password may itself contain `@` and
|
|
201
|
+
`:`, so nothing can recover where an unescaped one ended, and guessing would
|
|
202
|
+
be guessing what to connect to. (`@` and `:` do parse: last `@`, first `:`,
|
|
203
|
+
per the URI grammar.) Use `redact_uri` when the text may not parse at all and
|
|
204
|
+
the job is only keeping a secret out of a log.
|
|
205
|
+
|
|
206
|
+
`uri_hostname(value)` returns the bare hostname of a URI authority,
|
|
207
|
+
unbracketing an IPv6 literal; it is the sibling of `uri_host`, `redact_uri`
|
|
208
|
+
and `parse_credentials`.
|
|
195
209
|
|
|
196
210
|
`__version__` is the installed version string.
|
|
197
211
|
|
|
@@ -315,7 +329,12 @@ hostctl applies to whatever you return.
|
|
|
315
329
|
falls through to the next provider for **that call only**; it does not take
|
|
316
330
|
the provider out of service.
|
|
317
331
|
- `ProviderSelector` holds the ordered providers and the per-generation
|
|
318
|
-
declines; `ProviderSelection` is one resolved choice.
|
|
332
|
+
declines; `ProviderSelection` is one resolved choice. `select()` re-admits
|
|
333
|
+
a decline recorded by an earlier operation only when no other provider can
|
|
334
|
+
serve.
|
|
335
|
+
- A provider's optional `connect()` is where a pre-dispatch failure belongs.
|
|
336
|
+
Composite paths call a path provider's `connect()` before **every**
|
|
337
|
+
dispatch, so it must be idempotent and cheap once connected.
|
|
319
338
|
`ProviderSelector.redact(value)` is the one redaction used in traces --
|
|
320
339
|
rendered commands routinely carry credentials, so log through it.
|
|
321
340
|
- `SessionInitializer` is the hook a provider may accept to prepare a session
|
|
@@ -230,6 +230,12 @@ def redact_uri(uri: str) -> str:
|
|
|
230
230
|
|
|
231
231
|
A URI with no password is returned unchanged.
|
|
232
232
|
|
|
233
|
+
**A well-formed URI is never over-redacted.** When the URI parses, what
|
|
234
|
+
`urlsplit` says is the authority *is* the authority, so an `@` anywhere
|
|
235
|
+
else -- a mailbox in a path, `ssh://nas:22/mail/user@example.com` -- is
|
|
236
|
+
left alone. Guessing is confined to input that does not parse, where
|
|
237
|
+
there is nothing better to go on: see below.
|
|
238
|
+
|
|
233
239
|
This never raises. It is meant for error messages and log records, where
|
|
234
240
|
failing to render a diagnostic would be worse than rendering an odd one.
|
|
235
241
|
Characters `urlsplit` would delete (tab, CR, LF) are percent-encoded first,
|
|
@@ -238,31 +244,37 @@ def redact_uri(uri: str) -> str:
|
|
|
238
244
|
"""
|
|
239
245
|
try:
|
|
240
246
|
parsed = _urlsplit(_encode_stripped_characters(uri))
|
|
241
|
-
|
|
247
|
+
# Not dead code: `.port` is a property that RAISES, and touching it
|
|
248
|
+
# is what decides "well formed" here. It catches both shapes that
|
|
249
|
+
# matter in one place -- a non-numeric or out-of-range port, and an
|
|
250
|
+
# unencoded `/`, `?` or `#` in a password, since the latter ends the
|
|
251
|
+
# authority and leaves `user:secret` being read as host:port. So the
|
|
252
|
+
# bad port IS the symptom of the hidden password.
|
|
253
|
+
parsed.port # noqa: B018
|
|
242
254
|
except ValueError:
|
|
243
|
-
#
|
|
244
|
-
# the userinfo, which is the whole job.
|
|
245
|
-
password = None
|
|
255
|
+
pass # Malformed. The textual fallback below is all that is left.
|
|
246
256
|
else:
|
|
247
|
-
if password is
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
#
|
|
264
|
-
#
|
|
265
|
-
# the
|
|
257
|
+
if parsed.password is None:
|
|
258
|
+
# Well formed and carrying no password: there is nothing to
|
|
259
|
+
# strip, and the fallback must not run. It reads the LAST `@` in
|
|
260
|
+
# the whole string as the userinfo delimiter, which would rewrite
|
|
261
|
+
# `ssh://nas:22/mail/user@example.com` -- a URI with no
|
|
262
|
+
# credentials at all -- into `ssh://nas@example.com`, naming a
|
|
263
|
+
# different host.
|
|
264
|
+
return uri
|
|
265
|
+
try:
|
|
266
|
+
return _without_password(parsed).geturl()
|
|
267
|
+
except ValueError:
|
|
268
|
+
# Not reachable through `.port`, which is already validated above;
|
|
269
|
+
# kept because "never raises" is a contract this function is
|
|
270
|
+
# called from inside exception handlers to honour.
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
# Only a malformed URI reaches here. Read the userinfo textually: the last
|
|
274
|
+
# `@` wins, as it does in a real authority. This can over-redact -- but
|
|
275
|
+
# the text has already failed to say where its authority ended, so there
|
|
276
|
+
# is no reading of it that is known to be right, and an odd diagnostic
|
|
277
|
+
# beats a password in a log.
|
|
266
278
|
head, separator, _rest = uri.partition("://")
|
|
267
279
|
start = len(head) + len(separator)
|
|
268
280
|
authority_end = uri.rfind("@")
|
|
@@ -110,6 +110,23 @@ class ConnectionString:
|
|
|
110
110
|
not an invalid URI -- it is a URI with the scheme left off, which is what
|
|
111
111
|
people type on a command line.
|
|
112
112
|
|
|
113
|
+
Beyond that, **the input must be a valid URI, and a malformed one
|
|
114
|
+
raises** -- an unclosed IPv6 literal, a non-numeric port, or a password
|
|
115
|
+
written with an unescaped `/`, `?` or `#` (each ends the authority, so
|
|
116
|
+
what precedes it is read as a port and fails to cast).
|
|
117
|
+
|
|
118
|
+
**Reserved characters in a password must be percent-encoded, and one
|
|
119
|
+
that is not gets no rescue attempt.** There is no rescue to make: a
|
|
120
|
+
password may itself contain `@` and `:`, so no rule recovers where an
|
|
121
|
+
unescaped one ended. Those two do parse -- the last `@` and the
|
|
122
|
+
first `:` win, as in any authority, giving `pa@ss` and `pa:ss` -- but
|
|
123
|
+
that is the URI grammar being applied, not a guess about the secret.
|
|
124
|
+
Guessing here would be guessing about what to *connect to*, so a raise is
|
|
125
|
+
the answer. :func:`redact_uri` is the other job and keeps its own rules:
|
|
126
|
+
stripping a secret out of text that may not parse at all, for a log line
|
|
127
|
+
nothing connects to. It does not over-redact a well-formed URI either --
|
|
128
|
+
it guesses only once the text has failed to parse.
|
|
129
|
+
|
|
113
130
|
Every field can also be supplied directly, and three layers decide each
|
|
114
131
|
one: **an explicit argument wins, then whatever the string carried, then
|
|
115
132
|
`defaults`.**
|
|
@@ -706,10 +706,24 @@ class SftpPathProvider(PathProvider):
|
|
|
706
706
|
# Closing is owned by SshExecutorProvider for the shared transport;
|
|
707
707
|
# connecting is not, because the SFTP client has to be established from
|
|
708
708
|
# the caller's thread before any native recursive operation runs.
|
|
709
|
+
#
|
|
710
|
+
# Composite paths call this before EVERY dispatch, so once connected it
|
|
711
|
+
# must stay cheap: a lock, a liveness check and a cache hit.
|
|
709
712
|
def connect(self):
|
|
713
|
+
from .. import _async
|
|
714
|
+
|
|
710
715
|
try:
|
|
711
716
|
self.transport.connect()
|
|
712
|
-
|
|
717
|
+
try:
|
|
718
|
+
self.transport.warm_sftp()
|
|
719
|
+
except Exception as exc:
|
|
720
|
+
# pathlib_next dials its own SFTP connection and hands back
|
|
721
|
+
# asyncssh's errors raw -- `HostKeyNotVerifiable` is not an
|
|
722
|
+
# OSError -- so map them as the transport's own connect does.
|
|
723
|
+
normalized = _async.normalize_asyncssh_error(exc)
|
|
724
|
+
if normalized is exc:
|
|
725
|
+
raise
|
|
726
|
+
raise normalized from exc
|
|
713
727
|
except (OSError, TimeoutError) as exc:
|
|
714
728
|
log.debug(
|
|
715
729
|
"SFTP provider declining before dispatch: %s: %s",
|
|
@@ -498,10 +498,20 @@ class _CompositePathMixin:
|
|
|
498
498
|
candidates = self._providers_in_order(operation, pin=pin)
|
|
499
499
|
attempted = False
|
|
500
500
|
not_implemented: typing.Optional[NotImplementedError] = None
|
|
501
|
+
not_started: typing.Optional[OperationNotStarted] = None
|
|
501
502
|
for provider in candidates:
|
|
502
503
|
attempted = True
|
|
503
504
|
old = (self._provider, self._backend_path, self._factory, self._pinned)
|
|
504
505
|
try:
|
|
506
|
+
# Connect first, where a failure is provably pre-dispatch.
|
|
507
|
+
# SFTP used to be dialled lazily inside the backend by the
|
|
508
|
+
# operation itself, so an unreachable server or an untrusted
|
|
509
|
+
# host key escaped as a raw transport error and the next
|
|
510
|
+
# provider was never tried -- while `run()`, which connects
|
|
511
|
+
# before dispatch, fell back on the same host.
|
|
512
|
+
connect = getattr(provider, "connect", None)
|
|
513
|
+
if connect is not None:
|
|
514
|
+
connect()
|
|
505
515
|
backend_path = self._provider_path(provider)
|
|
506
516
|
if pin:
|
|
507
517
|
# A write/open operation owns the provider choice before
|
|
@@ -531,6 +541,7 @@ class _CompositePathMixin:
|
|
|
531
541
|
# `excluded` already stops us retrying it for this call.
|
|
532
542
|
continue
|
|
533
543
|
except OperationNotStarted as exc:
|
|
544
|
+
not_started = exc
|
|
534
545
|
if pin:
|
|
535
546
|
self._provider, self._backend_path, self._factory, self._pinned = (
|
|
536
547
|
old
|
|
@@ -554,7 +565,16 @@ class _CompositePathMixin:
|
|
|
554
565
|
# visibly with a single provider, where "try the next one" has
|
|
555
566
|
# nothing to try.
|
|
556
567
|
raise not_implemented
|
|
557
|
-
|
|
568
|
+
# Carry the last refusal: "no provider completed" alone hid WHY -- a
|
|
569
|
+
# rejected host key read the same as a refused port.
|
|
570
|
+
cause = not_started.cause if not_started is not None else None
|
|
571
|
+
detail = ""
|
|
572
|
+
if not_started is not None:
|
|
573
|
+
reason = f"{not_started}: {cause}" if cause is not None else not_started
|
|
574
|
+
detail = f" ({ProviderSelector.redact(reason)})"
|
|
575
|
+
raise OperationNotStarted(
|
|
576
|
+
f"no path provider completed {operation}{detail}", cause=cause
|
|
577
|
+
) from (cause or not_started)
|
|
558
578
|
|
|
559
579
|
def via(self, name: str):
|
|
560
580
|
provider = next((item for item in self._providers if item.name == name), None)
|
|
@@ -826,11 +826,11 @@ class SystemHost(Host):
|
|
|
826
826
|
raise NotImplementedError(
|
|
827
827
|
f"{type(self).__name__} does not provide the 'run' capability"
|
|
828
828
|
)
|
|
829
|
-
#
|
|
830
|
-
#
|
|
831
|
-
#
|
|
832
|
-
#
|
|
833
|
-
|
|
829
|
+
# No `retry_declined()` here. Clearing every refusal per call re-dialled
|
|
830
|
+
# a dead primary on EVERY run() -- ~9 s each against a refused port --
|
|
831
|
+
# although the fallback was serving. The selector re-admits an earlier
|
|
832
|
+
# refusal only when nothing else is left, which is what keeps a
|
|
833
|
+
# single-provider host from bricking on one transient failure.
|
|
834
834
|
excluded: list[str] = []
|
|
835
835
|
while True:
|
|
836
836
|
provider = self._executor_selector.select(exclude=excluded).provider
|
|
@@ -337,13 +337,12 @@ class ProviderSelector:
|
|
|
337
337
|
return self._generation
|
|
338
338
|
|
|
339
339
|
def retry_declined(self) -> None:
|
|
340
|
-
"""Forget
|
|
340
|
+
"""Forget every refusal recorded this generation.
|
|
341
341
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
the
|
|
345
|
-
|
|
346
|
-
until `close()`.
|
|
342
|
+
Not needed to recover a host: `select()` already re-admits an earlier
|
|
343
|
+
refusal when nothing else can serve. This is for a caller that knows
|
|
344
|
+
the declined provider is back (sshd restarted) and wants it preferred
|
|
345
|
+
over a fallback that is still serving.
|
|
347
346
|
"""
|
|
348
347
|
with self._lock:
|
|
349
348
|
self._declined.clear()
|
|
@@ -539,6 +538,15 @@ class ProviderSelector:
|
|
|
539
538
|
len(self._attempt_trace),
|
|
540
539
|
)
|
|
541
540
|
return result
|
|
541
|
+
if self._readmit_earlier_declines(excluded, capability):
|
|
542
|
+
# Nothing else can serve, so a refusal recorded by an EARLIER
|
|
543
|
+
# operation gets another chance rather than failing this one
|
|
544
|
+
# without dialling: one transient refusal (sshd restarting) on a
|
|
545
|
+
# single-provider host otherwise bricked it until close(). A
|
|
546
|
+
# decline this operation made itself is in `excluded` and stays.
|
|
547
|
+
return self.select(
|
|
548
|
+
capability=capability, exclude=excluded, policy=policy, pin=pin
|
|
549
|
+
)
|
|
542
550
|
log.debug(
|
|
543
551
|
"no provider available (generation %d); candidates: %s",
|
|
544
552
|
self._generation,
|
|
@@ -563,6 +571,32 @@ class ProviderSelector:
|
|
|
563
571
|
cause=cause,
|
|
564
572
|
) from cause
|
|
565
573
|
|
|
574
|
+
def _readmit_earlier_declines(
|
|
575
|
+
self, excluded: typing.AbstractSet[str], capability: str | None
|
|
576
|
+
) -> bool:
|
|
577
|
+
"""Forget declines that the running operation did not make.
|
|
578
|
+
|
|
579
|
+
A name the caller excluded is one it tried and saw refuse, so only
|
|
580
|
+
declines outside `excluded` came from an earlier operation.
|
|
581
|
+
"""
|
|
582
|
+
with self._lock:
|
|
583
|
+
stale = [
|
|
584
|
+
provider.name
|
|
585
|
+
for provider in self.providers
|
|
586
|
+
if provider.name in self._declined
|
|
587
|
+
and provider.name not in excluded
|
|
588
|
+
and (capability is None or capability in provider.capabilities)
|
|
589
|
+
]
|
|
590
|
+
for name in stale:
|
|
591
|
+
del self._declined[name]
|
|
592
|
+
self._decline_causes.pop(name, None)
|
|
593
|
+
for name in stale:
|
|
594
|
+
log.debug(
|
|
595
|
+
"provider %s re-admitted: every other candidate is unavailable",
|
|
596
|
+
_redacted_name(name),
|
|
597
|
+
)
|
|
598
|
+
return bool(stale)
|
|
599
|
+
|
|
566
600
|
def _record(self, entry: dict[str, object]) -> None:
|
|
567
601
|
"""Merge one trace entry, letting a later record supersede an earlier.
|
|
568
602
|
|
|
@@ -357,3 +357,39 @@ def test_replace_normalises_what_init_normalises():
|
|
|
357
357
|
|
|
358
358
|
assert parsed.replace(scheme="WSS") == parsed
|
|
359
359
|
assert ConnectionString("ssh://nas").replace(port="2222").port == 2222
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@pytest.mark.parametrize(
|
|
363
|
+
"target",
|
|
364
|
+
[
|
|
365
|
+
"ssh://root:pa/ss@nas",
|
|
366
|
+
"ssh://root:pa?ss@nas",
|
|
367
|
+
"ssh://root:pa#ss@nas",
|
|
368
|
+
"ssh://root:pw@[::1",
|
|
369
|
+
"ssh://root:pw@nas:notaport",
|
|
370
|
+
],
|
|
371
|
+
)
|
|
372
|
+
def test_a_malformed_uri_raises_rather_than_being_guessed_at(target):
|
|
373
|
+
"""The input must be a valid URI, and there is deliberately no lenient
|
|
374
|
+
mode: what this parses is what gets connected to, so a guess here is a
|
|
375
|
+
guess about the target. An unescaped `/`, `?` or `#` ends the authority,
|
|
376
|
+
which is why `root:pa` is then read as a port and fails to cast."""
|
|
377
|
+
with pytest.raises(ValueError):
|
|
378
|
+
ConnectionString(target)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def test_reserved_characters_in_a_password_must_be_percent_encoded():
|
|
382
|
+
value = ConnectionString("ssh://root:pa%2Fss@nas")
|
|
383
|
+
|
|
384
|
+
assert value.username == "root"
|
|
385
|
+
assert value.password == "pa/ss"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def test_at_and_colon_in_a_password_parse_by_the_uri_grammar():
|
|
389
|
+
"""These two do not raise -- the last `@` and the first `:` delimit, as in
|
|
390
|
+
any authority. They are also why no textual fallback is possible: a
|
|
391
|
+
password may contain both, so nothing recovers where an unescaped one
|
|
392
|
+
ended. `redact_uri` may over-redact, but only once the text has failed
|
|
393
|
+
to parse at all; this may not guess even then."""
|
|
394
|
+
assert ConnectionString("ssh://root:pa@ss@nas").password == "pa@ss"
|
|
395
|
+
assert ConnectionString("ssh://root:pa:ss@nas").password == "pa:ss"
|
|
@@ -638,6 +638,32 @@ def test_redact_uri_leaves_a_credential_free_uri_alone():
|
|
|
638
638
|
assert redact_uri("wss://nas:8443/api?x=1") == "wss://nas:8443/api?x=1"
|
|
639
639
|
|
|
640
640
|
|
|
641
|
+
@pytest.mark.parametrize(
|
|
642
|
+
"uri",
|
|
643
|
+
(
|
|
644
|
+
"ssh://nas:22/mail/user@example.com",
|
|
645
|
+
"ssh://user@nas:22/path/foo@bar",
|
|
646
|
+
"https://host:443/a@b",
|
|
647
|
+
"ssh://nas:22/mail/a@b/more@here",
|
|
648
|
+
),
|
|
649
|
+
)
|
|
650
|
+
def test_redact_uri_never_over_redacts_a_well_formed_uri(uri):
|
|
651
|
+
"""The textual fallback reads the LAST `@` as the userinfo delimiter, and
|
|
652
|
+
it used to run whenever `urlsplit` reported no password -- including for
|
|
653
|
+
URIs that simply do not have one.
|
|
654
|
+
|
|
655
|
+
`ssh://nas:22/mail/user@example.com` carries no credential at all and came
|
|
656
|
+
back as `ssh://nas@example.com`: not merely over-redacted but pointing at
|
|
657
|
+
a different host. Guessing is now confined to input that does not parse.
|
|
658
|
+
"""
|
|
659
|
+
assert redact_uri(uri) == uri
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def test_redact_uri_still_strips_a_password_beside_an_at_sign_in_the_path():
|
|
663
|
+
"""The guard above must not cost the actual redaction."""
|
|
664
|
+
assert redact_uri("ssh://root:pw@nas:22/mail/a@b") == "ssh://root@nas:22/mail/a@b"
|
|
665
|
+
|
|
666
|
+
|
|
641
667
|
def test_an_unsupported_session_raises_not_implemented_not_a_runtime_error():
|
|
642
668
|
"""AGENTS.md: unsupported run()/path()/spawn() raise NotImplementedError.
|
|
643
669
|
|
|
@@ -800,6 +800,137 @@ def test_path_falls_back_over_every_candidate_not_just_one():
|
|
|
800
800
|
assert path.provider.name == "third"
|
|
801
801
|
|
|
802
802
|
|
|
803
|
+
def _served_by_memory(text="served"):
|
|
804
|
+
backend = MemPathBackend()
|
|
805
|
+
MemPath("/etc", backend=backend).mkdir(parents=True)
|
|
806
|
+
MemPath("/etc/hostname", backend=backend).write_text(text)
|
|
807
|
+
return PathProvider("memory", lambda *parts: MemPath(*parts, backend=backend))
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _sftp_that_cannot_connect(*, connect=None, warm=None):
|
|
811
|
+
from hostctl import SshConfig
|
|
812
|
+
from hostctl.host._ssh import SftpPathProvider, _SshTransport
|
|
813
|
+
|
|
814
|
+
transport = _SshTransport(SshConfig("nas.example", username="root"))
|
|
815
|
+
transport.connect = connect or (lambda: None)
|
|
816
|
+
transport.warm_sftp = warm or (lambda: None)
|
|
817
|
+
return SftpPathProvider(transport)
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
@pytest.mark.parametrize(
|
|
821
|
+
"error",
|
|
822
|
+
(
|
|
823
|
+
# What `_SshTransport.connect()` makes of each: an untrusted host key
|
|
824
|
+
# (`HostKeyNotVerifiable`) and a refused or firewalled port.
|
|
825
|
+
ConnectionError("Host key is not trusted for host nas.example"),
|
|
826
|
+
ConnectionRefusedError(10061, "connection refused"),
|
|
827
|
+
TimeoutError("connect timed out"),
|
|
828
|
+
),
|
|
829
|
+
ids=("host-key", "refused", "timeout"),
|
|
830
|
+
)
|
|
831
|
+
def test_a_path_falls_back_when_sftp_cannot_connect(error):
|
|
832
|
+
"""`run()` connects before dispatch and fell back; `path()` did not.
|
|
833
|
+
|
|
834
|
+
Nothing on the composite-path route called `SftpPathProvider.connect()`:
|
|
835
|
+
pathlib_next dialled SFTP lazily inside the operation, so the transport
|
|
836
|
+
error escaped `_dispatch` and the working next provider was never tried.
|
|
837
|
+
"""
|
|
838
|
+
|
|
839
|
+
def refuse():
|
|
840
|
+
raise error
|
|
841
|
+
|
|
842
|
+
host = PosixHost(
|
|
843
|
+
path_providers=(_sftp_that_cannot_connect(connect=refuse), _served_by_memory())
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
path = host.path("/etc/hostname")
|
|
847
|
+
|
|
848
|
+
assert path.read_text() == "served"
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def test_a_raw_asyncssh_host_key_error_from_the_sftp_leg_declines():
|
|
852
|
+
"""pathlib_next dials its own SFTP connection and raises asyncssh's error
|
|
853
|
+
raw; `HostKeyNotVerifiable` is not an OSError, so it was never taken as
|
|
854
|
+
"nothing started" even where `connect()` did run."""
|
|
855
|
+
import asyncssh
|
|
856
|
+
|
|
857
|
+
def untrusted():
|
|
858
|
+
raise asyncssh.HostKeyNotVerifiable("Host key is not trusted")
|
|
859
|
+
|
|
860
|
+
host = PosixHost(
|
|
861
|
+
path_providers=(_sftp_that_cannot_connect(warm=untrusted), _served_by_memory())
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
assert host.path("/etc/hostname").read_text() == "served"
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def test_a_path_refusal_names_its_cause_when_nothing_is_left():
|
|
868
|
+
def refuse():
|
|
869
|
+
raise ConnectionError("Host key is not trusted for host nas.example")
|
|
870
|
+
|
|
871
|
+
host = PosixHost(path_providers=(_sftp_that_cannot_connect(connect=refuse),))
|
|
872
|
+
|
|
873
|
+
with pytest.raises(OperationNotStarted) as raised:
|
|
874
|
+
host.path("/etc/hostname").read_text()
|
|
875
|
+
|
|
876
|
+
assert "Host key is not trusted" in str(raised.value)
|
|
877
|
+
assert isinstance(raised.value.cause, ConnectionError)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def test_a_path_refusal_does_not_brick_a_single_provider_host():
|
|
881
|
+
"""Now that a failed SFTP connect declines, the decline must not outlive
|
|
882
|
+
the refusal on a host with nothing to fall back to."""
|
|
883
|
+
state = {"failing": True}
|
|
884
|
+
|
|
885
|
+
def flaky():
|
|
886
|
+
if state["failing"]:
|
|
887
|
+
raise ConnectionRefusedError(10061, "sshd restarting")
|
|
888
|
+
|
|
889
|
+
backend = MemPathBackend()
|
|
890
|
+
MemPath("/etc", backend=backend).mkdir(parents=True)
|
|
891
|
+
MemPath("/etc/hostname", backend=backend).write_text("back")
|
|
892
|
+
provider = _sftp_that_cannot_connect(connect=flaky)
|
|
893
|
+
provider.factory = lambda *parts: MemPath(*parts, backend=backend)
|
|
894
|
+
host = PosixHost(path_providers=(provider,))
|
|
895
|
+
|
|
896
|
+
with pytest.raises(OperationNotStarted):
|
|
897
|
+
host.path("/etc/hostname").read_text()
|
|
898
|
+
|
|
899
|
+
state["failing"] = False
|
|
900
|
+
assert host.path("/etc/hostname").read_text() == "back"
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def test_a_declined_primary_is_not_redialled_while_the_fallback_serves():
|
|
904
|
+
"""Every `run()` cleared every decline, so a dead primary was dialled
|
|
905
|
+
again on each call -- ~9 s apiece against a refused port -- although the
|
|
906
|
+
fallback had been serving all along."""
|
|
907
|
+
dialled = []
|
|
908
|
+
|
|
909
|
+
def refuse(command, *args, **options):
|
|
910
|
+
dialled.append(command)
|
|
911
|
+
raise OperationNotStarted(
|
|
912
|
+
"connection refused", cause=ConnectionRefusedError("refused")
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
host = PosixHost(
|
|
916
|
+
executor_providers=(
|
|
917
|
+
ExecutorProvider("ssh", refuse, capabilities=()),
|
|
918
|
+
ExecutorProvider(
|
|
919
|
+
"fallback",
|
|
920
|
+
lambda command, *args, **options: subprocess.CompletedProcess(
|
|
921
|
+
(command,), 0, b"ok", b""
|
|
922
|
+
),
|
|
923
|
+
capabilities=(),
|
|
924
|
+
),
|
|
925
|
+
)
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
for _ in range(3):
|
|
929
|
+
assert host.run("uptime", check=False).stdout == b"ok"
|
|
930
|
+
|
|
931
|
+
assert len(dialled) == 1
|
|
932
|
+
|
|
933
|
+
|
|
803
934
|
def test_two_threads_selecting_do_not_share_a_trace():
|
|
804
935
|
"""The per-operation trace and the probe/decline maps were unsynchronised
|
|
805
936
|
instance state, so concurrent operations on one host mixed trace entries
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|