git-ents.gitmain
⌘K
foforge
executor.rs361 lines · 14.0 KB · rusthistorycomment on this file
1//! The `Executor` seam: one trait, multiple sandbox backends
2//! (`effect.execution`).
3//!
4//! No execution logic is duplicated per backend: [`Executor::run`] is
5//! handed a fully materialized workdir and a fully materialized set of
6//! toolchain directories (the former produced by
7//! [`crate::materialize::checkout`], the latter by `ents-kiln`'s
8//! toolchain `materialize` — the same code the run loop and `git effect
9//! run` share, `effect.local-run`), and does only the backend-specific
10//! part: get those bytes into the sandbox, run the command, and report
11//! what happened.
12
13use std::path::{Path, PathBuf};
14
15use crate::error::Result;
16
17/// The inputs one sandboxed run needs, already materialized on the host —
18/// a backend's only job is to get these into its sandbox and run
19/// [`SandboxInputs::command`].
20#[derive(Debug, Clone)]
21pub struct SandboxInputs<'a> {
22 /// The host directory holding the tested commit's checked-out tree.
23 pub workdir: &'a Path,
24 /// Each declared toolchain's name and the host directory holding its
25 /// activated `bin/` (`ents-kiln`'s toolchain `materialize`'s return
26 /// value), in the effect's declared order — the order [`activate`]
27 /// honors when two toolchains would otherwise collide on `PATH`.
28 pub toolchains: &'a [(String, PathBuf)],
29 /// The run command, exactly as stored on the effect definition
30 /// (`model.effect-definition`).
31 pub command: &'a str,
32 /// Extra environment variables to inject into the launched command,
33 /// deployment state a composition root resolves (a per-member BYOK
34 /// credential, `roots.config-isolation`) and hands down for exactly
35 /// this one run — never read from repository data, never written back
36 /// to it. Every backend injects these the same way it launches the
37 /// command at all; empty for every ordinary effect run, which has
38 /// nothing to inject.
39 pub env: &'a [(String, String)],
40}
41
42/// What a completed run reported. Only `Pass` or `Fail`
43/// (`effect.result-taxonomy`: "a completed command's exit status MUST
44/// always be recorded as a result"); an infrastructure failure — the
45/// sandbox never started — is [`crate::Error`], not a variant here.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum RunStatus {
48 /// The command exited zero.
49 Pass,
50 /// The command exited nonzero.
51 Fail,
52}
53
54/// The output of one completed sandboxed run.
55#[derive(Debug, Clone)]
56pub struct RunOutput {
57 /// Whether the command passed or failed.
58 pub status: RunStatus,
59 /// The command's combined stdout/stderr.
60 pub log: String,
61}
62
63/// One sandbox backend (`effect.execution`): Docker, Sprite, or
64/// unsandboxed host-direct — selected only at a composition root
65/// (`roots.local`, `roots.hosted`), never by effect data
66/// (`effect.deployment-property`).
67///
68/// # Errors
69///
70/// [`Executor::run`] returns `Err` only for an infrastructure failure —
71/// the sandbox never started, or crashed before the command could report
72/// an exit status. A command that ran to completion and merely exited
73/// nonzero is `Ok(RunOutput { status: RunStatus::Fail, .. })`, never an
74/// `Err` (`effect.result-taxonomy`).
75///
76/// # Examples
77///
78/// A minimal executor for tests: runs the command directly on the host, no
79/// sandbox at all (this is deliberately *not* [`crate::UnsandboxedExecutor`]
80/// — it ignores `toolchains` entirely — so it demonstrates only the trait
81/// shape, not the `--unsandboxed` contract).
82///
83/// ```
84/// use ents_effect::{Executor, RunStatus, SandboxInputs};
85///
86/// struct AlwaysPass;
87/// impl Executor for AlwaysPass {
88/// fn run(&self, _inputs: &SandboxInputs<'_>) -> ents_effect::Result<ents_effect::RunOutput> {
89/// Ok(ents_effect::RunOutput { status: RunStatus::Pass, log: String::new() })
90/// }
91/// }
92///
93/// let dir = tempfile::tempdir().expect("tempdir");
94/// let inputs = SandboxInputs { workdir: dir.path(), toolchains: &[], command: "true", env: &[] };
95/// let output = AlwaysPass.run(&inputs).expect("infallible");
96/// assert_eq!(output.status, RunStatus::Pass);
97/// ```
98pub trait Executor: Send + Sync {
99 /// Run `inputs.command` in this backend's sandbox, materialized from
100 /// `inputs.workdir` and `inputs.toolchains`.
101 fn run(&self, inputs: &SandboxInputs<'_>) -> Result<RunOutput>;
102}
103
104/// Prefix `command` with a `PATH` export activating `dirs`' entries,
105/// declared order first (so the first-listed toolchain's `bin` wins on a
106/// name collision) — ported from `pre-redo`'s `engine::activate`. `dirs`
107/// holds each toolchain's *in-sandbox* path (a backend maps its host
108/// [`SandboxInputs::toolchains`] entries to sandbox paths before calling
109/// this), so it is a plain string, not a [`Path`].
110///
111/// # Examples
112///
113/// ```
114/// use ents_effect::executor::activate;
115///
116/// let dirs = vec![("rust".to_owned(), "/toolchains/rust/bin".to_owned())];
117/// assert_eq!(
118/// activate("cargo test", &dirs),
119/// "export PATH=/toolchains/rust/bin:$PATH; cargo test"
120/// );
121/// assert_eq!(activate("true", &[]), "true");
122/// ```
123#[must_use]
124pub fn activate(command: &str, dirs: &[(String, String)]) -> String {
125 if dirs.is_empty() {
126 return command.to_owned();
127 }
128 let path = dirs
129 .iter()
130 .map(|(_, dir)| dir.as_str())
131 .collect::<Vec<_>>()
132 .join(":");
133 format!("export PATH={path}:$PATH; {command}")
134}
135
136/// Prefix `command` with an `export` for each of `env`'s pairs, single-quoted
137/// so an arbitrary secret value (a BYOK credential,
138/// [`SandboxInputs::env`]) round-trips through a `sh -c` script unmodified
139/// regardless of its own content — used by every backend that ships the
140/// command as one shell-script string to a remote or containerized shell
141/// ([`crate::sprite::SpriteExecutor`], [`crate::docker::DockerExecutor`]).
142/// The unsandboxed backend needs no such quoting: it sets the child
143/// process's environment directly via `Command::envs`, never folding a
144/// secret into shell source at all.
145///
146/// # Examples
147///
148/// ```
149/// use ents_effect::executor::inject_env;
150///
151/// let env = vec![("ANTHROPIC_API_KEY".to_owned(), "sk-ant-abc".to_owned())];
152/// assert_eq!(
153/// inject_env("run-agent", &env),
154/// "export ANTHROPIC_API_KEY='sk-ant-abc'; run-agent"
155/// );
156/// assert_eq!(inject_env("run-agent", &[]), "run-agent");
157/// ```
158#[must_use]
159pub fn inject_env(command: &str, env: &[(String, String)]) -> String {
160 if env.is_empty() {
161 return command.to_owned();
162 }
163 let exports = env
164 .iter()
165 .map(|(var, secret)| format!("export {var}={}", shell_single_quote(secret)))
166 .collect::<Vec<_>>()
167 .join("; ");
168 format!("{exports}; {command}")
169}
170
171/// Single-quote `value` for a POSIX shell, escaping any embedded `'` by
172/// closing the quote, emitting an escaped literal quote, and reopening it —
173/// the standard `'\''` trick, so a credential containing an apostrophe (or
174/// any other shell metacharacter) still round-trips as one literal string.
175fn shell_single_quote(value: &str) -> String {
176 format!("'{}'", value.replace('\'', r"'\''"))
177}
178
179/// The sentinel [`wrap_exit_marker`] appends after the wrapped command, so
180/// a CLI-driven backend can tell "the command completed and exited with
181/// this status" apart from "the CLI or its transport failed" — the
182/// distinction `effect.result-taxonomy` requires: a completed command's
183/// exit status is always a result, while an infrastructure failure must
184/// never be recorded as one.
185pub const EXIT_MARKER: &str = "__ENTS_EFFECT_EXIT=";
186
187/// Wrap `command` so its combined output ends with an [`EXIT_MARKER`] line
188/// carrying the command's own exit status, and the wrapping script itself
189/// always exits zero once the command has run to completion.
190///
191/// A backend that shells out to a CLI (`docker run`, `sprite exec`) cannot
192/// trust that CLI's exit status to be the command's: `docker run` exits
193/// 125 for the daemon's own failures, and a transport can die mid-stream
194/// and surface any status at all. With this wrapper, the marker's presence
195/// *is* the completion signal — present means the command ran and the
196/// marker carries its status ([`parse_exit_marker`]); absent means the
197/// sandbox never completed the run, which is [`crate::Error::Sandbox`],
198/// never a recorded result (`effect.result-taxonomy`).
199///
200/// # Examples
201///
202/// ```
203/// use ents_effect::executor::{EXIT_MARKER, wrap_exit_marker};
204///
205/// let script = wrap_exit_marker("cargo test");
206/// assert!(script.contains("cargo test"));
207/// assert!(script.contains(EXIT_MARKER));
208/// ```
209// @relation(effect.result-taxonomy, scope=function)
210#[must_use]
211pub fn wrap_exit_marker(command: &str) -> String {
212 format!("{{\n{command}\n}} 2>&1; printf '\\n{EXIT_MARKER}%s\\n' \"$?\"")
213}
214
215/// Read a completed run's status out of `log`, the combined output of a
216/// [`wrap_exit_marker`]-wrapped command: the last [`EXIT_MARKER`] line wins
217/// (a command may echo the marker itself; the wrapper's own line is always
218/// printed after it), and the marker line is stripped from the returned
219/// [`RunOutput::log`].
220///
221/// `None` means the marker never appeared — the sandbox did not complete
222/// the run, and the caller must report [`crate::Error::Sandbox`] rather
223/// than fabricate a `fail` (`effect.result-taxonomy`).
224///
225/// # Examples
226///
227/// ```
228/// use ents_effect::executor::{RunStatus, parse_exit_marker};
229///
230/// let done = parse_exit_marker("hello\n__ENTS_EFFECT_EXIT=0\n").expect("completed");
231/// assert_eq!(done.status, RunStatus::Pass);
232/// assert_eq!(done.log, "hello");
233///
234/// // No marker: the run never completed; this is not a result.
235/// assert!(parse_exit_marker("transport died").is_none());
236/// ```
237// @relation(effect.result-taxonomy, scope=function)
238#[must_use]
239pub fn parse_exit_marker(log: &str) -> Option<RunOutput> {
240 let idx = log.rfind(EXIT_MARKER)?;
241 let tail = log.get(idx.saturating_add(EXIT_MARKER.len())..)?;
242 let code: i32 = tail.lines().next()?.trim().parse().ok()?;
243 let cleaned = log.get(..idx).unwrap_or_default().trim_end().to_owned();
244 Some(RunOutput {
245 status: if code == 0 {
246 RunStatus::Pass
247 } else {
248 RunStatus::Fail
249 },
250 log: cleaned,
251 })
252}
253
254#[cfg(test)]
255mod tests {
256 #![allow(clippy::expect_used, reason = "unit test")]
257
258 use rstest::rstest;
259
260 use super::*;
261
262 #[rstest]
263 // @relation(effect.execution, scope=function, role=Verifies)
264 fn activate_prefixes_path_in_declared_order() {
265 let dirs = vec![
266 ("a".to_owned(), "/t/a/bin".to_owned()),
267 ("b".to_owned(), "/t/b/bin".to_owned()),
268 ];
269 assert_eq!(
270 activate("run", &dirs),
271 "export PATH=/t/a/bin:/t/b/bin:$PATH; run"
272 );
273 }
274
275 #[rstest]
276 // @relation(effect.execution, scope=function, role=Verifies)
277 fn activate_is_identity_with_no_toolchains() {
278 assert_eq!(activate("run", &[]), "run");
279 }
280
281 #[rstest]
282 // @relation(roots.config-isolation, scope=function, role=Verifies)
283 fn inject_env_is_identity_with_no_env() {
284 assert_eq!(inject_env("run", &[]), "run");
285 }
286
287 #[rstest]
288 // @relation(roots.config-isolation, scope=function, role=Verifies)
289 fn inject_env_exports_every_pair_before_the_command() {
290 let env = vec![
291 ("A".to_owned(), "one".to_owned()),
292 ("B".to_owned(), "two".to_owned()),
293 ];
294 assert_eq!(
295 inject_env("run", &env),
296 "export A='one'; export B='two'; run"
297 );
298 }
299
300 #[rstest]
301 // @relation(roots.config-isolation, scope=function, role=Verifies)
302 fn inject_env_round_trips_a_secret_with_an_embedded_quote_through_a_real_shell() {
303 let env = vec![("SECRET".to_owned(), "it's a secret".to_owned())];
304 let script = inject_env("printf '%s' \"$SECRET\"", &env);
305 let output = std::process::Command::new("sh")
306 .arg("-c")
307 .arg(&script)
308 .output()
309 .expect("sh runs");
310 assert_eq!(String::from_utf8_lossy(&output.stdout), "it's a secret");
311 }
312
313 #[rstest]
314 #[case::pass("out\n__ENTS_EFFECT_EXIT=0\n", Some((RunStatus::Pass, "out")))]
315 #[case::fail("out\n__ENTS_EFFECT_EXIT=1\n", Some((RunStatus::Fail, "out")))]
316 #[case::high_exit("__ENTS_EFFECT_EXIT=127\n", Some((RunStatus::Fail, "")))]
317 #[case::no_marker_is_not_a_result("transport died mid-stream", None)]
318 #[case::garbled_marker_is_not_a_result("__ENTS_EFFECT_EXIT=oops\n", None)]
319 #[case::empty("", None)]
320 // @relation(effect.result-taxonomy, scope=function, role=Verifies)
321 fn parse_exit_marker_separates_completion_from_infrastructure(
322 #[case] log: &str,
323 #[case] expected: Option<(RunStatus, &str)>,
324 ) {
325 let parsed = parse_exit_marker(log);
326 match expected {
327 Some((status, cleaned)) => {
328 let run = parsed.expect("marker present means the run completed");
329 assert_eq!(run.status, status);
330 assert_eq!(run.log, cleaned);
331 }
332 None => assert!(parsed.is_none(), "no marker must never become a result"),
333 }
334 }
335
336 #[rstest]
337 // @relation(effect.result-taxonomy, scope=function, role=Verifies)
338 fn parse_exit_marker_takes_the_last_marker_when_the_command_echoes_one() {
339 let log = "echoing __ENTS_EFFECT_EXIT=1 for fun\n__ENTS_EFFECT_EXIT=0\n";
340 let run = parse_exit_marker(log).expect("completed");
341 assert_eq!(run.status, RunStatus::Pass);
342 }
343
344 #[rstest]
345 // @relation(effect.result-taxonomy, scope=function, role=Verifies)
346 fn wrap_then_parse_round_trips_through_a_real_shell() {
347 for (command, expected) in [("true", RunStatus::Pass), ("false", RunStatus::Fail)] {
348 let output = std::process::Command::new("sh")
349 .arg("-c")
350 .arg(wrap_exit_marker(command))
351 .output()
352 .expect("sh runs");
353 // The wrapper itself exits zero once the command has run to
354 // completion, whatever the command's own status was.
355 assert!(output.status.success());
356 let stdout = String::from_utf8_lossy(&output.stdout);
357 let run = parse_exit_marker(&stdout).expect("completed");
358 assert_eq!(run.status, expected, "command {command:?}");
359 }
360 }
361}