#!/usr/bin/env python3 """ CLI tool to help with AMI sharing. What I was doing before in bash hit its reasonable limit of complexity, and was getting hard to read because it was in bash. Some notes: [1] Specifying an AMI filter is mandatory, but regions and accounts are not. [2] Standard AWS_PROFILE environment variables are supported (because boto3 supports) [3] AWS_REGION environment variable (as used by the AWS CLI) is not supported because boto3 does not use it. It uses AWS_DEFAULT_REGION instead. If your $HOME/.aws/config lists a region for a given profile this is not needed. Example 1: Let's just run a report of all AMIs matching '*Duane*' in all regions that the profile has access to. Notice the wildcards in quotes so bash won't try to expand them out to filenames. [duane.e.waddle@DPS0591 bin]$ AWS_PROFILE=gov-common-services-terraformer ./update-ami-accounts '*Duane*' Looking for AMIs matching "*Duane*" in the following regions: us-gov-east-1 us-gov-west-1 AMIs matching the filter: region |ami id |ami name ===============|======================|======================================== us-gov-east-1 |ami-069f3e239427365b6 |Duane_Testing_20201124233617 us-gov-west-1 |ami-0ee37a86b09aefad0 |Duane_Testing_20201124233617 Example 2: Regions can be specified with a list or wildcard. This is just a report too: [duane.e.waddle@DPS0591 bin]$ AWS_PROFILE=gov-common-services-terraformer ./update-ami-accounts --region us-gov-east-1 --region '*west*' '*Duane*' Looking for AMIs matching "*Duane*" in the following regions: us-gov-east-1 us-gov-west-1 AMIs matching the filter: region |ami id |ami name ===============|======================|======================================== us-gov-east-1 |ami-069f3e239427365b6 |Duane_Testing_20201124233617 us-gov-west-1 |ami-0ee37a86b09aefad0 |Duane_Testing_20201124233617 Example 3: If we list one or more accounts then sharing is updated [duane.e.waddle@DPS0591 bin]$ AWS_PROFILE=gov-common-services-terraformer ./update-ami-accounts --region '*1' '*Duane*' 738800754746 721817724804 Looking for AMIs matching "*Duane*" in the following regions: us-gov-east-1 us-gov-west-1 Sharing AMIs with these accounts: 738800754746 721817724804 AMIs matching the filter: region |ami id |ami name |status ===============|======================|========================================|========== us-gov-east-1 |ami-069f3e239427365b6 |Duane_Testing_20201124233617 |success us-gov-west-1 |ami-0ee37a86b09aefad0 |Duane_Testing_20201124233617 |success Example 4: Sharing updates are atomic so if you could get a failure because one of several accounts you listed does not exist: [duane.e.waddle@DPS0591 bin]$ AWS_PROFILE=gov-common-services-terraformer ./update-ami-accounts --region '*1' '*Duane*' 738800754746 72181772480 Looking for AMIs matching "*Duane*" in the following regions: us-gov-east-1 us-gov-west-1 Sharing AMIs with these accounts: 738800754746 72181772480 AMIs matching the filter: region |ami id |ami name |status ===============|======================|========================================|========== us-gov-east-1 |ami-069f3e239427365b6 |Duane_Testing_20201124233617 |error us-gov-west-1 |ami-0ee37a86b09aefad0 |Duane_Testing_20201124233617 |error Notice one of the account numbers is missing a digit. You don't get a clear message as to which one caused the error. Maybe one day I'll improve that... """ import argparse import re import sys import boto3 import botocore from botocore.config import Config def list_matching_regions(region_filter): """ Return the list of regions matching a wildcard. """ ec2 = boto3.client('ec2') regions = ec2.describe_regions( Filters=[ { 'Name': 'region-name', 'Values': [ region_filter ] } ], AllRegions = False ) return [ x.get('RegionName') for x in regions.get('Regions',[]) ] def find_amis_matching_filter(ami_filter,region=None): """ Return a list of AMIs matching a given wildcard by name """ if region is not None: ec2 = boto3.client('ec2',config=Config(region_name=region)) else: ec2 = boto3.client('ec2') images = ec2.describe_images( Owners=['self'], Filters=[ { 'Name': 'name', 'Values': [ ami_filter ] } ] ) fields_of_interest = { 'ImageId', 'Name' } for entry in images.get('Images',[]): newentry = { k: entry[k] for k in entry.keys() & fields_of_interest } yield newentry def share_ami(ami,region,accounts,remove=False): """ Share a specific AMI (by id) with a list of AWS account IDs within a specific region """ launchparam = { } launchparam['Add'] = [] if not remove: for account in accounts: launchparam['Add'].extend([{'UserId': account}]) else: for account in accounts: launchparam['Remove'].extend([{'UserId': account}]) ec2 = boto3.resource('ec2',config=Config(region_name=region)) # expecting to possibly raise something here image = ec2.Image(ami) image.modify_attribute(Attribute='launchPermission', LaunchPermission=launchparam) def runmain(ami_filter,accounts,region_filters,remove=False): """ main """ region_list = [] if region_filters is None: region_list.extend(list_matching_regions('*')) else: for filt in region_filters: region_list.extend(list_matching_regions(filt)) region_list=list(sorted(set(region_list))) print('Looking for AMIs matching "{0}" in the following regions:'.format(ami_filter)) for region in region_list: print(" {0}".format(region)) print("") # Shorter format for when we're just doing a report of matching AMIs # No accounts means just report mode if len(accounts) > 0: print("Sharing AMIs with these accounts:") for account in accounts: print(" {0}".format(account)) print("") print("AMIs matching the filter:") report_format="{0:<15}|{1:<22}|{2:<40}|{3:<10}" print(report_format.format('region','ami id','ami name','status')) print(report_format.format('='*15,'='*22,'='*40,'='*10)) else: print("AMIs matching the filter:") report_format="{0:<15}|{1:<22}|{2:<40}" print(report_format.format('region','ami id','ami name')) print(report_format.format('='*15,'='*22,'='*40)) for region in region_list: for ami in find_amis_matching_filter(ami_filter,region): if len(accounts) > 0: try: share_ami(ami.get('ImageId'),region,accounts,remove) print(report_format.format(region,ami.get('ImageId'),ami.get('Name'),"success")) except botocore.exceptions.ClientError: print(report_format.format(region,ami.get('ImageId'),ami.get('Name'),"error")) # No accounts we're just making a report ... else: print(report_format.format(region,ami.get('ImageId'),ami.get('Name'))) def cli(): parser = argparse.ArgumentParser() parser.add_argument('--region',action='append',required=False, help='Region to add sharing in (can specify multiple)') parser.add_argument('--remove',action='store_true',required=False, help='Set this flag to remove instead of add sharing.') parser.add_argument('ami_filter',help='AMI Filter to apply') parser.add_argument('accounts',nargs='*',help='list of AWS accounts to add AMIs to') args = parser.parse_args() # Remove dashes from account IDs as a nice thing for user args.accounts = [ f.replace("-","") for f in args.accounts ] # AWS Accounts are 12 digits, all digits invalid_accounts = [] digit_check = re.compile(r"^\d+$") for account in args.accounts: if len(account) != 12: invalid_accounts.append("{0}: {1}".format(account,"Account length not 12")) elif digit_check.fullmatch(account) is None: invalid_accounts.append("{0}: {1}".format(account,"Account contains non-digit chars")) if len(invalid_accounts) > 0: for message in invalid_accounts: print(message) sys.exit(1) runmain(args.ami_filter,args.accounts,args.region,args.remove) if __name__ == "__main__": cli()