From 44d2b30965fd7f9d3265ba7e8822da8c4f3f22c0 Mon Sep 17 00:00:00 2001 From: Mandar Kulkarni Date: Sat, 26 Feb 2022 03:22:04 -0800 Subject: [PATCH] ec2_asg: Add functionality to detach specified instances from ASG (#933) ec2_asg: Add functionality to detach specified instances from ASG SUMMARY Adds feature to detach specified instances from a AutoScalingGroup rather than terminating them directly. Detached instances are not terminated and can be managed independently. Implements #649 ISSUE TYPE Feature Pull Request COMPONENT NAME ec2_asg ADDITIONAL INFORMATION Makes use of https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/autoscaling.html#AutoScaling.Client.detach_instances Reviewed-by: Alina Buzachis Reviewed-by: Mandar Kulkarni Reviewed-by: Jill R Reviewed-by: Joseph Torcasso --- .../933-ec2_asg-detach-instances-feature.yml | 3 + plugins/modules/ec2_asg.py | 73 +++++- .../targets/ec2_asg/tasks/instance_detach.yml | 208 ++++++++++++++++++ .../targets/ec2_asg/tasks/main.yml | 3 +- 4 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 changelogs/fragments/933-ec2_asg-detach-instances-feature.yml create mode 100644 tests/integration/targets/ec2_asg/tasks/instance_detach.yml diff --git a/changelogs/fragments/933-ec2_asg-detach-instances-feature.yml b/changelogs/fragments/933-ec2_asg-detach-instances-feature.yml new file mode 100644 index 00000000000..3ebb13fff78 --- /dev/null +++ b/changelogs/fragments/933-ec2_asg-detach-instances-feature.yml @@ -0,0 +1,3 @@ +minor_changes: + - ec2_asg - Added functionality to detach specific instances and/or decrement desired capacity + from ASG without terminating instances (https://github.com/ansible-collections/community.aws/pull/933). diff --git a/plugins/modules/ec2_asg.py b/plugins/modules/ec2_asg.py index 46cdcbf15b8..8dc7cd783f2 100644 --- a/plugins/modules/ec2_asg.py +++ b/plugins/modules/ec2_asg.py @@ -182,6 +182,21 @@ matching the current launch configuration. type: list elements: str + detach_instances: + description: + - Removes one or more instances from the specified AutoScalingGroup. + - If I(decrement_desired_capacity) flag is not set, new instance(s) are launched to replace the detached instance(s). + - If a Classic Load Balancer is attached to the AutoScalingGroup, the instances are also deregistered from the load balancer. + - If there are target groups attached to the AutoScalingGroup, the instances are also deregistered from the target groups. + type: list + elements: str + version_added: 3.2.0 + decrement_desired_capacity: + description: + - Indicates whether the AutoScalingGroup decrements the desired capacity value by the number of instances detached. + default: false + type: bool + version_added: 3.2.0 lc_check: description: - Check to make sure instances that are being replaced with I(replace_instances) do not already have the current I(launch_config). @@ -756,6 +771,12 @@ def terminate_asg_instance(connection, instance_id, decrement_capacity): ShouldDecrementDesiredCapacity=decrement_capacity) +@AWSRetry.jittered_backoff(**backoff_params) +def detach_asg_instances(connection, instance_ids, as_group_name, decrement_capacity): + connection.detach_instances(InstanceIds=instance_ids, AutoScalingGroupName=as_group_name, + ShouldDecrementDesiredCapacity=decrement_capacity) + + def enforce_required_arguments_for_create(): ''' As many arguments are not required for autoscale group deletion they cannot be mandatory arguments for the module, so we enforce @@ -1523,6 +1544,40 @@ def replace(connection): return changed, asg_properties +def detach(connection): + group_name = module.params.get('name') + detach_instances = module.params.get('detach_instances') + as_group = describe_autoscaling_groups(connection, group_name)[0] + decrement_desired_capacity = module.params.get('decrement_desired_capacity') + min_size = module.params.get('min_size') + props = get_properties(as_group) + instances = props['instances'] + + # check if provided instance exists in asg, create list of instances to detach which exist in asg + instances_to_detach = [] + for instance_id in detach_instances: + if instance_id in instances: + instances_to_detach.append(instance_id) + + # check if setting decrement_desired_capacity will make desired_capacity smaller + # than the currently set minimum size in ASG configuration + if decrement_desired_capacity: + decremented_desired_capacity = len(instances) - len(instances_to_detach) + if min_size and min_size > decremented_desired_capacity: + module.fail_json( + msg="Detaching instance(s) with 'decrement_desired_capacity' flag set reduces number of instances to {0}\ + which is below current min_size {1}, please update AutoScalingGroup Sizes properly.".format(decremented_desired_capacity, min_size)) + + if instances_to_detach: + try: + detach_asg_instances(connection, instances_to_detach, group_name, decrement_desired_capacity) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to detach instances from AutoScaling Group") + + asg_properties = get_properties(as_group) + return True, asg_properties + + def get_instances_by_launch_config(props, lc_check, initial_instances): new_instances = [] old_instances = [] @@ -1776,6 +1831,8 @@ def main(): replace_batch_size=dict(type='int', default=1), replace_all_instances=dict(type='bool', default=False), replace_instances=dict(type='list', default=[], elements='str'), + detach_instances=dict(type='list', default=[], elements='str'), + decrement_desired_capacity=dict(type='bool', default=False), lc_check=dict(type='bool', default=True), lt_check=dict(type='bool', default=True), wait_timeout=dict(type='int', default=300), @@ -1821,16 +1878,18 @@ def main(): argument_spec=argument_spec, mutually_exclusive=[ ['replace_all_instances', 'replace_instances'], - ['launch_config_name', 'launch_template'] + ['replace_all_instances', 'detach_instances'], + ['launch_config_name', 'launch_template'], ] ) state = module.params.get('state') replace_instances = module.params.get('replace_instances') replace_all_instances = module.params.get('replace_all_instances') + detach_instances = module.params.get('detach_instances') connection = module.client('autoscaling') - changed = create_changed = replace_changed = False + changed = create_changed = replace_changed = detach_changed = False exists = asg_exists(connection) if state == 'present': @@ -1847,7 +1906,15 @@ def main(): ): replace_changed, asg_properties = replace(connection) - if create_changed or replace_changed: + # Only detach instances if asg existed at start of call + if ( + exists + and (detach_instances) + and (module.params.get('launch_config_name') or module.params.get('launch_template')) + ): + detach_changed, asg_properties = detach(connection) + + if create_changed or replace_changed or detach_changed: changed = True module.exit_json(changed=changed, **asg_properties) diff --git a/tests/integration/targets/ec2_asg/tasks/instance_detach.yml b/tests/integration/targets/ec2_asg/tasks/instance_detach.yml new file mode 100644 index 00000000000..da574c2ebf2 --- /dev/null +++ b/tests/integration/targets/ec2_asg/tasks/instance_detach.yml @@ -0,0 +1,208 @@ +- name: Running instance detach tests + block: + #---------------------------------------------------------------------- + - name: create a launch configuration + ec2_lc: + name: "{{ resource_prefix }}-lc-detach-test" + image_id: "{{ ec2_ami_image }}" + region: "{{ aws_region }}" + instance_type: t2.micro + assign_public_ip: yes + register: create_lc + + - name: ensure that lc is created + assert: + that: + - create_lc is changed + - create_lc.failed is false + - '"autoscaling:CreateLaunchConfiguration" in create_lc.resource_actions' + + #---------------------------------------------------------------------- + - name: create a AutoScalingGroup to be used for instance_detach test + ec2_asg: + name: "{{ resource_prefix }}-asg-detach-test" + launch_config_name: "{{ resource_prefix }}-lc-detach-test" + health_check_period: 60 + health_check_type: ELB + replace_all_instances: yes + min_size: 3 + max_size: 6 + desired_capacity: 3 + region: "{{ aws_region }}" + register: create_asg + + - name: ensure that AutoScalingGroup is created + assert: + that: + - create_asg is changed + - create_asg.failed is false + - create_asg.instances | length == 3 + - create_asg.desired_capacity == 3 + - create_asg.in_service_instances == 3 + - '"autoscaling:CreateAutoScalingGroup" in create_asg.resource_actions' + + - name: gather info about asg, get instance ids + ec2_asg_info: + name: "{{ resource_prefix }}-asg-detach-test" + register: asg_info + - set_fact: + init_instance_1: "{{ asg_info.results[0].instances[0].instance_id }}" + init_instance_2: "{{ asg_info.results[0].instances[1].instance_id }}" + init_instance_3: "{{ asg_info.results[0].instances[2].instance_id }}" + + - name: Gather information about recently detached instances + amazon.aws.ec2_instance_info: + instance_ids: + - "{{ init_instance_1 }}" + - "{{ init_instance_2 }}" + - "{{ init_instance_3 }}" + register: instances_info + + # assert that there are 3 instances running in the AutoScalingGroup + - assert: + that: + - asg_info.results[0].instances | length == 3 + - "'{{ instances_info.instances[0].state.name }}' == 'running'" + - "'{{ instances_info.instances[1].state.name }}' == 'running'" + - "'{{ instances_info.instances[2].state.name }}' == 'running'" + + #---------------------------------------------------------------------- + + - name: detach 2 instance from the asg and replace with other instances + ec2_asg: + name: "{{ resource_prefix }}-asg-detach-test" + launch_config_name: "{{ resource_prefix }}-lc-detach-test" + health_check_period: 60 + health_check_type: ELB + min_size: 3 + max_size: 3 + desired_capacity: 3 + region: "{{ aws_region }}" + detach_instances: + - '{{ init_instance_1 }}' + - '{{ init_instance_2 }}' + + # pause to allow completion of instance replacement + - name: Pause for 1 minute + pause: + seconds: 30 + + # gather info about asg and get instance ids + - ec2_asg_info: + name: "{{ resource_prefix }}-asg-detach-test" + register: asg_info_replaced + - set_fact: + instance_replace_1: "{{ asg_info_replaced.results[0].instances[0].instance_id }}" + instance_replace_2: "{{ asg_info_replaced.results[0].instances[1].instance_id }}" + instance_replace_3: "{{ asg_info_replaced.results[0].instances[2].instance_id }}" + + # create a list of instance currently attached to asg + - set_fact: + asg_instance_detach_replace: "{{ asg_info_replaced.results[0].instances | map(attribute='instance_id') | list }}" + + - name: Gather information about recently detached instances + amazon.aws.ec2_instance_info: + instance_ids: + - "{{ init_instance_1 }}" + - "{{ init_instance_2 }}" + register: detached_instances_info + + # assert that + # there are 3 still instances in the AutoScalingGroup + # two specified instances are detached and still running independently(not terminated) + - assert: + that: + - asg_info_replaced.results[0].desired_capacity == 3 + - asg_info_replaced.results[0].instances | length == 3 + - "'{{ init_instance_1 }}' not in {{ asg_instance_detach_replace }}" + - "'{{ init_instance_2 }}' not in {{ asg_instance_detach_replace }}" + - "'{{ detached_instances_info.instances[0].state.name }}' == 'running'" + - "'{{ detached_instances_info.instances[1].state.name }}' == 'running'" + + #---------------------------------------------------------------------- + + # detach 2 instances from the asg and reduce the desired capacity from 3 to 1 + - name: detach 2 instance from the asg and reduce the desired capacity from 3 to 1 + ec2_asg: + name: "{{ resource_prefix }}-asg-detach-test" + launch_config_name: "{{ resource_prefix }}-lc-detach-test" + health_check_period: 60 + health_check_type: ELB + min_size: 1 + max_size: 5 + desired_capacity: 3 + region: "{{ aws_region }}" + decrement_desired_capacity: true + detach_instances: + - '{{ instance_replace_1 }}' + - '{{ instance_replace_2 }}' + + - name: Pause for 1 minute to allow completion of above task + pause: + seconds: 30 + + # gather information about asg and get instance id + - ec2_asg_info: + name: "{{ resource_prefix }}-asg-detach-test" + register: asg_info_decrement + - set_fact: + instance_detach_decrement: "{{ asg_info_decrement.results[0].instances[0].instance_id }}" + # create a list of instance ids from info result and set variable value to instance ID + - set_fact: + asg_instance_detach_decrement: "{{ asg_info_decrement.results[0].instances | map(attribute='instance_id') | list }}" + + - name: Gather information about recently detached instances + amazon.aws.ec2_instance_info: + instance_ids: + - "{{ instance_replace_1 }}" + - "{{ instance_replace_2 }}" + register: detached_instances_info + + # assert that + # detached instances are not replaced and there is only 1 instance in the AutoScalingGroup + # desired capacity is reduced to 1 + # detached instances are not terminated + - assert: + that: + - asg_info_decrement.results[0].instances | length == 1 + - asg_info_decrement.results[0].desired_capacity == 1 + - "'{{ instance_replace_1 }}' not in {{ asg_instance_detach_decrement }}" + - "'{{ instance_replace_2 }}' not in {{ asg_instance_detach_decrement }}" + - "'{{ detached_instances_info.instances[0].state.name }}' == 'running'" + - "'{{ detached_instances_info.instances[1].state.name }}' == 'running'" + - "'{{ instance_replace_3 }}' == '{{ instance_detach_decrement }}'" + + #---------------------------------------------------------------------- + + always: + + - name: terminate any instances created during this test + amazon.aws.ec2_instance: + instance_ids: + - "{{ item }}" + state: absent + loop: + - "{{ init_instance_1 }}" + - "{{ init_instance_2 }}" + - "{{ init_instance_3 }}" + - "{{ instance_replace_1 }}" + - "{{ instance_replace_2 }}" + - "{{ instance_replace_3 }}" + + - name: kill asg created in this test + ec2_asg: + name: "{{ resource_prefix }}-asg-detach-test" + state: absent + register: removed + until: removed is not failed + ignore_errors: yes + retries: 10 + + - name: remove launch config created in this test + ec2_lc: + name: "{{ resource_prefix }}-lc-detach-test" + state: absent + register: removed + until: removed is not failed + ignore_errors: yes + retries: 10 diff --git a/tests/integration/targets/ec2_asg/tasks/main.yml b/tests/integration/targets/ec2_asg/tasks/main.yml index 800c167bde8..68d2f9175a2 100644 --- a/tests/integration/targets/ec2_asg/tasks/main.yml +++ b/tests/integration/targets/ec2_asg/tasks/main.yml @@ -30,7 +30,6 @@ aws_secret_key: "{{ aws_secret_key }}" security_token: "{{ security_token | default(omit) }}" region: "{{ aws_region }}" - collections: - amazon.aws @@ -98,6 +97,8 @@ cidr_ip: 0.0.0.0/0 register: sg + - include_tasks: instance_detach.yml + - name: ensure launch configs exist ec2_lc: name: "{{ item }}"