hostctl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. hostctl/AGENTS.md +292 -0
  2. hostctl/README.md +248 -0
  3. hostctl/__init__.py +194 -0
  4. hostctl/__main__.py +6 -0
  5. hostctl/_async.py +169 -0
  6. hostctl/_cli.py +232 -0
  7. hostctl/executor/__init__.py +82 -0
  8. hostctl/executor/_common.py +234 -0
  9. hostctl/executor/_qga.py +646 -0
  10. hostctl/executor/container.py +171 -0
  11. hostctl/executor/local.py +91 -0
  12. hostctl/executor/psrp.py +145 -0
  13. hostctl/executor/qemu.py +305 -0
  14. hostctl/executor/serial.py +312 -0
  15. hostctl/executor/ssh.py +226 -0
  16. hostctl/executor/winrm.py +255 -0
  17. hostctl/host/__init__.py +93 -0
  18. hostctl/host/_common.py +804 -0
  19. hostctl/host/_local.py +258 -0
  20. hostctl/host/_ssh.py +587 -0
  21. hostctl/host/_winrm.py +1002 -0
  22. hostctl/host/composite_path.py +567 -0
  23. hostctl/host/container.py +606 -0
  24. hostctl/host/container_path.py +664 -0
  25. hostctl/host/qemu.py +1078 -0
  26. hostctl/host/serial.py +401 -0
  27. hostctl/host/system.py +809 -0
  28. hostctl/process/__init__.py +49 -0
  29. hostctl/process/_common.py +113 -0
  30. hostctl/process/container.py +265 -0
  31. hostctl/process/psrp.py +208 -0
  32. hostctl/process/qemu_serial.py +247 -0
  33. hostctl/process/serial.py +257 -0
  34. hostctl/process/ssh.py +165 -0
  35. hostctl/provider/__init__.py +33 -0
  36. hostctl/provider/_common.py +452 -0
  37. hostctl/provider/transports.py +303 -0
  38. hostctl/py.typed +0 -0
  39. hostctl/serial/__init__.py +285 -0
  40. hostctl/shell/__init__.py +123 -0
  41. hostctl/shell/_common.py +616 -0
  42. hostctl/shell/cmd.py +167 -0
  43. hostctl/shell/fish.py +63 -0
  44. hostctl/shell/posix.py +83 -0
  45. hostctl/shell/powershell.py +120 -0
  46. hostctl/sync.py +245 -0
  47. hostctl-0.1.0.dist-info/METADATA +302 -0
  48. hostctl-0.1.0.dist-info/RECORD +51 -0
  49. hostctl-0.1.0.dist-info/WHEEL +4 -0
  50. hostctl-0.1.0.dist-info/entry_points.txt +2 -0
  51. hostctl-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,452 @@
1
+ """Small, transport-independent provider and selection contracts.
2
+
3
+ Providers are deliberately adapters: they own connection-specific behaviour,
4
+ while :mod:`hostctl.host.system` owns operating-system semantics and fallback
5
+ safety.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import logging
12
+ import re
13
+ import typing
14
+
15
+ from pathlib_next import Path
16
+
17
+ #: Selection, probe, and decline diagnostics. Library convention: this module
18
+ #: never configures a handler and never calls ``basicConfig``; an application
19
+ #: opts in with ``logging.getLogger("hostctl").setLevel(logging.DEBUG)``.
20
+ log = logging.getLogger("hostctl.provider")
21
+
22
+
23
+ @dataclasses.dataclass(frozen=True)
24
+ class ProviderProbe:
25
+ availability: typing.Literal["available", "unavailable", "degraded"]
26
+ reason: str = ""
27
+ capabilities: frozenset[str] = frozenset()
28
+ system_hint: typing.Optional[str] = None
29
+
30
+ @property
31
+ def usable(self) -> bool:
32
+ return self.availability in ("available", "degraded")
33
+
34
+
35
+ class OperationNotStarted(RuntimeError):
36
+ """A provider declined before a remote operation could start."""
37
+
38
+ def __init__(
39
+ self,
40
+ reason: str = "operation was not started",
41
+ *,
42
+ cause: Exception | None = None,
43
+ ):
44
+ super().__init__(reason)
45
+ self.reason = reason
46
+ self.cause = cause
47
+
48
+
49
+ @dataclasses.dataclass(frozen=True)
50
+ class ProviderSelection:
51
+ provider: typing.Union["ExecutorProvider", "PathProvider"]
52
+ trace: tuple[dict[str, object], ...]
53
+ generation: int = 0
54
+ policy: str = "ordered"
55
+ pinned: bool = False
56
+
57
+
58
+ @dataclasses.dataclass(frozen=True)
59
+ class SessionInitializer:
60
+ """Optional post-connect bootstrap hook receiving the connected system host."""
61
+
62
+ initialize: typing.Callable[..., object]
63
+ timeout: float | None = None
64
+
65
+ def __call__(self, host, **options):
66
+ if self.timeout is not None:
67
+ options.setdefault("timeout", self.timeout)
68
+ return self.initialize(host, **options)
69
+
70
+
71
+ class _LazyRedaction:
72
+ """A log argument that redacts only if a handler actually formats it.
73
+
74
+ ``log.debug("... %s", _LazyRedaction(cmd, args))`` costs one object
75
+ allocation when nobody is listening; the regex work happens inside
76
+ ``__str__``, which ``logging`` calls only while rendering a record it has
77
+ decided to emit.
78
+ """
79
+
80
+ __slots__ = ("_command", "_args")
81
+
82
+ def __init__(self, command, args=()):
83
+ self._command = command
84
+ self._args = tuple(args)
85
+
86
+ def __str__(self) -> str:
87
+ parts = (self._command, *self._args)
88
+ return ProviderSelector.redact(" ".join(str(part) for part in parts))
89
+
90
+ __repr__ = __str__
91
+
92
+
93
+ def _redacted_command(command, args=()) -> _LazyRedaction:
94
+ """Wrap a command and its argv for redacted, deferred log formatting."""
95
+ return _LazyRedaction(command, args)
96
+
97
+
98
+ class ExecutorProvider:
99
+ """Named callable executor with a conservative probe hook."""
100
+
101
+ def __init__(
102
+ self,
103
+ name: str,
104
+ executor: typing.Callable[..., object],
105
+ *,
106
+ capabilities=None,
107
+ probe=None,
108
+ ):
109
+ if not name:
110
+ raise ValueError("provider name must not be empty")
111
+ self.name = name
112
+ self.executor = executor
113
+ raw = (
114
+ getattr(executor, "executor_capabilities", ())
115
+ if capabilities is None
116
+ else capabilities
117
+ )
118
+ self.capabilities = frozenset(getattr(item, "value", item) for item in raw)
119
+ self._probe = probe
120
+
121
+ def probe(self) -> ProviderProbe:
122
+ if self._probe is None:
123
+ return ProviderProbe("available", capabilities=self.capabilities)
124
+ result = self._probe()
125
+ if isinstance(result, ProviderProbe):
126
+ return dataclasses.replace(
127
+ result,
128
+ capabilities=frozenset(
129
+ getattr(item, "value", item) for item in result.capabilities
130
+ ),
131
+ )
132
+ return ProviderProbe(
133
+ "available" if result else "unavailable", capabilities=self.capabilities
134
+ )
135
+
136
+ def execute(self, command, *args, **options):
137
+ # The rendered command routinely carries credentials, so it is redacted
138
+ # on the way to the record and never interpolated eagerly: with no
139
+ # handler listening, `%`-style args mean the redaction never runs.
140
+ log.debug(
141
+ "provider %s dispatching: %s",
142
+ ProviderSelector.redact(self.name),
143
+ _redacted_command(command, args),
144
+ )
145
+ return self.executor(command, *args, **options)
146
+
147
+ __call__ = execute
148
+
149
+
150
+ class PathProvider:
151
+ """Named path factory. ``path`` must return a pathlib_next.Path."""
152
+
153
+ DEFAULT_CAPABILITIES = frozenset(
154
+ (
155
+ "stat",
156
+ "scandir",
157
+ "open",
158
+ "open_read",
159
+ "open_write",
160
+ "read",
161
+ "write",
162
+ "exists",
163
+ "is_file",
164
+ "is_dir",
165
+ "mkdir",
166
+ "chmod",
167
+ "unlink",
168
+ "rmdir",
169
+ "rename",
170
+ )
171
+ )
172
+
173
+ def __init__(
174
+ self,
175
+ name: str,
176
+ factory: typing.Callable[..., Path],
177
+ *,
178
+ capabilities=None,
179
+ probe=None,
180
+ ):
181
+ if not name:
182
+ raise ValueError("provider name must not be empty")
183
+ self.name = name
184
+ self.factory = factory
185
+ raw_capabilities = (
186
+ self.DEFAULT_CAPABILITIES if capabilities is None else capabilities
187
+ )
188
+ self.capabilities = frozenset(
189
+ getattr(item, "value", item) for item in raw_capabilities
190
+ )
191
+ self._probe = probe
192
+
193
+ def probe(self) -> ProviderProbe:
194
+ if self._probe is None:
195
+ return ProviderProbe("available", capabilities=self.capabilities)
196
+ result = self._probe()
197
+ if isinstance(result, ProviderProbe):
198
+ return dataclasses.replace(
199
+ result,
200
+ capabilities=frozenset(
201
+ getattr(item, "value", item) for item in result.capabilities
202
+ ),
203
+ )
204
+ return ProviderProbe(
205
+ "available" if result else "unavailable", capabilities=self.capabilities
206
+ )
207
+
208
+ def path(self, *segments):
209
+ log.debug(
210
+ "path provider %s building: %s",
211
+ ProviderSelector.redact(self.name),
212
+ _redacted_command("", segments),
213
+ )
214
+ value = self.factory(*segments)
215
+ if not isinstance(value, Path):
216
+ raise TypeError(
217
+ f"path provider {self.name!r} returned {type(value).__name__}, expected pathlib_next.Path"
218
+ )
219
+ return value
220
+
221
+
222
+ class ProviderSelector:
223
+ """Ordered selector which never retries a dispatched operation."""
224
+
225
+ def __init__(self, providers=()):
226
+ self.providers = tuple(providers)
227
+ if any(not getattr(p, "name", "") for p in self.providers):
228
+ raise ValueError("providers must have names")
229
+ names = tuple(provider.name for provider in self.providers)
230
+ if len(set(names)) != len(names):
231
+ raise ValueError("provider names must be unique within a selector")
232
+ self.last_selection: ProviderSelection | None = None
233
+ self._probe_cache: dict[str, ProviderProbe] = {}
234
+ self._declined: dict[str, str] = {}
235
+ self._generation = 0
236
+ # Trace entries accumulated across the failover attempts of one
237
+ # caller-level operation, keyed by provider name so a later, more
238
+ # informative record (a decline) supersedes the earlier optimistic one
239
+ # (chosen). See `select` for how an attempt sequence is delimited.
240
+ self._attempt_trace: dict[str, dict[str, object]] = {}
241
+
242
+ @property
243
+ def generation(self) -> int:
244
+ """The current connection generation; probes are cached per value."""
245
+ return self._generation
246
+
247
+ @property
248
+ def declines(self) -> dict[str, str]:
249
+ """Providers that refused before dispatch this generation, by name.
250
+
251
+ Maps provider name to the redacted refusal reason. A copy: mutating
252
+ the result does not change selector state.
253
+ """
254
+ return {
255
+ name: self._safe_name(reason) for name, reason in self._declined.items()
256
+ }
257
+
258
+ def decline(self, name: str, reason: str = "declined before dispatch") -> None:
259
+ """Record that a provider refused *before* dispatch this generation.
260
+
261
+ A provider which raised ``OperationNotStarted`` proved that nothing
262
+ started, so it is safe to skip it for the rest of this generation
263
+ rather than re-attempting it on every later operation. The record is
264
+ cleared by :meth:`invalidate` along with the probe cache.
265
+ """
266
+ self._declined[str(name)] = str(reason)
267
+ log.debug(
268
+ "provider %s declined before dispatch (generation %d): %s",
269
+ self._safe_name(name),
270
+ self._generation,
271
+ self._safe_name(reason),
272
+ )
273
+
274
+ #: ``name=value`` and ``name: value`` credential assignments.
275
+ _SECRET_ASSIGNMENT = re.compile(
276
+ r"(?i)\b(password|passwd|pwd|secret|token|api[_-]?key|key|credential)"
277
+ r"\s*[=:]\s*(\"[^\"]*\"|'[^']*'|[^&\s,;]+)"
278
+ )
279
+ #: ``scheme://user:password@host`` URI userinfo.
280
+ _SECRET_USERINFO = re.compile(r"(?i)\b([a-z][a-z0-9+.\-]*://[^/\s:@]+):[^/\s@]+@")
281
+
282
+ @classmethod
283
+ def _safe_name(cls, value: object) -> str:
284
+ text = str(value)
285
+ text = cls._SECRET_ASSIGNMENT.sub(r"\1=<redacted>", text)
286
+ return cls._SECRET_USERINFO.sub(r"\1:<redacted>@", text)
287
+
288
+ @classmethod
289
+ def redact(cls, value: object) -> str:
290
+ """Return a diagnostic string with credential-like values removed.
291
+
292
+ Best effort, and deliberately so. This recognizes credentials that
293
+ *announce themselves* -- a ``password=``/``token=``/``api_key=``
294
+ assignment, or the userinfo half of a ``scheme://user:secret@host``
295
+ URI. A bare positional secret (``mysql -p hunter2``, an argv element
296
+ that is only a token) carries no marker and cannot be detected by
297
+ inspection. Treat debug output as sensitive.
298
+ """
299
+ return cls._safe_name(value)
300
+
301
+ def probe(self, provider) -> ProviderProbe:
302
+ """Return a provider probe cached for this selector generation.
303
+
304
+ A provider that declined before dispatch this generation reports as
305
+ ``unavailable`` with the refusal reason, overriding the cached probe.
306
+ The probe describes whether the transport *could* work; the decline is
307
+ the newer and stronger evidence that right now it does not, so any
308
+ caller reading availability (``provider_details``, ``capabilities``)
309
+ sees the refusal instead of a stale ``available``.
310
+ """
311
+ name = provider.name
312
+ declined = self._declined.get(name)
313
+ if declined is not None:
314
+ return ProviderProbe("unavailable", self._safe_name(declined))
315
+ if name in self._probe_cache:
316
+ return self._probe_cache[name]
317
+ try:
318
+ result = provider.probe()
319
+ except Exception as exc:
320
+ result = ProviderProbe("unavailable", type(exc).__name__)
321
+ log.debug(
322
+ "provider %s probe raised %s; treating as unavailable",
323
+ self._safe_name(name),
324
+ type(exc).__name__,
325
+ )
326
+ self._probe_cache[name] = result
327
+ return result
328
+
329
+ def select(
330
+ self,
331
+ *,
332
+ capability: str | None = None,
333
+ exclude: typing.Iterable[str] = (),
334
+ policy: str = "ordered",
335
+ pin: bool = False,
336
+ ) -> ProviderSelection:
337
+ excluded = set(exclude)
338
+ # An empty `exclude` starts a new caller-level operation; a non-empty
339
+ # one is a failover step within the operation that is already running,
340
+ # because the only way a name lands in `exclude` is that this same
341
+ # caller just tried it (`run()`, `_providers_in_order`). Accumulating
342
+ # across those steps is what makes the FIRST failing call report every
343
+ # provider tried and every refusal, instead of the trace describing
344
+ # only whichever provider happened to win.
345
+ if not excluded:
346
+ self._attempt_trace = {}
347
+ for provider in self.providers:
348
+ declined = self._declined.get(provider.name)
349
+ if declined is not None:
350
+ # Recorded even when the caller also excluded this provider:
351
+ # `exclude` says "I already tried it", and the decline is the
352
+ # reason it did not work. Skipping the record here is exactly
353
+ # what used to strip the refusal from the trace of the call
354
+ # that suffered it.
355
+ self._record(
356
+ {
357
+ "provider": self._safe_name(provider.name),
358
+ "availability": "unavailable",
359
+ "reason": self._safe_name(declined),
360
+ "capabilities": (),
361
+ "chosen": False,
362
+ "generation": self._generation,
363
+ "policy": self._safe_name(policy),
364
+ "pin": bool(pin),
365
+ }
366
+ )
367
+ continue
368
+ if provider.name in excluded:
369
+ continue
370
+ probe = self.probe(provider)
371
+ allowed = probe.usable and (
372
+ capability is None
373
+ or capability in (probe.capabilities | provider.capabilities)
374
+ )
375
+ capabilities = frozenset(
376
+ getattr(item, "value", item) for item in probe.capabilities
377
+ )
378
+ self._record(
379
+ {
380
+ "provider": self._safe_name(provider.name),
381
+ "availability": probe.availability,
382
+ "reason": self._safe_name(probe.reason),
383
+ "capabilities": tuple(sorted(capabilities)),
384
+ "chosen": allowed,
385
+ "generation": self._generation,
386
+ "policy": self._safe_name(policy),
387
+ "pin": bool(pin),
388
+ }
389
+ )
390
+ if not allowed:
391
+ log.debug(
392
+ "provider %s skipped: availability=%s capability=%s reason=%s",
393
+ self._safe_name(provider.name),
394
+ probe.availability,
395
+ capability or "<any>",
396
+ self._safe_name(probe.reason) or "<none>",
397
+ )
398
+ continue
399
+ result = ProviderSelection(
400
+ provider,
401
+ self.trace,
402
+ generation=self._generation,
403
+ policy=policy,
404
+ pinned=bool(pin),
405
+ )
406
+ self.last_selection = result
407
+ log.debug(
408
+ "selected provider %s (generation %d, policy %s, pin %s) "
409
+ "after %d candidate(s)",
410
+ self._safe_name(provider.name),
411
+ self._generation,
412
+ self._safe_name(policy),
413
+ bool(pin),
414
+ len(self._attempt_trace),
415
+ )
416
+ return result
417
+ log.debug(
418
+ "no provider available (generation %d); candidates: %s",
419
+ self._generation,
420
+ ", ".join(
421
+ "%s/%s" % (item["provider"], item["availability"])
422
+ for item in self.trace
423
+ )
424
+ or "<none>",
425
+ )
426
+ raise OperationNotStarted("no provider is available")
427
+
428
+ def _record(self, entry: dict[str, object]) -> None:
429
+ """Merge one trace entry, letting a later record supersede an earlier.
430
+
431
+ Ordering follows first appearance, so the trace still reads in
432
+ provider precedence order after a decline rewrites an entry.
433
+ """
434
+ name = typing.cast(str, entry["provider"])
435
+ if name in self._attempt_trace:
436
+ self._attempt_trace[name].update(entry)
437
+ else:
438
+ self._attempt_trace[name] = dict(entry)
439
+
440
+ @property
441
+ def trace(self) -> tuple[dict[str, object], ...]:
442
+ """The accumulated trace for the operation currently being selected."""
443
+ return tuple(dict(entry) for entry in self._attempt_trace.values())
444
+
445
+ def invalidate(self) -> None:
446
+ """Start a new generation, dropping cached probes and declines."""
447
+ self.last_selection = None
448
+ self._probe_cache.clear()
449
+ self._declined.clear()
450
+ self._attempt_trace = {}
451
+ self._generation += 1
452
+ log.debug("selector invalidated; generation is now %d", self._generation)