UNPKG

cfn-forge

Version:

CloudFormation deployment automation tool with git-based workflows

522 lines (458 loc) 16.4 kB
/** * CFN-Forge Cost Command * Template-based AWS cost estimation */ const fs = require('fs'); const path = require('path'); const yaml = require('js-yaml'); const { logger } = require('../utils/logger'); /** * AWS Resource Cost Database * Simplified pricing for common resources (us-east-1) */ const AWS_PRICING = { // Compute 'AWS::EC2::Instance': { 't2.micro': { hourly: 0.0116, monthly: 8.47 }, 't2.small': { hourly: 0.023, monthly: 16.79 }, 't2.medium': { hourly: 0.0464, monthly: 33.87 }, 't3.micro': { hourly: 0.0104, monthly: 7.59 }, 't3.small': { hourly: 0.0208, monthly: 15.18 }, 't3.medium': { hourly: 0.0416, monthly: 30.37 }, }, // Networking 'AWS::EC2::NatGateway': { base: { hourly: 0.045, monthly: 32.85, dataProcessing: 0.045 }, // per GB }, // Storage 'AWS::EC2::Volume': { gp2: { monthly: 0.10 }, // per GB gp3: { monthly: 0.08 }, // per GB io1: { monthly: 0.125 }, // per GB io2: { monthly: 0.125 }, // per GB }, // Database 'AWS::RDS::DBInstance': { 'db.t3.micro': { hourly: 0.017, monthly: 12.41 }, 'db.t3.small': { hourly: 0.034, monthly: 24.82 }, 'db.t3.medium': { hourly: 0.068, monthly: 49.64 }, 'db.r5.large': { hourly: 0.24, monthly: 175.20 }, }, // Lambda 'AWS::Lambda::Function': { requests: { per1M: 0.20 }, duration: { per100ms: 0.0000166667 }, // per GB-second }, // API Gateway 'AWS::ApiGateway::RestApi': { requests: { per1M: 3.50 }, }, // S3 'AWS::S3::Bucket': { storage: { monthly: 0.023 }, // per GB Standard requests: { GET: 0.0004, PUT: 0.005 }, // per 1000 requests }, // DynamoDB 'AWS::DynamoDB::Table': { readCapacity: { monthly: 0.25 }, // per RCU writeCapacity: { monthly: 1.25 }, // per WCU storage: { monthly: 0.25 }, // per GB }, // CloudFront 'AWS::CloudFront::Distribution': { dataTransfer: { monthly: 0.085 }, // per GB (first 10TB) requests: { per10k: 0.0075 }, }, // Application Load Balancer 'AWS::ElasticLoadBalancingV2::LoadBalancer': { base: { hourly: 0.0225, monthly: 16.43 }, lcu: { hourly: 0.008, monthly: 5.84 }, // per LCU }, }; /** * Default usage assumptions for cost estimation */ const USAGE_ASSUMPTIONS = { typical: { ec2Utilization: 0.7, // 70% uptime natGatewayData: 100, // GB per month lambdaInvocations: 1000000, // 1M invocations per month lambdaMemory: 128, // MB lambdaDuration: 100, // ms average s3Storage: 10, // GB s3Requests: 10000, // per month dynamodbReads: 10, // RCU dynamodbWrites: 5, // WCU dynamodbStorage: 1, // GB apiGatewayRequests: 100000, // per month cloudfrontData: 50, // GB per month cloudfrontRequests: 100000, // per month albRequests: 1000000, // per month }, }; /** * Parse CloudFormation template */ function parseTemplate(templatePath) { try { const content = fs.readFileSync(templatePath, 'utf8'); // Try YAML first with CloudFormation tags support, then JSON try { // Define CloudFormation YAML schema with intrinsic functions const cfnSchema = yaml.DEFAULT_SCHEMA.extend([ new yaml.Type('!Ref', { kind: 'scalar', construct(data) { return { Ref: data }; }, }), new yaml.Type('!GetAtt', { kind: 'scalar', construct(data) { return { 'Fn::GetAtt': data.split('.') }; }, }), new yaml.Type('!Sub', { kind: 'scalar', construct(data) { return { 'Fn::Sub': data }; }, }), new yaml.Type('!Join', { kind: 'sequence', construct(data) { return { 'Fn::Join': data }; }, }), new yaml.Type('!Select', { kind: 'sequence', construct(data) { return { 'Fn::Select': data }; }, }), new yaml.Type('!Split', { kind: 'sequence', construct(data) { return { 'Fn::Split': data }; }, }), new yaml.Type('!Equals', { kind: 'sequence', construct(data) { return { 'Fn::Equals': data }; }, }), new yaml.Type('!And', { kind: 'sequence', construct(data) { return { 'Fn::And': data }; }, }), new yaml.Type('!Or', { kind: 'sequence', construct(data) { return { 'Fn::Or': data }; }, }), new yaml.Type('!Not', { kind: 'sequence', construct(data) { return { 'Fn::Not': data }; }, }), new yaml.Type('!If', { kind: 'sequence', construct(data) { return { 'Fn::If': data }; }, }), new yaml.Type('!Condition', { kind: 'scalar', construct(data) { return { Condition: data }; }, }), new yaml.Type('!GetAZs', { kind: 'scalar', construct(data) { return { 'Fn::GetAZs': data }; }, }), new yaml.Type('!Cidr', { kind: 'sequence', construct(data) { return { 'Fn::Cidr': data }; }, }), ]); return yaml.load(content, { schema: cfnSchema }); } catch (yamlError) { try { return JSON.parse(content); } catch (jsonError) { throw new Error(`Unable to parse as YAML or JSON: ${yamlError.message}`); } } } catch (error) { throw new Error(`Failed to parse template ${templatePath}: ${error.message}`); } } /** * Find template file in current directory */ function findTemplate(templatePath) { if (templatePath && fs.existsSync(templatePath)) { return templatePath; } // Look for common template files const commonNames = [ 'cloudformation.yaml', 'cloudformation.yml', 'template.yaml', 'template.yml', 'cloudformation.json', 'template.json', ]; for (const name of commonNames) { if (fs.existsSync(name)) { return name; } } throw new Error('No CloudFormation template found. Use --template to specify a file.'); } /** * Calculate cost for a single resource */ function calculateResourceCost(resourceType, properties, assumptions) { const pricing = AWS_PRICING[resourceType]; if (!pricing) { return { resourceType, cost: 0, note: 'Pricing not available', }; } let monthlyCost = 0; const details = []; switch (resourceType) { case 'AWS::EC2::Instance': const instanceType = properties.InstanceType || 't3.micro'; const instancePricing = pricing[instanceType] || pricing['t3.micro']; monthlyCost = instancePricing.monthly * assumptions.ec2Utilization; const utilizationPercent = assumptions.ec2Utilization * 100; details.push(`${instanceType}: $${instancePricing.monthly}/month × ${utilizationPercent}% utilization`); break; case 'AWS::EC2::NatGateway': monthlyCost = pricing.base.monthly + (assumptions.natGatewayData * pricing.base.dataProcessing); details.push(`Base: $${pricing.base.monthly}/month`); details.push(`Data processing: ${assumptions.natGatewayData}GB × $${pricing.base.dataProcessing}/GB`); break; case 'AWS::EC2::Volume': const volumeType = properties.VolumeType || 'gp3'; const volumeSize = properties.Size || 20; // GB const volumePricing = pricing[volumeType] || pricing.gp3; monthlyCost = volumeSize * volumePricing.monthly; details.push(`${volumeSize}GB ${volumeType}: $${volumePricing.monthly}/GB/month`); break; case 'AWS::RDS::DBInstance': const dbInstanceType = properties.DBInstanceClass || 'db.t3.micro'; const dbPricing = pricing[dbInstanceType] || pricing['db.t3.micro']; monthlyCost = dbPricing.monthly; details.push(`${dbInstanceType}: $${dbPricing.monthly}/month`); break; case 'AWS::Lambda::Function': const memoryMB = properties.MemorySize || assumptions.lambdaMemory; const memoryGB = memoryMB / 1024; const monthlyInvocations = assumptions.lambdaInvocations; const avgDurationMs = assumptions.lambdaDuration; const lambdaRequestCost = (monthlyInvocations / 1000000) * pricing.requests.per1M; const durationCost = (monthlyInvocations * avgDurationMs / 100) * memoryGB * pricing.duration.per100ms; monthlyCost = lambdaRequestCost + durationCost; details.push(`Requests: ${monthlyInvocations.toLocaleString()} × $${pricing.requests.per1M}/1M`); details.push(`Duration: ${avgDurationMs}ms avg × ${memoryMB}MB`); break; case 'AWS::S3::Bucket': const storageGB = assumptions.s3Storage; const requests = assumptions.s3Requests; const s3StorageCost = storageGB * pricing.storage.monthly; const s3RequestCost = (requests / 1000) * pricing.requests.GET; // Assume mostly GETs monthlyCost = s3StorageCost + s3RequestCost; details.push(`Storage: ${storageGB}GB × $${pricing.storage.monthly}/GB`); details.push(`Requests: ${requests.toLocaleString()} × $${pricing.requests.GET}/1000`); break; case 'AWS::DynamoDB::Table': const readCapacity = properties.BillingMode === 'PAY_PER_REQUEST' ? assumptions.dynamodbReads : (properties.ProvisionedThroughput?.ReadCapacityUnits || assumptions.dynamodbReads); const writeCapacity = properties.BillingMode === 'PAY_PER_REQUEST' ? assumptions.dynamodbWrites : (properties.ProvisionedThroughput?.WriteCapacityUnits || assumptions.dynamodbWrites); const readCost = readCapacity * pricing.readCapacity.monthly; const writeCost = writeCapacity * pricing.writeCapacity.monthly; const dynamoStorageCost = assumptions.dynamodbStorage * pricing.storage.monthly; monthlyCost = readCost + writeCost + dynamoStorageCost; details.push(`Read capacity: ${readCapacity} RCU × $${pricing.readCapacity.monthly}`); details.push(`Write capacity: ${writeCapacity} WCU × $${pricing.writeCapacity.monthly}`); details.push(`Storage: ${assumptions.dynamodbStorage}GB × $${pricing.storage.monthly}/GB`); break; case 'AWS::ApiGateway::RestApi': const apiRequests = assumptions.apiGatewayRequests; monthlyCost = (apiRequests / 1000000) * pricing.requests.per1M; details.push(`Requests: ${apiRequests.toLocaleString()} × $${pricing.requests.per1M}/1M`); break; case 'AWS::ElasticLoadBalancingV2::LoadBalancer': const { albRequests } = assumptions; const lcus = Math.max(1, Math.ceil(albRequests / 25 / 3600 / 24 / 30)); // Rough LCU calculation const albBaseCost = pricing.base.monthly; const lcuCost = lcus * pricing.lcu.monthly; monthlyCost = albBaseCost + lcuCost; details.push(`Base ALB: $${albBaseCost}/month`); details.push(`LCUs: ${lcus} × $${pricing.lcu.monthly}/month`); break; default: return { resourceType, cost: 0, note: 'Cost calculation not implemented for this resource type', }; } return { resourceType, cost: monthlyCost, details, note: monthlyCost === 0 ? 'Free tier or no ongoing costs' : null, }; } /** * Main cost estimation function */ async function costCommand(options) { logger.setContext('command', 'cost'); logger.setContext('options', options); try { logger.info('CFN-Forge Cost Estimation'); logger.info(''); // Find and parse template const templatePath = await logger.withErrorHandling('Template Discovery', () => findTemplate(options.template), { specifiedTemplate: options.template, }); logger.info(`Analyzing: ${path.basename(templatePath)}`); const template = await logger.withErrorHandling('Template Parsing', () => parseTemplate(templatePath), { templatePath, }); const resources = template.Resources || {}; if (Object.keys(resources).length === 0) { logger.warn('No resources found in template'); return; } // Use assumptions const assumptions = USAGE_ASSUMPTIONS.typical; logger.info(''); logger.info('Cost Breakdown:'); logger.info(''); let totalMonthlyCost = 0; const costsByCategory = {}; // Calculate costs for each resource Object.entries(resources).forEach(([resourceName, resourceDef]) => { const resourceType = resourceDef.Type; const properties = resourceDef.Properties || {}; const costInfo = calculateResourceCost(resourceType, properties, assumptions); totalMonthlyCost += costInfo.cost; // Group by category const category = getCategoryForResource(resourceType); if (!costsByCategory[category]) { costsByCategory[category] = { cost: 0, resources: [] }; } costsByCategory[category].cost += costInfo.cost; costsByCategory[category].resources.push({ name: resourceName, type: resourceType, ...costInfo, }); }); // Display cost breakdown by category Object.entries(costsByCategory).forEach(([category, info]) => { const categoryIcon = getCategoryIcon(category); console.log(` ${categoryIcon} ${category.padEnd(20)} $${info.cost.toFixed(2).padStart(8)}/month`); if (options.verbose) { info.resources.forEach((resource) => { console.log(` └─ ${resource.name} (${resource.type.split('::')[2]})`); if (resource.details) { resource.details.forEach((detail) => { console.log(` ${detail}`); }); } if (resource.note) { console.log(` Note: ${resource.note}`); } }); } }); logger.info(''); console.log(' ─'.repeat(32)); console.log(` Total Monthly Cost: $${totalMonthlyCost.toFixed(2)}`); console.log(` Yearly Projection: $${(totalMonthlyCost * 12).toFixed(2)}`); if (options.json) { const result = { template: templatePath, totalMonthlyCost, totalYearlyCost: totalMonthlyCost * 12, categories: costsByCategory, assumptions: 'typical', }; console.log(`\n${JSON.stringify(result, null, 2)}`); } logger.info(''); logger.info('Cost Optimization Tips:'); // Provide optimization suggestions if (costsByCategory.Networking?.cost > 30) { logger.info(' • Consider VPC Endpoints to reduce NAT Gateway usage'); } if (costsByCategory.Compute?.cost > 50) { logger.info(' • Use Spot Instances for non-critical workloads (-50-70%)'); logger.info(' • Consider Reserved Instances for predictable workloads (-20-40%)'); } if (costsByCategory.Storage?.cost > 20) { logger.info(' • Use S3 Intelligent Tiering for automatic cost optimization'); } logger.info(''); logger.warn('Warning: Estimates based on typical usage patterns and us-east-1 pricing'); if (!options.verbose) { logger.info('Use --verbose for detailed cost breakdown per resource'); } } catch (error) { logger.errorWithContext(error, 'Cost Command'); logger.reportErrorSummary(); process.exit(1); } finally { logger.clearContext(); } } /** * Get category for resource type */ function getCategoryForResource(resourceType) { if (resourceType.includes('::EC2::')) return 'Compute'; if (resourceType.includes('::RDS::')) return 'Database'; if (resourceType.includes('::S3::')) return 'Storage'; if (resourceType.includes('::Lambda::')) return 'Serverless'; if (resourceType.includes('::DynamoDB::')) return 'Database'; if (resourceType.includes('::ApiGateway::')) return 'API'; if (resourceType.includes('::ElasticLoadBalancing')) return 'Networking'; if (resourceType.includes('::CloudFront::')) return 'CDN'; if (['AWS::EC2::VPC', 'AWS::EC2::NatGateway', 'AWS::EC2::InternetGateway'].includes(resourceType)) return 'Networking'; return 'Other'; } /** * Get icon for category */ function getCategoryIcon(category) { const icons = { Compute: 'Compute', Database: 'Database', Storage: 'Storage', Serverless: 'Serverless', API: 'API', Networking: 'Networking', CDN: 'CDN', Other: 'Other', }; return icons[category] || 'Other'; } module.exports = costCommand;