Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ec2_asg: Add functionality to detach specified instances from ASG #933

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 lauched to replace the detached instance(s).
- If a Classic Load Balancer attached to the AutoScalingGroup, the instances are also deregistered from the load balancer.
mandar242 marked this conversation as resolved.
Show resolved Hide resolved
- If there are target groups attached to the AutoScalingGroup, the instances are also deregistered from the target groups.
type: list
elements: str
mandar242 marked this conversation as resolved.
Show resolved Hide resolved
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
mandar242 marked this conversation as resolved.
Show resolved Hide resolved
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]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a case where multiple autoscaling groups are returned here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think as we are passing only single group name, we will only single auto scaling group's info.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just checking! Wasnt sure if there were cases where multiple groups had the same name

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ASG name is unique within a region and account. Since we can only act on one region and account at a time, this works well.

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 below min_size,\
please update AutoScalingGroup Sizes properly.")
mandar242 marked this conversation as resolved.
Show resolved Hide resolved

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 describe launch configurations")
mandar242 marked this conversation as resolved.
Show resolved Hide resolved

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:
minutes: 1

# 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:
minutes: 1

# 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
4 changes: 3 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 @@ -117,6 +116,8 @@
- "{{ resource_prefix }}-lc"
- "{{ resource_prefix }}-lc-2"

- include_tasks: instance_detach.yml

# ============================================================

- name: launch asg and wait for instances to be deemed healthy (no ELB)
Expand Down Expand Up @@ -724,6 +725,7 @@
- "output.target_group_arns[0] == out_tg1.target_group_arn"
- "output.changed == false"


# ============================================================

always:
Expand Down