diff --git a/IaC/StagingStack.yml b/IaC/StagingStack.yml
index f50b68f43d..d6c02aa147 100644
--- a/IaC/StagingStack.yml
+++ b/IaC/StagingStack.yml
@@ -1,4 +1,3 @@
-# aws cloudformation --region us-east-2 create-stack --template-body file://IaC/StagingStack.yml --capabilities CAPABILITY_NAMED_IAM --stack-name pmm-staging --tags Key=iit-billing-tag,Value=pmm-staging --parameters ParameterKey=LambdaName,ParameterValue=email_running_instances ParameterKey=SShortName,ParameterValue=pmm-staging
---
AWSTemplateFormatVersion: 2010-09-09
Parameters:
@@ -352,6 +351,7 @@ Resources:
- ec2:DescribeSpotPriceHistory
- ec2:DescribeInstances
- ses:SendEmail
+ - pricing:GetProducts
- Effect: Allow
Resource: "arn:aws:logs:*:*:*"
Action:
@@ -380,9 +380,60 @@ Resources:
import boto3
import datetime
import collections
+ import json
+ from functools import lru_cache
+
+ # Region to location mapping for pricing API
+ REGION_LOCATION_MAP = {
+ 'us-east-1': 'US East (N. Virginia)',
+ 'us-east-2': 'US East (Ohio)',
+ 'us-west-1': 'US West (N. California)',
+ 'us-west-2': 'US West (Oregon)',
+ 'eu-west-1': 'EU (Ireland)',
+ 'eu-central-1': 'EU (Frankfurt)',
+ 'eu-west-2': 'EU (London)',
+ 'eu-west-3': 'EU (Paris)',
+ 'eu-north-1': 'EU (Stockholm)'
+ }
+
+ @lru_cache(maxsize=128)
+ def get_on_demand_price(instance_type, region='us-east-2'):
+ """Get accurate on-demand price from AWS Pricing API"""
+ try:
+ # Pricing API is only available in us-east-1
+ pricing_client = boto3.client('pricing', region_name='us-east-1')
+ location = REGION_LOCATION_MAP.get(region, 'US East (Ohio)')
+
+ response = pricing_client.get_products(
+ ServiceCode='AmazonEC2',
+ Filters=[
+ {'Type': 'TERM_MATCH', 'Field': 'instanceType', 'Value': instance_type},
+ {'Type': 'TERM_MATCH', 'Field': 'location', 'Value': location},
+ {'Type': 'TERM_MATCH', 'Field': 'tenancy', 'Value': 'Shared'},
+ {'Type': 'TERM_MATCH', 'Field': 'operatingSystem', 'Value': 'Linux'},
+ {'Type': 'TERM_MATCH', 'Field': 'preInstalledSw', 'Value': 'NA'},
+ {'Type': 'TERM_MATCH', 'Field': 'capacitystatus', 'Value': 'Used'}
+ ],
+ MaxResults=1
+ )
+
+ if response['PriceList']:
+ price_item = json.loads(response['PriceList'][0])
+ on_demand = price_item['terms']['OnDemand']
+
+ for term in on_demand.values():
+ for price_dimension in term['priceDimensions'].values():
+ price = float(price_dimension['pricePerUnit']['USD'])
+ if price > 0:
+ return price
+ except Exception as e:
+ print(f"Error: Could not get on-demand price for {instance_type} in {region}: {e}")
+ raise e # Re-raise to ensure we know if pricing fails
+
+ raise ValueError(f"No on-demand price found for {instance_type} in {region}")
def lambda_handler(event, context):
- fullReportEmail = ['alexander.tymchuk@percona.com', 'talha.rizwan@percona.com']
+ fullReportEmail = ['alexander.tymchuk@percona.com', 'talha.rizwan@percona.com', 'anderson.nogueira@percona.com']
region = 'us-east-2'
session = boto3.Session(region_name=region)
resources = session.resource('ec2')
@@ -397,21 +448,21 @@ Resources:
emails = collections.defaultdict(list)
for instance in instances:
# get instance Owner
- ownerFilter = filter(lambda x: 'owner' == x['Key'], instance.tags)
+ ownerFilter = list(filter(lambda x: 'owner' == x['Key'], instance.tags))
if len(ownerFilter) >= 1:
owner = ownerFilter[0]['Value'] + '@percona.com'
else:
owner = 'unknown'
# get instance allowed days
- allowedDaysFilter = filter(lambda x: 'stop-after-days' == x['Key'], instance.tags)
- if len(allowedDaysFilter) >= 1 and allowedDaysFilter[0]['Value'] > 0:
+ allowedDaysFilter = list(filter(lambda x: 'stop-after-days' == x['Key'], instance.tags))
+ if len(allowedDaysFilter) >= 1 and int(allowedDaysFilter[0]['Value']) > 0:
allowedDays = allowedDaysFilter[0]['Value'] + ' days'
else:
allowedDays = 'unlimited'
# get instance Name
- nameFilter = filter(lambda x: 'Name' == x['Key'], instance.tags)
+ nameFilter = list(filter(lambda x: 'Name' == x['Key'], instance.tags))
if len(nameFilter) >= 1:
name = nameFilter[0]['Value']
else:
@@ -421,25 +472,71 @@ Resources:
current_time = datetime.datetime.now(instance.launch_time.tzinfo)
uptime = current_time - instance.launch_time
- # get price
- priceHistory = ec2.describe_spot_price_history(
- InstanceTypes=[instance.instance_type],
- StartTime=instance.launch_time,
- EndTime=current_time,
- AvailabilityZone=instance.placement['AvailabilityZone']
- )
- totalCost = 0.0
- for price in priceHistory['SpotPriceHistory']:
- totalCost += float(price['SpotPrice'])
+ # get price - calculate time-weighted cost
+ is_spot = instance.instance_lifecycle == 'spot'
+
+ if is_spot:
+ # Get spot price history for Linux/UNIX only
+ priceHistory = ec2.describe_spot_price_history(
+ InstanceTypes=[instance.instance_type],
+ StartTime=instance.launch_time,
+ EndTime=current_time,
+ AvailabilityZone=instance.placement['AvailabilityZone'],
+ ProductDescriptions=['Linux/UNIX']
+ )
+
+ # Calculate time-weighted cost
+ totalCost = 0.0
+ sorted_prices = sorted(priceHistory['SpotPriceHistory'],
+ key=lambda x: x['Timestamp'])
+
+ for i, entry in enumerate(sorted_prices):
+ period_start = entry['Timestamp']
+ price = float(entry['SpotPrice'])
+
+ # Determine when this price period ended
+ if i < len(sorted_prices) - 1:
+ period_end = sorted_prices[i + 1]['Timestamp']
+ else:
+ period_end = current_time
+
+ # Only count time after instance launched
+ if period_end <= instance.launch_time:
+ continue
+ if period_start < instance.launch_time:
+ period_start = instance.launch_time
+
+ # Calculate hours at this price
+ hours = (period_end - period_start).total_seconds() / 3600
+ totalCost += hours * price
+ else:
+ # On-demand pricing using AWS Pricing API
+ hours = uptime.total_seconds() / 3600
+ try:
+ on_demand_price = get_on_demand_price(instance.instance_type, region)
+ totalCost = hours * on_demand_price
+ except Exception as e:
+ print(f"Failed to get on-demand price for {instance.instance_type}: {e}")
+ # Skip this instance if we can't get pricing
+ continue
+
costStr = '%0.2f USD' % totalCost
# prepare table for email
if uptime.total_seconds() > 5*60*60:
strUptime = re.match('^[^:]+:[^:]+', str(uptime)).group(0)
- emails[owner].append('
| ' + name + ' | ' + owner + ' | ' + strUptime + ' | ' + allowedDays + ' | ' + costStr + ' |
')
+ lifecycle = 'Spot' if is_spot else 'On-Demand'
+ instance_details = instance.instance_type + ' (' + lifecycle + ')'
+ region_az = region + '/' + instance.placement['AvailabilityZone']
+
+ row = ('| ' + name + ' | ' + instance_details + ' | ' +
+ region_az + ' | ' + owner + ' | ' + strUptime +
+ ' | ' + allowedDays + ' | ' + costStr + ' |
')
+
+ emails[owner].append(row)
for email in fullReportEmail:
if owner != email:
- emails[email].append('| ' + name + ' | ' + owner + ' | ' + strUptime + ' | ' + allowedDays + ' | ' + costStr + ' |
')
+ emails[email].append(row)
else:
print('Skip: ' + name)
@@ -452,8 +549,8 @@ Resources:
A friendly reminder - please don't forget to shutdown the following instances:
-
- | Name | Owner | Uptime | Expiry | Total Cost |
+
+ | Name | Type | Region/AZ | Owner | Uptime | Expiry | Total Cost |
%s