git-ents.gitmain
⌘K
foforge
sprite.rs385 lines · 14.1 KB · rusthistorycomment 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
23use std::path::Path;
24use std::process::{Command, Stdio};
25
26use crate::error::{Error, Result};
27use 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.
32pub 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.
37pub 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`].
41pub 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]
56pub 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.
89pub 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.
122pub 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".
139fn 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.
159fn 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`].
217fn 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`.
243fn 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)]
261pub struct SpriteExecutor {
262 /// The Sprite's name, from [`sprite_name`] or chosen by the
263 /// composition root.
264 pub name: String,
265}
266
267impl 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`).
284fn 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
291impl 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)]
329mod 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}