diff --git a/lib/ansible/modules/cloud/amazon/elb_target.py b/lib/ansible/modules/cloud/amazon/elb_target.py new file mode 100644 index 00000000000..5b2143d0994 --- /dev/null +++ b/lib/ansible/modules/cloud/amazon/elb_target.py @@ -0,0 +1,316 @@ +#!/usr/bin/python +# Copyright (c) 2017 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {'status': ['preview'], + 'supported_by': 'community', + 'metadata_version': '1.1'} + +DOCUMENTATION = ''' +--- +module: elb_target +short_description: Manage a target in a target group +description: + - Used to register or deregister a target in a target group +version_added: "2.5" +author: "Rob White (@wimnat)" +options: + deregister_unused: + description: + - The default behaviour for targets that are unused is to leave them registered. If instead you would like to remove them + set I(deregister_unused) to yes. + choices: [ 'yes', 'no' ] + target_az: + description: + - An Availability Zone or all. This determines whether the target receives traffic from the load balancer nodes in the specified + Availability Zone or from all enabled Availability Zones for the load balancer. This parameter is not supported if the target + type of the target group is instance. + target_group_arn: + description: + - The Amazon Resource Name (ARN) of the target group. Mutually exclusive of I(target_group_name). + target_group_name: + description: + - The name of the target group. Mutually exclusive of I(target_group_arn). + target_id: + description: + - The ID of the target. + required: true + target_port: + description: + - The port on which the target is listening. You can specify a port override. If a target is already registered, + you can register it again using a different port. + required: false + default: The default port for a target is the port for the target group. + target_status: + description: + - Blocks and waits for the target status to equal given value. For more detail on target status see + U(http://docs.aws.amazon.com/elasticloadbalancing/latest/application/target-group-health-checks.html#target-health-states) + required: false + choices: [ 'initial', 'healthy', 'unhealthy', 'unused', 'draining', 'unavailable' ] + target_status_timeout: + description: + - Maximum time in seconds to wait for target_status change + required: false + default: 60 + state: + description: + - Register or deregister the target. + required: true + choices: [ 'present', 'absent' ] +extends_documentation_fragment: + - aws + - ec2 +notes: + - If you specified a port override when you registered a target, you must specify both the target ID and the port when you deregister it. +''' + +EXAMPLES = ''' +# Note: These examples do not set authentication details, see the AWS Guide for details. + +# Register an IP address target to a target group +- elb_target: + target_group_name: myiptargetgroup + target_id: 10.0.0.10 + state: present + +# Register an instance target to a target group +- elb_target: + target_group_name: mytargetgroup + target_id: i-1234567 + state: present + +# Deregister a target from a target group +- elb_target: + target_group_name: mytargetgroup + target_id: i-1234567 + state: absent + +# Modify a target to use a different port +# Register a target to a target group +- elb_target: + target_group_name: mytargetgroup + target_id: i-1234567 + target_port: 8080 + state: present + +''' + +RETURN = ''' + +''' + +import traceback +from time import time, sleep +from ansible.module_utils._text import to_native +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.ec2 import (boto3_conn, camel_dict_to_snake_dict, + ec2_argument_spec, get_aws_connection_info) + +try: + import boto3 + from botocore.exceptions import ClientError, BotoCoreError + HAS_BOTO3 = True +except ImportError: + HAS_BOTO3 = False + + +def convert_tg_name_to_arn(connection, module, tg_name): + + try: + response = connection.describe_target_groups(Names=[tg_name]) + except ClientError as e: + module.fail_json(msg="Unable to describe target group {0}: {1}".format(tg_name, to_native(e)), + exception=traceback.format_exc(), **camel_dict_to_snake_dict(e.response)) + except BotoCoreError as e: + module.fail_json(msg="Unable to describe target group {0}: {1}".format(tg_name, to_native(e)), + exception=traceback.format_exc()) + + tg_arn = response['TargetGroups'][0]['TargetGroupArn'] + + return tg_arn + + +def describe_targets(connection, module, tg_arn, target): + + """ + Describe targets in a target group + + :param module: ansible module object + :param connection: boto3 connection + :param tg_arn: target group arn + :param target: dictionary containing target id and port + :return: + """ + + try: + targets = connection.describe_target_health(TargetGroupArn=tg_arn, Targets=target)['TargetHealthDescriptions'] + if not targets: + return {} + return targets[0] + except ClientError as e: + module.fail_json(msg="Unable to describe target health for target {0}: {1}".format(target, to_native(e)), + exception=traceback.format_exc(), **camel_dict_to_snake_dict(e.response)) + except BotoCoreError as e: + module.fail_json(msg="Unable to describe target health for target {0}: {1}".format(target, to_native(e)), + exception=traceback.format_exc()) + + +def register_target(connection, module): + + """ + Registers a target to a target group + + :param module: ansible module object + :param connection: boto3 connection + :return: + """ + + target_az = module.params.get("target_az") + target_group_arn = module.params.get("target_group_arn") + target_id = module.params.get("target_id") + target_port = module.params.get("target_port") + target_status = module.params.get("target_status") + target_status_timeout = module.params.get("target_status_timeout") + changed = False + + if not target_group_arn: + target_group_arn = convert_tg_name_to_arn(connection, module, module.params.get("target_group_name")) + + target = dict(Id=target_id) + if target_az: + target['AvailabilityZone'] = target_az + if target_port: + target['Port'] = target_port + + target_description = describe_targets(connection, module, target_group_arn, [target]) + + if 'Reason' in target_description['TargetHealth']: + if target_description['TargetHealth']['Reason'] == "Target.NotRegistered": + try: + connection.register_targets(TargetGroupArn=target_group_arn, Targets=[target]) + changed = True + if target_status: + target_status_check(connection, module, target_group_arn, target, target_status, target_status_timeout) + except ClientError as e: + module.fail_json(msg="Unable to deregister target {0}: {1}".format(target, to_native(e)), + exception=traceback.format_exc(), **camel_dict_to_snake_dict(e.response)) + except BotoCoreError as e: + module.fail_json(msg="Unable to deregister target {0}: {1}".format(target, to_native(e)), + exception=traceback.format_exc()) + + # Get all targets for the target group + target_descriptions = describe_targets(connection, module, target_group_arn, []) + + module.exit_json(changed=changed, target_health_descriptions=camel_dict_to_snake_dict(target_descriptions), target_group_arn=target_group_arn) + + +def deregister_target(connection, module): + + """ + Deregisters a target to a target group + + :param module: ansible module object + :param connection: boto3 connection + :return: + """ + + deregister_unused = module.params.get("deregister_unused") + target_group_arn = module.params.get("target_group_arn") + target_id = module.params.get("target_id") + target_port = module.params.get("target_port") + target_status = module.params.get("target_status") + target_status_timeout = module.params.get("target_status_timeout") + changed = False + + if not target_group_arn: + target_group_arn = convert_tg_name_to_arn(connection, module, module.params.get("target_group_name")) + + target = dict(Id=target_id) + if target_port: + target['Port'] = target_port + + target_description = describe_targets(connection, module, target_group_arn, [target]) + current_target_state = target_description['TargetHealth']['State'] + current_target_reason = target_description['TargetHealth'].get('Reason') + + needs_deregister = False + + if deregister_unused and current_target_state == 'unused': + if current_target_reason != 'Target.NotRegistered': + needs_deregister = True + elif current_target_state not in ['unused', 'draining']: + needs_deregister = True + + if needs_deregister: + try: + connection.deregister_targets(TargetGroupArn=target_group_arn, Targets=[target]) + changed = True + except ClientError as e: + module.fail_json(msg="Unable to deregister target {0}: {1}".format(target, to_native(e)), + exception=traceback.format_exc(), **camel_dict_to_snake_dict(e.response)) + except BotoCoreError as e: + module.fail_json(msg="Unable to deregister target {0}: {1}".format(target, to_native(e)), + exception=traceback.format_exc()) + else: + if current_target_reason != 'Target.NotRegistered' and current_target_state != 'draining': + module.warn(warning="Your specified target has an 'unused' state but is still registered to the target group. " + + "To force deregistration use the 'deregister_unused' option.") + + if target_status: + target_status_check(connection, module, target_group_arn, target, target_status, target_status_timeout) + + # Get all targets for the target group + target_descriptions = describe_targets(connection, module, target_group_arn, []) + + module.exit_json(changed=changed, target_health_descriptions=camel_dict_to_snake_dict(target_descriptions), target_group_arn=target_group_arn) + + +def target_status_check(connection, module, target_group_arn, target, target_status, target_status_timeout): + reached_state = False + timeout = target_status_timeout + time() + while time() < timeout: + health_state = describe_targets(connection, module, target_group_arn, [target])['TargetHealth']['State'] + if health_state == target_status: + reached_state = True + break + sleep(1) + if not reached_state: + module.fail_json(msg='Status check timeout of {0} exceeded, last status was {1}: '.format(target_status_timeout, health_state)) + + +def main(): + + argument_spec = ec2_argument_spec() + argument_spec.update( + dict( + deregister_unused=dict(type='bool', default=False), + target_az=dict(type='str'), + target_group_arn=dict(type='str'), + target_group_name=dict(type='str'), + target_id=dict(type='str', required=True), + target_port=dict(type='int'), + target_status=dict(choices=['initial', 'healthy', 'unhealthy', 'unused', 'draining', 'unavailable'], type='str'), + target_status_timeout=dict(type='int', default=60), + state=dict(required=True, choices=['present', 'absent'], type='str'), + ) + ) + + module = AnsibleModule(argument_spec=argument_spec, + mutually_exclusive=['target_group_arn', 'target_group_name'] + ) + + if not HAS_BOTO3: + module.fail_json(msg='boto3 required for this module') + + region, ec2_url, aws_connect_params = get_aws_connection_info(module, boto3=True) + connection = boto3_conn(module, conn_type='client', resource='elbv2', region=region, endpoint=ec2_url, **aws_connect_params) + + state = module.params.get("state") + + if state == 'present': + register_target(connection, module) + else: + deregister_target(connection, module) + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/elb_target/aliases b/test/integration/targets/elb_target/aliases new file mode 100644 index 00000000000..4ef4b2067d0 --- /dev/null +++ b/test/integration/targets/elb_target/aliases @@ -0,0 +1 @@ +cloud/aws diff --git a/test/integration/targets/elb_target/defaults/main.yml b/test/integration/targets/elb_target/defaults/main.yml new file mode 100644 index 00000000000..0096b1858d5 --- /dev/null +++ b/test/integration/targets/elb_target/defaults/main.yml @@ -0,0 +1,4 @@ +--- +ec2_ami_image: + us-east-1: ami-8c1be5f6 + us-east-2: ami-c5062ba0 diff --git a/test/integration/targets/elb_target/tasks/main.yml b/test/integration/targets/elb_target/tasks/main.yml new file mode 100644 index 00000000000..5ab41fac2b3 --- /dev/null +++ b/test/integration/targets/elb_target/tasks/main.yml @@ -0,0 +1,482 @@ +--- + - name: set up elb_target test prerequisites + + block: + + - name: + debug: msg="********** Setting up elb_target test dependencies **********" + + # ============================================================ + + - name: set up aws connection info + set_fact: + aws_connection_info: &aws_connection_info + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + region: "{{ aws_region }}" + no_log: yes + + # ============================================================ + + - name: create target group name + set_fact: + tg_name: "ansible-test-{{ resource_prefix | regex_search('([0-9]+)$') }}-tg" + + - name: create application load balancer name + set_fact: + lb_name: "ansible-test-{{ resource_prefix | regex_search('([0-9]+)$') }}-lb" + + # ============================================================ + + - name: set up testing VPC + ec2_vpc_net: + name: "{{ resource_prefix }}-vpc" + state: present + cidr_block: 20.0.0.0/16 + <<: *aws_connection_info + tags: + Name: "{{ resource_prefix }}-vpc" + Description: "Created by ansible-test" + register: vpc + + - name: set up testing internet gateway + ec2_vpc_igw: + vpc_id: "{{ vpc.vpc.id }}" + state: present + <<: *aws_connection_info + register: igw + + - name: set up testing subnet + ec2_vpc_subnet: + state: present + vpc_id: "{{ vpc.vpc.id }}" + cidr: 20.0.0.0/18 + az: "{{ aws_region }}a" + resource_tags: + Name: "{{ resource_prefix }}-subnet" + <<: *aws_connection_info + register: subnet_1 + + - name: set up testing subnet + ec2_vpc_subnet: + state: present + vpc_id: "{{ vpc.vpc.id }}" + cidr: 20.0.64.0/18 + az: "{{ aws_region }}b" + resource_tags: + Name: "{{ resource_prefix }}-subnet" + <<: *aws_connection_info + register: subnet_2 + + - name: create routing rules + ec2_vpc_route_table: + vpc_id: "{{ vpc.vpc.id }}" + tags: + created: "{{ resource_prefix }}-route" + routes: + - dest: 0.0.0.0/0 + gateway_id: "{{ igw.gateway_id }}" + subnets: + - "{{ subnet_1.subnet.id }}" + - "{{ subnet_2.subnet.id }}" + <<: *aws_connection_info + register: route_table + + - name: create testing security group + ec2_group: + name: "{{ resource_prefix }}-sg" + description: a security group for ansible tests + vpc_id: "{{ vpc.vpc.id }}" + rules: + - proto: tcp + from_port: 80 + to_port: 80 + cidr_ip: 0.0.0.0/0 + - proto: tcp + from_port: 22 + to_port: 22 + cidr_ip: 0.0.0.0/0 + <<: *aws_connection_info + register: sg + + - name: set up testing target group (type=instance) + elb_target_group: + name: "{{ tg_name }}" + health_check_port: 80 + protocol: http + port: 80 + vpc_id: '{{ vpc.vpc.id }}' + state: present + target_type: instance + tags: + Description: "Created by {{ resource_prefix }}" + <<: *aws_connection_info + + - name: set up testing target group for ALB (type=instance) + elb_target_group: + name: "{{ tg_name }}-used" + health_check_port: 80 + protocol: http + port: 80 + vpc_id: '{{ vpc.vpc.id }}' + state: present + target_type: instance + tags: + Description: "Created by {{ resource_prefix }}" + <<: *aws_connection_info + + - name: set up ec2 instance to use as a target + ec2: + group_id: "{{ sg.group_id }}" + instance_type: t2.micro + image: "{{ ec2_ami_image[aws_region] }}" + vpc_subnet_id: "{{ subnet_2.subnet.id }}" + instance_tags: + Name: "{{ resource_prefix }}-inst" + exact_count: 1 + count_tag: + Name: "{{ resource_prefix }}-inst" + assign_public_ip: true + volumes: [] + wait: true + ebs_optimized: false + user_data: | + #cloud-config + package_upgrade: true + package_update: true + packages: + - httpd + runcmd: + - "service httpd start" + - echo "HELLO ANSIBLE" > /var/www/html/index.html + <<: *aws_connection_info + register: ec2 + + - name: create an application load balancer + elb_application_lb: + name: "{{ lb_name }}" + security_groups: + - "{{ sg.group_id }}" + subnets: + - "{{ subnet_1.subnet.id }}" + - "{{ subnet_2.subnet.id }}" + listeners: + - Protocol: HTTP + Port: 80 + DefaultActions: + - Type: forward + TargetGroupName: "{{ tg_name }}-used" + state: present + <<: *aws_connection_info + + # ============================================================ + + - name: + debug: msg="********** Running elb_target integration tests **********" + + # ============================================================ + + - name: register an instance to unused target group + elb_target: + target_group_name: "{{ tg_name }}" + target_id: "{{ ec2.instance_ids[0] }}" + state: present + <<: *aws_connection_info + register: result + + - name: target is registered + assert: + that: + - result.changed + - result.target_group_arn + - "'{{ result.target_health_descriptions.target.id }}' == '{{ ec2.instance_ids[0] }}'" + + # ============================================================ + + - name: test idempotence + elb_target: + target_group_name: "{{ tg_name }}" + target_id: "{{ ec2.instance_ids[0] }}" + state: present + <<: *aws_connection_info + register: result + + - name: target was already registered + assert: + that: + - not result.changed + + # ============================================================ + + - name: remove an unused target + elb_target: + target_group_name: "{{ tg_name }}" + target_id: "{{ ec2.instance_ids[0] }}" + state: absent + deregister_unused: true + <<: *aws_connection_info + register: result + + - name: target group was deleted + assert: + that: + - result.changed + - not result.target_health_descriptions + + # ============================================================ + + - name: register an instance to used target group and wait until healthy + elb_target: + target_group_name: "{{ tg_name }}-used" + target_id: "{{ ec2.instance_ids[0] }}" + state: present + target_status: healthy + target_status_timeout: 200 + <<: *aws_connection_info + register: result + + - name: target is registered + assert: + that: + - result.changed + - result.target_group_arn + - "'{{ result.target_health_descriptions.target.id }}' == '{{ ec2.instance_ids[0] }}'" + - "{{ result.target_health_descriptions.target_health }} == {'state': 'healthy'}" + + # ============================================================ + + - name: remove a target from used target group + elb_target: + target_group_name: "{{ tg_name }}-used" + target_id: "{{ ec2.instance_ids[0] }}" + state: absent + target_status: unused + target_status_timeout: 400 + <<: *aws_connection_info + register: result + + - name: target was deregistered + assert: + that: + - result.changed + + # ============================================================ + + - name: test idempotence + elb_target: + target_group_name: "{{ tg_name }}-used" + target_id: "{{ ec2.instance_ids[0] }}" + state: absent + <<: *aws_connection_info + register: result + + - name: target was already deregistered + assert: + that: + - not result.changed + + # ============================================================ + + - name: register an instance to used target group and wait until healthy again to test deregistering differently + elb_target: + target_group_name: "{{ tg_name }}-used" + target_id: "{{ ec2.instance_ids[0] }}" + state: present + target_status: healthy + target_status_timeout: 200 + <<: *aws_connection_info + register: result + + - name: target is registered + assert: + that: + - result.changed + - result.target_group_arn + - "'{{ result.target_health_descriptions.target.id }}' == '{{ ec2.instance_ids[0] }}'" + - "{{ result.target_health_descriptions.target_health }} == {'state': 'healthy'}" + + - name: start deregisteration but don't wait + elb_target: + target_group_name: "{{ tg_name }}-used" + target_id: "{{ ec2.instance_ids[0] }}" + state: absent + <<: *aws_connection_info + register: result + + - name: target is starting to deregister + assert: + that: + - result.changed + - result.target_health_descriptions.target_health.reason == "Target.DeregistrationInProgress" + + - name: now wait for target to finish deregistering + elb_target: + target_group_name: "{{ tg_name }}-used" + target_id: "{{ ec2.instance_ids[0] }}" + state: absent + target_status: unused + target_status_timeout: 400 + <<: *aws_connection_info + register: result + + - name: target was deregistered already and now has finished + assert: + that: + - not result.changed + - not result.target_health_descriptions + + # ============================================================ + + always: + + - name: + debug: msg="********** Tearing down elb_target test dependencies **********" + + - name: remove ec2 instance + ec2: + group_id: "{{ sg.group_id }}" + instance_type: t2.micro + image: "{{ ec2_ami_image[aws_region] }}" + vpc_subnet_id: "{{ subnet_2.subnet.id }}" + instance_tags: + Name: "{{ resource_prefix }}-inst" + exact_count: 0 + count_tag: + Name: "{{ resource_prefix }}-inst" + assign_public_ip: true + volumes: [] + wait: true + ebs_optimized: false + <<: *aws_connection_info + ignore_errors: true + + - name: remove testing target groups + elb_target_group: + name: "{{ item }}" + health_check_port: 80 + protocol: http + port: 80 + vpc_id: '{{ vpc.vpc.id }}' + state: absent + target_type: instance + tags: + Description: "Created by {{ resource_prefix }}" + wait: true + wait_timeout: 200 + <<: *aws_connection_info + register: removed + retries: 10 + until: removed is not failed + with_items: + - "{{ tg_name }}" + - "{{ tg_name }}-used" + ignore_errors: true + + - name: remove application load balancer + elb_application_lb: + name: "{{ lb_name }}" + security_groups: + - "{{ sg.group_id }}" + subnets: + - "{{ subnet_1.subnet.id }}" + - "{{ subnet_2.subnet.id }}" + listeners: + - Protocol: HTTP + Port: 80 + DefaultActions: + - Type: forward + TargetGroupName: "{{ tg_name }}-used" + state: absent + wait: true + wait_timeout: 200 + <<: *aws_connection_info + register: removed + retries: 10 + until: removed is not failed + ignore_errors: true + + - name: remove testing security group + ec2_group: + state: absent + name: "{{ resource_prefix }}-sg" + description: a security group for ansible tests + vpc_id: "{{ vpc.vpc.id }}" + rules: + - proto: tcp + from_port: 80 + to_port: 80 + cidr_ip: 0.0.0.0/0 + - proto: tcp + from_port: 22 + to_port: 22 + cidr_ip: 0.0.0.0/0 + <<: *aws_connection_info + register: removed + retries: 10 + until: removed is not failed + ignore_errors: true + + - name: remove routing rules + ec2_vpc_route_table: + state: absent + lookup: id + route_table_id: "{{ route_table.route_table.id }}" + <<: *aws_connection_info + register: removed + retries: 10 + until: removed is not failed + ignore_errors: true + + - name: remove testing subnet + ec2_vpc_subnet: + state: absent + vpc_id: "{{ vpc.vpc.id }}" + cidr: 20.0.0.0/18 + az: "{{ aws_region }}a" + resource_tags: + Name: "{{ resource_prefix }}-subnet" + <<: *aws_connection_info + register: removed + retries: 10 + until: removed is not failed + ignore_errors: true + + - name: remove testing subnet + ec2_vpc_subnet: + state: absent + vpc_id: "{{ vpc.vpc.id }}" + cidr: 20.0.64.0/18 + az: "{{ aws_region }}b" + resource_tags: + Name: "{{ resource_prefix }}-subnet" + <<: *aws_connection_info + register: removed + retries: 10 + until: removed is not failed + ignore_errors: true + + - name: remove testing internet gateway + ec2_vpc_igw: + vpc_id: "{{ vpc.vpc.id }}" + state: absent + <<: *aws_connection_info + register: removed + retries: 10 + until: removed is not failed + ignore_errors: true + + - name: remove testing VPC + ec2_vpc_net: + name: "{{ resource_prefix }}-vpc" + state: absent + cidr_block: 20.0.0.0/16 + tags: + Name: "{{ resource_prefix }}-vpc" + Description: "Created by ansible-test" + <<: *aws_connection_info + register: removed + retries: 10 + until: removed is not failed + + # ============================================================