Skip to main content

ssh_cli/vps/
exec_ops.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Remote exec / sudo-exec / su-exec (SRP extract — G-COMP-05).
3//!
4//! Workload: **I/O-bound** SSH. Multi-host fan-out uses
5//! [`crate::concurrency::map_bounded`]. Single-host is one-shot
6//! connect → run → disconnect (rules one-shot).
7//!
8//! Secrets: [`secrecy::SecretString`] only; prefer `take` over clone (rules memory).
9#![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/// Common remote execution options.
26///
27/// G-SECDEV-02: password fields are [`SecretString`] so zeroize-on-drop applies
28/// through multi-host clone/fan-out (secrecy 0.10 `SecretString: Clone`).
29///
30/// G-TYPE-18/19: `timeout` is [`TimeoutMs`]; `steps` are [`RemoteCommand`].
31#[derive(Debug, Default, Clone)]
32pub struct ExecOptions {
33    /// Override password.
34    pub password: Option<SecretString>,
35    /// Override sudo.
36    pub sudo_password: Option<SecretString>,
37    /// Override su.
38    pub su_password: Option<SecretString>,
39    /// Override timeout (refined at CLI boundary).
40    pub timeout: Option<crate::domain::TimeoutMs>,
41    /// Override key path.
42    pub key: Option<String>,
43    /// Override key passphrase.
44    pub key_passphrase: Option<SecretString>,
45    /// Use ssh-agent (G-SSH-04).
46    pub use_agent: bool,
47    /// Agent socket path (CLI/XDG).
48    pub agent_socket: Option<String>,
49    /// Optional shell description comment.
50    pub description: Option<String>,
51    /// replace host key.
52    pub replace_host_key: bool,
53    /// disable sudo global.
54    pub disable_sudo: bool,
55    /// Extra commands on the same SSH session after the primary (G-O3 / G-TYPE-19).
56    pub steps: Vec<crate::domain::RemoteCommand>,
57}
58
59/// Kind of remote elevation for multi-host exec fan-out.
60#[derive(Clone, Copy)]
61enum ExecKind {
62    Plain,
63    Sudo,
64    Su,
65}
66
67/// Per-host result for multi-host exec JSON/text.
68#[derive(Debug, Clone)]
69pub struct HostExecResult {
70    /// VPS name.
71    pub name: String,
72    /// Whether the remote command succeeded (exit 0).
73    pub ok: bool,
74    /// Remote exit code when available.
75    pub exit_code: Option<i32>,
76    /// Captured stdout.
77    pub stdout: String,
78    /// Captured stderr or local error text.
79    pub stderr: String,
80    /// Wall duration in milliseconds.
81    pub duration_ms: u64,
82    /// Error summary when `ok` is false.
83    pub error: Option<String>,
84}
85
86/// G-DRY-01: disconnect + print + map non-zero exit (single-host exec family).
87///
88/// One-shot: always disconnect before returning so the session does not linger.
89async 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/// Runs a shell command on one VPS or a multi-host selection (bounded).
129///
130/// Workload: **I/O-bound** SSH. Multi-host (`All` / `Named`) uses
131/// [`crate::concurrency::map_bounded`]. Batch JSON when [`HostSelection::is_batch`].
132#[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    // Move the record out of the local map (file is discarded after connect setup).
160    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
186/// Testable version of run_exec.
187pub 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
197/// G-O3: one SSH session, primary command + optional extra `--step` commands.
198pub 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                // G8: multi-step guard on JSON path too (parity with text branch).
226                // Single-step must emit exactly one object (the aggregate at the end).
227                if (format == OutputFormat::Json || json) && cmds.len() > 1 {
228                    // Multi-step: emit one JSON line per step with index.
229                    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            // already printed above for multi; single without json path:
269            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/// Runs a command with `sudo` (packed via `sh -c`).
280///
281/// Workload: **I/O-bound** SSH. Multi-host uses [`crate::concurrency::map_bounded`].
282#[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
335/// Testable version of sudo-exec.
336pub 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/// Runs a command via `su -` one-shot (consumes `su_password`).
358///
359/// Workload: **I/O-bound** SSH. Multi-host uses [`crate::concurrency::map_bounded`].
360#[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    // `take` moves the secret out of the record (no clone of SecretString).
407    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/// Multi-host exec/sudo/su with bounded concurrency (I/O-bound SSH).
423///
424/// Uses [`resolve_host_jobs`] so `--all` and `--hosts` share one gate (G-PAR-31).
425#[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    // G-O6: Arc options — clone Arc per task, not full SecretString bundle by accident.
442    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    // G-O1: pad skipped hosts when fail-fast stopped admission mid-fleet.
606    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}