Skip to main content

ci_engine/
exec.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Sequential local check orchestration.
3
4use std::{collections::BTreeMap, path::Path, time::Instant};
5
6use ci_config::{Check, CiConfig, Trigger};
7use crypto::{Conclusion, FailureClass};
8
9use crate::{
10    cache::prepare_caches,
11    classify::{Disposition, classify},
12    env::HermeticEnv,
13    model::{AttemptRecord, CheckResult, ExecutionContext, RunControls, RunOptions},
14    proc_group::ProcGroupRegistry,
15    process::{RunOutput, run_process},
16    result::{CompletedRun, finalize, infra_result, skipped_result},
17    result_cache::{ResultCache, ResultCacheError, SpotCheck, with_cache},
18};
19
20/// Run every check with default controls.
21pub fn run_checks(
22    config: &CiConfig,
23    context: &ExecutionContext,
24    options: &RunOptions<'_>,
25) -> Result<Vec<CheckResult>, ResultCacheError> {
26    run_checks_with(config, context, options, &RunControls::default())
27}
28
29/// Run checks with explicit trigger/cache/environment controls.
30pub fn run_checks_with(
31    config: &CiConfig,
32    context: &ExecutionContext,
33    options: &RunOptions<'_>,
34    controls: &RunControls<'_>,
35) -> Result<Vec<CheckResult>, ResultCacheError> {
36    let default_environment = HermeticEnv::new();
37    let environment = controls.hermetic_env.unwrap_or(&default_environment);
38    let default_cache_root = options.workdir.join(".hci-cache");
39    let cache_root = controls.cache_root.unwrap_or(&default_cache_root);
40    let resolved = ResolvedRun {
41        options,
42        environment,
43        cache_root,
44        proc_groups: controls.proc_groups.as_ref(),
45        result_cache: controls.result_cache,
46        spot_check: controls.spot_check,
47    };
48    config
49        .checks
50        .iter()
51        .map(|check| match &controls.trigger {
52            Some(trigger) if !check_runs_for_trigger(&check.triggers, trigger) => {
53                Ok(skipped_result(check, context, &resolved))
54            }
55            _ => run_one_check(check, context, &resolved),
56        })
57        .collect()
58}
59
60pub(crate) struct ResolvedRun<'a> {
61    pub(crate) options: &'a RunOptions<'a>,
62    pub(crate) environment: &'a HermeticEnv,
63    pub(crate) cache_root: &'a Path,
64    pub(crate) proc_groups: Option<&'a ProcGroupRegistry>,
65    pub(crate) result_cache: Option<&'a dyn ResultCache>,
66    pub(crate) spot_check: SpotCheck,
67}
68
69fn run_one_check(
70    check: &Check,
71    context: &ExecutionContext,
72    run: &ResolvedRun<'_>,
73) -> Result<CheckResult, ResultCacheError> {
74    let service_environment = declared_service_env(check);
75    let key_environment = run
76        .environment
77        .build(&check.env, &service_environment, &BTreeMap::new());
78    with_cache(check, context, run, &key_environment, || {
79        run_one_check_uncached(check, context, run, &service_environment)
80    })
81}
82
83fn declared_service_env(check: &Check) -> BTreeMap<String, String> {
84    check
85        .services
86        .iter()
87        .flat_map(|service| {
88            service
89                .env
90                .iter()
91                .map(|entry| (entry.0.clone(), entry.1.clone()))
92        })
93        .collect()
94}
95
96fn run_one_check_uncached(
97    check: &Check,
98    context: &ExecutionContext,
99    run: &ResolvedRun<'_>,
100    service_environment: &BTreeMap<String, String>,
101) -> CheckResult {
102    let started_at = (run.options.now_rfc3339)();
103    let started = Instant::now();
104    let caches = prepare_caches(&check.cache_paths, run.cache_root);
105    let services = match run.options.services.up(&check.services) {
106        Ok(services) => services,
107        Err(error) => {
108            return infra_result(check, context, run, started_at, started.elapsed(), &error);
109        }
110    };
111    let environment = run
112        .environment
113        .build(&check.env, service_environment, &caches.env);
114    let (last, attempts) = run_attempts(check, run, &environment);
115    let _ = run.options.services.down(services);
116    finalize(
117        check,
118        context,
119        CompletedRun {
120            output: last,
121            attempts,
122            environment,
123            started_at,
124            finished_at: (run.options.now_rfc3339)(),
125            duration: started.elapsed(),
126        },
127    )
128}
129
130fn run_attempts(
131    check: &Check,
132    run: &ResolvedRun<'_>,
133    environment: &BTreeMap<String, String>,
134) -> (RunOutput, Vec<AttemptRecord>) {
135    let mut last = RunOutput::default();
136    let mut records = Vec::new();
137    for attempt in 1..=check.retry.max.saturating_add(1) {
138        let started = Instant::now();
139        last = run_process(check, run.options.workdir, environment, run.proc_groups);
140        let flake = last.disposition != Disposition::Success
141            && matches_flake_signature(check, &last.combined_output);
142        records.push(AttemptRecord {
143            attempt,
144            conclusion: disposition_conclusion(last.disposition, &last.combined_output),
145            duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
146            flake_matched: flake,
147        });
148        if last.disposition == Disposition::Success || !flake {
149            break;
150        }
151    }
152    (last, records)
153}
154
155fn matches_flake_signature(check: &Check, output: &str) -> bool {
156    check.retry.flake_signatures.iter().any(|pattern| {
157        regex::Regex::new(pattern)
158            .map(|regex| regex.is_match(output))
159            .unwrap_or(false)
160    })
161}
162
163fn disposition_conclusion(disposition: Disposition, output: &str) -> Conclusion {
164    match classify(disposition, output) {
165        None => Conclusion::Success,
166        Some(FailureClass::Timeout) => Conclusion::TimedOut,
167        Some(FailureClass::Infra) => Conclusion::InfraError,
168        Some(_) => Conclusion::Failure,
169    }
170}
171
172fn check_runs_for_trigger(check: &[Trigger], pick: &Trigger) -> bool {
173    if check.is_empty() {
174        return matches!(pick, Trigger::Push);
175    }
176    check.iter().any(|trigger| {
177        matches!(
178            (trigger, pick),
179            (Trigger::Push, Trigger::Push)
180                | (Trigger::Manual, Trigger::Manual)
181                | (Trigger::Cron(_), Trigger::Cron(_))
182        )
183    })
184}