gw_bin/actions/
script.rs

1use super::{utils::command::create_command, Action, ActionError};
2use crate::context::Context;
3use duct::Expression;
4use log::{debug, error, info};
5use std::io::{BufRead, BufReader};
6use thiserror::Error;
7
8const ACTION_NAME: &str = "SCRIPT";
9
10/// An action to run a custom shell script.
11///
12/// The passed script is running in a subshell (`/bin/sh` on *nix, `cmd.exe` on Windows).
13/// so it can use any feature in these shells: variable expansion, pipes, redirection.
14/// Both the stdout and stderr will be captured and logged. If the script fails,
15/// the failure will also be logged.
16#[derive(Debug)]
17pub struct ScriptAction {
18    directory: String,
19    command: String,
20    script: Expression,
21    runs_in_shell: bool,
22}
23
24/// Custom error describing the error cases for the ScriptAction.
25#[derive(Debug, Error)]
26pub enum ScriptError {
27    /// The command is invalid (usually mismatched quotations etc.).
28    #[error("the command {0:?} cannot be parsed")]
29    CommandParseFailure(String),
30    /// The underlying Rust command creation failed. The parameter contains the error.
31    #[error("the script cannot run: {0}")]
32    ScriptFailure(#[from] std::io::Error),
33    /// The script returned a non-zero exit code, usually meaning it failed to start
34    /// or encountered an error. The parameters are the exit code and the failed output.
35    #[error("the script returned non-zero exit code {0}")]
36    NonZeroExitcode(i32),
37    /// This means that an error occured when trying to read from the output of the script.
38    #[error("the script returned invalid output")]
39    OutputFailure,
40}
41
42impl From<ScriptError> for ActionError {
43    fn from(value: ScriptError) -> Self {
44        match value {
45            ScriptError::CommandParseFailure(_)
46            | ScriptError::ScriptFailure(_)
47            | ScriptError::NonZeroExitcode(_)
48            | ScriptError::OutputFailure => ActionError::FailedAction(value.to_string()),
49        }
50    }
51}
52
53impl ScriptAction {
54    /// Creates a new script to be started in the given directory.
55    pub fn new(
56        directory: String,
57        original_command: String,
58        runs_in_shell: bool,
59    ) -> Result<Self, ScriptError> {
60        let (command, script) = create_command(&original_command, runs_in_shell)
61            .ok_or(ScriptError::CommandParseFailure(original_command))?;
62
63        let script = script
64            .env("CI", "true")
65            .env("GW_ACTION_NAME", ACTION_NAME)
66            .env("GW_DIRECTORY", &directory)
67            .stderr_to_stdout()
68            .stdout_capture()
69            .dir(&directory)
70            .unchecked();
71
72        Ok(ScriptAction {
73            directory,
74            command,
75            script,
76            runs_in_shell,
77        })
78    }
79
80    fn run_inner(&self, context: &Context) -> Result<(), ScriptError> {
81        // We can run `sh_dangerous`, because it is on the user's computer.
82        let mut script = self.script.clone();
83
84        // Set the environment variables
85        for (key, value) in context {
86            script = script.env(format!("GW_{key}"), value);
87        }
88
89        // Start the shell script
90        info!(
91            "Running script {:?} {}in {}.",
92            self.command,
93            if self.runs_in_shell {
94                "in a shell "
95            } else {
96                ""
97            },
98            self.directory,
99        );
100        let child = script.reader()?;
101
102        let reader = BufReader::new(&child).lines();
103        let command_id = self.command.as_str();
104        for line in reader {
105            match line {
106                Ok(line) => debug!("[{command_id}] {line}"),
107                Err(_) => debug!("[{command_id}] <output cannot be parsed>"),
108            }
109        }
110
111        if let Ok(Some(output)) = child.try_wait() {
112            if output.status.success() {
113                info!("Script {:?} finished successfully.", self.command);
114                Ok(())
115            } else {
116                Err(ScriptError::NonZeroExitcode(
117                    output.status.code().unwrap_or(-1),
118                ))
119            }
120        } else {
121            Err(ScriptError::OutputFailure)
122        }
123    }
124}
125
126impl Action for ScriptAction {
127    /// Run the script in a subshell (`/bin/sh` on *nix, `cmd.exe` on Windows).
128    /// If the script fails to start, return a non-zero error code or prints non-utf8
129    /// characters, this function will result in an error.
130    fn run(&mut self, context: &Context) -> Result<(), ActionError> {
131        Ok(self.run_inner(context)?)
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use std::collections::HashMap;
139
140    fn validate_output<F>(command: &str, asserter: F)
141    where
142        F: Fn(Vec<&str>),
143    {
144        let command = format!("[{command}] ");
145        testing_logger::validate(|captured_logs| {
146            let output: Vec<&str> = captured_logs
147                .iter()
148                .filter_map(|line| {
149                    if line.body.starts_with(&command) {
150                        Some(line.body.as_str().trim_start_matches(&command))
151                    } else {
152                        None
153                    }
154                })
155                .collect();
156
157            asserter(output);
158        });
159    }
160
161    const ECHO_TEST: &str = "echo test";
162    const EXIT_NONZERO: &str = "exit 1";
163
164    #[cfg(unix)]
165    const ECHO_INVALID_UNICODE: &str =
166        "python -c \"import sys; sys.stdout.buffer.write(b'\\xc3\\x28')\"; sys.stdout.flush()";
167    #[cfg(unix)]
168    const ECHO_STDERR: &str = "echo err >&2";
169    #[cfg(unix)]
170    const PRINTENV: &str = "printenv";
171
172    #[cfg(not(unix))]
173    const PRINTENV: &str = "set";
174
175    #[test]
176    fn it_should_create_new_script() {
177        let command = String::from(ECHO_TEST);
178        let action = ScriptAction::new(String::from("."), command, true).unwrap();
179
180        assert_eq!("echo", action.command);
181        assert_eq!(".", action.directory);
182    }
183
184    #[test]
185    fn it_should_fail_if_command_is_invalid() {
186        let result = ScriptAction::new(String::from("."), String::from("echo 'test"), false);
187
188        assert!(
189            matches!(result, Err(ScriptError::CommandParseFailure(_))),
190            "{result:?} should match CommandParseFailure"
191        );
192    }
193
194    #[test]
195    fn it_should_run_the_script() -> Result<(), ScriptError> {
196        testing_logger::setup();
197
198        let command = String::from(ECHO_TEST);
199        let action = ScriptAction::new(String::from("."), command, true)?;
200
201        let context: Context = HashMap::new();
202        action.run_inner(&context)?;
203
204        validate_output("echo", |lines| {
205            assert_eq!(vec!["test"], lines);
206        });
207
208        Ok(())
209    }
210
211    #[test]
212    fn it_should_set_the_env_vars() -> Result<(), ScriptError> {
213        testing_logger::setup();
214
215        let command = String::from(PRINTENV);
216        let action = ScriptAction::new(String::from("."), command, true)?;
217
218        let context: Context = HashMap::from([
219            ("TRIGGER_NAME", "TEST-TRIGGER".to_string()),
220            ("CHECK_NAME", "TEST-CHECK".to_string()),
221        ]);
222        action.run_inner(&context)?;
223
224        validate_output(PRINTENV, |lines| {
225            assert!(lines.contains(&"CI=true"));
226            assert!(lines.contains(&"GW_TRIGGER_NAME=TEST-TRIGGER"));
227            assert!(lines.contains(&"GW_CHECK_NAME=TEST-CHECK"));
228            assert!(lines.contains(&"GW_ACTION_NAME=SCRIPT"));
229            assert!(lines.contains(&"GW_DIRECTORY=."));
230        });
231
232        Ok(())
233    }
234
235    #[test]
236    fn it_should_keep_the_already_set_env_vars() -> Result<(), ScriptError> {
237        testing_logger::setup();
238
239        std::env::set_var("GW_TEST", "GW_TEST");
240
241        let command = String::from(PRINTENV);
242        let action = ScriptAction::new(String::from("."), command, true)?;
243
244        let context: Context = HashMap::new();
245        action.run_inner(&context)?;
246
247        validate_output(PRINTENV, |lines| {
248            assert!(lines.contains(&"GW_TEST=GW_TEST"));
249        });
250
251        Ok(())
252    }
253
254    #[test]
255    #[cfg(unix)]
256    fn it_should_catch_error_output() -> Result<(), ScriptError> {
257        testing_logger::setup();
258
259        let command = String::from(ECHO_STDERR);
260        let action = ScriptAction::new(String::from("."), command, true)?;
261
262        let context: Context = HashMap::new();
263        action.run_inner(&context)?;
264
265        validate_output("echo", |lines| {
266            assert_eq!(vec!["err"], lines);
267        });
268
269        Ok(())
270    }
271
272    #[test]
273    #[cfg(unix)]
274    fn it_should_record_if_the_script_returns_non_utf8() -> Result<(), ScriptError> {
275        testing_logger::setup();
276
277        let command = String::from(ECHO_INVALID_UNICODE);
278        let action = ScriptAction::new(String::from("."), command, false)?;
279
280        let context: Context = HashMap::new();
281        action.run_inner(&context)?;
282
283        validate_output("python", |lines| {
284            assert_eq!(vec!["<output cannot be parsed>"], lines);
285        });
286
287        Ok(())
288    }
289
290    #[test]
291    fn it_should_fail_if_the_script_fails() -> Result<(), ScriptError> {
292        let command = String::from(EXIT_NONZERO);
293        let action = ScriptAction::new(String::from("."), command, true)?;
294
295        let context: Context = HashMap::new();
296        let result = action.run_inner(&context);
297        assert!(
298            matches!(result, Err(ScriptError::NonZeroExitcode(1))),
299            "{result:?} should match non zero exit code"
300        );
301
302        Ok(())
303    }
304}