# Copyright 2015-2019 Capital One Services, LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import absolute_import, division, print_function, unicode_literals
import itertools
import logging
from concurrent.futures import as_completed
import jmespath
from c7n.actions import BaseAction
from c7n.exceptions import ClientError
from c7n.filters import (
AgeFilter, Filter, CrossAccountAccessFilter)
from c7n.manager import resources
from c7n.query import QueryResourceManager, DescribeSource
from c7n.resolver import ValuesFrom
from c7n.utils import local_session, type_schema, chunks
log = logging.getLogger('custodian.ami')
[docs]@resources.register('ami')
class AMI(QueryResourceManager):
[docs] class resource_type(object):
service = 'ec2'
type = 'image'
enum_spec = (
'describe_images', 'Images', None)
detail_spec = None
id = 'ImageId'
filter_name = 'ImageIds'
filter_type = 'list'
name = 'Name'
dimension = None
date = 'CreationDate'
[docs] def resources(self, query=None):
query = query or {}
if query.get('Owners') is None:
query['Owners'] = ['self']
return super(AMI, self).resources(query=query)
[docs] def get_source(self, source_type):
if source_type == 'describe':
return DescribeImageSource(self)
return super(AMI, self).get_source(source_type)
[docs]class DescribeImageSource(DescribeSource):
[docs] def get_resources(self, ids, cache=True):
while ids:
try:
return super(DescribeImageSource, self).get_resources(ids, cache)
except ClientError as e:
bad_ami_ids = ErrorHandler.extract_bad_ami(e)
if bad_ami_ids:
for b in bad_ami_ids:
ids.remove(b)
continue
raise
return []
[docs]class ErrorHandler(object):
[docs] @staticmethod
def extract_bad_ami(e):
"""Handle various client side errors when describing images"""
msg = e.response['Error']['Message']
error = e.response['Error']['Code']
e_ami_ids = None
if error == 'InvalidAMIID.NotFound':
e_ami_ids = [
e_ami_id.strip() for e_ami_id
in msg[msg.find("'[") + 2:msg.rfind("]'")].split(',')]
log.warning("Image not found %s" % e_ami_ids)
elif error == 'InvalidAMIID.Malformed':
e_ami_ids = [msg[msg.find('"') + 1:msg.rfind('"')]]
log.warning("Image id malformed %s" % e_ami_ids)
return e_ami_ids
[docs]@AMI.action_registry.register('deregister')
class Deregister(BaseAction):
"""Action to deregister AMI
To prevent deregistering all AMI, it is advised to use in conjunction with
a filter (such as image-age)
:example:
.. code-block:: yaml
policies:
- name: ami-deregister-old
resource: ami
filters:
- type: image-age
days: 90
actions:
- deregister
"""
schema = type_schema('deregister', **{'delete-snapshots': {'type': 'boolean'}})
permissions = ('ec2:DeregisterImage',)
snap_expr = jmespath.compile('BlockDeviceMappings[].Ebs.SnapshotId')
[docs] def process(self, images):
client = local_session(self.manager.session_factory).client('ec2')
image_count = len(images)
images = [i for i in images if self.manager.ctx.options.account_id == i['OwnerId']]
if len(images) != image_count:
self.log.info("Implicitly filtered %d non owned images", image_count - len(images))
for i in images:
self.manager.retry(client.deregister_image, ImageId=i['ImageId'])
if not self.data.get('delete-snapshots'):
continue
snap_ids = self.snap_expr.search(i) or ()
for s in snap_ids:
try:
self.manager.retry(client.delete_snapshot, SnapshotId=s)
except ClientError as e:
if e.error['Code'] == 'InvalidSnapshot.InUse':
continue
[docs]@AMI.action_registry.register('remove-launch-permissions')
class RemoveLaunchPermissions(BaseAction):
"""Action to remove the ability to launch an instance from an AMI
This action will remove any launch permissions granted to other
AWS accounts from the image, leaving only the owner capable of
launching it
:example:
.. code-block:: yaml
policies:
- name: ami-remove-launch-permissions
resource: ami
filters:
- type: image-age
days: 60
actions:
- remove-launch-permissions
"""
schema = type_schema('remove-launch-permissions')
permissions = ('ec2:ResetImageAttribute',)
[docs] def process(self, images):
client = local_session(self.manager.session_factory).client('ec2')
for i in images:
self.process_image(client, i)
[docs] def process_image(self, client, image):
client.reset_image_attribute(
ImageId=image['ImageId'], Attribute="launchPermission")
[docs]@AMI.action_registry.register('copy')
class Copy(BaseAction):
"""Action to copy AMIs with optional encryption
This action can copy AMIs while optionally encrypting or decrypting
the target AMI. It is advised to use in conjunction with a filter.
Note there is a max in flight of 5 per account/region.
:example:
.. code-block:: yaml
policies:
- name: ami-ensure-encrypted
resource: ami
filters:
- type: value
key: encrypted
value: true
actions:
- type: copy
encrypt: true
key-id: 00000000-0000-0000-0000-000000000000
"""
permissions = ('ec2:CopyImage',)
schema = {
'type': 'object',
'additionalProperties': False,
'properties': {
'type': {'enum': ['copy']},
'name': {'type': 'string'},
'description': {'type': 'string'},
'region': {'type': 'string'},
'encrypt': {'type': 'boolean'},
'key-id': {'type': 'string'}
}
}
[docs] def process(self, images):
session = local_session(self.manager.session_factory)
client = session.client(
'ec2',
region_name=self.data.get('region', None))
for image in images:
client.copy_image(
Name=self.data.get('name', image['Name']),
Description=self.data.get('description', image['Description']),
SourceRegion=session.region_name,
SourceImageId=image['ImageId'],
Encrypted=self.data.get('encrypt', False),
KmsKeyId=self.data.get('key-id', ''))
[docs]@AMI.filter_registry.register('image-age')
class ImageAgeFilter(AgeFilter):
"""Filters images based on the age (in days)
:example:
.. code-block:: yaml
policies:
- name: ami-remove-launch-permissions
resource: ami
filters:
- type: image-age
days: 30
"""
date_attribute = "CreationDate"
schema = type_schema(
'image-age',
op={'$ref': '#/definitions/filters_common/comparison_operators'},
days={'type': 'number', 'minimum': 0})
[docs]@AMI.filter_registry.register('unused')
class ImageUnusedFilter(Filter):
"""Filters images based on usage
true: image has no instances spawned from it
false: image has instances spawned from it
:example:
.. code-block:: yaml
policies:
- name: ami-unused
resource: ami
filters:
- type: unused
value: true
"""
schema = type_schema('unused', value={'type': 'boolean'})
[docs] def get_permissions(self):
return list(itertools.chain([
self.manager.get_resource_manager(m).get_permissions()
for m in ('asg', 'launch-config', 'ec2')]))
def _pull_asg_images(self):
asgs = self.manager.get_resource_manager('asg').resources()
image_ids = set()
lcfgs = set(a['LaunchConfigurationName'] for a in asgs if 'LaunchConfigurationName' in a)
lcfg_mgr = self.manager.get_resource_manager('launch-config')
if lcfgs:
image_ids.update([
lcfg['ImageId'] for lcfg in lcfg_mgr.resources()
if lcfg['LaunchConfigurationName'] in lcfgs])
tmpl_mgr = self.manager.get_resource_manager('launch-template-version')
for tversion in tmpl_mgr.get_resources(
list(tmpl_mgr.get_asg_templates(asgs).keys())):
image_ids.add(tversion['LaunchTemplateData'].get('ImageId'))
return image_ids
def _pull_ec2_images(self):
ec2_manager = self.manager.get_resource_manager('ec2')
return set([i['ImageId'] for i in ec2_manager.resources()])
[docs] def process(self, resources, event=None):
images = self._pull_ec2_images().union(self._pull_asg_images())
if self.data.get('value', True):
return [r for r in resources if r['ImageId'] not in images]
return [r for r in resources if r['ImageId'] in images]
[docs]@AMI.filter_registry.register('cross-account')
class AmiCrossAccountFilter(CrossAccountAccessFilter):
schema = type_schema(
'cross-account',
# white list accounts
whitelist_from=ValuesFrom.schema,
whitelist={'type': 'array', 'items': {'type': 'string'}})
permissions = ('ec2:DescribeImageAttribute',)
[docs] def process_resource_set(self, client, accounts, resource_set):
results = []
for r in resource_set:
attrs = self.manager.retry(
client.describe_image_attribute,
ImageId=r['ImageId'],
Attribute='launchPermission')['LaunchPermissions']
image_accounts = {a.get('Group') or a.get('UserId') for a in attrs}
delta_accounts = image_accounts.difference(accounts)
if delta_accounts:
r['c7n:CrossAccountViolations'] = list(delta_accounts)
results.append(r)
return results
[docs] def process(self, resources, event=None):
results = []
client = local_session(self.manager.session_factory).client('ec2')
accounts = self.get_accounts()
with self.executor_factory(max_workers=2) as w:
futures = []
for resource_set in chunks(resources, 20):
futures.append(
w.submit(
self.process_resource_set, client, accounts, resource_set))
for f in as_completed(futures):
if f.exception():
self.log.error(
"Exception checking cross account access \n %s" % (
f.exception()))
continue
results.extend(f.result())
return results