//! Parallel testing. //! //! mcuboot simulator is strictly single threaded, as there is a lock around running the C startup //! code, because it contains numerous global variables. //! //! To help speed up testing, the Workflow configuration defines all of the configurations that can //! be run in parallel. Fortunately, cargo works well this way, and these can be run by simply //! using subprocess for each particular thread. //! //! For now, we assume all of the features are listed under //! jobs->environment->strategy->matric->features use chrono::Local; use clap::{Parser, Subcommand}; use log::{debug, error, warn}; use std::{ collections::HashSet, env, fs::{self, OpenOptions}, io::{ErrorKind, stdout, Write}, process::Command, result, sync::{ Arc, Mutex, }, thread, time::Duration, }; use std_semaphore::Semaphore; use yaml_rust::{ Yaml, YamlLoader, }; type Result = result::Result; fn main() -> Result<()> { env_logger::init(); let args = Cli::parse(); let workflow_text = fs::read_to_string(&args.workflow)?; let workflow = YamlLoader::load_from_str(&workflow_text)?; let ncpus = num_cpus::get(); let limiter = Arc::new(Semaphore::new(ncpus as isize)); let matrix = Matrix::from_yaml(&workflow); let matrix = if args.test.len() == 0 { matrix } else { matrix.only(&args.test) }; match args.command { Commands::List => { matrix.show(); return Ok(()); } Commands::Run => (), } let mut children = vec![]; let state = State::new(matrix.envs.len()); let st2 = state.clone(); let _status = thread::spawn(move || { loop { thread::sleep(Duration::new(15, 0)); st2.lock().unwrap().status(); } }); for env in matrix.envs { let state = state.clone(); let limiter = limiter.clone(); let child = thread::spawn(move || { let _run = limiter.access(); state.lock().unwrap().start(&env); let out = env.run(); state.lock().unwrap().done(&env, out); }); children.push(child); } for child in children { child.join().unwrap(); } println!(); Ok(()) } /// The main Cli. #[derive(Debug, Parser)] #[command(name = "ptest")] #[command(about = "Run MCUboot CI tests stand alone")] struct Cli { /// The workflow file to use. #[arg(short, long, default_value = "../.github/workflows/sim.yaml")] workflow: String, /// The tests to run (defaults to all) #[arg(short, long)] test: Vec, #[command(subcommand)] command: Commands, } #[derive(Debug, Subcommand)] enum Commands { /// Runs the tests. Run, /// List available tests. List, } /// State, for printing status. struct State { running: HashSet, done: HashSet, total: usize, } /// Result of a test run. struct TestResult { /// Was this run successful. success: bool, /// The captured output. output: Vec, } impl State { fn new(total: usize) -> Arc> { Arc::new(Mutex::new(State { running: HashSet::new(), done: HashSet::new(), total, })) } fn start(&mut self, fs: &FeatureSet) { let key = fs.textual(); if self.running.contains(&key) || self.done.contains(&key) { warn!("Duplicate: {:?}", key); } debug!("Starting: {} ({} running)", key, self.running.len() + 1); self.running.insert(key); self.status(); } fn done(&mut self, fs: &FeatureSet, output: Result) { let key = fs.textual(); self.running.remove(&key); self.done.insert(key.clone()); match output { Ok(output) => { if !output.success || log_all() { // Write the output into a file. let mut count = 1; let (mut fd, logname) = loop { let base = if output.success { "success" } else { "failure" }; let name = format!("./{}-{:04}.log", base, count); count += 1; match OpenOptions::new() .create_new(true) .write(true) .open(&name) { Ok(file) => break (file, name), Err(ref err) if err.kind() == ErrorKind::AlreadyExists => continue, Err(err) => { error!("Unable to write log file to current directory: {:?}", err); return; } } }; fd.write_all(&output.output).unwrap(); if !output.success { error!("Failure {} log:{:?} ({} running)", key, logname, self.running.len()); } } } Err(err) => { error!("Unable to run test {:?} ({:?}", key, err); } } self.status(); } fn status(&self) { let running = self.running.len(); let done = self.done.len(); print!(" {} running ({}/{}/{} done)\r", running, done, running + done, self.total); stdout().flush().unwrap(); } } /// The extracted configurations from the workflow config #[derive(Debug)] struct Matrix { envs: Vec, } #[derive(Debug, Eq, Hash, PartialEq)] struct FeatureSet { // The environment variable to set. env: String, // The successive values to set it to. values: Vec, } impl Matrix { fn from_yaml(yaml: &[Yaml]) -> Matrix { let mut envs = vec![]; let mut all_tests = HashSet::new(); for y in yaml { let m = match lookup_matrix(y) { Some (m) => m, None => continue, }; for elt in m { let elt = match elt.as_str() { None => { warn!("Unexpected yaml: {:?}", elt); continue; } Some(e) => e, }; let fset = FeatureSet::decode(elt); if false { // Respect the groupings in the `.workflow.yml` file. envs.push(fset); } else { // Break each test up so we can run more in // parallel. let env = fset.env.clone(); for val in fset.values { if !all_tests.contains(&val) { all_tests.insert(val.clone()); envs.push(FeatureSet { env: env.clone(), values: vec![val], }); } else { warn!("Duplicate: {:?}: {:?}", env, val); } } } } } Matrix { envs, } } /// Print out all of the feature sets. fn show(&self) { for (i, feature) in self.envs.iter().enumerate() { println!("{:3}. {}", i + 1, feature.simple_textual()); } } /// Replace this matrix with one that only has the chosen tests in it. Note /// that the original order is preserved, not that given in `pick`. fn only(self, pick: &[usize]) -> Self { let pick: HashSet = pick.iter().cloned().collect(); let envs: Vec<_> = self .envs .into_iter() .enumerate() .filter(|(ind, _)| pick.contains(&(ind + 1))) .map(|(_, item)| item) .collect(); Matrix { envs } } } impl FeatureSet { fn decode(text: &str) -> FeatureSet { // The github workflow is just a space separated set of values. let values: Vec<_> = text .split(',') .map(|s| s.to_string()) .collect(); FeatureSet { env: "MULTI_FEATURES".to_string(), values, } } /// Run a test for this given feature set. Output is captured and will be returned if there is /// an error. Each will be run successively, and the first failure will be returned. /// Otherwise, it returns None, which means everything worked. fn run(&self) -> Result { let mut output = vec![]; let mut success = true; for v in &self.values { let cmdout = Command::new("bash") .arg("./ci/sim_run.sh") .current_dir("..") .env(&self.env, v) .output()?; // Grab the output for logging, etc. writeln!(&mut output, "Test {} {}", if cmdout.status.success() { "success" } else { "FAILURE" }, self.textual())?; writeln!(&mut output, "time: {}", Local::now().to_rfc3339())?; writeln!(&mut output, "----------------------------------------")?; writeln!(&mut output, "stdout:")?; output.extend(&cmdout.stdout); writeln!(&mut output, "----------------------------------------")?; writeln!(&mut output, "stderr:")?; output.extend(&cmdout.stderr); if !cmdout.status.success() { success = false; } } Ok(TestResult { success, output }) } /// Convert this feature set into a textual representation fn textual(&self) -> String { use std::fmt::Write; let mut buf = String::new(); write!(&mut buf, "{}:", self.env).unwrap(); for v in &self.values { write!(&mut buf, " {}", v).unwrap(); } buf } /// Generate a simpler textual representation, without showing the environment. fn simple_textual(&self) -> String { use std::fmt::Write; let mut buf = String::new(); for v in &self.values { write!(&mut buf, " {}", v).unwrap(); } buf } } fn lookup_matrix(y: &Yaml) -> Option<&Vec> { let jobs = Yaml::String("jobs".to_string()); let environment = Yaml::String("environment".to_string()); let strategy = Yaml::String("strategy".to_string()); let matrix = Yaml::String("matrix".to_string()); let features = Yaml::String("features".to_string()); y .as_hash()?.get(&jobs)? .as_hash()?.get(&environment)? .as_hash()?.get(&strategy)? .as_hash()?.get(&matrix)? .as_hash()?.get(&features)? .as_vec() } /// Query if we should be logging all tests and not only failures. fn log_all() -> bool { env::var("PTEST_LOG_ALL").is_ok() }