1use anyhow::Context as _;
2use std::{fs, io::Write as _, path::PathBuf};
3
4use crate::{context::Context, prelude::Environment, utils::terraform};
5
6const DEFAULT_PATH: &str = "./.tfstates";
7
8#[tracel_xtask_macros::declare_command_args(None, InfraSubCommand)]
9pub struct InfraCmdArgs {
10 #[arg(long, default_value = DEFAULT_PATH)]
12 pub path: PathBuf,
13
14 #[arg(long, default_value = "tfplan")]
16 pub out: PathBuf,
17}
18
19#[derive(clap::Args, Clone, Default, PartialEq)]
20struct InfraInstallSubCmdArgs {
21 #[arg(long)]
23 version: Option<String>,
24}
25
26#[derive(clap::Args, Clone, Default, PartialEq)]
27pub struct InfraOutputSubCmdArgs {
28 #[arg(short, long)]
30 json: bool,
31}
32
33#[derive(clap::Args, Clone, Default, PartialEq)]
34pub struct InfraProvidersSubCmdArgs {
35 command: TerraformProvidersCommand,
37}
38
39#[derive(clap::ValueEnum, Copy, Clone, Debug, Default, PartialEq)]
40pub enum TerraformProvidersCommand {
41 #[default]
42 Schema,
44}
45
46#[derive(clap::Args, Clone, Default, PartialEq)]
47struct InfraUninstallSubCmdArgs {
48 #[arg(long)]
50 all: bool,
51 #[arg(short, long)]
53 list: bool,
54 #[arg(long)]
56 version: Option<String>,
57}
58
59pub fn handle_command(args: InfraCmdArgs, _env: Environment, _ctx: Context) -> anyhow::Result<()> {
60 match args.get_command() {
61 InfraSubCommand::Apply => {
62 apply(&args)?;
63 Ok(())
64 }
65 InfraSubCommand::Destroy => destroy(&args),
66 InfraSubCommand::Init => init(&args),
67 InfraSubCommand::Install(cmd_args) => install(&cmd_args),
68 InfraSubCommand::List => list(),
69 InfraSubCommand::Output(cmd_args) => output(&args, &cmd_args),
70 InfraSubCommand::Providers(cmd_args) => providers(&args, &cmd_args),
71 InfraSubCommand::Plan => plan(&args),
72 InfraSubCommand::Uninstall(cmd_args) => uninstall(&cmd_args),
73 InfraSubCommand::Update => update(),
74 }
75}
76
77pub fn apply(args: &InfraCmdArgs) -> anyhow::Result<bool> {
81 let out = args.out.to_string_lossy().to_string();
82
83 let tf_args = ["plan", "-out", out.as_str()];
85 terraform::call_terraform(&args.path, &tf_args)?;
86
87 eprintln!();
89 eprint!("Apply this Terraform plan? [y/N]: ");
90 std::io::stderr().flush()?;
91
92 let mut answer = String::new();
93 std::io::stdin()
94 .read_line(&mut answer)
95 .context("Failed to read confirmation from stdin")?;
96
97 let answer = answer.trim().to_ascii_lowercase();
98 let proceed = matches!(answer.as_str(), "y" | "yes");
99
100 if !proceed {
101 eprintln!("Skipping apply.");
102 return Ok(false);
103 }
104
105 let apply_args = ["apply", "-auto-approve", out.as_str()];
107 terraform::call_terraform(&args.path, &apply_args)?;
108
109 Ok(true)
110}
111
112pub fn destroy(args: &InfraCmdArgs) -> anyhow::Result<()> {
113 terraform::call_terraform(&args.path, &["destroy"])
114}
115
116pub fn init(args: &InfraCmdArgs) -> anyhow::Result<()> {
117 terraform::call_terraform(&args.path, &["init"])
118}
119
120fn install(args: &InfraInstallSubCmdArgs) -> anyhow::Result<()> {
121 let agent = ureq::agent();
122 let repo_root = std::env::current_dir().context("Failed to get current directory")?;
123
124 enum LockAction<'a> {
126 Keep,
128 WriteNew(&'a str),
130 WriteUpdate(&'a str),
132 }
133
134 let (version, lock_action) = if let Some(explicit) = args.version.as_deref() {
135 (explicit.to_string(), LockAction::WriteUpdate(explicit))
137 } else {
138 if let Some(locked) = terraform::read_locked_version(&repo_root)? {
140 (locked, LockAction::Keep)
141 } else {
142 let latest = terraform::fetch_latest_version(&agent)?;
143 (
144 latest.clone(),
145 LockAction::WriteNew(Box::leak(latest.into_boxed_str())),
146 )
147 }
148 };
149
150 let dest = terraform::terraform_bin_path(&version)?;
151 if dest.exists() {
152 eprintln!(
153 "terraform {} already installed at {}",
154 version,
155 dest.display()
156 );
157 } else {
158 eprintln!("Installing terraform {}...", version);
159 let bytes = terraform::download_terraform_zip(&agent, &version)?;
160 terraform::extract_and_install(&bytes, &dest)?;
161 eprintln!("Installed terraform {} to {}", version, dest.display());
162 }
163
164 match lock_action {
166 LockAction::Keep => { }
167 LockAction::WriteNew(v) | LockAction::WriteUpdate(v) => {
168 terraform::write_lockfile(&repo_root, v)?;
169 eprintln!("Wrote {} with version {v}", terraform::LOCKFILE);
170 }
171 }
172
173 Ok(())
174}
175
176fn list() -> anyhow::Result<()> {
177 let repo_root = std::env::current_dir().context("Failed to get current directory")?;
178 let locked = terraform::read_locked_version(&repo_root)?;
179 terraform::print_installed_versions_with_lock(&locked)
180}
181
182pub fn output(args: &InfraCmdArgs, output_args: &InfraOutputSubCmdArgs) -> anyhow::Result<()> {
183 let mut tf_args = vec!["output"];
184 if output_args.json {
185 tf_args.push("-json");
186 }
187 terraform::call_terraform(&args.path, &tf_args)
188}
189
190pub fn plan(args: &InfraCmdArgs) -> anyhow::Result<()> {
191 let out = args.out.to_string_lossy().to_string();
192 let tf_args = ["plan", "-out", out.as_str()];
193 terraform::call_terraform(&args.path, &tf_args)
194}
195
196fn providers(args: &InfraCmdArgs, provider_args: &InfraProvidersSubCmdArgs) -> anyhow::Result<()> {
197 let mut tf_args = vec!["providers"];
198 match provider_args.command {
199 TerraformProvidersCommand::Schema => {
200 tf_args.extend(vec!["schema", "-json", "-no-color"]);
201 terraform::call_terraform(&args.path, &tf_args)
202 }
203 }
204}
205
206fn uninstall(args: &InfraUninstallSubCmdArgs) -> anyhow::Result<()> {
207 let repo_root = std::env::current_dir().context("Failed to get current directory")?;
208
209 if args.list {
211 let locked = terraform::read_locked_version(&repo_root)?;
212 return terraform::print_installed_versions_with_lock(&locked);
213 }
214
215 if args.all {
217 let removed = terraform::uninstall_all_versions()?;
218 if removed == 0 {
219 eprintln!(
220 "No terraform binaries found in {}",
221 terraform::terraform_install_dir()?.display()
222 );
223 } else {
224 eprintln!("Removed {} terraform binaries.", removed);
225 }
226 let lf = terraform::lockfile_path(&repo_root);
228 if lf.exists() {
229 fs::remove_file(&lf).ok();
230 eprintln!("Removed {}", lf.display());
231 }
232 return Ok(());
233 }
234
235 if let Some(ver) = &args.version {
237 let path = terraform::terraform_bin_path(ver)?;
238 if path.exists() {
239 fs::remove_file(&path)
240 .with_context(|| format!("Failed to remove {}", path.display()))?;
241 eprintln!("Removed {}", path.display());
242 if terraform::read_locked_version(&repo_root)?.as_deref() == Some(ver.as_str()) {
244 let lf = terraform::lockfile_path(&repo_root);
245 if lf.exists() {
246 fs::remove_file(&lf).ok();
247 eprintln!("Removed {}", lf.display());
248 }
249 }
250 } else {
251 eprintln!("Terraform {} not found at {}", ver, path.display());
252 }
253 return Ok(());
254 }
255
256 if let Some(locked) = terraform::read_locked_version(&repo_root)? {
259 let path = terraform::terraform_bin_path(&locked)?;
260 if path.exists() {
261 fs::remove_file(&path)
262 .with_context(|| format!("Failed to remove {}", path.display()))?;
263 eprintln!("Removed {}", path.display());
264 } else {
265 eprintln!(
266 "Locked terraform {} not found at {}",
267 locked,
268 path.display()
269 );
270 }
271 let lf = terraform::lockfile_path(&repo_root);
273 if lf.exists() {
274 fs::remove_file(&lf).ok();
275 eprintln!("Removed {}", lf.display());
276 }
277 return Ok(());
278 }
279 let installed = terraform::list_installed_versions()?;
281 match installed.len() {
282 0 => {
283 eprintln!(
284 "No terraform binaries found in {}",
285 terraform::terraform_install_dir()?.display()
286 );
287 Ok(())
288 }
289 1 => {
290 let (ver, path) = &installed[0];
291 fs::remove_file(path)
292 .with_context(|| format!("Failed to remove {}", path.display()))?;
293 eprintln!("Removed {} ({})", path.display(), ver);
294 Ok(())
295 }
296 _ => {
298 eprintln!(
299 "Multiple terraform versions are installed; specify one with --version or use --all:"
300 );
301 for (ver, path) in installed {
302 eprintln!(" {ver}\t{}", path.display());
303 }
304 Ok(())
305 }
306 }
307}
308
309fn update() -> anyhow::Result<()> {
310 let agent = ureq::agent();
311 let repo_root = std::env::current_dir().context("Failed to get current directory")?;
312 let latest = terraform::fetch_latest_version(&agent)?;
313 let locked = terraform::read_locked_version(&repo_root)?;
314
315 if locked.as_deref() == Some(latest.as_str()) {
316 eprintln!("Terraform is already at latest: {}", latest);
317 } else {
318 let dest = terraform::terraform_bin_path(&latest)?;
319 if dest.exists() {
320 eprintln!(
321 "terraform {} already installed at {}",
322 latest,
323 dest.display()
324 );
325 } else {
326 eprintln!("Installing terraform {}...", &latest);
327 let bytes = terraform::download_terraform_zip(&agent, &latest)?;
328 terraform::extract_and_install(&bytes, &dest)?;
329 eprintln!("Installed terraform {} to {}", latest, dest.display());
330 }
331
332 terraform::write_lockfile(&repo_root, &latest)?;
333 match locked {
334 Some(prev) => eprintln!("Updated {} from {prev} -> {latest}", terraform::LOCKFILE),
335 None => eprintln!("Wrote {} with version {latest}", terraform::LOCKFILE),
336 }
337 }
338
339 Ok(())
340}