mirror of
https://seed.radicle.dev/rad:z3qg5TKmN83afz2fj9z3fQjU8vaYE.git
synced 2026-11-16 00:10:39 +08:00
344 lines
11 KiB
Rust
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),
|
|
}
|