Skip to main content

ambient_ci/
run.rs

1//! Execute a CI run.
2
3#![allow(clippy::result_large_err)]
4
5use std::{
6    collections::HashSet,
7    path::{Path, PathBuf},
8};
9
10use tempfile::{tempdir_in, TempDir};
11
12use crate::{
13    action::Context,
14    cloud_init::{CloudInitError, LocalDataStore, LocalDataStoreBuilder},
15    config::Config,
16    git::{git_head, git_is_clean, is_git, GitError},
17    plan::{construct_all_plans, PlanError, RunnablePlan},
18    project::{Project, ProjectError, Projects, State},
19    qemu::{QemuError, QemuRunner},
20    runlog::{RunLog, RunLogError, RunLogSource},
21    util::{mkdir, mkdir_child, recreate_dir, UtilError},
22    vdrive::{create_tar, create_tar_with_size, VirtualDrive, VirtualDriveError},
23};
24
25/// Execute a CI run.
26#[allow(clippy::too_many_arguments)]
27pub fn cmd_run(
28    config: &Config,
29    runlog: &mut RunLog,
30    projects: &Projects,
31    chosen_projects: Option<&[String]>,
32    dry_run: bool,
33    force: bool,
34    uefi: bool,
35) -> Result<(), RunError> {
36    let executor = config.executor().ok_or(RunError::NoRunCi)?;
37    eprintln!("executor from config: {}", executor.display());
38
39    let abs_executor = find_on_path(executor)?;
40    eprintln!("executor from PATH: {}", abs_executor.display());
41
42    let statedir = config.state();
43    if !statedir.exists() {
44        mkdir(statedir).map_err(|e| RunError::MkdirState(statedir.into(), e))?;
45    }
46
47    for (name, project) in chosen(projects, chosen_projects) {
48        let (do_run, mut state) = should_run(dry_run, force, statedir, name, project)?;
49
50        if do_run {
51            state.remove_run_log()?;
52            let log = state.create_run_log()?;
53            runlog.to_named_file(&log).map_err(RunError::RunLogCreate)?;
54
55            state.remove_raw_log()?;
56            let raw_log = state.create_raw_log()?;
57            let console_log = state.create_console_log()?;
58
59            runlog.run_ci(RunLogSource::Prelude, name);
60            let (pre_plan, mut plan, post_plan) =
61                construct_all_plans(config, name, project, &state)?;
62
63            let mut pre_plan_context = Context::new(runlog);
64            pre_plan.execute(RunLogSource::PrePlan, &mut pre_plan_context)?;
65            plan.carry_over_from_context(&pre_plan_context);
66
67            let tmp = tempdir_in(config.tmpdir()).map_err(RunError::TempDir)?;
68
69            let source_drive = create_source_vdrive(&tmp, project.source())?;
70            let deps_drive = create_dependencies_vdrive(&tmp, &state)?;
71            let executor_drive = create_executor_vdrive(&tmp, &plan, &abs_executor)?;
72
73            let artifactsdir = state.artifactsdir();
74            let artifact_drive = create_artifacts_vdrive(&tmp, config, project, &artifactsdir)?;
75
76            let cachedir = state.cachedir();
77            let cache_drive = create_cache_vdrive(&tmp, config, project, &cachedir)?;
78
79            let ds = create_cloud_init_iso(false)?;
80
81            let exit = QemuRunner::default()
82                .config(config)
83                .base_image(project.image())
84                .executor(&executor_drive)
85                .source(&source_drive)
86                .cache(&cache_drive)
87                .dependencies(&deps_drive)
88                .artifacts(&artifact_drive)
89                .raw_log(&raw_log)
90                .console_log(&console_log)
91                .cloud_init(&ds)
92                .uefi(uefi)
93                .run(RunLogSource::Plan, runlog)?;
94
95            if exit == 0 {
96                artifact_drive.extract_to(&artifactsdir)?;
97
98                if cachedir.exists() {
99                    recreate_dir(&cachedir)?;
100                }
101
102                cache_drive
103                    .extract_to(&cachedir)
104                    .map_err(|e| QemuError::ExtractCache(cachedir.clone(), Box::new(e)))?;
105
106                post_plan.execute(RunLogSource::PostPlan, &mut Context::new(runlog))?;
107            }
108
109            if is_git(project.source()) {
110                let head = git_head(project.source())?;
111                state.set_latest_commot(Some(&head));
112            } else {
113                state.set_latest_commot(None);
114            };
115            state.write_to_file()?;
116
117            if exit != 0 {
118                return Err(RunError::RunFailed);
119            }
120        } else {
121            runlog.skip_ci(RunLogSource::Prelude, name);
122        }
123    }
124
125    Ok(())
126}
127
128fn find_on_path(bin: &Path) -> Result<PathBuf, RunError> {
129    let path = std::env::var_os("PATH").ok_or(RunError::NoPath)?;
130    for dir in std::env::split_paths(&path) {
131        let full = dir.join(bin);
132        if full.exists() {
133            return Ok(full);
134        }
135    }
136    Err(RunError::NotOnPath(bin.to_path_buf()))
137}
138
139/// Create an ISO disk image for a `cloud-init` data store.
140pub fn create_cloud_init_iso(network: bool) -> Result<LocalDataStore, RunError> {
141    const BOOTSTRAP: &str = r#"
142(set -xeu
143env
144dir="$(mktemp -d)"
145cd "$dir"
146tar -xvf /dev/vdb
147find -ls || true
148ldd ./run-ci || true
149echo ================================ BEGIN ================================
150export RUST_BACKTRACE=1
151if ./run-ci; then
152        echo "EXIT CODE: 0"
153else
154        echo "EXIT CODE: $?"
155fi) > /dev/ttyS1 2>&1
156"#;
157    LocalDataStoreBuilder::default()
158        .with_hostname("ambient")
159        .with_runcmd("echo xyzzy > /dev/ttyS1")
160        .with_runcmd(BOOTSTRAP)
161        .with_runcmd("poweroff")
162        .with_network(network)
163        .build()
164        .map_err(RunError::CloudInit)
165}
166
167/// Create a drive for source code.
168pub fn create_source_vdrive(tmp: &TempDir, source_dir: &Path) -> Result<VirtualDrive, RunError> {
169    Ok(create_tar(tmp.path().join("src.tar"), source_dir)?)
170}
171
172fn create_artifacts_vdrive(
173    tmp: &TempDir,
174    config: &Config,
175    project: &Project,
176    artifactsdir: &Path,
177) -> Result<VirtualDrive, RunError> {
178    recreate_dir(artifactsdir)?;
179    Ok(create_tar_with_size(
180        tmp.path().join("artifacts.tar"),
181        artifactsdir,
182        project
183            .artifact_max_size()
184            .unwrap_or(config.artifacts_max_size()),
185    )?)
186}
187
188fn create_dependencies_vdrive(tmp: &TempDir, state: &State) -> Result<VirtualDrive, RunError> {
189    let dependencies = state.dependenciesdir();
190    if !dependencies.exists() {
191        mkdir(&dependencies)
192            .map_err(|e| RunError::MkdirProjectSubState(dependencies.clone(), e))?;
193    }
194    Ok(create_tar(tmp.path().join("deps.tar"), &dependencies)?)
195}
196
197fn create_cache_vdrive(
198    tmp: &TempDir,
199    config: &Config,
200    project: &Project,
201    cachedir: &Path,
202) -> Result<VirtualDrive, RunError> {
203    if !cachedir.exists() {
204        mkdir(cachedir).map_err(|e| RunError::MkdirProjectSubState(cachedir.into(), e))?;
205    }
206    Ok(create_tar_with_size(
207        tmp.path().join("cache.tar"),
208        cachedir,
209        project.cache_max_size().unwrap_or(config.cache_max_size()),
210    )?)
211}
212
213/// Create a virtual drive for the executro and runnable plan.
214pub fn create_executor_vdrive(
215    tmp: &TempDir,
216    plan: &RunnablePlan,
217    executor: &Path,
218) -> Result<VirtualDrive, RunError> {
219    assert!(executor.exists());
220
221    let dirname =
222        mkdir_child(tmp.path(), "ambient-execute-plan").map_err(RunError::MkdirProjectRunCi)?;
223    let bin2 = dirname.join("run-ci");
224
225    std::fs::copy(executor, &bin2).map_err(|e| RunError::Copy(executor.into(), bin2.clone(), e))?;
226
227    let plan_filename = dirname.join("plan.yaml");
228    plan.to_file(&plan_filename)?;
229
230    Ok(create_tar(tmp.path().join("executor.tar"), &dirname)?)
231}
232
233fn should_run(
234    dry_run: bool,
235    force: bool,
236    statedir: &Path,
237    name: &str,
238    project: &Project,
239) -> Result<(bool, State), RunError> {
240    let mut decision = Decision::default();
241
242    let state = State::from_file(statedir, name)?;
243    if let Some(latest_commit) = state.latest_commit() {
244        decision.latest_commit(latest_commit);
245    } else {
246        // No need to set latest commit to anything, the default is OK.
247    }
248
249    let is_git = is_git(project.source());
250    if is_git {
251        decision.is_git();
252    } else {
253        decision.is_not_git();
254    }
255
256    if git_is_clean(project.source()) {
257        decision.is_clean();
258    } else {
259        decision.is_dirty();
260    }
261
262    if is_git {
263        let head = git_head(project.source())?;
264        decision.current_commit(&head);
265    } else {
266        // No need to set current commit to anything, the default is OK.
267    }
268
269    if dry_run {
270        decision.dry_run();
271    } else {
272        decision.no_dry_run();
273    }
274
275    if force {
276        decision.force();
277    } else {
278        decision.no_force();
279    }
280
281    let do_run = decision.should_run() == ShouldRun::Run;
282
283    Ok((do_run, state))
284}
285
286fn chosen<'a>(projects: &'a Projects, chosen: Option<&[String]>) -> Vec<(&'a str, &'a Project)> {
287    let set: HashSet<&str> = match chosen {
288        Some(v) if !v.is_empty() => v.iter().map(|s| s.as_str()).collect(),
289        _ => projects.iter().map(|(k, _)| k).collect(),
290    };
291    let mut projects: Vec<(&'a str, &'a Project)> =
292        projects.iter().filter(|(k, _)| set.contains(k)).collect();
293    projects.sort_by(|(a_name, _), (b_name, _)| a_name.cmp(b_name));
294    projects
295}
296
297/// Errors from exeucting a CI run.
298#[derive(Debug, thiserror::Error)]
299pub enum RunError {
300    /// Forwarded from the `project` module.
301    #[error(transparent)]
302    Project(#[from] ProjectError),
303
304    /// Forwarded from the `util` module.
305    #[error(transparent)]
306    Util(#[from] UtilError),
307
308    /// Can't create a context.
309    #[error("failed to create a context for executing actions")]
310    Context(#[source] crate::action::ActionError),
311
312    /// Can't create a temporary directory.
313    #[error("failed to create temporary directory for running CI build")]
314    TempDir(#[source] std::io::Error),
315
316    /// Can't create state directory.
317    #[error("failed to create a general state directory")]
318    MkdirState(PathBuf, #[source] UtilError),
319
320    /// Can't create project directory.
321    #[error("failed to create a project state sub-directory")]
322    MkdirProjectSubState(PathBuf, #[source] UtilError),
323
324    /// Can't create temporary directory.
325    #[error("failed to create a temporary directory for action runner")]
326    MkdirProjectRunCi(#[source] UtilError),
327
328    /// Can't create `cloud-init` ISO file.
329    #[error("failed to create a cloud-init ISO file")]
330    CloudInit(#[source] CloudInitError),
331
332    /// The created virtual drive is too big.
333    #[error("virtual drive is too big: {0} > {1}")]
334    DriveTooBig(u64, u64),
335
336    /// Forwarded from the `qemu` module.
337    #[error(transparent)]
338    Qemu(#[from] QemuError),
339
340    /// CI run failed.
341    #[error("CI run failed inside QEMU")]
342    RunFailed,
343
344    /// Can't copy file.
345    #[error("failed to copy {0} to {1}")]
346    Copy(PathBuf, PathBuf, #[source] std::io::Error),
347
348    /// Forwarded from `plan` module.
349    #[error(transparent)]
350    Plan(#[from] PlanError),
351
352    /// Forwarded from `git` module.
353    #[error(transparent)]
354    Git(#[from] GitError),
355
356    /// Forwarded from `vdrive` module.
357    #[error(transparent)]
358    VDrive(#[from] VirtualDriveError),
359
360    /// No executor in config.
361    #[error("you must set path to ambient-execute-plan program with option or in configuration")]
362    NoRunCi,
363
364    /// Can't open run log.
365    #[error("failed to open run log")]
366    RunLogCreate(#[source] RunLogError),
367
368    /// No PATH.
369    #[error("failed to find PATH in the environment")]
370    NoPath,
371
372    /// No binary on PATH.
373    #[error("failed to find {0} on PATH")]
374    NotOnPath(PathBuf),
375}
376
377#[derive(Debug, Default)]
378struct Decision {
379    dry_run: Option<bool>,
380    force_run: Option<bool>,
381    is_git: Option<bool>,
382    latest_commit: Option<String>,
383    current_commit: Option<String>,
384    source_is_dirty: Option<bool>,
385}
386
387impl Decision {
388    fn dry_run(&mut self) {
389        self.dry_run = Some(true);
390    }
391
392    fn no_dry_run(&mut self) {
393        self.dry_run = Some(false);
394    }
395
396    fn force(&mut self) {
397        self.force_run = Some(true);
398    }
399
400    fn no_force(&mut self) {
401        self.force_run = Some(false);
402    }
403
404    fn is_git(&mut self) {
405        self.is_git = Some(true);
406    }
407
408    fn is_not_git(&mut self) {
409        self.is_git = Some(false);
410    }
411
412    fn latest_commit(&mut self, commit: &str) {
413        self.latest_commit = Some(commit.into());
414    }
415
416    fn current_commit(&mut self, commit: &str) {
417        self.current_commit = Some(commit.into());
418    }
419
420    fn is_clean(&mut self) {
421        self.source_is_dirty = Some(false);
422    }
423
424    fn is_dirty(&mut self) {
425        self.source_is_dirty = Some(true);
426    }
427
428    fn should_run(&self) -> ShouldRun {
429        let dry_run = self.dry_run == Some(true);
430        let force = self.force_run == Some(true);
431        let is_git = self.is_git == Some(true);
432        let dirty = self.source_is_dirty == Some(true);
433
434        if dry_run {
435            Self::log("dry run");
436            ShouldRun::DontRun
437        } else if force {
438            Self::log("force");
439            ShouldRun::Run
440        } else if !is_git {
441            Self::log("not git");
442            ShouldRun::Run
443        } else if dirty {
444            Self::log("dirty");
445            ShouldRun::Run
446        } else if self.current_commit == self.latest_commit {
447            Self::log("commits are equal");
448            ShouldRun::DontRun
449        } else {
450            Self::log("nothing prevents run");
451            ShouldRun::Run
452        }
453    }
454
455    #[allow(unused_variables)]
456    fn log(msg: &str) {
457        #[cfg(test)]
458        println!("{msg}");
459    }
460}
461
462// Use a custom enum to avoid confusing reader with interpreting
463// boolean values, specially in tests.
464#[derive(Debug, Eq, PartialEq, Copy, Clone)]
465enum ShouldRun {
466    Run,
467    DontRun,
468}
469
470#[cfg(test)]
471mod test_run_decision {
472    use super::{Decision, ShouldRun};
473
474    #[test]
475    fn is_not_git() {
476        let mut d = Decision::default();
477        d.no_dry_run();
478        d.no_force();
479        d.is_not_git();
480        d.is_clean();
481        assert_eq!(d.should_run(), ShouldRun::Run);
482    }
483
484    #[test]
485    fn unchanged() {
486        let mut d = Decision::default();
487        d.no_dry_run();
488        d.no_force();
489        d.is_git();
490        d.is_clean();
491        d.latest_commit("abcd");
492        d.current_commit("abcd");
493        assert_eq!(d.should_run(), ShouldRun::DontRun);
494    }
495
496    #[test]
497    fn unchanged_with_force() {
498        let mut d = Decision::default();
499        d.no_dry_run();
500        d.force();
501        d.is_git();
502        d.is_clean();
503        d.latest_commit("abcd");
504        d.current_commit("abcd");
505        assert_eq!(d.should_run(), ShouldRun::Run);
506    }
507
508    #[test]
509    fn unchanged_commit_but_dirty() {
510        let mut d = Decision::default();
511        d.no_dry_run();
512        d.no_force();
513        d.is_git();
514        d.is_dirty();
515        d.latest_commit("abcd");
516        d.current_commit("abcd");
517        assert_eq!(d.should_run(), ShouldRun::Run);
518    }
519
520    #[test]
521    fn commit_changed() {
522        let mut d = Decision::default();
523        d.no_dry_run();
524        d.no_force();
525        d.is_git();
526        d.is_clean();
527        d.latest_commit("abcd");
528        d.current_commit("efgh");
529        assert_eq!(d.should_run(), ShouldRun::Run);
530    }
531
532    #[test]
533    fn dry_run_for_unchanged() {
534        let mut d = Decision::default();
535        d.dry_run();
536        d.no_force();
537        d.is_git();
538        d.is_clean();
539        d.latest_commit("abcd");
540        d.current_commit("abcd");
541        assert_eq!(d.should_run(), ShouldRun::DontRun);
542    }
543
544    #[test]
545    fn dry_run_for_unchanged_but_dirty() {
546        let mut d = Decision::default();
547        d.dry_run();
548        d.no_force();
549        d.is_git();
550        d.is_dirty();
551        d.latest_commit("abcd");
552        d.current_commit("efgh");
553        assert_eq!(d.should_run(), ShouldRun::DontRun);
554    }
555
556    #[test]
557    fn dry_run_for_commit_changed() {
558        let mut d = Decision::default();
559        d.dry_run();
560        d.no_force();
561        d.is_git();
562        d.is_clean();
563        d.latest_commit("abcd");
564        d.current_commit("efgh");
565        assert_eq!(d.should_run(), ShouldRun::DontRun);
566    }
567
568    #[test]
569    fn dry_run_for_unchanged_with_force() {
570        let mut d = Decision::default();
571        d.dry_run();
572        d.no_force();
573        d.is_git();
574        d.is_clean();
575        d.latest_commit("abcd");
576        d.current_commit("abcd");
577        assert_eq!(d.should_run(), ShouldRun::DontRun);
578    }
579
580    #[test]
581    fn dry_run_for_unchanged_but_dirty_with_force() {
582        let mut d = Decision::default();
583        d.dry_run();
584        d.no_force();
585        d.is_git();
586        d.is_dirty();
587        d.latest_commit("abcd");
588        d.current_commit("efgh");
589        assert_eq!(d.should_run(), ShouldRun::DontRun);
590    }
591
592    #[test]
593    fn dry_run_for_commit_changed_with_force() {
594        let mut d = Decision::default();
595        d.dry_run();
596        d.no_force();
597        d.is_git();
598        d.is_clean();
599        d.latest_commit("abcd");
600        d.current_commit("efgh");
601        assert_eq!(d.should_run(), ShouldRun::DontRun);
602    }
603}