diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7b994d1b..1f57b42a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -212,6 +212,78 @@ async fn main() -> Result<()> { - Subcommand pattern adds complexity but improves UX - Modular structure increases file count but improves testability +#### pdsh Compatibility Mode (Issue #97) + +bssh supports pdsh compatibility mode, allowing it to act as a drop-in replacement for pdsh. This enables migration from pdsh without modifying existing scripts. + +**Module Structure:** +- `cli/mod.rs` - CLI module exports and pdsh re-exports +- `cli/bssh.rs` - Standard bssh CLI parser +- `cli/pdsh.rs` - pdsh-compatible CLI parser and conversion logic +- `cli/mode_detection_tests.rs` - Tests for mode detection + +**Activation Methods:** + +1. **Binary name detection**: When bssh is invoked as "pdsh" (via symlink) + ```bash + ln -s /usr/bin/bssh /usr/local/bin/pdsh + pdsh -w hosts "uptime" # Uses pdsh compat mode + ``` + +2. **Environment variable**: `BSSH_PDSH_COMPAT=1` or `BSSH_PDSH_COMPAT=true` + ```bash + BSSH_PDSH_COMPAT=1 bssh -w hosts "uptime" + ``` + +3. **CLI flag**: `--pdsh-compat` + ```bash + bssh --pdsh-compat -w hosts "uptime" + ``` + +**Option Mapping:** + +| pdsh option | bssh option | Description | +|-------------|-------------|-------------| +| `-w hosts` | `-H hosts` | Target hosts (comma-separated) | +| `-x hosts` | `--exclude hosts` | Exclude hosts from target list | +| `-f N` | `--parallel N` | Fanout (parallel connections) | +| `-l user` | `-l user` | Remote username | +| `-t N` | `--connect-timeout N` | Connection timeout (seconds) | +| `-u N` | `--timeout N` | Command timeout (seconds) | +| `-N` | `--no-prefix` | Disable hostname prefix in output | +| `-b` | `--batch` | Batch mode (single Ctrl+C terminates) | +| `-k` | `--fail-fast` | Stop on first failure | +| `-q` | (query mode) | Show hosts and exit | +| `-S` | `--any-failure` | Return largest exit code from any node | + +**Implementation Details:** + +```rust +// Mode detection in main.rs +let pdsh_mode = is_pdsh_compat_mode() || has_pdsh_compat_flag(&args); + +if pdsh_mode { + return run_pdsh_mode(&args).await; +} + +// pdsh CLI parsing and conversion +let pdsh_cli = PdshCli::parse_from(filtered_args.iter()); +let mut cli = pdsh_cli.to_bssh_cli(); +``` + +**Design Decisions:** + +1. **Separate parser**: pdsh CLI uses its own clap parser to avoid conflicts with bssh options +2. **Conversion method**: `to_bssh_cli()` converts pdsh options to bssh `Cli` struct +3. **Query mode**: pdsh `-q` shows target hosts without executing commands +4. **Default fanout**: pdsh default is 32, bssh default is 10 - pdsh mode uses 32 + +**Key Points:** +- Mode detection happens before any argument parsing +- pdsh and bssh modes are mutually exclusive +- Unknown pdsh options produce helpful error messages +- Normal bssh operation is completely unaffected by pdsh compat code + ### 2. Configuration Management (`config/*`) **Module Structure (Refactored 2025-10-17):** diff --git a/README.md b/README.md index 5f4e45cb..4c281e60 100644 --- a/README.md +++ b/README.md @@ -364,6 +364,90 @@ bssh -C production -b "long-running-command" bssh -H nodes --batch --stream "deployment-script.sh" ``` +### pdsh Compatibility Mode + +bssh supports pdsh compatibility mode, enabling it to act as a drop-in replacement for pdsh. This allows seamless migration from pdsh without modifying existing scripts. + +#### Activation Methods + +**1. Binary symlink** (recommended for full compatibility): +```bash +# Create symlink +sudo ln -s /usr/bin/bssh /usr/local/bin/pdsh + +# Now pdsh commands use bssh +pdsh -w host1,host2 "uptime" +``` + +**2. Environment variable**: +```bash +BSSH_PDSH_COMPAT=1 bssh -w host1,host2 "uptime" +``` + +**3. CLI flag**: +```bash +bssh --pdsh-compat -w host1,host2 "uptime" +``` + +#### pdsh Option Mapping + +| pdsh option | bssh equivalent | Description | +|-------------|-----------------|-------------| +| `-w hosts` | `-H hosts` | Target hosts (comma-separated) | +| `-x hosts` | `--exclude hosts` | Exclude hosts from target list | +| `-f N` | `--parallel N` | Fanout (parallel connections, default: 32) | +| `-l user` | `-l user` | Remote username | +| `-t N` | `--connect-timeout N` | Connection timeout in seconds | +| `-u N` | `--timeout N` | Command timeout in seconds | +| `-N` | `--no-prefix` | Disable hostname prefix in output | +| `-b` | `--batch` | Batch mode (single Ctrl+C terminates) | +| `-k` | `--fail-fast` | Stop on first failure | +| `-q` | (query mode) | Show target hosts and exit | +| `-S` | `--any-failure` | Return largest exit code from any node | + +#### pdsh Mode Examples + +```bash +# Basic command execution +pdsh -w node1,node2,node3 "uptime" + +# With fanout limit +pdsh -w nodes -f 10 "df -h" + +# Exclude specific hosts +pdsh -w node[1-5] -x node3 "hostname" + +# Query mode: show target hosts without executing +pdsh -w host1,host2,host3 -x host2 -q +# Output: +# host1 +# host3 + +# Combine multiple options +pdsh -w servers -f 20 -l admin -t 30 -N "systemctl status nginx" + +# Fail fast mode +pdsh -w nodes -k "critical-operation.sh" +``` + +#### Query Mode with Glob Patterns + +Query mode (`-q`) supports glob pattern matching for exclusions: + +```bash +# Exclude hosts matching a pattern +pdsh -w web1,web2,db1,db2 -x "db*" -q +# Output: +# web1 +# web2 + +# Use wildcards in exclusion +pdsh -w node1,node2,backup1,backup2 -x "*backup*" -q +# Output: +# node1 +# node2 +``` + ### Built-in Commands ```bash # Test connectivity to hosts diff --git a/docs/man/bssh.1 b/docs/man/bssh.1 index 84e3b38f..7ff870c5 100644 --- a/docs/man/bssh.1 +++ b/docs/man/bssh.1 @@ -1000,6 +1000,82 @@ Host legacy.example.com RequiredRSASize 1024 .fi +.SH PDSH COMPATIBILITY MODE +.B bssh +supports pdsh compatibility mode, enabling it to act as a drop-in replacement for pdsh. +This allows seamless migration from pdsh without modifying existing scripts. + +.SS Activation Methods + +.TP +.B Binary symlink (recommended) +Create a symlink to use bssh as pdsh: +.nf + sudo ln -s /usr/bin/bssh /usr/local/bin/pdsh + pdsh -w host1,host2 "uptime" +.fi + +.TP +.B Environment variable +Set BSSH_PDSH_COMPAT to enable pdsh mode: +.nf + BSSH_PDSH_COMPAT=1 bssh -w host1,host2 "uptime" +.fi + +.TP +.B CLI flag +Use --pdsh-compat flag: +.nf + bssh --pdsh-compat -w host1,host2 "uptime" +.fi + +.SS pdsh Option Mapping + +.TS +l l l. +pdsh option bssh equivalent Description +_ +-w hosts -H hosts Target hosts +-x hosts --exclude hosts Exclude hosts +-f N --parallel N Fanout (default: 32) +-l user -l user Remote username +-t N --connect-timeout N Connection timeout +-u N --timeout N Command timeout +-N --no-prefix No hostname prefix +-b --batch Batch mode +-k --fail-fast Stop on first failure +-q (query mode) Show hosts and exit +-S --any-failure Return largest exit code +.TE + +.SS pdsh Mode Examples + +.TP +Basic command execution: +.B pdsh -w node1,node2,node3 "uptime" + +.TP +With fanout limit: +.B pdsh -w nodes -f 10 "df -h" + +.TP +Exclude specific hosts: +.B pdsh -w node1,node2,node3 -x node2 "hostname" + +.TP +Query mode (show hosts without executing): +.B pdsh -w host1,host2,host3 -x host2 -q +.RS +Shows only host1 and host3 +.RE + +.TP +Query mode with glob patterns: +.B pdsh -w web1,web2,db1,db2 -x "db*" -q +.RS +Shows web1 and web2 (db* pattern excludes db1 and db2) +.RE + .SH BACKEND.AI INTEGRATION When running inside a Backend.AI multi-node session, bssh automatically detects cluster configuration from environment variables: diff --git a/src/cli.rs b/src/cli/bssh.rs similarity index 97% rename from src/cli.rs rename to src/cli/bssh.rs index 03f57292..129a86ee 100644 --- a/src/cli.rs +++ b/src/cli/bssh.rs @@ -203,6 +203,18 @@ pub struct Cli { )] pub fail_fast: bool, + #[arg( + long = "any-failure", + help = "Return largest exit code from any node (pdsh -S compatible)\nWhen enabled, returns the maximum exit code from all nodes\nUseful for build/test pipelines where any failure should be reported" + )] + pub any_failure: bool, + + #[arg( + long = "pdsh-compat", + help = "Enable pdsh compatibility mode\nAccepts pdsh-style command line arguments (-w, -x, -f, etc.)\nUseful when migrating from pdsh or in mixed environments" + )] + pub pdsh_compat: bool, + #[arg( trailing_var_arg = true, help = "Command to execute on remote hosts", @@ -412,11 +424,14 @@ pub enum Commands { impl Cli { pub fn get_command(&self) -> String { // In multi-server mode with destination, treat destination as first command arg - if self.is_multi_server_mode() && self.destination.is_some() { - let mut all_args = vec![self.destination.as_ref().unwrap().clone()]; - all_args.extend(self.command_args.clone()); - all_args.join(" ") - } else if !self.command_args.is_empty() { + if self.is_multi_server_mode() { + if let Some(dest) = &self.destination { + let mut all_args = vec![dest.clone()]; + all_args.extend(self.command_args.clone()); + return all_args.join(" "); + } + } + if !self.command_args.is_empty() { self.command_args.join(" ") } else { String::new() diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 00000000..70e15ba1 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,49 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! CLI module for bssh +//! +//! This module provides command-line interface parsing for bssh, including: +//! - Standard bssh CLI (`Cli`) +//! - pdsh compatibility layer (`pdsh` submodule) +//! +//! # Architecture +//! +//! The CLI module is structured as follows: +//! - `bssh.rs` - Main bssh CLI parser with all standard options +//! - `pdsh.rs` - pdsh-compatible CLI parser for drop-in replacement mode +//! +//! # pdsh Compatibility Mode +//! +//! bssh can operate in pdsh compatibility mode, activated by: +//! 1. Setting `BSSH_PDSH_COMPAT=1` environment variable +//! 2. Symlinking bssh to "pdsh" and invoking via that name +//! 3. Using the `--pdsh-compat` flag +//! +//! See the `pdsh` module documentation for details on option mapping. + +mod bssh; +pub mod pdsh; + +#[cfg(test)] +mod mode_detection_tests; + +// Re-export main CLI types from bssh module +pub use bssh::{Cli, Commands}; + +// Re-export pdsh compatibility utilities +pub use pdsh::{ + has_pdsh_compat_flag, is_pdsh_compat_mode, remove_pdsh_compat_flag, PdshCli, QueryResult, + PDSH_COMPAT_ENV_VAR, +}; diff --git a/src/cli/mode_detection_tests.rs b/src/cli/mode_detection_tests.rs new file mode 100644 index 00000000..8e5f18f5 --- /dev/null +++ b/src/cli/mode_detection_tests.rs @@ -0,0 +1,243 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Tests for pdsh compatibility mode detection +//! +//! These tests verify that pdsh compatibility mode is correctly detected +//! based on environment variables and binary names. + +#[cfg(test)] +mod tests { + use crate::cli::pdsh::PDSH_COMPAT_ENV_VAR; + use std::env; + + /// Test that environment variable detection works for "1" + #[test] + fn test_env_var_detection_one() { + // Save and restore env var state + let original = env::var(PDSH_COMPAT_ENV_VAR).ok(); + + env::set_var(PDSH_COMPAT_ENV_VAR, "1"); + + // Create a test for the env var checking logic + let value = env::var(PDSH_COMPAT_ENV_VAR).ok(); + assert!(value.is_some()); + let value = value.unwrap(); + assert!(value == "1" || value.to_lowercase() == "true"); + + // Restore + match original { + Some(v) => env::set_var(PDSH_COMPAT_ENV_VAR, v), + None => env::remove_var(PDSH_COMPAT_ENV_VAR), + } + } + + /// Test that environment variable detection works for "true" + #[test] + fn test_env_var_detection_true() { + let original = env::var(PDSH_COMPAT_ENV_VAR).ok(); + + env::set_var(PDSH_COMPAT_ENV_VAR, "true"); + + let value = env::var(PDSH_COMPAT_ENV_VAR).ok(); + assert!(value.is_some()); + assert_eq!(value.unwrap().to_lowercase(), "true"); + + match original { + Some(v) => env::set_var(PDSH_COMPAT_ENV_VAR, v), + None => env::remove_var(PDSH_COMPAT_ENV_VAR), + } + } + + /// Test that environment variable detection works for "TRUE" (case insensitive) + #[test] + fn test_env_var_detection_case_insensitive() { + // Test the case-insensitivity logic directly without relying on env var state + // This avoids race conditions with other tests + let test_values = ["TRUE", "True", "true", "TrUe"]; + + for test_val in test_values { + // The detection logic: value == "1" || value.to_lowercase() == "true" + let is_enabled = test_val == "1" || test_val.to_lowercase() == "true"; + assert!( + is_enabled, + "Expected '{test_val}' to be detected as enabled" + ); + } + } + + /// Test that environment variable is not detected when unset + #[test] + fn test_env_var_not_set() { + let original = env::var(PDSH_COMPAT_ENV_VAR).ok(); + + env::remove_var(PDSH_COMPAT_ENV_VAR); + + let value = env::var(PDSH_COMPAT_ENV_VAR).ok(); + assert!(value.is_none()); + + // Restore + if let Some(v) = original { + env::set_var(PDSH_COMPAT_ENV_VAR, v); + } + } + + /// Test that invalid env var values are not treated as enabled + #[test] + fn test_env_var_invalid_values() { + let original = env::var(PDSH_COMPAT_ENV_VAR).ok(); + + // Test "0" + env::set_var(PDSH_COMPAT_ENV_VAR, "0"); + let value = env::var(PDSH_COMPAT_ENV_VAR).unwrap(); + let enabled = value == "1" || value.to_lowercase() == "true"; + assert!(!enabled); + + // Test "false" + env::set_var(PDSH_COMPAT_ENV_VAR, "false"); + let value = env::var(PDSH_COMPAT_ENV_VAR).unwrap(); + let enabled = value == "1" || value.to_lowercase() == "true"; + assert!(!enabled); + + // Test empty string + env::set_var(PDSH_COMPAT_ENV_VAR, ""); + let value = env::var(PDSH_COMPAT_ENV_VAR).unwrap(); + let enabled = value == "1" || value.to_lowercase() == "true"; + assert!(!enabled); + + // Restore + match original { + Some(v) => env::set_var(PDSH_COMPAT_ENV_VAR, v), + None => env::remove_var(PDSH_COMPAT_ENV_VAR), + } + } + + /// Test binary name detection logic for "pdsh" + #[test] + fn test_binary_name_pdsh() { + use std::path::Path; + + let arg0 = "/usr/bin/pdsh"; + let binary_name = Path::new(arg0) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + assert_eq!(binary_name, "pdsh"); + assert!(binary_name == "pdsh" || binary_name.starts_with("pdsh.")); + } + + /// Test binary name detection for relative path + #[test] + fn test_binary_name_relative_path() { + use std::path::Path; + + let arg0 = "./pdsh"; + let binary_name = Path::new(arg0) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + assert_eq!(binary_name, "pdsh"); + } + + /// Test binary name detection for "pdsh.exe" (Windows) + #[test] + #[cfg(windows)] + fn test_binary_name_windows() { + use std::path::Path; + + let arg0 = "C:\\Program Files\\bssh\\pdsh.exe"; + let binary_name = Path::new(arg0) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + assert!(binary_name.starts_with("pdsh.")); + } + + /// Test binary name detection for "pdsh.exe" pattern + #[test] + fn test_binary_name_exe_extension() { + use std::path::Path; + + // Test just the filename (works cross-platform) + let arg0 = "pdsh.exe"; + let binary_name = Path::new(arg0) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + assert!(binary_name.starts_with("pdsh.")); + } + + /// Test that bssh binary name is not detected as pdsh + #[test] + fn test_binary_name_bssh() { + use std::path::Path; + + let arg0 = "/usr/bin/bssh"; + let binary_name = Path::new(arg0) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + assert_eq!(binary_name, "bssh"); + assert!(!(binary_name == "pdsh" || binary_name.starts_with("pdsh."))); + } + + /// Test that symlinked pdsh is detected + #[test] + fn test_binary_name_symlink() { + use std::path::Path; + + // When bssh is symlinked as pdsh, arg0 would be the symlink name + let arg0 = "/usr/local/bin/pdsh"; + let binary_name = Path::new(arg0) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + assert_eq!(binary_name, "pdsh"); + } + + /// Test edge case: empty arg0 + #[test] + fn test_binary_name_empty() { + use std::path::Path; + + let arg0 = ""; + let binary_name = Path::new(arg0) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + assert!(binary_name.is_empty()); + assert!(!(binary_name == "pdsh" || binary_name.starts_with("pdsh."))); + } + + /// Test edge case: just filename without path + #[test] + fn test_binary_name_no_path() { + use std::path::Path; + + let arg0 = "pdsh"; + let binary_name = Path::new(arg0) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + assert_eq!(binary_name, "pdsh"); + } +} diff --git a/src/cli/pdsh.rs b/src/cli/pdsh.rs new file mode 100644 index 00000000..3f9972e1 --- /dev/null +++ b/src/cli/pdsh.rs @@ -0,0 +1,577 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! pdsh compatibility layer for bssh +//! +//! This module provides pdsh-compatible CLI parsing and option mapping, +//! enabling bssh to act as a drop-in replacement for pdsh. +//! +//! # Usage +//! +//! pdsh compatibility mode is activated in three ways: +//! 1. Binary name detection: When bssh is invoked as "pdsh" (via symlink) +//! 2. Environment variable: `BSSH_PDSH_COMPAT=1` or `BSSH_PDSH_COMPAT=true` +//! 3. CLI flag: `bssh --pdsh-compat ...` +//! +//! # Option Mapping +//! +//! | pdsh option | bssh option | Notes | +//! |-------------|-------------|-------| +//! | `-w hosts` | `-H hosts` | Direct mapping | +//! | `-x hosts` | `--exclude hosts` | Direct mapping | +//! | `-f N` | `--parallel N` | Fanout to parallel | +//! | `-l user` | `-l user` | Same option | +//! | `-t N` | `--connect-timeout N` | Connection timeout | +//! | `-u N` | `--timeout N` | Command timeout | +//! | `-N` | `--no-prefix` | Direct mapping | +//! | `-b` | `--batch` | Direct mapping | +//! | `-k` | `--fail-fast` | Direct mapping | +//! | `-q` | (query mode) | Show hosts and exit | +//! | `-S` | `--any-failure` | Return largest exit code | + +use clap::Parser; +use std::path::Path; + +/// pdsh-compatible CLI parser +/// +/// This struct captures pdsh command-line arguments and can be converted +/// to the standard bssh `Cli` structure. +#[derive(Parser, Debug, Clone)] +#[command( + name = "pdsh", + version, + about = "Parallel distributed shell (bssh compatibility mode)", + long_about = "bssh running in pdsh compatibility mode.\n\n\ + This allows bssh to accept pdsh-style command line arguments.\n\ + All pdsh options are mapped to their bssh equivalents.", + after_help = "EXAMPLES:\n \ + pdsh -w host1,host2 \"uptime\" # Execute on hosts\n \ + pdsh -w host[1-3] -f 10 \"df -h\" # Fanout of 10\n \ + pdsh -w nodes -x badnode \"cmd\" # Exclude host\n \ + pdsh -w hosts -N \"hostname\" # No hostname prefix\n \ + pdsh -w hosts -q # Query mode (show hosts)\n \ + pdsh -w hosts -l admin \"cmd\" # Specify user\n\n\ + Note: This is bssh running in pdsh compatibility mode.\n\ + For full bssh features, run 'bssh --help'." +)] +pub struct PdshCli { + /// Target hosts (comma-separated or host[range] notation) + /// + /// Accepts comma-separated hostnames or pdsh-style ranges like host[1-5]. + #[arg(short = 'w', help = "Target hosts (comma-separated or range notation)")] + pub hosts: Option, + + /// Exclude hosts from target list (comma-separated) + #[arg(short = 'x', help = "Exclude hosts from target list")] + pub exclude: Option, + + /// Fanout (number of parallel connections) + /// + /// Sets the maximum number of concurrent SSH connections. + /// Default is 32 to match pdsh default. + #[arg( + short = 'f', + default_value = "32", + help = "Fanout (parallel connections)" + )] + pub fanout: usize, + + /// Remote username + #[arg(short = 'l', help = "Remote username")] + pub user: Option, + + /// Connect timeout in seconds + #[arg(short = 't', help = "Connect timeout (seconds)")] + pub connect_timeout: Option, + + /// Command timeout in seconds + #[arg(short = 'u', help = "Command timeout (seconds)")] + pub command_timeout: Option, + + /// Disable hostname prefix in output + #[arg(short = 'N', help = "Disable hostname prefix")] + pub no_prefix: bool, + + /// Batch mode (single Ctrl+C terminates) + #[arg(short = 'b', help = "Batch mode")] + pub batch: bool, + + /// Fail fast (stop on first failure) + /// + /// When enabled, cancels remaining commands if any host fails. + #[arg(short = 'k', help = "Fail fast (stop on first failure)")] + pub fail_fast: bool, + + /// Query mode - show target hosts and exit + /// + /// Lists all hosts that would be targeted without executing any command. + #[arg(short = 'q', help = "Query mode (show hosts and exit)")] + pub query: bool, + + /// Return exit status of any failing node + /// + /// When enabled, returns the largest exit code from any node. + #[arg(short = 'S', help = "Return largest exit code from any node")] + pub any_failure: bool, + + /// Command to execute (trailing arguments) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + pub command: Vec, +} + +/// Environment variable name for pdsh compatibility mode +pub const PDSH_COMPAT_ENV_VAR: &str = "BSSH_PDSH_COMPAT"; + +/// Checks if pdsh compatibility mode should be enabled. +/// +/// Returns `true` if any of the following conditions are met: +/// 1. The `BSSH_PDSH_COMPAT` environment variable is set to "1" or "true" +/// 2. The binary name (argv[0]) is "pdsh" or starts with "pdsh." +/// +/// # Examples +/// +/// ``` +/// use std::env; +/// +/// // When environment variable is set +/// env::set_var("BSSH_PDSH_COMPAT", "1"); +/// // is_pdsh_compat_mode() would return true +/// ``` +pub fn is_pdsh_compat_mode() -> bool { + // Check environment variable first + if let Ok(value) = std::env::var(PDSH_COMPAT_ENV_VAR) { + let value_lower = value.to_lowercase(); + if value_lower == "1" || value_lower == "true" { + return true; + } + } + + // Check argv[0] for "pdsh" binary name + if let Some(arg0) = std::env::args().next() { + let binary_name = Path::new(&arg0) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + // Match exact "pdsh" or "pdsh.exe" (Windows) or "pdsh.*" patterns + if binary_name == "pdsh" || binary_name.starts_with("pdsh.") { + return true; + } + } + + false +} + +/// Checks if pdsh compatibility mode should be enabled based on arguments. +/// +/// This function checks for the `--pdsh-compat` flag in the provided arguments. +/// Unlike `is_pdsh_compat_mode()`, this checks explicit CLI flag rather than +/// environment or binary name. +/// +/// # Arguments +/// +/// * `args` - Command line arguments to check +/// +/// # Returns +/// +/// `true` if `--pdsh-compat` flag is present in the arguments +pub fn has_pdsh_compat_flag(args: &[String]) -> bool { + args.iter().any(|arg| arg == "--pdsh-compat") +} + +/// Removes the `--pdsh-compat` flag from arguments. +/// +/// When bssh is invoked with `--pdsh-compat`, we need to remove this flag +/// before parsing with the pdsh CLI parser (which doesn't know this flag). +/// +/// # Arguments +/// +/// * `args` - Original command line arguments +/// +/// # Returns +/// +/// Arguments with `--pdsh-compat` flag removed +pub fn remove_pdsh_compat_flag(args: &[String]) -> Vec { + args.iter() + .filter(|arg| *arg != "--pdsh-compat") + .cloned() + .collect() +} + +impl PdshCli { + /// Parse pdsh-style arguments from command line. + /// + /// # Returns + /// + /// Parsed `PdshCli` instance + pub fn parse_args() -> Self { + PdshCli::parse() + } + + /// Parse pdsh-style arguments from a specific argument list. + /// + /// # Arguments + /// + /// * `args` - Iterator over command-line arguments + /// + /// # Returns + /// + /// Parsed `PdshCli` instance + pub fn parse_from_args(args: I) -> Self + where + I: IntoIterator, + T: Into + Clone, + { + PdshCli::parse_from(args) + } + + /// Returns whether this is a query-only request (show hosts and exit). + pub fn is_query_mode(&self) -> bool { + self.query + } + + /// Returns whether a command was specified. + pub fn has_command(&self) -> bool { + !self.command.is_empty() + } + + /// Gets the command as a single string. + pub fn get_command(&self) -> String { + self.command.join(" ") + } + + /// Converts pdsh CLI options to bssh CLI options. + /// + /// This method creates a bssh `Cli` instance with all options mapped + /// from their pdsh equivalents. + /// + /// # Returns + /// + /// A `Cli` instance with options mapped from pdsh arguments + pub fn to_bssh_cli(&self) -> super::Cli { + use std::path::PathBuf; + + super::Cli { + // Map -w hosts to -H hosts + hosts: self.hosts.as_ref().map(|h| { + h.split(',') + .map(|s| s.trim().to_string()) + .collect::>() + }), + // Map -x exclude to --exclude + exclude: self.exclude.as_ref().map(|x| { + x.split(',') + .map(|s| s.trim().to_string()) + .collect::>() + }), + // Map -f fanout to --parallel + parallel: self.fanout, + // Map -l user to -l/--login + user: self.user.clone(), + // Map -t to --connect-timeout + connect_timeout: self.connect_timeout.unwrap_or(30), + // Map -u to --timeout + timeout: self.command_timeout.unwrap_or(300), + // Map -N to --no-prefix + no_prefix: self.no_prefix, + // Map -b to --batch + batch: self.batch, + // Map -k to --fail-fast + fail_fast: self.fail_fast, + // Map -S to --any-failure + any_failure: self.any_failure, + // Map command + command_args: self.command.clone(), + // Set pdsh_compat flag + pdsh_compat: true, + // Default values for remaining fields + destination: None, + command: None, + filter: None, + cluster: None, + config: PathBuf::from("~/.config/bssh/config.yaml"), + identity: None, + use_agent: false, + password: false, + sudo_password: false, + jump_hosts: None, + port: None, + stream: false, + output_dir: None, + verbose: 0, + strict_host_key_checking: "accept-new".to_string(), + require_all_success: false, + check_all_nodes: false, + ssh_options: Vec::new(), + ssh_config: None, + quiet: false, + force_tty: false, + no_tty: false, + no_x11: false, + ipv4: false, + ipv6: false, + query: None, + local_forwards: Vec::new(), + remote_forwards: Vec::new(), + dynamic_forwards: Vec::new(), + } + } +} + +/// Result type for pdsh query mode +#[derive(Debug)] +pub struct QueryResult { + /// List of hosts that would be targeted + pub hosts: Vec, +} + +impl QueryResult { + /// Display query results to stdout + pub fn display(&self) { + for host in &self.hosts { + println!("{host}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pdsh_cli_basic_parsing() { + let args = vec!["pdsh", "-w", "host1,host2", "uptime"]; + let cli = PdshCli::parse_from_args(args); + + assert_eq!(cli.hosts, Some("host1,host2".to_string())); + assert_eq!(cli.command, vec!["uptime"]); + assert_eq!(cli.fanout, 32); // default + assert!(!cli.no_prefix); + assert!(!cli.batch); + assert!(!cli.fail_fast); + assert!(!cli.query); + } + + #[test] + fn test_pdsh_cli_all_options() { + let args = vec![ + "pdsh", + "-w", + "host1,host2", + "-x", + "badhost", + "-f", + "10", + "-l", + "admin", + "-t", + "30", + "-u", + "300", + "-N", + "-b", + "-k", + "df", + "-h", + ]; + let cli = PdshCli::parse_from_args(args); + + assert_eq!(cli.hosts, Some("host1,host2".to_string())); + assert_eq!(cli.exclude, Some("badhost".to_string())); + assert_eq!(cli.fanout, 10); + assert_eq!(cli.user, Some("admin".to_string())); + assert_eq!(cli.connect_timeout, Some(30)); + assert_eq!(cli.command_timeout, Some(300)); + assert!(cli.no_prefix); + assert!(cli.batch); + assert!(cli.fail_fast); + assert_eq!(cli.command, vec!["df", "-h"]); + } + + #[test] + fn test_pdsh_cli_query_mode() { + let args = vec!["pdsh", "-w", "hosts", "-q"]; + let cli = PdshCli::parse_from_args(args); + + assert!(cli.is_query_mode()); + assert!(cli.command.is_empty()); + } + + #[test] + fn test_pdsh_cli_any_failure() { + let args = vec!["pdsh", "-w", "hosts", "-S", "cmd"]; + let cli = PdshCli::parse_from_args(args); + + assert!(cli.any_failure); + } + + #[test] + fn test_has_command() { + let args_with_cmd = vec!["pdsh", "-w", "hosts", "uptime"]; + let cli_with_cmd = PdshCli::parse_from_args(args_with_cmd); + assert!(cli_with_cmd.has_command()); + + let args_without_cmd = vec!["pdsh", "-w", "hosts", "-q"]; + let cli_without_cmd = PdshCli::parse_from_args(args_without_cmd); + assert!(!cli_without_cmd.has_command()); + } + + #[test] + fn test_get_command() { + let args = vec!["pdsh", "-w", "hosts", "echo", "hello", "world"]; + let cli = PdshCli::parse_from_args(args); + + assert_eq!(cli.get_command(), "echo hello world"); + } + + #[test] + fn test_remove_pdsh_compat_flag() { + let args = vec![ + "bssh".to_string(), + "--pdsh-compat".to_string(), + "-w".to_string(), + "hosts".to_string(), + "cmd".to_string(), + ]; + let filtered = remove_pdsh_compat_flag(&args); + + assert_eq!( + filtered, + vec![ + "bssh".to_string(), + "-w".to_string(), + "hosts".to_string(), + "cmd".to_string() + ] + ); + } + + #[test] + fn test_has_pdsh_compat_flag() { + let args_with = vec![ + "bssh".to_string(), + "--pdsh-compat".to_string(), + "-w".to_string(), + "hosts".to_string(), + ]; + assert!(has_pdsh_compat_flag(&args_with)); + + let args_without = vec!["bssh".to_string(), "-w".to_string(), "hosts".to_string()]; + assert!(!has_pdsh_compat_flag(&args_without)); + } + + #[test] + fn test_to_bssh_cli_basic() { + let args = vec!["pdsh", "-w", "host1,host2", "uptime"]; + let pdsh_cli = PdshCli::parse_from_args(args); + let bssh_cli = pdsh_cli.to_bssh_cli(); + + assert_eq!( + bssh_cli.hosts, + Some(vec!["host1".to_string(), "host2".to_string()]) + ); + assert_eq!(bssh_cli.command_args, vec!["uptime"]); + assert_eq!(bssh_cli.parallel, 32); // default fanout + assert!(bssh_cli.pdsh_compat); + } + + #[test] + fn test_to_bssh_cli_all_options() { + let args = vec![ + "pdsh", + "-w", + "host1,host2", + "-x", + "badhost", + "-f", + "10", + "-l", + "admin", + "-t", + "60", + "-u", + "600", + "-N", + "-b", + "-k", + "-S", + "df", + "-h", + ]; + let pdsh_cli = PdshCli::parse_from_args(args); + let bssh_cli = pdsh_cli.to_bssh_cli(); + + // Hosts mapping + assert_eq!( + bssh_cli.hosts, + Some(vec!["host1".to_string(), "host2".to_string()]) + ); + // Exclude mapping + assert_eq!(bssh_cli.exclude, Some(vec!["badhost".to_string()])); + // Fanout to parallel + assert_eq!(bssh_cli.parallel, 10); + // User mapping + assert_eq!(bssh_cli.user, Some("admin".to_string())); + // Connect timeout + assert_eq!(bssh_cli.connect_timeout, 60); + // Command timeout + assert_eq!(bssh_cli.timeout, 600); + // No prefix flag + assert!(bssh_cli.no_prefix); + // Batch flag + assert!(bssh_cli.batch); + // Fail fast flag + assert!(bssh_cli.fail_fast); + // Any failure flag + assert!(bssh_cli.any_failure); + // Command + assert_eq!(bssh_cli.command_args, vec!["df", "-h"]); + } + + #[test] + fn test_to_bssh_cli_defaults() { + let args = vec!["pdsh", "-w", "hosts", "cmd"]; + let pdsh_cli = PdshCli::parse_from_args(args); + let bssh_cli = pdsh_cli.to_bssh_cli(); + + // Default connect timeout (30s) + assert_eq!(bssh_cli.connect_timeout, 30); + // Default command timeout (300s) + assert_eq!(bssh_cli.timeout, 300); + // Default parallel (32 from pdsh fanout) + assert_eq!(bssh_cli.parallel, 32); + // Default strict host key checking + assert_eq!(bssh_cli.strict_host_key_checking, "accept-new"); + } + + #[test] + fn test_to_bssh_cli_host_splitting() { + let args = vec!["pdsh", "-w", "host1, host2 , host3", "cmd"]; + let pdsh_cli = PdshCli::parse_from_args(args); + let bssh_cli = pdsh_cli.to_bssh_cli(); + + // Should trim whitespace + assert_eq!( + bssh_cli.hosts, + Some(vec![ + "host1".to_string(), + "host2".to_string(), + "host3".to_string() + ]) + ); + } + + // Note: is_pdsh_compat_mode() tests are in mode_detection_tests.rs + // since they require environment manipulation that can interfere with other tests +} diff --git a/src/executor/rank_detector.rs b/src/executor/rank_detector.rs index cfc52a6c..974e37bb 100644 --- a/src/executor/rank_detector.rs +++ b/src/executor/rank_detector.rs @@ -110,7 +110,12 @@ mod tests { } #[test] + #[serial] fn test_fallback_to_first_node() { + // Clear Backend.AI env vars to test fallback behavior + env::remove_var("BACKENDAI_CLUSTER_ROLE"); + env::remove_var("BACKENDAI_CLUSTER_HOST"); + let nodes = vec![ Node::new("host1".to_string(), 22, "user".to_string()), Node::new("host2".to_string(), 22, "user".to_string()), diff --git a/src/main.rs b/src/main.rs index 6afe60c2..4faab113 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,8 +13,11 @@ // limitations under the License. use anyhow::Result; -use bssh::cli::{Cli, Commands}; +use bssh::cli::{ + has_pdsh_compat_flag, is_pdsh_compat_mode, remove_pdsh_compat_flag, Cli, Commands, PdshCli, +}; use clap::Parser; +use glob::Pattern; mod app; @@ -23,10 +26,155 @@ use app::{ query::handle_query, utils::show_usage, }; +/// Main entry point for bssh +/// +/// Supports three modes of operation: +/// 1. Standard bssh CLI mode +/// 2. pdsh compatibility mode (via symlink, env var, or --pdsh-compat flag) +/// 3. SSH compatibility mode (single host) #[tokio::main] async fn main() -> Result<()> { - // Check if no arguments were provided let args: Vec = std::env::args().collect(); + + // Check for pdsh compatibility mode + // Priority: env var / binary name > --pdsh-compat flag + let pdsh_mode = is_pdsh_compat_mode() || has_pdsh_compat_flag(&args); + + if pdsh_mode { + return run_pdsh_mode(&args).await; + } + + // Standard bssh mode + run_bssh_mode(&args).await +} + +/// Run in pdsh compatibility mode +/// +/// Parses pdsh-style arguments and converts them to bssh CLI options. +async fn run_pdsh_mode(args: &[String]) -> Result<()> { + // Remove --pdsh-compat flag if present (pdsh parser doesn't know it) + let filtered_args = if has_pdsh_compat_flag(args) { + remove_pdsh_compat_flag(args) + } else { + args.to_vec() + }; + + // Parse pdsh-style arguments + let pdsh_cli = PdshCli::parse_from(filtered_args.iter()); + + // Handle query mode (-q): show hosts and exit + if pdsh_cli.is_query_mode() { + return handle_pdsh_query_mode(&pdsh_cli).await; + } + + // Convert to bssh CLI + let mut cli = pdsh_cli.to_bssh_cli(); + + // Check if we have hosts + if cli.hosts.is_none() { + eprintln!("Error: No hosts specified. Use -w to specify target hosts."); + eprintln!("Usage: pdsh -w hosts command"); + std::process::exit(1); + } + + // Check if we have a command (unless in query mode) + if cli.command_args.is_empty() { + eprintln!("Error: No command specified."); + eprintln!("Usage: pdsh -w hosts command"); + std::process::exit(1); + } + + // Initialize and run + let ctx = initialize_app(&mut cli, args).await?; + dispatch_command(&cli, &ctx).await +} + +/// Handle pdsh query mode (-q) +/// +/// Shows the list of hosts that would be targeted and exits. +/// Uses the same glob pattern matching as the standard --exclude option +/// for consistency. +async fn handle_pdsh_query_mode(pdsh_cli: &PdshCli) -> Result<()> { + if let Some(ref hosts_str) = pdsh_cli.hosts { + let hosts: Vec<&str> = hosts_str.split(',').map(|s| s.trim()).collect(); + + // Compile exclusion patterns (same logic as app/nodes.rs exclude_nodes) + let exclusion_patterns: Vec = if let Some(ref exclude_str) = pdsh_cli.exclude { + let patterns: Vec<&str> = exclude_str.split(',').map(|s| s.trim()).collect(); + let mut compiled = Vec::with_capacity(patterns.len()); + for pattern in patterns { + // Security: Validate pattern length + const MAX_PATTERN_LENGTH: usize = 256; + if pattern.len() > MAX_PATTERN_LENGTH { + anyhow::bail!( + "Exclusion pattern too long (max {MAX_PATTERN_LENGTH} characters)" + ); + } + + // Security: Skip empty patterns + if pattern.is_empty() { + continue; + } + + // Security: Prevent excessive wildcards + let wildcard_count = pattern.chars().filter(|c| *c == '*' || *c == '?').count(); + const MAX_WILDCARDS: usize = 10; + if wildcard_count > MAX_WILDCARDS { + anyhow::bail!( + "Exclusion pattern contains too many wildcards (max {MAX_WILDCARDS})" + ); + } + + // Compile the glob pattern + match Pattern::new(pattern) { + Ok(p) => compiled.push(p), + Err(_) => { + anyhow::bail!("Invalid exclusion pattern: {pattern}"); + } + } + } + compiled + } else { + Vec::new() + }; + + // Filter and display hosts + for host in hosts { + // Check if host matches any exclusion pattern + let is_excluded = if exclusion_patterns.is_empty() { + false + } else { + exclusion_patterns.iter().any(|pattern| { + // For patterns without wildcards, also do exact/contains matching + // (consistent with exclude_nodes in app/nodes.rs) + let pattern_str = pattern.as_str(); + if !pattern_str.contains('*') + && !pattern_str.contains('?') + && !pattern_str.contains('[') + { + host == pattern_str || host.contains(pattern_str) + } else { + pattern.matches(host) + } + }) + }; + + if !is_excluded { + println!("{host}"); + } + } + } else { + eprintln!("Error: No hosts specified for query mode."); + eprintln!("Usage: pdsh -w hosts -q"); + std::process::exit(1); + } + + Ok(()) +} + +/// Run in standard bssh mode +async fn run_bssh_mode(args: &[String]) -> Result<()> { + // Check if no arguments were provided if args.len() == 1 { // Show concise usage when no arguments provided (like SSH) show_usage(); @@ -63,7 +211,7 @@ async fn main() -> Result<()> { } // Initialize the application and load all configurations - let ctx = initialize_app(&mut cli, &args).await?; + let ctx = initialize_app(&mut cli, args).await?; // Dispatch to the appropriate command handler dispatch_command(&cli, &ctx).await diff --git a/tests/pdsh_compat_test.rs b/tests/pdsh_compat_test.rs new file mode 100644 index 00000000..520f3cd7 --- /dev/null +++ b/tests/pdsh_compat_test.rs @@ -0,0 +1,435 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Integration tests for pdsh compatibility mode +//! +//! These tests verify that bssh correctly handles pdsh-style arguments +//! and behaves as expected in pdsh compatibility mode. + +use bssh::cli::{has_pdsh_compat_flag, remove_pdsh_compat_flag, PdshCli, PDSH_COMPAT_ENV_VAR}; +use std::env; + +/// Helper to run a test with env var protection +fn with_env_var(key: &str, value: &str, f: F) -> T +where + F: FnOnce() -> T, +{ + let original = env::var(key).ok(); + env::set_var(key, value); + let result = f(); + match original { + Some(v) => env::set_var(key, v), + None => env::remove_var(key), + } + result +} + +/// Helper to run a test with env var removed +fn without_env_var(key: &str, f: F) -> T +where + F: FnOnce() -> T, +{ + let original = env::var(key).ok(); + env::remove_var(key); + let result = f(); + if let Some(v) = original { + env::set_var(key, v); + } + result +} + +// ============================================================================= +// CLI Flag Detection Tests +// ============================================================================= + +#[test] +fn test_pdsh_compat_flag_detection() { + let args = vec![ + "bssh".to_string(), + "--pdsh-compat".to_string(), + "-w".to_string(), + "host1,host2".to_string(), + "uptime".to_string(), + ]; + + assert!(has_pdsh_compat_flag(&args)); +} + +#[test] +fn test_no_pdsh_compat_flag() { + let args = vec![ + "bssh".to_string(), + "-H".to_string(), + "host1,host2".to_string(), + "uptime".to_string(), + ]; + + assert!(!has_pdsh_compat_flag(&args)); +} + +#[test] +fn test_remove_pdsh_compat_flag_preserves_order() { + let args = vec![ + "bssh".to_string(), + "-w".to_string(), + "--pdsh-compat".to_string(), + "hosts".to_string(), + "cmd".to_string(), + ]; + + let filtered = remove_pdsh_compat_flag(&args); + + assert_eq!(filtered.len(), 4); + assert_eq!(filtered[0], "bssh"); + assert_eq!(filtered[1], "-w"); + assert_eq!(filtered[2], "hosts"); + assert_eq!(filtered[3], "cmd"); +} + +#[test] +fn test_remove_pdsh_compat_flag_no_flag_present() { + let args = vec![ + "bssh".to_string(), + "-w".to_string(), + "hosts".to_string(), + "cmd".to_string(), + ]; + + let filtered = remove_pdsh_compat_flag(&args); + + assert_eq!(filtered, args); +} + +// ============================================================================= +// Environment Variable Detection Tests +// ============================================================================= + +#[test] +fn test_env_var_detection_with_one() { + without_env_var(PDSH_COMPAT_ENV_VAR, || { + with_env_var(PDSH_COMPAT_ENV_VAR, "1", || { + // We can't call is_pdsh_compat_mode directly because it also checks argv[0] + // Instead, verify the env var logic works + let value = env::var(PDSH_COMPAT_ENV_VAR).ok(); + assert!(value.is_some()); + let v = value.unwrap(); + assert!(v == "1" || v.to_lowercase() == "true"); + }); + }); +} + +#[test] +fn test_env_var_detection_with_true() { + without_env_var(PDSH_COMPAT_ENV_VAR, || { + with_env_var(PDSH_COMPAT_ENV_VAR, "true", || { + let value = env::var(PDSH_COMPAT_ENV_VAR).ok(); + assert!(value.is_some()); + assert_eq!(value.unwrap().to_lowercase(), "true"); + }); + }); +} + +#[test] +fn test_env_var_detection_disabled_with_zero() { + without_env_var(PDSH_COMPAT_ENV_VAR, || { + with_env_var(PDSH_COMPAT_ENV_VAR, "0", || { + let value = env::var(PDSH_COMPAT_ENV_VAR).ok(); + assert!(value.is_some()); + let v = value.unwrap(); + // "0" should NOT be treated as enabled + assert!(!(v == "1" || v.to_lowercase() == "true")); + }); + }); +} + +#[test] +fn test_env_var_detection_disabled_with_false() { + without_env_var(PDSH_COMPAT_ENV_VAR, || { + with_env_var(PDSH_COMPAT_ENV_VAR, "false", || { + let value = env::var(PDSH_COMPAT_ENV_VAR).ok(); + assert!(value.is_some()); + let v = value.unwrap(); + // "false" should NOT be treated as enabled + assert!(!(v == "1" || v.to_lowercase() == "true")); + }); + }); +} + +// ============================================================================= +// pdsh CLI Parsing Tests +// ============================================================================= + +#[test] +fn test_pdsh_cli_basic_command() { + let args = vec!["pdsh", "-w", "host1,host2", "uptime"]; + let cli = PdshCli::parse_from_args(args); + + assert_eq!(cli.hosts, Some("host1,host2".to_string())); + assert_eq!(cli.command, vec!["uptime"]); + assert_eq!(cli.fanout, 32); // pdsh default +} + +#[test] +fn test_pdsh_cli_with_exclusions() { + let args = vec!["pdsh", "-w", "host1,host2,host3", "-x", "host2", "df", "-h"]; + let cli = PdshCli::parse_from_args(args); + + assert_eq!(cli.hosts, Some("host1,host2,host3".to_string())); + assert_eq!(cli.exclude, Some("host2".to_string())); + assert_eq!(cli.command, vec!["df", "-h"]); +} + +#[test] +fn test_pdsh_cli_query_mode() { + let args = vec!["pdsh", "-w", "host1,host2,host3", "-q"]; + let cli = PdshCli::parse_from_args(args); + + assert!(cli.is_query_mode()); + assert_eq!(cli.hosts, Some("host1,host2,host3".to_string())); + assert!(cli.command.is_empty()); +} + +#[test] +fn test_pdsh_cli_all_flags() { + let args = vec![ + "pdsh", "-w", "hosts", "-x", "exclude", "-f", "16", "-l", "admin", "-t", "60", "-u", "300", + "-N", "-b", "-k", "-S", "command", + ]; + let cli = PdshCli::parse_from_args(args); + + assert_eq!(cli.hosts, Some("hosts".to_string())); + assert_eq!(cli.exclude, Some("exclude".to_string())); + assert_eq!(cli.fanout, 16); + assert_eq!(cli.user, Some("admin".to_string())); + assert_eq!(cli.connect_timeout, Some(60)); + assert_eq!(cli.command_timeout, Some(300)); + assert!(cli.no_prefix); + assert!(cli.batch); + assert!(cli.fail_fast); + assert!(cli.any_failure); +} + +#[test] +fn test_pdsh_cli_command_with_flags() { + // Test that command arguments with hyphens are correctly captured + let args = vec!["pdsh", "-w", "hosts", "grep", "-r", "pattern", "/path"]; + let cli = PdshCli::parse_from_args(args); + + assert_eq!(cli.command, vec!["grep", "-r", "pattern", "/path"]); +} + +// ============================================================================= +// Option Conversion Tests +// ============================================================================= + +#[test] +fn test_pdsh_to_bssh_hosts_conversion() { + let args = vec!["pdsh", "-w", "host1, host2 , host3", "cmd"]; + let pdsh_cli = PdshCli::parse_from_args(args); + let bssh_cli = pdsh_cli.to_bssh_cli(); + + // Host strings should be split and trimmed + assert_eq!( + bssh_cli.hosts, + Some(vec![ + "host1".to_string(), + "host2".to_string(), + "host3".to_string() + ]) + ); +} + +#[test] +fn test_pdsh_to_bssh_exclude_conversion() { + let args = vec!["pdsh", "-w", "hosts", "-x", "bad1, bad2", "cmd"]; + let pdsh_cli = PdshCli::parse_from_args(args); + let bssh_cli = pdsh_cli.to_bssh_cli(); + + // Exclude strings should be split and trimmed + assert_eq!( + bssh_cli.exclude, + Some(vec!["bad1".to_string(), "bad2".to_string()]) + ); +} + +#[test] +fn test_pdsh_to_bssh_fanout_to_parallel() { + let args = vec!["pdsh", "-w", "hosts", "-f", "20", "cmd"]; + let pdsh_cli = PdshCli::parse_from_args(args); + let bssh_cli = pdsh_cli.to_bssh_cli(); + + assert_eq!(bssh_cli.parallel, 20); +} + +#[test] +fn test_pdsh_to_bssh_default_timeouts() { + let args = vec!["pdsh", "-w", "hosts", "cmd"]; + let pdsh_cli = PdshCli::parse_from_args(args); + let bssh_cli = pdsh_cli.to_bssh_cli(); + + // Default connect timeout is 30s + assert_eq!(bssh_cli.connect_timeout, 30); + // Default command timeout is 300s + assert_eq!(bssh_cli.timeout, 300); +} + +#[test] +fn test_pdsh_to_bssh_custom_timeouts() { + let args = vec!["pdsh", "-w", "hosts", "-t", "10", "-u", "600", "cmd"]; + let pdsh_cli = PdshCli::parse_from_args(args); + let bssh_cli = pdsh_cli.to_bssh_cli(); + + assert_eq!(bssh_cli.connect_timeout, 10); + assert_eq!(bssh_cli.timeout, 600); +} + +#[test] +fn test_pdsh_to_bssh_flags_conversion() { + let args = vec!["pdsh", "-w", "hosts", "-N", "-b", "-k", "-S", "cmd"]; + let pdsh_cli = PdshCli::parse_from_args(args); + let bssh_cli = pdsh_cli.to_bssh_cli(); + + assert!(bssh_cli.no_prefix); + assert!(bssh_cli.batch); + assert!(bssh_cli.fail_fast); + assert!(bssh_cli.any_failure); + assert!(bssh_cli.pdsh_compat); // pdsh_compat should be set +} + +#[test] +fn test_pdsh_to_bssh_user_conversion() { + let args = vec!["pdsh", "-w", "hosts", "-l", "testuser", "cmd"]; + let pdsh_cli = PdshCli::parse_from_args(args); + let bssh_cli = pdsh_cli.to_bssh_cli(); + + assert_eq!(bssh_cli.user, Some("testuser".to_string())); +} + +// ============================================================================= +// Query Mode Glob Pattern Tests +// ============================================================================= + +#[test] +fn test_pdsh_query_mode_detection() { + let args = vec!["pdsh", "-w", "host1,host2", "-q"]; + let cli = PdshCli::parse_from_args(args); + + assert!(cli.is_query_mode()); + assert!(!cli.has_command()); +} + +#[test] +fn test_pdsh_query_mode_with_exclusion() { + let args = vec!["pdsh", "-w", "host1,host2,host3", "-x", "host2", "-q"]; + let cli = PdshCli::parse_from_args(args); + + assert!(cli.is_query_mode()); + assert_eq!(cli.hosts, Some("host1,host2,host3".to_string())); + assert_eq!(cli.exclude, Some("host2".to_string())); +} + +#[test] +fn test_pdsh_query_mode_with_wildcard_exclusion() { + let args = vec!["pdsh", "-w", "web1,web2,db1,db2", "-x", "db*", "-q"]; + let cli = PdshCli::parse_from_args(args); + + assert!(cli.is_query_mode()); + assert_eq!(cli.exclude, Some("db*".to_string())); +} + +// ============================================================================= +// Helper Method Tests +// ============================================================================= + +#[test] +fn test_pdsh_get_command() { + let args = vec!["pdsh", "-w", "hosts", "echo", "hello", "world"]; + let cli = PdshCli::parse_from_args(args); + + assert_eq!(cli.get_command(), "echo hello world"); +} + +#[test] +fn test_pdsh_has_command_true() { + let args = vec!["pdsh", "-w", "hosts", "uptime"]; + let cli = PdshCli::parse_from_args(args); + + assert!(cli.has_command()); +} + +#[test] +fn test_pdsh_has_command_false() { + let args = vec!["pdsh", "-w", "hosts", "-q"]; + let cli = PdshCli::parse_from_args(args); + + assert!(!cli.has_command()); +} + +// ============================================================================= +// Edge Case Tests +// ============================================================================= + +#[test] +fn test_pdsh_cli_empty_hosts() { + // pdsh with no -w flag - should result in None + let args = vec!["pdsh", "uptime"]; + let cli = PdshCli::parse_from_args(args); + + assert!(cli.hosts.is_none()); + assert_eq!(cli.command, vec!["uptime"]); +} + +#[test] +fn test_pdsh_cli_whitespace_in_hosts() { + let args = vec!["pdsh", "-w", " host1 , host2 , host3 ", "cmd"]; + let pdsh_cli = PdshCli::parse_from_args(args); + let bssh_cli = pdsh_cli.to_bssh_cli(); + + // Whitespace should be trimmed + assert_eq!( + bssh_cli.hosts, + Some(vec![ + "host1".to_string(), + "host2".to_string(), + "host3".to_string() + ]) + ); +} + +#[test] +fn test_pdsh_cli_single_host() { + let args = vec!["pdsh", "-w", "single-host", "cmd"]; + let pdsh_cli = PdshCli::parse_from_args(args); + let bssh_cli = pdsh_cli.to_bssh_cli(); + + assert_eq!(bssh_cli.hosts, Some(vec!["single-host".to_string()])); +} + +#[test] +fn test_pdsh_cli_complex_command() { + let args = vec![ + "pdsh", + "-w", + "hosts", + "bash", + "-c", + "for i in 1 2 3; do echo $i; done", + ]; + let cli = PdshCli::parse_from_args(args); + + assert_eq!( + cli.command, + vec!["bash", "-c", "for i in 1 2 3; do echo $i; done"] + ); +}