Skip to main content

terraform_wrapper/
exec.rs

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