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,219 @@
|
|
|
1
|
+
"""Running a child process on the worker: its workspace, its environment, and its lifetime.
|
|
2
|
+
|
|
3
|
+
Three blocks run code on the worker -- ``shell.run`` a command, ``docker.compose`` the
|
|
4
|
+
compose CLI, ``docker.build`` buildx -- and all three want the same containment: a process in
|
|
5
|
+
a session of its own so what it starts dies with it, an environment built from an allowlist
|
|
6
|
+
rather than inherited wholesale, and a workspace of its own inside the run's work directory.
|
|
7
|
+
That shared machinery lives here so a block is only the argv it assembles and the output it
|
|
8
|
+
parses.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from dirigent_block_execute.capture import Drained, LiveLines, drain
|
|
19
|
+
from dirigent_block_execute.environment import allowed
|
|
20
|
+
from dirigent_block_execute.messages import TIMED_OUT
|
|
21
|
+
from dirigent_plugin import BlockFailure, ErrorClass, StepContext
|
|
22
|
+
|
|
23
|
+
#: Inherited whether or not a step's allowlist names them.
|
|
24
|
+
BASELINE_ENV = ("PATH", "LANG", "LC_ALL", "TZ")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def segment(ctx: StepContext, name: str) -> str:
|
|
28
|
+
"""The path this attempt's files sit at, under either of the run's two roots.
|
|
29
|
+
|
|
30
|
+
``{name}/{step}[/{item}]/attempt-{n}``. Both roots are the run's, so the step key and,
|
|
31
|
+
inside a fan-out, the item id are what keep two steps of one run and two items of one step
|
|
32
|
+
out of each other's files and working directories.
|
|
33
|
+
"""
|
|
34
|
+
item = f"/{ctx.run_item_id}" if ctx.run_item_id is not None else ""
|
|
35
|
+
return f"{name}/{ctx.step}{item}/attempt-{ctx.attempt}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def prefix(ctx: StepContext, name: str) -> str:
|
|
39
|
+
"""The storage URI this attempt's artifact names are built on, under the run's scratch prefix."""
|
|
40
|
+
return f"{ctx.scratch.rstrip('/')}/{segment(ctx, name)}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def workspace(ctx: StepContext, name: str) -> Path:
|
|
44
|
+
"""This attempt's working directory inside the run's work directory, made if absent."""
|
|
45
|
+
directory = ctx.work / segment(ctx, name)
|
|
46
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
return directory
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def environment(env_allowlist: list[str], env: dict[str, str], home: Path) -> dict[str, str]:
|
|
51
|
+
"""Build the process environment from an allowlist, never from wholesale inheritance."""
|
|
52
|
+
built = allowed({*BASELINE_ENV, *env_allowlist})
|
|
53
|
+
built["HOME"] = str(home)
|
|
54
|
+
built.update(env)
|
|
55
|
+
return built
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def run(
|
|
59
|
+
*,
|
|
60
|
+
directory: Path,
|
|
61
|
+
ctx: StepContext,
|
|
62
|
+
stdout_uri: str,
|
|
63
|
+
stderr_uri: str,
|
|
64
|
+
timeout_seconds: float,
|
|
65
|
+
environ: dict[str, str],
|
|
66
|
+
argv: list[str] | None = None,
|
|
67
|
+
command: str | None = None,
|
|
68
|
+
what: str = "the command",
|
|
69
|
+
redact: Sequence[str] = (),
|
|
70
|
+
) -> tuple[int, Drained, Drained]:
|
|
71
|
+
"""Start the process, drain it under the timeout, and leave nothing of it behind.
|
|
72
|
+
|
|
73
|
+
Both pipes are read as the process writes them and written straight through to storage,
|
|
74
|
+
so what is held is two bounded ends of each stream rather than all of it: a process that
|
|
75
|
+
prints more than the worker has memory for is a file, not a dead worker. Each stream's
|
|
76
|
+
first lines go into the run log where they are printed, so a long command is visible
|
|
77
|
+
working; ``redact`` names the strings it was handed that must not reach one of them.
|
|
78
|
+
|
|
79
|
+
The process leads a session of its own, so what it starts is killable with it: a command
|
|
80
|
+
is usually ``sh -c``, and killing the shell alone leaves the children doing the work.
|
|
81
|
+
|
|
82
|
+
Nothing survives this function. The block's own timeout is one way out of it; the
|
|
83
|
+
engine's step timeout, which cancels the coroutine from outside, is another, and a
|
|
84
|
+
process that outlived that would run on under a worker that had stopped waiting for it.
|
|
85
|
+
"""
|
|
86
|
+
started = (
|
|
87
|
+
asyncio.create_subprocess_shell(
|
|
88
|
+
command,
|
|
89
|
+
cwd=directory,
|
|
90
|
+
env=environ,
|
|
91
|
+
stdout=asyncio.subprocess.PIPE,
|
|
92
|
+
stderr=asyncio.subprocess.PIPE,
|
|
93
|
+
start_new_session=True,
|
|
94
|
+
)
|
|
95
|
+
if command is not None
|
|
96
|
+
else asyncio.create_subprocess_exec(
|
|
97
|
+
*(argv or []),
|
|
98
|
+
cwd=directory,
|
|
99
|
+
env=environ,
|
|
100
|
+
stdout=asyncio.subprocess.PIPE,
|
|
101
|
+
stderr=asyncio.subprocess.PIPE,
|
|
102
|
+
start_new_session=True,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
process = await started
|
|
106
|
+
pgid = group_of(process)
|
|
107
|
+
try:
|
|
108
|
+
async with asyncio.timeout(timeout_seconds):
|
|
109
|
+
async with ctx.storage.open_write(stdout_uri) as out_sink, ctx.storage.open_write(stderr_uri) as err_sink:
|
|
110
|
+
out, err = await asyncio.gather(
|
|
111
|
+
drain(
|
|
112
|
+
process.stdout,
|
|
113
|
+
out_sink,
|
|
114
|
+
head_limit=ctx.inline_capture,
|
|
115
|
+
live=LiveLines(ctx, "stdout", redact=redact),
|
|
116
|
+
),
|
|
117
|
+
drain(
|
|
118
|
+
process.stderr,
|
|
119
|
+
err_sink,
|
|
120
|
+
head_limit=ctx.inline_capture,
|
|
121
|
+
live=LiveLines(ctx, "stderr", redact=redact),
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
await process.wait()
|
|
125
|
+
except TimeoutError as error:
|
|
126
|
+
raise BlockFailure(TIMED_OUT, error_class=ErrorClass.TRANSIENT, what=what, seconds=timeout_seconds) from error
|
|
127
|
+
finally:
|
|
128
|
+
await end(process, pgid)
|
|
129
|
+
return process.returncode or 0, out, err
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def output(
|
|
133
|
+
*,
|
|
134
|
+
argv: list[str],
|
|
135
|
+
directory: Path,
|
|
136
|
+
environ: dict[str, str],
|
|
137
|
+
timeout_seconds: float,
|
|
138
|
+
what: str = "the command",
|
|
139
|
+
stdin: bytes | None = None,
|
|
140
|
+
) -> tuple[int, bytes, bytes]:
|
|
141
|
+
"""Run a short read-only command to completion and hand back both streams whole.
|
|
142
|
+
|
|
143
|
+
For the small, bounded reads a block makes of a tool's own state -- ``compose ps``,
|
|
144
|
+
``compose config`` -- where the answer is parsed rather than streamed to an artifact.
|
|
145
|
+
|
|
146
|
+
``stdin`` is written to the process and the pipe closed, which is how a credential
|
|
147
|
+
reaches a tool without ever being an argument.
|
|
148
|
+
"""
|
|
149
|
+
process = await asyncio.create_subprocess_exec(
|
|
150
|
+
*argv,
|
|
151
|
+
cwd=directory,
|
|
152
|
+
env=environ,
|
|
153
|
+
stdin=asyncio.subprocess.PIPE if stdin is not None else None,
|
|
154
|
+
stdout=asyncio.subprocess.PIPE,
|
|
155
|
+
stderr=asyncio.subprocess.PIPE,
|
|
156
|
+
start_new_session=True,
|
|
157
|
+
)
|
|
158
|
+
pgid = group_of(process)
|
|
159
|
+
try:
|
|
160
|
+
async with asyncio.timeout(timeout_seconds):
|
|
161
|
+
out, err = await process.communicate(stdin)
|
|
162
|
+
except TimeoutError as error:
|
|
163
|
+
raise BlockFailure(TIMED_OUT, error_class=ErrorClass.TRANSIENT, what=what, seconds=timeout_seconds) from error
|
|
164
|
+
finally:
|
|
165
|
+
await end(process, pgid)
|
|
166
|
+
return process.returncode or 0, out, err
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def group_of(process: asyncio.subprocess.Process) -> int:
|
|
170
|
+
"""The process group to signal at the end, read while the leader is certainly still alive.
|
|
171
|
+
|
|
172
|
+
``os.getpgid`` fails once the leader has been reaped, so the group id is taken at the
|
|
173
|
+
start and held: it outlives the leader for as long as any member of the group lives.
|
|
174
|
+
"""
|
|
175
|
+
with contextlib.suppress(OSError):
|
|
176
|
+
return os.getpgid(process.pid)
|
|
177
|
+
return process.pid # pragma: no cover - only reachable if the process is already gone
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def end(process: asyncio.subprocess.Process, pgid: int) -> None:
|
|
181
|
+
"""Kill the process group and reap it, whatever the reason for leaving.
|
|
182
|
+
|
|
183
|
+
The group is signalled whether or not the leader is still running: a shell that
|
|
184
|
+
backgrounded its work exits 0 while the work it started keeps the group alive, and that
|
|
185
|
+
work must not outlive the step. A group the worker may not signal is not one this can do
|
|
186
|
+
anything about, and neither that nor a group already gone is worth failing a step over.
|
|
187
|
+
"""
|
|
188
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
189
|
+
kill(pgid, process.pid)
|
|
190
|
+
with contextlib.suppress(ProcessLookupError):
|
|
191
|
+
await process.wait()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def kill(pgid: int, pid: int) -> None:
|
|
195
|
+
"""Kill a process group and everything in it, and never anything else.
|
|
196
|
+
|
|
197
|
+
The group is only the command's own because it was started in a session of its own. A
|
|
198
|
+
process that is not its group's leader shares the worker's group, and signalling that
|
|
199
|
+
would kill the worker: kill just the process instead rather than everything running
|
|
200
|
+
beside it.
|
|
201
|
+
"""
|
|
202
|
+
if pgid > 1 and pgid != os.getpgrp():
|
|
203
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
204
|
+
else: # pragma: no cover - only reachable if the process stops leading its own session
|
|
205
|
+
os.kill(pid, signal.SIGKILL)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
__all__ = [
|
|
209
|
+
"BASELINE_ENV",
|
|
210
|
+
"end",
|
|
211
|
+
"environment",
|
|
212
|
+
"group_of",
|
|
213
|
+
"kill",
|
|
214
|
+
"output",
|
|
215
|
+
"prefix",
|
|
216
|
+
"run",
|
|
217
|
+
"segment",
|
|
218
|
+
"workspace",
|
|
219
|
+
]
|
|
@@ -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,18 @@
|
|
|
1
|
+
dirigent_block_execute/__init__.py,sha256=oyHeIBQtuKRoIEAAfXhuYjFfNKxtNno3J8VAhsDWqio,1493
|
|
2
|
+
dirigent_block_execute/build.py,sha256=qDMgoqbkt1UvNv8waCTIvKYW8Amc2weAwu4RggVRgc4,13486
|
|
3
|
+
dirigent_block_execute/capture.py,sha256=jr22uPIr0c77nUoMUS0LW8hF2lWcloJsC30VflSqXyc,10553
|
|
4
|
+
dirigent_block_execute/compose.py,sha256=Q0w2EKdKa0t_fumdA7VP7JW8iewKHSRMGUhFvvK3cUc,27986
|
|
5
|
+
dirigent_block_execute/docker.py,sha256=HIH9UH7IZ5OJ9bX3Vvi3AuzAXH9XZ3I4uE3oUbTe9Ns,55425
|
|
6
|
+
dirigent_block_execute/environment.py,sha256=MYdecVOBgsjC3Nta2DxhZCyEjRdNKJwW3YIlmUDeirs,2178
|
|
7
|
+
dirigent_block_execute/git.py,sha256=cEKhCHYoEj9tdh44Yzm-O1Te-B2TxYR0zzeznp-TCDc,24385
|
|
8
|
+
dirigent_block_execute/messages.py,sha256=18OCaA_Vu-HMJ6zLif_C9kdWI3w_0vIy4shygETtTSY,6695
|
|
9
|
+
dirigent_block_execute/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
dirigent_block_execute/reap.py,sha256=9ChMp6xoEfX4XoGU63Sne088HnuYLiI4Kt0rXwcZpEo,6657
|
|
11
|
+
dirigent_block_execute/secrets.py,sha256=v5ZbNCip744tt9PI3hJwwf8cPejaChGDGM6fAZtqdq4,1439
|
|
12
|
+
dirigent_block_execute/shell.py,sha256=EuAZnTg58ZDNqfoVRUIAKzwb8Hv7z-ncGOOIV0mrkoU,6841
|
|
13
|
+
dirigent_block_execute/subprocess.py,sha256=3Sm2k1php_1QpflxDn5AhtT8QkE_DR948PrU9ueH4Jc,8620
|
|
14
|
+
dirigent_block_execute-0.17.1.dist-info/licenses/LICENSE,sha256=LKBm7Cx-WBc1zca4DjGxq99VEpAiWGnZDxIKmntn1hQ,910
|
|
15
|
+
dirigent_block_execute-0.17.1.dist-info/WHEEL,sha256=R1d3uUTbmXM1FHXH_itQashbrqrOSVj-hvBCpmkIIGE,81
|
|
16
|
+
dirigent_block_execute-0.17.1.dist-info/entry_points.txt,sha256=nxO22WndCTu18egcPThUNmLv7E8GlBu9owyAuWICGNU,69
|
|
17
|
+
dirigent_block_execute-0.17.1.dist-info/METADATA,sha256=QLxSTOpygL_OmzYLbWMLqH3s54ibhU1uZvgd_yGVprY,1248
|
|
18
|
+
dirigent_block_execute-0.17.1.dist-info/RECORD,,
|
|
@@ -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.
|