Radish alpha
r
rad:z3qg5TKmN83afz2fj9z3fQjU8vaYE
Radicle CI adapter for native CI
Radicle
Git
refactor: split one Rust main module into many small module
Lars Wirzenius committed 2 years ago
commit bc7ffbad0f9cb4a9b26ed174be74cb81d51a69d5
parent e1f1e94
10 files changed +653 -524
added src/bin/radicle-native-ci.rs
@@ -0,0 +1,243 @@
+
//! 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,
+
    path::{Path, PathBuf},
+
};
+

+
use log::{debug, error, info};
+
use uuid::Uuid;
+

+
use radicle::prelude::Profile;
+
use radicle_ci_broker::msg::{Id, Oid, Request, Response, RunId, RunResult};
+

+
use radicle_native_ci::{
+
    config::{Config, ConfigError},
+
    logfile::{LogError, LogFile},
+
    msg::{read_request, write_response, NativeMessageError},
+
    report,
+
    runcmd::{runcmd, RunCmdError},
+
    runinfo::{RunInfo, RunInfoBuilder, RunInfoError},
+
    runspec::{RunSpec, RunSpecError},
+
};
+

+
/// 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()?;
+

+
    let mut builder = RunInfo::builder();
+

+
    let result = fallible_main_inner(&config, &mut logfile, &mut builder);
+
    if result.is_ok() {
+
        builder.result(RunResult::Success);
+
    } else {
+
        builder.result(RunResult::Failure);
+
    }
+
    builder.build()?.write()?;
+

+
    logfile.write(format!(
+
        "update report page in {}\n",
+
        config.state.display()
+
    ))?;
+
    if let Err(e) = report::build_report(&mut logfile, &config.state) {
+
        logfile.write(format!("report generation failed: {}\n", e))?;
+
    }
+
    logfile.write(format!("radicle-native-ci ends: {:?}\n", result))?;
+
    result
+
}
+

+
fn fallible_main_inner(
+
    config: &Config,
+
    logfile: &mut LogFile,
+
    builder: &mut RunInfoBuilder,
+
) -> Result<(), NativeError> {
+
    let (run_id, run_dir) = mkdir_run(config)?;
+
    let run_id = RunId::from(format!("{}", run_id).as_str());
+
    logfile.write(format!("run directory {}\n", run_dir.display()))?;
+

+
    let src = run_dir.join("src");
+
    let run_log = run_dir.join("log.txt");
+
    let run_info_file = run_dir.join("run.yaml");
+

+
    let profile = Profile::load().map_err(NativeError::LoadProfile)?;
+
    let storage = profile.storage.path();
+
    // logfile.write(format!("profile: {:#?}\n", profile))?;
+

+
    let req = read_request()?;
+
    logfile.write(format!("request: {:#?}\n", req))?;
+

+
    builder.id(run_id.clone());
+
    builder.log(&config.state, run_log.clone());
+
    builder.run_info(run_info_file.clone());
+

+
    if let Request::Trigger { repo, commit } = req {
+
        info!("Request to trigger CI on {}, {}", repo, commit);
+
        builder.repo(repo);
+
        builder.commit(commit);
+
        let result = run(
+
            run_id, storage, repo, commit, &src, logfile, &run_log, builder,
+
        );
+
        if let Err(e) = result {
+
            error!("CI run failed: {}", e);
+
            logfile.write(format!("CI failed: {:?}\n", e))?;
+
            builder.result(RunResult::Failure);
+
            return Err(e);
+
        }
+
        logfile.write_str("CI run exited zero")?;
+
    } else {
+
        write_response(&Response::error("first request was not Trigger\n"))?;
+
        builder.result(RunResult::Error("first request was not Trigger".into()));
+
    };
+

+
    logfile.write_str("radicle-native-ci ends successfully")?;
+
    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))
+
}
+

+
/// Perform the CI run.
+
#[allow(clippy::too_many_arguments)]
+
fn run(
+
    run_id: RunId,
+
    storage: &Path,
+
    repo: Id,
+
    commit: Oid,
+
    src: &Path,
+
    log: &mut LogFile,
+
    run_log: &Path,
+
    builder: &mut RunInfoBuilder,
+
) -> Result<(), NativeError> {
+
    let mut run_log = LogFile::open(run_log)?;
+

+
    log.write(format!("CI run on {}, {}\n", repo, commit))?;
+

+
    run_log.write_str("# Log from Radicle native CI\n\n")?;
+
    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_path = storage.join(repo.canonical());
+
    debug!("repo path: {}", repo_path.display());
+

+
    debug!("cloning repository to {}", src.display());
+
    log.write_str("clone repository\n")?;
+
    runcmd(
+
        &mut run_log,
+
        &[
+
            "git",
+
            "clone",
+
            repo_path.to_str().unwrap(),
+
            src.to_str().unwrap(),
+
        ],
+
        Path::new("."),
+
    )?;
+

+
    debug!("checking out commit {}", commit);
+
    log.write_str("check out commit\n")?;
+
    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");
+
    log.write_str("run shell snippet in repository\n")?;
+
    let snippet = format!("set -xeuo pipefail\n{}", &runspec.shell);
+
    runcmd(&mut run_log, &["bash", "-c", &snippet], src)?;
+

+
    let result = RunResult::Success;
+

+
    write_response(&Response::finished(result.clone()))?;
+

+
    run_log.write_str("CI run finished successfully\n")?;
+

+
    std::fs::remove_dir_all(src).map_err(|e| NativeError::RemoveDir(src.into(), e))?;
+

+
    builder.result(result);
+

+
    Ok(())
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
enum NativeError {
+
    #[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(transparent)]
+
    Config(#[from] ConfigError),
+

+
    #[error(transparent)]
+
    Log(#[from] LogError),
+

+
    #[error(transparent)]
+
    Message(#[from] NativeMessageError),
+

+
    #[error(transparent)]
+
    Report(#[from] report::ReportError),
+

+
    #[error(transparent)]
+
    RunCmd(#[from] RunCmdError),
+

+
    #[error(transparent)]
+
    RunInfo(#[from] RunInfoError),
+

+
    #[error(transparent)]
+
    RunSpec(#[from] RunSpecError),
+
}
added src/config.rs
@@ -0,0 +1,57 @@
+
use std::path::{Path, PathBuf};
+

+
use log::{debug, error};
+
use serde::Deserialize;
+

+
use crate::logfile::{LogError, LogFile};
+

+
/// Configuration file for `radicle-native-ci`.
+
#[derive(Debug, Deserialize)]
+
#[serde(deny_unknown_fields)]
+
pub struct Config {
+
    /// Directory where per-run directories are stored. Each run gets
+
    /// its own dedicated subdirectory.
+
    pub state: PathBuf,
+

+
    /// File where native CI should write a log.
+
    pub log: PathBuf,
+
}
+

+
impl Config {
+
    pub fn load_via_env() -> Result<Self, ConfigError> {
+
        const ENV: &str = "RADICLE_NATIVE_CI";
+
        let filename = std::env::var(ENV).map_err(|e| ConfigError::GetEnv(ENV, e))?;
+
        let filename = Path::new(&filename);
+
        let config = Config::read(filename)?;
+
        debug!("configuration from {}: {:#?}", filename.display(), config);
+
        Ok(config)
+
    }
+

+
    pub fn open_log(&self) -> Result<LogFile, ConfigError> {
+
        Ok(LogFile::open(&self.log)?)
+
    }
+

+
    /// Read configuration specification from a file.
+
    pub fn read(filename: &Path) -> Result<Self, ConfigError> {
+
        let file = std::fs::File::open(filename)
+
            .map_err(|e| ConfigError::ReadConfig(filename.into(), e))?;
+
        let config = serde_yaml::from_reader(&file)
+
            .map_err(|e| ConfigError::ParseConfig(filename.into(), e))?;
+
        Ok(config)
+
    }
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
pub enum ConfigError {
+
    #[error("failed to read configuration file {0}")]
+
    ReadConfig(PathBuf, #[source] std::io::Error),
+

+
    #[error("failed to parse configuration file as YAML: {0}")]
+
    ParseConfig(PathBuf, #[source] serde_yaml::Error),
+

+
    #[error("failed to get environment variable {0}")]
+
    GetEnv(&'static str, #[source] std::env::VarError),
+

+
    #[error(transparent)]
+
    Log(#[from] LogError),
+
}
added src/lib.rs
@@ -0,0 +1,7 @@
+
pub mod config;
+
pub mod logfile;
+
pub mod msg;
+
pub mod report;
+
pub mod runcmd;
+
pub mod runinfo;
+
pub mod runspec;
added src/logfile.rs
@@ -0,0 +1,43 @@
+
use std::{
+
    fs::{File, OpenOptions},
+
    io::Write,
+
    path::{Path, PathBuf},
+
};
+

+
pub struct LogFile {
+
    filename: PathBuf,
+
    file: File,
+
}
+

+
impl LogFile {
+
    pub fn open(filename: &Path) -> Result<Self, LogError> {
+
        let file = OpenOptions::new()
+
            .append(true)
+
            .create(true)
+
            .open(filename)
+
            .map_err(|e| LogError::OpenLogFile(filename.into(), e))?;
+
        Ok(Self {
+
            filename: filename.into(),
+
            file,
+
        })
+
    }
+

+
    pub fn write(&mut self, msg: String) -> Result<(), LogError> {
+
        self.write_str(&msg)
+
    }
+

+
    pub fn write_str(&mut self, msg: &str) -> Result<(), LogError> {
+
        self.file
+
            .write_all(msg.as_bytes())
+
            .map_err(|e| LogError::WriteLogFile(self.filename.clone(), e))
+
    }
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
pub enum LogError {
+
    #[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),
+
}
deleted src/main.rs
@@ -1,523 +0,0 @@
-
//! 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, OpenOptions},
-
    io::Write,
-
    path::{Path, PathBuf},
-
    process::Command,
-
};
-

-
use log::{debug, error, info};
-
use serde::{Deserialize, Serialize};
-
use time::{macros::format_description, OffsetDateTime};
-
use uuid::Uuid;
-

-
use radicle::prelude::Profile;
-
use radicle_ci_broker::msg::{Id, MessageError, Oid, Request, Response, RunId, RunResult};
-

-
mod report;
-

-
/// 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()?;
-

-
    let mut builder = RunInfo::builder();
-

-
    let result = fallible_main_inner(&config, &mut logfile, &mut builder);
-
    if result.is_ok() {
-
        builder.result(RunResult::Success);
-
    } else {
-
        builder.result(RunResult::Failure);
-
    }
-
    builder.build()?.write()?;
-

-
    logfile.write(format!(
-
        "update report page in {}\n",
-
        config.state.display()
-
    ))?;
-
    if let Err(e) = report::build_report(&mut logfile, &config.state) {
-
        logfile.write(format!("report generation failed: {}\n", e))?;
-
    }
-
    logfile.write(format!("radicle-native-ci ends: {:?}\n", result))?;
-
    result
-
}
-

-
fn fallible_main_inner(
-
    config: &Config,
-
    logfile: &mut LogFile,
-
    builder: &mut RunInfoBuilder,
-
) -> Result<(), NativeError> {
-
    let (run_id, run_dir) = mkdir_run(config)?;
-
    let run_id = RunId::from(format!("{}", run_id).as_str());
-
    logfile.write(format!("run directory {}\n", run_dir.display()))?;
-

-
    let src = run_dir.join("src");
-
    let run_log = run_dir.join("log.txt");
-
    let run_info_file = run_dir.join("run.yaml");
-

-
    let profile = Profile::load().map_err(NativeError::LoadProfile)?;
-
    let storage = profile.storage.path();
-
    // logfile.write(format!("profile: {:#?}\n", profile))?;
-

-
    let req = read_request()?;
-
    logfile.write(format!("request: {:#?}\n", req))?;
-

-
    builder.id(run_id.clone());
-
    builder.log(&config.state, run_log.clone());
-
    builder.run_info(run_info_file.clone());
-

-
    if let Request::Trigger { repo, commit } = req {
-
        info!("Request to trigger CI on {}, {}", repo, commit);
-
        builder.repo(repo);
-
        builder.commit(commit);
-
        let result = run(
-
            run_id, storage, repo, commit, &src, logfile, &run_log, builder,
-
        );
-
        if let Err(e) = result {
-
            error!("CI run failed: {}", e);
-
            logfile.write(format!("CI failed: {:?}\n", e))?;
-
            builder.result(RunResult::Failure);
-
            return Err(e);
-
        }
-
        logfile.write_str("CI run exited zero")?;
-
    } else {
-
        write_response(&Response::error("first request was not Trigger\n"))?;
-
        builder.result(RunResult::Error("first request was not Trigger".into()));
-
    };
-

-
    logfile.write_str("radicle-native-ci ends successfully")?;
-
    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.
-
#[allow(clippy::too_many_arguments)]
-
fn run(
-
    run_id: RunId,
-
    storage: &Path,
-
    repo: Id,
-
    commit: Oid,
-
    src: &Path,
-
    log: &mut LogFile,
-
    run_log: &Path,
-
    builder: &mut RunInfoBuilder,
-
) -> Result<(), NativeError> {
-
    let mut run_log = LogFile::open(run_log)?;
-

-
    log.write(format!("CI run on {}, {}\n", repo, commit))?;
-

-
    run_log.write_str("# Log from Radicle native CI\n\n")?;
-
    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_path = storage.join(repo.canonical());
-
    debug!("repo path: {}", repo_path.display());
-

-
    debug!("cloning repository to {}", src.display());
-
    log.write_str("clone repository\n")?;
-
    runcmd(
-
        &mut run_log,
-
        &[
-
            "git",
-
            "clone",
-
            repo_path.to_str().unwrap(),
-
            src.to_str().unwrap(),
-
        ],
-
        Path::new("."),
-
    )?;
-

-
    debug!("checking out commit {}", commit);
-
    log.write_str("check out commit\n")?;
-
    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");
-
    log.write_str("run shell snippet in repository\n")?;
-
    let snippet = format!("set -xeuo pipefail\n{}", &runspec.shell);
-
    runcmd(&mut run_log, &["bash", "-c", &snippet], src)?;
-

-
    let result = RunResult::Success;
-

-
    write_response(&Response::finished(result.clone()))?;
-

-
    run_log.write_str("CI run finished successfully\n")?;
-

-
    std::fs::remove_dir_all(src).map_err(|e| NativeError::RemoveDir(src.into(), e))?;
-

-
    builder.result(result);
-

-
    Ok(())
-
}
-

-
/// Run a command in a directory.
-
fn runcmd(log: &mut LogFile, argv: &[&str], cwd: &Path) -> Result<(), NativeError> {
-
    debug!("runcmd: argv={:?}", argv);
-
    debug!("runcmd: cwd={:?}", cwd);
-

-
    log.write_str("## Run command\n\n")?;
-
    log.write(format!("~~~\n{:?}\n~~~\n\n", argv))?;
-
    log.write(format!("in directory: {}\n\n", cwd.display()))?;
-

-
    assert!(!argv.is_empty());
-
    let argv0 = argv[0];
-
    let output = Command::new(argv0)
-
        .args(&argv[1..])
-
        .current_dir(cwd)
-
        .output()
-
        .map_err(|e| NativeError::Command(argv.iter().map(|s| s.to_string()).collect(), e))?;
-
    debug!("{:#?}", output);
-

-
    fenced(log, "Standard output:", &output.stdout)?;
-
    fenced(log, "Standard error:", &output.stderr)?;
-

-
    let exit = output.status;
-
    debug!("exit: {:?}", exit);
-
    log.write(format!("Exit: {}\n\n\n", exit.code().unwrap()))?;
-

-
    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 Err(NativeError::CommandFailed(
-
            exit.code().unwrap(),
-
            argv.iter().map(|s| s.to_string()).collect(),
-
        ));
-
    }
-
    Ok(())
-
}
-

-
fn fenced(log: &mut LogFile, msg: &str, data: &[u8]) -> Result<(), NativeError> {
-
    const FENCED_BLOCK: &str = "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n";
-
    log.write_str(msg)?;
-
    log.write_str("\n")?;
-
    log.write_str(FENCED_BLOCK)?;
-
    if !data.is_empty() {
-
        let text = String::from_utf8_lossy(data);
-
        log.write_str(&text)?;
-
        if !text.ends_with('\n') {
-
            log.write_str("\n")?;
-
        }
-
    }
-
    log.write_str(FENCED_BLOCK)?;
-
    log.write_str("\n")?;
-
    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: File,
-
}
-

-
impl LogFile {
-
    fn open(filename: &Path) -> Result<Self, NativeError> {
-
        let file = OpenOptions::new()
-
            .append(true)
-
            .create(true)
-
            .open(filename)
-
            .map_err(|e| NativeError::OpenLogFile(filename.into(), e))?;
-
        Ok(Self {
-
            filename: filename.into(),
-
            file,
-
        })
-
    }
-

-
    fn write(&mut self, msg: String) -> Result<(), NativeError> {
-
        self.write_str(&msg)
-
    }
-

-
    fn write_str(&mut self, msg: &str) -> Result<(), NativeError> {
-
        self.file
-
            .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)
-
    }
-
}
-

-
/// Metadata about a run.
-
#[derive(Debug, Serialize, Deserialize)]
-
#[serde(deny_unknown_fields)]
-
#[allow(dead_code)]
-
pub struct RunInfo {
-
    /// Repository ID.
-
    repo: String,
-

-
    /// Commit ID.
-
    commit: String,
-

-
    /// Identifier for the CI run.
-
    id: String,
-

-
    /// Result of the CI run.
-
    result: RunResult,
-

-
    /// Name of log file.
-
    log: PathBuf,
-

-
    /// Name of run info file.
-
    #[serde(skip)]
-
    pub run_info: PathBuf,
-

-
    /// Timestamp of when the run ended (the value was created).
-
    /// ISO8601 format.
-
    timestamp: String,
-
}
-

-
impl RunInfo {
-
    fn builder() -> RunInfoBuilder {
-
        RunInfoBuilder::default()
-
    }
-

-
    fn write(&self) -> Result<(), NativeError> {
-
        info!("Writing run info to {}", self.run_info.display());
-
        let yaml = serde_yaml::to_string(&self).map_err(NativeError::SerializeRunInfo)?;
-
        std::fs::write(&self.run_info, yaml.as_bytes())
-
            .map_err(|e| NativeError::WriteRunInfo(self.run_info.clone(), e))?;
-
        Ok(())
-
    }
-
}
-

-
#[derive(Debug, Default)]
-
#[allow(dead_code)]
-
struct RunInfoBuilder {
-
    repo: Option<Id>,
-
    commit: Option<Oid>,
-
    id: Option<String>,
-
    result: Option<RunResult>,
-
    log: Option<PathBuf>,
-
    run_info: Option<PathBuf>,
-
}
-

-
impl RunInfoBuilder {
-
    fn repo(&mut self, repo: Id) {
-
        self.repo = Some(repo);
-
    }
-

-
    fn commit(&mut self, commit: Oid) {
-
        self.commit = Some(commit);
-
    }
-

-
    fn id(&mut self, id: RunId) {
-
        self.id = Some(format!("{}", id));
-
    }
-

-
    fn result(&mut self, result: RunResult) {
-
        self.result = Some(result);
-
    }
-

-
    fn log(&mut self, state: &Path, log: PathBuf) {
-
        self.log = Some(log.strip_prefix(state).unwrap().into());
-
    }
-

-
    fn run_info(&mut self, filename: PathBuf) {
-
        self.run_info = Some(filename);
-
    }
-

-
    fn build(self) -> Result<RunInfo, NativeError> {
-
        let fmt = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
-
        let now = OffsetDateTime::now_utc()
-
            .format(fmt)
-
            .map_err(NativeError::TimeFormat)?;
-

-
        Ok(RunInfo {
-
            repo: self.repo.map(|x| x.to_string()).unwrap_or("".into()),
-
            commit: self.commit.map(|x| x.to_string()).unwrap_or("".into()),
-
            id: self.id.unwrap_or("<unknown>".into()),
-
            result: self
-
                .result
-
                .ok_or(NativeError::MissingInfo("result".into()))?,
-
            log: self.log.unwrap_or("<unknown>".into()),
-
            run_info: self.run_info.unwrap_or("<unknown>".into()),
-
            timestamp: now,
-
        })
-
    }
-
}
-

-
#[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 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),
-

-
    #[error("programming error: field {0} is not set")]
-
    MissingInfo(String),
-

-
    #[error("failed to write file for run metadata: {0}")]
-
    WriteRunInfo(PathBuf, #[source] std::io::Error),
-

-
    #[error("failed to serialize run metadata: programming error")]
-
    SerializeRunInfo(#[source] serde_yaml::Error),
-

-
    #[error(transparent)]
-
    Report(#[from] report::ReportError),
-

-
    #[error("failed to format current time as a string")]
-
    TimeFormat(#[source] time::error::Format),
-

-
    #[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>),
-
}
added src/msg.rs
@@ -0,0 +1,63 @@
+
use std::path::PathBuf;
+

+
use log::{debug, error};
+

+
use radicle_ci_broker::msg::{MessageError, Request, Response};
+

+
use crate::{
+
    config::ConfigError, logfile::LogError, report, runcmd::RunCmdError, runinfo::RunInfoError,
+
    runspec::RunSpecError,
+
};
+

+
/// Read a request from stdin.
+
pub fn read_request() -> Result<Request, NativeMessageError> {
+
    let req = Request::from_reader(std::io::stdin()).map_err(NativeMessageError::ReadRequest)?;
+
    debug!("request: {:#?}", req);
+
    Ok(req)
+
}
+

+
/// Write response to stdout.
+
pub fn write_response(resp: &Response) -> Result<(), NativeMessageError> {
+
    resp.to_writer(std::io::stdout())
+
        .map_err(|e| NativeMessageError::WriteResponse(resp.clone(), e))?;
+
    Ok(())
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
pub enum NativeMessageError {
+
    #[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 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(transparent)]
+
    Config(#[from] ConfigError),
+

+
    #[error(transparent)]
+
    Log(#[from] LogError),
+

+
    #[error(transparent)]
+
    Report(#[from] report::ReportError),
+

+
    #[error(transparent)]
+
    RunCmd(#[from] RunCmdError),
+

+
    #[error(transparent)]
+
    RunInfo(#[from] RunInfoError),
+

+
    #[error(transparent)]
+
    RunSpec(#[from] RunSpecError),
+
}
modified src/report.rs
@@ -7,7 +7,7 @@ use html_page::{Document, Element, Tag};
use radicle_ci_broker::msg::RunResult;
use walkdir::WalkDir;

-
use crate::{LogFile, RunInfo};
+
use crate::{logfile::LogFile, runinfo::RunInfo};

const CSS: &str = include_str!("native-ci.css");

added src/runcmd.rs
@@ -0,0 +1,77 @@
+
use std::{path::Path, process::Command};
+

+
use log::{debug, error};
+

+
use radicle_ci_broker::msg::{MessageError, Response};
+

+
use crate::logfile::{LogError, LogFile};
+

+
/// Run a command in a directory.
+
pub fn runcmd(log: &mut LogFile, argv: &[&str], cwd: &Path) -> Result<(), RunCmdError> {
+
    debug!("runcmd: argv={:?}", argv);
+
    debug!("runcmd: cwd={:?}", cwd);
+

+
    log.write_str("## Run command\n\n")?;
+
    log.write(format!("~~~\n{:?}\n~~~\n\n", argv))?;
+
    log.write(format!("in directory: {}\n\n", cwd.display()))?;
+

+
    assert!(!argv.is_empty());
+
    let argv0 = argv[0];
+
    let output = Command::new(argv0)
+
        .args(&argv[1..])
+
        .current_dir(cwd)
+
        .output()
+
        .map_err(|e| RunCmdError::Command(argv.iter().map(|s| s.to_string()).collect(), e))?;
+
    debug!("{:#?}", output);
+

+
    fenced(log, "Standard output:", &output.stdout)?;
+
    fenced(log, "Standard error:", &output.stderr)?;
+

+
    let exit = output.status;
+
    debug!("exit: {:?}", exit);
+
    log.write(format!("Exit: {}\n\n\n", exit.code().unwrap()))?;
+

+
    if !exit.success() {
+
        let error = Response::error(&format!("command failed: {:?}", argv));
+
        error
+
            .to_writer(std::io::stdout())
+
            .map_err(|e| RunCmdError::WriteResponse(error.clone(), e))?;
+
        return Err(RunCmdError::CommandFailed(
+
            exit.code().unwrap(),
+
            argv.iter().map(|s| s.to_string()).collect(),
+
        ));
+
    }
+
    Ok(())
+
}
+

+
fn fenced(log: &mut LogFile, msg: &str, data: &[u8]) -> Result<(), RunCmdError> {
+
    const FENCED_BLOCK: &str = "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n";
+
    log.write_str(msg)?;
+
    log.write_str("\n")?;
+
    log.write_str(FENCED_BLOCK)?;
+
    if !data.is_empty() {
+
        let text = String::from_utf8_lossy(data);
+
        log.write_str(&text)?;
+
        if !text.ends_with('\n') {
+
            log.write_str("\n")?;
+
        }
+
    }
+
    log.write_str(FENCED_BLOCK)?;
+
    log.write_str("\n")?;
+
    Ok(())
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
pub enum RunCmdError {
+
    #[error("failed to write response to stdout: {0:?}")]
+
    WriteResponse(Response, #[source] MessageError),
+

+
    #[error(transparent)]
+
    Log(#[from] LogError),
+

+
    #[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>),
+
}
added src/runinfo.rs
@@ -0,0 +1,121 @@
+
use std::path::{Path, PathBuf};
+

+
use log::{error, info};
+
use serde::{Deserialize, Serialize};
+
use time::{macros::format_description, OffsetDateTime};
+

+
use radicle_ci_broker::msg::{Id, Oid, RunId, RunResult};
+

+
/// Metadata about a run.
+
#[derive(Debug, Serialize, Deserialize)]
+
#[serde(deny_unknown_fields)]
+
#[allow(dead_code)]
+
pub struct RunInfo {
+
    /// Repository ID.
+
    pub repo: String,
+

+
    /// Commit ID.
+
    pub commit: String,
+

+
    /// Identifier for the CI run.
+
    pub id: String,
+

+
    /// Result of the CI run.
+
    pub result: RunResult,
+

+
    /// Name of log file.
+
    pub log: PathBuf,
+

+
    /// Name of run info file.
+
    #[serde(skip)]
+
    pub run_info: PathBuf,
+

+
    /// Timestamp of when the run ended (the value was created).
+
    /// ISO8601 format.
+
    pub timestamp: String,
+
}
+

+
impl RunInfo {
+
    pub fn builder() -> RunInfoBuilder {
+
        RunInfoBuilder::default()
+
    }
+

+
    pub fn write(&self) -> Result<(), RunInfoError> {
+
        info!("Writing run info to {}", self.run_info.display());
+
        let yaml = serde_yaml::to_string(&self).map_err(RunInfoError::SerializeRunInfo)?;
+
        std::fs::write(&self.run_info, yaml.as_bytes())
+
            .map_err(|e| RunInfoError::WriteRunInfo(self.run_info.clone(), e))?;
+
        Ok(())
+
    }
+
}
+

+
#[derive(Debug, Default)]
+
#[allow(dead_code)]
+
pub struct RunInfoBuilder {
+
    repo: Option<Id>,
+
    commit: Option<Oid>,
+
    id: Option<String>,
+
    result: Option<RunResult>,
+
    log: Option<PathBuf>,
+
    run_info: Option<PathBuf>,
+
}
+

+
impl RunInfoBuilder {
+
    pub fn repo(&mut self, repo: Id) {
+
        self.repo = Some(repo);
+
    }
+

+
    pub fn commit(&mut self, commit: Oid) {
+
        self.commit = Some(commit);
+
    }
+

+
    pub fn id(&mut self, id: RunId) {
+
        self.id = Some(format!("{}", id));
+
    }
+

+
    pub fn result(&mut self, result: RunResult) {
+
        self.result = Some(result);
+
    }
+

+
    pub fn log(&mut self, state: &Path, log: PathBuf) {
+
        self.log = Some(log.strip_prefix(state).unwrap().into());
+
    }
+

+
    pub fn run_info(&mut self, filename: PathBuf) {
+
        self.run_info = Some(filename);
+
    }
+

+
    pub fn build(self) -> Result<RunInfo, RunInfoError> {
+
        let fmt = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
+
        let now = OffsetDateTime::now_utc()
+
            .format(fmt)
+
            .map_err(RunInfoError::TimeFormat)?;
+

+
        Ok(RunInfo {
+
            repo: self.repo.map(|x| x.to_string()).unwrap_or("".into()),
+
            commit: self.commit.map(|x| x.to_string()).unwrap_or("".into()),
+
            id: self.id.unwrap_or("<unknown>".into()),
+
            result: self
+
                .result
+
                .ok_or(RunInfoError::MissingInfo("result".into()))?,
+
            log: self.log.unwrap_or("<unknown>".into()),
+
            run_info: self.run_info.unwrap_or("<unknown>".into()),
+
            timestamp: now,
+
        })
+
    }
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
pub enum RunInfoError {
+
    #[error("programming error: field {0} is not set")]
+
    MissingInfo(String),
+

+
    #[error("failed to write file for run metadata: {0}")]
+
    WriteRunInfo(PathBuf, #[source] std::io::Error),
+

+
    #[error("failed to serialize run metadata: programming error")]
+
    SerializeRunInfo(#[source] serde_yaml::Error),
+

+
    #[error("failed to format current time as a string")]
+
    TimeFormat(#[source] time::error::Format),
+
}
added src/runspec.rs
@@ -0,0 +1,41 @@
+
use std::path::{Path, PathBuf};
+

+
use log::{debug, error};
+
use serde::Deserialize;
+

+
use crate::logfile::LogError;
+

+
/// 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`.
+
    pub shell: String,
+
}
+

+
impl RunSpec {
+
    /// Read run specification from a file.
+
    pub fn from_file(filename: &Path) -> Result<Self, RunSpecError> {
+
        debug!("loading CI run spec from {}", filename.display());
+
        let file = std::fs::File::open(filename)
+
            .map_err(|e| RunSpecError::ReadRunSpec(filename.into(), e))?;
+
        let runspec: RunSpec = serde_yaml::from_reader(&file)
+
            .map_err(|e| RunSpecError::ParseRunSpec(filename.into(), e))?;
+
        debug!("runspec: {:#?}", runspec);
+
        Ok(runspec)
+
    }
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
pub enum RunSpecError {
+
    #[error("failed to read run specification file {0}")]
+
    ReadRunSpec(PathBuf, #[source] std::io::Error),
+

+
    #[error("failed to parse run spec as YAML: {0}")]
+
    ParseRunSpec(PathBuf, #[source] serde_yaml::Error),
+

+
    #[error(transparent)]
+
    Log(#[from] LogError),
+
}