use crate::writer::HclWriter;
use cloud_terrastodon_azure::Scope;
use cloud_terrastodon_azure::get_active_subscription_id;
use cloud_terrastodon_azure_devops::get_default_organization_url;
use cloud_terrastodon_command::CommandBuilder;
use cloud_terrastodon_command::CommandKind;
use cloud_terrastodon_command::OutputBehaviour;
use cloud_terrastodon_hcl_types::AsHclString;
use cloud_terrastodon_hcl_types::HclProviderBlock;
use cloud_terrastodon_hcl_types::ProviderAvailability;
use cloud_terrastodon_hcl_types::ProviderHostname;
use cloud_terrastodon_hcl_types::ProviderKind;
use cloud_terrastodon_hcl_types::ProviderNamespace;
use cloud_terrastodon_hcl_types::TerraformBlock;
use cloud_terrastodon_hcl_types::TerraformRequiredProvidersBlock;
use cloud_terrastodon_pathing::AppDir;
use cloud_terrastodon_pathing::Existy;
use directories_next::BaseDirs;
use eyre::Context;
use eyre::OptionExt;
use eyre::bail;
use hcl::edit::structure::Block;
use serde::Deserialize;
use std::collections::HashMap;
use std::collections::HashSet;
use std::env::{self};
use std::path::Path;
use std::path::PathBuf;
use std::str::FromStr;
use tempfile::TempDir;
use tokio::io::AsyncReadExt;
use tracing::debug;
pub struct ProviderManager {
pub local_mirror_dir: PathBuf,
}
impl ProviderManager {
pub fn get_default_tf_plugin_cache_dir() -> eyre::Result<PathBuf> {
if let Ok(path) = env::var("TF_PLUGIN_CACHE_DIR") {
return Ok(PathBuf::from(path));
};
#[allow(deprecated)]
let home_dir = env::home_dir();
if let Some(home_dir) = home_dir {
let mut path = home_dir;
path.push(".terraform.d/plugin-cache");
return Ok(path);
}
bail!(
"Failed to acquire TF_PLUGIN_CACHE_DIR from environment variable and failed to find home directory"
);
}
pub fn get_local_mirror_dir() -> eyre::Result<PathBuf> {
#[cfg(windows)]
{
Ok(BaseDirs::new()
.ok_or_eyre("Failed to get base dirs")?
.config_dir()
.join("terraform.d/plugins")
.to_path_buf())
}
#[cfg(not(windows))]
{
return Ok(BaseDirs::new()
.ok_or_eyre("Failed to get base dirs")?
.home_dir()
.join(".terraform.d/plugins")
.to_path_buf());
}
}
pub fn try_new() -> eyre::Result<Self> {
Ok(ProviderManager {
local_mirror_dir: Self::get_local_mirror_dir()?,
})
}
pub async fn list_cached_providers(&self) -> eyre::Result<HashSet<ProviderAvailability>> {
let mut rtn = HashSet::default();
if !matches!(
tokio::fs::try_exists(&self.local_mirror_dir).await,
Ok(true)
) {
return Ok(HashSet::new());
}
let mut cache_children = tokio::fs::read_dir(&self.local_mirror_dir).await?;
while let Some(registry) = cache_children.next_entry().await? {
let mut registry_children = tokio::fs::read_dir(®istry.path()).await?;
while let Some(author) = registry_children.next_entry().await? {
let mut author_children = tokio::fs::read_dir(&author.path()).await?;
while let Some(provider) = author_children.next_entry().await? {
let index_json = tokio::fs::OpenOptions::new()
.read(true)
.create(false)
.open(provider.path().join("index.json"))
.await;
if let Ok(mut index_json_file) = index_json {
let mut index_json_str = String::new();
index_json_file.read_to_string(&mut index_json_str).await?;
#[derive(Debug, Deserialize)]
struct IndexJson {
pub versions: HashMap<String, HashMap<(), ()>>,
}
let index_json: IndexJson = serde_json::from_str(&index_json_str)?;
for version in index_json.versions.into_keys() {
let registry_name = registry.file_name();
let registry_name = registry_name.to_string_lossy().into_owned();
let author_name = author.file_name();
let author_name = author_name.to_string_lossy().into_owned();
let provider_name = provider.file_name();
let provider_name = provider_name.to_string_lossy().into_owned();
debug!("Found json {registry_name}/{author_name}/{version}");
rtn.insert(ProviderAvailability {
hostname: ProviderHostname(registry_name),
namespace: ProviderNamespace(author_name),
kind: ProviderKind::from_str(&provider_name)?,
version: version.parse()?,
});
}
}
let mut provider_children = tokio::fs::read_dir(&provider.path()).await?;
while let Some(version) = provider_children.next_entry().await? {
if version.file_type().await?.is_dir() {
let mut version_children = tokio::fs::read_dir(&version.path()).await?;
while let Some(platform) = version_children.next_entry().await? {
if platform.file_type().await?.is_dir() {
let mut platform_children =
tokio::fs::read_dir(&platform.path()).await?;
while let Some(file) = platform_children.next_entry().await? {
if file.file_type().await?.is_file()
&& file
.path()
.extension()
.filter(|x| *x == "exe")
.is_some()
{
let registry_name = registry.file_name();
let registry_name =
registry_name.to_string_lossy().into_owned();
let author_name = author.file_name();
let author_name =
author_name.to_string_lossy().into_owned();
let provider_name = provider.file_name();
let provider_name =
provider_name.to_string_lossy().into_owned();
let version_name = version.file_name();
let version_name =
version_name.to_string_lossy().into_owned();
let platform_name = platform.file_name();
let platform_name =
platform_name.to_string_lossy().into_owned();
let exe_name = file.file_name();
let exe_name = exe_name.to_string_lossy().into_owned();
debug!(
"Found exe {registry_name}/{author_name}/{provider_name}/{version_name}/{platform_name}/{exe_name}",
);
rtn.insert(ProviderAvailability {
hostname: ProviderHostname(registry_name),
namespace: ProviderNamespace(author_name),
kind: ProviderKind::from_str(&provider_name)?,
version: version_name.parse()?,
});
}
}
}
}
}
}
}
}
}
Ok(rtn)
}
pub async fn populate_provider_cache(
&self,
desired_providers: &TerraformRequiredProvidersBlock,
) -> eyre::Result<Option<TempDir>> {
let provider_manager = self;
let available_providers = provider_manager.list_cached_providers().await?;
let missing_providers = desired_providers.identify_missing(&available_providers);
if missing_providers.0.is_empty() {
debug!("All required providers are already available");
return Ok(None);
}
let terraform_block = TerraformBlock {
backend: None,
required_providers: Some(missing_providers),
other: vec![],
};
let terraform_block: Block = terraform_block.into();
let boilerplate_tf = terraform_block.as_hcl_string();
debug!("Mirroring providers using this terraform:\n{boilerplate_tf}");
let app_temp_dir = AppDir::Temp.as_path_buf();
app_temp_dir.ensure_dir_exists().await?;
let temp_dir = tempfile::Builder::new().tempdir_in(&app_temp_dir)?;
let boilerplate_tf_path = temp_dir.path().join("boilerplate.tf");
HclWriter::new(boilerplate_tf_path)
.overwrite(boilerplate_tf)
.await?
.format_file()
.await?;
let mut cmd = CommandBuilder::new(CommandKind::Terraform);
cmd.args([
"providers",
"mirror",
&self
.local_mirror_dir
.display()
.to_string()
.replace("\\", "/"),
]);
cmd.use_output_behaviour(OutputBehaviour::Display);
cmd.use_run_dir(temp_dir.path());
cmd.run_raw().await?;
Ok(Some(temp_dir))
}
pub async fn write_default_provider_configs(
&self,
work_dir: impl AsRef<Path>,
) -> eyre::Result<()> {
let work_dir = work_dir.as_ref();
let org_service_url = format!(
"https://dev.azure.com/{name}/",
name = get_default_organization_url().await?.organization_name
);
let active_sub_id = get_active_subscription_id().await?;
debug!(path = %work_dir.display(), "Writing default provider configs");
let boilerplate_path = work_dir.join("boilerplate.tf");
HclWriter::new(boilerplate_path)
.merge(vec![TerraformBlock {
required_providers: Some(TerraformRequiredProvidersBlock::common()),
..Default::default()
}])
.await
.wrap_err("Writing terraform block")?
.merge(vec![
HclProviderBlock::AzureDevOps {
alias: None,
org_service_url,
},
HclProviderBlock::AzureRM {
alias: None,
subscription_id: Some(active_sub_id.short_form()),
},
])
.await
.wrap_err("Writing default provider blocks")?
.format_file()
.await?;
Ok(())
}
}
#[cfg(test)]
mod test {
use crate::ProviderManager;
use cloud_terrastodon_hcl_types::TerraformRequiredProvidersBlock;
use eyre::bail;
use hcl::edit::structure::Body;
#[tokio::test]
pub async fn it_works() -> eyre::Result<()> {
let provider_manager = ProviderManager::try_new()?;
let found = provider_manager.list_cached_providers().await?;
dbg!(found);
Ok(())
}
#[tokio::test]
#[ignore]
pub async fn install_missing_providers() -> eyre::Result<()> {
let required_providers = TerraformRequiredProvidersBlock::try_from(
r#"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = ">=4.18.0"
}
}
"#
.parse::<Body>()?
.into_blocks()
.next()
.unwrap(),
)?;
let provider_manager = ProviderManager::try_new()?;
let temp_dir = provider_manager
.populate_provider_cache(&required_providers)
.await?;
let temp_dir = match temp_dir {
None => {
bail!("All required providers are already installed");
}
Some(x) => x,
};
let persist = temp_dir.keep();
println!("Persisting dir for testing at {}", persist.display());
Ok(())
}
#[tokio::test]
#[ignore]
pub async fn install_default_providers() -> eyre::Result<()> {
let required_providers = TerraformRequiredProvidersBlock::common();
let provider_manager = ProviderManager::try_new()?;
let temp_dir = provider_manager
.populate_provider_cache(&required_providers)
.await?;
let temp_dir = match temp_dir {
None => {
bail!("All required providers are already installed");
}
Some(x) => x,
};
let persist = temp_dir.keep();
println!("Persisting dir for testing at {}", persist.display());
Ok(())
}
}