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,606 @@
1
+ """Docker Engine container host implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import subprocess
7
+ import typing
8
+ from pathlib import PurePath, PurePosixPath, PureWindowsPath
9
+ from urllib.parse import quote, unquote, urlencode
10
+
11
+ from pathlib_next import Pathname, PosixPathname, WindowsPathname
12
+
13
+ from ..executor.container import (
14
+ ContainerExecutor,
15
+ ContainerLike,
16
+ normalize_container_error,
17
+ )
18
+ from ..executor import normalize_environment
19
+ from ..provider import OperationNotStarted, ProviderSelector
20
+ from ..provider.transports import (
21
+ ContainerArchivePathProvider,
22
+ ContainerExecutorProvider,
23
+ )
24
+ from ..process import (
25
+ ContainerProcess,
26
+ Process,
27
+ TerminalRequest,
28
+ terminal_options,
29
+ )
30
+ from ..shell import (
31
+ POWERSHELL,
32
+ POSIX_SHELL,
33
+ ShellFlavour,
34
+ ShellFlavourSelection,
35
+ shell_flavour,
36
+ )
37
+ from ._common import (
38
+ CaptureOutput,
39
+ Command,
40
+ Environment,
41
+ FileHandle,
42
+ Host,
43
+ HostConfig,
44
+ HostInfo,
45
+ HostPath,
46
+ Input,
47
+ PathLike,
48
+ starts_direct_command,
49
+ parse_host_info,
50
+ strict_uri_credentials,
51
+ strict_uri_query,
52
+ )
53
+ from .container_path import (
54
+ ContainerPathBackend,
55
+ PosixContainerPath,
56
+ WindowsContainerPath,
57
+ )
58
+
59
+ PathnameConstructor = typing.Type[typing.Union[PurePath, Pathname]]
60
+ ContainerShellSelection = typing.Union[ShellFlavourSelection, typing.Literal["auto"]]
61
+ ContainerPathSelection = typing.Union[PathnameConstructor, typing.Literal["auto"]]
62
+
63
+
64
+ def _path_selection(value: str) -> ContainerPathSelection:
65
+ try:
66
+ return {
67
+ "auto": "auto",
68
+ "posix": PosixPathname,
69
+ "windows": WindowsPathname,
70
+ }[value]
71
+ except KeyError as exc:
72
+ raise ValueError("path_flavor must be 'auto', 'posix', or 'windows'") from exc
73
+
74
+
75
+ @dataclasses.dataclass
76
+ class ContainerConfig(HostConfig, schemes=("docker",)):
77
+ """Docker Engine target and in-container execution defaults."""
78
+
79
+ container: str
80
+ engine_url: typing.Optional[str] = None
81
+ user: typing.Optional[str] = None
82
+ workdir: typing.Optional[str] = None
83
+ executable: typing.Optional[str] = None
84
+ dialect: ContainerShellSelection = "auto"
85
+ path_flavor: ContainerPathSelection = "auto"
86
+ client_factory: typing.Optional[typing.Callable[..., object]] = dataclasses.field(
87
+ default=None, repr=False, compare=False
88
+ )
89
+
90
+ def __post_init__(self) -> None:
91
+ HostConfig.__init__(self)
92
+ if not self.container:
93
+ raise ValueError("container must not be empty")
94
+ if self.dialect != "auto":
95
+ self.dialect = shell_flavour(self.dialect)
96
+ elif self.executable is not None:
97
+ raise ValueError("executable cannot be combined with dialect='auto'")
98
+ if self.path_flavor != "auto":
99
+ value = self.path_flavor
100
+ if not isinstance(value, type) or not issubclass(
101
+ value, (Pathname, PurePath)
102
+ ):
103
+ raise TypeError("path_flavor must be a pure-path class or 'auto'")
104
+ if value is PurePath or not issubclass(
105
+ value, (PurePosixPath, PureWindowsPath)
106
+ ):
107
+ raise TypeError("path_flavor must use POSIX or Windows semantics")
108
+
109
+ @property
110
+ def connection_uri(self) -> str:
111
+ query: typing.Dict[str, object] = {
112
+ "dialect": self.dialect,
113
+ "path_flavor": (
114
+ self.path_flavor
115
+ if self.path_flavor == "auto"
116
+ else (
117
+ "windows"
118
+ if issubclass(
119
+ typing.cast(PathnameConstructor, self.path_flavor),
120
+ PureWindowsPath,
121
+ )
122
+ else "posix"
123
+ )
124
+ ),
125
+ }
126
+ for name in ("engine_url", "user", "workdir", "executable"):
127
+ value = getattr(self, name)
128
+ if value is not None:
129
+ query[name] = value
130
+ return f"docker://{quote(self.container, safe='')}" + (
131
+ f"?{urlencode(query)}" if query else ""
132
+ )
133
+
134
+ @classmethod
135
+ def _from_parsed_uri(cls, parsed, **credentials: object) -> ContainerConfig:
136
+ strict_uri_credentials(credentials, ("client_factory",))
137
+ query = strict_uri_query(
138
+ parsed,
139
+ {
140
+ "engine_url",
141
+ "user",
142
+ "workdir",
143
+ "executable",
144
+ "dialect",
145
+ "path_flavor",
146
+ },
147
+ )
148
+ if not parsed.netloc or parsed.path not in ("", "/"):
149
+ raise ValueError("Docker URI requires a container and no path")
150
+ if parsed.username or parsed.password or parsed.port:
151
+ raise ValueError("Docker URI authority must contain only a container name")
152
+ return cls(
153
+ container=unquote(parsed.netloc),
154
+ engine_url=query.get("engine_url") or None,
155
+ user=query.get("user") or None,
156
+ workdir=query.get("workdir") or None,
157
+ executable=query.get("executable") or None,
158
+ dialect=(
159
+ "auto"
160
+ if query.get("dialect", "auto") == "auto"
161
+ else shell_flavour(query["dialect"])
162
+ ),
163
+ path_flavor=_path_selection(query.get("path_flavor", "auto")),
164
+ client_factory=typing.cast(
165
+ typing.Optional[typing.Callable[..., object]],
166
+ credentials.get("client_factory"),
167
+ ),
168
+ )
169
+
170
+ def _create_host(self) -> ContainerHost:
171
+ return ContainerHost(self)
172
+
173
+
174
+ class ContainerHost(Host):
175
+ """A running container reached through the Docker Engine API.
176
+
177
+ Commands and paths are assembled from ordered providers
178
+ (:class:`ContainerExecutorProvider` and
179
+ :class:`ContainerArchivePathProvider`) without changing the public API.
180
+ The archive provider declares only the operations the Docker archive API
181
+ can perform, so an unsupported mutation is rejected outright instead of
182
+ falling through to another provider.
183
+ """
184
+
185
+ def __init__(
186
+ self,
187
+ config: ContainerConfig,
188
+ *,
189
+ executor_providers: typing.Iterable[object] = (),
190
+ path_providers: typing.Iterable[object] = (),
191
+ ) -> None:
192
+ self.config = config
193
+ self._client: typing.Optional[object] = None
194
+ self._container: typing.Optional[ContainerLike] = None
195
+ self._attrs: typing.Optional[typing.Mapping[str, object]] = None
196
+ self._executor = ContainerExecutor(
197
+ lambda: self.container,
198
+ user=config.user,
199
+ workdir=config.workdir,
200
+ )
201
+ self._executor_provider_selector = ProviderSelector(
202
+ tuple(executor_providers)
203
+ or (
204
+ ContainerExecutorProvider(
205
+ self._executor,
206
+ connect=lambda: self.container,
207
+ close=self._close_client,
208
+ ),
209
+ )
210
+ )
211
+ self._path_provider_selector = ProviderSelector(
212
+ tuple(path_providers) or (ContainerArchivePathProvider(self._archive_path),)
213
+ )
214
+
215
+ @property
216
+ def executor_providers(self) -> typing.Tuple[object, ...]:
217
+ """The ordered command providers backing :meth:`run`."""
218
+ return self._executor_provider_selector.providers
219
+
220
+ @property
221
+ def path_providers(self) -> typing.Tuple[object, ...]:
222
+ """The ordered filesystem providers backing :meth:`path`."""
223
+ return self._path_provider_selector.providers
224
+
225
+ def _run_selector(self) -> ProviderSelector:
226
+ return self._executor_provider_selector
227
+
228
+ @property
229
+ def capabilities(self) -> typing.FrozenSet[str]:
230
+ values = set()
231
+ if any(
232
+ self._executor_provider_selector.probe(provider).usable
233
+ for provider in self._executor_provider_selector.providers
234
+ ):
235
+ values.update(("run", "spawn", "tty"))
236
+ if any(
237
+ self._path_provider_selector.probe(provider).usable
238
+ for provider in self._path_provider_selector.providers
239
+ ):
240
+ values.add("path")
241
+ return frozenset(values)
242
+
243
+ @property
244
+ def client(self) -> object:
245
+ if self._client is None:
246
+ factory = self.config.client_factory
247
+ if factory is None:
248
+ try:
249
+ import docker
250
+ except ImportError as exc:
251
+ raise ImportError(
252
+ "container support requires the 'container' extra: "
253
+ "pip install hostctl[container]"
254
+ ) from exc
255
+ factory = (
256
+ docker.DockerClient if self.config.engine_url else docker.from_env
257
+ )
258
+ self._client = (
259
+ factory(base_url=self.config.engine_url)
260
+ if self.config.engine_url is not None
261
+ else factory()
262
+ )
263
+ return self._client
264
+
265
+ @property
266
+ def container(self) -> ContainerLike:
267
+ if self._container is None:
268
+ try:
269
+ container = self.client.containers.get(self.config.container)
270
+ container.reload()
271
+ except Exception as exc:
272
+ if (
273
+ type(exc).__name__ == "NotFound"
274
+ or getattr(exc, "status_code", None) == 404
275
+ ):
276
+ raise ConnectionError(
277
+ f"container {self.config.container!r} not found"
278
+ ) from exc
279
+ normalized = normalize_container_error(exc)
280
+ if normalized is exc:
281
+ raise
282
+ raise normalized from exc
283
+ attrs = typing.cast(typing.Mapping[str, object], container.attrs)
284
+ state = typing.cast(typing.Mapping[str, object], attrs.get("State", {}))
285
+ if not state.get("Running", False):
286
+ raise ConnectionError(
287
+ f"container {self.config.container!r} is not running"
288
+ )
289
+ self._container = typing.cast(ContainerLike, container)
290
+ self._attrs = attrs
291
+ return self._container
292
+
293
+ @property
294
+ def inspected_os(self) -> str:
295
+ _ = self.container
296
+ attrs = self._attrs or {}
297
+ value = str(attrs.get("Platform") or attrs.get("Os") or "").casefold()
298
+ if not value:
299
+ image = getattr(self._container, "image", None)
300
+ value = str(getattr(image, "attrs", {}).get("Os", "")).casefold()
301
+ if value not in ("linux", "windows"):
302
+ raise RuntimeError(
303
+ "unsupported or undetected container OS: " f"{value or '<empty>'}"
304
+ )
305
+ return value
306
+
307
+ @property
308
+ def shell_flavour(self) -> ShellFlavour:
309
+ if self.config.dialect != "auto":
310
+ return typing.cast(ShellFlavour, self.config.dialect)
311
+ return POWERSHELL if self.inspected_os == "windows" else POSIX_SHELL
312
+
313
+ @property
314
+ def executor(self) -> ContainerExecutor:
315
+ return self._executor_provider_selector.select().provider.executor
316
+
317
+ def connect(self) -> None:
318
+ _ = self.container
319
+
320
+ def _close_client(self) -> None:
321
+ self._container = None
322
+ self._attrs = None
323
+ if self._client is not None:
324
+ client, self._client = self._client, None
325
+ close = getattr(client, "close", None)
326
+ if close is not None:
327
+ close()
328
+
329
+ def close(self) -> None:
330
+ first_error: typing.Optional[BaseException] = None
331
+ closed: typing.List[int] = []
332
+ for provider in (
333
+ *self._executor_provider_selector.providers,
334
+ *self._path_provider_selector.providers,
335
+ ):
336
+ if id(provider) in closed:
337
+ continue
338
+ closed.append(id(provider))
339
+ close = getattr(provider, "close", None)
340
+ if close is None:
341
+ continue
342
+ try:
343
+ close()
344
+ except BaseException as exc: # pragma: no cover - defensive
345
+ if first_error is None:
346
+ first_error = exc
347
+ self._close_client()
348
+ self._executor_provider_selector.invalidate()
349
+ self._path_provider_selector.invalidate()
350
+ if first_error is not None: # pragma: no cover - defensive
351
+ raise first_error
352
+
353
+ def info(self) -> HostInfo:
354
+ result = self.run(self.shell_flavour.info_script, check=False, encoding="utf-8")
355
+ info = parse_host_info(result.stdout)
356
+ attrs = self._attrs or {}
357
+ return HostInfo(
358
+ hostname=info.hostname,
359
+ os_family=info.os_family or self.inspected_os,
360
+ os_name=info.os_name,
361
+ os_version=info.os_version,
362
+ architecture=info.architecture
363
+ or typing.cast(typing.Optional[str], attrs.get("Architecture")),
364
+ )
365
+
366
+ def path(
367
+ self, *segments: PathLike, backend: typing.Optional[str] = None
368
+ ) -> HostPath:
369
+ names = {provider.name for provider in self._path_provider_selector.providers}
370
+ if backend is not None and backend not in (names | {"docker"}):
371
+ raise ValueError(f"unsupported container path backend: {backend!r}")
372
+ if backend in (None, "docker"):
373
+ provider = self._path_provider_selector.select().provider
374
+ else:
375
+ provider = next(
376
+ item
377
+ for item in self._path_provider_selector.providers
378
+ if item.name == backend
379
+ )
380
+ if not provider.probe().usable:
381
+ raise OperationNotStarted(f"path provider {backend!r} is unavailable")
382
+ return provider.path(*segments)
383
+
384
+ def _archive_path(self, *segments: PathLike) -> HostPath:
385
+ """Build a Docker-archive-backed path with the configured flavour."""
386
+ selection = self.config.path_flavor
387
+ windows = (
388
+ self.inspected_os == "windows"
389
+ if selection == "auto"
390
+ else issubclass(
391
+ typing.cast(PathnameConstructor, selection), PureWindowsPath
392
+ )
393
+ )
394
+ path_backend = ContainerPathBackend(
395
+ self.container,
396
+ path_flavor=("windows" if windows else "posix"),
397
+ )
398
+ if windows:
399
+ values = segments or ("C:\\",)
400
+ return WindowsContainerPath(*values, backend=path_backend)
401
+ values = segments or ("/",)
402
+ return PosixContainerPath(*values, backend=path_backend)
403
+
404
+ def run(
405
+ self,
406
+ *cmds: Command,
407
+ bufsize: int = -1,
408
+ executable: typing.Optional[str] = None,
409
+ stdin: typing.Optional[FileHandle] = None,
410
+ stdout: typing.Optional[FileHandle] = None,
411
+ stderr: typing.Optional[FileHandle] = None,
412
+ cwd: typing.Optional[PathLike] = None,
413
+ env: typing.Optional[Environment] = None,
414
+ capture_output: CaptureOutput = True,
415
+ check: bool = True,
416
+ encoding: typing.Optional[str] = None,
417
+ errors: typing.Optional[str] = None,
418
+ input: Input = None,
419
+ timeout: typing.Optional[float] = None,
420
+ text: typing.Optional[bool] = None,
421
+ ) -> subprocess.CompletedProcess:
422
+ excluded: typing.List[str] = []
423
+ while True:
424
+ provider = self._executor_provider_selector.select(
425
+ exclude=excluded
426
+ ).provider
427
+ try:
428
+ return self._run_with_provider(
429
+ provider,
430
+ cmds,
431
+ bufsize=bufsize,
432
+ executable=executable,
433
+ stdin=stdin,
434
+ stdout=stdout,
435
+ stderr=stderr,
436
+ cwd=cwd,
437
+ env=env,
438
+ capture_output=capture_output,
439
+ check=check,
440
+ encoding=encoding,
441
+ errors=errors,
442
+ input=input,
443
+ timeout=timeout,
444
+ text=text,
445
+ )
446
+ except OperationNotStarted as exc:
447
+ # Only a proven pre-dispatch refusal may reach another
448
+ # provider; a dispatched exec is never replayed.
449
+ self._executor_provider_selector.decline(provider.name, str(exc))
450
+ excluded.append(provider.name)
451
+
452
+ def _run_with_provider(
453
+ self,
454
+ provider,
455
+ cmds: typing.Sequence[Command],
456
+ *,
457
+ bufsize: int,
458
+ executable: typing.Optional[str],
459
+ stdin,
460
+ stdout,
461
+ stderr,
462
+ cwd,
463
+ env,
464
+ capture_output,
465
+ check,
466
+ encoding,
467
+ errors,
468
+ input,
469
+ timeout,
470
+ text,
471
+ ) -> subprocess.CompletedProcess:
472
+ direct = starts_direct_command(cmds)
473
+ if direct is not None:
474
+ command, args = direct
475
+ if executable is not None:
476
+ raise NotImplementedError(
477
+ "executable cannot be combined with a direct command"
478
+ )
479
+ return provider.execute(
480
+ command,
481
+ *args,
482
+ bufsize=bufsize,
483
+ stdin=stdin,
484
+ stdout=stdout,
485
+ stderr=stderr,
486
+ cwd=cwd,
487
+ env=env,
488
+ capture_output=capture_output,
489
+ check=check,
490
+ encoding=encoding,
491
+ errors=errors,
492
+ input=input,
493
+ timeout=timeout,
494
+ text=text,
495
+ )
496
+ script = self.shell_flavour.script(cmds, cwd=None, env=None)
497
+ invocation = self.shell_flavour.invocation(
498
+ script, executable=executable or self.config.executable
499
+ )
500
+ return provider.execute(
501
+ invocation[0],
502
+ *invocation[1:],
503
+ bufsize=bufsize,
504
+ stdin=stdin,
505
+ stdout=stdout,
506
+ stderr=stderr,
507
+ cwd=cwd,
508
+ env=env,
509
+ capture_output=capture_output,
510
+ check=check,
511
+ encoding=encoding,
512
+ errors=errors,
513
+ input=input,
514
+ timeout=timeout,
515
+ text=text,
516
+ )
517
+
518
+ def spawn(
519
+ self,
520
+ *cmds: Command,
521
+ executable: typing.Optional[str] = None,
522
+ cwd: typing.Optional[PathLike] = None,
523
+ env: typing.Optional[Environment] = None,
524
+ terminal: TerminalRequest = None,
525
+ encoding: typing.Optional[str] = None,
526
+ errors: typing.Optional[str] = None,
527
+ ) -> Process:
528
+ """Start a persistent Docker exec process with an optional TTY."""
529
+ direct = starts_direct_command(cmds)
530
+ if direct is not None:
531
+ command, args = direct
532
+ invocation = [
533
+ str(command),
534
+ *[
535
+ value.decode() if isinstance(value, bytes) else str(value)
536
+ for value in args
537
+ ],
538
+ ]
539
+ environment = normalize_environment(env)
540
+ elif cmds:
541
+ script = self.shell_flavour.script(cmds, cwd=None, env=None)
542
+ invocation = self.shell_flavour.invocation(
543
+ script, executable=executable or self.config.executable
544
+ )
545
+ environment = normalize_environment(env)
546
+ else:
547
+ if cwd is not None or env is not None:
548
+ raise ValueError("cwd and env require a command when spawning")
549
+ selected = executable or self.config.executable
550
+ invocation = (
551
+ [selected]
552
+ if selected
553
+ else list(self.shell_flavour.invocation("", executable=None)[:1])
554
+ )
555
+ environment = None
556
+
557
+ selected_terminal = terminal_options(terminal)
558
+ tty = selected_terminal is not None
559
+ api = typing.cast(typing.Any, self.client).api
560
+ selected_workdir = str(cwd) if cwd is not None else self.config.workdir
561
+ options: typing.Dict[str, object] = {
562
+ "cmd": list(invocation),
563
+ "stdin": True,
564
+ "stdout": True,
565
+ "stderr": True,
566
+ "tty": tty,
567
+ }
568
+ if environment is not None:
569
+ options["environment"] = environment
570
+ if selected_workdir is not None:
571
+ options["workdir"] = selected_workdir
572
+ if self.config.user is not None:
573
+ options["user"] = self.config.user
574
+ try:
575
+ created = api.exec_create(
576
+ getattr(self.container, "id", self.config.container),
577
+ **options,
578
+ )
579
+ exec_id = created["Id"] if isinstance(created, dict) else created
580
+ stream = api.exec_start(exec_id, socket=True, tty=tty)
581
+ if selected_terminal is not None:
582
+ try:
583
+ api.exec_resize(
584
+ exec_id,
585
+ height=selected_terminal.rows,
586
+ width=selected_terminal.columns,
587
+ )
588
+ except Exception:
589
+ close = getattr(stream, "close", None)
590
+ if close is not None:
591
+ close()
592
+ raise
593
+ except Exception as exc:
594
+ normalized = normalize_container_error(exc)
595
+ if normalized is exc:
596
+ raise
597
+ raise normalized from exc
598
+ return ContainerProcess(
599
+ api,
600
+ str(exec_id),
601
+ stream,
602
+ tty=tty,
603
+ command=list(invocation),
604
+ encoding=encoding,
605
+ errors=errors,
606
+ )