#![forbid(unsafe_code)]
use super::selection::{resolve_host_jobs, HostSelection};
use super::{
apply_overrides, build_connection_config, load, resolve_config_path, validate_command_length,
};
use crate::cli::OutputFormat;
use crate::errors::{finish_batch, SshCliError};
use crate::output;
use crate::ssh::client::{ExecutionOutput, SshClient, SshClientTrait};
use crate::ssh::packing::{append_description, pack_su, pack_sudo, PackedCommand};
use crate::vps::model::{effective_limit, VpsRecord};
use anyhow::Result;
use secrecy::SecretString;
use std::path::PathBuf;
mod elevation;
mod fleet;
mod single;
mod types;
pub use elevation::{run_su_exec, run_sudo_exec, run_sudo_exec_with_client};
pub(crate) use fleet::run_exec_all;
pub use single::{run_exec, run_exec_with_client};
pub(crate) use single::{run_prepared_steps, step_labels, PreparedStep};
pub(crate) use types::{cancelled_err, expect_single, ExecKind};
pub use types::{ExecOptions, HostExecResult};
#[cfg(test)]
mod tests {
use super::*;
use crate::ssh::client::mocks::MockSshClient;
use crate::ssh::client::ExecutionOutput;
use secrecy::SecretString;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
fn record(sudo: Option<&str>, su: Option<&str>) -> VpsRecord {
VpsRecord::test_new(
"srv",
"host.example.com",
22,
"admin",
SecretString::from("pass".to_string()),
None,
None,
Some(60_000),
Some(1_000),
Some(50_000),
sudo.map(|s| SecretString::from(s.to_string())),
su.map(|s| SecretString::from(s.to_string())),
false,
)
}
fn steps(raw: &[&str]) -> Vec<crate::domain::RemoteCommand> {
raw.iter()
.map(|s| {
crate::domain::RemoteCommand::try_new(*s)
.unwrap_or_else(|_| unreachable!("test step is a valid remote command: {s}"))
})
.collect()
}
fn counting_mock(seen: &Arc<std::sync::Mutex<Vec<String>>>) -> MockSshClient {
let mut mock = MockSshClient::new();
let seen = Arc::clone(seen);
mock.expect_run_command().returning(move |cmd, _, _| {
if let Ok(mut v) = seen.lock() {
v.push(cmd.to_owned());
}
Ok(ExecutionOutput {
stdout: "ok".into(),
stderr: String::new(),
exit_code: Some(0),
truncated_stdout: false,
truncated_stderr: false,
duration_ms: 1,
})
});
mock.expect_disconnect().returning(|| Ok(()));
mock
}
fn test_target() -> crate::json_wire::ExecTarget {
crate::json_wire::ExecTarget::new("test-host", crate::json_wire::TargetSource::Argv)
}
#[tokio::test]
#[serial_test::serial]
async fn sudo_exec_runs_every_step() {
crate::signals::reset_flags_for_tests();
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let mock = counting_mock(&seen);
let vps = record(Some("sudopw"), None);
elevation::run_sudo_exec_with_client_steps(
&vps,
"first",
&steps(&["second", "third"]),
Box::new(mock),
OutputFormat::Text,
false,
&test_target(),
)
.await
.unwrap_or_else(|e| unreachable!("sudo steps must succeed: {e}"));
let sent = seen.lock().unwrap_or_else(|e| e.into_inner()).clone();
assert_eq!(sent.len(), 3, "one remote command per step: {sent:?}");
for (i, raw) in ["first", "second", "third"].iter().enumerate() {
assert!(sent[i].contains("sudo -S -p '' sh -c"), "{}", sent[i]);
assert!(sent[i].contains(raw), "{}", sent[i]);
assert!(!sent[i].contains("sudopw"), "password must stay off argv");
}
}
#[tokio::test]
#[serial_test::serial]
async fn exec_runs_every_step() {
crate::signals::reset_flags_for_tests();
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let mock = counting_mock(&seen);
let vps = record(None, None);
single::run_exec_with_client_steps(
&vps,
"first",
&steps(&["second"]),
Box::new(mock),
OutputFormat::Text,
false,
&test_target(),
)
.await
.unwrap_or_else(|e| unreachable!("exec steps must succeed: {e}"));
let sent = seen.lock().unwrap_or_else(|e| e.into_inner()).clone();
assert_eq!(sent, vec!["first".to_string(), "second".to_string()]);
}
#[tokio::test]
#[serial_test::serial]
async fn sudo_exec_step_failure_is_reported() {
crate::signals::reset_flags_for_tests();
let calls = Arc::new(AtomicUsize::new(0));
let mut mock = MockSshClient::new();
let calls_c = Arc::clone(&calls);
mock.expect_run_command().returning(move |_, _, _| {
let n = calls_c.fetch_add(1, Ordering::Relaxed);
Ok(ExecutionOutput {
stdout: String::new(),
stderr: if n == 0 { "boom".into() } else { String::new() },
exit_code: Some(if n == 0 { 7 } else { 0 }),
truncated_stdout: false,
truncated_stderr: false,
duration_ms: 1,
})
});
mock.expect_disconnect().returning(|| Ok(()));
let vps = record(Some("sudopw"), None);
let err = elevation::run_sudo_exec_with_client_steps(
&vps,
"first",
&steps(&["second"]),
Box::new(mock),
OutputFormat::Text,
false,
&test_target(),
)
.await
.expect_err("first step exited 7");
assert_eq!(calls.load(Ordering::Relaxed), 2, "later steps still run");
let downcast = err.downcast_ref::<SshCliError>();
assert!(
matches!(
downcast,
Some(SshCliError::CommandFailed { exit_code: 7, .. })
),
"{err}"
);
}
#[tokio::test]
#[serial_test::serial]
async fn step_zero_command_not_found_aborts_the_batch() {
crate::signals::reset_flags_for_tests();
let calls = Arc::new(AtomicUsize::new(0));
let mut mock = MockSshClient::new();
let calls_c = Arc::clone(&calls);
mock.expect_run_command().returning(move |_, _, _| {
let n = calls_c.fetch_add(1, Ordering::Relaxed);
Ok(ExecutionOutput {
stdout: String::new(),
stderr: if n == 0 {
"sh: ssh-danilo: command not found".into()
} else {
String::new()
},
exit_code: Some(if n == 0 { 127 } else { 0 }),
truncated_stdout: false,
truncated_stderr: false,
duration_ms: 1,
})
});
mock.expect_disconnect().returning(|| Ok(()));
let vps = record(None, None);
let err = single::run_exec_with_client_steps(
&vps,
"ssh-danilo",
&steps(&["second", "third"]),
Box::new(mock),
OutputFormat::Text,
false,
&test_target(),
)
.await
.expect_err("127 on step zero must fail the run");
assert_eq!(
calls.load(Ordering::Relaxed),
1,
"steps after a step-zero 127 must never reach the wire"
);
assert!(
matches!(
err.downcast_ref::<SshCliError>(),
Some(SshCliError::CommandFailed { exit_code: 127, .. })
),
"{err}"
);
}
#[tokio::test]
#[serial_test::serial]
async fn a_later_command_not_found_does_not_abort() {
crate::signals::reset_flags_for_tests();
let calls = Arc::new(AtomicUsize::new(0));
let mut mock = MockSshClient::new();
let calls_c = Arc::clone(&calls);
mock.expect_run_command().returning(move |_, _, _| {
let n = calls_c.fetch_add(1, Ordering::Relaxed);
Ok(ExecutionOutput {
stdout: String::new(),
stderr: String::new(),
exit_code: Some(if n == 1 { 127 } else { 0 }),
truncated_stdout: false,
truncated_stderr: false,
duration_ms: 1,
})
});
mock.expect_disconnect().returning(|| Ok(()));
let vps = record(None, None);
let err = single::run_exec_with_client_steps(
&vps,
"first",
&steps(&["missing-binary", "third"]),
Box::new(mock),
OutputFormat::Text,
false,
&test_target(),
)
.await
.expect_err("a non-zero step still fails the run");
assert_eq!(
calls.load(Ordering::Relaxed),
3,
"a 127 after step zero must not truncate the batch"
);
assert!(
matches!(
err.downcast_ref::<SshCliError>(),
Some(SshCliError::CommandFailed { exit_code: 127, .. })
),
"{err}"
);
}
}