update-ami-accounts 9.0 KB

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