1#!/usr/bin/python
2# This file is part of Ansible
3#
4# Ansible is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8#
9# Ansible is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
16
17ANSIBLE_METADATA = {'metadata_version': '1.1',
18                    'status': ['preview'],
19                    'supported_by': 'community'}
20
21
22DOCUMENTATION = '''
23---
24module: ecs_service
25short_description: create, terminate, start or stop a service in ecs
26description:
27  - Creates or terminates ecs services.
28notes:
29  - the service role specified must be assumable (i.e. have a trust relationship for the ecs service, ecs.amazonaws.com)
30  - for details of the parameters and returns see U(https://boto3.readthedocs.io/en/latest/reference/services/ecs.html)
31  - An IAM role must have been previously created
32version_added: "2.1"
33author:
34    - "Mark Chance (@Java1Guy)"
35    - "Darek Kaczynski (@kaczynskid)"
36    - "Stephane Maarek (@simplesteph)"
37    - "Zac Blazic (@zacblazic)"
38
39requirements: [ json, botocore, boto3 ]
40options:
41    state:
42        description:
43          - The desired state of the service
44        required: true
45        choices: ["present", "absent", "deleting"]
46    name:
47        description:
48          - The name of the service
49        required: true
50    cluster:
51        description:
52          - The name of the cluster in which the service exists
53        required: false
54    task_definition:
55        description:
56          - The task definition the service will run. This parameter is required when state=present
57        required: false
58    load_balancers:
59        description:
60          - The list of ELBs defined for this service
61        required: false
62    desired_count:
63        description:
64          - The count of how many instances of the service. This parameter is required when state=present
65        required: false
66    client_token:
67        description:
68          - Unique, case-sensitive identifier you provide to ensure the idempotency of the request. Up to 32 ASCII characters are allowed.
69        required: false
70    role:
71        description:
72          - The name or full Amazon Resource Name (ARN) of the IAM role that allows your Amazon ECS container agent to make calls to your load balancer
73            on your behalf. This parameter is only required if you are using a load balancer with your service, in a network mode other than `awsvpc`.
74        required: false
75    delay:
76        description:
77          - The time to wait before checking that the service is available
78        required: false
79        default: 10
80    repeat:
81        description:
82          - The number of times to check that the service is available
83        required: false
84        default: 10
85    force_new_deployment:
86        description:
87          - Force deployment of service even if there are no changes
88        required: false
89        version_added: 2.8
90        type: bool
91    deployment_configuration:
92        description:
93          - Optional parameters that control the deployment_configuration; format is '{"maximum_percent":<integer>, "minimum_healthy_percent":<integer>}
94        required: false
95        version_added: 2.3
96    placement_constraints:
97        description:
98          - The placement constraints for the tasks in the service
99        required: false
100        version_added: 2.4
101    placement_strategy:
102        description:
103          - The placement strategy objects to use for tasks in your service. You can specify a maximum of 5 strategy rules per service
104        required: false
105        version_added: 2.4
106    network_configuration:
107        description:
108          - network configuration of the service. Only applicable for task definitions created with C(awsvpc) I(network_mode).
109          - assign_public_ip requires botocore >= 1.8.4
110        suboptions:
111          subnets:
112            description:
113              - A list of subnet IDs to associate with the task
114            version_added: 2.6
115          security_groups:
116            description:
117              - A list of security group names or group IDs to associate with the task
118            version_added: 2.6
119          assign_public_ip:
120            description:
121              - Whether the task's elastic network interface receives a public IP address. This option requires botocore >= 1.8.4.
122            type: bool
123            version_added: 2.7
124    launch_type:
125        description:
126          - The launch type on which to run your service
127        required: false
128        version_added: 2.7
129        choices: ["EC2", "FARGATE"]
130    health_check_grace_period_seconds:
131        description:
132          - Seconds to wait before health checking the freshly added/updated services. This option requires botocore >= 1.8.20.
133        required: false
134        version_added: 2.8
135    service_registries:
136        description:
137          - describes service discovery registries this service will register with.
138        required: false
139        version_added: 2.8
140        suboptions:
141            container_name:
142                description:
143                  - container name for service discovery registration
144            container_port:
145                description:
146                  - container port for service discovery registration
147            arn:
148                description:
149                  - Service discovery registry ARN
150    scheduling_strategy:
151        description:
152          - The scheduling strategy, defaults to "REPLICA" if not given to preserve previous behavior
153        required: false
154        version_added: 2.8
155        choices: ["DAEMON", "REPLICA"]
156extends_documentation_fragment:
157    - aws
158    - ec2
159'''
160
161EXAMPLES = '''
162# Note: These examples do not set authentication details, see the AWS Guide for details.
163
164# Basic provisioning example
165- ecs_service:
166    state: present
167    name: console-test-service
168    cluster: new_cluster
169    task_definition: 'new_cluster-task:1'
170    desired_count: 0
171
172- name: create ECS service on VPC network
173  ecs_service:
174    state: present
175    name: console-test-service
176    cluster: new_cluster
177    task_definition: 'new_cluster-task:1'
178    desired_count: 0
179    network_configuration:
180      subnets:
181      - subnet-abcd1234
182      security_groups:
183      - sg-aaaa1111
184      - my_security_group
185
186# Simple example to delete
187- ecs_service:
188    name: default
189    state: absent
190    cluster: new_cluster
191
192# With custom deployment configuration (added in version 2.3), placement constraints and strategy (added in version 2.4)
193- ecs_service:
194    state: present
195    name: test-service
196    cluster: test-cluster
197    task_definition: test-task-definition
198    desired_count: 3
199    deployment_configuration:
200      minimum_healthy_percent: 75
201      maximum_percent: 150
202    placement_constraints:
203      - type: memberOf
204        expression: 'attribute:flavor==test'
205    placement_strategy:
206      - type: binpack
207        field: memory
208'''
209
210RETURN = '''
211service:
212    description: Details of created service.
213    returned: when creating a service
214    type: complex
215    contains:
216        clusterArn:
217            description: The Amazon Resource Name (ARN) of the of the cluster that hosts the service.
218            returned: always
219            type: str
220        desiredCount:
221            description: The desired number of instantiations of the task definition to keep running on the service.
222            returned: always
223            type: int
224        loadBalancers:
225            description: A list of load balancer objects
226            returned: always
227            type: complex
228            contains:
229                loadBalancerName:
230                    description: the name
231                    returned: always
232                    type: str
233                containerName:
234                    description: The name of the container to associate with the load balancer.
235                    returned: always
236                    type: str
237                containerPort:
238                    description: The port on the container to associate with the load balancer.
239                    returned: always
240                    type: int
241        pendingCount:
242            description: The number of tasks in the cluster that are in the PENDING state.
243            returned: always
244            type: int
245        runningCount:
246            description: The number of tasks in the cluster that are in the RUNNING state.
247            returned: always
248            type: int
249        serviceArn:
250            description: The Amazon Resource Name (ARN) that identifies the service. The ARN contains the arn:aws:ecs namespace, followed by the region
251                         of the service, the AWS account ID of the service owner, the service namespace, and then the service name. For example,
252                         arn:aws:ecs:region :012345678910 :service/my-service .
253            returned: always
254            type: str
255        serviceName:
256            description: A user-generated string used to identify the service
257            returned: always
258            type: str
259        status:
260            description: The valid values are ACTIVE, DRAINING, or INACTIVE.
261            returned: always
262            type: str
263        taskDefinition:
264            description: The ARN of a task definition to use for tasks in the service.
265            returned: always
266            type: str
267        deployments:
268            description: list of service deployments
269            returned: always
270            type: list of complex
271        deploymentConfiguration:
272            description: dictionary of deploymentConfiguration
273            returned: always
274            type: complex
275            contains:
276                maximumPercent:
277                    description: maximumPercent param
278                    returned: always
279                    type: int
280                minimumHealthyPercent:
281                    description: minimumHealthyPercent param
282                    returned: always
283                    type: int
284        events:
285            description: list of service events
286            returned: always
287            type: list of complex
288        placementConstraints:
289            description: List of placement constraints objects
290            returned: always
291            type: list of complex
292            contains:
293                type:
294                    description: The type of constraint. Valid values are distinctInstance and memberOf.
295                    returned: always
296                    type: str
297                expression:
298                    description: A cluster query language expression to apply to the constraint. Note you cannot specify an expression if the constraint type is
299                                 distinctInstance.
300                    returned: always
301                    type: str
302        placementStrategy:
303            description: List of placement strategy objects
304            returned: always
305            type: list of complex
306            contains:
307                type:
308                    description: The type of placement strategy. Valid values are random, spread and binpack.
309                    returned: always
310                    type: str
311                field:
312                    description: The field to apply the placement strategy against. For the spread placement strategy, valid values are instanceId
313                                 (or host, which has the same effect), or any platform or custom attribute that is applied to a container instance,
314                                 such as attribute:ecs.availability-zone. For the binpack placement strategy, valid values are CPU and MEMORY.
315                    returned: always
316                    type: str
317ansible_facts:
318    description: Facts about deleted service.
319    returned: when deleting a service
320    type: complex
321    contains:
322        service:
323            description: Details of deleted service in the same structure described above for service creation.
324            returned: when service existed and was deleted
325            type: complex
326'''
327import time
328
329DEPLOYMENT_CONFIGURATION_TYPE_MAP = {
330    'maximum_percent': 'int',
331    'minimum_healthy_percent': 'int'
332}
333
334from ansible.module_utils.aws.core import AnsibleAWSModule
335from ansible.module_utils.ec2 import ec2_argument_spec
336from ansible.module_utils.ec2 import snake_dict_to_camel_dict, map_complex_type, get_ec2_security_group_ids_from_names
337
338try:
339    import botocore
340except ImportError:
341    pass  # handled by AnsibleAWSModule
342
343
344class EcsServiceManager:
345    """Handles ECS Services"""
346
347    def __init__(self, module):
348        self.module = module
349        self.ecs = module.client('ecs')
350        self.ec2 = module.client('ec2')
351
352    def format_network_configuration(self, network_config):
353        result = dict()
354        if network_config['subnets'] is not None:
355            result['subnets'] = network_config['subnets']
356        else:
357            self.module.fail_json(msg="Network configuration must include subnets")
358        if network_config['security_groups'] is not None:
359            groups = network_config['security_groups']
360            if any(not sg.startswith('sg-') for sg in groups):
361                try:
362                    vpc_id = self.ec2.describe_subnets(SubnetIds=[result['subnets'][0]])['Subnets'][0]['VpcId']
363                    groups = get_ec2_security_group_ids_from_names(groups, self.ec2, vpc_id)
364                except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
365                    self.module.fail_json_aws(e, msg="Couldn't look up security groups")
366            result['securityGroups'] = groups
367        if network_config['assign_public_ip'] is not None:
368            if self.module.botocore_at_least('1.8.4'):
369                if network_config['assign_public_ip'] is True:
370                    result['assignPublicIp'] = "ENABLED"
371                else:
372                    result['assignPublicIp'] = "DISABLED"
373            else:
374                self.module.fail_json(msg='botocore needs to be version 1.8.4 or higher to use assign_public_ip in network_configuration')
375        return dict(awsvpcConfiguration=result)
376
377    def find_in_array(self, array_of_services, service_name, field_name='serviceArn'):
378        for c in array_of_services:
379            if c[field_name].endswith(service_name):
380                return c
381        return None
382
383    def describe_service(self, cluster_name, service_name):
384        response = self.ecs.describe_services(
385            cluster=cluster_name,
386            services=[service_name])
387        msg = ''
388        if len(response['failures']) > 0:
389            c = self.find_in_array(response['failures'], service_name, 'arn')
390            msg += ", failure reason is " + c['reason']
391            if c and c['reason'] == 'MISSING':
392                return None
393            # fall thru and look through found ones
394        if len(response['services']) > 0:
395            c = self.find_in_array(response['services'], service_name)
396            if c:
397                return c
398        raise Exception("Unknown problem describing service %s." % service_name)
399
400    def is_matching_service(self, expected, existing):
401        if expected['task_definition'] != existing['taskDefinition']:
402            return False
403
404        if (expected['load_balancers'] or []) != existing['loadBalancers']:
405            return False
406
407        # expected is params. DAEMON scheduling strategy returns desired count equal to
408        # number of instances running; don't check desired count if scheduling strat is daemon
409        if (expected['scheduling_strategy'] != 'DAEMON'):
410            if (expected['desired_count'] or 0) != existing['desiredCount']:
411                return False
412
413        return True
414
415    def create_service(self, service_name, cluster_name, task_definition, load_balancers,
416                       desired_count, client_token, role, deployment_configuration,
417                       placement_constraints, placement_strategy, health_check_grace_period_seconds,
418                       network_configuration, service_registries, launch_type, scheduling_strategy):
419
420        params = dict(
421            cluster=cluster_name,
422            serviceName=service_name,
423            taskDefinition=task_definition,
424            loadBalancers=load_balancers,
425            clientToken=client_token,
426            role=role,
427            deploymentConfiguration=deployment_configuration,
428            placementConstraints=placement_constraints,
429            placementStrategy=placement_strategy
430        )
431        if network_configuration:
432            params['networkConfiguration'] = network_configuration
433        if launch_type:
434            params['launchType'] = launch_type
435        if self.health_check_setable(params) and health_check_grace_period_seconds is not None:
436            params['healthCheckGracePeriodSeconds'] = health_check_grace_period_seconds
437        if service_registries:
438            params['serviceRegistries'] = service_registries
439        # desired count is not required if scheduling strategy is daemon
440        if desired_count is not None:
441            params['desiredCount'] = desired_count
442
443        if scheduling_strategy:
444            params['schedulingStrategy'] = scheduling_strategy
445        response = self.ecs.create_service(**params)
446        return self.jsonize(response['service'])
447
448    def update_service(self, service_name, cluster_name, task_definition,
449                       desired_count, deployment_configuration, network_configuration,
450                       health_check_grace_period_seconds, force_new_deployment):
451        params = dict(
452            cluster=cluster_name,
453            service=service_name,
454            taskDefinition=task_definition,
455            deploymentConfiguration=deployment_configuration)
456        if network_configuration:
457            params['networkConfiguration'] = network_configuration
458        if force_new_deployment:
459            params['forceNewDeployment'] = force_new_deployment
460        if health_check_grace_period_seconds is not None:
461            params['healthCheckGracePeriodSeconds'] = health_check_grace_period_seconds
462        # desired count is not required if scheduling strategy is daemon
463        if desired_count is not None:
464            params['desiredCount'] = desired_count
465
466        response = self.ecs.update_service(**params)
467        return self.jsonize(response['service'])
468
469    def jsonize(self, service):
470        # some fields are datetime which is not JSON serializable
471        # make them strings
472        if 'createdAt' in service:
473            service['createdAt'] = str(service['createdAt'])
474        if 'deployments' in service:
475            for d in service['deployments']:
476                if 'createdAt' in d:
477                    d['createdAt'] = str(d['createdAt'])
478                if 'updatedAt' in d:
479                    d['updatedAt'] = str(d['updatedAt'])
480        if 'events' in service:
481            for e in service['events']:
482                if 'createdAt' in e:
483                    e['createdAt'] = str(e['createdAt'])
484        return service
485
486    def delete_service(self, service, cluster=None):
487        return self.ecs.delete_service(cluster=cluster, service=service)
488
489    def ecs_api_handles_network_configuration(self):
490        # There doesn't seem to be a nice way to inspect botocore to look
491        # for attributes (and networkConfiguration is not an explicit argument
492        # to e.g. ecs.run_task, it's just passed as a keyword argument)
493        return self.module.botocore_at_least('1.7.44')
494
495    def health_check_setable(self, params):
496        load_balancers = params.get('loadBalancers', [])
497        # check if botocore (and thus boto3) is new enough for using the healthCheckGracePeriodSeconds parameter
498        return len(load_balancers) > 0 and self.module.botocore_at_least('1.8.20')
499
500
501def main():
502    argument_spec = ec2_argument_spec()
503    argument_spec.update(dict(
504        state=dict(required=True, choices=['present', 'absent', 'deleting']),
505        name=dict(required=True, type='str'),
506        cluster=dict(required=False, type='str'),
507        task_definition=dict(required=False, type='str'),
508        load_balancers=dict(required=False, default=[], type='list'),
509        desired_count=dict(required=False, type='int'),
510        client_token=dict(required=False, default='', type='str'),
511        role=dict(required=False, default='', type='str'),
512        delay=dict(required=False, type='int', default=10),
513        repeat=dict(required=False, type='int', default=10),
514        force_new_deployment=dict(required=False, default=False, type='bool'),
515        deployment_configuration=dict(required=False, default={}, type='dict'),
516        placement_constraints=dict(required=False, default=[], type='list'),
517        placement_strategy=dict(required=False, default=[], type='list'),
518        health_check_grace_period_seconds=dict(required=False, type='int'),
519        network_configuration=dict(required=False, type='dict', options=dict(
520            subnets=dict(type='list'),
521            security_groups=dict(type='list'),
522            assign_public_ip=dict(type='bool')
523        )),
524        launch_type=dict(required=False, choices=['EC2', 'FARGATE']),
525        service_registries=dict(required=False, type='list', default=[]),
526        scheduling_strategy=dict(required=False, choices=['DAEMON', 'REPLICA'])
527    ))
528
529    module = AnsibleAWSModule(argument_spec=argument_spec,
530                              supports_check_mode=True,
531                              required_if=[('state', 'present', ['task_definition']),
532                                           ('launch_type', 'FARGATE', ['network_configuration'])],
533                              required_together=[['load_balancers', 'role']])
534
535    if module.params['state'] == 'present' and module.params['scheduling_strategy'] == 'REPLICA':
536        if module.params['desired_count'] is None:
537            module.fail_json(msg='state is present, scheduling_strategy is REPLICA; missing desired_count')
538
539    service_mgr = EcsServiceManager(module)
540    if module.params['network_configuration']:
541        if not service_mgr.ecs_api_handles_network_configuration():
542            module.fail_json(msg='botocore needs to be version 1.7.44 or higher to use network configuration')
543        network_configuration = service_mgr.format_network_configuration(module.params['network_configuration'])
544    else:
545        network_configuration = None
546
547    deployment_configuration = map_complex_type(module.params['deployment_configuration'],
548                                                DEPLOYMENT_CONFIGURATION_TYPE_MAP)
549
550    deploymentConfiguration = snake_dict_to_camel_dict(deployment_configuration)
551    serviceRegistries = list(map(snake_dict_to_camel_dict, module.params['service_registries']))
552
553    try:
554        existing = service_mgr.describe_service(module.params['cluster'], module.params['name'])
555    except Exception as e:
556        module.fail_json(msg="Exception describing service '" + module.params['name'] + "' in cluster '" + module.params['cluster'] + "': " + str(e))
557
558    results = dict(changed=False)
559
560    if module.params['launch_type']:
561        if not module.botocore_at_least('1.8.4'):
562            module.fail_json(msg='botocore needs to be version 1.8.4 or higher to use launch_type')
563    if module.params['force_new_deployment']:
564        if not module.botocore_at_least('1.8.4'):
565            module.fail_json(msg='botocore needs to be version 1.8.4 or higher to use force_new_deployment')
566    if module.params['health_check_grace_period_seconds']:
567        if not module.botocore_at_least('1.8.20'):
568            module.fail_json(msg='botocore needs to be version 1.8.20 or higher to use health_check_grace_period_seconds')
569
570    if module.params['state'] == 'present':
571
572        matching = False
573        update = False
574
575        if existing and 'status' in existing and existing['status'] == "ACTIVE":
576            if module.params['force_new_deployment']:
577                update = True
578            elif service_mgr.is_matching_service(module.params, existing):
579                matching = True
580                results['service'] = existing
581            else:
582                update = True
583
584        if not matching:
585            if not module.check_mode:
586
587                role = module.params['role']
588                clientToken = module.params['client_token']
589
590                loadBalancers = []
591                for loadBalancer in module.params['load_balancers']:
592                    if 'containerPort' in loadBalancer:
593                        loadBalancer['containerPort'] = int(loadBalancer['containerPort'])
594                    loadBalancers.append(loadBalancer)
595
596                for loadBalancer in loadBalancers:
597                    if 'containerPort' in loadBalancer:
598                        loadBalancer['containerPort'] = int(loadBalancer['containerPort'])
599
600                if update:
601                    # check various parameters and boto versions and give a helpful error in boto is not new enough for feature
602
603                    if module.params['scheduling_strategy']:
604                        if not module.botocore_at_least('1.10.37'):
605                            module.fail_json(msg='botocore needs to be version 1.10.37 or higher to use scheduling_strategy')
606                        elif (existing['schedulingStrategy']) != module.params['scheduling_strategy']:
607                            module.fail_json(msg="It is not possible to update the scheduling strategy of an existing service")
608
609                    if module.params['service_registries']:
610                        if not module.botocore_at_least('1.9.15'):
611                            module.fail_json(msg='botocore needs to be version 1.9.15 or higher to use service_registries')
612                        elif (existing['serviceRegistries'] or []) != serviceRegistries:
613                            module.fail_json(msg="It is not possible to update the service registries of an existing service")
614
615                    if (existing['loadBalancers'] or []) != loadBalancers:
616                        module.fail_json(msg="It is not possible to update the load balancers of an existing service")
617
618                    # update required
619                    response = service_mgr.update_service(module.params['name'],
620                                                          module.params['cluster'],
621                                                          module.params['task_definition'],
622                                                          module.params['desired_count'],
623                                                          deploymentConfiguration,
624                                                          network_configuration,
625                                                          module.params['health_check_grace_period_seconds'],
626                                                          module.params['force_new_deployment'])
627
628                else:
629                    try:
630                        response = service_mgr.create_service(module.params['name'],
631                                                              module.params['cluster'],
632                                                              module.params['task_definition'],
633                                                              loadBalancers,
634                                                              module.params['desired_count'],
635                                                              clientToken,
636                                                              role,
637                                                              deploymentConfiguration,
638                                                              module.params['placement_constraints'],
639                                                              module.params['placement_strategy'],
640                                                              module.params['health_check_grace_period_seconds'],
641                                                              network_configuration,
642                                                              serviceRegistries,
643                                                              module.params['launch_type'],
644                                                              module.params['scheduling_strategy']
645                                                              )
646                    except botocore.exceptions.ClientError as e:
647                        module.fail_json_aws(e, msg="Couldn't create service")
648
649                results['service'] = response
650
651            results['changed'] = True
652
653    elif module.params['state'] == 'absent':
654        if not existing:
655            pass
656        else:
657            # it exists, so we should delete it and mark changed.
658            # return info about the cluster deleted
659            del existing['deployments']
660            del existing['events']
661            results['ansible_facts'] = existing
662            if 'status' in existing and existing['status'] == "INACTIVE":
663                results['changed'] = False
664            else:
665                if not module.check_mode:
666                    try:
667                        service_mgr.delete_service(
668                            module.params['name'],
669                            module.params['cluster']
670                        )
671                    except botocore.exceptions.ClientError as e:
672                        module.fail_json_aws(e, msg="Couldn't delete service")
673                results['changed'] = True
674
675    elif module.params['state'] == 'deleting':
676        if not existing:
677            module.fail_json(msg="Service '" + module.params['name'] + " not found.")
678            return
679        # it exists, so we should delete it and mark changed.
680        # return info about the cluster deleted
681        delay = module.params['delay']
682        repeat = module.params['repeat']
683        time.sleep(delay)
684        for i in range(repeat):
685            existing = service_mgr.describe_service(module.params['cluster'], module.params['name'])
686            status = existing['status']
687            if status == "INACTIVE":
688                results['changed'] = True
689                break
690            time.sleep(delay)
691        if i is repeat - 1:
692            module.fail_json(msg="Service still not deleted after " + str(repeat) + " tries of " + str(delay) + " seconds each.")
693            return
694
695    module.exit_json(**results)
696
697
698if __name__ == '__main__':
699    main()
700