crates/kernel/ents-effect/src/sprite.rs
sprite.rshistorycomment on this file
| 1 | //! The Fly.io Sprite [`Executor`] backend (`effect.execution`, |
| 2 | //! `roots.hosted`): a persistent, hardware-isolated sandbox driven through |
| 3 | //! the `sprite` CLI, one Sprite kept per deployment so a toolchain's |
| 4 | //! extracted bytes survive between runs (`ents-kiln`'s toolchain |
| 5 | //! `materialize`'s host-side cache has a Sprite-side mirror, `sync_dir`'s |
| 6 | //! extract-once check). |
| 7 | //! |
| 8 | //! Ported from `pre-redo`'s `git-effect::engine` Sprite half: the CLI |
| 9 | //! authentication quirk ([`ensure_auth`]), the idempotent-create quirk |
| 10 | //! ([`ensure_sprite`]), and the orphan-process-kill quirk in |
| 11 | //! `unpack_script` all carry over verbatim — these are exactly the |
| 12 | //! "environment is the risk" gotchas the development plan calls out. |
| 13 | //! Rewritten against this phase's design: no `git archive` (this crate |
| 14 | //! never assumes an on-disk `.git`, `arch.no-object-store-trait`) — the |
| 15 | //! workdir and each toolchain are materialized to a host directory first |
| 16 | //! (the same [`crate::materialize::checkout`] and `ents-kiln`'s toolchain |
| 17 | //! `materialize` every backend shares), then `tar`'d |
| 18 | //! from that host directory into the Sprite over `sprite exec`'s stdin; |
| 19 | //! and no PTY/asciicast live-streaming (`effect.adoc` names no such |
| 20 | //! requirement for this phase — deferred to `ents-web`, which owns any |
| 21 | //! live view). |
| 22 | |
| 23 | use std::path::Path; |
| 24 | use std::process::{Command, Stdio}; |
| 25 | |
| 26 | use crate::error::{Error, Result}; |
| 27 | use crate::executor::{ |
| 28 | Executor, RunOutput, SandboxInputs, activate, inject_env, parse_exit_marker, wrap_exit_marker, |
| 29 | }; |
| 30 | |
| 31 | /// Where the workdir is unpacked inside the Sprite. |
| 32 | pub const WORKDIR: &str = "/work"; |
| 33 | |
| 34 | /// Where a toolchain's `bin/` is extracted inside the Sprite, one directory |
| 35 | /// per content key (`{TOOLCHAINS_DIR}/<key>/bin`) — never cleared: the |
| 36 | /// Sprite's persistent filesystem is the cache. |
| 37 | pub const TOOLCHAINS_DIR: &str = "/toolchains"; |
| 38 | |
| 39 | /// The env var the hosted worker passes the `sprite` CLI's auth token |
| 40 | /// through, per [`ensure_auth`]. |
| 41 | pub const SPRITES_TOKEN_VAR: &str = "SPRITES_TOKEN"; |
| 42 | |
| 43 | /// A Sprite name derived from `seed`, kept to the `[a-z0-9-]` a Sprite name |
| 44 | /// allows so the same seed (a deployment id, a repository path) always |
| 45 | /// reuses the same sandbox. |
| 46 | /// |
| 47 | /// # Examples |
| 48 | /// |
| 49 | /// ``` |
| 50 | /// use ents_effect::sprite::sprite_name; |
| 51 | /// |
| 52 | /// assert_eq!(sprite_name("git-ents.cloud"), "ents-effect-git-ents-cloud"); |
| 53 | /// assert_eq!(sprite_name(""), "ents-effect-sprite"); |
| 54 | /// ``` |
| 55 | #[must_use] |
| 56 | pub fn sprite_name(seed: &str) -> String { |
| 57 | let sanitized: String = seed |
| 58 | .chars() |
| 59 | .map(|c| { |
| 60 | if c.is_ascii_alphanumeric() { |
| 61 | c.to_ascii_lowercase() |
| 62 | } else { |
| 63 | '-' |
| 64 | } |
| 65 | }) |
| 66 | .collect(); |
| 67 | let trimmed = sanitized.trim_matches('-'); |
| 68 | format!( |
| 69 | "ents-effect-{}", |
| 70 | if trimmed.is_empty() { |
| 71 | "sprite" |
| 72 | } else { |
| 73 | trimmed |
| 74 | } |
| 75 | ) |
| 76 | } |
| 77 | |
| 78 | /// Configure the `sprite` CLI from [`SPRITES_TOKEN_VAR`]. The CLI persists |
| 79 | /// its credentials to a config file rather than reading the token per |
| 80 | /// call, so without this it reports "no organizations configured" even |
| 81 | /// with the token in the environment. `auth setup` is idempotent, so a |
| 82 | /// caller may run this before every batch of runs to keep the steady state |
| 83 | /// self-healing. |
| 84 | /// |
| 85 | /// # Errors |
| 86 | /// |
| 87 | /// [`Error::Process`] if [`SPRITES_TOKEN_VAR`] is unset, or the CLI ran and |
| 88 | /// refused it; [`Error::Spawn`] if the CLI could not be started. |
| 89 | pub fn ensure_auth() -> Result<()> { |
| 90 | let token = std::env::var(SPRITES_TOKEN_VAR).map_err(|_unset| Error::Process { |
| 91 | program: "sprite".to_owned(), |
| 92 | detail: format!("{SPRITES_TOKEN_VAR} is not set in the worker's environment"), |
| 93 | })?; |
| 94 | let output = Command::new("sprite") |
| 95 | .args(["auth", "setup", "--token", &token]) |
| 96 | .output() |
| 97 | .map_err(|e| Error::Spawn { |
| 98 | program: "sprite".to_owned(), |
| 99 | detail: e.to_string(), |
| 100 | })?; |
| 101 | if output.status.success() { |
| 102 | Ok(()) |
| 103 | } else { |
| 104 | Err(Error::Process { |
| 105 | program: "sprite".to_owned(), |
| 106 | detail: format!( |
| 107 | "auth setup failed: {}", |
| 108 | String::from_utf8_lossy(&output.stderr).trim() |
| 109 | ), |
| 110 | }) |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | /// Create the Sprite named `name` if it does not already exist. |
| 115 | /// `sprite create` fails when the Sprite is already there — the steady |
| 116 | /// state once the first run has happened — so its failure is tolerated |
| 117 | /// here and surfaces only later if the Sprite turns out unreachable. |
| 118 | /// |
| 119 | /// # Errors |
| 120 | /// |
| 121 | /// [`Error::Spawn`] if the CLI could not be started. |
| 122 | pub fn ensure_sprite(name: &str) -> Result<()> { |
| 123 | let _existing = Command::new("sprite") |
| 124 | .args(["create", "--skip-console", name]) |
| 125 | .output() |
| 126 | .map_err(|e| Error::Spawn { |
| 127 | program: "sprite".to_owned(), |
| 128 | detail: e.to_string(), |
| 129 | })?; |
| 130 | Ok(()) |
| 131 | } |
| 132 | |
| 133 | /// The in-Sprite script [`sync_dir`] runs to replace `dest`'s contents with |
| 134 | /// the tar streamed over stdin, first killing any process still working |
| 135 | /// under `dest`: a worker killed mid-run (a deploy, a restart) leaves its |
| 136 | /// in-Sprite build processes alive, since `sprite exec` only tethers the |
| 137 | /// local CLI process — an orphaned build still writing under `dest` races |
| 138 | /// the wipe, failing `rm -rf` with "Directory not empty". |
| 139 | fn unpack_script(dest: &str) -> String { |
| 140 | format!( |
| 141 | "for cwd in /proc/[0-9]*/cwd; do\n\ |
| 142 | case \"$(readlink \"$cwd\" 2>/dev/null)\" in\n\ |
| 143 | {dest}|{dest}/*) kill -9 \"$(basename \"${{cwd%/cwd}}\")\" 2>/dev/null || true ;;\n\ |
| 144 | esac\n\ |
| 145 | done\n\ |
| 146 | rm -rf {dest} && mkdir -p {dest} && tar -x -C {dest}" |
| 147 | ) |
| 148 | } |
| 149 | |
| 150 | /// Stream `host_dir`'s contents into the Sprite `name` at `dest`, replacing |
| 151 | /// whatever was there — used both for the workdir (always re-synced: a |
| 152 | /// fresh checkout per run) and, via [`sync_toolchain`], for a toolchain not |
| 153 | /// already cached in-Sprite. |
| 154 | /// |
| 155 | /// # Errors |
| 156 | /// |
| 157 | /// [`Error::Spawn`] if `tar` or `sprite` could not be started; |
| 158 | /// [`Error::Process`] if either exited nonzero. |
| 159 | fn sync_dir(host_dir: &Path, name: &str, dest: &str) -> Result<()> { |
| 160 | let mut archive = Command::new("tar") |
| 161 | .args(["-c", "-C"]) |
| 162 | .arg(host_dir) |
| 163 | .arg(".") |
| 164 | .stdout(Stdio::piped()) |
| 165 | .spawn() |
| 166 | .map_err(|e| Error::Spawn { |
| 167 | program: "tar".to_owned(), |
| 168 | detail: e.to_string(), |
| 169 | })?; |
| 170 | let tar_stdout = archive.stdout.take().ok_or_else(|| Error::Process { |
| 171 | program: "tar".to_owned(), |
| 172 | detail: "no stdout".to_owned(), |
| 173 | })?; |
| 174 | |
| 175 | let unpack = Command::new("sprite") |
| 176 | .args(["exec", "-s", name, "--", "sh", "-c", &unpack_script(dest)]) |
| 177 | .stdin(Stdio::from(tar_stdout)) |
| 178 | .output() |
| 179 | .map_err(|e| Error::Spawn { |
| 180 | program: "sprite".to_owned(), |
| 181 | detail: e.to_string(), |
| 182 | })?; |
| 183 | |
| 184 | let tar_status = archive.wait().map_err(|e| Error::Process { |
| 185 | program: "tar".to_owned(), |
| 186 | detail: e.to_string(), |
| 187 | })?; |
| 188 | if !tar_status.success() { |
| 189 | return Err(Error::Process { |
| 190 | program: "tar".to_owned(), |
| 191 | detail: format!("could not archive {}", host_dir.display()), |
| 192 | }); |
| 193 | } |
| 194 | if !unpack.status.success() { |
| 195 | return Err(Error::Process { |
| 196 | program: "sprite".to_owned(), |
| 197 | detail: format!( |
| 198 | "could not sync into {dest}: {}", |
| 199 | String::from_utf8_lossy(&unpack.stderr).trim() |
| 200 | ), |
| 201 | }); |
| 202 | } |
| 203 | Ok(()) |
| 204 | } |
| 205 | |
| 206 | /// Extract-once sync of one toolchain's `bin/` directory into the Sprite |
| 207 | /// `name`, at `{TOOLCHAINS_DIR}/<key>/bin` — a directory already present |
| 208 | /// from an earlier run is left alone rather than re-extracted, since the |
| 209 | /// Sprite's persistent filesystem is the cache. `key` is the same content |
| 210 | /// key `ents-kiln`'s toolchain `materialize` cached `host_bin`'s parent |
| 211 | /// directory under, so two runs of the same toolchain content sync it at |
| 212 | /// most once. |
| 213 | /// |
| 214 | /// # Errors |
| 215 | /// |
| 216 | /// See [`sync_dir`]. |
| 217 | fn sync_toolchain(host_bin: &Path, name: &str, key: &str) -> Result<String> { |
| 218 | let sandbox_dir = format!("{TOOLCHAINS_DIR}/{key}/bin"); |
| 219 | let cached = Command::new("sprite") |
| 220 | .args([ |
| 221 | "exec", |
| 222 | "-s", |
| 223 | name, |
| 224 | "--", |
| 225 | "sh", |
| 226 | "-c", |
| 227 | &format!("[ -d {sandbox_dir} ]"), |
| 228 | ]) |
| 229 | .status() |
| 230 | .map_err(|e| Error::Spawn { |
| 231 | program: "sprite".to_owned(), |
| 232 | detail: e.to_string(), |
| 233 | })?; |
| 234 | if !cached.success() { |
| 235 | sync_dir(host_bin, name, &sandbox_dir)?; |
| 236 | } |
| 237 | Ok(sandbox_dir) |
| 238 | } |
| 239 | |
| 240 | /// The content key `ents-kiln`'s toolchain `materialize` cached `host_bin` |
| 241 | /// under — `host_bin`'s parent directory name, since `materialize` always |
| 242 | /// returns `<cache_root>/<key>/bin`. |
| 243 | fn content_key(host_bin: &Path) -> Result<String> { |
| 244 | host_bin |
| 245 | .parent() |
| 246 | .and_then(Path::file_name) |
| 247 | .and_then(std::ffi::OsStr::to_str) |
| 248 | .map(str::to_owned) |
| 249 | .ok_or_else(|| Error::Process { |
| 250 | program: "sprite".to_owned(), |
| 251 | detail: format!( |
| 252 | "{} is not a materialize()-shaped toolchain directory", |
| 253 | host_bin.display() |
| 254 | ), |
| 255 | }) |
| 256 | } |
| 257 | |
| 258 | /// [`Executor`] running each effect in a persistent, hardware-isolated Fly |
| 259 | /// Sprite (`roots.hosted`). |
| 260 | #[derive(Debug, Clone)] |
| 261 | pub struct SpriteExecutor { |
| 262 | /// The Sprite's name, from [`sprite_name`] or chosen by the |
| 263 | /// composition root. |
| 264 | pub name: String, |
| 265 | } |
| 266 | |
| 267 | impl SpriteExecutor { |
| 268 | /// A Sprite executor targeting the Sprite named `name`. |
| 269 | #[must_use] |
| 270 | pub fn new(name: impl Into<String>) -> Self { |
| 271 | Self { name: name.into() } |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | /// The in-Sprite script for one run: enter the synced workdir, run the |
| 276 | /// activated command — with `env`'s pairs exported first |
| 277 | /// ([`inject_env`], `roots.config-isolation`: a per-member BYOK credential |
| 278 | /// the composition root resolved, injected only here, at sandbox launch, |
| 279 | /// never written to any tree this crate builds) — wrapped by |
| 280 | /// [`wrap_exit_marker`]. A `cd` failure (the workdir sync silently lost) |
| 281 | /// exits before the marker can print, so it surfaces as infrastructure, not |
| 282 | /// as a recorded `fail` — the same discrimination the marker gives a dying |
| 283 | /// transport (`effect.result-taxonomy`). |
| 284 | fn run_script(activated: &str, env: &[(String, String)]) -> String { |
| 285 | format!( |
| 286 | "cd {WORKDIR} || exit 70\n{}", |
| 287 | wrap_exit_marker(&inject_env(activated, env)) |
| 288 | ) |
| 289 | } |
| 290 | |
| 291 | impl Executor for SpriteExecutor { |
| 292 | // @relation(effect.result-taxonomy, scope=function) |
| 293 | fn run(&self, inputs: &SandboxInputs<'_>) -> Result<RunOutput> { |
| 294 | ensure_auth()?; |
| 295 | ensure_sprite(&self.name)?; |
| 296 | sync_dir(inputs.workdir, &self.name, WORKDIR)?; |
| 297 | |
| 298 | let mut sandbox_dirs: Vec<(String, String)> = Vec::with_capacity(inputs.toolchains.len()); |
| 299 | for (toolchain_name, host_bin) in inputs.toolchains { |
| 300 | let key = content_key(host_bin)?; |
| 301 | let sandbox_dir = sync_toolchain(host_bin, &self.name, &key)?; |
| 302 | sandbox_dirs.push((toolchain_name.clone(), sandbox_dir)); |
| 303 | } |
| 304 | |
| 305 | let script = run_script(&activate(inputs.command, &sandbox_dirs), inputs.env); |
| 306 | let output = Command::new("sprite") |
| 307 | .args(["exec", "-s", &self.name, "--", "sh", "-c", &script]) |
| 308 | .output() |
| 309 | .map_err(|e| Error::Spawn { |
| 310 | program: "sprite".to_owned(), |
| 311 | detail: e.to_string(), |
| 312 | })?; |
| 313 | let stdout = String::from_utf8_lossy(&output.stdout); |
| 314 | // The marker, not `sprite exec`'s exit status, is the completion |
| 315 | // signal: the CLI exits nonzero for its own transport failures too, |
| 316 | // which must surface as an infrastructure error, never a recorded |
| 317 | // `fail` (`effect.result-taxonomy`). |
| 318 | parse_exit_marker(&stdout).ok_or_else(|| { |
| 319 | Error::Sandbox(format!( |
| 320 | "sprite exec did not complete the command (exit {:?}): {}", |
| 321 | output.status.code(), |
| 322 | String::from_utf8_lossy(&output.stderr).trim() |
| 323 | )) |
| 324 | }) |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | #[cfg(test)] |
| 329 | mod tests { |
| 330 | #![allow(clippy::expect_used, reason = "unit test")] |
| 331 | |
| 332 | use rstest::rstest; |
| 333 | |
| 334 | use super::*; |
| 335 | |
| 336 | #[rstest] |
| 337 | #[case::normal("git-ents.cloud", "ents-effect-git-ents-cloud")] |
| 338 | #[case::empty("", "ents-effect-sprite")] |
| 339 | #[case::only_punctuation("///", "ents-effect-sprite")] |
| 340 | #[case::mixed_case("Repo_Name", "ents-effect-repo-name")] |
| 341 | // @relation(effect.execution, scope=function, role=Verifies) |
| 342 | fn sprite_name_sanitizes_to_a_valid_shape(#[case] seed: &str, #[case] expected: &str) { |
| 343 | assert_eq!(sprite_name(seed), expected); |
| 344 | } |
| 345 | |
| 346 | #[rstest] |
| 347 | // @relation(effect.result-taxonomy, scope=function, role=Verifies) |
| 348 | fn run_script_gates_the_exit_marker_on_a_successful_cd() { |
| 349 | let script = run_script("cargo test", &[]); |
| 350 | // A failed cd exits before the marker can print, so a lost workdir |
| 351 | // sync surfaces as infrastructure, never as a recorded fail. |
| 352 | assert!(script.starts_with("cd /work || exit 70\n")); |
| 353 | assert!(script.contains(crate::executor::EXIT_MARKER)); |
| 354 | assert!(script.contains("cargo test")); |
| 355 | } |
| 356 | |
| 357 | #[rstest] |
| 358 | // @relation(roots.config-isolation, scope=function, role=Verifies) |
| 359 | fn run_script_exports_env_before_the_activated_command() { |
| 360 | let env = vec![("ANTHROPIC_API_KEY".to_owned(), "sk-ant-abc".to_owned())]; |
| 361 | let script = run_script("cargo test", &env); |
| 362 | assert!(script.contains("export ANTHROPIC_API_KEY='sk-ant-abc'; cargo test")); |
| 363 | } |
| 364 | |
| 365 | #[rstest] |
| 366 | // @relation(effect.execution, scope=function, role=Verifies) |
| 367 | fn unpack_script_kills_orphans_before_wiping_the_destination() { |
| 368 | let script = unpack_script("/work"); |
| 369 | assert!(script.contains("kill -9")); |
| 370 | assert!(script.contains("rm -rf /work && mkdir -p /work && tar -x -C /work")); |
| 371 | } |
| 372 | |
| 373 | #[rstest] |
| 374 | // @relation(effect.toolchains, scope=function, role=Verifies) |
| 375 | fn content_key_reads_the_materialize_cache_layout() { |
| 376 | let bin = Path::new("/cache/deadbeef/bin"); |
| 377 | assert_eq!(content_key(bin).expect("valid shape"), "deadbeef"); |
| 378 | } |
| 379 | |
| 380 | #[rstest] |
| 381 | // @relation(effect.toolchains, scope=function, role=Verifies) |
| 382 | fn content_key_rejects_a_path_with_no_parent() { |
| 383 | content_key(Path::new("/")).expect_err("no parent"); |
| 384 | } |
| 385 | } |