Files
radicle-native-ci/src/run.rs
T
Lars Wirzenius decc8e979c fix: filename for run log
Signed-off-by: Lars Wirzenius <liw@liw.fi>
2024-01-16 19:41:55 +02:00

247 lines
8.0 KiB
Rust

use std::{
path::{Path, PathBuf},
process::Command,
};
use radicle_ci_broker::msg::{Id, Oid, RunId};
use crate::{
msg::NativeMessageError,
report,
runinfo::RunInfoError,
runlog::{RunLog, RunLogError},
runspec::{RunSpec, RunSpecError},
};
// Exit code to indicate we didn't get one from the process.
const NO_EXIT: i32 = 999;
// Exit code for the timeout command to indicate it timed out.
const EXIT_TIMEOUT: i32 = 124;
// Path to the repository's CI run specification. This is relative to
// the root of the repository.
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<Id>,
commit: Option<Oid>,
storage: Option<PathBuf>,
timeout: Option<usize>,
src: PathBuf,
}
impl Run {
/// Create a new `Run`.
pub fn new(run_dir: &Path, run_id: RunId) -> Result<Self, RunError> {
let run_log_filename = run_dir.join("log.html");
let run_log = RunLog::new(&run_log_filename);
Ok(Self {
run_log,
rid: None,
commit: None,
storage: None,
timeout: None,
src: run_dir.join("src"),
})
}
/// Set the git repository to use for this run.
pub fn set_repository(&mut self, rid: Id) {
self.rid = Some(rid);
}
/// 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());
}
/// Optionally set the maximum duration to execute the shell
/// commands specified for a project, in seconds. If not set, a
/// generous built-in default is used.
pub fn set_timeout(&mut self, seconds: usize) {
self.timeout = Some(seconds);
}
/// 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.
self.run_log.write()?;
// Likewise, if we can't clean up disk space, the node admin
// needs to know about that.
std::fs::remove_dir_all(&self.src).map_err(|e| RunError::RemoveDir(self.src.clone(), e))?;
// 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 commit = self.commit.ok_or(RunError::Missing("commit"))?;
let storage = self.storage.as_ref().ok_or(RunError::Missing("storage"))?;
let timeout = self.timeout.ok_or(RunError::Missing("timeout"))?;
// Record metadata in the run log.
self.run_log.title("Log from Radicle native CI");
self.run_log.rid(rid);
self.run_log.commit(commit);
// 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)?;
let runspec_path = self.src.join(RUNSPEC_PATH);
let runspec = match RunSpec::from_file(&runspec_path) {
Ok(runspec) => 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(&e);
return Ok(());
}
};
let snippet = format!("set -xeuo pipefail\n{}", &runspec.shell);
let timeout = format!("{}", timeout);
let exit = self.runcmd(&["timeout", &timeout, "bash", "-c", &snippet], &src)?;
if exit == EXIT_TIMEOUT {
self.run_log.command_timed_out();
}
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", "checkout", &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 argv0 = argv[0];
let output = Command::new(argv0)
.args(&argv[1..])
.current_dir(cwd)
.output()
.map_err(|e| RunError::Command(argv.iter().map(|s| s.to_string()).collect(), e))?;
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,
&output.stderr,
);
eprintln!("RUNCMD: argv={:?}", argv);
eprintln!("RUNCMD: exit={}", exit);
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] NativeMessageError),
#[error(transparent)]
Report(#[from] report::ReportError),
#[error(transparent)]
RunInfo(#[from] RunInfoError),
#[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),
}