dirigent-block-execute 0.17.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dirigent_block_execute/__init__.py +43 -0
- dirigent_block_execute/build.py +319 -0
- dirigent_block_execute/capture.py +289 -0
- dirigent_block_execute/compose.py +675 -0
- dirigent_block_execute/docker.py +1262 -0
- dirigent_block_execute/environment.py +47 -0
- dirigent_block_execute/git.py +548 -0
- dirigent_block_execute/messages.py +183 -0
- dirigent_block_execute/py.typed +0 -0
- dirigent_block_execute/reap.py +174 -0
- dirigent_block_execute/secrets.py +42 -0
- dirigent_block_execute/shell.py +174 -0
- dirigent_block_execute/subprocess.py +219 -0
- dirigent_block_execute-0.17.1.dist-info/METADATA +26 -0
- dirigent_block_execute-0.17.1.dist-info/RECORD +18 -0
- dirigent_block_execute-0.17.1.dist-info/WHEEL +4 -0
- dirigent_block_execute-0.17.1.dist-info/entry_points.txt +3 -0
- dirigent_block_execute-0.17.1.dist-info/licenses/LICENSE +18 -0
|
@@ -0,0 +1,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
|