Skip to main content

radicle_native_ci/
engine.rs

1use std::path::PathBuf;
2
3use uuid::Uuid;
4
5use radicle::prelude::Profile;
6use radicle_ci_broker::{
7    ergo::Oid,
8    msg::{
9        helper::{
10            read_request, write_failed, write_succeeded, write_triggered, MessageHelperError,
11        },
12        EventCommonFields, Patch, PatchEvent, PushEvent, RepoId, Repository, Request, RunId,
13        RunResult,
14    },
15};
16
17use crate::{
18    config::{Config, ConfigError},
19    logfile::{AdminLog, LogError},
20    run::{Run, RunError},
21    runlog::RunLogError,
22    runspec::RunSpecError,
23};
24
25/// Run CI for a project once.
26#[derive(Debug)]
27pub struct Engine {
28    config: Config,
29    adminlog: AdminLog,
30    result: Option<RunResult>,
31}
32
33impl Engine {
34    /// Create a new engine and prepare for actually running CI. This
35    /// may fail, for various reasons, such as the configuration file
36    /// not existing. The caller should handle the error in some
37    /// suitable way, such as report that to its stderr, and exit
38    /// non-zero.
39    #[allow(clippy::result_large_err)]
40    pub fn new() -> Result<Self, EngineError> {
41        // Get config, open admin log for writing. If either of these
42        // fails, we can't write about the problem to the admin log,
43        // but we can return an error to the caller.
44        let config = Config::load_via_env()?;
45        let adminlog = config.open_log()?;
46
47        // From here on, it's plain sailing.
48
49        Ok(Self {
50            config,
51            adminlog,
52            result: None,
53        })
54    }
55
56    /// Return config that has been loaded for the engine.
57    pub fn config(&self) -> &Config {
58        &self.config
59    }
60
61    /// Set up and run CI on a project once: read the trigger request
62    /// from stdin, write responses to stdout. Update node admin log
63    /// with any problems that aren't inherent in the git repository
64    /// (those go into the run log).
65    #[allow(clippy::result_large_err)]
66    pub fn run(&mut self) -> Result<bool, EngineError> {
67        let req = match self.setup() {
68            Ok(req) => req,
69            Err(e) => {
70                self.adminlog.writeln(&format!("Error setting up: {e}"))?;
71                return Err(e);
72            }
73        };
74
75        // Check that we got the right kind of request.
76        let mut success = false;
77        match &req {
78            Request::Trigger {
79                common:
80                    EventCommonFields {
81                        repository: Repository { name, .. },
82                        ..
83                    },
84                push: Some(PushEvent { branch, .. }),
85                ..
86            } => {
87                let repo = req.repo();
88                let commit = req.commit().map_err(EngineError::BrokerMessage)?;
89
90                match self.run_helper(repo, name, req.clone(), commit, Some(branch), None) {
91                    Ok(true) => success = true,
92                    Ok(false) => (),
93                    Err(e) => {
94                        // If the run helper return an error, something
95                        // went wrong in that is not due to the repository
96                        // under test. So we don't put it in the run log,
97                        // but the admin log and return it to the caller.
98                        self.adminlog.writeln(&format!("Error running CI: {e}"))?;
99                        return Err(e);
100                    }
101                }
102            }
103            Request::Trigger {
104                common:
105                    EventCommonFields {
106                        repository: Repository { name, .. },
107                        ..
108                    },
109                patch:
110                    Some(PatchEvent {
111                        patch: Patch { id, title, .. },
112                        ..
113                    }),
114                ..
115            } => {
116                let repo = req.repo();
117                let commit = req.commit().map_err(EngineError::BrokerMessage)?;
118                self.adminlog
119                    .writeln(&format!("run CI for {repo} commit {commit}"))?;
120
121                match self.run_helper(repo, name, req.clone(), commit, None, Some((*id, title))) {
122                    Ok(true) => success = true,
123                    Ok(false) => (),
124                    Err(e) => {
125                        // If the run helper return an error, something
126                        // went wrong in that is not due to the repository
127                        // under test. So we don't put it in the run log,
128                        // but the admin log and return it to the caller.
129                        self.adminlog.writeln(&format!("Error running CI: {e}"))?;
130                        return Err(e);
131                    }
132                }
133            }
134            _ => {
135                // Protocol error. Log in admin log and report to caller.
136                self.adminlog
137                    .writeln("First request was not a message to trigger a run.")?;
138                return Err(EngineError::NotTrigger(req));
139            }
140        }
141
142        if let Err(e) = self.finish() {
143            self.adminlog.writeln(&format!("Error finishing up: {e}"))?;
144            return Err(e);
145        }
146
147        Ok(success)
148    }
149
150    // Set up CI to run. If something goes wrong, return the error,
151    // and assume the caller logs it to the admin log.
152    #[allow(clippy::result_large_err)]
153    fn setup(&mut self) -> Result<Request, EngineError> {
154        // Write something to the admin log to indicate we start.
155        self.adminlog.writeln("Native CI run starts")?;
156
157        // Read request and log it.
158        let req = read_request()?;
159        self.adminlog.writeln(&format!("request: {req:#?}"))?;
160
161        Ok(req)
162    }
163
164    // Finish up after a CI run. If something goes wrong, return the
165    // error, and assume the caller logs it to the admin log.
166    #[allow(clippy::result_large_err)]
167    fn finish(&mut self) -> Result<(), EngineError> {
168        // Write response message indicating the run has finished.
169        match &self.result {
170            Some(RunResult::Success) => write_succeeded()?,
171            Some(RunResult::Failure) => write_failed()?,
172            _ => panic!("do not know how to handle {:#?}", self.result),
173        }
174
175        // Log that we've reached the end successfully.
176        self.adminlog.writeln("Native CI ends successfully")?;
177
178        Ok(())
179    }
180
181    // Execute the CI run. Log any problems to a log for this run, and
182    // persist that. Update the run info builder as needed.
183    #[allow(clippy::result_large_err)]
184    fn run_helper(
185        &mut self,
186        rid: RepoId,
187        name: &str,
188        req: Request,
189        commit: Oid,
190        branch: Option<&str>,
191        patch: Option<(Oid, &str)>,
192    ) -> Result<bool, EngineError> {
193        // Pick a run id and create a directory for files related to
194        // the run.
195        let (run_id, run_dir) = mkdir_run(&self.config)?;
196        let run_id = RunId::from(format!("{run_id}").as_str());
197
198        // Set the file where the run info should be written, now that
199        // we have the run directory.
200        let run_log_filename = run_dir.join("log.html");
201
202        // Get node git storage. We do this before responding to the
203        // trigger, so that if this fails, we haven't said that a run
204        // has started.
205        let profile = Profile::load().map_err(EngineError::LoadProfile)?;
206        let storage = profile.storage.path();
207
208        // Write response to indicate run has been triggered.
209        if let Some(url) = &self.config.base_url {
210            let url = if url.ends_with('/') {
211                format!("{url}{run_id}/log.html")
212            } else {
213                format!("{url}/{run_id}/log.html")
214            };
215            write_triggered(&run_id, Some(&url))?;
216        } else {
217            write_triggered(&run_id, None)?;
218        }
219
220        // Create and set up the run.
221        let mut run = Run::new(run_id, &run_dir, &run_log_filename)?;
222        run.set_repository(rid, name);
223        run.set_request(req);
224        run.set_commit(commit);
225        run.set_storage(storage);
226
227        // Actually run. Examine the run log to decide if the run
228        // succeeded or failed.
229        let result = run.run();
230        if let Ok(mut run_log) = result {
231            if let Some(branch) = branch {
232                run_log.branch(branch);
233            }
234            if let Some((patch, title)) = patch {
235                run_log.patch(patch, title);
236            }
237            self.result = if run_log.all_commands_succeeded() {
238                Some(RunResult::Success)
239            } else {
240                Some(RunResult::Failure)
241            };
242            let all = run_log.all_commands_succeeded();
243            Ok(all)
244        } else {
245            self.result = Some(RunResult::Failure);
246            Ok(false)
247        }
248    }
249
250    /// Report results to caller (via stdout) and to users (via report
251    /// on web page).
252    #[allow(clippy::result_large_err)]
253    pub fn report(&mut self) -> Result<(), EngineError> {
254        Ok(())
255    }
256}
257
258/// Create a per-run directory.
259#[allow(clippy::result_large_err)]
260fn mkdir_run(config: &Config) -> Result<(Uuid, PathBuf), EngineError> {
261    let state = &config.state;
262    if !state.exists() {
263        std::fs::create_dir_all(state).map_err(|e| EngineError::CreateState(state.into(), e))?;
264    }
265
266    let run_id = Uuid::new_v4();
267    let run_dir = state.join(run_id.to_string());
268    std::fs::create_dir(&run_dir).map_err(|e| EngineError::CreateRunDir(run_dir.clone(), e))?;
269    Ok((run_id, run_dir))
270}
271
272#[derive(Debug, thiserror::Error)]
273#[allow(clippy::large_enum_variant)]
274pub enum EngineError {
275    #[error("failed to create per-run parent directory {0}")]
276    CreateState(PathBuf, #[source] std::io::Error),
277
278    #[error("failed to create per-run directory {0}")]
279    CreateRunDir(PathBuf, #[source] std::io::Error),
280
281    #[error("failed to load Radicle profile")]
282    LoadProfile(#[source] radicle::profile::Error),
283
284    #[error("programming error: failed to set field {0}")]
285    Unset(&'static str),
286
287    #[error(transparent)]
288    Config(#[from] ConfigError),
289
290    #[error(transparent)]
291    Log(#[from] LogError),
292
293    #[error(transparent)]
294    BrokerMessage(#[from] radicle_ci_broker::msg::MessageError),
295
296    #[error(transparent)]
297    Message(#[from] MessageHelperError),
298
299    #[error(transparent)]
300    RunLog(#[from] RunLogError),
301
302    #[error(transparent)]
303    RunSpec(#[from] RunSpecError),
304
305    #[error(transparent)]
306    Run(#[from] RunError),
307
308    #[error("request message was not a trigger message: {0:?}")]
309    NotTrigger(Request),
310}