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.
- hostctl/AGENTS.md +292 -0
- hostctl/README.md +248 -0
- hostctl/__init__.py +194 -0
- hostctl/__main__.py +6 -0
- hostctl/_async.py +169 -0
- hostctl/_cli.py +232 -0
- hostctl/executor/__init__.py +82 -0
- hostctl/executor/_common.py +234 -0
- hostctl/executor/_qga.py +646 -0
- hostctl/executor/container.py +171 -0
- hostctl/executor/local.py +91 -0
- hostctl/executor/psrp.py +145 -0
- hostctl/executor/qemu.py +305 -0
- hostctl/executor/serial.py +312 -0
- hostctl/executor/ssh.py +226 -0
- hostctl/executor/winrm.py +255 -0
- hostctl/host/__init__.py +93 -0
- hostctl/host/_common.py +804 -0
- hostctl/host/_local.py +258 -0
- hostctl/host/_ssh.py +587 -0
- hostctl/host/_winrm.py +1002 -0
- hostctl/host/composite_path.py +567 -0
- hostctl/host/container.py +606 -0
- hostctl/host/container_path.py +664 -0
- hostctl/host/qemu.py +1078 -0
- hostctl/host/serial.py +401 -0
- hostctl/host/system.py +809 -0
- hostctl/process/__init__.py +49 -0
- hostctl/process/_common.py +113 -0
- hostctl/process/container.py +265 -0
- hostctl/process/psrp.py +208 -0
- hostctl/process/qemu_serial.py +247 -0
- hostctl/process/serial.py +257 -0
- hostctl/process/ssh.py +165 -0
- hostctl/provider/__init__.py +33 -0
- hostctl/provider/_common.py +452 -0
- hostctl/provider/transports.py +303 -0
- hostctl/py.typed +0 -0
- hostctl/serial/__init__.py +285 -0
- hostctl/shell/__init__.py +123 -0
- hostctl/shell/_common.py +616 -0
- hostctl/shell/cmd.py +167 -0
- hostctl/shell/fish.py +63 -0
- hostctl/shell/posix.py +83 -0
- hostctl/shell/powershell.py +120 -0
- hostctl/sync.py +245 -0
- hostctl-0.1.0.dist-info/METADATA +302 -0
- hostctl-0.1.0.dist-info/RECORD +51 -0
- hostctl-0.1.0.dist-info/WHEEL +4 -0
- hostctl-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|