Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 124 additions & 11 deletions docs/man/bssh.1
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,29 @@ Example: -D 1080 (SOCKS5 proxy on localhost:1080), -D *:1080/4 (SOCKS4 on all in
.TP
.BR \-H ", " \-\-hosts " " \fIHOSTS\fR
Comma-separated list of hosts in [user@]hostname[:port] format.
Example: user1@host1:2222,user2@host2
Supports pdsh-style hostlist expressions for range expansion.
.RS
.PP
Simple host list:
.IP \[bu] 2
-H "user1@host1:2222,user2@host2"
.PP
Hostlist expressions (range expansion):
.IP \[bu] 2
-H "node[1-5]" \[->] node1, node2, node3, node4, node5
.IP \[bu] 2
-H "node[01-05]" \[->] node01, node02, ... (zero-padded)
.IP \[bu] 2
-H "node[1,3,5]" \[->] node1, node3, node5 (specific values)
.IP \[bu] 2
-H "rack[1-2]-node[1-3]" \[->] 6 hosts (cartesian product)
.IP \[bu] 2
-H "web[1-3].example.com" \[->] web1.example.com, web2.example.com, ...
.IP \[bu] 2
-H "admin@web[1-3]:22" \[->] expands with user and port preserved
.IP \[bu] 2
-H "^/path/to/hostfile" \[->] read hosts from file
.RE

.TP
.BR \-C ", " \-\-cluster " " \fICLUSTER\fR
Expand Down Expand Up @@ -166,29 +188,47 @@ Password is never logged or printed in any output

.TP
.BR \-f ", " \-\-filter " " \fIPATTERN\fR
Filter hosts by pattern (supports wildcards like 'web*').
Filter hosts by pattern. Supports both wildcards and hostlist expressions.
Use with -H or -C to execute on a subset of hosts.
Example: -f "web*" matches web01, web02, etc.
.RS
.PP
Examples:
.IP \[bu] 2
-f "web*" \[->] matches web01, web02, etc. (glob pattern)
.IP \[bu] 2
-f "node[1-5]" \[->] matches node1 through node5 (hostlist expression)
.IP \[bu] 2
-f "node[1,3,5]" \[->] matches node1, node3, node5 (specific values)
.RE

.TP
.BR \-\-exclude " " \fIHOSTS\fR
Exclude hosts from target list (comma-separated).
Supports wildcard patterns: '*' (any chars), '?' (single char), '[abc]' (char set).
Patterns with wildcards use glob matching; plain patterns use substring matching.
Supports wildcards, glob patterns, and hostlist expressions.
Applied after --filter option.
.RS
.PP
Examples:
Glob patterns:
.IP \[bu] 2
--exclude "db*" \[->] exclude hosts starting with 'db'
.IP \[bu] 2
--exclude "node2" - Exclude single host
--exclude "*-backup" \[->] exclude backup nodes
.IP \[bu] 2
--exclude "web1,web2" - Exclude multiple hosts
--exclude "web[12]" \[->] exclude web1 and web2 (glob character class)
.PP
Hostlist expressions:
.IP \[bu] 2
--exclude "node[3-5]" \[->] exclude node3, node4, node5 (range)
.IP \[bu] 2
--exclude "db*" - Exclude hosts starting with 'db'
--exclude "node[1,3,5]" \[->] exclude node1, node3, node5 (specific values)
.IP \[bu] 2
--exclude "*-backup" - Exclude backup nodes
--exclude "rack[1-2]-node[1-3]" \[->] exclude 6 hosts (cartesian product)
.PP
Simple patterns:
.IP \[bu] 2
--exclude "web[12]" - Exclude web1 and web2
--exclude "node2" \[->] exclude single host
.IP \[bu] 2
--exclude "web1,web2" \[->] exclude multiple hosts
.RE

.TP
Expand Down Expand Up @@ -1094,6 +1134,79 @@ Current node's role (main or sub)

Note: Backend.AI multi-node clusters use SSH port 2200 by default, which is automatically configured.

.SH HOSTLIST EXPRESSIONS
Hostlist expressions provide a compact way to specify multiple hosts using range notation,
compatible with pdsh syntax. This allows efficient targeting of large numbers of hosts
without listing each one individually.

.SS Basic Syntax
.TP
.B Simple range
.B node[1-5]
expands to node1, node2, node3, node4, node5
.TP
.B Zero-padded range
.B node[01-05]
expands to node01, node02, node03, node04, node05
.TP
.B Comma-separated values
.B node[1,3,5]
expands to node1, node3, node5
.TP
.B Mixed ranges and values
.B node[1-3,7,9-10]
expands to node1, node2, node3, node7, node9, node10

.SS Advanced Patterns
.TP
.B Multiple ranges (cartesian product)
.B rack[1-2]-node[1-3]
expands to rack1-node1, rack1-node2, rack1-node3, rack2-node1, rack2-node2, rack2-node3
.TP
.B Domain suffix
.B web[1-3].example.com
expands to web1.example.com, web2.example.com, web3.example.com
.TP
.B With user and port
.B admin@server[1-3]:2222
expands to admin@server1:2222, admin@server2:2222, admin@server3:2222

.SS File Input
.TP
.B ^/path/to/hostfile
Reads hosts from file, one per line. Lines starting with # are comments.
Maximum file size: 1MB, maximum lines: 100,000.

.SS Using with Options
Hostlist expressions can be used with:
.TP
.B -H, --hosts
Specify target hosts:
.B bssh -H "node[1-10]" "uptime"
.TP
.B --filter
Include only matching hosts:
.B bssh -c cluster --filter "web[1-5]" "systemctl status nginx"
.TP
.B --exclude
Exclude matching hosts:
.B bssh -c cluster --exclude "node[1,3,5]" "df -h"

.SS Examples
.nf
# Execute on 100 nodes
bssh -H "compute[001-100]" "hostname"

# Target specific racks
bssh -H "rack[A-C]-node[1-8]" "uptime"

# Use hosts from file
bssh -H "^/etc/cluster/hosts" "date"

# Combine with exclusions
bssh -H "node[1-20]" --exclude "node[5,10,15]" "ps aux"
.fi

.SH EXAMPLES

.SS SSH Compatibility Mode (Single Host)
Expand Down
78 changes: 3 additions & 75 deletions src/app/nodes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,79 +349,6 @@ pub fn filter_nodes(nodes: Vec<Node>, pattern: &str) -> Result<Vec<Node>> {
}
}

/// Check if a pattern is a hostlist expression (contains range brackets)
fn is_hostlist_expression(pattern: &str) -> bool {
// A hostlist expression has [...] with numbers/ranges inside
// But we need to distinguish from glob patterns like [abc]
if !pattern.contains('[') || !pattern.contains(']') {
return false;
}

// Find bracket content and check if it looks like a hostlist range
let mut in_bracket = false;
let mut bracket_content = String::new();

for ch in pattern.chars() {
match ch {
'[' if !in_bracket => {
in_bracket = true;
bracket_content.clear();
}
']' if in_bracket => {
// Check if bracket content looks like a hostlist range
// Hostlist: [1-5], [01-05], [1,2,3], [1-3,5-7]
// Glob: [abc], [!xyz], [a-z]
if looks_like_hostlist_range(&bracket_content) {
return true;
}
in_bracket = false;
}
_ if in_bracket => {
bracket_content.push(ch);
}
_ => {}
}
}

false
}

/// Check if bracket content looks like a hostlist numeric range
fn looks_like_hostlist_range(content: &str) -> bool {
if content.is_empty() {
return false;
}

// Hostlist ranges are numeric: 1-5, 01-05, 1,2,3, 1-3,5-7
// Glob patterns have letters: abc, a-z, !xyz
for part in content.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}

// Check if it's a range (contains -)
if part.contains('-') {
let parts: Vec<&str> = part.splitn(2, '-').collect();
if parts.len() == 2 {
// Both parts should be numeric for hostlist
if parts[0].chars().all(|c| c.is_ascii_digit())
&& parts[1].chars().all(|c| c.is_ascii_digit())
{
return true;
}
}
} else {
// Single value should be numeric for hostlist
if part.chars().all(|c| c.is_ascii_digit()) {
return true;
}
}
}

false
}

/// Filter nodes with hostlist expression support
///
/// If the pattern contains hostlist expressions (e.g., node[1-5]),
Expand All @@ -434,7 +361,7 @@ pub fn filter_nodes_with_hostlist(nodes: Vec<Node>, pattern: &str) -> Result<Vec
}

// Check if this looks like a hostlist expression
if is_hostlist_expression(pattern) {
if hostlist::is_hostlist_expression(pattern) {
// Expand the hostlist expression
let expanded_patterns = hostlist::expander::expand_host_specs(pattern)
.with_context(|| format!("Failed to expand filter pattern: {pattern}"))?;
Expand Down Expand Up @@ -472,7 +399,7 @@ pub fn exclude_nodes_with_hostlist(nodes: Vec<Node>, patterns: &[String]) -> Res
let mut glob_patterns = Vec::new();

for pattern in patterns {
if is_hostlist_expression(pattern) {
if hostlist::is_hostlist_expression(pattern) {
// Expand hostlist expression
let expanded = hostlist::expander::expand_host_specs(pattern)
.with_context(|| format!("Failed to expand exclusion pattern: {pattern}"))?;
Expand Down Expand Up @@ -507,6 +434,7 @@ pub fn exclude_nodes_with_hostlist(nodes: Vec<Node>, patterns: &[String]) -> Res
#[cfg(test)]
mod tests {
use super::*;
use bssh::hostlist::is_hostlist_expression;

fn create_test_nodes() -> Vec<Node> {
vec![
Expand Down
20 changes: 19 additions & 1 deletion src/hostlist/expander.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,25 @@ fn expand_segments(segments: &[PatternSegment]) -> Result<Vec<String>, HostlistE
PatternSegment::Range(range_expr) => {
// Expand with all values from the range
let values = range_expr.values();
let mut new_results = Vec::with_capacity(results.len() * values.len());

// Use checked multiplication to prevent integer overflow
let new_capacity = results.len().checked_mul(values.len()).ok_or_else(|| {
HostlistError::RangeTooLarge {
expression: "cartesian product".to_string(),
count: usize::MAX,
limit: MAX_EXPANSION_SIZE,
}
})?;

if new_capacity > MAX_EXPANSION_SIZE {
return Err(HostlistError::RangeTooLarge {
expression: "cartesian product".to_string(),
count: new_capacity,
limit: MAX_EXPANSION_SIZE,
});
}

let mut new_results = Vec::with_capacity(new_capacity);

for result in &results {
for value in &values {
Expand Down
73 changes: 73 additions & 0 deletions src/hostlist/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,79 @@ pub use error::HostlistError;
pub use expander::{expand_host_spec, expand_host_specs, expand_hostlist};
pub use parser::{parse_host_pattern, parse_hostfile, HostPattern};

/// Check if a pattern is a hostlist expression (contains numeric range brackets)
///
/// Hostlist expressions have brackets containing numeric ranges like [1-5], [01-05], [1,2,3]
/// Glob patterns have brackets containing characters like [abc], [a-z], [!xyz]
pub fn is_hostlist_expression(pattern: &str) -> bool {
// A hostlist expression has [...] with numbers/ranges inside
if !pattern.contains('[') || !pattern.contains(']') {
return false;
}

// Find bracket content and check if it looks like a hostlist range
let mut in_bracket = false;
let mut bracket_content = String::new();

for ch in pattern.chars() {
match ch {
'[' if !in_bracket => {
in_bracket = true;
bracket_content.clear();
}
']' if in_bracket => {
// Check if bracket content looks like a hostlist range
if looks_like_hostlist_range(&bracket_content) {
return true;
}
in_bracket = false;
}
_ if in_bracket => {
bracket_content.push(ch);
}
_ => {}
}
}

false
}

/// Check if bracket content looks like a hostlist numeric range
pub fn looks_like_hostlist_range(content: &str) -> bool {
if content.is_empty() {
return false;
}

// Hostlist ranges are numeric: 1-5, 01-05, 1,2,3, 1-3,5-7
// Glob patterns have letters: abc, a-z, !xyz
for part in content.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}

// Check if it's a range (contains -)
if part.contains('-') {
let parts: Vec<&str> = part.splitn(2, '-').collect();
if parts.len() == 2 {
// Both parts should be numeric for hostlist
if parts[0].chars().all(|c| c.is_ascii_digit())
&& parts[1].chars().all(|c| c.is_ascii_digit())
{
return true;
}
}
} else {
// Single value should be numeric for hostlist
if part.chars().all(|c| c.is_ascii_digit()) {
return true;
}
}
}

false
}

/// Expand a comma-separated list of host patterns
///
/// This function handles multiple patterns separated by commas,
Expand Down
Loading