crates/kernel/ents-effect/src/executor.rs
executor.rshistorycomment 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 | |
| 13 | use std::path::{Path, PathBuf}; |
| 14 | |
| 15 | use 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)] |
| 21 | pub 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)] |
| 47 | pub 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)] |
| 56 | pub 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 | /// ``` |
| 98 | pub 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] |
| 124 | pub 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] |
| 159 | pub 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. |
| 175 | fn 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. |
| 185 | pub 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] |
| 211 | pub 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] |
| 239 | pub 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)] |
| 255 | mod 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 | } |