feat: crud-pricing initial implementation
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
Complete CRUD service for AWS pricing operations - single source of truth. Features: - Dual pricing model (retail + account-specific with auto EDP/PPA detection) - Get/Put pricing operations with intelligent caching - AWS Pricing API integration for public list prices - AWS Cost Explorer integration for account-specific pricing - Access counting for self-learning 14-day refresh - Query most-accessed instances (powers smart refresh) - TTL: 30 days (retail), 7 days (account-specific) Architecture: - All other lambdas use this for pricing operations - No direct DynamoDB access from other components - Consistent schema enforcement - Complete IAM setup for Pricing API, Cost Explorer, STS Infrastructure: - Complete Terraform configuration - Full CI/CD pipeline (Jenkinsfile) - Comprehensive documentation - Production-ready scaffolding Part of Phase 1 - foundation for pricing system. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
287
src/main.rs
Normal file
287
src/main.rs
Normal file
@@ -0,0 +1,287 @@
|
||||
mod aws_pricing;
|
||||
mod cost_explorer;
|
||||
mod db;
|
||||
mod models;
|
||||
|
||||
use aws_config::BehaviorVersion;
|
||||
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 serde_json::Value;
|
||||
use std::env;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::models::{PricingOperation, PricingRequest, PricingResponse, PricingType};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Error> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::INFO)
|
||||
.with_target(false)
|
||||
.without_time()
|
||||
.json()
|
||||
.init();
|
||||
|
||||
lambda_runtime::run(service_fn(function_handler)).await
|
||||
}
|
||||
|
||||
async fn function_handler(event: LambdaEvent<Value>) -> Result<Value, Error> {
|
||||
let (payload, _context) = event.into_parts();
|
||||
|
||||
info!("Received pricing request: {:?}", payload);
|
||||
|
||||
// Parse request
|
||||
let request: PricingRequest = match serde_json::from_value(payload) {
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
error!("Invalid request: {}", e);
|
||||
let response = PricingResponse::error(400, &format!("Invalid request: {}", e));
|
||||
return Ok(serde_json::to_value(response)?);
|
||||
}
|
||||
};
|
||||
|
||||
// Load AWS config and create clients
|
||||
let config = aws_config::load_defaults(BehaviorVersion::latest()).await;
|
||||
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());
|
||||
|
||||
// Route operation
|
||||
let result = handle_operation(
|
||||
request.operation,
|
||||
&dynamodb_client,
|
||||
&pricing_client,
|
||||
&sts_client,
|
||||
&table_name,
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = match result {
|
||||
Ok(data) => PricingResponse::success(data),
|
||||
Err(e) => {
|
||||
error!("Operation failed: {}", e);
|
||||
PricingResponse::error(500, &e)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(serde_json::to_value(response)?)
|
||||
}
|
||||
|
||||
async fn handle_operation(
|
||||
operation: PricingOperation,
|
||||
dynamodb_client: &DynamoDbClient,
|
||||
pricing_client: &PricingClient,
|
||||
sts_client: &StsClient,
|
||||
table_name: &str,
|
||||
) -> Result<Value, String> {
|
||||
match operation {
|
||||
PricingOperation::Get {
|
||||
instance_type,
|
||||
region,
|
||||
pricing_type,
|
||||
aws_account_id,
|
||||
fetch_if_missing,
|
||||
} => {
|
||||
info!("Operation: Get (type={:?}, fetch_if_missing={})", pricing_type, fetch_if_missing);
|
||||
|
||||
// Check cache
|
||||
let cached = db::get_pricing(
|
||||
dynamodb_client,
|
||||
table_name,
|
||||
&instance_type,
|
||||
®ion,
|
||||
&pricing_type,
|
||||
aws_account_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
match cached {
|
||||
Some(mut pricing) => {
|
||||
// Cache hit - increment access count asynchronously
|
||||
let client = dynamodb_client.clone();
|
||||
let table = table_name.to_string();
|
||||
let inst = instance_type.clone();
|
||||
let reg = region.clone();
|
||||
let pt = pricing_type.clone();
|
||||
let acc_id = aws_account_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = db::increment_access_count(
|
||||
&client,
|
||||
&table,
|
||||
&inst,
|
||||
®,
|
||||
&pt,
|
||||
acc_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
pricing.access_count += 1; // Show incremented count in response
|
||||
Ok(serde_json::json!({
|
||||
"pricing": pricing,
|
||||
"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 => {
|
||||
Err("Pricing not found and fetch_if_missing=false".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PricingOperation::Put {
|
||||
instance_type,
|
||||
region,
|
||||
pricing_type,
|
||||
pricing_data,
|
||||
} => {
|
||||
info!("Operation: Put ({} in {})", instance_type, region);
|
||||
|
||||
db::put_pricing(dynamodb_client, table_name, &pricing_data).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"instanceType": instance_type,
|
||||
"region": region,
|
||||
"pricingType": pricing_type
|
||||
}))
|
||||
}
|
||||
|
||||
PricingOperation::ListCommon {
|
||||
limit,
|
||||
min_access_count,
|
||||
} => {
|
||||
info!("Operation: ListCommon (limit={}, min_access={})", limit, min_access_count.unwrap_or(0));
|
||||
|
||||
let common = db::query_most_accessed(dynamodb_client, table_name, limit, min_access_count).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"instances": common,
|
||||
"count": common.len()
|
||||
}))
|
||||
}
|
||||
|
||||
PricingOperation::IncrementAccess {
|
||||
instance_type,
|
||||
region,
|
||||
} => {
|
||||
info!("Operation: IncrementAccess ({} in {})", instance_type, region);
|
||||
|
||||
// Default to retail for access counting
|
||||
db::increment_access_count(
|
||||
dynamodb_client,
|
||||
table_name,
|
||||
&instance_type,
|
||||
®ion,
|
||||
&PricingType::Retail,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"instanceType": instance_type,
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user