use std::{ path::{Path, PathBuf}, process::Command, time::SystemTime, }; use radicle_ci_broker::{ ergo::Oid, msg::{helper::MessageHelperError, RepoId, Request, RunId}, }; use crate::{ runlog::{RunLog, RunLogError}, runspec::{RunSpec, RunSpecError}, }; // Exit code to indicate we didn't get one from the process. const NO_EXIT: i32 = 999; // Path to the repository's CI run specification. This is relative to // the root of the repository. pub const RUNSPEC_PATH: &str = ".radicle/native.yaml"; /// Execute the project-specific parts of a CI run. This needs to be /// set up with `set_*` methods, and then the [`run`](Run::run) method /// needs to be called. #[derive(Debug)] pub struct Run { run_log: RunLog, rid: Option, request: Option, repo_name: Option, commit: Option, storage: Option, src: PathBuf, } impl Run { /// Create a new `Run`. pub fn new(run_id: RunId, run_dir: &Path, run_log_filename: &Path) -> Result { let mut run_log = RunLog::new(run_log_filename); run_log.adapter_run_id(run_id); Ok(Self { run_log, rid: None, repo_name: None, request: None, commit: None, storage: None, src: run_dir.join("src"), }) } /// Set the message that triggered this run. pub fn set_request(&mut self, req: Request) { self.request = Some(req); } /// Set the git repository to use for this run. pub fn set_repository(&mut self, rid: RepoId, repo_name: &str) { self.rid = Some(rid); self.repo_name = Some(repo_name.into()); } /// Set the commit to use for this run. pub fn set_commit(&mut self, commit: Oid) { self.commit = Some(commit); } /// Set the location of the Radicle node git storage. pub fn set_storage(&mut self, path: &Path) { self.storage = Some(path.into()); } /// Run CI on a project. /// /// This runs CI for the project, and then clean up: persist the /// per-run log, and clean up some disk space after a CI run. On /// success, return the run log. A CI run has succeeded, if all /// the commands run as part of it succeeded. /// /// Note that this consume the `Run`. pub fn run(mut self) -> Result { // Execute the actual CI run. let result = self.run_helper(); // If writing the run log fails, it will obscure any previous // problem from the CI run. That's intentional: we want to // make the node admin aware of the log writing problem, as // that can be a problem for all future runs, e.g., due to a // full file system, whereas, e.g., a syntax error in the code // under test, is more fleeting. let write_result = self.run_log.write(); if result.is_ok() { write_result?; } // Likewise, if we can't clean up disk space, the node admin // needs to know about that. let rmdir_result = std::fs::remove_dir_all(&self.src) .map_err(|e| RunError::RemoveDir(self.src.clone(), e)); if result.is_ok() { rmdir_result?; } // Return result from the actual CI run. result.map(|_| self.run_log) } // Execute CI run once, without worrying about cleanup. Store any // problems in the run log. The caller is responsible for // persisting the run log. This returns an error only if it's a // programming error. fn run_helper(&mut self) -> Result<(), RunError> { // Get values fields we'll need to use, if they've been set. let rid = self.rid.ok_or(RunError::Missing("rid"))?; let repo_name = self .repo_name .as_ref() .ok_or(RunError::Missing("repo_name"))?; let request = self.request.clone().ok_or(RunError::Missing("request"))?; let commit = self.commit.ok_or(RunError::Missing("commit"))?; let storage = self.storage.as_ref().ok_or(RunError::Missing("storage"))?; // Record metadata in the run log. self.run_log.title("Log from Radicle native CI"); self.run_log.rid(rid, repo_name); self.run_log.commit(commit); self.run_log.request(request); // Clone the repository and check out the right commit. If // these fail, the problem is stored in the run log. let repo_path = storage.join(rid.canonical()); let src = self.src.to_path_buf(); self.git_clone(&repo_path, &src)?; self.git_checkout(commit, &src)?; self.git_show(commit, &src)?; let runspec_path = self.src.join(RUNSPEC_PATH); let runspec = match RunSpec::from_file(&runspec_path) { Ok(runspec) => { self.run_log.runspec(runspec.clone()); runspec } Err(e) => { // Log error in run log, then return. We can't do // anything more if we don't have the run spec. // However, return `Ok`, so that the CI engine doesn't // report that the engine failed. self.run_log.runspec_error(&e); return Ok(()); } }; let snippet = format!("set -xeuo pipefail\n{}", runspec.shell); self.runcmd(&["bash", "-c", &snippet], &src)?; Ok(()) } fn git_clone(&mut self, repo_path: &Path, src: &Path) -> Result<(), RunError> { self.runcmd( &[ "git", "clone", repo_path.to_str().unwrap(), src.to_str().unwrap(), ], Path::new("."), )?; Ok(()) } fn git_checkout(&mut self, commit: Oid, src: &Path) -> Result<(), RunError> { self.runcmd(&["git", "config", "advice.detachedHead", "false"], src)?; self.runcmd(&["git", "checkout", &commit.to_string()], src)?; Ok(()) } fn git_show(&mut self, commit: Oid, src: &Path) -> Result<(), RunError> { self.runcmd(&["git", "show", &commit.to_string()], src)?; Ok(()) } // Run an external command in a directory. Log the command and the // result of running it to the run log. Return an error if the // command could not be executed at all, but the exit code if it // ran but failed. fn runcmd(&mut self, argv: &[&str], cwd: &Path) -> Result { if argv.is_empty() { return Err(RunError::EmptyArgv); } let started = SystemTime::now(); let output = Command::new("bash") .arg("-c") .arg(r#""$@" 2>&1"#) .arg("--") .args(argv) .current_dir(cwd) .output() .map_err(|e| RunError::Command(argv.iter().map(|s| s.to_string()).collect(), e))?; let ended = SystemTime::now(); let exit = output.status.code().unwrap_or(NO_EXIT); self.run_log.runcmd( argv, &cwd.canonicalize() .map_err(|e| RunError::Canonicalize(cwd.into(), e))?, exit, &output.stdout, started, ended, ); Ok(exit) } } #[derive(Debug, thiserror::Error)] pub enum RunError { #[error("failed to create per-run parent directory {0}")] CreateState(PathBuf, #[source] std::io::Error), #[error("failed to create per-run directory {0}")] CreateRunDir(PathBuf, #[source] std::io::Error), #[error("failed to load Radicle profile")] LoadProfile(#[source] radicle::profile::Error), #[error("failed to remove {0}")] RemoveDir(PathBuf, #[source] std::io::Error), #[error("programming error: failed to set field {0}")] Unset(&'static str), #[error(transparent)] Message(#[from] MessageHelperError), #[error(transparent)] RunLog(#[from] RunLogError), #[error(transparent)] RunSpec(#[from] RunSpecError), #[error("failed to run command {0:?}")] Command(Vec, #[source] std::io::Error), #[error("command failed with exit code {0}: {1:?}")] CommandFailed(i32, Vec), #[error("failed to make pathname absolute: {0}")] Canonicalize(PathBuf, #[source] std::io::Error), #[error("programming error: function runcmd called with empty argv")] EmptyArgv, #[error("programming error: field '{0}' was not set in struct Run")] Missing(&'static str), }