Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/c7n/resources/ami.py: 35%
310 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-08 06:51 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-08 06:51 +0000
1# Copyright The Cloud Custodian Authors.
2# SPDX-License-Identifier: Apache-2.0
3import re
4import datetime
5from datetime import timedelta
6from dateutil.tz import tzutc
8import itertools
9import logging
11from concurrent.futures import as_completed
13from c7n.actions import BaseAction
14from c7n.exceptions import ClientError, PolicyValidationError
15from c7n.filters import (
16 AgeFilter, ValueFilter, Filter, CrossAccountAccessFilter)
17from c7n.manager import resources
18from c7n.query import QueryResourceManager, DescribeSource, TypeInfo
19from c7n.resolver import ValuesFrom
20from c7n.utils import (
21 local_session,
22 type_schema,
23 chunks,
24 merge_dict_list,
25 parse_date,
26 jmespath_compile
27)
28from c7n import deprecated
31log = logging.getLogger('custodian.ami')
34class DescribeImageSource(DescribeSource):
36 def get_resources(self, ids, cache=True):
37 while ids:
38 try:
39 return super(DescribeImageSource, self).get_resources(ids, cache)
40 except ClientError as e:
41 bad_ami_ids = ErrorHandler.extract_bad_ami(e)
42 if bad_ami_ids:
43 for b in bad_ami_ids:
44 ids.remove(b)
45 continue
46 raise
47 return []
50@resources.register('ami')
51class AMI(QueryResourceManager):
53 class resource_type(TypeInfo):
54 service = 'ec2'
55 arn_type = 'image'
56 enum_spec = (
57 'describe_images', 'Images', None)
58 id = 'ImageId'
59 filter_name = 'ImageIds'
60 filter_type = 'list'
61 name = 'Name'
62 date = 'CreationDate'
63 id_prefix = "ami-"
65 source_mapping = {
66 'describe': DescribeImageSource
67 }
69 def resources(self, query=None):
70 if query is None and 'query' in self.data:
71 query = merge_dict_list(self.data['query'])
72 elif query is None:
73 query = {}
74 if query.get('Owners') is None:
75 query['Owners'] = ['self']
76 return super(AMI, self).resources(query=query)
79class ErrorHandler:
81 @staticmethod
82 def extract_bad_ami(e):
83 """Handle various client side errors when describing images"""
84 msg = e.response['Error']['Message']
85 error = e.response['Error']['Code']
86 e_ami_ids = None
87 if error == 'InvalidAMIID.NotFound':
88 e_ami_ids = [
89 e_ami_id.strip() for e_ami_id
90 in msg[msg.find("'[") + 2:msg.rfind("]'")].split(',')]
91 log.warning("Image not found %s" % e_ami_ids)
92 elif error == 'InvalidAMIID.Malformed':
93 e_ami_ids = [msg[msg.find('"') + 1:msg.rfind('"')]]
94 log.warning("Image id malformed %s" % e_ami_ids)
95 return e_ami_ids
98@AMI.action_registry.register('deregister')
99class Deregister(BaseAction):
100 """Action to deregister AMI
102 To prevent deregistering all AMI, it is advised to use in conjunction with
103 a filter (such as image-age)
105 :example:
107 .. code-block:: yaml
109 policies:
110 - name: ami-deregister-old
111 resource: ami
112 filters:
113 - type: image-age
114 days: 90
115 actions:
116 - deregister
117 """
119 schema = type_schema('deregister', **{'delete-snapshots': {'type': 'boolean'}})
120 permissions = ('ec2:DeregisterImage',)
121 snap_expr = jmespath_compile('BlockDeviceMappings[].Ebs.SnapshotId')
123 def process(self, images):
124 client = local_session(self.manager.session_factory).client('ec2')
125 image_count = len(images)
126 images = self.filter_resources(images, 'OwnerId', self.manager.ctx.options.account_id)
127 if len(images) != image_count:
128 self.log.info("Implicitly filtered %d non owned images", image_count - len(images))
130 for i in images:
131 self.manager.retry(client.deregister_image, ImageId=i['ImageId'])
133 if not self.data.get('delete-snapshots'):
134 continue
135 snap_ids = self.snap_expr.search(i) or ()
136 for s in snap_ids:
137 try:
138 self.manager.retry(client.delete_snapshot, SnapshotId=s)
139 except ClientError as e:
140 if e.response['Error']['Code'] == 'InvalidSnapshot.InUse':
141 continue
144@AMI.action_registry.register('set-deprecation')
145class SetDeprecation(BaseAction):
146 """Action to enable or disable AMI deprecation
148 To prevent deprecation of all AMIs, it is advised to use in conjunction with
149 a filter (such as image-age)
151 :example:
153 .. code-block:: yaml
155 policies:
156 - name: ami-deprecate-old
157 resource: ami
158 filters:
159 - type: image-age
160 days: 30
161 actions:
162 - type: set-deprecation
163 #Number of days from AMI creation
164 age: 90
165 #Number of days from now
166 #days: 90
167 #Specific date/time
168 #date: "2023-11-30"
170 """
172 schema = type_schema(
173 'set-deprecation',
174 date={'type': 'string'},
175 days={'type': 'integer'},
176 age={'type': 'integer'})
177 permissions = ('ec2:EnableImageDeprecation', 'ec2:DisableImageDeprecation')
178 dep_date = None
179 dep_age = None
181 def validate(self):
182 try:
183 if 'date' in self.data:
184 self.dep_date = parse_date(self.data.get('date'))
185 if not self.dep_date:
186 raise PolicyValidationError(
187 "policy:%s filter:%s has invalid date format" % (
188 self.manager.ctx.policy.name, self.type))
189 elif 'days' in self.data:
190 self.dep_date = (datetime.datetime.now(tz=tzutc()) +
191 timedelta(days=int(self.data.get('days'))))
192 elif 'age' in self.data:
193 self.dep_age = (int(self.data.get('age')))
194 except (ValueError, OverflowError):
195 raise PolicyValidationError(
196 "policy:%s filter:%s has invalid time interval" % (
197 self.manager.ctx.policy.name, self.type))
199 def process(self, images):
200 client = local_session(self.manager.session_factory).client('ec2')
201 image_count = len(images)
202 images = self.filter_resources(images, 'OwnerId', self.manager.ctx.options.account_id)
203 if len(images) != image_count:
204 self.log.info("Implicitly filtered %d non owned images", image_count - len(images))
205 for i in images:
206 if not self.dep_date and not self.dep_age:
207 self.manager.retry(client.disable_image_deprecation, ImageId=i['ImageId'])
208 else:
209 if self.dep_age:
210 date = parse_date(i['CreationDate']) + timedelta(days=self.dep_age)
211 else:
212 date = self.dep_date
213 # Hack because AWS won't let you set a deprecation time in the
214 # past - set to now + 1 minute if the time is in the past
215 if date < datetime.datetime.now(tz=tzutc()):
216 odate = str(date)
217 date = datetime.datetime.now(tz=tzutc()) + timedelta(minutes=1)
218 log.warning("Deprecation time %s is in the past for Image %s. Setting to %s.",
219 odate, i['ImageId'], date)
220 self.manager.retry(client.enable_image_deprecation,
221 ImageId=i['ImageId'], DeprecateAt=date)
224@AMI.action_registry.register('remove-launch-permissions')
225class RemoveLaunchPermissions(BaseAction):
226 """Action to remove the ability to launch an instance from an AMI
228 DEPRECATED - use set-permissions instead to support AWS Organizations
229 sharing as well as adding permissions
231 This action will remove any launch permissions granted to other
232 AWS accounts from the image, leaving only the owner capable of
233 launching it
235 :example:
237 .. code-block:: yaml
239 policies:
240 - name: ami-stop-share-old
241 resource: ami
242 filters:
243 - type: image-age
244 days: 60
245 actions:
246 - type: remove-launch-permissions
248 """
249 deprecations = (
250 deprecated.action("use set-permissions instead with 'remove' attribute"),
251 )
252 schema = type_schema(
253 'remove-launch-permissions',
254 accounts={'oneOf': [
255 {'enum': ['matched']},
256 {'type': 'string', 'minLength': 12, 'maxLength': 12}]})
258 permissions = ('ec2:ResetImageAttribute', 'ec2:ModifyImageAttribute',)
260 def validate(self):
261 if 'accounts' in self.data and self.data['accounts'] == 'matched':
262 found = False
263 for f in self.manager.iter_filters():
264 if isinstance(f, AmiCrossAccountFilter):
265 found = True
266 break
267 if not found:
268 raise PolicyValidationError(
269 "policy:%s filter:%s with matched requires cross-account filter" % (
270 self.manager.ctx.policy.name, self.type))
272 def process(self, images):
273 client = local_session(self.manager.session_factory).client('ec2')
274 for i in images:
275 self.process_image(client, i)
277 def process_image(self, client, image):
278 accounts = self.data.get('accounts')
279 if not accounts:
280 return client.reset_image_attribute(
281 ImageId=image['ImageId'], Attribute="launchPermission")
282 if accounts == 'matched':
283 accounts = image.get(AmiCrossAccountFilter.annotation_key)
284 if not accounts:
285 return
286 remove = []
287 if 'all' in accounts:
288 remove.append({'Group': 'all'})
289 accounts.remove('all')
290 remove.extend([{'UserId': a} for a in accounts if not a.startswith('arn:')])
291 if not remove:
292 return
293 client.modify_image_attribute(
294 ImageId=image['ImageId'],
295 LaunchPermission={'Remove': remove},
296 OperationType='remove')
298@AMI.action_registry.register('cancel-launch-permission')
299class CancelLaunchPermissions(BaseAction):
300 """Action to cancel this account's access to another another account's shared AMI
302 If another AWS account shares an image with your account, and you
303 no longer want to allow its use in your account, this action will
304 remove the permission for your account to laucnh from the image.
306 As this is not reversible without accessing the AMI source account, it defaults
307 to running in dryrun mode. Set dryrun to false to enforce.
309 Note this does not apply to AMIs shared by Organization or OU.
310 https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/cancel-sharing-an-AMI.html
312 :example:
314 .. code-block:: yaml
316 policies:
317 - name: ami-cancel-share-to-me-old
318 resource: ami
319 query:
320 - ExecutableUsers: [self]
321 - Owners: []
322 filters:
323 - type: image-age
324 days: 90
325 actions:
326 - type: cancel-launch-permission
328 """
329 schema = type_schema('cancel-launch-permission', dryrun={'type': 'boolean'})
331 permissions = ('ec2:CancelImageLaunchPermission',)
333 def process(self, images):
334 client = local_session(self.manager.session_factory).client('ec2')
335 for i in images:
336 self.process_image(client, i)
338 def process_image(self, client, image):
339 client.cancel_image_launch_permission(
340 ImageId=image['ImageId'],
341 DryRun=self.data.get('dryrun', True))
344@AMI.action_registry.register('set-permissions')
345class SetPermissions(BaseAction):
346 """Set or remove AMI launch permissions
348 This action will add or remove launch permissions granted to other
349 AWS accounts, organizations or organizational units from the image.
351 Use the 'add' and 'remove' parameters to control which principals
352 to add or remove, respectively. The default is to remove any permissions
353 granted to other AWS accounts. Principals can be an AWS account id,
354 an organization ARN, or an organizational unit ARN
356 Use 'remove: matched' in combination with the 'cross-account' filter
357 for more flexible removal options such as preserving access for a set of
358 whitelisted accounts:
360 :example:
362 .. code-block:: yaml
364 policies:
365 - name: ami-share-remove-cross-account
366 resource: ami
367 filters:
368 - type: cross-account
369 whitelist:
370 - '112233445566'
371 - 'arn:aws:organizations::112233445566:organization/o-xxyyzzaabb'
372 - 'arn:aws:organizations::112233445566:ou/o-xxyyzzaabb/ou-xxyy-aabbccdd'
373 actions:
374 - type: set-permissions
375 remove: matched
376 # To remove all permissions
377 # - type: set-permissions
378 # To remove public permissions
379 # - type: set-permissions
380 # remove:
381 # - all
382 # To remove specific permissions
383 # - type: set-permissions
384 # remove:
385 # - '223344556677'
386 # - 'arn:aws:organizations::112233445566:organization/o-zzyyxxbbaa'
387 # - 'arn:aws:organizations::112233445566:ou/o-zzyyxxbbaa/ou-xxyy-ddccbbaa'
388 # To set specific permissions
389 # - type: set-permissions
390 # remove: matched
391 # add:
392 # - '223344556677'
393 # - 'arn:aws:organizations::112233445566:organization/o-zzyyxxbbaa'
394 # - 'arn:aws:organizations::112233445566:ou/o-zzyyxxbbaa/ou-xxyy-ddccbbaa'
395 """
397 schema = type_schema(
398 'set-permissions',
399 remove={'oneOf': [
400 {'enum': ['matched']},
401 {'type': 'array', 'items': {'type': 'string'}}
402 ]},
403 add={'type': 'array', 'items': {'type': 'string'}}
404 )
406 permissions = ('ec2:ResetImageAttribute', 'ec2:ModifyImageAttribute',)
408 def validate(self):
409 if self.data.get('remove') == 'matched':
410 found = False
411 for f in self.manager.iter_filters():
412 if isinstance(f, AmiCrossAccountFilter):
413 found = True
414 break
415 if not found:
416 raise PolicyValidationError(
417 "policy:%s filter:%s with matched requires cross-account filter" % (
418 self.manager.ctx.policy.name, self.type))
420 def process(self, images):
421 client = local_session(self.manager.session_factory).client('ec2')
422 for i in images:
423 self.process_image(client, i)
425 def process_image(self, client, image):
426 to_add = self.data.get('add')
427 to_remove = self.data.get('remove')
428 # Default is to remove all permissions
429 if not to_add and not to_remove:
430 return client.reset_image_attribute(
431 ImageId=image['ImageId'], Attribute="launchPermission")
432 remove = []
433 add = []
434 account_regex = re.compile('\\d{12}')
435 # https://docs.aws.amazon.com/organizations/latest/APIReference/API_Organization.html
436 org_regex = re.compile(
437 r'arn:[a-zA-Z-]+:organizations::\d{12}:organization\/o-[a-z0-9]{10,32}'
438 )
439 # https://docs.aws.amazon.com/organizations/latest/APIReference/API_OrganizationalUnit.html
440 ou_regex = re.compile(
441 r'arn:[a-zA-Z-]+:organizations::\d{12}:ou\/o-[a-z0-9]{10,32}\/ou-[0-9a-z]{4,32}-[0-9a-z]{8,32}'
442 )
443 if to_remove:
444 if 'all' in to_remove:
445 remove.append({'Group': 'all'})
446 to_remove.remove('all')
447 if to_remove == 'matched':
448 to_remove = image.get(AmiCrossAccountFilter.annotation_key)
449 if to_remove:
450 principals = [v for v in to_remove if account_regex.match(v)]
451 if principals:
452 remove.extend([{'UserId': a} for a in principals])
453 principals = [v for v in to_remove if org_regex.match(v)]
454 if principals:
455 remove.extend([{'OrganizationArn': a} for a in principals])
456 principals = [v for v in to_remove if ou_regex.match(v)]
457 if principals:
458 remove.extend([{'OrganizationalUnitArn': a} for a in principals])
460 if to_add:
461 if 'all' in to_add:
462 add.append({'Group': 'all'})
463 to_add.remove('all')
464 if to_add:
465 principals = [v for v in to_add if account_regex.match(v)]
466 if principals:
467 add.extend([{'UserId': a} for a in principals])
468 principals = [v for v in to_add if org_regex.match(v)]
469 if principals:
470 add.extend([{'OrganizationArn': a} for a in principals])
471 principals = [v for v in to_add if ou_regex.match(v)]
472 if principals:
473 add.extend([{'OrganizationalUnitArn': a} for a in principals])
475 if remove:
476 self.manager.retry(client.modify_image_attribute,
477 ImageId=image['ImageId'],
478 LaunchPermission={'Remove': remove},
479 OperationType='remove')
481 if add:
482 self.manager.retry(client.modify_image_attribute,
483 ImageId=image['ImageId'],
484 LaunchPermission={'Add': add},
485 OperationType='add')
488@AMI.action_registry.register('copy')
489class Copy(BaseAction):
490 """Action to copy AMIs with optional encryption
492 This action can copy AMIs while optionally encrypting or decrypting
493 the target AMI. It is advised to use in conjunction with a filter.
495 Note there is a max in flight of 5 per account/region.
497 :example:
499 .. code-block:: yaml
501 policies:
502 - name: ami-ensure-encrypted
503 resource: ami
504 filters:
505 - type: value
506 key: encrypted
507 value: true
508 actions:
509 - type: copy
510 encrypt: true
511 key-id: 00000000-0000-0000-0000-000000000000
512 """
514 permissions = ('ec2:CopyImage',)
515 schema = {
516 'type': 'object',
517 'additionalProperties': False,
518 'properties': {
519 'type': {'enum': ['copy']},
520 'name': {'type': 'string'},
521 'description': {'type': 'string'},
522 'region': {'type': 'string'},
523 'encrypt': {'type': 'boolean'},
524 'key-id': {'type': 'string'}
525 }
526 }
528 def process(self, images):
529 session = local_session(self.manager.session_factory)
530 client = session.client(
531 'ec2',
532 region_name=self.data.get('region', None))
534 for image in images:
535 client.copy_image(
536 Name=self.data.get('name', image['Name']),
537 Description=self.data.get('description', image['Description']),
538 SourceRegion=session.region_name,
539 SourceImageId=image['ImageId'],
540 Encrypted=self.data.get('encrypt', False),
541 KmsKeyId=self.data.get('key-id', ''))
544@AMI.filter_registry.register('image-age')
545class ImageAgeFilter(AgeFilter):
546 """Filters images based on the age (in days)
548 :example:
550 .. code-block:: yaml
552 policies:
553 - name: ami-remove-launch-permissions
554 resource: ami
555 filters:
556 - type: image-age
557 days: 30
558 """
560 date_attribute = "CreationDate"
561 schema = type_schema(
562 'image-age',
563 op={'$ref': '#/definitions/filters_common/comparison_operators'},
564 days={'type': 'number', 'minimum': 0})
567@AMI.filter_registry.register('unused')
568class ImageUnusedFilter(Filter):
569 """Filters images based on usage
571 true: image has no instances spawned from it
572 false: image has instances spawned from it
574 :example:
576 .. code-block:: yaml
578 policies:
579 - name: ami-unused
580 resource: ami
581 filters:
582 - type: unused
583 value: true
584 """
586 schema = type_schema('unused', value={'type': 'boolean'})
588 def get_permissions(self):
589 return list(itertools.chain(*[
590 self.manager.get_resource_manager(m).get_permissions()
591 for m in ('asg', 'launch-config', 'ec2')]))
593 def _pull_asg_images(self):
594 asgs = self.manager.get_resource_manager('asg').resources()
595 image_ids = set()
596 lcfgs = set(a['LaunchConfigurationName'] for a in asgs if 'LaunchConfigurationName' in a)
597 lcfg_mgr = self.manager.get_resource_manager('launch-config')
599 if lcfgs:
600 image_ids.update([
601 lcfg['ImageId'] for lcfg in lcfg_mgr.resources()
602 if lcfg['LaunchConfigurationName'] in lcfgs])
604 tmpl_mgr = self.manager.get_resource_manager('launch-template-version')
605 for tversion in tmpl_mgr.get_resources(
606 list(tmpl_mgr.get_asg_templates(asgs).keys())):
607 image_ids.add(tversion['LaunchTemplateData'].get('ImageId'))
608 return image_ids
610 def _pull_ec2_images(self):
611 ec2_manager = self.manager.get_resource_manager('ec2')
612 return {i['ImageId'] for i in ec2_manager.resources()}
614 def process(self, resources, event=None):
615 images = self._pull_ec2_images().union(self._pull_asg_images())
616 if self.data.get('value', True):
617 return [r for r in resources if r['ImageId'] not in images]
618 return [r for r in resources if r['ImageId'] in images]
621@AMI.filter_registry.register('cross-account')
622class AmiCrossAccountFilter(CrossAccountAccessFilter):
624 schema = type_schema(
625 'cross-account',
626 # white list accounts
627 whitelist_from=ValuesFrom.schema,
628 whitelist={'type': 'array', 'items': {'type': 'string'}})
630 permissions = ('ec2:DescribeImageAttribute',)
631 annotation_key = 'c7n:CrossAccountViolations'
633 def process_resource_set(self, client, accounts, resource_set):
634 results = []
635 for r in resource_set:
636 attrs = self.manager.retry(
637 client.describe_image_attribute,
638 ImageId=r['ImageId'],
639 Attribute='launchPermission')['LaunchPermissions']
640 r['c7n:LaunchPermissions'] = attrs
641 image_accounts = {
642 a.get('Group') or a.get('UserId') or
643 a.get('OrganizationArn') or a.get('OrganizationalUnitArn')
644 for a in attrs
645 }
646 delta_accounts = image_accounts.difference(accounts)
647 if delta_accounts:
648 r[self.annotation_key] = list(delta_accounts)
649 results.append(r)
650 return results
652 def process(self, resources, event=None):
653 results = []
654 client = local_session(self.manager.session_factory).client('ec2')
655 accounts = self.get_accounts()
656 with self.executor_factory(max_workers=2) as w:
657 futures = []
658 for resource_set in chunks(resources, 20):
659 futures.append(
660 w.submit(
661 self.process_resource_set, client, accounts, resource_set))
662 for f in as_completed(futures):
663 if f.exception():
664 self.log.error(
665 "Exception checking cross account access \n %s" % (
666 f.exception()))
667 continue
668 results.extend(f.result())
669 return results
672@AMI.filter_registry.register('image-attribute')
673class ImageAttribute(ValueFilter):
674 """AMI Image Value Filter on a given image attribute.
676 Filters AMI's with the given AMI attribute
678 :example:
680 .. code-block:: yaml
682 policies:
683 - name: ami-unused-recently
684 resource: ami
685 filters:
686 - type: image-attribute
687 attribute: lastLaunchedTime
688 key: "Value"
689 op: gte
690 value_type: age
691 value: 30
692 """
694 valid_attrs = (
695 'description',
696 'kernel',
697 'ramdisk',
698 'launchPermissions',
699 'productCodes',
700 'blockDeviceMapping',
701 'sriovNetSupport',
702 'bootMode',
703 'tpmSupport',
704 'uefiData',
705 'lastLaunchedTime',
706 'imdsSupport'
707 )
709 schema = type_schema(
710 'image-attribute',
711 rinherit=ValueFilter.schema,
712 attribute={'enum': valid_attrs},
713 required=('attribute',))
714 schema_alias = False
716 def get_permissions(self):
717 return ('ec2:DescribeImageAttribute',)
719 def process(self, resources, event=None):
720 attribute = self.data['attribute']
721 self.get_image_attribute(resources, attribute)
722 return [resource for resource in resources
723 if self.match(resource['c7n:attribute-%s' % attribute])]
725 def get_image_attribute(self, resources, attribute):
726 client = local_session(
727 self.manager.session_factory).client('ec2')
729 for resource in resources:
730 image_id = resource['ImageId']
731 fetched_attribute = self.manager.retry(
732 client.describe_image_attribute,
733 ImageId=image_id,
734 Attribute=attribute)
735 keys = set(fetched_attribute) - {'ResponseMetadata', 'ImageId'}
736 resource['c7n:attribute-%s' % attribute] = fetched_attribute[keys.pop()]