use crate::error::{PricingError, Result};
use crate::types::CloudProvider;
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Clone, Debug)]
pub struct InstanceInfo {
pub name: String,
pub vcpus: f32,
pub memory_gb: f32,
pub hourly_price: f64,
}
#[derive(Clone)]
pub struct PricingFetcher {
client: reqwest::Client,
cache: HashMap<String, CachedPricing>,
}
#[derive(Clone)]
struct CachedPricing {
instances: Vec<InstanceInfo>,
fetched_at: std::time::Instant,
}
impl Default for PricingFetcher {
fn default() -> Self {
Self::new_or_default()
}
}
impl PricingFetcher {
pub fn new() -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| PricingError::Other(format!("Failed to create HTTP client: {e}")))?;
Ok(Self {
client,
cache: HashMap::new(),
})
}
pub fn new_or_default() -> Self {
Self::new().unwrap_or_else(|_| Self {
client: reqwest::Client::new(),
cache: HashMap::new(),
})
}
pub async fn find_best_instance(
&mut self,
provider: CloudProvider,
region: &str,
min_cpu: f32,
min_memory_gb: f32,
max_price: f64,
) -> Result<InstanceInfo> {
let instances = self.get_instances(provider, region).await?;
let mut best: Option<InstanceInfo> = None;
for instance in instances {
if instance.vcpus >= min_cpu
&& instance.memory_gb >= min_memory_gb
&& instance.hourly_price <= max_price
{
let is_better = best
.as_ref()
.map(|current| instance.hourly_price < current.hourly_price)
.unwrap_or(true);
if is_better {
best = Some(instance);
}
}
}
best.ok_or_else(|| {
PricingError::Other(format!(
"No instance found for {min_cpu} vCPUs, {min_memory_gb} GB RAM under ${max_price}/hr"
))
})
}
async fn get_instances(
&mut self,
provider: CloudProvider,
region: &str,
) -> Result<Vec<InstanceInfo>> {
let cache_key = format!("{provider:?}-{region}");
if let Some(cached) = self.cache.get(&cache_key) {
if cached.fetched_at.elapsed() < std::time::Duration::from_secs(86400) {
return Ok(cached.instances.clone());
}
}
let instances = match provider {
CloudProvider::AWS => self.fetch_aws_instances(region).await?,
CloudProvider::Azure => self.fetch_azure_instances(region).await?,
CloudProvider::GCP => self.fetch_gcp_instances(region).await?,
CloudProvider::DigitalOcean => self.fetch_digitalocean_instances(region).await?,
CloudProvider::Vultr => self.fetch_vultr_instances(region).await?,
_ => {
return Err(PricingError::Other(format!(
"No pricing API available for provider: {provider:?}"
)));
}
};
self.cache.insert(
cache_key,
CachedPricing {
instances: instances.clone(),
fetched_at: std::time::Instant::now(),
},
);
Ok(instances)
}
async fn fetch_aws_instances(&self, _region: &str) -> Result<Vec<InstanceInfo>> {
let url = "https://ec2.shop/?format=json";
let response = self
.client
.get(url)
.header("User-Agent", "blueprint-pricing-engine/0.1.0")
.timeout(std::time::Duration::from_secs(30))
.send()
.await
.map_err(|e| {
PricingError::Other(format!("Failed to fetch AWS pricing from ec2.shop: {e}"))
})?;
if !response.status().is_success() {
return Err(PricingError::Other(format!(
"ec2.shop API returned status: {}",
response.status()
)));
}
#[derive(Deserialize, Debug)]
struct Ec2ShopResponse {
#[serde(rename = "Prices")]
prices: Vec<Ec2ShopInstance>,
}
#[derive(Deserialize, Debug)]
struct Ec2ShopInstance {
#[serde(rename = "InstanceType")]
instance_type: String,
#[serde(rename = "Memory")]
memory: String,
#[serde(rename = "VCPUS")]
vcpus: i32,
#[serde(rename = "Cost")]
cost: f64,
}
let pricing_data: Ec2ShopResponse = response
.json()
.await
.map_err(|e| PricingError::Other(format!("Failed to parse ec2.shop JSON: {e}")))?;
let mut instances = Vec::new();
for price in pricing_data.prices.into_iter().take(100) {
let memory_gb = price
.memory
.split_whitespace()
.next()
.and_then(|s| s.parse::<f32>().ok())
.unwrap_or(0.0);
if price.vcpus > 0 && memory_gb > 0.0 && price.cost > 0.0 {
instances.push(InstanceInfo {
name: price.instance_type,
vcpus: price.vcpus as f32,
memory_gb,
hourly_price: price.cost,
});
}
}
if instances.is_empty() {
return Err(PricingError::Other(
"No AWS instances found in ec2.shop data".to_string(),
));
}
Ok(instances)
}
async fn fetch_azure_instances(&self, region: &str) -> Result<Vec<InstanceInfo>> {
let url = "https://instances.vantage.sh/azure/instances.json";
#[derive(Deserialize, Debug)]
struct VantageAzureInstance {
pretty_name: String,
vcpu: Option<f32>,
memory: Option<f32>,
pricing: Option<serde_json::Value>,
}
let response = self
.client
.get(url)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| PricingError::Other(format!("Failed to fetch Azure pricing: {e}")))?;
let instances: Vec<VantageAzureInstance> = response
.json()
.await
.map_err(|e| PricingError::Other(format!("Failed to parse Azure pricing: {e}")))?;
let mut result = Vec::new();
for inst in instances.into_iter().take(1000) {
if let (Some(vcpu), Some(memory)) = (inst.vcpu, inst.memory) {
let price = if let Some(pricing) = inst.pricing {
if let Some(region_data) = pricing.get(region) {
if let Some(linux_data) = region_data.get("linux") {
if let Some(ondemand) = linux_data.get("ondemand") {
ondemand.as_f64().unwrap_or(0.0)
} else {
0.0
}
} else {
0.0
}
} else {
0.0
}
} else {
0.0
};
if price > 0.0 {
result.push(InstanceInfo {
name: inst.pretty_name,
vcpus: vcpu,
memory_gb: memory,
hourly_price: price,
});
}
}
}
if result.is_empty() {
Err(PricingError::Other(
"No instances found for region".to_string(),
))
} else {
Ok(result)
}
}
async fn fetch_gcp_instances(&self, _region: &str) -> Result<Vec<InstanceInfo>> {
let api_key = std::env::var("GCP_API_KEY").map_err(|_| {
PricingError::Config(
"GCP_API_KEY environment variable required for GCP pricing. \
Get API key from: https://console.cloud.google.com/apis/credentials"
.to_string(),
)
})?;
let service_id = "services/6F81-5844-456A";
let url = format!("https://cloudbilling.googleapis.com/v1/{service_id}/skus?key={api_key}");
#[derive(Deserialize, Debug)]
struct GcpBillingResponse {
skus: Vec<GcpSku>,
}
#[derive(Deserialize, Debug)]
#[allow(dead_code)] struct GcpSku {
description: String,
category: GcpCategory,
#[serde(rename = "pricingInfo")]
pricing_info: Vec<GcpPricingInfo>,
}
#[derive(Deserialize, Debug)]
struct GcpCategory {
#[serde(rename = "resourceFamily")]
resource_family: String,
}
#[derive(Deserialize, Debug)]
#[allow(dead_code)] struct GcpPricingInfo {
#[serde(rename = "pricingExpression")]
pricing_expression: GcpPricingExpression,
}
#[derive(Deserialize, Debug)]
#[allow(dead_code)] struct GcpPricingExpression {
#[serde(rename = "tieredRates")]
tiered_rates: Vec<GcpTieredRate>,
}
#[derive(Deserialize, Debug)]
#[allow(dead_code)] struct GcpTieredRate {
#[serde(rename = "unitPrice")]
unit_price: GcpMoney,
}
#[derive(Deserialize, Debug)]
#[allow(dead_code)] struct GcpMoney {
units: String,
nanos: i64,
}
let response = self
.client
.get(&url)
.timeout(std::time::Duration::from_secs(30))
.send()
.await
.map_err(|e| PricingError::HttpError(format!("Failed to fetch GCP pricing: {e}")))?;
if !response.status().is_success() {
let status = response.status();
return Err(PricingError::HttpError(format!(
"GCP Cloud Billing API returned status: {status}. Check API key is valid."
)));
}
let billing_data: GcpBillingResponse = response
.json()
.await
.map_err(|e| PricingError::HttpError(format!("Failed to parse GCP pricing: {e}")))?;
let _result: Vec<InstanceInfo> = Vec::new();
for sku in billing_data.skus.iter().take(100) {
if sku.category.resource_family == "Compute"
&& sku.description.contains("Instance Core")
{
}
}
Err(PricingError::Config(
"GCP pricing requires using GCP Compute API with service account credentials. \
Cloud Billing Catalog API does not provide ready-to-use instance pricing. \
Consider using gcloud CLI or Compute Engine API directly."
.to_string(),
))
}
async fn fetch_digitalocean_instances(&self, _region: &str) -> Result<Vec<InstanceInfo>> {
let url = "https://www.digitalocean.com/pricing/droplets";
let response = self
.client
.get(url)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| PricingError::Other(format!("Failed to fetch DO pricing: {e}")))?;
let html = response
.text()
.await
.map_err(|e| PricingError::Other(format!("Failed to read DO pricing: {e}")))?;
let json_start = html
.find(r#"__NEXT_DATA__" type="application/json">{"#)
.ok_or_else(|| PricingError::Other("Could not find pricing data".to_string()))?;
let json_start = json_start + r#"__NEXT_DATA__" type="application/json">"#.len();
let json_end = html[json_start..]
.find("</script>")
.ok_or_else(|| PricingError::Other("Could not find end of pricing data".to_string()))?;
let json_str = &html[json_start..json_start + json_end];
let data: serde_json::Value = serde_json::from_str(json_str)
.map_err(|e| PricingError::Other(format!("Failed to parse DO pricing JSON: {e}")))?;
let mut result = Vec::new();
if let Some(droplets) = data
.get("props")
.and_then(|p| p.get("pageProps"))
.and_then(|p| p.get("data"))
.and_then(|d| d.get("basic"))
.and_then(|b| b.get("regular"))
.and_then(|r| r.as_array())
{
for droplet in droplets {
if let (Some(memory), Some(cpus), Some(price_obj)) = (
droplet.get("memory").and_then(|m| m.as_f64()),
droplet.get("cpus").and_then(|c| c.as_f64()),
droplet.get("price"),
) {
if let Some(hourly) = price_obj.get("hourly").and_then(|h| h.as_f64()) {
if let Some(slug) = droplet.get("slug").and_then(|s| s.as_str()) {
result.push(InstanceInfo {
name: slug.to_string(),
vcpus: cpus as f32,
memory_gb: memory as f32,
hourly_price: hourly,
});
}
}
}
}
}
if result.is_empty() {
Err(PricingError::Other(
"No DigitalOcean instances found".to_string(),
))
} else {
Ok(result)
}
}
async fn fetch_vultr_instances(&self, _region: &str) -> Result<Vec<InstanceInfo>> {
let api_key = std::env::var("VULTR_API_KEY").map_err(|_| {
PricingError::Config(
"VULTR_API_KEY environment variable required for Vultr pricing. \
Get API key from: https://my.vultr.com/settings/#settingsapi"
.to_string(),
)
})?;
let url = "https://api.vultr.com/v2/plans";
#[derive(Deserialize, Debug)]
struct VultrPlansResponse {
plans: Vec<VultrPlan>,
}
#[derive(Deserialize, Debug)]
#[allow(dead_code)] struct VultrPlan {
id: String,
vcpu_count: i32,
ram: i64, disk: i64, monthly_cost: f64,
#[serde(rename = "type")]
plan_type: String,
}
let response = self
.client
.get(url)
.header("Authorization", format!("Bearer {api_key}"))
.timeout(std::time::Duration::from_secs(30))
.send()
.await
.map_err(|e| PricingError::HttpError(format!("Failed to fetch Vultr pricing: {e}")))?;
if !response.status().is_success() {
let status = response.status();
return Err(PricingError::HttpError(format!(
"Vultr API returned status: {status}. Check API key is valid."
)));
}
let plans_data: VultrPlansResponse = response
.json()
.await
.map_err(|e| PricingError::HttpError(format!("Failed to parse Vultr pricing: {e}")))?;
let mut result = Vec::new();
for plan in plans_data.plans {
if plan.plan_type == "vc2" || plan.plan_type == "vhf" || plan.plan_type == "vhp" {
let memory_gb = plan.ram as f32 / 1024.0;
let hourly_price = plan.monthly_cost / 730.0;
if plan.vcpu_count > 0 && memory_gb > 0.0 && hourly_price > 0.0 {
result.push(InstanceInfo {
name: plan.id,
vcpus: plan.vcpu_count as f32,
memory_gb,
hourly_price,
});
}
}
}
if result.is_empty() {
Err(PricingError::Other(
"No Vultr instances found in API response".to_string(),
))
} else {
Ok(result)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_azure_pricing_api() {
let mut fetcher = PricingFetcher::new_or_default();
let result = fetcher
.find_best_instance(CloudProvider::Azure, "eastus", 2.0, 4.0, 0.10)
.await;
if result.is_ok() {
let instance = result.unwrap();
assert!(instance.hourly_price <= 0.10);
assert!(instance.vcpus >= 2.0);
assert!(instance.memory_gb >= 4.0);
}
}
#[tokio::test]
async fn test_aws_pricing_works() {
let fetcher = PricingFetcher::new_or_default();
let result = fetcher.fetch_aws_instances("us-east-1").await;
if result.is_ok() {
let instances = result.unwrap();
assert!(!instances.is_empty());
assert!(instances.iter().any(|i| i.hourly_price > 0.0));
}
}
}