use std::process::Stdio;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command as TokioCommand;
use tracing::{debug, trace, warn};
use crate::Terraform;
use crate::command::TerraformCommand;
use crate::error::{Error, Result};
use crate::exec::CommandOutput;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct JsonLogLine {
#[serde(rename = "@level")]
pub level: String,
#[serde(rename = "@message")]
pub message: String,
#[serde(rename = "@module", default)]
pub module: String,
#[serde(rename = "@timestamp", default)]
pub timestamp: String,
#[serde(rename = "type", default)]
pub log_type: String,
#[serde(default)]
pub change: serde_json::Value,
#[serde(default)]
pub hook: serde_json::Value,
#[serde(default)]
pub changes: serde_json::Value,
#[serde(default)]
pub outputs: serde_json::Value,
}
pub async fn stream_terraform<C, F>(
tf: &Terraform,
command: C,
allowed_exit_codes: &[i32],
mut handler: F,
) -> Result<CommandOutput>
where
C: TerraformCommand,
F: FnMut(JsonLogLine),
{
let args = command.prepare_args(tf);
let mut cmd = TokioCommand::new(&tf.binary);
if let Some(ref working_dir) = tf.working_dir {
cmd.arg(format!("-chdir={}", working_dir.display()));
}
for arg in &args {
cmd.arg(arg);
}
for arg in &tf.global_args {
cmd.arg(arg);
}
for (key, value) in &tf.env {
cmd.env(key, value);
}
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
trace!(binary = ?tf.binary, args = ?args, timeout_secs = ?tf.timeout.map(|t| t.as_secs()), "streaming terraform command");
let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::NotFound
} else {
Error::Io {
message: format!("failed to spawn terraform: {e}"),
source: e,
}
}
})?;
let stdout = child.stdout.take().ok_or_else(|| Error::Io {
message: "failed to capture stdout".to_string(),
source: std::io::Error::other("no stdout"),
})?;
let stream_and_wait = async {
let mut reader = BufReader::new(stdout).lines();
while let Some(line) = reader.next_line().await.map_err(|e| Error::Io {
message: format!("failed to read stdout line: {e}"),
source: e,
})? {
trace!(%line, "stream line");
match serde_json::from_str::<JsonLogLine>(&line) {
Ok(log_line) => handler(log_line),
Err(e) => {
warn!(%line, error = %e, "failed to parse streaming json line, skipping");
}
}
}
child.wait_with_output().await.map_err(|e| Error::Io {
message: format!("failed to wait for terraform: {e}"),
source: e,
})
};
let output = if let Some(duration) = tf.timeout {
match tokio::time::timeout(duration, stream_and_wait).await {
Ok(result) => result?,
Err(_) => {
warn!(
timeout_seconds = duration.as_secs(),
"streaming terraform command timed out"
);
return Err(Error::Timeout {
timeout_seconds: duration.as_secs(),
});
}
}
} else {
stream_and_wait.await?
};
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let exit_code = output.status.code().unwrap_or(-1);
let success = allowed_exit_codes.contains(&exit_code);
debug!(exit_code, success, "streaming terraform command completed");
if !success {
return Err(Error::CommandFailed {
command: args.first().cloned().unwrap_or_default(),
exit_code,
stdout: String::new(),
stderr,
});
}
Ok(CommandOutput {
stdout: String::new(), stderr,
exit_code,
success,
})
}