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,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
|
+
)
|