dirigent-block-execute 0.17.1__tar.gz

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,18 @@
1
+ Copyright (c) 2026 Morten Olav Hansen <morten@winterop.com>. All rights reserved.
2
+
3
+ This source code and accompanying documentation are the property of
4
+ Morten Olav Hansen. No license, express or implied, is granted to use, copy,
5
+ modify, merge, publish, distribute, sublicense, or sell copies of this
6
+ software or its derivatives.
7
+
8
+ The source is published for reference only. Any use beyond reading
9
+ requires written permission from the copyright holder.
10
+
11
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
12
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
13
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
14
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES,
15
+ OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
16
+
17
+ Third-party components redistributed with this software, and the licences they
18
+ carry, are listed in THIRD_PARTY_NOTICES.md.
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: dirigent-block-execute
3
+ Version: 0.17.1
4
+ Summary: The execute block family for dirigent: shell, docker, compose, buildx, and a checkout.
5
+ License-Expression: LicenseRef-Proprietary
6
+ License-File: LICENSE
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.13
9
+ Requires-Dist: dirigent-block-http==0.17.1
10
+ Requires-Dist: dirigent-common==0.17.1
11
+ Requires-Dist: dirigent-plugin==0.17.1
12
+ Requires-Dist: httpx2>=2.12.0
13
+ Requires-Python: >=3.13
14
+ Description-Content-Type: text/markdown
15
+
16
+ # dirigent-block-execute
17
+
18
+ The execute block family: everything that runs something on the worker. `shell.run` a command,
19
+ `docker.run` a container, `docker.compose.up` and `docker.compose.down` a stack, `docker.build`
20
+ an image, and `git.checkout` a working tree.
21
+
22
+ These are the blocks that declare themselves unsafe, because what they run is the step's own
23
+ code rather than a call to a service. The family registers the `docker` and `git` connection
24
+ kinds, and carries the shared internals the six have in common: process containment, output
25
+ capture, the environment allowlist, and the on-disk credential material a tool has to be
26
+ handed. `dg reap` uses the same internals to clear what a run left behind.
@@ -0,0 +1,11 @@
1
+ # dirigent-block-execute
2
+
3
+ The execute block family: everything that runs something on the worker. `shell.run` a command,
4
+ `docker.run` a container, `docker.compose.up` and `docker.compose.down` a stack, `docker.build`
5
+ an image, and `git.checkout` a working tree.
6
+
7
+ These are the blocks that declare themselves unsafe, because what they run is the step's own
8
+ code rather than a call to a service. The family registers the `docker` and `git` connection
9
+ kinds, and carries the shared internals the six have in common: process containment, output
10
+ capture, the environment allowlist, and the on-disk credential material a tool has to be
11
+ handed. `dg reap` uses the same internals to clear what a run left behind.
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "dirigent-block-execute"
3
+ version = "0.17.1"
4
+ description = "The execute block family for dirigent: shell, docker, compose, buildx, and a checkout."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "LicenseRef-Proprietary"
8
+ license-files = ["LICENSE"]
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "Programming Language :: Python :: 3.13",
12
+ ]
13
+ dependencies = [
14
+ "dirigent-block-http==0.17.1",
15
+ "dirigent-common==0.17.1",
16
+ "dirigent-plugin==0.17.1",
17
+ "httpx2>=2.12.0",
18
+ ]
19
+
20
+ [project.entry-points."dirigent.plugins.v1"]
21
+ block-execute = "dirigent_block_execute:plugin"
22
+
23
+ [build-system]
24
+ requires = ["uv_build>=0.12.0,<0.13.0"]
25
+ build-backend = "uv_build"
26
+
27
+ [tool.uv.sources.dirigent-block-http]
28
+ workspace = true
29
+
30
+ [tool.uv.sources.dirigent-common]
31
+ workspace = true
32
+
33
+ [tool.uv.sources.dirigent-plugin]
34
+ workspace = true
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "dirigent-block-execute"
3
+ version = "0.17.1"
4
+ description = "The execute block family for dirigent: shell, docker, compose, buildx, and a checkout."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "LicenseRef-Proprietary"
8
+ license-files = ["LICENSE"]
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "Programming Language :: Python :: 3.13",
12
+ ]
13
+ dependencies = [
14
+ "dirigent-block-http==0.17.1",
15
+ "dirigent-common==0.17.1",
16
+ "dirigent-plugin==0.17.1",
17
+ "httpx2>=2.12.0",
18
+ ]
19
+
20
+ [project.entry-points."dirigent.plugins.v1"]
21
+ block-execute = "dirigent_block_execute:plugin"
22
+
23
+ [build-system]
24
+ requires = ["uv_build>=0.12.0,<0.13.0"]
25
+ build-backend = "uv_build"
26
+
27
+ [tool.uv.sources]
28
+ dirigent-block-http = { workspace = true }
29
+ dirigent-common = { workspace = true }
30
+ dirigent-plugin = { workspace = true }
@@ -0,0 +1,43 @@
1
+ """The execute block family: what runs code on the worker, in a shell, a container, or a checkout."""
2
+
3
+ from dirigent_block_execute.build import DockerBuildOperator
4
+ from dirigent_block_execute.compose import DockerComposeDownOperator, DockerComposeUpOperator
5
+ from dirigent_block_execute.docker import DockerConnectionKind, DockerRunOperator
6
+ from dirigent_block_execute.git import GitCheckoutOperator, GitConnectionKind
7
+ from dirigent_block_execute.shell import ShellRunOperator
8
+ from dirigent_plugin import Contribution, extension
9
+
10
+
11
+ class ExecuteBlocks:
12
+ """The plugin object the host discovers under the dirigent.plugins.v1 entry-point group."""
13
+
14
+ @extension
15
+ def contribute(self) -> Contribution:
16
+ """Contribute the blocks that run something on the worker, and their connection kinds."""
17
+ return Contribution(
18
+ operators=[
19
+ ShellRunOperator(),
20
+ DockerRunOperator(),
21
+ DockerComposeUpOperator(),
22
+ DockerComposeDownOperator(),
23
+ DockerBuildOperator(),
24
+ GitCheckoutOperator(),
25
+ ],
26
+ connection_kinds=[DockerConnectionKind(), GitConnectionKind()],
27
+ )
28
+
29
+
30
+ plugin = ExecuteBlocks()
31
+
32
+ __all__ = [
33
+ "DockerBuildOperator",
34
+ "DockerComposeDownOperator",
35
+ "DockerComposeUpOperator",
36
+ "DockerConnectionKind",
37
+ "DockerRunOperator",
38
+ "ExecuteBlocks",
39
+ "GitCheckoutOperator",
40
+ "GitConnectionKind",
41
+ "ShellRunOperator",
42
+ "plugin",
43
+ ]
@@ -0,0 +1,319 @@
1
+ """``docker.build``: build an image from a context on the worker, with buildx.
2
+
3
+ The block shells out to ``docker buildx build`` -- BuildKit, its cache and multi-stage builds
4
+ come for free -- and reads the built image's id from an ``--iidfile`` rather than scraping the
5
+ log. The image lands in the worker's own daemon store, so a later ``docker.run`` or
6
+ ``docker.compose`` step on the same worker references it by tag.
7
+
8
+ Reaching the Docker daemon is reaching root on the host when the daemon is the host's own, so
9
+ the block declares ``local_execution`` and the engine refuses it unless the instance allowlists
10
+ its id.
11
+
12
+ ``push`` needs a ``docker`` connection carrying a registry credential, and is refused without
13
+ one. The login goes to a ``DOCKER_CONFIG`` directory of its own under the run's work directory --
14
+ never the worker's own config -- with the password on stdin rather than in an argument, and the
15
+ directory and the session in it go when the step leaves.
16
+ """
17
+
18
+ import re
19
+ from datetime import timedelta
20
+ from pathlib import Path
21
+ from typing import ClassVar
22
+
23
+ from pydantic import BaseModel, Field, model_validator
24
+
25
+ from dirigent_block_execute import subprocess
26
+ from dirigent_block_execute.capture import log_stream, scrub, tail
27
+ from dirigent_block_execute.docker import (
28
+ DAEMON_ENV,
29
+ DockerConnectionConfig,
30
+ Sealed,
31
+ daemon_environment,
32
+ login,
33
+ logout,
34
+ sealed,
35
+ write_cli_config,
36
+ )
37
+ from dirigent_block_execute.environment import reject_reserved
38
+ from dirigent_block_execute.messages import (
39
+ BUILD_EXITED,
40
+ BUILD_PATHS_STAY_INSIDE,
41
+ LOGIN_FAILED,
42
+ NO_IMAGE_ID,
43
+ NO_REGISTRY_CREDENTIAL,
44
+ PUSH_EXITED,
45
+ PUSH_NEEDS_A_CONNECTION,
46
+ PUSH_NEEDS_A_TAG,
47
+ )
48
+ from dirigent_common import BlockModel, Duration
49
+ from dirigent_plugin import (
50
+ BlockFailure,
51
+ ConnectionRef,
52
+ ErrorClass,
53
+ Operator,
54
+ OperatorSpec,
55
+ RemoteHandle,
56
+ StepContext,
57
+ )
58
+
59
+ #: What a daemon that is not there or not ours says, a transient condition rather than a broken build.
60
+ DAEMON_UNREACHABLE = ("cannot connect to the docker daemon", "is the docker daemon running", "permission denied")
61
+
62
+ #: What ``docker push`` prints once the registry has taken a tag, which is where its digest is.
63
+ PUSHED_DIGEST = re.compile(r"digest:\s*(sha256:[0-9a-f]{64})")
64
+
65
+
66
+ class DockerBuildConfig(BlockModel):
67
+ """Which context to build, with what Dockerfile, tags, and build arguments."""
68
+
69
+ context: str
70
+ """The build context, as a directory inside the run's work directory; never absolute, never climbing out."""
71
+
72
+ dockerfile: str = "Dockerfile"
73
+ """The Dockerfile, as a path relative to the context."""
74
+
75
+ tags: list[str] = Field(default_factory=list[str])
76
+ """Tags to give the built image (``--tag``); a later step references the image by one of them."""
77
+
78
+ build_args: dict[str, str] = Field(default_factory=dict[str, str])
79
+ """Build arguments the Dockerfile reads (``--build-arg``)."""
80
+
81
+ target: str | None = None
82
+ """The stage to stop at in a multi-stage build (``--target``)."""
83
+
84
+ platform: str | None = None
85
+ """The platform to build for, such as ``linux/amd64`` (``--platform``)."""
86
+
87
+ pull: bool = False
88
+ """Always attempt to pull a newer version of the base image (``--pull``)."""
89
+
90
+ no_cache: bool = False
91
+ """Build every layer from scratch, ignoring the cache (``--no-cache``)."""
92
+
93
+ push: bool = False
94
+ """Push every tag the build produced to the registry the connection names.
95
+
96
+ Needs a ``connection`` carrying a registry username and password: an anonymous push is
97
+ refused rather than attempted."""
98
+
99
+ connection: ConnectionRef | None = None
100
+ """A ``docker`` connection naming the daemon to build on, the registry to push to, or both.
101
+
102
+ Absent, the build runs against whatever daemon the worker's own environment names and
103
+ nothing is pushed."""
104
+
105
+ env: dict[str, str] = Field(default_factory=dict[str, str])
106
+ """Variables set for the CLI itself, such as BuildKit's own toggles."""
107
+
108
+ env_allowlist: list[str] = Field(default_factory=list[str])
109
+ """Worker environment variables the CLI is allowed to inherit; never the instance's ``DIRIGENT_*``."""
110
+
111
+ command_path: list[str] = Field(default_factory=lambda: ["docker", "buildx", "build"])
112
+ """The CLI to invoke, for a host that wraps buildx."""
113
+
114
+ timeout: Duration = timedelta(minutes=30)
115
+ """The overall deadline on the build, after which it is killed as transient."""
116
+
117
+ @model_validator(mode="after")
118
+ def _check_shape(self) -> "DockerBuildConfig":
119
+ """Refuse an unbacked push, a context or Dockerfile that climbs out, and a reserved env inheritance."""
120
+ if self.push and self.connection is None:
121
+ raise ValueError(PUSH_NEEDS_A_CONNECTION.render())
122
+ if self.push and not self.tags:
123
+ raise ValueError(PUSH_NEEDS_A_TAG.render())
124
+ for path in (self.context, self.dockerfile):
125
+ if Path(path).is_absolute() or ".." in Path(path).parts:
126
+ raise ValueError(BUILD_PATHS_STAY_INSIDE.render())
127
+ reject_reserved(self.env_allowlist)
128
+ return self
129
+
130
+
131
+ class DockerBuildOutput(BlockModel):
132
+ """The image the build produced, with its full log addressable as an artifact."""
133
+
134
+ image_id: str
135
+ """The built image's id, read from the ``--iidfile`` the daemon wrote, not scraped from the log."""
136
+
137
+ tags: list[str] = Field(default_factory=list[str])
138
+ """The tags the image was given, by which a later step on the same worker references it."""
139
+
140
+ size_bytes: int = 0
141
+ """The image's size on disk, from the daemon; zero when it could not be read."""
142
+
143
+ pushed: list[str] = Field(default_factory=list[str])
144
+ """The tags that reached the registry, empty when the step pushed nothing."""
145
+
146
+ digests: dict[str, str] = Field(default_factory=dict[str, str])
147
+ """The registry's digest for each pushed tag, for the tags the CLI reported one for."""
148
+
149
+ build_log_uri: str
150
+ """Where the whole of the build log (buildx's progress on stderr) was written."""
151
+
152
+ stdout_uri: str
153
+ """Where the whole of the CLI's stdout was written."""
154
+
155
+
156
+ class DockerBuildOperator(Operator[DockerBuildConfig, DockerBuildOutput]):
157
+ """Builds an image from a context in the run's work directory with buildx, behind the allowlist."""
158
+
159
+ spec = OperatorSpec(
160
+ id="docker.build",
161
+ group="execute",
162
+ summary="Build a container image on the worker.",
163
+ idempotent=False,
164
+ local_execution=True,
165
+ )
166
+ config_model: ClassVar[type[BaseModel]] = DockerBuildConfig
167
+ output_model: ClassVar[type[BaseModel]] = DockerBuildOutput
168
+
169
+ async def execute(self, config: DockerBuildConfig, ctx: StepContext) -> DockerBuildOutput | RemoteHandle:
170
+ """Build the image, read its id from the iidfile, and report its size and tags."""
171
+ root = ctx.work
172
+ context = root / config.context
173
+ iidfile = Path(f"{root / subprocess.segment(ctx, 'build')}.iid")
174
+ iidfile.parent.mkdir(parents=True, exist_ok=True)
175
+ settings = ctx.connection(config.connection, DockerConnectionConfig) if config.connection else None
176
+ if config.push and (settings is None or not settings.authenticates):
177
+ raise BlockFailure(NO_REGISTRY_CREDENTIAL, error_class=ErrorClass.REJECTED)
178
+ timeout = config.timeout.total_seconds()
179
+ artifacts = subprocess.prefix(ctx, "build")
180
+ stdout_uri = f"{artifacts}-stdout.txt"
181
+ build_log_uri = f"{artifacts}-build.log"
182
+
183
+ with sealed(settings, root, isolate_config=config.push) as material:
184
+ environ = daemon_environment(
185
+ subprocess.environment([*DAEMON_ENV, *config.env_allowlist], config.env, root), material
186
+ )
187
+ write_cli_config(root / ".docker")
188
+ code, out, err = await subprocess.run(
189
+ directory=root,
190
+ ctx=ctx,
191
+ stdout_uri=stdout_uri,
192
+ stderr_uri=build_log_uri,
193
+ timeout_seconds=timeout,
194
+ environ=environ,
195
+ argv=build_argv(config, context, iidfile),
196
+ what="docker buildx build",
197
+ redact=material.secrets,
198
+ )
199
+ log_stream(ctx, "stdout", out, material.secrets)
200
+ log_stream(ctx, "stderr", err, material.secrets)
201
+ ctx.log.info("docker build finished", exit_code=code, tags=", ".join(config.tags))
202
+ if code != 0:
203
+ detail = (
204
+ tail(err.tail, redact=material.secrets) or tail(out.tail, redact=material.secrets) or "no output"
205
+ )
206
+ raise BlockFailure(BUILD_EXITED, error_class=_classify(err.tail), code=code, detail=detail)
207
+
208
+ image_id = iidfile.read_text().strip() if iidfile.exists() else ""
209
+ if not image_id:
210
+ raise BlockFailure(NO_IMAGE_ID, error_class=ErrorClass.UNKNOWN)
211
+ size_bytes = await _image_size(config, image_id, root, environ, timeout)
212
+ pushed, digests = await _push(config, settings, root, environ, material, timeout, ctx)
213
+ ctx.log.info("image built", image_id=image_id[:19], size_bytes=size_bytes, tags=", ".join(config.tags))
214
+ return DockerBuildOutput(
215
+ image_id=image_id,
216
+ tags=config.tags,
217
+ size_bytes=size_bytes,
218
+ pushed=pushed,
219
+ digests=digests,
220
+ build_log_uri=build_log_uri,
221
+ stdout_uri=stdout_uri,
222
+ )
223
+
224
+
225
+ def build_argv(config: DockerBuildConfig, context: Path, iidfile: Path) -> list[str]:
226
+ """Assemble the ``buildx build`` argv, flag by flag, with the context last."""
227
+ argv = [*config.command_path, "--file", str(context / config.dockerfile), "--iidfile", str(iidfile)]
228
+ for tag in config.tags:
229
+ argv += ["--tag", tag]
230
+ for name, value in config.build_args.items():
231
+ argv += ["--build-arg", f"{name}={value}"]
232
+ if config.target is not None:
233
+ argv += ["--target", config.target]
234
+ if config.platform is not None:
235
+ argv += ["--platform", config.platform]
236
+ if config.pull:
237
+ argv.append("--pull")
238
+ if config.no_cache:
239
+ argv.append("--no-cache")
240
+ # Load the result into the worker's daemon store, so a later step can run it by tag; the
241
+ # container-driver default would otherwise leave the image only in the builder's cache.
242
+ argv.append("--load")
243
+ argv.append(str(context))
244
+ return argv
245
+
246
+
247
+ async def _push(
248
+ config: DockerBuildConfig,
249
+ settings: DockerConnectionConfig | None,
250
+ root: Path,
251
+ environ: dict[str, str],
252
+ material: Sealed,
253
+ timeout: float,
254
+ ctx: StepContext,
255
+ ) -> tuple[list[str], dict[str, str]]:
256
+ """Log in, push every tag the build produced, and log out again.
257
+
258
+ The login lives in the ``DOCKER_CONFIG`` directory the sealed material made, so it reaches
259
+ neither the worker's own config nor any later step, and the logout takes even that away.
260
+ """
261
+ if not config.push:
262
+ return [], {}
263
+ assert settings is not None
264
+ docker = config.command_path[0] if config.command_path else "docker"
265
+ code, err = await login(docker, root, environ, settings)
266
+ if code != 0:
267
+ detail = tail(err, redact=material.secrets) or f"docker login exited {code}"
268
+ raise BlockFailure(
269
+ LOGIN_FAILED, error_class=ErrorClass.REJECTED, registry=settings.registry_name, detail=detail
270
+ )
271
+ pushed: list[str] = []
272
+ digests: dict[str, str] = {}
273
+ try:
274
+ for tag in config.tags:
275
+ code, out, err = await subprocess.output(
276
+ argv=[docker, "push", tag],
277
+ directory=root,
278
+ environ=environ,
279
+ timeout_seconds=timeout,
280
+ what="docker push",
281
+ )
282
+ if code != 0:
283
+ detail = tail(err, redact=material.secrets) or tail(out, redact=material.secrets) or "no output"
284
+ raise BlockFailure(PUSH_EXITED, error_class=_classify(err), tag=tag, code=code, detail=detail)
285
+ found = PUSHED_DIGEST.search(scrub(out.decode("utf-8", errors="replace"), material.secrets))
286
+ if found is not None:
287
+ digests[tag] = found.group(1)
288
+ pushed.append(tag)
289
+ ctx.log.info("image pushed", tag=tag, registry=settings.registry_name, digest=digests.get(tag, ""))
290
+ finally:
291
+ await logout(docker, root, environ, settings)
292
+ return pushed, digests
293
+
294
+
295
+ async def _image_size(
296
+ config: DockerBuildConfig, image_id: str, root: Path, environ: dict[str, str], timeout: float
297
+ ) -> int:
298
+ """Read the built image's size from the daemon, best effort; zero when it cannot be read."""
299
+ docker = config.command_path[0] if config.command_path else "docker"
300
+ try:
301
+ _code, out, _err = await subprocess.output(
302
+ argv=[docker, "image", "inspect", "--format", "{{.Size}}", image_id],
303
+ directory=root,
304
+ environ=environ,
305
+ timeout_seconds=timeout,
306
+ what="docker image inspect",
307
+ )
308
+ except BlockFailure:
309
+ return 0
310
+ text = out.decode("utf-8", errors="replace").strip()
311
+ return int(text) if text.isdigit() else 0
312
+
313
+
314
+ def _classify(stderr_tail: bytes) -> ErrorClass:
315
+ """A daemon that cannot be reached is transient; a build that would not compile is not."""
316
+ text = stderr_tail.decode("utf-8", errors="replace").lower()
317
+ if any(marker in text for marker in DAEMON_UNREACHABLE):
318
+ return ErrorClass.TRANSIENT
319
+ return ErrorClass.UNKNOWN