up_rs/
tasks.rs

1//! Logic for dealing with tasks executed by up.
2use self::task::CommandType;
3use self::task::Task;
4use self::TaskError as E;
5use crate::config;
6use crate::env::get_env;
7use crate::tasks::task::TaskStatus;
8use crate::utils::files;
9use crate::utils::user::current_user_is_root;
10use crate::utils::user::get_and_keep_sudo;
11use camino::Utf8Path;
12use camino::Utf8PathBuf;
13use chrono::SecondsFormat;
14use color_eyre::eyre::bail;
15use color_eyre::eyre::eyre;
16use color_eyre::eyre::Result;
17use displaydoc::Display;
18use indicatif::ProgressState;
19use indicatif::ProgressStyle;
20use itertools::Itertools;
21use rayon::prelude::*;
22use std::collections::HashMap;
23use std::collections::HashSet;
24use std::io;
25use std::time::Duration;
26use std::time::Instant;
27use thiserror::Error;
28use tracing::debug;
29use tracing::error;
30use tracing::info;
31use tracing::trace;
32use tracing::warn;
33use tracing_indicatif::span_ext::IndicatifSpanExt;
34
35pub mod completions;
36pub mod defaults;
37pub mod git;
38pub mod link;
39pub(crate) mod schema;
40pub mod task;
41pub mod update_self;
42
43/// Trait that tasks implement to specify how to replace environment variables in their
44/// configuration.
45pub trait ResolveEnv {
46    /// Expand env vars in `self` by running `env_fn()` on its component
47    /// strings.
48    ///
49    /// # Errors
50    /// `resolve_env()` should return any errors returned by the `env_fn()`.
51    fn resolve_env<F>(&mut self, _env_fn: F) -> Result<(), E>
52    where
53        F: Fn(&str) -> Result<String, E>,
54    {
55        Ok(())
56    }
57}
58
59/// What to do with the tasks.
60#[derive(Debug, Clone, Copy)]
61pub enum TasksAction {
62    /// Run tasks.
63    Run,
64    /// Just list the matching tasks.
65    List,
66}
67
68/// Directory in which to find the tasks.
69#[derive(Debug, Clone, Copy)]
70pub enum TasksDir {
71    /// Normal tasks to execute.
72    Tasks,
73    /// Generation tasks (that generate your main tasks).
74    GenerateTasks,
75}
76
77impl TasksDir {
78    /// The default directory names for task types.
79    fn to_dir_name(self) -> String {
80        match self {
81            TasksDir::Tasks => "tasks".to_owned(),
82            TasksDir::GenerateTasks => "generate_tasks".to_owned(),
83        }
84    }
85}
86
87/// Run a set of tasks specified in a subdir of the directory containing the up
88/// config.
89pub fn run(
90    config: &config::UpConfig,
91    tasks_dirname: TasksDir,
92    tasks_action: TasksAction,
93) -> Result<()> {
94    // TODO(gib): Handle missing dir & move into config.
95    let mut tasks_dir = config
96        .up_yaml_path
97        .as_ref()
98        .ok_or(E::UnexpectedNone)?
99        .clone();
100    tasks_dir.pop();
101    tasks_dir.push(tasks_dirname.to_dir_name());
102
103    let env = get_env(
104        config.config_yaml.inherit_env.as_ref(),
105        config.config_yaml.env.as_ref(),
106    )?;
107
108    // If in macOS, don't let the display sleep until the command exits.
109    #[cfg(target_os = "macos")]
110    {
111        use crate::cmd;
112        _ = cmd!("caffeinate", "-ds", "-w", &std::process::id().to_string()).start()?;
113    }
114
115    // TODO(gib): Handle and filter by constraints.
116
117    let bootstrap_tasks = match (config.bootstrap, &config.config_yaml.bootstrap_tasks) {
118        (false, _) => Ok(Vec::new()),
119        (true, None) => Err(eyre!(
120            "Bootstrap flag set but no bootstrap_tasks specified in config."
121        )),
122        (true, Some(b_tasks)) => Ok(b_tasks.clone()),
123    }?;
124
125    let filter_tasks_set: Option<HashSet<String>> =
126        config.tasks.clone().map(|v| v.into_iter().collect());
127    debug!("Filter tasks set: {filter_tasks_set:?}");
128
129    let excluded_tasks: HashSet<String> = config
130        .exclude_tasks
131        .clone()
132        .map_or_else(HashSet::new, |v| v.into_iter().collect());
133    debug!("Excluded tasks set: {excluded_tasks:?}");
134
135    let mut tasks: HashMap<String, task::Task> = HashMap::new();
136    for entry in tasks_dir.read_dir().map_err(|e| E::ReadDir {
137        path: tasks_dir.clone(),
138        source: e,
139    })? {
140        let entry = entry?;
141        if entry.file_type()?.is_dir() {
142            continue;
143        }
144        let path = Utf8PathBuf::try_from(entry.path())?;
145        // If file is a broken symlink.
146        if !path.exists() && path.symlink_metadata().is_ok() {
147            files::remove_broken_symlink(&path)?;
148            continue;
149        }
150        let task = task::Task::from(&path)?;
151        let name = &task.name;
152
153        if excluded_tasks.contains(name) {
154            debug!(
155                "Not running task '{name}' as it is in the excluded tasks set {excluded_tasks:?}"
156            );
157            continue;
158        }
159
160        if let Some(filter) = filter_tasks_set.as_ref() {
161            if !filter.contains(name) {
162                debug!("Not running task '{name}' as not in tasks filter {filter:?}",);
163                continue;
164            }
165        }
166        tasks.insert(name.clone(), task);
167    }
168
169    if matches!(tasks_action, TasksAction::Run)
170        && tasks.values().any(|t| t.config.needs_sudo)
171        && !current_user_is_root()
172    {
173        get_and_keep_sudo(false)?;
174    }
175
176    debug!("Task count: {:?}", tasks.len());
177    trace!("Task list: {tasks:#?}");
178
179    let console = config
180        .console
181        .unwrap_or_else(|| bootstrap_tasks.len() + tasks.len() == 1);
182    trace!("Setting console option to: {console}");
183
184    match tasks_action {
185        TasksAction::List => println!("{}", tasks.keys().join("\n")),
186        TasksAction::Run => {
187            let run_tempdir = config.temp_dir.join(format!(
188                "runs/{start_time}",
189                start_time = config
190                    .start_time
191                    .to_rfc3339_opts(SecondsFormat::AutoSi, true)
192                    // : is not an allowed filename character in Finder.
193                    .replace(':', "_")
194            ));
195
196            run_tasks(
197                bootstrap_tasks,
198                tasks,
199                &env,
200                &run_tempdir,
201                config.keep_going,
202                console,
203            )?;
204        }
205    }
206    Ok(())
207}
208
209/// Runs a set of tasks.
210fn run_tasks(
211    bootstrap_tasks: Vec<String>,
212    mut tasks: HashMap<String, task::Task>,
213    env: &HashMap<String, String>,
214    temp_dir: &Utf8Path,
215    keep_going: bool,
216    console: bool,
217) -> Result<()> {
218    let mut completed_tasks = Vec::new();
219
220    // Has to be top-level so span continues for whole run.
221    let _header_span;
222    if !console {
223        _header_span = set_up_header(tasks.len() + bootstrap_tasks.len())?;
224    }
225
226    if !bootstrap_tasks.is_empty() {
227        for task_name in bootstrap_tasks {
228            let task_tempdir = create_task_tempdir(temp_dir, &task_name)?;
229
230            let task = run_task(
231                tasks
232                    .remove(&task_name)
233                    .ok_or_else(|| eyre!("Task '{task_name}' was missing."))?,
234                env,
235                &task_tempdir,
236                console,
237            );
238            if !keep_going {
239                if let TaskStatus::Failed(e) = task.status {
240                    bail!(e);
241                }
242            }
243            completed_tasks.push(task);
244        }
245    }
246
247    completed_tasks.extend(
248        tasks
249            .into_par_iter()
250            .filter(|(_, task)| task.config.auto_run.unwrap_or(true))
251            .map(|(_, task)| {
252                let task_name = task.name.as_str();
253                let _span = if console {
254                    tracing::info_span!("task", task = task_name, indicatif.pb_hide = true)
255                        .entered()
256                } else {
257                    tracing::info_span!("task", task = task_name).entered()
258                };
259                let task_tempdir = create_task_tempdir(temp_dir, task_name)?;
260                Ok(run_task(task, env, &task_tempdir, console))
261            })
262            .collect::<Result<Vec<Task>>>()?,
263    );
264    let completed_tasks_len = completed_tasks.len();
265
266    let mut tasks_passed = Vec::new();
267    let mut tasks_skipped = Vec::new();
268    let mut tasks_failed = Vec::new();
269    let mut tasks_incomplete = Vec::new();
270
271    for task in completed_tasks {
272        match task.status {
273            TaskStatus::Failed(_) => {
274                tasks_failed.push(task);
275            }
276            TaskStatus::Passed => tasks_passed.push(task),
277            TaskStatus::Skipped => tasks_skipped.push(task),
278            TaskStatus::Incomplete => tasks_incomplete.push(task),
279        }
280    }
281
282    info!(
283        "Ran {completed_tasks_len} tasks, {} passed, {} failed, {} skipped",
284        tasks_passed.len(),
285        tasks_failed.len(),
286        tasks_skipped.len()
287    );
288    if !tasks_passed.is_empty() {
289        info!(
290            "Tasks passed: {:?}",
291            tasks_passed.iter().map(|t| &t.name).collect::<Vec<_>>()
292        );
293    }
294    if !tasks_skipped.is_empty() {
295        info!(
296            "Tasks skipped: {:?}",
297            tasks_skipped.iter().map(|t| &t.name).collect::<Vec<_>>()
298        );
299    }
300
301    if !tasks_failed.is_empty() {
302        error!("One or more tasks failed, exiting.");
303
304        error!(
305            "Tasks failed: {:#?}",
306            tasks_failed.iter().map(|t| &t.name).collect::<Vec<_>>()
307        );
308
309        let mut tasks_failed_iter = tasks_failed.into_iter().filter_map(|t| match t.status {
310            TaskStatus::Failed(e) => Some(e),
311            _ => None,
312        });
313        let err = tasks_failed_iter.next().ok_or(E::UnexpectedNone)?;
314        let err = eyre!(err);
315        tasks_failed_iter.fold(Err(err), color_eyre::Help::error)?;
316    }
317
318    Ok(())
319}
320
321/// Runs a specific task.
322fn run_task(
323    mut task: Task,
324    env: &HashMap<String, String>,
325    task_tempdir: &Utf8Path,
326    console: bool,
327) -> Task {
328    let env_fn = &|s: &str| {
329        let home_dir = files::home_dir().map_err(|e| E::EyreError { source: e })?;
330        let out = shellexpand::full_with_context(
331            s,
332            || Some(home_dir),
333            |k| env.get(k).ok_or_else(|| eyre!("Value not found")).map(Some),
334        )
335        .map(std::borrow::Cow::into_owned)
336        .map_err(|e| E::ResolveEnv {
337            var: e.var_name,
338            source: e.cause,
339        })?;
340
341        Ok(out)
342    };
343
344    let now = Instant::now();
345    task.run(env_fn, env, task_tempdir, console);
346    let elapsed_time = now.elapsed();
347    if elapsed_time > Duration::from_secs(60) {
348        warn!("Task took {elapsed_time:?}");
349    }
350    task
351}
352
353/// Create a subdir of the current temporary directory for the task.
354fn create_task_tempdir(temp_dir: &Utf8Path, task_name: &str) -> Result<Utf8PathBuf> {
355    let task_tempdir = temp_dir.join(task_name);
356    files::create_dir_all(&task_tempdir)?;
357    Ok(task_tempdir)
358}
359
360/**
361Set up a header span to show progress.
362
363If you don't want this to show, filter out Indicatif progress bars by default with
364[`tracing_indicatif::filter::IndicatifFilter::new`] as `IndicatifFilter::new(false)`.
365*/
366fn set_up_header(tasks_count: usize) -> Result<tracing::Span> {
367    let header_span = tracing::info_span!("header");
368    let command = std::env::args().join(" ");
369    header_span.pb_set_style(
370        &ProgressStyle::with_template(&format!(
371            "Running {tasks_count} tasks for command: `{command}`. {{wide_msg}} \
372             {{elapsed_sec}}\n{{wide_bar}}"
373        ))?
374        .with_key(
375            "elapsed_sec",
376            |state: &ProgressState, writer: &mut dyn std::fmt::Write| {
377                let seconds = state.elapsed().as_secs();
378                let _ = writer.write_str(&format!("{seconds}s"));
379            },
380        )
381        .progress_chars("---"),
382    );
383    header_span.pb_start();
384    Ok(header_span)
385}
386
387#[allow(clippy::doc_markdown)]
388#[derive(Error, Debug, Display)]
389/// Errors thrown by this file.
390pub enum TaskError {
391    /// Task `{name}` {lib} failed.
392    TaskError {
393        /// Source error.
394        source: color_eyre::eyre::Error,
395        /// The task library we were running.
396        lib: String,
397        /// The task name.
398        name: String,
399    },
400    /// Error walking directory `{path}`:
401    ReadDir {
402        /// The path we failed to walk.
403        path: Utf8PathBuf,
404        /// Source error.
405        source: io::Error,
406    },
407    /// Error reading file `{path}`:
408    ReadFile {
409        /// The path we failed to read.
410        path: Utf8PathBuf,
411        /// Source error.
412        source: io::Error,
413    },
414    /// Env lookup error, please define `{var}` in your up.yaml:"
415    EnvLookup {
416        /// The env var we couldn't find.
417        var: String,
418        /// Source error.
419        source: color_eyre::eyre::Error,
420    },
421    /// Command was empty.
422    EmptyCmd,
423    /// Task `{name}` had no run command.
424    MissingCmd {
425        /// The task name.
426        name: String,
427    },
428    /**
429    Task `{name}` {command_type} failed.Command: {cmd:?}.{suggestion}
430    */
431    CmdFailed {
432        /// The type of command that failed (check or run).
433        command_type: CommandType,
434        /// Task name.
435        name: String,
436        /// Source error.
437        source: io::Error,
438        /// The command itself.
439        cmd: Vec<String>,
440        /// Suggestion for how to fix it.
441        suggestion: String,
442    },
443    /**
444    Task `{name}` {command_type} failed with exit code {code}. Command: {cmd:?}.
445      Output: {output_file}
446    */
447    CmdNonZero {
448        /// The type of command that failed (check or run).
449        command_type: CommandType,
450        /// Task name.
451        name: String,
452        /// The command itself.
453        cmd: Vec<String>,
454        /// Error code.
455        code: i32,
456        /// File containing stdout and stderr of the file.
457        output_file: Utf8PathBuf,
458    },
459    /**
460    Task `{name}` {command_type} was terminated. Command: {cmd:?}, output: {output_file}.
461      Output: {output_file}
462    */
463    CmdTerminated {
464        /// The type of command that failed (check or run).
465        command_type: CommandType,
466        /// Task name.
467        name: String,
468        /// The command itself.
469        cmd: Vec<String>,
470        /// File containing stdout and stderr of the file.
471        output_file: Utf8PathBuf,
472    },
473    /// Unexpectedly empty option found.
474    UnexpectedNone,
475    /// Invalid yaml at `{path}`:
476    InvalidYaml {
477        /// Path that contained invalid yaml.
478        path: Utf8PathBuf,
479        /// Source error.
480        source: serde_yaml::Error,
481    },
482    /// Unable to calculate the current user's home directory.
483    MissingHomeDir,
484    /// Env lookup error, please define `{var}` in your up.yaml
485    ResolveEnv {
486        /// Env var we couldn't find.
487        var: String,
488        /// Source error.
489        source: color_eyre::eyre::Error,
490    },
491    /// Task {task} must have data.
492    TaskDataRequired {
493        /// Task name.
494        task: String,
495    },
496    /// Failed to parse the config.
497    DeserializeError {
498        /// Source error.
499        source: serde_yaml::Error,
500    },
501    /// Task error.
502    EyreError {
503        /// Source error.
504        source: color_eyre::Report,
505    },
506}