Radish alpha
r
rad:z3qg5TKmN83afz2fj9z3fQjU8vaYE
Radicle CI adapter for native CI
Radicle
Git
radicle-native-ci src run.rs
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),
}