├── tests ├── __init__.py ├── mocks │ ├── block_device.json │ ├── ami.json │ └── ec2instance.json ├── test_parsing.py ├── test_cli.py └── test_core.py ├── amicleaner ├── resources │ ├── __init__.py │ ├── config.py │ └── models.py ├── __init__.py ├── fetch.py ├── utils.py ├── cli.py └── core.py ├── requirements.txt ├── tox.ini ├── pytest.ini ├── requirements_build.txt ├── Dockerfile-python2 ├── HISTORY.rst ├── Dockerfile ├── MANIFEST.in ├── .travis.yml ├── .gitignore ├── LICENSE ├── setup.py ├── CONTRIBUTING.rst └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /amicleaner/resources/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | awscli 2 | argparse 3 | boto 4 | boto3 5 | prettytable 6 | blessings==1.6 7 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27 3 | 4 | [testenv] 5 | command = py.test -v --cov-report html --cov-fail-under 80 --cov . --pep8 6 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | rootdir = tests 3 | addopts = --cov-report html --cov-fail-under 80 --cov . --pep8 4 | pep8maxlinelength = 120 5 | -------------------------------------------------------------------------------- /requirements_build.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | blessings==1.6 3 | codecov 4 | moto 5 | pep8>=1.7.0 6 | pytest 7 | pytest-pep8 8 | pytest-cov 9 | -------------------------------------------------------------------------------- /Dockerfile-python2: -------------------------------------------------------------------------------- 1 | FROM python:2.7 2 | 3 | WORKDIR /aws-amicleaner 4 | 5 | ADD . . 6 | 7 | RUN pip install -r requirements.txt 8 | 9 | CMD amicleaner/cli.py -h 10 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | 0.1.0 (2016-08-22) 6 | ------------------ 7 | 8 | * First release on PyPI. 9 | 10 | 0.1.1 (2016-08-22) 11 | ------------------ 12 | 13 | * Documentation update 14 | * rst files in pip package 15 | -------------------------------------------------------------------------------- /tests/mocks/block_device.json: -------------------------------------------------------------------------------- 1 | { 2 | "DeviceName": "/dev/xvda", 3 | "Ebs": { 4 | "DeleteOnTermination": true, 5 | "SnapshotId": "snap-4e8fae6b", 6 | "VolumeSize": 8, 7 | "VolumeType": "standard", 8 | "Encrypted": false 9 | } 10 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6 2 | 3 | RUN apt-get update && apt-get install -y \ 4 | vim \ 5 | awscli \ 6 | twine \ 7 | jq 8 | 9 | ENV PATH="${PATH}:/root/.local/bin/" 10 | 11 | WORKDIR /aws-amicleaner 12 | ADD . . 13 | RUN python setup.py install 14 | CMD bash 15 | -------------------------------------------------------------------------------- /amicleaner/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __title__ = 'amicleaner' 4 | __version__ = '0.2.2' 5 | __short_version__ = '.'.join(__version__.split('.')[:2]) 6 | __author__ = 'Guy Rodrigue Koffi' 7 | __author_email__ = 'koffirodrigue@gmail.com' 8 | __license__ = 'MIT' 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | 2 | include CONTRIBUTING.rst 3 | 4 | include CONTRIBUTING.rst 5 | include HISTORY.rst 6 | include LICENSE 7 | include README.rst 8 | 9 | recursive-include tests * 10 | recursive-exclude * __pycache__ 11 | recursive-exclude * *.py[co] 12 | 13 | recursive-include docs *.rst conf.py 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | env: 2 | global: 3 | - AWS_DEFAULT_REGION=eu-west-1 4 | 5 | dist: trusty 6 | language: python 7 | python: 8 | - "3.4" 9 | - "3.5" 10 | - "3.6" 11 | 12 | install: 13 | - pip install -r requirements_build.txt 14 | 15 | script: 16 | - py.test -v 17 | 18 | after_success: 19 | - codecov 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # pycharm 7 | .idea 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .cache 42 | .pytest_cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # PyBuilder 49 | target/ 50 | 51 | 52 | # pyenv 53 | .python-version 54 | 55 | # dotenv 56 | .env 57 | -------------------------------------------------------------------------------- /amicleaner/resources/config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # set your aws env vars to production 5 | 6 | from blessings import Terminal 7 | 8 | # terminal colors 9 | TERM = Terminal() 10 | 11 | # Number of previous amis to keep based on grouping strategy 12 | # not including the ami currently running by an ec2 instance 13 | KEEP_PREVIOUS = 4 14 | 15 | # the way to regroup AMIs, the default filtering pattern is the creation date 16 | # and the possible other values are : 17 | # 18 | # name : with a grep into the ami name 19 | # ex: ubuntu => ["ubuntu-20160122", "ubuntu-20160123"] 20 | # tags : with keys provided in GROUPING_STRATEGY_TAGS_KEYS, it filters AMI tags 21 | # ex: ["Role", "Env"] => ["ubuntu-20160122"] 22 | # 23 | MAPPING_KEY = "tags" 24 | 25 | 26 | MAPPING_VALUES = ["environment", "role"] 27 | 28 | 29 | EXCLUDED_MAPPING_VALUES = [] 30 | 31 | 32 | # Number of days amis to keep based on creation date and grouping strategy 33 | # not including the ami currently running by an ec2 instance 34 | AMI_MIN_DAYS = -1 35 | 36 | BOTO3_RETRIES = 10 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2016, Guy Rodrigue Koffi 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | 12 | -------------------------------------------------------------------------------- /tests/mocks/ami.json: -------------------------------------------------------------------------------- 1 | { 2 | "VirtualizationType": "hvm", 3 | "Name": "custom-debian-201511040131", 4 | "Tags": [ 5 | { 6 | "Value": "prod", 7 | "Key": "env" 8 | }, 9 | { 10 | "Value": "source", 11 | "Key": "ami-f28bfa92" 12 | } 13 | ], 14 | "Hypervisor": "xen", 15 | "ImageId": "ami-02197662", 16 | "State": "available", 17 | "BlockDeviceMappings": [ 18 | { 19 | "DeviceName": "/dev/xvda", 20 | "Ebs": { 21 | "DeleteOnTermination": true, 22 | "SnapshotId": "snap-b4f78391", 23 | "VolumeSize": 8, 24 | "VolumeType": "standard", 25 | "Encrypted": false 26 | } 27 | }, 28 | { 29 | "DeviceName": "/dev/xvdb", 30 | "Ebs": { 31 | "DeleteOnTermination": true, 32 | "SnapshotId": "snap-b4f78392", 33 | "VolumeSize": 8, 34 | "VolumeType": "standard", 35 | "Encrypted": false 36 | } 37 | }, 38 | { 39 | "VirtualName": "ephemeral0", 40 | "DeviceName": "/dev/sdb" 41 | } 42 | ], 43 | "Architecture": "x86_64", 44 | "ImageLocation": "awsaccount/custom-debian-201511040131", 45 | "RootDeviceType": "ebs", 46 | "OwnerId": "awsaccount", 47 | "RootDeviceName": "/dev/xvda", 48 | "CreationDate": "2015-11-04T01:35:31.000Z", 49 | "Public": false, 50 | "ImageType": "machine" 51 | } 52 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | from amicleaner import __author__, __author_email__ 5 | from amicleaner import __license__, __version__ 6 | 7 | 8 | with open('README.rst') as readme_file: 9 | readme = readme_file.read() 10 | 11 | with open('HISTORY.rst') as history_file: 12 | history = history_file.read() 13 | 14 | install_requirements = ['awscli', 'argparse', 'boto', 15 | 'boto3', 'prettytable', 'blessings'] 16 | 17 | test_requirements = ['moto', 'pytest', 'pytest-pep8', 'pytest-cov'] 18 | 19 | 20 | setup( 21 | name='aws-amicleaner', 22 | version=__version__, 23 | description='Cleanup tool for AWS AMIs and snapshots', 24 | long_description=readme + "\n\n" + history, 25 | author=__author__, 26 | author_email=__author_email__, 27 | url='https://github.com/bonclay7/aws-amicleaner/', 28 | license=__license__, 29 | packages=find_packages(exclude=['tests']), 30 | classifiers=[ 31 | 'Development Status :: 5 - Production/Stable', 32 | 'Environment :: Console', 33 | 'Intended Audience :: Developers', 34 | 'Intended Audience :: System Administrators', 35 | 'Intended Audience :: Information Technology', 36 | 'License :: OSI Approved :: MIT License', 37 | 'Operating System :: OS Independent', 38 | 'Programming Language :: Python', 39 | 'Programming Language :: Python :: 2', 40 | 'Programming Language :: Python :: 2.7', 41 | 'Programming Language :: Python :: 3', 42 | 'Programming Language :: Python :: 3.3', 43 | 'Programming Language :: Python :: 3.4', 44 | 'Programming Language :: Python :: 3.5', 45 | 'Programming Language :: Python :: 3.6', 46 | 'Topic :: Software Development', 47 | 'Topic :: Software Development :: Libraries :: Python Modules', 48 | ], 49 | entry_points={ 50 | 'console_scripts': [ 51 | 'amicleaner = amicleaner.cli:main', 52 | ], 53 | }, 54 | tests_require=test_requirements, 55 | install_requires=install_requirements, 56 | ) 57 | -------------------------------------------------------------------------------- /tests/mocks/ec2instance.json: -------------------------------------------------------------------------------- 1 | { 2 | "Monitoring": { 3 | "State": "disabled" 4 | }, 5 | "PublicDnsName": "", 6 | "RootDeviceType": "ebs", 7 | "State": { 8 | "Code": 80, 9 | "Name": "stopped" 10 | }, 11 | "EbsOptimized": false, 12 | "LaunchTime": "2015-07-30T12:23:07.000Z", 13 | "PrivateIpAddress": "172.21.3.245", 14 | "ProductCodes": [], 15 | "VpcId": "vpc-6add9f00", 16 | "StateTransitionReason": "User initiated (2015-09-21 11:36:45 GMT)", 17 | "InstanceId": "i-096266cb", 18 | "ImageId": "ami-05cf2541", 19 | "PrivateDnsName": "ip-172-21-3-245.us-west-1.compute.internal", 20 | "KeyName": "test", 21 | "SecurityGroups": [ 22 | { 23 | "GroupName": "default", 24 | "GroupId": "sg-563b243a" 25 | } 26 | ], 27 | "ClientToken": "QthZK1438258986503", 28 | "SubnetId": "subnet-cccfd28a", 29 | "InstanceType": "t2.micro", 30 | "NetworkInterfaces": [ 31 | { 32 | "Status": "in-use", 33 | "MacAddress": "06:94:a6:35:49:a3", 34 | "SourceDestCheck": true, 35 | "VpcId": "vpc-6add9f03", 36 | "Description": "Primary network interface", 37 | "NetworkInterfaceId": "eni-efa09fb7", 38 | "PrivateIpAddresses": [ 39 | { 40 | "PrivateDnsName": "ip-172-21-3-245.us-west-1.compute.internal", 41 | "Primary": true, 42 | "PrivateIpAddress": "172.21.3.245" 43 | } 44 | ], 45 | "PrivateDnsName": "ip-172-21-3-245.us-west-1.compute.internal", 46 | "Attachment": { 47 | "Status": "attached", 48 | "DeviceIndex": 0, 49 | "DeleteOnTermination": true, 50 | "AttachmentId": "eni-attach-ffa0c7ac", 51 | "AttachTime": "2015-07-30T12:23:07.000Z" 52 | }, 53 | "Groups": [ 54 | { 55 | "GroupName": "default", 56 | "GroupId": "sg-563b243a" 57 | } 58 | ], 59 | "SubnetId": "subnet-cccfd28a", 60 | "OwnerId": "062010136920", 61 | "PrivateIpAddress": "172.1.3.2" 62 | } 63 | ], 64 | "SourceDestCheck": true, 65 | "Placement": { 66 | "Tenancy": "default", 67 | "GroupName": "", 68 | "AvailabilityZone": "us-west-1b" 69 | }, 70 | "Hypervisor": "xen", 71 | "BlockDeviceMappings": [ 72 | { 73 | "DeviceName": "/dev/xvda", 74 | "Ebs": { 75 | "Status": "attached", 76 | "DeleteOnTermination": true, 77 | "VolumeId": "vol-6366f99a", 78 | "AttachTime": "2015-07-30T12:23:10.000Z" 79 | } 80 | } 81 | ], 82 | "Architecture": "x86_64", 83 | "StateReason": { 84 | "Message": "Client.UserInitiatedShutdown: User initiated shutdown", 85 | "Code": "Client.UserInitiatedShutdown" 86 | }, 87 | "RootDeviceName": "/dev/xvda", 88 | "VirtualizationType": "hvm", 89 | "Tags": [ 90 | { 91 | "Value": "test", 92 | "Key": "Name" 93 | } 94 | ], 95 | "AmiLaunchIndex": 0 96 | } -------------------------------------------------------------------------------- /tests/test_parsing.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | 5 | from amicleaner.resources.models import AMI, AWSBlockDevice, AWSEC2Instance 6 | from amicleaner.resources.models import AWSTag 7 | 8 | 9 | def test_get_awstag_from_none(): 10 | aws_tag = AWSTag.object_with_json(None) 11 | assert aws_tag is None 12 | 13 | 14 | def test_get_awstag_from_json(): 15 | json_to_parse = {"Key": "Name", "Value": "Test"} 16 | aws_tag = AWSTag.object_with_json(json_to_parse) 17 | assert aws_tag.value == "Test" 18 | assert aws_tag.key == "Name" 19 | 20 | 21 | def test_get_awsblockdevice_from_none(): 22 | aws_block_device = AWSBlockDevice.object_with_json(None) 23 | assert aws_block_device is None 24 | 25 | 26 | def test_get_awsblockdevice_from_json(): 27 | with open("tests/mocks/block_device.json") as mock_file: 28 | json_to_parse = json.load(mock_file) 29 | aws_block_device = AWSBlockDevice.object_with_json(json_to_parse) 30 | assert aws_block_device.device_name == "/dev/xvda" 31 | assert aws_block_device.snapshot_id == "snap-4e8fae6b" 32 | assert aws_block_device.volume_size == 8 33 | assert aws_block_device.encrypted is False 34 | 35 | 36 | def test_get_awsec2instance_from_none(): 37 | aws_ec2_instance = AWSEC2Instance.object_with_json(None) 38 | assert aws_ec2_instance is None 39 | 40 | 41 | def test_get_awsec2instance_from_json(): 42 | with open("tests/mocks/ec2instance.json") as mock_file: 43 | json_to_parse = json.load(mock_file) 44 | aws_ec2_instance = AWSEC2Instance.object_with_json(json_to_parse) 45 | assert aws_ec2_instance.image_id == "ami-05cf2541" 46 | assert aws_ec2_instance.tags[0].key == "Name" 47 | assert aws_ec2_instance.tags[0].value == "test" 48 | assert aws_ec2_instance.id == "i-096266cb" 49 | assert aws_ec2_instance.launch_time is not None 50 | assert aws_ec2_instance.key_name == "test" 51 | assert aws_ec2_instance.vpc_id == "vpc-6add9f00" 52 | 53 | 54 | def test_get_ami_from_none(): 55 | ami = AMI.object_with_json(None) 56 | assert ami is None 57 | 58 | 59 | def test_get_ami_from_json(): 60 | with open("tests/mocks/ami.json") as mock_file: 61 | json_to_parse = json.load(mock_file) 62 | ami = AMI.object_with_json(json_to_parse) 63 | assert ami.id == "ami-02197662" 64 | assert ami.virtualization_type == "hvm" 65 | assert ami.name == "custom-debian-201511040131" 66 | assert repr(ami) == "AMI: ami-02197662 2015-11-04T01:35:31.000Z" 67 | assert ami.tags[0].value is not None 68 | assert ami.tags[0].value is not None 69 | assert len(ami.tags) == 2 70 | assert len(ami.block_device_mappings) == 2 71 | 72 | 73 | def test_models_to_tring(): 74 | assert str(AMI()) is not None 75 | assert str(AWSBlockDevice()) is not None 76 | assert str(AWSEC2Instance()) is not None 77 | assert str(AWSTag()) is not None 78 | -------------------------------------------------------------------------------- /amicleaner/fetch.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import absolute_import 5 | from builtins import object 6 | import boto3 7 | from botocore.config import Config 8 | from .resources.config import BOTO3_RETRIES 9 | from .resources.models import AMI 10 | 11 | 12 | class Fetcher(object): 13 | 14 | """ Fetches function for AMI candidates to deletion """ 15 | 16 | def __init__(self, ec2=None, autoscaling=None): 17 | 18 | """ Initializes aws sdk clients """ 19 | 20 | self.ec2 = ec2 or boto3.client('ec2', config=Config(retries={'max_attempts': BOTO3_RETRIES})) 21 | self.asg = autoscaling or boto3.client('autoscaling') 22 | 23 | def fetch_available_amis(self): 24 | 25 | """ Retrieve from your aws account your custom AMIs""" 26 | 27 | available_amis = dict() 28 | 29 | my_custom_images = self.ec2.describe_images(Owners=['self']) 30 | for image_json in my_custom_images.get('Images'): 31 | ami = AMI.object_with_json(image_json) 32 | available_amis[ami.id] = ami 33 | 34 | return available_amis 35 | 36 | def fetch_unattached_lc(self): 37 | 38 | """ 39 | Find AMIs for launch configurations unattached 40 | to autoscaling groups 41 | """ 42 | 43 | resp = self.asg.describe_auto_scaling_groups() 44 | used_lc = (asg.get("LaunchConfigurationName", "") 45 | for asg in resp.get("AutoScalingGroups", [])) 46 | 47 | resp = self.asg.describe_launch_configurations() 48 | all_lcs = (lc.get("LaunchConfigurationName", "") 49 | for lc in resp.get("LaunchConfigurations", [])) 50 | 51 | unused_lcs = list(set(all_lcs) - set(used_lc)) 52 | 53 | resp = self.asg.describe_launch_configurations( 54 | LaunchConfigurationNames=unused_lcs 55 | ) 56 | amis = [lc.get("ImageId") 57 | for lc in resp.get("LaunchConfigurations", [])] 58 | 59 | return amis 60 | 61 | def fetch_zeroed_asg(self): 62 | 63 | """ 64 | Find AMIs for autoscaling groups who's desired capacity is set to 0 65 | """ 66 | 67 | resp = self.asg.describe_auto_scaling_groups() 68 | zeroed_lcs = [asg.get("LaunchConfigurationName", "") 69 | for asg in resp.get("AutoScalingGroups", []) 70 | if asg.get("DesiredCapacity", 0) == 0] 71 | 72 | resp = self.asg.describe_launch_configurations( 73 | LaunchConfigurationNames=zeroed_lcs 74 | ) 75 | 76 | amis = [lc.get("ImageId", "") 77 | for lc in resp.get("LaunchConfigurations", [])] 78 | 79 | return amis 80 | 81 | def fetch_instances(self): 82 | 83 | """ Find AMIs for not terminated EC2 instances """ 84 | 85 | resp = self.ec2.describe_instances( 86 | Filters=[ 87 | { 88 | 'Name': 'instance-state-name', 89 | 'Values': [ 90 | 'pending', 91 | 'running', 92 | 'shutting-down', 93 | 'stopping', 94 | 'stopped' 95 | ] 96 | } 97 | ] 98 | ) 99 | amis = [i.get("ImageId", None) 100 | for r in resp.get("Reservations", []) 101 | for i in r.get("Instances", [])] 102 | 103 | return amis 104 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every 8 | little bit helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/bonclay7/aws-amicleaner/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" 30 | and "help wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | amicleaner could always use more documentation, whether as part of the 42 | official amicleaner docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/bonclay7/aws-amicleaner/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `aws-amicleaner` for local development. 61 | 62 | 1. Fork the `aws-amicleaner` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:bonclay7/aws-amicleaner.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv amicleaner 70 | $ cd aws-amicleaner/ 71 | $ python setup.py develop 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: 80 | 81 | $ flake8 amicleaner tests 82 | $ python setup.py test or py.test 83 | $ tox 84 | 85 | To get flake8 and tox, just pip install them into your virtualenv. 86 | 87 | 6. Commit your changes and push your branch to GitHub:: 88 | 89 | $ git add . 90 | $ git commit -m "Your detailed description of your changes." 91 | $ git push origin name-of-your-bugfix-or-feature 92 | 93 | 7. Submit a pull request through the GitHub website. 94 | 95 | Pull Request Guidelines 96 | ----------------------- 97 | 98 | Before you submit a pull request, check that it meets these guidelines: 99 | 100 | 1. The pull request should include tests. 101 | 2. If the pull request adds functionality, the docs should be updated. Put 102 | your new functionality into a function with a docstring, and add the 103 | feature to the list in README.rst. 104 | 3. The pull request should work for Python 2.6, 2.7 and for PyPy. Check 105 | https://travis-ci.org/bonclay7/aws-amicleaner/pull_requests 106 | and make sure that the tests pass for all supported Python versions. 107 | 108 | Tips 109 | ---- 110 | 111 | To run a subset of tests:: 112 | 113 | $ py.test tests.test_amicleaner 114 | 115 | -------------------------------------------------------------------------------- /amicleaner/utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import print_function 5 | from __future__ import absolute_import 6 | from builtins import object 7 | import argparse 8 | 9 | from prettytable import PrettyTable 10 | 11 | from .resources.config import KEEP_PREVIOUS, AMI_MIN_DAYS 12 | 13 | 14 | class Printer(object): 15 | 16 | """ Pretty table prints methods """ 17 | @staticmethod 18 | def print_report(candidates, full_report=False): 19 | 20 | """ Print AMI collection results """ 21 | 22 | if not candidates: 23 | return 24 | 25 | groups_table = PrettyTable(["Group name", "candidates"]) 26 | 27 | for group_name, amis in candidates.items(): 28 | groups_table.add_row([group_name, len(amis)]) 29 | eligible_amis_table = PrettyTable( 30 | ["AMI ID", "AMI Name", "Creation Date"] 31 | ) 32 | for ami in amis: 33 | eligible_amis_table.add_row([ 34 | ami.id, 35 | ami.name, 36 | ami.creation_date 37 | ]) 38 | if full_report: 39 | print(group_name) 40 | print(eligible_amis_table.get_string(sortby="AMI Name"), "\n\n") 41 | 42 | print("\nAMIs to be removed:") 43 | print(groups_table.get_string(sortby="Group name")) 44 | 45 | @staticmethod 46 | def print_failed_snapshots(snapshots): 47 | 48 | snap_table = PrettyTable(["Failed Snapshots"]) 49 | 50 | for snap in snapshots: 51 | snap_table.add_row([snap]) 52 | print(snap_table) 53 | 54 | @staticmethod 55 | def print_orphan_snapshots(snapshots): 56 | 57 | snap_table = PrettyTable(["Orphan Snapshots"]) 58 | 59 | for snap in snapshots: 60 | snap_table.add_row([snap]) 61 | print(snap_table) 62 | 63 | 64 | def parse_args(args): 65 | parser = argparse.ArgumentParser(description='Clean your AMIs on your ' 66 | 'AWS account. Your AWS ' 67 | 'credentials must be sourced') 68 | 69 | parser.add_argument("-v", "--version", 70 | dest='version', 71 | action="store_true", 72 | help="Prints version and exits") 73 | 74 | parser.add_argument("--from-ids", 75 | dest='from_ids', 76 | nargs='+', 77 | help="AMI id(s) you simply want to remove") 78 | 79 | parser.add_argument("--full-report", 80 | dest='full_report', 81 | action="store_true", 82 | help="Prints a full report of what to be cleaned") 83 | 84 | parser.add_argument("--mapping-key", 85 | dest='mapping_key', 86 | help="How to regroup AMIs : [name|tags]") 87 | 88 | parser.add_argument("--mapping-values", 89 | dest='mapping_values', 90 | nargs='+', 91 | help="List of values for tags or name") 92 | 93 | parser.add_argument("--excluded-mapping-values", 94 | dest='excluded_mapping_values', 95 | nargs='+', 96 | help="List of values to be excluded from tags") 97 | 98 | parser.add_argument("--keep-previous", 99 | dest='keep_previous', 100 | type=int, 101 | default=KEEP_PREVIOUS, 102 | help="Number of previous AMI to keep excluding those " 103 | "currently being running") 104 | 105 | parser.add_argument("-f", "--force-delete", 106 | dest='force_delete', 107 | action="store_true", 108 | help="Skip confirmation") 109 | 110 | parser.add_argument("--check-orphans", 111 | dest='check_orphans', 112 | action="store_true", 113 | help="Check and clean orphaned snapshots") 114 | 115 | parser.add_argument("--ami-min-days", 116 | dest='ami_min_days', 117 | type=int, 118 | default=AMI_MIN_DAYS, 119 | help="Number of days AMI to keep excluding those " 120 | "currently being running") 121 | 122 | parsed_args = parser.parse_args(args) 123 | if parsed_args.mapping_key and not parsed_args.mapping_values: 124 | print("missing mapping-values\n") 125 | parser.print_help() 126 | return None 127 | 128 | return parsed_args 129 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | aws-amicleaner 2 | ============== 3 | 4 | Cleanup your old unused ami and related snapshots 5 | 6 | |Travis CI| |codecov.io| |pypi| 7 | 8 | 9 | Maintenance note 10 | ---------------- 11 | This project is not under active maintenance but still active. 12 | Consider using the official AWS-supported `Amazon Data Lifecycle Manager 13 | `__. 14 | 15 | Description 16 | ----------- 17 | 18 | This tool enables you to clean your custom `Amazon Machine Images (AMI) 19 | `__ and 20 | related `EBS Snapshots 21 | `__. 22 | 23 | You can either run in ``fetch and clean`` mode where the tool will 24 | retrieve all your private **AMIs** and EC2 instances, exclude AMIs being 25 | holded by your EC2 instances (it can be useful if you use autoscaling, 26 | and so on ...). It applies a filter based on their **names** or **tags** 27 | and a number of **previous AMIs** you want to keep. You can also check and 28 | delete EBS snapshots left orphaned by manual deletion of AMIs. 29 | 30 | It can simply remove AMIs with a list of provided ids. 31 | 32 | Prerequisites 33 | ------------- 34 | 35 | - `awscli `__ 36 | - `python 2.7 or 3+` 37 | - `python pip `__ 38 | 39 | This tool assumes your AWS credentials are in your environment, either with AWS 40 | credentials variables : 41 | 42 | .. code:: bash 43 | 44 | export AWS_DEFAULT_REGION='your region' 45 | export AWS_ACCESS_KEY_ID='with token Access ID' 46 | export AWS_SECRET_ACCESS_KEY='with token AWS Secret' 47 | 48 | or with ``awscli`` : 49 | 50 | .. code:: bash 51 | 52 | export AWS_PROFILE=profile-name 53 | 54 | Minimum AWS IAM permissions 55 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 56 | 57 | To run the script properly, your ``aws`` user must have at least these 58 | permissions in ``iam``: 59 | 60 | .. code:: json 61 | 62 | { 63 | "Version": "2012-10-17", 64 | "Statement": [ 65 | { 66 | "Sid": "Stmt1458638250000", 67 | "Effect": "Allow", 68 | "Action": [ 69 | "ec2:DeleteSnapshot", 70 | "ec2:DeregisterImage", 71 | "ec2:DescribeImages", 72 | "ec2:DescribeInstances", 73 | "ec2:DescribeSnapshots", 74 | "autoscaling:DescribeAutoScalingGroups", 75 | "autoscaling:DescribeLaunchConfigurations" 76 | ], 77 | "Resource": [ 78 | "*" 79 | ] 80 | } 81 | ] 82 | } 83 | 84 | Installation 85 | ------------ 86 | 87 | amicleaner is available on pypi and can be installed on your system with pip 88 | 89 | From pypi 90 | ~~~~~~~~~ 91 | 92 | .. code:: bash 93 | 94 | [sudo] pip install aws-amicleaner 95 | 96 | From source 97 | ~~~~~~~~~~~ 98 | 99 | You can also clone or download from github the source and install with pip 100 | 101 | .. code:: bash 102 | 103 | cd aws-amicleaner/ 104 | pip install [--user] -e . 105 | 106 | Usage 107 | ----- 108 | 109 | 110 | Getting help 111 | ~~~~~~~~~~~~ 112 | 113 | .. code:: bash 114 | 115 | amicleaner --help 116 | 117 | 118 | Fetch and clean 119 | ~~~~~~~~~~~~~~~ 120 | 121 | Print report of groups and amis to be cleaned 122 | 123 | .. code:: bash 124 | 125 | amicleaner --full-report 126 | 127 | Keep previous number of AMIs 128 | 129 | .. code:: bash 130 | 131 | amicleaner --full-report --keep-previous 10 132 | 133 | Regroup by name or tags 134 | 135 | .. code:: bash 136 | 137 | amicleaner --mapping-key tags --mapping-values role env 138 | 139 | Exclude amis based on tag values 140 | 141 | .. code:: bash 142 | 143 | amicleaner --mapping-key tags --mapping-values role env -excluded-mapping-values prod 144 | 145 | Skip confirmation, can be useful for automation 146 | 147 | .. code:: bash 148 | 149 | amicleaner -f --keep-previous 2 150 | 151 | 152 | Activate orphan snapshots checking 153 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 154 | 155 | .. code:: bash 156 | 157 | amicleaner --check-orphans 158 | 159 | 160 | Delete a list of AMIs 161 | ~~~~~~~~~~~~~~~~~~~~~ 162 | 163 | .. code:: bash 164 | 165 | amicleaner --from-ids ami-abcdef01 ami-abcdef02 166 | 167 | 168 | .. |Travis CI| image:: https://travis-ci.org/bonclay7/aws-amicleaner.svg?branch=master 169 | :target: https://travis-ci.org/bonclay7/aws-amicleaner 170 | .. |codecov.io| image:: https://codecov.io/github/bonclay7/aws-amicleaner/coverage.svg?branch=master 171 | :target: https://codecov.io/github/bonclay7/aws-amicleaner?branch=master 172 | .. |pypi| image:: https://img.shields.io/pypi/v/aws-amicleaner.svg 173 | :target: https://pypi.python.org/pypi/aws-amicleaner 174 | 175 | 176 | See this `blog article 177 | `__ 178 | for more information. 179 | -------------------------------------------------------------------------------- /amicleaner/resources/models.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | from builtins import str 6 | from builtins import object 7 | 8 | 9 | class AMI(object): 10 | def __init__(self): 11 | self.id = None 12 | self.architecture = None 13 | self.block_device_mappings = [] 14 | self.creation_date = None 15 | self.hypervisor = None 16 | self.image_type = None 17 | self.location = None 18 | self.name = None 19 | self.owner_id = None 20 | self.public = None 21 | self.root_device_name = None 22 | self.root_device_type = None 23 | self.state = None 24 | self.tags = [] 25 | self.virtualization_type = None 26 | 27 | def __str__(self): 28 | return str({ 29 | 'id': self.id, 30 | 'virtualization_type': self.virtualization_type, 31 | 'creation_date': self.creation_date, 32 | }) 33 | 34 | @staticmethod 35 | def object_with_json(json): 36 | if json is None: 37 | return None 38 | 39 | o = AMI() 40 | o.id = json.get('ImageId') 41 | o.name = json.get('Name') 42 | o.architecture = json.get('Architecture') 43 | o.creation_date = json.get('CreationDate') 44 | o.hypervisor = json.get('Hypervisor') 45 | o.image_type = json.get('ImageType') 46 | o.location = json.get('ImageLocation') 47 | o.owner_id = json.get('OwnerId') 48 | o.public = json.get('ImageLocation') 49 | o.root_device_name = json.get('RootDeviceName') 50 | o.root_device_type = json.get('RootDeviceType') 51 | o.state = json.get('State') 52 | o.virtualization_type = json.get('VirtualizationType') 53 | 54 | o.tags = [AWSTag.object_with_json(tag) for tag in json.get('Tags', [])] 55 | ebs_snapshots = [ 56 | AWSBlockDevice.object_with_json(block_device) for block_device 57 | in json.get('BlockDeviceMappings', []) 58 | ] 59 | o.block_device_mappings = [f for f in ebs_snapshots if f] 60 | 61 | return o 62 | 63 | def __repr__(self): 64 | return '{0}: {1} {2}'.format(self.__class__.__name__, 65 | self.id, 66 | self.creation_date) 67 | 68 | 69 | class AWSEC2Instance(object): 70 | def __init__(self): 71 | self.id = None 72 | self.name = None 73 | self.launch_time = None 74 | self.private_ip_address = None 75 | self.public_ip_address = None 76 | self.vpc_id = None 77 | self.image_id = None 78 | self.private_dns_name = None 79 | self.key_name = None 80 | self.subnet_id = None 81 | self.instance_type = None 82 | self.availability_zone = None 83 | self.asg_name = None 84 | self.tags = [] 85 | 86 | def __str__(self): 87 | return str({ 88 | 'id': self.id, 89 | 'name': self.name, 90 | 'image_id': self.image_id, 91 | 'launch_time': self.launch_time, 92 | }) 93 | 94 | @staticmethod 95 | def object_with_json(json): 96 | if json is None: 97 | return None 98 | 99 | o = AWSEC2Instance() 100 | o.id = json.get('InstanceId') 101 | o.name = json.get('PrivateDnsName') 102 | o.launch_time = json.get('LaunchTime') 103 | o.private_ip_address = json.get('PrivateIpAddress') 104 | o.public_ip_address = json.get('PublicIpAddress') 105 | o.vpc_id = json.get('VpcId') 106 | o.image_id = json.get('ImageId') 107 | o.private_dns_name = json.get('PrivateDnsName') 108 | o.key_name = json.get('KeyName') 109 | o.subnet_id = json.get('SubnetId') 110 | o.instance_type = json.get('InstanceType') 111 | o.availability_zone = json.get('Placement').get('AvailabilityZone') 112 | o.tags = [AWSTag.object_with_json(tag) for tag in json.get('Tags', [])] 113 | 114 | return o 115 | 116 | 117 | class AWSBlockDevice(object): 118 | def __init__(self): 119 | self.device_name = None 120 | self.snapshot_id = None 121 | self.volume_size = None 122 | self.volume_type = None 123 | self.encrypted = None 124 | 125 | def __str__(self): 126 | return str({ 127 | 'device_name': self.device_name, 128 | 'snapshot_id': self.snapshot_id, 129 | 'volume_size': self.volume_size, 130 | 'volume_type': self.volume_type, 131 | 'encrypted': self.encrypted, 132 | }) 133 | 134 | @staticmethod 135 | def object_with_json(json): 136 | if json is None: 137 | return None 138 | 139 | ebs = json.get('Ebs') 140 | if ebs is None: 141 | return None 142 | 143 | o = AWSBlockDevice() 144 | o.device_name = json.get('DeviceName') 145 | o.snapshot_id = ebs.get('SnapshotId') 146 | o.volume_size = ebs.get('VolumeSize') 147 | o.volume_type = ebs.get('VolumeType') 148 | o.encrypted = ebs.get('Encrypted') 149 | 150 | return o 151 | 152 | 153 | class AWSTag(object): 154 | def __init__(self): 155 | self.key = None 156 | self.value = None 157 | 158 | def __str__(self): 159 | return str({ 160 | 'key': self.key, 161 | 'value': self.value, 162 | }) 163 | 164 | @staticmethod 165 | def object_with_json(json): 166 | if json is None: 167 | return None 168 | 169 | o = AWSTag() 170 | o.key = json.get('Key') 171 | o.value = json.get('Value') 172 | return o 173 | -------------------------------------------------------------------------------- /amicleaner/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import print_function 5 | from __future__ import absolute_import 6 | from builtins import input 7 | from builtins import object 8 | import sys 9 | 10 | from amicleaner import __version__ 11 | from .core import AMICleaner, OrphanSnapshotCleaner 12 | from .fetch import Fetcher 13 | from .resources.config import MAPPING_KEY, MAPPING_VALUES, EXCLUDED_MAPPING_VALUES 14 | from .resources.config import TERM 15 | from .utils import Printer, parse_args 16 | 17 | 18 | class App(object): 19 | 20 | def __init__(self, args): 21 | 22 | self.version = args.version 23 | self.mapping_key = args.mapping_key or MAPPING_KEY 24 | self.mapping_values = args.mapping_values or MAPPING_VALUES 25 | self.excluded_mapping_values = args.excluded_mapping_values or EXCLUDED_MAPPING_VALUES 26 | self.keep_previous = args.keep_previous 27 | self.check_orphans = args.check_orphans 28 | self.from_ids = args.from_ids 29 | self.full_report = args.full_report 30 | self.force_delete = args.force_delete 31 | self.ami_min_days = args.ami_min_days 32 | 33 | self.mapping_strategy = { 34 | "key": self.mapping_key, 35 | "values": self.mapping_values, 36 | "excluded": self.excluded_mapping_values, 37 | } 38 | 39 | def fetch_candidates(self, available_amis=None, excluded_amis=None): 40 | 41 | """ 42 | Collects created AMIs, 43 | AMIs from ec2 instances, launch configurations, autoscaling groups 44 | and returns unused AMIs. 45 | """ 46 | f = Fetcher() 47 | 48 | available_amis = available_amis or f.fetch_available_amis() 49 | excluded_amis = excluded_amis or [] 50 | 51 | if not excluded_amis: 52 | excluded_amis += f.fetch_unattached_lc() 53 | excluded_amis += f.fetch_zeroed_asg() 54 | excluded_amis += f.fetch_instances() 55 | 56 | candidates = [v 57 | for k, v 58 | in available_amis.items() 59 | if k not in excluded_amis] 60 | return candidates 61 | 62 | def prepare_candidates(self, candidates_amis=None): 63 | 64 | """ From an AMI list apply mapping strategy and filters """ 65 | 66 | candidates_amis = candidates_amis or self.fetch_candidates() 67 | 68 | if not candidates_amis: 69 | return None 70 | 71 | c = AMICleaner() 72 | 73 | mapped_amis = c.map_candidates( 74 | candidates_amis=candidates_amis, 75 | mapping_strategy=self.mapping_strategy, 76 | ) 77 | 78 | if not mapped_amis: 79 | return None 80 | 81 | candidates = [] 82 | report = dict() 83 | 84 | for group_name, amis in mapped_amis.items(): 85 | group_name = group_name or "" 86 | 87 | if not group_name: 88 | report["no-tags (excluded)"] = amis 89 | else: 90 | reduced = c.reduce_candidates(amis, self.keep_previous, self.ami_min_days) 91 | if reduced: 92 | report[group_name] = reduced 93 | candidates.extend(reduced) 94 | 95 | Printer.print_report(report, self.full_report) 96 | 97 | return candidates 98 | 99 | def prepare_delete_amis(self, candidates, from_ids=False): 100 | 101 | """ Prepare deletion of candidates AMIs""" 102 | 103 | failed = [] 104 | 105 | if from_ids: 106 | print(TERM.bold("\nCleaning from {} AMI id(s) ...".format( 107 | len(candidates)) 108 | )) 109 | failed = AMICleaner().remove_amis_from_ids(candidates) 110 | else: 111 | print(TERM.bold("\nCleaning {} AMIs ...".format(len(candidates)))) 112 | failed = AMICleaner().remove_amis(candidates) 113 | 114 | if failed: 115 | print(TERM.red("\n{0} failed snapshots".format(len(failed)))) 116 | Printer.print_failed_snapshots(failed) 117 | 118 | def clean_orphans(self): 119 | 120 | """ Find and removes orphan snapshots """ 121 | 122 | cleaner = OrphanSnapshotCleaner() 123 | snaps = cleaner.fetch() 124 | 125 | if not snaps: 126 | return 127 | 128 | Printer.print_orphan_snapshots(snaps) 129 | 130 | answer = input( 131 | "Do you want to continue and remove {} orphan snapshots " 132 | "[y/N] ? : ".format(len(snaps))) 133 | confirm = (answer.lower() == "y") 134 | 135 | if confirm: 136 | print("Removing orphan snapshots... ") 137 | count = cleaner.clean(snaps) 138 | print("\n{0} orphan snapshots successfully removed !".format(count)) 139 | 140 | def print_defaults(self): 141 | 142 | print(TERM.bold("\nDefault values : ==>")) 143 | print(TERM.green("mapping_key : {0}".format(self.mapping_key))) 144 | print(TERM.green("mapping_values : {0}".format(self.mapping_values))) 145 | print(TERM.green("excluded_mapping_values : {0}".format(self.excluded_mapping_values))) 146 | print(TERM.green("keep_previous : {0}".format(self.keep_previous))) 147 | print(TERM.green("ami_min_days : {0}".format(self.ami_min_days))) 148 | 149 | @staticmethod 150 | def print_version(): 151 | print(__version__) 152 | 153 | def run_cli(self): 154 | 155 | if self.check_orphans: 156 | self.clean_orphans() 157 | 158 | if self.from_ids: 159 | self.prepare_delete_amis(self.from_ids, from_ids=True) 160 | else: 161 | # print defaults 162 | self.print_defaults() 163 | 164 | print(TERM.bold("\nRetrieving AMIs to clean ...")) 165 | candidates = self.prepare_candidates() 166 | 167 | if not candidates: 168 | sys.exit(0) 169 | 170 | delete = False 171 | 172 | if not self.force_delete: 173 | answer = input( 174 | "Do you want to continue and remove {} AMIs " 175 | "[y/N] ? : ".format(len(candidates))) 176 | delete = (answer.lower() == "y") 177 | else: 178 | delete = True 179 | 180 | if delete: 181 | self.prepare_delete_amis(candidates) 182 | 183 | 184 | def main(): 185 | 186 | args = parse_args(sys.argv[1:]) 187 | if not args: 188 | sys.exit(1) 189 | 190 | app = App(args) 191 | 192 | if app.version is True: 193 | app.print_version() 194 | else: 195 | app.run_cli() 196 | 197 | 198 | if __name__ == "__main__": 199 | main() 200 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | 5 | import boto3 6 | from moto import mock_ec2, mock_autoscaling 7 | from datetime import datetime 8 | 9 | from amicleaner.cli import App 10 | from amicleaner.fetch import Fetcher 11 | from amicleaner.utils import parse_args, Printer 12 | from amicleaner.resources.models import AMI, AWSEC2Instance 13 | 14 | 15 | @mock_ec2 16 | @mock_autoscaling 17 | def test_fetch_and_prepare(): 18 | parser = parse_args(['--keep-previous', '0']) 19 | assert App(parser).prepare_candidates() is None 20 | 21 | 22 | @mock_ec2 23 | @mock_autoscaling 24 | def test_deletion(): 25 | """ Test deletion methods """ 26 | 27 | base_ami = "ami-1234abcd" 28 | 29 | parser = parse_args( 30 | [ 31 | '--keep-previous', '0', 32 | '--mapping-key', 'name', 33 | '--mapping-values', 'test-ami'] 34 | ) 35 | 36 | ec2 = boto3.client('ec2') 37 | reservation = ec2.run_instances( 38 | ImageId=base_ami, MinCount=1, MaxCount=1 39 | ) 40 | instance = reservation["Instances"][0] 41 | 42 | # create amis 43 | images = [] 44 | for i in range(5): 45 | image = ec2.create_image( 46 | InstanceId=instance.get("InstanceId"), 47 | Name="test-ami" 48 | ) 49 | images.append(image.get("ImageId")) 50 | 51 | # delete one AMI by id 52 | app = App(parser) 53 | asg = boto3.client('autoscaling') 54 | f = Fetcher(ec2=ec2, autoscaling=asg) 55 | 56 | assert len(f.fetch_available_amis()) == 5 57 | assert app.prepare_delete_amis( 58 | candidates=[images[4]], from_ids=True 59 | ) is None 60 | assert len(f.fetch_available_amis()) == 4 61 | 62 | # delete with mapping strategy 63 | candidates = app.prepare_candidates() 64 | assert len(candidates) == 4 65 | assert app.prepare_delete_amis(candidates) is None 66 | assert len(f.fetch_available_amis()) == 0 67 | 68 | 69 | @mock_ec2 70 | @mock_autoscaling 71 | def test_deletion_ami_min_days(): 72 | """ Test deletion methods """ 73 | 74 | # creating tests objects 75 | first_ami = AMI() 76 | first_ami.name = "test-ami" 77 | first_ami.id = 'ami-28c2b348' 78 | first_ami.creation_date = "2017-11-04T01:35:31.000Z" 79 | 80 | second_ami = AMI() 81 | second_ami.name = "test-ami" 82 | second_ami.id = 'ami-28c2b349' 83 | second_ami.creation_date = "2017-11-04T01:35:31.000Z" 84 | 85 | # constructing dicts 86 | amis_dict = dict() 87 | amis_dict[first_ami.id] = first_ami 88 | amis_dict[second_ami.id] = second_ami 89 | 90 | parser = parse_args( 91 | [ 92 | '--keep-previous', '0', 93 | '--ami-min-days', '1', 94 | '--mapping-key', 'name', 95 | '--mapping-values', 'test-ami'] 96 | ) 97 | 98 | app = App(parser) 99 | # testing filter 100 | candidates = app.fetch_candidates(amis_dict) 101 | 102 | candidates_tobedeleted = app.prepare_candidates(candidates) 103 | assert len(candidates) == 2 104 | assert len(candidates_tobedeleted) == 2 105 | 106 | parser = parse_args( 107 | [ 108 | '--keep-previous', '0', 109 | '--ami-min-days', '10000', 110 | '--mapping-key', 'name', 111 | '--mapping-values', 'test-ami'] 112 | ) 113 | 114 | app = App(parser) 115 | candidates_tobedeleted2 = app.prepare_candidates(candidates) 116 | assert len(candidates) == 2 117 | assert len(candidates_tobedeleted2) == 0 118 | 119 | 120 | def test_fetch_candidates(): 121 | # creating tests objects 122 | first_ami = AMI() 123 | first_ami.id = 'ami-28c2b348' 124 | first_ami.creation_date = datetime.now() 125 | 126 | first_instance = AWSEC2Instance() 127 | first_instance.id = 'i-9f9f6a2a' 128 | first_instance.name = "first-instance" 129 | first_instance.image_id = first_ami.id 130 | first_instance.launch_time = datetime.now() 131 | 132 | second_ami = AMI() 133 | second_ami.id = 'unused-ami' 134 | second_ami.creation_date = datetime.now() 135 | 136 | second_instance = AWSEC2Instance() 137 | second_instance.id = 'i-9f9f6a2b' 138 | second_instance.name = "second-instance" 139 | second_instance.image_id = first_ami.id 140 | second_instance.launch_time = datetime.now() 141 | 142 | # constructing dicts 143 | amis_dict = dict() 144 | amis_dict[first_ami.id] = first_ami 145 | amis_dict[second_ami.id] = second_ami 146 | 147 | instances_dict = dict() 148 | instances_dict[first_instance.image_id] = instances_dict 149 | instances_dict[second_instance.image_id] = second_instance 150 | 151 | # testing filter 152 | unused_ami_dict = App(parse_args([])).fetch_candidates( 153 | amis_dict, list(instances_dict) 154 | ) 155 | assert len(unused_ami_dict) == 1 156 | assert amis_dict.get('unused-ami') is not None 157 | 158 | 159 | def test_parse_args_no_args(): 160 | parser = parse_args([]) 161 | assert parser.force_delete is False 162 | assert parser.from_ids is None 163 | assert parser.from_ids is None 164 | assert parser.full_report is False 165 | assert parser.mapping_key is None 166 | assert parser.mapping_values is None 167 | assert parser.keep_previous is 4 168 | assert parser.ami_min_days is -1 169 | 170 | 171 | def test_parse_args(): 172 | parser = parse_args(['--keep-previous', '10', '--full-report']) 173 | assert parser.keep_previous == 10 174 | assert parser.full_report is True 175 | 176 | parser = parse_args(['--mapping-key', 'name']) 177 | assert parser is None 178 | 179 | parser = parse_args(['--mapping-key', 'tags', 180 | '--mapping-values', 'group1', 'group2']) 181 | assert parser.mapping_key == "tags" 182 | assert len(parser.mapping_values) == 2 183 | 184 | parser = parse_args(['--ami-min-days', '10', '--full-report']) 185 | assert parser.ami_min_days == 10 186 | assert parser.full_report is True 187 | 188 | 189 | def test_print_report(): 190 | assert Printer.print_report({}) is None 191 | 192 | with open("tests/mocks/ami.json") as mock_file: 193 | json_to_parse = json.load(mock_file) 194 | ami = AMI.object_with_json(json_to_parse) 195 | candidates = {'test': [ami]} 196 | assert Printer.print_report(candidates) is None 197 | assert Printer.print_report(candidates, full_report=True) is None 198 | 199 | 200 | def test_print_failed_snapshots(): 201 | assert Printer.print_failed_snapshots({}) is None 202 | assert Printer.print_failed_snapshots(["ami-one", "ami-two"]) is None 203 | 204 | 205 | def test_print_orphan_snapshots(): 206 | assert Printer.print_orphan_snapshots({}) is None 207 | assert Printer.print_orphan_snapshots(["ami-one", "ami-two"]) is None 208 | 209 | 210 | def test_print_defaults(): 211 | assert App(parse_args([])).print_defaults() is None 212 | -------------------------------------------------------------------------------- /amicleaner/core.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import print_function 5 | from __future__ import absolute_import 6 | from builtins import object 7 | import boto3 8 | from botocore.exceptions import ClientError 9 | from botocore.config import Config 10 | 11 | from .resources.config import BOTO3_RETRIES 12 | from .resources.models import AMI 13 | 14 | from datetime import datetime 15 | 16 | 17 | class OrphanSnapshotCleaner(object): 18 | 19 | """ Finds and removes ebs snapshots left orphaned """ 20 | 21 | def __init__(self, ec2=None): 22 | self.ec2 = ec2 or boto3.client('ec2', config=Config(retries={'max_attempts': BOTO3_RETRIES})) 23 | 24 | def get_snapshots_filter(self): 25 | 26 | return [{ 27 | 'Name': 'status', 28 | 'Values': [ 29 | 'completed', 30 | ]}, { 31 | 'Name': 'description', 32 | 'Values': [ 33 | 'Created by CreateImage*' 34 | ] 35 | }] 36 | 37 | def get_owner_id(self, images_json): 38 | 39 | """ Return AWS owner id from a ami json list """ 40 | 41 | images = images_json or [] 42 | 43 | if not images: 44 | return None 45 | 46 | return images[0].get("OwnerId", "") 47 | 48 | def fetch(self): 49 | 50 | """ retrieve orphan snapshots """ 51 | 52 | resp = self.ec2.describe_images(Owners=['self']) 53 | 54 | used_snaps = [ 55 | ebs.get("Ebs", {}).get("SnapshotId") 56 | for image in resp.get("Images") 57 | for ebs in image.get("BlockDeviceMappings") 58 | ] 59 | snap_filter = self.get_snapshots_filter() 60 | owner_id = self.get_owner_id(resp.get("Images")) 61 | 62 | if not owner_id: 63 | return [] 64 | 65 | # all snapshots created for AMIs 66 | resp = self.ec2.describe_snapshots( 67 | Filters=snap_filter, OwnerIds=[owner_id] 68 | ) 69 | 70 | all_snaps = [snap.get("SnapshotId") for snap in resp["Snapshots"]] 71 | return list(set(all_snaps) - set(used_snaps)) 72 | 73 | def clean(self, snapshots): 74 | 75 | """ 76 | actually deletes the snapshots with an array 77 | of snapshots ids 78 | """ 79 | count = len(snapshots) 80 | 81 | snapshots = snapshots or [] 82 | 83 | for snap in snapshots: 84 | try: 85 | self.ec2.delete_snapshot(SnapshotId=snap) 86 | except ClientError as e: 87 | self.log("{0} deletion failed : {1}".format(snap, e)) 88 | count -= 1 89 | 90 | return count 91 | 92 | def log(self, msg): 93 | print(msg) 94 | 95 | 96 | class AMICleaner(object): 97 | 98 | def __init__(self, ec2=None): 99 | self.ec2 = ec2 or boto3.client('ec2', config=Config(retries={'max_attempts': BOTO3_RETRIES})) 100 | 101 | @staticmethod 102 | def get_ami_sorting_key(ami): 103 | 104 | """ return a key for sorting array of AMIs """ 105 | 106 | return ami.creation_date 107 | 108 | def remove_amis(self, amis): 109 | 110 | """ 111 | deregister AMIs (array) and removes related snapshots 112 | :param amis: array of AMI objects 113 | """ 114 | 115 | failed_snapshots = [] 116 | 117 | amis = amis or [] 118 | for ami in amis: 119 | self.ec2.deregister_image(ImageId=ami.id) 120 | print("{0} deregistered".format(ami.id)) 121 | for block_device in ami.block_device_mappings: 122 | if block_device.snapshot_id is not None: 123 | try: 124 | self.ec2.delete_snapshot( 125 | SnapshotId=block_device.snapshot_id 126 | ) 127 | except ClientError: 128 | failed_snapshots.append(block_device.snapshot_id) 129 | print("{0} deleted\n".format(block_device.snapshot_id)) 130 | 131 | return failed_snapshots 132 | 133 | def remove_amis_from_ids(self, ami_ids): 134 | 135 | """ 136 | takes a list of AMI ids, verify on aws and removes them 137 | :param ami_ids: array of AMI ids 138 | """ 139 | 140 | if not ami_ids: 141 | return False 142 | 143 | my_custom_images = self.ec2.describe_images( 144 | Owners=['self'], 145 | ImageIds=ami_ids 146 | ) 147 | amis = [] 148 | for image_json in my_custom_images.get('Images'): 149 | ami = AMI.object_with_json(image_json) 150 | amis.append(ami) 151 | 152 | return self.remove_amis(amis) 153 | 154 | def map_candidates(self, candidates_amis=None, mapping_strategy=None): 155 | 156 | """ 157 | Given a dict of AMIs to clean, and a mapping strategy (see config.py), 158 | this function returns a dict of grouped amis with the mapping strategy 159 | name as a key. 160 | 161 | example : 162 | mapping_strategy = {"key": "name", "values": ["ubuntu", "debian"]} 163 | or 164 | mapping_strategy = {"key": "tags", "values": ["env", "role"], "excluded": ["master", "develop"]} 165 | 166 | print map_candidates(candidates_amis, mapping_strategy) 167 | ==> 168 | { 169 | "ubuntu": [obj1, obj3], 170 | "debian": [obj2, obj5] 171 | } 172 | 173 | or 174 | ==> 175 | { 176 | "prod.nginx": [obj1, obj3], 177 | "prod.tomcat": [obj2, obj5], 178 | "test.nginx": [obj6, obj7], 179 | } 180 | """ 181 | 182 | if not candidates_amis: 183 | return {} 184 | 185 | mapping_strategy = mapping_strategy or {} 186 | 187 | if not mapping_strategy: 188 | return candidates_amis 189 | 190 | candidates_map = dict() 191 | for ami in candidates_amis: 192 | # case : grouping on name 193 | if mapping_strategy.get("key") == "name": 194 | for mapping_value in mapping_strategy.get("values"): 195 | if mapping_value in ami.name: 196 | mapping_list = candidates_map.get(mapping_value) or [] 197 | mapping_list.append(ami) 198 | candidates_map[mapping_value] = mapping_list 199 | # case : grouping on tags 200 | elif mapping_strategy.get("key") == "tags": 201 | mapping_value = self.tags_values_to_string( 202 | ami.tags, 203 | mapping_strategy.get("values") 204 | ) 205 | if mapping_strategy.get("excluded"): 206 | for excluded_mapping_value in mapping_strategy.get("excluded"): 207 | if excluded_mapping_value not in mapping_value: 208 | mapping_list = candidates_map.get(mapping_value) or [] 209 | mapping_list.append(ami) 210 | candidates_map[mapping_value] = mapping_list 211 | else: 212 | mapping_list = candidates_map.get(mapping_value) or [] 213 | mapping_list.append(ami) 214 | candidates_map[mapping_value] = mapping_list 215 | 216 | return candidates_map 217 | 218 | @staticmethod 219 | def tags_values_to_string(tags, filters=None): 220 | """ 221 | filters tags(key,value) array and return a string with tags values 222 | :tags is an array of AWSTag objects 223 | """ 224 | 225 | if tags is None: 226 | return None 227 | 228 | tag_values = [] 229 | 230 | filters = filters or [] 231 | filters_to_string = ".".join(filters) 232 | 233 | for tag in tags: 234 | if not filters: 235 | tag_values.append(tag.value) 236 | elif tag.key in filters_to_string: 237 | tag_values.append(tag.value) 238 | 239 | return ".".join(sorted(tag_values)) 240 | 241 | def reduce_candidates(self, mapped_candidates_ami, keep_previous=0, ami_min_days=-1): 242 | 243 | """ 244 | Given a array of AMIs to clean this function return a subsequent 245 | list by preserving a given number of them (history) based on creation 246 | time and rotation_strategy param 247 | """ 248 | 249 | result_amis = [] 250 | result_amis.extend(mapped_candidates_ami) 251 | 252 | if ami_min_days > 0: 253 | for ami in mapped_candidates_ami: 254 | f_date = datetime.strptime(ami.creation_date, '%Y-%m-%dT%H:%M:%S.%fZ') 255 | present = datetime.now() 256 | delta = present - f_date 257 | if delta.days < ami_min_days: 258 | result_amis.remove(ami) 259 | 260 | mapped_candidates_ami = result_amis 261 | 262 | if not keep_previous: 263 | return mapped_candidates_ami 264 | 265 | if not mapped_candidates_ami: 266 | return mapped_candidates_ami 267 | 268 | amis = sorted( 269 | mapped_candidates_ami, 270 | key=self.get_ami_sorting_key, 271 | reverse=True 272 | ) 273 | 274 | return amis[keep_previous:] 275 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from datetime import datetime 4 | from moto import mock_ec2 5 | 6 | from amicleaner.core import AMICleaner, OrphanSnapshotCleaner 7 | from amicleaner.resources.models import AMI, AWSTag, AWSBlockDevice 8 | 9 | 10 | def test_map_candidates_with_null_arguments(): 11 | assert AMICleaner().map_candidates({}, {}) == {} 12 | 13 | 14 | def test_tags_values_to_string(): 15 | first_tag = AWSTag() 16 | first_tag.key = "Key1" 17 | first_tag.value = "Value1" 18 | 19 | second_tag = AWSTag() 20 | second_tag.key = "Key2" 21 | second_tag.value = "Value2" 22 | 23 | third_tag = AWSTag() 24 | third_tag.key = "Key3" 25 | third_tag.value = "Value3" 26 | 27 | fourth_tag = AWSTag() 28 | fourth_tag.key = "Key4" 29 | fourth_tag.value = "Value4" 30 | 31 | tags = [first_tag, third_tag, second_tag, fourth_tag] 32 | filters = ["Key2", "Key3"] 33 | 34 | tags_values_string = AMICleaner.tags_values_to_string(tags, filters) 35 | assert tags_values_string is not None 36 | assert tags_values_string == "Value2.Value3" 37 | 38 | 39 | def test_tags_values_to_string_with_none(): 40 | assert AMICleaner.tags_values_to_string(None) is None 41 | 42 | 43 | def test_tags_values_to_string_without_filters(): 44 | first_tag = AWSTag() 45 | first_tag.key = "Key1" 46 | first_tag.value = "Value1" 47 | 48 | second_tag = AWSTag() 49 | second_tag.key = "Key2" 50 | second_tag.value = "Value2" 51 | 52 | third_tag = AWSTag() 53 | third_tag.key = "Key3" 54 | third_tag.value = "Value3" 55 | 56 | tags = [first_tag, third_tag, second_tag] 57 | filters = [] 58 | 59 | tags_values_string = AMICleaner.tags_values_to_string(tags, filters) 60 | assert tags_values_string is not None 61 | assert tags_values_string == "Value1.Value2.Value3" 62 | 63 | 64 | def test_map_with_names(): 65 | # creating tests objects 66 | first_ami = AMI() 67 | first_ami.id = 'ami-28c2b348' 68 | first_ami.name = "ubuntu-20160102" 69 | first_ami.creation_date = datetime.now() 70 | 71 | second_ami = AMI() 72 | second_ami.id = 'ami-28c2b349' 73 | second_ami.name = "ubuntu-20160103" 74 | second_ami.creation_date = datetime.now() 75 | 76 | third_ami = AMI() 77 | third_ami.id = 'ami-28c2b350' 78 | third_ami.name = "debian-20160104" 79 | third_ami.creation_date = datetime.now() 80 | 81 | # creating amis to drop dict 82 | candidates = [first_ami, second_ami, third_ami] 83 | 84 | # grouping strategy 85 | grouping_strategy = {"key": "name", "values": ["ubuntu", "debian"]} 86 | 87 | grouped_amis = AMICleaner().map_candidates(candidates, grouping_strategy) 88 | assert grouped_amis is not None 89 | assert len(grouped_amis.get('ubuntu')) == 2 90 | assert len(grouped_amis.get('debian')) == 1 91 | 92 | 93 | def test_map_with_tags(): 94 | # tags 95 | stack_tag = AWSTag() 96 | stack_tag.key = "stack" 97 | stack_tag.value = "web-server" 98 | 99 | env_tag = AWSTag() 100 | env_tag.key = "env" 101 | env_tag.value = "prod" 102 | 103 | # creating tests objects 104 | # prod and web-server 105 | first_ami = AMI() 106 | first_ami.id = 'ami-28c2b348' 107 | first_ami.name = "ubuntu-20160102" 108 | first_ami.tags.append(stack_tag) 109 | first_ami.tags.append(env_tag) 110 | first_ami.creation_date = datetime.now() 111 | 112 | # just prod 113 | second_ami = AMI() 114 | second_ami.id = 'ami-28c2b349' 115 | second_ami.name = "ubuntu-20160103" 116 | second_ami.tags.append(env_tag) 117 | second_ami.creation_date = datetime.now() 118 | 119 | # prod and web-server 120 | third_ami = AMI() 121 | third_ami.id = 'ami-28c2b350' 122 | third_ami.name = "debian-20160104" 123 | third_ami.tags.append(stack_tag) 124 | third_ami.tags.append(env_tag) 125 | third_ami.creation_date = datetime.now() 126 | 127 | # creating amis to drop dict 128 | candidates = [first_ami, second_ami, third_ami] 129 | 130 | # grouping strategy 131 | grouping_strategy = {"key": "tags", "values": ["stack", "env"]} 132 | grouped_amis = AMICleaner().map_candidates(candidates, grouping_strategy) 133 | assert grouped_amis is not None 134 | assert len(grouped_amis.get("prod")) == 1 135 | assert len(grouped_amis.get("prod.web-server")) == 2 136 | 137 | 138 | def test_map_with_tag_exclusions(): 139 | # tags 140 | stack_tag = AWSTag() 141 | stack_tag.key = "stack" 142 | stack_tag.value = "web-server" 143 | 144 | env_tag = AWSTag() 145 | env_tag.key = "env" 146 | env_tag.value = "prod" 147 | 148 | # creating tests objects 149 | # prod and web-server 150 | first_ami = AMI() 151 | first_ami.id = 'ami-28c2b348' 152 | first_ami.name = "ubuntu-20160102" 153 | first_ami.tags.append(stack_tag) 154 | first_ami.tags.append(env_tag) 155 | first_ami.creation_date = datetime.now() 156 | 157 | # just prod 158 | second_ami = AMI() 159 | second_ami.id = 'ami-28c2b349' 160 | second_ami.name = "ubuntu-20160103" 161 | second_ami.tags.append(env_tag) 162 | second_ami.creation_date = datetime.now() 163 | 164 | # just web-server 165 | third_ami = AMI() 166 | third_ami.id = 'ami-28c2b350' 167 | third_ami.name = "debian-20160104" 168 | third_ami.tags.append(stack_tag) 169 | third_ami.creation_date = datetime.now() 170 | 171 | # creating amis to drop dict 172 | candidates = [first_ami, second_ami, third_ami] 173 | 174 | # grouping strategy 175 | grouping_strategy = {"key": "tags", "values": ["stack", "env"], "excluded": ["prod"]} 176 | grouped_amis = AMICleaner().map_candidates(candidates, grouping_strategy) 177 | assert grouped_amis is not None 178 | assert grouped_amis.get("prod") is None 179 | assert grouped_amis.get("prod.web-server") is None 180 | assert len(grouped_amis.get("web-server")) == 1 181 | 182 | 183 | def test_reduce_without_rotation_number(): 184 | # creating tests objects 185 | first_ami = AMI() 186 | first_ami.id = 'ami-28c2b348' 187 | first_ami.name = "ubuntu-20160102" 188 | first_ami.creation_date = datetime(2016, 1, 10) 189 | 190 | # just prod 191 | second_ami = AMI() 192 | second_ami.id = 'ami-28c2b349' 193 | second_ami.name = "ubuntu-20160103" 194 | second_ami.creation_date = datetime(2016, 1, 11) 195 | 196 | # prod and web-server 197 | third_ami = AMI() 198 | third_ami.id = 'ami-28c2b350' 199 | third_ami.name = "debian-20160104" 200 | third_ami.creation_date = datetime(2016, 1, 12) 201 | 202 | # creating amis to drop dict 203 | candidates = [second_ami, third_ami, first_ami] 204 | 205 | assert AMICleaner().reduce_candidates(candidates) == candidates 206 | 207 | 208 | def test_reduce_without_snapshot_id(): 209 | # creating block device 210 | first_block_device = AWSBlockDevice() 211 | first_block_device.snapshot_id = None 212 | 213 | # creating tests objects 214 | first_ami = AMI() 215 | first_ami.id = 'ami-28c2b348' 216 | first_ami.name = "ubuntu-20160102" 217 | first_ami.block_device_mappings.append(first_block_device) 218 | 219 | # creating amis to drop dict 220 | candidates = [first_ami] 221 | 222 | assert AMICleaner().reduce_candidates(candidates) == candidates 223 | 224 | 225 | def test_reduce(): 226 | # creating tests objects 227 | first_ami = AMI() 228 | first_ami.id = 'ami-28c2b348' 229 | first_ami.name = "ubuntu-20160102" 230 | first_ami.creation_date = datetime(2016, 1, 10) 231 | 232 | # just prod 233 | second_ami = AMI() 234 | second_ami.id = 'ami-28c2b349' 235 | second_ami.name = "ubuntu-20160103" 236 | second_ami.creation_date = datetime(2016, 1, 11) 237 | 238 | # prod and web-server 239 | third_ami = AMI() 240 | third_ami.id = 'ami-28c2b350' 241 | third_ami.name = "debian-20160104" 242 | third_ami.creation_date = datetime(2016, 1, 12) 243 | 244 | # keep 2 recent amis 245 | candidates = [second_ami, third_ami, first_ami] 246 | rotation_number = 2 247 | cleaner = AMICleaner() 248 | left = cleaner.reduce_candidates(candidates, rotation_number) 249 | assert len(left) == 1 250 | assert left[0].id == first_ami.id 251 | 252 | # keep 1 recent ami 253 | rotation_number = 1 254 | left = cleaner.reduce_candidates(candidates, rotation_number) 255 | assert len(left) == 2 256 | assert left[0].id == second_ami.id 257 | 258 | # keep 5 recent amis 259 | rotation_number = 5 260 | left = cleaner.reduce_candidates(candidates, rotation_number) 261 | assert len(left) == 0 262 | 263 | 264 | def test_remove_ami_from_none(): 265 | assert AMICleaner().remove_amis(None) == [] 266 | 267 | 268 | @mock_ec2 269 | def test_fetch_snapshots_from_none(): 270 | 271 | cleaner = OrphanSnapshotCleaner() 272 | 273 | assert len(cleaner.get_snapshots_filter()) > 0 274 | assert type(cleaner.fetch()) is list 275 | assert len(cleaner.fetch()) == 0 276 | 277 | 278 | """ 279 | @mock_ec2 280 | def test_fetch_snapshots(): 281 | base_ami = "ami-1234abcd" 282 | 283 | conn = boto3.client('ec2') 284 | reservation = conn.run_instances( 285 | ImageId=base_ami, MinCount=1, MaxCount=1 286 | ) 287 | instance = reservation["Instances"][0] 288 | 289 | # create amis 290 | images = [] 291 | for i in xrange(5): 292 | image = conn.create_image( 293 | InstanceId=instance.get("InstanceId"), 294 | Name="test-ami" 295 | ) 296 | images.append(image.get("ImageId")) 297 | 298 | # deleting two amis, creating orphan snpashots condition 299 | conn.deregister_image(ImageId=images[0]) 300 | conn.deregister_image(ImageId=images[1]) 301 | 302 | cleaner = OrphanSnapshotCleaner() 303 | assert len(cleaner.fetch()) == 0 304 | """ 305 | --------------------------------------------------------------------------------