Skip to main content

choir_queue/
remote.rs

1//! An executor that is not in this process (D18).
2//!
3//! [`LocalRunner`](crate::local::LocalRunner) proves the seam can run
4//! real work. It cannot prove the part a microVM, a container, or a
5//! build farm actually adds, which is not virtualization but
6//! *distance*: the jobs have to leave this address space and the
7//! verdicts have to come back, aligned, including when the far side
8//! stops talking halfway through.
9//!
10//! So the third conformance backend is the process boundary itself.
11//! [`ProtocolRunner`] spawns a helper, hands it a batch over stdin as
12//! JSON lines, and reads verdicts back from stdout. `choir-ci-local` is
13//! the reference helper and runs the batch with `LocalRunner` on the
14//! far side, which means the conformance suite runs the same assertions
15//! against the same execution engine with a pipe in the middle -- and
16//! anything that only passes in-process shows up as a difference rather
17//! than as a story about pipes.
18//!
19//! A Firecracker or Cloud Hypervisor driver is a different helper
20//! behind the same three lines of protocol. Neither can be *built*
21//! here: both require KVM, which is Linux-only, so a version written on
22//! this machine would be exactly the untested single implementation
23//! this seam exists to forbid. It can still be *gated* by the same
24//! list, which is what `choir-ci-conform` is for: the conformance
25//! suite lives in [`crate::conform`] and takes a helper argv, so a
26//! driver on a Linux box answers the checks the four in-tree backends
27//! answer.
28//!
29//! # Protocol
30//!
31//! One JSON object per line, both directions, and the count is agreed
32//! before any work is described so neither side has to guess where the
33//! batch ends.
34//!
35//! ```text
36//! host   -> {"protocol":2,"jobs":2}
37//! helper -> {"name":"choir-ci-local","protocol":2}
38//! host   -> {"subject":"...","label":"1","command":["true"],"directory":null,...}
39//! host   -> {"subject":"...","label":"2","command":["false"],"directory":"/w",...}
40//! helper -> {"verdict":"passed"}
41//! helper -> {"verdict":"failed","exit_code":1}
42//! ```
43//!
44//! # Examples
45//!
46//! ```no_run
47//! use choir_queue::executor::CiExecutor;
48//! use choir_queue::remote::ProtocolRunner;
49//!
50//! let mut ci = ProtocolRunner::new(vec!["choir-ci-local".to_string()]);
51//! let info = ci.info().expect("the helper answers");
52//! assert_eq!(info.protocol, choir_queue::executor::PROTOCOL);
53//! ```
54
55use crate::executor::{CiExecutor, ExecutorError, ExecutorInfo, Job, Verdict, PROTOCOL};
56use std::collections::BTreeMap;
57use std::io::{BufRead, BufReader, Write};
58use std::process::{Command, Stdio};
59use std::time::Duration;
60
61/// Renders one job as the line a helper reads.
62///
63/// # Errors
64///
65/// The job names a directory that is not UTF-8, which JSON cannot
66/// carry. Refused rather than lossily converted: a helper that runs the
67/// job in a path spelled differently returns a verdict about a
68/// different tree, and it would be index-aligned and therefore believed.
69pub fn job_to_json(job: &Job) -> Result<serde_json::Value, String> {
70    let directory = match &job.directory {
71        None => serde_json::Value::Null,
72        Some(dir) => serde_json::Value::String(
73            dir.to_str()
74                .ok_or_else(|| {
75                    format!("job directory {dir:?} is not UTF-8 and cannot be sent as JSON")
76                })?
77                .to_string(),
78        ),
79    };
80    Ok(serde_json::json!({
81        "subject": job.subject.to_hex(),
82        "label": job.label,
83        "command": job.command,
84        "environment": job.environment,
85        "directory": directory,
86        "deadline_ms": u64::try_from(job.deadline.as_millis()).unwrap_or(u64::MAX),
87        "may_write_cache": job.may_write_cache,
88    }))
89}
90
91/// Parses one job line, the inverse of [`job_to_json`].
92///
93/// # Errors
94///
95/// A message naming the first field that was missing or the wrong type.
96/// Helpers report this rather than guessing: a job decoded with a
97/// defaulted command is a job that tests something other than what was
98/// asked, and the verdict would still be index-aligned and therefore
99/// believed.
100pub fn job_from_json(value: &serde_json::Value) -> Result<Job, String> {
101    let subject = value["subject"]
102        .as_str()
103        .and_then(choir_hash::ContentHash::from_hex)
104        .ok_or("job needs a hex `subject`")?;
105    let command = value["command"]
106        .as_array()
107        .ok_or("job needs an array `command`")?
108        .iter()
109        .map(|a| {
110            a.as_str()
111                .map(str::to_string)
112                .ok_or_else(|| "every `command` element must be a string".to_string())
113        })
114        .collect::<Result<Vec<String>, String>>()?;
115    let mut job = Job::new(subject, command);
116    job.label = value["label"].as_str().unwrap_or_default().to_string();
117    if let Some(env) = value["environment"].as_object() {
118        let mut map = BTreeMap::new();
119        for (k, v) in env {
120            let v = v
121                .as_str()
122                .ok_or("every environment value must be a string")?;
123            map.insert(k.clone(), v.to_string());
124        }
125        job.environment = map;
126    }
127    if let Some(dir) = value["directory"].as_str() {
128        job.directory = Some(std::path::PathBuf::from(dir));
129    }
130    if let Some(ms) = value["deadline_ms"].as_u64() {
131        job.deadline = Duration::from_millis(ms);
132    }
133    job.may_write_cache = value["may_write_cache"].as_bool().unwrap_or(false);
134    Ok(job)
135}
136
137/// Renders one verdict as the line a host reads.
138#[must_use]
139pub fn verdict_to_json(verdict: &Verdict) -> serde_json::Value {
140    match verdict {
141        Verdict::Passed => serde_json::json!({ "verdict": "passed" }),
142        Verdict::Failed { exit_code } => {
143            serde_json::json!({ "verdict": "failed", "exit_code": exit_code })
144        }
145        Verdict::Errored { provider, detail } => serde_json::json!({
146            "verdict": "errored",
147            "provider": provider,
148            "detail": detail,
149        }),
150        Verdict::TimedOut => serde_json::json!({ "verdict": "timed_out" }),
151    }
152}
153
154/// Parses one verdict line, the inverse of [`verdict_to_json`].
155///
156/// # Errors
157///
158/// A message naming the unknown or missing tag. An unrecognized verdict
159/// is never coerced to a neighbour: `Passed` would land untested work
160/// and `Failed` would eject a change on our own bug, so the only safe
161/// answer is to refuse the line.
162pub fn verdict_from_json(value: &serde_json::Value) -> Result<Verdict, String> {
163    match value["verdict"].as_str() {
164        Some("passed") => Ok(Verdict::Passed),
165        Some("failed") => Ok(Verdict::Failed {
166            exit_code: value["exit_code"]
167                .as_i64()
168                .and_then(|c| i32::try_from(c).ok()),
169        }),
170        Some("errored") => Ok(Verdict::Errored {
171            provider: value["provider"].as_str().unwrap_or("remote").to_string(),
172            detail: value["detail"].as_str().unwrap_or_default().to_string(),
173        }),
174        Some("timed_out") => Ok(Verdict::TimedOut),
175        Some(other) => Err(format!("unknown verdict `{other}`")),
176        None => Err("verdict line needs a string `verdict`".to_string()),
177    }
178}
179
180/// An executor reached by spawning a helper and talking JSON lines.
181pub struct ProtocolRunner {
182    command: Vec<String>,
183}
184
185impl ProtocolRunner {
186    /// A runner that spawns `command` (argv) once per batch.
187    ///
188    /// Once per batch, not once per job and not once for the runner's
189    /// lifetime: it is the shape a VM supervisor already has, it gives
190    /// the far side a natural place to tear down whatever it built, and
191    /// a helper that dies takes one batch with it rather than every
192    /// batch after it.
193    #[must_use]
194    pub fn new(command: Vec<String>) -> Self {
195        Self { command }
196    }
197
198    /// Runs `jobs`, or says why it could not.
199    fn talk(&self, jobs: &[Job]) -> Result<(ExecutorInfo, Vec<Verdict>), ExecutorError> {
200        let Some((program, args)) = self.command.split_first() else {
201            return Err(ExecutorError::Unavailable(
202                "no helper command configured".into(),
203            ));
204        };
205        let mut child = Command::new(program)
206            .args(args)
207            .stdin(Stdio::piped())
208            .stdout(Stdio::piped())
209            .stderr(Stdio::null())
210            .spawn()
211            .map_err(|e| ExecutorError::Unavailable(format!("could not start `{program}`: {e}")))?;
212
213        let mut stdin = child.stdin.take().expect("stdin was piped");
214        let stdout = child.stdout.take().expect("stdout was piped");
215
216        // The write runs on its own thread. A helper that answers as it
217        // goes fills the stdout pipe while we are still filling its
218        // stdin, and two processes each blocked on the other's full
219        // buffer is a hang with no error and no timeout attached to it.
220        let hello = serde_json::json!({ "protocol": PROTOCOL, "jobs": jobs.len() });
221        let lines: Vec<String> = jobs
222            .iter()
223            .map(|j| job_to_json(j).map(|v| v.to_string()))
224            .collect::<Result<Vec<String>, String>>()
225            .map_err(ExecutorError::Protocol)?;
226        let writer = std::thread::spawn(move || {
227            let _ = writeln!(stdin, "{hello}");
228            for line in lines {
229                if writeln!(stdin, "{line}").is_err() {
230                    break;
231                }
232            }
233            drop(stdin);
234        });
235
236        let mut reader = BufReader::new(stdout);
237        let mut first = String::new();
238        let read = reader.read_line(&mut first).map_err(|e| {
239            ExecutorError::Unavailable(format!("could not read from `{program}`: {e}"))
240        })?;
241        if read == 0 {
242            let _ = writer.join();
243            let _ = child.wait();
244            return Err(ExecutorError::Unavailable(format!(
245                "`{program}` closed without answering the handshake"
246            )));
247        }
248        let hello: serde_json::Value = serde_json::from_str(first.trim()).map_err(|e| {
249            ExecutorError::Protocol(format!(
250                "`{program}` sent a handshake that is not JSON: {e}"
251            ))
252        })?;
253        let name = hello["name"].as_str().unwrap_or(program).to_string();
254        let spoken = hello["protocol"]
255            .as_u64()
256            .and_then(|p| u32::try_from(p).ok())
257            .ok_or_else(|| {
258                ExecutorError::Protocol(format!("`{program}` did not name a protocol version"))
259            })?;
260        if spoken != PROTOCOL {
261            let _ = writer.join();
262            let _ = child.kill();
263            let _ = child.wait();
264            return Err(ExecutorError::Protocol(format!(
265                "`{name}` speaks protocol {spoken}, this build speaks {PROTOCOL}"
266            )));
267        }
268
269        let mut verdicts = Vec::with_capacity(jobs.len());
270        let mut protocol_error = None;
271        for line in reader.lines() {
272            if verdicts.len() == jobs.len() {
273                break;
274            }
275            let line = match line {
276                Ok(l) => l,
277                Err(e) => {
278                    protocol_error = Some(format!("`{name}` stopped mid-batch: {e}"));
279                    break;
280                }
281            };
282            if line.trim().is_empty() {
283                continue;
284            }
285            match serde_json::from_str(&line)
286                .map_err(|e| e.to_string())
287                .and_then(|v: serde_json::Value| verdict_from_json(&v))
288            {
289                Ok(v) => verdicts.push(v),
290                Err(why) => {
291                    protocol_error = Some(format!("`{name}` sent an unusable verdict: {why}"));
292                    break;
293                }
294            }
295        }
296        let _ = writer.join();
297        let _ = child.kill();
298        let _ = child.wait();
299
300        if let Some(why) = protocol_error {
301            return Err(ExecutorError::Protocol(why));
302        }
303        // A helper that stopped early judged the jobs it answered for
304        // and nobody else. Filling the tail with `Errored` keeps the
305        // batch index-aligned and says the true thing about the rest;
306        // returning a short list instead would be refused wholesale by
307        // the queue, throwing away answers we actually have.
308        let short = jobs.len() - verdicts.len();
309        if short > 0 {
310            let answered = verdicts.len();
311            for _ in 0..short {
312                verdicts.push(Verdict::Errored {
313                    provider: name.clone(),
314                    detail: format!("stopped after {answered} of {} verdicts", jobs.len()),
315                });
316            }
317        }
318        Ok((
319            ExecutorInfo {
320                name,
321                protocol: spoken,
322            },
323            verdicts,
324        ))
325    }
326}
327
328impl CiExecutor for ProtocolRunner {
329    fn info(&mut self) -> Result<ExecutorInfo, ExecutorError> {
330        self.talk(&[]).map(|(info, _)| info)
331    }
332
333    fn run(&mut self, jobs: &[Job]) -> Result<Vec<Verdict>, ExecutorError> {
334        self.talk(jobs).map(|(_, verdicts)| verdicts)
335    }
336}