hostctl 0.3.0__tar.gz → 0.3.1__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.1}/CHANGELOG.md +24 -1
- {hostctl-0.3.0 → hostctl-0.3.1}/PKG-INFO +1 -1
- {hostctl-0.3.0 → hostctl-0.3.1}/docs/guide/providers.md +9 -1
- {hostctl-0.3.0 → hostctl-0.3.1}/pyproject.toml +1 -1
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/AGENTS.md +6 -1
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/_ssh.py +15 -1
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/composite_path.py +21 -1
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/system.py +5 -5
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/provider/_common.py +40 -6
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_provider_fault_injection.py +131 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/.gitignore +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/AGENTS.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/LICENSE +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/README.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/benchmarks/README.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/benchmarks/results/0.2.7-py3.14.json +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/benchmarks/results/0.2.7-py3.9.json +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/benchmarks/results/0.3.0-py3.14.json +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/benchmarks/results/0.3.0-py3.9.json +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/benchmarks/run.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/docs/api/reference.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/docs/changelog.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/docs/guide/cli.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/docs/guide/contracts.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/docs/guide/extending.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/docs/guide/path.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/docs/guide/run.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/docs/guide/transfer.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/docs/index.md +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/examples/application_provider.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/examples/copy_between_hosts.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/examples/local_run.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/examples/remote_run.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/mkdocs.yml +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/__init__.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/__main__.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/_async.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/_cli.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/__init__.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/_common.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/_qga.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/container.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/local.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/psrp.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/qemu.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/serial.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/ssh.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/winrm.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/__init__.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/_common.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/_connection.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/_local.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/_qga_helper.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/_staged_io.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/_winrm.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/container.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/container_path.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/qemu.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/serial.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/process/__init__.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/process/_common.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/process/container.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/process/psrp.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/process/qemu_serial.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/process/serial.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/process/ssh.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/provider/__init__.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/provider/transports.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/py.typed +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/serial/__init__.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/shell/__init__.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/shell/_common.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/shell/_grammar.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/shell/cmd.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/shell/fish.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/shell/posix.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/shell/powershell.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/sync.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/__init__.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/path_fakes.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/providers.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/test_composite_path_routing.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/test_live.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/test_path_contract.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/test_process_contract.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/test_registry.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/test_run_contract.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/test_sync_contract.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/conftest.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_application_provider.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_asyncssh_errors.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_cli.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_cli_console.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_cmd_live_effects.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_composite_path_backend_kwargs.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_composite_path_inherited_operations.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_composite_path_propagation.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_connection_string.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_container_path.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_container_process.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_docs_snippets.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_exec_command.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_executor_exports.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_host_api.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_host_container.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_host_local.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_host_module_layout.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_host_qemu.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_host_remote.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_host_winrm.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_login_shell.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_module_hygiene.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_observability.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_packaging.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_powershell_status_live.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_process.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_psrp.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qemu_executor.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qemu_guest_semantics.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qemu_lifecycle.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qemu_path.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qemu_serial.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qemu_serial_process.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qga_errors.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qga_path_helper.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qga_ssh_transport.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qga_transport.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_serial_contract.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_serial_executor.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_serial_framing.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_serial_host.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_serial_live.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_shell.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_shell_rendering.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_ssh_process.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_staged_io.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_sync_helpers.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_system_host_fidelity.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_system_hosts.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_uri_hygiene.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_winrm_path.py +0 -0
- {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_winrm_powershell_live.py +0 -0
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.1] - 2026-09-22
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **A composite path falls back when SFTP cannot connect.** Nothing on the
|
|
15
|
+
path route connected the provider first: pathlib_next dialled SFTP inside
|
|
16
|
+
the operation, so an untrusted host key (`HostKeyNotVerifiable`) or a
|
|
17
|
+
refused port escaped raw and the next path provider was never tried --
|
|
18
|
+
while `run()` on the same host fell back. A composite path now calls the
|
|
19
|
+
provider's `connect()` before dispatch, and SFTP maps asyncssh's connect
|
|
20
|
+
errors the way the SSH leg already did. Most likely to fire since 0.3.0
|
|
21
|
+
started verifying the SFTP host key: any host not yet in `known_hosts`.
|
|
22
|
+
- **`run()` no longer re-dials a declined provider on every call.** 0.3.0
|
|
23
|
+
cleared every decline per call, so a dead primary cost a full connect
|
|
24
|
+
attempt (~9 s against a refused port) on each `run()` while the fallback
|
|
25
|
+
served. A decline now lasts for the generation again, and is re-admitted
|
|
26
|
+
only when no other provider can serve -- which keeps a single-provider
|
|
27
|
+
host from bricking on one transient refusal, for paths as well as `run()`.
|
|
28
|
+
- A path refusal with nothing left to try names why ("no path provider
|
|
29
|
+
completed read (...: Host key is not trusted ...)") and keeps the cause.
|
|
30
|
+
|
|
10
31
|
## [0.3.0] - 2026-09-22
|
|
11
32
|
|
|
12
33
|
### Added
|
|
@@ -741,7 +762,9 @@ test suite on Python 3.9 through 3.14.
|
|
|
741
762
|
assigned to it; a config-less host now builds its own family configuration
|
|
742
763
|
instead.
|
|
743
764
|
|
|
744
|
-
[Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.
|
|
765
|
+
[Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.3.1...HEAD
|
|
766
|
+
[0.3.1]: https://github.com/jose-pr/hostctl/compare/v0.3.0...v0.3.1
|
|
767
|
+
[0.3.0]: https://github.com/jose-pr/hostctl/compare/v0.2.7...v0.3.0
|
|
745
768
|
[0.2.7]: https://github.com/jose-pr/hostctl/compare/v0.2.6...v0.2.7
|
|
746
769
|
[0.2.6]: https://github.com/jose-pr/hostctl/compare/v0.2.5...v0.2.6
|
|
747
770
|
[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.1
|
|
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
|
|
|
@@ -315,7 +315,12 @@ hostctl applies to whatever you return.
|
|
|
315
315
|
falls through to the next provider for **that call only**; it does not take
|
|
316
316
|
the provider out of service.
|
|
317
317
|
- `ProviderSelector` holds the ordered providers and the per-generation
|
|
318
|
-
declines; `ProviderSelection` is one resolved choice.
|
|
318
|
+
declines; `ProviderSelection` is one resolved choice. `select()` re-admits
|
|
319
|
+
a decline recorded by an earlier operation only when no other provider can
|
|
320
|
+
serve.
|
|
321
|
+
- A provider's optional `connect()` is where a pre-dispatch failure belongs.
|
|
322
|
+
Composite paths call a path provider's `connect()` before **every**
|
|
323
|
+
dispatch, so it must be idempotent and cheap once connected.
|
|
319
324
|
`ProviderSelector.redact(value)` is the one redaction used in traces --
|
|
320
325
|
rendered commands routinely carry credentials, so log through it.
|
|
321
326
|
- `SessionInitializer` is the hook a provider may accept to prepare a session
|
|
@@ -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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|