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.
- dirigent_block_execute-0.17.1/LICENSE +18 -0
- dirigent_block_execute-0.17.1/PKG-INFO +26 -0
- dirigent_block_execute-0.17.1/README.md +11 -0
- dirigent_block_execute-0.17.1/pyproject.toml +34 -0
- dirigent_block_execute-0.17.1/pyproject.toml.orig +30 -0
- dirigent_block_execute-0.17.1/src/dirigent_block_execute/__init__.py +43 -0
- dirigent_block_execute-0.17.1/src/dirigent_block_execute/build.py +319 -0
- dirigent_block_execute-0.17.1/src/dirigent_block_execute/capture.py +289 -0
- dirigent_block_execute-0.17.1/src/dirigent_block_execute/compose.py +675 -0
- dirigent_block_execute-0.17.1/src/dirigent_block_execute/docker.py +1262 -0
- dirigent_block_execute-0.17.1/src/dirigent_block_execute/environment.py +47 -0
- dirigent_block_execute-0.17.1/src/dirigent_block_execute/git.py +548 -0
- dirigent_block_execute-0.17.1/src/dirigent_block_execute/messages.py +183 -0
- dirigent_block_execute-0.17.1/src/dirigent_block_execute/py.typed +0 -0
- dirigent_block_execute-0.17.1/src/dirigent_block_execute/reap.py +174 -0
- dirigent_block_execute-0.17.1/src/dirigent_block_execute/secrets.py +42 -0
- dirigent_block_execute-0.17.1/src/dirigent_block_execute/shell.py +174 -0
- dirigent_block_execute-0.17.1/src/dirigent_block_execute/subprocess.py +219 -0
|
@@ -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
|