atar/
lib.rs

1//! Library API for Terraform ephemeral deployments.
2//!
3//! Exposes two functions:
4//! - `deploy`: applies a Terraform configuration and returns its outputs
5//! - `undeploy`: destroys an existing Terraform configuration
6
7use anyhow::{Context, Result, bail};
8use serde_json::{self, Value};
9use std::{
10  collections::HashMap,
11  env,
12  fs,
13  path::{Path, PathBuf},
14  process::{Command, Stdio},
15};
16use sha2::{Digest, Sha256};
17
18fn ensure_terraform_installed() -> Result<()> {
19  let status = Command::new("terraform")
20    .arg("-version")
21    .stdout(Stdio::null())
22    .stderr(Stdio::null())
23    .status()
24    .context("Failed to execute `terraform -version`")?;
25  if !status.success() {
26    bail!("Terraform must be installed and in PATH");
27  }
28  Ok(())
29}
30
31/// Recursively copy a directory tree from `src` to `dst`.
32fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
33  fs::create_dir_all(dst).with_context(|| format!("Failed to create directory {:?}", dst))?;
34  for entry in fs::read_dir(src).with_context(|| format!("Failed to read directory {:?}", src))? {
35    let entry = entry.with_context(|| format!("Failed to access entry in {:?}", src))?;
36    let path = entry.path();
37    let dest = dst.join(entry.file_name());
38    if path.is_dir() {
39      copy_dir_recursive(&path, &dest)?;
40    } else {
41      fs::copy(&path, &dest)
42        .with_context(|| format!("Failed to copy file {:?} to {:?}", path, dest))?;
43    }
44  }
45  Ok(())
46}
47
48/// Prepare a deterministic temp workspace based on the source directory path.
49fn prepare_work_dir(src_dir: &Path) -> Result<PathBuf> {
50  let mut hasher = Sha256::new();
51  hasher.update(src_dir.to_string_lossy().as_bytes());
52  let hash = format!("{:x}", hasher.finalize());
53  let work = env::temp_dir().join("atar").join(hash);
54  if !work.exists() {
55    println!("Copying Terraform files to temporary directory {}", work.display());
56    copy_dir_recursive(src_dir, &work)?;
57  }
58  Ok(work)
59}
60
61/// Apply Terraform config at `file` with provided `vars`.
62///
63/// Returns a map from output names to their stringified values.
64pub fn deploy<P: AsRef<Path>>(
65  file: P,
66  vars: &HashMap<String, String>,
67  debug: bool,
68) -> Result<HashMap<String, String>> {
69  ensure_terraform_installed()?;
70  let file = file
71    .as_ref()
72    .canonicalize()
73    .context("Failed to canonicalize Terraform path")?;
74  let src_dir = file
75    .parent()
76    .context("Cannot determine Terraform directory")?;
77  let work_dir = prepare_work_dir(src_dir)?;
78
79  // init
80  println!("Initializing Terraform...");
81
82  let mut init = Command::new("terraform");
83  init.current_dir(&work_dir).arg("init");
84  if !debug {
85    init.stdout(Stdio::null()).stderr(Stdio::null());
86  }
87  let status = init
88    .status()
89    .context("Failed to execute `terraform init`")?;
90  if !status.success() {
91    bail!("`terraform init` failed with exit code {}", status);
92  }
93
94  println!("Applying Terraform...");
95  {
96    let mut cmd = Command::new("terraform");
97    cmd.current_dir(&work_dir).arg("apply").arg("-auto-approve");
98    for (k, v) in vars {
99      cmd.arg("-var").arg(format!("{}={}", k, v));
100    }
101    if !debug {
102      cmd.stdout(Stdio::null()).stderr(Stdio::null());
103    }
104    let status = cmd
105      .status()
106      .context("Failed to execute `terraform apply`")?;
107    if !status.success() {
108      bail!("`terraform apply` failed with exit code {}", status);
109    }
110  }
111
112  // output JSON
113  let output = Command::new("terraform")
114    .current_dir(&work_dir)
115    .arg("output")
116    .arg("-json")
117    .output()
118    .context("Failed to execute `terraform output -json`")?;
119  if !output.status.success() {
120    bail!(
121      "`terraform output -json` failed with exit code {}",
122      output.status
123    );
124  }
125  let raw: HashMap<String, Value> = serde_json::from_slice(&output.stdout)
126    .context("Failed to parse Terraform output JSON")?;
127  let mut results = HashMap::new();
128  for (key, val) in raw {
129    if let Some(inner) = val.get("value") {
130      let s = if inner.is_string() {
131        inner.as_str().unwrap().to_string()
132      } else {
133        inner.to_string()
134      };
135      results.insert(key, s);
136    }
137  }
138  Ok(results)
139}
140
141/// Destroy Terraform config at `file` with provided `vars`.
142pub fn undeploy<P: AsRef<Path>>(
143  file: P,
144  vars: &HashMap<String, String>,
145  debug: bool,
146) -> Result<()> {
147  ensure_terraform_installed()?;
148  let file = file
149    .as_ref()
150    .canonicalize()
151    .context("Failed to canonicalize Terraform path")?;
152  let src_dir = file
153    .parent()
154    .context("Cannot determine Terraform directory")?;
155  let work_dir = prepare_work_dir(src_dir)?;
156
157  println!("Destroying Terraform...");
158
159  let mut cmd = Command::new("terraform");
160  cmd.current_dir(&work_dir).arg("destroy").arg("-auto-approve");
161  for (k, v) in vars {
162    cmd.arg("-var").arg(format!("{}={}", k, v));
163  }
164  if !debug {
165    cmd.stdout(Stdio::null()).stderr(Stdio::null());
166  }
167  let status = cmd
168    .status()
169    .context("Failed to execute `terraform destroy`")?;
170  if !status.success() {
171    bail!("`terraform destroy` failed with exit code {}", status);
172  }
173  println!("All resources have been destroyed.");
174  Ok(())
175}