use std::path::PathBuf;
use uuid::Uuid;
use radicle::prelude::Profile;
use radicle_ci_broker::{
ergo::Oid,
msg::{
helper::{
read_request, write_failed, write_succeeded, write_triggered, MessageHelperError,
},
EventCommonFields, 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<RunResult>,
}
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<Self, EngineError> {
// 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<bool, EngineError> {
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<Request, EngineError> {
// 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<bool, EngineError> {
// 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}{run_id}/log.html")
} else {
format!("{url}/{run_id}/log.html")
};
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] MessageHelperError),
#[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),
}