Skip to main content

choir_queue/
executor.rs

1//! The CI executor seam (D18): what runs a candidate merge, and what it
2//! is allowed to say about the result.
3//!
4//! This replaces a `fn verdict(&Change, &str) -> bool` that could not
5//! carry a real executor. Three things the bool could not express, each
6//! of which changed a decision the queue makes:
7//!
8//! 1. **A provider fault is not a test failure.** A VM that fails to
9//!    boot and a test that legitimately fails both returned `false`, and
10//!    the queue ejected the change either way — along with everything
11//!    that transitively depended on it. [`Verdict::evicts`] is now the
12//!    single place that rule lives, and only [`Verdict::Failed`] says
13//!    yes.
14//! 2. **A batch is the unit, so a provider may be concurrent.** The
15//!    train's cost model assumes every member's CI runs in parallel;
16//!    a one-job-at-a-time method with `&mut self` made that impossible
17//!    to implement no matter what the model said.
18//! 3. **A job is content-addressed**, so a shared build cache has
19//!    something to key on. A queue-local change id does not survive
20//!    being asked the same question twice.
21//!
22//! # Examples
23//!
24//! ```
25//! use choir_queue::executor::{CiExecutor, Job, Synthetic, Verdict};
26//! use choir_hash::ContentHash;
27//!
28//! let mut ci = Synthetic::passing();
29//! let job = Job::new(ContentHash::blake3(b"tree"), vec!["true".into()]);
30//! assert_eq!(ci.run(&[job]).unwrap(), vec![Verdict::Passed]);
31//! ```
32
33use choir_hash::ContentHash;
34use std::collections::BTreeMap;
35use std::path::PathBuf;
36use std::time::Duration;
37
38/// Wire-protocol version a provider must echo before receiving work.
39///
40/// A provider built against a different schema is refused at the
41/// handshake rather than discovered through a verdict that means
42/// something else than it appears to.
43///
44/// Version 2 added [`Job::directory`]. That is why the number moved for
45/// what looks like an additive field: a version-1 helper does not know
46/// the key, so it runs the job in whatever directory it happens to be
47/// in and returns a verdict that is well-formed, index-aligned, and
48/// about the wrong tree. The handshake exists for exactly the changes
49/// a silent default would survive.
50pub const PROTOCOL: u32 = 2;
51
52/// Default wall-clock ceiling for one job.
53pub const DEFAULT_DEADLINE: Duration = Duration::from_secs(600);
54
55/// One unit of work, addressed by content so a shared cache can hit.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Job {
58    /// Content address of the speculative tree state under test.
59    pub subject: ContentHash,
60    /// What this job is testing, for reports and for an operator
61    /// reading a failure. Typically the change id.
62    ///
63    /// Deliberately outside [`Job::cache_key`]: two jobs testing
64    /// byte-identical trees with the same command are the same work
65    /// whichever change produced them, and that is precisely the case a
66    /// shared cache exists to collapse. A label inside the key would
67    /// make every job unique and the cache useless.
68    pub label: String,
69    /// The command, as argv. Never a shell string: the same rule
70    /// [`crate::differential`] already follows, for the same reason.
71    pub command: Vec<String>,
72    /// Exactly what the child process sees. Never inherited from this
73    /// process, so a job's result cannot depend on the environment of
74    /// whoever happened to run the queue.
75    pub environment: BTreeMap<String, String>,
76    /// Where the command runs.
77    ///
78    /// `None` leaves the choice to the provider, which is what a
79    /// self-provisioning executor wants: a microVM materializes
80    /// [`Job::subject`] itself and the host has no path to name. A
81    /// provider that runs on this machine has nothing to materialize
82    /// from, so a caller with a checkout on disk -- the merge train,
83    /// the forge bridge -- names it here. Without this field the seam
84    /// could only run commands that are correct from any directory,
85    /// which is no build command at all.
86    pub directory: Option<PathBuf>,
87    /// Wall-clock ceiling. Exceeding it is [`Verdict::TimedOut`], which
88    /// is a provider outcome and not a statement about the change.
89    pub deadline: Duration,
90    /// Whether this job's artifacts may be written to a shared build
91    /// cache.
92    ///
93    /// False for untrusted and fork builds. This is the CREEP-class
94    /// mitigation ("only trusted executors write the action cache")
95    /// expressed as a property of the job rather than as a deployment
96    /// note, because a deployment note is not enforcement.
97    pub may_write_cache: bool,
98}
99
100impl Job {
101    /// A job with the documented defaults: a full deadline, an empty
102    /// environment, and no permission to write the shared cache.
103    ///
104    /// Cache-write permission is opt-in rather than opt-out on purpose.
105    /// The failure mode of the safe default is a slow build; the
106    /// failure mode of the other one is a poisoned artifact.
107    #[must_use]
108    pub fn new(subject: ContentHash, command: Vec<String>) -> Self {
109        Self {
110            subject,
111            label: String::new(),
112            command,
113            environment: BTreeMap::new(),
114            directory: None,
115            deadline: DEFAULT_DEADLINE,
116            may_write_cache: false,
117        }
118    }
119
120    /// Content address of the work, for a shared cache to key on.
121    ///
122    /// Covers what determines the output — the tree, the command, the
123    /// environment, the directory — and deliberately not `deadline` or
124    /// `may_write_cache`, which govern *how* the job may run rather
125    /// than what it computes. Two jobs that differ only in how long
126    /// they are allowed to take are the same question.
127    ///
128    /// [`Job::directory`] is in the key and [`Job::label`] is not,
129    /// which is the same test applied twice: a label is not observable
130    /// to the command, and the working directory is. Toolchains write
131    /// absolute paths into what they build — rustc puts them in debug
132    /// info — so two identical trees checked out at different paths can
133    /// produce artifacts that differ. One key over both of them is the
134    /// false sharing [`Job::may_write_cache`] exists to bound, arrived
135    /// at from the honest direction instead of the malicious one.
136    #[must_use]
137    pub fn cache_key(&self) -> ContentHash {
138        let mut bytes = Vec::new();
139        push_field(&mut bytes, self.subject.to_hex().as_bytes());
140        push_len(&mut bytes, self.command.len());
141        for arg in &self.command {
142            push_field(&mut bytes, arg.as_bytes());
143        }
144        push_len(&mut bytes, self.environment.len());
145        for (k, v) in &self.environment {
146            push_field(&mut bytes, k.as_bytes());
147            push_field(&mut bytes, v.as_bytes());
148        }
149        // A count rather than a plain field, so that "no directory" and
150        // "the empty directory" are different preimages instead of the
151        // same zero-length one.
152        match &self.directory {
153            None => push_len(&mut bytes, 0),
154            Some(dir) => {
155                push_len(&mut bytes, 1);
156                push_field(&mut bytes, dir.as_os_str().as_encoded_bytes());
157            }
158        }
159        ContentHash::blake3(&bytes)
160    }
161}
162
163/// Write a count into the cache-key preimage.
164fn push_len(bytes: &mut Vec<u8>, len: usize) {
165    bytes.extend_from_slice(&(len as u64).to_le_bytes());
166}
167
168/// Write one length-prefixed field into the cache-key preimage.
169///
170/// Separators are not enough here. With a `\0` written between
171/// arguments, `["a", "b"]` and `["a\0b"]` produce the same preimage,
172/// and a Rust `String` may contain a NUL. Two different commands
173/// sharing one cache key is exactly the first-to-cache-wins poisoning
174/// that [`Job::may_write_cache`] exists to bound, so the framing has to
175/// be unambiguous rather than merely conventional. The counts do the
176/// same job for the boundary between argv and the environment.
177fn push_field(bytes: &mut Vec<u8>, field: &[u8]) {
178    push_len(bytes, field.len());
179    bytes.extend_from_slice(field);
180}
181
182/// What an executor found. Four cases, because the queue's response to
183/// each of them differs.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub enum Verdict {
186    /// The command ran and exited zero.
187    Passed,
188    /// The command ran and exited nonzero. A statement about the change.
189    Failed {
190        /// Exit code, or `None` when the process ended by signal.
191        exit_code: Option<i32>,
192    },
193    /// The provider could not produce a verdict at all. A statement
194    /// about us, never about the change.
195    Errored {
196        /// Which provider failed, for an operator reading the report.
197        provider: String,
198        /// What went wrong.
199        detail: String,
200    },
201    /// The job's deadline elapsed. Also not a statement about the
202    /// change: a job may time out because the executor was oversubscribed.
203    TimedOut,
204}
205
206impl Verdict {
207    /// Whether this verdict may evict the change from the train.
208    ///
209    /// The whole reason [`Verdict`] is not a bool. Ejection is
210    /// permanent for the change *and everything that transitively
211    /// depends on it*, so it is reserved for the one case that is
212    /// actually a statement about the change's content.
213    #[must_use]
214    pub fn evicts(&self) -> bool {
215        matches!(self, Verdict::Failed { .. })
216    }
217
218    /// The durable form of this verdict (D49).
219    ///
220    /// Total, and the mapping is the seam's claim restated: a deadline
221    /// is a provider outcome, so `TimedOut` records as `Errored` rather
222    /// than as a check the commit failed. Nothing here produces
223    /// `CheckStatus::Running` -- a `Verdict` is by definition an
224    /// executor that has finished answering.
225    #[must_use]
226    pub fn as_check_status(&self) -> choir_view::CheckStatus {
227        match self {
228            Verdict::Passed => choir_view::CheckStatus::Passed,
229            Verdict::Failed { .. } => choir_view::CheckStatus::Failed,
230            Verdict::Errored { .. } | Verdict::TimedOut => choir_view::CheckStatus::Errored,
231        }
232    }
233
234    /// Whether the executor answered the question it was asked.
235    ///
236    /// `Passed` and `Failed` are answers. `Errored` and `TimedOut` are
237    /// the absence of one, and a train holding either must stall rather
238    /// than draw a conclusion.
239    #[must_use]
240    pub fn is_conclusive(&self) -> bool {
241        matches!(self, Verdict::Passed | Verdict::Failed { .. })
242    }
243}
244
245impl std::fmt::Display for Verdict {
246    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247        match self {
248            Verdict::Passed => write!(f, "passed"),
249            Verdict::Failed { exit_code: Some(c) } => write!(f, "failed (exit {c})"),
250            Verdict::Failed { exit_code: None } => write!(f, "failed (killed by signal)"),
251            Verdict::Errored { provider, detail } => write!(f, "{provider}: {detail}"),
252            Verdict::TimedOut => write!(f, "timed out"),
253        }
254    }
255}
256
257/// Who a provider says it is, established once before any job.
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct ExecutorInfo {
260    /// Provider name, for reports and for operator diagnosis.
261    pub name: String,
262    /// Protocol version the provider speaks. Must equal [`PROTOCOL`].
263    pub protocol: u32,
264}
265
266/// Why an executor produced no verdicts at all.
267///
268/// Distinct from [`Verdict::Errored`], which is a per-job fault: these
269/// are faults of the whole call, and none of them is evidence about any
270/// change.
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub enum ExecutorError {
273    /// The provider could not be reached or did not complete a
274    /// handshake.
275    Unavailable(String),
276    /// The provider answered, but not in the shape the seam requires --
277    /// a version it does not speak, or a verdict count that does not
278    /// match the jobs it was given.
279    Protocol(String),
280}
281
282impl std::fmt::Display for ExecutorError {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        match self {
285            ExecutorError::Unavailable(m) => write!(f, "executor unavailable: {m}"),
286            ExecutorError::Protocol(m) => write!(f, "executor protocol error: {m}"),
287        }
288    }
289}
290
291impl std::error::Error for ExecutorError {}
292
293/// The CI executor seam (D18).
294///
295/// Implementations must pass [`crate::conform::conform`], which the
296/// in-tree backends run from `choir-queue/tests/it/executor.rs` and a
297/// helper built elsewhere runs through `choir-ci-conform`. A seam with
298/// one implementation
299/// has never been tested against disagreement, which is how the trait
300/// this replaces stayed plausible while being unable to carry a real
301/// executor.
302pub trait CiExecutor {
303    /// Identity and protocol version. Called once before any job, so a
304    /// mismatched provider is refused before it can return a verdict
305    /// that means something else than it appears to.
306    ///
307    /// # Errors
308    ///
309    /// [`ExecutorError::Unavailable`] if the provider cannot be reached,
310    /// [`ExecutorError::Protocol`] if it speaks a version we do not.
311    fn info(&mut self) -> Result<ExecutorInfo, ExecutorError>;
312
313    /// Runs one train's worth of jobs. The provider chooses its own
314    /// concurrency.
315    ///
316    /// The returned vector is index-aligned with `jobs`, so a provider
317    /// that drops or reorders a job fails a length check rather than
318    /// silently attributing one change's result to another.
319    ///
320    /// # Errors
321    ///
322    /// [`ExecutorError`] when the call as a whole produced no verdicts.
323    /// A per-job fault is [`Verdict::Errored`], not an error here.
324    fn run(&mut self, jobs: &[Job]) -> Result<Vec<Verdict>, ExecutorError>;
325}
326
327/// An executor that answers from a caller-supplied function instead of
328/// running anything.
329///
330/// Named rather than a blanket impl over `FnMut` on purpose. The seam
331/// this replaces had exactly such a blanket impl, which meant every
332/// test got a fake without ever choosing one, and the trait looked
333/// exercised while no implementation had ever run a process. Reaching
334/// for a fake is now a thing you can see in the source.
335pub struct Synthetic {
336    name: String,
337    answer: Box<dyn FnMut(&Job) -> Verdict + Send>,
338}
339
340impl std::fmt::Debug for Synthetic {
341    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342        f.debug_struct("Synthetic")
343            .field("name", &self.name)
344            .finish()
345    }
346}
347
348impl Synthetic {
349    /// An executor whose every verdict is [`Verdict::Passed`].
350    #[must_use]
351    pub fn passing() -> Self {
352        Self::new(|_| Verdict::Passed)
353    }
354
355    /// An executor that fails exactly the jobs whose label is listed
356    /// and passes the rest.
357    ///
358    /// The shape most queue tests need: "change 7 breaks the build".
359    #[must_use]
360    pub fn failing_labels(labels: &[&str]) -> Self {
361        let owned: Vec<String> = labels.iter().map(|l| (*l).to_string()).collect();
362        Self::new(move |job| {
363            if owned.contains(&job.label) {
364                Verdict::Failed { exit_code: Some(1) }
365            } else {
366                Verdict::Passed
367            }
368        })
369    }
370
371    /// An executor answering from `answer`.
372    pub fn new(answer: impl FnMut(&Job) -> Verdict + Send + 'static) -> Self {
373        Self {
374            name: "synthetic".to_string(),
375            answer: Box::new(answer),
376        }
377    }
378}
379
380impl CiExecutor for Synthetic {
381    fn info(&mut self) -> Result<ExecutorInfo, ExecutorError> {
382        Ok(ExecutorInfo {
383            name: self.name.clone(),
384            protocol: PROTOCOL,
385        })
386    }
387
388    fn run(&mut self, jobs: &[Job]) -> Result<Vec<Verdict>, ExecutorError> {
389        Ok(jobs.iter().map(|j| (self.answer)(j)).collect())
390    }
391}