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.
@@ -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}"