up_rs/tasks/
task.rs

1#![allow(clippy::str_to_string)] // schemars conflicts with this lint.
2
3//! Up task execution.
4use crate::exec::cmd_log;
5use crate::exec::UpDuct;
6use crate::generate;
7use crate::log;
8use crate::opts::GenerateGitConfig;
9use crate::opts::LinkOptions;
10use crate::opts::UpdateSelfOptions;
11use crate::tasks;
12use crate::tasks::defaults::DefaultsConfig;
13use crate::tasks::git::GitConfig;
14use crate::tasks::ResolveEnv;
15use crate::tasks::TaskError as E;
16use camino::Utf8Path;
17use camino::Utf8PathBuf;
18use color_eyre::eyre::eyre;
19use color_eyre::eyre::Result;
20use schemars::JsonSchema;
21use serde_derive::Deserialize;
22use serde_derive::Serialize;
23use std::collections::HashMap;
24use std::fmt;
25use std::fmt::Display;
26use std::fs;
27use std::process::Output;
28use std::string::String;
29use std::time::Duration;
30use std::time::Instant;
31use tracing::debug;
32use tracing::info;
33use tracing::trace;
34use tracing::Level;
35
36/// Possible statuses an asynchronously running task can have.
37#[derive(Debug)]
38pub enum TaskStatus {
39    /// Skipped.
40    Incomplete,
41    /// Skipped.
42    Skipped,
43    /// Completed successfully.
44    Passed,
45    /// Completed unsuccessfully.
46    Failed(E),
47}
48
49/// A task's state.
50#[derive(Debug)]
51pub struct Task {
52    /// Task name.
53    pub name: String,
54    /// Path to the task config on disk.
55    pub path: Utf8PathBuf,
56    /// The parsed task config file contents.
57    pub config: TaskConfig,
58    /// When the task was started.
59    pub start_time: Instant,
60    /// Current task status.
61    pub status: TaskStatus,
62}
63
64/// Configuration a task can have, a `~/.config/up/tasks/<name>.yaml` will deserialize to this
65/// struct.
66#[derive(Debug, Serialize, Deserialize, JsonSchema)]
67#[serde(deny_unknown_fields)]
68pub struct TaskConfig {
69    /// Task name, defaults to file name (minus extension) if unset.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub name: Option<String>,
72    /// Set of Constraints that will cause the task to be run.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub constraints: Option<HashMap<String, String>>,
75    /// Tasks that must have been executed beforehand.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub requires: Option<Vec<String>>,
78    /// Whether to run this by default, or only if required.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub auto_run: Option<bool>,
81    /// Run library: up library to use for this task. Either use this or
82    /// `run_cmd` + `run_if_cmd`.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub run_lib: Option<String>,
85    /**
86    Run if command: only run the `run_cmd` if this command passes (returns exit code 0).
87
88    The task will be skipped if exit code 204 is returned (HTTP 204 means "No Content").
89    Any other exit code means the command failed to run.
90    */
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub run_if_cmd: Option<Vec<String>>,
93    /**
94    Run command: command to run to perform the update.
95
96    The task will be marked as skipped if exit code 204 is returned (HTTP 204 means "No Content").
97    Any other exit code means the command failed to run.
98    */
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub run_cmd: Option<Vec<String>>,
101    /// Description of the task.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub description: Option<String>,
104    /// Set to true to prompt for superuser privileges before running.
105    /// This will allow all subtasks that up executes in this iteration.
106    #[serde(default = "default_false")]
107    pub needs_sudo: bool,
108    // This field must be the last one in this struct in order for the yaml serializer in the
109    // generate functions to be able to serialise it properly.
110    /// Set of data provided to the Run library.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    // schemars doesn't have built-in support for YAML values, but it does have support for
113    // JSON values (https://github.com/GREsau/schemars/pull/153).
114    #[schemars(with = "Option<serde_json::Value>")]
115    pub data: Option<serde_yaml::Value>,
116}
117
118/// Used for serde defaults above.
119const fn default_false() -> bool {
120    false
121}
122
123/// Shell commands we run.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum CommandType {
126    /// `run_if_cmd` field in the yaml.
127    RunIf,
128    /// `run_cmd` field in the yaml.
129    Run,
130}
131
132impl Display for CommandType {
133    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
134        match self {
135            Self::Run => write!(f, "run command"),
136            Self::RunIf => write!(f, "run_if command"),
137        }
138    }
139}
140
141impl Task {
142    /// Parse a Task from a path to a task config file.
143    pub fn from(path: &Utf8Path) -> Result<Self> {
144        let start_time = Instant::now();
145        let s = fs::read_to_string(path).map_err(|e| E::ReadFile {
146            path: path.to_owned(),
147            source: e,
148        })?;
149        trace!("Task '{path}' contents: <<<{s}>>>");
150        let config = serde_yaml::from_str::<TaskConfig>(&s).map_err(|e| E::InvalidYaml {
151            path: path.to_owned(),
152            source: e,
153        })?;
154        let name = match &config.name {
155            Some(n) => n.clone(),
156            None => path
157                .file_stem()
158                .ok_or_else(|| eyre!("Task had no path."))?
159                .to_owned(),
160        };
161        let task = Self {
162            name,
163            path: path.to_owned(),
164            config,
165            start_time,
166            status: TaskStatus::Incomplete,
167        };
168        debug!("Task '{name}': {task:?}", name = &task.name);
169        Ok(task)
170    }
171
172    /// Run a task.
173    pub fn run<F>(
174        &mut self,
175        env_fn: F,
176        env: &HashMap<String, String>,
177        task_tempdir: &Utf8Path,
178        console: bool,
179    ) where
180        F: Fn(&str) -> Result<String, E>,
181    {
182        match self.try_run(env_fn, env, task_tempdir, console) {
183            Ok(status) => self.status = status,
184            Err(e) => self.status = TaskStatus::Failed(e),
185        }
186    }
187
188    /// Try to run the task.
189    pub fn try_run<F>(
190        &mut self,
191        env_fn: F,
192        env: &HashMap<String, String>,
193        task_tempdir: &Utf8Path,
194        console: bool,
195    ) -> Result<TaskStatus, E>
196    where
197        F: Fn(&str) -> Result<String, E>,
198    {
199        let name = &self.name;
200        info!("Running");
201
202        if let Some(mut cmd) = self.config.run_if_cmd.clone() {
203            debug!("Running run_if command.");
204            for s in &mut cmd {
205                *s = env_fn(s)?;
206            }
207            // TODO(gib): Allow choosing how to validate run_if_cmd output (stdout, zero exit
208            // code, non-zero exit code).
209            if !self.run_command(CommandType::RunIf, &cmd, env, task_tempdir, console)? {
210                debug!("Skipping task as run_if command failed.");
211                return Ok(TaskStatus::Skipped);
212            }
213        } else {
214            debug!("You haven't specified a run_if command, so it will always be run",);
215        }
216
217        if let Some(lib) = &self.config.run_lib {
218            let maybe_data = self.config.data.clone();
219
220            let status = match lib.as_str() {
221                "defaults" => {
222                    let data: DefaultsConfig =
223                        parse_task_config(maybe_data, &self.name, false, env_fn)?;
224                    tasks::defaults::run(data, task_tempdir)
225                }
226
227                "generate_git" => {
228                    let data: Vec<GenerateGitConfig> =
229                        parse_task_config(maybe_data, &self.name, false, env_fn)?;
230                    generate::git::run(&data)
231                }
232
233                "git" => {
234                    let data: Vec<GitConfig> =
235                        parse_task_config(maybe_data, &self.name, false, env_fn)?;
236                    tasks::git::run(&data)
237                }
238
239                "link" => {
240                    let data: LinkOptions =
241                        parse_task_config(maybe_data, &self.name, false, env_fn)?;
242                    tasks::link::run(data, task_tempdir)
243                }
244
245                "self" => {
246                    let data: UpdateSelfOptions =
247                        parse_task_config(maybe_data, &self.name, true, env_fn)?;
248                    tasks::update_self::run(&data)
249                }
250
251                _ => Err(eyre!("This run_lib is invalid or not yet implemented.")),
252            }
253            .map_err(|e| E::TaskError {
254                name: self.name.clone(),
255                lib: lib.to_string(),
256                source: e,
257            })?;
258            return Ok(status);
259        }
260
261        if let Some(mut cmd) = self.config.run_cmd.clone() {
262            debug!("Running '{name}' run command.");
263            for s in &mut cmd {
264                *s = env_fn(s)?;
265            }
266            if self.run_command(CommandType::Run, &cmd, env, task_tempdir, console)? {
267                return Ok(TaskStatus::Passed);
268            }
269            return Ok(TaskStatus::Skipped);
270        }
271
272        Err(E::MissingCmd {
273            name: self.name.clone(),
274        })
275    }
276
277    /**
278    Run a command.
279    If the `command_type` is `RunIf`, then `Ok(false)` may be returned if the command was skipped.
280    */
281    pub fn run_command(
282        &self,
283        command_type: CommandType,
284        cmd: &[String],
285        env: &HashMap<String, String>,
286        task_tempdir: &Utf8Path,
287        console: bool,
288    ) -> Result<bool, E> {
289        let now = Instant::now();
290        let task_output_file = task_tempdir.join("task_stdout_stderr.txt");
291
292        let command = cmd_log(
293            Level::DEBUG,
294            cmd.first().ok_or(E::EmptyCmd)?,
295            cmd.get(1..).unwrap_or(&[]),
296        )
297        .dir(task_tempdir)
298        .full_env(env)
299        .unchecked();
300
301        let output = if console {
302            command.run_with_inherit()
303        } else {
304            command
305                .stderr_path(&task_output_file)
306                .run_with_path(&task_output_file)
307        };
308
309        let output = output.map_err(|e| {
310            let suggestion = match e.kind() {
311                std::io::ErrorKind::PermissionDenied => format!(
312                    "\n Suggestion: Try making the file executable with `chmod +x {path}`",
313                    path = cmd.first().map_or("", String::as_str)
314                ),
315                _ => String::new(),
316            };
317            E::CmdFailed {
318                command_type,
319                name: self.name.clone(),
320                cmd: cmd.into(),
321                source: e,
322                suggestion,
323            }
324        })?;
325
326        let elapsed_time = now.elapsed();
327        let command_result = match output.status.code() {
328            Some(0) => Ok(true),
329            Some(204) => Ok(false),
330            Some(code) => Err(E::CmdNonZero {
331                name: self.name.clone(),
332                command_type,
333                cmd: cmd.to_owned(),
334                output_file: task_output_file,
335                code,
336            }),
337            None => Err(E::CmdTerminated {
338                command_type,
339                name: self.name.clone(),
340                cmd: cmd.to_owned(),
341                output_file: task_output_file,
342            }),
343        };
344        self.log_command_output(command_type, command_result.is_ok(), &output, elapsed_time);
345        command_result
346    }
347
348    /// Logs command output (as `debug` if it passed, or as `error` otherwise).
349    pub fn log_command_output(
350        &self,
351        command_type: CommandType,
352        command_success: bool,
353        output: &Output,
354        elapsed_time: Duration,
355    ) {
356        let name = &self.name;
357        let level = if command_success {
358            Level::DEBUG
359        } else {
360            Level::ERROR
361        };
362
363        // TODO(gib): Document error codes.
364        log!(
365            level,
366            "Task '{name}' {command_type} ran in {elapsed_time:?} with {}",
367            output.status
368        );
369        if !output.stdout.is_empty() {
370            log!(
371                level,
372                "Task '{name}' {command_type} stdout:\n<<<\n{}>>>\n",
373                String::from_utf8_lossy(&output.stdout),
374            );
375        }
376        if !output.stderr.is_empty() {
377            log!(
378                level,
379                "Task '{name}' {command_type} command stderr:\n<<<\n{}>>>\n",
380                String::from_utf8_lossy(&output.stderr),
381            );
382        }
383    }
384}
385
386/// Convert a task's `data:` block into a task config.
387/// Set `has_default` to `true` if the task should fall back to `Default::default()`, or `false` if
388/// it should error when no value was passed.
389fn parse_task_config<F, T: ResolveEnv + Default + for<'de> serde::Deserialize<'de>>(
390    maybe_data: Option<serde_yaml::Value>,
391    task_name: &str,
392    has_default: bool,
393    env_fn: F,
394) -> Result<T, E>
395where
396    F: Fn(&str) -> Result<String, E>,
397{
398    let data = match maybe_data {
399        Some(data) => data,
400        None if has_default => return Ok(T::default()),
401        None => {
402            return Err(E::TaskDataRequired {
403                task: task_name.to_owned(),
404            });
405        }
406    };
407
408    let mut raw_opts: T =
409        serde_yaml::from_value(data).map_err(|e| E::DeserializeError { source: e })?;
410    raw_opts.resolve_env(env_fn)?;
411    Ok(raw_opts)
412}