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                if format == OutputFormat::Json || json {
226                    // Multi-step: emit one JSON line per step with index.
227                    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            // already printed above for multi; single without json path:
266            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/// Runs a command with `sudo` (packed via `sh -c`).
277///
278/// Workload: **I/O-bound** SSH. Multi-host uses [`crate::concurrency::map_bounded`].
279#[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
332/// Testable version of sudo-exec.
333pub 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/// Runs a command via `su -` one-shot (consumes `su_password`).
355///
356/// Workload: **I/O-bound** SSH. Multi-host uses [`crate::concurrency::map_bounded`].
357#[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    // `take` moves the secret out of the record (no clone of SecretString).
404    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/// Multi-host exec/sudo/su with bounded concurrency (I/O-bound SSH).
418///
419/// Uses [`resolve_host_jobs`] so `--all` and `--hosts` share one gate (G-PAR-31).
420#[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    // G-O6: Arc options — clone Arc per task, not full SecretString bundle by accident.
437    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    // G-O1: pad skipped hosts when fail-fast stopped admission mid-fleet.
604    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}