1use 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#[derive(Parser)]
32#[command(name = "claw", version, about)]
33pub struct Args {
34 #[arg(short = 'w', long = "nodes")]
37 nodes: Option<String>,
38
39 #[arg(short = 'x', long = "exclude")]
41 exclude: Vec<String>,
42
43 #[arg(short = 'a', long = "all")]
45 all: bool,
46
47 #[arg(short = 'g', long = "group")]
49 group: Vec<String>,
50
51 #[arg(short = 'X', long = "exclude-group")]
53 exclude_group: Vec<String>,
54
55 #[arg(long = "pick")]
57 pick: Option<usize>,
58
59 #[arg(long = "hostfile")]
61 hostfile: Option<String>,
62
63 #[arg(short = 'f', long = "fanout")]
66 fanout: Option<usize>,
67
68 #[arg(short = 'l', long = "user")]
70 user: Option<String>,
71
72 #[arg(short = 't', long = "connect-timeout")]
74 connect_timeout: Option<u64>,
75
76 #[arg(short = 'u', long = "command-timeout")]
78 command_timeout: Option<u64>,
79
80 #[arg(short = 'R', long = "worker")]
82 worker: Option<String>,
83
84 #[arg(short = 'o', long = "options")]
86 options: Option<String>,
87
88 #[arg(long = "remote", default_value = "ssh")]
90 remote: String,
91
92 #[arg(long = "topology")]
94 topology: Option<String>,
95
96 #[arg(short = 'b', long = "dshbak")]
99 dshbak: bool,
100
101 #[arg(short = 'L', long = "line")]
103 line: bool,
104
105 #[arg(short = 'N', long = "label")]
107 no_label: bool,
108
109 #[arg(short = 'q', long = "quiet")]
111 quiet: bool,
112
113 #[arg(short = 'S', long = "maxrc")]
115 maxrc: bool,
116
117 #[arg(long = "axis", value_name = "RANGESET")]
119 axis: Option<String>,
120
121 #[command(flatten)]
122 output: OutputArgs,
123
124 #[arg(long = "copy")]
127 copy: Option<String>,
128
129 #[arg(long = "rcopy")]
131 rcopy: Option<String>,
132
133 #[arg(long = "dest")]
135 dest: Option<String>,
136
137 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 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 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 let command = if !args.command.is_empty() {
187 args.command.join(" ")
188 } else if !is_terminal::is_terminal(io::stdin()) {
189 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 let _ssh_opts = build_ssh_options(&args);
203
204 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 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 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 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 let mut task = Task::new();
247 let worker =
248 ExecWorker::new(node_list.clone(), effective_command, fanout, timeout).with_stderr(true);
249
250 let num_nodes = node_list.len();
252 let show_progress = !args.quiet && is_terminal::is_terminal(io::stderr()) && num_nodes > 1;
253
254 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 if let (Some(bar), Some(state)) = (&pb, &state) {
294 display::finish_progress(bar, state, start.elapsed());
295 }
296
297 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
309fn resolve_nodes(args: &Args) -> anyhow::Result<NodeSet> {
311 let mut ns = NodeSet::new();
312
313 if let Some(ref pattern) = args.nodes {
315 let parsed = NodeSet::parse(pattern)?;
316 ns.update(&parsed);
317 }
318
319 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 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 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 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 for pat in &args.exclude {
362 let other = NodeSet::parse(pat)?;
363 ns.difference_update(&other);
364 }
365
366 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 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
382fn 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 opts.ssh_path = args.remote.clone(); }
396 opts
397}
398
399fn 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 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 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
445fn display_dshbak(
453 task: &Task,
454 fold_axis: Option<&[i64]>,
455 out: &mut impl Write,
456) -> anyhow::Result<()> {
457 let mut output_to_nodes: HashMap<Vec<u8>, Vec<String>> = HashMap::new();
459
460 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 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
491fn 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
515fn shell_escape(s: &str) -> String {
517 if s.chars()
519 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | '=' | ':' | ','))
520 {
521 return s.to_string();
522 }
523 format!("'{}'", s.replace('\'', "'\\''"))
525}