Skip to main content

radicle_native_ci/
run.rs

1use std::{
2    path::{Path, PathBuf},
3    process::Command,
4    time::SystemTime,
5};
6
7use radicle_ci_broker::{
8    ergo::Oid,
9    msg::{helper::MessageHelperError, RepoId, Request, RunId},
10};
11
12use crate::{
13    runlog::{RunLog, RunLogError},
14    runspec::{RunSpec, RunSpecError},
15};
16
17// Exit code to indicate we didn't get one from the process.
18const NO_EXIT: i32 = 999;
19
20// Path to the repository's CI run specification. This is relative to
21// the root of the repository.
22pub const RUNSPEC_PATH: &str = ".radicle/native.yaml";
23
24/// Execute the project-specific parts of a CI run. This needs to be
25/// set up with `set_*` methods, and then the [`run`](Run::run) method
26/// needs to be called.
27#[derive(Debug)]
28pub struct Run {
29    run_log: RunLog,
30    rid: Option<RepoId>,
31    request: Option<Request>,
32    repo_name: Option<String>,
33    commit: Option<Oid>,
34    storage: Option<PathBuf>,
35    src: PathBuf,
36}
37
38impl Run {
39    /// Create a new `Run`.
40    pub fn new(run_id: RunId, run_dir: &Path, run_log_filename: &Path) -> Result<Self, RunError> {
41        let mut run_log = RunLog::new(run_log_filename);
42        run_log.adapter_run_id(run_id);
43
44        Ok(Self {
45            run_log,
46            rid: None,
47            repo_name: None,
48            request: None,
49            commit: None,
50            storage: None,
51            src: run_dir.join("src"),
52        })
53    }
54
55    /// Set the message that triggered this run.
56    pub fn set_request(&mut self, req: Request) {
57        self.request = Some(req);
58    }
59
60    /// Set the git repository to use for this run.
61    pub fn set_repository(&mut self, rid: RepoId, repo_name: &str) {
62        self.rid = Some(rid);
63        self.repo_name = Some(repo_name.into());
64    }
65
66    /// Set the commit to use for this run.
67    pub fn set_commit(&mut self, commit: Oid) {
68        self.commit = Some(commit);
69    }
70
71    /// Set the location of the Radicle node git storage.
72    pub fn set_storage(&mut self, path: &Path) {
73        self.storage = Some(path.into());
74    }
75
76    /// Run CI on a project.
77    ///
78    /// This runs CI for the project, and then clean up: persist the
79    /// per-run log, and clean up some disk space after a CI run. On
80    /// success, return the run log. A CI run has succeeded, if all
81    /// the commands run as part of it succeeded.
82    ///
83    /// Note that this consume the `Run`.
84    pub fn run(mut self) -> Result<RunLog, RunError> {
85        // Execute the actual CI run.
86        let result = self.run_helper();
87
88        // If writing the run log fails, it will obscure any previous
89        // problem from the CI run. That's intentional: we want to
90        // make the node admin aware of the log writing problem, as
91        // that can be a problem for all future runs, e.g., due to a
92        // full file system, whereas, e.g., a syntax error in the code
93        // under test, is more fleeting.
94        let write_result = self.run_log.write();
95        if result.is_ok() {
96            write_result?;
97        }
98
99        // Likewise, if we can't clean up disk space, the node admin
100        // needs to know about that.
101        let rmdir_result = std::fs::remove_dir_all(&self.src)
102            .map_err(|e| RunError::RemoveDir(self.src.clone(), e));
103        if result.is_ok() {
104            rmdir_result?;
105        }
106
107        // Return result from the actual CI run.
108        result.map(|_| self.run_log)
109    }
110
111    // Execute CI run once, without worrying about cleanup. Store any
112    // problems in the run log. The caller is responsible for
113    // persisting the run log. This returns an error only if it's a
114    // programming error.
115    fn run_helper(&mut self) -> Result<(), RunError> {
116        // Get values fields we'll need to use, if they've been set.
117        let rid = self.rid.ok_or(RunError::Missing("rid"))?;
118        let repo_name = self
119            .repo_name
120            .as_ref()
121            .ok_or(RunError::Missing("repo_name"))?;
122        let request = self.request.clone().ok_or(RunError::Missing("request"))?;
123        let commit = self.commit.ok_or(RunError::Missing("commit"))?;
124        let storage = self.storage.as_ref().ok_or(RunError::Missing("storage"))?;
125
126        // Record metadata in the run log.
127        self.run_log.title("Log from Radicle native CI");
128        self.run_log.rid(rid, repo_name);
129        self.run_log.commit(commit);
130        self.run_log.request(request);
131
132        // Clone the repository and check out the right commit. If
133        // these fail, the problem is stored in the run log.
134        let repo_path = storage.join(rid.canonical());
135        let src = self.src.to_path_buf();
136        self.git_clone(&repo_path, &src)?;
137        self.git_checkout(commit, &src)?;
138        self.git_show(commit, &src)?;
139
140        let runspec_path = self.src.join(RUNSPEC_PATH);
141        let runspec = match RunSpec::from_file(&runspec_path) {
142            Ok(runspec) => {
143                self.run_log.runspec(runspec.clone());
144                runspec
145            }
146            Err(e) => {
147                // Log error in run log, then return. We can't do
148                // anything more if we don't have the run spec.
149                // However, return `Ok`, so that the CI engine doesn't
150                // report that the engine failed.
151                self.run_log.runspec_error(&e);
152                return Ok(());
153            }
154        };
155
156        let snippet = format!("set -xeuo pipefail\n{}", &runspec.shell);
157        self.runcmd(&["bash", "-c", &snippet], &src)?;
158
159        Ok(())
160    }
161
162    fn git_clone(&mut self, repo_path: &Path, src: &Path) -> Result<(), RunError> {
163        self.runcmd(
164            &[
165                "git",
166                "clone",
167                repo_path.to_str().unwrap(),
168                src.to_str().unwrap(),
169            ],
170            Path::new("."),
171        )?;
172        Ok(())
173    }
174
175    fn git_checkout(&mut self, commit: Oid, src: &Path) -> Result<(), RunError> {
176        self.runcmd(&["git", "config", "advice.detachedHead", "false"], src)?;
177        self.runcmd(&["git", "checkout", &commit.to_string()], src)?;
178        Ok(())
179    }
180
181    fn git_show(&mut self, commit: Oid, src: &Path) -> Result<(), RunError> {
182        self.runcmd(&["git", "show", &commit.to_string()], src)?;
183        Ok(())
184    }
185
186    // Run an external command in a directory. Log the command and the
187    // result of running it to the run log. Return an error if the
188    // command could not be executed at all, but the exit code if it
189    // ran but failed.
190    fn runcmd(&mut self, argv: &[&str], cwd: &Path) -> Result<i32, RunError> {
191        if argv.is_empty() {
192            return Err(RunError::EmptyArgv);
193        }
194
195        let started = SystemTime::now();
196        let output = Command::new("bash")
197            .arg("-c")
198            .arg(r#""$@" 2>&1"#)
199            .arg("--")
200            .args(argv)
201            .current_dir(cwd)
202            .output()
203            .map_err(|e| RunError::Command(argv.iter().map(|s| s.to_string()).collect(), e))?;
204        let ended = SystemTime::now();
205
206        let exit = output.status.code().unwrap_or(NO_EXIT);
207        self.run_log.runcmd(
208            argv,
209            &cwd.canonicalize()
210                .map_err(|e| RunError::Canonicalize(cwd.into(), e))?,
211            exit,
212            &output.stdout,
213            started,
214            ended,
215        );
216        Ok(exit)
217    }
218}
219
220#[derive(Debug, thiserror::Error)]
221pub enum RunError {
222    #[error("failed to create per-run parent directory {0}")]
223    CreateState(PathBuf, #[source] std::io::Error),
224
225    #[error("failed to create per-run directory {0}")]
226    CreateRunDir(PathBuf, #[source] std::io::Error),
227
228    #[error("failed to load Radicle profile")]
229    LoadProfile(#[source] radicle::profile::Error),
230
231    #[error("failed to remove {0}")]
232    RemoveDir(PathBuf, #[source] std::io::Error),
233
234    #[error("programming error: failed to set field {0}")]
235    Unset(&'static str),
236
237    #[error(transparent)]
238    Message(#[from] MessageHelperError),
239
240    #[error(transparent)]
241    RunLog(#[from] RunLogError),
242
243    #[error(transparent)]
244    RunSpec(#[from] RunSpecError),
245
246    #[error("failed to run command {0:?}")]
247    Command(Vec<String>, #[source] std::io::Error),
248
249    #[error("command failed with exit code {0}: {1:?}")]
250    CommandFailed(i32, Vec<String>),
251
252    #[error("failed to make pathname absolute: {0}")]
253    Canonicalize(PathBuf, #[source] std::io::Error),
254
255    #[error("programming error: function runcmd called with empty argv")]
256    EmptyArgv,
257
258    #[error("programming error: field '{0}' was not set in struct Run")]
259    Missing(&'static str),
260}