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}