Merge pull request 'Add command module' (#10) from command-execution into master

Reviewed-on: https://stupstech.de/dev/Mr_Steppy/multi-ssh/pulls/10
This commit is contained in:
Leonard Steppy 2025-01-08 16:32:45 +01:00
commit effe1418eb
5 changed files with 186 additions and 36 deletions

View File

@ -78,3 +78,7 @@ Once you have that installed, just run
cargo build --release
```
and you will find an executable in `target/release`.
### Unit tests
In order for the unit tests to pass, you will need `python3`

156
src/command.rs Normal file
View File

@ -0,0 +1,156 @@
use crate::log;
use crate::logger::{LogLevel, Logger};
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::io;
use std::iter::once;
use std::process::{Command, ExitStatus, Output};
pub trait LogRunnable {
fn run(&mut self, logger: &Logger) -> Result<(), SpecificExecutionError>;
fn collect_output(&mut self) -> Result<Output, SpecificExecutionError>;
}
impl LogRunnable for Command {
fn run(&mut self, logger: &Logger) -> Result<(), SpecificExecutionError> {
run(self, logger).map_err(|error| SpecificExecutionError {
command: self,
error,
})
}
fn collect_output(&mut self) -> Result<Output, SpecificExecutionError> {
collect_output(self, None).map_err(|error| SpecificExecutionError {
command: self,
error,
})
}
}
fn run(command: &mut Command, logger: &Logger) -> Result<(), ExecutionError> {
match logger.level {
LogLevel::Debug | LogLevel::Info => {
let status = command.status()?;
if !status.success() {
Err(status)?;
}
}
LogLevel::Error => {
collect_output(command, Some(logger))?;
}
}
Ok(())
}
fn collect_output(
command: &mut Command,
logger: Option<&Logger>,
) -> Result<Output, ExecutionError> {
let output = command.output()?; //pipes stdout and stderr automatically
if !output.status.success() {
if let Some(logger) = logger {
log!(logger, error, "{}", String::from_utf8_lossy(&output.stderr));
}
Err(output.status)?;
}
Ok(output)
}
#[derive(Debug)]
pub struct SpecificExecutionError<'a> {
pub command: &'a Command,
pub error: ExecutionError,
}
impl Display for SpecificExecutionError<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Failed to execute command '{}': {}",
command_to_string(self.command),
self.error
)
}
}
fn command_to_string(command: &Command) -> String {
once(command.get_program().to_string_lossy())
.chain(command.get_args().map(|arg| arg.to_string_lossy()))
.collect::<Vec<_>>()
.join(" ")
}
impl Error for SpecificExecutionError<'_> {}
#[derive(Debug)]
pub enum ExecutionError {
StartError(io::Error),
BadExitStatus(ExitStatus),
}
impl From<io::Error> for ExecutionError {
fn from(value: io::Error) -> Self {
Self::StartError(value)
}
}
impl From<ExitStatus> for ExecutionError {
fn from(value: ExitStatus) -> Self {
Self::BadExitStatus(value)
}
}
impl Display for ExecutionError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ExecutionError::StartError(e) => write!(f, "Failed to start command: {}", e),
ExecutionError::BadExitStatus(status) => {
write!(f, "Command failed with {}", status)
}
}
}
}
impl Error for ExecutionError {}
#[cfg(test)]
mod test {
use crate::command::{ExecutionError, LogRunnable, SpecificExecutionError};
use crate::logger::Logger;
use std::path::PathBuf;
use std::process::Command;
#[test]
fn test_unknown_command() {
let mut command = Command::new("python7");
let Err(
e @ SpecificExecutionError {
error: ExecutionError::StartError(_),
..
},
) = command
.args([PathBuf::from("test-ressources/python/exit_1.py")])
.run(&Logger::default())
else {
panic!("command shouldn't exist");
};
assert_eq!(e.to_string(), "Failed to execute command 'python7': Failed to start command: No such file or directory (os error 2)");
}
#[test]
fn test_error() {
let mut command = Command::new("python3");
let Err(
e @ SpecificExecutionError {
error: ExecutionError::BadExitStatus(_),
..
},
) = command
.arg("test-ressources/python/exit_1.py")
.run(&Logger::default())
else {
panic!("command should return exit-code 1")
};
assert_eq!(e.to_string(), "Failed to execute command 'python3 test-ressources/python/exit_1.py': Command failed with exit status: 1");
}
}

View File

@ -39,7 +39,7 @@ macro_rules! log {
$logger.$level(format!($($args)*));
};
($logger:expr, $($args:tt)*) => {
log!($logger, info, $($args)*);
log!($logger, info, $($args)*); //TODO better use default level with log function instead of assuming info as default
}
}

View File

@ -1,10 +1,12 @@
mod action;
mod command;
mod file;
mod logger;
mod os_string_builder;
mod server;
use crate::action::{Action, FileAction, ServerActions};
use crate::command::LogRunnable;
use crate::file::{FileMatcher, FileNameInfo};
use crate::logger::{LogLevel, Logger};
use crate::os_string_builder::ReplaceWithOsStr;
@ -16,7 +18,6 @@ use std::hash::Hash;
use std::io::Write;
use std::iter::once;
use std::path::PathBuf;
use std::process::Stdio;
use std::str::FromStr;
use std::{env, fs};
@ -159,7 +160,7 @@ fn main() -> Result<(), String> {
file_name,
} => {
require_non_empty_servers(&servers)?;
start_ssh_agent()?;
start_ssh_agent(&logger)?;
let file_name_info =
FileNameInfo::try_from(file.clone()).map_err(|e| format!("bad file: {e}"))?;
@ -175,9 +176,8 @@ fn main() -> Result<(), String> {
let output = ShellCmd::new("ssh")
.arg(&server.ssh_name)
.arg(osf!("ls ") + &working_directory)
.stdout(Stdio::piped())
.output()
.map_err(|e| format!("failed to query files via ssh: {e}"))?;
.collect_output()
.map_err(|e| format!("failed to query files: {e}"))?;
let output = String::from_utf8_lossy(&output.stdout);
let mut file_matcher =
@ -274,19 +274,15 @@ fn main() -> Result<(), String> {
ShellCmd::new("scp")
.arg(file.clone())
.arg(osf!(&server.ssh_name) + ":" + &server_actions.working_directory)
.spawn()
.map_err(|e| format!("failed to upload file: {e}"))?
.wait()
.map_err(|e| format!("failed to wait for upload: {e}"))?;
.run(&logger)
.map_err(|e| format!("upload failure: {e}"))?;
}
Action::Delete => {
ShellCmd::new("ssh")
.arg(&server.ssh_name)
.arg(osf!("cd ") + &server_actions.working_directory + "; rm " + &file_action.file)
.spawn()
.map_err(|e| format!("failed to send delete command: {e}"))?
.wait()
.map_err(|e| format!("failed to wait for delete command: {e}"))?;
.run(&logger)
.map_err(|e| format!("failed to delete old version: {e}"))?;
}
Action::Rename { new_name } => {
ShellCmd::new("ssh")
@ -299,10 +295,8 @@ fn main() -> Result<(), String> {
+ " "
+ new_name,
)
.spawn()
.map_err(|e| format!("failed to send rename command: {e}"))?
.wait()
.map_err(|e| format!("failed to wait for rename command: {e}"))?;
.run(&logger)
.map_err(|e| format!("failed to rename: {e}"))?;
}
}
}
@ -311,17 +305,15 @@ fn main() -> Result<(), String> {
log!(logger, "Done!");
}
Command::Command { command } => {
start_ssh_agent()?;
start_ssh_agent(&logger)?;
require_non_empty_servers(&servers)?;
for server in servers {
log!(logger, "Running command on '{}'...", server.ssh_name);
ShellCmd::new("ssh")
.arg(server.ssh_name)
.arg(osf!("cd ") + server.server_directory_path + "; " + &command)
.spawn()
.map_err(|_| "failed to start ssh command".to_string())?
.wait()
.map_err(|e| format!("failed to wait for ssh command completion: {e}"))?;
.run(&logger)
.map_err(|e| format!("{e}"))?;
}
log!(logger, "Done!");
}
@ -357,15 +349,15 @@ fn main() -> Result<(), String> {
}
require_non_empty_servers(&servers)?;
start_ssh_agent()?;
start_ssh_agent(&logger)?;
for server in servers {
log!(logger, "Downloading file from {}...", server.ssh_name);
ShellCmd::new("scp")
.arg(osf!(&server.ssh_name) + ":" + server.server_directory_path.join(&file))
.arg(&working_directory)
.status()
.map_err(|e| format!("failed to download file: {e}"))?;
.run(&logger)
.map_err(|e| format!("download failure: {e}"))?;
//open file in editor
let mut shell_args = shell_words::split(&editor)
@ -377,15 +369,15 @@ fn main() -> Result<(), String> {
let command = shell_args.remove(0);
ShellCmd::new(command)
.args(shell_args)
.status()
.run(&logger)
.map_err(|e| format!("failed to open file in editor: {e}"))?;
//upload file again
ShellCmd::new("scp")
.arg(working_directory.join(file_name))
.arg(osf!(&server.ssh_name) + ":" + server.server_directory_path.join(&file))
.status()
.map_err(|e| format!("failed to upload file again: {e}"))?;
.run(&logger)
.map_err(|e| format!("failed to re-upload file: {e}"))?;
}
log!(logger, "Done!");
@ -403,12 +395,11 @@ fn require_non_empty_servers(servers: &[Server]) -> Result<(), String> {
}
}
fn start_ssh_agent() -> Result<(), String> {
fn start_ssh_agent(logger: &Logger) -> Result<(), String> {
//start the ssh agent
let agent_output = ShellCmd::new("ssh-agent")
.arg("-s")
.stdout(Stdio::piped())
.output()
.collect_output()
.map_err(|e| format!("failed to start ssh agent: {e}"))?;
let agent_stdout = String::from_utf8_lossy(&agent_output.stdout);
if !agent_output.status.success() {
@ -424,10 +415,8 @@ fn start_ssh_agent() -> Result<(), String> {
//add the ssh key
ShellCmd::new("ssh-add")
.spawn()
.map_err(|e| format!("failed to add ssh key: {}", e))?
.wait()
.expect("failed to wait on ssh-add");
.run(logger)
.map_err(|e| format!("failed to add ssh-key: {e}"))?;
Ok(())
}

View File

@ -0,0 +1 @@
exit(1)