1#!/usr/local/bin/python3.8 2# -*- coding: utf-8 -* 3 4# Copyright: Ansible Project 5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 7from __future__ import absolute_import, division, print_function 8 9__metaclass__ = type 10 11 12DOCUMENTATION = ''' 13--- 14module: ecs_ecr 15version_added: 1.0.0 16short_description: Manage Elastic Container Registry repositories 17description: 18 - Manage Elastic Container Registry repositories. 19requirements: [ boto3 ] 20options: 21 name: 22 description: 23 - The name of the repository. 24 required: true 25 type: str 26 registry_id: 27 description: 28 - AWS account id associated with the registry. 29 - If not specified, the default registry is assumed. 30 required: false 31 type: str 32 policy: 33 description: 34 - JSON or dict that represents the new policy. 35 required: false 36 type: json 37 force_set_policy: 38 description: 39 - If I(force_set_policy=false), it prevents setting a policy that would prevent you from 40 setting another policy in the future. 41 required: false 42 default: false 43 type: bool 44 purge_policy: 45 description: 46 - If yes, remove the policy from the repository. 47 - Alias C(delete_policy) has been deprecated and will be removed after 2022-06-01. 48 - Defaults to C(false). 49 required: false 50 type: bool 51 aliases: [ delete_policy ] 52 image_tag_mutability: 53 description: 54 - Configure whether repository should be mutable (ie. an already existing tag can be overwritten) or not. 55 required: false 56 choices: [mutable, immutable] 57 default: 'mutable' 58 type: str 59 lifecycle_policy: 60 description: 61 - JSON or dict that represents the new lifecycle policy. 62 required: false 63 type: json 64 purge_lifecycle_policy: 65 description: 66 - if C(true), remove the lifecycle policy from the repository. 67 - Defaults to C(false). 68 required: false 69 type: bool 70 state: 71 description: 72 - Create or destroy the repository. 73 required: false 74 choices: [present, absent] 75 default: 'present' 76 type: str 77 scan_on_push: 78 description: 79 - if C(true), images are scanned for known vulnerabilities after being pushed to the repository. 80 - I(scan_on_push) requires botocore >= 1.13.3 81 required: false 82 default: false 83 type: bool 84 version_added: 1.3.0 85author: 86 - David M. Lee (@leedm777) 87extends_documentation_fragment: 88- amazon.aws.aws 89- amazon.aws.ec2 90 91''' 92 93EXAMPLES = ''' 94# If the repository does not exist, it is created. If it does exist, would not 95# affect any policies already on it. 96- name: ecr-repo 97 community.aws.ecs_ecr: 98 name: super/cool 99 100- name: destroy-ecr-repo 101 community.aws.ecs_ecr: 102 name: old/busted 103 state: absent 104 105- name: Cross account ecr-repo 106 community.aws.ecs_ecr: 107 registry_id: 999999999999 108 name: cross/account 109 110- name: set-policy as object 111 community.aws.ecs_ecr: 112 name: needs-policy-object 113 policy: 114 Version: '2008-10-17' 115 Statement: 116 - Sid: read-only 117 Effect: Allow 118 Principal: 119 AWS: '{{ read_only_arn }}' 120 Action: 121 - ecr:GetDownloadUrlForLayer 122 - ecr:BatchGetImage 123 - ecr:BatchCheckLayerAvailability 124 125- name: set-policy as string 126 community.aws.ecs_ecr: 127 name: needs-policy-string 128 policy: "{{ lookup('template', 'policy.json.j2') }}" 129 130- name: delete-policy 131 community.aws.ecs_ecr: 132 name: needs-no-policy 133 purge_policy: yes 134 135- name: create immutable ecr-repo 136 community.aws.ecs_ecr: 137 name: super/cool 138 image_tag_mutability: immutable 139 140- name: set-lifecycle-policy 141 community.aws.ecs_ecr: 142 name: needs-lifecycle-policy 143 scan_on_push: yes 144 lifecycle_policy: 145 rules: 146 - rulePriority: 1 147 description: new policy 148 selection: 149 tagStatus: untagged 150 countType: sinceImagePushed 151 countUnit: days 152 countNumber: 365 153 action: 154 type: expire 155 156- name: purge-lifecycle-policy 157 community.aws.ecs_ecr: 158 name: needs-no-lifecycle-policy 159 purge_lifecycle_policy: true 160''' 161 162RETURN = ''' 163state: 164 type: str 165 description: The asserted state of the repository (present, absent) 166 returned: always 167created: 168 type: bool 169 description: If true, the repository was created 170 returned: always 171name: 172 type: str 173 description: The name of the repository 174 returned: "when state == 'absent'" 175repository: 176 type: dict 177 description: The created or updated repository 178 returned: "when state == 'present'" 179 sample: 180 createdAt: '2017-01-17T08:41:32-06:00' 181 registryId: '999999999999' 182 repositoryArn: arn:aws:ecr:us-east-1:999999999999:repository/ecr-test-1484664090 183 repositoryName: ecr-test-1484664090 184 repositoryUri: 999999999999.dkr.ecr.us-east-1.amazonaws.com/ecr-test-1484664090 185''' 186 187import json 188import traceback 189 190try: 191 import botocore 192except ImportError: 193 pass # Handled by AnsibleAWSModule 194 195from ansible.module_utils.six import string_types 196 197from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule 198from ansible_collections.amazon.aws.plugins.module_utils.core import is_boto3_error_code 199from ansible_collections.amazon.aws.plugins.module_utils.ec2 import boto_exception 200from ansible_collections.amazon.aws.plugins.module_utils.ec2 import compare_policies 201from ansible_collections.amazon.aws.plugins.module_utils.ec2 import sort_json_policy_dict 202 203 204def build_kwargs(registry_id): 205 """ 206 Builds a kwargs dict which may contain the optional registryId. 207 208 :param registry_id: Optional string containing the registryId. 209 :return: kwargs dict with registryId, if given 210 """ 211 if not registry_id: 212 return dict() 213 else: 214 return dict(registryId=registry_id) 215 216 217class EcsEcr: 218 def __init__(self, module): 219 self.ecr = module.client('ecr') 220 self.sts = module.client('sts') 221 self.check_mode = module.check_mode 222 self.changed = False 223 self.skipped = False 224 225 def get_repository(self, registry_id, name): 226 try: 227 res = self.ecr.describe_repositories( 228 repositoryNames=[name], **build_kwargs(registry_id)) 229 repos = res.get('repositories') 230 return repos and repos[0] 231 except is_boto3_error_code('RepositoryNotFoundException'): 232 return None 233 234 def get_repository_policy(self, registry_id, name): 235 try: 236 res = self.ecr.get_repository_policy( 237 repositoryName=name, **build_kwargs(registry_id)) 238 text = res.get('policyText') 239 return text and json.loads(text) 240 except is_boto3_error_code('RepositoryPolicyNotFoundException'): 241 return None 242 243 def create_repository(self, registry_id, name, image_tag_mutability): 244 if registry_id: 245 default_registry_id = self.sts.get_caller_identity().get('Account') 246 if registry_id != default_registry_id: 247 raise Exception('Cannot create repository in registry {0}.' 248 'Would be created in {1} instead.'.format(registry_id, default_registry_id)) 249 250 if not self.check_mode: 251 repo = self.ecr.create_repository( 252 repositoryName=name, 253 imageTagMutability=image_tag_mutability).get('repository') 254 self.changed = True 255 return repo 256 else: 257 self.skipped = True 258 return dict(repositoryName=name) 259 260 def set_repository_policy(self, registry_id, name, policy_text, force): 261 if not self.check_mode: 262 policy = self.ecr.set_repository_policy( 263 repositoryName=name, 264 policyText=policy_text, 265 force=force, 266 **build_kwargs(registry_id)) 267 self.changed = True 268 return policy 269 else: 270 self.skipped = True 271 if self.get_repository(registry_id, name) is None: 272 printable = name 273 if registry_id: 274 printable = '{0}:{1}'.format(registry_id, name) 275 raise Exception( 276 'could not find repository {0}'.format(printable)) 277 return 278 279 def delete_repository(self, registry_id, name): 280 if not self.check_mode: 281 repo = self.ecr.delete_repository( 282 repositoryName=name, **build_kwargs(registry_id)) 283 self.changed = True 284 return repo 285 else: 286 repo = self.get_repository(registry_id, name) 287 if repo: 288 self.skipped = True 289 return repo 290 return None 291 292 def delete_repository_policy(self, registry_id, name): 293 if not self.check_mode: 294 policy = self.ecr.delete_repository_policy( 295 repositoryName=name, **build_kwargs(registry_id)) 296 self.changed = True 297 return policy 298 else: 299 policy = self.get_repository_policy(registry_id, name) 300 if policy: 301 self.skipped = True 302 return policy 303 return None 304 305 def put_image_tag_mutability(self, registry_id, name, new_mutability_configuration): 306 repo = self.get_repository(registry_id, name) 307 current_mutability_configuration = repo.get('imageTagMutability') 308 309 if current_mutability_configuration != new_mutability_configuration: 310 if not self.check_mode: 311 self.ecr.put_image_tag_mutability( 312 repositoryName=name, 313 imageTagMutability=new_mutability_configuration, 314 **build_kwargs(registry_id)) 315 else: 316 self.skipped = True 317 self.changed = True 318 319 repo['imageTagMutability'] = new_mutability_configuration 320 return repo 321 322 def get_lifecycle_policy(self, registry_id, name): 323 try: 324 res = self.ecr.get_lifecycle_policy( 325 repositoryName=name, **build_kwargs(registry_id)) 326 text = res.get('lifecyclePolicyText') 327 return text and json.loads(text) 328 except is_boto3_error_code('LifecyclePolicyNotFoundException'): 329 return None 330 331 def put_lifecycle_policy(self, registry_id, name, policy_text): 332 if not self.check_mode: 333 policy = self.ecr.put_lifecycle_policy( 334 repositoryName=name, 335 lifecyclePolicyText=policy_text, 336 **build_kwargs(registry_id)) 337 self.changed = True 338 return policy 339 else: 340 self.skipped = True 341 if self.get_repository(registry_id, name) is None: 342 printable = name 343 if registry_id: 344 printable = '{0}:{1}'.format(registry_id, name) 345 raise Exception( 346 'could not find repository {0}'.format(printable)) 347 return 348 349 def purge_lifecycle_policy(self, registry_id, name): 350 if not self.check_mode: 351 policy = self.ecr.delete_lifecycle_policy( 352 repositoryName=name, **build_kwargs(registry_id)) 353 self.changed = True 354 return policy 355 else: 356 policy = self.get_lifecycle_policy(registry_id, name) 357 if policy: 358 self.skipped = True 359 return policy 360 return None 361 362 def put_image_scanning_configuration(self, registry_id, name, scan_on_push): 363 if not self.check_mode: 364 if registry_id: 365 scan = self.ecr.put_image_scanning_configuration( 366 registryId=registry_id, 367 repositoryName=name, 368 imageScanningConfiguration={'scanOnPush': scan_on_push} 369 ) 370 else: 371 scan = self.ecr.put_image_scanning_configuration( 372 repositoryName=name, 373 imageScanningConfiguration={'scanOnPush': scan_on_push} 374 ) 375 self.changed = True 376 return scan 377 else: 378 self.skipped = True 379 return None 380 381 382def sort_lists_of_strings(policy): 383 for statement_index in range(0, len(policy.get('Statement', []))): 384 for key in policy['Statement'][statement_index]: 385 value = policy['Statement'][statement_index][key] 386 if isinstance(value, list) and all(isinstance(item, string_types) for item in value): 387 policy['Statement'][statement_index][key] = sorted(value) 388 return policy 389 390 391def run(ecr, params): 392 # type: (EcsEcr, dict, int) -> Tuple[bool, dict] 393 result = {} 394 try: 395 name = params['name'] 396 state = params['state'] 397 policy_text = params['policy'] 398 purge_policy = params['purge_policy'] 399 registry_id = params['registry_id'] 400 force_set_policy = params['force_set_policy'] 401 image_tag_mutability = params['image_tag_mutability'].upper() 402 lifecycle_policy_text = params['lifecycle_policy'] 403 purge_lifecycle_policy = params['purge_lifecycle_policy'] 404 scan_on_push = params['scan_on_push'] 405 406 # Parse policies, if they are given 407 try: 408 policy = policy_text and json.loads(policy_text) 409 except ValueError: 410 result['policy'] = policy_text 411 result['msg'] = 'Could not parse policy' 412 return False, result 413 414 try: 415 lifecycle_policy = \ 416 lifecycle_policy_text and json.loads(lifecycle_policy_text) 417 except ValueError: 418 result['lifecycle_policy'] = lifecycle_policy_text 419 result['msg'] = 'Could not parse lifecycle_policy' 420 return False, result 421 422 result['state'] = state 423 result['created'] = False 424 425 repo = ecr.get_repository(registry_id, name) 426 427 if state == 'present': 428 result['created'] = False 429 430 if not repo: 431 repo = ecr.create_repository(registry_id, name, image_tag_mutability) 432 result['changed'] = True 433 result['created'] = True 434 else: 435 repo = ecr.put_image_tag_mutability(registry_id, name, image_tag_mutability) 436 result['repository'] = repo 437 438 if purge_lifecycle_policy: 439 original_lifecycle_policy = \ 440 ecr.get_lifecycle_policy(registry_id, name) 441 442 result['lifecycle_policy'] = None 443 444 if original_lifecycle_policy: 445 ecr.purge_lifecycle_policy(registry_id, name) 446 result['changed'] = True 447 448 elif lifecycle_policy_text is not None: 449 try: 450 lifecycle_policy = sort_json_policy_dict(lifecycle_policy) 451 result['lifecycle_policy'] = lifecycle_policy 452 453 original_lifecycle_policy = ecr.get_lifecycle_policy( 454 registry_id, name) 455 456 if original_lifecycle_policy: 457 original_lifecycle_policy = sort_json_policy_dict( 458 original_lifecycle_policy) 459 460 if original_lifecycle_policy != lifecycle_policy: 461 ecr.put_lifecycle_policy(registry_id, name, 462 lifecycle_policy_text) 463 result['changed'] = True 464 except Exception: 465 # Some failure w/ the policy. It's helpful to know what the 466 # policy is. 467 result['lifecycle_policy'] = lifecycle_policy_text 468 raise 469 470 if purge_policy: 471 original_policy = ecr.get_repository_policy(registry_id, name) 472 473 result['policy'] = None 474 475 if original_policy: 476 ecr.delete_repository_policy(registry_id, name) 477 result['changed'] = True 478 479 elif policy_text is not None: 480 try: 481 # Sort any lists containing only string types 482 policy = sort_lists_of_strings(policy) 483 484 result['policy'] = policy 485 486 original_policy = ecr.get_repository_policy( 487 registry_id, name) 488 if original_policy: 489 original_policy = sort_lists_of_strings(original_policy) 490 491 if compare_policies(original_policy, policy): 492 ecr.set_repository_policy( 493 registry_id, name, policy_text, force_set_policy) 494 result['changed'] = True 495 except Exception: 496 # Some failure w/ the policy. It's helpful to know what the 497 # policy is. 498 result['policy'] = policy_text 499 raise 500 501 original_scan_on_push = ecr.get_repository(registry_id, name) 502 if original_scan_on_push is not None: 503 if scan_on_push != original_scan_on_push['imageScanningConfiguration']['scanOnPush']: 504 result['changed'] = True 505 result['repository']['imageScanningConfiguration']['scanOnPush'] = scan_on_push 506 response = ecr.put_image_scanning_configuration(registry_id, name, scan_on_push) 507 508 elif state == 'absent': 509 result['name'] = name 510 if repo: 511 ecr.delete_repository(registry_id, name) 512 result['changed'] = True 513 514 except Exception as err: 515 msg = str(err) 516 if isinstance(err, botocore.exceptions.ClientError): 517 msg = boto_exception(err) 518 result['msg'] = msg 519 result['exception'] = traceback.format_exc() 520 return False, result 521 522 if ecr.skipped: 523 result['skipped'] = True 524 525 if ecr.changed: 526 result['changed'] = True 527 528 return True, result 529 530 531def main(): 532 argument_spec = dict( 533 name=dict(required=True), 534 registry_id=dict(required=False), 535 state=dict(required=False, choices=['present', 'absent'], 536 default='present'), 537 force_set_policy=dict(required=False, type='bool', default=False), 538 policy=dict(required=False, type='json'), 539 image_tag_mutability=dict(required=False, choices=['mutable', 'immutable'], 540 default='mutable'), 541 purge_policy=dict(required=False, type='bool', aliases=['delete_policy'], 542 deprecated_aliases=[dict(name='delete_policy', date='2022-06-01', collection_name='community.aws')]), 543 lifecycle_policy=dict(required=False, type='json'), 544 purge_lifecycle_policy=dict(required=False, type='bool'), 545 scan_on_push=(dict(required=False, type='bool', default=False)) 546 ) 547 mutually_exclusive = [ 548 ['policy', 'purge_policy'], 549 ['lifecycle_policy', 'purge_lifecycle_policy']] 550 551 module = AnsibleAWSModule(argument_spec=argument_spec, supports_check_mode=True, mutually_exclusive=mutually_exclusive) 552 553 ecr = EcsEcr(module) 554 passed, result = run(ecr, module.params) 555 556 if passed: 557 module.exit_json(**result) 558 else: 559 module.fail_json(**result) 560 561 562if __name__ == '__main__': 563 main() 564