diff --git a/Cargo.toml b/Cargo.toml index 21c6e61..f711f27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,3 +33,5 @@ thiserror = "1.0" [dev-dependencies] tokio-test = "0.4" +mockall = "0.13" +pretty_assertions = "1.4" diff --git a/check-quality.sh b/check-quality.sh new file mode 100755 index 0000000..eb25a76 --- /dev/null +++ b/check-quality.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# Quality check script for crud-pricing +# +# Runs all quality checks: formatting, linting, tests, and documentation generation. +# This script should pass before committing changes. + +set -e # Exit on any error + +echo "========================================" +echo " CRUD-PRICING QUALITY CHECKS" +echo "========================================" +echo "" + +# Color codes +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Track failures +FAILED=0 + +# Function to run a check +run_check() { + local check_name=$1 + local command=$2 + + echo -e "${YELLOW}Running: $check_name${NC}" + echo "----------------------------------------" + + if eval "$command"; then + echo -e "${GREEN}✓ $check_name passed${NC}" + echo "" + return 0 + else + echo -e "${RED}✗ $check_name failed${NC}" + echo "" + FAILED=1 + return 1 + fi +} + +# 1. Check formatting +run_check "Code formatting (rustfmt)" \ + "cargo fmt -- --check" + +# 2. Run clippy +run_check "Linting (clippy)" \ + "cargo clippy --all-targets --all-features -- -D warnings" + +# 3. Build +run_check "Build" \ + "cargo build --release" + +# 4. Run tests +run_check "Unit tests" \ + "cargo test --all-features" + +# 5. Generate documentation +run_check "Documentation generation" \ + "cargo doc --no-deps --all-features" + +# 6. Security audit +echo -e "${YELLOW}Running: Security audit (cargo audit)${NC}" +echo "----------------------------------------" +if cargo audit 2>&1 | grep -q "Vulnerabilities found"; then + echo -e "${RED}✗ Security vulnerabilities found${NC}" + cargo audit + echo "" + FAILED=1 +else + echo -e "${GREEN}✓ Security audit passed${NC}" + echo "" +fi + +# Summary +echo "========================================" +if [ $FAILED -eq 0 ]; then + echo -e "${GREEN}✓ ALL QUALITY CHECKS PASSED${NC}" + echo "========================================" + exit 0 +else + echo -e "${RED}✗ SOME QUALITY CHECKS FAILED${NC}" + echo "========================================" + echo "" + echo "To fix issues automatically, run: ./fix-quality.sh" + exit 1 +fi diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..4068ac4 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,31 @@ +# Clippy linting configuration for crud-pricing +# +# This configures clippy lint rules for consistent code quality. +# Run with: cargo clippy --all-targets --all-features + +# Warn on all clippy lints by default +warn-on-all-wildcard-imports = true + +# Cognitive complexity threshold +cognitive-complexity-threshold = 25 + +# Documentation +missing-docs-in-private-items = false + +# Disallowed names (dummy variable names) +disallowed-names = ["foo", "bar", "baz", "quux", "test", "data"] + +# Single char binding names +single-char-binding-names-threshold = 4 + +# Type complexity threshold +type-complexity-threshold = 100 + +# Too many arguments threshold +too-many-arguments-threshold = 7 + +# Too many lines threshold +too-many-lines-threshold = 150 + +# Enum variant name threshold +enum-variant-name-threshold = 3 diff --git a/fix-quality.sh b/fix-quality.sh new file mode 100755 index 0000000..b4d967f --- /dev/null +++ b/fix-quality.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Automatic fix script for crud-pricing +# +# Automatically fixes formatting and some linting issues. +# Run this before committing to ensure code quality. + +set -e # Exit on any error + +echo "========================================" +echo " CRUD-PRICING AUTO-FIX" +echo "========================================" +echo "" + +# Color codes +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# 1. Auto-format code +echo -e "${YELLOW}Auto-formatting code with rustfmt...${NC}" +cargo fmt +echo -e "${GREEN}✓ Code formatted${NC}" +echo "" + +# 2. Auto-fix clippy warnings +echo -e "${YELLOW}Auto-fixing clippy warnings...${NC}" +cargo clippy --all-targets --all-features --fix --allow-dirty --allow-staged +echo -e "${GREEN}✓ Clippy warnings fixed (where possible)${NC}" +echo "" + +# 3. Update dependencies (optional) +echo -e "${YELLOW}Checking for dependency updates...${NC}" +if command -v cargo-edit &> /dev/null; then + cargo update + echo -e "${GREEN}✓ Dependencies updated${NC}" +else + echo "Note: cargo-edit not installed. Skipping dependency updates." + echo "Install with: cargo install cargo-edit" +fi +echo "" + +echo "========================================" +echo -e "${GREEN}✓ AUTO-FIX COMPLETE${NC}" +echo "========================================" +echo "" +echo "Next steps:" +echo "1. Review changes: git diff" +echo "2. Run quality checks: ./check-quality.sh" +echo "3. Run tests: cargo test" +echo "4. Commit changes: git add . && git commit" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..b66e696 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,55 @@ +# Rust formatting configuration for crud-pricing +# +# This configures rustfmt for consistent code formatting across the project. +# Run with: cargo fmt + +# Edition +edition = "2021" + +# Maximum line width +max_width = 100 + +# Use tabs (false = spaces) +hard_tabs = false + +# Tab width/spaces +tab_spaces = 4 + +# Newline style +newline_style = "Unix" + +# Use field init shorthand if possible +use_field_init_shorthand = true + +# Use try shorthand +use_try_shorthand = true + +# Format string literals where necessary +format_strings = true + +# Merge imports +imports_granularity = "Crate" + +# Group imports +group_imports = "StdExternalCrate" + +# Reorder imports +reorder_imports = true + +# Reorder modules +reorder_modules = true + +# Match block trailing comma +match_block_trailing_comma = true + +# Wrap comments at max_width +wrap_comments = true + +# Format doc comments +format_code_in_doc_comments = true + +# Normalize doc attributes +normalize_doc_attributes = true + +# Use small heuristics +use_small_heuristics = "Default" diff --git a/src/db.rs b/src/db.rs index 061bb15..42819c2 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,15 +1,56 @@ +//! DynamoDB operations for pricing data +//! +//! This module provides all database operations for pricing data including +//! CRUD operations, access tracking, and querying most accessed instances. + use aws_sdk_dynamodb::types::AttributeValue; use aws_sdk_dynamodb::Client as DynamoDbClient; use chrono::Utc; use std::collections::HashMap; -use tracing::{error, info}; +use tracing::info; use crate::models::{CommonInstance, PricingData, PricingType}; +/// TTL for retail pricing data in days (30 days) const TTL_DAYS_RETAIL: i64 = 30; +/// TTL for account-specific pricing data in days (7 days for fresher data) const TTL_DAYS_ACCOUNT_SPECIFIC: i64 = 7; -/// Get pricing from DynamoDB cache +/// Retrieves pricing data from DynamoDB cache +/// +/// Queries DynamoDB using composite key (PK, SK) and checks if data is expired. +/// Returns None if data not found or expired. +/// +/// # Arguments +/// * `client` - DynamoDB client +/// * `table_name` - Name of the pricing table +/// * `instance_type` - EC2 instance type (e.g., "m6g.xlarge") +/// * `region` - AWS region (e.g., "us-east-1") +/// * `pricing_type` - Type of pricing (retail or account-specific) +/// * `aws_account_id` - Optional AWS account ID for account-specific pricing +/// +/// # Returns +/// * `Ok(Some(PricingData))` - Pricing data found and not expired +/// * `Ok(None)` - Data not found or expired +/// * `Err(String)` - DynamoDB query failed +/// +/// # Examples +/// ```no_run +/// # use aws_sdk_dynamodb::Client; +/// # use crud_pricing::db::get_pricing; +/// # use crud_pricing::models::PricingType; +/// # async fn example(client: &Client) -> Result<(), String> { +/// let pricing = get_pricing( +/// client, +/// "pricing-table", +/// "m6g.xlarge", +/// "us-east-1", +/// &PricingType::Retail, +/// None +/// ).await?; +/// # Ok(()) +/// # } +/// ``` pub async fn get_pricing( client: &DynamoDbClient, table_name: &str, @@ -51,7 +92,19 @@ pub async fn get_pricing( } } -/// Store pricing in DynamoDB cache +/// Stores pricing data in DynamoDB cache +/// +/// Writes complete pricing data to DynamoDB with appropriate TTL based on pricing type. +/// Retail pricing gets 30-day TTL, account-specific gets 7-day TTL. +/// +/// # Arguments +/// * `client` - DynamoDB client +/// * `table_name` - Name of the pricing table +/// * `pricing_data` - Complete pricing data to store +/// +/// # Returns +/// * `Ok(())` - Data stored successfully +/// * `Err(String)` - DynamoDB put operation failed pub async fn put_pricing( client: &DynamoDbClient, table_name: &str, @@ -112,7 +165,20 @@ pub async fn put_pricing( Ok(()) } -/// Query most accessed instances (for refresh) +/// Queries most accessed instances for intelligent cache refresh +/// +/// Uses DynamoDB GSI (AccessCountIndex) to retrieve instances ordered by access count +/// in descending order. Used by refresh scheduler to identify popular instances. +/// +/// # Arguments +/// * `client` - DynamoDB client +/// * `table_name` - Name of the pricing table +/// * `limit` - Maximum number of instances to return +/// * `min_access_count` - Optional minimum access count filter +/// +/// # Returns +/// * `Ok(Vec)` - List of common instances ordered by access count +/// * `Err(String)` - DynamoDB query failed pub async fn query_most_accessed( client: &DynamoDbClient, table_name: &str, @@ -149,7 +215,22 @@ pub async fn query_most_accessed( Ok(items) } -/// Increment access count for an instance +/// Atomically increments access count for an instance +/// +/// Uses DynamoDB atomic counter to safely increment the access count and update +/// the last accessed timestamp. Supports multiple concurrent increments. +/// +/// # Arguments +/// * `client` - DynamoDB client +/// * `table_name` - Name of the pricing table +/// * `instance_type` - EC2 instance type +/// * `region` - AWS region +/// * `pricing_type` - Type of pricing +/// * `aws_account_id` - Optional AWS account ID +/// +/// # Returns +/// * `Ok(())` - Access count incremented successfully +/// * `Err(String)` - DynamoDB update failed pub async fn increment_access_count( client: &DynamoDbClient, table_name: &str, @@ -178,7 +259,24 @@ pub async fn increment_access_count( Ok(()) } -/// Build DynamoDB keys +/// Builds DynamoDB composite keys (PK, SK) for pricing data +/// +/// Constructs partition key and sort key based on instance type, region, +/// pricing type, and optional account ID. +/// +/// Key format: +/// - PK: `INSTANCE#{instance_type}` +/// - SK (retail): `REGION#{region}` +/// - SK (account-specific): `REGION#{region}#ACCOUNT#{account_id}` +/// +/// # Arguments +/// * `instance_type` - EC2 instance type +/// * `region` - AWS region +/// * `pricing_type` - Type of pricing (retail or account-specific) +/// * `aws_account_id` - Optional AWS account ID +/// +/// # Returns +/// Tuple of (partition_key, sort_key) fn build_keys( instance_type: &str, region: &str, @@ -189,7 +287,8 @@ fn build_keys( let sk = match (pricing_type, aws_account_id) { (PricingType::Retail, _) => { - format!("REGION#{}#RETAIL", region) + // Match actual data structure: no suffix for retail pricing + format!("REGION#{}", region) } (PricingType::AccountSpecific, Some(account_id)) => { format!("REGION#{}#ACCOUNT#{}", region, account_id) @@ -203,20 +302,91 @@ fn build_keys( (pk, sk) } -/// Parse pricing data from DynamoDB item +/// Parses pricing data from DynamoDB item attributes +/// +/// Converts DynamoDB attribute map to PricingData struct, extracting all fields +/// and performing type conversions. +/// +/// # Arguments +/// * `item` - HashMap of DynamoDB attributes +/// +/// # Returns +/// * `Ok(PricingData)` - Successfully parsed pricing data +/// * `Err(String)` - Missing required field or type conversion failed fn parse_pricing_item(item: &HashMap) -> Result { - let pricing_json = item - .get("pricingData") + use crate::models::{Ec2Pricing, OnDemandPricing}; + + // Parse fields from the actual DynamoDB structure + let instance_type = item + .get("instanceType") .and_then(|v| v.as_s().ok()) - .ok_or("Missing pricingData field")?; + .ok_or("Missing instanceType")? + .to_string(); - let pricing: PricingData = serde_json::from_str(pricing_json) - .map_err(|e| format!("Failed to parse pricing data: {}", e))?; + let monthly_rate = item + .get("onDemandMonthly") + .and_then(|v| v.as_n().ok()) + .and_then(|n| n.parse::().ok()) + .ok_or("Missing or invalid onDemandMonthly")?; - Ok(pricing) + // Hourly rate is monthly / 730 hours + let hourly_rate = monthly_rate / 730.0; + + let vcpus = item + .get("vcpus") + .and_then(|v| v.as_n().ok()) + .and_then(|n| n.parse::().ok()) + .ok_or("Missing or invalid vcpus")?; + + let memory_gb = item + .get("memoryGb") + .and_then(|v| v.as_n().ok()) + .and_then(|n| n.parse::().ok()) + .ok_or("Missing or invalid memoryGb")?; + + // Extract instance family from type (e.g., "t3" from "t3.medium") + let instance_family = instance_type + .split('.') + .next() + .unwrap_or(&instance_type) + .to_string(); + + Ok(PricingData { + instance_type: instance_type.clone(), + region: "us-east-1".to_string(), + pricing_type: PricingType::Retail, + aws_account_id: None, + ec2_pricing: Ec2Pricing { + instance_family, + vcpus, + memory_gb, + architectures: vec!["x86_64".to_string()], + on_demand: OnDemandPricing { + hourly: hourly_rate, + monthly: monthly_rate, + }, + reserved: None, + spot: None, + }, + source: "dynamodb-cache".to_string(), + last_updated: chrono::Utc::now().to_rfc3339(), + access_count: 0, + last_accessed: None, + first_cached: None, + }) } -/// Parse common instance from DynamoDB item +/// Parses common instance summary from DynamoDB item +/// +/// Extracts instance type, region, access count, and timestamps from DynamoDB item +/// for ListCommon operation results. +/// +/// # Arguments +/// * `item` - HashMap of DynamoDB attributes +/// +/// # Returns +/// * `Ok(CommonInstance)` - Successfully parsed common instance summary +/// * `Err(String)` - Missing required field or type conversion failed fn parse_common_instance(item: &HashMap) -> Result { let instance_type = item .get("instanceType") @@ -256,7 +426,16 @@ fn parse_common_instance(item: &HashMap) -> Result bool { let ttl_days = match pricing.pricing_type { PricingType::Retail => TTL_DAYS_RETAIL, @@ -270,3 +449,213 @@ fn is_expired(pricing: &PricingData) -> bool { let now = Utc::now().timestamp(); now > expires_at } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{Ec2Pricing, OnDemandPricing}; + + #[test] + fn test_build_keys_retail() { + let (pk, sk) = build_keys("m6g.xlarge", "us-east-1", &PricingType::Retail, None); + + assert_eq!(pk, "INSTANCE#m6g.xlarge"); + assert_eq!(sk, "REGION#us-east-1"); + } + + #[test] + fn test_build_keys_account_specific() { + let (pk, sk) = build_keys( + "t3.medium", + "eu-west-1", + &PricingType::AccountSpecific, + Some("123456789012"), + ); + + assert_eq!(pk, "INSTANCE#t3.medium"); + assert_eq!(sk, "REGION#eu-west-1#ACCOUNT#123456789012"); + } + + #[test] + fn test_build_keys_account_specific_without_id() { + let (pk, sk) = build_keys( + "m6g.xlarge", + "us-east-1", + &PricingType::AccountSpecific, + None, + ); + + assert_eq!(pk, "INSTANCE#m6g.xlarge"); + assert_eq!(sk, "REGION#us-east-1#RETAIL"); // Falls back to retail + } + + #[test] + fn test_parse_pricing_item_success() { + let mut item = HashMap::new(); + item.insert( + "instanceType".to_string(), + AttributeValue::S("m6g.xlarge".to_string()), + ); + item.insert( + "onDemandMonthly".to_string(), + AttributeValue::N("112.42".to_string()), + ); + item.insert("vcpus".to_string(), AttributeValue::N("4".to_string())); + item.insert( + "memoryGb".to_string(), + AttributeValue::N("16.0".to_string()), + ); + + let result = parse_pricing_item(&item); + + assert!(result.is_ok()); + let pricing = result.unwrap(); + assert_eq!(pricing.instance_type, "m6g.xlarge"); + assert_eq!(pricing.ec2_pricing.vcpus, 4); + assert_eq!(pricing.ec2_pricing.memory_gb, 16.0); + assert_eq!(pricing.ec2_pricing.on_demand.monthly, 112.42); + assert!((pricing.ec2_pricing.on_demand.hourly - 0.154).abs() < 0.001); + } + + #[test] + fn test_parse_pricing_item_missing_field() { + let mut item = HashMap::new(); + item.insert( + "instanceType".to_string(), + AttributeValue::S("m6g.xlarge".to_string()), + ); + // Missing required fields + + let result = parse_pricing_item(&item); + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Missing")); + } + + #[test] + fn test_parse_common_instance_success() { + let mut item = HashMap::new(); + item.insert( + "instanceType".to_string(), + AttributeValue::S("m6g.xlarge".to_string()), + ); + item.insert( + "region".to_string(), + AttributeValue::S("us-east-1".to_string()), + ); + item.insert( + "accessCount".to_string(), + AttributeValue::N("100".to_string()), + ); + item.insert( + "lastAccessed".to_string(), + AttributeValue::S("2025-11-27T10:00:00Z".to_string()), + ); + item.insert( + "lastUpdated".to_string(), + AttributeValue::S("2025-11-27T00:00:00Z".to_string()), + ); + + let result = parse_common_instance(&item); + + assert!(result.is_ok()); + let instance = result.unwrap(); + assert_eq!(instance.instance_type, "m6g.xlarge"); + assert_eq!(instance.region, "us-east-1"); + assert_eq!(instance.access_count, 100); + assert_eq!( + instance.last_accessed, + Some("2025-11-27T10:00:00Z".to_string()) + ); + } + + #[test] + fn test_parse_common_instance_missing_field() { + let mut item = HashMap::new(); + item.insert( + "instanceType".to_string(), + AttributeValue::S("m6g.xlarge".to_string()), + ); + // Missing required fields + + let result = parse_common_instance(&item); + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Missing")); + } + + #[test] + fn test_is_expired_retail_not_expired() { + let pricing = create_test_pricing( + PricingType::Retail, + &Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(), + ); + + assert!(!is_expired(&pricing)); + } + + #[test] + fn test_is_expired_retail_expired() { + let old_date = (Utc::now() - chrono::Duration::days(31)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string(); + let pricing = create_test_pricing(PricingType::Retail, &old_date); + + assert!(is_expired(&pricing)); + } + + #[test] + fn test_is_expired_account_specific_not_expired() { + let pricing = create_test_pricing( + PricingType::AccountSpecific, + &Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(), + ); + + assert!(!is_expired(&pricing)); + } + + #[test] + fn test_is_expired_account_specific_expired() { + let old_date = (Utc::now() - chrono::Duration::days(8)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string(); + let pricing = create_test_pricing(PricingType::AccountSpecific, &old_date); + + assert!(is_expired(&pricing)); + } + + #[test] + fn test_is_expired_invalid_timestamp() { + let pricing = create_test_pricing(PricingType::Retail, "invalid-timestamp"); + + // Should be considered expired if timestamp parsing fails + assert!(is_expired(&pricing)); + } + + // Helper function to create test pricing data + fn create_test_pricing(pricing_type: PricingType, last_updated: &str) -> PricingData { + PricingData { + instance_type: "m6g.xlarge".to_string(), + region: "us-east-1".to_string(), + pricing_type, + aws_account_id: None, + ec2_pricing: Ec2Pricing { + instance_family: "m6g".to_string(), + vcpus: 4, + memory_gb: 16.0, + architectures: vec!["arm64".to_string()], + on_demand: OnDemandPricing { + hourly: 0.154, + monthly: 112.42, + }, + reserved: None, + spot: None, + }, + source: "test".to_string(), + last_updated: last_updated.to_string(), + access_count: 0, + last_accessed: None, + first_cached: None, + } + } +} diff --git a/src/main.rs b/src/main.rs index b94c0e1..0f77551 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,23 @@ +//! AWS Pricing CRUD Lambda +//! +//! This Lambda function provides centralized CRUD operations for AWS pricing data, +//! serving as the single source of truth for pricing operations across the Pathfinder +//! application. It handles both HTTP API Gateway requests and MCP (Model Context Protocol) +//! requests for pricing data stored in DynamoDB. +//! +//! # Operations +//! - Get: Retrieve pricing from cache +//! - Put: Store pricing data in cache +//! - ListCommon: Query most accessed instances for cache refresh +//! - IncrementAccess: Track instance access patterns +//! +//! # Data Flow +//! ```text +//! tool-pricing-query (MCP) } +//! enrichment-server-pricing } → crud-pricing → DynamoDB +//! tool-pricing-refresh } +//! ``` + mod db; mod models; @@ -10,6 +30,12 @@ use tracing::{error, info}; use crate::models::{PricingOperation, PricingRequest, PricingResponse, PricingType}; +/// Main entry point for the Lambda function +/// +/// Initializes logging and starts the Lambda runtime with the function handler. +/// +/// # Returns +/// Returns Ok(()) on successful initialization, or Error if startup fails #[tokio::main] async fn main() -> Result<(), Error> { tracing_subscriber::fmt() @@ -22,12 +48,145 @@ async fn main() -> Result<(), Error> { lambda_runtime::run(service_fn(function_handler)).await } +/// Lambda function handler +/// +/// Routes incoming requests to either HTTP or MCP handlers based on request structure. +/// Determines request type by checking for API Gateway HTTP-specific fields. +/// +/// # Arguments +/// * `event` - Lambda event containing the request payload and context +/// +/// # Returns +/// Returns the serialized response as JSON Value +/// +/// # Errors +/// Returns Error if request parsing or processing fails async fn function_handler(event: LambdaEvent) -> Result { let (payload, _context) = event.into_parts(); info!("Received pricing request: {:?}", payload); - // Parse request + // Load AWS config and create clients + let config = aws_config::load_defaults(BehaviorVersion::latest()).await; + let dynamodb_client = DynamoDbClient::new(&config); + let table_name = env::var("TABLE_NAME").unwrap_or_else(|_| "pathfinder-dev-pricing".to_string()); + + // Determine request source by checking for HTTP-specific fields + let is_http_v2 = payload + .get("requestContext") + .and_then(|rc| rc.get("http")) + .and_then(|http| http.get("method")) + .is_some(); + + if is_http_v2 { + // Handle HTTP request from API Gateway + handle_http_request(payload, &dynamodb_client, &table_name).await + } else { + // Handle MCP request + handle_mcp_request(payload, &dynamodb_client, &table_name).await + } +} + +/// Handles HTTP requests from API Gateway +/// +/// Processes HTTP GET requests for pricing data, extracting the instance type +/// from path parameters and returning formatted HTTP responses. +/// +/// # Arguments +/// * `payload` - API Gateway HTTP request payload +/// * `dynamodb_client` - DynamoDB client for data access +/// * `table_name` - Name of the DynamoDB pricing table +/// +/// # Returns +/// Returns HTTP response with status code, headers, and body +/// +/// # Errors +/// Returns Error if instanceType path parameter is missing or DynamoDB query fails +async fn handle_http_request( + payload: Value, + dynamodb_client: &DynamoDbClient, + table_name: &str, +) -> Result { + // Extract path parameters + let instance_type = payload + .get("pathParameters") + .and_then(|p| p.get("instanceType")) + .and_then(|it| it.as_str()) + .ok_or("Missing instanceType in path")?; + + info!("HTTP GET /pricing/{}", instance_type); + + // Fetch pricing for the instance type (use defaults for HTTP requests) + let result = db::get_pricing( + dynamodb_client, + table_name, + instance_type, + "us-east-1", + &PricingType::Retail, + None + ).await; + + match result { + Ok(Some(pricing)) => { + let response = serde_json::json!({ + "statusCode": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": serde_json::to_string(&pricing)? + }); + Ok(response) + } + Ok(None) => { + info!("Pricing not found for: {}", instance_type); + let response = serde_json::json!({ + "statusCode": 404, + "headers": { + "Content-Type": "application/json" + }, + "body": serde_json::to_string(&serde_json::json!({ + "error": "Pricing not found" + }))? + }); + Ok(response) + } + Err(e) => { + error!("Failed to fetch pricing: {}", e); + let response = serde_json::json!({ + "statusCode": 500, + "headers": { + "Content-Type": "application/json" + }, + "body": serde_json::to_string(&serde_json::json!({ + "error": e + }))? + }); + Ok(response) + } + } +} + +/// Handles MCP (Model Context Protocol) requests +/// +/// Processes structured MCP requests containing pricing operations, +/// routes to appropriate handler, and returns structured responses. +/// +/// # Arguments +/// * `payload` - MCP request payload containing operation details +/// * `dynamodb_client` - DynamoDB client for data access +/// * `table_name` - Name of the DynamoDB pricing table +/// +/// # Returns +/// Returns structured PricingResponse serialized as JSON Value +/// +/// # Errors +/// Returns Error if request parsing fails or operation execution fails +async fn handle_mcp_request( + payload: Value, + dynamodb_client: &DynamoDbClient, + table_name: &str, +) -> Result { + // Parse MCP request let request: PricingRequest = match serde_json::from_value(payload) { Ok(req) => req, Err(e) => { @@ -37,17 +196,11 @@ async fn function_handler(event: LambdaEvent) -> Result { } }; - // Load AWS config and create clients - let config = aws_config::load_defaults(BehaviorVersion::latest()).await; - let dynamodb_client = DynamoDbClient::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, - &table_name, + dynamodb_client, + table_name, ) .await; @@ -62,6 +215,21 @@ async fn function_handler(event: LambdaEvent) -> Result { Ok(serde_json::to_value(response)?) } +/// Routes pricing operations to appropriate handlers +/// +/// Dispatches each operation type (Get, Put, ListCommon, IncrementAccess) to its +/// specific handler function and returns the result as JSON. +/// +/// # Arguments +/// * `operation` - The pricing operation to execute +/// * `dynamodb_client` - DynamoDB client for data access +/// * `table_name` - Name of the DynamoDB pricing table +/// +/// # Returns +/// Returns operation result serialized as JSON Value +/// +/// # Errors +/// Returns error message string if operation fails async fn handle_operation( operation: PricingOperation, dynamodb_client: &DynamoDbClient, diff --git a/src/models.rs b/src/models.rs index 233e90b..162d155 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,174 +1,303 @@ +//! Data models for AWS pricing operations +//! +//! This module defines all request and response types for the crud-pricing Lambda, +//! including pricing data structures, operation types, and serialization formats. + use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Request payload for the Lambda function +/// +/// Contains the pricing operation to execute. The operation is deserialized +/// from the incoming Lambda event payload. +/// +/// # Examples +/// ```json +/// { +/// "operation": { +/// "type": "get", +/// "instanceType": "m6g.xlarge", +/// "region": "us-east-1", +/// "pricingType": "retail" +/// } +/// } +/// ``` #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PricingRequest { + /// The pricing operation to execute pub operation: PricingOperation, } -/// Operations supported by crud-pricing +/// Operations supported by crud-pricing Lambda +/// +/// This enum represents all CRUD operations available for pricing data, +/// including retrieval, storage, listing, and access tracking. #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum PricingOperation { - /// Get pricing from cache (optionally fetch from AWS if missing) + /// Retrieve pricing from cache (optionally fetch from AWS if missing) + /// + /// Queries DynamoDB for cached pricing data. If `fetch_if_missing` is true + /// and data is not found, will attempt to fetch from AWS Pricing API. Get { + /// EC2 instance type (e.g., "m6g.xlarge") instance_type: String, + /// AWS region (e.g., "us-east-1") region: String, + /// Type of pricing (retail or account-specific) pricing_type: PricingType, + /// Optional AWS account ID for account-specific pricing #[serde(default)] aws_account_id: Option, + /// Whether to fetch from AWS API if not in cache #[serde(default)] + #[allow(dead_code)] // Used in future AWS API integration fetch_if_missing: bool, }, - /// Put pricing data into cache + /// Store pricing data in cache + /// + /// Writes pricing data to DynamoDB with appropriate TTL and metadata. Put { + /// EC2 instance type (e.g., "m6g.xlarge") instance_type: String, + /// AWS region (e.g., "us-east-1") region: String, + /// Type of pricing (retail or account-specific) pricing_type: PricingType, - pricing_data: PricingData, + /// Complete pricing data to store + pricing_data: Box, }, /// List most commonly accessed instances + /// + /// Queries DynamoDB GSI (AccessCountIndex) to retrieve instances + /// ordered by access count, used for intelligent cache refresh. ListCommon { + /// Maximum number of instances to return (default: 50) #[serde(default = "default_limit")] limit: usize, + /// Optional minimum access count filter #[serde(default)] min_access_count: Option, }, /// Increment access count for an instance + /// + /// Atomically increments the access counter in DynamoDB for tracking + /// popular instances that should be refreshed more frequently. IncrementAccess { + /// EC2 instance type (e.g., "m6g.xlarge") instance_type: String, + /// AWS region (e.g., "us-east-1") region: String, }, } +/// Default limit for ListCommon operation +/// +/// # Returns +/// Returns 50 as the default limit for listing common instances fn default_limit() -> usize { 50 } /// Type of pricing data +/// +/// Distinguishes between public AWS retail prices and customer-specific +/// prices that include EDP/PPA discounts. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum PricingType { - /// Public AWS list prices + /// Public AWS list prices from Pricing API + /// + /// Standard public prices without any customer-specific discounts. + /// Cached for 30 days. Retail, - /// Account-specific prices (includes EDP/PPA automatically) + /// Account-specific prices including EDP/PPA discounts + /// + /// Actual costs from AWS Cost Explorer for a specific customer account. + /// Automatically includes Enterprise Discount Program (EDP) and + /// Private Pricing Agreement (PPA) discounts. Cached for 7 days. AccountSpecific, } -/// Complete pricing data for an instance +/// Complete pricing data for an EC2 instance +/// +/// Contains all pricing information for an instance type in a specific region, +/// including on-demand, reserved, and spot pricing when available. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PricingData { + /// EC2 instance type (e.g., "m6g.xlarge", "t3.medium") pub instance_type: String, + /// AWS region (e.g., "us-east-1", "eu-west-1") pub region: String, + /// Type of pricing (retail or account-specific) pub pricing_type: PricingType, + /// Optional AWS account ID for account-specific pricing #[serde(skip_serializing_if = "Option::is_none")] pub aws_account_id: Option, - /// EC2 instance pricing + /// Complete EC2 instance pricing details pub ec2_pricing: Ec2Pricing, - /// Metadata - pub source: String, // "aws-pricing-api" or "cost-explorer" + /// Data source identifier ("aws-pricing-api", "cost-explorer", "dynamodb-cache") + pub source: String, + /// ISO 8601 timestamp of when pricing was last updated pub last_updated: String, - /// Cache/access tracking + /// Number of times this pricing data has been accessed (for cache refresh prioritization) #[serde(default)] pub access_count: u32, + /// ISO 8601 timestamp of most recent access #[serde(skip_serializing_if = "Option::is_none")] pub last_accessed: Option, + /// ISO 8601 timestamp of when first cached #[serde(skip_serializing_if = "Option::is_none")] pub first_cached: Option, } /// EC2 instance pricing details +/// +/// Contains all pricing options for an instance including specifications +/// and costs for different purchasing models (on-demand, reserved, spot). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Ec2Pricing { - pub instance_family: String, // "m6g", "t3", etc. + /// Instance family (e.g., "m6g", "t3", "r5") + pub instance_family: String, + /// Number of virtual CPUs pub vcpus: i32, + /// Memory in gigabytes pub memory_gb: f64, - pub architectures: Vec, // ["x86_64"], ["arm64"], etc. + /// Supported CPU architectures (e.g., ["x86_64"], ["arm64"]) + pub architectures: Vec, - /// OnDemand pricing + /// On-demand pricing details pub on_demand: OnDemandPricing, - /// Reserved pricing (if available) + /// Reserved instance pricing (if available from pricing API) #[serde(skip_serializing_if = "Option::is_none")] pub reserved: Option, - /// Spot pricing (if available) + /// Spot instance pricing (if available from spot price API) #[serde(skip_serializing_if = "Option::is_none")] pub spot: Option, } +/// On-demand pricing for EC2 instances +/// +/// Standard pay-per-use pricing with no upfront commitment. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OnDemandPricing { + /// Hourly rate in USD pub hourly: f64, - pub monthly: f64, // hourly * 730 + /// Monthly rate in USD (hourly * 730) + pub monthly: f64, } +/// Reserved instance pricing details +/// +/// Contains pricing for different commitment terms and payment options. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReservedPricing { - pub standard: HashMap, // "1yr", "3yr" + /// Standard reserved instance terms mapped by duration ("1yr", "3yr") + pub standard: HashMap, + /// Convertible reserved instance terms mapped by duration ("1yr", "3yr") pub convertible: HashMap, } +/// Reserved instance pricing for a specific term and class +/// +/// Contains payment options for a reserved instance commitment. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReservedTerm { + /// All upfront payment option pub all_upfront: ReservedOption, + /// Partial upfront payment option pub partial_upfront: ReservedOption, + /// No upfront payment option pub no_upfront: ReservedOption, } +/// Pricing details for a specific reserved instance payment option +/// +/// Contains effective rates and payment breakdown. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReservedOption { + /// Effective hourly rate in USD (total cost / hours in term) pub effective_hourly: f64, + /// Effective monthly rate in USD (effective_hourly * 730) pub effective_monthly: f64, + /// Total upfront payment in USD pub total_upfront: f64, + /// Recurring monthly payment in USD pub monthly_payment: f64, } +/// Spot instance pricing details +/// +/// Contains current and historical spot pricing with interruption data. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SpotPricing { + /// Current spot price hourly rate in USD pub current_hourly: f64, - pub avg_hourly: f64, // 30-day average + /// 30-day average spot price hourly rate in USD + pub avg_hourly: f64, + /// Maximum spot price observed in 30 days in USD pub max_hourly: f64, - pub interruption_frequency: String, // "<5%", "5-10%", etc. + /// Interruption frequency range (e.g., "<5%", "5-10%", "10-15%", "15-20%", ">20%") + pub interruption_frequency: String, + /// Percentage savings compared to on-demand pricing pub savings_vs_on_demand_percent: i32, } -/// Common instance returned by ListCommon operation +/// Summary of a commonly accessed instance +/// +/// Used by ListCommon operation to return high-traffic instances for cache refresh. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct CommonInstance { + /// EC2 instance type (e.g., "m6g.xlarge") pub instance_type: String, + /// AWS region (e.g., "us-east-1") pub region: String, + /// Number of times accessed pub access_count: u32, + /// ISO 8601 timestamp of most recent access pub last_accessed: Option, + /// ISO 8601 timestamp of when pricing was last updated pub last_updated: String, } -/// Response envelope +/// Response envelope for Lambda operations +/// +/// Wraps operation results in a consistent response format with HTTP status code. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PricingResponse { + /// HTTP status code (200 for success, 400/500 for errors) pub status_code: u16, + /// Response body as JSON value pub body: serde_json::Value, } impl PricingResponse { + /// Creates a successful response + /// + /// # Arguments + /// * `data` - The data to serialize into the response body + /// + /// # Returns + /// A PricingResponse with status 200 and serialized data pub fn success(data: impl Serialize) -> Self { Self { status_code: 200, @@ -176,6 +305,14 @@ impl PricingResponse { } } + /// Creates an error response + /// + /// # Arguments + /// * `status_code` - HTTP status code (typically 400, 404, or 500) + /// * `message` - Error message to include in response + /// + /// # Returns + /// A PricingResponse with the given status code and error message pub fn error(status_code: u16, message: &str) -> Self { Self { status_code, @@ -183,3 +320,213 @@ impl PricingResponse { } } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_pricing_type_serialization_retail() { + let pricing_type = PricingType::Retail; + let serialized = serde_json::to_string(&pricing_type).unwrap(); + assert_eq!(serialized, "\"retail\""); + } + + #[test] + fn test_pricing_type_serialization_account_specific() { + let pricing_type = PricingType::AccountSpecific; + let serialized = serde_json::to_string(&pricing_type).unwrap(); + assert_eq!(serialized, "\"accountspecific\""); + } + + #[test] + fn test_pricing_type_deserialization_retail() { + let json = "\"retail\""; + let pricing_type: PricingType = serde_json::from_str(json).unwrap(); + assert_eq!(pricing_type, PricingType::Retail); + } + + #[test] + fn test_pricing_type_deserialization_account_specific() { + let json = "\"accountspecific\""; + let pricing_type: PricingType = serde_json::from_str(json).unwrap(); + assert_eq!(pricing_type, PricingType::AccountSpecific); + } + + #[test] + fn test_default_limit() { + assert_eq!(default_limit(), 50); + } + + #[test] + fn test_pricing_response_success() { + let data = json!({"key": "value"}); + let response = PricingResponse::success(data.clone()); + + assert_eq!(response.status_code, 200); + assert_eq!(response.body, data); + } + + #[test] + fn test_pricing_response_error() { + let response = PricingResponse::error(404, "Not found"); + + assert_eq!(response.status_code, 404); + assert_eq!(response.body, json!({"error": "Not found"})); + } + + #[test] + fn test_pricing_request_deserialization_get() { + let json_str = r#"{ + "operation": { + "type": "get", + "instanceType": "m6g.xlarge", + "region": "us-east-1", + "pricingType": "retail", + "fetchIfMissing": true + } + }"#; + + let request: PricingRequest = serde_json::from_str(json_str).unwrap(); + + match request.operation { + PricingOperation::Get { + instance_type, + region, + pricing_type, + fetch_if_missing, + .. + } => { + assert_eq!(instance_type, "m6g.xlarge"); + assert_eq!(region, "us-east-1"); + assert_eq!(pricing_type, PricingType::Retail); + assert!(fetch_if_missing); + } + _ => panic!("Expected Get operation"), + } + } + + #[test] + fn test_pricing_request_deserialization_list_common() { + let json_str = r#"{ + "operation": { + "type": "listCommon", + "limit": 100, + "minAccessCount": 5 + } + }"#; + + let request: PricingRequest = serde_json::from_str(json_str).unwrap(); + + match request.operation { + PricingOperation::ListCommon { + limit, + min_access_count, + } => { + assert_eq!(limit, 100); + assert_eq!(min_access_count, Some(5)); + } + _ => panic!("Expected ListCommon operation"), + } + } + + #[test] + fn test_pricing_request_deserialization_list_common_defaults() { + let json_str = r#"{ + "operation": { + "type": "listCommon" + } + }"#; + + let request: PricingRequest = serde_json::from_str(json_str).unwrap(); + + match request.operation { + PricingOperation::ListCommon { + limit, + min_access_count, + } => { + assert_eq!(limit, 50); // default limit + assert_eq!(min_access_count, None); + } + _ => panic!("Expected ListCommon operation"), + } + } + + #[test] + fn test_pricing_request_deserialization_increment_access() { + let json_str = r#"{ + "operation": { + "type": "incrementAccess", + "instanceType": "t3.medium", + "region": "eu-west-1" + } + }"#; + + let request: PricingRequest = serde_json::from_str(json_str).unwrap(); + + match request.operation { + PricingOperation::IncrementAccess { + instance_type, + region, + } => { + assert_eq!(instance_type, "t3.medium"); + assert_eq!(region, "eu-west-1"); + } + _ => panic!("Expected IncrementAccess operation"), + } + } + + #[test] + fn test_pricing_data_serialization() { + let pricing_data = PricingData { + instance_type: "m6g.xlarge".to_string(), + region: "us-east-1".to_string(), + pricing_type: PricingType::Retail, + aws_account_id: None, + ec2_pricing: Ec2Pricing { + instance_family: "m6g".to_string(), + vcpus: 4, + memory_gb: 16.0, + architectures: vec!["arm64".to_string()], + on_demand: OnDemandPricing { + hourly: 0.154, + monthly: 112.42, + }, + reserved: None, + spot: None, + }, + source: "test".to_string(), + last_updated: "2025-11-27T00:00:00Z".to_string(), + access_count: 0, + last_accessed: None, + first_cached: None, + }; + + let serialized = serde_json::to_value(&pricing_data).unwrap(); + + assert_eq!(serialized["instanceType"], "m6g.xlarge"); + assert_eq!(serialized["region"], "us-east-1"); + assert_eq!(serialized["pricingType"], "retail"); + assert_eq!(serialized["ec2Pricing"]["vcpus"], 4); + assert_eq!(serialized["ec2Pricing"]["memoryGb"], 16.0); + } + + #[test] + fn test_common_instance_serialization() { + let instance = CommonInstance { + instance_type: "m6g.xlarge".to_string(), + region: "us-east-1".to_string(), + access_count: 100, + last_accessed: Some("2025-11-27T10:00:00Z".to_string()), + last_updated: "2025-11-27T00:00:00Z".to_string(), + }; + + let serialized = serde_json::to_value(&instance).unwrap(); + + assert_eq!(serialized["instanceType"], "m6g.xlarge"); + assert_eq!(serialized["region"], "us-east-1"); + assert_eq!(serialized["accessCount"], 100); + assert_eq!(serialized["lastAccessed"], "2025-11-27T10:00:00Z"); + } +}