Skip to content

Commit a3129be

Browse files
committed
feat: Implement pdsh compatibility layer core infrastructure (#97)
Add pdsh compatibility mode allowing bssh to act as a drop-in replacement for pdsh. This implements Phase 2 of the pdsh compatibility roadmap. Changes: - Create CLI module structure: cli/mod.rs, cli/bssh.rs, cli/pdsh.rs - Add is_pdsh_compat_mode() for binary name and env var detection - Add PdshCli struct with pdsh-compatible argument parsing - Implement to_bssh_cli() conversion method for option mapping - Add --pdsh-compat flag to bssh CLI - Add --any-failure flag for pdsh -S compatibility - Wire up mode detection in main.rs entry point - Add query mode (-q) support to show hosts and exit Option mapping: - -w hosts -> -H hosts - -x hosts -> --exclude hosts - -f N -> --parallel N - -l user -> -l user - -t N -> --connect-timeout N - -u N -> --timeout N - -N -> --no-prefix - -b -> --batch - -k -> --fail-fast - -q -> query mode - -S -> --any-failure Activation methods: 1. Symlink bssh as "pdsh" 2. Set BSSH_PDSH_COMPAT=1 environment variable 3. Use --pdsh-compat flag Includes 24 unit tests for mode detection and option mapping. Updates ARCHITECTURE.md with pdsh compatibility documentation.
1 parent f0253b8 commit a3129be

File tree

6 files changed

+1048
-3
lines changed

6 files changed

+1048
-3
lines changed

ARCHITECTURE.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,78 @@ async fn main() -> Result<()> {
212212
- Subcommand pattern adds complexity but improves UX
213213
- Modular structure increases file count but improves testability
214214

215+
#### pdsh Compatibility Mode (Issue #97)
216+
217+
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.
218+
219+
**Module Structure:**
220+
- `cli/mod.rs` - CLI module exports and pdsh re-exports
221+
- `cli/bssh.rs` - Standard bssh CLI parser
222+
- `cli/pdsh.rs` - pdsh-compatible CLI parser and conversion logic
223+
- `cli/mode_detection_tests.rs` - Tests for mode detection
224+
225+
**Activation Methods:**
226+
227+
1. **Binary name detection**: When bssh is invoked as "pdsh" (via symlink)
228+
```bash
229+
ln -s /usr/bin/bssh /usr/local/bin/pdsh
230+
pdsh -w hosts "uptime" # Uses pdsh compat mode
231+
```
232+
233+
2. **Environment variable**: `BSSH_PDSH_COMPAT=1` or `BSSH_PDSH_COMPAT=true`
234+
```bash
235+
BSSH_PDSH_COMPAT=1 bssh -w hosts "uptime"
236+
```
237+
238+
3. **CLI flag**: `--pdsh-compat`
239+
```bash
240+
bssh --pdsh-compat -w hosts "uptime"
241+
```
242+
243+
**Option Mapping:**
244+
245+
| pdsh option | bssh option | Description |
246+
|-------------|-------------|-------------|
247+
| `-w hosts` | `-H hosts` | Target hosts (comma-separated) |
248+
| `-x hosts` | `--exclude hosts` | Exclude hosts from target list |
249+
| `-f N` | `--parallel N` | Fanout (parallel connections) |
250+
| `-l user` | `-l user` | Remote username |
251+
| `-t N` | `--connect-timeout N` | Connection timeout (seconds) |
252+
| `-u N` | `--timeout N` | Command timeout (seconds) |
253+
| `-N` | `--no-prefix` | Disable hostname prefix in output |
254+
| `-b` | `--batch` | Batch mode (single Ctrl+C terminates) |
255+
| `-k` | `--fail-fast` | Stop on first failure |
256+
| `-q` | (query mode) | Show hosts and exit |
257+
| `-S` | `--any-failure` | Return largest exit code from any node |
258+
259+
**Implementation Details:**
260+
261+
```rust
262+
// Mode detection in main.rs
263+
let pdsh_mode = is_pdsh_compat_mode() || has_pdsh_compat_flag(&args);
264+
265+
if pdsh_mode {
266+
return run_pdsh_mode(&args).await;
267+
}
268+
269+
// pdsh CLI parsing and conversion
270+
let pdsh_cli = PdshCli::parse_from(filtered_args.iter());
271+
let mut cli = pdsh_cli.to_bssh_cli();
272+
```
273+
274+
**Design Decisions:**
275+
276+
1. **Separate parser**: pdsh CLI uses its own clap parser to avoid conflicts with bssh options
277+
2. **Conversion method**: `to_bssh_cli()` converts pdsh options to bssh `Cli` struct
278+
3. **Query mode**: pdsh `-q` shows target hosts without executing commands
279+
4. **Default fanout**: pdsh default is 32, bssh default is 10 - pdsh mode uses 32
280+
281+
**Key Points:**
282+
- Mode detection happens before any argument parsing
283+
- pdsh and bssh modes are mutually exclusive
284+
- Unknown pdsh options produce helpful error messages
285+
- Normal bssh operation is completely unaffected by pdsh compat code
286+
215287
### 2. Configuration Management (`config/*`)
216288

217289
**Module Structure (Refactored 2025-10-17):**

src/cli.rs renamed to src/cli/bssh.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,18 @@ pub struct Cli {
203203
)]
204204
pub fail_fast: bool,
205205

206+
#[arg(
207+
long = "any-failure",
208+
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"
209+
)]
210+
pub any_failure: bool,
211+
212+
#[arg(
213+
long = "pdsh-compat",
214+
help = "Enable pdsh compatibility mode\nAccepts pdsh-style command line arguments (-w, -x, -f, etc.)\nUseful when migrating from pdsh or in mixed environments"
215+
)]
216+
pub pdsh_compat: bool,
217+
206218
#[arg(
207219
trailing_var_arg = true,
208220
help = "Command to execute on remote hosts",

src/cli/mod.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright 2025 Lablup Inc. and Jeongkyu Shin
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
//! CLI module for bssh
16+
//!
17+
//! This module provides command-line interface parsing for bssh, including:
18+
//! - Standard bssh CLI (`Cli`)
19+
//! - pdsh compatibility layer (`pdsh` submodule)
20+
//!
21+
//! # Architecture
22+
//!
23+
//! The CLI module is structured as follows:
24+
//! - `bssh.rs` - Main bssh CLI parser with all standard options
25+
//! - `pdsh.rs` - pdsh-compatible CLI parser for drop-in replacement mode
26+
//!
27+
//! # pdsh Compatibility Mode
28+
//!
29+
//! bssh can operate in pdsh compatibility mode, activated by:
30+
//! 1. Setting `BSSH_PDSH_COMPAT=1` environment variable
31+
//! 2. Symlinking bssh to "pdsh" and invoking via that name
32+
//! 3. Using the `--pdsh-compat` flag
33+
//!
34+
//! See the `pdsh` module documentation for details on option mapping.
35+
36+
mod bssh;
37+
pub mod pdsh;
38+
39+
#[cfg(test)]
40+
mod mode_detection_tests;
41+
42+
// Re-export main CLI types from bssh module
43+
pub use bssh::{Cli, Commands};
44+
45+
// Re-export pdsh compatibility utilities
46+
pub use pdsh::{
47+
has_pdsh_compat_flag, is_pdsh_compat_mode, remove_pdsh_compat_flag, PdshCli, QueryResult,
48+
PDSH_COMPAT_ENV_VAR,
49+
};

src/cli/mode_detection_tests.rs

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// Copyright 2025 Lablup Inc. and Jeongkyu Shin
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
//! Tests for pdsh compatibility mode detection
16+
//!
17+
//! These tests verify that pdsh compatibility mode is correctly detected
18+
//! based on environment variables and binary names.
19+
20+
#[cfg(test)]
21+
mod tests {
22+
use crate::cli::pdsh::PDSH_COMPAT_ENV_VAR;
23+
use std::env;
24+
25+
/// Test that environment variable detection works for "1"
26+
#[test]
27+
fn test_env_var_detection_one() {
28+
// Save and restore env var state
29+
let original = env::var(PDSH_COMPAT_ENV_VAR).ok();
30+
31+
env::set_var(PDSH_COMPAT_ENV_VAR, "1");
32+
33+
// Create a test for the env var checking logic
34+
let value = env::var(PDSH_COMPAT_ENV_VAR).ok();
35+
assert!(value.is_some());
36+
let value = value.unwrap();
37+
assert!(value == "1" || value.to_lowercase() == "true");
38+
39+
// Restore
40+
match original {
41+
Some(v) => env::set_var(PDSH_COMPAT_ENV_VAR, v),
42+
None => env::remove_var(PDSH_COMPAT_ENV_VAR),
43+
}
44+
}
45+
46+
/// Test that environment variable detection works for "true"
47+
#[test]
48+
fn test_env_var_detection_true() {
49+
let original = env::var(PDSH_COMPAT_ENV_VAR).ok();
50+
51+
env::set_var(PDSH_COMPAT_ENV_VAR, "true");
52+
53+
let value = env::var(PDSH_COMPAT_ENV_VAR).ok();
54+
assert!(value.is_some());
55+
assert_eq!(value.unwrap().to_lowercase(), "true");
56+
57+
match original {
58+
Some(v) => env::set_var(PDSH_COMPAT_ENV_VAR, v),
59+
None => env::remove_var(PDSH_COMPAT_ENV_VAR),
60+
}
61+
}
62+
63+
/// Test that environment variable detection works for "TRUE" (case insensitive)
64+
#[test]
65+
fn test_env_var_detection_case_insensitive() {
66+
let original = env::var(PDSH_COMPAT_ENV_VAR).ok();
67+
68+
env::set_var(PDSH_COMPAT_ENV_VAR, "TRUE");
69+
70+
let value = env::var(PDSH_COMPAT_ENV_VAR).ok();
71+
assert!(value.is_some());
72+
assert_eq!(value.unwrap().to_lowercase(), "true");
73+
74+
match original {
75+
Some(v) => env::set_var(PDSH_COMPAT_ENV_VAR, v),
76+
None => env::remove_var(PDSH_COMPAT_ENV_VAR),
77+
}
78+
}
79+
80+
/// Test that environment variable is not detected when unset
81+
#[test]
82+
fn test_env_var_not_set() {
83+
let original = env::var(PDSH_COMPAT_ENV_VAR).ok();
84+
85+
env::remove_var(PDSH_COMPAT_ENV_VAR);
86+
87+
let value = env::var(PDSH_COMPAT_ENV_VAR).ok();
88+
assert!(value.is_none());
89+
90+
// Restore
91+
if let Some(v) = original {
92+
env::set_var(PDSH_COMPAT_ENV_VAR, v);
93+
}
94+
}
95+
96+
/// Test that invalid env var values are not treated as enabled
97+
#[test]
98+
fn test_env_var_invalid_values() {
99+
let original = env::var(PDSH_COMPAT_ENV_VAR).ok();
100+
101+
// Test "0"
102+
env::set_var(PDSH_COMPAT_ENV_VAR, "0");
103+
let value = env::var(PDSH_COMPAT_ENV_VAR).unwrap();
104+
let enabled = value == "1" || value.to_lowercase() == "true";
105+
assert!(!enabled);
106+
107+
// Test "false"
108+
env::set_var(PDSH_COMPAT_ENV_VAR, "false");
109+
let value = env::var(PDSH_COMPAT_ENV_VAR).unwrap();
110+
let enabled = value == "1" || value.to_lowercase() == "true";
111+
assert!(!enabled);
112+
113+
// Test empty string
114+
env::set_var(PDSH_COMPAT_ENV_VAR, "");
115+
let value = env::var(PDSH_COMPAT_ENV_VAR).unwrap();
116+
let enabled = value == "1" || value.to_lowercase() == "true";
117+
assert!(!enabled);
118+
119+
// Restore
120+
match original {
121+
Some(v) => env::set_var(PDSH_COMPAT_ENV_VAR, v),
122+
None => env::remove_var(PDSH_COMPAT_ENV_VAR),
123+
}
124+
}
125+
126+
/// Test binary name detection logic for "pdsh"
127+
#[test]
128+
fn test_binary_name_pdsh() {
129+
use std::path::Path;
130+
131+
let arg0 = "/usr/bin/pdsh";
132+
let binary_name = Path::new(arg0)
133+
.file_name()
134+
.and_then(|n| n.to_str())
135+
.unwrap_or("");
136+
137+
assert_eq!(binary_name, "pdsh");
138+
assert!(binary_name == "pdsh" || binary_name.starts_with("pdsh."));
139+
}
140+
141+
/// Test binary name detection for relative path
142+
#[test]
143+
fn test_binary_name_relative_path() {
144+
use std::path::Path;
145+
146+
let arg0 = "./pdsh";
147+
let binary_name = Path::new(arg0)
148+
.file_name()
149+
.and_then(|n| n.to_str())
150+
.unwrap_or("");
151+
152+
assert_eq!(binary_name, "pdsh");
153+
}
154+
155+
/// Test binary name detection for "pdsh.exe" (Windows)
156+
#[test]
157+
#[cfg(windows)]
158+
fn test_binary_name_windows() {
159+
use std::path::Path;
160+
161+
let arg0 = "C:\\Program Files\\bssh\\pdsh.exe";
162+
let binary_name = Path::new(arg0)
163+
.file_name()
164+
.and_then(|n| n.to_str())
165+
.unwrap_or("");
166+
167+
assert!(binary_name.starts_with("pdsh."));
168+
}
169+
170+
/// Test binary name detection for "pdsh.exe" pattern
171+
#[test]
172+
fn test_binary_name_exe_extension() {
173+
use std::path::Path;
174+
175+
// Test just the filename (works cross-platform)
176+
let arg0 = "pdsh.exe";
177+
let binary_name = Path::new(arg0)
178+
.file_name()
179+
.and_then(|n| n.to_str())
180+
.unwrap_or("");
181+
182+
assert!(binary_name.starts_with("pdsh."));
183+
}
184+
185+
/// Test that bssh binary name is not detected as pdsh
186+
#[test]
187+
fn test_binary_name_bssh() {
188+
use std::path::Path;
189+
190+
let arg0 = "/usr/bin/bssh";
191+
let binary_name = Path::new(arg0)
192+
.file_name()
193+
.and_then(|n| n.to_str())
194+
.unwrap_or("");
195+
196+
assert_eq!(binary_name, "bssh");
197+
assert!(!(binary_name == "pdsh" || binary_name.starts_with("pdsh.")));
198+
}
199+
200+
/// Test that symlinked pdsh is detected
201+
#[test]
202+
fn test_binary_name_symlink() {
203+
use std::path::Path;
204+
205+
// When bssh is symlinked as pdsh, arg0 would be the symlink name
206+
let arg0 = "/usr/local/bin/pdsh";
207+
let binary_name = Path::new(arg0)
208+
.file_name()
209+
.and_then(|n| n.to_str())
210+
.unwrap_or("");
211+
212+
assert_eq!(binary_name, "pdsh");
213+
}
214+
215+
/// Test edge case: empty arg0
216+
#[test]
217+
fn test_binary_name_empty() {
218+
use std::path::Path;
219+
220+
let arg0 = "";
221+
let binary_name = Path::new(arg0)
222+
.file_name()
223+
.and_then(|n| n.to_str())
224+
.unwrap_or("");
225+
226+
assert!(binary_name.is_empty());
227+
assert!(!(binary_name == "pdsh" || binary_name.starts_with("pdsh.")));
228+
}
229+
230+
/// Test edge case: just filename without path
231+
#[test]
232+
fn test_binary_name_no_path() {
233+
use std::path::Path;
234+
235+
let arg0 = "pdsh";
236+
let binary_name = Path::new(arg0)
237+
.file_name()
238+
.and_then(|n| n.to_str())
239+
.unwrap_or("");
240+
241+
assert_eq!(binary_name, "pdsh");
242+
}
243+
}

0 commit comments

Comments
 (0)