Skip to main content

terraform_wrapper/
exec.rs

1use std::fmt;
2use std::process::Stdio;
3
4use tokio::process::Command as TokioCommand;
5use tracing::{debug, trace, warn};
6
7use crate::Terraform;
8use crate::error::{Error, Result};
9
10/// Raw output from a Terraform command execution.
11#[derive(Debug, Clone)]
12pub struct CommandOutput {
13    /// Standard output.
14    pub stdout: String,
15    /// Standard error.
16    pub stderr: String,
17    /// Process exit code.
18    pub exit_code: i32,
19    /// Whether the command exited successfully.
20    pub success: bool,
21}
22
23impl CommandOutput {
24    /// Split stdout into lines.
25    #[must_use]
26    pub fn stdout_lines(&self) -> Vec<&str> {
27        self.stdout.lines().collect()
28    }
29}
30
31impl fmt::Display for CommandOutput {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        write!(f, "{}", self.stdout.trim())
34    }
35}
36
37/// Execute a Terraform command.
38///
39/// Builds the full invocation:
40/// `<binary> [-chdir=<dir>] <subcommand> [global_args...] [command_args...]`
41///
42/// Note: `-chdir` is the only true global option (before the subcommand).
43/// Options like `-no-color` and `-input=false` are per-subcommand flags
44/// and are placed after the subcommand name.
45///
46/// Uses the client's default timeout if set.
47pub async fn run_terraform(tf: &Terraform, command_args: Vec<String>) -> Result<CommandOutput> {
48    run_terraform_inner(tf, command_args, &[0], tf.timeout).await
49}
50
51/// Execute a Terraform command, accepting additional exit codes as success.
52///
53/// Terraform `plan` uses exit code 2 to indicate "changes present" which is
54/// not an error. Pass `&[0, 2]` to accept both.
55pub async fn run_terraform_allow_exit_codes(
56    tf: &Terraform,
57    command_args: Vec<String>,
58    allowed_codes: &[i32],
59) -> Result<CommandOutput> {
60    run_terraform_inner(tf, command_args, allowed_codes, tf.timeout).await
61}
62
63/// Execute a Terraform command with a specific timeout override.
64///
65/// The provided timeout takes precedence over the client's default.
66pub async fn run_terraform_with_timeout(
67    tf: &Terraform,
68    command_args: Vec<String>,
69    timeout: std::time::Duration,
70) -> Result<CommandOutput> {
71    run_terraform_inner(tf, command_args, &[0], Some(timeout)).await
72}
73
74async fn run_terraform_inner(
75    tf: &Terraform,
76    command_args: Vec<String>,
77    allowed_codes: &[i32],
78    timeout: Option<std::time::Duration>,
79) -> Result<CommandOutput> {
80    let mut cmd = TokioCommand::new(&tf.binary);
81
82    // -chdir is the only true global option (must come before the subcommand)
83    if let Some(ref working_dir) = tf.working_dir {
84        cmd.arg(format!("-chdir={}", working_dir.display()));
85    }
86
87    // Command args (subcommand name + flags)
88    for arg in &command_args {
89        cmd.arg(arg);
90    }
91
92    // Global args (-no-color) at the end, after all command args.
93    // This handles compound commands like "workspace show" and "state list".
94    for arg in &tf.global_args {
95        cmd.arg(arg);
96    }
97
98    // Environment variables
99    for (key, value) in &tf.env {
100        cmd.env(key, value);
101    }
102
103    cmd.stdout(Stdio::piped());
104    cmd.stderr(Stdio::piped());
105
106    trace!(binary = ?tf.binary, args = ?command_args, timeout_secs = ?timeout.map(|t| t.as_secs()), "executing terraform command");
107
108    let io_result = if let Some(duration) = timeout {
109        match tokio::time::timeout(duration, cmd.output()).await {
110            Ok(result) => result,
111            Err(_) => {
112                warn!(
113                    timeout_seconds = duration.as_secs(),
114                    "terraform command timed out"
115                );
116                return Err(Error::Timeout {
117                    timeout_seconds: duration.as_secs(),
118                });
119            }
120        }
121    } else {
122        cmd.output().await
123    };
124
125    let output = io_result.map_err(|e| {
126        if e.kind() == std::io::ErrorKind::NotFound {
127            Error::NotFound
128        } else {
129            Error::Io {
130                message: format!("failed to execute terraform: {e}"),
131                source: e,
132            }
133        }
134    })?;
135
136    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
137    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
138    let exit_code = output.status.code().unwrap_or(-1);
139    let success = allowed_codes.contains(&exit_code);
140
141    debug!(exit_code, success, "terraform command completed");
142    trace!(%stdout, "stdout");
143    if !stderr.is_empty() {
144        trace!(%stderr, "stderr");
145    }
146
147    if !success {
148        return Err(Error::CommandFailed {
149            command: command_args.first().cloned().unwrap_or_default(),
150            exit_code,
151            stdout,
152            stderr,
153        });
154    }
155
156    Ok(CommandOutput {
157        stdout,
158        stderr,
159        exit_code,
160        success,
161    })
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn display_command_output_trims_whitespace() {
170        let output = CommandOutput {
171            stdout: "  hello world  \n".to_string(),
172            stderr: String::new(),
173            exit_code: 0,
174            success: true,
175        };
176        assert_eq!(output.to_string(), "hello world");
177    }
178
179    #[test]
180    fn display_command_output_empty() {
181        let output = CommandOutput {
182            stdout: String::new(),
183            stderr: String::new(),
184            exit_code: 0,
185            success: true,
186        };
187        assert_eq!(output.to_string(), "");
188    }
189
190    #[test]
191    fn display_command_output_multiline() {
192        let output = CommandOutput {
193            stdout: "line1\nline2\nline3\n".to_string(),
194            stderr: String::new(),
195            exit_code: 0,
196            success: true,
197        };
198        assert_eq!(output.to_string(), "line1\nline2\nline3");
199    }
200}