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    // Subcommand name comes first (e.g., "init", "plan", "apply")
67    if let Some(subcommand) = command_args.first() {
68        cmd.arg(subcommand);
69    }
70
71    // Global args (-no-color, -input=false) are per-subcommand flags
72    for arg in &tf.global_args {
73        cmd.arg(arg);
74    }
75
76    // Remaining command-specific args
77    for arg in command_args.iter().skip(1) {
78        cmd.arg(arg);
79    }
80
81    // Environment variables
82    for (key, value) in &tf.env {
83        cmd.env(key, value);
84    }
85
86    cmd.stdout(Stdio::piped());
87    cmd.stderr(Stdio::piped());
88
89    trace!(binary = ?tf.binary, args = ?command_args, "executing terraform command");
90
91    let output = cmd.output().await.map_err(|e| {
92        if e.kind() == std::io::ErrorKind::NotFound {
93            Error::NotFound
94        } else {
95            Error::Io {
96                message: format!("failed to execute terraform: {e}"),
97                source: e,
98            }
99        }
100    })?;
101
102    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
103    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
104    let exit_code = output.status.code().unwrap_or(-1);
105    let success = allowed_codes.contains(&exit_code);
106
107    debug!(exit_code, success, "terraform command completed");
108    trace!(%stdout, "stdout");
109    if !stderr.is_empty() {
110        trace!(%stderr, "stderr");
111    }
112
113    if !success {
114        return Err(Error::CommandFailed {
115            command: command_args.first().cloned().unwrap_or_default(),
116            exit_code,
117            stdout,
118            stderr,
119        });
120    }
121
122    Ok(CommandOutput {
123        stdout,
124        stderr,
125        exit_code,
126        success,
127    })
128}