diff --git a/changelogs/fragments/migrate_autoscaling_instance_refresh_autoscaling_instance_refresh_info.yaml b/changelogs/fragments/migrate_autoscaling_instance_refresh_autoscaling_instance_refresh_info.yaml new file mode 100644 index 00000000000..64604207795 --- /dev/null +++ b/changelogs/fragments/migrate_autoscaling_instance_refresh_autoscaling_instance_refresh_info.yaml @@ -0,0 +1,8 @@ +--- +major_changes: + - autoscaling_instance_refresh - The module has been migrated from the ``community.aws`` + collection. Playbooks using the Fully Qualified Collection Name for this module + should be updated to use ``amazon.aws.autoscaling_instance_refresh`` (https://github.com/ansible-collections/amazon.aws/pull/2338). + - autoscaling_instance_refresh_info - The module has been migrated from the ``community.aws`` + collection. Playbooks using the Fully Qualified Collection Name for this module + should be updated to use ``amazon.aws.autoscaling_instance_refresh_info`` (https://github.com/ansible-collections/amazon.aws/pull/2338). diff --git a/meta/runtime.yml b/meta/runtime.yml index ed131257c86..b2c9bc08ab5 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -4,6 +4,8 @@ action_groups: aws: - autoscaling_group - autoscaling_group_info + - autoscaling_instance_refresh + - autoscaling_instance_refresh_info - aws_az_info - aws_caller_info - aws_region_info @@ -149,6 +151,12 @@ plugin_routing: ec2_asg_info: # Deprecation for this alias should not *start* prior to 2024-09-01 redirect: amazon.aws.autoscaling_group_info + ec2_asg_instance_refresh: + # Deprecation for this alias should not *start* prior to 2024-09-01 + redirect: amazon.aws.autoscaling_instance_refresh + ec2_asg_instance_refresh_info: + # Deprecation for this alias should not *start* prior to 2024-09-01 + redirect: amazon.aws.autoscaling_instance_refresh_info ec2_elb_lb: # Deprecation for this alias should not *start* prior to 2024-09-01 redirect: amazon.aws.elb_classic_lb diff --git a/plugins/modules/autoscaling_instance_refresh.py b/plugins/modules/autoscaling_instance_refresh.py new file mode 100644 index 00000000000..e1685b96219 --- /dev/null +++ b/plugins/modules/autoscaling_instance_refresh.py @@ -0,0 +1,298 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: autoscaling_instance_refresh +version_added: 3.2.0 +version_added_collection: community.aws +short_description: Start or cancel an EC2 Auto Scaling Group (ASG) instance refresh in AWS +description: + - Start or cancel an EC2 Auto Scaling Group instance refresh in AWS. + - Can be used with M(amazon.aws.autoscaling_instance_refresh_info) to track the subsequent progress. + - 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)" +options: + state: + description: + - Desired state of the ASG. + type: str + required: true + choices: [ 'started', 'cancelled' ] + name: + description: + - The name of the auto scaling group you are searching for. + type: str + required: true + strategy: + description: + - 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. + type: str + default: 'Rolling' + preferences: + description: + - Set of preferences associated with the instance refresh request. + - If not provided, the default values are used. + - 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: + description: + - The number of seconds until a newly launched instance is configured and ready to use. + - 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 + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Note: These examples do not set authentication details, see the AWS Guide for details. + +- name: Start a refresh + amazon.aws.autoscaling_instance_refresh: + name: some-asg + state: started + +- name: Cancel a refresh + amazon.aws.autoscaling_instance_refresh: + name: some-asg + state: cancelled + +- name: Start a refresh and pass preferences + amazon.aws.autoscaling_instance_refresh: + name: some-asg + state: started + preferences: + min_healthy_percentage: 91 + instance_warmup: 60 + skip_matching: true +""" + +RETURN = r""" +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 +""" + +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.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.modules import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.transformation import scrub_none_parameters + + +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 + """ + + asg_state = module.params.get("state") + asg_name = module.params.get("name") + preferences = module.params.get("preferences") + + args = {} + 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") + 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": 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": + if ongoing_refresh: + module.exit_json( + changed=False, + msg="In check_mode - Instance Refresh is already in progress, can not start new instance refresh.", + ) + else: + module.exit_json(changed=True, msg="Would have started instance refresh if not in check mode.") + elif asg_state == "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(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.") + 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(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(): + argument_spec = dict( + state=dict( + type="str", + required=True, + choices=["started", "cancelled"], + ), + name=dict(required=True), + strategy=dict(type="str", default="Rolling", required=False), + preferences=dict( + type="dict", + required=False, + 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"), + ), + ), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + autoscaling = module.client("autoscaling") + + start_or_cancel_instance_refresh(autoscaling, module) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/autoscaling_instance_refresh_info.py b/plugins/modules/autoscaling_instance_refresh_info.py new file mode 100644 index 00000000000..06a96d151ea --- /dev/null +++ b/plugins/modules/autoscaling_instance_refresh_info.py @@ -0,0 +1,224 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: autoscaling_instance_refresh_info +version_added: 3.2.0 +version_added_collection: community.aws +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 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)" +options: + name: + description: + - The name of the Auto Scaling group. + type: str + required: true + ids: + description: + - One or more instance refresh IDs. + type: list + elements: str + default: [] + next_token: + description: + - The token for the next set of items to return. (You received this token from a previous call.) + type: str + max_records: + description: + - 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: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Note: These examples do not set authentication details, see the AWS Guide for details. + +- name: Find an refresh by ASG name + amazon.aws.autoscaling_instance_refresh_info: + name: somename-asg + +- name: Find an refresh by ASG name and one or more refresh-IDs + amazon.aws.autoscaling_instance_refresh_info: + name: somename-asg + ids: ['some-id-123'] + register: asgs + +- name: Find an refresh by ASG name and set max_records + amazon.aws.autoscaling_instance_refresh_info: + name: somename-asg + max_records: 4 + register: asgs + +- name: Find an refresh by ASG name and NextToken, if received from a previous call + amazon.aws.autoscaling_instance_refresh_info: + name: somename-asg + next_token: 'some-token-123' + register: asgs +""" + +RETURN = r""" +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 +""" + +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.autoscaling import AnsibleAutoScalingError +from ansible_collections.amazon.aws.plugins.module_utils.autoscaling import describe_instance_refreshes +from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule + + +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: + client (boto3.AutoScaling.Client): Valid Boto3 ASG client. + module: AnsibleAWSModule object + """ + + try: + 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, + ) + 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"] + + 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(): + argument_spec = dict( + name=dict(required=True, type="str"), + ids=dict(required=False, default=[], elements="str", type="list"), + next_token=dict(required=False, default=None, type="str", no_log=True), + max_records=dict(required=False, type="int"), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + autoscaling = module.client("autoscaling") + find_asg_instance_refreshes(autoscaling, module) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/autoscaling_instance_refresh/aliases b/tests/integration/targets/autoscaling_instance_refresh/aliases new file mode 100644 index 00000000000..6ce549da4bb --- /dev/null +++ b/tests/integration/targets/autoscaling_instance_refresh/aliases @@ -0,0 +1,3 @@ +time=14m +cloud/aws +autoscaling_instance_refresh_info diff --git a/tests/integration/targets/autoscaling_instance_refresh/defaults/main.yml b/tests/integration/targets/autoscaling_instance_refresh/defaults/main.yml new file mode 100644 index 00000000000..08e57d2558e --- /dev/null +++ b/tests/integration/targets/autoscaling_instance_refresh/defaults/main.yml @@ -0,0 +1,16 @@ +--- +# defaults file for ec2_asg +vpc_seed: '{{ tiny_prefix }}' +subnet_a_cidr: '10.{{ 256 | random(seed=vpc_seed) }}.32.0/24' + +default_resource_name: '{{ resource_prefix }}-asg-refresh' +short_resource_name: '{{ tiny_prefix }}-asg-refresh' + +vpc_name: '{{ default_resource_name }}' +subnet_name: '{{ default_resource_name }}' +route_name: '{{ default_resource_name }}' +sg_name: '{{ default_resource_name }}' +asg_name: '{{ default_resource_name }}' +lc_name_1: '{{ default_resource_name }}-1' +lc_name_2: '{{ default_resource_name }}-2' +load_balancer_name: '{{ short_resource_name }}' diff --git a/tests/integration/targets/autoscaling_instance_refresh/meta/main.yml b/tests/integration/targets/autoscaling_instance_refresh/meta/main.yml new file mode 100644 index 00000000000..1471b11f658 --- /dev/null +++ b/tests/integration/targets/autoscaling_instance_refresh/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - setup_ec2_facts 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..b3590867899 --- /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 + amazon.aws.autoscaling_instance_refresh_info: + name: "{{ asg_name }}" + register: output + +- name: Assert that the correct number of records are returned + ansible.builtin.assert: + that: + - output.instance_refreshes | map(attribute='instance_refresh_id') | unique | length == 7 + +- name: Test using fake refresh ID + amazon.aws.autoscaling_instance_refresh_info: + name: "{{ asg_name }}" + ids: ['0e367f58-blabla-bla-bla-ca870dc5dbfe'] + register: output + +- name: Assert that no record is returned + ansible.builtin.assert: + that: + - output.instance_refreshes | length == 0 + +- name: Test using a real refresh ID + amazon.aws.autoscaling_instance_refresh_info: + name: "{{ asg_name }}" + ids: [ '{{ refreshout.instance_refreshes.instance_refresh_id }}' ] + register: output + +- name: Assert that the correct record is returned + ansible.builtin.assert: + that: + - output.instance_refreshes | length == 1 + +- name: Test getting info for an ASG name which doesn't exist + amazon.aws.autoscaling_instance_refresh_info: + name: n0n3x1stentname27b + ignore_errors: true + register: output + +- name: Assert that module failed to return record + ansible.builtin.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 + amazon.aws.autoscaling_instance_refresh_info: + name: "{{ asg_name }}" + register: output + +- name: Assert that the correct number of records are returned + ansible.builtin.assert: + that: + - output.instance_refreshes | length == 7 + +- name: Retrieve instance refresh info using next_token + amazon.aws.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 + ansible.builtin.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 + amazon.aws.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 + ansible.builtin.assert: + that: + - output_with_token.instance_refreshes | length == 1 + +- name: Retrieve instance refresh using valid token + amazon.aws.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 + ansible.builtin.assert: + that: + - output.instance_refreshes | length == 6 + +- name: Test using both real nextToken and max_records=1 + amazon.aws.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 + ansible.builtin.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 new file mode 100644 index 00000000000..46e343ec29d --- /dev/null +++ b/tests/integration/targets/autoscaling_instance_refresh/tasks/main.yml @@ -0,0 +1,223 @@ +--- +- name: setup credentials and region + module_defaults: + group/aws: + access_key: "{{ aws_access_key }}" + secret_key: "{{ aws_secret_key }}" + session_token: "{{ security_token | default(omit) }}" + region: "{{ aws_region }}" + + block: + # Set up the testing dependencies: VPC, subnet, security group, and two launch configurations + - name: Create VPC for use in testing + amazon.aws.ec2_vpc_net: + name: "{{ vpc_name }}" + cidr_block: '{{ subnet_a_cidr }}' + tenancy: default + register: testing_vpc + + - name: Create internet gateway for use in testing + amazon.aws.ec2_vpc_igw: + vpc_id: "{{ testing_vpc.vpc.id }}" + state: present + register: igw + + - name: Create subnet for use in testing + amazon.aws.ec2_vpc_subnet: + state: present + vpc_id: "{{ testing_vpc.vpc.id }}" + cidr: '{{ subnet_a_cidr }}' + az: "{{ aws_region }}a" + resource_tags: + Name: "{{ subnet_name }}" + register: testing_subnet + + - name: Create routing rules + amazon.aws.ec2_vpc_route_table: + vpc_id: "{{ testing_vpc.vpc.id }}" + tags: + created: "{{ route_name }}" + routes: + - dest: 0.0.0.0/0 + gateway_id: "{{ igw.gateway_id }}" + subnets: + - "{{ testing_subnet.subnet.id }}" + + - name: Create a security group with the vpc created in the ec2_setup + amazon.aws.ec2_security_group: + name: "{{ sg_name }}" + description: a security group for ansible tests + vpc_id: "{{ testing_vpc.vpc.id }}" + rules: + - proto: tcp + from_port: 22 + to_port: 22 + cidr_ip: 0.0.0.0/0 + - proto: tcp + from_port: 80 + to_port: 80 + cidr_ip: 0.0.0.0/0 + register: sg + + - name: Ensure launch configs exist + community.aws.autoscaling_launch_config: + name: "{{ item }}" + assign_public_ip: true + image_id: "{{ ec2_ami_id }}" + user_data: | + package_upgrade: true + package_update: true + packages: + - httpd + runcmd: + - "service httpd start" + security_groups: "{{ sg.group_id }}" + instance_type: t3.micro + loop: + - "{{ lc_name_1 }}" + - "{{ lc_name_2 }}" + + - name: Launch asg and do not wait for instances to be deemed healthy (no ELB) + amazon.aws.autoscaling_group: + name: "{{ asg_name }}" + launch_config_name: "{{ lc_name_1 }}" + desired_capacity: 1 + min_size: 1 + max_size: 1 + vpc_zone_identifier: "{{ testing_subnet.subnet.id }}" + wait_for_instances: no + state: present + register: output + + - name: Assert that there is no viable instance + ansible.builtin.assert: + that: + - "output.viable_instances == 0" + + # ============================================================ + - name: Run test with start_cancel_instance_refresh.yml + ansible.builtin.include_tasks: start_cancel_instance_refresh.yml + + # ============================================================ + + - name: Run test with refresh_and_cancel_three_times.yml + ansible.builtin.include_tasks: refresh_and_cancel_three_times.yml + loop: "{{ query('sequence', 'start=1 end=3') }}" + + - name: Run test with instance_refresh_info.yml + ansible.builtin.include_tasks: instance_refresh_info.yml + + always: + + - name: Kill asg + amazon.aws.autoscaling_group: + name: "{{ asg_name }}" + state: absent + register: removed + until: removed is not failed + ignore_errors: true + retries: 10 + # Remove the testing dependencies + + - name: Remove the load balancer + amazon.aws.elb_classic_lb: + name: "{{ load_balancer_name }}" + state: absent + security_group_ids: + - "{{ sg.group_id }}" + subnets: "{{ testing_subnet.subnet.id }}" + wait: true + connection_draining_timeout: 60 + listeners: + - protocol: http + load_balancer_port: 80 + instance_port: 80 + health_check: + ping_protocol: tcp + ping_port: 80 + ping_path: "/" + response_timeout: 5 + interval: 10 + unhealthy_threshold: 4 + healthy_threshold: 2 + register: removed + until: removed is not failed + ignore_errors: true + retries: 10 + + - name: Remove launch configs + community.aws.autoscaling_launch_config: + name: "{{ item }}" + state: absent + register: removed + until: removed is not failed + ignore_errors: true + retries: 10 + loop: + - "{{ lc_name_1 }}" + - "{{ lc_name_2 }}" + + - name: Delete launch template + community.aws.ec2_launch_template: + name: "{{ resource_prefix }}-lt" + state: absent + register: del_lt + retries: 10 + until: del_lt is not failed + ignore_errors: true + + - name: Remove the security group + amazon.aws.ec2_security_group: + name: "{{ sg_name }}" + description: a security group for ansible tests + vpc_id: "{{ testing_vpc.vpc.id }}" + state: absent + register: removed + until: removed is not failed + ignore_errors: true + retries: 10 + + - name: Remove routing rules + amazon.aws.ec2_vpc_route_table: + state: absent + vpc_id: "{{ testing_vpc.vpc.id }}" + tags: + created: "{{ route_name }}" + routes: + - dest: 0.0.0.0/0 + gateway_id: "{{ igw.gateway_id }}" + subnets: + - "{{ testing_subnet.subnet.id }}" + register: removed + until: removed is not failed + ignore_errors: true + retries: 10 + + - name: Remove internet gateway + amazon.aws.ec2_vpc_igw: + vpc_id: "{{ testing_vpc.vpc.id }}" + state: absent + register: removed + until: removed is not failed + ignore_errors: true + retries: 10 + + - name: Remove the subnet + amazon.aws.ec2_vpc_subnet: + state: absent + vpc_id: "{{ testing_vpc.vpc.id }}" + cidr: '{{ subnet_a_cidr }}' + register: removed + until: removed is not failed + ignore_errors: true + retries: 10 + + - name: Remove the VPC + amazon.aws.ec2_vpc_net: + name: "{{ vpc_name }}" + cidr_block: '{{ subnet_a_cidr }}' + state: absent + register: removed + until: removed is not failed + 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 new file mode 100644 index 00000000000..64f6e7752bb --- /dev/null +++ b/tests/integration/targets/autoscaling_instance_refresh/tasks/refresh_and_cancel_three_times.yml @@ -0,0 +1,14 @@ +--- +- name: Test starting a refresh with an ASG name + amazon.aws.autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "started" + retries: 10 + delay: 5 + register: refreshout + until: refreshout is not failed + +- name: Test cancelling a refresh with an ASG name + amazon.aws.autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "cancelled" 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..24d6b9d67ab --- /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) + amazon.aws.autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "cancelled" + ignore_errors: true + register: result + +- name: Assert that module failed with proper message + ansible.builtin.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 + amazon.aws.autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "started" + check_mode: true + register: output + +- name: Validate starting in check_mode + ansible.builtin.assert: + that: + - output is changed + - '"autoscaling:StartInstanceRefresh" not in output.resource_actions' + +- name: Test starting a refresh with a valid ASG name + amazon.aws.autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "started" + register: output + +- name: Validate start + ansible.builtin.assert: + that: + - "'instance_refresh_id' in output.instance_refreshes" + +- name: Test starting a refresh with a valid ASG name - Idempotent + amazon.aws.autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "started" + ignore_errors: true + register: output + +- name: Validate starting Idempotency + ansible.builtin.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) + amazon.aws.autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "started" + check_mode: true + register: output + +- name: Validate starting Idempotency in check_mode + ansible.builtin.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 + amazon.aws.autoscaling_instance_refresh: + name: "nonexistentname-asg" + state: "started" + ignore_errors: true + register: result + +- name: Assert that module failed with proper message + ansible.builtin.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 + amazon.aws.autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "cancelled" + check_mode: true + register: output + +- name: Validate cancelation + ansible.builtin.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 + amazon.aws.autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "cancelled" + register: output + +- name: Validate cancelation + ansible.builtin.assert: + that: + - "'instance_refresh_id' in output.instance_refreshes" + +- name: Test canceling a refresh with a ASG name - Idempotent + amazon.aws.autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "cancelled" + register: output + ignore_errors: true + +- name: Validate cancelling Idempotency + ansible.builtin.assert: + that: + - output is not changed + +- name: Test cancelling a refresh with a valid ASG name - Idempotent (check_mode) + amazon.aws.autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "cancelled" + check_mode: true + register: output + +- name: Validate cancelling Idempotency in check_mode + ansible.builtin.assert: + that: + - output is not changed + +- name: Test starting a refresh with an ASG name and preferences dict + amazon.aws.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 + ansible.builtin.assert: + that: + - "'instance_refresh_id' in output.instance_refreshes" + +- name: Re-test canceling a refresh with an ASG name + amazon.aws.autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "cancelled" + register: output + +- name: Assert that module returned instance refresh id + ansible.builtin.assert: + that: + - "'instance_refresh_id' in output.instance_refreshes" + +- name: Test valid start - V1 - (with preferences missing instance_warmup) + amazon.aws.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 + ansible.builtin.assert: + that: + - "'instance_refresh_id' in output.instance_refreshes" + +- name: Re-test canceling a refresh with an ASG name + amazon.aws.autoscaling_instance_refresh: + name: "{{ asg_name }}" + state: "cancelled" + register: output + +- name: Validate canceling Idempotency + ansible.builtin.assert: + that: + - "'instance_refresh_id' in output.instance_refreshes" + +- name: Test valid start - V2 - (with preferences missing min_healthy_percentage) + amazon.aws.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 + ansible.builtin.assert: + that: + - "'instance_refresh_id' in output.instance_refreshes" + +- name: Test invalid cancelation - V2 - (with preferences) + amazon.aws.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 + ansible.builtin.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..aa371166077 --- /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.amazon.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)