1use 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
61pub 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
91pub 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#[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
154pub 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
180pub struct ProtocolRunner {
182 command: Vec<String>,
183}
184
185impl ProtocolRunner {
186 #[must_use]
194 pub fn new(command: Vec<String>) -> Self {
195 Self { command }
196 }
197
198 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 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 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}