summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2024-02-02 17:32:48 +0200
committerLars Wirzenius <liw@liw.fi>2024-02-02 17:41:52 +0200
commit1c90e35cef8d04745536199ec37d81b7b9597a73 (patch)
tree97a6ce2d87300503902ab1217452dab7bc4f718b
parentb096d9fb6f6322aae92070c0c8d6c6faad325a18 (diff)
downloadradicle-ci-broker-1c90e35cef8d04745536199ec37d81b7b9597a73.tar.gz
feat(src/adapter.rs): run a CI adapter
Add module to represent a CI adapter and execute it. Add tests for a variety of test cases. Signed-off-by: Lars Wirzenius <liw@liw.fi>
-rw-r--r--src/adapter.rs418
1 files changed, 418 insertions, 0 deletions
diff --git a/src/adapter.rs b/src/adapter.rs
new file mode 100644
index 0000000..d9a37d0
--- /dev/null
+++ b/src/adapter.rs
@@ -0,0 +1,418 @@
+//! Run a Radicle CI adapter.
+//!
+//! Given an executable that conforms to the CI adapter API, execute
+//! it by feeding it the "trigger" message via its stdin and reading
+//! response messages from its stdout. Return the result of the run,
+//! or an error if something went badly wrong. The CI run failing due
+//! to something in the repository under test is expected, and not
+//! considered as something going badly wrong.
+
+use std::{
+ collections::HashMap,
+ ffi::OsStr,
+ io::{BufRead, BufReader},
+ os::unix::process::ExitStatusExt,
+ path::{Path, PathBuf},
+ process::{Command, Stdio},
+};
+
+use crate::{
+ msg::{MessageError, Request, Response},
+ run::{Run, RunState},
+};
+
+/// An external executable that runs CI on request.
+#[derive(Debug, Default, Clone, Eq, PartialEq)]
+pub struct Adapter {
+ bin: PathBuf,
+ env: HashMap<String, String>,
+}
+
+impl Adapter {
+ pub fn new(bin: &Path) -> Self {
+ Self {
+ bin: bin.into(),
+ env: HashMap::new(),
+ }
+ }
+
+ pub fn with_environment(mut self, env: &HashMap<String, String>) -> Self {
+ for (key, value) in env.iter() {
+ self.env.insert(key.into(), value.into());
+ }
+ self
+ }
+
+ fn envs(&self) -> impl Iterator<Item = (&OsStr, &OsStr)> {
+ self.env.iter().map(|(k, v)| (k.as_ref(), v.as_ref()))
+ }
+
+ pub fn run(&self, trigger: &Request, run: &mut Run) -> Result<(), AdapterError> {
+ run.set_state(RunState::Running);
+ let x = self.run_helper(trigger, run);
+ run.set_state(RunState::Finished);
+ x
+ }
+
+ fn run_helper(&self, trigger: &Request, run: &mut Run) -> Result<(), AdapterError> {
+ assert!(matches!(trigger, Request::Trigger { .. }));
+
+ // Spawn the adapter sub-process.
+ let mut child = Command::new(&self.bin)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .envs(self.envs())
+ .spawn()
+ .map_err(|e| AdapterError::SpawnAdapter(self.bin.clone(), e))?;
+
+ // Write the request to trigger a run to the child's stdin.
+ // Then close the pipe to prevent the child from trying to
+ // read another message that will never be sent.
+ {
+ let stdin = child.stdin.take().ok_or(AdapterError::StdinHandle)?;
+ trigger.to_writer(stdin)?;
+ }
+
+ // Get the child's stdout into a BufReader so that we can loop
+ // over lines.
+ let stdout = child.stdout.take().ok_or(AdapterError::StdoutHandle)?;
+ let stdout = BufReader::new(stdout);
+ let mut lines = stdout.lines();
+
+ if let Some(line) = lines.next() {
+ let line = line.map_err(AdapterError::ReadLine)?;
+ let resp = Response::from_str(&line)?;
+ match resp {
+ Response::Triggered { run_id } => {
+ run.set_adapter_run_id(run_id);
+ }
+ _ => return Err(AdapterError::NotTriggered(resp)),
+ }
+ }
+
+ if let Some(line) = lines.next() {
+ let line = line.map_err(AdapterError::ReadLine)?;
+ let resp = Response::from_str(&line)?;
+ match resp {
+ Response::Finished { result } => {
+ run.set_result(result);
+ }
+ _ => return Err(AdapterError::NotTriggered(resp)),
+ }
+ }
+
+ if let Some(line) = lines.next() {
+ let line = line.map_err(AdapterError::ReadLine)?;
+ let resp = Response::from_str(&line)?;
+ return Err(AdapterError::TooMany(resp));
+ }
+
+ let wait = child.wait().map_err(AdapterError::Wait)?;
+ let wait = wait.into_raw();
+ if wait != 0 {
+ return Err(AdapterError::Failed(wait));
+ }
+
+ Ok(())
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum AdapterError {
+ /// A message related error.
+ #[error(transparent)]
+ Message(#[from] MessageError),
+
+ /// Error from spawning a sub-process.
+ #[error("failed to spawn a CI adapter sub-process: {0}")]
+ SpawnAdapter(PathBuf, #[source] std::io::Error),
+
+ /// Error getting the file handle for the adapter's stdin.
+ #[error("failed to get handle for adapter's stdin")]
+ StdinHandle,
+
+ /// Error getting the file handle for the adapter's stdout.
+ #[error("failed to get handle for adapter's stdout")]
+ StdoutHandle,
+
+ #[error("failed to read from adapter stdout")]
+ ReadLine(#[source] std::io::Error),
+
+ /// Waiting for child process failed.
+ #[error("failed to wait for child process to exit")]
+ Wait(#[source] std::io::Error),
+
+ /// Child process failed.
+ #[error("child process failed with wait status {0}")]
+ Failed(i32),
+
+ /// First message is not `Response::Triggered`
+ #[error("adapter's first message is not 'triggered', but {0:?}")]
+ NotTriggered(Response),
+
+ /// Too many messages from adapter.
+ #[error("adapter sent too many messages: first extra is {0:#?}")]
+ TooMany(Response),
+}
+
+#[cfg(test)]
+mod test {
+ use std::{fs::write, io::ErrorKind};
+
+ use tempfile::tempdir;
+
+ use super::{Adapter, Run};
+ use crate::{
+ adapter::AdapterError,
+ msg::{MessageError, Response, RunResult},
+ test::{mock_adapter, trigger_request, TestResult},
+ };
+
+ #[test]
+ fn adapter_reports_success() -> TestResult<()> {
+ const ADAPTER: &str = r#"#!/bin/bash
+echo '{"response":"triggered","run_id":{"id":"xyzzy"}}'
+echo '{"response":"finished","result":"success"}'
+"#;
+
+ let tmp = tempdir()?;
+ let bin = tmp.path().join("adapter.sh");
+ mock_adapter(&bin, ADAPTER)?;
+
+ let mut run = Run::default();
+ Adapter::new(&bin).run(&trigger_request()?, &mut run)?;
+ assert_eq!(run.result(), Some(&RunResult::Success));
+
+ Ok(())
+ }
+
+ #[test]
+ fn adapter_reports_failure() -> TestResult<()> {
+ const ADAPTER: &str = r#"#!/bin/bash
+echo '{"response":"triggered","run_id":{"id":"xyzzy"}}'
+echo '{"response":"finished","result":"failure"}'
+"#;
+
+ let tmp = tempdir()?;
+ let bin = tmp.path().join("adapter.sh");
+ mock_adapter(&bin, ADAPTER)?;
+
+ let mut run = Run::default();
+ Adapter::new(&bin).run(&trigger_request()?, &mut run)?;
+ assert_eq!(run.result(), Some(&RunResult::Failure));
+
+ Ok(())
+ }
+
+ #[test]
+ fn adapter_returns_error() -> TestResult<()> {
+ const ADAPTER: &str = r#"#!/bin/bash
+echo '{"response":"triggered","run_id":{"id":"xyzzy"}}'
+echo '{"response":"finished","result":{"error":"error message\nsecond line"}}'
+"#;
+
+ let tmp = tempdir()?;
+ let bin = tmp.path().join("adapter.sh");
+ mock_adapter(&bin, ADAPTER)?;
+
+ let mut run = Run::default();
+ Adapter::new(&bin).run(&trigger_request()?, &mut run)?;
+ assert_eq!(
+ run.result(),
+ Some(&RunResult::Error("error message\nsecond line".into()))
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn adapter_is_killed_before_any_messages() -> TestResult<()> {
+ const ADAPTER: &str = r#"#!/bin/bash
+kill -9 $BASHPID
+"#;
+
+ let tmp = tempdir()?;
+ let bin = tmp.path().join("adapter.sh");
+ mock_adapter(&bin, ADAPTER)?;
+
+ let mut run = Run::default();
+ let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);
+ assert!(matches!(x, Err(AdapterError::Failed(_))));
+
+ Ok(())
+ }
+
+ #[test]
+ fn adapter_is_killed_after_first_message() -> TestResult<()> {
+ const ADAPTER: &str = r#"#!/bin/bash
+echo '{"response":"triggered","run_id":{"id":"xyzzy"}}'
+kill -9 $BASHPID
+"#;
+
+ let tmp = tempdir()?;
+ let bin = tmp.path().join("adapter.sh");
+ mock_adapter(&bin, ADAPTER)?;
+
+ let mut run = Run::default();
+ let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);
+ assert!(matches!(x, Err(AdapterError::Failed(_))));
+
+ Ok(())
+ }
+
+ #[test]
+ fn adapter_is_killed_after_second_message() -> TestResult<()> {
+ const ADAPTER: &str = r#"#!/bin/bash
+echo '{"response":"triggered","run_id":{"id":"xyzzy"}}'
+echo '{"response":"finished","result":"success"}'
+kill -9 $BASHPID
+"#;
+
+ let tmp = tempdir()?;
+ let bin = tmp.path().join("adapter.sh");
+ mock_adapter(&bin, ADAPTER)?;
+
+ let mut run = Run::default();
+ let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);
+ assert!(matches!(x, Err(AdapterError::Failed(_))));
+
+ Ok(())
+ }
+
+ #[test]
+ fn adapter_produces_as_bad_message() -> TestResult<()> {
+ const ADAPTER: &str = r#"#!/bin/bash
+echo '{"response":"triggered","run_id":{"id":"xyzzy"}}'
+echo '{"response":"finished","result":"success","bad":"field"}'
+"#;
+
+ let tmp = tempdir()?;
+ let bin = tmp.path().join("adapter.sh");
+ mock_adapter(&bin, ADAPTER)?;
+
+ let mut run = Run::default();
+ let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);
+ assert!(matches!(
+ x,
+ Err(AdapterError::Message(MessageError::DeserializeResponse(_)))
+ ));
+
+ Ok(())
+ }
+
+ #[test]
+ fn adapter_first_message_isnt_triggered() -> TestResult<()> {
+ const ADAPTER: &str = r#"#!/bin/bash
+echo '{"response":"finished","result":"success"}'
+"#;
+
+ let tmp = tempdir()?;
+ let bin = tmp.path().join("adapter.sh");
+ mock_adapter(&bin, ADAPTER)?;
+
+ let mut run = Run::default();
+ let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);
+ assert!(matches!(
+ x,
+ Err(AdapterError::NotTriggered(Response::Finished {
+ result: RunResult::Success
+ }))
+ ));
+
+ Ok(())
+ }
+
+ #[test]
+ fn adapter_outputs_too_many_messages() -> TestResult<()> {
+ const ADAPTER: &str = r#"#!/bin/bash
+echo '{"response":"triggered","run_id":{"id":"xyzzy"}}'
+echo '{"response":"finished","result":"success"}'
+echo '{"response":"finished","result":"success"}'
+"#;
+
+ let tmp = tempdir()?;
+ let bin = tmp.path().join("adapter.sh");
+ mock_adapter(&bin, ADAPTER)?;
+
+ let mut run = Run::default();
+ let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);
+ assert!(matches!(
+ x,
+ Err(AdapterError::TooMany(Response::Finished {
+ result: RunResult::Success
+ }))
+ ));
+
+ Ok(())
+ }
+
+ #[test]
+ fn adapter_does_not_exist() -> TestResult<()> {
+ let tmp = tempdir()?;
+ let bin = tmp.path().join("adapter.sh");
+
+ let mut run = Run::default();
+ let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);
+ match x {
+ Err(AdapterError::SpawnAdapter(filename, e)) => {
+ assert_eq!(bin, filename);
+ assert_eq!(e.kind(), ErrorKind::NotFound);
+ }
+ _ => panic!("expected a specific error"),
+ }
+
+ Ok(())
+ }
+
+ #[test]
+ fn adapter_is_not_executable() -> TestResult<()> {
+ const ADAPTER: &str = r#"#!/bin/bash
+echo '{"response":"triggered","run_id":{"id":"xyzzy"}}'
+echo '{"response":"finished","result":"success"}'
+"#;
+
+ let tmp = tempdir()?;
+ let bin = tmp.path().join("adapter.sh");
+ write(&bin, ADAPTER)?;
+
+ let mut run = Run::default();
+ let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);
+ match x {
+ Err(AdapterError::SpawnAdapter(filename, e)) => {
+ assert_eq!(bin, filename);
+ assert_eq!(e.kind(), ErrorKind::PermissionDenied);
+ }
+ _ => panic!("expected a specific error"),
+ }
+
+ Ok(())
+ }
+
+ #[test]
+ fn adapter_has_bad_interpreter() -> TestResult<()> {
+ // We test this with a shebang. However, the same kind of code
+ // paths and errors should happen when a binary can't be
+ // loaded due to missing dynamic linker or library or such.
+
+ const ADAPTER: &str = r#"#!/bin/does-not-exist
+echo '{"response":"triggered","run_id":{"id":"xyzzy"}}'
+echo '{"response":"finished","result":"success"}'
+"#;
+
+ let tmp = tempdir()?;
+ let bin = tmp.path().join("adapter.sh");
+ mock_adapter(&bin, ADAPTER)?;
+
+ let mut run = Run::default();
+ let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);
+ match x {
+ Err(AdapterError::SpawnAdapter(filename, e)) => {
+ assert_eq!(bin, filename);
+ assert_eq!(e.kind(), ErrorKind::NotFound);
+ }
+ _ => panic!("expected a specific error"),
+ }
+
+ Ok(())
+ }
+}