update-ami-accounts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. #!/usr/bin/env python3
  2. """
  3. CLI tool to help with AMI sharing. What I was doing before in bash
  4. hit its reasonable limit of complexity, and was getting hard to read
  5. because it was in bash.
  6. Some notes:
  7. [1] Specifying an AMI filter is mandatory, but regions and accounts are not.
  8. [2] Standard AWS_PROFILE environment variables are supported (because boto3 supports)
  9. [3] AWS_REGION environment variable (as used by the AWS CLI) is not supported because
  10. boto3 does not use it. It uses AWS_DEFAULT_REGION instead. If your
  11. $HOME/.aws/config lists a region for a given profile this is not needed.
  12. Example 1: Let's just run a report of all AMIs matching '*Duane*' in all regions that the
  13. profile has access to. Notice the wildcards in quotes so bash won't try to expand them
  14. out to filenames.
  15. [duane.e.waddle@DPS0591 bin]$ AWS_PROFILE=gov-common-services-terraformer ./update-ami-accounts '*Duane*'
  16. Looking for AMIs matching "*Duane*" in the following regions:
  17. us-gov-east-1
  18. us-gov-west-1
  19. AMIs matching the filter:
  20. region |ami id |ami name
  21. ===============|======================|========================================
  22. us-gov-east-1 |ami-069f3e239427365b6 |Duane_Testing_20201124233617
  23. us-gov-west-1 |ami-0ee37a86b09aefad0 |Duane_Testing_20201124233617
  24. Example 2: Regions can be specified with a list or wildcard. This is just a report too:
  25. [duane.e.waddle@DPS0591 bin]$ AWS_PROFILE=gov-common-services-terraformer ./update-ami-accounts --region us-gov-east-1 --region '*west*' '*Duane*'
  26. Looking for AMIs matching "*Duane*" in the following regions:
  27. us-gov-east-1
  28. us-gov-west-1
  29. AMIs matching the filter:
  30. region |ami id |ami name
  31. ===============|======================|========================================
  32. us-gov-east-1 |ami-069f3e239427365b6 |Duane_Testing_20201124233617
  33. us-gov-west-1 |ami-0ee37a86b09aefad0 |Duane_Testing_20201124233617
  34. Example 3: If we list one or more accounts then sharing is updated
  35. [duane.e.waddle@DPS0591 bin]$ AWS_PROFILE=gov-common-services-terraformer ./update-ami-accounts --region '*1' '*Duane*' 738800754746 721817724804
  36. Looking for AMIs matching "*Duane*" in the following regions:
  37. us-gov-east-1
  38. us-gov-west-1
  39. Sharing AMIs with these accounts:
  40. 738800754746
  41. 721817724804
  42. AMIs matching the filter:
  43. region |ami id |ami name |status
  44. ===============|======================|========================================|==========
  45. us-gov-east-1 |ami-069f3e239427365b6 |Duane_Testing_20201124233617 |success
  46. us-gov-west-1 |ami-0ee37a86b09aefad0 |Duane_Testing_20201124233617 |success
  47. Example 4: Sharing updates are atomic so if you could get a failure because
  48. one of several accounts you listed does not exist:
  49. [duane.e.waddle@DPS0591 bin]$ AWS_PROFILE=gov-common-services-terraformer ./update-ami-accounts --region '*1' '*Duane*' 738800754746 72181772480
  50. Looking for AMIs matching "*Duane*" in the following regions:
  51. us-gov-east-1
  52. us-gov-west-1
  53. Sharing AMIs with these accounts:
  54. 738800754746
  55. 72181772480
  56. AMIs matching the filter:
  57. region |ami id |ami name |status
  58. ===============|======================|========================================|==========
  59. us-gov-east-1 |ami-069f3e239427365b6 |Duane_Testing_20201124233617 |error
  60. us-gov-west-1 |ami-0ee37a86b09aefad0 |Duane_Testing_20201124233617 |error
  61. Notice one of the account numbers is missing a digit. You don't get a clear
  62. message as to which one caused the error. Maybe one day I'll improve that...
  63. """
  64. import argparse
  65. import re
  66. import sys
  67. import boto3
  68. import botocore
  69. from botocore.config import Config
  70. def list_matching_regions(region_filter):
  71. """
  72. Return the list of regions matching a wildcard.
  73. """
  74. ec2 = boto3.client('ec2')
  75. regions = ec2.describe_regions(
  76. Filters=[
  77. {
  78. 'Name': 'region-name',
  79. 'Values': [ region_filter ]
  80. }
  81. ],
  82. AllRegions = False
  83. )
  84. return [ x.get('RegionName') for x in regions.get('Regions',[]) ]
  85. def find_amis_matching_filter(ami_filter,region=None):
  86. """
  87. Return a list of AMIs matching a given wildcard by name
  88. """
  89. if region is not None:
  90. ec2 = boto3.client('ec2',config=Config(region_name=region))
  91. else:
  92. ec2 = boto3.client('ec2')
  93. images = ec2.describe_images(
  94. Owners=['self'],
  95. Filters=[
  96. {
  97. 'Name': 'name',
  98. 'Values': [ ami_filter ]
  99. }
  100. ]
  101. )
  102. fields_of_interest = { 'ImageId', 'Name' }
  103. for entry in images.get('Images',[]):
  104. newentry = { k: entry[k] for k in entry.keys() & fields_of_interest }
  105. yield newentry
  106. def share_ami(ami,region,accounts,remove=False):
  107. """
  108. Share a specific AMI (by id) with a list of AWS account IDs
  109. within a specific region
  110. """
  111. launchparam = { }
  112. launchparam['Add'] = []
  113. if not remove:
  114. for account in accounts:
  115. launchparam['Add'].extend([{'UserId': account}])
  116. else:
  117. for account in accounts:
  118. launchparam['Remove'].extend([{'UserId': account}])
  119. ec2 = boto3.resource('ec2',config=Config(region_name=region))
  120. # expecting to possibly raise something here
  121. image = ec2.Image(ami)
  122. image.modify_attribute(Attribute='launchPermission', LaunchPermission=launchparam)
  123. def runmain(ami_filter,accounts,region_filters,remove=False):
  124. """
  125. main
  126. """
  127. region_list = []
  128. if region_filters is None:
  129. region_list.extend(list_matching_regions('*'))
  130. else:
  131. for filt in region_filters:
  132. region_list.extend(list_matching_regions(filt))
  133. region_list=list(sorted(set(region_list)))
  134. print('Looking for AMIs matching "{0}" in the following regions:'.format(ami_filter))
  135. for region in region_list:
  136. print(" {0}".format(region))
  137. print("")
  138. # Shorter format for when we're just doing a report of matching AMIs
  139. # No accounts means just report mode
  140. if len(accounts) > 0:
  141. print("Sharing AMIs with these accounts:")
  142. for account in accounts:
  143. print(" {0}".format(account))
  144. print("")
  145. print("AMIs matching the filter:")
  146. report_format="{0:<15}|{1:<22}|{2:<40}|{3:<10}"
  147. print(report_format.format('region','ami id','ami name','status'))
  148. print(report_format.format('='*15,'='*22,'='*40,'='*10))
  149. else:
  150. print("AMIs matching the filter:")
  151. report_format="{0:<15}|{1:<22}|{2:<40}"
  152. print(report_format.format('region','ami id','ami name'))
  153. print(report_format.format('='*15,'='*22,'='*40))
  154. for region in region_list:
  155. for ami in find_amis_matching_filter(ami_filter,region):
  156. if len(accounts) > 0:
  157. try:
  158. share_ami(ami.get('ImageId'),region,accounts,remove)
  159. print(report_format.format(region,ami.get('ImageId'),ami.get('Name'),"success"))
  160. except botocore.exceptions.ClientError:
  161. print(report_format.format(region,ami.get('ImageId'),ami.get('Name'),"error"))
  162. # No accounts we're just making a report ...
  163. else:
  164. print(report_format.format(region,ami.get('ImageId'),ami.get('Name')))
  165. def cli():
  166. parser = argparse.ArgumentParser()
  167. parser.add_argument('--region',action='append',required=False,
  168. help='Region to add sharing in (can specify multiple)')
  169. parser.add_argument('--remove',action='store_true',required=False,
  170. help='Set this flag to remove instead of add sharing.')
  171. parser.add_argument('ami_filter',help='AMI Filter to apply')
  172. parser.add_argument('accounts',nargs='*',help='list of AWS accounts to add AMIs to')
  173. args = parser.parse_args()
  174. # Remove dashes from account IDs as a nice thing for user
  175. args.accounts = [ f.replace("-","") for f in args.accounts ]
  176. # AWS Accounts are 12 digits, all digits
  177. invalid_accounts = []
  178. digit_check = re.compile(r"^\d+$")
  179. for account in args.accounts:
  180. if len(account) != 12:
  181. invalid_accounts.append("{0}: {1}".format(account,"Account length not 12"))
  182. elif digit_check.fullmatch(account) is None:
  183. invalid_accounts.append("{0}: {1}".format(account,"Account contains non-digit chars"))
  184. if len(invalid_accounts) > 0:
  185. for message in invalid_accounts:
  186. print(message)
  187. sys.exit(1)
  188. runmain(args.ami_filter,args.accounts,args.region,args.remove)
  189. if __name__ == "__main__":
  190. cli()