├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── bin └── distami ├── dev_requirements.txt ├── distami ├── __init__.py ├── cli.py ├── core.py ├── exceptions.py └── utils.py ├── requirements.txt ├── setup.py └── tests ├── __init__.py └── unit ├── __init__.py ├── test_exceptions.py └── test_utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | sdist 14 | develop-eggs 15 | .installed.cfg 16 | 17 | # Installer logs 18 | pip-log.txt 19 | 20 | # Unit test / coverage reports 21 | .coverage 22 | .tox 23 | nosetests.xml 24 | 25 | # Translations 26 | *.mo 27 | 28 | # Mr Developer 29 | .mr.developer.cfg 30 | .project 31 | .pydevproject 32 | .settings 33 | 34 | # Misc 35 | *~ 36 | .*~ 37 | .DS_Store 38 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '2.7' 4 | install: 5 | - pip install --use-mirrors -r requirements.txt 6 | - pip install --use-mirrors -r dev_requirements.txt 7 | - python setup.py develop 8 | script: nosetests tests/unit 9 | env: 10 | global: 11 | secure: JeL+zHsW1cm5756IUxa/4TjZBjEQK5MKT9j478OjMBsHSnN10TiRpo0V3o1fq6Sp9Z9NMb9jTm4hKP01QSrO0F/HGirS+l9bpz2MVcIJ8N5tmORDXG4WbrjJfm836lqtiXRf9MuvvtCwGe+jZTjemuX1DnufTAnf+A9es92m9nk= 12 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2013 Answers for AWS LLC 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt README.rst requirements.txt dev_requirements.txt 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | DistAMI 2 | ======= 3 | 4 | .. image:: https://travis-ci.org/Answers4AWS/distami.png?branch=master 5 | :target: https://travis-ci.org/Answers4AWS/distami 6 | :alt: Build Status 7 | 8 | .. image:: https://img.shields.io/pypi/dm/distami.svg 9 | :target: https://pypi.python.org/pypi/distami 10 | :alt: PyPI Downloads 11 | 12 | .. image:: https://img.shields.io/pypi/v/distami.svg 13 | :target: https://pypi.python.org/pypi/distami 14 | :alt: PyPI Version 15 | 16 | Distributes an AMI by copying it to one, many, or all AWS regions, and by optionally making the AMIs and Snapshots public or shared with specific AWS Accounts. 17 | 18 | Usage 19 | ----- 20 | 21 | :: 22 | 23 | usage: distami [-h] [--region REGION] [--to REGIONS] [--non-public] 24 | [--accounts AWS_ACCOUNT_IDs] [-p] [-v] [--version] 25 | AMI_ID 26 | 27 | Distributes an AMI by copying it to one, many, or all AWS regions, and by 28 | optionally making the AMIs and Snapshots public. 29 | 30 | positional arguments: 31 | AMI_ID the source AMI ID to distribute. E.g. ami-1234abcd 32 | 33 | optional arguments: 34 | -h, --help show this help message and exit 35 | --region REGION the region the AMI is in (default is current region of 36 | EC2 instance this is running on). E.g. us-east-1 37 | --to REGIONS comma-separated list of regions to copy the AMI to. 38 | The default is all regions. Specify "none" to prevent 39 | copying to other regions. E.g. us-east-1,us-west-1,us- 40 | west-2 41 | --non-public Copies the AMIs to other regions, but does not make 42 | the AMIs or snapshots public. Bad karma, but good for 43 | AMIs that need to be private/internal only 44 | --accounts AWS_ACCOUNT_IDs 45 | comma-separated list of AWS Account IDs to share an 46 | AMI with. Assumes --non-public. Specify --to=none to 47 | share without copying. 48 | -p, --parallel Perform each copy to another region in parallel. The 49 | default is in serial which can take a long time 50 | -v, --verbose enable verbose output (-vvv for more) 51 | --version display version number and exit 52 | 53 | 54 | Security 55 | -------- 56 | 57 | "As a publisher of a public AMI, you are responsible for the initial security posture of the machine images 58 | that you distribute...when an AMI is made public, it can be launched by customers who are not security 59 | experts, and who are not familiar with the history and details of the AMI." Make sure to secure your AMI 60 | before distributing it publicly: `Public AMI Publishing: Hardening and Clean-up Requirements 61 | `__ 62 | 63 | Examples 64 | -------- 65 | 66 | Copy an AMI to all regions in parallel from an EC2 instance such as 67 | Aminator: 68 | 69 | :: 70 | 71 | distami -p ami-abcd1234 72 | 73 | Copy AMI in ``us-east-1`` to ``us-west-1`` 74 | 75 | :: 76 | 77 | distami --region us-east-1 ami-abcd1234 --to us-west-1 78 | 79 | Copy an AMI in ``eu-west-1`` to ``us-west-1`` and ``us-west-2``, but do not make the AMI or its copies public 80 | 81 | :: 82 | 83 | distami --region eu-west-1 ami-abcd1234 --to us-west-1,us-west-2 --non-public 84 | 85 | Share an AMI in ``us-east-1`` with the AWS account IDs 123412341234 and 987698769876. Do not copy to other regions and do not make public. 86 | 87 | :: 88 | 89 | distami --region=us-east-1 ami-abcd1234 --to=none --accounts=123412341234,987698769876 90 | 91 | 92 | Installation 93 | ------------ 94 | 95 | You can install DistAMI using the usual PyPI channels. Example: 96 | 97 | :: 98 | 99 | sudo pip install distami 100 | 101 | You can find the package details here: https://pypi.python.org/pypi/distami 102 | 103 | Alternatively, if you prefer to install from source: 104 | 105 | :: 106 | 107 | git clone git@github.com:Answers4AWS/distami.git 108 | cd distami 109 | python setup.py install 110 | 111 | 112 | Configuration 113 | ------------- 114 | 115 | DistAMI uses Boto to make the API calls, which means you can use IAM Roles and run DistAMI from an EC2 instance, or use environment variables or a `.boto` file to pass along your AWS credentials. 116 | 117 | For more information: 118 | 119 | http://boto.readthedocs.org/en/latest/boto_config_tut.html 120 | 121 | 122 | Source Code 123 | ----------- 124 | 125 | The Python source code for DistAMI is available on GitHub: 126 | 127 | https://github.com/Answers4AWS/distami 128 | 129 | 130 | About Answers for AWS 131 | --------------------- 132 | 133 | This code was written by `Peter 134 | Sankauskas `__, founder of `Answers for 135 | AWS `__ - a company focused on 136 | helping business get the most out of AWS. If you are looking for help 137 | with AWS, please `contact us `__. 138 | 139 | 140 | LICENSE 141 | ------- 142 | 143 | Copyright 2013 Answers for AWS LLC 144 | 145 | Licensed under the Apache License, Version 2.0 (the "License"); you may 146 | not use this file except in compliance with the License. You may obtain 147 | a copy of the License at 148 | 149 | http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable 150 | law or agreed to in writing, software distributed under the License is 151 | distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 152 | KIND, either express or implied. See the License for the specific 153 | language governing permissions and limitations under the License. 154 | -------------------------------------------------------------------------------- /bin/distami: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # Copyright 2013 Answers for AWS LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | """ 17 | DistAMI 18 | ======= 19 | This script distributes an AMI by making it public 20 | """ 21 | 22 | import sys, os 23 | sys.path.insert(0, os.path.abspath('..')) 24 | 25 | from distami import cli 26 | 27 | if __name__ == '__main__': 28 | cli.run() 29 | -------------------------------------------------------------------------------- /dev_requirements.txt: -------------------------------------------------------------------------------- 1 | nose==1.3.0 2 | -------------------------------------------------------------------------------- /distami/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Answers for AWS LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | __author__ = 'Peter Sankauskas' 16 | __version__ = '1.0.7' 17 | -------------------------------------------------------------------------------- /distami/cli.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Answers for AWS LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import argparse 16 | import logging 17 | import sys 18 | 19 | from distami.core import Distami, Logging 20 | from distami import __version__, utils 21 | from distami.exceptions import DistamiException 22 | 23 | from boto.utils import get_instance_metadata 24 | 25 | from multiprocessing import Pool 26 | 27 | 28 | __all__ = ('run', ) 29 | log = logging.getLogger(__name__) 30 | 31 | 32 | def _fail(message="Unknown failure", code=1): 33 | log.error(message) 34 | sys.exit(code) 35 | 36 | 37 | def copy(param_array): 38 | ''' Copies distami to the given region ''' 39 | 40 | distami = param_array[0] 41 | to_region = param_array[1] 42 | args = param_array[2] 43 | copied_ami_id = distami.copy_to_region(to_region) 44 | ami_cp = Distami(copied_ami_id, to_region) 45 | 46 | if args.non_public: 47 | distami.make_ami_non_public() 48 | distami.make_snapshot_non_public() 49 | else: 50 | ami_cp.make_ami_public() 51 | ami_cp.make_snapshot_public() 52 | 53 | if args.accounts: 54 | ami_cp.share_ami_with_accounts(args.accounts) 55 | ami_cp.share_snapshot_with_accounts(args.accounts) 56 | 57 | 58 | def run(): 59 | parser = argparse.ArgumentParser(description='Distributes an AMI by copying it to one, many, or all AWS regions, and by optionally making the AMIs and Snapshots public or shared with specific AWS Accounts.') 60 | parser.add_argument('ami_id', metavar='AMI_ID', 61 | help='the source AMI ID to distribute. E.g. ami-1234abcd') 62 | parser.add_argument('--region', metavar='REGION', 63 | help='the region the AMI is in (default is current region of EC2 instance this is running on). E.g. us-east-1') 64 | parser.add_argument('--to', metavar='REGIONS', 65 | help='comma-separated list of regions to copy the AMI to. The default is all regions. Specify "none" to prevent copying to other regions. E.g. us-east-1,us-west-1,us-west-2') 66 | parser.add_argument('--non-public', action='store_true', default=False, 67 | help='Copies the AMIs to other regions, but does not make the AMIs or snapshots public. Bad karma, but good for AMIs that need to be private/internal only') 68 | parser.add_argument('--accounts', metavar='AWS_ACCOUNT_IDs', 69 | help='comma-separated list of AWS Account IDs to share an AMI with. Assumes --non-public. Specify --to=none to share without copying.') 70 | parser.add_argument('-p', '--parallel', action='store_true', default=False, 71 | help='Perform each copy to another region in parallel. The default is in serial which can take a long time') 72 | parser.add_argument('-v', '--verbose', action='count', 73 | help='enable verbose output (-vvv for more)') 74 | parser.add_argument('--version', action='version', version='%(prog)s ' + __version__, 75 | help='display version number and exit') 76 | args = parser.parse_args() 77 | 78 | # Argument manipulation 79 | if args.accounts: 80 | args.non_public = True 81 | 82 | Logging().configure(args.verbose) 83 | 84 | log.debug("CLI parse args: %s", args) 85 | 86 | if args.region: 87 | ami_region = args.region 88 | else: 89 | # If no region was specified, assume this is running on an EC2 instance 90 | # and work out what region it is in 91 | log.debug("Figure out which region I am running in...") 92 | instance_metadata = get_instance_metadata(timeout=5) 93 | log.debug('Instance meta-data: %s', instance_metadata) 94 | if not instance_metadata: 95 | _fail('Could not determine region. This script is either not running on an EC2 instance, or the meta-data service is down') 96 | 97 | ami_region = instance_metadata['placement']['availability-zone'][:-1] 98 | log.debug("Running in region: %s", ami_region) 99 | 100 | try: 101 | distami = Distami(args.ami_id, ami_region) 102 | if not args.non_public: 103 | distami.make_ami_public() 104 | distami.make_snapshot_public() 105 | if args.accounts: 106 | account_ids = args.accounts.split(',') 107 | distami.share_ami_with_accounts(account_ids) 108 | distami.share_snapshot_with_accounts(account_ids) 109 | 110 | if args.to and args.to == 'none': 111 | to_regions = [] 112 | elif args.to and args.to != 'all': 113 | # TODO It is probably worth sanity checking this for typos 114 | to_regions = args.to.split(',') 115 | else: 116 | to_regions = utils.get_regions_to_copy_to(ami_region) 117 | 118 | if args.parallel: 119 | # Get input to copy function 120 | param_array = zip([distami] * len(to_regions), to_regions, [args] * len(to_regions)) 121 | log.debug(param_array) 122 | 123 | # Copy to regions in parallel 124 | log.info('Copying in parallel. Hold on to your hat...') 125 | pool = Pool(processes=len(to_regions)) 126 | pool.map(copy, param_array) 127 | pool.close() 128 | pool.join() 129 | else: 130 | # Copy to regions one at a time 131 | for region in to_regions: 132 | copy([distami, region, args]) 133 | 134 | except DistamiException as e: 135 | _fail(e.message) 136 | 137 | log.info('AMI successfully distributed!') 138 | sys.exit(0) 139 | 140 | -------------------------------------------------------------------------------- /distami/core.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Answers for AWS LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | import boto 17 | 18 | from boto import ec2 19 | 20 | from distami.exceptions import * 21 | from distami import utils 22 | 23 | __all__ = ('Distami', 'Logging') 24 | log = logging.getLogger(__name__) 25 | 26 | 27 | class Distami(object): 28 | def __init__(self, ami_id, ami_region): 29 | self._ami_id = ami_id 30 | self._ami_region = ami_region 31 | 32 | log.info("Looking for AMI %s in region %s", self._ami_id, self._ami_region) 33 | try: 34 | self._conn = ec2.connect_to_region(self._ami_region) 35 | except boto.exception.NoAuthHandlerFound: 36 | log.error('Could not connect to region %s' % self._ami_region) 37 | log.critical('No AWS credentials found. To configure Boto, please read: http://boto.readthedocs.org/en/latest/boto_config_tut.html') 38 | raise DistamiException('No AWS credentials found.') 39 | self._image = utils.wait_for_ami_to_be_available(self._conn, self._ami_id) 40 | log.debug('AMI details: %s', vars(self._image)) 41 | 42 | # Get current launch permissions 43 | self._launch_perms = self._image.get_launch_permissions() 44 | log.debug("Current launch permissions: %s", self._launch_perms) 45 | 46 | # Figure out the underlying snapshot 47 | ami = utils.get_ami(self._conn, self._ami_id) 48 | bdm = self._image.block_device_mapping[ami.root_device_name] 49 | log.debug('Block device mapping for %s: %s', ami.root_device_name, vars(bdm)) 50 | self._snapshot_id = bdm.snapshot_id 51 | 52 | log.info("Found AMI %s with snapshot %s", self._ami_id, self._snapshot_id) 53 | 54 | 55 | def make_ami_public(self): 56 | ''' Adds the 'all' group permission to the AMI, making it publicly accessible ''' 57 | 58 | log.info('Making AMI %s public', self._ami_id) 59 | 60 | if 'groups' in self._launch_perms and any("all" in groups for groups in self._launch_perms['groups']): 61 | log.debug('AMI already public, nothing to do') 62 | return True 63 | 64 | res = self._image.set_launch_permissions(group_names='all') 65 | self._launch_perms = self._image.get_launch_permissions() 66 | return res 67 | 68 | 69 | def make_ami_non_public(self): 70 | ''' Removes the 'all' group permission from the AMI ''' 71 | 72 | log.info('Making AMI %s non-public', self._ami_id) 73 | 74 | if 'groups' in self._launch_perms and any("all" in groups for groups in self._launch_perms['groups']): 75 | res = self._image.remove_launch_permissions(group_names='all') 76 | self._launch_perms = self._image.get_launch_permissions() 77 | return res 78 | 79 | log.debug('AMI is already not public') 80 | return True 81 | 82 | def share_ami_with_accounts(self, account_ids): 83 | ''' Shares an AMI with the supplied list of AWS Account IDs ''' 84 | 85 | log.info('Sharing AMI %s with AWS Accounts %s', self._ami_id, account_ids) 86 | 87 | res = self._image.set_launch_permissions(user_ids=account_ids) 88 | self._launch_perms = self._image.get_launch_permissions() 89 | return res 90 | 91 | def make_snapshot_public(self): 92 | ''' Makes a snapshot public ''' 93 | 94 | snapshot = utils.get_snapshot(self._conn, self._snapshot_id) 95 | log.debug('Snapshot details: %s', vars(snapshot)) 96 | 97 | log.info('Making snapshot %s public', self._snapshot_id) 98 | return snapshot.share(groups=['all']) 99 | 100 | 101 | def make_snapshot_non_public(self): 102 | ''' Removes the 'all' group permission from the snapshot ''' 103 | 104 | snapshot = utils.get_snapshot(self._conn, self._snapshot_id) 105 | log.debug('Snapshot details: %s', vars(snapshot)) 106 | 107 | log.info('Making snapshot %s non-public', self._snapshot_id) 108 | return snapshot.unshare(groups=['all']) 109 | 110 | 111 | def share_snapshot_with_accounts(self, account_ids): 112 | ''' Shares a snapshot with the supplied list of AWS Account IDs ''' 113 | 114 | snapshot = utils.get_snapshot(self._conn, self._snapshot_id) 115 | log.debug('Snapshot details: %s', vars(snapshot)) 116 | 117 | log.info('Sharing snapshot %s with AWS Accounts %s', self._snapshot_id, account_ids) 118 | return snapshot.share(user_ids=account_ids) 119 | 120 | 121 | def copy_to_region(self, region): 122 | ''' Copies this AMI to another region ''' 123 | 124 | dest_conn = ec2.connect_to_region(region) 125 | log.info('Copying AMI to %s', region) 126 | cp_ami = dest_conn.copy_image(self._ami_region, self._ami_id, self._image.name, self._image.description) 127 | copied_ami_id = cp_ami.image_id 128 | 129 | # Wait for AMI to finish copying before returning 130 | copied_image = utils.wait_for_ami_to_be_available(dest_conn, copied_ami_id) 131 | 132 | # Copy AMI tags to new AMI 133 | log.info('Copying tags to %s in %s', copied_ami_id, region) 134 | if self._image.tags: 135 | dest_conn.create_tags(copied_ami_id, self._image.tags) 136 | else: 137 | log.info('AMI tags empty, nothing to copy') 138 | 139 | # Also copy snapshot tags to new snapshot 140 | ami = utils.get_ami(self._conn, self._ami_id) 141 | copied_snapshot_id = copied_image.block_device_mapping[ami.root_device_name].snapshot_id 142 | snapshot = utils.get_snapshot(self._conn, self._snapshot_id) 143 | log.info('Copying tags to %s in %s', copied_snapshot_id, region) 144 | 145 | if snapshot.tags: 146 | dest_conn.create_tags(copied_snapshot_id, snapshot.tags) 147 | else: 148 | log.info('Snapshot tags empty, nothing to copy') 149 | 150 | log.info('Copy to %s complete', region) 151 | return copied_ami_id 152 | 153 | 154 | 155 | class Logging(object): 156 | # Logging formats 157 | _log_simple_format = '%(asctime)s [%(levelname)s] %(message)s' 158 | _log_detailed_format = '%(asctime)s [%(levelname)s] [%(name)s(%(lineno)s):%(funcName)s] %(message)s' 159 | 160 | def configure(self, verbosity = None): 161 | ''' Configure the logging format and verbosity ''' 162 | 163 | # Configure our logging output 164 | if verbosity >= 2: 165 | logging.basicConfig(level=logging.DEBUG, format=self._log_detailed_format, datefmt='%F %T') 166 | elif verbosity >= 1: 167 | logging.basicConfig(level=logging.INFO, format=self._log_detailed_format, datefmt='%F %T') 168 | else: 169 | logging.basicConfig(level=logging.INFO, format=self._log_simple_format, datefmt='%F %T') 170 | 171 | # Configure Boto's logging output 172 | if verbosity >= 4: 173 | logging.getLogger('boto').setLevel(logging.DEBUG) 174 | elif verbosity >= 3: 175 | logging.getLogger('boto').setLevel(logging.INFO) 176 | else: 177 | logging.getLogger('boto').setLevel(logging.CRITICAL) 178 | 179 | -------------------------------------------------------------------------------- /distami/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Answers for AWS LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | class DistamiException(Exception): 16 | ''' Base Distami Exception ''' 17 | pass 18 | -------------------------------------------------------------------------------- /distami/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Answers for AWS LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | import time 17 | import boto 18 | from boto import ec2 19 | 20 | from distami.exceptions import * 21 | 22 | log = logging.getLogger(__name__) 23 | 24 | 25 | def get_ami(conn, ami_id): 26 | ''' Gets a single AMI as a boto.ec2.image.Image object ''' 27 | 28 | attempts = 0 29 | max_attempts = 5 30 | while (attempts < max_attempts): 31 | try: 32 | attempts += 1 33 | images = conn.get_all_images(ami_id) 34 | except boto.exception.EC2ResponseError: 35 | msg = "Could not find AMI '%s' in region '%s'" % (ami_id, conn.region.name) 36 | if attempts < max_attempts: 37 | # The API call to initiate an AMI copy is not blocking, so the 38 | # copied AMI may not be available right away 39 | log.debug(msg + ' so waiting 5 seconds and retrying') 40 | time.sleep(5) 41 | else: 42 | raise DistamiException(msg) 43 | 44 | log.debug("Found AMIs: %s", images) 45 | if len(images) != 1: 46 | msg = "Somehow more than 1 AMI was detected - this is a weird error" 47 | raise DistamiException(msg) 48 | 49 | return images[0] 50 | 51 | 52 | def get_snapshot(conn, snapshot_id): 53 | ''' Gets a single snapshot as a boto.ec2.snapshot.Snapshot object ''' 54 | 55 | try: 56 | snapshots = conn.get_all_snapshots(snapshot_id) 57 | except boto.exception.EC2ResponseError: 58 | msg = "Could not snapshot '%s' in region '%s'" % (snapshot_id, conn.region.name) 59 | raise DistamiException(msg) 60 | 61 | log.debug("Found snapshots: %s", snapshots) 62 | if len(snapshots) != 1: 63 | msg = "Somehow more than 1 snapshot was detected - this is a weird error" 64 | raise DistamiException(msg) 65 | 66 | return snapshots[0] 67 | 68 | 69 | def get_regions_to_copy_to(source_region): 70 | ''' Gets the list of regions to copy an AMI to ''' 71 | 72 | regions = [] 73 | for region in ec2.regions(): 74 | if region.name == source_region: 75 | continue 76 | # Filter out GovCloud 77 | if region.name == 'us-gov-west-1': 78 | continue 79 | # Filter out China 80 | if region.name == 'cn-north-1': 81 | continue 82 | regions.append(region.name) 83 | 84 | return regions 85 | 86 | 87 | def wait_for_ami_to_be_available(conn, ami_id): 88 | ''' Blocking wait until the AMI is available ''' 89 | 90 | ami = get_ami(conn, ami_id) 91 | log.debug('AMI details: %s', vars(ami)) 92 | 93 | while ami.state != 'available': 94 | log.info("%s in %s not available, waiting...", ami_id, conn.region.name) 95 | time.sleep(30) 96 | ami = get_ami(conn, ami_id) 97 | 98 | if ami.state == 'failed': 99 | msg = "AMI '%s' is in a failed state and will never be available" % ami_id 100 | raise DistamiException(msg) 101 | 102 | return ami -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | boto>=2.7 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2013 Answers for AWS LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | """ 17 | setuptools install script for DistAMI 18 | """ 19 | 20 | import sys 21 | major, minor = sys.version_info[0:2] 22 | if major != 2 or minor < 7: 23 | print 'DistAMI requires Python 2.7.x' 24 | sys.exit(1) 25 | 26 | from setuptools import setup, find_packages 27 | 28 | import distami 29 | 30 | with open('requirements.txt') as fh: 31 | requires = [requirement.strip() for requirement in fh] 32 | 33 | entry_points = { 34 | 'console_scripts': [ 35 | 'distami = distami.cli:run', 36 | ] 37 | } 38 | 39 | exclude_packages = [ 40 | 'tests', 41 | 'tests.*', 42 | ] 43 | 44 | setup( 45 | name='distami', 46 | version=distami.__version__, 47 | description='Distribute Amazon Machine Images (AMIs) to the public', 48 | long_description=open('README.rst').read(), 49 | author=distami.__author__, 50 | author_email='info@answersforaws.com', 51 | url='https://github.com/Answers4AWS/distami', 52 | packages=find_packages(exclude=exclude_packages), 53 | package_dir={'distami': 'distami'}, 54 | include_package_data=True, 55 | zip_safe=False, 56 | install_requires=requires, 57 | entry_points=entry_points, 58 | license=open("LICENSE.txt").read(), 59 | classifiers=( 60 | 'Development Status :: 4 - Beta', 61 | 'Environment :: Console', 62 | 'Intended Audience :: Developers', 63 | 'Intended Audience :: Information Technology', 64 | 'Intended Audience :: System Administrators', 65 | 'License :: OSI Approved :: Apache Software License', 66 | 'Natural Language :: English', 67 | 'Programming Language :: Python :: 2.7', 68 | 'Topic :: System :: Installation/Setup', 69 | 'Topic :: Utilities', 70 | ) 71 | ) 72 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Answers for AWS LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Answers for AWS LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | -------------------------------------------------------------------------------- /tests/unit/test_exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Answers for AWS LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import unittest 16 | 17 | from distami.exceptions import * 18 | 19 | class ExceptionTests(unittest.TestCase): 20 | def test_new_exception(self): 21 | e = DistamiException() 22 | self.assertIsInstance(e, DistamiException) 23 | 24 | def raise_DistamiException(self): 25 | raise DistamiException('msg') 26 | 27 | def test_raise_DistamiException(self): 28 | self.assertRaises(DistamiException, self.raise_DistamiException) 29 | -------------------------------------------------------------------------------- /tests/unit/test_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Answers for AWS LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import unittest 16 | 17 | from distami import utils 18 | 19 | class UtilTests(unittest.TestCase): 20 | def test_get_regions_to_copy_to(self): 21 | all_public_regions = ['ap-southeast-1', 'ap-southeast-2', 'ap-northeast-1', 'us-east-1', 'us-west-1', 'us-west-2', 'sa-east-1', 'eu-west-1', 'eu-central-1'] 22 | regions = utils.get_regions_to_copy_to('not-a-real-region') 23 | self.assertItemsEqual(regions, all_public_regions) 24 | --------------------------------------------------------------------------------