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,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
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Capturing what a local process printed: decode it, log it, store it, quote it."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
from dirigent_plugin import ByteSink, StepContext
|
|
8
|
+
|
|
9
|
+
LOGGED_LINES = 40
|
|
10
|
+
|
|
11
|
+
FAILURE_TAIL_LINES = 5
|
|
12
|
+
|
|
13
|
+
#: What a scrubbed secret is replaced by, matching the marker the API redacts a connection with.
|
|
14
|
+
REDACTED = "***"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Captured(NamedTuple):
|
|
18
|
+
"""One stream as an output carries it: the head that inlines, and the truth about the rest."""
|
|
19
|
+
|
|
20
|
+
text: str
|
|
21
|
+
"""The head of the stream, as much of it as the instance lets a block inline."""
|
|
22
|
+
|
|
23
|
+
total_bytes: int
|
|
24
|
+
"""How much the process printed, whether it inlined or not."""
|
|
25
|
+
|
|
26
|
+
truncated: bool
|
|
27
|
+
"""Whether the text above is short of the stream; the artifact URI always holds all of it."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def captured(payload: bytes, limit: int) -> Captured:
|
|
31
|
+
"""Cut one captured stream down to what may be inlined, and say what was cut."""
|
|
32
|
+
return Captured(text=decode(payload[:limit]), total_bytes=len(payload), truncated=len(payload) > limit)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
#: How much of the end of a stream is kept, for the lines a failure message quotes. The head
|
|
36
|
+
#: is what a block may inline; this is the other end, and together they bound what is held.
|
|
37
|
+
TAIL_BYTES = 8 * 1024
|
|
38
|
+
|
|
39
|
+
#: How much is read from a pipe at a time.
|
|
40
|
+
CHUNK_BYTES = 64 * 1024
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Logged(NamedTuple):
|
|
44
|
+
"""What a stream put into the run log while it was still being read."""
|
|
45
|
+
|
|
46
|
+
lines: int
|
|
47
|
+
"""Non-blank lines the stream carried."""
|
|
48
|
+
|
|
49
|
+
live: int
|
|
50
|
+
"""How many of them reached the run log as they arrived."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Drained(NamedTuple):
|
|
54
|
+
"""What was kept of a stream that was written through to storage as it arrived."""
|
|
55
|
+
|
|
56
|
+
head: bytes
|
|
57
|
+
"""The beginning, as much as may be inlined."""
|
|
58
|
+
|
|
59
|
+
tail: bytes
|
|
60
|
+
"""The end, for the lines a failure quotes."""
|
|
61
|
+
|
|
62
|
+
total_bytes: int
|
|
63
|
+
"""How much the process printed, all of which reached storage."""
|
|
64
|
+
|
|
65
|
+
logged: Logged | None = None
|
|
66
|
+
"""What was already logged live, or ``None`` when the stream was not logged as it arrived."""
|
|
67
|
+
|
|
68
|
+
def captured(self, limit: int) -> Captured:
|
|
69
|
+
"""Present the head as an output carries it, against what the whole stream was."""
|
|
70
|
+
return Captured(
|
|
71
|
+
text=decode(self.head[:limit]),
|
|
72
|
+
total_bytes=self.total_bytes,
|
|
73
|
+
truncated=self.total_bytes > limit,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Ends:
|
|
78
|
+
"""Both ends of a stream and its size, holding nothing of the middle.
|
|
79
|
+
|
|
80
|
+
A stream is fed in as it arrives. What is kept is the head, which is what may be inlined,
|
|
81
|
+
and the last few kilobytes, which is what a failure message quotes.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, head_limit: int) -> None:
|
|
85
|
+
"""Keep at most this much of the beginning."""
|
|
86
|
+
self._head_limit = head_limit
|
|
87
|
+
self._head = bytearray()
|
|
88
|
+
self._tail = bytearray()
|
|
89
|
+
self.total_bytes = 0
|
|
90
|
+
|
|
91
|
+
def add(self, chunk: bytes) -> None:
|
|
92
|
+
"""Take one piece of the stream."""
|
|
93
|
+
self.total_bytes += len(chunk)
|
|
94
|
+
if len(self._head) < self._head_limit:
|
|
95
|
+
self._head.extend(chunk[: self._head_limit - len(self._head)])
|
|
96
|
+
self._tail.extend(chunk)
|
|
97
|
+
if len(self._tail) > TAIL_BYTES:
|
|
98
|
+
del self._tail[: len(self._tail) - TAIL_BYTES]
|
|
99
|
+
|
|
100
|
+
def drained(self, live: "LiveLines | None" = None) -> Drained:
|
|
101
|
+
"""Present what was kept, and what a live logger already said about it."""
|
|
102
|
+
return Drained(
|
|
103
|
+
head=bytes(self._head),
|
|
104
|
+
tail=bytes(self._tail),
|
|
105
|
+
total_bytes=self.total_bytes,
|
|
106
|
+
logged=live.logged() if live is not None else None,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class LiveLines:
|
|
111
|
+
"""Puts the first lines of a stream into the run log as the process prints them.
|
|
112
|
+
|
|
113
|
+
Bytes are fed in as they are read. Every complete non-blank line is counted, and the
|
|
114
|
+
first ``budget`` of them are logged where they happen, so a run screen shows a long
|
|
115
|
+
command working rather than everything it printed at the moment it exited. Past the
|
|
116
|
+
budget nothing is held: the line is counted and dropped, and the other end of the stream
|
|
117
|
+
reaches the log when the stream closes.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
ctx: StepContext,
|
|
123
|
+
stream: str,
|
|
124
|
+
*,
|
|
125
|
+
budget: int = LOGGED_LINES // 2,
|
|
126
|
+
redact: Sequence[str] = (),
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Log to this stream's level, redacting the strings the block handed the process."""
|
|
129
|
+
self._log = ctx.log.info if stream == "stdout" else ctx.log.warning
|
|
130
|
+
self._stream = stream
|
|
131
|
+
self._budget = budget
|
|
132
|
+
self._redact = redact
|
|
133
|
+
self._pending = bytearray()
|
|
134
|
+
self._nonblank = False
|
|
135
|
+
self._lines = 0
|
|
136
|
+
self._live = 0
|
|
137
|
+
|
|
138
|
+
def feed(self, chunk: bytes) -> None:
|
|
139
|
+
"""Take one piece of the stream, logging every complete line it completes."""
|
|
140
|
+
start = 0
|
|
141
|
+
while (cut := chunk.find(b"\n", start)) >= 0:
|
|
142
|
+
self._take(chunk[start:cut])
|
|
143
|
+
self._finish()
|
|
144
|
+
start = cut + 1
|
|
145
|
+
self._take(chunk[start:])
|
|
146
|
+
|
|
147
|
+
def close(self) -> None:
|
|
148
|
+
"""Log a last line the process left without a newline."""
|
|
149
|
+
self._finish()
|
|
150
|
+
|
|
151
|
+
def logged(self) -> Logged:
|
|
152
|
+
"""How many lines the stream carried and how many of them are in the log already."""
|
|
153
|
+
return Logged(lines=self._lines, live=self._live)
|
|
154
|
+
|
|
155
|
+
def _take(self, piece: bytes) -> None:
|
|
156
|
+
"""Hold a piece of the line being read, or only whether it has anything in it."""
|
|
157
|
+
if not piece:
|
|
158
|
+
return
|
|
159
|
+
self._nonblank = self._nonblank or bool(piece.strip())
|
|
160
|
+
if self._live < self._budget:
|
|
161
|
+
self._pending.extend(piece)
|
|
162
|
+
|
|
163
|
+
def _finish(self) -> None:
|
|
164
|
+
"""End the line being read: count it, and log it while the budget lasts."""
|
|
165
|
+
if self._nonblank:
|
|
166
|
+
self._lines += 1
|
|
167
|
+
if self._live < self._budget:
|
|
168
|
+
self._live += 1
|
|
169
|
+
self._log(scrub(decode(bytes(self._pending)).rstrip("\r"), self._redact), stream=self._stream)
|
|
170
|
+
self._pending.clear()
|
|
171
|
+
self._nonblank = False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def drain(
|
|
175
|
+
reader: "asyncio.StreamReader | None",
|
|
176
|
+
sink: ByteSink,
|
|
177
|
+
*,
|
|
178
|
+
head_limit: int,
|
|
179
|
+
live: LiveLines | None = None,
|
|
180
|
+
) -> Drained:
|
|
181
|
+
"""Read one pipe to its end, writing it through to storage and keeping both ends.
|
|
182
|
+
|
|
183
|
+
Nothing holds the whole stream. A command that prints a gigabyte is a file in storage and
|
|
184
|
+
two bounded buffers here, rather than a gigabyte in the worker.
|
|
185
|
+
|
|
186
|
+
With a ``live`` logger the lines go into the run log as they are read, up to its budget.
|
|
187
|
+
"""
|
|
188
|
+
ends = Ends(head_limit)
|
|
189
|
+
while reader is not None:
|
|
190
|
+
chunk = await reader.read(CHUNK_BYTES)
|
|
191
|
+
if not chunk:
|
|
192
|
+
break
|
|
193
|
+
ends.add(chunk)
|
|
194
|
+
if live is not None:
|
|
195
|
+
live.feed(chunk)
|
|
196
|
+
await sink.write(chunk)
|
|
197
|
+
if live is not None:
|
|
198
|
+
live.close()
|
|
199
|
+
return ends.drained(live)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def decode(payload: bytes) -> str:
|
|
203
|
+
"""Render captured bytes as text without ever failing on what a process printed."""
|
|
204
|
+
return payload.decode("utf-8", errors="replace")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def lines_of(payload: bytes) -> list[str]:
|
|
208
|
+
"""Split a captured stream into its non-blank lines."""
|
|
209
|
+
return [line for line in decode(payload).splitlines() if line.strip()]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def scrub(text: str, secrets: Sequence[str] = ()) -> str:
|
|
213
|
+
"""Replace every occurrence of a secret with the redaction marker.
|
|
214
|
+
|
|
215
|
+
A tool prints what it was given. ``git`` echoes a remote URL, an ssh command line and,
|
|
216
|
+
when a credential is wrong, sometimes the credential itself, so a block that hands a
|
|
217
|
+
process a secret hands this the same strings and nothing reaches a log or a failure
|
|
218
|
+
message with them still in it. Longest first, so a secret that contains another is
|
|
219
|
+
replaced whole rather than left in pieces.
|
|
220
|
+
"""
|
|
221
|
+
for secret in sorted((one for one in secrets if one), key=len, reverse=True):
|
|
222
|
+
text = text.replace(secret, REDACTED)
|
|
223
|
+
return text
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def tail(
|
|
227
|
+
payload: bytes,
|
|
228
|
+
*,
|
|
229
|
+
count: int = FAILURE_TAIL_LINES,
|
|
230
|
+
separator: str = " / ",
|
|
231
|
+
redact: Sequence[str] = (),
|
|
232
|
+
) -> str:
|
|
233
|
+
"""Take the last few lines of a stream, which is what a failure message wants."""
|
|
234
|
+
return scrub(separator.join(lines_of(payload)[-count:]), redact)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
async def store(ctx: StepContext, uri: str, payload: bytes) -> None:
|
|
238
|
+
"""Write one captured stream to storage, so it outlives the worker that produced it."""
|
|
239
|
+
async with ctx.storage.open_write(uri) as sink:
|
|
240
|
+
await sink.write(payload)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def log_stream(ctx: StepContext, stream: str, drained: Drained, redact: Sequence[str] = ()) -> None:
|
|
244
|
+
"""Put what the process printed into the run's log, from both ends of the stream.
|
|
245
|
+
|
|
246
|
+
A caller that read the stream through to storage holds only its two ends, and identical
|
|
247
|
+
ends are how one small enough to be held whole says so. The whole of a stream is elided
|
|
248
|
+
by line count; what is held in two pieces can only be logged as two pieces.
|
|
249
|
+
|
|
250
|
+
A stream that was logged live already has its first lines in the log: this adds the last
|
|
251
|
+
of them, and only the ones that were never said.
|
|
252
|
+
|
|
253
|
+
``redact`` names the strings a block handed the process that must not reach the log.
|
|
254
|
+
"""
|
|
255
|
+
log = ctx.log.info if stream == "stdout" else ctx.log.warning
|
|
256
|
+
half = LOGGED_LINES // 2
|
|
257
|
+
if drained.logged is not None:
|
|
258
|
+
remaining = drained.logged.lines - drained.logged.live
|
|
259
|
+
if remaining <= 0:
|
|
260
|
+
return
|
|
261
|
+
last = lines_of(drained.tail)[-min(remaining, half) :]
|
|
262
|
+
if remaining > len(last):
|
|
263
|
+
log(f"... {remaining - len(last)} more lines, see the {stream} artifact ...", stream=stream)
|
|
264
|
+
for line in last:
|
|
265
|
+
log(scrub(line, redact), stream=stream)
|
|
266
|
+
return
|
|
267
|
+
if drained.head == drained.tail:
|
|
268
|
+
lines = lines_of(drained.head)
|
|
269
|
+
if not lines:
|
|
270
|
+
return
|
|
271
|
+
shown = lines
|
|
272
|
+
if len(lines) > LOGGED_LINES:
|
|
273
|
+
shown = [
|
|
274
|
+
*lines[:half],
|
|
275
|
+
f"... {len(lines) - LOGGED_LINES} more lines, see the {stream} artifact ...",
|
|
276
|
+
*lines[-half:],
|
|
277
|
+
]
|
|
278
|
+
for line in shown:
|
|
279
|
+
log(scrub(line, redact), stream=stream)
|
|
280
|
+
return
|
|
281
|
+
first = lines_of(drained.head)
|
|
282
|
+
last = lines_of(drained.tail)
|
|
283
|
+
if not first and not last:
|
|
284
|
+
return
|
|
285
|
+
for line in first[:half]:
|
|
286
|
+
log(scrub(line, redact), stream=stream)
|
|
287
|
+
log(f"... the middle is not in the log, see the {stream} artifact ...", stream=stream)
|
|
288
|
+
for line in last[-half:]:
|
|
289
|
+
log(scrub(line, redact), stream=stream)
|