use super::preflight::{default_region, region_env_var};
use blueprint_remote_providers::security::auth;
use color_eyre::{Result, eyre::Context};
use dialoguer::{Select, theme::ColorfulTheme};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::IsTerminal;
use std::path::PathBuf;
pub use blueprint_core::resources::CloudProvider;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloudConfig {
pub default_provider: Option<CloudProvider>,
pub providers: HashMap<CloudProvider, ProviderSettings>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderSettings {
pub region: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
}
impl CloudConfig {
pub fn load() -> Result<Self> {
let path = Self::config_path()?;
if path.exists() {
let content = std::fs::read_to_string(&path).context("Failed to read cloud config")?;
serde_json::from_str(&content)
.or_else(|_| toml::from_str(&content))
.context("Failed to parse cloud config")
} else {
Ok(Self::default())
}
}
pub fn save(&self) -> Result<()> {
let path = Self::config_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).context("Failed to create config directory")?;
}
let content = serde_json::to_string_pretty(self).context("Failed to serialize config")?;
std::fs::write(&path, content).context("Failed to write cloud config")?;
Ok(())
}
fn config_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.ok_or_else(|| color_eyre::eyre::eyre!("Could not find config directory"))?;
Ok(config_dir.join("tangle").join("cloud.json"))
}
}
impl Default for CloudConfig {
fn default() -> Self {
Self {
default_provider: None,
providers: HashMap::new(),
}
}
}
pub async fn configure(
provider: CloudProvider,
region: Option<String>,
set_default: bool,
) -> Result<()> {
if credential_variables(&provider).is_empty() {
color_eyre::eyre::bail!("`cloud configure` does not support {provider}");
}
println!("🔧 Configuring {}...\n", provider);
let mut config = CloudConfig::load()?;
let region = match region.or_else(|| std::env::var(region_env_var(&provider)).ok()) {
Some(region) => region,
None if std::io::stdin().is_terminal() => prompt_region(&provider)?,
None => default_region(&provider).to_owned(),
};
if credentials_present(&provider).await {
println!("✓ Found {provider} credentials");
} else {
println!(
"⚠️ {provider} credentials not found. Export {} before running cloud commands; \
cargo-tangle reads them from the environment and does not store them.",
credential_variables(&provider).join(", ")
);
}
let settings = ProviderSettings {
region,
project_id: std::env::var("GCP_PROJECT_ID")
.ok()
.filter(|_| provider == CloudProvider::GCP),
};
config.providers.insert(provider.clone(), settings);
if set_default || config.default_provider.is_none() {
config.default_provider = Some(provider.clone());
}
config.save()?;
println!("\n✅ {} region saved", provider);
if config.default_provider.as_ref() == Some(&provider) {
println!(" Set as default provider");
}
Ok(())
}
fn credential_variables(provider: &CloudProvider) -> &'static [&'static str] {
match provider {
CloudProvider::AWS => &["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
CloudProvider::GCP => &["GCP_PROJECT_ID", "GCP_ACCESS_TOKEN"],
CloudProvider::Azure => &["AZURE_SUBSCRIPTION_ID", "AZURE_ACCESS_TOKEN"],
CloudProvider::DigitalOcean => &["DIGITALOCEAN_TOKEN"],
CloudProvider::Vultr => &["VULTR_API_KEY"],
CloudProvider::Hetzner => &["HETZNER_API_TOKEN"],
CloudProvider::Ovh => &[
"OVH_ENDPOINT",
"OVH_APPLICATION_KEY",
"OVH_APPLICATION_SECRET",
"OVH_CONSUMER_KEY",
"OVH_CLOUD_PROJECT_SERVICE",
],
CloudProvider::RunPod => &["RUNPOD_API_KEY"],
CloudProvider::LambdaLabs => &["LAMBDA_LABS_API_KEY"],
CloudProvider::PrimeIntellect => &["PRIME_INTELLECT_API_KEY"],
CloudProvider::VastAi => &["VAST_AI_API_KEY"],
CloudProvider::Crusoe => &["CRUSOE_API_KEY", "CRUSOE_API_SECRET", "CRUSOE_PROJECT_ID"],
_ => &[],
}
}
pub(super) async fn credentials_present(provider: &CloudProvider) -> bool {
let exported = |names: &[&str]| {
!names.is_empty() && names.iter().all(|name| std::env::var_os(name).is_some())
};
match provider {
CloudProvider::AWS => {
exported(credential_variables(provider))
|| dirs::home_dir().is_some_and(|home| home.join(".aws/credentials").exists())
}
CloudProvider::GCP => {
exported(&["GCP_PROJECT_ID"]) && auth::gcp_access_token().await.is_ok()
}
CloudProvider::Azure => {
exported(&["AZURE_SUBSCRIPTION_ID"]) && auth::azure_access_token().await.is_ok()
}
_ => exported(credential_variables(provider)),
}
}
fn prompt_region(provider: &CloudProvider) -> Result<String> {
let regions = match provider {
CloudProvider::AWS => vec![
("us-east-1", "US East (N. Virginia)"),
("us-west-2", "US West (Oregon)"),
("eu-west-1", "Europe (Ireland)"),
("ap-northeast-1", "Asia Pacific (Tokyo)"),
],
CloudProvider::GCP => vec![
("us-central1", "US Central (Iowa)"),
("us-west1", "US West (Oregon)"),
("europe-west1", "Europe (Belgium)"),
("asia-northeast1", "Asia (Tokyo)"),
],
CloudProvider::Azure => vec![
("eastus", "East US"),
("westus2", "West US 2"),
("northeurope", "North Europe"),
("japaneast", "Japan East"),
],
CloudProvider::DigitalOcean => vec![
("nyc3", "New York 3"),
("sfo3", "San Francisco 3"),
("ams3", "Amsterdam 3"),
("sgp1", "Singapore 1"),
],
CloudProvider::Vultr => vec![
("ewr", "New Jersey"),
("lax", "Los Angeles"),
("ams", "Amsterdam"),
("nrt", "Tokyo"),
],
CloudProvider::Hetzner => vec![
("fsn1", "Falkenstein (DE)"),
("nbg1", "Nuremberg (DE)"),
("hel1", "Helsinki (FI)"),
("ash", "Ashburn (US)"),
("hil", "Hillsboro (US)"),
],
CloudProvider::Ovh => vec![
("GRA11", "Gravelines (FR)"),
("SBG5", "Strasbourg (FR)"),
("BHS5", "Beauharnois (CA)"),
("US-EAST-VA-1", "Vint Hill (US)"),
("US-WEST-OR-1", "Hillsboro (US)"),
],
CloudProvider::RunPod => vec![("US", "United States"), ("EU", "Europe"), ("CA", "Canada")],
CloudProvider::LambdaLabs => vec![
("us-west-1", "US West"),
("us-east-1", "US East"),
("us-south-1", "US South"),
("europe-central-1", "Europe Central"),
],
CloudProvider::PrimeIntellect => vec![
("us-east", "US East"),
("us-west", "US West"),
("eu-west", "EU West"),
],
CloudProvider::VastAi => vec![
("any", "Any (cheapest)"),
("US", "United States"),
("EU", "Europe"),
],
CloudProvider::Crusoe => vec![
("us-east1", "US East"),
("us-central1", "US Central"),
("us-northwest1", "US Northwest"),
],
_ => color_eyre::eyre::bail!("No region list for {provider}"),
};
let display_regions: Vec<String> = regions
.iter()
.map(|(code, name)| format!("{} ({})", name, code))
.collect();
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Select region")
.items(&display_regions)
.default(0)
.interact()?;
Ok(regions[selection].0.to_string())
}
pub async fn list_providers() -> Result<()> {
let config = CloudConfig::load()?;
if config.providers.is_empty() {
println!("No cloud providers configured.");
println!("Run `cargo tangle cloud configure <provider>` to get started.");
return Ok(());
}
println!("Configured providers:\n");
for (provider, settings) in &config.providers {
let default = if Some(provider) == config.default_provider.as_ref() {
" (default)"
} else {
""
};
println!(" {} {}", provider, default);
println!(" Region: {}", settings.region);
if let Some(project) = &settings.project_id {
println!(" Project: {}", project);
}
println!();
}
Ok(())
}