dirigent-block-execute 0.17.1__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.
- dirigent_block_execute/__init__.py +43 -0
- dirigent_block_execute/build.py +319 -0
- dirigent_block_execute/capture.py +289 -0
- dirigent_block_execute/compose.py +675 -0
- dirigent_block_execute/docker.py +1262 -0
- dirigent_block_execute/environment.py +47 -0
- dirigent_block_execute/git.py +548 -0
- dirigent_block_execute/messages.py +183 -0
- dirigent_block_execute/py.typed +0 -0
- dirigent_block_execute/reap.py +174 -0
- dirigent_block_execute/secrets.py +42 -0
- dirigent_block_execute/shell.py +174 -0
- dirigent_block_execute/subprocess.py +219 -0
- dirigent_block_execute-0.17.1.dist-info/METADATA +26 -0
- dirigent_block_execute-0.17.1.dist-info/RECORD +18 -0
- dirigent_block_execute-0.17.1.dist-info/WHEEL +4 -0
- dirigent_block_execute-0.17.1.dist-info/entry_points.txt +3 -0
- dirigent_block_execute-0.17.1.dist-info/licenses/LICENSE +18 -0
|
@@ -0,0 +1,1262 @@
|
|
|
1
|
+
"""``docker.run``: run one container on the worker, submitted once and then probed.
|
|
2
|
+
|
|
3
|
+
Reaching the Docker socket is reaching root on the host, so this block declares
|
|
4
|
+
``local_execution`` and the engine refuses it unless the instance allowlists its id.
|
|
5
|
+
|
|
6
|
+
The ``docker`` connection kind lives here too, because every block in the family resolves its
|
|
7
|
+
daemon through the same material: a ``DOCKER_HOST``, and for a tcp daemon the client TLS
|
|
8
|
+
triple as three 0600 files in a 0700 directory that goes when the step leaves.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import errno
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import ssl
|
|
17
|
+
import stat
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Generator, Mapping
|
|
21
|
+
from contextlib import asynccontextmanager
|
|
22
|
+
from datetime import timedelta
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from types import TracebackType
|
|
25
|
+
from typing import Annotated, ClassVar, NamedTuple, cast
|
|
26
|
+
|
|
27
|
+
import httpx2
|
|
28
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue, SecretStr, model_validator
|
|
29
|
+
|
|
30
|
+
from dirigent_block_execute import secrets, subprocess
|
|
31
|
+
from dirigent_block_execute.capture import Drained, Ends, log_stream, scrub, tail
|
|
32
|
+
from dirigent_block_execute.environment import allowed, reject_reserved
|
|
33
|
+
from dirigent_block_execute.messages import (
|
|
34
|
+
CONTAINER_GONE,
|
|
35
|
+
CREDENTIAL_IS_A_PAIR,
|
|
36
|
+
DAEMON_REFUSED,
|
|
37
|
+
DOCKER_CONNECTION_NAMES_NEITHER,
|
|
38
|
+
MOUNTED_FILE_STAYS_INSIDE,
|
|
39
|
+
NO_DAEMON,
|
|
40
|
+
NOT_A_DAEMON_SCHEME,
|
|
41
|
+
OUTPUT_NOT_WRITTEN,
|
|
42
|
+
OUTPUT_STAYS_INSIDE,
|
|
43
|
+
REGISTRY_WITHOUT_A_CREDENTIAL,
|
|
44
|
+
RUN_ONE_CPU_FIELD,
|
|
45
|
+
RUN_ONE_DAEMON,
|
|
46
|
+
RUN_ONE_FORM,
|
|
47
|
+
TLS_IS_ALL_THREE,
|
|
48
|
+
TLS_NEEDS_TCP,
|
|
49
|
+
)
|
|
50
|
+
from dirigent_block_http.http import status_class
|
|
51
|
+
from dirigent_common import BlockModel, Duration, HealthReport, Size
|
|
52
|
+
from dirigent_plugin import (
|
|
53
|
+
BlockFailure,
|
|
54
|
+
ByteSink,
|
|
55
|
+
ConnectionKind,
|
|
56
|
+
ConnectionRef,
|
|
57
|
+
ErrorClass,
|
|
58
|
+
Operator,
|
|
59
|
+
OperatorSpec,
|
|
60
|
+
ProbeResult,
|
|
61
|
+
ProbeStatus,
|
|
62
|
+
RemoteHandle,
|
|
63
|
+
ShellString,
|
|
64
|
+
ShellVariables,
|
|
65
|
+
StepContext,
|
|
66
|
+
classify_default,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
CONTAINER_POLL = timedelta(seconds=2)
|
|
70
|
+
|
|
71
|
+
DEFAULT_SOCKET_PATH = "/var/run/docker.sock"
|
|
72
|
+
|
|
73
|
+
#: The variables that name the daemon rather than carry a step's data, so the CLI blocks
|
|
74
|
+
#: inherit them from the worker without a document having to name them.
|
|
75
|
+
DAEMON_ENV = ("DOCKER_HOST", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH")
|
|
76
|
+
|
|
77
|
+
#: The socket carries no host, so the URL needs a placeholder authority the daemon ignores.
|
|
78
|
+
DAEMON_BASE_URL = "http://docker"
|
|
79
|
+
|
|
80
|
+
SHELL_ARGV = ("/bin/sh", "-c")
|
|
81
|
+
|
|
82
|
+
UNFINISHED_STATES = frozenset({"created", "running", "restarting", "paused"})
|
|
83
|
+
|
|
84
|
+
#: The handle key holding how far into the container's log a probe has read, as the unix
|
|
85
|
+
#: timestamp the daemon's ``since`` filter takes.
|
|
86
|
+
LOGS_SINCE = "logs_since"
|
|
87
|
+
|
|
88
|
+
#: Docker frames its log stream with an eight-byte header when the container has no TTY.
|
|
89
|
+
STREAM_HEADER_BYTES = 8
|
|
90
|
+
|
|
91
|
+
STDOUT_FRAME = 1
|
|
92
|
+
|
|
93
|
+
STDERR_FRAME = 2
|
|
94
|
+
|
|
95
|
+
#: The smallest memory limit the daemon accepts.
|
|
96
|
+
MINIMUM_MEMORY = 6 * 1024 * 1024
|
|
97
|
+
|
|
98
|
+
NANO_CPUS_PER_CPU = 1_000_000_000
|
|
99
|
+
|
|
100
|
+
COPY_CHUNK_BYTES = 1024 * 1024
|
|
101
|
+
|
|
102
|
+
#: The daemon answers 304 for a container already started and 409 for one not running.
|
|
103
|
+
NOT_MODIFIED = 304
|
|
104
|
+
|
|
105
|
+
#: How much of a daemon error body is quoted back in a failure message.
|
|
106
|
+
ERROR_DETAIL_CHARS = 8 * 1024
|
|
107
|
+
BAD_REQUEST = 400
|
|
108
|
+
NOT_FOUND = 404
|
|
109
|
+
CONFLICT = 409
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
#: The schemes a daemon URL may be written in, which are the ones the docker CLI accepts.
|
|
113
|
+
DAEMON_SCHEMES = ("tcp://", "ssh://", "unix://")
|
|
114
|
+
|
|
115
|
+
#: What a registry credential that names no registry authenticates to.
|
|
116
|
+
DOCKER_HUB = "docker.io"
|
|
117
|
+
|
|
118
|
+
#: How long one docker invocation made outside a step -- a connection check, a login -- may take.
|
|
119
|
+
CHECK_TIMEOUT_SECONDS = 60.0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class DockerConnectionConfig(BlockModel):
|
|
123
|
+
"""A daemon to reach, a registry to authenticate to, or both."""
|
|
124
|
+
|
|
125
|
+
host: str = ""
|
|
126
|
+
"""The daemon, as ``tcp://host:2376``, ``ssh://user@host`` or ``unix:///var/run/docker.sock``.
|
|
127
|
+
|
|
128
|
+
Left empty, a step using this connection reaches the daemon the worker's own environment
|
|
129
|
+
names, which is what a step naming no connection does."""
|
|
130
|
+
|
|
131
|
+
tls_ca: SecretStr | None = None
|
|
132
|
+
"""The CA the daemon's server certificate is checked against, in PEM form, for a ``tcp://`` host."""
|
|
133
|
+
|
|
134
|
+
tls_cert: SecretStr | None = None
|
|
135
|
+
"""The client certificate presented to the daemon, in PEM form."""
|
|
136
|
+
|
|
137
|
+
tls_key: SecretStr | None = None
|
|
138
|
+
"""The client key, in PEM form.
|
|
139
|
+
|
|
140
|
+
Sealed like every other connection secret: a client key for a docker daemon is a
|
|
141
|
+
credential for root on whatever that daemon runs on. It reaches the CLI and the API only
|
|
142
|
+
as a 0600 file in a private directory that goes away when the step leaves."""
|
|
143
|
+
|
|
144
|
+
registry: str = ""
|
|
145
|
+
"""The registry the credential below authenticates to, such as ``ghcr.io``.
|
|
146
|
+
|
|
147
|
+
Empty means Docker Hub, which is what ``docker login`` with no argument means."""
|
|
148
|
+
|
|
149
|
+
username: str = ""
|
|
150
|
+
"""The user half of the registry credential."""
|
|
151
|
+
|
|
152
|
+
password: SecretStr | None = None
|
|
153
|
+
"""The password or token half, sealed, and handed to ``docker login`` on stdin so it never
|
|
154
|
+
becomes an argument and never reaches the worker's own docker config."""
|
|
155
|
+
|
|
156
|
+
@model_validator(mode="after")
|
|
157
|
+
def _check_shape(self) -> "DockerConnectionConfig":
|
|
158
|
+
"""Refuse a daemon URL of an unknown form, half a TLS triple, and half a credential."""
|
|
159
|
+
if self.host and not self.host.startswith(DAEMON_SCHEMES):
|
|
160
|
+
raise ValueError(NOT_A_DAEMON_SCHEME.render(schemes=", ".join(DAEMON_SCHEMES), host=repr(self.host)))
|
|
161
|
+
pems = [self.tls_ca, self.tls_cert, self.tls_key]
|
|
162
|
+
if any(pem is not None for pem in pems) and not all(pem is not None for pem in pems):
|
|
163
|
+
raise ValueError(TLS_IS_ALL_THREE.render())
|
|
164
|
+
if self.tls_key is not None and not self.host.startswith("tcp://"):
|
|
165
|
+
raise ValueError(TLS_NEEDS_TCP.render())
|
|
166
|
+
if (self.password is not None) != bool(self.username):
|
|
167
|
+
raise ValueError(CREDENTIAL_IS_A_PAIR.render())
|
|
168
|
+
if self.registry and self.password is None:
|
|
169
|
+
raise ValueError(REGISTRY_WITHOUT_A_CREDENTIAL.render(registry=repr(self.registry)))
|
|
170
|
+
if not self.host and self.password is None:
|
|
171
|
+
raise ValueError(DOCKER_CONNECTION_NAMES_NEITHER.render())
|
|
172
|
+
return self
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def secured(self) -> bool:
|
|
176
|
+
"""Whether this connection carries client TLS material for a tcp daemon."""
|
|
177
|
+
return self.tls_key is not None
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def authenticates(self) -> bool:
|
|
181
|
+
"""Whether this connection carries a registry credential."""
|
|
182
|
+
return self.password is not None
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def registry_name(self) -> str:
|
|
186
|
+
"""The registry a login addresses, named even where the config left it to the default."""
|
|
187
|
+
return self.registry or DOCKER_HUB
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class DockerConnectionKind(ConnectionKind):
|
|
191
|
+
"""The connection kind the ``docker.*`` blocks resolve their daemon and registry through."""
|
|
192
|
+
|
|
193
|
+
id: ClassVar[str] = "docker"
|
|
194
|
+
config_model: ClassVar[type[BaseModel]] = DockerConnectionConfig
|
|
195
|
+
|
|
196
|
+
async def check(self, config: BaseModel) -> HealthReport:
|
|
197
|
+
"""Ask the daemon its version, and log in and out of the registry, for whichever is configured.
|
|
198
|
+
|
|
199
|
+
The login is a dry run against a config directory of its own, undone by a logout
|
|
200
|
+
before this returns, so nothing about it persists on the host.
|
|
201
|
+
"""
|
|
202
|
+
settings = DockerConnectionConfig.model_validate(config.model_dump())
|
|
203
|
+
binary = shutil.which("docker")
|
|
204
|
+
if binary is None:
|
|
205
|
+
return HealthReport(healthy=False, detail="docker is not on this host's PATH")
|
|
206
|
+
with tempfile.TemporaryDirectory(prefix="dirigent-docker-check-") as home:
|
|
207
|
+
root = Path(home)
|
|
208
|
+
with sealed(settings, root, isolate_config=settings.authenticates) as material:
|
|
209
|
+
environ = daemon_environment(subprocess.environment([], {}, root), material)
|
|
210
|
+
verified: list[str] = []
|
|
211
|
+
for verify in (_verify_daemon, _verify_registry):
|
|
212
|
+
reached = await verify(binary, root, environ, material, settings)
|
|
213
|
+
if isinstance(reached, HealthReport):
|
|
214
|
+
return reached
|
|
215
|
+
if reached:
|
|
216
|
+
verified.append(reached)
|
|
217
|
+
return HealthReport(healthy=True, detail=", ".join(verified))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def _verify_daemon(
|
|
221
|
+
binary: str,
|
|
222
|
+
root: Path,
|
|
223
|
+
environ: dict[str, str],
|
|
224
|
+
material: "Sealed",
|
|
225
|
+
settings: DockerConnectionConfig,
|
|
226
|
+
) -> "str | HealthReport":
|
|
227
|
+
"""Read the daemon's own version, which is the smallest thing that proves reach and TLS."""
|
|
228
|
+
if not settings.host:
|
|
229
|
+
return ""
|
|
230
|
+
try:
|
|
231
|
+
code, out, err = await subprocess.output(
|
|
232
|
+
argv=[binary, "version", "--format", "{{.Server.Version}}"],
|
|
233
|
+
directory=root,
|
|
234
|
+
environ=environ,
|
|
235
|
+
timeout_seconds=CHECK_TIMEOUT_SECONDS,
|
|
236
|
+
what="docker version",
|
|
237
|
+
)
|
|
238
|
+
except BlockFailure as error:
|
|
239
|
+
return HealthReport(healthy=False, detail=scrub(str(error), material.secrets))
|
|
240
|
+
if code != 0:
|
|
241
|
+
return HealthReport(healthy=False, detail=tail(err, redact=material.secrets) or f"docker exited {code}")
|
|
242
|
+
return f"daemon {out.decode('utf-8', errors='replace').strip()} at {settings.host}"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
async def _verify_registry(
|
|
246
|
+
binary: str,
|
|
247
|
+
root: Path,
|
|
248
|
+
environ: dict[str, str],
|
|
249
|
+
material: "Sealed",
|
|
250
|
+
settings: DockerConnectionConfig,
|
|
251
|
+
) -> "str | HealthReport":
|
|
252
|
+
"""Log in to the registry and out again, leaving the host as it was found."""
|
|
253
|
+
if not settings.authenticates:
|
|
254
|
+
return ""
|
|
255
|
+
try:
|
|
256
|
+
code, err = await login(binary, root, environ, settings)
|
|
257
|
+
except BlockFailure as error:
|
|
258
|
+
return HealthReport(healthy=False, detail=scrub(str(error), material.secrets))
|
|
259
|
+
if code != 0:
|
|
260
|
+
return HealthReport(healthy=False, detail=tail(err, redact=material.secrets) or f"docker login exited {code}")
|
|
261
|
+
await logout(binary, root, environ, settings)
|
|
262
|
+
return f"registry {settings.registry_name} as {settings.username}"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
async def login(
|
|
266
|
+
binary: str,
|
|
267
|
+
directory: Path,
|
|
268
|
+
environ: Mapping[str, str],
|
|
269
|
+
settings: DockerConnectionConfig,
|
|
270
|
+
) -> tuple[int, bytes]:
|
|
271
|
+
"""Sign in to the registry, handing the password over on stdin rather than in an argument."""
|
|
272
|
+
assert settings.password is not None
|
|
273
|
+
argv = [binary, "login", "--username", settings.username, "--password-stdin"]
|
|
274
|
+
if settings.registry:
|
|
275
|
+
argv.append(settings.registry)
|
|
276
|
+
code, _out, err = await subprocess.output(
|
|
277
|
+
argv=argv,
|
|
278
|
+
directory=directory,
|
|
279
|
+
environ=dict(environ),
|
|
280
|
+
timeout_seconds=CHECK_TIMEOUT_SECONDS,
|
|
281
|
+
what="docker login",
|
|
282
|
+
stdin=settings.password.get_secret_value().encode(),
|
|
283
|
+
)
|
|
284
|
+
return code, err
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
async def logout(binary: str, directory: Path, environ: Mapping[str, str], settings: DockerConnectionConfig) -> None:
|
|
288
|
+
"""Sign out again, so the credential does not outlive the step even in a private config directory."""
|
|
289
|
+
argv = [binary, "logout"]
|
|
290
|
+
if settings.registry:
|
|
291
|
+
argv.append(settings.registry)
|
|
292
|
+
with contextlib.suppress(BlockFailure, OSError):
|
|
293
|
+
await subprocess.output(
|
|
294
|
+
argv=argv,
|
|
295
|
+
directory=directory,
|
|
296
|
+
environ=dict(environ),
|
|
297
|
+
timeout_seconds=CHECK_TIMEOUT_SECONDS,
|
|
298
|
+
what="docker logout",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class Sealed(NamedTuple):
|
|
303
|
+
"""What a connection became for one invocation: an environment, and the strings never to log."""
|
|
304
|
+
|
|
305
|
+
environ: dict[str, str]
|
|
306
|
+
secrets: list[str]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@contextlib.contextmanager
|
|
310
|
+
def sealed(settings: DockerConnectionConfig | None, parent: Path, *, isolate_config: bool = False) -> Generator[Sealed]:
|
|
311
|
+
"""Materialise a connection's daemon and registry material, and take it away again.
|
|
312
|
+
|
|
313
|
+
The TLS triple becomes three 0600 files in a 0700 directory that ``DOCKER_CERT_PATH``
|
|
314
|
+
names, and a login gets a ``DOCKER_CONFIG`` directory of its own beside them. The tree
|
|
315
|
+
goes whether the step succeeded, failed, or was cancelled. Without a connection there is
|
|
316
|
+
nothing to materialise and the worker's own environment stands.
|
|
317
|
+
"""
|
|
318
|
+
if settings is None:
|
|
319
|
+
yield Sealed({}, [])
|
|
320
|
+
return
|
|
321
|
+
with secrets.private_directory(parent, "dirigent-docker-") as directory:
|
|
322
|
+
yield _materialise(settings, directory, isolate_config=isolate_config)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _materialise(settings: DockerConnectionConfig, directory: Path, *, isolate_config: bool) -> Sealed:
|
|
326
|
+
"""Write the connection's files and name the environment that points docker at them."""
|
|
327
|
+
environ: dict[str, str] = {}
|
|
328
|
+
hidden: list[str] = []
|
|
329
|
+
if settings.host:
|
|
330
|
+
environ["DOCKER_HOST"] = settings.host
|
|
331
|
+
if settings.tls_key is not None:
|
|
332
|
+
for name, pem in (("ca.pem", settings.tls_ca), ("cert.pem", settings.tls_cert), ("key.pem", settings.tls_key)):
|
|
333
|
+
assert pem is not None
|
|
334
|
+
secrets.write(directory / name, pem.get_secret_value().rstrip("\n") + "\n")
|
|
335
|
+
environ["DOCKER_TLS_VERIFY"] = "1"
|
|
336
|
+
environ["DOCKER_CERT_PATH"] = str(directory)
|
|
337
|
+
hidden.append(settings.tls_key.get_secret_value())
|
|
338
|
+
if isolate_config:
|
|
339
|
+
config = directory / "config"
|
|
340
|
+
config.mkdir(mode=stat.S_IRWXU)
|
|
341
|
+
write_cli_config(config)
|
|
342
|
+
environ["DOCKER_CONFIG"] = str(config)
|
|
343
|
+
if settings.password is not None:
|
|
344
|
+
hidden.append(settings.password.get_secret_value())
|
|
345
|
+
return Sealed(environ, hidden)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def daemon_environment(base: dict[str, str], material: Sealed) -> dict[str, str]:
|
|
349
|
+
"""Overlay a connection's variables on an environment, taking the worker's own daemon out of the way.
|
|
350
|
+
|
|
351
|
+
A connection that names a host names the whole daemon, so the worker's ``DOCKER_HOST`` and
|
|
352
|
+
its certificate path go rather than half of each surviving. A connection with only a
|
|
353
|
+
registry credential leaves the worker's daemon exactly as it was.
|
|
354
|
+
"""
|
|
355
|
+
kept = {name: value for name, value in base.items() if name not in DAEMON_ENV}
|
|
356
|
+
return {**(kept if "DOCKER_HOST" in material.environ else base), **material.environ}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def cli_plugin_dirs() -> list[Path]:
|
|
360
|
+
"""The worker's own docker CLI plugin directory, if it has one.
|
|
361
|
+
|
|
362
|
+
Docker Desktop installs ``buildx`` and ``compose`` under the user's ``~/.docker/cli-plugins``
|
|
363
|
+
and nowhere a run whose ``HOME`` is its work directory would look.
|
|
364
|
+
"""
|
|
365
|
+
base = Path(os.environ["DOCKER_CONFIG"]) if os.environ.get("DOCKER_CONFIG") else Path.home() / ".docker"
|
|
366
|
+
plugins = base / "cli-plugins"
|
|
367
|
+
return [plugins] if plugins.is_dir() else []
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def write_cli_config(directory: Path) -> None:
|
|
371
|
+
"""Write a docker config in ``directory`` that names the worker's plugin directories.
|
|
372
|
+
|
|
373
|
+
Nothing is written when the worker has no plugin directory of its own. The directory is
|
|
374
|
+
made 0700 if it is not there yet.
|
|
375
|
+
"""
|
|
376
|
+
dirs = cli_plugin_dirs()
|
|
377
|
+
if not dirs:
|
|
378
|
+
return
|
|
379
|
+
directory.mkdir(mode=stat.S_IRWXU, parents=True, exist_ok=True)
|
|
380
|
+
(directory / "config.json").write_text(json.dumps({"cliPluginsExtraDirs": [str(path) for path in dirs]}) + "\n")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def private_parent(ctx: StepContext) -> Path:
|
|
384
|
+
"""Where a connection's material is written: the run's own work directory."""
|
|
385
|
+
return ctx.work
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class DockerRunConfig(ShellVariables):
|
|
389
|
+
"""Which image to run, with what command, environment, mounts, and limits."""
|
|
390
|
+
|
|
391
|
+
image: str = Field(min_length=1)
|
|
392
|
+
"""The image reference, tag included; ``latest`` is assumed when none is given."""
|
|
393
|
+
|
|
394
|
+
argv: list[str] = Field(default_factory=list[str])
|
|
395
|
+
"""The command as an argument vector, which replaces the image's ``CMD``."""
|
|
396
|
+
|
|
397
|
+
command: Annotated[str | None, ShellString()] = None
|
|
398
|
+
"""The command as a shell string, run through ``/bin/sh -c`` inside the container.
|
|
399
|
+
|
|
400
|
+
Every ``${...}`` in it is rewritten by the engine to a variable it sets in the container's
|
|
401
|
+
environment, so a value that came from a webhook payload is one word the shell never
|
|
402
|
+
parses, however the reference was quoted -- and inside single quotes, which a shell keeps
|
|
403
|
+
literal, the command reads that variable's name rather than its value. Prefer ``argv``: it
|
|
404
|
+
involves no shell at all."""
|
|
405
|
+
|
|
406
|
+
workdir: str | None = None
|
|
407
|
+
"""The working directory inside the container, overriding the image's own."""
|
|
408
|
+
|
|
409
|
+
env: dict[str, str] = Field(default_factory=dict[str, str])
|
|
410
|
+
"""Variables set explicitly for this container."""
|
|
411
|
+
|
|
412
|
+
env_allowlist: list[str] = Field(default_factory=list[str])
|
|
413
|
+
"""Worker environment variables this container is allowed to inherit.
|
|
414
|
+
|
|
415
|
+
Never the instance's own ``DIRIGENT_*`` variables: those hold this instance's secrets,
|
|
416
|
+
and a container that could inherit them could print the envelope key into the run log."""
|
|
417
|
+
|
|
418
|
+
inputs: dict[str, str] = Field(default_factory=dict[str, str])
|
|
419
|
+
"""Files to stage into the read-only input mount, as ``name inside the mount -> storage URI``."""
|
|
420
|
+
|
|
421
|
+
outputs: dict[str, str] = Field(default_factory=dict[str, str])
|
|
422
|
+
"""Files the container writes to the output mount, as ``name inside the mount -> target``.
|
|
423
|
+
|
|
424
|
+
A target carrying a URI scheme is copied to storage, where it outlives the run. A target
|
|
425
|
+
without one is a path relative to the run's work directory on this worker, which is where
|
|
426
|
+
a later step's tool opens a file from: a build context, a compose file, a bind mount. Such
|
|
427
|
+
a path is never absolute and never climbs out of that directory."""
|
|
428
|
+
|
|
429
|
+
inputs_path: str = "/dirigent/inputs"
|
|
430
|
+
"""Where the staged inputs appear inside the container."""
|
|
431
|
+
|
|
432
|
+
outputs_path: str = "/dirigent/outputs"
|
|
433
|
+
"""Where the container is expected to write its declared outputs."""
|
|
434
|
+
|
|
435
|
+
network: str = "none"
|
|
436
|
+
"""The daemon's network mode. It defaults to ``none``: an image the pipeline named should
|
|
437
|
+
not reach the worker's network, or anything the worker can reach, unless the step says so."""
|
|
438
|
+
|
|
439
|
+
pull: bool = False
|
|
440
|
+
"""Pull the image before creating the container, rather than requiring it to be present."""
|
|
441
|
+
|
|
442
|
+
memory: Size | None = Field(default=None, ge=MINIMUM_MEMORY)
|
|
443
|
+
"""A hard memory limit, such as ``64mb``; the container is OOM-killed rather than the worker."""
|
|
444
|
+
|
|
445
|
+
cpus: float | None = Field(default=None, gt=0.0)
|
|
446
|
+
"""A CPU quota expressed the way ``docker run --cpus`` expresses it."""
|
|
447
|
+
|
|
448
|
+
nano_cpus: int | None = Field(default=None, gt=0)
|
|
449
|
+
"""The same quota expressed the way the daemon counts it, for a config that prefers exactness."""
|
|
450
|
+
|
|
451
|
+
pids_limit: int | None = Field(default=None, gt=0)
|
|
452
|
+
"""A cap on how many processes the container may create."""
|
|
453
|
+
|
|
454
|
+
connection: ConnectionRef | None = None
|
|
455
|
+
"""A ``docker`` connection naming the daemon this container runs on.
|
|
456
|
+
|
|
457
|
+
Absent, the container runs on whatever daemon the worker's own environment names. A
|
|
458
|
+
``docker.run`` and the ``docker.compose.down`` that tears its stack away should name the
|
|
459
|
+
same connection, so both address the same daemon."""
|
|
460
|
+
|
|
461
|
+
socket_path: str | None = None
|
|
462
|
+
"""The daemon socket, when neither the default nor ``DOCKER_HOST`` is right."""
|
|
463
|
+
|
|
464
|
+
api_timeout: Duration = Field(default=timedelta(seconds=60), gt=timedelta(0))
|
|
465
|
+
"""How long one call to the daemon may take. This is not the container's runtime: the
|
|
466
|
+
container runs under the step's deadline, which the engine owns and this block never waits on."""
|
|
467
|
+
|
|
468
|
+
pull_timeout: Duration = Field(default=timedelta(minutes=10), gt=timedelta(0))
|
|
469
|
+
"""How long a pull may take, which is a different order of magnitude from an API call."""
|
|
470
|
+
|
|
471
|
+
@model_validator(mode="after")
|
|
472
|
+
def _check_shape(self) -> "DockerRunConfig":
|
|
473
|
+
"""Reject a config that names two commands, two CPU quotas, or a mount that climbs out."""
|
|
474
|
+
if self.argv and self.command is not None:
|
|
475
|
+
raise ValueError(RUN_ONE_FORM.render())
|
|
476
|
+
if self.cpus is not None and self.nano_cpus is not None:
|
|
477
|
+
raise ValueError(RUN_ONE_CPU_FIELD.render())
|
|
478
|
+
if self.connection is not None and self.socket_path is not None:
|
|
479
|
+
raise ValueError(RUN_ONE_DAEMON.render())
|
|
480
|
+
for name in (*self.inputs, *self.outputs):
|
|
481
|
+
if not name or Path(name).is_absolute() or ".." in Path(name).parts:
|
|
482
|
+
raise ValueError(MOUNTED_FILE_STAYS_INSIDE.render())
|
|
483
|
+
for target in self.outputs.values():
|
|
484
|
+
if "://" in target:
|
|
485
|
+
continue
|
|
486
|
+
if Path(target).is_absolute() or ".." in Path(target).parts:
|
|
487
|
+
raise ValueError(OUTPUT_STAYS_INSIDE.render())
|
|
488
|
+
reject_reserved(self.env_allowlist)
|
|
489
|
+
return self
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
class DockerRunOutput(BlockModel):
|
|
493
|
+
"""What the container did, with its full streams and produced files addressable as artifacts."""
|
|
494
|
+
|
|
495
|
+
exit_code: int
|
|
496
|
+
|
|
497
|
+
stdout: str
|
|
498
|
+
"""The head of what the container printed, cut at the instance's inline capture size.
|
|
499
|
+
|
|
500
|
+
Verbatim, including the trailing newline a command like ``echo`` ends with."""
|
|
501
|
+
|
|
502
|
+
stderr: str
|
|
503
|
+
"""The head of what the container printed to stderr, cut the same way."""
|
|
504
|
+
|
|
505
|
+
stdout_uri: str
|
|
506
|
+
"""Where the whole of stdout was written; never truncated, whatever the field above holds."""
|
|
507
|
+
|
|
508
|
+
stderr_uri: str
|
|
509
|
+
"""Where the whole of stderr was written; never truncated either."""
|
|
510
|
+
|
|
511
|
+
stdout_bytes: int
|
|
512
|
+
"""How much the container printed to stdout altogether, inlined or not."""
|
|
513
|
+
|
|
514
|
+
stderr_bytes: int
|
|
515
|
+
"""How much it printed to stderr altogether."""
|
|
516
|
+
|
|
517
|
+
stdout_truncated: bool
|
|
518
|
+
"""Whether ``stdout`` above is short of the stream. Only the inline copy is ever cut."""
|
|
519
|
+
|
|
520
|
+
stderr_truncated: bool
|
|
521
|
+
"""Whether ``stderr`` above is short of the stream, which is an independent question."""
|
|
522
|
+
|
|
523
|
+
container_id: str
|
|
524
|
+
image: str
|
|
525
|
+
outputs: dict[str, str] = Field(default_factory=dict[str, str])
|
|
526
|
+
"""Where each declared output was written, by the name the config gave it: the storage URI,
|
|
527
|
+
or the path relative to the run's work directory it landed at."""
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
class DockerRunOperator(Operator[DockerRunConfig, DockerRunOutput]):
|
|
531
|
+
"""Starts a container on the worker and lets the engine probe it, behind the allowlist."""
|
|
532
|
+
|
|
533
|
+
spec = OperatorSpec(
|
|
534
|
+
id="docker.run",
|
|
535
|
+
group="execute",
|
|
536
|
+
summary="Run a container on the worker.",
|
|
537
|
+
idempotent=False,
|
|
538
|
+
local_execution=True,
|
|
539
|
+
default_poll=CONTAINER_POLL,
|
|
540
|
+
)
|
|
541
|
+
config_model: ClassVar[type[BaseModel]] = DockerRunConfig
|
|
542
|
+
output_model: ClassVar[type[BaseModel]] = DockerRunOutput
|
|
543
|
+
|
|
544
|
+
async def execute(self, config: DockerRunConfig, ctx: StepContext) -> DockerRunOutput | RemoteHandle:
|
|
545
|
+
"""Stage the inputs, create the container, start it, and hand back the claim on it."""
|
|
546
|
+
mounts = _mounts(config, ctx)
|
|
547
|
+
with _material(config, ctx) as environ:
|
|
548
|
+
async with _daemon(config, environ) as daemon:
|
|
549
|
+
if config.pull:
|
|
550
|
+
await daemon.pull(config.image, config.pull_timeout.total_seconds())
|
|
551
|
+
await _stage_inputs(config, ctx, mounts)
|
|
552
|
+
container = await daemon.create(_creation(config, ctx, mounts))
|
|
553
|
+
await daemon.start(container)
|
|
554
|
+
ctx.log.info("container started", container_id=container[:12], image=config.image, network=config.network)
|
|
555
|
+
meta = {"image": config.image}
|
|
556
|
+
if mounts.outputs is not None:
|
|
557
|
+
meta["outputs_dir"] = str(mounts.outputs)
|
|
558
|
+
return RemoteHandle(block_id=self.spec.id, ref=container, meta=meta)
|
|
559
|
+
|
|
560
|
+
async def probe(self, handle: RemoteHandle, config: DockerRunConfig, ctx: StepContext) -> ProbeResult:
|
|
561
|
+
"""Ask the daemon what the container is doing, and map its vocabulary onto a probe status."""
|
|
562
|
+
with _material(config, ctx) as environ:
|
|
563
|
+
async with _daemon(config, environ) as daemon:
|
|
564
|
+
inspected = await daemon.inspect(handle.ref)
|
|
565
|
+
if inspected is None:
|
|
566
|
+
return ProbeResult(
|
|
567
|
+
status=ProbeStatus.GONE,
|
|
568
|
+
message=f"the daemon no longer knows container {handle.ref[:12]}",
|
|
569
|
+
)
|
|
570
|
+
state = inspected.state
|
|
571
|
+
if state.running or state.status in UNFINISHED_STATES:
|
|
572
|
+
advanced = await _stream_since(daemon, handle, ctx)
|
|
573
|
+
return ProbeResult(status=ProbeStatus.RUNNING, message=state.status or "running", meta=advanced)
|
|
574
|
+
if state.exit_code == 0:
|
|
575
|
+
return ProbeResult(status=ProbeStatus.SUCCEEDED, message=state.status or "exited")
|
|
576
|
+
stdout, stderr = await drain_logs(daemon, handle.ref, ctx.inline_capture)
|
|
577
|
+
# A failed container settles here, and fetch is only ever called after a
|
|
578
|
+
# successful probe, so this is the last chance to take it off the worker.
|
|
579
|
+
await _discard(daemon, handle, ctx)
|
|
580
|
+
detail = tail(stderr.tail) or tail(stdout.tail) or state.error or "no output"
|
|
581
|
+
return ProbeResult(status=ProbeStatus.FAILED, message=f"the container exited {state.exit_code}: {detail}")
|
|
582
|
+
|
|
583
|
+
async def fetch(self, handle: RemoteHandle, config: DockerRunConfig, ctx: StepContext) -> DockerRunOutput:
|
|
584
|
+
"""Collect the exit code, the streams, and the declared files, then drop the container."""
|
|
585
|
+
artifacts = subprocess.prefix(ctx, "docker")
|
|
586
|
+
stdout_uri = f"{artifacts}-stdout.txt"
|
|
587
|
+
stderr_uri = f"{artifacts}-stderr.txt"
|
|
588
|
+
with _material(config, ctx) as environ:
|
|
589
|
+
async with _daemon(config, environ) as daemon:
|
|
590
|
+
inspected = await daemon.inspect(handle.ref)
|
|
591
|
+
if inspected is None:
|
|
592
|
+
raise BlockFailure(CONTAINER_GONE, error_class=ErrorClass.TRANSIENT, container=handle.ref[:12])
|
|
593
|
+
async with (
|
|
594
|
+
ctx.storage.open_write(stdout_uri) as out_sink,
|
|
595
|
+
ctx.storage.open_write(stderr_uri) as err_sink,
|
|
596
|
+
):
|
|
597
|
+
stdout, stderr = await drain_logs(
|
|
598
|
+
daemon, handle.ref, ctx.inline_capture, sinks=(out_sink, err_sink)
|
|
599
|
+
)
|
|
600
|
+
collected = await _collect_outputs(config, handle, ctx)
|
|
601
|
+
# The run log gets only what the probes have not already streamed: the segment
|
|
602
|
+
# between the last committed cursor and the exit. The full read above is for
|
|
603
|
+
# storage and the output, not for saying every line a second time.
|
|
604
|
+
fresh_out, fresh_err = await drain_logs(
|
|
605
|
+
daemon, handle.ref, ctx.inline_capture, since=handle.meta.get(LOGS_SINCE, "")
|
|
606
|
+
)
|
|
607
|
+
await _discard(daemon, handle, ctx)
|
|
608
|
+
|
|
609
|
+
log_stream(ctx, "stdout", fresh_out)
|
|
610
|
+
log_stream(ctx, "stderr", fresh_err)
|
|
611
|
+
ctx.log.info(
|
|
612
|
+
"container finished",
|
|
613
|
+
container_id=handle.ref[:12],
|
|
614
|
+
exit_code=inspected.state.exit_code,
|
|
615
|
+
stdout_bytes=stdout.total_bytes,
|
|
616
|
+
stderr_bytes=stderr.total_bytes,
|
|
617
|
+
)
|
|
618
|
+
printed = stdout.captured(ctx.inline_capture)
|
|
619
|
+
failed = stderr.captured(ctx.inline_capture)
|
|
620
|
+
return DockerRunOutput(
|
|
621
|
+
exit_code=inspected.state.exit_code,
|
|
622
|
+
stdout=printed.text,
|
|
623
|
+
stderr=failed.text,
|
|
624
|
+
stdout_uri=stdout_uri,
|
|
625
|
+
stderr_uri=stderr_uri,
|
|
626
|
+
stdout_bytes=printed.total_bytes,
|
|
627
|
+
stderr_bytes=failed.total_bytes,
|
|
628
|
+
stdout_truncated=printed.truncated,
|
|
629
|
+
stderr_truncated=failed.truncated,
|
|
630
|
+
container_id=handle.ref,
|
|
631
|
+
image=handle.meta.get("image", config.image),
|
|
632
|
+
outputs=collected,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
async def cancel(self, handle: RemoteHandle, config: DockerRunConfig, ctx: StepContext) -> bool:
|
|
636
|
+
"""Kill the container. A container that has already stopped is cancelled; one that is gone is not."""
|
|
637
|
+
try:
|
|
638
|
+
with _material(config, ctx) as environ:
|
|
639
|
+
async with _daemon(config, environ) as daemon:
|
|
640
|
+
killed = await daemon.kill(handle.ref)
|
|
641
|
+
await _discard(daemon, handle, ctx)
|
|
642
|
+
except (httpx2.HTTPError, OSError, BlockFailure) as error:
|
|
643
|
+
ctx.log.warning("the daemon could not be told to kill the container", error=str(error))
|
|
644
|
+
return False
|
|
645
|
+
ctx.log.info(
|
|
646
|
+
"container cancelled" if killed else "the container was already gone", container_id=handle.ref[:12]
|
|
647
|
+
)
|
|
648
|
+
return killed
|
|
649
|
+
|
|
650
|
+
def classify_error(self, error: Exception) -> ErrorClass:
|
|
651
|
+
"""A daemon that cannot be reached is transient; everything else keeps the default reading."""
|
|
652
|
+
if isinstance(error, OSError):
|
|
653
|
+
return ErrorClass.TRANSIENT
|
|
654
|
+
return classify_default(error)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
async def _discard(daemon: "DockerDaemon", handle: RemoteHandle, ctx: StepContext) -> None:
|
|
658
|
+
"""Take a settled container off the worker.
|
|
659
|
+
|
|
660
|
+
Every terminal path removes: a container that failed, one that was cancelled, and one
|
|
661
|
+
whose result was collected. A removal that does not work is logged and swallowed,
|
|
662
|
+
because the result is already in hand by the time this runs and must not be lost to a
|
|
663
|
+
container that would not delete.
|
|
664
|
+
"""
|
|
665
|
+
try:
|
|
666
|
+
await daemon.remove(handle.ref)
|
|
667
|
+
except (httpx2.HTTPError, OSError) as error:
|
|
668
|
+
ctx.log.warning("the container could not be removed", container_id=handle.ref[:12], error=str(error))
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
# -- the container's creation ----------------------------------------------------
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
class Mounts(BaseModel):
|
|
675
|
+
"""The local directories backing this attempt's mounts, when it asked for any."""
|
|
676
|
+
|
|
677
|
+
inputs: Path | None = None
|
|
678
|
+
outputs: Path | None = None
|
|
679
|
+
|
|
680
|
+
def binds(self, config: DockerRunConfig) -> list[JsonValue]:
|
|
681
|
+
"""Render the mounts the way the daemon's ``HostConfig.Binds`` wants them."""
|
|
682
|
+
binds: list[JsonValue] = []
|
|
683
|
+
if self.inputs is not None:
|
|
684
|
+
binds.append(f"{self.inputs}:{config.inputs_path}:ro")
|
|
685
|
+
if self.outputs is not None:
|
|
686
|
+
binds.append(f"{self.outputs}:{config.outputs_path}:rw")
|
|
687
|
+
return binds
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _mounts(config: DockerRunConfig, ctx: StepContext) -> Mounts:
|
|
691
|
+
"""Place this attempt's mounts inside the run's work directory, which a bind needs to be local."""
|
|
692
|
+
if not config.inputs and not config.outputs:
|
|
693
|
+
return Mounts()
|
|
694
|
+
workspace = ctx.work / subprocess.segment(ctx, "docker")
|
|
695
|
+
mounts = Mounts(
|
|
696
|
+
inputs=workspace / "inputs" if config.inputs else None,
|
|
697
|
+
outputs=workspace / "outputs" if config.outputs else None,
|
|
698
|
+
)
|
|
699
|
+
for directory in (mounts.inputs, mounts.outputs):
|
|
700
|
+
if directory is not None:
|
|
701
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
702
|
+
return mounts
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _creation(config: DockerRunConfig, ctx: StepContext, mounts: Mounts) -> dict[str, JsonValue]:
|
|
706
|
+
"""Build the ``/containers/create`` body: the command, the environment, and the containment."""
|
|
707
|
+
host: dict[str, JsonValue] = {
|
|
708
|
+
"NetworkMode": config.network,
|
|
709
|
+
"Binds": mounts.binds(config),
|
|
710
|
+
"AutoRemove": False,
|
|
711
|
+
"Memory": config.memory or 0,
|
|
712
|
+
"NanoCpus": _nano_cpus(config) or 0,
|
|
713
|
+
}
|
|
714
|
+
if config.pids_limit is not None:
|
|
715
|
+
host["PidsLimit"] = config.pids_limit
|
|
716
|
+
payload: dict[str, JsonValue] = {
|
|
717
|
+
"Image": config.image,
|
|
718
|
+
"Env": _environment(config),
|
|
719
|
+
"Tty": False,
|
|
720
|
+
"Labels": {"dirigent.run": str(ctx.run_id), "dirigent.attempt": str(ctx.attempt)},
|
|
721
|
+
"HostConfig": host,
|
|
722
|
+
}
|
|
723
|
+
command = _command(config)
|
|
724
|
+
if command is not None:
|
|
725
|
+
payload["Cmd"] = command
|
|
726
|
+
if config.workdir is not None:
|
|
727
|
+
payload["WorkingDir"] = config.workdir
|
|
728
|
+
return payload
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _command(config: DockerRunConfig) -> list[JsonValue] | None:
|
|
732
|
+
"""Resolve the command: the argument vector, the shell form, or the image's own entrypoint."""
|
|
733
|
+
if config.argv:
|
|
734
|
+
return list(config.argv)
|
|
735
|
+
if config.command is not None:
|
|
736
|
+
return [*SHELL_ARGV, config.command]
|
|
737
|
+
return None
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _environment(config: DockerRunConfig) -> list[JsonValue]:
|
|
741
|
+
"""Build the container's environment from an allowlist, never from wholesale inheritance.
|
|
742
|
+
|
|
743
|
+
The values the engine substituted out of the shell string come last, so a document cannot
|
|
744
|
+
override what a reference resolved to.
|
|
745
|
+
"""
|
|
746
|
+
inherited = allowed(config.env_allowlist)
|
|
747
|
+
built = {**inherited, **config.env, **config.shell_variables}
|
|
748
|
+
return [f"{name}={value}" for name, value in built.items()]
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _nano_cpus(config: DockerRunConfig) -> int | None:
|
|
752
|
+
"""Express whichever CPU quota the config gave the way the daemon counts them."""
|
|
753
|
+
if config.nano_cpus is not None:
|
|
754
|
+
return config.nano_cpus
|
|
755
|
+
if config.cpus is not None:
|
|
756
|
+
return round(config.cpus * NANO_CPUS_PER_CPU)
|
|
757
|
+
return None
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
# -- data across the boundary ----------------------------------------------------
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
async def _stage_inputs(config: DockerRunConfig, ctx: StepContext, mounts: Mounts) -> None:
|
|
764
|
+
"""Stream every declared input into the directory that becomes the read-only mount."""
|
|
765
|
+
if mounts.inputs is None:
|
|
766
|
+
return
|
|
767
|
+
for name, uri in config.inputs.items():
|
|
768
|
+
target = mounts.inputs / name
|
|
769
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
770
|
+
with target.open("wb") as handle:
|
|
771
|
+
async for chunk in ctx.storage.open_read(uri):
|
|
772
|
+
handle.write(chunk)
|
|
773
|
+
ctx.log.debug("input staged", name=name, uri=uri)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
async def _collect_outputs(config: DockerRunConfig, handle: RemoteHandle, ctx: StepContext) -> dict[str, str]:
|
|
777
|
+
"""Move every declared output out of the container's mount, to storage or to the work directory."""
|
|
778
|
+
if not config.outputs:
|
|
779
|
+
return {}
|
|
780
|
+
directory = Path(handle.meta["outputs_dir"])
|
|
781
|
+
collected: dict[str, str] = {}
|
|
782
|
+
for name, target in config.outputs.items():
|
|
783
|
+
produced = directory / name
|
|
784
|
+
if not produced.is_file():
|
|
785
|
+
raise BlockFailure(
|
|
786
|
+
OUTPUT_NOT_WRITTEN,
|
|
787
|
+
error_class=ErrorClass.REJECTED,
|
|
788
|
+
name=repr(name),
|
|
789
|
+
path=config.outputs_path,
|
|
790
|
+
)
|
|
791
|
+
if "://" in target:
|
|
792
|
+
written = 0
|
|
793
|
+
with produced.open("rb") as source:
|
|
794
|
+
async with ctx.storage.open_write(target) as sink:
|
|
795
|
+
while chunk := source.read(COPY_CHUNK_BYTES):
|
|
796
|
+
written += await sink.write(chunk)
|
|
797
|
+
ctx.log.info("output collected", name=name, uri=target, bytes_written=written)
|
|
798
|
+
else:
|
|
799
|
+
written = 0
|
|
800
|
+
destination = ctx.work / target
|
|
801
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
802
|
+
with produced.open("rb") as source, destination.open("wb") as sink:
|
|
803
|
+
while chunk := source.read(COPY_CHUNK_BYTES):
|
|
804
|
+
written += sink.write(chunk)
|
|
805
|
+
ctx.log.info("output collected", name=name, path=target, bytes_written=written)
|
|
806
|
+
collected[name] = target
|
|
807
|
+
return collected
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
async def _stream_since(daemon: "DockerDaemon", handle: RemoteHandle, ctx: StepContext) -> dict[str, str]:
|
|
811
|
+
"""Put what the container printed since the last probe into the run, and advance the cursor.
|
|
812
|
+
|
|
813
|
+
The cursor is a reading of the daemon's clock, which is this worker's: the container runs
|
|
814
|
+
here. It is taken before the read, so a line printed during the read is read again rather
|
|
815
|
+
than skipped, and a probe whose outcome never committed is read from again. Only the ends
|
|
816
|
+
of each stream are kept, so a chatty container cannot flood the run. This is the live view
|
|
817
|
+
of a stream still being written, and it may repeat a line or never reach the ones printed
|
|
818
|
+
between the last probe and the container exiting; fetch stores the whole of both streams.
|
|
819
|
+
"""
|
|
820
|
+
reached = f"{time.time():.9f}"
|
|
821
|
+
stdout, stderr = await drain_logs(daemon, handle.ref, ctx.inline_capture, since=handle.meta.get(LOGS_SINCE, ""))
|
|
822
|
+
log_stream(ctx, "stdout", stdout)
|
|
823
|
+
log_stream(ctx, "stderr", stderr)
|
|
824
|
+
return {**handle.meta, LOGS_SINCE: reached}
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
async def drain_logs(
|
|
828
|
+
daemon: "DockerDaemon",
|
|
829
|
+
container: str,
|
|
830
|
+
head_limit: int,
|
|
831
|
+
sinks: "tuple[ByteSink, ByteSink] | None" = None,
|
|
832
|
+
since: str = "",
|
|
833
|
+
) -> tuple[Drained, Drained]:
|
|
834
|
+
"""Read a container's logs as they arrive, keeping both ends of each stream.
|
|
835
|
+
|
|
836
|
+
With sinks, each stream is written through to storage as it is split, so a container that
|
|
837
|
+
printed more than the worker can hold is a file. Without them, only the ends are kept,
|
|
838
|
+
which is all a failure message needs. A ``since`` reads only what was printed after that
|
|
839
|
+
timestamp, which is what a probe following a running container asks for.
|
|
840
|
+
"""
|
|
841
|
+
frames = Frames()
|
|
842
|
+
out, err = Ends(head_limit), Ends(head_limit)
|
|
843
|
+
async with daemon.streamed_logs(container, since) as stream:
|
|
844
|
+
async for chunk in stream:
|
|
845
|
+
for is_stderr, body in frames.take(chunk):
|
|
846
|
+
ends = err if is_stderr else out
|
|
847
|
+
ends.add(body)
|
|
848
|
+
if sinks is not None:
|
|
849
|
+
await sinks[1 if is_stderr else 0].write(body)
|
|
850
|
+
for is_stderr, body in frames.rest():
|
|
851
|
+
ends = err if is_stderr else out
|
|
852
|
+
ends.add(body)
|
|
853
|
+
if sinks is not None:
|
|
854
|
+
await sinks[1 if is_stderr else 0].write(body)
|
|
855
|
+
return out.drained(), err.drained()
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
class Frames:
|
|
859
|
+
"""Docker's framed log stream, split as it arrives rather than once it has all arrived.
|
|
860
|
+
|
|
861
|
+
A frame is an eight-byte header -- a stream type byte, three zero bytes, then a big-endian
|
|
862
|
+
length -- and that many payload bytes. A chunk off the wire is not a frame, so what is
|
|
863
|
+
held is the part of one that has arrived and no more.
|
|
864
|
+
"""
|
|
865
|
+
|
|
866
|
+
def __init__(self) -> None:
|
|
867
|
+
"""Start with nothing buffered."""
|
|
868
|
+
self._buffer = bytearray()
|
|
869
|
+
|
|
870
|
+
def take(self, chunk: bytes) -> list[tuple[bool, bytes]]:
|
|
871
|
+
"""Take one chunk and return the frames it completed, each as (is_stderr, body)."""
|
|
872
|
+
self._buffer.extend(chunk)
|
|
873
|
+
found: list[tuple[bool, bytes]] = []
|
|
874
|
+
while len(self._buffer) >= STREAM_HEADER_BYTES:
|
|
875
|
+
length = int.from_bytes(self._buffer[4:STREAM_HEADER_BYTES], "big")
|
|
876
|
+
end = STREAM_HEADER_BYTES + length
|
|
877
|
+
if len(self._buffer) < end:
|
|
878
|
+
break
|
|
879
|
+
found.append((self._buffer[0] == STDERR_FRAME, bytes(self._buffer[STREAM_HEADER_BYTES:end])))
|
|
880
|
+
del self._buffer[:end]
|
|
881
|
+
return found
|
|
882
|
+
|
|
883
|
+
def rest(self) -> list[tuple[bool, bytes]]:
|
|
884
|
+
"""Return what a stream cut short left, which still carries the lines that explain why."""
|
|
885
|
+
if len(self._buffer) <= STREAM_HEADER_BYTES:
|
|
886
|
+
return []
|
|
887
|
+
body = bytes(self._buffer[STREAM_HEADER_BYTES:])
|
|
888
|
+
stderr = self._buffer[0] == STDERR_FRAME
|
|
889
|
+
self._buffer.clear()
|
|
890
|
+
return [(stderr, body)]
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def demultiplex(payload: bytes) -> tuple[bytes, bytes]:
|
|
894
|
+
"""Split Docker's framed log stream into the container's stdout and stderr.
|
|
895
|
+
|
|
896
|
+
A container created without a TTY has its output multiplexed onto one connection: each
|
|
897
|
+
frame is an eight-byte header -- a stream type byte, three zero bytes, then a big-endian
|
|
898
|
+
length -- followed by that many payload bytes. Anything that is not stderr counts as
|
|
899
|
+
stdout, and a truncated final frame contributes whatever arrived, because a stream cut
|
|
900
|
+
short still carries the lines that explain why.
|
|
901
|
+
"""
|
|
902
|
+
stdout: list[bytes] = []
|
|
903
|
+
stderr: list[bytes] = []
|
|
904
|
+
offset = 0
|
|
905
|
+
while offset + STREAM_HEADER_BYTES <= len(payload):
|
|
906
|
+
header = payload[offset : offset + STREAM_HEADER_BYTES]
|
|
907
|
+
length = int.from_bytes(header[4:STREAM_HEADER_BYTES], "big")
|
|
908
|
+
body = payload[offset + STREAM_HEADER_BYTES : offset + STREAM_HEADER_BYTES + length]
|
|
909
|
+
(stderr if header[0] == STDERR_FRAME else stdout).append(body)
|
|
910
|
+
if len(body) < length:
|
|
911
|
+
break
|
|
912
|
+
offset += STREAM_HEADER_BYTES + length
|
|
913
|
+
return b"".join(stdout), b"".join(stderr)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
# -- the daemon ------------------------------------------------------------------
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
class ContainerHealth(BaseModel):
|
|
920
|
+
"""A container's healthcheck verdict, when it declares one."""
|
|
921
|
+
|
|
922
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
923
|
+
|
|
924
|
+
status: str = Field(default="", alias="Status")
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
class ContainerState(BaseModel):
|
|
928
|
+
"""The part of a container's state the block reads, named the way the daemon names it."""
|
|
929
|
+
|
|
930
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
931
|
+
|
|
932
|
+
status: str = Field(default="", alias="Status")
|
|
933
|
+
running: bool = Field(default=False, alias="Running")
|
|
934
|
+
exit_code: int = Field(default=0, alias="ExitCode")
|
|
935
|
+
error: str = Field(default="", alias="Error")
|
|
936
|
+
oom_killed: bool = Field(default=False, alias="OOMKilled")
|
|
937
|
+
health: ContainerHealth = Field(default_factory=ContainerHealth, alias="Health")
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
class ContainerConfig(BaseModel):
|
|
941
|
+
"""The part of a container's ``Config`` the compose status read names it and its service by."""
|
|
942
|
+
|
|
943
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
944
|
+
|
|
945
|
+
image: str = Field(default="", alias="Image")
|
|
946
|
+
labels: dict[str, str] = Field(default_factory=dict[str, str], alias="Labels")
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
class PortBinding(BaseModel):
|
|
950
|
+
"""One host binding of a container port, as ``NetworkSettings.Ports`` maps it."""
|
|
951
|
+
|
|
952
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
953
|
+
|
|
954
|
+
host_ip: str = Field(default="", alias="HostIp")
|
|
955
|
+
host_port: str = Field(default="", alias="HostPort")
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
class NetworkSettings(BaseModel):
|
|
959
|
+
"""The part of ``NetworkSettings`` the compose status read draws ports and networks from."""
|
|
960
|
+
|
|
961
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
962
|
+
|
|
963
|
+
ports: dict[str, list[PortBinding] | None] = Field(
|
|
964
|
+
default_factory=dict[str, "list[PortBinding] | None"], alias="Ports"
|
|
965
|
+
)
|
|
966
|
+
networks: dict[str, JsonValue] = Field(default_factory=dict[str, JsonValue], alias="Networks")
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
class ContainerInspect(BaseModel):
|
|
970
|
+
"""The part of ``/containers/{id}/json`` the blocks read."""
|
|
971
|
+
|
|
972
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
973
|
+
|
|
974
|
+
id: str = Field(default="", alias="Id")
|
|
975
|
+
name: str = Field(default="", alias="Name")
|
|
976
|
+
state: ContainerState = Field(default_factory=ContainerState, alias="State")
|
|
977
|
+
config: ContainerConfig = Field(default_factory=ContainerConfig, alias="Config")
|
|
978
|
+
network_settings: NetworkSettings = Field(default_factory=NetworkSettings, alias="NetworkSettings")
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
class ListedContainer(BaseModel):
|
|
982
|
+
"""The part of ``/containers/json`` a listing reads: when it was created, and its labels."""
|
|
983
|
+
|
|
984
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
985
|
+
|
|
986
|
+
id: str = Field(default="", alias="Id")
|
|
987
|
+
created: int = Field(default=0, alias="Created")
|
|
988
|
+
"""The unix second the container was created at, which is the daemon's clock."""
|
|
989
|
+
|
|
990
|
+
labels: dict[str, str] = Field(default_factory=dict[str, str], alias="Labels")
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
class CreatedContainer(BaseModel):
|
|
994
|
+
"""What the daemon answers when it has created a container."""
|
|
995
|
+
|
|
996
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
997
|
+
|
|
998
|
+
id: str = Field(alias="Id")
|
|
999
|
+
warnings: list[str] = Field(default_factory=list[str], alias="Warnings")
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
class DockerDaemon:
|
|
1003
|
+
"""The slice of the Docker Engine API this block speaks."""
|
|
1004
|
+
|
|
1005
|
+
def __init__(self, client: httpx2.AsyncClient, socket: str | None = None) -> None:
|
|
1006
|
+
"""Bind to an already-built client, so a caller can supply its own."""
|
|
1007
|
+
self.client = client
|
|
1008
|
+
self.socket = socket
|
|
1009
|
+
|
|
1010
|
+
async def __aenter__(self) -> "DockerDaemon":
|
|
1011
|
+
"""Enter the client's lifetime."""
|
|
1012
|
+
return self
|
|
1013
|
+
|
|
1014
|
+
async def __aexit__(
|
|
1015
|
+
self,
|
|
1016
|
+
exc_type: type[BaseException] | None,
|
|
1017
|
+
exc: BaseException | None,
|
|
1018
|
+
traceback: TracebackType | None,
|
|
1019
|
+
) -> None:
|
|
1020
|
+
"""Close the connection to the socket, naming the daemon a connect error hides.
|
|
1021
|
+
|
|
1022
|
+
`ConnectError: [Errno 2] No such file or directory` is the whole of what httpx says
|
|
1023
|
+
about a socket that is not there, which is exactly what a worker in a container sees:
|
|
1024
|
+
it has no daemon of its own, and mounting the host's socket into it hands the
|
|
1025
|
+
container root on the host. The sentence a run shows has to say that.
|
|
1026
|
+
"""
|
|
1027
|
+
await self.client.aclose()
|
|
1028
|
+
if isinstance(exc, httpx2.ConnectError) and self.socket is not None:
|
|
1029
|
+
cause = exc.__cause__ if isinstance(exc.__cause__, OSError) else None
|
|
1030
|
+
missing = cause is not None and cause.errno == errno.ENOENT
|
|
1031
|
+
raise BlockFailure(
|
|
1032
|
+
NO_DAEMON,
|
|
1033
|
+
error_class=ErrorClass.REJECTED if missing else ErrorClass.TRANSIENT,
|
|
1034
|
+
socket=self.socket,
|
|
1035
|
+
detail=str(exc),
|
|
1036
|
+
) from exc
|
|
1037
|
+
|
|
1038
|
+
async def pull(self, image: str, timeout: float) -> None:
|
|
1039
|
+
"""Pull an image, reading the progress stream to completion so the pull actually finishes."""
|
|
1040
|
+
repository, tag = split_image(image)
|
|
1041
|
+
response = await self.client.post(
|
|
1042
|
+
"/images/create",
|
|
1043
|
+
params={"fromImage": repository, "tag": tag},
|
|
1044
|
+
timeout=timeout,
|
|
1045
|
+
)
|
|
1046
|
+
_require_ok(response, f"pull {image}")
|
|
1047
|
+
|
|
1048
|
+
async def create(self, payload: dict[str, JsonValue]) -> str:
|
|
1049
|
+
"""Create a container and return its id."""
|
|
1050
|
+
response = await self.client.post("/containers/create", json=payload)
|
|
1051
|
+
_require_ok(response, "create the container")
|
|
1052
|
+
return CreatedContainer.model_validate(response.json()).id
|
|
1053
|
+
|
|
1054
|
+
async def start(self, container: str) -> None:
|
|
1055
|
+
"""Start a created container, detached."""
|
|
1056
|
+
response = await self.client.post(f"/containers/{container}/start")
|
|
1057
|
+
if response.status_code == NOT_MODIFIED:
|
|
1058
|
+
return
|
|
1059
|
+
_require_ok(response, "start the container")
|
|
1060
|
+
|
|
1061
|
+
async def inspect(self, container: str) -> ContainerInspect | None:
|
|
1062
|
+
"""Describe a container, or report that the daemon no longer knows it."""
|
|
1063
|
+
response = await self.client.get(f"/containers/{container}/json")
|
|
1064
|
+
if response.status_code == NOT_FOUND:
|
|
1065
|
+
return None
|
|
1066
|
+
_require_ok(response, "inspect the container")
|
|
1067
|
+
return ContainerInspect.model_validate(response.json())
|
|
1068
|
+
|
|
1069
|
+
async def list_containers(self, filters: dict[str, list[str]]) -> list[str]:
|
|
1070
|
+
"""List the ids of every container matching a filter, stopped ones included.
|
|
1071
|
+
|
|
1072
|
+
Compose labels each of a project's containers with
|
|
1073
|
+
``com.docker.compose.project``, so a label filter is how the status read finds a
|
|
1074
|
+
stack's containers without the CLI.
|
|
1075
|
+
"""
|
|
1076
|
+
response = await self.client.get("/containers/json", params={"all": "1", "filters": json.dumps(filters)})
|
|
1077
|
+
_require_ok(response, "list the project's containers")
|
|
1078
|
+
listed = cast("list[dict[str, JsonValue]]", response.json())
|
|
1079
|
+
return [str(item["Id"]) for item in listed if item.get("Id")]
|
|
1080
|
+
|
|
1081
|
+
async def list_labelled(self, filters: dict[str, list[str]]) -> list[ListedContainer]:
|
|
1082
|
+
"""List every container matching a filter with its labels and its creation time.
|
|
1083
|
+
|
|
1084
|
+
The reaper groups these by compose project, which is a label, and ages each project by
|
|
1085
|
+
the newest container in it.
|
|
1086
|
+
"""
|
|
1087
|
+
response = await self.client.get("/containers/json", params={"all": "1", "filters": json.dumps(filters)})
|
|
1088
|
+
_require_ok(response, "list the daemon's containers")
|
|
1089
|
+
return [ListedContainer.model_validate(item) for item in cast("list[JsonValue]", response.json())]
|
|
1090
|
+
|
|
1091
|
+
@asynccontextmanager
|
|
1092
|
+
async def streamed_logs(self, container: str, since: str = "") -> AsyncGenerator[AsyncIterator[bytes]]:
|
|
1093
|
+
"""Hand over the container's log stream in pieces, framed as the daemon sends it.
|
|
1094
|
+
|
|
1095
|
+
A container that printed more than the worker can hold is still a container whose
|
|
1096
|
+
output belongs in storage, so nothing here reads it whole. A ``since`` -- a unix
|
|
1097
|
+
timestamp -- narrows the read to what was printed after it.
|
|
1098
|
+
"""
|
|
1099
|
+
params = {"stdout": "1", "stderr": "1", "tail": "all"}
|
|
1100
|
+
if since:
|
|
1101
|
+
params["since"] = since
|
|
1102
|
+
request = self.client.build_request("GET", f"/containers/{container}/logs", params=params)
|
|
1103
|
+
response = await self.client.send(request, stream=True)
|
|
1104
|
+
try:
|
|
1105
|
+
if response.status_code == NOT_FOUND:
|
|
1106
|
+
yield _nothing()
|
|
1107
|
+
return
|
|
1108
|
+
_require_ok(response, "read the container's logs")
|
|
1109
|
+
yield response.aiter_bytes()
|
|
1110
|
+
finally:
|
|
1111
|
+
await response.aclose()
|
|
1112
|
+
|
|
1113
|
+
async def kill(self, container: str) -> bool:
|
|
1114
|
+
"""Kill a container; a container that has already stopped counts as killed, a missing one does not."""
|
|
1115
|
+
response = await self.client.post(f"/containers/{container}/kill")
|
|
1116
|
+
if response.status_code == NOT_FOUND:
|
|
1117
|
+
return False
|
|
1118
|
+
if response.status_code == CONFLICT:
|
|
1119
|
+
return True
|
|
1120
|
+
_require_ok(response, "kill the container")
|
|
1121
|
+
return True
|
|
1122
|
+
|
|
1123
|
+
async def remove(self, container: str) -> None:
|
|
1124
|
+
"""Remove a container and its anonymous volumes, best effort.
|
|
1125
|
+
|
|
1126
|
+
The status is deliberately not checked: the result has already been collected by the
|
|
1127
|
+
time this runs, and a result must not be lost to a container that would not delete.
|
|
1128
|
+
"""
|
|
1129
|
+
await self.client.delete(f"/containers/{container}", params={"v": "1", "force": "1"})
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
class DaemonEndpoint(NamedTuple):
|
|
1133
|
+
"""Where the daemon is and how to speak to it, resolved from the config and the environment.
|
|
1134
|
+
|
|
1135
|
+
A ``uds`` names a local unix socket the client speaks plain HTTP over; otherwise ``base_url``
|
|
1136
|
+
is a ``tcp://`` daemon reached as ``http(s)://host:port``, with ``verify`` and ``cert``
|
|
1137
|
+
carrying the TLS material when the environment asks for it. ``socket`` is the unix path a
|
|
1138
|
+
connect error names, and is ``None`` for a tcp daemon.
|
|
1139
|
+
"""
|
|
1140
|
+
|
|
1141
|
+
base_url: str
|
|
1142
|
+
uds: str | None
|
|
1143
|
+
verify: bool | str
|
|
1144
|
+
cert: tuple[str, str] | None
|
|
1145
|
+
socket: str | None
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def resolve_endpoint(explicit_socket: str | None, environ: Mapping[str, str] | None = None) -> DaemonEndpoint:
|
|
1149
|
+
"""Resolve the daemon: an explicit socket, then ``DOCKER_HOST`` (unix or tcp+TLS), else the default.
|
|
1150
|
+
|
|
1151
|
+
A ``tcp://`` ``DOCKER_HOST`` points the worker at a daemon of its own -- a docker-in-docker
|
|
1152
|
+
sidecar, say -- rather than the host's socket, which is the deployment that keeps a
|
|
1153
|
+
pipeline's containers off the host. ``DOCKER_TLS_VERIFY`` with ``DOCKER_CERT_PATH`` turns
|
|
1154
|
+
that into https with client certificates, the way the docker CLI reads the same variables.
|
|
1155
|
+
|
|
1156
|
+
``environ`` is what a step with a ``docker`` connection reads instead of the worker's own.
|
|
1157
|
+
"""
|
|
1158
|
+
source = os.environ if environ is None else environ
|
|
1159
|
+
if explicit_socket:
|
|
1160
|
+
return DaemonEndpoint(DAEMON_BASE_URL, explicit_socket, True, None, explicit_socket)
|
|
1161
|
+
host = source.get("DOCKER_HOST", "")
|
|
1162
|
+
if host.startswith("tcp://"):
|
|
1163
|
+
secure = source.get("DOCKER_TLS_VERIFY", "") not in ("", "0")
|
|
1164
|
+
authority = host.removeprefix("tcp://")
|
|
1165
|
+
base_url = f"{'https' if secure else 'http'}://{authority}"
|
|
1166
|
+
verify: bool | str = True
|
|
1167
|
+
cert: tuple[str, str] | None = None
|
|
1168
|
+
cert_path = source.get("DOCKER_CERT_PATH", "")
|
|
1169
|
+
if secure and cert_path:
|
|
1170
|
+
verify = str(Path(cert_path) / "ca.pem")
|
|
1171
|
+
cert = (str(Path(cert_path) / "cert.pem"), str(Path(cert_path) / "key.pem"))
|
|
1172
|
+
return DaemonEndpoint(base_url, None, verify, cert, None)
|
|
1173
|
+
if host.startswith("unix://"):
|
|
1174
|
+
socket = host.removeprefix("unix://") or DEFAULT_SOCKET_PATH
|
|
1175
|
+
return DaemonEndpoint(DAEMON_BASE_URL, socket, True, None, socket)
|
|
1176
|
+
return DaemonEndpoint(DAEMON_BASE_URL, DEFAULT_SOCKET_PATH, True, None, DEFAULT_SOCKET_PATH)
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def socket_path(config: DockerRunConfig) -> str:
|
|
1180
|
+
"""The unix socket a real-daemon check looks for; the default stands in for a tcp daemon."""
|
|
1181
|
+
return resolve_endpoint(config.socket_path).uds or DEFAULT_SOCKET_PATH
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def open_client(endpoint: DaemonEndpoint, timeout: float) -> httpx2.AsyncClient:
|
|
1185
|
+
"""Open a client onto a resolved endpoint: the unix socket, or the tcp daemon with its TLS."""
|
|
1186
|
+
if endpoint.uds is not None:
|
|
1187
|
+
return httpx2.AsyncClient(
|
|
1188
|
+
base_url=endpoint.base_url,
|
|
1189
|
+
transport=httpx2.AsyncHTTPTransport(uds=endpoint.uds),
|
|
1190
|
+
timeout=timeout,
|
|
1191
|
+
)
|
|
1192
|
+
return httpx2.AsyncClient(base_url=endpoint.base_url, verify=_tls(endpoint), timeout=timeout)
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def _tls(endpoint: DaemonEndpoint) -> ssl.SSLContext | bool | str:
|
|
1196
|
+
"""Fold the CA and the client chain into one context, which is how httpx2 takes a client certificate."""
|
|
1197
|
+
if endpoint.cert is None or not isinstance(endpoint.verify, str):
|
|
1198
|
+
return endpoint.verify
|
|
1199
|
+
context = ssl.create_default_context(cafile=endpoint.verify)
|
|
1200
|
+
context.load_cert_chain(*endpoint.cert)
|
|
1201
|
+
return context
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def connect(config: DockerRunConfig, environ: Mapping[str, str] | None = None) -> httpx2.AsyncClient:
|
|
1205
|
+
"""Open a client onto the daemon this config resolves to."""
|
|
1206
|
+
return open_client(resolve_endpoint(config.socket_path, environ), config.api_timeout.total_seconds())
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def split_image(image: str) -> tuple[str, str]:
|
|
1210
|
+
"""Split an image reference into its repository and tag, defaulting to ``latest``."""
|
|
1211
|
+
repository, separator, tag = image.rpartition(":")
|
|
1212
|
+
if separator and "/" not in tag:
|
|
1213
|
+
return repository, tag
|
|
1214
|
+
return image, "latest"
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
async def _nothing() -> AsyncIterator[bytes]:
|
|
1218
|
+
"""An empty stream, for a container the daemon no longer knows."""
|
|
1219
|
+
return
|
|
1220
|
+
yield b"" # pragma: no cover - unreachable, and what makes this a generator
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def _daemon(config: DockerRunConfig, environ: Mapping[str, str] | None = None) -> DockerDaemon:
|
|
1224
|
+
"""Build the daemon facade one call uses."""
|
|
1225
|
+
return DockerDaemon(connect(config, environ), resolve_endpoint(config.socket_path, environ).socket)
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
@contextlib.contextmanager
|
|
1229
|
+
def _material(config: DockerRunConfig, ctx: StepContext) -> Generator[dict[str, str]]:
|
|
1230
|
+
"""Resolve the step's connection into an environment, and take its files away afterwards.
|
|
1231
|
+
|
|
1232
|
+
Every entry point does this for itself -- execute, probe, fetch and cancel are four calls
|
|
1233
|
+
with nothing shared between them -- so a client key is on the worker's filesystem only for
|
|
1234
|
+
the length of one call, whichever way that call leaves.
|
|
1235
|
+
"""
|
|
1236
|
+
settings = ctx.connection(config.connection, DockerConnectionConfig) if config.connection else None
|
|
1237
|
+
with sealed(settings, private_parent(ctx)) as sealed_material:
|
|
1238
|
+
yield daemon_environment(dict(os.environ), sealed_material)
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def _require_ok(response: httpx2.Response, action: str) -> None:
|
|
1242
|
+
"""Turn a daemon error into a classified block failure, carrying the message it gave."""
|
|
1243
|
+
if response.status_code < BAD_REQUEST:
|
|
1244
|
+
return
|
|
1245
|
+
raise BlockFailure(
|
|
1246
|
+
DAEMON_REFUSED,
|
|
1247
|
+
error_class=status_class(response.status_code),
|
|
1248
|
+
action=action,
|
|
1249
|
+
detail=_daemon_message(response),
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def _daemon_message(response: httpx2.Response) -> str:
|
|
1254
|
+
"""Read the daemon's own explanation, which it puts in a JSON ``message`` field."""
|
|
1255
|
+
body: object = None
|
|
1256
|
+
with contextlib.suppress(ValueError):
|
|
1257
|
+
body = response.json()
|
|
1258
|
+
if isinstance(body, dict):
|
|
1259
|
+
message = cast("dict[str, object]", body).get("message")
|
|
1260
|
+
if isinstance(message, str):
|
|
1261
|
+
return message
|
|
1262
|
+
return response.text[:ERROR_DETAIL_CHARS].strip() or f"HTTP {response.status_code}"
|