Skip to main content

tracel_xtask/commands/
infra.rs

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    /// Path where to generate or read the infra configuration.
11    #[arg(long, default_value = DEFAULT_PATH)]
12    pub path: PathBuf,
13
14    /// Path to the Terraform plan file used by `plan` and `apply`.
15    #[arg(long, default_value = "tfplan")]
16    pub out: PathBuf,
17}
18
19#[derive(clap::Args, Clone, Default, PartialEq)]
20struct InfraInstallSubCmdArgs {
21    /// Install a specific Terraform version (e.g. 1.9.6) and update the lockfile to that version
22    #[arg(long)]
23    version: Option<String>,
24}
25
26#[derive(clap::Args, Clone, Default, PartialEq)]
27pub struct InfraOutputSubCmdArgs {
28    /// If specified, use JSON format for outputs format
29    #[arg(short, long)]
30    json: bool,
31}
32
33#[derive(clap::Args, Clone, Default, PartialEq)]
34pub struct InfraProvidersSubCmdArgs {
35    /// The command to pass to provider.
36    command: TerraformProvidersCommand,
37}
38
39#[derive(clap::ValueEnum, Copy, Clone, Debug, Default, PartialEq)]
40pub enum TerraformProvidersCommand {
41    #[default]
42    /// Output the provider schema in JSON format.
43    Schema,
44}
45
46#[derive(clap::Args, Clone, Default, PartialEq)]
47struct InfraUninstallSubCmdArgs {
48    /// Uninstall all installed Terraform binaries under ~/.cache/xtask/terraform
49    #[arg(long)]
50    all: bool,
51    /// List installed terraform versions and exit
52    #[arg(short, long)]
53    list: bool,
54    /// Uninstall a specific Terraform version (e.g. 1.9.6)
55    #[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
77// Commands ------------------------------------------------------------------
78
79/// Returns true if the user confirmed to apply the plan
80pub fn apply(args: &InfraCmdArgs) -> anyhow::Result<bool> {
81    let out = args.out.to_string_lossy().to_string();
82
83    // 1) Run plan
84    let tf_args = ["plan", "-out", out.as_str()];
85    terraform::call_terraform(&args.path, &tf_args)?;
86
87    // 2) Ask the user if they want to run apply.
88    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    // 3) User approved: apply the *saved* plan file (no re-planning).
106    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    // Decide version + lock policy
125    enum LockAction<'a> {
126        /// Do not touch existing lock (already present).
127        Keep,
128        /// Write a new lock because there wasn't one.
129        WriteNew(&'a str),
130        /// Overwrite/update lock to this version (explicit user request).
131        WriteUpdate(&'a str),
132    }
133
134    let (version, lock_action) = if let Some(explicit) = args.version.as_deref() {
135        // Explicit version requested: install and update lockfile unconditionally
136        (explicit.to_string(), LockAction::WriteUpdate(explicit))
137    } else {
138        // No explicit version: follow lock if present, otherwise install latest
139        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    // Apply lock policy
165    match lock_action {
166        LockAction::Keep => { /* do nothing */ }
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    // --list, print installed versions
210    if args.list {
211        let locked = terraform::read_locked_version(&repo_root)?;
212        return terraform::print_installed_versions_with_lock(&locked);
213    }
214
215    // --all, remove everything
216    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        // Remove lockfile if present
227        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    // --version, uninstall specific version
236    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 lockfile matches this version, remove it too.
243            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    // default if no option is provided:
257    // a) if lock exists, uninstall that version and delete the lockfile.
258    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        // Remove lockfile
272        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    // b) if no lock and exactly one version installed, uninstall that one.
280    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        // c) if multiple versions installed and no lock, list them and exit without action.
297        _ => {
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}