1#![forbid(unsafe_code)]
10
11use super::selection::{resolve_host_jobs, HostSelection};
12use super::{
13 apply_overrides, build_connection_config, load, resolve_config_path, validate_command_length,
14};
15use crate::cli::OutputFormat;
16use crate::errors::{SshCliError, SshCliResult};
17use crate::output;
18use crate::ssh::client::{ExecutionOutput, SshClient, SshClientTrait};
19use crate::ssh::packing::{append_description, pack_su, pack_sudo};
20use crate::vps::model::{effective_limit, VpsRecord};
21use anyhow::Result;
22use secrecy::SecretString;
23use std::path::PathBuf;
24
25#[derive(Debug, Default, Clone)]
32pub struct ExecOptions {
33 pub password: Option<SecretString>,
35 pub sudo_password: Option<SecretString>,
37 pub su_password: Option<SecretString>,
39 pub timeout: Option<crate::domain::TimeoutMs>,
41 pub key: Option<String>,
43 pub key_passphrase: Option<SecretString>,
45 pub use_agent: bool,
47 pub agent_socket: Option<String>,
49 pub description: Option<String>,
51 pub replace_host_key: bool,
53 pub disable_sudo: bool,
55 pub steps: Vec<crate::domain::RemoteCommand>,
57}
58
59#[derive(Clone, Copy)]
61enum ExecKind {
62 Plain,
63 Sudo,
64 Su,
65}
66
67#[derive(Debug, Clone)]
69pub struct HostExecResult {
70 pub name: String,
72 pub ok: bool,
74 pub exit_code: Option<i32>,
76 pub stdout: String,
78 pub stderr: String,
80 pub duration_ms: u64,
82 pub error: Option<String>,
84}
85
86async fn finish_execution_output(
90 client: Box<dyn SshClientTrait>,
91 result: SshCliResult<ExecutionOutput>,
92 format: OutputFormat,
93 json: bool,
94) -> Result<()> {
95 let _ = client.disconnect().await;
96 let output = result?;
97 if format == OutputFormat::Json || json {
98 output::print_execution_output_json(&output)?;
99 } else {
100 output::print_execution_output(&output);
101 }
102 if let Some(code) = output.exit_code {
103 if code != 0 {
104 return Err(SshCliError::CommandFailed {
105 exit_code: code,
106 stderr: output.stderr,
107 }
108 .into());
109 }
110 }
111 Ok(())
112}
113
114fn cancelled_err() -> anyhow::Error {
115 anyhow::anyhow!(crate::i18n::t(crate::i18n::Message::OperationCancelled))
116}
117
118fn expect_single(selection: HostSelection) -> Result<String> {
119 match selection {
120 HostSelection::Single(name) => Ok(name.into_inner()),
121 _ => Err(SshCliError::InvalidArgument(
122 "internal: expected single-host selection for non-batch exec".into(),
123 )
124 .into()),
125 }
126}
127
128#[allow(clippy::too_many_arguments)]
133pub async fn run_exec(
134 selection: HostSelection,
135 command: &str,
136 config_override: Option<PathBuf>,
137 format: OutputFormat,
138 json: bool,
139 opts: ExecOptions,
140) -> Result<()> {
141 if crate::signals::should_stop() {
142 return Err(cancelled_err());
143 }
144 if selection.is_batch() {
145 return run_exec_all(
146 &selection,
147 command,
148 config_override,
149 format,
150 json,
151 opts,
152 ExecKind::Plain,
153 )
154 .await;
155 }
156 let vps_name = expect_single(selection)?;
157 let path = resolve_config_path(config_override.as_deref())?;
158 let mut file = load(&path)?;
159 let mut vps = file
161 .hosts
162 .remove(&vps_name)
163 .ok_or(SshCliError::VpsNotFound(vps_name))?;
164
165 apply_overrides(
166 &mut vps,
167 opts.password,
168 opts.sudo_password,
169 opts.su_password,
170 opts.timeout,
171 opts.key,
172 opts.key_passphrase,
173 opts.use_agent,
174 opts.agent_socket,
175 );
176 let cmd = append_description(command, opts.description.as_deref());
177 validate_command_length(&cmd, vps.max_command_chars.wire())?;
178 for s in &opts.steps {
179 validate_command_length(s.as_str(), vps.max_command_chars.wire())?;
180 }
181 let cfg = build_connection_config(&vps, Some(&path), opts.replace_host_key);
182 let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
183 run_exec_with_client_steps(&vps, &cmd, &opts.steps, client, format, json).await
184}
185
186pub async fn run_exec_with_client(
188 vps: &VpsRecord,
189 command: &str,
190 client: Box<dyn SshClientTrait>,
191 format: OutputFormat,
192 json: bool,
193) -> Result<()> {
194 run_exec_with_client_steps(vps, command, &[], client, format, json).await
195}
196
197pub async fn run_exec_with_client_steps(
199 vps: &VpsRecord,
200 command: &str,
201 steps: &[crate::domain::RemoteCommand],
202 mut client: Box<dyn SshClientTrait>,
203 format: OutputFormat,
204 json: bool,
205) -> Result<()> {
206 if crate::signals::should_stop() {
207 return Err(cancelled_err());
208 }
209 let max_out = effective_limit(vps.max_output_chars.wire());
210 let mut cmds: Vec<&str> = Vec::with_capacity(1 + steps.len());
211 cmds.push(command);
212 for s in steps {
213 cmds.push(s.as_str());
214 }
215 let mut last_output = None;
216 let mut failed: Option<(i32, String)> = None;
217 for (i, cmd) in cmds.iter().enumerate() {
218 if crate::signals::should_stop() {
219 let _ = client.disconnect().await;
220 return Err(cancelled_err());
221 }
222 tracing::debug!(step = i, "exec multi-cmd step");
223 match client.run_command(cmd, max_out, None).await {
224 Ok(output) => {
225 if format == OutputFormat::Json || json {
226 let mut v = serde_json::to_value(crate::json_wire::ExecutionJson::from(&output))
228 .unwrap_or_else(|_| serde_json::json!({}));
229 if let Some(obj) = v.as_object_mut() {
230 obj.insert("step".into(), serde_json::json!(i));
231 obj.insert("command".into(), serde_json::json!(cmd));
232 }
233 crate::output::print_json_value(&v)?;
234 } else if cmds.len() > 1 {
235 crate::output::write_line_fmt(format_args!("--- step {i}: {cmd} ---"))?;
236 crate::output::print_execution_output(&output);
237 } else {
238 last_output = Some(output.clone());
239 }
240 if let Some(code) = output.exit_code {
241 if code != 0 && failed.is_none() {
242 failed = Some((code, output.stderr.clone()));
243 }
244 }
245 if cmds.len() == 1 {
246 last_output = Some(output);
247 }
248 }
249 Err(e) => {
250 let _ = client.disconnect().await;
251 return Err(e.into());
252 }
253 }
254 }
255 let _ = client.disconnect().await;
256 if let Some((code, stderr)) = failed {
257 return Err(SshCliError::CommandFailed {
258 exit_code: code,
259 stderr,
260 }
261 .into());
262 }
263 if let Some(output) = last_output {
264 if format == OutputFormat::Json || json {
265 if cmds.len() == 1 {
267 crate::output::print_execution_output_json(&output)?;
268 }
269 } else if cmds.len() == 1 {
270 crate::output::print_execution_output(&output);
271 }
272 }
273 Ok(())
274}
275
276#[allow(clippy::too_many_arguments)]
280pub async fn run_sudo_exec(
281 selection: HostSelection,
282 command: &str,
283 config_override: Option<PathBuf>,
284 format: OutputFormat,
285 json: bool,
286 opts: ExecOptions,
287) -> Result<()> {
288 if crate::signals::should_stop() {
289 return Err(cancelled_err());
290 }
291 if selection.is_batch() {
292 return run_exec_all(
293 &selection,
294 command,
295 config_override,
296 format,
297 json,
298 opts,
299 ExecKind::Sudo,
300 )
301 .await;
302 }
303 let vps_name = expect_single(selection)?;
304 let path = resolve_config_path(config_override.as_deref())?;
305 let mut file = load(&path)?;
306 let mut vps = file
307 .hosts
308 .remove(&vps_name)
309 .ok_or(SshCliError::VpsNotFound(vps_name))?;
310
311 apply_overrides(
312 &mut vps,
313 opts.password,
314 opts.sudo_password,
315 opts.su_password,
316 opts.timeout,
317 opts.key,
318 opts.key_passphrase,
319 opts.use_agent,
320 opts.agent_socket,
321 );
322 if opts.disable_sudo || vps.disable_sudo {
323 return Err(SshCliError::SudoDisabled.into());
324 }
325 let cmd = append_description(command, opts.description.as_deref());
326 validate_command_length(&cmd, vps.max_command_chars.wire())?;
327 let cfg = build_connection_config(&vps, Some(&path), opts.replace_host_key);
328 let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
329 run_sudo_exec_with_client(&vps, &cmd, client, format, json).await
330}
331
332pub async fn run_sudo_exec_with_client(
334 vps: &VpsRecord,
335 command: &str,
336 client: Box<dyn SshClientTrait>,
337 format: OutputFormat,
338 json: bool,
339) -> Result<()> {
340 if crate::signals::should_stop() {
341 return Err(cancelled_err());
342 }
343 if vps.disable_sudo {
344 return Err(SshCliError::SudoDisabled.into());
345 }
346 let mut pack = pack_sudo(command, vps.sudo_password.as_ref());
347 let max_out = effective_limit(vps.max_output_chars.wire());
348 let stdin = pack.take_stdin();
349 let mut client = client;
350 let result = client.run_command(&pack.command, max_out, stdin).await;
351 finish_execution_output(client, result, format, json).await
352}
353
354#[allow(clippy::too_many_arguments)]
358pub async fn run_su_exec(
359 selection: HostSelection,
360 command: &str,
361 config_override: Option<PathBuf>,
362 format: OutputFormat,
363 json: bool,
364 opts: ExecOptions,
365) -> Result<()> {
366 if crate::signals::should_stop() {
367 return Err(cancelled_err());
368 }
369 if selection.is_batch() {
370 return run_exec_all(
371 &selection,
372 command,
373 config_override,
374 format,
375 json,
376 opts,
377 ExecKind::Su,
378 )
379 .await;
380 }
381 let vps_name = expect_single(selection)?;
382 let path = resolve_config_path(config_override.as_deref())?;
383 let mut file = load(&path)?;
384 let mut vps = file
385 .hosts
386 .remove(&vps_name)
387 .ok_or(SshCliError::VpsNotFound(vps_name))?;
388
389 apply_overrides(
390 &mut vps,
391 opts.password,
392 opts.sudo_password,
393 opts.su_password,
394 opts.timeout,
395 opts.key,
396 opts.key_passphrase,
397 opts.use_agent,
398 opts.agent_socket,
399 );
400 if opts.disable_sudo || vps.disable_sudo {
401 return Err(SshCliError::SudoDisabled.into());
402 }
403 let su_password = vps.su_password.take().ok_or(SshCliError::SuPasswordMissing)?;
405 let cmd = append_description(command, opts.description.as_deref());
406 validate_command_length(&cmd, vps.max_command_chars.wire())?;
407 let mut pack = pack_su(&cmd, &su_password);
408 let cfg = build_connection_config(&vps, Some(&path), opts.replace_host_key);
409 let mut client: Box<dyn SshClientTrait> =
410 <SshClient as SshClientTrait>::connect(cfg).await?;
411 let max_out = effective_limit(vps.max_output_chars.wire());
412 let stdin = pack.take_stdin();
413 let result = client.run_command(&pack.command, max_out, stdin).await;
414 finish_execution_output(client, result, format, json).await
415}
416
417#[allow(clippy::too_many_arguments)]
421async fn run_exec_all(
422 selection: &HostSelection,
423 command: &str,
424 config_override: Option<PathBuf>,
425 format: OutputFormat,
426 json: bool,
427 opts: ExecOptions,
428 kind: ExecKind,
429) -> Result<()> {
430 let path = resolve_config_path(config_override.as_deref())?;
431 let file = load(&path)?;
432 let jobs = resolve_host_jobs(selection, &file)?;
433 let limit = crate::concurrency::effective_limit();
434 let cmd_base = command.to_string();
435 let path_c = path.clone();
436 let opts_c = std::sync::Arc::new(opts);
438 let replace = opts_c.replace_host_key;
439 let total_jobs = jobs.len();
440
441 tracing::info!(
442 hosts = jobs.len(),
443 max_concurrency = limit,
444 fail_fast = crate::concurrency::fail_fast_enabled(),
445 kind = ?match kind {
446 ExecKind::Plain => "exec",
447 ExecKind::Sudo => "sudo-exec",
448 ExecKind::Su => "su-exec",
449 },
450 "multi-host exec fan-out"
451 );
452
453 let results = crate::concurrency::map_bounded_with(
454 jobs,
455 limit,
456 move |(name, mut vps)| {
457 let cmd_base = cmd_base.clone();
458 let path_c = path_c.clone();
459 let opts_arc = std::sync::Arc::clone(&opts_c);
460 async move {
461 let mut opts = (*opts_arc).clone();
462
463 if crate::signals::should_stop() {
464 return HostExecResult {
465 name,
466 ok: false,
467 exit_code: None,
468 stdout: String::new(),
469 stderr: "cancelled".into(),
470 duration_ms: 0,
471 error: Some("operation cancelled by signal".into()),
472 };
473 }
474 apply_overrides(
475 &mut vps,
476 opts.password.take(),
477 opts.sudo_password.take(),
478 opts.su_password.take(),
479 opts.timeout,
480 opts.key.take(),
481 opts.key_passphrase.take(),
482 opts.use_agent,
483 opts.agent_socket.take(),
484 );
485 let cmd = append_description(&cmd_base, opts.description.as_deref());
486 if let Err(e) = validate_command_length(&cmd, vps.max_command_chars.wire()) {
487 return HostExecResult {
488 name,
489 ok: false,
490 exit_code: None,
491 stdout: String::new(),
492 stderr: e.to_string(),
493 duration_ms: 0,
494 error: Some(e.to_string()),
495 };
496 }
497 match kind {
498 ExecKind::Sudo | ExecKind::Su if opts.disable_sudo || vps.disable_sudo => {
499 return HostExecResult {
500 name,
501 ok: false,
502 exit_code: None,
503 stdout: String::new(),
504 stderr: "sudo/su disabled".into(),
505 duration_ms: 0,
506 error: Some("sudo/su disabled".into()),
507 };
508 }
509 _ => {}
510 }
511 let start = std::time::Instant::now();
512 let run = async {
513 let cfg = build_connection_config(&vps, Some(&path_c), replace);
514 let mut client: Box<dyn SshClientTrait> =
515 <SshClient as SshClientTrait>::connect(cfg).await?;
516 let max_out = effective_limit(vps.max_output_chars.wire());
517 let output = match kind {
518 ExecKind::Plain => client.run_command(&cmd, max_out, None).await?,
519 ExecKind::Sudo => {
520 let mut pack = pack_sudo(&cmd, vps.sudo_password.as_ref());
521 let stdin = pack.take_stdin();
522 client.run_command(&pack.command, max_out, stdin).await?
523 }
524 ExecKind::Su => {
525 let su_pw = vps
526 .su_password
527 .take()
528 .ok_or(SshCliError::SuPasswordMissing)?;
529 let mut pack = pack_su(&cmd, &su_pw);
530 let stdin = pack.take_stdin();
531 client.run_command(&pack.command, max_out, stdin).await?
532 }
533 };
534 let _ = client.disconnect().await;
535 Ok::<_, SshCliError>(output)
536 }
537 .await;
538 let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
539 match run {
540 Ok(output) => {
541 let code_ok = output.exit_code.unwrap_or(0) == 0;
542 HostExecResult {
543 name,
544 ok: code_ok,
545 exit_code: output.exit_code,
546 stdout: output.stdout,
547 stderr: output.stderr,
548 duration_ms,
549 error: if code_ok {
550 None
551 } else {
552 Some(format!(
553 "exit {}",
554 output.exit_code.unwrap_or(-1)
555 ))
556 },
557 }
558 }
559 Err(e) => HostExecResult {
560 name,
561 ok: false,
562 exit_code: None,
563 stdout: String::new(),
564 stderr: e.to_string(),
565 duration_ms,
566 error: Some(e.to_string()),
567 },
568 }
569 }
570 },
571 |h: &HostExecResult| !h.ok,
572 )
573 .await;
574
575 let mut host_results = Vec::with_capacity(total_jobs.max(results.len()));
576 let mut failures = 0usize;
577 let mut seen = std::collections::BTreeSet::new();
578 for r in results {
579 match r.outcome {
580 Ok(h) => {
581 if !h.ok {
582 failures += 1;
583 }
584 seen.insert(r.index);
585 host_results.push(h);
586 }
587 Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()),
588 Err(e) => {
589 failures += 1;
590 seen.insert(r.index);
591 host_results.push(HostExecResult {
592 name: format!("task-{}", r.index),
593 ok: false,
594 exit_code: None,
595 stdout: String::new(),
596 stderr: e.to_string(),
597 duration_ms: 0,
598 error: Some(e.to_string()),
599 });
600 }
601 }
602 }
603 if crate::concurrency::fail_fast_enabled() && host_results.len() < total_jobs {
605 let skipped = total_jobs - host_results.len();
606 for i in 0..total_jobs {
607 if !seen.contains(&i) {
608 host_results.push(HostExecResult {
609 name: format!("skipped-{i}"),
610 ok: false,
611 exit_code: None,
612 stdout: String::new(),
613 stderr: "skipped (fail-fast)".into(),
614 duration_ms: 0,
615 error: Some("skipped (fail-fast)".into()),
616 });
617 }
618 }
619 let _ = skipped;
620 }
621
622 let as_json = format == OutputFormat::Json || json;
623 output::print_exec_batch(&host_results, limit, as_json)?;
624 if failures > 0 || host_results.iter().any(|h| !h.ok) {
625 let failed = host_results.iter().filter(|h| !h.ok).count();
626 return Err(SshCliError::Config(format!(
627 "{failed}/{} hosts failed multi-host exec",
628 host_results.len()
629 ))
630 .into());
631 }
632 Ok(())
633}