cfn-forge
Version:
CloudFormation deployment automation tool with git-based workflows
522 lines (458 loc) • 16.4 kB
JavaScript
/**
* 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;