Skip to content

Commit

Permalink
ec2_asg: Add functionality to detach specified instances from ASG (#933)
Browse files Browse the repository at this point in the history
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 <None>
Reviewed-by: Mandar Kulkarni <[email protected]>
Reviewed-by: Jill R <None>
Reviewed-by: Joseph Torcasso <None>
  • Loading branch information
mandar242 authored Feb 26, 2022
1 parent 82ad084 commit 44d2b30
Show file tree
Hide file tree
Showing 4 changed files with 283 additions and 4 deletions.
3 changes: 3 additions & 0 deletions changelogs/fragments/933-ec2_asg-detach-instances-feature.yml
Original file line number Diff line number Diff line change
@@ -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).
73 changes: 70 additions & 3 deletions plugins/modules/ec2_asg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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':
Expand All @@ -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)
Expand Down
208 changes: 208 additions & 0 deletions tests/integration/targets/ec2_asg/tasks/instance_detach.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion tests/integration/targets/ec2_asg/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token | default(omit) }}"
region: "{{ aws_region }}"

collections:
- amazon.aws

Expand Down Expand Up @@ -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 }}"
Expand Down

0 comments on commit 44d2b30

Please sign in to comment.