1use 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
20pub 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
29pub 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}