2024-12-13 00:03:25 +01:00
mod action ;
2025-01-08 10:24:15 +01:00
mod command ;
2024-12-12 19:45:45 +01:00
mod file ;
2024-12-17 05:49:37 +01:00
mod logger ;
2024-12-13 12:50:05 +01:00
mod os_string_builder ;
2024-12-13 00:03:25 +01:00
mod server ;
2024-12-12 11:59:00 +01:00
2024-12-13 00:03:25 +01:00
use crate ::action ::{ Action , FileAction , ServerActions } ;
2025-01-08 10:24:15 +01:00
use crate ::command ::LogRunnable ;
2024-12-13 00:03:25 +01:00
use crate ::file ::{ FileMatcher , FileNameInfo } ;
2024-12-16 15:48:05 +01:00
use crate ::logger ::{ LogLevel , Logger } ;
2024-12-15 14:34:19 +01:00
use crate ::os_string_builder ::ReplaceWithOsStr ;
2024-12-11 14:13:32 +01:00
use clap ::{ Parser , Subcommand , ValueEnum } ;
2024-12-12 11:59:00 +01:00
use lazy_regex ::{ lazy_regex , Lazy , Regex } ;
use server ::{ Server , ServerReference } ;
2024-12-11 21:27:13 +01:00
use std ::cell ::LazyCell ;
2024-12-12 11:59:00 +01:00
use std ::hash ::Hash ;
2024-12-13 00:03:25 +01:00
use std ::io ::Write ;
use std ::iter ::once ;
2024-12-11 10:42:44 +01:00
use std ::path ::PathBuf ;
2024-12-11 16:37:15 +01:00
use std ::str ::FromStr ;
2025-01-08 18:34:59 +01:00
use std ::{ env , fs , io } ;
2024-12-11 10:42:44 +01:00
2024-12-11 14:13:32 +01:00
const SERVERS_ENV_VAR : & str = " MSSH_SERVERS " ;
2024-12-15 14:34:19 +01:00
const EDITOR_ENV_VAR : & str = " MSSH_EDITOR " ;
const FILE_PLACEHOLDER : & str = " <file> " ;
2024-12-12 11:59:00 +01:00
type ShellCmd = std ::process ::Command ;
2024-12-11 10:42:44 +01:00
2024-12-11 14:13:32 +01:00
/// Uploads a file or executes a command on multiple configured servers
2024-12-11 10:42:44 +01:00
///
2024-12-11 14:13:32 +01:00
/// Servers must either be configured via environment variable or denote their server directory with
/// a double colon: crea:home/crea.
2024-12-11 10:42:44 +01:00
///
2024-12-11 14:13:32 +01:00
/// --- Configuration via environment variable ---
2024-12-11 10:42:44 +01:00
///
2024-12-15 14:34:19 +01:00
/// Use `MSSH_SERVERS="crea:home/crea,sky:sky,lobby:,city:city2"` to configure servers.
2024-12-11 10:42:44 +01:00
#[ derive(Parser, Debug) ]
#[ command(version, about, long_about) ]
struct Args {
2024-12-11 14:13:32 +01:00
/// The action to perform
#[ command(subcommand) ]
command : Command ,
/// The ssh names and optionally home directories of the servers to perform the action on
2024-12-11 16:37:15 +01:00
#[ arg(num_args = 1.., value_parser = ServerReference::from_str) ]
servers : Vec < ServerReference > ,
2024-12-16 15:48:05 +01:00
/// How verbose logging output should be
2024-12-17 05:38:39 +01:00
#[ arg(long, default_value = " info " , conflicts_with_all = [ " quiet " , " info " ] ) ]
log_level : LogLevel ,
2024-12-16 15:48:05 +01:00
/// Only log errors
2024-12-17 05:38:39 +01:00
#[ arg(short, long, default_value = " false " , conflicts_with_all = [ " info " ] ) ]
2024-12-16 15:48:05 +01:00
quiet : bool ,
/// Log additional debugging info
2024-12-17 05:49:37 +01:00
#[ arg(short = 'v', long, default_value = " false " ) ]
2024-12-16 15:48:05 +01:00
info : bool ,
2024-12-11 14:13:32 +01:00
}
#[ derive(Subcommand, Debug) ]
enum Command {
/// Upload a file to the servers
#[ command(visible_short_flag_alias = 'u') ]
Upload {
/// The file to upload
file : PathBuf ,
/// How to handle older versions of the file
2024-12-13 00:46:35 +01:00
#[ arg(short = 'a', long, default_value = " delete " , default_missing_value = " archive " , num_args = 0..=1) ]
2024-12-11 14:13:32 +01:00
old_version_policy : OldVersionPolicy ,
2024-12-12 12:07:07 +01:00
/// The directory where to upload to, relative to the server directory
2024-12-12 09:50:32 +01:00
#[ arg(short = 'p', long, default_value = " plugins " ) ]
upload_directory : PathBuf ,
2024-12-12 11:59:00 +01:00
/// Skip the confirmation dialog
2024-12-13 00:46:35 +01:00
#[ arg(long, default_value = " false " ) ]
2024-12-12 11:59:00 +01:00
no_confirm : bool ,
2024-12-12 12:07:07 +01:00
/// The prefix of the name of older versions of the file, which should be replaced or deleted
#[ arg(short, long) ]
file_name : Option < String > ,
2024-12-11 14:13:32 +01:00
} ,
/// Execute a command on the servers
#[ command(visible_short_flag_alias = 'c') ]
2024-12-15 14:34:19 +01:00
#[ allow(clippy::enum_variant_names) ]
2024-12-11 14:13:32 +01:00
Command {
/// The command to execute
command : String ,
} ,
2024-12-15 14:34:19 +01:00
/// Open a file in your local editor and upload it afterward.
///
/// The editor will be opened for every server in the order provided by you.
/// The command to start the editor can be configured via environment variable `MSSH_EDITOR`:
///
/// `export MSSH_EDITOR="nano <file>"`
///
/// File is a placeholder that will be replaced with the actual name of the file.
#[ command(visible_short_flag_alias = 'e') ]
Editor {
/// A path to the file to edit, relative to the server directory
file : PathBuf ,
/// The command to start the editor. Supports the placeholder `<file>`, e.g. "nano <file>".
///
/// If omitted, the command will be taken from the environment variable `MSSH_EDITOR`.
#[ arg(short, long) ]
editor : Option < String > ,
/// The directory where to save the file to
#[ arg(short = 'd', long, default_value = " .mssh/downloads " ) ]
working_directory : PathBuf ,
/// Override existing files in the working directory
#[ arg(short = 'f', long = " override " , default_value = " false " ) ]
override_existing : bool ,
} ,
2024-12-11 14:13:32 +01:00
}
#[ derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default, ValueEnum) ]
enum OldVersionPolicy {
/// Ignore the existence of older versions
Ignore ,
/// Rename older versions: foo.jar -> foo.jarr
Archive ,
/// Delete older versions
#[ default ]
Delete ,
2024-12-11 10:42:44 +01:00
}
fn main ( ) -> Result < ( ) , String > {
let args = Args ::parse ( ) ;
2024-12-17 05:49:37 +01:00
let logger = Logger {
//all the below options are conflicting with each other so an if else is fine
level : if args . quiet {
LogLevel ::Error
} else if args . info {
LogLevel ::Debug
} else {
args . log_level
} ,
} ;
2024-12-11 10:42:44 +01:00
2024-12-11 22:17:36 +01:00
let mut configured_servers = LazyCell ::new ( parse_server_configuration_from_env ) ;
let servers = args
. servers
. iter ( )
. map ( | server_reference | {
let server_name = server_reference . get_name ( ) ;
server_reference
. clone ( )
. try_resolve_lazy ( & mut configured_servers )
. map_err ( | msg | format! ( " Can't resolve server directory for ' {server_name} ': {msg} " ) )
. and_then ( | opt_server | {
opt_server . ok_or ( format! (
" no server directory has been configured for server '{server_name}' "
) )
} )
} )
. collect ::< Result < Vec < _ > , _ > > ( ) ? ;
2024-12-12 11:59:00 +01:00
match args . command {
Command ::Upload {
file ,
old_version_policy ,
upload_directory ,
no_confirm ,
2024-12-12 12:07:07 +01:00
file_name ,
2024-12-12 11:59:00 +01:00
} = > {
2024-12-17 06:02:25 +01:00
require_non_empty_servers ( & servers ) ? ;
2025-01-08 10:24:15 +01:00
start_ssh_agent ( & logger ) ? ;
2024-12-13 00:03:25 +01:00
let file_name_info =
FileNameInfo ::try_from ( file . clone ( ) ) . map_err ( | e | format! ( " bad file: {e} " ) ) ? ;
//create overview of what has to be done on each server
let actions = servers
. iter ( )
. map ( | server | {
2024-12-13 18:09:17 +01:00
let working_directory = server . server_directory_path . join ( & upload_directory ) ;
2024-12-13 00:03:25 +01:00
Ok ( ServerActions {
server ,
actions : {
let output = ShellCmd ::new ( " ssh " )
. arg ( & server . ssh_name )
2024-12-13 18:09:17 +01:00
. arg ( osf! ( " ls " ) + & working_directory )
2025-01-08 13:43:05 +01:00
. collect_output ( )
. map_err ( | e | format! ( " failed to query files: {e} " ) ) ? ;
2024-12-13 00:03:25 +01:00
let output = String ::from_utf8_lossy ( & output . stdout ) ;
let mut file_matcher =
FileMatcher ::from ( file_name . as_ref ( ) . unwrap_or ( & file_name_info . name ) ) ;
if let Some ( extension ) = file_name_info . extension . as_ref ( ) {
file_matcher = file_matcher . and_extension ( extension ) ;
}
2024-12-13 00:46:35 +01:00
let file_name = file_name_info . to_full_file_name ( ) ;
let add_action = FileAction {
file : PathBuf ::from ( & file_name ) ,
2024-12-13 00:03:25 +01:00
kind : Action ::Add ,
2024-12-13 00:46:35 +01:00
} ;
2024-12-13 00:03:25 +01:00
let mut files = output . lines ( ) ;
match old_version_policy {
OldVersionPolicy ::Ignore = > {
vec! [ if files . any ( | file | file = = file_name ) {
FileAction {
2024-12-13 00:46:35 +01:00
file : PathBuf ::from ( & file_name ) ,
2024-12-13 00:03:25 +01:00
kind : Action ::Replace ,
}
} else {
2024-12-13 00:46:35 +01:00
add_action
2024-12-13 00:03:25 +01:00
} ]
}
OldVersionPolicy ::Archive = > files
. filter ( | file | file_matcher . matches ( file ) )
. map ( | file | FileAction {
file : PathBuf ::from ( file ) ,
kind : Action ::Rename {
new_name : format ! ( " {file}{} " , file . chars ( ) . last ( ) . unwrap_or ( '1' ) ) . into ( ) ,
} ,
} )
2024-12-13 00:46:35 +01:00
. chain ( once ( add_action ) )
2024-12-13 00:03:25 +01:00
. collect ( ) ,
2024-12-13 13:07:52 +01:00
OldVersionPolicy ::Delete = > {
let mut actions : Vec < _ > = files
. filter ( | file | file_matcher . matches ( file ) )
. map ( | file | {
//special case -> file has the same name as current file, then we just need to replace it
if file = = file_name {
FileAction {
file : PathBuf ::from ( file ) ,
kind : Action ::Replace ,
}
} else {
FileAction {
file : PathBuf ::from ( file ) ,
kind : Action ::Delete ,
}
}
} )
. collect ( ) ;
if ! actions . iter ( ) . any ( | action | action . kind = = Action ::Replace ) {
actions . push ( add_action ) ;
}
actions
2024-12-13 18:09:17 +01:00
}
2024-12-13 00:03:25 +01:00
}
} ,
2024-12-13 18:09:17 +01:00
working_directory ,
2024-12-13 00:03:25 +01:00
} )
} )
. collect ::< Result < Vec < _ > , String > > ( ) ? ;
2024-12-17 06:02:25 +01:00
log! ( logger , " The following actions will be performed: " ) ;
2024-12-13 00:03:25 +01:00
for server_actions in & actions {
2024-12-17 06:02:25 +01:00
log! ( logger , " {server_actions} " ) ;
2024-12-13 00:03:25 +01:00
}
if ! no_confirm {
2024-12-17 06:02:25 +01:00
log! ( logger , " Continue? [Y|n] " ) ;
2024-12-13 00:03:25 +01:00
std ::io ::stdout ( ) . flush ( ) . expect ( " failed to flush stdout " ) ;
let mut buffer = String ::new ( ) ;
std ::io ::stdin ( )
. read_line ( & mut buffer )
. expect ( " failed to read stdin " ) ;
match buffer . to_lowercase ( ) . trim ( ) {
" n " | " no " = > {
2024-12-17 06:02:25 +01:00
log! ( logger , " Aborting... " ) ;
2024-12-13 00:03:25 +01:00
return Ok ( ( ) ) ;
}
_ = > { }
}
}
for server_actions in actions {
let server = server_actions . server ;
2024-12-17 06:02:25 +01:00
log! ( logger , " Performing actions on {}... " , server . ssh_name ) ;
2024-12-13 00:03:25 +01:00
for file_action in server_actions . actions {
match file_action . kind {
Action ::Add | Action ::Replace = > {
ShellCmd ::new ( " scp " )
. arg ( file . clone ( ) )
2024-12-13 18:09:17 +01:00
. arg ( osf! ( & server . ssh_name ) + " : " + & server_actions . working_directory )
2025-01-08 10:24:15 +01:00
. run ( & logger )
. map_err ( | e | format! ( " upload failure: {e} " ) ) ? ;
2024-12-13 00:03:25 +01:00
}
Action ::Delete = > {
ShellCmd ::new ( " ssh " )
. arg ( & server . ssh_name )
2024-12-13 18:09:17 +01:00
. arg ( osf! ( " cd " ) + & server_actions . working_directory + " ; rm " + & file_action . file )
2025-01-08 10:24:15 +01:00
. run ( & logger )
. map_err ( | e | format! ( " failed to delete old version: {e} " ) ) ? ;
2024-12-13 00:03:25 +01:00
}
Action ::Rename { new_name } = > {
ShellCmd ::new ( " ssh " )
. arg ( & server . ssh_name )
2024-12-13 12:50:05 +01:00
. arg (
osf! ( " cd " )
2024-12-13 18:09:17 +01:00
+ & server_actions . working_directory
2024-12-13 12:50:05 +01:00
+ " ; mv "
+ & file_action . file
+ " "
+ new_name ,
)
2025-01-08 10:24:15 +01:00
. run ( & logger )
. map_err ( | e | format! ( " failed to rename: {e} " ) ) ? ;
2024-12-13 00:03:25 +01:00
}
}
}
}
2024-12-17 06:02:25 +01:00
log! ( logger , " Done! " ) ;
2024-12-12 11:59:00 +01:00
}
Command ::Command { command } = > {
2025-01-08 10:24:15 +01:00
start_ssh_agent ( & logger ) ? ;
2024-12-17 06:02:25 +01:00
require_non_empty_servers ( & servers ) ? ;
2024-12-12 11:59:00 +01:00
for server in servers {
2024-12-17 06:02:25 +01:00
log! ( logger , " Running command on '{}'... " , server . ssh_name ) ;
2024-12-12 11:59:00 +01:00
ShellCmd ::new ( " ssh " )
. arg ( server . ssh_name )
2024-12-13 12:50:05 +01:00
. arg ( osf! ( " cd " ) + server . server_directory_path + " ; " + & command )
2025-01-08 10:24:15 +01:00
. run ( & logger )
. map_err ( | e | format! ( " {e} " ) ) ? ;
2024-12-12 11:59:00 +01:00
}
2024-12-17 06:02:25 +01:00
log! ( logger , " Done! " ) ;
2024-12-12 11:59:00 +01:00
}
2024-12-15 14:34:19 +01:00
Command ::Editor {
file ,
editor ,
working_directory ,
override_existing ,
} = > {
//get editor
let editor = editor . ok_or ( ( ) ) . or_else ( | _ | env ::var ( EDITOR_ENV_VAR ) . map_err ( | e | format! ( " You have not specified an editor. Please do so using the --editor flag or the {EDITOR_ENV_VAR} environment variable: {e} " ) ) ) ? ;
fs ::create_dir_all ( & working_directory )
. map_err ( | e | format! ( " failed to create working directory: {e} " ) ) ? ;
//make sure file doesn't exist in working directory yet, or will be overridden
let file_name = file
. file_name ( )
. ok_or ( " can only edit files, not directories " ) ? ;
2025-01-08 18:34:59 +01:00
' duplicate_check : {
if ! override_existing
& & fs ::read_dir ( & working_directory )
. map_err ( | e | format! ( " failed to open working directory: {e} " ) ) ?
. collect ::< Result < Vec < _ > , _ > > ( )
. map_err ( | e | format! ( " error while querying working directory contents: {e} " ) ) ?
. iter ( )
. any ( | entry | entry . file_name ( ) = = file_name )
{
//TODO ask user whether they want to override, unless silent flag is set
let duplication_notification = format! ( " A file with the name {} already exists in {} " , file_name . to_string_lossy ( ) , working_directory . to_string_lossy ( ) ) ;
if ! args . quiet {
print! ( " {duplication_notification} . Do you want to replace it? [N|y] " ) ;
}
return Err ( format! (
2024-12-17 06:02:25 +01:00
" A file with the name {} already exists in {}. You can override it with --override or -f " ,
2024-12-15 14:34:19 +01:00
file_name . to_string_lossy ( ) ,
working_directory . to_string_lossy ( )
) ) ;
2025-01-08 18:34:59 +01:00
}
2024-12-15 14:34:19 +01:00
}
2024-12-17 06:02:25 +01:00
require_non_empty_servers ( & servers ) ? ;
2025-01-08 10:24:15 +01:00
start_ssh_agent ( & logger ) ? ;
2024-12-15 14:34:19 +01:00
for server in servers {
2024-12-17 06:02:25 +01:00
log! ( logger , " Downloading file from {}... " , server . ssh_name ) ;
2024-12-15 14:34:19 +01:00
ShellCmd ::new ( " scp " )
. arg ( osf! ( & server . ssh_name ) + " : " + server . server_directory_path . join ( & file ) )
. arg ( & working_directory )
2025-01-08 10:24:15 +01:00
. run ( & logger )
. map_err ( | e | format! ( " download failure: {e} " ) ) ? ;
2024-12-15 14:34:19 +01:00
//open file in editor
let mut shell_args = shell_words ::split ( & editor )
. map_err ( | e | format! ( " failed to parse editor command: {e} " ) ) ?
. into_iter ( )
. map ( | part | part . replace_with_os_str ( FILE_PLACEHOLDER , working_directory . join ( file_name ) ) )
. collect ::< Vec < _ > > ( ) ;
let command = shell_args . remove ( 0 ) ;
ShellCmd ::new ( command )
. args ( shell_args )
2025-01-08 10:24:15 +01:00
. run ( & logger )
2024-12-15 14:34:19 +01:00
. 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 ) )
2025-01-08 10:24:15 +01:00
. run ( & logger )
. map_err ( | e | format! ( " failed to re-upload file: {e} " ) ) ? ;
2024-12-15 14:34:19 +01:00
}
2024-12-17 06:02:25 +01:00
log! ( logger , " Done! " ) ;
2024-12-15 14:34:19 +01:00
}
2024-12-12 11:59:00 +01:00
}
Ok ( ( ) )
}
2025-01-08 18:34:59 +01:00
fn get_user_input < S > ( prompt : S ) -> String where S : Into < String > {
print! ( " {} " , prompt . into ( ) ) ;
io ::stdout ( ) . flush ( ) . expect ( " failed to flush stdout " ) ;
let mut buf = String ::new ( ) ;
io ::stdin ( ) . read_line ( & mut buf ) . expect ( " failed to read stdin " ) ;
buf
}
2024-12-17 06:02:25 +01:00
fn require_non_empty_servers ( servers : & [ Server ] ) -> Result < ( ) , String > {
if servers . is_empty ( ) {
Err ( " You did not provide any servers for this operation. Please see --help " . to_string ( ) )
} else {
Ok ( ( ) )
}
}
2025-01-08 10:24:15 +01:00
fn start_ssh_agent ( logger : & Logger ) -> Result < ( ) , String > {
2024-12-12 11:59:00 +01:00
//start the ssh agent
let agent_output = ShellCmd ::new ( " ssh-agent " )
. arg ( " -s " )
2025-01-08 13:43:05 +01:00
. collect_output ( )
2024-12-12 11:59:00 +01:00
. map_err ( | e | format! ( " failed to start ssh agent: {e} " ) ) ? ;
2024-12-13 21:03:05 +01:00
let agent_stdout = String ::from_utf8_lossy ( & agent_output . stdout ) ;
if ! agent_output . status . success ( ) {
return Err ( " failed to start ssh agent; maybe try to run ssh-agent manually? " . to_string ( ) ) ;
}
2024-12-12 11:59:00 +01:00
//set the env vars from the agent
static ENV_VAR_REGEX : Lazy < Regex > = lazy_regex! ( " (.+?)=(.+?); " ) ;
2024-12-13 21:03:05 +01:00
for capture in ENV_VAR_REGEX . captures_iter ( & agent_stdout ) {
2024-12-12 11:59:00 +01:00
let ( _ , [ env_var , value ] ) = capture . extract ( ) ;
env ::set_var ( env_var , value ) ;
}
//add the ssh key
2025-01-08 13:43:05 +01:00
ShellCmd ::new ( " ssh-add " )
. run ( logger )
. map_err ( | e | format! ( " failed to add ssh-key: {e} " ) ) ? ;
2024-12-11 10:42:44 +01:00
Ok ( ( ) )
}
fn parse_server_configuration_from_env ( ) -> Result < Vec < Server > , String > {
env ::var ( SERVERS_ENV_VAR )
. map_err ( | _ | format! ( " Missing environment variable {} " , SERVERS_ENV_VAR ) )
. and_then ( | value | parse_server_configuration ( & value ) )
}
fn parse_server_configuration ( config_str : & str ) -> Result < Vec < Server > , String > {
2024-12-11 16:37:15 +01:00
config_str
. split ( ',' )
. map ( | server_entry | {
Server ::from_str ( server_entry )
. map_err ( | e | format! ( " Invalid server entry ' {server_entry} ': {e} " ) )
2024-12-11 10:42:44 +01:00
} )
2024-12-11 16:37:15 +01:00
. collect ( )
2024-12-11 10:42:44 +01:00
}
#[ cfg(test) ]
mod test {
2024-12-12 11:59:00 +01:00
use crate ::parse_server_configuration ;
use crate ::server ::Server ;
2024-12-11 10:42:44 +01:00
use std ::path ::PathBuf ;
#[ test ]
fn test_parse_server_configuration ( ) {
2024-12-11 14:13:32 +01:00
let servers =
parse_server_configuration ( " foo:bar,fizz:buzz/bizz " ) . expect ( " valid server configuration " ) ;
assert_eq! (
vec! [
Server {
ssh_name : " foo " . to_string ( ) ,
server_directory_path : PathBuf ::from ( " bar " ) ,
} ,
Server {
ssh_name : " fizz " . to_string ( ) ,
server_directory_path : PathBuf ::from ( " buzz/bizz " ) ,
}
] ,
servers
) ;
2024-12-11 10:42:44 +01:00
}
2024-12-13 12:50:05 +01:00
#[ test ]
fn path_experiment ( ) {
let server_dir = PathBuf ::from ( " steptech " ) ;
let upload_dir = PathBuf ::from ( " /home " ) ; //absolute path
let joined = server_dir . join ( upload_dir ) ;
assert_eq! ( PathBuf ::from ( " /home " ) , joined ) ;
}
2024-12-11 10:42:44 +01:00
}