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) && cmds.len() > 1 {
228 let mut v =
230 serde_json::to_value(crate::json_wire::ExecutionJson::from(&output))
231 .unwrap_or_else(|_| serde_json::json!({}));
232 if let Some(obj) = v.as_object_mut() {
233 obj.insert("step".into(), serde_json::json!(i));
234 obj.insert("command".into(), serde_json::json!(cmd));
235 }
236 crate::output::print_json_value(&v)?;
237 } else if cmds.len() > 1 {
238 crate::output::write_line_fmt(format_args!("--- step {i}: {cmd} ---"))?;
239 crate::output::print_execution_output(&output);
240 } else {
241 last_output = Some(output.clone());
242 }
243 if let Some(code) = output.exit_code {
244 if code != 0 && failed.is_none() {
245 failed = Some((code, output.stderr.clone()));
246 }
247 }
248 if cmds.len() == 1 {
249 last_output = Some(output);
250 }
251 }
252 Err(e) => {
253 let _ = client.disconnect().await;
254 return Err(e.into());
255 }
256 }
257 }
258 let _ = client.disconnect().await;
259 if let Some((code, stderr)) = failed {
260 return Err(SshCliError::CommandFailed {
261 exit_code: code,
262 stderr,
263 }
264 .into());
265 }
266 if let Some(output) = last_output {
267 if format == OutputFormat::Json || json {
268 if cmds.len() == 1 {
270 crate::output::print_execution_output_json(&output)?;
271 }
272 } else if cmds.len() == 1 {
273 crate::output::print_execution_output(&output);
274 }
275 }
276 Ok(())
277}
278
279#[allow(clippy::too_many_arguments)]
283pub async fn run_sudo_exec(
284 selection: HostSelection,
285 command: &str,
286 config_override: Option<PathBuf>,
287 format: OutputFormat,
288 json: bool,
289 opts: ExecOptions,
290) -> Result<()> {
291 if crate::signals::should_stop() {
292 return Err(cancelled_err());
293 }
294 if selection.is_batch() {
295 return run_exec_all(
296 &selection,
297 command,
298 config_override,
299 format,
300 json,
301 opts,
302 ExecKind::Sudo,
303 )
304 .await;
305 }
306 let vps_name = expect_single(selection)?;
307 let path = resolve_config_path(config_override.as_deref())?;
308 let mut file = load(&path)?;
309 let mut vps = file
310 .hosts
311 .remove(&vps_name)
312 .ok_or(SshCliError::VpsNotFound(vps_name))?;
313
314 apply_overrides(
315 &mut vps,
316 opts.password,
317 opts.sudo_password,
318 opts.su_password,
319 opts.timeout,
320 opts.key,
321 opts.key_passphrase,
322 opts.use_agent,
323 opts.agent_socket,
324 );
325 if opts.disable_sudo || vps.disable_sudo {
326 return Err(SshCliError::SudoDisabled.into());
327 }
328 let cmd = append_description(command, opts.description.as_deref());
329 validate_command_length(&cmd, vps.max_command_chars.wire())?;
330 let cfg = build_connection_config(&vps, Some(&path), opts.replace_host_key);
331 let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
332 run_sudo_exec_with_client(&vps, &cmd, client, format, json).await
333}
334
335pub async fn run_sudo_exec_with_client(
337 vps: &VpsRecord,
338 command: &str,
339 client: Box<dyn SshClientTrait>,
340 format: OutputFormat,
341 json: bool,
342) -> Result<()> {
343 if crate::signals::should_stop() {
344 return Err(cancelled_err());
345 }
346 if vps.disable_sudo {
347 return Err(SshCliError::SudoDisabled.into());
348 }
349 let mut pack = pack_sudo(command, vps.sudo_password.as_ref());
350 let max_out = effective_limit(vps.max_output_chars.wire());
351 let stdin = pack.take_stdin();
352 let mut client = client;
353 let result = client.run_command(&pack.command, max_out, stdin).await;
354 finish_execution_output(client, result, format, json).await
355}
356
357#[allow(clippy::too_many_arguments)]
361pub async fn run_su_exec(
362 selection: HostSelection,
363 command: &str,
364 config_override: Option<PathBuf>,
365 format: OutputFormat,
366 json: bool,
367 opts: ExecOptions,
368) -> Result<()> {
369 if crate::signals::should_stop() {
370 return Err(cancelled_err());
371 }
372 if selection.is_batch() {
373 return run_exec_all(
374 &selection,
375 command,
376 config_override,
377 format,
378 json,
379 opts,
380 ExecKind::Su,
381 )
382 .await;
383 }
384 let vps_name = expect_single(selection)?;
385 let path = resolve_config_path(config_override.as_deref())?;
386 let mut file = load(&path)?;
387 let mut vps = file
388 .hosts
389 .remove(&vps_name)
390 .ok_or(SshCliError::VpsNotFound(vps_name))?;
391
392 apply_overrides(
393 &mut vps,
394 opts.password,
395 opts.sudo_password,
396 opts.su_password,
397 opts.timeout,
398 opts.key,
399 opts.key_passphrase,
400 opts.use_agent,
401 opts.agent_socket,
402 );
403 if opts.disable_sudo || vps.disable_sudo {
404 return Err(SshCliError::SudoDisabled.into());
405 }
406 let su_password = vps
408 .su_password
409 .take()
410 .ok_or(SshCliError::SuPasswordMissing)?;
411 let cmd = append_description(command, opts.description.as_deref());
412 validate_command_length(&cmd, vps.max_command_chars.wire())?;
413 let mut pack = pack_su(&cmd, &su_password);
414 let cfg = build_connection_config(&vps, Some(&path), opts.replace_host_key);
415 let mut client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
416 let max_out = effective_limit(vps.max_output_chars.wire());
417 let stdin = pack.take_stdin();
418 let result = client.run_command(&pack.command, max_out, stdin).await;
419 finish_execution_output(client, result, format, json).await
420}
421
422#[allow(clippy::too_many_arguments)]
426async fn run_exec_all(
427 selection: &HostSelection,
428 command: &str,
429 config_override: Option<PathBuf>,
430 format: OutputFormat,
431 json: bool,
432 opts: ExecOptions,
433 kind: ExecKind,
434) -> Result<()> {
435 let path = resolve_config_path(config_override.as_deref())?;
436 let file = load(&path)?;
437 let jobs = resolve_host_jobs(selection, &file)?;
438 let limit = crate::concurrency::effective_limit();
439 let cmd_base = command.to_string();
440 let path_c = path.clone();
441 let opts_c = std::sync::Arc::new(opts);
443 let replace = opts_c.replace_host_key;
444 let total_jobs = jobs.len();
445
446 tracing::info!(
447 hosts = jobs.len(),
448 max_concurrency = limit,
449 fail_fast = crate::concurrency::fail_fast_enabled(),
450 kind = ?match kind {
451 ExecKind::Plain => "exec",
452 ExecKind::Sudo => "sudo-exec",
453 ExecKind::Su => "su-exec",
454 },
455 "multi-host exec fan-out"
456 );
457
458 let results = crate::concurrency::map_bounded_with(
459 jobs,
460 limit,
461 move |(name, mut vps)| {
462 let cmd_base = cmd_base.clone();
463 let path_c = path_c.clone();
464 let opts_arc = std::sync::Arc::clone(&opts_c);
465 async move {
466 let mut opts = (*opts_arc).clone();
467
468 if crate::signals::should_stop() {
469 return HostExecResult {
470 name,
471 ok: false,
472 exit_code: None,
473 stdout: String::new(),
474 stderr: "cancelled".into(),
475 duration_ms: 0,
476 error: Some("operation cancelled by signal".into()),
477 };
478 }
479 apply_overrides(
480 &mut vps,
481 opts.password.take(),
482 opts.sudo_password.take(),
483 opts.su_password.take(),
484 opts.timeout,
485 opts.key.take(),
486 opts.key_passphrase.take(),
487 opts.use_agent,
488 opts.agent_socket.take(),
489 );
490 let cmd = append_description(&cmd_base, opts.description.as_deref());
491 if let Err(e) = validate_command_length(&cmd, vps.max_command_chars.wire()) {
492 return HostExecResult {
493 name,
494 ok: false,
495 exit_code: None,
496 stdout: String::new(),
497 stderr: e.to_string(),
498 duration_ms: 0,
499 error: Some(e.to_string()),
500 };
501 }
502 match kind {
503 ExecKind::Sudo | ExecKind::Su if opts.disable_sudo || vps.disable_sudo => {
504 return HostExecResult {
505 name,
506 ok: false,
507 exit_code: None,
508 stdout: String::new(),
509 stderr: "sudo/su disabled".into(),
510 duration_ms: 0,
511 error: Some("sudo/su disabled".into()),
512 };
513 }
514 _ => {}
515 }
516 let start = std::time::Instant::now();
517 let run = async {
518 let cfg = build_connection_config(&vps, Some(&path_c), replace);
519 let mut client: Box<dyn SshClientTrait> =
520 <SshClient as SshClientTrait>::connect(cfg).await?;
521 let max_out = effective_limit(vps.max_output_chars.wire());
522 let output = match kind {
523 ExecKind::Plain => client.run_command(&cmd, max_out, None).await?,
524 ExecKind::Sudo => {
525 let mut pack = pack_sudo(&cmd, vps.sudo_password.as_ref());
526 let stdin = pack.take_stdin();
527 client.run_command(&pack.command, max_out, stdin).await?
528 }
529 ExecKind::Su => {
530 let su_pw = vps
531 .su_password
532 .take()
533 .ok_or(SshCliError::SuPasswordMissing)?;
534 let mut pack = pack_su(&cmd, &su_pw);
535 let stdin = pack.take_stdin();
536 client.run_command(&pack.command, max_out, stdin).await?
537 }
538 };
539 let _ = client.disconnect().await;
540 Ok::<_, SshCliError>(output)
541 }
542 .await;
543 let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
544 match run {
545 Ok(output) => {
546 let code_ok = output.exit_code.unwrap_or(0) == 0;
547 HostExecResult {
548 name,
549 ok: code_ok,
550 exit_code: output.exit_code,
551 stdout: output.stdout,
552 stderr: output.stderr,
553 duration_ms,
554 error: if code_ok {
555 None
556 } else {
557 Some(format!("exit {}", output.exit_code.unwrap_or(-1)))
558 },
559 }
560 }
561 Err(e) => HostExecResult {
562 name,
563 ok: false,
564 exit_code: None,
565 stdout: String::new(),
566 stderr: e.to_string(),
567 duration_ms,
568 error: Some(e.to_string()),
569 },
570 }
571 }
572 },
573 |h: &HostExecResult| !h.ok,
574 )
575 .await;
576
577 let mut host_results = Vec::with_capacity(total_jobs.max(results.len()));
578 let mut failures = 0usize;
579 let mut seen = std::collections::BTreeSet::new();
580 for r in results {
581 match r.outcome {
582 Ok(h) => {
583 if !h.ok {
584 failures += 1;
585 }
586 seen.insert(r.index);
587 host_results.push(h);
588 }
589 Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()),
590 Err(e) => {
591 failures += 1;
592 seen.insert(r.index);
593 host_results.push(HostExecResult {
594 name: format!("task-{}", r.index),
595 ok: false,
596 exit_code: None,
597 stdout: String::new(),
598 stderr: e.to_string(),
599 duration_ms: 0,
600 error: Some(e.to_string()),
601 });
602 }
603 }
604 }
605 if crate::concurrency::fail_fast_enabled() && host_results.len() < total_jobs {
607 let skipped = total_jobs - host_results.len();
608 for i in 0..total_jobs {
609 if !seen.contains(&i) {
610 host_results.push(HostExecResult {
611 name: format!("skipped-{i}"),
612 ok: false,
613 exit_code: None,
614 stdout: String::new(),
615 stderr: "skipped (fail-fast)".into(),
616 duration_ms: 0,
617 error: Some("skipped (fail-fast)".into()),
618 });
619 }
620 }
621 let _ = skipped;
622 }
623
624 let as_json = format == OutputFormat::Json || json;
625 output::print_exec_batch(&host_results, limit, as_json)?;
626 if failures > 0 || host_results.iter().any(|h| !h.ok) {
627 let failed = host_results.iter().filter(|h| !h.ok).count();
628 return Err(SshCliError::Config(format!(
629 "{failed}/{} hosts failed multi-host exec",
630 host_results.len()
631 ))
632 .into());
633 }
634 Ok(())
635}