feat: crud-pricing initial implementation
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:
2025-11-27 04:20:56 -05:00
commit e88609d724
13 changed files with 2494 additions and 0 deletions

287
src/main.rs Normal file
View 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,
&region,
&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,
&reg,
&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,
&region,
&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,
&region,
&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, &region).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,
&region,
&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
}
}
}