dirigent-block-execute 0.17.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,183 @@
1
+ """Every refusal the execute family makes, catalogued under the ``execute`` prefix."""
2
+
3
+ from dirigent_common import Catalogue
4
+
5
+ EXECUTE = Catalogue("execute")
6
+
7
+ TIMED_OUT = EXECUTE.define("timed_out", "{what} did not finish within {seconds}s")
8
+
9
+ NO_GIT = EXECUTE.define(
10
+ "no_git",
11
+ "git is not on the worker's PATH, so nothing here can check a repository out; "
12
+ "the worker image installs it, a bare host may not",
13
+ )
14
+
15
+ GIT_EXITED = EXECUTE.define("git_exited", "git {command} exited {code}: {detail}")
16
+
17
+ CHECKOUT_THROUGH_A_SYMLINK = EXECUTE.define(
18
+ "checkout_through_a_symlink",
19
+ "the checkout target {target} leads through the symlink {walked}, which can point "
20
+ "anywhere; a target is a path of real directories under the run's work directory {base}",
21
+ )
22
+
23
+ CHECKOUT_OUTSIDE_THE_WORK_DIRECTORY = EXECUTE.define(
24
+ "checkout_outside_the_work_directory",
25
+ "the checkout target {target} lands at {destination}, which is outside the run's work directory {base}",
26
+ )
27
+
28
+ COMPOSE_UP_EXITED = EXECUTE.define("compose_up_exited", "docker compose up exited {code}: {detail}")
29
+
30
+ COMPOSE_DOWN_EXITED = EXECUTE.define("compose_down_exited", "docker compose down exited {code}: {detail}")
31
+
32
+ NO_REGISTRY_CREDENTIAL = EXECUTE.define(
33
+ "no_registry_credential",
34
+ "docker.build cannot push through a connection with no registry credential: set "
35
+ "username and password on the docker connection, and registry unless it is Docker Hub",
36
+ )
37
+
38
+ BUILD_EXITED = EXECUTE.define("build_exited", "docker build exited {code}: {detail}")
39
+
40
+ NO_IMAGE_ID = EXECUTE.define("no_image_id", "docker build reported success but wrote no image id to the iidfile")
41
+
42
+ LOGIN_FAILED = EXECUTE.define("login_failed", "docker login to {registry} failed: {detail}")
43
+
44
+ PUSH_EXITED = EXECUTE.define("push_exited", "docker push {tag} exited {code}: {detail}")
45
+
46
+ CONTAINER_GONE = EXECUTE.define(
47
+ "container_gone",
48
+ "container {container} disappeared before its result could be collected",
49
+ )
50
+
51
+ OUTPUT_NOT_WRITTEN = EXECUTE.define(
52
+ "output_not_written",
53
+ "the container did not write the declared output {name} to {path}",
54
+ )
55
+
56
+ NO_DAEMON = EXECUTE.define(
57
+ "no_daemon",
58
+ "the Docker daemon at {socket} did not answer ({detail}); a worker in a "
59
+ "container has no daemon unless the host's socket is mounted into it, and "
60
+ "mounting it grants the container root on the host",
61
+ )
62
+
63
+ DAEMON_REFUSED = EXECUTE.define("daemon_refused", "the daemon refused to {action}: {detail}")
64
+
65
+ COMMAND_EXITED = EXECUTE.define("command_exited", "the command exited {code}: {detail}")
66
+
67
+
68
+ # What a config refuses at validation. Pydantic owns the code a validator's refusal reaches
69
+ # the wire under, so these are rendered into the ``ValueError`` it wraps.
70
+
71
+ PUSH_NEEDS_A_CONNECTION = EXECUTE.define(
72
+ "push_needs_a_connection",
73
+ "docker.build cannot push without a connection: set connection to a docker connection "
74
+ "holding registry, username and password",
75
+ )
76
+
77
+ PUSH_NEEDS_A_TAG = EXECUTE.define(
78
+ "push_needs_a_tag",
79
+ "docker.build pushes the tags it built, so a push needs at least one tag",
80
+ )
81
+
82
+ BUILD_PATHS_STAY_INSIDE = EXECUTE.define(
83
+ "build_paths_stay_inside",
84
+ "the context and Dockerfile are paths inside the run's work directory, so they cannot be absolute or climb out",
85
+ )
86
+
87
+ COMPOSE_UP_ONE_SOURCE = EXECUTE.define(
88
+ "compose_up_one_source",
89
+ "docker.compose.up takes either file or content, and exactly one of them",
90
+ )
91
+
92
+ COMPOSE_DOWN_ONE_SOURCE = EXECUTE.define(
93
+ "compose_down_one_source",
94
+ "docker.compose.down takes at most one of file or content",
95
+ )
96
+
97
+ COMPOSE_PATHS_STAY_INSIDE = EXECUTE.define(
98
+ "compose_paths_stay_inside",
99
+ "a compose file or env file is a path inside the run's work directory, so it cannot be absolute or climb out",
100
+ )
101
+
102
+ GIT_ONE_CREDENTIAL = EXECUTE.define(
103
+ "git_one_credential",
104
+ "a git connection carries one credential: a token for an https remote or an ssh_key for an ssh one, never both",
105
+ )
106
+
107
+ GIT_TOKEN_NEEDS_HTTPS = EXECUTE.define(
108
+ "git_token_needs_https",
109
+ "a token is HTTP basic auth, so the url must be http or https, not {url}",
110
+ )
111
+
112
+ GIT_KEY_NEEDS_SSH = EXECUTE.define("git_key_needs_ssh", "an ssh_key needs an ssh remote, and {url} is not one")
113
+
114
+ CHECKOUT_STAYS_INSIDE = EXECUTE.define(
115
+ "checkout_stays_inside",
116
+ "a checkout target is a path inside the run's work directory, so it cannot be absolute or climb out",
117
+ )
118
+
119
+ SHELL_ONE_FORM = EXECUTE.define("shell_one_form", "shell.run takes either argv or command, and exactly one of them")
120
+
121
+ CWD_STAYS_INSIDE = EXECUTE.define(
122
+ "cwd_stays_inside",
123
+ "cwd is a path inside the run's work directory, so it cannot be absolute or climb out",
124
+ )
125
+
126
+ NOT_A_DAEMON_SCHEME = EXECUTE.define(
127
+ "not_a_daemon_scheme",
128
+ "a docker host is one of {schemes}, and {host} is none of them",
129
+ )
130
+
131
+ TLS_IS_ALL_THREE = EXECUTE.define(
132
+ "tls_is_all_three",
133
+ "client TLS is all three of tls_ca, tls_cert and tls_key, or none of them",
134
+ )
135
+
136
+ TLS_NEEDS_TCP = EXECUTE.define(
137
+ "tls_needs_tcp",
138
+ "client TLS is how a tcp:// daemon is reached, so the host must be a tcp:// one",
139
+ )
140
+
141
+ CREDENTIAL_IS_A_PAIR = EXECUTE.define(
142
+ "credential_is_a_pair",
143
+ "a registry credential is a username and a password together, never one of them",
144
+ )
145
+
146
+ REGISTRY_WITHOUT_A_CREDENTIAL = EXECUTE.define(
147
+ "registry_without_a_credential",
148
+ "a registry ({registry}) with no username and password authenticates to nothing",
149
+ )
150
+
151
+ DOCKER_CONNECTION_NAMES_NEITHER = EXECUTE.define(
152
+ "docker_connection_names_neither",
153
+ "a docker connection names a daemon, a registry credential, or both, and this names neither",
154
+ )
155
+
156
+ RUN_ONE_FORM = EXECUTE.define(
157
+ "run_one_form",
158
+ "docker.run takes either argv or command, and never both; omit both to run the image's own entrypoint",
159
+ )
160
+
161
+ RUN_ONE_CPU_FIELD = EXECUTE.define("run_one_cpu_field", "docker.run takes either cpus or nano_cpus, and never both")
162
+
163
+ RUN_ONE_DAEMON = EXECUTE.define(
164
+ "run_one_daemon",
165
+ "a connection names the daemon, so docker.run takes either connection or socket_path",
166
+ )
167
+
168
+ MOUNTED_FILE_STAYS_INSIDE = EXECUTE.define(
169
+ "mounted_file_stays_inside",
170
+ "a mounted file is named inside its mount, so it cannot be absolute or climb out",
171
+ )
172
+
173
+ OUTPUT_STAYS_INSIDE = EXECUTE.define(
174
+ "output_stays_inside",
175
+ "an output without a URI scheme is a path inside the run's work directory, so it cannot be absolute or climb out",
176
+ )
177
+
178
+ RESERVED_ENVIRONMENT = EXECUTE.define(
179
+ "reserved_environment",
180
+ "env_allowlist may not inherit the instance's own configuration: {reserved}. "
181
+ "{prefix}* holds this instance's secrets, including the envelope key for "
182
+ "every stored connection; pass what the step needs through env, or a connection.",
183
+ )
File without changes
@@ -0,0 +1,174 @@
1
+ """The orphan reaper: compose stacks a run left behind, taken down on the daemon that holds them.
2
+
3
+ ``docker.compose.up`` names its project ``dirigent-<run id>`` and compose labels every container
4
+ of it ``com.docker.compose.project``, so a stack is addressable by the run that created it long
5
+ after that run has gone. A stack is an orphan when its run is terminal, or when no such run
6
+ exists at all, and the project is still up: a worker died mid-run, a pipeline was cancelled
7
+ between ``up`` and ``down``, a ``down`` step never ran.
8
+
9
+ The reaper sees only the daemon it is pointed at. With one docker-in-docker sidecar per worker
10
+ that is exactly the stacks that worker created, which is the deployment this is written for.
11
+ """
12
+
13
+ import re
14
+ from collections.abc import Awaitable, Callable, Mapping
15
+ from datetime import UTC, datetime, timedelta
16
+ from pathlib import Path
17
+ from typing import NamedTuple
18
+ from uuid import UUID
19
+
20
+ from dirigent_block_execute import subprocess
21
+ from dirigent_block_execute.capture import tail
22
+ from dirigent_block_execute.compose import PROJECT_LABEL
23
+ from dirigent_block_execute.docker import DockerDaemon, open_client, resolve_endpoint
24
+ from dirigent_plugin import BlockFailure
25
+
26
+ #: A compose project this instance created, and the run id written into its name.
27
+ DIRIGENT_PROJECT = re.compile(r"^dirigent-([0-9a-f]{32})$")
28
+
29
+ #: The status a project whose run no longer exists is reported under.
30
+ UNKNOWN_RUN = "unknown"
31
+
32
+ #: How long one reap invocation of the CLI or the daemon may take.
33
+ REAP_TIMEOUT_SECONDS = 180.0
34
+
35
+
36
+ class RunFact(NamedTuple):
37
+ """What the reaper needs to know about the run a project was named after."""
38
+
39
+ status: str
40
+ """The run's status, as the instance spells it."""
41
+
42
+ active: bool
43
+ """Whether the run may still be driving this stack, in which case it is never touched."""
44
+
45
+
46
+ #: Answers what a run id is doing, or ``None`` where the instance holds no such run.
47
+ type RunLookup = Callable[[UUID], Awaitable[RunFact | None]]
48
+
49
+
50
+ class Project(NamedTuple):
51
+ """One compose project on the daemon, with the moment its youngest container was created."""
52
+
53
+ name: str
54
+ run_id: UUID
55
+ created: datetime
56
+
57
+
58
+ class Reaped(NamedTuple):
59
+ """One project the reaper acted on, or would have acted on."""
60
+
61
+ project: str
62
+ run_id: UUID
63
+ run_status: str
64
+ torn_down: bool
65
+ """False for a dry run, and for a teardown the CLI refused."""
66
+
67
+ detail: str = ""
68
+ """What compose said, when it said anything worth carrying."""
69
+
70
+
71
+ def daemon(environ: Mapping[str, str], timeout: float = REAP_TIMEOUT_SECONDS) -> DockerDaemon:
72
+ """Build the daemon facade the reaper reads the project list through."""
73
+ endpoint = resolve_endpoint(None, environ)
74
+ return DockerDaemon(open_client(endpoint, timeout), endpoint.socket)
75
+
76
+
77
+ async def projects(client: DockerDaemon) -> list[Project]:
78
+ """List this instance's compose projects on the daemon, newest container first per project.
79
+
80
+ A project's age is its **youngest** container's, so a stack something is still adding to is
81
+ young however long its first container has been up.
82
+ """
83
+ listed = await client.list_labelled({"label": [PROJECT_LABEL]})
84
+ newest: dict[str, tuple[UUID, datetime]] = {}
85
+ for container in listed:
86
+ name = container.labels.get(PROJECT_LABEL, "")
87
+ found = DIRIGENT_PROJECT.match(name)
88
+ if found is None:
89
+ continue
90
+ created = datetime.fromtimestamp(container.created, tz=UTC)
91
+ run_id = UUID(found.group(1))
92
+ held = newest.get(name)
93
+ if held is None or created > held[1]:
94
+ newest[name] = (run_id, created)
95
+ return sorted(
96
+ (Project(name, run_id, created) for name, (run_id, created) in newest.items()),
97
+ key=lambda project: project.name,
98
+ )
99
+
100
+
101
+ async def orphans(
102
+ found: list[Project], look_up: RunLookup, *, grace: timedelta, now: datetime | None = None
103
+ ) -> list[tuple[Project, str]]:
104
+ """Choose the projects to reap, and say which status each was chosen for.
105
+
106
+ A project younger than the grace is left alone whatever its run says, because a stack whose
107
+ ``up`` has only just returned is a stack whose ``down`` has not been reached yet. A run that
108
+ is still active is never reaped; one that is terminal, and one the instance no longer holds,
109
+ both are.
110
+ """
111
+ moment = now or datetime.now(UTC)
112
+ chosen: list[tuple[Project, str]] = []
113
+ for project in found:
114
+ if moment - project.created < grace:
115
+ continue
116
+ fact = await look_up(project.run_id)
117
+ if fact is None:
118
+ chosen.append((project, UNKNOWN_RUN))
119
+ elif not fact.active:
120
+ chosen.append((project, fact.status))
121
+ return chosen
122
+
123
+
124
+ async def reap(
125
+ environ: Mapping[str, str],
126
+ root: Path,
127
+ look_up: RunLookup,
128
+ *,
129
+ grace: timedelta,
130
+ dry_run: bool = False,
131
+ command_path: list[str] | None = None,
132
+ now: datetime | None = None,
133
+ ) -> list[Reaped]:
134
+ """Run one pass: list the projects, choose the orphans, and take each one down.
135
+
136
+ A teardown that fails is reported rather than raised, so one stack the daemon will not let
137
+ go of does not stop the pass reaching the next.
138
+ """
139
+ async with daemon(environ) as client:
140
+ found = await projects(client)
141
+ chosen = await orphans(found, look_up, grace=grace, now=now)
142
+ argv_head = command_path or ["docker", "compose"]
143
+ results: list[Reaped] = []
144
+ for project, status in chosen:
145
+ if dry_run:
146
+ results.append(Reaped(project.name, project.run_id, status, torn_down=False))
147
+ continue
148
+ results.append(await _tear_down(argv_head, project, status, environ, root))
149
+ return results
150
+
151
+
152
+ async def _tear_down(
153
+ command_path: list[str], project: Project, status: str, environ: Mapping[str, str], root: Path
154
+ ) -> Reaped:
155
+ """Take one orphaned project down, volumes and orphans included, and say how it went."""
156
+ try:
157
+ code, out, err = await subprocess.output(
158
+ argv=[*command_path, "-p", project.name, "down", "-v", "--remove-orphans"],
159
+ directory=root,
160
+ environ=dict(environ),
161
+ timeout_seconds=REAP_TIMEOUT_SECONDS,
162
+ what="docker compose down",
163
+ )
164
+ except BlockFailure as error:
165
+ return Reaped(project.name, project.run_id, status, torn_down=False, detail=str(error))
166
+ if code != 0:
167
+ return Reaped(
168
+ project.name,
169
+ project.run_id,
170
+ status,
171
+ torn_down=False,
172
+ detail=tail(err) or tail(out) or f"docker compose down exited {code}",
173
+ )
174
+ return Reaped(project.name, project.run_id, status, torn_down=True)
@@ -0,0 +1,42 @@
1
+ """A private directory for the credential files a CLI reads, and the writes that fill it.
2
+
3
+ A tool that takes a credential takes it from a file: git through an askpass helper, docker
4
+ through a client key and a config directory. The file is 0600 inside a 0700 directory, is
5
+ created with those permissions rather than fixed after, and goes away when the step leaves
6
+ however it leaves.
7
+ """
8
+
9
+ import contextlib
10
+ import os
11
+ import shutil
12
+ import stat
13
+ import tempfile
14
+ from collections.abc import Generator
15
+ from pathlib import Path
16
+
17
+ #: A file only its owner may read or write.
18
+ PRIVATE = stat.S_IRUSR | stat.S_IWUSR
19
+
20
+ #: A file only its owner may read, write or run.
21
+ EXECUTABLE = stat.S_IRWXU
22
+
23
+
24
+ @contextlib.contextmanager
25
+ def private_directory(parent: Path, prefix: str) -> Generator[Path]:
26
+ """Make a 0700 directory under a parent, and remove it and everything in it afterwards."""
27
+ directory = Path(tempfile.mkdtemp(prefix=prefix, dir=parent))
28
+ directory.chmod(stat.S_IRWXU)
29
+ try:
30
+ yield directory
31
+ finally:
32
+ shutil.rmtree(directory, ignore_errors=True)
33
+
34
+
35
+ def write(path: Path, text: str, mode: int = PRIVATE) -> None:
36
+ """Write one credential file, created with its final permissions rather than fixed after."""
37
+ handle = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
38
+ with os.fdopen(handle, "w") as sink:
39
+ sink.write(text)
40
+
41
+
42
+ __all__ = ["EXECUTABLE", "PRIVATE", "private_directory", "write"]
@@ -0,0 +1,174 @@
1
+ """``shell.run``: the one built-in block that executes code on the worker.
2
+
3
+ It declares ``local_execution``, which means the engine refuses to run it at all unless the
4
+ instance config allowlists its id. That gate exists because "can edit pipelines" must never
5
+ silently mean "can run code on workers".
6
+ """
7
+
8
+ from datetime import timedelta
9
+ from pathlib import Path
10
+ from typing import Annotated, ClassVar
11
+
12
+ from pydantic import BaseModel, Field, model_validator
13
+
14
+ from dirigent_block_execute import subprocess
15
+ from dirigent_block_execute.capture import log_stream, tail
16
+ from dirigent_block_execute.environment import reject_reserved
17
+ from dirigent_block_execute.messages import (
18
+ COMMAND_EXITED,
19
+ CWD_STAYS_INSIDE,
20
+ SHELL_ONE_FORM,
21
+ )
22
+ from dirigent_common import BlockModel, Duration
23
+ from dirigent_plugin import (
24
+ BlockFailure,
25
+ ErrorClass,
26
+ Operator,
27
+ OperatorSpec,
28
+ RemoteHandle,
29
+ ShellString,
30
+ ShellVariables,
31
+ StepContext,
32
+ )
33
+
34
+
35
+ class ShellRunConfig(ShellVariables):
36
+ """What to run, where, with what environment, and for how long."""
37
+
38
+ argv: list[str] = Field(default_factory=list[str])
39
+ """The command as an argument vector, which does not involve a shell."""
40
+
41
+ command: Annotated[str | None, ShellString()] = None
42
+ """The command as a shell string, for when a pipe or a redirect is the point.
43
+
44
+ Every ``${...}`` in it is rewritten by the engine to a variable it sets in this command's
45
+ environment, so a value that came from a webhook payload is one word the shell never
46
+ parses, however the reference was quoted -- and inside single quotes, which a shell keeps
47
+ literal, the command reads that variable's name rather than its value. Prefer ``argv``
48
+ anyway: it involves no shell at all."""
49
+
50
+ cwd: str | None = None
51
+ """A directory relative to the run's work directory; never an absolute path."""
52
+
53
+ env: dict[str, str] = Field(default_factory=dict[str, str])
54
+ """Variables set explicitly for this command."""
55
+
56
+ env_allowlist: list[str] = Field(default_factory=list[str])
57
+ """Worker environment variables this command is allowed to inherit.
58
+
59
+ Never the instance's own ``DIRIGENT_*`` variables: those hold this instance's secrets,
60
+ and a step that could inherit them could print the envelope key into the run log."""
61
+
62
+ timeout: Duration = Field(default=timedelta(minutes=5), gt=timedelta(0))
63
+ """How long the process may run before it is killed as a transient failure."""
64
+
65
+ @model_validator(mode="after")
66
+ def _require_one_form(self) -> "ShellRunConfig":
67
+ """Reject a config that names both forms of the command, or neither."""
68
+ if bool(self.argv) == bool(self.command):
69
+ raise ValueError(SHELL_ONE_FORM.render())
70
+ if self.cwd and (Path(self.cwd).is_absolute() or ".." in Path(self.cwd).parts):
71
+ raise ValueError(CWD_STAYS_INSIDE.render())
72
+ reject_reserved(self.env_allowlist)
73
+ return self
74
+
75
+
76
+ class ShellRunOutput(BlockModel):
77
+ """What the command did, with its full streams addressable as artifacts."""
78
+
79
+ exit_code: int
80
+
81
+ stdout: str
82
+ """The head of what the command printed, cut at the instance's inline capture size.
83
+
84
+ Verbatim, including the trailing newline a command like ``echo`` ends with. Use
85
+ ``printf '%s'`` where the value is read by another step."""
86
+
87
+ stderr: str
88
+ """The head of what the command printed to stderr, cut the same way."""
89
+
90
+ stdout_uri: str
91
+ """Where the whole of stdout was written; never truncated, whatever the field above holds."""
92
+
93
+ stderr_uri: str
94
+ """Where the whole of stderr was written; never truncated either."""
95
+
96
+ stdout_bytes: int
97
+ """How much the command printed to stdout altogether, inlined or not."""
98
+
99
+ stderr_bytes: int
100
+ """How much it printed to stderr altogether."""
101
+
102
+ stdout_truncated: bool
103
+ """Whether ``stdout`` above is short of the stream. Only the inline copy is ever cut."""
104
+
105
+ stderr_truncated: bool
106
+ """Whether ``stderr`` above is short of the stream, which is an independent question."""
107
+
108
+
109
+ def _environment(config: ShellRunConfig, home: Path) -> dict[str, str]:
110
+ """Build the command's environment, the substituted values last.
111
+
112
+ They are set after ``env`` so a document cannot override what a reference resolved to,
113
+ and they are set whatever the allowlist holds: the command reads them because the engine
114
+ put them in it.
115
+ """
116
+ environ = subprocess.environment(config.env_allowlist, config.env, home)
117
+ environ.update(config.shell_variables)
118
+ return environ
119
+
120
+
121
+ class ShellRunOperator(Operator[ShellRunConfig, ShellRunOutput]):
122
+ """Runs a command on the worker, inside the run's work directory and behind the allowlist."""
123
+
124
+ spec = OperatorSpec(
125
+ id="shell.run",
126
+ group="execute",
127
+ summary="Run a command on the worker.",
128
+ idempotent=False,
129
+ local_execution=True,
130
+ )
131
+ config_model: ClassVar[type[BaseModel]] = ShellRunConfig
132
+ output_model: ClassVar[type[BaseModel]] = ShellRunOutput
133
+
134
+ async def execute(self, config: ShellRunConfig, ctx: StepContext) -> ShellRunOutput | RemoteHandle:
135
+ """Run the command once, capture both streams, and classify how it ended."""
136
+ workspace = subprocess.workspace(ctx, "shell")
137
+ directory = workspace / config.cwd if config.cwd else workspace
138
+ directory.mkdir(parents=True, exist_ok=True)
139
+ artifacts = subprocess.prefix(ctx, "shell")
140
+ stdout_uri = f"{artifacts}-stdout.txt"
141
+ stderr_uri = f"{artifacts}-stderr.txt"
142
+ code, out, err = await subprocess.run(
143
+ directory=directory,
144
+ ctx=ctx,
145
+ stdout_uri=stdout_uri,
146
+ stderr_uri=stderr_uri,
147
+ timeout_seconds=config.timeout.total_seconds(),
148
+ environ=_environment(config, directory),
149
+ argv=config.argv or None,
150
+ command=config.command,
151
+ )
152
+ log_stream(ctx, "stdout", out)
153
+ log_stream(ctx, "stderr", err)
154
+ ctx.log.info("command finished", exit_code=code, stdout_bytes=out.total_bytes, stderr_bytes=err.total_bytes)
155
+ if code != 0:
156
+ raise BlockFailure(
157
+ COMMAND_EXITED,
158
+ error_class=ErrorClass.UNKNOWN,
159
+ code=code,
160
+ detail=tail(err.tail) or tail(out.tail) or "no output",
161
+ )
162
+ printed = out.captured(ctx.inline_capture)
163
+ failed = err.captured(ctx.inline_capture)
164
+ return ShellRunOutput(
165
+ exit_code=code,
166
+ stdout=printed.text,
167
+ stderr=failed.text,
168
+ stdout_uri=stdout_uri,
169
+ stderr_uri=stderr_uri,
170
+ stdout_bytes=printed.total_bytes,
171
+ stderr_bytes=failed.total_bytes,
172
+ stdout_truncated=printed.truncated,
173
+ stderr_truncated=failed.truncated,
174
+ )