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
@@ -220,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.
@@ -645,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',
@@ -1097,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')
@@ -1205,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:
@@ -1218,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)
 
@@ -1838,6 +1852,7 @@ def main():
         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),


Test patch:
diff --git a/tests/integration/targets/ec2_asg/tasks/instance_detach.yml b/tests/integration/targets/ec2_asg/tasks/instance_detach.yml
--- a/tests/integration/targets/ec2_asg/tasks/instance_detach.yml
+++ b/tests/integration/targets/ec2_asg/tasks/instance_detach.yml
@@ -83,7 +83,7 @@
           - '{{ init_instance_2 }}'
 
     # pause to allow completion of instance replacement
-    - name: Pause for 1 minute
+    - name: Pause for 30 seconds
       pause:
         seconds: 30
 
@@ -137,7 +137,7 @@
           - '{{ instance_replace_1 }}'
           - '{{ instance_replace_2 }}'
 
-    - name: Pause for 1 minute to allow completion of above task
+    - name: Pause for 30 seconds to allow completion of above task
       pause:
         seconds: 30
 
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
@@ -97,6 +97,8 @@
             cidr_ip: 0.0.0.0/0
       register: sg
 
+    - include_tasks: tag_operations.yml
+
     - include_tasks: instance_detach.yml
 
     - name: ensure launch configs exist
@@ -136,62 +138,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