Repository: ansible-collections/community.aws

Issue:
AutoScaling Group tags module
<!--- Verify first that your feature was not already discussed on GitHub -->
<!--- Complete *all* sections as described, this form is processed automatically -->

##### SUMMARY

Add a new module `ec2_asg_tag` analogous to `ec2_tag` to manage tags on AutoScaling Groups.

##### ISSUE TYPE
- Feature Idea

##### COMPONENT NAME

`ec2_asg_tag`

##### ADDITIONAL INFORMATION

Using `ec2_asg` to manage tags is difficult since you must [specify the entire list of tags to manage them](https://github.com/ansible-collections/community.aws/blob/9b60a2d5d50ff1d77e74cf79715ccebc4632757f/tests/integration/targets/ec2_asg/tasks/main.yml#L194-L248).
This is cumbersome since you first need to use `ec2_asg_info` to query the current set of tags, munge the result to create a new list of tags that includes or excludes the tags you with to add/remove and call `ec2_asg` again.

A more declarative approach would be to follow the pattern of `ec2_tag`, e.g.:

<!--- Paste example playbooks or commands between quotes below -->
```yaml
- name: Ensure tags are present on an ASG
  community.aws.ec2_asg_tag:
    resource: my-auto-scaling-group
    state: present
    tags:
      - environment: production
        propagate_at_launch: true
      - role: webserver
        propagate_at_launch: true

- name: Ensure tag is absent on an ASG
  community.aws.ec2_asg_tag:
    resource: my-auto-scaling-group
    state: absent
    tags:
      - environment: development
```

The module would utilise underlying API calls to:
* https://docs.aws.amazon.com/autoscaling/ec2/APIReference/API_DescribeTags.html
* https://docs.aws.amazon.com/autoscaling/ec2/APIReference/API_CreateOrUpdateTags.html
* https://docs.aws.amazon.com/autoscaling/ec2/APIReference/API_DeleteTags.html


Discussion:
Files identified in the description:
None

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 --->
Hi @tremble, how do you feel about this PR? 
@alinabuzachis,

Generally I prefer the alternative option of adding a "purge" option to the ec2_asg module (defaulting to purge=True, but supporting purge=False for the behaviour @jsok is after).  On the whole it's more consistent with our other modules. However, we do have ec2_tag and ecs_tag, so it's not entirely inconsistent.  Consider me neither a +1 nor a -1.

Resolving patch:
diff --git a/plugins/modules/ec2_asg.py b/plugins/modules/ec2_asg.py
--- a/plugins/modules/ec2_asg.py
+++ b/plugins/modules/ec2_asg.py
@@ -182,6 +182,21 @@
         matching the current launch configuration.
     type: list
     elements: str
+  detach_instances:
+    description:
+      - Removes one or more instances from the specified AutoScalingGroup.
+      - If I(decrement_desired_capacity) flag is not set, new instance(s) are launched to replace the detached instance(s).
+      - If a Classic Load Balancer is attached to the AutoScalingGroup, the instances are also deregistered from the load balancer.
+      - If there are target groups attached to the AutoScalingGroup, the instances are also deregistered from the target groups.
+    type: list
+    elements: str
+    version_added: 3.2.0
+  decrement_desired_capacity:
+    description:
+      - Indicates whether the AutoScalingGroup decrements the desired capacity value by the number of instances detached.
+    default: false
+    type: bool
+    version_added: 3.2.0
   lc_check:
     description:
       - Check to make sure instances that are being replaced with I(replace_instances) do not already have the current I(launch_config).
@@ -205,6 +220,13 @@
       - When I(propagate_at_launch) is true the tags will be propagated to the Instances created.
     type: list
     elements: dict
+  purge_tags:
+    description:
+      - If C(true), existing tags will be purged from the resource to match exactly what is defined by I(tags) parameter.
+      - If the I(tags) parameter is not set then tags will not be modified.
+    default: true
+    type: bool
+    version_added: 3.2.0
   health_check_period:
     description:
       - Length of time in seconds after a new EC2 instance comes into service that Auto Scaling starts checking its health.
@@ -630,6 +652,7 @@
 from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AWSRetry
 from ansible_collections.amazon.aws.plugins.module_utils.ec2 import snake_dict_to_camel_dict
 from ansible_collections.amazon.aws.plugins.module_utils.ec2 import camel_dict_to_snake_dict
+from ansible_collections.amazon.aws.plugins.module_utils.ec2 import ansible_dict_to_boto3_filter_list
 
 ASG_ATTRIBUTES = ('AvailabilityZones', 'DefaultCooldown', 'DesiredCapacity',
                   'HealthCheckGracePeriod', 'HealthCheckType', 'LaunchConfigurationName',
@@ -756,6 +779,12 @@ def terminate_asg_instance(connection, instance_id, decrement_capacity):
                                                         ShouldDecrementDesiredCapacity=decrement_capacity)
 
 
+@AWSRetry.jittered_backoff(**backoff_params)
+def detach_asg_instances(connection, instance_ids, as_group_name, decrement_capacity):
+    connection.detach_instances(InstanceIds=instance_ids, AutoScalingGroupName=as_group_name,
+                                ShouldDecrementDesiredCapacity=decrement_capacity)
+
+
 def enforce_required_arguments_for_create():
     ''' As many arguments are not required for autoscale group deletion
         they cannot be mandatory arguments for the module, so we enforce
@@ -1076,6 +1105,7 @@ def create_autoscaling_group(connection):
     desired_capacity = module.params.get('desired_capacity')
     vpc_zone_identifier = module.params.get('vpc_zone_identifier')
     set_tags = module.params.get('tags')
+    purge_tags = module.params.get('purge_tags')
     health_check_period = module.params.get('health_check_period')
     health_check_type = module.params.get('health_check_type')
     default_cooldown = module.params.get('default_cooldown')
@@ -1184,9 +1214,12 @@ def create_autoscaling_group(connection):
             changed = True
 
         # process tag changes
+        have_tags = as_group.get('Tags')
+        want_tags = asg_tags
+        if purge_tags and not want_tags and have_tags:
+            connection.delete_tags(Tags=list(have_tags))
+
         if len(set_tags) > 0:
-            have_tags = as_group.get('Tags')
-            want_tags = asg_tags
             if have_tags:
                 have_tags.sort(key=lambda x: x["Key"])
             if want_tags:
@@ -1197,9 +1230,11 @@ def create_autoscaling_group(connection):
 
             for dead_tag in set(have_tag_keyvals).difference(want_tag_keyvals):
                 changed = True
-                dead_tags.append(dict(ResourceId=as_group['AutoScalingGroupName'],
-                                      ResourceType='auto-scaling-group', Key=dead_tag))
+                if purge_tags:
+                    dead_tags.append(dict(
+                        ResourceId=as_group['AutoScalingGroupName'], ResourceType='auto-scaling-group', Key=dead_tag))
                 have_tags = [have_tag for have_tag in have_tags if have_tag['Key'] != dead_tag]
+
             if dead_tags:
                 connection.delete_tags(Tags=dead_tags)
 
@@ -1523,6 +1558,40 @@ def replace(connection):
     return changed, asg_properties
 
 
+def detach(connection):
+    group_name = module.params.get('name')
+    detach_instances = module.params.get('detach_instances')
+    as_group = describe_autoscaling_groups(connection, group_name)[0]
+    decrement_desired_capacity = module.params.get('decrement_desired_capacity')
+    min_size = module.params.get('min_size')
+    props = get_properties(as_group)
+    instances = props['instances']
+
+    # check if provided instance exists in asg, create list of instances to detach which exist in asg
+    instances_to_detach = []
+    for instance_id in detach_instances:
+        if instance_id in instances:
+            instances_to_detach.append(instance_id)
+
+    # check if setting decrement_desired_capacity will make desired_capacity smaller
+    # than the currently set minimum size in ASG configuration
+    if decrement_desired_capacity:
+        decremented_desired_capacity = len(instances) - len(instances_to_detach)
+        if min_size and min_size > decremented_desired_capacity:
+            module.fail_json(
+                msg="Detaching instance(s) with 'decrement_desired_capacity' flag set reduces number of instances to {0}\
+                        which is below current min_size {1}, please update AutoScalingGroup Sizes properly.".format(decremented_desired_capacity, min_size))
+
+    if instances_to_detach:
+        try:
+            detach_asg_instances(connection, instances_to_detach, group_name, decrement_desired_capacity)
+        except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
+            module.fail_json_aws(e, msg="Failed to detach instances from AutoScaling Group")
+
+    asg_properties = get_properties(as_group)
+    return True, asg_properties
+
+
 def get_instances_by_launch_config(props, lc_check, initial_instances):
     new_instances = []
     old_instances = []
@@ -1776,11 +1845,14 @@ def main():
         replace_batch_size=dict(type='int', default=1),
         replace_all_instances=dict(type='bool', default=False),
         replace_instances=dict(type='list', default=[], elements='str'),
+        detach_instances=dict(type='list', default=[], elements='str'),
+        decrement_desired_capacity=dict(type='bool', default=False),
         lc_check=dict(type='bool', default=True),
         lt_check=dict(type='bool', default=True),
         wait_timeout=dict(type='int', default=300),
         state=dict(default='present', choices=['present', 'absent']),
         tags=dict(type='list', default=[], elements='dict'),
+        purge_tags=dict(type='bool', default=True),
         health_check_period=dict(type='int', default=300),
         health_check_type=dict(default='EC2', choices=['EC2', 'ELB']),
         default_cooldown=dict(type='int', default=300),
@@ -1821,16 +1893,18 @@ def main():
         argument_spec=argument_spec,
         mutually_exclusive=[
             ['replace_all_instances', 'replace_instances'],
-            ['launch_config_name', 'launch_template']
+            ['replace_all_instances', 'detach_instances'],
+            ['launch_config_name', 'launch_template'],
         ]
     )
 
     state = module.params.get('state')
     replace_instances = module.params.get('replace_instances')
     replace_all_instances = module.params.get('replace_all_instances')
+    detach_instances = module.params.get('detach_instances')
 
     connection = module.client('autoscaling')
-    changed = create_changed = replace_changed = False
+    changed = create_changed = replace_changed = detach_changed = False
     exists = asg_exists(connection)
 
     if state == 'present':
@@ -1847,7 +1921,15 @@ def main():
     ):
         replace_changed, asg_properties = replace(connection)
 
-    if create_changed or replace_changed:
+    # Only detach instances if asg existed at start of call
+    if (
+        exists
+        and (detach_instances)
+        and (module.params.get('launch_config_name') or module.params.get('launch_template'))
+    ):
+        detach_changed, asg_properties = detach(connection)
+
+    if create_changed or replace_changed or detach_changed:
         changed = True
 
     module.exit_json(changed=changed, **asg_properties)


Test patch:
diff --git a/tests/integration/targets/ec2_asg/tasks/instance_detach.yml b/tests/integration/targets/ec2_asg/tasks/instance_detach.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_asg/tasks/instance_detach.yml
@@ -0,0 +1,208 @@
+- name: Running instance detach tests
+  block:
+    #----------------------------------------------------------------------
+    - name: create a launch configuration
+      ec2_lc:
+        name: "{{ resource_prefix }}-lc-detach-test"
+        image_id: "{{ ec2_ami_image }}"
+        region: "{{ aws_region }}"
+        instance_type: t2.micro
+        assign_public_ip: yes
+      register: create_lc
+
+    - name: ensure that lc is created
+      assert:
+        that:
+          - create_lc is changed
+          - create_lc.failed is false
+          - '"autoscaling:CreateLaunchConfiguration" in create_lc.resource_actions'
+
+    #----------------------------------------------------------------------
+    - name: create a AutoScalingGroup to be used for instance_detach test
+      ec2_asg:
+        name: "{{ resource_prefix }}-asg-detach-test"
+        launch_config_name: "{{ resource_prefix }}-lc-detach-test"
+        health_check_period: 60
+        health_check_type: ELB
+        replace_all_instances: yes
+        min_size: 3
+        max_size: 6
+        desired_capacity: 3
+        region: "{{ aws_region }}"
+      register: create_asg
+
+    - name: ensure that AutoScalingGroup is created
+      assert:
+        that:
+          - create_asg is changed
+          - create_asg.failed is false
+          - create_asg.instances | length == 3
+          - create_asg.desired_capacity == 3
+          - create_asg.in_service_instances == 3
+          - '"autoscaling:CreateAutoScalingGroup" in create_asg.resource_actions'
+
+    - name: gather info about asg, get instance ids
+      ec2_asg_info:
+        name: "{{ resource_prefix }}-asg-detach-test"
+      register: asg_info
+    - set_fact:
+        init_instance_1: "{{ asg_info.results[0].instances[0].instance_id }}"
+        init_instance_2: "{{ asg_info.results[0].instances[1].instance_id }}"
+        init_instance_3: "{{ asg_info.results[0].instances[2].instance_id }}"
+
+    - name: Gather information about recently detached instances
+      amazon.aws.ec2_instance_info:
+        instance_ids:
+          - "{{ init_instance_1 }}"
+          - "{{ init_instance_2 }}"
+          - "{{ init_instance_3 }}"
+      register: instances_info
+
+    # assert that there are 3 instances running in the AutoScalingGroup
+    - assert:
+        that:
+          - asg_info.results[0].instances | length == 3
+          - "'{{ instances_info.instances[0].state.name }}' == 'running'"
+          - "'{{ instances_info.instances[1].state.name }}' == 'running'"
+          - "'{{ instances_info.instances[2].state.name }}' == 'running'"
+
+    #----------------------------------------------------------------------
+
+    - name: detach 2 instance from the asg and replace with other instances
+      ec2_asg:
+        name: "{{ resource_prefix }}-asg-detach-test"
+        launch_config_name: "{{ resource_prefix }}-lc-detach-test"
+        health_check_period: 60
+        health_check_type: ELB
+        min_size: 3
+        max_size: 3
+        desired_capacity: 3
+        region: "{{ aws_region }}"
+        detach_instances:
+          - '{{ init_instance_1 }}'
+          - '{{ init_instance_2 }}'
+
+    # pause to allow completion of instance replacement
+    - name: Pause for 30 seconds
+      pause:
+        seconds: 30
+
+    # gather info about asg and get instance ids
+    - ec2_asg_info:
+        name: "{{ resource_prefix }}-asg-detach-test"
+      register: asg_info_replaced
+    - set_fact:
+        instance_replace_1: "{{ asg_info_replaced.results[0].instances[0].instance_id }}"
+        instance_replace_2: "{{ asg_info_replaced.results[0].instances[1].instance_id }}"
+        instance_replace_3: "{{ asg_info_replaced.results[0].instances[2].instance_id }}"
+
+    # create a list of instance currently attached to asg
+    - set_fact:
+        asg_instance_detach_replace: "{{ asg_info_replaced.results[0].instances | map(attribute='instance_id') | list }}"
+
+    - name: Gather information about recently detached instances
+      amazon.aws.ec2_instance_info:
+        instance_ids:
+          - "{{ init_instance_1 }}"
+          - "{{ init_instance_2 }}"
+      register: detached_instances_info
+
+    # assert that
+    #   there are 3 still instances in the AutoScalingGroup
+    #   two specified instances are detached and still running independently(not terminated)
+    - assert:
+        that:
+          - asg_info_replaced.results[0].desired_capacity == 3
+          - asg_info_replaced.results[0].instances | length == 3
+          - "'{{ init_instance_1 }}' not in {{ asg_instance_detach_replace }}"
+          - "'{{ init_instance_2 }}' not in {{ asg_instance_detach_replace }}"
+          - "'{{ detached_instances_info.instances[0].state.name }}' == 'running'"
+          - "'{{ detached_instances_info.instances[1].state.name }}' == 'running'"
+
+    #----------------------------------------------------------------------
+
+    # detach 2 instances from the asg and reduce the desired capacity from 3 to 1
+    - name: detach 2 instance from the asg and reduce the desired capacity from 3 to 1
+      ec2_asg:
+        name: "{{ resource_prefix }}-asg-detach-test"
+        launch_config_name: "{{ resource_prefix }}-lc-detach-test"
+        health_check_period: 60
+        health_check_type: ELB
+        min_size: 1
+        max_size: 5
+        desired_capacity: 3
+        region: "{{ aws_region }}"
+        decrement_desired_capacity: true
+        detach_instances:
+          - '{{ instance_replace_1 }}'
+          - '{{ instance_replace_2 }}'
+
+    - name: Pause for 30 seconds to allow completion of above task
+      pause:
+        seconds: 30
+
+    # gather information about asg and get instance id
+    - ec2_asg_info:
+        name: "{{ resource_prefix }}-asg-detach-test"
+      register: asg_info_decrement
+    - set_fact:
+        instance_detach_decrement: "{{ asg_info_decrement.results[0].instances[0].instance_id }}"
+    # create a list of instance ids from info result and set variable value to instance ID
+    - set_fact:
+        asg_instance_detach_decrement: "{{ asg_info_decrement.results[0].instances | map(attribute='instance_id') | list }}"
+
+    - name: Gather information about recently detached instances
+      amazon.aws.ec2_instance_info:
+        instance_ids:
+          - "{{ instance_replace_1 }}"
+          - "{{ instance_replace_2 }}"
+      register: detached_instances_info
+
+    # assert that
+    #   detached instances are not replaced and there is only 1 instance in the AutoScalingGroup
+    #   desired capacity is reduced to 1
+    #   detached instances are not terminated
+    - assert:
+        that:
+          - asg_info_decrement.results[0].instances | length == 1
+          - asg_info_decrement.results[0].desired_capacity == 1
+          - "'{{ instance_replace_1 }}' not in {{ asg_instance_detach_decrement }}"
+          - "'{{ instance_replace_2 }}' not in {{ asg_instance_detach_decrement }}"
+          - "'{{ detached_instances_info.instances[0].state.name }}' == 'running'"
+          - "'{{ detached_instances_info.instances[1].state.name }}' == 'running'"
+          - "'{{ instance_replace_3 }}' == '{{ instance_detach_decrement }}'"
+
+    #----------------------------------------------------------------------
+
+  always:
+
+  - name: terminate any instances created during this test
+    amazon.aws.ec2_instance:
+      instance_ids:
+        - "{{ item }}"
+      state: absent
+    loop:
+      - "{{ init_instance_1 }}"
+      - "{{ init_instance_2 }}"
+      - "{{ init_instance_3 }}"
+      - "{{ instance_replace_1 }}"
+      - "{{ instance_replace_2 }}"
+      - "{{ instance_replace_3 }}"
+
+  - name: kill asg created in this test
+    ec2_asg:
+      name: "{{ resource_prefix }}-asg-detach-test"
+      state: absent
+    register: removed
+    until: removed is not failed
+    ignore_errors: yes
+    retries: 10
+
+  - name: remove launch config created in this test
+    ec2_lc:
+      name: "{{ resource_prefix }}-lc-detach-test"
+      state: absent
+    register: removed
+    until: removed is not failed
+    ignore_errors: yes
+    retries: 10
diff --git a/tests/integration/targets/ec2_asg/tasks/main.yml b/tests/integration/targets/ec2_asg/tasks/main.yml
--- a/tests/integration/targets/ec2_asg/tasks/main.yml
+++ b/tests/integration/targets/ec2_asg/tasks/main.yml
@@ -30,7 +30,6 @@
       aws_secret_key: "{{ aws_secret_key }}"
       security_token: "{{ security_token | default(omit) }}"
       region: "{{ aws_region }}"
-
   collections:
     - amazon.aws
 
@@ -106,6 +105,10 @@
             cidr_ip: 0.0.0.0/0
       register: sg
 
+    - include_tasks: tag_operations.yml
+
+    - include_tasks: instance_detach.yml
+
     - name: ensure launch configs exist
       ec2_lc:
         name: "{{ item }}"
@@ -143,62 +146,6 @@
         that:
           - "output.viable_instances == 1"
 
-    - name: Tag asg
-      ec2_asg:
-        name: "{{ resource_prefix }}-asg"
-        tags:
-        - tag_a: 'value 1'
-          propagate_at_launch: no
-        - tag_b: 'value 2'
-          propagate_at_launch: yes
-      register: output
-
-    - assert:
-        that:
-          - "output.tags | length == 2"
-          - output is changed
-
-    - name: Re-Tag asg (different order)
-      ec2_asg:
-        name: "{{ resource_prefix }}-asg"
-        tags:
-        - tag_b: 'value 2'
-          propagate_at_launch: yes
-        - tag_a: 'value 1'
-          propagate_at_launch: no
-      register: output
-
-    - assert:
-        that:
-          - "output.tags | length == 2"
-          - output is not changed
-
-    - name: Re-Tag asg new tags
-      ec2_asg:
-        name: "{{ resource_prefix }}-asg"
-        tags:
-        - tag_c: 'value 3'
-          propagate_at_launch: no
-      register: output
-
-    - assert:
-        that:
-          - "output.tags | length == 1"
-          - output is changed
-
-    - name: Re-Tag asg update propagate_at_launch
-      ec2_asg:
-        name: "{{ resource_prefix }}-asg"
-        tags:
-        - tag_c: 'value 3'
-          propagate_at_launch: yes
-      register: output
-
-    - assert:
-        that:
-          - "output.tags | length == 1"
-          - output is changed
-
     - name: Enable metrics collection
       ec2_asg:
         name: "{{ resource_prefix }}-asg"
diff --git a/tests/integration/targets/ec2_asg/tasks/tag_operations.yml b/tests/integration/targets/ec2_asg/tasks/tag_operations.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_asg/tasks/tag_operations.yml
@@ -0,0 +1,352 @@
+- name: Running AutoScalingGroup Tag operations test
+  block:
+    #----------------------------------------------------------------------
+    - name: create a launch configuration
+      ec2_lc:
+        name: "{{ resource_prefix }}-lc-tag-test"
+        image_id: "{{ ec2_ami_image }}"
+        region: "{{ aws_region }}"
+        instance_type: t2.micro
+        assign_public_ip: yes
+      register: create_lc
+
+    - name: ensure that lc is created
+      assert:
+        that:
+          - create_lc is changed
+          - create_lc.failed is false
+          - '"autoscaling:CreateLaunchConfiguration" in create_lc.resource_actions'
+
+    #----------------------------------------------------------------------
+    - name: create a AutoScalingGroup to be used for tag_operations test
+      ec2_asg:
+        name: "{{ resource_prefix }}-asg-tag-test"
+        launch_config_name: "{{ resource_prefix }}-lc-tag-test"
+        health_check_period: 60
+        health_check_type: ELB
+        replace_all_instances: yes
+        min_size: 1
+        max_size: 1
+        desired_capacity: 1
+        region: "{{ aws_region }}"
+      register: create_asg
+
+    - name: ensure that AutoScalingGroup is created
+      assert:
+        that:
+          - create_asg is changed
+          - create_asg.failed is false
+          - '"autoscaling:CreateAutoScalingGroup" in create_asg.resource_actions'
+
+    #----------------------------------------------------------------------
+
+    - name: Get asg info
+      ec2_asg_info:
+        name: "{{ resource_prefix }}-asg-tag-test"
+      register: info_result
+
+    - assert:
+        that:
+          - info_result.results[0].tags | length == 0
+
+    - name: Tag asg
+      ec2_asg:
+        name: "{{ resource_prefix }}-asg-tag-test"
+        tags:
+        - tag_a: 'value 1'
+          propagate_at_launch: no
+        - tag_b: 'value 2'
+          propagate_at_launch: yes
+      register: output
+
+    - assert:
+        that:
+          - "output.tags | length == 2"
+          - output is changed
+
+    - name: Re-Tag asg (different order)
+      ec2_asg:
+        name: "{{ resource_prefix }}-asg-tag-test"
+        tags:
+        - tag_b: 'value 2'
+          propagate_at_launch: yes
+        - tag_a: 'value 1'
+          propagate_at_launch: no
+      register: output
+
+    - assert:
+        that:
+          - "output.tags | length == 2"
+          - output is not changed
+
+    - name: Re-Tag asg new tags
+      ec2_asg:
+        name: "{{ resource_prefix }}-asg-tag-test"
+        tags:
+        - tag_c: 'value 3'
+          propagate_at_launch: no
+      register: output
+
+    - assert:
+        that:
+          - "output.tags | length == 1"
+          - output is changed
+
+    - name: Re-Tag asg update propagate_at_launch
+      ec2_asg:
+        name: "{{ resource_prefix }}-asg-tag-test"
+        tags:
+        - tag_c: 'value 3'
+          propagate_at_launch: yes
+      register: output
+
+    - assert:
+        that:
+          - "output.tags | length == 1"
+          - output is changed
+
+    - name: Remove all tags
+      ec2_asg:
+        name: "{{ resource_prefix }}-asg-tag-test"
+        tags: []
+      register: add_empty
+
+    - name: Get asg info
+      ec2_asg_info:
+        name: "{{ resource_prefix }}-asg-tag-test"
+      register: info_result
+    # create a list of tag_keys from info result
+    - set_fact:
+        tag_keys: "{{ info_result.results[0].tags | map(attribute='key') | list }}"
+
+    - assert:
+        that:
+          - add_empty is changed
+          - info_result.results[0].tags | length == 0
+          - '"autoscaling:CreateOrUpdateTags" not in add_empty.resource_actions'
+          - '"autoscaling:DeleteTags" in add_empty.resource_actions'
+
+    - name: Add 4 new tags - do not purge existing tags
+      ec2_asg:
+        name: "{{ resource_prefix }}-asg-tag-test"
+        tags:
+          - lowercase spaced: "hello cruel world"
+            propagate_at_launch: no
+          - Title Case: "Hello Cruel World"
+            propagate_at_launch: yes
+          - CamelCase: "SimpleCamelCase"
+            propagate_at_launch: yes
+          - snake_case: "simple_snake_case"
+            propagate_at_launch: no
+        purge_tags: false
+      register: add_result
+
+    - name: Get asg info
+      ec2_asg_info:
+        name: "{{ resource_prefix }}-asg-tag-test"
+      register: info_result
+    # create a list of tag_keys from info result
+    - set_fact:
+        tag_keys: "{{ info_result.results[0].tags | map(attribute='key') | list }}"
+
+    - assert:
+        that:
+          - add_result is changed
+          - info_result.results[0].tags | length == 4
+          - '"lowercase spaced" in tag_keys'
+          - '"Title Case" in tag_keys'
+          - '"CamelCase" in tag_keys'
+          - '"snake_case" in tag_keys'
+          - '"autoscaling:CreateOrUpdateTags" in add_result.resource_actions'
+
+    - name: Add 4 new tags - do not purge existing tags - idempotency
+      ec2_asg:
+        name: "{{ resource_prefix }}-asg-tag-test"
+        tags:
+          - lowercase spaced: "hello cruel world"
+            propagate_at_launch: no
+          - Title Case: "Hello Cruel World"
+            propagate_at_launch: yes
+          - CamelCase: "SimpleCamelCase"
+            propagate_at_launch: yes
+          - snake_case: "simple_snake_case"
+            propagate_at_launch: no
+        purge_tags: false
+      register: add_result
+
+    - name: Get asg info
+      ec2_asg_info:
+        name: "{{ resource_prefix }}-asg-tag-test"
+      register: info_result
+
+    - assert:
+        that:
+          - add_result is not changed
+          - info_result.results[0].tags | length == 4
+          - '"autoscaling:CreateOrUpdateTags" not in add_result.resource_actions'
+
+    - name: Add 2 new tags - purge existing tags
+      ec2_asg:
+        name: "{{ resource_prefix }}-asg-tag-test"
+        tags:
+          - tag_a: 'val_a'
+            propagate_at_launch: no
+          - tag_b: 'val_b'
+            propagate_at_launch: yes
+      register: add_purge_result
+
+    - name: Get asg info
+      ec2_asg_info:
+        name: "{{ resource_prefix }}-asg-tag-test"
+      register: info_result
+    # create a list of tag_keys from info result
+    - set_fact:
+        tag_keys: "{{ info_result.results[0].tags | map(attribute='key') | list }}"
+
+    - assert:
+        that:
+          - add_purge_result is changed
+          - info_result.results[0].tags | length == 2
+          - '"tag_a" in tag_keys'
+          - '"tag_b" in tag_keys'
+          - '"lowercase spaced" not in tag_keys'
+          - '"Title Case" not in tag_keys'
+          - '"CamelCase" not in tag_keys'
+          - '"snake_case" not in tag_keys'
+          - '"autoscaling:CreateOrUpdateTags" in add_purge_result.resource_actions'
+
+    - name: Re-tag ASG - modify values
+      ec2_asg:
+        name: "{{ resource_prefix }}-asg-tag-test"
+        tags:
+          - tag_a: 'new_val_a'
+            propagate_at_launch: no
+          - tag_b: 'new_val_b'
+            propagate_at_launch: yes
+      register: add_purge_result
+
+    - name: Get asg info
+      ec2_asg_info:
+        name: "{{ resource_prefix }}-asg-tag-test"
+      register: info_result
+    # create a list of tag_keys and tag_values from info result
+    - set_fact:
+        tag_keys: "{{ info_result.results[0].tags | map(attribute='key') | list }}"
+    - set_fact:
+        tag_values: "{{ info_result.results[0].tags | map(attribute='value') | list }}"
+
+
+    - assert:
+        that:
+          - add_purge_result is changed
+          - info_result.results[0].tags | length == 2
+          - '"tag_a" in tag_keys'
+          - '"tag_b" in tag_keys'
+          - '"new_val_a" in tag_values'
+          - '"new_val_b" in tag_values'
+          - '"lowercase spaced" not in tag_keys'
+          - '"Title Case" not in tag_keys'
+          - '"CamelCase" not in tag_keys'
+          - '"snake_case" not in tag_keys'
+          - '"autoscaling:CreateOrUpdateTags" in add_purge_result.resource_actions'
+
+    - name: Add 2 more tags - do not purge existing tags
+      ec2_asg:
+        name: "{{ resource_prefix }}-asg-tag-test"
+        tags:
+          - lowercase spaced: "hello cruel world"
+            propagate_at_launch: no
+          - Title Case: "Hello Cruel World"
+            propagate_at_launch: yes
+        purge_tags: false
+      register: add_result
+
+    - name: Get asg info
+      ec2_asg_info:
+        name: "{{ resource_prefix }}-asg-tag-test"
+      register: info_result
+    # create a list of tag_keys from info result
+    - set_fact:
+        tag_keys: "{{ info_result.results[0].tags | map(attribute='key') | list }}"
+
+    - assert:
+        that:
+          - add_result is changed
+          - info_result.results[0].tags | length == 4
+          - '"tag_a" in tag_keys'
+          - '"tag_b" in tag_keys'
+          - '"lowercase spaced" in tag_keys'
+          - '"Title Case" in tag_keys'
+          - '"autoscaling:CreateOrUpdateTags" in add_result.resource_actions'
+
+    - name: Add empty tags with purge set to false to assert that existing tags are retained
+      ec2_asg:
+        name: "{{ resource_prefix }}-asg-tag-test"
+        tags: []
+        purge_tags: false
+      register: add_empty
+
+    - name: Get asg info
+      ec2_asg_info:
+        name: "{{ resource_prefix }}-asg-tag-test"
+      register: info_result
+    # create a list of tag_keys from info result
+    - set_fact:
+        tag_keys: "{{ info_result.results[0].tags | map(attribute='key') | list }}"
+
+    - assert:
+        that:
+          - add_empty is not changed
+          - info_result.results[0].tags | length == 4
+          - '"tag_a" in tag_keys'
+          - '"tag_b" in tag_keys'
+          - '"lowercase spaced" in tag_keys'
+          - '"Title Case" in tag_keys'
+          - '"autoscaling:CreateOrUpdateTags" not in add_empty.resource_actions'
+
+    - name: Add empty tags with purge set to true to assert that existing tags are removed
+      ec2_asg:
+        name: "{{ resource_prefix }}-asg-tag-test"
+        tags: []
+      register: add_empty
+
+    - name: Get asg info
+      ec2_asg_info:
+        name: "{{ resource_prefix }}-asg-tag-test"
+      register: info_result
+    # create a list of tag_keys from info result
+    - set_fact:
+        tag_keys: "{{ info_result.results[0].tags | map(attribute='key') | list }}"
+
+    - assert:
+        that:
+          - add_empty is changed
+          - info_result.results[0].tags | length == 0
+          - '"tag_a" not in tag_keys'
+          - '"tag_b" not in tag_keys'
+          - '"lowercase spaced" not in tag_keys'
+          - '"Title Case" not in tag_keys'
+          - '"autoscaling:CreateOrUpdateTags" not in add_empty.resource_actions'
+          - '"autoscaling:DeleteTags" in add_empty.resource_actions'
+
+    #----------------------------------------------------------------------
+
+  always:
+
+  - name: kill asg created in this test
+    ec2_asg:
+      name: "{{ resource_prefix }}-asg-tag-test"
+      state: absent
+    register: removed
+    until: removed is not failed
+    ignore_errors: yes
+    retries: 10
+
+  - name: remove launch config created in this test
+    ec2_lc:
+      name: "{{ resource_prefix }}-lc-tag-test"
+      state: absent
+    register: removed
+    until: removed is not failed
+    ignore_errors: yes
+    retries: 10