Skip to main content

consortium_cli/
claw.rs

1//! claw — parallel cluster command execution (replaces clush).
2//!
3//! Usage:
4//!   claw -w node[1-5] echo hello        # run on nodes
5//!   claw -w node[1-5] -b 'uname -r'     # gather identical output
6//!   claw -w node[1-5] -l root uptime     # run as root
7//!   claw --copy /etc/hosts -w node[1-5]  # copy file to nodes
8//!   echo 'script' | claw -w node[1-5]    # pipe stdin to command
9
10use std::collections::HashMap;
11use std::io::{self, Read, Write};
12use std::process;
13use std::time::{Duration, Instant};
14
15use clap::Parser;
16
17use crate::display;
18use crate::fold;
19use crate::groups;
20use crate::output::{CliOutput, OutputArgs};
21use consortium::node_set::NodeSet;
22use consortium::task::{Task, TaskError};
23use consortium::worker::exec::ExecWorker;
24use consortium::worker::ssh::SshOptions;
25use consortium::worker::EventHandler;
26
27/// claw — execute commands in parallel across cluster nodes.
28///
29/// Runs shell commands on multiple nodes simultaneously with
30/// configurable fanout, output gathering, and timeout control.
31#[derive(Parser)]
32#[command(name = "claw", version, about)]
33pub struct Args {
34    // ── Node selection ─────────────────────────────────────────────────
35    /// Target nodes (bracket notation, e.g., node[1-5]).
36    #[arg(short = 'w', long = "nodes")]
37    nodes: Option<String>,
38
39    /// Exclude nodes from the target set.
40    #[arg(short = 'x', long = "exclude")]
41    exclude: Vec<String>,
42
43    /// Use all nodes from the default group.
44    #[arg(short = 'a', long = "all")]
45    all: bool,
46
47    /// Use nodes from a named group.
48    #[arg(short = 'g', long = "group")]
49    group: Vec<String>,
50
51    /// Exclude nodes from a named group.
52    #[arg(short = 'X', long = "exclude-group")]
53    exclude_group: Vec<String>,
54
55    /// Pick N random nodes.
56    #[arg(long = "pick")]
57    pick: Option<usize>,
58
59    /// Read nodes from a file (one per line).
60    #[arg(long = "hostfile")]
61    hostfile: Option<String>,
62
63    // ── Execution ──────────────────────────────────────────────────────
64    /// Maximum concurrent connections (fanout).
65    #[arg(short = 'f', long = "fanout")]
66    fanout: Option<usize>,
67
68    /// Remote user for SSH connections.
69    #[arg(short = 'l', long = "user")]
70    user: Option<String>,
71
72    /// SSH connect timeout in seconds.
73    #[arg(short = 't', long = "connect-timeout")]
74    connect_timeout: Option<u64>,
75
76    /// Command timeout in seconds.
77    #[arg(short = 'u', long = "command-timeout")]
78    command_timeout: Option<u64>,
79
80    /// Worker type: exec or ssh (default: ssh for remote, exec for localhost).
81    #[arg(short = 'R', long = "worker")]
82    worker: Option<String>,
83
84    /// SSH options to pass through (e.g., "-o BatchMode=yes").
85    #[arg(short = 'o', long = "options")]
86    options: Option<String>,
87
88    /// Remote shell command (default: ssh).
89    #[arg(long = "remote", default_value = "ssh")]
90    remote: String,
91
92    /// Topology file for tree mode.
93    #[arg(long = "topology")]
94    topology: Option<String>,
95
96    // ── Output ─────────────────────────────────────────────────────────
97    /// Gather identical output together (dshbak mode).
98    #[arg(short = 'b', long = "dshbak")]
99    dshbak: bool,
100
101    /// Line mode — no output gathering.
102    #[arg(short = 'L', long = "line")]
103    line: bool,
104
105    /// Disable node labels on output.
106    #[arg(short = 'N', long = "label")]
107    no_label: bool,
108
109    /// Quiet mode — suppress output, just return exit code.
110    #[arg(short = 'q', long = "quiet")]
111    quiet: bool,
112
113    /// Return the maximum exit code from all nodes.
114    #[arg(short = 'S', long = "maxrc")]
115    maxrc: bool,
116
117    /// Fold along these axis only (axis 1..n for nD nodeset).
118    #[arg(long = "axis", value_name = "RANGESET")]
119    axis: Option<String>,
120
121    #[command(flatten)]
122    output: OutputArgs,
123
124    // ── File transfer ──────────────────────────────────────────────────
125    /// Copy a file to all target nodes.
126    #[arg(long = "copy")]
127    copy: Option<String>,
128
129    /// Reverse copy — copy file from nodes to local.
130    #[arg(long = "rcopy")]
131    rcopy: Option<String>,
132
133    /// Destination directory for copy operations.
134    #[arg(long = "dest")]
135    dest: Option<String>,
136
137    // ── Positional ─────────────────────────────────────────────────────
138    /// Command and arguments to execute.
139    command: Vec<String>,
140}
141
142pub fn run() {
143    let args = Args::parse();
144    let exit_code = match execute(args) {
145        Ok(code) => code,
146        Err(e) => {
147            eprintln!("claw: {e}");
148            1
149        }
150    };
151    process::exit(exit_code);
152}
153
154fn execute(args: Args) -> anyhow::Result<i32> {
155    let cli_out = CliOutput::from_args(&args.output);
156
157    // User-specified nD-nodeset fold axis for output display (#356).
158    // An empty axis list behaves like Python's empty DEFAULTS.fold_axis
159    // tuple (falsy → fold along all axes).
160    let fold_axes = match &args.axis {
161        Some(axis) => {
162            Some(fold::parse_fold_axis(axis).map_err(|e| anyhow::anyhow!("Parse error: {e}"))?)
163        }
164        None => None,
165    };
166    let fold_axis: Option<&[i64]> = fold_axes.as_deref().filter(|v| !v.is_empty());
167
168    let stdout = io::stdout();
169    let mut out = stdout.lock();
170
171    // ── Resolve target nodes ───────────────────────────────────────────
172    let target_nodes = resolve_nodes(&args)?;
173    if target_nodes.is_empty() {
174        anyhow::bail!("no target nodes specified (use -w, -a, -g, or --hostfile)");
175    }
176
177    if cli_out.is_verbose() {
178        cli_out.info(format!(
179            "claw: targeting {} node(s): {}",
180            target_nodes.len(),
181            target_nodes
182        ));
183    }
184
185    // ── Build the command string ───────────────────────────────────────
186    let command = if !args.command.is_empty() {
187        args.command.join(" ")
188    } else if !is_terminal::is_terminal(io::stdin()) {
189        // Read command from stdin
190        let mut buf = String::new();
191        io::stdin().read_to_string(&mut buf)?;
192        buf.trim_end().to_string()
193    } else {
194        anyhow::bail!("no command specified");
195    };
196
197    if command.is_empty() {
198        anyhow::bail!("empty command");
199    }
200
201    // ── Build SSH options if needed ────────────────────────────────────
202    let _ssh_opts = build_ssh_options(&args);
203
204    // ── Determine worker type ──────────────────────────────────────────
205    let use_ssh = match args.worker.as_deref() {
206        Some("exec") => false,
207        Some("ssh") => true,
208        Some(other) => anyhow::bail!("unknown worker type: {other} (use 'exec' or 'ssh')"),
209        None => {
210            // Auto-detect: if all nodes are localhost, use exec
211            let all_local = target_nodes
212                .iter()
213                .all(|n| n == "localhost" || n == "127.0.0.1" || n == "::1");
214            !all_local
215        }
216    };
217
218    // ── Build the command with SSH wrapping if needed ───────────────────
219    let node_list: Vec<String> = target_nodes.iter().collect();
220    let fanout = args.fanout.unwrap_or(64);
221    let timeout = args.command_timeout.map(Duration::from_secs);
222
223    let effective_command = if use_ssh {
224        // Build SSH command: ssh [options] [user@]%h command
225        let mut ssh_cmd = args.remote.clone();
226        if let Some(ref opts) = args.options {
227            ssh_cmd.push(' ');
228            ssh_cmd.push_str(opts);
229        }
230        if let Some(ref ct) = args.connect_timeout {
231            ssh_cmd.push_str(&format!(" -o ConnectTimeout={ct}"));
232        }
233        if let Some(ref user) = args.user {
234            ssh_cmd.push_str(&format!(" {user}@%h"));
235        } else {
236            ssh_cmd.push_str(" %h");
237        }
238        ssh_cmd.push(' ');
239        ssh_cmd.push_str(&shell_escape(&command));
240        ssh_cmd
241    } else {
242        command.clone()
243    };
244
245    // ── Create and run the task ────────────────────────────────────────
246    let mut task = Task::new();
247    let worker =
248        ExecWorker::new(node_list.clone(), effective_command, fanout, timeout).with_stderr(true);
249
250    // Set up progress tracking (only when stderr is a TTY and >1 node)
251    let num_nodes = node_list.len();
252    let show_progress = !args.quiet && is_terminal::is_terminal(io::stderr()) && num_nodes > 1;
253
254    // In standard (non-gather, non-quiet) mode, stream output lines as they
255    // arrive (GH#528/GH#597 line-buffering port): a LineStreamHandler prints
256    // each completed line immediately instead of buffering until exit.
257    let stream_lines = !args.dshbak && !args.quiet;
258
259    let progress = if show_progress {
260        let (bar, state, handler) = display::create_progress(num_nodes);
261        (Some(bar), Some(state), Some(handler))
262    } else {
263        (None, None, None)
264    };
265
266    let user_handler: Option<Box<dyn EventHandler>> = if stream_lines {
267        let mut handler = display::LineStreamHandler::new(!args.no_label);
268        if let Some(progress_handler) = progress.2 {
269            handler = handler.with_inner(Box::new(progress_handler));
270        }
271        Some(Box::new(handler))
272    } else {
273        progress.2.map(|h| Box::new(h) as Box<dyn EventHandler>)
274    };
275
276    task.schedule(Box::new(worker), user_handler, false);
277    let pb = progress.0;
278    let state = progress.1;
279
280    let start = Instant::now();
281    let task_timeout = args.command_timeout.map(|t| Duration::from_secs(t + 5));
282    match task.run(task_timeout) {
283        Ok(()) => {}
284        Err(TaskError::Timeout) => {
285            if !args.quiet {
286                eprintln!("claw: command timed out");
287            }
288        }
289        Err(e) => return Err(e.into()),
290    }
291
292    // Finalize progress display
293    if let (Some(bar), Some(state)) = (&pb, &state) {
294        display::finish_progress(bar, state, start.elapsed());
295    }
296
297    // ── Collect and display output ─────────────────────────────────────
298    let max_rc = display_results(&task, &args, fold_axis, stream_lines, &mut out)?;
299
300    if args.maxrc {
301        Ok(max_rc.unwrap_or(0))
302    } else if max_rc.map_or(false, |rc| rc != 0) {
303        Ok(1)
304    } else {
305        Ok(0)
306    }
307}
308
309/// Resolve target nodes from all sources.
310fn resolve_nodes(args: &Args) -> anyhow::Result<NodeSet> {
311    let mut ns = NodeSet::new();
312
313    // -w / --nodes
314    if let Some(ref pattern) = args.nodes {
315        let parsed = NodeSet::parse(pattern)?;
316        ns.update(&parsed);
317    }
318
319    // -g / --group
320    if !args.group.is_empty() || args.all {
321        let mut config = groups::load_group_resolver()?;
322        let resolver = config.resolver()?;
323
324        if args.all {
325            // -a: all nodes from default group source
326            let all_nodes = resolver.all_nodes(None)?;
327            for node in &all_nodes {
328                let _ = ns.update_str(node);
329            }
330        }
331
332        for group_name in &args.group {
333            let nodes = resolver.group_nodes(group_name, None)?;
334            for node in &nodes {
335                let _ = ns.update_str(node);
336            }
337        }
338
339        // -X / --exclude-group
340        for group_name in &args.exclude_group {
341            let nodes = resolver.group_nodes(group_name, None)?;
342            for node in &nodes {
343                let exclude_ns = NodeSet::parse(node)?;
344                ns.difference_update(&exclude_ns);
345            }
346        }
347    }
348
349    // --hostfile
350    if let Some(ref path) = args.hostfile {
351        let content = std::fs::read_to_string(path)?;
352        for line in content.lines() {
353            let trimmed = line.trim();
354            if !trimmed.is_empty() && !trimmed.starts_with('#') {
355                let _ = ns.update_str(trimmed);
356            }
357        }
358    }
359
360    // Excludes (-x)
361    for pat in &args.exclude {
362        let other = NodeSet::parse(pat)?;
363        ns.difference_update(&other);
364    }
365
366    // Pick random subset
367    if let Some(n) = args.pick {
368        let all: Vec<String> = ns.iter().collect();
369        if n < all.len() {
370            let mut new_ns = NodeSet::new();
371            // Simple sequential pick (deterministic for now)
372            for node in all.iter().take(n) {
373                let _ = new_ns.update_str(node);
374            }
375            ns = new_ns;
376        }
377    }
378
379    Ok(ns)
380}
381
382/// Build SSH options from CLI args.
383fn build_ssh_options(args: &Args) -> SshOptions {
384    let mut opts = SshOptions::default();
385    if let Some(ref user) = args.user {
386        opts.user = Some(user.clone());
387    }
388    if let Some(ct) = args.connect_timeout {
389        opts.connect_timeout = Some(ct as u32);
390    }
391    if let Some(ref _extra) = args.options {
392        // Parse "-o Key=Value" style options
393        opts.ssh_path = args.remote.clone(); // String, not Option
394                                             // Store raw options for passthrough
395    }
396    opts
397}
398
399/// Display results from a completed task.
400fn display_results(
401    task: &Task,
402    args: &Args,
403    fold_axis: Option<&[i64]>,
404    streamed: bool,
405    out: &mut impl Write,
406) -> anyhow::Result<Option<i32>> {
407    if args.quiet {
408        return Ok(task.max_retcode());
409    }
410
411    if args.dshbak {
412        display_dshbak(task, fold_axis, out)?;
413    } else if !streamed {
414        display_standard(task, args, out)?;
415    }
416    // When `streamed`, standard output was already printed line by line
417    // during execution by the LineStreamHandler.
418
419    // Show return codes for non-zero exits
420    let retcodes = task.iter_retcodes(None);
421    for (rc, nodes) in &retcodes {
422        if *rc != 0 {
423            let mut ns = NodeSet::new();
424            for node in nodes {
425                let _ = ns.update_str(node);
426            }
427            eprintln!("claw: {ns}: exited with return code {rc}");
428        }
429    }
430
431    // Show timeouts
432    let timeout_count = task.num_timeout();
433    if timeout_count > 0 {
434        let timeout_nodes: Vec<&str> = task.iter_keys_timeout().collect();
435        let mut ns = NodeSet::new();
436        for node in &timeout_nodes {
437            let _ = ns.update_str(node);
438        }
439        eprintln!("claw: {ns}: command timeout");
440    }
441
442    Ok(task.max_retcode())
443}
444
445/// Display output in dshbak (gathered) mode.
446///
447/// Groups nodes by identical output, then prints one header block per group.
448/// Groups sort like upstream clush (`bufnodeset_cmpkey`): larger groups
449/// first, then by first node name. Headers fold with the nD-aware folder
450/// honoring the `--axis` constraint (#356) and carry the ` (N)` node count
451/// like upstream `Display.format_header()`.
452fn display_dshbak(
453    task: &Task,
454    fold_axis: Option<&[i64]>,
455    out: &mut impl Write,
456) -> anyhow::Result<()> {
457    // Group nodes by their output
458    let mut output_to_nodes: HashMap<Vec<u8>, Vec<String>> = HashMap::new();
459
460    // Iterate over all nodes that ran
461    let retcodes = task.iter_retcodes(None);
462    for (_rc, nodes) in &retcodes {
463        for node in nodes {
464            let buf = task.node_buffer(node).unwrap_or_default();
465            output_to_nodes
466                .entry(buf)
467                .or_default()
468                .push(node.to_string());
469        }
470    }
471
472    let mut groups: Vec<(Vec<u8>, Vec<String>)> = output_to_nodes.into_iter().collect();
473    for (_buf, names) in groups.iter_mut() {
474        names.sort();
475    }
476    // larger nodeset first, then sorted by first node index
477    groups.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.1[0].cmp(&b.1[0])));
478
479    for (output, names) in &groups {
480        let folded = fold::fold_nodes(names, fold_axis);
481        display::print_gathered_header(&folded, names.len(), out)?;
482        out.write_all(output)?;
483        if !output.is_empty() && !output.ends_with(b"\n") {
484            writeln!(out)?;
485        }
486    }
487
488    Ok(())
489}
490
491/// Display output in standard (labeled line) mode.
492fn display_standard(task: &Task, args: &Args, out: &mut impl Write) -> anyhow::Result<()> {
493    let retcodes = task.iter_retcodes(None);
494
495    for (_rc, nodes) in &retcodes {
496        for node in nodes {
497            let buf = task.node_buffer(node).unwrap_or_default();
498            if buf.is_empty() {
499                continue;
500            }
501            let text = String::from_utf8_lossy(&buf);
502            for line in text.lines() {
503                if args.no_label {
504                    writeln!(out, "{line}")?;
505                } else {
506                    display::print_line_with_label(node, line, out)?;
507                }
508            }
509        }
510    }
511
512    Ok(())
513}
514
515/// Shell-escape a command string for SSH passthrough.
516fn shell_escape(s: &str) -> String {
517    // If it contains no special chars, return as-is
518    if s.chars()
519        .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | '=' | ':' | ','))
520    {
521        return s.to_string();
522    }
523    // Single-quote escape
524    format!("'{}'", s.replace('\'', "'\\''"))
525}