use crate::job::{Job, Jobs}; use rand::Rng; use serde::{Deserialize, Serialize}; use std::collections::HashMap; #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(untagged)] pub enum Command { Single(String), Multiple(Vec), } impl Default for Command { fn default() -> Self { Self::Single(String::new()) } } impl From for Command { fn from(source: String) -> Self { Self::Single(source) } } impl From<&str> for Command { fn from(source: &str) -> Self { Self::Single(source.to_string()) } } impl std::fmt::Display for Command { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Single(v) => write!(f, "{}", &v), Self::Multiple(v) => write!(f, "{}", &v.join(" && ")), } } } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(untagged)] pub enum Dependencies { Single(String), Multiple(Vec), } impl Dependencies { pub fn to_vec(&self) -> Vec { match self { Dependencies::Single(v) => vec![v.clone()], Dependencies::Multiple(v) => v.clone(), } } } impl Default for Dependencies { fn default() -> Self { Self::Multiple(vec![]) } } #[derive(Serialize, Deserialize, Default)] #[serde(default)] /// Represents an entire configuration. This is deserialized from the complete conductor.yml file. pub struct Project { /// The components that are defined within the project. This is stored as a map with the name of /// of the component and its definition. pub components: HashMap, /// The list of groups defined in the project. These groups are stored as a map with the name of /// the group and its definition. pub groups: HashMap, /// A map of values that are populated into the environment for all components and tasks. /// This value is inclusive with any environment variables defined in the individual task or /// component. /// /// If the same value is defined here and within the task/component definition. The value /// defined in the task/component will take priority. pub env: HashMap, /// The tasks defined inside project. This is stored as a map with the task name and task /// definition pub tasks: HashMap, } impl Project { /// Retreive a list of jobs from the name of a task. This function expects the fully qualified /// task name. For subcomponents that means component::task. /// The jobs structure returned contains a seuqential execution list with all dependencies fn get_absolute_task(&self, name: &str) -> Option { self.tasks .get(name) .map(|task| { let job = self.build_project_task_job(name, task); let mut jobs = task .before .to_vec() .iter() .flat_map(|name| self.get_absolute_task(name)) .flat_map(|jobs| jobs.to_vec()) .collect::>(); jobs.push(job); Jobs::new(jobs) }) .or_else(|| { self.components.iter().find_map(|(c_name, component)| { component .tasks .iter() .find(|(t_name, _task)| format!("{}:{}", c_name, t_name) == name) .map(|(t_name, task)| { let job = self.build_component_task_job(c_name, t_name, component, task); let mut jobs = task .before .to_vec() .iter() .flat_map(|name| self.get_absolute_task(name)) .flat_map(|jobs| jobs.to_vec()) .collect::>(); jobs.push(job); Jobs::new(jobs) }) }) }) } /// Retrieves a job based on a relative task definition. The task is found by the direct task name /// and a component name separately. fn get_relative_task(&self, task_name: &str, component_name: &str) -> Option { if let Some(component) = self.components.get(component_name) { component.tasks.get(task_name).map(|task| { let job = self.build_component_task_job(component_name, task_name, component, task); let mut jobs = task .before .to_vec() .iter() .flat_map(|name| self.get_by_name(name)) .flat_map(|jobs| jobs.to_vec()) .collect::>(); jobs.push(job); Jobs::new(jobs) }) } else { None } } /// Retrieves a job based on a component definition fn get_component(&self, component_name: &str) -> Option { self.components.get(component_name).map(|c| { let component_job = self.build_component_job(component_name, c); let mut absolute_tasks = c .before .to_vec() .iter() .flat_map(|name| self.get_absolute_task(name)) .flat_map(|jobs| jobs.to_vec()) .collect::>(); let mut relative_tasks = c .before .to_vec() .iter() .flat_map(|task_name| self.get_relative_task(task_name, component_name)) .flat_map(|jobs| jobs.to_vec()) .collect::>(); absolute_tasks.append(&mut relative_tasks); absolute_tasks.push(component_job); Jobs::new(absolute_tasks) }) } /// Retrieves a vector of jobs based on a group definition. /// The return value is similar to `get_jobplan`, its is effectively a two-dimensional /// array representing parallel and sequential jobs. fn get_group(&self, name: &str) -> Option> { self.groups.get(name).map(|group| { group .components .iter() .flat_map(|name| self.get_by_name(name)) .collect() }) } /// Converts a component definition to a job definition fn build_component_job(&self, name: &str, c: &Component) -> Job { let mut env = self.env.clone(); env.extend(c.env.clone()); Job { name: name.to_string(), command: c.command.clone(), color: c.color, path: c.path.clone().unwrap_or_else(|| String::from(".")), env, ..Job::default() } } /// Converts a root level task definition to a job definition fn build_project_task_job(&self, name: &str, task: &TaskDefinition) -> Job { let mut env = self.env.clone(); env.extend(task.env.clone()); Job { env, name: name.to_string(), ..Job::from(task) } } /// Converts a component level task definition to a job definition fn build_component_task_job( &self, component_name: &str, task_name: &str, component: &Component, task: &TaskDefinition, ) -> Job { let mut env = self.env.clone(); env.extend(component.env.clone()); env.extend(task.env.clone()); Job { env, name: format!("{}:{}", component_name, task_name), ..Job::from(task) } } /// Returns a list of jobs for an object by name. This name could be a task or component and /// the jobs returned will be the matched item with all of its dependencies pub fn get_by_name(&self, name: &str) -> Option { self.get_component(name) .or_else(|| self.get_absolute_task(name)) } /// Returns a vector of jobs for an object by name. This name could be a task, component, or /// or group. The vector contains jobs that can be run in parallel. The individual jobs are run /// in sequence. pub fn get_runplan(&self, name: &str) -> Option> { self.get_by_name(name) .map(|i| vec![i]) .or_else(|| self.get_group(name)) } /// Load a project from a yaml string pub fn load_str(s: &str) -> anyhow::Result { Ok(serde_yaml::from_str(s)?) } } /// Groups are used to define a series of components that can be ran in parallel. #[derive(Serialize, Deserialize, Default, Clone)] #[serde(default)] pub struct Group { /// A list of component names to run when the group is launched pub components: Vec, /// A description for the group that is displayed in the task list pub description: Option, } /// A component represents a long running task. /// In general, these are things that need to be kept alive during the /// the operation. #[derive(Serialize, Deserialize, Clone)] #[serde(default)] pub struct Component { /// A description for the component that is displayed in the task list pub description: Option, /// A map of environment settings that are set before launching the component pub env: HashMap, /// The command(s) to execute when the component is launched pub command: Command, /// The working path the commands are executed in. This can be relative to the conductor.yaml pub path: Option, /// If true the component will be relaunched after it exits pub keep_alive: bool, /// The number of seconds to wait before relaunching the component pub retry_delay: u64, /// The tasks that should be ran before the component's commands pub before: Dependencies, /// A map of component level tasks. Tasks can be placed under the component for orgnaizational purposes. /// They can be ran and referenced from anywhere using an absolute namespace: component::task pub tasks: HashMap, /// The color to use for the component name in the terminal output from the command. pub color: (u8, u8, u8), } impl Default for Component { fn default() -> Self { let mut rng = rand::thread_rng(); Self { description: None, env: HashMap::new(), command: Command::default(), path: None, keep_alive: true, retry_delay: 2, before: Dependencies::default(), tasks: HashMap::new(), color: ( rng.gen_range(100..255), rng.gen_range(100..255), rng.gen_range(100..255), ), } } } /// Tasks are short lived jobs that can be used to perform /// utility tasks or to setup a component. #[derive(Serialize, Deserialize, Default, Clone)] #[serde(default)] pub struct TaskDefinition { pub description: String, /// A map of environment variables that are provided before running the task pub env: HashMap, /// The command(s) to execute when the task is launched pub command: Command, /// The path to execute the task commands from. This can be relative from the conductor.yaml pub path: Option, /// Other tasks that should be ran before running the task's command pub before: Dependencies, } impl From<&TaskDefinition> for Job { fn from(source: &TaskDefinition) -> Self { Self { env: source.env.clone(), command: source.command.clone(), retry: false, keep_alive: false, retry_delay: 0, ..Default::default() } } } #[cfg(test)] mod tests { use super::Project; #[test] fn component_by_name() { let project = Project::load_str( r#" components: test-component: {} "#, ) .unwrap(); let jobs = project.get_by_name("test-component").unwrap(); assert_eq!(jobs.len(), 1); } #[test] fn project_task() { let project = Project::load_str( r#" tasks: task1: command: pwd "#, ) .unwrap(); let jobs = project.get_by_name("task1").unwrap(); assert_eq!(jobs.len(), 1); } #[test] fn component_task() { let project = Project::load_str( r#" components: c1: tasks: task1: command: pwd "#, ) .unwrap(); let jobs = project.get_by_name("c1:task1").unwrap(); assert_eq!(jobs.len(), 1); } #[test] fn component_dependent_absolute_component_task() { let project = Project::load_str( r#" components: main-cmp: before: main-cmp:sub-task tasks: sub-task: command: pwd "#, ) .unwrap(); let jobs = project.get_by_name("main-cmp").unwrap(); assert_eq!(jobs.len(), 2); assert_eq!(jobs.first().unwrap().name, "main-cmp:sub-task"); } #[test] fn component_dependent_relative() { let project = Project::load_str( r#" components: main-cmp: before: - sub-task tasks: sub-task: command: pwd "#, ) .unwrap(); let jobs = project.get_by_name("main-cmp").unwrap(); assert_eq!(jobs.len(), 2); assert_eq!(jobs.first().unwrap().name, "main-cmp:sub-task"); } #[test] fn complicated_dependencies() { let project = Project::load_str( r#" components: ui: before: - build-ui tasks: build-ui: command: pwd server: before: - setup tasks: setup: command: pwd build: command: pwd before: - server:setup tasks: build: before: - ui:build-ui - server:build "#, ) .unwrap(); let jobs = project.get_by_name("build").unwrap(); assert_eq!(jobs.get(0).unwrap().name, "ui:build-ui"); assert_eq!(jobs.get(1).unwrap().name, "server:setup"); assert_eq!(jobs.get(2).unwrap().name, "server:build"); assert_eq!(jobs.get(3).unwrap().name, "build"); assert_eq!(jobs.len(), 4); } #[test] fn component_env() { let project = Project::load_str( r#" env: foo: one sub: two components: main-cmp: env: sub: three "#, ) .unwrap(); let jobs = project.get_by_name("main-cmp").unwrap(); let job = jobs.first().unwrap(); assert_eq!(job.env.get("foo"), Some(&String::from("one"))); assert_eq!(job.env.get("sub"), Some(&String::from("three"))); } #[test] fn task_env() { let project = Project::load_str( r#" env: root: ten foo: one sub: two components: main-cmp: env: sub: three tasks: subtask: env: foo: four "#, ) .unwrap(); let jobs = project.get_by_name("main-cmp:subtask").unwrap(); let job = jobs.first().unwrap(); assert_eq!(job.env.get("foo"), Some(&String::from("four"))); assert_eq!(job.env.get("sub"), Some(&String::from("three"))); assert_eq!(job.env.get("root"), Some(&String::from("ten"))); } #[test] fn group() { let project = Project::load_str( r#" groups: all: components: - ui - server components: ui: before: - build-ui tasks: build-ui: command: pwd server: before: - setup tasks: setup: command: pwd build: command: pwd before: - server:setup tasks: build: before: - ui:build-ui - server:build "#, ) .unwrap(); let plan = project.get_runplan("all").unwrap(); assert_eq!(plan[0][0].name, "ui:build-ui"); assert_eq!(plan[0][1].name, "ui"); assert_eq!(plan[1][0].name, "server:setup"); assert_eq!(plan[1][1].name, "server"); assert_eq!(plan.len(), 2); assert_eq!(plan[0].len(), 2); assert_eq!(plan[1].len(), 2); } #[test] pub fn shorthand_single_command() { let project = Project::load_str( r#" components: test: command: pwd "#, ) .unwrap(); let jobs = project.get_by_name("test").unwrap(); assert_eq!(jobs[0].command.to_string(), String::from("pwd")); } #[test] pub fn multiple_commands() { let project = Project::load_str( r#" components: test: command: - sleep 1 - pwd "#, ) .unwrap(); let jobs = project.get_by_name("test").unwrap(); assert_eq!(jobs[0].command.to_string(), String::from("sleep 1 && pwd")); } }