Skip to content

Commit

Permalink
ec2_instance add the possibility to upgrade/downgrade instance type o…
Browse files Browse the repository at this point in the history
…n the fly (#2298)

SUMMARY

Closes #469

Add the possibiliy to upgrade/downgrade instance type on existing ec2 instances.
The module will stop the instance, modify the instance and then ensure the instance is in the expected state set in state argument.
ISSUE TYPE


Feature Pull Request

COMPONENT NAME

ec2_instance

Reviewed-by: Alina Buzachis
Reviewed-by: GomathiselviS
  • Loading branch information
abikouo authored Sep 30, 2024
1 parent efcaf86 commit fba059c
Show file tree
Hide file tree
Showing 10 changed files with 397 additions and 43 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
minor_changes:
- ec2_instance - add the possibility to upgrade / downgrade existing ec2 instance type (https://github.com/ansible-collections/amazon.aws/issues/469).
130 changes: 87 additions & 43 deletions plugins/modules/ec2_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@
description:
- Instance type to use for the instance, see
U(https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-types.html).
- Only required when instance is not already present.
- At least one of O(instance_type) or O(launch_template) must be specificed when launching an
instance.
- When the instance is present and the O(instance_type) specified value is different from the current value,
the instance will be stopped and the instance type will be updated.
type: str
count:
description:
Expand Down Expand Up @@ -1881,6 +1882,7 @@ def value_wrapper(v):
param_mappings = [
ParamMapper("ebs_optimized", "EbsOptimized", "ebsOptimized", value_wrapper),
ParamMapper("termination_protection", "DisableApiTermination", "disableApiTermination", value_wrapper),
ParamMapper("instance_type", "InstanceType", "instanceType", value_wrapper),
# user data is an immutable property
# ParamMapper('user_data', 'UserData', 'userData', value_wrapper),
]
Expand Down Expand Up @@ -2327,6 +2329,43 @@ def determine_iam_role(module: AnsibleAWSModule, name_or_arn: Optional[str]) ->
)


def modify_instance_type(
client,
module: AnsibleAWSModule,
state: str,
instance_id: str,
changes: Dict[str, Dict[str, str]],
) -> None:
filters = {
"instance-id": [instance_id],
}
# Ensure that the instance is stopped before changing the instance type
ensure_instance_state(client, module, "stopped", filters)

# force wait for the instance to be stopped
await_instances(client, module, ids=[instance_id], desired_module_state="stopped", force_wait=True)

# Modify instance type
modify_instance_attribute(client, instance_id=instance_id, **changes)

# Ensure instance state
desired_module_state = "running" if state == "present" else state
ensure_instance_state(client, module, desired_module_state, filters)


def modify_ec2_instance_attribute(client, module: AnsibleAWSModule, state: str, changes: List[Dict[str, Any]]) -> None:
if not module.check_mode:
for c in changes:
instance_id = c.pop("InstanceId")
try:
if "InstanceType" in c:
modify_instance_type(client, module, state, instance_id, c)
else:
modify_instance_attribute(client, instance_id=instance_id, **c)
except AnsibleEC2Error as e:
module.fail_json_aws(e, msg=f"Could not apply change {str(c)} to existing instance.")


def handle_existing(
client,
module: AnsibleAWSModule,
Expand Down Expand Up @@ -2356,13 +2395,9 @@ def handle_existing(
changed |= change_instance_metadata_options(client, module, instance)

changes = diff_instance_and_params(client, module, instance)
for c in changes:
if not module.check_mode:
try:
instance_id = c.pop("InstanceId")
modify_instance_attribute(client, instance_id=instance_id, **c)
except AnsibleEC2Error as e:
module.fail_json_aws(e, msg=f"Could not apply change {str(c)} to existing instance.")
# modify instance attributes
modify_ec2_instance_attribute(client, module, state, changes)

all_changes.extend(changes)
changed |= bool(changes)
changed |= add_or_update_instance_profile(client, module, instance, module.params.get("iam_instance_profile"))
Expand Down Expand Up @@ -2394,10 +2429,12 @@ def enforce_count(

current_count = len(existing_matches)
if current_count == exact_count:
if desired_module_state != "present":
if desired_module_state not in ("absent", "terminated"):
results = handle_existing(client, module, existing_matches, desired_module_state, filters)
else:
results = ensure_instance_state(client, module, desired_module_state, filters)
if results["changed"]:
return results
if results["changed"]:
return results
return dict(
changed=False,
instances=[pretty_instance(i) for i in existing_matches],
Expand All @@ -2407,43 +2444,53 @@ def enforce_count(

if current_count < exact_count:
# launch instances
return ensure_present(
results = ensure_present(
client,
module,
existing_matches=existing_matches,
desired_module_state=desired_module_state,
current_count=current_count,
)

to_terminate = current_count - exact_count
# sort the instances from least recent to most recent based on launch time
existing_matches = sorted(existing_matches, key=lambda inst: inst["LaunchTime"])
# get the instance ids of instances with the count tag on them
all_instance_ids = [x["InstanceId"] for x in existing_matches]
terminate_ids = all_instance_ids[0:to_terminate]
if module.check_mode:
return dict(
else:
# terminate instances
to_terminate = current_count - exact_count
# sort the instances from least recent to most recent based on launch time
existing_matches = sorted(existing_matches, key=lambda inst: inst["LaunchTime"])
# get the instance ids of instances with the count tag on them
all_instance_ids = [x["InstanceId"] for x in existing_matches]
terminate_ids = all_instance_ids[0:to_terminate]
results = dict(
changed=True,
terminated_ids=terminate_ids,
instance_ids=all_instance_ids,
msg=f"Would have terminated following instances if not in check mode {terminate_ids}",
)
# terminate instances
try:
terminate_instances(client, terminate_ids)
except AnsibleAWSError as e:
module.fail_json(e, msg="Unable to terminate instances")
await_instances(client, module, terminate_ids, desired_module_state="terminated", force_wait=True)

# include data for all matched instances in addition to the list of terminations
# allowing for recovery of metadata from the destructive operation
return dict(
changed=True,
msg="Successfully terminated instances.",
terminated_ids=terminate_ids,
instance_ids=all_instance_ids,
instances=existing_matches,
)
if not module.check_mode:
# terminate instances
try:
terminate_instances(client, terminate_ids)
except AnsibleAWSError as e:
module.fail_json(e, msg="Unable to terminate instances")
await_instances(client, module, terminate_ids, desired_module_state="terminated", force_wait=True)

# include data for all matched instances in addition to the list of terminations
# allowing for recovery of metadata from the destructive operation
results = dict(
changed=True,
msg="Successfully terminated instances.",
terminated_ids=terminate_ids,
instance_ids=all_instance_ids,
instances=existing_matches,
)

if not module.check_mode:
# Find instances
existing_matches = find_instances(client, module, filters=filters)
# Update instance attributes
updated_results = handle_existing(client, module, existing_matches, desired_module_state, filters)
if updated_results["changed"]:
results = updated_results
return results


def ensure_present(
Expand Down Expand Up @@ -2495,12 +2542,9 @@ def ensure_present(
except AnsibleEC2Error as e:
module.fail_json_aws(e, msg="Failed to fetch status of new EC2 instance")
changes = diff_instance_and_params(client, module, ins, skip=["UserData", "EbsOptimized"])
for c in changes:
try:
instance_id = c.pop("InstanceId")
modify_instance_attribute(client, instance_id=instance_id, **c)
except AnsibleEC2Error as e:
module.fail_json_aws(e, msg=f"Could not apply change {str(c)} to new instance.")
# modify instance attributes
modify_ec2_instance_attribute(client, module, desired_module_state, changes)

if existing_matches:
# If we came from enforce_count, create a second list to distinguish
# between existing and new instances when returning the entire cohort
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
region: "{{ aws_region }}"
image_id: "{{ ec2_ami_id }}"
state: present
purge_tags: false
tags:
TestId: "{{ ec2_instance_tag_TestId }}"
filters:
Expand All @@ -38,6 +39,7 @@
state: present
tags:
TestId: "{{ ec2_instance_tag_TestId }}"
purge_tags: false
filters:
tag:TestId: "{{ ec2_instance_tag_TestId }}"
wait: true
Expand Down Expand Up @@ -121,6 +123,7 @@
region: "{{ aws_region }}"
name: "{{ resource_prefix }}-test-enf_cnt"
image_id: "{{ ec2_ami_id }}"
purge_tags: false
tags:
TestId: "{{ ec2_instance_tag_TestId }}"
register: create_multiple_instances
Expand All @@ -140,6 +143,7 @@
region: "{{ aws_region }}"
image_id: "{{ ec2_ami_id }}"
name: "{{ resource_prefix }}-test-enf_cnt"
purge_tags: false
tags:
TestId: "{{ ec2_instance_tag_TestId }}"
wait: true
Expand All @@ -160,6 +164,7 @@
region: "{{ aws_region }}"
image_id: "{{ ec2_ami_id }}"
name: "{{ resource_prefix }}-test-enf_cnt"
purge_tags: false
tags:
TestId: "{{ ec2_instance_tag_TestId }}"
register: create_multiple_instances
Expand All @@ -180,6 +185,7 @@
region: "{{ aws_region }}"
image_id: "{{ ec2_ami_id }}"
name: "{{ resource_prefix }}-test-enf_cnt"
purge_tags: false
tags:
TestId: "{{ ec2_instance_tag_TestId }}"
wait: true
Expand All @@ -200,6 +206,7 @@
region: "{{ aws_region }}"
image_id: "{{ ec2_ami_id }}"
name: "{{ resource_prefix }}-test-enf_cnt"
purge_tags: false
tags:
TestId: "{{ ec2_instance_tag_TestId }}"
register: terminate_multiple_instances
Expand All @@ -222,6 +229,7 @@
region: "{{ aws_region }}"
image_id: "{{ ec2_ami_id }}"
name: "{{ resource_prefix }}-test-enf_cnt"
purge_tags: false
tags:
TestId: "{{ ec2_instance_tag_TestId }}"
wait: true
Expand All @@ -243,6 +251,7 @@
region: "{{ aws_region }}"
image_id: "{{ ec2_ami_id }}"
name: "{{ resource_prefix }}-test-enf_cnt"
purge_tags: false
tags:
TestId: "{{ ec2_instance_tag_TestId }}"
register: terminate_multiple_instances
Expand All @@ -264,6 +273,7 @@
region: "{{ aws_region }}"
image_id: "{{ ec2_ami_id }}"
name: "{{ resource_prefix }}-test-enf_cnt"
purge_tags: false
tags:
TestId: "{{ ec2_instance_tag_TestId }}"
register: terminate_multiple_instances
Expand All @@ -285,6 +295,7 @@
region: "{{ aws_region }}"
image_id: "{{ ec2_ami_id }}"
name: "{{ resource_prefix }}-test-enf_cnt"
purge_tags: false
tags:
TestId: "{{ ec2_instance_tag_TestId }}"
register: restart_multiple_instances
Expand All @@ -310,6 +321,7 @@
region: "{{ aws_region }}"
image_id: "{{ ec2_ami_id }}"
name: "{{ resource_prefix }}-test-enf_cnt"
purge_tags: false
tags:
TestId: "{{ ec2_instance_tag_TestId }}"
wait: true
Expand All @@ -335,6 +347,7 @@
region: "{{ aws_region }}"
image_id: "{{ ec2_ami_id }}"
name: "{{ resource_prefix }}-test-enf_cnt"
purge_tags: false
tags:
TestId: "{{ ec2_instance_tag_TestId }}"
wait: true
Expand All @@ -358,6 +371,7 @@
region: "{{ aws_region }}"
image_id: "{{ ec2_ami_id }}"
name: "{{ resource_prefix }}-test-enf_cnt"
purge_tags: false
tags:
TestId: "{{ ec2_instance_tag_TestId }}"
wait: true
Expand All @@ -381,6 +395,7 @@
region: "{{ aws_region }}"
image_id: "{{ ec2_ami_id }}"
name: "{{ resource_prefix }}-test-enf_cnt"
purge_tags: false
tags:
TestId: "{{ ec2_instance_tag_TestId }}"
wait: true
Expand All @@ -403,6 +418,7 @@
region: "{{ aws_region }}"
image_id: "{{ ec2_ami_id }}"
name: "{{ resource_prefix }}-test-enf_cnt"
purge_tags: false
tags:
TestId: "{{ ec2_instance_tag_TestId }}"
wait: true
Expand Down
4 changes: 4 additions & 0 deletions tests/integration/targets/ec2_instance_type/aliases
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
time=15m
cloud/aws
ec2_instance_info
ec2_instance
3 changes: 3 additions & 0 deletions tests/integration/targets/ec2_instance_type/defaults/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
ec2_instance_type_initial: t2.micro
ec2_instance_type_updated: t3.nano
4 changes: 4 additions & 0 deletions tests/integration/targets/ec2_instance_type/meta/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
dependencies:
- role: setup_ec2_facts
- role: setup_ec2_instance_env
43 changes: 43 additions & 0 deletions tests/integration/targets/ec2_instance_type/tasks/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
- module_defaults:
group/aws:
access_key: "{{ aws_access_key }}"
secret_key: "{{ aws_secret_key }}"
session_token: "{{ security_token | default(omit) }}"
region: "{{ aws_region }}"
block:
- include_tasks: single_instance.yml
vars:
ec2_instance_name: "{{ resource_prefix }}-test-instance-type-single"

- name: "Test update instance type using exact_count"
vars:
ec2_instance_name: "{{ resource_prefix }}-test-instance-type-multiple"
block:
- name: Create multiple ec2 instances
amazon.aws.ec2_instance:
state: present
name: "{{ ec2_instance_name }}"
image_id: "{{ ec2_ami_id }}"
subnet_id: "{{ testing_subnet_a.subnet.id }}"
instance_type: "{{ ec2_instance_type_initial }}"
wait: false
exact_count: 2

- name: Test upgrade instance type with various number of instances
include_tasks: update_instance_type.yml
with_items:
- new_instance_type: "{{ ec2_instance_type_updated }}"
new_instance_count: 2
- new_instance_type: "{{ ec2_instance_type_initial }}"
new_instance_count: 3
- new_instance_type: "{{ ec2_instance_type_updated }}"
new_instance_count: 2

always:
- name: Delete ec2 instances
amazon.aws.ec2_instance:
state: absent
instance_ids: "{{ _instances_info.instances | map(attribute='instance_id') | list }}"
wait: false
when: _instances_info is defined
Loading

0 comments on commit fba059c

Please sign in to comment.