├── requirements.txt ├── README.md ├── LICENSE ├── .gitignore └── ec2res.py /requirements.txt: -------------------------------------------------------------------------------- 1 | botocore>=1.11.9 2 | boto3>=1.8.9 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ec2res 2 | Script that checks coverage of EC2 and RDS reserved instances. 3 | 4 | Run with AWS credentials in your environment. 5 | 6 | Example: 7 | 8 | ``` 9 | $ AWS_PROFILE=myprofile ./ec2res.py 10 | EC2 INSTANCES: 11 | instance1 us-east-1a --VPC-- t2.small (region) --VPC-- t2.small $118/yr 98 days left aabbccdd... 12 | EC2 UNUSED RESERVATIONS: (none) 13 | RDS INSTANCES: 14 | rds01 us-east-1c NoMulti db.t2.micro postgres NOT COVERED 15 | RDS UNUSED RESERVATIONS: (none) 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Battlehouse Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /ec2res.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Report coverage of reserved instances in Amazon EC2 and RDS. 4 | 5 | # This script checks for running instances that are not covered by reservations, 6 | # as well as any reservations that aren't being used by a running instance. 7 | # It also reports any upcoming service events that will affect EC2 instances. 8 | 9 | # This is a stand-alone script with no dependencies other than Amazon's "boto" library. 10 | 11 | # Copyright (c) 2015 Battlehouse Inc. All rights reserved. 12 | # Use of this source code is governed by an MIT-style license that can be 13 | # found in the LICENSE file. 14 | 15 | # Updated for public release by Dan Maas. 16 | 17 | import sys, time, datetime, calendar, getopt 18 | import boto.ec2, boto.rds2 19 | import boto3 20 | 21 | time_now = int(time.time()) 22 | 23 | class NoColor: 24 | """ For notcolorizing the terminal output """ 25 | @classmethod 26 | def bold(self, x): return x 27 | @classmethod 28 | def red(self, x): return x 29 | @classmethod 30 | def green(self, x): return x 31 | @classmethod 32 | def yellow(self, x): return x 33 | 34 | class ANSIColor: 35 | """ For colorizing the terminal output """ 36 | BOLD = '\033[1m' 37 | YELLOW = '\033[93m' 38 | GREEN = '\033[92m' 39 | RED = '\033[91m' 40 | ENDC = '\033[0m' 41 | @classmethod 42 | def bold(self, x): return self.BOLD+x+self.ENDC 43 | @classmethod 44 | def red(self, x): return self.RED+x+self.ENDC 45 | @classmethod 46 | def green(self, x): return self.GREEN+x+self.ENDC 47 | @classmethod 48 | def yellow(self, x): return self.YELLOW+x+self.ENDC 49 | 50 | def decode_time_string(amztime): 51 | """ Translate Amazon API time string to a UNIX timestamp """ 52 | return calendar.timegm(time.strptime(amztime.split('.')[0], '%Y-%m-%dT%H:%M:%S')) 53 | 54 | # Utility to convert a Python datetime to UNIX timestamp 55 | 56 | ZERO = datetime.timedelta(0) 57 | class DummyPythonUTC(datetime.tzinfo): 58 | def utcoffset(self, dt): return ZERO 59 | def tzname(self, dt): return "UTC" 60 | def dst(self, dt): return ZERO 61 | dummy_python_utc = DummyPythonUTC() 62 | 63 | def decode_time_datetime(dt): 64 | """ Translate Python datetime to UNIX timestamp """ 65 | ret = dt - datetime.datetime(1970, 1, 1, tzinfo=dummy_python_utc) 66 | if hasattr(ret, 'total_seconds'): 67 | return ret.total_seconds() 68 | # Python 2.6 compatibility 69 | return (ret.microseconds + (ret.seconds + ret.days * 24 * 3600) * 10**6) / 10**6 70 | 71 | # Utilities that operate on objects returned from the AWS API queries 72 | 73 | def ec2_inst_is_vpc(inst): 74 | """ Is this EC2 instance a VPC instance? """ 75 | return (inst.vpc_id is not None) 76 | 77 | def ec2_res_is_vpc(res): 78 | """ Is this EC2 reservation for a VPC instance? """ 79 | # note: As of March, 2022, AWS has made all reservations VPC reservations 80 | return True # ('VPC' in res['ProductDescription']) 81 | 82 | def ec2_res_match(res, inst): 83 | """ Return true if this EC2 reservation can cover this EC2 instance. """ 84 | if res['Scope'] == 'Availability Zone': 85 | if res['AvailabilityZone'] != inst.placement: 86 | return False 87 | elif res['Scope'] == 'Region': 88 | pass 89 | else: 90 | raise Exception('unknown scope for %r' % res) 91 | 92 | return res['InstanceType'] == inst.instance_type and \ 93 | ec2_res_is_vpc(res) == ec2_inst_is_vpc(inst) and \ 94 | res['State'] == 'active' 95 | 96 | def rds_product_engine_match(product, engine): 97 | """ Check whether an RDS reservation 'product' matches a running instance 'engine' """ 98 | return (product, engine) in (('postgresql','postgres'), 99 | ('mysql','mysql'), # note: not sure if this is correct 100 | ) 101 | 102 | def rds_res_match(res, inst, rds_offerings): 103 | """ Return true if this RDS reservation can cover this RDS instance. """ 104 | # note: RDS uses slightly different terminology for the reservation "product" vs. the instance "engine" 105 | if 'ProductDescription' in res: 106 | product = res['ProductDescription'] 107 | elif res['ReservedDBInstancesOfferingId'] in rds_offerings: 108 | product = rds_offerings[res['ReservedDBInstancesOfferingId']]['ProductDescription'] 109 | else: 110 | # no way to find the "product" type 111 | return False 112 | 113 | engine = inst['Engine'] 114 | return res['DBInstanceClass'] == inst['DBInstanceClass'] and \ 115 | rds_product_engine_match(product, engine) and \ 116 | res['MultiAZ'] == inst['MultiAZ'] 117 | 118 | # Pretty-printing utilities for objects returned from the AWS API 119 | 120 | def pretty_print_ec2_res_price(res): 121 | """ Pretty-print price of an EC2 reservation """ 122 | yearly = float(res['FixedPrice']) * (365*86400)/float(res['Duration']) 123 | for charge in res['RecurringCharges']: 124 | assert charge['Frequency'] == 'Hourly' 125 | yearly += float(charge['Amount']) * (365*24) 126 | yearly += float(res['UsagePrice']) * (365*24) # ??? 127 | return '$%.0f/yr' % yearly 128 | 129 | def pretty_print_ec2_res_where(res): 130 | """ Pretty-print zonal placement of an EC2 reservation """ 131 | if res['Scope'] == 'Region': 132 | return '(region)' 133 | elif res['Scope'] == 'Availability Zone': 134 | return res['AvailabilityZone'] 135 | else: 136 | raise Exception('unknown where %r' % res) 137 | 138 | def pretty_print_ec2_res(res, override_count = None, my_index = None): 139 | """ Pretty-print an entire EC2 reservation """ 140 | assert res['State'] == 'active' 141 | lifetime = decode_time_datetime(res['Start']) + res['Duration'] - time_now 142 | days = lifetime//86400 143 | is_vpc = '--VPC--' if ec2_res_is_vpc(res) else 'Classic' 144 | if my_index is not None and res['InstanceCount'] > 1: 145 | count = ' (%d of %d)' % (my_index+1, res['InstanceCount']) 146 | else: 147 | instance_count = override_count if override_count is not None else res['InstanceCount'] 148 | count = ' (x%d)' % instance_count if (instance_count!=1 or override_count is not None) else '' 149 | return '%-10s %-7s %-22s %10s %3d days left' % (pretty_print_ec2_res_where(res), is_vpc, res['InstanceType']+count, pretty_print_ec2_res_price(res), days) 150 | 151 | def pretty_print_ec2_res_id(res): 152 | """ Pretty-print the EC2 reservation ID """ 153 | return res['ReservedInstancesId'].split('-')[0]+'...' 154 | 155 | def pretty_print_ec2_instance(inst): 156 | """ Pretty-print a running EC2 instance """ 157 | is_vpc = '--VPC--' if ec2_inst_is_vpc(inst) else 'Classic' 158 | return '%-24s %-10s %-7s %-11s' % (inst.tags['Name'], inst.placement, is_vpc, inst.instance_type) 159 | 160 | def pretty_print_rds_offering_price(offer): 161 | """ Pretty-print the price of an RDS reserved offering """ 162 | yearly = float(offer['FixedPrice']) * (365*86400)/float(offer['Duration']) 163 | for charge in offer['RecurringCharges']: 164 | assert charge['RecurringChargeFrequency'] == 'Hourly' 165 | yearly += float(charge['RecurringChargeAmount']) * (365*24) 166 | yearly += float(offer['UsagePrice']) * (365*24) # ??? 167 | return '$%.0f/yr' % yearly 168 | 169 | def pretty_print_multiaz(flag): 170 | return 'MultiAZ' if flag else 'NoMulti' 171 | 172 | def pretty_print_rds_res(res, rds_offerings, override_count = None, my_index = None): 173 | """ Pretty-print an RDS reservation """ 174 | lifetime = res['StartTime'] + res['Duration'] - time_now 175 | days = lifetime//86400 176 | if my_index is not None and res['DBInstanceCount'] > 1: 177 | count = ' (%d of %d)' % (my_index+1, res['DBInstanceCount']) 178 | else: 179 | instance_count = override_count if override_count is not None else res['DBInstanceCount'] 180 | count = ' (x%d)' % instance_count if (instance_count!=1 or override_count is not None) else '' 181 | #offer = rds_offerings.get(res['ReservedDBInstancesOfferingId']) 182 | return '%s %-22s %-12s %10s %3d days left' % (pretty_print_multiaz(res['MultiAZ']), res['DBInstanceClass']+count, 183 | res['ProductDescription'], # offer['ProductDescription'] if offer else 'UNKNOWN'), 184 | pretty_print_rds_offering_price(res), # pretty_print_rds_offering_price(offer) if offer else '?', 185 | days) 186 | 187 | def pretty_print_rds_instance(inst): 188 | """ Pretty-print a running RDS instance """ 189 | return '%-16s %-10s %s %-13s %-8s' % (inst['DBInstanceIdentifier'], inst['AvailabilityZone'], pretty_print_multiaz(inst['MultiAZ']), inst['DBInstanceClass'], inst['Engine']) 190 | 191 | def get_rds_res_offerings(rds): 192 | """ Query RDS API for the reserved offerings """ 193 | ret = {} 194 | marker = None 195 | while True: 196 | r = rds.describe_reserved_db_instances_offerings(marker=marker)['DescribeReservedDBInstancesOfferingsResponse']['DescribeReservedDBInstancesOfferingsResult'] 197 | rlist = r['ReservedDBInstancesOfferings'] 198 | marker = r['Marker'] 199 | for x in rlist: ret[x['ReservedDBInstancesOfferingId']] = x 200 | if not rlist or not marker: 201 | break 202 | return ret 203 | 204 | if __name__ == '__main__': 205 | opts, args = getopt.gnu_getopt(sys.argv[1:], 'v', ['region=','color=']) 206 | verbose = False 207 | region = 'us-east-1' 208 | use_color = True 209 | for key, val in opts: 210 | if key == '-v': verbose = True 211 | elif key == '--region': region = val 212 | elif key == '--color' and val == 'no': use_color = False 213 | 214 | colors = ANSIColor if use_color else NoColor 215 | 216 | conn = boto.ec2.connect_to_region(region) 217 | conn3 = boto3.client('ec2', region_name = region) 218 | rds = boto.rds2.connect_to_region(region) 219 | 220 | # query EC2 instances and reservations 221 | ec2_instance_list = conn.get_only_instances() 222 | ec2_res_list = conn3.describe_reserved_instances(Filters = [{'Name':'state','Values':['active']}])['ReservedInstances'] 223 | ec2_status_list = conn.get_all_instance_status() 224 | 225 | # query RDS instances and reservations 226 | rds_instance_list = rds.describe_db_instances()['DescribeDBInstancesResponse']['DescribeDBInstancesResult']['DBInstances'] 227 | rds_res_list = rds.describe_reserved_db_instances()['DescribeReservedDBInstancesResponse']['DescribeReservedDBInstancesResult']['ReservedDBInstances'] 228 | 229 | rds_res_offerings = get_rds_res_offerings(rds) 230 | 231 | # only show running instances, and sort by name 232 | ec2_instance_list = sorted(filter(lambda x: x.state=='running' and not x.spot_instance_request_id, 233 | ec2_instance_list), key = lambda x: x.tags['Name']) 234 | rds_instance_list.sort(key = lambda x: x['DBInstanceIdentifier']) 235 | 236 | # disregard expired reservations 237 | ec2_res_list = filter(lambda x: x['State']=='active', ec2_res_list) 238 | rds_res_list = filter(lambda x: x['State']=='active', rds_res_list) 239 | 240 | # maps instance ID -> reservation that covers it 241 | ec2_res_coverage = dict((inst.id, None) for inst in ec2_instance_list) 242 | rds_res_coverage = dict((inst['DBInstanceIdentifier'], None) for inst in rds_instance_list) 243 | 244 | # maps reservation ID -> list of instances that it covers 245 | ec2_res_usage = dict((res['ReservedInstancesId'], []) for res in ec2_res_list) 246 | rds_res_usage = dict((res['ReservedDBInstanceId'], []) for res in rds_res_list) 247 | 248 | # figure out which instances are currently covered by reservations 249 | for res in ec2_res_list: 250 | for i in xrange(res['InstanceCount']): 251 | for inst in ec2_instance_list: 252 | if ec2_res_coverage[inst.id]: continue # instance already covered 253 | if ec2_res_match(res, inst): 254 | ec2_res_coverage[inst.id] = res 255 | ec2_res_usage[res['ReservedInstancesId']].append(inst) 256 | break 257 | 258 | for res in rds_res_list: 259 | for i in xrange(res['DBInstanceCount']): 260 | for inst in rds_instance_list: 261 | if rds_res_coverage[inst['DBInstanceIdentifier']]: continue # instance already covered 262 | if rds_res_match(res, inst, rds_res_offerings): 263 | rds_res_coverage[inst['DBInstanceIdentifier']] = res 264 | rds_res_usage[res['ReservedDBInstanceId']].append(inst) 265 | break 266 | 267 | # map instance ID -> upcoming service events 268 | ec2_instance_status = {} 269 | for stat in ec2_status_list: 270 | if stat.events: 271 | for event in stat.events: 272 | if '[Canceled]' in event.description or '[Completed]' in event.description: continue 273 | if stat.id not in ec2_instance_status: ec2_instance_status[stat.id] = [] 274 | msg = event.description 275 | for timestring in (event.not_before,): # event.not_after): 276 | ts = decode_time_string(timestring) 277 | days_until = (ts - time_now)//86400 278 | st = time.gmtime(ts) 279 | msg += ' in %d days (%s/%d)' % (days_until, st.tm_mon, st.tm_mday) 280 | ec2_instance_status[stat.id].append(msg) 281 | 282 | # print console output 283 | 284 | print 'EC2 INSTANCES:' 285 | for inst in ec2_instance_list: 286 | res = ec2_res_coverage[inst.id] 287 | if res: 288 | my_index = ec2_res_usage[res['ReservedInstancesId']].index(inst) 289 | print colors.green(pretty_print_ec2_instance(inst)+' '+pretty_print_ec2_res(res, my_index = my_index)), pretty_print_ec2_res_id(res), 290 | else: 291 | print colors.red(pretty_print_ec2_instance(inst)+' NOT COVERED'), 292 | if inst.id in ec2_instance_status: 293 | print colors.yellow('EVENTS! '+','.join(ec2_instance_status[inst.id])), 294 | print 295 | 296 | ec2_any_unused = False 297 | print 'EC2 UNUSED RESERVATIONS:', 298 | for res in ec2_res_list: 299 | use_count = len(ec2_res_usage[res['ReservedInstancesId']]) 300 | if use_count >= res['InstanceCount']: continue 301 | if not ec2_any_unused: 302 | print 303 | ec2_any_unused = True 304 | print colors.red(pretty_print_ec2_res(res, override_count = res['InstanceCount'] - use_count)), pretty_print_ec2_res_id(res) 305 | if not ec2_any_unused: 306 | print '(none)' 307 | 308 | print 'RDS INSTANCES:' 309 | for inst in rds_instance_list: 310 | res = rds_res_coverage[inst['DBInstanceIdentifier']] 311 | if res: 312 | my_index = rds_res_usage[res['ReservedDBInstanceId']].index(inst) 313 | print colors.green(pretty_print_rds_instance(inst)+' '+pretty_print_rds_res(res, rds_res_offerings, my_index = my_index)), 314 | else: 315 | print colors.red(pretty_print_rds_instance(inst)+' NOT COVERED'), 316 | print 317 | 318 | rds_any_unused = False 319 | print 'RDS UNUSED RESERVATIONS:', 320 | for res in rds_res_list: 321 | use_count = len(rds_res_usage[res['ReservedDBInstanceId']]) 322 | if use_count >= res['DBInstanceCount']: continue 323 | if not rds_any_unused: 324 | print 325 | rds_any_unused = True 326 | print colors.red(pretty_print_rds_res(res, rds_res_offerings, override_count = res['DBInstanceCount'] - use_count)), res['ReservedDBInstanceId'] 327 | if not rds_any_unused: 328 | print '(none)' 329 | --------------------------------------------------------------------------------