Skip to main content

terraform_wrapper/
exec.rs

1use std::process::Stdio;
2
3use tokio::process::Command as TokioCommand;
4use tracing::{debug, trace};
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.
38pub async fn run_terraform(tf: &Terraform, command_args: Vec<String>) -> Result<CommandOutput> {
39    run_terraform_inner(tf, command_args, &[0]).await
40}
41
42/// Execute a Terraform command, accepting additional exit codes as success.
43///
44/// Terraform `plan` uses exit code 2 to indicate "changes present" which is
45/// not an error. Pass `&[0, 2]` to accept both.
46pub async fn run_terraform_allow_exit_codes(
47    tf: &Terraform,
48    command_args: Vec<String>,
49    allowed_codes: &[i32],
50) -> Result<CommandOutput> {
51    run_terraform_inner(tf, command_args, allowed_codes).await
52}
53
54async fn run_terraform_inner(
55    tf: &Terraform,
56    command_args: Vec<String>,
57    allowed_codes: &[i32],
58) -> Result<CommandOutput> {
59    let mut cmd = TokioCommand::new(&tf.binary);
60
61    // -chdir is the only true global option (must come before the subcommand)
62    if let Some(ref working_dir) = tf.working_dir {
63        cmd.arg(format!("-chdir={}", working_dir.display()));
64    }
65
66    // Command args (subcommand name + flags)
67    for arg in &command_args {
68        cmd.arg(arg);
69    }
70
71    // Global args (-no-color) at the end, after all command args.
72    // This handles compound commands like "workspace show" and "state list".
73    for arg in &tf.global_args {
74        cmd.arg(arg);
75    }
76
77    // Environment variables
78    for (key, value) in &tf.env {
79        cmd.env(key, value);
80    }
81
82    cmd.stdout(Stdio::piped());
83    cmd.stderr(Stdio::piped());
84
85    trace!(binary = ?tf.binary, args = ?command_args, "executing terraform command");
86
87    let output = cmd.output().await.map_err(|e| {
88        if e.kind() == std::io::ErrorKind::NotFound {
89            Error::NotFound
90        } else {
91            Error::Io {
92                message: format!("failed to execute terraform: {e}"),
93                source: e,
94            }
95        }
96    })?;
97
98    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
99    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
100    let exit_code = output.status.code().unwrap_or(-1);
101    let success = allowed_codes.contains(&exit_code);
102
103    debug!(exit_code, success, "terraform command completed");
104    trace!(%stdout, "stdout");
105    if !stderr.is_empty() {
106        trace!(%stderr, "stderr");
107    }
108
109    if !success {
110        return Err(Error::CommandFailed {
111            command: command_args.first().cloned().unwrap_or_default(),
112            exit_code,
113            stdout,
114            stderr,
115        });
116    }
117
118    Ok(CommandOutput {
119        stdout,
120        stderr,
121        exit_code,
122        success,
123    })
124}