Workspace warning count: 408 → 105.
Three buckets cleared:
* Auto-fixable (`cargo fix` + `cargo clippy --fix`): unused imports
removed, unused variables prefixed with `_`, deprecated method
calls updated. Applied across harmony, harmony-k8s, harmony-agent,
harmony_inventory_agent, the fleet/ workspace, and ~15 examples.
* Generated code (opnsense-api/src/generated/): 269 snake_case
warnings + ~10 unreachable-pattern warnings come from
CamelCase-preserving bindings to OPNsense's HAProxy/Caddy XML
schemas. Scoped a single `#[allow(non_snake_case,
unreachable_patterns)]` at `pub mod generated;` rather than
fighting the codegen — renaming would break serde round-trips
and the codegen would regenerate them anyway.
* opnsense-codegen parser's defensive `let...else` guards on
`XmlNode` (currently single-variant): file-level
`#![allow(irrefutable_let_patterns)]` with a comment explaining
why we keep the `else` arms (they re-arm if the IR grows a
second variant).
`harmony_inventory_agent::local_presence::{DiscoveryEvent,
discover_agents}` re-exports were stripped twice by the auto-fix
passes (consumers live in another crate, so the local crate looks
"unused" to lint). Anchored with explicit `pub use` + an
`#[allow(unused_imports)]` annotation noting why.
All 151 harmony lib tests still pass. Remaining ~105 warnings are
mostly real dead code in non-fleet modules + a handful of
unused-imports/variables clippy couldn't auto-resolve; cleared in
the next pass.
471 lines
14 KiB
Rust
471 lines
14 KiB
Rust
use std::io::{BufRead, BufReader};
|
|
use std::process::{Command, Stdio};
|
|
use std::sync::Arc;
|
|
use std::thread;
|
|
|
|
/// Captured output from a command execution
|
|
#[derive(Debug, Clone)]
|
|
pub struct CommandOutput {
|
|
/// Captured stdout content
|
|
pub stdout: String,
|
|
/// Captured stderr content
|
|
pub stderr: String,
|
|
/// Exit status of the command
|
|
pub status: CommandStatus,
|
|
}
|
|
|
|
impl CommandOutput {
|
|
/// Returns true if the command succeeded
|
|
pub fn is_success(&self) -> bool {
|
|
self.status.is_success()
|
|
}
|
|
|
|
/// Formats the complete output for display
|
|
pub fn format_output(&self) -> String {
|
|
format!(
|
|
"Stdout:\n{}\n\nStderr:\n{}",
|
|
if self.stdout.is_empty() {
|
|
"<empty>"
|
|
} else {
|
|
&self.stdout
|
|
},
|
|
if self.stderr.is_empty() {
|
|
"<empty>"
|
|
} else {
|
|
&self.stderr
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Result status of a command execution
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum CommandStatus {
|
|
/// Command executed successfully (exit code 0)
|
|
Success,
|
|
/// Command failed with an exit code
|
|
Failed(i32),
|
|
/// Command was terminated by a signal
|
|
Terminated(i32),
|
|
/// Command execution could not be started
|
|
Error(String),
|
|
}
|
|
|
|
impl CommandStatus {
|
|
pub fn is_success(&self) -> bool {
|
|
matches!(self, CommandStatus::Success)
|
|
}
|
|
}
|
|
|
|
impl From<std::process::ExitStatus> for CommandStatus {
|
|
fn from(status: std::process::ExitStatus) -> Self {
|
|
if status.success() {
|
|
CommandStatus::Success
|
|
} else if let Some(code) = status.code() {
|
|
CommandStatus::Failed(code)
|
|
} else {
|
|
CommandStatus::Terminated(0) // Signal codes are platform-specific
|
|
}
|
|
}
|
|
}
|
|
|
|
type Callback = Arc<dyn Fn(&str) + Send + Sync>;
|
|
|
|
/// Options for configuring command execution
|
|
#[derive(Clone)]
|
|
pub struct RunnerOptions {
|
|
/// Whether to print stdout to console in real-time
|
|
pub print_stdout: bool,
|
|
/// Whether to print stderr to console in real-time
|
|
pub print_stderr: bool,
|
|
/// Optional callback for each stdout line
|
|
pub stdout_callback: Callback,
|
|
/// Optional callback for each stderr line
|
|
pub stderr_callback: Callback,
|
|
}
|
|
|
|
impl RunnerOptions {
|
|
fn empty_callback() -> Callback {
|
|
Arc::new(|_| {})
|
|
}
|
|
/// Create default options with real-time printing enabled
|
|
pub fn print_to_console() -> Self {
|
|
Self {
|
|
print_stdout: true,
|
|
print_stderr: true,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Create options that capture output silently
|
|
pub fn silent() -> Self {
|
|
Self {
|
|
print_stdout: false,
|
|
print_stderr: false,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Set custom callbacks for stdout and stderr lines
|
|
pub fn with_callbacks<F1, F2>(mut self, stdout_callback: F1, stderr_callback: F2) -> Self
|
|
where
|
|
F1: Fn(&str) + Send + Sync + 'static,
|
|
F2: Fn(&str) + Send + Sync + 'static,
|
|
{
|
|
self.stdout_callback = Arc::new(stdout_callback);
|
|
self.stderr_callback = Arc::new(stderr_callback);
|
|
self
|
|
}
|
|
}
|
|
|
|
impl Default for RunnerOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
print_stdout: true,
|
|
print_stderr: true,
|
|
stdout_callback: Self::empty_callback(),
|
|
stderr_callback: Self::empty_callback(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Error type for command execution failures
|
|
#[derive(Debug)]
|
|
pub struct CommandError {
|
|
/// Human-readable error description
|
|
pub message: String,
|
|
/// Captured output if execution started
|
|
pub output: Option<CommandOutput>,
|
|
}
|
|
|
|
impl std::fmt::Display for CommandError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.message)?;
|
|
if let Some(output) = &self.output {
|
|
write!(f, "\n{}", output.format_output())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for CommandError {}
|
|
|
|
/// Runs a command and captures its output while streaming to console
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```
|
|
/// use harmony_execution::command::{run_command, RunnerOptions};
|
|
/// use std::process::Command;
|
|
///
|
|
/// let output = run_command(
|
|
/// Command::new("echo").arg("hello"),
|
|
/// RunnerOptions::print_to_console()
|
|
/// ).unwrap();
|
|
/// assert!(output.is_success());
|
|
/// assert_eq!(output.stdout, "hello\n");
|
|
/// ```
|
|
pub fn run_command(
|
|
command: &mut Command,
|
|
options: RunnerOptions,
|
|
) -> Result<CommandOutput, CommandError> {
|
|
let mut child = command
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.map_err(|e| CommandError {
|
|
message: format!("Failed to spawn command: {}", e),
|
|
output: None,
|
|
})?;
|
|
|
|
let stdout = child.stdout.take().ok_or_else(|| CommandError {
|
|
message: "Failed to capture stdout".to_string(),
|
|
output: None,
|
|
})?;
|
|
|
|
let stderr = child.stderr.take().ok_or_else(|| CommandError {
|
|
message: "Failed to capture stderr".to_string(),
|
|
output: None,
|
|
})?;
|
|
|
|
let stdout_reader = BufReader::new(stdout);
|
|
let stderr_reader = BufReader::new(stderr);
|
|
|
|
let (stdout_sender, stdout_receiver) = std::sync::mpsc::channel();
|
|
let (stderr_sender, stderr_receiver) = std::sync::mpsc::channel();
|
|
|
|
// Spawn thread to handle stdout
|
|
let stdout_handle = thread::spawn(move || {
|
|
let mut output = String::new();
|
|
for line in stdout_reader.lines() {
|
|
match line {
|
|
Ok(line_content) => {
|
|
if options.print_stdout {
|
|
println!("{}", line_content);
|
|
}
|
|
(options.stdout_callback)(&line_content);
|
|
output.push_str(&line_content);
|
|
output.push('\n');
|
|
}
|
|
Err(e) => {
|
|
// Silently handle read errors - corrupted data at end is common
|
|
log::trace!("Error reading stdout line: {}", e);
|
|
}
|
|
}
|
|
}
|
|
let _ = stdout_sender.send(output);
|
|
});
|
|
|
|
// Spawn thread to handle stderr
|
|
let stderr_handle = thread::spawn(move || {
|
|
let mut output = String::new();
|
|
for line in stderr_reader.lines() {
|
|
match line {
|
|
Ok(line_content) => {
|
|
if options.print_stderr {
|
|
eprintln!("{}", line_content);
|
|
}
|
|
(options.stderr_callback)(&line_content);
|
|
output.push_str(&line_content);
|
|
output.push('\n');
|
|
}
|
|
Err(e) => {
|
|
log::trace!("Error reading stderr line: {}", e);
|
|
}
|
|
}
|
|
}
|
|
let _ = stderr_sender.send(output);
|
|
});
|
|
|
|
let status = child.wait().map_err(|e| CommandError {
|
|
message: format!("Failed to wait for command process: {}", e),
|
|
output: None,
|
|
})?;
|
|
|
|
let stdout_lines = stdout_handle
|
|
.join()
|
|
.map_err(|e| CommandError {
|
|
message: format!("Stdout thread panicked: {:?}", e),
|
|
output: None,
|
|
})
|
|
.and_then(|_| {
|
|
stdout_receiver.recv().map_err(|e| CommandError {
|
|
message: format!("Failed to receive stdout: {}", e),
|
|
output: None,
|
|
})
|
|
})?;
|
|
|
|
let stderr_lines = stderr_handle
|
|
.join()
|
|
.map_err(|e| CommandError {
|
|
message: format!("Stderr thread panicked: {:?}", e),
|
|
output: None,
|
|
})
|
|
.and_then(|_| {
|
|
stderr_receiver.recv().map_err(|e| CommandError {
|
|
message: format!("Failed to receive stderr: {}", e),
|
|
output: None,
|
|
})
|
|
})?;
|
|
|
|
Ok(CommandOutput {
|
|
stdout: stdout_lines,
|
|
stderr: stderr_lines,
|
|
status: status.into(),
|
|
})
|
|
}
|
|
|
|
/// Convenience function to run a command with default options (print to console)
|
|
pub fn run(command: &mut Command) -> Result<CommandOutput, CommandError> {
|
|
run_command(command, RunnerOptions::print_to_console())
|
|
}
|
|
|
|
/// Convenience function to run a command silently (capture output only)
|
|
pub fn run_silent(command: &mut Command) -> Result<CommandOutput, CommandError> {
|
|
run_command(command, RunnerOptions::silent())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::process::Command;
|
|
|
|
#[test]
|
|
fn test_simple_echo_command() {
|
|
let output = run_silent(Command::new("echo").arg("hello world")).unwrap();
|
|
assert!(output.is_success());
|
|
assert_eq!(output.stdout.trim(), "hello world");
|
|
assert!(output.stderr.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_command_failure() {
|
|
let output = run_silent(Command::new("sh").args(["-c", "exit 42"])).unwrap();
|
|
assert!(!output.is_success());
|
|
assert_eq!(output.status, CommandStatus::Failed(42));
|
|
}
|
|
|
|
#[test]
|
|
fn test_command_output_format() {
|
|
let output = run_silent(Command::new("echo").arg("test")).unwrap();
|
|
let formatted = output.format_output();
|
|
assert!(formatted.contains("Stdout:"));
|
|
assert!(formatted.contains("test"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_runner_options() {
|
|
let opts = RunnerOptions::print_to_console();
|
|
assert!(opts.print_stdout);
|
|
assert!(opts.print_stderr);
|
|
|
|
let opts = RunnerOptions::silent();
|
|
assert!(!opts.print_stdout);
|
|
assert!(!opts.print_stderr);
|
|
}
|
|
|
|
#[test]
|
|
fn test_command_status_from_exit_status() {
|
|
let output = run_silent(&mut Command::new("true")).unwrap();
|
|
assert_eq!(output.status, CommandStatus::Success);
|
|
|
|
let output = run_silent(&mut Command::new("false")).unwrap();
|
|
assert_eq!(output.status, CommandStatus::Failed(1));
|
|
}
|
|
|
|
#[test]
|
|
fn test_stdout_callback_receives_lines() {
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
let captured = Arc::new(Mutex::new(Vec::new()));
|
|
let captured_clone = Arc::clone(&captured);
|
|
|
|
let opts = RunnerOptions::silent().with_callbacks(
|
|
move |line| captured_clone.lock().unwrap().push(line.to_string()),
|
|
|_| {},
|
|
);
|
|
|
|
run_command(Command::new("echo").arg("hello world"), opts).unwrap();
|
|
|
|
let lines = captured.lock().unwrap();
|
|
assert_eq!(lines.len(), 1);
|
|
assert_eq!(lines[0], "hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn test_stderr_callback_receives_lines() {
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
let captured = Arc::new(Mutex::new(Vec::new()));
|
|
let captured_clone = Arc::clone(&captured);
|
|
|
|
let opts = RunnerOptions::silent().with_callbacks(
|
|
|_| {},
|
|
move |line| captured_clone.lock().unwrap().push(line.to_string()),
|
|
);
|
|
|
|
run_command(Command::new("sh").args(["-c", "echo error >&2"]), opts).unwrap();
|
|
|
|
let lines = captured.lock().unwrap();
|
|
assert_eq!(lines.len(), 1);
|
|
assert_eq!(lines[0], "error");
|
|
}
|
|
|
|
#[test]
|
|
fn test_callback_and_capture_both_work() {
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
let callback_lines = Arc::new(Mutex::new(Vec::new()));
|
|
let callback_clone = Arc::clone(&callback_lines);
|
|
|
|
let opts = RunnerOptions::silent().with_callbacks(
|
|
move |line| callback_clone.lock().unwrap().push(line.to_string()),
|
|
|_| {},
|
|
);
|
|
|
|
let output =
|
|
run_command(Command::new("printf").args(["line1\nline2\nline3\n"]), opts).unwrap();
|
|
|
|
// Verify captured output
|
|
assert_eq!(output.stdout, "line1\nline2\nline3\n");
|
|
|
|
// Verify callback received all lines
|
|
let lines = callback_lines.lock().unwrap();
|
|
assert_eq!(lines.len(), 3);
|
|
assert_eq!(lines[0], "line1");
|
|
assert_eq!(lines[1], "line2");
|
|
assert_eq!(lines[2], "line3");
|
|
}
|
|
|
|
#[test]
|
|
fn test_multiline_output_capture() {
|
|
let output = run_silent(Command::new("printf").args(["line1\nline2\nline3\n"])).unwrap();
|
|
|
|
assert_eq!(output.stdout, "line1\nline2\nline3\n");
|
|
assert!(output.stderr.trim().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_mixed_stdout_stderr_capture() {
|
|
let output = run_silent(Command::new("sh").args([
|
|
"-c",
|
|
"echo stdout1 && echo stderr1 >&2 && echo stdout2 && echo stderr2 >&2",
|
|
]))
|
|
.unwrap();
|
|
|
|
assert!(output.stdout.contains("stdout1"));
|
|
assert!(output.stdout.contains("stdout2"));
|
|
assert!(output.stderr.contains("stderr1"));
|
|
assert!(output.stderr.contains("stderr2"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_output_command() {
|
|
let output = run_silent(&mut Command::new("true")).unwrap();
|
|
|
|
assert!(output.stdout.is_empty());
|
|
assert!(output.stderr.is_empty());
|
|
assert!(output.is_success());
|
|
}
|
|
|
|
#[test]
|
|
fn test_command_output_format_with_empty_streams() {
|
|
let output = run_silent(&mut Command::new("true")).unwrap();
|
|
let formatted = output.format_output();
|
|
|
|
assert!(formatted.contains("Stdout:"));
|
|
assert!(formatted.contains("<empty>"));
|
|
assert!(formatted.contains("Stderr:"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_contains_message_and_output() {
|
|
let error = CommandError {
|
|
message: "Test error".to_string(),
|
|
output: Some(CommandOutput {
|
|
stdout: "captured stdout".to_string(),
|
|
stderr: "captured stderr".to_string(),
|
|
status: CommandStatus::Success,
|
|
}),
|
|
};
|
|
|
|
let display = format!("{}", error);
|
|
assert!(display.contains("Test error"));
|
|
assert!(display.contains("captured stdout"));
|
|
assert!(display.contains("captured stderr"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_without_output() {
|
|
let error = CommandError {
|
|
message: "Spawn failed".to_string(),
|
|
output: None,
|
|
};
|
|
|
|
let display = format!("{}", error);
|
|
assert!(display.contains("Spawn failed"));
|
|
assert!(!display.contains("Stdout:"));
|
|
assert!(!display.contains("Stderr:"));
|
|
}
|
|
}
|