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<RepoId>,
request: Option<Request>,
repo_name: Option<String>,
commit: Option<Oid>,
storage: Option<PathBuf>,
src: PathBuf,
}
impl Run {
/// Create a new `Run`.
pub fn new(run_id: RunId, run_dir: &Path, run_log_filename: &Path) -> Result<Self, RunError> {
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<RunLog, RunError> {
// 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<i32, RunError> {
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<String>, #[source] std::io::Error),
#[error("command failed with exit code {0}: {1:?}")]
CommandFailed(i32, Vec<String>),
#[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),
}