use std::path::PathBuf; use uuid::Uuid; use radicle::prelude::Profile; use radicle_ci_broker::msg::{ helper::{read_request, write_failed, write_succeeded, write_triggered, NativeMessageError}, EventCommonFields, Oid, Patch, PatchEvent, PushEvent, RepoId, Repository, Request, RunId, RunResult, }; use crate::{ config::{Config, ConfigError}, logfile::{AdminLog, LogError}, run::{Run, RunError}, runlog::RunLogError, runspec::RunSpecError, }; /// Run CI for a project once. #[derive(Debug)] pub struct Engine { config: Config, adminlog: AdminLog, result: Option, } impl Engine { /// Create a new engine and prepare for actually running CI. This /// may fail, for various reasons, such as the configuration file /// not existing. The caller should handle the error in some /// suitable way, such as report that to its stderr, and exit /// non-zero. #[allow(clippy::result_large_err)] pub fn new() -> Result { // Get config, open admin log for writing. If either of these // fails, we can't write about the problem to the admin log, // but we can return an error to the caller. let config = Config::load_via_env()?; let adminlog = config.open_log()?; // From here on, it's plain sailing. Ok(Self { config, adminlog, result: None, }) } /// Return config that has been loaded for the engine. pub fn config(&self) -> &Config { &self.config } /// Set up and run CI on a project once: read the trigger request /// from stdin, write responses to stdout. Update node admin log /// with any problems that aren't inherent in the git repository /// (those go into the run log). #[allow(clippy::result_large_err)] pub fn run(&mut self) -> Result { let req = match self.setup() { Ok(req) => req, Err(e) => { self.adminlog.writeln(&format!("Error setting up: {}", e))?; return Err(e); } }; // Check that we got the right kind of request. let mut success = false; match &req { Request::Trigger { common: EventCommonFields { repository: Repository { name, .. }, .. }, push: Some(PushEvent { branch, .. }), .. } => { let repo = req.repo(); let commit = req.commit().map_err(EngineError::BrokerMessage)?; match self.run_helper(repo, name, req.clone(), commit, Some(branch), None) { Ok(true) => success = true, Ok(false) => (), Err(e) => { // If the run helper return an error, something // went wrong in that is not due to the repository // under test. So we don't put it in the run log, // but the admin log and return it to the caller. self.adminlog.writeln(&format!("Error running CI: {}", e))?; return Err(e); } } } Request::Trigger { common: EventCommonFields { repository: Repository { name, .. }, .. }, patch: Some(PatchEvent { patch: Patch { id, title, .. }, .. }), .. } => { let repo = req.repo(); let commit = req.commit().map_err(EngineError::BrokerMessage)?; self.adminlog .writeln(&format!("run CI for {repo} commit {commit}"))?; match self.run_helper(repo, name, req.clone(), commit, None, Some((*id, title))) { Ok(true) => success = true, Ok(false) => (), Err(e) => { // If the run helper return an error, something // went wrong in that is not due to the repository // under test. So we don't put it in the run log, // but the admin log and return it to the caller. self.adminlog.writeln(&format!("Error running CI: {}", e))?; return Err(e); } } } _ => { // Protocol error. Log in admin log and report to caller. self.adminlog .writeln("First request was not a message to trigger a run.")?; return Err(EngineError::NotTrigger(req)); } } if let Err(e) = self.finish() { self.adminlog .writeln(&format!("Error finishing up: {}", e))?; return Err(e); } Ok(success) } // Set up CI to run. If something goes wrong, return the error, // and assume the caller logs it to the admin log. #[allow(clippy::result_large_err)] fn setup(&mut self) -> Result { // Write something to the admin log to indicate we start. self.adminlog.writeln("Native CI run starts")?; // Read request and log it. let req = read_request()?; self.adminlog.writeln(&format!("request: {:#?}", req))?; Ok(req) } // Finish up after a CI run. If something goes wrong, return the // error, and assume the caller logs it to the admin log. #[allow(clippy::result_large_err)] fn finish(&mut self) -> Result<(), EngineError> { // Write response message indicating the run has finished. match &self.result { Some(RunResult::Success) => write_succeeded()?, Some(RunResult::Failure) => write_failed()?, _ => panic!("do not know how to handle {:#?}", self.result), } // Log that we've reached the end successfully. self.adminlog.writeln("Native CI ends successfully")?; Ok(()) } // Execute the CI run. Log any problems to a log for this run, and // persist that. Update the run info builder as needed. #[allow(clippy::result_large_err)] fn run_helper( &mut self, rid: RepoId, name: &str, req: Request, commit: Oid, branch: Option<&str>, patch: Option<(Oid, &str)>, ) -> Result { // Pick a run id and create a directory for files related to // the run. let (run_id, run_dir) = mkdir_run(&self.config)?; let run_id = RunId::from(format!("{}", run_id).as_str()); // Set the file where the run info should be written, now that // we have the run directory. let run_log_filename = run_dir.join("log.html"); // Get node git storage. We do this before responding to the // trigger, so that if this fails, we haven't said that a run // has started. let profile = Profile::load().map_err(EngineError::LoadProfile)?; let storage = profile.storage.path(); // Write response to indicate run has been triggered. if let Some(url) = &self.config.base_url { let url = if url.ends_with('/') { format!("{url}{}/log.html", run_id) } else { format!("{url}/{}/log.html", run_id) }; write_triggered(&run_id, Some(&url))?; } else { write_triggered(&run_id, None)?; } // Create and set up the run. let mut run = Run::new(run_id, &run_dir, &run_log_filename)?; run.set_repository(rid, name); run.set_request(req); run.set_commit(commit); run.set_storage(storage); // Actually run. Examine the run log to decide if the run // succeeded or failed. let result = run.run(); if let Ok(mut run_log) = result { if let Some(branch) = branch { run_log.branch(branch); } if let Some((patch, title)) = patch { run_log.patch(patch, title); } self.result = if run_log.all_commands_succeeded() { Some(RunResult::Success) } else { Some(RunResult::Failure) }; let all = run_log.all_commands_succeeded(); Ok(all) } else { self.result = Some(RunResult::Failure); Ok(false) } } /// Report results to caller (via stdout) and to users (via report /// on web page). #[allow(clippy::result_large_err)] pub fn report(&mut self) -> Result<(), EngineError> { Ok(()) } } /// Create a per-run directory. #[allow(clippy::result_large_err)] fn mkdir_run(config: &Config) -> Result<(Uuid, PathBuf), EngineError> { let state = &config.state; if !state.exists() { std::fs::create_dir_all(state).map_err(|e| EngineError::CreateState(state.into(), e))?; } let run_id = Uuid::new_v4(); let run_dir = state.join(run_id.to_string()); std::fs::create_dir(&run_dir).map_err(|e| EngineError::CreateRunDir(run_dir.clone(), e))?; Ok((run_id, run_dir)) } #[derive(Debug, thiserror::Error)] #[allow(clippy::large_enum_variant)] pub enum EngineError { #[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("programming error: failed to set field {0}")] Unset(&'static str), #[error(transparent)] Config(#[from] ConfigError), #[error(transparent)] Log(#[from] LogError), #[error(transparent)] BrokerMessage(#[from] radicle_ci_broker::msg::MessageError), #[error(transparent)] Message(#[from] NativeMessageError), #[error(transparent)] RunLog(#[from] RunLogError), #[error(transparent)] RunSpec(#[from] RunSpecError), #[error(transparent)] Run(#[from] RunError), #[error("request message was not a trigger message: {0:?}")] NotTrigger(Request), }