add: make shell command configurable

This commit is contained in:
Arne Beer 2023-08-18 20:09:21 +02:00
parent 887c409104
commit 22b7fc0c1d
No known key found for this signature in database
GPG key ID: CC9408F679023B65
14 changed files with 139 additions and 44 deletions

View file

@ -15,6 +15,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- QoL improvement: Don't pause groups if there're no queued tasks. [#452](https://github.com/Nukesor/pueue/issues/452)
Auto-pausing of groups was only done to prevent the unwanted execution of other tasks, but this isn't necessary, if there're no queued tasks.
### Added
- Allow configuration of the shell command that executes task commands. [#454](https://github.com/Nukesor/pueue/issues/454)
## [3.2.0] - 2023-06-13
### Added

1
Cargo.lock generated
View file

@ -1213,6 +1213,7 @@ dependencies = [
"chrono",
"command-group",
"dirs",
"handlebars",
"libproc",
"log",
"portpicker",

View file

@ -25,6 +25,7 @@ snap = "1.1"
strum = "0.25"
strum_macros = "0.25"
tokio = { version = "1.32", features = ["rt-multi-thread", "time", "io-std"] }
handlebars = "4.3"
# Dev dependencies
anyhow = "1"

View file

@ -24,7 +24,6 @@ clap_complete = "4.3"
comfy-table = "7"
crossterm = { version = "0.26", default-features = false }
ctrlc = { version = "3", features = ["termination"] }
handlebars = "4.3"
pest = "2.7"
pest_derive = "2.7"
shell-escape = "0.1"
@ -33,6 +32,7 @@ tempfile = "3"
chrono = { workspace = true }
command-group = { workspace = true }
handlebars = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

View file

@ -199,7 +199,15 @@ impl Client {
path,
label,
} => {
let message = edit(&mut self.stream, *task_id, *command, *path, *label).await?;
let message = edit(
&mut self.stream,
&self.settings,
*task_id,
*command,
*path,
*label,
)
.await?;
self.handle_response(message)?;
Ok(true)
}
@ -231,6 +239,7 @@ impl Client {
(self.settings.client.restart_in_place || *in_place) && !*not_in_place;
restart(
&mut self.stream,
&self.settings,
task_ids.clone(),
*all_failed,
failed_in_group.clone(),

View file

@ -3,6 +3,7 @@ use std::io::{Read, Seek, Write};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use pueue_lib::settings::Settings;
use tempfile::NamedTempFile;
use pueue_lib::network::message::*;
@ -18,6 +19,7 @@ use pueue_lib::process_helper::compile_shell_command;
/// Upon exiting the text editor, the line will then be read and sent to the server
pub async fn edit(
stream: &mut GenericStream,
settings: &Settings,
task_id: usize,
edit_command: bool,
edit_path: bool,
@ -41,6 +43,7 @@ pub async fn edit(
// Edit all requested properties.
let edit_result = edit_task_properties(
settings,
&init_response.command,
&init_response.path,
&init_response.label,
@ -100,6 +103,7 @@ pub struct EditedProperties {
///
/// The returned values are: `(command, path, label)`
pub fn edit_task_properties(
settings: &Settings,
original_command: &str,
original_path: &Path,
original_label: &Option<String>,
@ -111,7 +115,7 @@ pub fn edit_task_properties(
// Update the command if requested.
if edit_command {
props.command = Some(edit_line(original_command)?);
props.command = Some(edit_line(settings, original_command)?);
};
// Update the path if requested.
@ -119,13 +123,13 @@ pub fn edit_task_properties(
let str_path = original_path
.to_str()
.context("Failed to convert task path to string")?;
let changed_path = edit_line(str_path)?;
let changed_path = edit_line(settings, str_path)?;
props.path = Some(PathBuf::from(changed_path));
}
// Update the label if requested.
if edit_label {
let edited_label = edit_line(&original_label.clone().unwrap_or_default())?;
let edited_label = edit_line(settings, &original_label.clone().unwrap_or_default())?;
// If the user deletes the label in their editor, an empty string will be returned.
// This is an indicator that the task should no longer have a label, in which case we
@ -143,7 +147,7 @@ pub fn edit_task_properties(
/// This function enables the user to edit a task's details.
/// Save any string to a temporary file, which is opened in the specified `$EDITOR`.
/// As soon as the editor is closed, read the file content and return the line.
fn edit_line(line: &str) -> Result<String> {
fn edit_line(settings: &Settings, line: &str) -> Result<String> {
// Create a temporary file with the command so we can edit it with the editor.
let mut file = NamedTempFile::new().expect("Failed to create a temporary file");
writeln!(file, "{line}").context("Failed to write to temporary file.")?;
@ -158,7 +162,7 @@ fn edit_line(line: &str) -> Result<String> {
// We escape the file path for good measure, but it shouldn't be necessary.
let path = shell_escape::escape(file.path().to_string_lossy());
let editor_command = format!("{editor} {path}");
let status = compile_shell_command(&editor_command)
let status = compile_shell_command(settings, &editor_command)
.status()
.context("Editor command did somehow fail. Aborting.")?;

View file

@ -2,6 +2,7 @@ use anyhow::{bail, Result};
use pueue_lib::network::message::*;
use pueue_lib::network::protocol::*;
use pueue_lib::settings::Settings;
use pueue_lib::state::FilteredTasks;
use pueue_lib::task::{Task, TaskResult, TaskStatus};
@ -16,6 +17,7 @@ use crate::client::commands::get_state;
#[allow(clippy::too_many_arguments)]
pub async fn restart(
stream: &mut GenericStream,
settings: &Settings,
task_ids: Vec<usize>,
all_failed: bool,
failed_in_group: Option<String>,
@ -85,6 +87,7 @@ pub async fn restart(
// Edit any properties, if requested.
let edited_props = edit_task_properties(
settings,
&task.command,
&task.path,
&task.label,

View file

@ -20,7 +20,7 @@ impl TaskHandler {
}
};
let mut command = compile_shell_command(&callback_command);
let mut command = compile_shell_command(&self.settings, &callback_command);
// Spawn the callback subprocess and log if it fails.
let spawn_result = command.spawn();

View file

@ -135,7 +135,7 @@ impl TaskHandler {
};
// Build the shell command that should be executed.
let mut command = compile_shell_command(&command);
let mut command = compile_shell_command(&self.settings, &command);
// Determine the worker's id depending on the current group.
// Inject that info into the environment.

View file

@ -32,6 +32,7 @@ thiserror = "1.0"
tokio-rustls = { version = "0.24", default-features = false }
command-group = { workspace = true }
handlebars = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

View file

@ -4,7 +4,9 @@
//! each supported platform.
//! Depending on the target, the respective platform is read and loaded into this scope.
use crate::network::message::Signal as InternalSignal;
use std::{collections::HashMap, process::Command};
use crate::{network::message::Signal as InternalSignal, settings::Settings};
// Unix specific process handling
// Shared between Linux and Apple
@ -63,3 +65,39 @@ impl From<InternalSignal> for Signal {
}
}
}
/// Take a platform specific shell command and insert the actual task command via templating.
pub fn compile_shell_command(settings: &Settings, command: &str) -> Command {
let shell_command = get_shell_command(settings);
let mut handlebars = handlebars::Handlebars::new();
handlebars.set_strict_mode(true);
handlebars.register_escape_fn(handlebars::no_escape);
// Make the command available to the template engine.
let mut parameters = HashMap::new();
parameters.insert("pueue_command_string", command);
// We allow users to provide their own shell command.
// They should use the `{{ pueue_command_string }}` placeholder.
let mut compiled_command = Vec::new();
for part in shell_command {
let compiled_part = handlebars
.render_template(&part, &parameters)
.unwrap_or_else(|_| {
panic!("Failed to render shell command for template: {part} and parameters: {parameters:?}")
});
compiled_command.push(compiled_part);
}
let executable = compiled_command.remove(0);
// Chain two `powershell` commands, one that sets the output encoding to utf8 and then the user provided one.
let mut command = Command::new(executable);
for arg in compiled_command {
command.arg(&arg);
}
command
}

View file

@ -1,5 +1,3 @@
use std::process::Command;
// We allow anyhow in here, as this is a module that'll be strictly used internally.
// As soon as it's obvious that this is code is intended to be exposed to library users, we have to
// go ahead and replace any `anyhow` usage by proper error handling via our own Error type.
@ -7,11 +5,18 @@ use anyhow::Result;
use command_group::{GroupChild, Signal, UnixChildExt};
use log::info;
pub fn compile_shell_command(command_string: &str) -> Command {
let mut command = Command::new("sh");
command.arg("-c").arg(command_string);
use crate::settings::Settings;
command
pub fn get_shell_command(settings: &Settings) -> Vec<String> {
let Some(ref shell_command) = settings.daemon.shell_command else {
return vec![
"sh".into(),
"-c".into(),
"{{ pueue_command_string }}".into(),
];
};
shell_command.clone()
}
/// Send a signal to one of Pueue's child process group handle.
@ -39,17 +44,18 @@ pub fn kill_child(task_id: usize, child: &mut GroupChild) -> std::io::Result<()>
#[cfg(test)]
mod tests {
use log::warn;
use std::process::Command;
use std::thread::sleep;
use std::time::Duration;
use anyhow::Result;
use command_group::CommandGroup;
use libproc::processes::{pids_by_type, ProcFilter};
use log::warn;
use pretty_assertions::assert_eq;
use super::*;
use crate::process_helper::process_exists;
use crate::process_helper::{compile_shell_command, process_exists};
/// List all PIDs that are part of the process group
pub fn get_process_group_pids(pgrp: u32) -> Vec<u32> {
@ -75,7 +81,8 @@ mod tests {
#[test]
fn test_spawn_command() {
let mut child = compile_shell_command("sleep 0.1")
let settings = Settings::default();
let mut child = compile_shell_command(&settings, "sleep 0.1")
.group_spawn()
.expect("Failed to spawn echo");
@ -87,9 +94,11 @@ mod tests {
#[test]
/// Ensure a `sh -c` command will be properly killed without detached processes.
fn test_shell_command_is_killed() -> Result<()> {
let mut child = compile_shell_command("sleep 60 & sleep 60 && echo 'this is a test'")
.group_spawn()
.expect("Failed to spawn echo");
let settings = Settings::default();
let mut child =
compile_shell_command(&settings, "sleep 60 & sleep 60 && echo 'this is a test'")
.group_spawn()
.expect("Failed to spawn echo");
let pid = child.id();
// Sleep a little to give everything a chance to spawn.
sleep(Duration::from_millis(500));
@ -120,9 +129,11 @@ mod tests {
/// Ensure a `sh -c` command will be properly killed without detached processes when using unix
/// signals directly.
fn test_shell_command_is_killed_with_signal() -> Result<()> {
let mut child = compile_shell_command("sleep 60 & sleep 60 && echo 'this is a test'")
.group_spawn()
.expect("Failed to spawn echo");
let settings = Settings::default();
let mut child =
compile_shell_command(&settings, "sleep 60 & sleep 60 && echo 'this is a test'")
.group_spawn()
.expect("Failed to spawn echo");
let pid = child.id();
// Sleep a little to give everything a chance to spawn.
sleep(Duration::from_millis(500));
@ -153,9 +164,11 @@ mod tests {
/// Ensure that a `sh -c` process with a child process that has children of its own
/// will properly kill all processes and their children's children without detached processes.
fn test_shell_command_children_are_killed() -> Result<()> {
let mut child = compile_shell_command("bash -c 'sleep 60 && sleep 60' && sleep 60")
.group_spawn()
.expect("Failed to spawn echo");
let settings = Settings::default();
let mut child =
compile_shell_command(&settings, "bash -c 'sleep 60 && sleep 60' && sleep 60")
.group_spawn()
.expect("Failed to spawn echo");
let pid = child.id();
// Sleep a little to give everything a chance to spawn.
sleep(Duration::from_millis(500));

View file

@ -1,5 +1,3 @@
use std::process::Command;
// We allow anyhow in here, as this is a module that'll be strictly used internally.
// As soon as it's obvious that this is code is intended to be exposed to library users, we have to
// go ahead and replace any `anyhow` usage by proper error handling via our own Error type.
@ -17,6 +15,8 @@ use winapi::um::tlhelp32::{
};
use winapi::um::winnt::THREAD_SUSPEND_RESUME;
use crate::settings::Settings;
/// Shim signal enum for windows.
pub enum Signal {
SIGINT,
@ -26,14 +26,18 @@ pub enum Signal {
SIGSTOP,
}
pub fn compile_shell_command(command_string: &str) -> Command {
// Chain two `powershell` commands, one that sets the output encoding to utf8 and then the user provided one.
let mut command = Command::new("powershell");
command.arg("-c").arg(format!(
"[Console]::OutputEncoding = [Text.UTF8Encoding]::UTF8; {command_string}"
));
pub fn get_shell_command(settings: &Settings) -> Vec<String> {
let Some(ref shell_command) = settings.daemon.shell_command else {
// Chain two `powershell` commands, one that sets the output encoding to utf8 and then the user provided one.
return vec![
"powershell".into(),
"-c".into(),
"[Console]::OutputEncoding = [Text.UTF8Encoding]::UTF8; {{ pueue_command_string }}"
.into(),
];
};
command
shell_command.clone()
}
/// Send a signal to a windows process.
@ -251,12 +255,14 @@ pub fn process_exists(pid: u32) -> bool {
#[cfg(test)]
mod test {
use std::process::Command;
use std::thread::sleep;
use std::time::Duration;
use command_group::CommandGroup;
use super::*;
use crate::process_helper::compile_shell_command;
/// Assert that certain process id no longer exists
fn process_is_gone(pid: u32) -> bool {
@ -292,7 +298,8 @@ mod test {
#[test]
fn test_spawn_command() {
let mut child = compile_shell_command("sleep 0.1")
let settings = Settings::default();
let mut child = compile_shell_command(&settings, "sleep 0.1")
.group_spawn()
.expect("Failed to spawn echo");
@ -308,9 +315,11 @@ mod test {
/// This test is ignored for now, as it is flaky from time to time.
/// See https://github.com/Nukesor/pueue/issues/315
fn test_shell_command_is_killed() -> Result<()> {
let mut child = compile_shell_command("sleep 60; sleep 60; echo 'this is a test'")
.group_spawn()
.expect("Failed to spawn echo");
let settings = Settings::default();
let mut child =
compile_shell_command(&settings, "sleep 60; sleep 60; echo 'this is a test'")
.group_spawn()
.expect("Failed to spawn echo");
let pid = child.id();
// Get all processes, so we can make sure they no longer exist afterwards.
@ -338,9 +347,11 @@ mod test {
/// Ensure that a `powershell -c` process with a child process that has children of it's own
/// will properly kill all processes and their children's children without detached processes.
fn test_shell_command_children_are_killed() -> Result<()> {
let mut child = compile_shell_command("powershell -c 'sleep 60; sleep 60'; sleep 60")
.group_spawn()
.expect("Failed to spawn echo");
let settings = Settings::default();
let mut child =
compile_shell_command(&settings, "powershell -c 'sleep 60; sleep 60'; sleep 60")
.group_spawn()
.expect("Failed to spawn echo");
let pid = child.id();
// Get all processes, so we can make sure they no longer exist afterwards.
let process_ids = assert_process_ids(pid, 2, 5000)?;

View file

@ -119,6 +119,15 @@ pub struct Daemon {
/// The amount of log lines from stdout/stderr that are passed to the callback command.
#[serde(default = "default_callback_log_lines")]
pub callback_log_lines: usize,
/// The command that should be used for task and callback execution.
/// The following are the only officially supported modi for Pueue.
///
/// Unix default:
/// `vec!["sh", "-c", "{{ pueue_command_string }}"]`.
///
/// Windows default:
/// `vec!["powershell", "-c", "[Console]::OutputEncoding = [Text.UTF8Encoding]::UTF8; {{ pueue_command_string }}"]`
pub shell_command: Option<Vec<String>>,
}
impl Default for Shared {
@ -165,6 +174,7 @@ impl Default for Daemon {
pause_all_on_failure: false,
callback: None,
callback_log_lines: default_callback_log_lines(),
shell_command: None,
}
}
}