refactor: simplify crud-pricing to pure CRUD layer - remove AWS API clients
All checks were successful
kinec.tech/airun-pathfinder-crud-pricing/pipeline/head This commit looks good
All checks were successful
kinec.tech/airun-pathfinder-crud-pricing/pipeline/head This commit looks good
Removed AWS Pricing API and Cost Explorer clients to make crud-pricing a pure DynamoDB CRUD layer. This fixes layer violation where CRUD was making external AWS API calls. Changes: - Deleted src/aws_pricing.rs (AWS Pricing API client) - Deleted src/cost_explorer.rs (Cost Explorer client) - Removed PricingClient and StsClient from main.rs - Removed QueryAwsApi and QueryCostExplorer operations - Removed fetch_if_missing behavior (Get operation now only reads from cache) - Removed aws-sdk-pricing, aws-sdk-costexplorer, aws-sdk-sts dependencies - Removed pricing_api_access and sts_assume_role IAM policies Result: Pure CRUD layer with only DynamoDB operations (Get, Put, ListCommon, IncrementAccess) Reduced from ~1100 lines to ~600 lines 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -11,9 +11,6 @@ path = "src/main.rs"
|
|||||||
# AWS SDK
|
# AWS SDK
|
||||||
aws-config = { version = "1.5", features = ["behavior-version-latest"] }
|
aws-config = { version = "1.5", features = ["behavior-version-latest"] }
|
||||||
aws-sdk-dynamodb = "1.60"
|
aws-sdk-dynamodb = "1.60"
|
||||||
aws-sdk-pricing = "1.60"
|
|
||||||
aws-sdk-costexplorer = "1.60"
|
|
||||||
aws-sdk-sts = "1.60"
|
|
||||||
|
|
||||||
# Lambda runtime
|
# Lambda runtime
|
||||||
lambda_runtime = "0.13"
|
lambda_runtime = "0.13"
|
||||||
|
|||||||
@@ -1,383 +0,0 @@
|
|||||||
use aws_sdk_pricing::types::Filter;
|
|
||||||
use aws_sdk_pricing::Client as PricingClient;
|
|
||||||
use chrono::Utc;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use tracing::{error, info, warn};
|
|
||||||
|
|
||||||
use crate::models::{
|
|
||||||
Ec2Pricing, OnDemandPricing, PricingData, PricingType, ReservedOption, ReservedPricing,
|
|
||||||
ReservedTerm,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Fetch EC2 instance pricing from AWS Pricing API
|
|
||||||
pub async fn fetch_ec2_pricing(
|
|
||||||
client: &PricingClient,
|
|
||||||
instance_type: &str,
|
|
||||||
region: &str,
|
|
||||||
) -> Result<PricingData, String> {
|
|
||||||
info!("Fetching pricing from AWS Pricing API for {} in {}", instance_type, region);
|
|
||||||
|
|
||||||
let location_name = region_to_location_name(region);
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get_products()
|
|
||||||
.service_code("AmazonEC2")
|
|
||||||
.filters(
|
|
||||||
Filter::builder()
|
|
||||||
.field("instanceType")
|
|
||||||
.value(instance_type)
|
|
||||||
.r#type("TERM_MATCH")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("Failed to build filter: {}", e))?,
|
|
||||||
)
|
|
||||||
.filters(
|
|
||||||
Filter::builder()
|
|
||||||
.field("location")
|
|
||||||
.value(location_name)
|
|
||||||
.r#type("TERM_MATCH")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("Failed to build filter: {}", e))?,
|
|
||||||
)
|
|
||||||
.filters(
|
|
||||||
Filter::builder()
|
|
||||||
.field("operatingSystem")
|
|
||||||
.value("Linux")
|
|
||||||
.r#type("TERM_MATCH")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("Failed to build filter: {}", e))?,
|
|
||||||
)
|
|
||||||
.filters(
|
|
||||||
Filter::builder()
|
|
||||||
.field("tenancy")
|
|
||||||
.value("Shared")
|
|
||||||
.r#type("TERM_MATCH")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("Failed to build filter: {}", e))?,
|
|
||||||
)
|
|
||||||
.filters(
|
|
||||||
Filter::builder()
|
|
||||||
.field("capacitystatus")
|
|
||||||
.value("Used")
|
|
||||||
.r#type("TERM_MATCH")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("Failed to build filter: {}", e))?,
|
|
||||||
)
|
|
||||||
.filters(
|
|
||||||
Filter::builder()
|
|
||||||
.field("preInstalledSw")
|
|
||||||
.value("NA")
|
|
||||||
.r#type("TERM_MATCH")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("Failed to build filter: {}", e))?,
|
|
||||||
)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("AWS Pricing API call failed: {}", e))?;
|
|
||||||
|
|
||||||
let price_list = response.price_list();
|
|
||||||
|
|
||||||
if price_list.is_empty() {
|
|
||||||
return Err(format!("No pricing found for {} in {}", instance_type, region));
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Parsing {} pricing items", price_list.len());
|
|
||||||
|
|
||||||
// Parse the first (and usually only) price item
|
|
||||||
let pricing = parse_pricing_response(price_list[0].as_str(), instance_type, region)?;
|
|
||||||
|
|
||||||
Ok(pricing)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse AWS Pricing API response
|
|
||||||
fn parse_pricing_response(
|
|
||||||
price_json: &str,
|
|
||||||
instance_type: &str,
|
|
||||||
region: &str,
|
|
||||||
) -> Result<PricingData, String> {
|
|
||||||
let item: Value = serde_json::from_str(price_json)
|
|
||||||
.map_err(|e| format!("Failed to parse pricing JSON: {}", e))?;
|
|
||||||
|
|
||||||
// Extract instance attributes
|
|
||||||
let attributes = item["product"]["attributes"]
|
|
||||||
.as_object()
|
|
||||||
.ok_or("Missing product attributes")?;
|
|
||||||
|
|
||||||
let vcpus: i32 = attributes
|
|
||||||
.get("vcpu")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.and_then(|s| s.parse().ok())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let memory_str = attributes
|
|
||||||
.get("memory")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("0 GiB");
|
|
||||||
let memory_gb: f64 = memory_str
|
|
||||||
.replace(" GiB", "")
|
|
||||||
.replace(",", "")
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(0.0);
|
|
||||||
|
|
||||||
let instance_family = attributes
|
|
||||||
.get("instanceFamily")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let processor_architecture = attributes
|
|
||||||
.get("processorArchitecture")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("x86_64");
|
|
||||||
|
|
||||||
let architectures = vec![processor_architecture.to_string()];
|
|
||||||
|
|
||||||
// Parse OnDemand pricing
|
|
||||||
let on_demand = parse_on_demand_pricing(&item)?;
|
|
||||||
|
|
||||||
// Parse Reserved pricing (optional)
|
|
||||||
let reserved = parse_reserved_pricing(&item).ok();
|
|
||||||
|
|
||||||
// Spot pricing not available in Pricing API (would need EC2 Spot Price API)
|
|
||||||
let spot = None;
|
|
||||||
|
|
||||||
let ec2_pricing = Ec2Pricing {
|
|
||||||
instance_family,
|
|
||||||
vcpus,
|
|
||||||
memory_gb,
|
|
||||||
architectures,
|
|
||||||
on_demand,
|
|
||||||
reserved,
|
|
||||||
spot,
|
|
||||||
};
|
|
||||||
|
|
||||||
let pricing_data = PricingData {
|
|
||||||
instance_type: instance_type.to_string(),
|
|
||||||
region: region.to_string(),
|
|
||||||
pricing_type: PricingType::Retail,
|
|
||||||
aws_account_id: None,
|
|
||||||
ec2_pricing,
|
|
||||||
source: "aws-pricing-api".to_string(),
|
|
||||||
last_updated: Utc::now().to_rfc3339(),
|
|
||||||
access_count: 0,
|
|
||||||
last_accessed: None,
|
|
||||||
first_cached: Some(Utc::now().to_rfc3339()),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(pricing_data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse OnDemand pricing from terms
|
|
||||||
fn parse_on_demand_pricing(item: &Value) -> Result<OnDemandPricing, String> {
|
|
||||||
let on_demand_terms = item["terms"]["OnDemand"]
|
|
||||||
.as_object()
|
|
||||||
.ok_or("Missing OnDemand terms")?;
|
|
||||||
|
|
||||||
// Get first (and usually only) term
|
|
||||||
let term = on_demand_terms
|
|
||||||
.values()
|
|
||||||
.next()
|
|
||||||
.ok_or("No OnDemand term found")?;
|
|
||||||
|
|
||||||
let price_dimensions = term["priceDimensions"]
|
|
||||||
.as_object()
|
|
||||||
.ok_or("Missing priceDimensions")?;
|
|
||||||
|
|
||||||
let price_dim = price_dimensions
|
|
||||||
.values()
|
|
||||||
.next()
|
|
||||||
.ok_or("No price dimension found")?;
|
|
||||||
|
|
||||||
let hourly_str = price_dim["pricePerUnit"]["USD"]
|
|
||||||
.as_str()
|
|
||||||
.ok_or("Missing USD price")?;
|
|
||||||
|
|
||||||
let hourly: f64 = hourly_str
|
|
||||||
.parse()
|
|
||||||
.map_err(|e| format!("Failed to parse hourly price: {}", e))?;
|
|
||||||
|
|
||||||
let monthly = hourly * 730.0; // Hours per month
|
|
||||||
|
|
||||||
Ok(OnDemandPricing { hourly, monthly })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse Reserved pricing from terms
|
|
||||||
fn parse_reserved_pricing(item: &Value) -> Result<ReservedPricing, String> {
|
|
||||||
let reserved_terms = item["terms"]["Reserved"]
|
|
||||||
.as_object()
|
|
||||||
.ok_or("Missing Reserved terms")?;
|
|
||||||
|
|
||||||
let mut standard: HashMap<String, ReservedTerm> = HashMap::new();
|
|
||||||
let mut convertible: HashMap<String, ReservedTerm> = HashMap::new();
|
|
||||||
|
|
||||||
for (_, term) in reserved_terms {
|
|
||||||
let term_attributes = term["termAttributes"]
|
|
||||||
.as_object()
|
|
||||||
.ok_or("Missing termAttributes")?;
|
|
||||||
|
|
||||||
let lease_contract_length = term_attributes
|
|
||||||
.get("LeaseContractLength")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
let purchase_option = term_attributes
|
|
||||||
.get("PurchaseOption")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
let offering_class = term_attributes
|
|
||||||
.get("OfferingClass")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("standard");
|
|
||||||
|
|
||||||
// Parse the pricing
|
|
||||||
if let Ok(pricing) = parse_reserved_option(term) {
|
|
||||||
let term_key = if lease_contract_length.contains("1yr") || lease_contract_length.contains("1 year") {
|
|
||||||
"1yr"
|
|
||||||
} else if lease_contract_length.contains("3yr") || lease_contract_length.contains("3 year") {
|
|
||||||
"3yr"
|
|
||||||
} else {
|
|
||||||
continue; // Skip unknown terms
|
|
||||||
};
|
|
||||||
|
|
||||||
let option_type = if purchase_option.contains("All Upfront") {
|
|
||||||
"all_upfront"
|
|
||||||
} else if purchase_option.contains("Partial Upfront") {
|
|
||||||
"partial_upfront"
|
|
||||||
} else if purchase_option.contains("No Upfront") {
|
|
||||||
"no_upfront"
|
|
||||||
} else {
|
|
||||||
continue; // Skip unknown options
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add to appropriate collection
|
|
||||||
let collection = if offering_class.contains("convertible") {
|
|
||||||
&mut convertible
|
|
||||||
} else {
|
|
||||||
&mut standard
|
|
||||||
};
|
|
||||||
|
|
||||||
let term_entry = collection
|
|
||||||
.entry(term_key.to_string())
|
|
||||||
.or_insert_with(|| ReservedTerm {
|
|
||||||
all_upfront: ReservedOption::default(),
|
|
||||||
partial_upfront: ReservedOption::default(),
|
|
||||||
no_upfront: ReservedOption::default(),
|
|
||||||
});
|
|
||||||
|
|
||||||
match option_type {
|
|
||||||
"all_upfront" => term_entry.all_upfront = pricing,
|
|
||||||
"partial_upfront" => term_entry.partial_upfront = pricing,
|
|
||||||
"no_upfront" => term_entry.no_upfront = pricing,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if standard.is_empty() && convertible.is_empty() {
|
|
||||||
return Err("No valid reserved pricing found".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ReservedPricing {
|
|
||||||
standard,
|
|
||||||
convertible,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a single reserved pricing option
|
|
||||||
fn parse_reserved_option(term: &Value) -> Result<ReservedOption, String> {
|
|
||||||
let price_dimensions = term["priceDimensions"]
|
|
||||||
.as_object()
|
|
||||||
.ok_or("Missing priceDimensions")?;
|
|
||||||
|
|
||||||
let mut total_upfront = 0.0;
|
|
||||||
let mut hourly_rate = 0.0;
|
|
||||||
|
|
||||||
for (_, price_dim) in price_dimensions {
|
|
||||||
let unit = price_dim["unit"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
let price_str = price_dim["pricePerUnit"]["USD"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or("0");
|
|
||||||
|
|
||||||
let price: f64 = price_str.parse().unwrap_or(0.0);
|
|
||||||
|
|
||||||
if unit == "Quantity" {
|
|
||||||
total_upfront = price;
|
|
||||||
} else if unit == "Hrs" {
|
|
||||||
hourly_rate = price;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let effective_hourly = if total_upfront > 0.0 {
|
|
||||||
// Calculate effective hourly from upfront
|
|
||||||
let term_attributes = term["termAttributes"]
|
|
||||||
.as_object()
|
|
||||||
.ok_or("Missing termAttributes")?;
|
|
||||||
|
|
||||||
let lease_length = term_attributes
|
|
||||||
.get("LeaseContractLength")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
let hours = if lease_length.contains("1yr") || lease_length.contains("1 year") {
|
|
||||||
8760.0 // Hours in 1 year
|
|
||||||
} else if lease_length.contains("3yr") || lease_length.contains("3 year") {
|
|
||||||
26280.0 // Hours in 3 years
|
|
||||||
} else {
|
|
||||||
8760.0 // Default to 1 year
|
|
||||||
};
|
|
||||||
|
|
||||||
(total_upfront / hours) + hourly_rate
|
|
||||||
} else {
|
|
||||||
hourly_rate
|
|
||||||
};
|
|
||||||
|
|
||||||
let effective_monthly = effective_hourly * 730.0;
|
|
||||||
let monthly_payment = hourly_rate * 730.0;
|
|
||||||
|
|
||||||
Ok(ReservedOption {
|
|
||||||
effective_hourly,
|
|
||||||
effective_monthly,
|
|
||||||
total_upfront,
|
|
||||||
monthly_payment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ReservedOption {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
effective_hourly: 0.0,
|
|
||||||
effective_monthly: 0.0,
|
|
||||||
total_upfront: 0.0,
|
|
||||||
monthly_payment: 0.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert AWS region code to location name used by Pricing API
|
|
||||||
pub fn region_to_location_name(region: &str) -> &str {
|
|
||||||
match region {
|
|
||||||
"us-east-1" => "US East (N. Virginia)",
|
|
||||||
"us-east-2" => "US East (Ohio)",
|
|
||||||
"us-west-1" => "US West (N. California)",
|
|
||||||
"us-west-2" => "US West (Oregon)",
|
|
||||||
"eu-west-1" => "EU (Ireland)",
|
|
||||||
"eu-west-2" => "EU (London)",
|
|
||||||
"eu-west-3" => "EU (Paris)",
|
|
||||||
"eu-central-1" => "EU (Frankfurt)",
|
|
||||||
"ap-southeast-1" => "Asia Pacific (Singapore)",
|
|
||||||
"ap-southeast-2" => "Asia Pacific (Sydney)",
|
|
||||||
"ap-northeast-1" => "Asia Pacific (Tokyo)",
|
|
||||||
"ap-northeast-2" => "Asia Pacific (Seoul)",
|
|
||||||
"ap-south-1" => "Asia Pacific (Mumbai)",
|
|
||||||
"sa-east-1" => "South America (Sao Paulo)",
|
|
||||||
"ca-central-1" => "Canada (Central)",
|
|
||||||
_ => {
|
|
||||||
warn!("Unknown region: {}, using as-is", region);
|
|
||||||
region
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
use aws_config::Region;
|
|
||||||
use aws_sdk_costexplorer::types::{
|
|
||||||
DateInterval, Dimension, DimensionValues, Expression, Granularity, Metric,
|
|
||||||
};
|
|
||||||
use aws_sdk_costexplorer::Client as CostExplorerClient;
|
|
||||||
use aws_sdk_sts::Client as StsClient;
|
|
||||||
use chrono::{Duration, Utc};
|
|
||||||
use tracing::{error, info, warn};
|
|
||||||
|
|
||||||
use crate::models::{Ec2Pricing, OnDemandPricing, PricingData, PricingType};
|
|
||||||
|
|
||||||
/// Fetch account-specific pricing from Cost Explorer
|
|
||||||
/// This automatically includes EDP/PPA discounts!
|
|
||||||
pub async fn fetch_account_specific_pricing(
|
|
||||||
sts_client: &StsClient,
|
|
||||||
instance_type: &str,
|
|
||||||
region: &str,
|
|
||||||
aws_account_id: &str,
|
|
||||||
role_arn: &str,
|
|
||||||
) -> Result<PricingData, String> {
|
|
||||||
info!(
|
|
||||||
"Fetching account-specific pricing for {} in {} (account: {})",
|
|
||||||
instance_type, region, aws_account_id
|
|
||||||
);
|
|
||||||
|
|
||||||
// 1. Assume role into customer account
|
|
||||||
let credentials = sts_client
|
|
||||||
.assume_role()
|
|
||||||
.role_arn(role_arn)
|
|
||||||
.role_session_name("pathfinder-pricing-query")
|
|
||||||
.duration_seconds(900) // 15 minutes
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to assume role: {}", e))?;
|
|
||||||
|
|
||||||
let creds = credentials
|
|
||||||
.credentials()
|
|
||||||
.ok_or("No credentials returned from assume role")?;
|
|
||||||
|
|
||||||
info!("✅ Successfully assumed role into account {}", aws_account_id);
|
|
||||||
|
|
||||||
// 2. Create Cost Explorer client with assumed credentials
|
|
||||||
let config = aws_config::from_env()
|
|
||||||
.credentials_provider(aws_sdk_sts::config::Credentials::new(
|
|
||||||
creds.access_key_id(),
|
|
||||||
creds.secret_access_key(),
|
|
||||||
Some(creds.session_token().to_string()),
|
|
||||||
None,
|
|
||||||
"assumed-role",
|
|
||||||
))
|
|
||||||
.region(Region::new(region.to_string()))
|
|
||||||
.load()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let ce_client = CostExplorerClient::new(&config);
|
|
||||||
|
|
||||||
// 3. Query Cost Explorer for historical usage and costs
|
|
||||||
let end_date = Utc::now();
|
|
||||||
let start_date = end_date - Duration::days(30);
|
|
||||||
|
|
||||||
info!(
|
|
||||||
"Querying Cost Explorer from {} to {}",
|
|
||||||
start_date.format("%Y-%m-%d"),
|
|
||||||
end_date.format("%Y-%m-%d")
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = ce_client
|
|
||||||
.get_cost_and_usage()
|
|
||||||
.time_period(
|
|
||||||
DateInterval::builder()
|
|
||||||
.start(start_date.format("%Y-%m-%d").to_string())
|
|
||||||
.end(end_date.format("%Y-%m-%d").to_string())
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("Failed to build date interval: {}", e))?,
|
|
||||||
)
|
|
||||||
.granularity(Granularity::Daily)
|
|
||||||
.metrics(Metric::UnblendedCost)
|
|
||||||
.metrics(Metric::UsageQuantity)
|
|
||||||
.filter(
|
|
||||||
Expression::builder()
|
|
||||||
.dimensions(
|
|
||||||
DimensionValues::builder()
|
|
||||||
.key(Dimension::InstanceType)
|
|
||||||
.values(instance_type)
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("Failed to build dimension: {}", e))?,
|
|
||||||
)
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Cost Explorer API call failed: {}", e))?;
|
|
||||||
|
|
||||||
// 4. Calculate average hourly cost from actual usage
|
|
||||||
let results = response.results_by_time();
|
|
||||||
|
|
||||||
if results.is_empty() {
|
|
||||||
return Err(format!(
|
|
||||||
"No usage data found for {} in account {}. Instance may not be in use.",
|
|
||||||
instance_type, aws_account_id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let (total_cost, total_hours) = results.iter().fold((0.0, 0.0), |(cost_acc, hours_acc), result| {
|
|
||||||
let cost: f64 = result
|
|
||||||
.total()
|
|
||||||
.and_then(|t| t.get("UnblendedCost"))
|
|
||||||
.and_then(|m| m.amount())
|
|
||||||
.and_then(|a| a.parse().ok())
|
|
||||||
.unwrap_or(0.0);
|
|
||||||
|
|
||||||
let usage_quantity: f64 = result
|
|
||||||
.total()
|
|
||||||
.and_then(|t| t.get("UsageQuantity"))
|
|
||||||
.and_then(|m| m.amount())
|
|
||||||
.and_then(|a| a.parse().ok())
|
|
||||||
.unwrap_or(0.0);
|
|
||||||
|
|
||||||
(cost_acc + cost, hours_acc + usage_quantity)
|
|
||||||
});
|
|
||||||
|
|
||||||
if total_hours == 0.0 {
|
|
||||||
return Err(format!(
|
|
||||||
"No usage hours found for {} (zero usage in past 30 days)",
|
|
||||||
instance_type
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let avg_hourly_cost = total_cost / total_hours;
|
|
||||||
let monthly_cost = avg_hourly_cost * 730.0;
|
|
||||||
|
|
||||||
info!(
|
|
||||||
"✅ Calculated account-specific pricing: ${:.4}/hr, ${:.2}/mo (from {} hours usage)",
|
|
||||||
avg_hourly_cost, monthly_cost, total_hours
|
|
||||||
);
|
|
||||||
|
|
||||||
// 5. Build pricing data
|
|
||||||
// Note: We don't have vcpu/memory from Cost Explorer, so we'd need to get that from Pricing API
|
|
||||||
// For now, we'll query Pricing API for instance specs and use Cost Explorer for pricing
|
|
||||||
let instance_specs = fetch_instance_specs(instance_type, region).await?;
|
|
||||||
|
|
||||||
let ec2_pricing = Ec2Pricing {
|
|
||||||
instance_family: instance_specs.instance_family,
|
|
||||||
vcpus: instance_specs.vcpus,
|
|
||||||
memory_gb: instance_specs.memory_gb,
|
|
||||||
architectures: instance_specs.architectures,
|
|
||||||
on_demand: OnDemandPricing {
|
|
||||||
hourly: avg_hourly_cost,
|
|
||||||
monthly: monthly_cost,
|
|
||||||
},
|
|
||||||
reserved: None, // Reserved pricing from Cost Explorer is complex, skip for now
|
|
||||||
spot: None, // Spot pricing not in Cost Explorer
|
|
||||||
};
|
|
||||||
|
|
||||||
let pricing_data = PricingData {
|
|
||||||
instance_type: instance_type.to_string(),
|
|
||||||
region: region.to_string(),
|
|
||||||
pricing_type: PricingType::AccountSpecific,
|
|
||||||
aws_account_id: Some(aws_account_id.to_string()),
|
|
||||||
ec2_pricing,
|
|
||||||
source: "cost-explorer".to_string(),
|
|
||||||
last_updated: Utc::now().to_rfc3339(),
|
|
||||||
access_count: 0,
|
|
||||||
last_accessed: None,
|
|
||||||
first_cached: Some(Utc::now().to_rfc3339()),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(pricing_data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Simple struct for instance specs
|
|
||||||
struct InstanceSpecs {
|
|
||||||
instance_family: String,
|
|
||||||
vcpus: i32,
|
|
||||||
memory_gb: f64,
|
|
||||||
architectures: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch instance specifications from Pricing API
|
|
||||||
/// We need this because Cost Explorer only gives us costs, not specs
|
|
||||||
async fn fetch_instance_specs(
|
|
||||||
instance_type: &str,
|
|
||||||
region: &str,
|
|
||||||
) -> Result<InstanceSpecs, String> {
|
|
||||||
info!("Fetching instance specs for {}", instance_type);
|
|
||||||
|
|
||||||
// Create new pricing client from default config
|
|
||||||
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
|
|
||||||
let pricing_client = aws_sdk_pricing::Client::new(&config);
|
|
||||||
|
|
||||||
let location_name = crate::aws_pricing::region_to_location_name(region);
|
|
||||||
|
|
||||||
let response = pricing_client
|
|
||||||
.get_products()
|
|
||||||
.service_code("AmazonEC2")
|
|
||||||
.filters(
|
|
||||||
aws_sdk_pricing::types::Filter::builder()
|
|
||||||
.field("instanceType")
|
|
||||||
.value(instance_type)
|
|
||||||
.r#type("TERM_MATCH")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("Failed to build filter: {}", e))?,
|
|
||||||
)
|
|
||||||
.filters(
|
|
||||||
aws_sdk_pricing::types::Filter::builder()
|
|
||||||
.field("location")
|
|
||||||
.value(location_name)
|
|
||||||
.r#type("TERM_MATCH")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("Failed to build filter: {}", e))?,
|
|
||||||
)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to fetch instance specs: {}", e))?;
|
|
||||||
|
|
||||||
let price_list = response.price_list();
|
|
||||||
|
|
||||||
if price_list.is_empty() {
|
|
||||||
return Err(format!("No instance specs found for {}", instance_type));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse just the attributes we need
|
|
||||||
let item: serde_json::Value = serde_json::from_str(price_list[0].as_str())
|
|
||||||
.map_err(|e| format!("Failed to parse pricing JSON: {}", e))?;
|
|
||||||
|
|
||||||
let attributes = item["product"]["attributes"]
|
|
||||||
.as_object()
|
|
||||||
.ok_or("Missing product attributes")?;
|
|
||||||
|
|
||||||
let vcpus: i32 = attributes
|
|
||||||
.get("vcpu")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.and_then(|s| s.parse().ok())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let memory_str = attributes
|
|
||||||
.get("memory")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("0 GiB");
|
|
||||||
let memory_gb: f64 = memory_str
|
|
||||||
.replace(" GiB", "")
|
|
||||||
.replace(",", "")
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(0.0);
|
|
||||||
|
|
||||||
let instance_family = attributes
|
|
||||||
.get("instanceFamily")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let processor_architecture = attributes
|
|
||||||
.get("processorArchitecture")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("x86_64");
|
|
||||||
|
|
||||||
let architectures = vec![processor_architecture.to_string()];
|
|
||||||
|
|
||||||
Ok(InstanceSpecs {
|
|
||||||
instance_family,
|
|
||||||
vcpus,
|
|
||||||
memory_gb,
|
|
||||||
architectures,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
112
src/main.rs
112
src/main.rs
@@ -1,12 +1,8 @@
|
|||||||
mod aws_pricing;
|
|
||||||
mod cost_explorer;
|
|
||||||
mod db;
|
mod db;
|
||||||
mod models;
|
mod models;
|
||||||
|
|
||||||
use aws_config::BehaviorVersion;
|
use aws_config::BehaviorVersion;
|
||||||
use aws_sdk_dynamodb::Client as DynamoDbClient;
|
use aws_sdk_dynamodb::Client as DynamoDbClient;
|
||||||
use aws_sdk_pricing::Client as PricingClient;
|
|
||||||
use aws_sdk_sts::Client as StsClient;
|
|
||||||
use lambda_runtime::{service_fn, Error, LambdaEvent};
|
use lambda_runtime::{service_fn, Error, LambdaEvent};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::env;
|
use std::env;
|
||||||
@@ -44,8 +40,6 @@ async fn function_handler(event: LambdaEvent<Value>) -> Result<Value, Error> {
|
|||||||
// Load AWS config and create clients
|
// Load AWS config and create clients
|
||||||
let config = aws_config::load_defaults(BehaviorVersion::latest()).await;
|
let config = aws_config::load_defaults(BehaviorVersion::latest()).await;
|
||||||
let dynamodb_client = DynamoDbClient::new(&config);
|
let dynamodb_client = DynamoDbClient::new(&config);
|
||||||
let pricing_client = PricingClient::new(&config);
|
|
||||||
let sts_client = StsClient::new(&config);
|
|
||||||
|
|
||||||
let table_name = env::var("TABLE_NAME").unwrap_or_else(|_| "pathfinder-dev-pricing".to_string());
|
let table_name = env::var("TABLE_NAME").unwrap_or_else(|_| "pathfinder-dev-pricing".to_string());
|
||||||
|
|
||||||
@@ -53,8 +47,6 @@ async fn function_handler(event: LambdaEvent<Value>) -> Result<Value, Error> {
|
|||||||
let result = handle_operation(
|
let result = handle_operation(
|
||||||
request.operation,
|
request.operation,
|
||||||
&dynamodb_client,
|
&dynamodb_client,
|
||||||
&pricing_client,
|
|
||||||
&sts_client,
|
|
||||||
&table_name,
|
&table_name,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -73,8 +65,6 @@ async fn function_handler(event: LambdaEvent<Value>) -> Result<Value, Error> {
|
|||||||
async fn handle_operation(
|
async fn handle_operation(
|
||||||
operation: PricingOperation,
|
operation: PricingOperation,
|
||||||
dynamodb_client: &DynamoDbClient,
|
dynamodb_client: &DynamoDbClient,
|
||||||
pricing_client: &PricingClient,
|
|
||||||
sts_client: &StsClient,
|
|
||||||
table_name: &str,
|
table_name: &str,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
match operation {
|
match operation {
|
||||||
@@ -83,9 +73,9 @@ async fn handle_operation(
|
|||||||
region,
|
region,
|
||||||
pricing_type,
|
pricing_type,
|
||||||
aws_account_id,
|
aws_account_id,
|
||||||
fetch_if_missing,
|
fetch_if_missing: _,
|
||||||
} => {
|
} => {
|
||||||
info!("Operation: Get (type={:?}, fetch_if_missing={})", pricing_type, fetch_if_missing);
|
info!("Operation: Get (type={:?})", pricing_type);
|
||||||
|
|
||||||
// Check cache
|
// Check cache
|
||||||
let cached = db::get_pricing(
|
let cached = db::get_pricing(
|
||||||
@@ -126,30 +116,8 @@ async fn handle_operation(
|
|||||||
"cacheStatus": "hit"
|
"cacheStatus": "hit"
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
None if fetch_if_missing => {
|
|
||||||
// Cache miss - fetch from AWS
|
|
||||||
info!("Cache miss, fetching from AWS API");
|
|
||||||
|
|
||||||
let pricing = fetch_pricing(
|
|
||||||
pricing_client,
|
|
||||||
sts_client,
|
|
||||||
&instance_type,
|
|
||||||
®ion,
|
|
||||||
&pricing_type,
|
|
||||||
aws_account_id.as_deref(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Cache it
|
|
||||||
db::put_pricing(dynamodb_client, table_name, &pricing).await?;
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"pricing": pricing,
|
|
||||||
"cacheStatus": "miss"
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
None => {
|
None => {
|
||||||
Err("Pricing not found and fetch_if_missing=false".to_string())
|
Err("Pricing not found in cache".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,79 +177,5 @@ async fn handle_operation(
|
|||||||
"region": region
|
"region": region
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
PricingOperation::QueryAwsApi {
|
|
||||||
instance_type,
|
|
||||||
region,
|
|
||||||
} => {
|
|
||||||
info!("Operation: QueryAwsApi ({} in {})", instance_type, region);
|
|
||||||
|
|
||||||
let pricing = aws_pricing::fetch_ec2_pricing(pricing_client, &instance_type, ®ion).await?;
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"pricing": pricing,
|
|
||||||
"source": "aws-pricing-api"
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
PricingOperation::QueryCostExplorer {
|
|
||||||
instance_type,
|
|
||||||
region,
|
|
||||||
aws_account_id,
|
|
||||||
role_arn,
|
|
||||||
} => {
|
|
||||||
info!(
|
|
||||||
"Operation: QueryCostExplorer ({} in {}, account={})",
|
|
||||||
instance_type, region, aws_account_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let pricing = cost_explorer::fetch_account_specific_pricing(
|
|
||||||
sts_client,
|
|
||||||
&instance_type,
|
|
||||||
®ion,
|
|
||||||
&aws_account_id,
|
|
||||||
&role_arn,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"pricing": pricing,
|
|
||||||
"source": "cost-explorer",
|
|
||||||
"includesEDP": true
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch pricing based on type (retail or account-specific)
|
|
||||||
async fn fetch_pricing(
|
|
||||||
pricing_client: &PricingClient,
|
|
||||||
sts_client: &StsClient,
|
|
||||||
instance_type: &str,
|
|
||||||
region: &str,
|
|
||||||
pricing_type: &PricingType,
|
|
||||||
aws_account_id: Option<&str>,
|
|
||||||
) -> Result<crate::models::PricingData, String> {
|
|
||||||
match pricing_type {
|
|
||||||
PricingType::Retail => {
|
|
||||||
info!("Fetching retail pricing from AWS Pricing API");
|
|
||||||
aws_pricing::fetch_ec2_pricing(pricing_client, instance_type, region).await
|
|
||||||
}
|
|
||||||
PricingType::AccountSpecific => {
|
|
||||||
let account_id = aws_account_id.ok_or("aws_account_id required for account-specific pricing")?;
|
|
||||||
|
|
||||||
// Role ARN is expected to be in format: arn:aws:iam::{account_id}:role/pathfinder-pricing-access
|
|
||||||
let role_arn = format!("arn:aws:iam::{}:role/pathfinder-pricing-access", account_id);
|
|
||||||
|
|
||||||
info!("Fetching account-specific pricing from Cost Explorer");
|
|
||||||
cost_explorer::fetch_account_specific_pricing(
|
|
||||||
sts_client,
|
|
||||||
instance_type,
|
|
||||||
region,
|
|
||||||
account_id,
|
|
||||||
&role_arn,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,20 +44,6 @@ pub enum PricingOperation {
|
|||||||
instance_type: String,
|
instance_type: String,
|
||||||
region: String,
|
region: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Query AWS Pricing API directly
|
|
||||||
QueryAwsApi {
|
|
||||||
instance_type: String,
|
|
||||||
region: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Query AWS Cost Explorer for account-specific pricing
|
|
||||||
QueryCostExplorer {
|
|
||||||
instance_type: String,
|
|
||||||
region: String,
|
|
||||||
aws_account_id: String,
|
|
||||||
role_arn: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_limit() -> usize {
|
fn default_limit() -> usize {
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ provider "aws" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Data source: Get Gateway ID from platform-core
|
||||||
|
data "aws_ssm_parameter" "gateway_id" {
|
||||||
|
name = "/airun-pathfinder/${var.environment}/gateway-id"
|
||||||
|
}
|
||||||
|
|
||||||
# Get current AWS account ID
|
# Get current AWS account ID
|
||||||
data "aws_caller_identity" "current" {}
|
data "aws_caller_identity" "current" {}
|
||||||
|
|
||||||
@@ -124,47 +129,6 @@ resource "aws_iam_role_policy" "dynamodb_access" {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
# AWS Pricing API access
|
|
||||||
resource "aws_iam_role_policy" "pricing_api_access" {
|
|
||||||
name = "airun-pathfinder-crud-pricing-pricing-api-policy"
|
|
||||||
role = aws_iam_role.lambda.id
|
|
||||||
|
|
||||||
policy = jsonencode({
|
|
||||||
Version = "2012-10-17"
|
|
||||||
Statement = [
|
|
||||||
{
|
|
||||||
Effect = "Allow"
|
|
||||||
Action = [
|
|
||||||
"pricing:GetProducts",
|
|
||||||
"pricing:DescribeServices",
|
|
||||||
"pricing:GetAttributeValues"
|
|
||||||
]
|
|
||||||
Resource = "*"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
# STS AssumeRole access (for Cost Explorer in customer accounts)
|
|
||||||
resource "aws_iam_role_policy" "sts_assume_role" {
|
|
||||||
name = "airun-pathfinder-crud-pricing-sts-policy"
|
|
||||||
role = aws_iam_role.lambda.id
|
|
||||||
|
|
||||||
policy = jsonencode({
|
|
||||||
Version = "2012-10-17"
|
|
||||||
Statement = [
|
|
||||||
{
|
|
||||||
Effect = "Allow"
|
|
||||||
Action = "sts:AssumeRole"
|
|
||||||
Resource = "arn:aws:iam::*:role/pathfinder-pricing-access"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cost Explorer access (when assuming role)
|
|
||||||
# Note: This is granted via the role in the customer account, not here
|
|
||||||
|
|
||||||
# Local variables
|
# Local variables
|
||||||
locals {
|
locals {
|
||||||
table_name = var.table_name != "" ? var.table_name : "pathfinder-${var.environment}-pricing"
|
table_name = var.table_name != "" ? var.table_name : "pathfinder-${var.environment}-pricing"
|
||||||
|
|||||||
Reference in New Issue
Block a user