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