From f1900e041ce7165ba73c717985ba9074e38a57a7 Mon Sep 17 00:00:00 2001 From: embh0 <270190780+embh0@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:01:20 +0200 Subject: [PATCH 1/5] feat: add sshot-style playbook execution --- README.md | 58 ++ docs/bssh-playbook.schema.json | 33 + examples/playbook-combined.yaml | 15 + examples/playbook-inventory.yaml | 22 + examples/playbook.yaml | 29 + src/app/dispatcher.rs | 10 +- src/cli/bssh.rs | 33 +- src/lib.rs | 1 + src/main.rs | 20 + src/playbook/condition.rs | 165 ++++ src/playbook/executor.rs | 1413 ++++++++++++++++++++++++++++++ src/playbook/mod.rs | 15 + src/playbook/model.rs | 117 +++ src/playbook/parser.rs | 767 ++++++++++++++++ 14 files changed, 2695 insertions(+), 3 deletions(-) create mode 100644 docs/bssh-playbook.schema.json create mode 100644 examples/playbook-combined.yaml create mode 100644 examples/playbook-inventory.yaml create mode 100644 examples/playbook.yaml create mode 100644 src/playbook/condition.rs create mode 100644 src/playbook/executor.rs create mode 100644 src/playbook/mod.rs create mode 100644 src/playbook/model.rs create mode 100644 src/playbook/parser.rs diff --git a/README.md b/README.md index 522e7cec..bb22cbe4 100644 --- a/README.md +++ b/README.md @@ -1437,6 +1437,64 @@ bssh -C all-servers --output-dir ./system-info "uname -a; df -h; free -m" bssh -C webservers --output-dir ./debug "systemctl status nginx" ``` +## sshot-style playbooks + +`bssh playbook` runs bssh's intentionally limited sshot-style YAML format. It is +**not an Ansible compatibility mode** and does not accept arbitrary Ansible +modules, expressions, or filters. + +```bash +# Separate inventory and playbook files +bssh playbook examples/playbook.yaml \ + --inventory examples/playbook-inventory.yaml --dry-run +bssh playbook examples/playbook.yaml -i examples/playbook-inventory.yaml \ + --full-output + +# Legacy combined inventory/playbook YAML +bssh playbook examples/playbook-combined.yaml --dry-run +``` + +Inventory YAML supports sshot's `ssh_config` defaults (`user`, `port`, +`key_file`, `use_agent`, and `strict_host_key_check`), host list entries with +`name`/`address`, and group list entries with `order`, `parallel`, and +`depends_on`. When groups exist they are authoritative: direct hosts are +ignored, each ordered group runs the complete task chain, dependencies require +successful groups, and the group's `parallel` flag controls host concurrency. +Named-map inventory forms are accepted as a bssh convenience. Standalone +playbooks use boolean `parallel`; legacy combined files contain +`inventory` and `playbook` mappings. JSON collectors use +`facts.collectors` entries with `name`, `command`, and optional `sudo`. + +Tasks support `command`, `shell`, UTF-8 `script`, UTF-8 `copy` with `mode` +(up to 1 KiB inline, with a final 16 KiB SSH-command limit), sshot `wait_for` +strings (`port:`, `service:`, `file:`, and `http:`), and `local_action`, plus +`sudo` (non-interactive `sudo -n`): + +- `register` and `{{ variable }}` interpolation (including dotted registered + values such as `result.stdout` and `result.exit_code`) +- `retries` as N retries after the initial attempt, a default five-second + delay when retries are enabled, and cancellable `timeout` +- `allowed_exit_codes` and `ignore_error` +- task `vars`, dependencies, `only_groups`, and `skip_groups` +- `until_success` (an initial attempt plus 60 retries by default) +- `delegate_to` using a named inventory host, or `localhost` for local execution +- `run_once`, which tries eligible hosts in inventory order until one succeeds + +Conditions are deliberately strict. The only forms are `name == literal`, +`name != literal`, `name is defined`, and `name is not defined`; unsupported +syntax is an error. Host checking defaults to strict `yes`; plaintext inventory +`password` and `key_password` fields are rejected. A `wait_for` action without +an explicit timeout still stops after 30 checks at two-second intervals. Fact +collector values are commands whose stdout must be +valid JSON. `--dry-run` parses and validates the full playbook, resolves hosts, +delegates, templates available at planning time, and prints actions without +opening SSH connections or executing local commands. Runtime-produced fact or +register values are marked for runtime condition evaluation. + +See [`examples/playbook.yaml`](examples/playbook.yaml), +[`examples/playbook-inventory.yaml`](examples/playbook-inventory.yaml), and +[`examples/playbook-combined.yaml`](examples/playbook-combined.yaml). + ## Development Read [ARCHITECTURE](ARCHITECTURE.md) documentation for more information. diff --git a/docs/bssh-playbook.schema.json b/docs/bssh-playbook.schema.json new file mode 100644 index 00000000..72e3de5d --- /dev/null +++ b/docs/bssh-playbook.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "bssh limited sshot-style playbook", + "oneOf": [ + { "$ref": "#/$defs/playbook" }, + { + "type": "object", + "required": ["inventory", "playbook"], + "properties": { + "inventory": { "type": "object" }, + "playbook": { "$ref": "#/$defs/playbook" } + } + } + ], + "$defs": { + "playbook": { + "type": "object", + "required": ["name", "tasks"], + "properties": { + "name": { "type": "string" }, + "parallel": { "type": ["boolean", "integer"] }, + "hosts": { "type": ["string", "array"] }, + "vars": { "type": "object" }, + "facts": { "type": "object" }, + "tasks": { + "type": "array", + "items": { "type": "object" } + } + }, + "additionalProperties": true + } + } +} diff --git a/examples/playbook-combined.yaml b/examples/playbook-combined.yaml new file mode 100644 index 00000000..21940e5a --- /dev/null +++ b/examples/playbook-combined.yaml @@ -0,0 +1,15 @@ +# Legacy combined sshot-style inventory/playbook YAML +inventory: + ssh_config: + user: deploy + use_agent: true + hosts: + - name: app1 + address: app1.example.com +playbook: + name: health + parallel: false + tasks: + - name: health-check + shell: "test -f /etc/hostname" + allowed_exit_codes: [0] diff --git a/examples/playbook-inventory.yaml b/examples/playbook-inventory.yaml new file mode 100644 index 00000000..35577196 --- /dev/null +++ b/examples/playbook-inventory.yaml @@ -0,0 +1,22 @@ +# sshot-style inventory for bssh playbooks +ssh_config: + user: deploy + port: 22 + key_file: ~/.ssh/id_ed25519 + strict_host_key_check: true +hosts: + - name: utility + address: utility.example.com +groups: + - name: web + order: 1 + parallel: true + hosts: + - name: web1 + address: web1.example.com + vars: + service: api + - name: web2 + address: web2.example.com + vars: + service: api diff --git a/examples/playbook.yaml b/examples/playbook.yaml new file mode 100644 index 00000000..51cb7e57 --- /dev/null +++ b/examples/playbook.yaml @@ -0,0 +1,29 @@ +# yaml-language-server: $schema=../docs/bssh-playbook.schema.json +--- +# bssh's limited sshot-style format (not an Ansible playbook) +name: inspect-web +parallel: true +facts: + collectors: + - name: kernel + command: >- + uname -r | python3 -c + 'import json,sys; print(json.dumps(sys.stdin.read().strip()))' +tasks: + - name: show-host + command: >- + printf '%s\n' '{{ .hostname }} {{ .service }}' + only_groups: [web] + register: greeting + + - name: wait-for-service + wait_for: service:api + only_groups: [web] + timeout: 30 + retries: 2 + retry_delay: 1 + + - name: controller-summary + local_action: "printf 'checked %s\n' '{{ .hostname }}'" + when: "{{ .greeting.success }} == true" + run_once: true diff --git a/src/app/dispatcher.rs b/src/app/dispatcher.rs index 8cfe3d4e..e38f1630 100644 --- a/src/app/dispatcher.rs +++ b/src/app/dispatcher.rs @@ -104,7 +104,8 @@ fn sudo_password_is_applicable(command: &Option, command_text: &str) - | Some(Commands::Download { .. }) | Some(Commands::List) | Some(Commands::Interactive { .. }) - | Some(Commands::CacheStats { .. }) => false, + | Some(Commands::CacheStats { .. }) + | Some(Commands::Playbook { .. }) => false, // `None` is the exec/SSH-compatibility path. A non-empty command can // use sudo injection; an empty command is an interactive SSH shell and // has no sudo-injection hook. @@ -121,7 +122,7 @@ fn sudo_password_is_applicable(command: &Option, command_text: &str) - fn ssh_password_is_applicable(command: &Option) -> bool { !matches!( command, - Some(Commands::List) | Some(Commands::CacheStats { .. }) + Some(Commands::List) | Some(Commands::CacheStats { .. }) | Some(Commands::Playbook { .. }) ) } @@ -134,6 +135,7 @@ fn subcommand_name(command: &Option) -> &'static str { Some(Commands::Download { .. }) => "download", Some(Commands::Interactive { .. }) => "interactive", Some(Commands::CacheStats { .. }) => "cache-stats", + Some(Commands::Playbook { .. }) => "playbook", None => "exec", } } @@ -329,6 +331,10 @@ pub async fn dispatch_command(cli: &Cli, ctx: &AppContext) -> Result<()> { // This is handled in main.rs before node resolution unreachable!("CacheStats should be handled before dispatch") } + Some(Commands::Playbook { .. }) => { + // This is handled in main.rs before the regular bssh initialization. + unreachable!("Playbook should be handled before dispatch") + } None => { // Execute command (auto-exec or interactive shell) handle_exec_command(cli, ctx, &command, ssh_password.clone()).await diff --git a/src/cli/bssh.rs b/src/cli/bssh.rs index cd050a35..9816d871 100644 --- a/src/cli/bssh.rs +++ b/src/cli/bssh.rs @@ -439,6 +439,37 @@ pub enum Commands { #[arg(long, help = "Perform cache maintenance (remove expired entries)")] maintain: bool, }, + + #[command( + about = "Run an sshot-style YAML playbook", + long_about = "Runs bssh's deliberately limited sshot-style playbook format. Supports separate inventory/playbook YAML and legacy combined YAML. This is not an Ansible compatibility mode." + )] + Playbook { + #[arg(value_name = "PLAYBOOK", help = "Playbook YAML path")] + playbook: PathBuf, + + #[arg( + short = 'i', + long, + value_name = "INVENTORY", + help = "Separate inventory YAML path (optional for legacy combined YAML)" + )] + inventory: Option, + + #[arg( + short = 'n', + long, + help = "Validate and print actions without SSH, file transfer, or local command execution" + )] + dry_run: bool, + + #[arg( + short = 'f', + long, + help = "Print stdout and stderr for successful tasks" + )] + full_output: bool, + }, } impl Cli { @@ -462,7 +493,7 @@ impl Cli { pub fn is_known_subcommand(arg: &str) -> bool { matches!( arg, - "list" | "ping" | "upload" | "download" | "interactive" | "cache-stats" + "list" | "ping" | "upload" | "download" | "interactive" | "cache-stats" | "playbook" ) } diff --git a/src/lib.rs b/src/lib.rs index ae49660a..3615e164 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ pub mod hostlist; pub mod jump; pub mod keygen; pub mod node; +pub mod playbook; pub mod pty; pub mod security; pub mod server; diff --git a/src/main.rs b/src/main.rs index 34e56fff..202db1d4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -230,6 +230,26 @@ async fn run_bssh_mode(args: &[String]) -> Result<()> { return Ok(()); } + // Playbooks carry their own inventory and SSH defaults. Keep this path + // isolated from the existing cluster initialization and executor. + if let Some(Commands::Playbook { + playbook, + inventory, + dry_run, + full_output, + }) = &cli.command + { + return bssh::playbook::run_file( + playbook, + inventory.as_deref(), + bssh::playbook::RunOptions { + dry_run: *dry_run, + full_output: *full_output, + }, + ) + .await; + } + // Initialize the application and load all configurations let ctx = initialize_app(&mut cli, args).await?; diff --git a/src/playbook/condition.rs b/src/playbook/condition.rs new file mode 100644 index 00000000..748538e6 --- /dev/null +++ b/src/playbook/condition.rs @@ -0,0 +1,165 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +use super::model::Variables; +use anyhow::{Result, bail}; +use serde_yaml::Value; + +/// Evaluate the deliberately small condition language supported by playbooks. +/// No truthiness, boolean composition, filters, or arbitrary expressions are accepted. +pub fn evaluate(expression: &str, variables: &Variables) -> Result { + let expression = expression.trim(); + if expression.is_empty() { + bail!("condition cannot be empty"); + } + if let Some(name) = expression.strip_suffix(" is not defined") { + let name = normalize_identifier(name); + validate_identifier(&name)?; + return Ok(resolve(variables, &name).is_none()); + } + if let Some(name) = expression.strip_suffix(" is defined") { + let name = normalize_identifier(name); + validate_identifier(&name)?; + return Ok(resolve(variables, &name).is_some()); + } + if let Some((left, right)) = expression.split_once(" != ") { + return compare(left, right, variables).map(|equal| !equal); + } + if let Some((left, right)) = expression.split_once(" == ") { + return compare(left, right, variables); + } + bail!( + "unsupported condition '{expression}'; supported forms are ==, !=, is defined, and is not defined" + ) +} + +fn compare(left: &str, right: &str, variables: &Variables) -> Result { + let left = normalize_identifier(left); + validate_identifier(&left)?; + let actual = resolve(variables, &left) + .ok_or_else(|| anyhow::anyhow!("condition references undefined variable '{left}'"))?; + let expected: Value = serde_yaml::from_str(right.trim()) + .map_err(|_| anyhow::anyhow!("invalid condition literal '{}'", right.trim()))?; + Ok(actual == &expected) +} + +fn normalize_identifier(name: &str) -> String { + let name = name.trim(); + let name = name + .strip_prefix("{{") + .and_then(|value| value.strip_suffix("}}")) + .unwrap_or(name); + name.trim().trim_start_matches('.').to_owned() +} + +fn validate_identifier(name: &str) -> Result<()> { + if name.is_empty() + || name.split('.').any(|part| { + part.is_empty() || !part.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + }) + { + bail!("invalid variable name '{name}' in condition"); + } + Ok(()) +} + +pub fn resolve<'a>(variables: &'a Variables, path: &str) -> Option<&'a Value> { + let mut parts = path.split('.'); + let mut value = variables.get(parts.next()?)?; + for part in parts { + value = value.as_mapping()?.get(&Value::String(part.to_owned()))?; + } + Some(value) +} + +pub fn render(input: &str, variables: &Variables) -> Result { + render_inner(input, variables, false) +} + +/// Render values known during planning while preserving runtime-produced +/// fact/register placeholders verbatim. +pub fn render_planning(input: &str, variables: &Variables) -> Result { + render_inner(input, variables, true) +} + +fn render_inner(input: &str, variables: &Variables, allow_unknown: bool) -> Result { + let mut output = String::with_capacity(input.len()); + let mut rest = input; + while let Some(start) = rest.find("{{") { + output.push_str(&rest[..start]); + let after = &rest[start + 2..]; + let end = after + .find("}}") + .ok_or_else(|| anyhow::anyhow!("unterminated variable template in '{input}'"))?; + let raw_name = after[..end].trim(); + let name = normalize_identifier(raw_name); + validate_identifier(&name)?; + if let Some(value) = resolve(variables, &name) { + output.push_str(&display_value(value)?); + } else if allow_unknown { + output.push_str("{{ "); + output.push_str(raw_name); + output.push_str(" }}"); + } else { + bail!("template references undefined variable '{name}'"); + } + rest = &after[end + 2..]; + } + if rest.contains("}}") { + bail!("unexpected template terminator in '{input}'"); + } + output.push_str(rest); + Ok(output) +} + +fn display_value(value: &Value) -> Result { + match value { + Value::String(s) => Ok(s.clone()), + Value::Bool(v) => Ok(v.to_string()), + Value::Number(v) => Ok(v.to_string()), + Value::Null => Ok("null".to_owned()), + _ => serde_json::to_string(value).map_err(Into::into), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn vars() -> Variables { + BTreeMap::from([ + ("env".into(), Value::String("prod".into())), + ("enabled".into(), Value::Bool(true)), + ("count".into(), Value::Number(2.into())), + ]) + } + + #[test] + fn evaluates_only_supported_conditions() { + let vars = vars(); + assert!(evaluate("env == prod", &vars).unwrap()); + assert!( + evaluate( + "{{ .env }} == production", + &BTreeMap::from([("env".into(), Value::String("production".into()))]) + ) + .unwrap() + ); + assert!(evaluate("count != 3", &vars).unwrap()); + assert!(evaluate("enabled is defined", &vars).unwrap()); + assert!(evaluate("missing is not defined", &vars).unwrap()); + assert!(evaluate("enabled and env == prod", &vars).is_err()); + assert!(evaluate("enabled", &vars).is_err()); + } + + #[test] + fn renders_variables_and_rejects_missing_values() { + assert_eq!( + render("deploy {{ env }} {{ count }}", &vars()).unwrap(), + "deploy prod 2" + ); + assert_eq!(render("{{ .env }}", &vars()).unwrap(), "prod"); + assert!(render("{{ missing }}", &vars()).is_err()); + } +} diff --git a/src/playbook/executor.rs b/src/playbook/executor.rs new file mode 100644 index 00000000..de72a9b1 --- /dev/null +++ b/src/playbook/executor.rs @@ -0,0 +1,1413 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +use super::condition::{evaluate, render, render_planning}; +use super::model::*; +use super::parser::{self, shell_quote}; +use anyhow::{Context, Result, bail}; +use futures::{StreamExt, stream}; +use serde_yaml::{Mapping, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; + +use crate::ssh::client::{ConnectionConfig, SshClient}; +use crate::ssh::known_hosts::StrictHostKeyChecking; + +#[derive(Debug, Clone, Copy)] +pub struct RunOptions { + pub dry_run: bool, + pub full_output: bool, +} + +#[derive(Debug, Clone)] +struct Outcome { + stdout: String, + stderr: String, + exit_code: u32, + success: bool, +} + +#[derive(Debug, Clone)] +struct HostState { + host: InventoryHost, + variables: Variables, + completed: BTreeMap, + pending_run_once_failures: BTreeSet, + runtime_unknowns: BTreeSet, + failed: bool, +} + +pub async fn run_file( + playbook_path: &Path, + inventory_path: Option<&Path>, + options: RunOptions, +) -> Result<()> { + let mut book = parser::load(playbook_path, inventory_path)?; + let inventory = book.inventory.take().ok_or_else(|| { +anyhow::anyhow!( +"no inventory found; pass --inventory or use a legacy combined YAML with an inventory section" +) +})?; + validate(&book.plays, &inventory, playbook_path)?; + let plays = order_plays(book.plays)?; + let base_dir = playbook_path.parent().unwrap_or_else(|| Path::new(".")); + let mut completed_plays = BTreeSet::new(); + let mut any_failed = false; + + for mut play in plays { + if !play + .dependencies + .iter() + .all(|d| completed_plays.contains(d)) + { + bail!("play '{}' has an unsatisfied dependency", play.name); + } + println!("PLAY [{}]", play.name); + play.tasks = order_tasks(std::mem::take(&mut play.tasks))?; + let run_once_successes = Arc::new(Mutex::new(BTreeSet::new())); + let mut play_failed = false; + + if inventory.groups.is_empty() { + let hosts = resolve_hosts(&inventory, &play.hosts, &play.groups)?; + let states = execute_batch( + hosts, + &play, + &inventory, + base_dir, + options, + play.parallel > 1, + Arc::clone(&run_once_successes), + ) + .await?; + play_failed = states.iter().any(|state| state.failed); + } else { + // In sshot group mode, groups are authoritative and direct + // inventory hosts are deliberately ignored. + let mut completed_groups = BTreeSet::new(); + for group in ordered_groups(&inventory)? { + let dependencies = inventory + .group_dependencies + .get(&group) + .cloned() + .unwrap_or_default(); + if !dependencies.iter().all(|d| completed_groups.contains(d)) { + eprintln!("SKIPPED GROUP [{group}] (dependency group did not succeed)"); + play_failed = true; + continue; + } + println!("GROUP [{group}]"); + let hosts = inventory.groups[&group] + .iter() + .filter_map(|name| inventory.hosts.get(name).cloned()) + .collect::>(); + let states = execute_batch( + hosts, + &play, + &inventory, + base_dir, + options, + inventory + .group_parallel + .get(&group) + .copied() + .unwrap_or(false), + Arc::clone(&run_once_successes), + ) + .await?; + if states.iter().any(|state| state.failed) { + play_failed = true; + } else { + completed_groups.insert(group); + } + } + } + + if play_failed { + any_failed = true; + } else { + completed_plays.insert(play.name); + } + } + + if any_failed { + bail!("playbook completed with failed tasks"); + } + Ok(()) +} + +fn ordered_groups(inventory: &Inventory) -> Result> { + let mut pending = inventory.groups.keys().cloned().collect::>(); + pending.sort_by_key(|group| inventory.group_order.get(group).copied().unwrap_or(0)); + let mut complete = BTreeSet::new(); + let mut ordered = Vec::new(); + while !pending.is_empty() { + let position = pending.iter().position(|group| { + inventory + .group_dependencies + .get(group) + .map(|deps| deps.iter().all(|dep| complete.contains(dep))) + .unwrap_or(true) + }); + let Some(position) = position else { + bail!("cyclic inventory group dependencies"); + }; + let group = pending.remove(position); + complete.insert(group.clone()); + ordered.push(group); + } + Ok(ordered) +} + +async fn execute_batch( + hosts: Vec, + play: &Play, + inventory: &Inventory, + base_dir: &Path, + options: RunOptions, + parallel: bool, + run_once_successes: Arc>>, +) -> Result> { + if hosts.is_empty() { + bail!("inventory batch contains no hosts"); + } + let states = hosts + .into_iter() + .map(|host| new_host_state(host, play)) + .collect::>(); + let concurrency = if parallel && !options.dry_run { + states.len().max(1) + } else { + 1 + }; + let mut results = stream::iter(states) + .map(|state| { + execute_host_chain( + state, + play, + inventory, + base_dir, + options, + Arc::clone(&run_once_successes), + ) + }) + .buffered(concurrency) + .collect::>>() + .await + .into_iter() + .collect::>>()?; + let successes = run_once_successes.lock().await; + for state in &mut results { + if state + .pending_run_once_failures + .iter() + .any(|task| !successes.contains(task)) + { + state.failed = true; + } + } + Ok(results) +} + +fn new_host_state(host: InventoryHost, play: &Play) -> HostState { + let mut variables = play.variables.clone(); + variables.extend(host.variables.clone()); + add_builtin_variables(&mut variables, &host); + HostState { + host, + variables, + completed: BTreeMap::new(), + pending_run_once_failures: BTreeSet::new(), + runtime_unknowns: BTreeSet::new(), + failed: false, + } +} + +async fn execute_host_chain( + mut state: HostState, + play: &Play, + inventory: &Inventory, + base_dir: &Path, + options: RunOptions, + run_once_successes: Arc>>, +) -> Result { + if options.dry_run { + for (fact, (command, _sudo)) in &play.facts { + state.runtime_unknowns.insert(fact.clone()); + println!( + "DRY-RUN [{}] FACT {} => {}", + state.host.name, + fact, + render_planning(command, &state.variables)? + ); + } + } else { + collect_facts(play, std::slice::from_mut(&mut state), options).await?; + } + + for task in &play.tasks { + println!("TASK [{}] [{}]", task.name, state.host.name); + if !task_is_eligible(task, &state) { + state.completed.insert(task.name.clone(), false); + continue; + } + + if task.run_once { + let mut successes = run_once_successes.lock().await; + if successes.contains(&task.name) { + state.completed.insert(task.name.clone(), true); + continue; + } + match execute_for_state(task, inventory, &state, base_dir, options).await? { + None => { + state.completed.insert(task.name.clone(), true); + } + Some(outcome) => { + if options.dry_run { + record_planned_outcome(task, &mut state); + } else { + record_outcome(task, &mut state, &outcome)?; + } + if outcome.success || options.dry_run { + successes.insert(task.name.clone()); + } else if !task.ignore_error { + state.pending_run_once_failures.insert(task.name.clone()); + } + } + } + } else { + match execute_for_state(task, inventory, &state, base_dir, options).await? { + None => { + state.completed.insert(task.name.clone(), true); + } + Some(outcome) => { + if options.dry_run { + record_planned_outcome(task, &mut state); + } else { + record_outcome(task, &mut state, &outcome)?; + } + if !outcome.success && !task.ignore_error { + state.failed = true; + } + } + } + } + if state.failed { + break; + } + } + Ok(state) +} + +#[allow(dead_code)] +async fn run_file_legacy( + playbook_path: &Path, + inventory_path: Option<&Path>, + options: RunOptions, +) -> Result<()> { + let mut book = parser::load(playbook_path, inventory_path)?; + let inventory = book.inventory.take().ok_or_else(|| { + anyhow::anyhow!( + "no inventory found; pass --inventory or use a legacy combined YAML with an inventory section" + ) + })?; + validate(&book.plays, &inventory, playbook_path)?; + let plays = order_plays(book.plays)?; + let base_dir = playbook_path.parent().unwrap_or_else(|| Path::new(".")); + let mut completed_plays = BTreeSet::new(); + let mut failed = false; + + for play in plays { + if !play + .dependencies + .iter() + .all(|d| completed_plays.contains(d)) + { + bail!("play '{}' has an unsatisfied dependency", play.name); + } + println!("PLAY [{}]", play.name); + let hosts = resolve_hosts(&inventory, &play.hosts, &play.groups)?; + let mut states = Vec::with_capacity(hosts.len()); + for host in hosts { + let mut variables = play.variables.clone(); + variables.extend(host.variables.clone()); + add_builtin_variables(&mut variables, &host); + states.push(HostState { + host, + variables, + completed: BTreeMap::new(), + pending_run_once_failures: BTreeSet::new(), + runtime_unknowns: BTreeSet::new(), + failed: false, + }); + } + + let selected_group_modes = states + .iter() + .flat_map(|state| state.host.groups.iter()) + .filter_map(|group| inventory.group_parallel.get(group)) + .copied() + .collect::>(); + let effective_parallel = if selected_group_modes.is_empty() { + play.parallel + } else if selected_group_modes.iter().all(|parallel| *parallel) { + play.parallel.max(10) + } else { + 1 + }; + + if options.dry_run { + for (fact, (command, _sudo)) in &play.facts { + for state in &states { + println!( + "DRY-RUN [{}] FACT {} => {}", + state.host.name, + fact, + render_planning(command, &state.variables)? + ); + } + } + } else { + collect_facts(&play, &mut states, options).await?; + } + + for task in order_tasks(play.tasks.clone())? { + println!("TASK [{}]", task.name); + if task.run_once { + let succeeded = + run_once_task(&task, &inventory, &mut states, base_dir, options).await?; + for state in &mut states { + state + .completed + .insert(task.name.clone(), succeeded || task.ignore_error); + } + if !succeeded && !task.ignore_error { + failed = true; + } + continue; + } + + let work: Vec<(usize, HostState)> = states + .iter() + .cloned() + .enumerate() + .filter(|(_, state)| task_is_eligible(&task, state)) + .collect(); + let results: Vec<(usize, Result>)> = stream::iter(work) + .map(|(index, state)| { + let task = task.clone(); + let inventory = &inventory; + async move { + let result = + execute_for_state(&task, inventory, &state, base_dir, options).await; + (index, result) + } + }) + .buffer_unordered(effective_parallel) + .collect() + .await; + + let mut touched = BTreeSet::new(); + for (index, result) in results { + touched.insert(index); + match result? { + None => { + states[index].completed.insert(task.name.clone(), true); + } + Some(outcome) => { + record_outcome(&task, &mut states[index], &outcome)?; + if !outcome.success && !task.ignore_error { + states[index].failed = true; + failed = true; + } + } + } + } + for (index, state) in states.iter_mut().enumerate() { + if !touched.contains(&index) { + state.completed.insert(task.name.clone(), false); + } + } + } + if !states.iter().any(|state| state.failed) { + completed_plays.insert(play.name); + } + } + + if failed { + bail!("playbook completed with failed tasks"); + } + Ok(()) +} + +fn validate(plays: &[Play], inventory: &Inventory, playbook_path: &Path) -> Result<()> { + for (group, dependencies) in &inventory.group_dependencies { + for dependency in dependencies { + if !inventory.groups.contains_key(dependency) { + bail!("inventory group '{group}' depends on unknown group '{dependency}'"); + } + } + } + let play_names: BTreeSet<&str> = plays.iter().map(|p| p.name.as_str()).collect(); + if play_names.len() != plays.len() { + bail!("play names must be unique"); + } + for play in plays { + for dependency in &play.dependencies { + if !play_names.contains(dependency.as_str()) { + bail!( + "play '{}' depends on unknown play '{dependency}'", + play.name + ); + } + } + let task_names: BTreeSet<&str> = play.tasks.iter().map(|t| t.name.as_str()).collect(); + if task_names.len() != play.tasks.len() { + bail!("task names in play '{}' must be unique", play.name); + } + for task in &play.tasks { + for dependency in &task.dependencies { + if !task_names.contains(dependency.as_str()) { + bail!( + "task '{}' depends on unknown task '{dependency}'", + task.name + ); + } + } + if let Some(delegate) = &task.delegate_to + && delegate != "localhost" + && !inventory.hosts.contains_key(delegate) + { + bail!( + "task '{}' delegates to unknown inventory host '{delegate}'", + task.name + ); + } + if let Some(condition) = &task.condition { + // Syntax-check without requiring runtime values. + let empty = Variables::new(); + if let Err(error) = evaluate(condition, &empty) + && !error.to_string().contains("undefined variable") + { + return Err(error).with_context(|| format!("task '{}' condition", task.name)); + } + } + match &task.action { + Action::Script { source, .. } | Action::Copy { source, .. } => { + let source = resolve_local_path(playbook_path, source); + if !source.is_file() { + bail!( + "task '{}' source does not exist: {}", + task.name, + source.display() + ); + } + let bytes = std::fs::read(&source).with_context(|| { + format!("failed to validate task '{}' source", task.name) + })?; + if bytes.len() > MAX_INLINE_SOURCE_BYTES { + bail!( + "task '{}' source exceeds the {}-byte inline playbook limit", + task.name, + MAX_INLINE_SOURCE_BYTES + ); + } + std::str::from_utf8(&bytes) + .with_context(|| format!("task '{}' source must be UTF-8", task.name))?; + } + _ => {} + } + } + } + Ok(()) +} + +fn order_plays(plays: Vec) -> Result> { + topo_sort(plays, |p| &p.name, |p| &p.dependencies, "play") +} + +fn order_tasks(tasks: Vec) -> Result> { + topo_sort(tasks, |t| &t.name, |t| &t.dependencies, "task") +} + +fn topo_sort( + items: Vec, + name: impl Fn(&T) -> &String, + dependencies: impl Fn(&T) -> &Vec, + label: &str, +) -> Result> { + let mut pending = items; + let mut complete = BTreeSet::new(); + let mut output = Vec::new(); + while !pending.is_empty() { + let position = pending + .iter() + .position(|item| dependencies(item).iter().all(|d| complete.contains(d))); + let Some(position) = position else { + bail!("cyclic {label} dependencies"); + }; + let item = pending.remove(position); + complete.insert(name(&item).clone()); + output.push(item); + } + Ok(output) +} + +fn resolve_hosts( + inventory: &Inventory, + selectors: &[String], + groups: &[String], +) -> Result> { + let mut wanted = BTreeSet::new(); + let mut direct = BTreeMap::new(); + for selector in selectors.iter().chain(groups) { + if selector == "all" { + wanted.extend(inventory.hosts.keys().cloned()); + } else if let Some(members) = inventory.groups.get(selector) { + wanted.extend(members.iter().cloned()); + } else if inventory.hosts.contains_key(selector) { + wanted.insert(selector.clone()); + } else if groups.contains(selector) { + bail!("unknown inventory group '{selector}'"); + } else { + // sshot accepts a direct hostname alongside inventory aliases. + let mut host = InventoryHost { + name: selector.clone(), + hostname: selector.clone(), + ssh: inventory.ssh.clone(), + variables: Variables::new(), + groups: BTreeSet::new(), + }; + if let Ok(node) = crate::Node::parse(selector, inventory.ssh.user.as_deref()) { + host.hostname = node.host; + host.ssh.user = Some(node.username); + host.ssh.port = Some(node.port); + } + direct.insert(selector.clone(), host); + wanted.insert(selector.clone()); + } + } + let mut names = inventory.order.clone(); + for name in inventory.hosts.keys().chain(direct.keys()) { + if !names.contains(name) { + names.push(name.clone()); + } + } + let hosts = names + .into_iter() + .filter(|name| wanted.contains(name)) + .filter_map(|name| { + inventory + .hosts + .get(&name) + .cloned() + .or_else(|| direct.get(&name).cloned()) + }) + .collect::>(); + if hosts.is_empty() { + bail!("play selected no hosts"); + } + Ok(hosts) +} + +fn task_is_eligible(task: &Task, state: &HostState) -> bool { + !state.failed + && task + .dependencies + .iter() + .all(|d| state.completed.get(d) == Some(&true)) + && (task.groups.is_empty() || task.groups.iter().any(|g| state.host.groups.contains(g))) + && !task + .skip_groups + .iter() + .any(|g| state.host.groups.contains(g)) +} + +async fn collect_facts(play: &Play, states: &mut [HostState], options: RunOptions) -> Result<()> { + for (name, (command, sudo)) in &play.facts { + for state in states.iter_mut() { + let command = render(command, &state.variables)?; + let command = with_sudo(&command, *sudo); + let outcome = run_remote(&state.host, &command, Some(60)) + .await + .with_context(|| format!("fact '{name}' on {}", state.host.name))?; + if outcome.exit_code != 0 { + bail!( + "fact '{name}' failed on {} with exit code {}", + state.host.name, + outcome.exit_code + ); + } + let json: serde_json::Value = serde_json::from_str(outcome.stdout.trim()) + .with_context(|| { + format!( + "fact '{name}' on {} did not emit valid JSON", + state.host.name + ) + })?; + state + .variables + .insert(name.clone(), serde_yaml::to_value(json)?); + print_outcome(&state.host.name, &format!("fact:{name}"), &outcome, options); + } + } + Ok(()) +} + +async fn run_once_task( + task: &Task, + inventory: &Inventory, + states: &mut [HostState], + base_dir: &Path, + options: RunOptions, +) -> Result { + let mut attempted = false; + let mut eligible = false; + for state in states.iter_mut().filter(|s| task_is_eligible(task, s)) { + eligible = true; + match execute_for_state(task, inventory, state, base_dir, options).await? { + None => continue, + Some(outcome) => { + attempted = true; + record_outcome(task, state, &outcome)?; + if outcome.success || options.dry_run { + return Ok(true); + } + } + } + } + // A conditionally skipped run-once task is complete; a task with no + // eligible hosts (for example because dependencies failed) is not. + Ok(eligible && !attempted) +} + +async fn execute_for_state( + task: &Task, + inventory: &Inventory, + state: &HostState, + base_dir: &Path, + options: RunOptions, +) -> Result> { + let mut variables = state.variables.clone(); + variables.extend(task.variables.clone()); + if let Some(condition) = &task.condition { + let condition_is_runtime_unknown = options.dry_run + && state + .runtime_unknowns + .iter() + .any(|name| condition.contains(name)); + if condition_is_runtime_unknown { + println!( + "DRY-RUN [{}] {} (condition depends on runtime value)", + state.host.name, task.name + ); + } else { + match evaluate(condition, &variables) { + Ok(false) => { + println!("SKIPPED [{}] {} (condition)", state.host.name, task.name); + return Ok(None); + } + Ok(true) => {} + Err(error) + if options.dry_run && error.to_string().contains("undefined variable") => + { + println!( + "DRY-RUN [{}] {} (condition evaluated at runtime)", + state.host.name, task.name + ); + } + Err(error) => { + return Err(error).with_context(|| format!("task '{}' condition", task.name)); + } + } + } + } + let delegate_local = task.delegate_to.as_deref() == Some("localhost"); + let target = task + .delegate_to + .as_deref() + .filter(|delegate| *delegate != "localhost") + .map(|delegate| inventory.hosts.get(delegate).expect("delegate validated")) + .unwrap_or(&state.host); + let prepared = prepare_action(&task.action, &variables, base_dir, options.dry_run)?; + if options.dry_run { + if !delegate_local { + validate_planned_remote_action(&prepared, task.sudo)?; + } + let target_name = if delegate_local { + "localhost" + } else { + &target.name + }; + println!( + "DRY-RUN [{} -> {}] {}", + state.host.name, + target_name, + describe_action(&prepared) + ); + return Ok(Some(Outcome { + stdout: String::new(), + stderr: String::new(), + exit_code: 0, + success: true, + })); + } + + let mut last_error = None; + for attempt in 1..=task.retries { + let result = run_action(target, &prepared, task.timeout, task.sudo, delegate_local).await; + match result { + Ok(mut outcome) => { + outcome.success = task.allowed_exit_codes.contains(&outcome.exit_code); + print_outcome(&state.host.name, &task.name, &outcome, options); + if outcome.success { + return Ok(Some(outcome)); + } + last_error = Some(anyhow::anyhow!("exit code {}", outcome.exit_code)); + if attempt == task.retries { + return Ok(Some(outcome)); + } + } + Err(error) => { + eprintln!( + "FAILED [{}] {} attempt {}/{}: {error:#}", + state.host.name, task.name, attempt, task.retries + ); + last_error = Some(error); + } + } + if attempt < task.retries && task.retry_delay > 0 { + tokio::time::sleep(Duration::from_secs(task.retry_delay)).await; + } + } + let error = last_error.unwrap_or_else(|| anyhow::anyhow!("task failed")); + if task.ignore_error { + let outcome = Outcome { + stdout: String::new(), + stderr: format!("{error:#}"), + exit_code: 255, + success: false, + }; + print_outcome(&state.host.name, &task.name, &outcome, options); + Ok(Some(outcome)) + } else { + Err(error) + } +} + +fn prepare_action( + action: &Action, + variables: &Variables, + base_dir: &Path, + planning: bool, +) -> Result { + let interpolate = |value: &str| { + if planning { + render_planning(value, variables) + } else { + render(value, variables) + } + }; + Ok(match action { + Action::Command(command) => Action::Command(interpolate(command)?), + Action::Shell(command) => Action::Shell(interpolate(command)?), + Action::Local(command) => Action::Local(interpolate(command)?), + Action::WaitFor { command, interval } => Action::WaitFor { + command: interpolate(command)?, + interval: *interval, + }, + Action::Script { source, args } => Action::Script { + source: resolve_path(base_dir, source), + args: interpolate(args)?, + }, + Action::Copy { + source, + destination, + mode, + } => Action::Copy { + source: resolve_path(base_dir, source), + destination: interpolate(destination)?, + mode: mode.clone(), + }, + }) +} + +fn describe_action(action: &Action) -> String { + match action { + Action::Command(c) => format!("command: {c}"), + Action::Shell(c) => format!("shell: {c}"), + Action::Local(c) => format!("local_action: {c}"), + Action::Script { source, args } => format!("script: {} {args}", source.display()), + Action::Copy { + source, + destination, + mode, + } => format!( + "copy: {} -> {destination}{}", + source.display(), + mode.as_deref() + .map(|m| format!(" mode={m}")) + .unwrap_or_default() + ), + Action::WaitFor { command, interval } => format!("wait_for: {command} (every {interval}s)"), + } +} + +async fn run_action( + host: &InventoryHost, + action: &Action, + timeout: Option, + sudo: bool, + delegate_local: bool, +) -> Result { + match action { + Action::Local(command) => run_local(&with_sudo(command, sudo), timeout).await, + Action::Command(command) | Action::Shell(command) => { + run_command(host, &with_sudo(command, sudo), timeout, delegate_local).await + } + Action::WaitFor { command, interval } => { + let command = wait_for_shell(command, *interval); + run_command(host, &with_sudo(&command, sudo), timeout, delegate_local).await + } + Action::Script { source, args } => { + let script = std::fs::read_to_string(source) + .with_context(|| format!("script is not UTF-8: {}", source.display()))?; + let marker = "BSSH_PLAYBOOK_SCRIPT_EOF"; + if script.lines().any(|line| line == marker) { + bail!( + "script {} contains reserved heredoc marker", + source.display() + ); + } + let args = shell_words::split(args) + .context("invalid script args")? + .iter() + .map(|a| shell_quote(a)) + .collect::>() + .join(" "); + if script.len() > MAX_INLINE_SOURCE_BYTES { + bail!( + "script source exceeds the {}-byte inline playbook limit", + MAX_INLINE_SOURCE_BYTES + ); + } + let command = format!("sh -s -- {args} <<'{marker}'\n{script}\n{marker}"); + run_command(host, &with_sudo(&command, sudo), timeout, delegate_local).await + } + Action::Copy { + source, + destination, + mode, + } => { + let bytes = std::fs::read(source) + .with_context(|| format!("failed to read {}", source.display()))?; + if bytes.len() > MAX_INLINE_SOURCE_BYTES { + bail!( + "copy source exceeds the {}-byte inline playbook limit; use bssh upload for larger files", + MAX_INLINE_SOURCE_BYTES + ); + } + let text = std::str::from_utf8(&bytes) + .with_context(|| format!("copy source must be UTF-8: {}", source.display()))?; + let parent = Path::new(destination) + .parent() + .unwrap_or_else(|| Path::new(".")); + let mode = mode + .as_deref() + .map(|mode| { + format!( + " && chmod {} {}", + shell_quote(mode), + shell_quote(destination) + ) + }) + .unwrap_or_default(); + let command = format!( + "mkdir -p {} && printf %s {} > {}{}", + shell_quote(&parent.to_string_lossy()), + shell_quote(text), + shell_quote(destination), + mode + ); + run_command(host, &with_sudo(&command, sudo), timeout, delegate_local).await + } + } +} + +fn wait_for_shell(command: &str, interval: u64) -> String { + // Match sshot's finite wait: at most 30 checks, two seconds + // apart by default, even when no task timeout is supplied. + format!( + "i=0; while [ \"$i\" -lt 30 ]; do ({command}) && exit 0; i=$((i+1)); [ \"$i\" -lt 30 ] && sleep {interval}; done; exit 1" + ) +} + +fn validate_planned_remote_action(action: &Action, sudo: bool) -> Result<()> { + let command = match action { + Action::Command(command) | Action::Shell(command) => Some(command.clone()), + Action::WaitFor { command, interval } => Some(wait_for_shell(command, *interval)), + Action::Local(_) | Action::Script { .. } | Action::Copy { .. } => None, + }; + if let Some(command) = command { + let command = with_sudo(&command, sudo); + validate_ssh_command_size(&command)?; + } + Ok(()) +} + +const MAX_SSH_COMMAND_BYTES: usize = 16 * 1024; +// This remains safe after two layers of worst-case single-quote escaping +// (payload shell quoting plus sudo's sh -c quoting). +const MAX_INLINE_SOURCE_BYTES: usize = 1024; + +async fn run_command( + host: &InventoryHost, + command: &str, + timeout: Option, + local: bool, +) -> Result { + if local { + run_local(command, timeout).await + } else { + run_remote(host, command, timeout).await + } +} + +fn with_sudo(command: &str, sudo: bool) -> String { + if sudo { + format!("sudo -n sh -c {}", shell_quote(command)) + } else { + command.to_owned() + } +} + +async fn run_local(command: &str, timeout: Option) -> Result { + let mut child = tokio::process::Command::new("sh"); + child + .arg("-c") + .arg(command) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let future = child.output(); + let output = if let Some(seconds) = timeout.filter(|s| *s > 0) { + tokio::time::timeout(Duration::from_secs(seconds), future) + .await + .with_context(|| format!("local action timed out after {seconds}s"))?? + } else { + future.await? + }; + Ok(Outcome { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + exit_code: output.status.code().unwrap_or(255) as u32, + success: output.status.success(), + }) +} + +fn validate_ssh_command_size(command: &str) -> Result<()> { + if command.len() > MAX_SSH_COMMAND_BYTES { + bail!( + "playbook SSH command is {} bytes after shell expansion (maximum {} bytes)", + command.len(), + MAX_SSH_COMMAND_BYTES + ); + } + Ok(()) +} + +async fn run_remote(host: &InventoryHost, command: &str, timeout: Option) -> Result { + validate_ssh_command_size(command)?; + let user = host.ssh.user.clone().unwrap_or_else(current_user); + let port = host.ssh.port.unwrap_or(22); + let strict_mode = host + .ssh + .strict_host_key_checking + .as_deref() + .unwrap_or("yes") + .parse::() + .map_err(|_| anyhow::anyhow!("invalid strict_host_key_checking value"))?; + let config = ConnectionConfig { + key_path: host.ssh.identity_file.as_deref(), + strict_mode: Some(strict_mode), + use_agent: host.ssh.use_agent, + use_password: false, + #[cfg(target_os = "macos")] + use_keychain: false, + timeout_seconds: timeout, + connect_timeout_seconds: host.ssh.connect_timeout, + jump_hosts_spec: None, + ssh_connection_config: None, + ssh_password: None, + }; + let mut client = SshClient::new(host.hostname.clone(), port, user); + let result = client + .connect_and_execute_with_jump_hosts(command, &config) + .await?; + Ok(Outcome { + stdout: result.stdout_string(), + stderr: result.stderr_string(), + exit_code: result.exit_status, + success: result.exit_status == 0, + }) +} + +fn record_planned_outcome(task: &Task, state: &mut HostState) { + state.variables.extend(task.variables.clone()); + state.completed.insert(task.name.clone(), true); + if let Some(name) = &task.register { + state.runtime_unknowns.insert(name.clone()); + } +} + +fn record_outcome(task: &Task, state: &mut HostState, outcome: &Outcome) -> Result<()> { + state.variables.extend(task.variables.clone()); + state + .completed + .insert(task.name.clone(), outcome.success || task.ignore_error); + if let Some(name) = &task.register { + let mut map = Mapping::new(); + map.insert( + Value::String("stdout".into()), + Value::String(outcome.stdout.clone()), + ); + map.insert( + Value::String("stderr".into()), + Value::String(outcome.stderr.clone()), + ); + map.insert( + Value::String("exit_code".into()), + Value::Number(outcome.exit_code.into()), + ); + map.insert( + Value::String("success".into()), + Value::Bool(outcome.success), + ); + state.variables.insert(name.clone(), Value::Mapping(map)); + } + Ok(()) +} + +fn print_outcome(host: &str, task: &str, outcome: &Outcome, options: RunOptions) { + let status = if outcome.success { "OK" } else { "FAILED" }; + println!("{status} [{host}] {task} (exit {})", outcome.exit_code); + if options.full_output || !outcome.success { + if !outcome.stdout.is_empty() { + print!("{}", outcome.stdout); + if !outcome.stdout.ends_with('\n') { + println!(); + } + } + if !outcome.stderr.is_empty() { + eprint!("{}", outcome.stderr); + if !outcome.stderr.ends_with('\n') { + eprintln!(); + } + } + } +} + +fn add_builtin_variables(vars: &mut Variables, host: &InventoryHost) { + vars.insert( + "inventory_hostname".into(), + Value::String(host.name.clone()), + ); + vars.insert("host".into(), Value::String(host.hostname.clone())); + vars.insert("hostname".into(), Value::String(host.hostname.clone())); + vars.insert( + "port".into(), + Value::Number(host.ssh.port.unwrap_or(22).into()), + ); + vars.insert( + "user".into(), + Value::String(host.ssh.user.clone().unwrap_or_else(current_user)), + ); +} + +fn current_user() -> String { + std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| whoami::username().unwrap_or_else(|_| "user".to_owned())) +} + +fn resolve_local_path(playbook_path: &Path, path: &Path) -> PathBuf { + resolve_path( + playbook_path.parent().unwrap_or_else(|| Path::new(".")), + path, + ) +} +fn resolve_path(base: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_owned() + } else { + base.join(path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn dry_run_never_executes_local_action() { + let dir = tempdir().unwrap(); + let marker = dir.path().join("marker"); + let inventory = dir.path().join("inventory.yaml"); + let playbook = dir.path().join("playbook.yaml"); + std::fs::write( + &inventory, + "ssh_config:\n user: test\nhosts:\n - name: localhost\n address: 127.0.0.1\n", + ) + .unwrap(); + std::fs::write(&playbook, format!("name: dry-run\nparallel: false\ntasks:\n - name: local\n local_action: touch {}\n register: result\n - name: conditional\n local_action: 'true'\n when: '{{{{ .result.success }}}} == true'\n", marker.display())).unwrap(); + run_file( + &playbook, + Some(&inventory), + RunOptions { + dry_run: true, + full_output: true, + }, + ) + .await + .unwrap(); + assert!(!marker.exists()); + } + + #[tokio::test] + async fn ignore_error_satisfies_task_dependencies() { + let dir = tempdir().unwrap(); + let marker = dir.path().join("dependency-ran"); + let inventory = dir.path().join("inventory.yaml"); + let playbook = dir.path().join("playbook.yaml"); + std::fs::write( + &inventory, + "hosts:\n - name: localhost\n address: 127.0.0.1\n", + ) + .unwrap(); + std::fs::write(&playbook, format!("name: ignored\ntasks:\n - name: expected failure\n local_action: exit 9\n ignore_error: true\n - name: dependent\n local_action: touch {}\n depends_on: [expected failure]\n", marker.display())).unwrap(); + run_file( + &playbook, + Some(&inventory), + RunOptions { + dry_run: false, + full_output: false, + }, + ) + .await + .unwrap(); + assert!(marker.exists()); + } + + #[tokio::test] + async fn groups_run_ordered_full_host_chains_and_ignore_direct_hosts() { + let dir = tempdir().unwrap(); + let log = dir.path().join("order.log"); + let inventory = dir.path().join("inventory.yaml"); + let playbook = dir.path().join("playbook.yaml"); + std::fs::write( + &inventory, + "hosts:\n - name: ignored\n address: ignored\ngroups:\n - name: first\n order: 1\n parallel: false\n hosts:\n - {name: a, address: a}\n - {name: b, address: b}\n - name: second\n order: 2\n depends_on: [first]\n hosts:\n - {name: c, address: c}\n", + ) + .unwrap(); + std::fs::write( + &playbook, + format!( + "name: grouped\ntasks:\n - name: one\n local_action: echo '{{{{ .inventory_hostname }}}}:one' >> {}\n - name: two\n local_action: echo '{{{{ .inventory_hostname }}}}:two' >> {}\n", + log.display(), + log.display() + ), + ) + .unwrap(); + run_file( + &playbook, + Some(&inventory), + RunOptions { + dry_run: false, + full_output: false, + }, + ) + .await + .unwrap(); + assert_eq!( + std::fs::read_to_string(log).unwrap(), + "a:one\na:two\nb:one\nb:two\nc:one\nc:two\n" + ); + } + + #[tokio::test] + async fn failed_group_gates_dependent_group() { + let dir = tempdir().unwrap(); + let marker = dir.path().join("must-not-run"); + let inventory = dir.path().join("inventory.yaml"); + let playbook = dir.path().join("playbook.yaml"); + std::fs::write( + &inventory, + "groups:\n - name: first\n order: 1\n hosts: [{name: a, address: a}]\n - name: second\n order: 2\n depends_on: [first]\n hosts: [{name: b, address: b}]\n", + ) + .unwrap(); + std::fs::write( + &playbook, + format!( + "name: gated\ntasks:\n - name: fail\n local_action: exit 1\n only_groups: [first]\n - name: marker\n local_action: touch {}\n only_groups: [second]\n", + marker.display() + ), + ) + .unwrap(); + assert!( + run_file( + &playbook, + Some(&inventory), + RunOptions { + dry_run: false, + full_output: false + }, + ) + .await + .is_err() + ); + assert!(!marker.exists()); + } + + #[tokio::test] + async fn localhost_delegation_and_success_based_run_once() { + let dir = tempdir().unwrap(); + let marker = dir.path().join("delegated"); + let inventory = dir.path().join("inventory.yaml"); + let playbook = dir.path().join("playbook.yaml"); + std::fs::write( + &inventory, + "hosts:\n - {name: first, address: unreachable.invalid}\n - {name: second, address: unreachable.invalid}\n", + ) + .unwrap(); + std::fs::write( + &playbook, + format!( + "name: delegated\ntasks:\n - name: eventually\n command: test '{{{{ .inventory_hostname }}}}' = second\n delegate_to: localhost\n run_once: true\n - name: write\n command: touch {}\n delegate_to: localhost\n run_once: true\n", + marker.display() + ), + ) + .unwrap(); + run_file( + &playbook, + Some(&inventory), + RunOptions { + dry_run: false, + full_output: false, + }, + ) + .await + .unwrap(); + assert!(marker.exists()); + } + + #[tokio::test] + async fn failed_run_once_fails_play_and_inline_bound_is_dry_run_validated() { + let dir = tempdir().unwrap(); + let inventory = dir.path().join("inventory.yaml"); + let playbook = dir.path().join("playbook.yaml"); + std::fs::write(&inventory, "hosts: [{name: host, address: host}]\n").unwrap(); + std::fs::write( + &playbook, + "name: failure\ntasks:\n - name: once\n command: exit 1\n delegate_to: localhost\n run_once: true\n", + ) + .unwrap(); + assert!( + run_file( + &playbook, + Some(&inventory), + RunOptions { + dry_run: false, + full_output: false + }, + ) + .await + .is_err() + ); + + let source = dir.path().join("large.txt"); + std::fs::write(&source, vec![b'x'; MAX_INLINE_SOURCE_BYTES + 1]).unwrap(); + std::fs::write( + &playbook, + format!( + "name: bound\ntasks:\n - copy: {{src: {}, dest: /tmp/x}}\n", + source.display() + ), + ) + .unwrap(); + let error = run_file( + &playbook, + Some(&inventory), + RunOptions { + dry_run: true, + full_output: false, + }, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("inline playbook limit")); + } + + #[test] + fn wait_for_shell_is_finite() { + let command = wait_for_shell("false", 2); + assert!(command.contains("-lt 30")); + assert!(command.contains("sleep 2")); + assert!(command.ends_with("exit 1")); + } + + #[tokio::test] + async fn retries_are_additional_attempts() { + let dir = tempdir().unwrap(); + let count = dir.path().join("count"); + let inventory = dir.path().join("inventory.yaml"); + let playbook = dir.path().join("playbook.yaml"); + std::fs::write(&inventory, "hosts: [{name: host, address: host}]\n").unwrap(); + std::fs::write( + &playbook, + format!( + "name: retry\ntasks:\n - name: retry\n local_action: n=$(cat {} 2>/dev/null || echo 0); n=$((n+1)); echo $n > {}; exit 1\n retries: 2\n retry_delay: 0\n ignore_error: true\n", + count.display(), + count.display() + ), + ) + .unwrap(); + run_file( + &playbook, + Some(&inventory), + RunOptions { + dry_run: false, + full_output: false, + }, + ) + .await + .unwrap(); + assert_eq!(std::fs::read_to_string(count).unwrap().trim(), "3"); + } + + #[tokio::test] + async fn local_action_retries_and_allowed_exit_codes() { + let dir = tempdir().unwrap(); + let inventory = dir.path().join("inventory.yaml"); + let playbook = dir.path().join("playbook.yaml"); + std::fs::write( + &inventory, + "hosts:\n - name: localhost\n address: 127.0.0.1\n", + ) + .unwrap(); + std::fs::write(&playbook, "name: local\nparallel: false\ntasks:\n - name: accepted\n local_action: exit 3\n allowed_exit_codes: [3]\n").unwrap(); + run_file( + &playbook, + Some(&inventory), + RunOptions { + dry_run: false, + full_output: false, + }, + ) + .await + .unwrap(); + } +} diff --git a/src/playbook/mod.rs b/src/playbook/mod.rs new file mode 100644 index 00000000..d5011e16 --- /dev/null +++ b/src/playbook/mod.rs @@ -0,0 +1,15 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +//! sshot-style YAML playbooks. +//! +//! This is a deliberately limited playbook runner, not an Ansible compatibility +//! layer. Parsing, condition evaluation, and execution are isolated here so the +//! existing bssh command paths remain unchanged. + +mod condition; +mod executor; +mod model; +mod parser; + +pub use executor::{RunOptions, run_file}; diff --git a/src/playbook/model.rs b/src/playbook/model.rs new file mode 100644 index 00000000..fcb5ac66 --- /dev/null +++ b/src/playbook/model.rs @@ -0,0 +1,117 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +use serde_yaml::{Mapping, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +pub type Variables = BTreeMap; + +#[derive(Debug, Clone)] +pub struct SshDefaults { + pub user: Option, + pub port: Option, + pub identity_file: Option, + pub use_agent: bool, + pub strict_host_key_checking: Option, + pub connect_timeout: Option, +} + +impl Default for SshDefaults { + fn default() -> Self { + Self { + user: None, + port: None, + identity_file: None, + use_agent: false, + strict_host_key_checking: Some("yes".to_owned()), + connect_timeout: None, + } + } +} + +#[derive(Debug, Clone)] +pub struct InventoryHost { + pub name: String, + pub hostname: String, + pub ssh: SshDefaults, + pub variables: Variables, + pub groups: BTreeSet, +} + +#[derive(Debug, Clone, Default)] +pub struct Inventory { + pub ssh: SshDefaults, + pub hosts: BTreeMap, + pub groups: BTreeMap>, + pub group_order: BTreeMap, + pub group_parallel: BTreeMap, + pub group_dependencies: BTreeMap>, + pub order: Vec, +} + +#[derive(Debug, Clone)] +pub struct Playbook { + pub plays: Vec, + pub inventory: Option, +} + +#[derive(Debug, Clone)] +pub struct Play { + pub name: String, + pub hosts: Vec, + pub groups: Vec, + pub variables: Variables, + pub facts: BTreeMap, + pub tasks: Vec, + pub parallel: usize, + pub dependencies: Vec, +} + +#[derive(Debug, Clone)] +pub struct Task { + pub name: String, + pub action: Action, + pub condition: Option, + pub register: Option, + pub retries: u32, + pub retry_delay: u64, + pub timeout: Option, + pub allowed_exit_codes: Vec, + pub ignore_error: bool, + pub sudo: bool, + pub variables: Variables, + pub dependencies: Vec, + pub groups: Vec, + pub skip_groups: Vec, + pub delegate_to: Option, + pub run_once: bool, +} + +#[derive(Debug, Clone)] +pub enum Action { + Command(String), + Shell(String), + Script { + source: PathBuf, + args: String, + }, + Copy { + source: PathBuf, + destination: String, + mode: Option, + }, + WaitFor { + command: String, + interval: u64, + }, + Local(String), +} + +pub(crate) fn key(name: &str) -> Value { + Value::String(name.to_owned()) +} + +pub(crate) fn map_get<'a>(map: &'a Mapping, name: &str) -> Option<&'a Value> { + map.get(&key(name)) +} diff --git a/src/playbook/parser.rs b/src/playbook/parser.rs new file mode 100644 index 00000000..cfd1bf0b --- /dev/null +++ b/src/playbook/parser.rs @@ -0,0 +1,767 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +use super::model::*; +use anyhow::{Context, Result, bail}; +use serde_yaml::{Mapping, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +pub fn load(playbook_path: &Path, inventory_path: Option<&Path>) -> Result { + let playbook_value = read_yaml(playbook_path)?; + let external_inventory = inventory_path.map(read_yaml).transpose()?; + let embedded = root_map(&playbook_value) + .and_then(|m| map_get(m, "inventory")) + .map(parse_inventory) + .transpose()?; + let inventory = external_inventory + .as_ref() + .map(parse_inventory) + .transpose()? + .or(embedded); + let plays = parse_plays(&playbook_value)?; + if plays.is_empty() { + bail!("playbook contains no plays or tasks"); + } + Ok(Playbook { plays, inventory }) +} + +fn read_yaml(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("failed to read YAML {}", path.display()))?; + serde_yaml::from_str(&text).with_context(|| format!("invalid YAML in {}", path.display())) +} + +fn root_map(value: &Value) -> Option<&Mapping> { + value.as_mapping() +} + +pub fn parse_inventory(value: &Value) -> Result { + let mut inventory = Inventory::default(); + let mut root = value + .as_mapping() + .ok_or_else(|| anyhow::anyhow!("inventory must be a YAML mapping"))?; + if let Some(inner) = map_get(root, "inventory").and_then(Value::as_mapping) { + root = inner; + } + if let Some(all) = map_get(root, "all").and_then(Value::as_mapping) { + parse_inventory_section(all, &mut inventory, None)?; + } + parse_inventory_section(root, &mut inventory, None)?; + if inventory.order.is_empty() { + let mut groups = inventory.groups.keys().cloned().collect::>(); + groups.sort_by_key(|group| inventory.group_order.get(group).copied().unwrap_or(0)); + for group in groups { + for host in &inventory.groups[&group] { + if !inventory.order.contains(host) { + inventory.order.push(host.clone()); + } + } + } + for host in inventory.hosts.keys() { + if !inventory.order.contains(host) { + inventory.order.push(host.clone()); + } + } + } + // Resolve group members after all host aliases are known. + for (group, members) in inventory.groups.clone() { + for name in members { + let host = inventory.hosts.get_mut(&name).ok_or_else(|| { + anyhow::anyhow!("group '{group}' references unknown inventory host '{name}'") + })?; + host.groups.insert(group.clone()); + } + } + if inventory.hosts.is_empty() { + bail!("inventory contains no hosts"); + } + Ok(inventory) +} + +fn parse_inventory_section( + map: &Mapping, + inventory: &mut Inventory, + inherited_group: Option<&str>, +) -> Result<()> { + if let Some(ssh) = map_get(map, "ssh_config") + .or_else(|| map_get(map, "ssh")) + .or_else(|| map_get(map, "defaults")) + { + inventory.ssh = parse_ssh(ssh, &inventory.ssh)?; + } + if let Some(vars) = map_get(map, "vars").and_then(Value::as_mapping) { + let mut overlay = Mapping::new(); + for (k, v) in vars { + overlay.insert(k.clone(), v.clone()); + } + inventory.ssh = parse_ssh(&Value::Mapping(overlay), &inventory.ssh)?; + } + if let Some(hosts) = map_get(map, "hosts") { + parse_hosts(hosts, inventory, inherited_group)?; + } + if let Some(order) = map_get(map, "order") { + inventory.order = strings(order, "inventory order")?; + } + let group_values = map_get(map, "groups").or_else(|| map_get(map, "children")); + if let Some(groups) = group_values { + match groups { + Value::Sequence(sequence) => { + for body in sequence { + let group_map = body + .as_mapping() + .ok_or_else(|| anyhow::anyhow!("group list entries must be mappings"))?; + let group = required_string(group_map, &["name"])?; + parse_group(&group, body, inventory)?; + } + } + Value::Mapping(groups) => { + for (group_key, body) in groups { + let group = scalar_string(group_key, "group name")?; + parse_group(&group, body, inventory)?; + if let Some(group_map) = body.as_mapping() + && let Some(children) = + map_get(group_map, "children").and_then(Value::as_mapping) + { + parse_inventory_section( + &Mapping::from_iter([( + key("groups"), + Value::Mapping(children.clone()), + )]), + inventory, + Some(&group), + )?; + } + } + } + _ => bail!("inventory groups must be a sequence or mapping"), + } + } + Ok(()) +} + +fn parse_group(group: &str, body: &Value, inventory: &mut Inventory) -> Result<()> { + let members = if let Some(sequence) = body.as_sequence() { + sequence + .iter() + .map(|value| scalar_string(value, "group host")) + .collect::>>()? + } else if let Some(map) = body.as_mapping() { + if let Some(hosts) = map_get(map, "hosts") { + parse_hosts(hosts, inventory, Some(group))?; + Vec::new() + } else { + Vec::new() + } + } else { + bail!("group '{group}' must be a sequence or mapping") + }; + inventory + .groups + .entry(group.to_owned()) + .or_default() + .extend(members); + if let Some(map) = body.as_mapping() { + if let Some(order) = map_get(map, "order").and_then(Value::as_i64) { + inventory.group_order.insert(group.to_owned(), order); + } + if let Some(parallel) = opt_bool(map, &["parallel"])? { + inventory.group_parallel.insert(group.to_owned(), parallel); + } + if let Some(dependencies) = map_get(map, "depends_on") { + inventory.group_dependencies.insert( + group.to_owned(), + strings(dependencies, "group dependencies")?, + ); + } + } + Ok(()) +} + +fn parse_hosts(value: &Value, inventory: &mut Inventory, group: Option<&str>) -> Result<()> { + match value { + Value::Sequence(seq) => { + for item in seq { + if let Some(map) = item.as_mapping() { + let name = opt_string(map, &["name"])? + .or(opt_string(map, &["hostname", "address"])?) + .ok_or_else(|| { + anyhow::anyhow!("host list entry requires name, hostname, or address") + })?; + add_host(inventory, &name, item.clone(), group)?; + } else { + let name = scalar_string(item, "inventory host")?; + add_host(inventory, &name, Value::Null, group)?; + } + } + } + Value::Mapping(map) => { + for (name, body) in map { + add_host( + inventory, + &scalar_string(name, "host name")?, + body.clone(), + group, + )?; + } + } + _ => bail!("inventory hosts must be a sequence or mapping"), + } + Ok(()) +} + +fn add_host(inventory: &mut Inventory, name: &str, body: Value, group: Option<&str>) -> Result<()> { + let map = body.as_mapping(); + let hostname = match &body { + Value::String(s) => s.clone(), + _ => map + .and_then(|m| { + map_get(m, "address") + .or_else(|| map_get(m, "hostname")) + .or_else(|| map_get(m, "host")) + }) + .map(|v| scalar_string(v, "hostname")) + .transpose()? + .unwrap_or_else(|| name.to_owned()), + }; + let ssh = if let Some(m) = map { + parse_ssh(&Value::Mapping(m.clone()), &inventory.ssh)? + } else { + inventory.ssh.clone() + }; + let variables = map + .and_then(|m| map_get(m, "variables").or_else(|| map_get(m, "vars"))) + .map(parse_variables) + .transpose()? + .unwrap_or_default(); + let entry = inventory + .hosts + .entry(name.to_owned()) + .or_insert(InventoryHost { + name: name.to_owned(), + hostname, + ssh, + variables, + groups: BTreeSet::new(), + }); + if let Some(group) = group { + entry.groups.insert(group.to_owned()); + inventory + .groups + .entry(group.to_owned()) + .or_default() + .push(name.to_owned()); + } + Ok(()) +} + +fn parse_ssh(value: &Value, base: &SshDefaults) -> Result { + let Some(map) = value.as_mapping() else { + return Ok(base.clone()); + }; + let nested = map_get(map, "ssh") + .or_else(|| map_get(map, "ssh_config")) + .and_then(Value::as_mapping) + .unwrap_or(map); + for unsupported in ["password", "key_password"] { + if map_get(nested, unsupported).is_some() { + bail!( + "inventory SSH field '{unsupported}' is unsupported: bssh playbooks do not accept plaintext passwords or key passphrases" + ); + } + } + let mut out = base.clone(); + out.user = opt_string(nested, &["user", "username", "ansible_user"])?.or(out.user); + out.port = opt_u64(nested, &["port", "ansible_port"])? + .map(|n| n as u16) + .or(out.port); + out.identity_file = opt_string( + nested, + &[ + "key_file", + "identity_file", + "key", + "private_key", + "ansible_ssh_private_key_file", + ], + )? + .map(expand_tilde) + .or(out.identity_file); + out.use_agent = opt_bool(nested, &["use_agent", "agent"])?.unwrap_or(out.use_agent); + if let Some(strict) = opt_bool(nested, &["strict_host_key_check"])? { + out.strict_host_key_checking = Some(if strict { "yes" } else { "no" }.to_owned()); + } else { + out.strict_host_key_checking = + opt_string(nested, &["strict_host_key_checking"])?.or(out.strict_host_key_checking); + } + out.connect_timeout = opt_u64(nested, &["connect_timeout"])?.or(out.connect_timeout); + Ok(out) +} + +fn parse_plays(value: &Value) -> Result> { + let source = if let Some(map) = value.as_mapping() { + map_get(map, "plays") + .or_else(|| map_get(map, "playbook")) + .unwrap_or(value) + } else { + value + }; + match source { + Value::Sequence(seq) => seq.iter().map(parse_play).collect(), + Value::Mapping(map) if map_get(map, "tasks").is_some() => Ok(vec![parse_play(source)?]), + _ => bail!("playbook must be a play mapping, a sequence of plays, or contain 'plays'"), + } +} + +fn parse_play(value: &Value) -> Result { + let map = value + .as_mapping() + .ok_or_else(|| anyhow::anyhow!("play must be a mapping"))?; + let name = opt_string(map, &["name"])?.unwrap_or_else(|| "play".to_owned()); + let hosts = map_get(map, "hosts") + .map(|v| strings(v, "play hosts")) + .transpose()? + .unwrap_or_else(|| vec!["all".to_owned()]); + let groups = map_get(map, "groups") + .map(|v| strings(v, "play groups")) + .transpose()? + .unwrap_or_default(); + let variables = map_get(map, "variables") + .or_else(|| map_get(map, "vars")) + .map(parse_variables) + .transpose()? + .unwrap_or_default(); + let facts = map_get(map, "facts") + .map(parse_facts) + .transpose()? + .unwrap_or_default(); + let tasks = map_get(map, "tasks") + .and_then(Value::as_sequence) + .ok_or_else(|| anyhow::anyhow!("play '{name}' requires a tasks sequence"))? + .iter() + .map(parse_task) + .collect::>()?; + let parallel = match map_get(map, "parallel") { + Some(Value::Bool(true)) => 10, + Some(Value::Bool(false)) => 1, + Some(value) => value + .as_u64() + .ok_or_else(|| anyhow::anyhow!("parallel must be a boolean or positive integer"))? + .max(1) as usize, + None => 1, + }; + let dependencies = map_get(map, "dependencies") + .or_else(|| map_get(map, "depends_on")) + .map(|v| strings(v, "play dependencies")) + .transpose()? + .unwrap_or_default(); + Ok(Play { + name, + hosts, + groups, + variables, + facts, + tasks, + parallel, + dependencies, + }) +} + +fn parse_task(value: &Value) -> Result { + let map = value + .as_mapping() + .ok_or_else(|| anyhow::anyhow!("task must be a mapping"))?; + let name = opt_string(map, &["name"])?.unwrap_or_else(|| "task".to_owned()); + let action = parse_action(map).with_context(|| format!("task '{name}'"))?; + let condition = opt_string(map, &["when", "condition"])?; + let register = opt_string(map, &["register"])?; + let until_success = opt_bool(map, &["until_success"])?.unwrap_or(false); + let configured_retries = + opt_u64(map, &["retries"])?.unwrap_or(if until_success { 60 } else { 0 }); + let retries = configured_retries + .checked_add(1) + .and_then(|attempts| u32::try_from(attempts).ok()) + .ok_or_else(|| anyhow::anyhow!("retries is too large"))?; + let retry_delay = opt_u64(map, &["retry_delay", "delay"])? + .unwrap_or(if configured_retries > 0 { 5 } else { 0 }); + let timeout = opt_u64(map, &["timeout"])?; + let allowed_exit_codes = map_get(map, "allowed_exit_codes") + .or_else(|| map_get(map, "success_exit_codes")) + .map(u32s) + .transpose()? + .unwrap_or_else(|| vec![0]); + let ignore_error = opt_bool(map, &["ignore_error", "ignore_errors"])?.unwrap_or(false); + let sudo = opt_bool(map, &["sudo"])?.unwrap_or(false); + let variables = map_get(map, "vars") + .map(parse_variables) + .transpose()? + .unwrap_or_default(); + let dependencies = map_get(map, "dependencies") + .or_else(|| map_get(map, "depends_on")) + .map(|v| strings(v, "task dependencies")) + .transpose()? + .unwrap_or_default(); + let groups = map_get(map, "groups") + .or_else(|| map_get(map, "only_groups")) + .map(|v| strings(v, "task groups")) + .transpose()? + .unwrap_or_default(); + let skip_groups = map_get(map, "skip_groups") + .map(|v| strings(v, "task skip_groups")) + .transpose()? + .unwrap_or_default(); + let delegate_to = opt_string(map, &["delegate_to"])?; + let run_once = opt_bool(map, &["run_once"])?.unwrap_or(false); + Ok(Task { + name, + action, + condition, + register, + retries, + retry_delay, + timeout, + allowed_exit_codes, + ignore_error, + sudo, + variables, + dependencies, + groups, + skip_groups, + delegate_to, + run_once, + }) +} + +fn parse_action(map: &Mapping) -> Result { + if let Some(v) = map_get(map, "command") { + return Ok(Action::Command(action_text(v)?)); + } + if let Some(v) = map_get(map, "shell") { + return Ok(Action::Shell(action_text(v)?)); + } + if let Some(v) = map_get(map, "local_action") { + return Ok(Action::Local(action_text(v)?)); + } + if let Some(v) = map_get(map, "script") { + return match v { + Value::String(s) => Ok(Action::Script { + source: PathBuf::from(s), + args: String::new(), + }), + Value::Mapping(m) => Ok(Action::Script { + source: PathBuf::from(required_string(m, &["source", "src", "path"])?), + args: opt_string(m, &["args"])?.unwrap_or_default(), + }), + _ => bail!("script must be a path or mapping"), + }; + } + if let Some(v) = map_get(map, "copy") { + let m = v + .as_mapping() + .ok_or_else(|| anyhow::anyhow!("copy must be a mapping"))?; + return Ok(Action::Copy { + source: PathBuf::from(required_string(m, &["source", "src"])?), + destination: required_string(m, &["destination", "dest"])?, + mode: opt_string(m, &["mode"])?, + }); + } + if let Some(v) = map_get(map, "wait_for") { + return match v { + Value::String(s) => Ok(Action::WaitFor { + command: wait_for_command(s)?, + interval: 2, + }), + Value::Mapping(m) => { + let command = if let Some(c) = opt_string(m, &["command"])? { + c + } else { + let host = opt_string(m, &["host"])?.unwrap_or_else(|| "127.0.0.1".to_owned()); + let port = opt_u64(m, &["port"])? + .ok_or_else(|| anyhow::anyhow!("wait_for requires command or port"))?; + format!("nc -z {} {}", shell_quote(&host), port) + }; + Ok(Action::WaitFor { + command, + interval: opt_u64(m, &["interval", "sleep"])?.unwrap_or(1).max(1), + }) + } + _ => bail!("wait_for must be a command or mapping"), + }; + } + bail!("requires one action: command, shell, script, copy, wait_for, or local_action") +} + +fn wait_for_command(spec: &str) -> Result { + let (kind, value) = spec + .split_once(':') + .ok_or_else(|| anyhow::anyhow!("wait_for must use type:value"))?; + if value.is_empty() { + bail!("wait_for value cannot be empty") + } + Ok(match kind { + "port" => format!("nc -z localhost {}", shell_quote(value)), + "service" => format!("systemctl is-active {}", shell_quote(value)), + "file" => format!("test -f {}", shell_quote(value)), + "http" => format!("curl -sf {}", shell_quote(value)), + _ => bail!("unsupported wait_for type '{kind}'"), + }) +} + +fn action_text(value: &Value) -> Result { + match value { + Value::String(s) => Ok(s.clone()), + Value::Mapping(m) => required_string(m, &["command", "cmd", "argv", "args"]), + _ => bail!("action must be a string or mapping"), + } +} + +fn parse_variables(value: &Value) -> Result { + let map = value + .as_mapping() + .ok_or_else(|| anyhow::anyhow!("variables must be a mapping"))?; + map.iter() + .map(|(k, v)| Ok((scalar_string(k, "variable name")?, v.clone()))) + .collect() +} + +fn parse_facts(value: &Value) -> Result> { + let map = value + .as_mapping() + .ok_or_else(|| anyhow::anyhow!("facts must be a mapping"))?; + if let Some(collectors) = map_get(map, "collectors") { + let sequence = collectors + .as_sequence() + .ok_or_else(|| anyhow::anyhow!("facts.collectors must be a sequence"))?; + return sequence + .iter() + .map(|collector| { + let collector = collector + .as_mapping() + .ok_or_else(|| anyhow::anyhow!("fact collector must be a mapping"))?; + Ok(( + required_string(collector, &["name"])?, + ( + required_string(collector, &["command"])?, + opt_bool(collector, &["sudo"])?.unwrap_or(false), + ), + )) + }) + .collect(); + } + map.iter() + .map(|(k, v)| Ok((scalar_string(k, "fact name")?, (action_text(v)?, false)))) + .collect() +} + +fn strings(value: &Value, label: &str) -> Result> { + match value { + Value::Sequence(seq) => seq.iter().map(|v| scalar_string(v, label)).collect(), + Value::String(s) => Ok(s + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect()), + _ => bail!("{label} must be a string or sequence"), + } +} + +fn u32s(value: &Value) -> Result> { + match value { + Value::Sequence(seq) => seq + .iter() + .map(|v| { + v.as_u64() + .and_then(|n| u32::try_from(n).ok()) + .ok_or_else(|| anyhow::anyhow!("exit codes must be non-negative integers")) + }) + .collect(), + _ => value + .as_u64() + .and_then(|n| u32::try_from(n).ok()) + .map(|n| vec![n]) + .ok_or_else(|| anyhow::anyhow!("exit codes must be an integer or sequence")), + } +} + +fn scalar_string(value: &Value, label: &str) -> Result { + match value { + Value::String(s) => Ok(s.clone()), + Value::Number(n) => Ok(n.to_string()), + _ => bail!("{label} must be a string"), + } +} + +fn opt_string(map: &Mapping, names: &[&str]) -> Result> { + for name in names { + if let Some(v) = map_get(map, name) { + return scalar_string(v, name).map(Some); + } + } + Ok(None) +} +fn required_string(map: &Mapping, names: &[&str]) -> Result { + opt_string(map, names)?.ok_or_else(|| anyhow::anyhow!("missing '{}'", names.join("/"))) +} +fn opt_u64(map: &Mapping, names: &[&str]) -> Result> { + for name in names { + if let Some(v) = map_get(map, name) { + return v + .as_u64() + .ok_or_else(|| anyhow::anyhow!("{name} must be a non-negative integer")) + .map(Some); + } + } + Ok(None) +} +fn opt_bool(map: &Mapping, names: &[&str]) -> Result> { + for name in names { + if let Some(v) = map_get(map, name) { + return v + .as_bool() + .ok_or_else(|| anyhow::anyhow!("{name} must be a boolean")) + .map(Some); + } + } + Ok(None) +} + +fn expand_tilde(path: String) -> PathBuf { + if path == "~" { + dirs::home_dir().unwrap_or_else(|| PathBuf::from(path)) + } else if let Some(rest) = path.strip_prefix("~/") { + dirs::home_dir() + .map(|p| p.join(rest)) + .unwrap_or_else(|| PathBuf::from(path)) + } else { + PathBuf::from(path) + } +} + +pub(crate) fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_exact_sshot_standalone_shapes() { + let inv: Value = serde_yaml::from_str( + r#" +ssh_config: + user: deploy + key_file: ~/.ssh/deploy + port: 2222 + strict_host_key_check: false +hosts: + - name: direct + address: 10.0.0.10 +groups: + - name: web + order: 2 + parallel: true + depends_on: [database] + hosts: + - name: web1 + address: 10.0.0.11 + vars: {env: production} +"#, + ) + .unwrap(); + let inv = parse_inventory(&inv).unwrap(); + assert_eq!(inv.hosts["web1"].ssh.user.as_deref(), Some("deploy")); + assert_eq!(inv.hosts["web1"].hostname, "10.0.0.11"); + assert_eq!(inv.group_order["web"], 2); + assert_eq!(inv.group_parallel["web"], true); + assert_eq!(inv.group_dependencies["web"], vec!["database"]); + assert_eq!(inv.ssh.strict_host_key_checking.as_deref(), Some("no")); + + let pb: Value = serde_yaml::from_str( + r#" +name: deploy +parallel: true +facts: + collectors: + - name: os + command: echo '{"name":"linux"}' + sudo: true +tasks: + - name: copy + copy: {src: app.conf, dest: /etc/app.conf, mode: "0640"} + sudo: true + vars: {release: v1} + skip_groups: [database] + until_success: true + ignore_error: true + allowed_exit_codes: [0, 3] +"#, + ) + .unwrap(); + let play = &parse_plays(&pb).unwrap()[0]; + assert_eq!(play.parallel, 10); + assert_eq!(play.facts["os"].1, true); + let task = &play.tasks[0]; + assert!(task.sudo && task.ignore_error); + assert_eq!(task.retries, 61); + assert_eq!(task.retry_delay, 5); + assert_eq!(task.skip_groups, vec!["database"]); + assert!(matches!(&task.action, Action::Copy { mode: Some(mode), .. } if mode == "0640")); + } + + #[test] + fn retry_counts_and_security_defaults_are_exact() { + let play: Value = serde_yaml::from_str( + "name: retry\ntasks:\n - name: retry\n command: 'false'\n retries: 2\n", + ) + .unwrap(); + let task = &parse_plays(&play).unwrap()[0].tasks[0]; + assert_eq!(task.retries, 3); + assert_eq!(task.retry_delay, 5); + + let inventory: Value = + serde_yaml::from_str("hosts:\n - name: host\n address: 127.0.0.1\n").unwrap(); + assert_eq!( + parse_inventory(&inventory) + .unwrap() + .ssh + .strict_host_key_checking + .as_deref(), + Some("yes") + ); + for forbidden in ["password", "key_password"] { + let yaml = format!( + "ssh_config:\n {forbidden}: secret\nhosts:\n - name: host\n address: 127.0.0.1\n" + ); + let error = parse_inventory(&serde_yaml::from_str(&yaml).unwrap()).unwrap_err(); + assert!(error.to_string().contains("unsupported")); + assert!(error.to_string().contains(forbidden)); + } + } + + #[test] + fn parses_sshot_wait_for_forms_and_legacy_combined_shape() { + for spec in [ + "port:80", + "service:sshd", + "file:/tmp/ready", + "http:https://example.test/health", + ] { + let yaml = format!("name: wait\\ntasks:\\n - name: wait\\n wait_for: {spec}\\n") + .replace("\\n", "\n"); + let value: Value = serde_yaml::from_str(&yaml).unwrap(); + assert!(matches!( + parse_plays(&value).unwrap()[0].tasks[0].action, + Action::WaitFor { .. } + )); + } + let value: Value = serde_yaml::from_str("inventory:\n hosts:\n - name: localhost\n address: 127.0.0.1\nplaybook:\n name: legacy\n parallel: false\n tasks:\n - local_action: echo ok\n").unwrap(); + assert!( + parse_inventory(map_get(value.as_mapping().unwrap(), "inventory").unwrap()).is_ok() + ); + let play = &parse_plays(&value).unwrap()[0]; + assert_eq!(play.parallel, 1); + assert!(matches!(play.tasks[0].action, Action::Local(_))); + } +} From 0e10e1bd34c1a0863a2dbcf2ae86662f0fc7acbd Mon Sep 17 00:00:00 2001 From: embh0 <270190780+embh0@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:13:00 +0200 Subject: [PATCH 2/5] build: add Makefile workflows --- Makefile | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..66f2d546 --- /dev/null +++ b/Makefile @@ -0,0 +1,106 @@ +SHELL := /bin/sh + +CARGO ?= cargo +BINARY ?= bssh +BUILD_DIR ?= target/release +BIN := $(BUILD_DIR)/$(BINARY) +PLAYBOOK ?= examples/playbook.yaml +INVENTORY ?= examples/playbook-inventory.yaml +ARGS ?= +RUST_VERSION ?= 1.93 +DOCKER_IMAGE ?= rust:$(RUST_VERSION) + +.PHONY: help setup toolchain dependencies deps build install check test test-playbook \ + fmt fmt-check lint clean playbook playbook-combined dry-run dry-run-combined \ + demo playbook-help docker-check docker-test + +help: ## Show available commands + @printf '%s\n' \ + 'Common commands:' \ + ' make setup Install Rust tooling and fetch dependencies' \ + ' make dependencies Download locked Cargo dependencies' \ + ' make build Build the release binary' \ + ' make install Install bssh with cargo' \ + ' make check Check all Rust targets' \ + ' make test Run the workspace tests' \ + ' make test-playbook Run only playbook tests' \ + ' make demo Dry-run the included example' \ + ' make playbook Run the configured playbook' \ + ' make dry-run Dry-run the configured playbook' \ + '' \ + 'Playbook overrides:' \ + ' make playbook PLAYBOOK=deploy.yaml INVENTORY=inventory.yaml' \ + ' make playbook-combined PLAYBOOK=combined.yaml' \ + ' make dry-run-combined PLAYBOOK=combined.yaml' \ + ' make playbook ARGS="--full-output"' \ + '' \ + 'Toolchain-free validation (requires Docker):' \ + ' make docker-check' \ + ' make docker-test' + +setup: toolchain dependencies ## Install the Rust toolchain and fetch dependencies + +toolchain: ## Install the supported Rust toolchain, formatter, and linter + @command -v rustup >/dev/null 2>&1 || { echo "rustup is required: https://rustup.rs" >&2; exit 1; } + rustup toolchain install $(RUST_VERSION) --profile minimal --component rustfmt,clippy + +dependencies: ## Download all locked Cargo dependencies + $(CARGO) fetch --locked + +deps: dependencies ## Alias for dependencies + +build: ## Build the optimized bssh binary + $(CARGO) build --release --locked --bin $(BINARY) + +install: ## Install bssh from this checkout + $(CARGO) install --path . --locked + +check: ## Type-check all targets + $(CARGO) check --all-targets --locked + +test: ## Run all workspace tests + $(CARGO) test --workspace --locked + +test-playbook: ## Run only playbook tests + $(CARGO) test --lib playbook --locked + +fmt: ## Format Rust sources + $(CARGO) fmt --all + +fmt-check: ## Verify Rust formatting + $(CARGO) fmt --all -- --check + +lint: ## Run Clippy with warnings treated as errors + $(CARGO) clippy --all-targets --all-features --locked -- -D warnings + +clean: ## Remove build artifacts + $(CARGO) clean + +playbook: build ## Run separate playbook and inventory files + "$(BIN)" playbook "$(PLAYBOOK)" --inventory "$(INVENTORY)" $(ARGS) + +playbook-combined: build ## Run a legacy combined inventory/playbook file + "$(BIN)" playbook "$(PLAYBOOK)" $(ARGS) + +dry-run: build ## Preview separate playbook and inventory files + "$(BIN)" playbook "$(PLAYBOOK)" --inventory "$(INVENTORY)" --dry-run $(ARGS) + +dry-run-combined: build ## Preview a legacy combined inventory/playbook file + "$(BIN)" playbook "$(PLAYBOOK)" --dry-run $(ARGS) + +demo: dry-run ## Dry-run the included example inventory and playbook + +playbook-help: build ## Show playbook CLI help + "$(BIN)" playbook --help + +docker-check: ## Run cargo check with Rust $(RUST_VERSION) in Docker + docker run --rm --user "$$(id -u):$$(id -g)" \ + -e HOME=/tmp/home -e CARGO_HOME=/tmp/cargo-home \ + -v "$(CURDIR):/work" -w /work $(DOCKER_IMAGE) \ + cargo check --all-targets --locked + +docker-test: ## Run playbook tests with Rust $(RUST_VERSION) in Docker + docker run --rm --user "$$(id -u):$$(id -g)" \ + -e HOME=/tmp/home -e CARGO_HOME=/tmp/cargo-home \ + -v "$(CURDIR):/work" -w /work $(DOCKER_IMAGE) \ + cargo test --lib playbook --locked From 0dd89d72387eb6fcab70d90e8f4ca2afbe39e60a Mon Sep 17 00:00:00 2001 From: embh0 <270190780+embh0@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:55:30 +0200 Subject: [PATCH 3/5] build: bootstrap rustup and prepend ~/.cargo/bin in Makefile Co-Authored-By: Claude Fable 5 --- Makefile | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/Makefile b/Makefile index 66f2d546..71be3ebc 100644 --- a/Makefile +++ b/Makefile @@ -10,14 +10,15 @@ ARGS ?= RUST_VERSION ?= 1.93 DOCKER_IMAGE ?= rust:$(RUST_VERSION) -.PHONY: help setup toolchain dependencies deps build install check test test-playbook \ - fmt fmt-check lint clean playbook playbook-combined dry-run dry-run-combined \ - demo playbook-help docker-check docker-test +.PHONY: help setup bootstrap-rustup toolchain dependencies deps build install check test \ + test-playbook fmt fmt-check lint clean playbook playbook-combined dry-run \ + dry-run-combined demo playbook-help docker-check docker-test help: ## Show available commands @printf '%s\n' \ 'Common commands:' \ - ' make setup Install Rust tooling and fetch dependencies' \ + ' make setup Bootstrap rustup, install Rust, and fetch dependencies' \ + ' make bootstrap-rustup Install rustup when it is missing' \ ' make dependencies Download locked Cargo dependencies' \ ' make build Build the release binary' \ ' make install Install bssh with cargo' \ @@ -38,43 +39,46 @@ help: ## Show available commands ' make docker-check' \ ' make docker-test' -setup: toolchain dependencies ## Install the Rust toolchain and fetch dependencies +setup: toolchain dependencies ## Bootstrap Rust and fetch dependencies -toolchain: ## Install the supported Rust toolchain, formatter, and linter - @command -v rustup >/dev/null 2>&1 || { echo "rustup is required: https://rustup.rs" >&2; exit 1; } - rustup toolchain install $(RUST_VERSION) --profile minimal --component rustfmt,clippy +bootstrap-rustup: ## Install rustup from rustup.rs when it is missing + command -v rustup >/dev/null 2>&1 || [ -x "$(HOME)/.cargo/bin/rustup" ] || { command -v curl >/dev/null 2>&1 || { echo "curl is required to install rustup" >&2; exit 1; }; curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --no-modify-path; } + +toolchain: bootstrap-rustup ## Install the supported Rust toolchain, formatter, and linter + PATH="$(HOME)/.cargo/bin:$$PATH" rustup toolchain install $(RUST_VERSION) --profile minimal --component rustfmt,clippy + PATH="$(HOME)/.cargo/bin:$$PATH" rustup default $(RUST_VERSION) dependencies: ## Download all locked Cargo dependencies - $(CARGO) fetch --locked + PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) fetch --locked deps: dependencies ## Alias for dependencies build: ## Build the optimized bssh binary - $(CARGO) build --release --locked --bin $(BINARY) + PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) build --release --locked --bin $(BINARY) install: ## Install bssh from this checkout - $(CARGO) install --path . --locked + PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) install --path . --locked check: ## Type-check all targets - $(CARGO) check --all-targets --locked + PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) check --all-targets --locked test: ## Run all workspace tests - $(CARGO) test --workspace --locked + PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) test --workspace --locked test-playbook: ## Run only playbook tests - $(CARGO) test --lib playbook --locked + PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) test --lib playbook --locked fmt: ## Format Rust sources - $(CARGO) fmt --all + PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) fmt --all fmt-check: ## Verify Rust formatting - $(CARGO) fmt --all -- --check + PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) fmt --all -- --check lint: ## Run Clippy with warnings treated as errors - $(CARGO) clippy --all-targets --all-features --locked -- -D warnings + PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) clippy --all-targets --all-features --locked -- -D warnings clean: ## Remove build artifacts - $(CARGO) clean + PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) clean playbook: build ## Run separate playbook and inventory files "$(BIN)" playbook "$(PLAYBOOK)" --inventory "$(INVENTORY)" $(ARGS) From 2ec41d2d2bbc89eb1969c2f2a6df64de5d060a10 Mon Sep 17 00:00:00 2001 From: embh0 <270190780+embh0@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:06:36 +0200 Subject: [PATCH 4/5] feat: restructure CLI help into cobra-style grouped output Short one-line help for -h with grouped flag headings, full detail and examples under --help, plus an Available Commands section and per-command help footer. Co-Authored-By: Claude Fable 5 --- src/cli/bssh.rs | 140 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 108 insertions(+), 32 deletions(-) diff --git a/src/cli/bssh.rs b/src/cli/bssh.rs index 9816d871..c6a77421 100644 --- a/src/cli/bssh.rs +++ b/src/cli/bssh.rs @@ -20,15 +20,19 @@ use std::path::PathBuf; #[command( name = "bssh", version, - before_help = "\n\nBroadcast SSH - Parallel command execution across cluster nodes", about = "Broadcast SSH - SSH-compatible parallel command execution tool", long_about = "bssh is a high-performance SSH client with parallel execution capabilities.\nIt can be used as a drop-in replacement for SSH (single host) or as a powerful cluster management tool (multiple hosts).\n\nThe tool provides secure file transfer using SFTP and supports SSH keys, SSH agent, and password authentication.\nIt automatically detects Backend.AI multi-node session environments.\n\nOutput Modes:\n- TUI Mode (default): Interactive terminal UI with real-time monitoring (auto-enabled in terminals)\n- Stream Mode (--stream): Real-time output with [node] prefixes\n- File Mode (--output-dir): Save per-node output to timestamped files\n- Normal Mode: Traditional output after all nodes complete\n\nSSH Configuration Support:\n- Reads standard SSH config files (defaulting to ~/.ssh/config)\n- Supports Host patterns, HostName, User, Port, IdentityFile, StrictHostKeyChecking\n- ProxyJump, and many other SSH configuration directives\n- CLI arguments override SSH config values following SSH precedence rules", - after_help = "EXAMPLES:\n SSH Mode:\n bssh user@host # Interactive shell\n bssh admin@server.com \"uptime\" # Execute command\n bssh -p 2222 -i ~/.ssh/key user@host # Custom port and key\n bssh -F ~/.ssh/myconfig webserver # Use custom SSH config\n\n Port Forwarding:\n bssh -L 8080:example.com:80 user@host # Local forward: localhost:8080 -> example.com:80\n bssh -R 8080:localhost:80 user@host # Remote forward: remote:8080 -> localhost:80\n bssh -D 1080 user@host # SOCKS5 proxy on localhost:1080\n bssh -L 3306:db:3306 -R 80:web:80 user@host # Multiple forwards\n bssh -D *:1080/4 user@host # SOCKS4 proxy on all interfaces\n\n Multi-Server Mode:\n bssh -C production \"systemctl status\" # Execute on cluster (TUI mode auto-enabled)\n bssh -H \"web1,web2,web3\" \"df -h\" # Execute on multiple hosts\n bssh -H \"web1,web2,web3\" -f \"web1\" \"df -h\" # Filter to web1 only\n bssh -C production -f \"web*\" \"uptime\" # Filter cluster nodes\n bssh --parallel 20 -H web* \"apt update\" # Increase parallelism\n\n Hostlist Expression (pdsh-style range expansion):\n bssh -H \"node[1-5]\" \"uptime\" # Expands to node1, node2, node3, node4, node5\n bssh -H \"node[01-03]\" \"df -h\" # Zero-padded: node01, node02, node03\n bssh -H \"node[1,3,5]\" \"ps aux\" # Specific values: node1, node3, node5\n bssh -H \"node[1-3,7,9-10]\" \"uptime\" # Mixed: node1-3, node7, node9-10\n bssh -H \"rack[1-2]-node[1-3]\" \"uptime\" # Cartesian product: 6 hosts\n bssh -H \"web[1-3].example.com\" \"uptime\" # With domain suffix\n bssh -H \"admin@db[01-03]:5432\" \"psql\" # With user and port\n bssh -H \"^/etc/hosts.cluster\" \"uptime\" # Read hosts from file\n\n Host Exclusion (--exclude):\n bssh -H \"node1,node2,node3\" --exclude \"node2\" \"uptime\" # Exclude single host\n bssh -C production --exclude \"web1,web2\" \"apt update\" # Exclude multiple hosts\n bssh -C production --exclude \"db*\" \"systemctl restart\" # Exclude with wildcard pattern\n bssh -H \"node[1-10]\" --exclude \"node[3-5]\" \"uptime\" # Exclude with hostlist expression\n\n Fail-Fast Mode (pdsh -k compatible):\n bssh -k -H \"web[1-3]\" \"deploy.sh\" # Stop on first failure\n bssh --fail-fast -C prod \"apt upgrade\" # Critical deployment - stop if any node fails\n bssh -k --require-all-success -C prod cmd # Fail-fast + require all success\n\n Output Modes:\n bssh -C prod \"apt-get update\" # TUI mode (default, interactive monitoring)\n bssh -C prod --stream \"tail -f log\" # Stream mode (real-time with [node] prefixes)\n bssh -C prod --output-dir ./logs \"ps\" # File mode (save to timestamped files)\n bssh -C prod \"uptime\" | tee log.txt # Normal mode (auto-detected when piped)\n\n Batch Mode (Ctrl+C Handling):\n bssh -C prod \"long-running-command\" # Default: first Ctrl+C shows status, second terminates\n bssh -C prod -b \"long-command\" # Batch mode: single Ctrl+C terminates immediately\n bssh -H nodes --batch --stream \"cmd\" # Useful for CI/CD and non-interactive scripts\n\n TUI Mode Controls (when in TUI):\n 1-9 Jump to node detail view\n s Enter split view (2-4 nodes)\n d Enter diff view (compare nodes)\n f Toggle auto-scroll\n Up/Down Scroll output\n Left/Right Switch nodes\n Esc Return to summary\n ? Show help\n q Quit\n\n File Operations:\n bssh -C staging upload file.txt /tmp/ # Upload to cluster\n bssh -H host1,host2 download /etc/hosts ./backups/\n\n Other Commands:\n bssh list # List configured clusters\n bssh -C production ping # Test connectivity\n bssh -H hosts interactive # Interactive mode\n\n SSH Config Example (~/.ssh/config):\n Host web*\n HostName web.example.com\n User webuser\n Port 2222\n IdentityFile ~/.ssh/web_key\n StrictHostKeyChecking yes\n\nDeveloped and maintained as part of the Backend.AI project.\nFor more information: https://github.com/lablup/bssh" + subcommand_help_heading = "Available Commands", + help_template = "{about-with-newline}\nUsage:\n {usage}\n\n{all-args}{after-help}", + after_help = "\nUse \"bssh --help\" for more information about a command.\nUse \"bssh --help\" for the full flag reference and examples.", + after_long_help = "EXAMPLES:\n SSH Mode:\n bssh user@host # Interactive shell\n bssh admin@server.com \"uptime\" # Execute command\n bssh -p 2222 -i ~/.ssh/key user@host # Custom port and key\n bssh -F ~/.ssh/myconfig webserver # Use custom SSH config\n\n Port Forwarding:\n bssh -L 8080:example.com:80 user@host # Local forward: localhost:8080 -> example.com:80\n bssh -R 8080:localhost:80 user@host # Remote forward: remote:8080 -> localhost:80\n bssh -D 1080 user@host # SOCKS5 proxy on localhost:1080\n bssh -L 3306:db:3306 -R 80:web:80 user@host # Multiple forwards\n bssh -D *:1080/4 user@host # SOCKS4 proxy on all interfaces\n\n Multi-Server Mode:\n bssh -C production \"systemctl status\" # Execute on cluster (TUI mode auto-enabled)\n bssh -H \"web1,web2,web3\" \"df -h\" # Execute on multiple hosts\n bssh -H \"web1,web2,web3\" -f \"web1\" \"df -h\" # Filter to web1 only\n bssh -C production -f \"web*\" \"uptime\" # Filter cluster nodes\n bssh --parallel 20 -H web* \"apt update\" # Increase parallelism\n\n Hostlist Expression (pdsh-style range expansion):\n bssh -H \"node[1-5]\" \"uptime\" # Expands to node1, node2, node3, node4, node5\n bssh -H \"node[01-03]\" \"df -h\" # Zero-padded: node01, node02, node03\n bssh -H \"node[1,3,5]\" \"ps aux\" # Specific values: node1, node3, node5\n bssh -H \"node[1-3,7,9-10]\" \"uptime\" # Mixed: node1-3, node7, node9-10\n bssh -H \"rack[1-2]-node[1-3]\" \"uptime\" # Cartesian product: 6 hosts\n bssh -H \"web[1-3].example.com\" \"uptime\" # With domain suffix\n bssh -H \"admin@db[01-03]:5432\" \"psql\" # With user and port\n bssh -H \"^/etc/hosts.cluster\" \"uptime\" # Read hosts from file\n\n Host Exclusion (--exclude):\n bssh -H \"node1,node2,node3\" --exclude \"node2\" \"uptime\" # Exclude single host\n bssh -C production --exclude \"web1,web2\" \"apt update\" # Exclude multiple hosts\n bssh -C production --exclude \"db*\" \"systemctl restart\" # Exclude with wildcard pattern\n bssh -H \"node[1-10]\" --exclude \"node[3-5]\" \"uptime\" # Exclude with hostlist expression\n\n Fail-Fast Mode (pdsh -k compatible):\n bssh -k -H \"web[1-3]\" \"deploy.sh\" # Stop on first failure\n bssh --fail-fast -C prod \"apt upgrade\" # Critical deployment - stop if any node fails\n bssh -k --require-all-success -C prod cmd # Fail-fast + require all success\n\n Output Modes:\n bssh -C prod \"apt-get update\" # TUI mode (default, interactive monitoring)\n bssh -C prod --stream \"tail -f log\" # Stream mode (real-time with [node] prefixes)\n bssh -C prod --output-dir ./logs \"ps\" # File mode (save to timestamped files)\n bssh -C prod \"uptime\" | tee log.txt # Normal mode (auto-detected when piped)\n\n Batch Mode (Ctrl+C Handling):\n bssh -C prod \"long-running-command\" # Default: first Ctrl+C shows status, second terminates\n bssh -C prod -b \"long-command\" # Batch mode: single Ctrl+C terminates immediately\n bssh -H nodes --batch --stream \"cmd\" # Useful for CI/CD and non-interactive scripts\n\n TUI Mode Controls (when in TUI):\n 1-9 Jump to node detail view\n s Enter split view (2-4 nodes)\n d Enter diff view (compare nodes)\n f Toggle auto-scroll\n Up/Down Scroll output\n Left/Right Switch nodes\n Esc Return to summary\n ? Show help\n q Quit\n\n File Operations:\n bssh -C staging upload file.txt /tmp/ # Upload to cluster\n bssh -H host1,host2 download /etc/hosts ./backups/\n\n Other Commands:\n bssh list # List configured clusters\n bssh -C production ping # Test connectivity\n bssh -H hosts interactive # Interactive mode\n\n SSH Config Example (~/.ssh/config):\n Host web*\n HostName web.example.com\n User webuser\n Port 2222\n IdentityFile ~/.ssh/web_key\n StrictHostKeyChecking yes\n\nDeveloped and maintained as part of the Backend.AI project.\nFor more information: https://github.com/lablup/bssh\n\nUse \"bssh --help\" for more information about a command." )] pub struct Cli { - /// SSH destination in format: [user@]hostname[:port] or ssh://[user@]hostname[:port] - /// Used for SSH compatibility mode (single host connection) - #[arg(value_name = "destination")] + #[arg( + value_name = "destination", + help = "SSH destination [user@]hostname[:port] (single-host SSH mode)", + long_help = "SSH destination in format: [user@]hostname[:port] or ssh://[user@]hostname[:port]\nUsed for SSH compatibility mode (single host connection)" + )] pub destination: Option, #[command(subcommand)] @@ -38,27 +42,34 @@ pub struct Cli { short = 'H', long, value_delimiter = ',', - help = "Comma-separated list of hosts with hostlist expansion support\nFormat: [user@]hostname[:port] with optional range expressions\nRange expressions:\n node[1-5] -> node1, node2, node3, node4, node5\n node[01-03] -> node01, node02, node03 (zero-padded)\n node[1,3,5] -> node1, node3, node5\n rack[1-2]-node[1-3] -> 6 hosts (cartesian product)\n ^/path/to/file -> read hosts from file\nExamples:\n 'web[1-3].example.com' for web1-web3\n 'admin@db[01-03]:5432' for db01-db03 with user and port" + help_heading = "Target Selection", + help = "Comma-separated hosts with hostlist expansion (e.g. node[1-5])", + long_help = "Comma-separated list of hosts with hostlist expansion support\nFormat: [user@]hostname[:port] with optional range expressions\nRange expressions:\n node[1-5] -> node1, node2, node3, node4, node5\n node[01-03] -> node01, node02, node03 (zero-padded)\n node[1,3,5] -> node1, node3, node5\n rack[1-2]-node[1-3] -> 6 hosts (cartesian product)\n ^/path/to/file -> read hosts from file\nExamples:\n 'web[1-3].example.com' for web1-web3\n 'admin@db[01-03]:5432' for db01-db03 with user and port" )] pub hosts: Option>, #[arg( short = 'f', long = "filter", - help = "Filter hosts by pattern (supports wildcards and hostlist expressions)\nUse with -H or -C to execute on a subset of hosts\nExamples:\n 'web*' -> matches web01, web02, etc. (glob)\n 'node[1-3]' -> matches node1, node2, node3 (hostlist)" + help_heading = "Target Selection", + help = "Filter target hosts by pattern (glob or hostlist)", + long_help = "Filter hosts by pattern (supports wildcards and hostlist expressions)\nUse with -H or -C to execute on a subset of hosts\nExamples:\n 'web*' -> matches web01, web02, etc. (glob)\n 'node[1-3]' -> matches node1, node2, node3 (hostlist)" )] pub filter: Option, #[arg( long = "exclude", value_delimiter = ',', - help = "Exclude hosts from target list (comma-separated)\nSupports wildcards and hostlist expressions:\n Wildcards: '*' (any chars), '?' (single char), '[abc]' (char set)\n Hostlist: 'node[1-5]', 'node[1,3,5]', 'rack[1-2]-node[1-3]'\nMatching: glob for wildcards, exact for hostlist expansions\nApplied after --filter option" + help_heading = "Target Selection", + help = "Exclude hosts from target list (glob or hostlist)", + long_help = "Exclude hosts from target list (comma-separated)\nSupports wildcards and hostlist expressions:\n Wildcards: '*' (any chars), '?' (single char), '[abc]' (char set)\n Hostlist: 'node[1-5]', 'node[1,3,5]', 'rack[1-2]-node[1-3]'\nMatching: glob for wildcards, exact for hostlist expansions\nApplied after --filter option" )] pub exclude: Option>, #[arg( short = 'C', long = "cluster", + help_heading = "Target Selection", help = "Cluster name from configuration file (multi-server mode)" )] pub cluster: Option, @@ -66,33 +77,41 @@ pub struct Cli { #[arg( long, default_value = "~/.config/bssh/config.yaml", - help = "Configuration file path [default: ~/.config/bssh/config.yaml]\nConfig loading priority:\n 1. Backend.AI env vars (auto-detected)\n 2. Current directory (./config.yaml)\n 3. User config (~/.config/bssh/config.yaml)\n 4. This flag's value" + help_heading = "Configuration", + help = "Configuration file path", + long_help = "Configuration file path [default: ~/.config/bssh/config.yaml]\nConfig loading priority:\n 1. Backend.AI env vars (auto-detected)\n 2. Current directory (./config.yaml)\n 3. User config (~/.config/bssh/config.yaml)\n 4. This flag's value" )] pub config: PathBuf, #[arg( short = 'l', long = "login", - help = "Specifies the user to log in as on the remote machine (SSH-compatible)" + help_heading = "Authentication", + help = "User to log in as on the remote machine (SSH-compatible)" )] pub user: Option, #[arg( short = 'i', long, - help = "SSH private key file path (prompts for passphrase if encrypted)\nAutomatically detects encrypted keys and prompts for passphrase\nFalls back to default keys (~/.ssh/id_ed25519, ~/.ssh/id_rsa, etc.) if not specified" + help_heading = "Authentication", + help = "SSH private key file path", + long_help = "SSH private key file path (prompts for passphrase if encrypted)\nAutomatically detects encrypted keys and prompts for passphrase\nFalls back to default keys (~/.ssh/id_ed25519, ~/.ssh/id_rsa, etc.) if not specified" )] pub identity: Option, #[arg( short = 'A', long, - help = "Use SSH agent for authentication (Unix/Linux/macOS only)\nAuto-detected when SSH_AUTH_SOCK is set. Falls back to key file if agent auth fails" + help_heading = "Authentication", + help = "Use SSH agent for authentication", + long_help = "Use SSH agent for authentication (Unix/Linux/macOS only)\nAuto-detected when SSH_AUTH_SOCK is set. Falls back to key file if agent auth fails" )] pub use_agent: bool, #[arg( long = "password", + help_heading = "Authentication", help = "Use password authentication (will prompt for password)" )] pub password: bool, @@ -100,21 +119,27 @@ pub struct Cli { #[arg( short = 'S', long = "sudo-password", - help = "Prompt for sudo password to automatically respond to sudo prompts\nWhen enabled, bssh will:\n 1. Securely prompt for sudo password before execution\n 2. Detect sudo password prompts in command output\n 3. Automatically inject the password when prompted\n\nAlternatively, set BSSH_SUDO_PASSWORD environment variable (not recommended)\nSecurity: Password is cleared from memory after use" + help_heading = "Authentication", + help = "Prompt for sudo password and auto-respond to sudo prompts", + long_help = "Prompt for sudo password to automatically respond to sudo prompts\nWhen enabled, bssh will:\n 1. Securely prompt for sudo password before execution\n 2. Detect sudo password prompts in command output\n 3. Automatically inject the password when prompted\n\nAlternatively, set BSSH_SUDO_PASSWORD environment variable (not recommended)\nSecurity: Password is cleared from memory after use" )] pub sudo_password: bool, #[arg( short = 'b', long = "batch", - help = "Batch mode: single Ctrl+C immediately terminates all jobs\nDisables two-stage Ctrl+C handling (status display on first press)\nUseful for non-interactive scripts and CI/CD pipelines\nNote: TUI mode has its own quit handling (q or Ctrl+C) and ignores this flag" + help_heading = "Execution Control", + help = "Batch mode: single Ctrl+C immediately terminates all jobs", + long_help = "Batch mode: single Ctrl+C immediately terminates all jobs\nDisables two-stage Ctrl+C handling (status display on first press)\nUseful for non-interactive scripts and CI/CD pipelines\nNote: TUI mode has its own quit handling (q or Ctrl+C) and ignores this flag" )] pub batch: bool, #[arg( short = 'J', long = "jump-host", - help = "Comma-separated list of jump hosts (ProxyJump)\n\ + help_heading = "Connection", + help = "Comma-separated jump hosts (ProxyJump)", + long_help = "Comma-separated list of jump hosts (ProxyJump)\n\ Format: [user@]hostname[:port]\n\ Examples:\n \ bai@bastion:4300 (user 'bai' on port 4300)\n \ @@ -128,6 +153,7 @@ pub struct Cli { #[arg( long = "parallel", default_value = "10", + help_heading = "Target Selection", help = "Maximum parallel connections (multi-server mode)" )] pub parallel: usize, @@ -136,26 +162,33 @@ pub struct Cli { short = 'p', long = "port", value_name = "port", + help_heading = "Connection", help = "Port to connect to on the remote host (SSH-compatible)" )] pub port: Option, #[arg( long, - help = "Stream output in real-time with [node] prefixes\nEach line of output is prefixed with the node hostname and displayed as it arrives.\nUseful for monitoring long-running commands across multiple nodes.\nAutomatically disabled when output is piped or in CI environments." + help_heading = "Output", + help = "Stream output in real-time with [node] prefixes", + long_help = "Stream output in real-time with [node] prefixes\nEach line of output is prefixed with the node hostname and displayed as it arrives.\nUseful for monitoring long-running commands across multiple nodes.\nAutomatically disabled when output is piped or in CI environments." )] pub stream: bool, #[arg( short = 'N', long = "no-prefix", - help = "Disable hostname prefix in output lines (pdsh -N compatibility)\nUseful for programmatic parsing or cleaner display" + help_heading = "Output", + help = "Disable hostname prefix in output lines (pdsh -N)", + long_help = "Disable hostname prefix in output lines (pdsh -N compatibility)\nUseful for programmatic parsing or cleaner display" )] pub no_prefix: bool, #[arg( long, - help = "Output directory for per-node command results\nCreates timestamped files:\n - hostname_TIMESTAMP.stdout (command output)\n - hostname_TIMESTAMP.stderr (error output)\n - hostname_TIMESTAMP.error (connection failures)\n - summary_TIMESTAMP.txt (execution summary)" + help_heading = "Output", + help = "Save per-node command results to timestamped files", + long_help = "Output directory for per-node command results\nCreates timestamped files:\n - hostname_TIMESTAMP.stdout (command output)\n - hostname_TIMESTAMP.stderr (error output)\n - hostname_TIMESTAMP.error (connection failures)\n - summary_TIMESTAMP.txt (execution summary)" )] pub output_dir: Option, @@ -163,6 +196,7 @@ pub struct Cli { short = 'v', long, action = clap::ArgAction::Count, + help_heading = "Output", help = "Increase verbosity (-v, -vv, -vvv)" )] pub verbose: u8, @@ -170,12 +204,15 @@ pub struct Cli { #[arg( long, default_value = "accept-new", - help = "Host key checking mode (yes/no/accept-new) [default: accept-new]\n yes - Strict checking against known_hosts (most secure)\n no - Accept all host keys (insecure, testing only)\n accept-new - Accept new hosts, reject changed keys (recommended)" + help_heading = "Connection", + help = "Host key checking mode (yes/no/accept-new)", + long_help = "Host key checking mode (yes/no/accept-new) [default: accept-new]\n yes - Strict checking against known_hosts (most secure)\n no - Accept all host keys (insecure, testing only)\n accept-new - Accept new hosts, reject changed keys (recommended)" )] pub strict_host_key_checking: String, #[arg( long, + help_heading = "Execution Control", help = "Command timeout in seconds (0 for unlimited, default: 300 if not specified)" )] pub timeout: Option, @@ -185,6 +222,7 @@ pub struct Cli { default_value = "30", value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..), + help_heading = "Connection", help = "SSH connection timeout in seconds (minimum: 1)" )] pub connect_timeout: u64, @@ -192,46 +230,60 @@ pub struct Cli { #[arg( long = "server-alive-interval", value_name = "SECONDS", - help = "Keepalive interval in seconds (default: 30, 0 to disable)\nSends keepalive packets to prevent idle connection timeouts.\nMatches OpenSSH ServerAliveInterval option." + help_heading = "Connection", + help = "Keepalive interval in seconds (default: 30, 0 to disable)", + long_help = "Keepalive interval in seconds (default: 30, 0 to disable)\nSends keepalive packets to prevent idle connection timeouts.\nMatches OpenSSH ServerAliveInterval option." )] pub server_alive_interval: Option, #[arg( long = "server-alive-count-max", value_name = "COUNT", - help = "Max keepalive messages without response before disconnect (default: 3)\nConnection is considered dead after this many missed keepalives.\nMatches OpenSSH ServerAliveCountMax option." + help_heading = "Connection", + help = "Max missed keepalives before disconnect (default: 3)", + long_help = "Max keepalive messages without response before disconnect (default: 3)\nConnection is considered dead after this many missed keepalives.\nMatches OpenSSH ServerAliveCountMax option." )] pub server_alive_count_max: Option, #[arg( long, - help = "Require all nodes to succeed (v1.0-v1.1 behavior)\nDefault: return main rank's exit code (v1.2+)\nUseful for health checks and monitoring where all nodes must be operational" + help_heading = "Execution Control", + help = "Require all nodes to succeed", + long_help = "Require all nodes to succeed (v1.0-v1.1 behavior)\nDefault: return main rank's exit code (v1.2+)\nUseful for health checks and monitoring where all nodes must be operational" )] pub require_all_success: bool, #[arg( long, conflicts_with = "require_all_success", - help = "Check all nodes but preserve main rank exit code\nReturns main rank's exit code if non-zero, or 1 if main succeeded but others failed\nHybrid approach for production deployments" + help_heading = "Execution Control", + help = "Check all nodes but preserve main rank exit code", + long_help = "Check all nodes but preserve main rank exit code\nReturns main rank's exit code if non-zero, or 1 if main succeeded but others failed\nHybrid approach for production deployments" )] pub check_all_nodes: bool, #[arg( short = 'k', long = "fail-fast", - help = "Stop execution immediately on first failure (pdsh -k compatible)\nCancels pending commands when any node fails (connection error or non-zero exit)\nUseful for critical operations where partial execution is unacceptable" + help_heading = "Execution Control", + help = "Stop execution immediately on first failure (pdsh -k)", + long_help = "Stop execution immediately on first failure (pdsh -k compatible)\nCancels pending commands when any node fails (connection error or non-zero exit)\nUseful for critical operations where partial execution is unacceptable" )] 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" + help_heading = "Execution Control", + help = "Return largest exit code from any node (pdsh -S)", + long_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.)\nAuto-enabled when invoked as 'pdsh' via symlink or when BSSH_PDSH_COMPAT=1\nUseful when migrating from pdsh or in mixed environments\nSee docs/pdsh-migration.md for complete migration guide" + help_heading = "Execution Control", + help = "Enable pdsh compatibility mode", + long_help = "Enable pdsh compatibility mode\nAccepts pdsh-style command line arguments (-w, -x, -f, etc.)\nAuto-enabled when invoked as 'pdsh' via symlink or when BSSH_PDSH_COMPAT=1\nUseful when migrating from pdsh or in mixed environments\nSee docs/pdsh-migration.md for complete migration guide" )] pub pdsh_compat: bool, @@ -244,6 +296,7 @@ pub struct Cli { // SSH-compatible options #[arg(short = 'o', long = "option", value_name = "option", action = clap::ArgAction::Append, + help_heading = "Configuration", help = "SSH options (e.g., -o StrictHostKeyChecking=no)")] pub ssh_options: Vec, @@ -251,7 +304,9 @@ pub struct Cli { short = 'F', long = "ssh-config", value_name = "configfile", - help = "Specifies an alternative SSH configuration file\nSupports standard SSH config format with Host, HostName, User, Port, IdentityFile, etc.\nDefaults to ~/.ssh/config if not specified and file exists" + help_heading = "Configuration", + help = "Alternative SSH configuration file", + long_help = "Specifies an alternative SSH configuration file\nSupports standard SSH config format with Host, HostName, User, Port, IdentityFile, etc.\nDefaults to ~/.ssh/config if not specified and file exists" )] pub ssh_config: Option, @@ -259,28 +314,41 @@ pub struct Cli { short = 'q', long = "quiet", conflicts_with = "verbose", + help_heading = "Output", help = "Quiet mode (suppress non-error messages)" )] pub quiet: bool, - #[arg(short = 't', long = "tty", help = "Force pseudo-terminal allocation")] + #[arg( + short = 't', + long = "tty", + help_heading = "Terminal", + help = "Force pseudo-terminal allocation" + )] pub force_tty: bool, #[arg( short = 'T', long = "no-tty", conflicts_with = "force_tty", + help_heading = "Terminal", help = "Disable pseudo-terminal allocation" )] pub no_tty: bool, - #[arg(short = 'x', long = "no-x11", help = "Disable X11 forwarding")] + #[arg( + short = 'x', + long = "no-x11", + help_heading = "Terminal", + help = "Disable X11 forwarding" + )] pub no_x11: bool, #[arg( short = '4', long = "ipv4", conflicts_with = "ipv6", + help_heading = "Connection", help = "Force use of IPv4 addresses only" )] pub ipv4: bool, @@ -289,6 +357,7 @@ pub struct Cli { short = '6', long = "ipv6", conflicts_with = "ipv4", + help_heading = "Connection", help = "Force use of IPv6 addresses only" )] pub ipv6: bool, @@ -297,6 +366,7 @@ pub struct Cli { short = 'Q', long = "query", value_name = "query_option", + help_heading = "Configuration", help = "Query SSH configuration options" )] pub query: Option, @@ -307,7 +377,9 @@ pub struct Cli { long = "local-forward", value_name = "local_forward_spec", action = clap::ArgAction::Append, - help = "Local port forwarding [bind_address:]port:host:hostport\nBinds a local port to forward connections to a remote destination via SSH.\nMultiple -L options can be specified for multiple forwards.\nExample: -L 8080:example.com:80 (localhost:8080 → example.com:80)" + help_heading = "Port Forwarding", + help = "Local port forwarding [bind_address:]port:host:hostport", + long_help = "Local port forwarding [bind_address:]port:host:hostport\nBinds a local port to forward connections to a remote destination via SSH.\nMultiple -L options can be specified for multiple forwards.\nExample: -L 8080:example.com:80 (localhost:8080 → example.com:80)" )] pub local_forwards: Vec, @@ -316,7 +388,9 @@ pub struct Cli { long = "remote-forward", value_name = "remote_forward_spec", action = clap::ArgAction::Append, - help = "Remote port forwarding [bind_address:]port:host:hostport\nRequests the SSH server to bind a port and forward connections to local destination.\nMultiple -R options can be specified for multiple forwards.\nExample: -R 8080:localhost:80 (remote:8080 → localhost:80)" + help_heading = "Port Forwarding", + help = "Remote port forwarding [bind_address:]port:host:hostport", + long_help = "Remote port forwarding [bind_address:]port:host:hostport\nRequests the SSH server to bind a port and forward connections to local destination.\nMultiple -R options can be specified for multiple forwards.\nExample: -R 8080:localhost:80 (remote:8080 → localhost:80)" )] pub remote_forwards: Vec, @@ -325,7 +399,9 @@ pub struct Cli { long = "dynamic-forward", value_name = "dynamic_forward_spec", action = clap::ArgAction::Append, - help = "Dynamic port forwarding (SOCKS proxy) [bind_address:]port[/socks_version]\nCreates a local SOCKS proxy that dynamically forwards connections via SSH.\nMultiple -D options can be specified for multiple SOCKS proxies.\nExample: -D 1080 (SOCKS5 proxy on localhost:1080), -D *:1080/4 (SOCKS4 on all interfaces)" + help_heading = "Port Forwarding", + help = "Dynamic SOCKS proxy [bind_address:]port[/socks_version]", + long_help = "Dynamic port forwarding (SOCKS proxy) [bind_address:]port[/socks_version]\nCreates a local SOCKS proxy that dynamically forwards connections via SSH.\nMultiple -D options can be specified for multiple SOCKS proxies.\nExample: -D 1080 (SOCKS5 proxy on localhost:1080), -D *:1080/4 (SOCKS4 on all interfaces)" )] pub dynamic_forwards: Vec, } From 1fe0ed216944fcc7f28f4636a0a74e196f4a0b51 Mon Sep 17 00:00:00 2001 From: embh0 <270190780+embh0@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:47:01 +0200 Subject: [PATCH 5/5] fix: resolve clippy needless_borrows_for_generic_args lints Co-Authored-By: Claude Fable 5 --- src/playbook/condition.rs | 2 +- src/playbook/model.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/playbook/condition.rs b/src/playbook/condition.rs index 748538e6..146563bf 100644 --- a/src/playbook/condition.rs +++ b/src/playbook/condition.rs @@ -67,7 +67,7 @@ pub fn resolve<'a>(variables: &'a Variables, path: &str) -> Option<&'a Value> { let mut parts = path.split('.'); let mut value = variables.get(parts.next()?)?; for part in parts { - value = value.as_mapping()?.get(&Value::String(part.to_owned()))?; + value = value.as_mapping()?.get(Value::String(part.to_owned()))?; } Some(value) } diff --git a/src/playbook/model.rs b/src/playbook/model.rs index fcb5ac66..69a911cb 100644 --- a/src/playbook/model.rs +++ b/src/playbook/model.rs @@ -113,5 +113,5 @@ pub(crate) fn key(name: &str) -> Value { } pub(crate) fn map_get<'a>(map: &'a Mapping, name: &str) -> Option<&'a Value> { - map.get(&key(name)) + map.get(key(name)) }