Files
radicle-native-ci/src/main.rs
T
2023-12-21 16:28:12 +02:00

344 lines
11 KiB
Rust

//! A Radicle CI adapter for native CI.
//!
//! Perform the CI run locally, without any isolation. This is not
//! safe and secure, but it's simple, and easy to get working. Do not
//! use this unless you trust the repository.
//!
//! The repository must contain a file `.radicle/native.yaml` that
//! specifies how CI is run for the repository. For example:
//!
//! ```yaml
//! shell: |
//! cargo test --locked --workspace
//! ```
use std::{
error::Error,
fs::File,
io::Write,
path::{Path, PathBuf},
rc::Rc,
};
use log::{debug, info};
use serde::Deserialize;
use subprocess::{Popen, PopenConfig, Redirection};
use uuid::Uuid;
use radicle::prelude::Profile;
use radicle_ci_broker::msg::{Id, MessageError, Oid, Request, Response, RunId, RunResult};
/// Path to the repository's CI run specification. This is relative to
/// the root of the repository.
const RUNSPEC_PATH: &str = ".radicle/native.yaml";
/// The main program.
fn main() {
if let Err(e) = fallible_main() {
eprintln!("ERROR: {}", e);
let mut e = e.source();
while let Some(source) = e {
eprintln!("caused by: {}", source);
e = source.source();
}
std::process::exit(1);
}
}
fn fallible_main() -> Result<(), NativeError> {
pretty_env_logger::init_custom_env("RADICLE_NATIVE_CI_LOG");
info!("radicle-native-ci starts");
let config = Config::load_via_env()?;
let mut logfile = config.open_log()?;
logfile.write("radicle-native-ci starts\n".into())?;
let (run_id, run_dir) = mkdir_run(&config)?;
logfile.write(format!("run directory {}\n", run_dir.display()))?;
let src = run_dir.join("src");
let run_log = run_dir.join("log.txt");
let profile = Profile::load().map_err(NativeError::LoadProfile)?;
let storage = profile.storage.path();
let req = read_request()?;
if let Request::Trigger { repo, commit } = req {
info!("Request to trigger CI on {}, {}", repo, commit);
run(run_id, storage, repo, commit, &src, &mut logfile, &run_log)?;
} else {
write_response(&Response::error("first request was not Trigger\n"))?;
}
logfile.write("radicle-native-ci ends successfully".into())?;
info!("radicle-native-ci ends");
Ok(())
}
/// Create a per-run directory.
fn mkdir_run(config: &Config) -> Result<(Uuid, PathBuf), NativeError> {
let state = &config.state;
if !state.exists() {
debug!("creating state directory {}", state.display());
std::fs::create_dir_all(state).map_err(|e| NativeError::CreateState(state.into(), e))?;
}
let run_id = Uuid::new_v4();
let run_dir = state.join(run_id.to_string());
debug!("directory for this run: {}", run_dir.display());
std::fs::create_dir(&run_dir).map_err(|e| NativeError::CreateRunDir(run_dir.clone(), e))?;
Ok((run_id, run_dir))
}
/// Read a request from stdin.
fn read_request() -> Result<Request, NativeError> {
let req = Request::from_reader(std::io::stdin()).map_err(NativeError::ReadRequest)?;
debug!("request: {:#?}", req);
Ok(req)
}
/// Write response to stdout.
fn write_response(resp: &Response) -> Result<(), NativeError> {
resp.to_writer(std::io::stdout())
.map_err(|e| NativeError::WriteResponse(resp.clone(), e))?;
Ok(())
}
/// Perform the CI run.
fn run(
run_id: Uuid,
storage: &Path,
repo: Id,
commit: Oid,
src: &Path,
log: &mut LogFile,
run_log: &Path,
) -> Result<(), NativeError> {
let mut run_log = LogFile::open(run_log)?;
log.write(format!("CI run on {}, {}\n", repo, commit))?;
run_log.write("# Log from Radicle native CI\n\n".into())?;
run_log.write(format!("* Repository id: `{}`\n", repo))?;
run_log.write(format!("* Commit: `{}`\n\n", commit))?;
write_response(&Response::triggered(RunId::from(
run_id.to_string().as_str(),
)))?;
let repo = storage.join(repo.canonical());
debug!("repo path: {}", repo.display());
debug!("cloning repository to {}", src.display());
runcmd(
&mut run_log,
&[
"git",
"clone",
repo.to_str().unwrap(),
src.to_str().unwrap(),
],
storage,
)?;
debug!("checking out commit {}", commit);
runcmd(&mut run_log, &["git", "checkout", &commit.to_string()], src)?;
let runspec = RunSpec::from_file(&src.join(RUNSPEC_PATH))?;
log.write(format!("CI run spec: {:#?}\n", runspec))?;
debug!("running CI in cloned repository");
let snippet = format!("set -xeuo pipefail\n{}", &runspec.shell);
runcmd(&mut run_log, &["bash", "-c", &snippet], src)?;
write_response(&Response::finished(RunResult::Success))?;
run_log.write("CI run finished successfully\n".into())?;
std::fs::remove_dir_all(src).map_err(|e| NativeError::RemoveDir(src.into(), e))?;
Ok(())
}
/// Run a command in a directory.
fn runcmd(log: &mut LogFile, argv: &[&str], cwd: &Path) -> Result<(), NativeError> {
let mut p = Popen::create(
argv,
PopenConfig {
cwd: Some(cwd.as_os_str().into()),
stdout: Redirection::RcFile(log.file()?),
stderr: Redirection::Merge,
..Default::default()
},
)
.map_err(|e| NativeError::PopenCreate(format!("{:?}", argv), e))?;
const FENCD_BLOCK: &str = "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~";
log.write("# Run command\n\n".into())?;
log.write(format!("~~~\n{:?}\n~~~\n\n", argv))?;
log.write(format!("Output:\n\n{}\n", FENCD_BLOCK))?;
p.communicate(None)
.map_err(|e| NativeError::ChildComms(format!("{:?}", argv), e))?;
let exit = p
.wait()
.map_err(|e| NativeError::PopenFailed(format!("{:?}", argv), e))?;
debug!("exit: {:?}", exit);
log.write(format!("\n{}\n\n\n", FENCD_BLOCK))?;
if !exit.success() {
let error = Response::error(&format!("command failed: {:?}", argv));
error
.to_writer(std::io::stdout())
.map_err(|e| NativeError::WriteResponse(error.clone(), e))?;
return Ok(());
}
Ok(())
}
/// Configuration file for `radicle-native-ci`.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Config {
/// Directory where per-run directories are stored. Each run gets
/// its own dedicated subdirectory.
state: PathBuf,
/// File where native CI should write a log.
log: PathBuf,
}
impl Config {
fn load_via_env() -> Result<Self, NativeError> {
const ENV: &str = "RADICLE_NATIVE_CI";
let filename = std::env::var(ENV).map_err(|e| NativeError::GetEnv(ENV, e))?;
let filename = Path::new(&filename);
let config = Config::read(filename)?;
debug!("configuration from {}: {:#?}", filename.display(), config);
Ok(config)
}
fn open_log(&self) -> Result<LogFile, NativeError> {
LogFile::open(&self.log)
}
/// Read configuration specification from a file.
fn read(filename: &Path) -> Result<Self, NativeError> {
let file = std::fs::File::open(filename)
.map_err(|e| NativeError::ReadConfig(filename.into(), e))?;
let config = serde_yaml::from_reader(&file)
.map_err(|e| NativeError::ParseConfig(filename.into(), e))?;
Ok(config)
}
}
struct LogFile {
filename: PathBuf,
file: Rc<std::fs::File>,
}
impl LogFile {
fn open(filename: &Path) -> Result<Self, NativeError> {
let file = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(filename)
.map_err(|e| NativeError::OpenLogFile(filename.into(), e))?;
Ok(Self {
filename: filename.into(),
file: Rc::new(file),
})
}
fn file(&self) -> Result<Rc<File>, NativeError> {
let file = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(&self.filename)
.map_err(|e| NativeError::OpenLogFile(self.filename.clone(), e))?;
Ok(Rc::new(file))
}
fn write(&mut self, msg: String) -> Result<(), NativeError> {
self.file
.as_ref()
.write_all(msg.as_bytes())
.map_err(|e| NativeError::WriteLogFile(self.filename.clone(), e))
}
}
/// How to run CI for this repository.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
#[allow(dead_code)]
pub struct RunSpec {
/// The shell script snippet to run. It will be run with `bash`
/// and with `set -xeuo pipefail`.
shell: String,
}
impl RunSpec {
/// Read run specification from a file.
fn from_file(filename: &Path) -> Result<Self, NativeError> {
debug!("loading CI run spec from {}", filename.display());
let file = std::fs::File::open(filename)
.map_err(|e| NativeError::ReadRunSpec(filename.into(), e))?;
let runspec: RunSpec = serde_yaml::from_reader(&file)
.map_err(|e| NativeError::ParseRunSpec(filename.into(), e))?;
debug!("runspec: {:#?}", runspec);
Ok(runspec)
}
}
#[derive(Debug, thiserror::Error)]
enum NativeError {
#[error("failed to read configuration file {0}")]
ReadConfig(PathBuf, #[source] std::io::Error),
#[error("failed to read run specification file {0}")]
ReadRunSpec(PathBuf, #[source] std::io::Error),
#[error("failed to communicate with child process {0}")]
ChildComms(String, #[source] std::io::Error),
#[error("failed to run program {0}")]
PopenCreate(String, #[source] subprocess::PopenError),
#[error("program failed: {0}")]
PopenFailed(String, #[source] subprocess::PopenError),
#[error("failed to parse configuration file as YAML: {0}")]
ParseConfig(PathBuf, #[source] serde_yaml::Error),
#[error("failed to parse run spec as YAML: {0}")]
ParseRunSpec(PathBuf, #[source] serde_yaml::Error),
#[error("failed to read request from stdin: {0:?}")]
ReadRequest(#[source] MessageError),
#[error("failed to write response to stdout: {0:?}")]
WriteResponse(Response, #[source] MessageError),
#[error("failed to get environment variable {0}")]
GetEnv(&'static str, #[source] std::env::VarError),
#[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 open log file {0}")]
OpenLogFile(PathBuf, #[source] std::io::Error),
#[error("failed to write to log file {0}")]
WriteLogFile(PathBuf, #[source] std::io::Error),
#[error("failed to remove {0}")]
RemoveDir(PathBuf, #[source] std::io::Error),
}