Major refactor to cleanly run tasks from files or inline

ExecutableTask needs to find a new home, and some other things still need to get
shuffled around. This commit does however copy a temp file over for execution
which seems to be the best possible scenario for safe execution

An example run:

❯ RUST_LOG=debug ./target/debug/zap task tasks/shell/bash.ztask -p "script=set -xe; pwd" -t zap-freebsd
Running task with: TaskOpts { task: "tasks/shell/bash.ztask", parameter: ["script=set -xe; pwd"], targets: "zap-freebsd" }
 INFO  zap > Task located, preparing to execute
 DEBUG handlebars::render > Rendering value: Path(Relative(([Named("script")], "script")))
 DEBUG handlebars::context > Accessing context value: AbsolutePath(["script"])
err: + pwd
/root
This commit is contained in:
R Tyler Croy 2020-12-31 13:46:34 -08:00
parent 865850bd64
commit eda6f523a3
12 changed files with 268 additions and 137 deletions

1
Cargo.lock generated
View File

@ -634,6 +634,7 @@ dependencies = [
name = "zap-parser" name = "zap-parser"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"handlebars",
"log", "log",
"pest", "pest",
"pest_derive", "pest_derive",

View File

@ -38,7 +38,8 @@ a wrapped shell/ruby/python script which does some specific piece of
functionality. Tasks may also take parameters, which allow for some functionality. Tasks may also take parameters, which allow for some
pluggability of new values. pluggability of new values.
Tasks have some built-in parameters that should not be overridden. Tasks have some default parameters that should not be overridden in new task
definitions.
.Built-in Parameters .Built-in Parameters
|=== |===

View File

@ -10,7 +10,7 @@ mod transport;
use crate::inventory::*; use crate::inventory::*;
use crate::transport::ssh::Ssh; use crate::transport::ssh::Ssh;
use zap_parser::plan::Plan; use zap_parser::plan::{ExecutableTask, Plan};
use zap_parser::task::Task; use zap_parser::task::Task;
fn main() { fn main() {
@ -51,13 +51,7 @@ fn handle_plan(opts: PlanOpts, runner: &dyn crate::transport::Transport, invento
info!("Plan located, preparing to execute"); info!("Plan located, preparing to execute");
for task in plan.tasks { for task in plan.tasks {
info!("Running executable task: {:?}", task); info!("Running executable task: {:?}", task);
exit = execute_task_on( exit = execute_task_on(opts.targets.clone(), &task, runner, &inventory);
opts.targets.clone(),
&task.task,
&task.parameters,
runner,
&inventory,
);
} }
} }
Err(err) => { Err(err) => {
@ -69,21 +63,16 @@ fn handle_plan(opts: PlanOpts, runner: &dyn crate::transport::Transport, invento
fn execute_task_on( fn execute_task_on(
targets: String, targets: String,
task: &Task, task: &ExecutableTask,
parameters: &HashMap<String, String>,
runner: &dyn crate::transport::Transport, runner: &dyn crate::transport::Transport,
inventory: &Inventory, inventory: &Inventory,
) -> i32 { ) -> i32 {
if let Some(script) = task.get_script() { if let Some(group) = inventory.groups.iter().find(|g| g.name == targets) {
let command = render_command(&script, &parameters); return runner.run_group(task, &group, &inventory);
}
if let Some(group) = inventory.groups.iter().find(|g| g.name == targets) { if let Some(target) = inventory.targets.iter().find(|t| t.name == targets) {
return runner.run_group(&command, &group, &inventory); return runner.run(task, &target);
}
if let Some(target) = inventory.targets.iter().find(|t| t.name == targets) {
return runner.run(&command, &target);
}
} }
error!("Failed to locate a script to execute for the task!"); error!("Failed to locate a script to execute for the task!");
return -1; return -1;
@ -110,13 +99,10 @@ fn handle_task(opts: TaskOpts, runner: &dyn crate::transport::Transport, invento
parameters.insert(parts[0].to_string(), parts[1].to_string()); parameters.insert(parts[0].to_string(), parts[1].to_string());
} }
} }
std::process::exit(execute_task_on(
opts.targets, let task = ExecutableTask::new(task, parameters);
&task,
&parameters, std::process::exit(execute_task_on(opts.targets, &task, runner, &inventory));
runner,
&inventory,
));
} }
Err(err) => { Err(err) => {
println!("Failed to load task: {:?}", err); println!("Failed to load task: {:?}", err);
@ -135,38 +121,9 @@ fn handle_task(opts: TaskOpts, runner: &dyn crate::transport::Transport, invento
* non-zero. * non-zero.
*/ */
fn handle_cmd(opts: CmdOpts, runner: &dyn crate::transport::Transport, inventory: Inventory) { fn handle_cmd(opts: CmdOpts, runner: &dyn crate::transport::Transport, inventory: Inventory) {
let mut task = Task::new("Dynamic"); let mut task = ExecutableTask::new(Task::new("Dynamic"), HashMap::new());
task.inline = Some(opts.command); task.task.script.inline = Some(opts.command);
let parameters = HashMap::new(); std::process::exit(execute_task_on(opts.targets, &task, runner, &inventory));
std::process::exit(execute_task_on(
opts.targets,
&task,
&parameters,
runner,
&inventory,
));
}
/**
* render_command will handle injecting the parameters for a given command
* into the string where appropriate, using the Handlebars syntax.
*
* If the template fails to render, then this will just return the command it
* was given
*/
fn render_command(cmd: &str, parameters: &HashMap<String, String>) -> String {
use handlebars::Handlebars;
let handlebars = Handlebars::new();
match handlebars.render_template(cmd, parameters) {
Ok(rendered) => {
return rendered;
}
Err(err) => {
error!("Failed to render command ({:?}): {}", err, cmd);
return cmd.to_string();
}
}
} }
#[derive(Debug, Options)] #[derive(Debug, Options)]
@ -238,26 +195,4 @@ struct PlanOpts {
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {}
use super::*;
#[test]
fn test_render_command() {
let cmd = "echo \"{{msg}}\"";
let mut params = HashMap::new();
params.insert("msg".to_string(), "hello".to_string());
let output = render_command(&cmd, &params);
assert_eq!(output, "echo \"hello\"");
}
#[test]
fn test_render_command_bad_template() {
let cmd = "echo \"{{msg\"";
let mut params = HashMap::new();
params.insert("msg".to_string(), "hello".to_string());
let output = render_command(&cmd, &params);
assert_eq!(output, "echo \"{{msg\"");
}
}

View File

@ -1,4 +1,5 @@
use crate::inventory::{Group, Inventory, Target}; use crate::inventory::{Group, Inventory, Target};
use zap_parser::plan::ExecutableTask;
pub mod ssh; pub mod ssh;
@ -7,6 +8,6 @@ pub mod ssh;
* connecting to targets * connecting to targets
*/ */
pub trait Transport { pub trait Transport {
fn run_group(&self, cmd: &str, group: &Group, inv: &Inventory) -> i32; fn run_group(&self, cmd: &ExecutableTask, group: &Group, inv: &Inventory) -> i32;
fn run(&self, command: &str, target: &Target) -> i32; fn run(&self, command: &ExecutableTask, target: &Target) -> i32;
} }

View File

@ -1,10 +1,16 @@
use crate::inventory::{Group, Inventory, Target}; use crate::inventory::{Group, Inventory, Target};
use crate::transport::Transport; use crate::transport::Transport;
use log::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ssh2::Session; use ssh2::Session;
use std::convert::TryInto;
use std::io::prelude::*; use std::io::prelude::*;
use std::io::BufReader;
use std::net::TcpStream; use std::net::TcpStream;
use std::path::Path;
use zap_parser::plan::ExecutableTask;
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Ssh {} pub struct Ssh {}
@ -16,7 +22,7 @@ impl Default for Ssh {
} }
impl Transport for Ssh { impl Transport for Ssh {
fn run_group(&self, command: &str, group: &Group, inventory: &Inventory) -> i32 { fn run_group(&self, command: &ExecutableTask, group: &Group, inventory: &Inventory) -> i32 {
let mut status = 1; let mut status = 1;
for target_name in group.targets.iter() { for target_name in group.targets.iter() {
// XXX: This is inefficient // XXX: This is inefficient
@ -29,7 +35,8 @@ impl Transport for Ssh {
} }
status status
} }
fn run(&self, command: &str, target: &Target) -> i32 {
fn run(&self, command: &ExecutableTask, target: &Target) -> i32 {
// Connect to the local SSH server // Connect to the local SSH server
let tcp = TcpStream::connect(format!("{}:22", target.uri)).unwrap(); let tcp = TcpStream::connect(format!("{}:22", target.uri)).unwrap();
let mut sess = Session::new().unwrap(); let mut sess = Session::new().unwrap();
@ -51,13 +58,54 @@ impl Transport for Ssh {
.unwrap(); .unwrap();
} }
let mut channel = sess.channel_session().unwrap(); let remote_script = "._zap_command";
channel.exec(command).unwrap();
let mut s = String::new(); if let Some(script) = command.task.script.as_bytes(Some(&command.parameters)) {
channel.read_to_string(&mut s).unwrap(); let mut remote_file = sess
print!("{}", s); .scp_send(
channel.wait_close().expect("Failed to close the channel"); Path::new(remote_script),
return channel.exit_status().unwrap(); 0o700,
script
.len()
.try_into()
.expect("Overflow converting the size of the generated file, yikes!"),
None,
)
.unwrap();
remote_file.write(&script).unwrap();
// Close the channel and wait for the whole content to be tranferred
remote_file.send_eof().unwrap();
remote_file.wait_eof().unwrap();
remote_file.close().unwrap();
remote_file.wait_close().unwrap();
let mut channel = sess.channel_session().unwrap();
let stderr = channel.stderr();
channel.exec(&format!("./{}", remote_script)).unwrap();
let reader = BufReader::new(stderr);
for line in reader.lines() {
println!("err: {}", line.unwrap());
}
let mut s = String::new();
channel.read_to_string(&mut s).unwrap();
print!("{}", s);
channel.wait_close().expect("Failed to close the channel");
let exit = channel.exit_status().unwrap();
/*
* This seems a little dumb and hacky, but we need to clean up the file
* somehow and I'm not seeing anything that would allow me to just reach
* out and remove a file
*/
let mut channel = sess.channel_session().unwrap();
channel.exec(&format!("rm -f {}", remote_script)).unwrap();
return exit;
} else {
error!("No script available to run for task!");
return -1;
}
} }
} }

View File

@ -5,6 +5,7 @@ authors = ["R. Tyler Croy <rtyler@brokenco.de>"]
edition = "2018" edition = "2018"
[dependencies] [dependencies]
handlebars = "~3.5"
log = "0.4" log = "0.4"
pest = "~2.1" pest = "~2.1"
pest_derive = "~2.1" pest_derive = "~2.1"

View File

@ -26,11 +26,16 @@ identifier = { ASCII_ALPHA ~ (ASCII_ALPHANUMERIC | "_")* }
opening_brace = _{ "{" } opening_brace = _{ "{" }
closing_brace = _{ "}" } closing_brace = _{ "}" }
equals = _{ "=" } equals = _{ "=" }
quote = _{ "\"" } quote = _{ "'" }
triple_quote = _{ "'''" }
string = { triple_quoted | single_quoted }
single_quoted = ${ (quote ~ inner_single_str ~ quote) }
inner_single_str = @{ (!(quote | "\\") ~ ANY)* ~ (escape ~ inner_single_str)? }
triple_quoted = ${ (triple_quote ~ inner_triple_str ~ triple_quote) }
inner_triple_str = @{ (!(triple_quote | "\\") ~ ANY)* ~ (escape ~ inner_triple_str)? }
string = { double_quoted }
double_quoted = ${ (quote ~ inner_double_str ~ quote) }
inner_double_str = @{ (!("\"" | "\\") ~ ANY)* ~ (escape ~ inner_double_str)? }
escape = @{ "\\" ~ ("\"" | "\\" | "r" | "n" | "t" | "0" | "'" | code | unicode) } escape = @{ "\\" ~ ("\"" | "\\" | "r" | "n" | "t" | "0" | "'" | code | unicode) }
code = @{ "x" ~ hex_digit{2} } code = @{ "x" ~ hex_digit{2} }
unicode = @{ "u" ~ opening_brace ~ hex_digit{2, 6} ~ closing_brace } unicode = @{ "u" ~ opening_brace ~ hex_digit{2, 6} ~ closing_brace }

View File

@ -17,7 +17,7 @@ pub struct ExecutableTask {
} }
impl ExecutableTask { impl ExecutableTask {
fn new(task: crate::task::Task, parameters: HashMap<String, String>) -> Self { pub fn new(task: crate::task::Task, parameters: HashMap<String, String>) -> Self {
Self { task, parameters } Self { task, parameters }
} }
} }
@ -138,10 +138,10 @@ fn parse_str(parser: &mut Pairs<Rule>) -> Result<String, PestError<Rule>> {
Rule::string => { Rule::string => {
return parse_str(&mut parsed.into_inner()); return parse_str(&mut parsed.into_inner());
} }
Rule::double_quoted => { Rule::single_quoted => {
return parse_str(&mut parsed.into_inner()); return parse_str(&mut parsed.into_inner());
} }
Rule::inner_double_str => { Rule::inner_single_str => {
return Ok(parsed.as_str().to_string()); return Ok(parsed.as_str().to_string());
} }
_ => {} _ => {}
@ -168,12 +168,12 @@ mod tests {
* It is expected to be run from the root of the project tree. * It is expected to be run from the root of the project tree.
*/ */
task "../tasks/echo.ztask" { task '../tasks/echo.ztask' {
msg = "Hello from the wonderful world of zplans!" msg = 'Hello from the wonderful world of zplans!'
} }
task "../tasks/echo.ztask" { task '../tasks/echo.ztask' {
msg = "This can actually take inline shells too: $(date)" msg = 'This can actually take inline shells too: $(date)'
}"#; }"#;
let _plan = PlanParser::parse(Rule::planfile, buf) let _plan = PlanParser::parse(Rule::planfile, buf)
.unwrap() .unwrap()
@ -183,12 +183,12 @@ task "../tasks/echo.ztask" {
#[test] #[test]
fn parse_plan_fn() { fn parse_plan_fn() {
let buf = r#"task "../tasks/echo.ztask" { let buf = r#"task '../tasks/echo.ztask' {
msg = "Hello from the wonderful world of zplans!" msg = 'Hello from the wonderful world of zplans!'
} }
task "../tasks/echo.ztask" { task '../tasks/echo.ztask' {
msg = "This can actually take inline shells too: $(date)" msg = 'This can actually take inline shells too: $(date)'
}"#; }"#;
let plan = Plan::from_str(buf).expect("Failed to parse the plan"); let plan = Plan::from_str(buf).expect("Failed to parse the plan");
assert_eq!(plan.tasks.len(), 2); assert_eq!(plan.tasks.len(), 2);

View File

@ -32,10 +32,11 @@ ptype = { "type" ~ equals ~ typedef }
script = { "script" script = { "script"
~ opening_brace ~ opening_brace
~ (script_inline) ~ (script_inline | script_file)
~ closing_brace ~ closing_brace
} }
script_inline = _{ "inline" ~ equals ~ string } script_inline = { "inline" ~ equals ~ string }
script_file = { "file" ~ equals ~ string }
@ -49,11 +50,16 @@ identifier = { ASCII_ALPHA ~ (ASCII_ALPHANUMERIC | "_")* }
opening_brace = _{ "{" } opening_brace = _{ "{" }
closing_brace = _{ "}" } closing_brace = _{ "}" }
equals = _{ "=" } equals = _{ "=" }
quote = _{ "\"" } quote = _{ "'" }
triple_quote = _{ "'''" }
string = { triple_quoted | single_quoted }
single_quoted = ${ (quote ~ inner_single_str ~ quote) }
inner_single_str = @{ (!(quote | "\\") ~ ANY)* ~ (escape ~ inner_single_str)? }
triple_quoted = ${ (triple_quote ~ inner_triple_str ~ triple_quote) }
inner_triple_str = @{ (!(triple_quote | "\\") ~ ANY)* ~ (escape ~ inner_triple_str)? }
string = { double_quoted }
double_quoted = ${ (quote ~ inner_double_str ~ quote) }
inner_double_str = @{ (!("\"" | "\\") ~ ANY)* ~ (escape ~ inner_double_str)? }
escape = @{ "\\" ~ ("\"" | "\\" | "r" | "n" | "t" | "0" | "'" | code | unicode) } escape = @{ "\\" ~ ("\"" | "\\" | "r" | "n" | "t" | "0" | "'" | code | unicode) }
code = @{ "x" ~ hex_digit{2} } code = @{ "x" ~ hex_digit{2} }
unicode = @{ "u" ~ opening_brace ~ hex_digit{2, 6} ~ closing_brace } unicode = @{ "u" ~ opening_brace ~ hex_digit{2, 6} ~ closing_brace }

View File

@ -1,33 +1,121 @@
use log::*;
use pest::error::Error as PestError; use pest::error::Error as PestError;
use pest::error::ErrorVariant; use pest::error::ErrorVariant;
use pest::iterators::Pairs; use pest::iterators::Pairs;
use pest::Parser; use pest::Parser;
use std::collections::HashMap;
use std::fs::File;
use std::io::Read;
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Parser)] #[derive(Parser)]
#[grammar = "task.pest"] #[grammar = "task.pest"]
struct TaskParser; struct TaskParser;
/**
* A Script represents something that can be executed oa a remote host.
*
* These come in two variants:
* - Inline string of shell commands to run
* - A script or binary file to transfer and execute
*/
#[derive(Clone, Debug)]
pub struct Script {
/**
* Inline scripts will have parameters rendered into then with handlebars syntax
*/
pub inline: Option<String>,
/**
* File scripts will be executed with the parameters passed as command line
* arguments, e.g. the "msg" parameter would be passed as:
* ./file --msg=value
*/
pub file: Option<PathBuf>,
}
impl Script {
fn new() -> Self {
Self {
inline: None,
file: None,
}
}
/**
* Return the script's contents as bytes
*
* This is useful for transferring the script to another host for execution
*
* If the `file` member is defined, that will be preferred, even if `inline` is also defined
*/
pub fn as_bytes(&self, parameters: Option<&HashMap<String, String>>) -> Option<Vec<u8>> {
use handlebars::Handlebars;
if self.inline.is_some() && self.file.is_some() {
warn!("Both inline and file structs are defined for this script, only file will be used!\n({})",
self.inline.as_ref().unwrap());
}
if let Some(path) = &self.file {
match File::open(path) {
Ok(mut file) => {
let mut buf = vec![];
if let Ok(count) = file.read_to_end(&mut buf) {
debug!("Read {} bytes of {}", count, path.display());
return Some(buf);
} else {
error!("Failed to read the file {}", path.display());
}
}
Err(err) => {
error!("Failed to open the file at {}, {:?}", path.display(), err);
}
}
}
if let Some(inline) = &self.inline {
// Early exit if there are no parameters to render
if parameters.is_none() {
return Some(inline.as_bytes().to_vec());
}
let parameters = parameters.unwrap();
let handlebars = Handlebars::new();
match handlebars.render_template(inline, &parameters) {
Ok(rendered) => {
return Some(rendered.as_bytes().to_vec());
}
Err(err) => {
error!("Failed to render command ({:?}): {}", err, inline);
return Some(inline.as_bytes().to_vec());
}
}
}
None
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Task { pub struct Task {
pub name: String, pub name: String,
pub inline: Option<String>, pub script: Script,
} }
impl Task { impl Task {
pub fn get_script(&self) -> Option<&String> {
self.inline.as_ref()
}
pub fn new(name: &str) -> Self { pub fn new(name: &str) -> Self {
Task { Task {
name: name.to_string(), name: name.to_string(),
inline: None, script: Script::new(),
} }
} }
fn parse(parser: &mut Pairs<Rule>) -> Result<Self, PestError<Rule>> { fn parse(parser: &mut Pairs<Rule>) -> Result<Self, PestError<Rule>> {
let mut task: Option<Self> = None; let mut task: Option<Self> = None;
let mut inline = None;
let mut file = None;
while let Some(parsed) = parser.next() { while let Some(parsed) = parser.next() {
match parsed.as_rule() { match parsed.as_rule() {
@ -35,17 +123,27 @@ impl Task {
task = Some(Task::new(parsed.as_str())); task = Some(Task::new(parsed.as_str()));
} }
Rule::script => { Rule::script => {
let script = parse_str(&mut parsed.into_inner())?; for pair in parsed.into_inner() {
match pair.as_rule() {
if let Some(ref mut task) = task { Rule::script_inline => {
task.inline = Some(script); inline = Some(parse_str(&mut pair.into_inner())?);
}
Rule::script_file => {
let path = parse_str(&mut pair.into_inner())?;
file = Some(PathBuf::from(path));
}
_ => {}
}
} }
} }
_ => {} _ => {}
} }
} }
if let Some(task) = task { if let Some(mut task) = task {
task.script.inline = inline;
task.script.file = file;
return Ok(task); return Ok(task);
} else { } else {
return Err(PestError::new_from_pos( return Err(PestError::new_from_pos(
@ -78,9 +176,6 @@ impl Task {
} }
pub fn from_path(path: &PathBuf) -> Result<Self, PestError<Rule>> { pub fn from_path(path: &PathBuf) -> Result<Self, PestError<Rule>> {
use std::fs::File;
use std::io::Read;
match File::open(path) { match File::open(path) {
Ok(mut file) => { Ok(mut file) => {
let mut contents = String::new(); let mut contents = String::new();
@ -118,10 +213,16 @@ fn parse_str(parser: &mut Pairs<Rule>) -> Result<String, PestError<Rule>> {
Rule::string => { Rule::string => {
return parse_str(&mut parsed.into_inner()); return parse_str(&mut parsed.into_inner());
} }
Rule::double_quoted => { Rule::triple_quoted => {
return parse_str(&mut parsed.into_inner()); return parse_str(&mut parsed.into_inner());
} }
Rule::inner_double_str => { Rule::single_quoted => {
return parse_str(&mut parsed.into_inner());
}
Rule::inner_single_str => {
return Ok(parsed.as_str().to_string());
}
Rule::inner_triple_str => {
return Ok(parsed.as_str().to_string()); return Ok(parsed.as_str().to_string());
} }
_ => {} _ => {}
@ -146,12 +247,12 @@ mod tests {
parameters { parameters {
package { package {
required = true required = true
help = "Name of package to be installed" help = 'Name of package to be installed'
type = string type = string
} }
} }
script { script {
inline = "zypper in -y ${ZAP_PACKAGE}" inline = 'zypper in -y {{package}}'
} }
}"#; }"#;
let _task = TaskParser::parse(Rule::task, buf).unwrap().next().unwrap(); let _task = TaskParser::parse(Rule::task, buf).unwrap().next().unwrap();
@ -161,7 +262,7 @@ mod tests {
fn parse_no_parameters() { fn parse_no_parameters() {
let buf = r#"task PrintEnv { let buf = r#"task PrintEnv {
script { script {
inline = "env" inline = 'env'
} }
}"#; }"#;
let _task = TaskParser::parse(Rule::task, buf).unwrap().next().unwrap(); let _task = TaskParser::parse(Rule::task, buf).unwrap().next().unwrap();
@ -171,11 +272,28 @@ mod tests {
fn parse_task_fn() { fn parse_task_fn() {
let buf = r#"task PrintEnv { let buf = r#"task PrintEnv {
script { script {
inline = "env" inline = 'env'
} }
}"#; }"#;
let task = Task::from_str(buf).expect("Failed to parse the task"); let task = Task::from_str(buf).expect("Failed to parse the task");
assert_eq!(task.name, "PrintEnv"); assert_eq!(task.name, "PrintEnv");
assert_eq!(task.get_script().unwrap(), "env");
let script = task.script;
assert_eq!(script.as_bytes(None).unwrap(), "env".as_bytes());
}
#[test]
fn parse_task_fn_with_triple_quotes() {
let buf = r#"task PrintEnv {
script {
inline = '''env'''
}
}"#;
let task = Task::from_str(buf).expect("Failed to parse the task");
assert_eq!(task.name, "PrintEnv");
let script = task.script;
assert_eq!(script.as_bytes(None).unwrap(), "env".as_bytes());
} }
} }

View File

@ -2,12 +2,12 @@ task Echo {
parameters { parameters {
msg { msg {
required = true required = true
help = "String to echo back to the client" help = 'String to echo back to the client'
type = string type = string
} }
} }
script { script {
inline = "echo {{msg}}" inline = 'echo "{{msg}}"'
} }
} }

15
tasks/shell/bash.ztask Normal file
View File

@ -0,0 +1,15 @@
task Bash {
parameters {
script {
required = true
help = 'A script to run via the bash shell (assumes bash is in the defult PATH)'
type = string
}
}
script {
inline = '''#!/usr/bin/env bash
{{script}}
'''
}
}