Radish alpha
r
rad:z3qg5TKmN83afz2fj9z3fQjU8vaYE
Radicle CI adapter for native CI
Radicle
Git
radicle-native-ci src engine.rs
use std::path::PathBuf;

use uuid::Uuid;

use radicle::prelude::Profile;
use radicle_ci_broker::{
    ergo::Oid,
    msg::{
        helper::{
            read_request, write_failed, write_succeeded, write_triggered, MessageHelperError,
        },
        EventCommonFields, Patch, PatchEvent, PushEvent, RepoId, Repository, Request, RunId,
        RunResult,
    },
};

use crate::{
    config::{Config, ConfigError},
    logfile::{AdminLog, LogError},
    run::{Run, RunError},
    runlog::RunLogError,
    runspec::RunSpecError,
};

/// Run CI for a project once.
#[derive(Debug)]
pub struct Engine {
    config: Config,
    adminlog: AdminLog,
    result: Option<RunResult>,
}

impl Engine {
    /// Create a new engine and prepare for actually running CI. This
    /// may fail, for various reasons, such as the configuration file
    /// not existing. The caller should handle the error in some
    /// suitable way, such as report that to its stderr, and exit
    /// non-zero.
    #[allow(clippy::result_large_err)]
    pub fn new() -> Result<Self, EngineError> {
        // Get config, open admin log for writing. If either of these
        // fails, we can't write about the problem to the admin log,
        // but we can return an error to the caller.
        let config = Config::load_via_env()?;
        let adminlog = config.open_log()?;

        // From here on, it's plain sailing.

        Ok(Self {
            config,
            adminlog,
            result: None,
        })
    }

    /// Return config that has been loaded for the engine.
    pub fn config(&self) -> &Config {
        &self.config
    }

    /// Set up and run CI on a project once: read the trigger request
    /// from stdin, write responses to stdout. Update node admin log
    /// with any problems that aren't inherent in the git repository
    /// (those go into the run log).
    #[allow(clippy::result_large_err)]
    pub fn run(&mut self) -> Result<bool, EngineError> {
        let req = match self.setup() {
            Ok(req) => req,
            Err(e) => {
                self.adminlog.writeln(&format!("Error setting up: {e}"))?;
                return Err(e);
            }
        };

        // Check that we got the right kind of request.
        let mut success = false;
        match &req {
            Request::Trigger {
                common:
                    EventCommonFields {
                        repository: Repository { name, .. },
                        ..
                    },
                push: Some(PushEvent { branch, .. }),
                ..
            } => {
                let repo = req.repo();
                let commit = req.commit().map_err(EngineError::BrokerMessage)?;

                match self.run_helper(repo, name, req.clone(), commit, Some(branch), None) {
                    Ok(true) => success = true,
                    Ok(false) => (),
                    Err(e) => {
                        // If the run helper return an error, something
                        // went wrong in that is not due to the repository
                        // under test. So we don't put it in the run log,
                        // but the admin log and return it to the caller.
                        self.adminlog.writeln(&format!("Error running CI: {e}"))?;
                        return Err(e);
                    }
                }
            }
            Request::Trigger {
                common:
                    EventCommonFields {
                        repository: Repository { name, .. },
                        ..
                    },
                patch:
                    Some(PatchEvent {
                        patch: Patch { id, title, .. },
                        ..
                    }),
                ..
            } => {
                let repo = req.repo();
                let commit = req.commit().map_err(EngineError::BrokerMessage)?;
                self.adminlog
                    .writeln(&format!("run CI for {repo} commit {commit}"))?;

                match self.run_helper(repo, name, req.clone(), commit, None, Some((*id, title))) {
                    Ok(true) => success = true,
                    Ok(false) => (),
                    Err(e) => {
                        // If the run helper return an error, something
                        // went wrong in that is not due to the repository
                        // under test. So we don't put it in the run log,
                        // but the admin log and return it to the caller.
                        self.adminlog.writeln(&format!("Error running CI: {e}"))?;
                        return Err(e);
                    }
                }
            }
            _ => {
                // Protocol error. Log in admin log and report to caller.
                self.adminlog
                    .writeln("First request was not a message to trigger a run.")?;
                return Err(EngineError::NotTrigger(req));
            }
        }

        if let Err(e) = self.finish() {
            self.adminlog.writeln(&format!("Error finishing up: {e}"))?;
            return Err(e);
        }

        Ok(success)
    }

    // Set up CI to run. If something goes wrong, return the error,
    // and assume the caller logs it to the admin log.
    #[allow(clippy::result_large_err)]
    fn setup(&mut self) -> Result<Request, EngineError> {
        // Write something to the admin log to indicate we start.
        self.adminlog.writeln("Native CI run starts")?;

        // Read request and log it.
        let req = read_request()?;
        self.adminlog.writeln(&format!("request: {req:#?}"))?;

        Ok(req)
    }

    // Finish up after a CI run. If something goes wrong, return the
    // error, and assume the caller logs it to the admin log.
    #[allow(clippy::result_large_err)]
    fn finish(&mut self) -> Result<(), EngineError> {
        // Write response message indicating the run has finished.
        match &self.result {
            Some(RunResult::Success) => write_succeeded()?,
            Some(RunResult::Failure) => write_failed()?,
            _ => panic!("do not know how to handle {:#?}", self.result),
        }

        // Log that we've reached the end successfully.
        self.adminlog.writeln("Native CI ends successfully")?;

        Ok(())
    }

    // Execute the CI run. Log any problems to a log for this run, and
    // persist that. Update the run info builder as needed.
    #[allow(clippy::result_large_err)]
    fn run_helper(
        &mut self,
        rid: RepoId,
        name: &str,
        req: Request,
        commit: Oid,
        branch: Option<&str>,
        patch: Option<(Oid, &str)>,
    ) -> Result<bool, EngineError> {
        // Pick a run id and create a directory for files related to
        // the run.
        let (run_id, run_dir) = mkdir_run(&self.config)?;
        let run_id = RunId::from(format!("{run_id}").as_str());

        // Set the file where the run info should be written, now that
        // we have the run directory.
        let run_log_filename = run_dir.join("log.html");

        // Get node git storage. We do this before responding to the
        // trigger, so that if this fails, we haven't said that a run
        // has started.
        let profile = Profile::load().map_err(EngineError::LoadProfile)?;
        let storage = profile.storage.path();

        // Write response to indicate run has been triggered.
        if let Some(url) = &self.config.base_url {
            let url = if url.ends_with('/') {
                format!("{url}{run_id}/log.html")
            } else {
                format!("{url}/{run_id}/log.html")
            };
            write_triggered(&run_id, Some(&url))?;
        } else {
            write_triggered(&run_id, None)?;
        }

        // Create and set up the run.
        let mut run = Run::new(run_id, &run_dir, &run_log_filename)?;
        run.set_repository(rid, name);
        run.set_request(req);
        run.set_commit(commit);
        run.set_storage(storage);

        // Actually run. Examine the run log to decide if the run
        // succeeded or failed.
        let result = run.run();
        if let Ok(mut run_log) = result {
            if let Some(branch) = branch {
                run_log.branch(branch);
            }
            if let Some((patch, title)) = patch {
                run_log.patch(patch, title);
            }
            self.result = if run_log.all_commands_succeeded() {
                Some(RunResult::Success)
            } else {
                Some(RunResult::Failure)
            };
            let all = run_log.all_commands_succeeded();
            Ok(all)
        } else {
            self.result = Some(RunResult::Failure);
            Ok(false)
        }
    }

    /// Report results to caller (via stdout) and to users (via report
    /// on web page).
    #[allow(clippy::result_large_err)]
    pub fn report(&mut self) -> Result<(), EngineError> {
        Ok(())
    }
}

/// Create a per-run directory.
#[allow(clippy::result_large_err)]
fn mkdir_run(config: &Config) -> Result<(Uuid, PathBuf), EngineError> {
    let state = &config.state;
    if !state.exists() {
        std::fs::create_dir_all(state).map_err(|e| EngineError::CreateState(state.into(), e))?;
    }

    let run_id = Uuid::new_v4();
    let run_dir = state.join(run_id.to_string());
    std::fs::create_dir(&run_dir).map_err(|e| EngineError::CreateRunDir(run_dir.clone(), e))?;
    Ok((run_id, run_dir))
}

#[derive(Debug, thiserror::Error)]
#[allow(clippy::large_enum_variant)]
pub enum EngineError {
    #[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("programming error: failed to set field {0}")]
    Unset(&'static str),

    #[error(transparent)]
    Config(#[from] ConfigError),

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

    #[error(transparent)]
    BrokerMessage(#[from] radicle_ci_broker::msg::MessageError),

    #[error(transparent)]
    Message(#[from] MessageHelperError),

    #[error(transparent)]
    RunLog(#[from] RunLogError),

    #[error(transparent)]
    RunSpec(#[from] RunSpecError),

    #[error(transparent)]
    Run(#[from] RunError),

    #[error("request message was not a trigger message: {0:?}")]
    NotTrigger(Request),
}