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.
Files changed (142) hide show
  1. {hostctl-0.3.0 → hostctl-0.3.1}/CHANGELOG.md +24 -1
  2. {hostctl-0.3.0 → hostctl-0.3.1}/PKG-INFO +1 -1
  3. {hostctl-0.3.0 → hostctl-0.3.1}/docs/guide/providers.md +9 -1
  4. {hostctl-0.3.0 → hostctl-0.3.1}/pyproject.toml +1 -1
  5. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/AGENTS.md +6 -1
  6. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/_ssh.py +15 -1
  7. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/composite_path.py +21 -1
  8. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/system.py +5 -5
  9. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/provider/_common.py +40 -6
  10. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_provider_fault_injection.py +131 -0
  11. {hostctl-0.3.0 → hostctl-0.3.1}/.gitignore +0 -0
  12. {hostctl-0.3.0 → hostctl-0.3.1}/AGENTS.md +0 -0
  13. {hostctl-0.3.0 → hostctl-0.3.1}/LICENSE +0 -0
  14. {hostctl-0.3.0 → hostctl-0.3.1}/README.md +0 -0
  15. {hostctl-0.3.0 → hostctl-0.3.1}/benchmarks/README.md +0 -0
  16. {hostctl-0.3.0 → hostctl-0.3.1}/benchmarks/results/0.2.7-py3.14.json +0 -0
  17. {hostctl-0.3.0 → hostctl-0.3.1}/benchmarks/results/0.2.7-py3.9.json +0 -0
  18. {hostctl-0.3.0 → hostctl-0.3.1}/benchmarks/results/0.3.0-py3.14.json +0 -0
  19. {hostctl-0.3.0 → hostctl-0.3.1}/benchmarks/results/0.3.0-py3.9.json +0 -0
  20. {hostctl-0.3.0 → hostctl-0.3.1}/benchmarks/run.py +0 -0
  21. {hostctl-0.3.0 → hostctl-0.3.1}/docs/api/reference.md +0 -0
  22. {hostctl-0.3.0 → hostctl-0.3.1}/docs/changelog.md +0 -0
  23. {hostctl-0.3.0 → hostctl-0.3.1}/docs/guide/cli.md +0 -0
  24. {hostctl-0.3.0 → hostctl-0.3.1}/docs/guide/contracts.md +0 -0
  25. {hostctl-0.3.0 → hostctl-0.3.1}/docs/guide/extending.md +0 -0
  26. {hostctl-0.3.0 → hostctl-0.3.1}/docs/guide/path.md +0 -0
  27. {hostctl-0.3.0 → hostctl-0.3.1}/docs/guide/run.md +0 -0
  28. {hostctl-0.3.0 → hostctl-0.3.1}/docs/guide/transfer.md +0 -0
  29. {hostctl-0.3.0 → hostctl-0.3.1}/docs/index.md +0 -0
  30. {hostctl-0.3.0 → hostctl-0.3.1}/examples/application_provider.py +0 -0
  31. {hostctl-0.3.0 → hostctl-0.3.1}/examples/copy_between_hosts.py +0 -0
  32. {hostctl-0.3.0 → hostctl-0.3.1}/examples/local_run.py +0 -0
  33. {hostctl-0.3.0 → hostctl-0.3.1}/examples/remote_run.py +0 -0
  34. {hostctl-0.3.0 → hostctl-0.3.1}/mkdocs.yml +0 -0
  35. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/__init__.py +0 -0
  36. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/__main__.py +0 -0
  37. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/_async.py +0 -0
  38. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/_cli.py +0 -0
  39. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/__init__.py +0 -0
  40. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/_common.py +0 -0
  41. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/_qga.py +0 -0
  42. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/container.py +0 -0
  43. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/local.py +0 -0
  44. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/psrp.py +0 -0
  45. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/qemu.py +0 -0
  46. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/serial.py +0 -0
  47. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/ssh.py +0 -0
  48. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/executor/winrm.py +0 -0
  49. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/__init__.py +0 -0
  50. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/_common.py +0 -0
  51. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/_connection.py +0 -0
  52. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/_local.py +0 -0
  53. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/_qga_helper.py +0 -0
  54. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/_staged_io.py +0 -0
  55. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/_winrm.py +0 -0
  56. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/container.py +0 -0
  57. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/container_path.py +0 -0
  58. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/qemu.py +0 -0
  59. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/host/serial.py +0 -0
  60. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/process/__init__.py +0 -0
  61. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/process/_common.py +0 -0
  62. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/process/container.py +0 -0
  63. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/process/psrp.py +0 -0
  64. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/process/qemu_serial.py +0 -0
  65. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/process/serial.py +0 -0
  66. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/process/ssh.py +0 -0
  67. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/provider/__init__.py +0 -0
  68. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/provider/transports.py +0 -0
  69. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/py.typed +0 -0
  70. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/serial/__init__.py +0 -0
  71. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/shell/__init__.py +0 -0
  72. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/shell/_common.py +0 -0
  73. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/shell/_grammar.py +0 -0
  74. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/shell/cmd.py +0 -0
  75. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/shell/fish.py +0 -0
  76. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/shell/posix.py +0 -0
  77. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/shell/powershell.py +0 -0
  78. {hostctl-0.3.0 → hostctl-0.3.1}/src/hostctl/sync.py +0 -0
  79. {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/__init__.py +0 -0
  80. {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/path_fakes.py +0 -0
  81. {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/providers.py +0 -0
  82. {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/test_composite_path_routing.py +0 -0
  83. {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/test_live.py +0 -0
  84. {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/test_path_contract.py +0 -0
  85. {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/test_process_contract.py +0 -0
  86. {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/test_registry.py +0 -0
  87. {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/test_run_contract.py +0 -0
  88. {hostctl-0.3.0 → hostctl-0.3.1}/tests/conformance/test_sync_contract.py +0 -0
  89. {hostctl-0.3.0 → hostctl-0.3.1}/tests/conftest.py +0 -0
  90. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_application_provider.py +0 -0
  91. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_asyncssh_errors.py +0 -0
  92. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_cli.py +0 -0
  93. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_cli_console.py +0 -0
  94. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_cmd_live_effects.py +0 -0
  95. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_composite_path_backend_kwargs.py +0 -0
  96. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_composite_path_inherited_operations.py +0 -0
  97. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_composite_path_propagation.py +0 -0
  98. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_connection_string.py +0 -0
  99. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_container_path.py +0 -0
  100. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_container_process.py +0 -0
  101. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_docs_snippets.py +0 -0
  102. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_exec_command.py +0 -0
  103. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_executor_exports.py +0 -0
  104. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_host_api.py +0 -0
  105. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_host_container.py +0 -0
  106. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_host_local.py +0 -0
  107. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_host_module_layout.py +0 -0
  108. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_host_qemu.py +0 -0
  109. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_host_remote.py +0 -0
  110. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_host_winrm.py +0 -0
  111. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_login_shell.py +0 -0
  112. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_module_hygiene.py +0 -0
  113. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_observability.py +0 -0
  114. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_packaging.py +0 -0
  115. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_powershell_status_live.py +0 -0
  116. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_process.py +0 -0
  117. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_psrp.py +0 -0
  118. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qemu_executor.py +0 -0
  119. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qemu_guest_semantics.py +0 -0
  120. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qemu_lifecycle.py +0 -0
  121. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qemu_path.py +0 -0
  122. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qemu_serial.py +0 -0
  123. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qemu_serial_process.py +0 -0
  124. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qga_errors.py +0 -0
  125. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qga_path_helper.py +0 -0
  126. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qga_ssh_transport.py +0 -0
  127. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_qga_transport.py +0 -0
  128. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_serial_contract.py +0 -0
  129. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_serial_executor.py +0 -0
  130. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_serial_framing.py +0 -0
  131. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_serial_host.py +0 -0
  132. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_serial_live.py +0 -0
  133. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_shell.py +0 -0
  134. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_shell_rendering.py +0 -0
  135. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_ssh_process.py +0 -0
  136. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_staged_io.py +0 -0
  137. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_sync_helpers.py +0 -0
  138. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_system_host_fidelity.py +0 -0
  139. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_system_hosts.py +0 -0
  140. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_uri_hygiene.py +0 -0
  141. {hostctl-0.3.0 → hostctl-0.3.1}/tests/test_winrm_path.py +0 -0
  142. {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.2.7...HEAD
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.0
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hostctl"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  authors = [{ name = "Jose A." }]
9
9
  description = "Run commands and access files on a host, local or remote, protocol-agnostic"
10
10
  readme = "README.md"
@@ -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
- self.transport.warm_sftp()
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
- raise OperationNotStarted(f"no path provider completed {operation}")
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
- # A refusal recorded by an earlier call must not decide this one: a
830
- # single-provider SSH host that hit one transient ConnectionError
831
- # never dialled out again until close(). Within this call, `excluded`
832
- # still stops a provider being retried after it declines.
833
- self._executor_selector.retry_declined()
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 refusals recorded during an earlier call.
340
+ """Forget every refusal recorded this generation.
341
341
 
342
- A decline exists to stop one dispatch re-dialling a provider that just
343
- refused; it was never meant to outlive the call. Kept for the life of
344
- the selector it bricked a single-provider host: one transient refusal
345
- (sshd restarting, a network blip) and hostctl never dialled out again
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