dirigent-blocks 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,668 @@
1
+ """``docker.compose.up`` / ``docker.compose.down``: run a whole compose stack for a run.
2
+
3
+ The pair is a hybrid. Compose has no Engine HTTP API, so both blocks shell out to the
4
+ ``docker compose`` CLI -- only the CLI can orchestrate a stack. The state ``up`` reports,
5
+ though, comes from the Engine HTTP API the same way ``docker.run`` reads a container: compose
6
+ labels every container ``com.docker.compose.project=<project>``, so the block lists the
7
+ project's containers by that label and inspects each into a structured ``ComposeService`` --
8
+ state, health, published ports and the network a later step joins -- rather than parsing CLI
9
+ text.
10
+
11
+ Reaching the Docker daemon is reaching root on the host when the daemon is the host's own, so
12
+ both blocks declare ``local_execution`` and the engine refuses them unless the instance
13
+ allowlists their ids. The daemon is whatever ``DOCKER_HOST`` names, or the one a ``docker``
14
+ connection names where the step carries one; a worker with its own daemon keeps a pipeline's
15
+ containers off the host. A stack exists only on the daemon that created it, so the ``up``, the
16
+ steps that drive it and the ``down`` all have to name the same one.
17
+
18
+ The lifecycle is two steps. ``docker.compose.up`` brings a stack up detached and it
19
+ **persists** for the run, so later steps drive it; a separate ``docker.compose.down`` step
20
+ with ``rule: all_done`` tears it down whether the drive step passed or failed. Both default
21
+ their project name deterministically from the run id, so the ``down`` addresses the same
22
+ project the ``up`` created with no wiring from the author. A bring-up that does not succeed --
23
+ a non-zero exit, a timeout, a cancelled step -- is the one case a block cleans up itself,
24
+ because a half-built stack should never be left behind.
25
+ """
26
+
27
+ import asyncio
28
+ import contextlib
29
+ from collections.abc import Mapping
30
+ from datetime import timedelta
31
+ from pathlib import Path
32
+ from typing import ClassVar, Literal
33
+
34
+ import httpx2
35
+ from pydantic import BaseModel, Field, model_validator
36
+
37
+ from dirigent_blocks import subprocess
38
+ from dirigent_blocks.capture import log_stream, tail
39
+ from dirigent_blocks.docker import (
40
+ DAEMON_ENV,
41
+ ContainerInspect,
42
+ DockerConnectionConfig,
43
+ DockerDaemon,
44
+ daemon_environment,
45
+ open_client,
46
+ resolve_endpoint,
47
+ sealed,
48
+ )
49
+ from dirigent_blocks.environment import reject_reserved
50
+ from dirigent_common import BlockModel, Duration
51
+ from dirigent_plugin import (
52
+ BlockFailure,
53
+ ConnectionRef,
54
+ ErrorClass,
55
+ Operator,
56
+ OperatorSpec,
57
+ RemoteHandle,
58
+ StepContext,
59
+ )
60
+
61
+ #: The label compose stamps on every container of a project, which is how the status read finds them.
62
+ PROJECT_LABEL = "com.docker.compose.project"
63
+
64
+ #: The label carrying a container's service name in the compose file.
65
+ SERVICE_LABEL = "com.docker.compose.service"
66
+
67
+ #: The deadline on the teardown of a bring-up that failed or was abandoned, so a step that has
68
+ #: already lost its own deadline, or been cancelled, cannot hang on the cleanup.
69
+ CLEANUP_TIMEOUT_SECONDS = 120.0
70
+
71
+ #: What a daemon that is not there or not ours says, a transient condition rather than a fault
72
+ #: in the stack: a worker has no daemon of its own unless the deployment gives it one.
73
+ DAEMON_UNREACHABLE = ("cannot connect to the docker daemon", "is the docker daemon running", "permission denied")
74
+
75
+
76
+ class ComposePublisher(BlockModel):
77
+ """One host port a service published."""
78
+
79
+ url: str = ""
80
+ """The address the port was bound on, often ``0.0.0.0`` or a specific host."""
81
+
82
+ target_port: int = 0
83
+ """The port inside the container."""
84
+
85
+ published_port: int = 0
86
+ """The port on the host it was mapped to."""
87
+
88
+ protocol: str = ""
89
+ """``tcp`` or ``udp``."""
90
+
91
+
92
+ class ComposeService(BlockModel):
93
+ """One service's container, as the Engine API reports it."""
94
+
95
+ name: str
96
+ """The container's name, which is what the daemon knows it by."""
97
+
98
+ service: str
99
+ """The service's name in the compose file."""
100
+
101
+ image: str = ""
102
+ """The image the container runs."""
103
+
104
+ state: str = ""
105
+ """The container's state: ``running``, ``exited``, and so on."""
106
+
107
+ health: str = ""
108
+ """The healthcheck's verdict when the service declares one, else empty."""
109
+
110
+ exit_code: int = 0
111
+ """The container's exit code, meaningful once it has stopped."""
112
+
113
+ publishers: list[ComposePublisher] = Field(default_factory=list[ComposePublisher])
114
+ """The host ports the service published, if any."""
115
+
116
+
117
+ # -- shared configuration --------------------------------------------------------
118
+
119
+
120
+ class DockerComposeUpConfig(BlockModel):
121
+ """Which compose file to bring up, under what project, with which flags."""
122
+
123
+ file: str | None = None
124
+ """The compose file, as a path inside the run's scratch space; never absolute, never climbing out.
125
+
126
+ Exactly one of ``file`` or ``content`` is given. This form is for a file an upstream step
127
+ produced, such as a ``storage.copy`` into scratch."""
128
+
129
+ content: str | None = None
130
+ """The compose file inline, written into scratch before the CLI runs.
131
+
132
+ Exactly one of ``file`` or ``content`` is given. This form keeps a small stack in the
133
+ pipeline document itself."""
134
+
135
+ project_name: str | None = None
136
+ """The compose project (``-p``). Defaults to a deterministic name derived from the run id,
137
+
138
+ so this ``up``, the steps that drive it, and a later ``down`` all address the same project,
139
+ and two runs never collide."""
140
+
141
+ profiles: list[str] = Field(default_factory=list[str])
142
+ """Compose profiles to activate (``--profile``)."""
143
+
144
+ env_files: list[str] = Field(default_factory=list[str])
145
+ """Env files for compose to read, as paths inside the run's scratch space (``--env-file``)."""
146
+
147
+ env: dict[str, str] = Field(default_factory=dict[str, str])
148
+ """Variables set for the CLI itself, such as those a compose file interpolates."""
149
+
150
+ env_allowlist: list[str] = Field(default_factory=list[str])
151
+ """Worker environment variables the CLI is allowed to inherit.
152
+
153
+ Never the instance's own ``DIRIGENT_*`` variables: those hold this instance's secrets."""
154
+
155
+ wait: bool = False
156
+ """Wait until every service is running and healthy before the step returns (``--wait``)."""
157
+
158
+ wait_timeout: Duration = timedelta(seconds=300)
159
+ """How long ``--wait`` may wait before it gives up."""
160
+
161
+ remove_orphans: bool = True
162
+ """Remove containers for services no longer in the compose file (``--remove-orphans``)."""
163
+
164
+ pull: Literal["always", "missing", "never"] = "missing"
165
+ """When to pull images: ``missing`` pulls only what is absent, the compose default."""
166
+
167
+ cleanup: bool = True
168
+ """On a bring-up that does not succeed, best-effort ``down`` the same project before leaving.
169
+
170
+ A non-zero exit, a timeout and a cancelled step all leave containers compose had already
171
+ started. This never tears down a *successful* ``up`` -- that persists for the run by
172
+ design."""
173
+
174
+ command_path: list[str] = Field(default_factory=lambda: ["docker", "compose"])
175
+ """The CLI to invoke, for a host that spells it ``docker-compose`` or wraps it."""
176
+
177
+ connection: ConnectionRef | None = None
178
+ """A ``docker`` connection naming the daemon this stack runs on.
179
+
180
+ Absent, the stack runs on whatever daemon the worker's own environment names. The ``up``,
181
+ the steps that drive it and the ``down`` should all name the same connection, because a
182
+ stack only exists on the daemon that was told to create it."""
183
+
184
+ socket_path: str | None = None
185
+ """The daemon socket the status read speaks to, when neither the default nor ``DOCKER_HOST`` is right."""
186
+
187
+ api_timeout: Duration = Field(default=timedelta(seconds=60), gt=timedelta(0))
188
+ """How long one call to the daemon for the status read may take."""
189
+
190
+ timeout: Duration = timedelta(minutes=10)
191
+ """The overall deadline on the CLI invocation, after which it is killed as transient."""
192
+
193
+ @model_validator(mode="after")
194
+ def _check_shape(self) -> "DockerComposeUpConfig":
195
+ """Reject a config that names both compose-file forms or neither, or a path that climbs out."""
196
+ if bool(self.file) == bool(self.content):
197
+ raise ValueError("docker.compose.up takes either file or content, and exactly one of them")
198
+ _reject_escaping((self.file, *self.env_files))
199
+ reject_reserved(self.env_allowlist)
200
+ return self
201
+
202
+
203
+ class DockerComposeDownConfig(BlockModel):
204
+ """Which project to tear down, and how thoroughly."""
205
+
206
+ project_name: str | None = None
207
+ """The compose project (``-p``). Defaults to the same deterministic name ``up`` derives from
208
+
209
+ the run id, so a teardown step in the same run addresses the stack the ``up`` created with
210
+ no wiring from the author."""
211
+
212
+ file: str | None = None
213
+ """A compose file, only if the CLI needs ``-f`` to resolve the project; a project tears down
214
+
215
+ by its label alone otherwise. As a path inside the run's scratch space."""
216
+
217
+ content: str | None = None
218
+ """A compose file inline, written to scratch, for the same reason as ``file``."""
219
+
220
+ profiles: list[str] = Field(default_factory=list[str])
221
+ """Compose profiles to activate, only meaningful alongside a ``file``."""
222
+
223
+ env_files: list[str] = Field(default_factory=list[str])
224
+ """Env files for compose to read, as paths inside the run's scratch space (``--env-file``)."""
225
+
226
+ env: dict[str, str] = Field(default_factory=dict[str, str])
227
+ """Variables set for the CLI itself, such as those a compose file interpolates."""
228
+
229
+ env_allowlist: list[str] = Field(default_factory=list[str])
230
+ """Worker environment variables the CLI is allowed to inherit; never the instance's ``DIRIGENT_*``."""
231
+
232
+ down_volumes: bool = False
233
+ """Also remove the named volumes the stack declared (``-v``)."""
234
+
235
+ down_timeout: Duration = timedelta(seconds=10)
236
+ """How long to wait for a container to stop before killing it (``--timeout``)."""
237
+
238
+ down_remove_images: Literal["none", "local", "all"] = "none"
239
+ """Which images to remove: ``none``, ``local`` (only untagged), or ``all`` (``--rmi``)."""
240
+
241
+ remove_orphans: bool = True
242
+ """Remove containers for services no longer in the compose file (``--remove-orphans``)."""
243
+
244
+ connection: ConnectionRef | None = None
245
+ """A ``docker`` connection naming the daemon this stack runs on.
246
+
247
+ Absent, the stack runs on whatever daemon the worker's own environment names. The ``up``,
248
+ the steps that drive it and the ``down`` should all name the same connection, because a
249
+ stack only exists on the daemon that was told to create it."""
250
+
251
+ command_path: list[str] = Field(default_factory=lambda: ["docker", "compose"])
252
+ """The CLI to invoke, for a host that spells it ``docker-compose`` or wraps it."""
253
+
254
+ timeout: Duration = timedelta(minutes=10)
255
+ """The overall deadline on the CLI invocation, after which it is killed as transient."""
256
+
257
+ @model_validator(mode="after")
258
+ def _check_shape(self) -> "DockerComposeDownConfig":
259
+ """Reject two compose-file forms at once, or a path that climbs out of scratch."""
260
+ if self.file and self.content:
261
+ raise ValueError("docker.compose.down takes at most one of file or content")
262
+ _reject_escaping((self.file, *self.env_files))
263
+ reject_reserved(self.env_allowlist)
264
+ return self
265
+
266
+
267
+ class DockerComposeUpOutput(BlockModel):
268
+ """What the stack looks like once it is up, from the project's own containers."""
269
+
270
+ project: str
271
+ """The compose project brought up."""
272
+
273
+ services: list[ComposeService] = Field(default_factory=list[ComposeService])
274
+ """Every service's container, from the Engine API."""
275
+
276
+ networks: list[str] = Field(default_factory=list[str])
277
+ """The docker networks the project's containers are attached to, by the names the daemon knows."""
278
+
279
+ default_network: str
280
+ """The project's default network, which a downstream ``docker.run`` names to join the stack."""
281
+
282
+ up: bool = True
283
+ """Always true: the step returns only once the stack is up."""
284
+
285
+ compose_file: str
286
+ """The compose file the CLI was given, as a path inside the run's scratch space.
287
+
288
+ A later step that needs the same document -- a ``docker.compose.down`` of a stack this step
289
+ brought up from inline content -- passes this back as its own ``file``."""
290
+
291
+ stdout_uri: str
292
+ """Where the whole of the CLI's stdout was written."""
293
+
294
+ stderr_uri: str
295
+ """Where the whole of the CLI's stderr was written."""
296
+
297
+
298
+ class DockerComposeDownOutput(BlockModel):
299
+ """What the teardown did."""
300
+
301
+ project: str
302
+ """The compose project torn down."""
303
+
304
+ torn_down: bool = True
305
+ """Always true: the step returns only once the stack is down."""
306
+
307
+ stdout_uri: str
308
+ """Where the whole of the CLI's stdout was written."""
309
+
310
+ stderr_uri: str
311
+ """Where the whole of the CLI's stderr was written."""
312
+
313
+
314
+ # -- the operators, thin over the shared core ------------------------------------
315
+
316
+
317
+ class DockerComposeUpOperator(Operator[DockerComposeUpConfig, DockerComposeUpOutput]):
318
+ """Brings a compose stack up detached and reports its state over the API, behind the allowlist."""
319
+
320
+ spec = OperatorSpec(
321
+ id="docker.compose.up",
322
+ group="execute",
323
+ summary="Bring a compose stack up on the worker.",
324
+ idempotent=False,
325
+ local_execution=True,
326
+ )
327
+ config_model: ClassVar[type[BaseModel]] = DockerComposeUpConfig
328
+ output_model: ClassVar[type[BaseModel]] = DockerComposeUpOutput
329
+
330
+ async def execute(self, config: DockerComposeUpConfig, ctx: StepContext) -> DockerComposeUpOutput | RemoteHandle:
331
+ """Bring the stack up, clean up a bring-up that did not succeed, and report the project's state."""
332
+ root = _workspace(ctx)
333
+ compose_file = _compose_file(config.file, config.content, ctx, root)
334
+ assert compose_file is not None
335
+ project = config.project_name or _default_project(ctx)
336
+ timeout = config.timeout.total_seconds()
337
+ stdout_uri, stderr_uri = _artifact_uris(ctx, "up")
338
+
339
+ with sealed(_connection(config.connection, ctx), root) as material:
340
+ environ = daemon_environment(
341
+ subprocess.environment([*DAEMON_ENV, *config.env_allowlist], config.env, root), material
342
+ )
343
+ try:
344
+ code, out, err = await subprocess.run(
345
+ directory=root,
346
+ ctx=ctx,
347
+ stdout_uri=stdout_uri,
348
+ stderr_uri=stderr_uri,
349
+ timeout_seconds=timeout,
350
+ environ=environ,
351
+ argv=up_argv(config, project, compose_file, root),
352
+ what="docker compose up",
353
+ )
354
+ except BaseException:
355
+ # The bring-up timed out or the step was cancelled, and whatever compose had
356
+ # already started is running: tear it down before leaving, the same as a
357
+ # non-zero exit does.
358
+ if config.cleanup:
359
+ await _abandoned(config, project, compose_file, root, environ, ctx)
360
+ raise
361
+ log_stream(ctx, "stdout", out)
362
+ log_stream(ctx, "stderr", err)
363
+ ctx.log.info("docker compose up finished", project=project, exit_code=code)
364
+ if code != 0:
365
+ if config.cleanup:
366
+ await _cleanup(config, project, compose_file, root, environ, CLEANUP_TIMEOUT_SECONDS, ctx)
367
+ detail = tail(err.tail) or tail(out.tail) or "no output"
368
+ raise BlockFailure(f"docker compose up exited {code}: {detail}", error_class=_classify(err.tail))
369
+
370
+ services, networks, default_network = await read_status(
371
+ config.socket_path, config.api_timeout.total_seconds(), project, environ
372
+ )
373
+ return DockerComposeUpOutput(
374
+ project=project,
375
+ services=services,
376
+ networks=networks,
377
+ default_network=default_network,
378
+ stdout_uri=stdout_uri,
379
+ stderr_uri=stderr_uri,
380
+ compose_file=_scratch_relative(compose_file, root),
381
+ )
382
+
383
+
384
+ class DockerComposeDownOperator(Operator[DockerComposeDownConfig, DockerComposeDownOutput]):
385
+ """Tears a compose stack down on the worker, behind the allowlist."""
386
+
387
+ spec = OperatorSpec(
388
+ id="docker.compose.down",
389
+ group="execute",
390
+ summary="Tear a compose stack down on the worker.",
391
+ idempotent=False,
392
+ local_execution=True,
393
+ )
394
+ config_model: ClassVar[type[BaseModel]] = DockerComposeDownConfig
395
+ output_model: ClassVar[type[BaseModel]] = DockerComposeDownOutput
396
+
397
+ async def execute(
398
+ self, config: DockerComposeDownConfig, ctx: StepContext
399
+ ) -> DockerComposeDownOutput | RemoteHandle:
400
+ """Tear the stack down, by its project label alone unless a compose file was given."""
401
+ root = _workspace(ctx)
402
+ compose_file = _compose_file(config.file, config.content, ctx, root)
403
+ project = config.project_name or _default_project(ctx)
404
+ timeout = config.timeout.total_seconds()
405
+ stdout_uri, stderr_uri = _artifact_uris(ctx, "down")
406
+
407
+ with sealed(_connection(config.connection, ctx), root) as material:
408
+ environ = daemon_environment(
409
+ subprocess.environment([*DAEMON_ENV, *config.env_allowlist], config.env, root), material
410
+ )
411
+ code, out, err = await subprocess.run(
412
+ directory=root,
413
+ ctx=ctx,
414
+ stdout_uri=stdout_uri,
415
+ stderr_uri=stderr_uri,
416
+ timeout_seconds=timeout,
417
+ environ=environ,
418
+ argv=down_argv(config, project, compose_file, root),
419
+ what="docker compose down",
420
+ )
421
+ log_stream(ctx, "stdout", out)
422
+ log_stream(ctx, "stderr", err)
423
+ ctx.log.info("docker compose down finished", project=project, exit_code=code)
424
+ if code != 0:
425
+ detail = tail(err.tail) or tail(out.tail) or "no output"
426
+ raise BlockFailure(f"docker compose down exited {code}: {detail}", error_class=_classify(err.tail))
427
+ return DockerComposeDownOutput(project=project, stdout_uri=stdout_uri, stderr_uri=stderr_uri)
428
+
429
+
430
+ # -- the CLI, for up and down ----------------------------------------------------
431
+
432
+
433
+ def _global_flags(
434
+ command_path: list[str],
435
+ project: str,
436
+ compose_file: Path | None,
437
+ profiles: list[str],
438
+ env_files: list[str],
439
+ root: Path,
440
+ ) -> list[str]:
441
+ """The flags every compose invocation carries: the project, an optional file, profiles, env files.
442
+
443
+ ``--project-directory`` is the run's scratch space whatever directory the compose file itself
444
+ sits in, so relative build contexts and env files in the document resolve against the run's
445
+ own space rather than against a generated file's directory.
446
+ """
447
+ flags = [*command_path, "--project-name", project, "--project-directory", str(root)]
448
+ if compose_file is not None:
449
+ flags += ["-f", str(compose_file)]
450
+ for profile in profiles:
451
+ flags += ["--profile", profile]
452
+ for env_file in env_files:
453
+ flags += ["--env-file", str(root / env_file)]
454
+ return flags
455
+
456
+
457
+ def up_argv(config: DockerComposeUpConfig, project: str, compose_file: Path, root: Path) -> list[str]:
458
+ """Assemble the ``up`` argv, flag by flag."""
459
+ argv = [
460
+ *_global_flags(config.command_path, project, compose_file, config.profiles, config.env_files, root),
461
+ "up",
462
+ "-d",
463
+ ]
464
+ if config.wait:
465
+ argv += ["--wait", "--wait-timeout", str(int(config.wait_timeout.total_seconds()))]
466
+ if config.remove_orphans:
467
+ argv.append("--remove-orphans")
468
+ argv += ["--pull", config.pull]
469
+ return argv
470
+
471
+
472
+ def down_argv(config: DockerComposeDownConfig, project: str, compose_file: Path | None, root: Path) -> list[str]:
473
+ """Assemble the ``down`` argv, flag by flag."""
474
+ argv = [
475
+ *_global_flags(config.command_path, project, compose_file, config.profiles, config.env_files, root),
476
+ "down",
477
+ ]
478
+ if config.down_volumes:
479
+ argv.append("-v")
480
+ argv += ["--timeout", str(int(config.down_timeout.total_seconds()))]
481
+ if config.remove_orphans:
482
+ argv.append("--remove-orphans")
483
+ if config.down_remove_images != "none":
484
+ argv += ["--rmi", config.down_remove_images]
485
+ return argv
486
+
487
+
488
+ def _cleanup_argv(config: DockerComposeUpConfig, project: str, compose_file: Path, root: Path) -> list[str]:
489
+ """A plain ``down`` for the failed-up backstop, built from the ``up`` config."""
490
+ argv = [
491
+ *_global_flags(config.command_path, project, compose_file, config.profiles, config.env_files, root),
492
+ "down",
493
+ ]
494
+ if config.remove_orphans:
495
+ argv.append("--remove-orphans")
496
+ return argv
497
+
498
+
499
+ async def _abandoned(
500
+ config: DockerComposeUpConfig,
501
+ project: str,
502
+ compose_file: Path,
503
+ root: Path,
504
+ environ: dict[str, str],
505
+ ctx: StepContext,
506
+ ) -> None:
507
+ """Tear down a bring-up nothing is waiting for any more, out of reach of the cancellation.
508
+
509
+ A cancelled step is cancelled again as soon as it awaits, so the teardown runs as a task
510
+ behind a shield and is bounded by its own deadline rather than the step's.
511
+ """
512
+ teardown = asyncio.ensure_future(
513
+ _cleanup(config, project, compose_file, root, environ, CLEANUP_TIMEOUT_SECONDS, ctx)
514
+ )
515
+ try:
516
+ await asyncio.shield(teardown)
517
+ except asyncio.CancelledError:
518
+ with contextlib.suppress(BaseException):
519
+ await asyncio.shield(teardown)
520
+ raise
521
+
522
+
523
+ async def _cleanup(
524
+ config: DockerComposeUpConfig,
525
+ project: str,
526
+ compose_file: Path,
527
+ root: Path,
528
+ environ: dict[str, str],
529
+ timeout: float,
530
+ ctx: StepContext,
531
+ ) -> None:
532
+ """Best-effort tear down a stack whose own ``up`` did not succeed, so nothing half-built is left."""
533
+ try:
534
+ code, _, err = await subprocess.output(
535
+ argv=_cleanup_argv(config, project, compose_file, root),
536
+ directory=root,
537
+ environ=environ,
538
+ timeout_seconds=timeout,
539
+ what="docker compose down",
540
+ )
541
+ except BlockFailure as error:
542
+ ctx.log.warning("the failed stack could not be cleaned up", project=project, error=str(error))
543
+ return
544
+ if code != 0:
545
+ ctx.log.warning("the failed stack could not be cleaned up", project=project, detail=tail(err))
546
+ else:
547
+ ctx.log.info("the failed stack was cleaned up", project=project)
548
+
549
+
550
+ # -- the status read, over the Engine API ----------------------------------------
551
+
552
+
553
+ def connect(socket_path: str | None, timeout: float, environ: Mapping[str, str] | None = None) -> httpx2.AsyncClient:
554
+ """Open a client onto the daemon the status read speaks to."""
555
+ return open_client(resolve_endpoint(socket_path, environ), timeout)
556
+
557
+
558
+ def _daemon(socket_path: str | None, timeout: float, environ: Mapping[str, str] | None = None) -> DockerDaemon:
559
+ """Build the daemon facade the status read uses."""
560
+ return DockerDaemon(connect(socket_path, timeout, environ), resolve_endpoint(socket_path, environ).socket)
561
+
562
+
563
+ def _connection(ref: ConnectionRef | None, ctx: StepContext) -> DockerConnectionConfig | None:
564
+ """Resolve the step's ``docker`` connection, or report that it named none."""
565
+ return ctx.connection(ref, DockerConnectionConfig) if ref else None
566
+
567
+
568
+ async def read_status(
569
+ socket_path: str | None, timeout: float, project: str, environ: Mapping[str, str] | None = None
570
+ ) -> tuple[list[ComposeService], list[str], str]:
571
+ """List the project's containers by label, inspect each, and name the networks they are on."""
572
+ default_network = f"{project}_default"
573
+ networks: set[str] = {default_network}
574
+ services: list[ComposeService] = []
575
+ async with _daemon(socket_path, timeout, environ) as daemon:
576
+ ids = await daemon.list_containers({"label": [f"{PROJECT_LABEL}={project}"]})
577
+ for container in ids:
578
+ inspected = await daemon.inspect(container)
579
+ if inspected is None:
580
+ continue
581
+ services.append(_service_from(inspected))
582
+ networks.update(inspected.network_settings.networks.keys())
583
+ services.sort(key=lambda service: service.name)
584
+ return services, sorted(networks), default_network
585
+
586
+
587
+ def _service_from(inspected: ContainerInspect) -> ComposeService:
588
+ """Build one ``ComposeService`` from a container's inspect: its state, health and ports."""
589
+ publishers: list[ComposePublisher] = []
590
+ for spec, bindings in inspected.network_settings.ports.items():
591
+ port, _, protocol = spec.partition("/")
592
+ for binding in bindings or []:
593
+ publishers.append(
594
+ ComposePublisher(
595
+ url=binding.host_ip,
596
+ target_port=int(port) if port.isdigit() else 0,
597
+ published_port=int(binding.host_port) if binding.host_port.isdigit() else 0,
598
+ protocol=protocol,
599
+ )
600
+ )
601
+ return ComposeService(
602
+ name=inspected.name.lstrip("/"),
603
+ service=inspected.config.labels.get(SERVICE_LABEL, ""),
604
+ image=inspected.config.image,
605
+ state=inspected.state.status,
606
+ health=inspected.state.health.status,
607
+ exit_code=inspected.state.exit_code,
608
+ publishers=publishers,
609
+ )
610
+
611
+
612
+ # -- scratch and naming ----------------------------------------------------------
613
+
614
+
615
+ def _reject_escaping(paths: tuple[str | None, ...]) -> None:
616
+ """Refuse a scratch-relative path that is absolute or climbs out of the run's scratch space."""
617
+ for path in paths:
618
+ if path and (Path(path).is_absolute() or ".." in Path(path).parts):
619
+ raise ValueError(
620
+ "a compose file or env file is a path inside the run's scratch space, so it cannot "
621
+ "be absolute or climb out"
622
+ )
623
+
624
+
625
+ def _workspace(ctx: StepContext) -> Path:
626
+ """The run's work directory, which the compose CLI needs a real filesystem for."""
627
+ return subprocess.local_root(ctx)
628
+
629
+
630
+ def _compose_file(file: str | None, content: str | None, ctx: StepContext, root: Path) -> Path | None:
631
+ """Resolve the compose file: inline content, a named scratch file, or none.
632
+
633
+ Inline content is written under ``compose/{step}[/{item}]/attempt-{n}`` so two compose steps
634
+ of one run, or two items of one fan-out, never write over each other's document. A named file
635
+ is the author's own path, which stays relative to the run's scratch space.
636
+ """
637
+ if content is not None:
638
+ directory = subprocess.workspace(ctx, "compose")
639
+ path = directory / "docker-compose.yaml"
640
+ path.write_text(content)
641
+ return path
642
+ if file is not None:
643
+ return root / file
644
+ return None
645
+
646
+
647
+ def _scratch_relative(path: Path, root: Path) -> str:
648
+ """A compose file's path as a later step would name it, relative to the run's scratch space."""
649
+ return str(path.relative_to(root))
650
+
651
+
652
+ def _artifact_uris(ctx: StepContext, action: str) -> tuple[str, str]:
653
+ """Where the CLI's two streams are written for this attempt."""
654
+ root = f"{subprocess.prefix(ctx, 'compose')}-{action}"
655
+ return f"{root}-stdout.txt", f"{root}-stderr.txt"
656
+
657
+
658
+ def _default_project(ctx: StepContext) -> str:
659
+ """A project name that is the same across a run's up, drive and down, and unique between runs."""
660
+ return f"dirigent-{ctx.run_id.hex}"
661
+
662
+
663
+ def _classify(stderr_tail: bytes) -> ErrorClass:
664
+ """A daemon that cannot be reached is transient; a stack that would not come up is not."""
665
+ text = stderr_tail.decode("utf-8", errors="replace").lower()
666
+ if any(marker in text for marker in DAEMON_UNREACHABLE):
667
+ return ErrorClass.TRANSIENT
668
+ return ErrorClass.UNKNOWN