Repository: ansible-collections/community.aws

Issue:
Autoscaling instance refresh API support
<!--- Verify first that your feature was not already discussed on GitHub -->
<!--- Complete *all* sections as described, this form is processed automatically -->

##### SUMMARY
<!--- Describe the new feature/improvement briefly below -->
AWS recently introduced "Instance refresh" feature in ASG. Using it is preferred way to update launch configuration in ASG

##### ISSUE TYPE
- Feature Idea

##### COMPONENT NAME
<!--- Write the short name of the module, plugin, task or feature below, use your best guess if unsure -->
ec2_asg
##### ADDITIONAL INFORMATION
<!--- Describe how the feature would be used, why it is needed and what it would solve -->
Deploying ASG from the client depends on internet connectivity and is not transactional. In the middle of deployment of ASG it can fail leaving ASG in unpredicted state. Using AWS infrastructure will add more stability in deployment (and probably will make it faster)

More about the feature: https://aws.amazon.com/blogs/compute/introducing-instance-refresh-for-ec2-auto-scaling/

Boto3 documentation: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/autoscaling.html#AutoScaling.Client.start_instance_refresh

<!--- Paste example playbooks or commands between quotes below -->
```yaml
- commutiny.aws.ec2_asg_instance_refresh:
    name: some-backend
```

<!--- HINT: You can also paste gist.github.com links for larger files -->



Discussion:
Files identified in the description:
* [`plugins/modules/ec2_asg.py`](https://github.com/ansible-collections/community.aws/blob/main/plugins/modules/ec2_asg.py)

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @garethr @jillr @s-hertel @tremble @wimnat
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
Hello @goneri et al. I've submitted a PR here: https://github.com/ansible-collections/community.aws/pull/425
cc @markuman
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->

Resolving patch:
diff --git a/plugins/modules/ec2_asg_instance_refresh.py b/plugins/modules/ec2_asg_instance_refresh.py
new file mode 100644
--- /dev/null
+++ b/plugins/modules/ec2_asg_instance_refresh.py
@@ -0,0 +1,267 @@
+#!/usr/bin/python
+# Copyright: Ansible Project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+
+DOCUMENTATION = '''
+---
+module: ec2_asg_instance_refresh
+version_added: 3.2.0
+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(community.aws.ec2_asg_instance_refresh_info) to track the subsequent progress.
+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 C(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 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'.
+    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.
+        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
+    type: dict
+extends_documentation_fragment:
+- amazon.aws.aws
+- amazon.aws.ec2
+
+'''
+
+EXAMPLES = '''
+# Note: These examples do not set authentication details, see the AWS Guide for details.
+
+- name: Start a refresh
+  community.aws.ec2_asg_instance_refresh:
+    name: some-asg
+    state: started
+
+- name: Cancel a refresh
+  community.aws.ec2_asg_instance_refresh:
+    name: some-asg
+    state: cancelled
+
+- name: Start a refresh and pass preferences
+  community.aws.ec2_asg_instance_refresh:
+    name: some-asg
+    state: started
+    preferences:
+      min_healthy_percentage: 91
+      instance_warmup: 60
+
+'''
+
+RETURN = '''
+---
+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
+'''
+
+try:
+    from botocore.exceptions import BotoCoreError, ClientError
+except ImportError:
+    pass  # caught by AnsibleAWSModule
+
+
+from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule
+from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AWSRetry
+from ansible_collections.amazon.aws.plugins.module_utils.ec2 import camel_dict_to_snake_dict
+from ansible_collections.amazon.aws.plugins.module_utils.core import scrub_none_parameters
+from ansible.module_utils.common.dict_transformations import snake_dict_to_camel_dict
+
+
+def start_or_cancel_instance_refresh(conn, module):
+    """
+    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')
+    asg_name = module.params.get('name')
+    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)
+    cmd_invocations = {
+        'cancelled': conn.cancel_instance_refresh,
+        'started': conn.start_instance_refresh,
+    }
+    try:
+        if module.check_mode:
+            if asg_state == 'started':
+                ongoing_refresh = conn.describe_instance_refreshes(AutoScalingGroupName=asg_name).get('InstanceRefreshes', '[]')
+                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':
+                ongoing_refresh = conn.describe_instance_refreshes(AutoScalingGroupName=asg_name).get('InstanceRefreshes', '[]')[0]
+                if ongoing_refresh.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.')
+                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']])
+        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='Failed to {0} InstanceRefresh'.format(
+                asg_state.replace('ed', '')
+            )
+        )
+
+
+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'),
+            )
+        ),
+    )
+
+    module = AnsibleAWSModule(
+        argument_spec=argument_spec,
+        supports_check_mode=True,
+    )
+    autoscaling = module.client(
+        'autoscaling',
+        retry_decorator=AWSRetry.jittered_backoff(
+            retries=10,
+            catch_extra_error_codes=['InstanceRefreshInProgress']
+        )
+    )
+
+    start_or_cancel_instance_refresh(autoscaling, module)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/plugins/modules/ec2_asg_instance_refresh_info.py b/plugins/modules/ec2_asg_instance_refresh_info.py
new file mode 100644
--- /dev/null
+++ b/plugins/modules/ec2_asg_instance_refresh_info.py
@@ -0,0 +1,219 @@
+#!/usr/bin/python
+# Copyright: Ansible Project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+
+DOCUMENTATION = '''
+---
+module: ec2_asg_instance_refresh_info
+version_added: 3.2.0
+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.
+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 50 and the maximum value is 100.
+    type: int
+    required: false
+extends_documentation_fragment:
+- amazon.aws.aws
+- amazon.aws.ec2
+
+'''
+
+EXAMPLES = '''
+# Note: These examples do not set authentication details, see the AWS Guide for details.
+
+- name: Find an refresh by ASG name
+  community.aws.ec2_asg_instance_refresh_info:
+    name: somename-asg
+
+- name: Find an refresh by ASG name and one or more refresh-IDs
+  community.aws.ec2_asg_instance_refresh_info:
+    name: somename-asg
+    ids: ['some-id-123']
+  register: asgs
+
+- name: Find an refresh by ASG name and set max_records
+  community.aws.ec2_asg_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
+  community.aws.ec2_asg_instance_refresh_info:
+    name: somename-asg
+    next_token: 'some-token-123'
+  register: asgs
+'''
+
+RETURN = '''
+---
+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
+'''
+
+try:
+    from botocore.exceptions import BotoCoreError, ClientError
+except ImportError:
+    pass  # caught by AnsibleAWSModule
+
+from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule
+from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AWSRetry
+from ansible_collections.amazon.aws.plugins.module_utils.ec2 import camel_dict_to_snake_dict
+
+
+def find_asg_instance_refreshes(conn, module):
+    """
+    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': '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', '')))
+                instance_refreshes_result.update(instance_refreshes_dict)
+
+        return module.exit_json(**instance_refreshes_result)
+    except (BotoCoreError, ClientError) as e:
+        module.fail_json_aws(e, msg='Failed to describe InstanceRefreshes')
+
+
+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',
+        retry_decorator=AWSRetry.jittered_backoff(retries=10)
+    )
+    find_asg_instance_refreshes(autoscaling, module)
+
+
+if __name__ == '__main__':
+    main()


Test patch:
diff --git a/tests/integration/targets/ec2_asg_instance_refresh/aliases b/tests/integration/targets/ec2_asg_instance_refresh/aliases
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_asg_instance_refresh/aliases
@@ -0,0 +1,2 @@
+cloud/aws
+ec2_asg_instance_refresh_info
diff --git a/tests/integration/targets/ec2_asg_instance_refresh/defaults/main.yml b/tests/integration/targets/ec2_asg_instance_refresh/defaults/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_asg_instance_refresh/defaults/main.yml
@@ -0,0 +1,5 @@
+---
+# defaults file for ec2_asg
+vpc_seed: '{{ tiny_prefix }}'
+ec2_ami_name: 'amzn2-ami-hvm-2.*-x86_64-gp2'
+subnet_a_cidr: '10.{{ 256 | random(seed=vpc_seed) }}.32.0/24'
diff --git a/tests/integration/targets/ec2_asg_instance_refresh/tasks/main.yml b/tests/integration/targets/ec2_asg_instance_refresh/tasks/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_asg_instance_refresh/tasks/main.yml
@@ -0,0 +1,533 @@
+---
+- name: setup credentials and region
+  module_defaults:
+    group/aws:
+      aws_access_key: "{{ aws_access_key }}"
+      aws_secret_key: "{{ aws_secret_key }}"
+      security_token: "{{ security_token | default(omit) }}"
+      region: "{{ aws_region }}"
+
+  collections:
+    - amazon.aws
+
+  block:
+
+    #NOTE: entire ASG setup is 'borrowed' from ec2_asg
+    - name: Find AMI to use
+      ec2_ami_info:
+        owners: 'amazon'
+        filters:
+          name: '{{ ec2_ami_name }}'
+      register: ec2_amis
+    - set_fact:
+        ec2_ami_image: '{{ ec2_amis.images[0].image_id }}'
+
+    - name: load balancer name has to be less than 32 characters
+      set_fact:
+        load_balancer_name: "{{ item }}-lb"
+      loop: "{{ resource_prefix | regex_findall('.{8}$') }}"
+
+    # Set up the testing dependencies: VPC, subnet, security group, and two launch configurations
+    - name: Create VPC for use in testing
+      ec2_vpc_net:
+        name: "{{ resource_prefix }}-vpc"
+        cidr_block: '{{ subnet_a_cidr }}'
+        tenancy: default
+      register: testing_vpc
+
+    - name: Create internet gateway for use in testing
+      ec2_vpc_igw:
+        vpc_id: "{{ testing_vpc.vpc.id }}"
+        state: present
+      register: igw
+
+    - name: Create subnet for use in testing
+      ec2_vpc_subnet:
+        state: present
+        vpc_id: "{{ testing_vpc.vpc.id }}"
+        cidr: '{{ subnet_a_cidr }}'
+        az: "{{ aws_region }}a"
+        resource_tags:
+          Name: "{{ resource_prefix }}-subnet"
+      register: testing_subnet
+
+    - name: create routing rules
+      ec2_vpc_route_table:
+        vpc_id: "{{ testing_vpc.vpc.id }}"
+        tags:
+          created: "{{ resource_prefix }}-route"
+        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
+      ec2_group:
+        name: "{{ resource_prefix }}-sg"
+        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
+      ec2_lc:
+        name: "{{ item }}"
+        assign_public_ip: true
+        image_id: "{{ ec2_ami_image }}"
+        user_data: |
+          package_upgrade: true
+          package_update: true
+          packages:
+            - httpd
+          runcmd:
+            - "service httpd start"
+        security_groups: "{{ sg.group_id }}"
+        instance_type: t3.micro
+      loop:
+        - "{{ resource_prefix }}-lc"
+        - "{{ resource_prefix }}-lc-2"
+
+    - name: launch asg and do not wait for instances to be deemed healthy (no ELB)
+      ec2_asg:
+        name: "{{ resource_prefix }}-asg"
+        launch_config_name: "{{ resource_prefix }}-lc"
+        desired_capacity: 1
+        min_size: 1
+        max_size: 1
+        vpc_zone_identifier: "{{ testing_subnet.subnet.id }}"
+        wait_for_instances: no
+        state: present
+      register: output
+
+    - assert:
+        that:
+        - "output.viable_instances == 0"
+
+  # ============================================================
+
+    - name: test invalid cancelation - V1 - (pre-refresh)
+      ec2_asg_instance_refresh:
+        name: "{{ resource_prefix }}-asg"
+        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
+      ec2_asg_instance_refresh:
+        name: "{{ resource_prefix }}-asg"
+        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
+      ec2_asg_instance_refresh:
+        name: "{{ resource_prefix }}-asg"
+        state: "started"
+      register: output
+
+    - assert:
+        that:
+        - "'instance_refresh_id' in output.instance_refreshes"
+
+    - name: test starting a refresh with a valid ASG name - Idempotent
+      ec2_asg_instance_refresh:
+        name: "{{ resource_prefix }}-asg"
+        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)
+      ec2_asg_instance_refresh:
+        name: "{{ resource_prefix }}-asg"
+        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
+      ec2_asg_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
+      ec2_asg_instance_refresh:
+        name: "{{ resource_prefix }}-asg"
+        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
+      ec2_asg_instance_refresh:
+        name: "{{ resource_prefix }}-asg"
+        state: "cancelled"
+      register: output
+
+    - assert:
+        that:
+        - "'instance_refresh_id' in output.instance_refreshes"
+
+    - name: test canceling a refresh with a ASG name - Idempotent
+      ec2_asg_instance_refresh:
+        name: "{{ resource_prefix }}-asg"
+        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)
+      ec2_asg_instance_refresh:
+        name: "{{ resource_prefix }}-asg"
+        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
+      ec2_asg_instance_refresh:
+        name: "{{ resource_prefix }}-asg"
+        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
+      ec2_asg_instance_refresh:
+        name: "{{ resource_prefix }}-asg"
+        state: "cancelled"
+      register: output
+
+    - assert:
+        that:
+        - "'instance_refresh_id' in output.instance_refreshes"
+
+    - name: test valid start - V1 - (with preferences missing instance_warmup)
+      ec2_asg_instance_refresh:
+        name: "{{ resource_prefix }}-asg"
+        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
+      ec2_asg_instance_refresh:
+        name: "{{ resource_prefix }}-asg"
+        state: "cancelled"
+      register: output
+
+    - assert:
+        that:
+        - "'instance_refresh_id' in output.instance_refreshes"
+
+    - name: test valid start - V2 - (with preferences missing min_healthy_percentage)
+      ec2_asg_instance_refresh:
+        name: "{{ resource_prefix }}-asg"
+        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)
+      ec2_asg_instance_refresh:
+        name: "{{ resource_prefix }}-asg"
+        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 setup 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
+      ec2_asg_instance_refresh_info:
+        name: "{{ resource_prefix }}-asg"
+        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
+      ec2_asg_instance_refresh_info:
+        name: "{{ resource_prefix }}-asg"
+        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
+      ec2_asg_instance_refresh_info:
+        name: "{{ resource_prefix }}-asg"
+        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
+      ec2_asg_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
+      ec2_asg_instance_refresh_info:
+        name: "{{ resource_prefix }}-asg"
+      ignore_errors: yes
+      register: output
+
+    - assert:
+        that:
+          - "{{ output.instance_refreshes|length }} == 7"
+
+    - name: assert that valid message with fake-token is returned
+      ec2_asg_instance_refresh_info:
+        name: "{{ resource_prefix }}-asg"
+        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
+      ec2_asg_instance_refresh_info:
+        name: "{{ resource_prefix }}-asg"
+        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
+      ec2_asg_instance_refresh_info:
+        name: "{{ resource_prefix }}-asg"
+        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
+      ec2_asg_instance_refresh_info:
+        name: "{{ resource_prefix }}-asg"
+        max_records: 1
+        next_token: "{{ output.next_token }}"
+      ignore_errors: yes
+      register: output
+
+    - assert:
+        that:
+          - "{{ output.instance_refreshes|length }} == 1"
+
+  always:
+
+    - name: kill asg
+      ec2_asg:
+        name: "{{ resource_prefix }}-asg"
+        state: absent
+      register: removed
+      until: removed is not failed
+      ignore_errors: yes
+      retries: 10
+    # Remove the testing dependencies
+
+    - name: remove the load balancer
+      ec2_elb_lb:
+        name: "{{ load_balancer_name }}"
+        state: absent
+        security_group_ids:
+          - "{{ sg.group_id }}"
+        subnets: "{{ testing_subnet.subnet.id }}"
+        wait: yes
+        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: yes
+      retries: 10
+
+    - name: remove launch configs
+      ec2_lc:
+        name: "{{ resource_prefix }}-lc"
+        state: absent
+      register: removed
+      until: removed is not failed
+      ignore_errors: yes
+      retries: 10
+      loop:
+        - "{{ resource_prefix }}-lc"
+        - "{{ resource_prefix }}-lc-2"
+
+    - name: delete launch template
+      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
+      ec2_group:
+        name: "{{ resource_prefix }}-sg"
+        description: a security group for ansible tests
+        vpc_id: "{{ testing_vpc.vpc.id }}"
+        state: absent
+      register: removed
+      until: removed is not failed
+      ignore_errors: yes
+      retries: 10
+
+    - name: remove routing rules
+      ec2_vpc_route_table:
+        state: absent
+        vpc_id: "{{ testing_vpc.vpc.id }}"
+        tags:
+          created: "{{ resource_prefix }}-route"
+        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: yes
+      retries: 10
+
+    - 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
+      retries: 10
+
+    - 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
+      retries: 10
+
+    - name: remove the VPC
+      ec2_vpc_net:
+        name: "{{ resource_prefix }}-vpc"
+        cidr_block: '{{ subnet_a_cidr }}'
+        state: absent
+      register: removed
+      until: removed is not failed
+      ignore_errors: yes
+      retries: 10
diff --git a/tests/integration/targets/ec2_asg_instance_refresh/tasks/refresh_and_cancel_three_times.yml b/tests/integration/targets/ec2_asg_instance_refresh/tasks/refresh_and_cancel_three_times.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_asg_instance_refresh/tasks/refresh_and_cancel_three_times.yml
@@ -0,0 +1,29 @@
+---
+
+- name: try to cancel pre-loop
+  ec2_asg_instance_refresh:
+    name: "{{ resource_prefix }}-asg"
+    state: "cancelled"
+  ignore_errors: yes
+
+- name: test starting a refresh with an ASG name
+  ec2_asg_instance_refresh:
+    name: "{{ resource_prefix }}-asg"
+    state: "started"
+    aws_access_key: "{{ aws_access_key }}"
+    aws_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
+  ec2_asg_instance_refresh:
+    name: "{{ resource_prefix }}-asg"
+    state: "cancelled"
+    aws_access_key: "{{ aws_access_key }}"
+    aws_secret_key: "{{ aws_secret_key }}"
+    region: "{{ aws_region }}"
+  ignore_errors: yes
diff --git a/tests/integration/targets/ec2_asg_instance_refresh/vars/main.yml b/tests/integration/targets/ec2_asg_instance_refresh/vars/main.yml
new file mode 100644