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