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