From c5a1e641497b8ed85b5b10a8fd873dc746020b93 Mon Sep 17 00:00:00 2001 From: Bikouo Aubin <79859644+abikouo@users.noreply.github.com> Date: Wed, 25 Sep 2024 18:00:10 +0200 Subject: [PATCH] autoscaling_instance_refresh - prepare modules for promotion (#2150) SUMMARY Closes #2120 Closes #2019 Closes #2016 Prepare modules autoscaling_instance_refresh and autoscaling_instance_refresh_info for promotion: Refactor modules to use common code from ansible_collections.amazon.aws.plugins.module_utils.autoscaling Add type hinting Update integration tests ISSUE TYPE Feature Pull Request Reviewed-by: GomathiselviS Reviewed-by: Bikouo Aubin Reviewed-by: Alina Buzachis This commit was initially merged in https://github.com/ansible-collections/community.aws See: https://github.com/ansible-collections/community.aws/commit/d59fa93cf463cbb194cd10ab1bb0e59763a5b3f0 --- .../modules/autoscaling_instance_refresh.py | 238 +++++++----- .../autoscaling_instance_refresh_info.py | 230 ++++++------ .../tasks/instance_refresh_info.yml | 99 +++++ .../tasks/main.yml | 351 ++---------------- .../tasks/refresh_and_cancel_three_times.yml | 19 +- .../tasks/start_cancel_instance_refresh.yml | 206 ++++++++++ .../test_autoscaling_instance_refresh.py | 28 ++ 7 files changed, 618 insertions(+), 553 deletions(-) create mode 100644 tests/integration/targets/autoscaling_instance_refresh/tasks/instance_refresh_info.yml create mode 100644 tests/integration/targets/autoscaling_instance_refresh/tasks/start_cancel_instance_refresh.yml create mode 100644 tests/unit/plugins/modules/test_autoscaling_instance_refresh.py diff --git a/plugins/modules/autoscaling_instance_refresh.py b/plugins/modules/autoscaling_instance_refresh.py index b301fea9439..b337b5b1f52 100644 --- a/plugins/modules/autoscaling_instance_refresh.py +++ b/plugins/modules/autoscaling_instance_refresh.py @@ -12,7 +12,7 @@ description: - Start or cancel an EC2 Auto Scaling Group instance refresh in AWS. - Can be used with M(community.aws.autoscaling_instance_refresh_info) to track the subsequent progress. - - Prior to release 5.0.0 this module was called C(community.aws.ec2_asg_instance_refresh). + - Prior to release 5.0.0 this module was called M(community.aws.ec2_asg_instance_refresh). The usage did not change. author: - "Dan Khersonsky (@danquixote)" @@ -30,7 +30,7 @@ required: true strategy: description: - - The strategy to use for the instance refresh. The only valid value is C(Rolling). + - The strategy to use for the instance refresh. The only valid value is V(Rolling). - A rolling update is an update that is applied to all instances in an Auto Scaling group until all instances have been updated. - A rolling update can fail due to failed health checks or if instances are on standby or are protected from scale in. - If the rolling update process fails, any instances that were already replaced are not rolled back to their previous configuration. @@ -40,15 +40,16 @@ description: - Set of preferences associated with the instance refresh request. - If not provided, the default values are used. - - For I(min_healthy_percentage), the default value is C(90). - - For I(instance_warmup), the default is to use the value specified for the health check grace period for the Auto Scaling group. - - Can not be specified when I(state) is set to 'cancelled'. + - For O(preferences.min_healthy_percentage), the default value is V(90). + - For O(preferences.instance_warmup), the default is to use the value specified for the health check grace period for the Auto Scaling group. + - Can not be specified when O(state=cancelled). required: false suboptions: min_healthy_percentage: description: - Total percent of capacity in ASG that must remain healthy during instance refresh to allow operation to continue. - It is rounded up to the nearest integer. + - Value range is V(0) to V(100). type: int default: 90 instance_warmup: @@ -57,6 +58,21 @@ - During this time, Amazon EC2 Auto Scaling does not immediately move on to the next replacement. - The default is to use the value for the health check grace period defined for the group. type: int + skip_matching: + description: + - Indicates whether skip matching is enabled. + - If enabled V(true), then Amazon EC2 Auto Scaling skips replacing instances that match the desired configuration. + type: bool + version_added: 9.0.0 + max_healthy_percentage: + description: + - Specifies the maximum percentage of the group that can be in service and healthy, or pending, + to support your workload when replacing instances. + - The value is expressed as a percentage of the desired capacity of the Auto Scaling group. + - Value range is V(100) to V(200). + - When specified, you must also specify O(preferences.min_healthy_percentage), and the difference between them cannot be greater than V(100). + type: int + version_added: 9.0.0 type: dict extends_documentation_fragment: - amazon.aws.common.modules @@ -84,98 +100,117 @@ preferences: min_healthy_percentage: 91 instance_warmup: 60 + skip_matching: true """ RETURN = r""" ---- -instance_refresh_id: - description: instance refresh id - returned: success - type: str - sample: "08b91cf7-8fa6-48af-b6a6-d227f40f1b9b" -auto_scaling_group_name: - description: Name of autoscaling group - returned: success - type: str - sample: "public-webapp-production-1" -status: - description: - - The current state of the group when DeleteAutoScalingGroup is in progress. - - The following are the possible statuses - - Pending -- The request was created, but the operation has not started. - - InProgress -- The operation is in progress. - - Successful -- The operation completed successfully. - - Failed -- The operation failed to complete. You can troubleshoot using the status reason and the scaling activities. - - Cancelling -- - - An ongoing operation is being cancelled. - - Cancellation does not roll back any replacements that have already been completed, - - but it prevents new replacements from being started. - - Cancelled -- The operation is cancelled. - returned: success - type: str - sample: "Pending" -start_time: - description: The date and time this ASG was created, in ISO 8601 format. - returned: success - type: str - sample: "2015-11-25T00:05:36.309Z" -end_time: - description: The date and time this ASG was created, in ISO 8601 format. - returned: success - type: str - sample: "2015-11-25T00:05:36.309Z" -percentage_complete: - description: the % of completeness - returned: success - type: int - sample: 100 -instances_to_update: - description: num. of instance to update - returned: success - type: int - sample: 5 +instance_refreshes: + description: Details of the instance refreshes for the Auto Scaling group. + returned: always + type: complex + contains: + instance_refresh_id: + description: Instance refresh id. + returned: success + type: str + sample: "08b91cf7-8fa6-48af-b6a6-d227f40f1b9b" + auto_scaling_group_name: + description: Name of autoscaling group. + returned: success + type: str + sample: "public-webapp-production-1" + status: + description: + - The current state of the group when DeleteAutoScalingGroup is in progress. + - The following are the possible statuses + - Pending - The request was created, but the operation has not started. + - InProgress - The operation is in progress. + - Successful - The operation completed successfully. + - Failed - The operation failed to complete. + You can troubleshoot using the status reason and the scaling activities. + - Cancelling - An ongoing operation is being cancelled. + Cancellation does not roll back any replacements that have already been + completed, but it prevents new replacements from being started. + - Cancelled - The operation is cancelled. + returned: success + type: str + sample: "Pending" + preferences: + description: The preferences for an instance refresh. + returned: always + type: dict + sample: { + 'AlarmSpecification': { + 'Alarms': [ + 'my-alarm', + ], + }, + 'AutoRollback': True, + 'InstanceWarmup': 200, + 'MinHealthyPercentage': 90, + 'ScaleInProtectedInstances': 'Ignore', + 'SkipMatching': False, + 'StandbyInstances': 'Ignore', + } + start_time: + description: The date and time this ASG was created, in ISO 8601 format. + returned: success + type: str + sample: "2015-11-25T00:05:36.309Z" + end_time: + description: The date and time this ASG was created, in ISO 8601 format. + returned: success + type: str + sample: "2015-11-25T00:05:36.309Z" + percentage_complete: + description: the % of completeness. + returned: success + type: int + sample: 100 + instances_to_update: + description: number of instances to update. + returned: success + type: int + sample: 5 """ -try: - from botocore.exceptions import BotoCoreError - from botocore.exceptions import ClientError -except ImportError: - pass # caught by AnsibleAWSModule +from typing import Dict +from typing import Optional +from typing import Union from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict from ansible.module_utils.common.dict_transformations import snake_dict_to_camel_dict -from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry +from ansible_collections.amazon.aws.plugins.module_utils.autoscaling import AnsibleAutoScalingError +from ansible_collections.amazon.aws.plugins.module_utils.autoscaling import cancel_instance_refresh +from ansible_collections.amazon.aws.plugins.module_utils.autoscaling import describe_instance_refreshes +from ansible_collections.amazon.aws.plugins.module_utils.autoscaling import start_instance_refresh from ansible_collections.amazon.aws.plugins.module_utils.transformation import scrub_none_parameters from ansible_collections.community.aws.plugins.module_utils.modules import AnsibleCommunityAWSModule as AnsibleAWSModule -def start_or_cancel_instance_refresh(conn, module): +def validate_healthy_percentage(preferences: Dict[str, Union[bool, int]]) -> Optional[str]: + min_healthy_percentage = preferences.get("min_healthy_percentage") + max_healthy_percentage = preferences.get("max_healthy_percentage") + + if min_healthy_percentage is not None and (min_healthy_percentage < 0 or min_healthy_percentage > 100): + return "The value range for the min_healthy_percentage is 0 to 100." + if max_healthy_percentage is not None: + if max_healthy_percentage < 100 or max_healthy_percentage > 200: + return "The value range for the max_healthy_percentage is 100 to 200." + if min_healthy_percentage is None: + return "You must also specify min_healthy_percentage when max_healthy_percentage is specified." + if (max_healthy_percentage - min_healthy_percentage) > 100: + return "The difference between the max_healthy_percentage and min_healthy_percentage cannot be greater than 100." + return None + + +def start_or_cancel_instance_refresh(conn, module: AnsibleAWSModule) -> None: """ Args: conn (boto3.AutoScaling.Client): Valid Boto3 ASG client. module: AnsibleAWSModule object - - Returns: - { - "instance_refreshes": [ - { - 'auto_scaling_group_name': 'ansible-test-hermes-63642726-asg', - 'instance_refresh_id': '6507a3e5-4950-4503-8978-e9f2636efc09', - 'instances_to_update': 1, - 'percentage_complete': 0, - "preferences": { - "instance_warmup": 60, - "min_healthy_percentage": 90, - "skip_matching": false - }, - 'start_time': '2021-02-04T03:39:40+00:00', - 'status': 'Cancelling', - 'status_reason': 'Replacing instances before cancelling.', - } - ] - } """ asg_state = module.params.get("state") @@ -183,24 +218,25 @@ def start_or_cancel_instance_refresh(conn, module): preferences = module.params.get("preferences") args = {} - args["AutoScalingGroupName"] = asg_name if asg_state == "started": args["Strategy"] = module.params.get("strategy") if preferences: if asg_state == "cancelled": module.fail_json(msg="can not pass preferences dict when canceling a refresh") - _prefs = scrub_none_parameters(preferences) - args["Preferences"] = snake_dict_to_camel_dict(_prefs, capitalize_first=True) + error = validate_healthy_percentage(preferences) + if error: + module.fail_json(msg=error) + args["Preferences"] = snake_dict_to_camel_dict(scrub_none_parameters(preferences), capitalize_first=True) cmd_invocations = { - "cancelled": conn.cancel_instance_refresh, - "started": conn.start_instance_refresh, + "cancelled": cancel_instance_refresh, + "started": start_instance_refresh, } try: if module.check_mode: + ongoing_refresh = describe_instance_refreshes(conn, auto_scaling_group_name=asg_name).get( + "InstanceRefreshes", [] + ) if asg_state == "started": - ongoing_refresh = conn.describe_instance_refreshes(AutoScalingGroupName=asg_name).get( - "InstanceRefreshes", "[]" - ) if ongoing_refresh: module.exit_json( changed=False, @@ -209,26 +245,23 @@ def start_or_cancel_instance_refresh(conn, module): else: module.exit_json(changed=True, msg="Would have started instance refresh if not in check mode.") elif asg_state == "cancelled": - ongoing_refresh = conn.describe_instance_refreshes(AutoScalingGroupName=asg_name).get( - "InstanceRefreshes", "[]" - )[0] - if ongoing_refresh.get("Status", "") in ["Cancelling", "Cancelled"]: + if ongoing_refresh and ongoing_refresh[0].get("Status", "") in ["Cancelling", "Cancelled"]: module.exit_json( changed=False, msg="In check_mode - Instance Refresh already cancelled or is pending cancellation.", ) elif not ongoing_refresh: - module.exit_json(chaned=False, msg="In check_mode - No active referesh found, nothing to cancel.") + module.exit_json(changed=False, msg="In check_mode - No active referesh found, nothing to cancel.") else: module.exit_json(changed=True, msg="Would have cancelled instance refresh if not in check mode.") - result = cmd_invocations[asg_state](aws_retry=True, **args) - instance_refreshes = conn.describe_instance_refreshes( - AutoScalingGroupName=asg_name, InstanceRefreshIds=[result["InstanceRefreshId"]] + instance_refresh_id = cmd_invocations[asg_state](conn, auto_scaling_group_name=asg_name, **args) + response = describe_instance_refreshes( + conn, auto_scaling_group_name=asg_name, instance_refresh_ids=[instance_refresh_id] ) - result = dict(instance_refreshes=camel_dict_to_snake_dict(instance_refreshes["InstanceRefreshes"][0])) - return module.exit_json(**result) - except (BotoCoreError, ClientError) as e: - module.fail_json_aws(e, msg=f"Failed to {asg_state.replace('ed', '')} InstanceRefresh") + result = dict(instance_refreshes=camel_dict_to_snake_dict(response["InstanceRefreshes"][0])) + module.exit_json(**result) + except AnsibleAutoScalingError as e: + module.fail_json_aws(e, msg=f"Failed to {asg_state.replace('ed', '')} InstanceRefresh: {e}") def main(): @@ -246,6 +279,8 @@ def main(): options=dict( min_healthy_percentage=dict(type="int", default=90), instance_warmup=dict(type="int"), + skip_matching=dict(type="bool"), + max_healthy_percentage=dict(type="int"), ), ), ) @@ -254,10 +289,7 @@ def main(): argument_spec=argument_spec, supports_check_mode=True, ) - autoscaling = module.client( - "autoscaling", - retry_decorator=AWSRetry.jittered_backoff(retries=10, catch_extra_error_codes=["InstanceRefreshInProgress"]), - ) + autoscaling = module.client("autoscaling") start_or_cancel_instance_refresh(autoscaling, module) diff --git a/plugins/modules/autoscaling_instance_refresh_info.py b/plugins/modules/autoscaling_instance_refresh_info.py index 639940b1b77..1d3bf8fe90c 100644 --- a/plugins/modules/autoscaling_instance_refresh_info.py +++ b/plugins/modules/autoscaling_instance_refresh_info.py @@ -11,8 +11,8 @@ short_description: Gather information about EC2 Auto Scaling Group (ASG) Instance Refreshes in AWS description: - Describes one or more instance refreshes. - - You can determine the status of a request by looking at the I(status) parameter. - - Prior to release 5.0.0 this module was called C(community.aws.ec2_asg_instance_refresh_info). + - You can determine the status of a request by looking at the RV(instance_refreshes.status) return value. + - Prior to release 5.0.0 this module was called M(community.aws.ec2_asg_instance_refresh_info). The usage did not change. author: - "Dan Khersonsky (@danquixote)" @@ -34,7 +34,7 @@ type: str max_records: description: - - The maximum number of items to return with this call. The default value is 50 and the maximum value is 100. + - The maximum number of items to return with this call. The default value is V(50) and the maximum value is V(100). type: int required: false extends_documentation_fragment: @@ -70,131 +70,137 @@ """ RETURN = r""" ---- -instance_refresh_id: - description: instance refresh id - returned: success - type: str - sample: "08b91cf7-8fa6-48af-b6a6-d227f40f1b9b" -auto_scaling_group_name: - description: Name of autoscaling group - returned: success - type: str - sample: "public-webapp-production-1" -status: - description: - - The current state of the group when DeleteAutoScalingGroup is in progress. - - The following are the possible statuses - - C(Pending) - The request was created, but the operation has not started. - - C(InProgress) - The operation is in progress. - - C(Successful) - The operation completed successfully. - - C(Failed) - The operation failed to complete. - You can troubleshoot using the status reason and the scaling activities. - - C(Cancelling) - An ongoing operation is being cancelled. - Cancellation does not roll back any replacements that have already been - completed, but it prevents new replacements from being started. - - C(Cancelled) - The operation is cancelled.' - returned: success - type: str - sample: "Pending" -start_time: - description: The date and time this ASG was created, in ISO 8601 format. - returned: success - type: str - sample: "2015-11-25T00:05:36.309Z" -end_time: - description: The date and time this ASG was created, in ISO 8601 format. - returned: success - type: str - sample: "2015-11-25T00:05:36.309Z" -percentage_complete: - description: the % of completeness - returned: success - type: int - sample: 100 -instances_to_update: - description: num. of instance to update - returned: success - type: int - sample: 5 +next_token: + description: A string that indicates that the response contains more items than can be returned in a single response. + returned: always + type: str +instance_refreshes: + description: A list of instance refreshes. + returned: always + type: complex + contains: + instance_refresh_id: + description: instance refresh id. + returned: success + type: str + sample: "08b91cf7-8fa6-48af-b6a6-d227f40f1b9b" + auto_scaling_group_name: + description: Name of autoscaling group. + returned: success + type: str + sample: "public-webapp-production-1" + status: + description: + - The current state of the group when DeleteAutoScalingGroup is in progress. + - The following are the possible statuses + - Pending - The request was created, but the operation has not started. + - InProgress - The operation is in progress. + - Successful - The operation completed successfully. + - Failed - The operation failed to complete. + You can troubleshoot using the status reason and the scaling activities. + - Cancelling - An ongoing operation is being cancelled. + Cancellation does not roll back any replacements that have already been + completed, but it prevents new replacements from being started. + - Cancelled - The operation is cancelled. + returned: success + type: str + sample: "Pending" + preferences: + description: The preferences for an instance refresh. + returned: always + type: dict + sample: { + 'AlarmSpecification': { + 'Alarms': [ + 'my-alarm', + ], + }, + 'AutoRollback': True, + 'InstanceWarmup': 200, + 'MinHealthyPercentage': 90, + 'ScaleInProtectedInstances': 'Ignore', + 'SkipMatching': False, + 'StandbyInstances': 'Ignore', + } + start_time: + description: The date and time this ASG was created, in ISO 8601 format. + returned: success + type: str + sample: "2015-11-25T00:05:36.309Z" + end_time: + description: The date and time this ASG was created, in ISO 8601 format. + returned: success + type: str + sample: "2015-11-25T00:05:36.309Z" + percentage_complete: + description: the % of completeness + returned: success + type: int + sample: 100 + instances_to_update: + description: number of instances to update. + returned: success + type: int + sample: 5 """ -try: - from botocore.exceptions import BotoCoreError - from botocore.exceptions import ClientError -except ImportError: - pass # caught by AnsibleAWSModule +from typing import Any +from typing import Dict from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict -from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry +from ansible_collections.amazon.aws.plugins.module_utils.autoscaling import AnsibleAutoScalingError +from ansible_collections.amazon.aws.plugins.module_utils.autoscaling import describe_instance_refreshes from ansible_collections.community.aws.plugins.module_utils.modules import AnsibleCommunityAWSModule as AnsibleAWSModule -def find_asg_instance_refreshes(conn, module): +def format_response(response: Dict[str, Any]) -> Dict[str, Any]: + result = {} + if "InstanceRefreshes" in response: + instance_refreshes_dict = { + "instance_refreshes": response["InstanceRefreshes"], + "next_token": response.get("NextToken", ""), + } + result = camel_dict_to_snake_dict(instance_refreshes_dict) + return result + + +def find_asg_instance_refreshes(client, module: AnsibleAWSModule) -> None: """ Args: - conn (boto3.AutoScaling.Client): Valid Boto3 ASG client. + client (boto3.AutoScaling.Client): Valid Boto3 ASG client. module: AnsibleAWSModule object - - Returns: - { - "instance_refreshes": [ - { - 'auto_scaling_group_name': 'ansible-test-hermes-63642726-asg', - 'instance_refresh_id': '6507a3e5-4950-4503-8978-e9f2636efc09', - 'instances_to_update': 1, - 'percentage_complete': 0, - "preferences": { - "instance_warmup": 60, - "min_healthy_percentage": 90, - "skip_matching": false - }, - 'start_time': '2021-02-04T03:39:40+00:00', - 'status': 'Cancelled', - 'status_reason': 'Cancelled due to user request.', - } - ], - 'next_token': 'string' - } """ - asg_name = module.params.get("name") - asg_ids = module.params.get("ids") - asg_next_token = module.params.get("next_token") - asg_max_records = module.params.get("max_records") - - args = {} - args["AutoScalingGroupName"] = asg_name - if asg_ids: - args["InstanceRefreshIds"] = asg_ids - if asg_next_token: - args["NextToken"] = asg_next_token - if asg_max_records: - args["MaxRecords"] = asg_max_records - try: - instance_refreshes_result = {} - response = conn.describe_instance_refreshes(**args) - if "InstanceRefreshes" in response: - instance_refreshes_dict = dict( - instance_refreshes=response["InstanceRefreshes"], next_token=response.get("next_token", "") - ) - instance_refreshes_result = camel_dict_to_snake_dict(instance_refreshes_dict) - - while "NextToken" in response: - args["NextToken"] = response["NextToken"] - response = conn.describe_instance_refreshes(**args) - if "InstanceRefreshes" in response: - instance_refreshes_dict = camel_dict_to_snake_dict( - dict(instance_refreshes=response["InstanceRefreshes"], next_token=response.get("next_token", "")) + max_records = module.params.get("max_records") + response = describe_instance_refreshes( + client, + auto_scaling_group_name=module.params.get("name"), + instance_refresh_ids=module.params.get("ids"), + next_token=module.params.get("next_token"), + max_records=max_records, + ) + instance_refreshes_result = format_response(response) + + if max_records is None: + while "NextToken" in response: + response = describe_instance_refreshes( + client, + auto_scaling_group_name=module.params.get("name"), + instance_refresh_ids=module.params.get("ids"), + next_token=response["NextToken"], + max_records=max_records, ) - instance_refreshes_result.update(instance_refreshes_dict) + f_response = format_response(response) + if "instance_refreshes" in f_response: + instance_refreshes_result["instance_refreshes"].extend(f_response["instance_refreshes"]) + instance_refreshes_result["next_token"] = f_response["next_token"] - return module.exit_json(**instance_refreshes_result) - except (BotoCoreError, ClientError) as e: - module.fail_json_aws(e, msg="Failed to describe InstanceRefreshes") + module.exit_json(changed=False, **instance_refreshes_result) + except AnsibleAutoScalingError as e: + module.fail_json_aws(e, msg=f"Failed to describe InstanceRefreshes: {e}") def main(): @@ -210,7 +216,7 @@ def main(): supports_check_mode=True, ) - autoscaling = module.client("autoscaling", retry_decorator=AWSRetry.jittered_backoff(retries=10)) + autoscaling = module.client("autoscaling") find_asg_instance_refreshes(autoscaling, module) diff --git a/tests/integration/targets/autoscaling_instance_refresh/tasks/instance_refresh_info.yml b/tests/integration/targets/autoscaling_instance_refresh/tasks/instance_refresh_info.yml new file mode 100644 index 00000000000..bf95b1668db --- /dev/null +++ b/tests/integration/targets/autoscaling_instance_refresh/tasks/instance_refresh_info.yml @@ -0,0 +1,99 @@ +--- +- name: Test getting info for an ASG name + autoscaling_instance_refresh_info: + name: "{{ asg_name }}" + register: output + +- name: Assert that the correct number of records are returned + assert: + that: + - output.instance_refreshes | map(attribute='instance_refresh_id') | unique | length == 7 + +- name: Test using fake refresh ID + autoscaling_instance_refresh_info: + name: "{{ asg_name }}" + ids: ['0e367f58-blabla-bla-bla-ca870dc5dbfe'] + register: output + +- name: Assert that no record is returned + assert: + that: + - output.instance_refreshes | length == 0 + +- name: Test using a real refresh ID + autoscaling_instance_refresh_info: + name: "{{ asg_name }}" + ids: [ '{{ refreshout.instance_refreshes.instance_refresh_id }}' ] + register: output + +- name: Assert that the correct record is returned + assert: + that: + - output.instance_refreshes | length == 1 + +- name: Test getting info for an ASG name which doesn't exist + autoscaling_instance_refresh_info: + name: n0n3x1stentname27b + ignore_errors: true + register: output + +- name: Assert that module failed to return record + assert: + that: + - "'Failed to describe InstanceRefreshes: An error occurred (ValidationError) when calling the DescribeInstanceRefreshes operation: AutoScalingGroup name not found - AutoScalingGroup n0n3x1stentname27b not found' in output.msg" + +- name: Retrieve instance refresh info + autoscaling_instance_refresh_info: + name: "{{ asg_name }}" + register: output + +- name: Assert that the correct number of records are returned + assert: + that: + - output.instance_refreshes | length == 7 + +- name: Retrieve instance refresh info using next_token + autoscaling_instance_refresh_info: + name: "{{ asg_name }}" + next_token: "fake-token-123" + ignore_errors: true + register: output + +- name: Assert that valid message with fake-token is returned + assert: + that: + - '"Failed to describe InstanceRefreshes: An error occurred (InvalidNextToken) when calling the DescribeInstanceRefreshes operation: The token ''********'' is invalid." in output.msg' + +- name: Retrieve instance refresh info using max_records + autoscaling_instance_refresh_info: + name: "{{ asg_name }}" + max_records: 1 + register: output_with_token + +- name: Assert that max records=1 returns no more than one record + assert: + that: + - output_with_token.instance_refreshes | length == 1 + +- name: Retrieve instance refresh using valid token + autoscaling_instance_refresh_info: + name: "{{ asg_name }}" + next_token: "{{ output_with_token.next_token }}" + register: output + +- name: Assert that valid message with real-token is returned + assert: + that: + - output.instance_refreshes | length == 6 + +- name: Test using both real nextToken and max_records=1 + autoscaling_instance_refresh_info: + name: "{{ asg_name }}" + max_records: 1 + next_token: "{{ output_with_token.next_token }}" + register: output + +- name: Assert that only one instance refresh is returned + assert: + that: + - output.instance_refreshes | length == 1 diff --git a/tests/integration/targets/autoscaling_instance_refresh/tasks/main.yml b/tests/integration/targets/autoscaling_instance_refresh/tasks/main.yml index 5b754d47d69..cdfa2e00106 100644 --- a/tests/integration/targets/autoscaling_instance_refresh/tasks/main.yml +++ b/tests/integration/targets/autoscaling_instance_refresh/tasks/main.yml @@ -35,7 +35,7 @@ Name: "{{ subnet_name }}" register: testing_subnet - - name: create routing rules + - name: Create routing rules ec2_vpc_route_table: vpc_id: "{{ testing_vpc.vpc.id }}" tags: @@ -46,7 +46,7 @@ subnets: - "{{ testing_subnet.subnet.id }}" - - name: create a security group with the vpc created in the ec2_setup + - name: Create a security group with the vpc created in the ec2_setup ec2_security_group: name: "{{ sg_name }}" description: a security group for ansible tests @@ -62,7 +62,7 @@ cidr_ip: 0.0.0.0/0 register: sg - - name: ensure launch configs exist + - name: Ensure launch configs exist autoscaling_launch_config: name: "{{ item }}" assign_public_ip: true @@ -80,7 +80,7 @@ - "{{ lc_name_1 }}" - "{{ lc_name_2 }}" - - name: launch asg and do not wait for instances to be deemed healthy (no ELB) + - name: Launch asg and do not wait for instances to be deemed healthy (no ELB) autoscaling_group: name: "{{ asg_name }}" launch_config_name: "{{ lc_name_1 }}" @@ -92,335 +92,44 @@ state: present register: output - - assert: + - name: Assert that there is no viable instance + assert: that: - "output.viable_instances == 0" # ============================================================ - - - name: test invalid cancelation - V1 - (pre-refresh) - autoscaling_instance_refresh: - name: "{{ asg_name }}" - state: "cancelled" - ignore_errors: yes - register: result - - - assert: - that: - - "'An error occurred (ActiveInstanceRefreshNotFound) when calling the CancelInstanceRefresh operation: No in progress or pending Instance Refresh found for Auto Scaling group ' ~ resource_prefix ~ '-asg' in result.msg" - - - name: test starting a refresh with a valid ASG name - check_mode - autoscaling_instance_refresh: - name: "{{ asg_name }}" - state: "started" - check_mode: true - register: output - - - assert: - that: - - output is not failed - - output is changed - - '"autoscaling:StartInstanceRefresh" not in output.resource_actions' - - - name: test starting a refresh with a valid ASG name - autoscaling_instance_refresh: - name: "{{ asg_name }}" - state: "started" - register: output - - - assert: - that: - - "'instance_refresh_id' in output.instance_refreshes" - - - name: test starting a refresh with a valid ASG name - Idempotent - autoscaling_instance_refresh: - name: "{{ asg_name }}" - state: "started" - ignore_errors: true - register: output - - - assert: - that: - - output is not changed - - '"Failed to start InstanceRefresh: An error occurred (InstanceRefreshInProgress) when calling the StartInstanceRefresh operation: An Instance Refresh is already in progress and blocks the execution of this Instance Refresh." in output.msg' - - - name: test starting a refresh with a valid ASG name - Idempotent (check_mode) - autoscaling_instance_refresh: - name: "{{ asg_name }}" - state: "started" - ignore_errors: true - check_mode: true - register: output - - - assert: - that: - - output is not changed - - output is not failed - - '"In check_mode - Instance Refresh is already in progress, can not start new instance refresh." in output.msg' - - - name: test starting a refresh with a nonexistent ASG name - autoscaling_instance_refresh: - name: "nonexistentname-asg" - state: "started" - ignore_errors: yes - register: result - - - assert: - that: - - "'Failed to start InstanceRefresh: An error occurred (ValidationError) when calling the StartInstanceRefresh operation: AutoScalingGroup name not found' in result.msg" - - - name: test canceling a refresh with an ASG name - check_mode - autoscaling_instance_refresh: - name: "{{ asg_name }}" - state: "cancelled" - check_mode: true - register: output - - - assert: - that: - - output is not failed - - output is changed - - '"autoscaling:CancelInstanceRefresh" not in output.resource_actions' - - - name: test canceling a refresh with an ASG name - autoscaling_instance_refresh: - name: "{{ asg_name }}" - state: "cancelled" - register: output - - - assert: - that: - - "'instance_refresh_id' in output.instance_refreshes" - - - name: test canceling a refresh with a ASG name - Idempotent - autoscaling_instance_refresh: - name: "{{ asg_name }}" - state: "cancelled" - ignore_errors: yes - register: output - - - assert: - that: - - output is not changed - - - name: test cancelling a refresh with a valid ASG name - Idempotent (check_mode) - autoscaling_instance_refresh: - name: "{{ asg_name }}" - state: "cancelled" - ignore_errors: true - check_mode: true - register: output - - - assert: - that: - - output is not changed - - output is not failed - - - name: test starting a refresh with an ASG name and preferences dict - autoscaling_instance_refresh: - name: "{{ asg_name }}" - state: "started" - preferences: - min_healthy_percentage: 10 - instance_warmup: 10 - retries: 5 - register: output - until: output is not failed - - - assert: - that: - - "'instance_refresh_id' in output.instance_refreshes" - - - name: re-test canceling a refresh with an ASG name - autoscaling_instance_refresh: - name: "{{ asg_name }}" - state: "cancelled" - register: output - - - assert: - that: - - "'instance_refresh_id' in output.instance_refreshes" - - - name: test valid start - V1 - (with preferences missing instance_warmup) - autoscaling_instance_refresh: - name: "{{ asg_name }}" - state: "started" - preferences: - min_healthy_percentage: 10 - ignore_errors: yes - retries: 5 - register: output - until: output is not failed - - - assert: - that: - - "'instance_refresh_id' in output.instance_refreshes" - - - name: re-test canceling a refresh with an ASG name - autoscaling_instance_refresh: - name: "{{ asg_name }}" - state: "cancelled" - register: output - - - assert: - that: - - "'instance_refresh_id' in output.instance_refreshes" - - - name: test valid start - V2 - (with preferences missing min_healthy_percentage) - autoscaling_instance_refresh: - name: "{{ asg_name }}" - state: "started" - preferences: - instance_warmup: 10 - retries: 5 - register: output - until: output is not failed - ignore_errors: yes - - - assert: - that: - - "'instance_refresh_id' in output.instance_refreshes" - - - name: test invalid cancelation - V2 - (with preferences) - autoscaling_instance_refresh: - name: "{{ asg_name }}" - state: "cancelled" - preferences: - min_healthy_percentage: 10 - instance_warmup: 10 - ignore_errors: yes - register: result - - - assert: - that: - - "'can not pass preferences dict when canceling a refresh' in result.msg" - + - name: Run test with start_cancel_instance_refresh.yml + include_tasks: start_cancel_instance_refresh.yml + # ============================================================ - - name: run setup with refresh_and_cancel_three_times.yml + - name: Run test with refresh_and_cancel_three_times.yml include_tasks: refresh_and_cancel_three_times.yml loop: "{{ query('sequence', 'start=1 end=3') }}" - - name: test getting info for an ASG name - autoscaling_instance_refresh_info: - name: "{{ asg_name }}" - region: "{{ aws_region }}" - ignore_errors: yes - register: output - - - assert: - that: - - output | community.general.json_query(inst_refresh_id_json_query) | unique | length == 7 - vars: - inst_refresh_id_json_query: instance_refreshes[].instance_refresh_id - - - name: test using fake refresh ID - autoscaling_instance_refresh_info: - name: "{{ asg_name }}" - ids: ['0e367f58-blabla-bla-bla-ca870dc5dbfe'] - ignore_errors: yes - register: output - - - assert: - that: - - output.instance_refreshes | length == 0 - - - name: test using a real refresh ID - autoscaling_instance_refresh_info: - name: "{{ asg_name }}" - ids: [ '{{ refreshout.instance_refreshes.instance_refresh_id }}' ] - ignore_errors: yes - register: output - - - assert: - that: - - output.instance_refreshes | length == 1 - - - name: test getting info for an ASG name which doesn't exist - autoscaling_instance_refresh_info: - name: n0n3x1stentname27b - ignore_errors: yes - register: output - - - assert: - that: - - "'Failed to describe InstanceRefreshes: An error occurred (ValidationError) when calling the DescribeInstanceRefreshes operation: AutoScalingGroup name not found - AutoScalingGroup n0n3x1stentname27b not found' == output.msg" - - - name: assert that the correct number of records are returned - autoscaling_instance_refresh_info: - name: "{{ asg_name }}" - ignore_errors: yes - register: output - - - assert: - that: - - output.instance_refreshes | length == 7 - - - name: assert that valid message with fake-token is returned - autoscaling_instance_refresh_info: - name: "{{ asg_name }}" - next_token: "fake-token-123" - ignore_errors: yes - register: output - - - assert: - that: - - '"Failed to describe InstanceRefreshes: An error occurred (InvalidNextToken) when calling the DescribeInstanceRefreshes operation: The token ''********'' is invalid." == output.msg' - - - name: assert that max records=1 returns no more than one record - autoscaling_instance_refresh_info: - name: "{{ asg_name }}" - max_records: 1 - ignore_errors: yes - register: output - - - assert: - that: - - output.instance_refreshes | length < 2 - - - name: assert that valid message with real-token is returned - autoscaling_instance_refresh_info: - name: "{{ asg_name }}" - next_token: "{{ output.next_token }}" - ignore_errors: yes - register: output - - - assert: - that: - - output.instance_refreshes | length == 7 - - - name: test using both real nextToken and max_records=1 - autoscaling_instance_refresh_info: - name: "{{ asg_name }}" - max_records: 1 - next_token: "{{ output.next_token }}" - ignore_errors: yes - register: output - - - assert: - that: - - output.instance_refreshes | length == 1 + - name: Run test with instance_refresh_info.yml + include_tasks: instance_refresh_info.yml always: - - name: kill asg + - name: Kill asg autoscaling_group: name: "{{ asg_name }}" state: absent register: removed until: removed is not failed - ignore_errors: yes + ignore_errors: true retries: 10 # Remove the testing dependencies - - name: remove the load balancer + - name: Remove the load balancer elb_classic_lb: name: "{{ load_balancer_name }}" state: absent security_group_ids: - "{{ sg.group_id }}" subnets: "{{ testing_subnet.subnet.id }}" - wait: yes + wait: true connection_draining_timeout: 60 listeners: - protocol: http @@ -436,22 +145,22 @@ healthy_threshold: 2 register: removed until: removed is not failed - ignore_errors: yes + ignore_errors: true retries: 10 - - name: remove launch configs + - name: Remove launch configs autoscaling_launch_config: name: "{{ item }}" state: absent register: removed until: removed is not failed - ignore_errors: yes + ignore_errors: true retries: 10 loop: - "{{ lc_name_1 }}" - "{{ lc_name_2 }}" - - name: delete launch template + - name: Delete launch template ec2_launch_template: name: "{{ resource_prefix }}-lt" state: absent @@ -460,7 +169,7 @@ until: del_lt is not failed ignore_errors: true - - name: remove the security group + - name: Remove the security group ec2_security_group: name: "{{ sg_name }}" description: a security group for ansible tests @@ -468,10 +177,10 @@ state: absent register: removed until: removed is not failed - ignore_errors: yes + ignore_errors: true retries: 10 - - name: remove routing rules + - name: Remove routing rules ec2_vpc_route_table: state: absent vpc_id: "{{ testing_vpc.vpc.id }}" @@ -484,34 +193,34 @@ - "{{ testing_subnet.subnet.id }}" register: removed until: removed is not failed - ignore_errors: yes + ignore_errors: true retries: 10 - - name: remove internet gateway + - name: Remove internet gateway ec2_vpc_igw: vpc_id: "{{ testing_vpc.vpc.id }}" state: absent register: removed until: removed is not failed - ignore_errors: yes + ignore_errors: true retries: 10 - - name: remove the subnet + - name: Remove the subnet ec2_vpc_subnet: state: absent vpc_id: "{{ testing_vpc.vpc.id }}" cidr: '{{ subnet_a_cidr }}' register: removed until: removed is not failed - ignore_errors: yes + ignore_errors: true retries: 10 - - name: remove the VPC + - name: Remove the VPC ec2_vpc_net: name: "{{ vpc_name }}" cidr_block: '{{ subnet_a_cidr }}' state: absent register: removed until: removed is not failed - ignore_errors: yes + ignore_errors: true retries: 10 diff --git a/tests/integration/targets/autoscaling_instance_refresh/tasks/refresh_and_cancel_three_times.yml b/tests/integration/targets/autoscaling_instance_refresh/tasks/refresh_and_cancel_three_times.yml index 9b051a054e0..e2cebe924a9 100644 --- a/tests/integration/targets/autoscaling_instance_refresh/tasks/refresh_and_cancel_three_times.yml +++ b/tests/integration/targets/autoscaling_instance_refresh/tasks/refresh_and_cancel_three_times.yml @@ -1,29 +1,14 @@ --- - -- name: try to cancel pre-loop - autoscaling_instance_refresh: - name: "{{ asg_name }}" - state: "cancelled" - ignore_errors: yes - -- name: test starting a refresh with an ASG name +- name: Test starting a refresh with an ASG name autoscaling_instance_refresh: name: "{{ asg_name }}" state: "started" - access_key: "{{ aws_access_key }}" - secret_key: "{{ aws_secret_key }}" - region: "{{ aws_region }}" - ignore_errors: no retries: 10 delay: 5 register: refreshout until: refreshout is not failed -- name: test cancelling a refresh with an ASG name +- name: Test cancelling a refresh with an ASG name autoscaling_instance_refresh: name: "{{ asg_name }}" state: "cancelled" - access_key: "{{ aws_access_key }}" - secret_key: "{{ aws_secret_key }}" - region: "{{ aws_region }}" - ignore_errors: yes diff --git a/tests/integration/targets/autoscaling_instance_refresh/tasks/start_cancel_instance_refresh.yml b/tests/integration/targets/autoscaling_instance_refresh/tasks/start_cancel_instance_refresh.yml new file mode 100644 index 00000000000..a15e71cc3d3 --- /dev/null +++ b/tests/integration/targets/autoscaling_instance_refresh/tasks/start_cancel_instance_refresh.yml @@ -0,0 +1,206 @@ +--- +- name: test invalid cancelation - V1 - (pre-refresh) + autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "cancelled" + ignore_errors: true + register: result + +- name: Assert that module failed with proper message + assert: + that: + - "'An error occurred (ActiveInstanceRefreshNotFound) when calling the CancelInstanceRefresh operation: No in progress or pending Instance Refresh found for Auto Scaling group ' ~ resource_prefix ~ '-asg' in result.msg" + +- name: Test starting a refresh with a valid ASG name - check_mode + autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "started" + check_mode: true + register: output + +- name: Validate starting in check_mode + assert: + that: + - output is changed + - '"autoscaling:StartInstanceRefresh" not in output.resource_actions' + +- name: Test starting a refresh with a valid ASG name + autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "started" + register: output + +- name: Validate start + assert: + that: + - "'instance_refresh_id' in output.instance_refreshes" + +- name: Test starting a refresh with a valid ASG name - Idempotent + autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "started" + ignore_errors: true + register: output + +- name: Validate starting Idempotency + assert: + that: + - output is not changed + - '"Failed to start InstanceRefresh: An error occurred (InstanceRefreshInProgress) when calling the StartInstanceRefresh operation: An Instance Refresh is already in progress and blocks the execution of this Instance Refresh." in output.msg' + +- name: Test starting a refresh with a valid ASG name - Idempotent (check_mode) + autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "started" + check_mode: true + register: output + +- name: Validate starting Idempotency in check_mode + assert: + that: + - output is not changed + - '"In check_mode - Instance Refresh is already in progress, can not start new instance refresh." in output.msg' + +- name: Test starting a refresh with a nonexistent ASG name + autoscaling_instance_refresh: + name: "nonexistentname-asg" + state: "started" + ignore_errors: true + register: result + +- name: Assert that module failed with proper message + assert: + that: + - "'Failed to start InstanceRefresh: An error occurred (ValidationError) when calling the StartInstanceRefresh operation: AutoScalingGroup name not found' in result.msg" + +- name: Test canceling a refresh with an ASG name - check_mode + autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "cancelled" + check_mode: true + register: output + +- name: Validate cancelation + assert: + that: + - output is not failed + - output is changed + - '"autoscaling:CancelInstanceRefresh" not in output.resource_actions' + +- name: Test canceling a refresh with an ASG name + autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "cancelled" + register: output + +- name: Validate cancelation + assert: + that: + - "'instance_refresh_id' in output.instance_refreshes" + +- name: Test canceling a refresh with a ASG name - Idempotent + autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "cancelled" + register: output + ignore_errors: true + +- name: Validate cancelling Idempotency + assert: + that: + - output is not changed + +- name: Test cancelling a refresh with a valid ASG name - Idempotent (check_mode) + autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "cancelled" + check_mode: true + register: output + +- name: Validate cancelling Idempotency in check_mode + assert: + that: + - output is not changed + +- name: Test starting a refresh with an ASG name and preferences dict + autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "started" + preferences: + min_healthy_percentage: 10 + instance_warmup: 10 + retries: 5 + register: output + until: output is not failed + +- name: Assert that module succeed with preferences + assert: + that: + - "'instance_refresh_id' in output.instance_refreshes" + +- name: Re-test canceling a refresh with an ASG name + autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "cancelled" + register: output + +- name: Assert that module returned instance refresh id + assert: + that: + - "'instance_refresh_id' in output.instance_refreshes" + +- name: Test valid start - V1 - (with preferences missing instance_warmup) + autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "started" + preferences: + min_healthy_percentage: 10 + retries: 5 + register: output + until: output is not failed + +- name: Validate start with preferences missing instance warmup + assert: + that: + - "'instance_refresh_id' in output.instance_refreshes" + +- name: Re-test canceling a refresh with an ASG name + autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "cancelled" + register: output + +- name: Validate canceling Idempotency + assert: + that: + - "'instance_refresh_id' in output.instance_refreshes" + +- name: Test valid start - V2 - (with preferences missing min_healthy_percentage) + autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "started" + preferences: + instance_warmup: 10 + retries: 5 + register: output + until: output is not failed + +- name: Assert that module did not returned and instance refresh id + assert: + that: + - "'instance_refresh_id' in output.instance_refreshes" + +- name: Test invalid cancelation - V2 - (with preferences) + autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "cancelled" + preferences: + min_healthy_percentage: 10 + instance_warmup: 10 + ignore_errors: true + register: result + +- name: Assert that module failed with proper message + assert: + that: + - "'can not pass preferences dict when canceling a refresh' in result.msg" diff --git a/tests/unit/plugins/modules/test_autoscaling_instance_refresh.py b/tests/unit/plugins/modules/test_autoscaling_instance_refresh.py new file mode 100644 index 00000000000..590074367a2 --- /dev/null +++ b/tests/unit/plugins/modules/test_autoscaling_instance_refresh.py @@ -0,0 +1,28 @@ +# (c) 2024 Red Hat Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +import pytest + +from ansible_collections.community.aws.plugins.modules.autoscaling_instance_refresh import validate_healthy_percentage + + +@pytest.mark.parametrize( + "min_healthy, max_healthy, expected_error", + [ + (90, None, None), + (-1, None, "The value range for the min_healthy_percentage is 0 to 100."), + (101, None, "The value range for the min_healthy_percentage is 0 to 100."), + (None, 90, "The value range for the max_healthy_percentage is 100 to 200."), + (None, 201, "The value range for the max_healthy_percentage is 100 to 200."), + (None, 100, "You must also specify min_healthy_percentage when max_healthy_percentage is specified."), + (10, 100, None), + ( + 10, + 150, + "The difference between the max_healthy_percentage and min_healthy_percentage cannot be greater than 100.", + ), + ], +) +def test_validate_healthy_percentage(min_healthy, max_healthy, expected_error): + preferences = dict(min_healthy_percentage=min_healthy, max_healthy_percentage=max_healthy) + assert expected_error == validate_healthy_percentage(preferences)