From 9619414fc34a455691c2297b6a67b83336a5947c Mon Sep 17 00:00:00 2001 From: Jonathan Sokolowski Date: Wed, 17 Mar 2021 14:38:26 +1100 Subject: [PATCH] Add ec2_asg_tag module A new module to manage ASG tags, analogous to the `ec2_tag` module in `amazon.aws` collection. Fixes #481 --- plugins/modules/ec2_asg_tag.py | 228 ++++++++++++++++++ .../targets/ec2_asg/tasks/main.yml | 86 +++++++ .../unit/plugins/modules/test_ec2_asg_tag.py | 115 +++++++++ 3 files changed, 429 insertions(+) create mode 100644 plugins/modules/ec2_asg_tag.py create mode 100644 tests/unit/plugins/modules/test_ec2_asg_tag.py diff --git a/plugins/modules/ec2_asg_tag.py b/plugins/modules/ec2_asg_tag.py new file mode 100644 index 00000000000..fb767e08a7c --- /dev/null +++ b/plugins/modules/ec2_asg_tag.py @@ -0,0 +1,228 @@ +#!/usr/bin/python +# This file is part of Ansible +# 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 = r''' +--- +module: ec2_asg_tag +version_added: 1.5.0 +short_description: Create and remove tags on AWS AutoScaling Groups (ASGs) +description: + - Creates, modifies and removes tags for AutoScaling Groups +author: "Jonathan Sokolowski (@jsok)" +requirements: [ "boto3", "botocore" ] +options: + name: + description: + - The ASG name. + required: true + type: str + state: + description: + - Whether the tags should be present or absent on the ASG. + default: present + choices: ['present', 'absent'] + type: str + tags: + description: + - A list of tags to add or remove from the ASG. + - If the value provided for a key is not set and I(state=absent), the tag will be removed regardless of its current value. + - Optional key is I(propagate_at_launch), which defaults to true. + type: list + elements: dict + purge_tags: + description: + - Whether unspecified tags should be removed from the resource. + - Note that when combined with I(state=absent), specified tags with non-matching values are not purged. + type: bool + default: false +extends_documentation_fragment: +- amazon.aws.aws +- amazon.aws.ec2 +''' + +EXAMPLES = r''' +- name: Ensure tags are present on an ASG + community.aws.ec2_asg_tag: + name: 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: + name: my-auto-scaling-group + state: absent + tags: + - environment: development +''' + +RETURN = r''' +--- +tags: + description: A list containing the tags on the resource + returned: always + type: list + sample: [ + { + "key": "Name", + "value": "public-webapp-production-1", + "resource_id": "public-webapp-production-1", + "resource_type": "auto-scaling-group", + "propagate_at_launch": "true" + }, + { + "key": "env", + "value": "production", + "resource_id": "public-webapp-production-1", + "resource_type": "auto-scaling-group", + "propagate_at_launch": "true" + } + ] +added_tags: + description: A list of tags that were added to the ASG + returned: If tags were added + type: list +removed_tags: + description: A list of tags that were removed from the ASG + returned: If tags were removed + type: list +''' + +try: + from botocore.exceptions import BotoCoreError, ClientError +except ImportError: + pass # Handled by AnsibleAWSModule + +from ansible.module_utils._text import to_native + +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AWSRetry + +backoff_params = dict(tries=10, delay=3, backoff=1.5) + + +def to_boto3_tag_list(tags, group_name): + tag_list = [] + for tag in tags: + for k, v in tag.items(): + if k == 'propagate_at_launch': + continue + tag_list.append(dict(Key=k, + Value=to_native(v) if v else v, + PropagateAtLaunch=bool(tag.get('propagate_at_launch', True)), + ResourceType='auto-scaling-group', + ResourceId=group_name)) + return tag_list + + +def compare_asg_tags(current_tags_dict, new_tags_dict, purge_tags=True): + """ + Compare two ASG tag dicts. + + :param current_tags_dict: dict of currently defined boto3 tags. + :param new_tags_dict: dict of new boto3 tags to apply. + :param purge_tags: whether to consider tags not in new_tags_dict for removal. + :return: tags_to_set: a dict of boto3 tags to set. If all tags are identical this list will be empty. + :return: tags_keys_to_unset: a list of tag keys to be unset. If no tags need to be unset this list will be empty. + """ + + tags_to_set = {} + tag_keys_to_unset = [] + + for key in current_tags_dict.keys(): + if key not in new_tags_dict and purge_tags: + tag_keys_to_unset.append(key) + + for key in set(new_tags_dict.keys()) - set(tag_keys_to_unset): + if new_tags_dict[key] != current_tags_dict.get(key): + tags_to_set[key] = new_tags_dict[key] + + return tags_to_set, tag_keys_to_unset + + +def tag_list_to_dict(tag_list): + return {tag['Key']: tag for tag in tag_list} + + +def get_tags(autoscaling, module, group_name): + filters = [{'Name': 'auto-scaling-group', 'Values': [group_name]}] + try: + result = AWSRetry.jittered_backoff()(autoscaling.describe_tags)(Filters=filters) + return result['Tags'] + except (BotoCoreError, ClientError) as e: + module.fail_json_aws(e, msg='Failed to fetch tags for ASG {0}'.format(group_name)) + + +def main(): + argument_spec = dict( + name=dict(required=True, type='str'), + tags=dict(type='list', default=[], elements='dict'), + purge_tags=dict(type='bool', default=False), + state=dict(default='present', choices=['present', 'absent']), + ) + + module = AnsibleAWSModule(argument_spec=argument_spec, supports_check_mode=True) + + group_name = module.params['name'] + state = module.params['state'] + tags = module.params['tags'] + purge_tags = module.params['purge_tags'] + + result = {'changed': False} + + autoscaling = module.client('autoscaling') + current_tag_list = get_tags(autoscaling, module, group_name) + new_tag_list = to_boto3_tag_list(tags, group_name) + + # convert to a dict keyed by the tag Key to simplify existence checks + current_tags = tag_list_to_dict(current_tag_list) + new_tags = tag_list_to_dict(new_tag_list) + + add_tags, remove_keys = compare_asg_tags(current_tags, new_tags, purge_tags=purge_tags) + + remove_tags = {} + if state == 'absent': + for key in new_tags: + tag_value = new_tags[key].get('Value') + if key in current_tags: + if tag_value is None or current_tags[key] == new_tags[key]: + remove_tags[key] = current_tags[key] + + for key in remove_keys: + remove_tags[key] = current_tags[key] + + if remove_tags: + remove_tag_list = remove_tags.items() + result['changed'] = True + result['removed_tags'] = remove_tag_list + if not module.check_mode: + try: + AWSRetry.jittered_backoff()(autoscaling.delete_tags)(Tags=remove_tag_list) + except (BotoCoreError, ClientError) as e: + module.fail_json_aws(e, msg='Failed to remove tags {0} from ASG {1}'.format(remove_tag_list, group_name)) + + if state == 'present' and add_tags: + add_tag_list = add_tags.items() + result['changed'] = True + result['added_tags'] = add_tag_list + if not module.check_mode: + try: + AWSRetry.jittered_backoff()(autoscaling.create_or_update_tags)(Tags=add_tag_list) + except (BotoCoreError, ClientError) as e: + module.fail_json_aws(e, msg='Failed to add tags {0} from ASG {1}'.format(add_tag_list, group_name)) + + result['tags'] = get_tags(autoscaling, module, group_name) + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/ec2_asg/tasks/main.yml b/tests/integration/targets/ec2_asg/tasks/main.yml index aa53e9688ea..0ad2dc6185a 100644 --- a/tests/integration/targets/ec2_asg/tasks/main.yml +++ b/tests/integration/targets/ec2_asg/tasks/main.yml @@ -247,6 +247,92 @@ - "output.tags | length == 1" - output is changed + # ============================================================ + + - name: Add some tags to the asg + ec2_asg_tag: + name: "{{ resource_prefix }}-asg" + state: present + tags: + - tag_d: 'value 4' + propagate_at_launch: no + - tag_e: 'value 5' + propagate_at_launch: yes + register: output + + - assert: + that: + - "output.added_tags | length == 2" + - output is changed + + - name: Update propagate_at_launch on existing tag + ec2_asg_tag: + name: "{{ resource_prefix }}-asg" + state: present + tags: + - tag_d: 'value 4' + propagate_at_launch: yes + register: output + + - assert: + that: + - "output.added_tags | length == 1" + - output is changed + + - name: Add an existing tag to the asg + ec2_asg_tag: + name: "{{ resource_prefix }}-asg" + state: present + tags: + - tag_d: 'value 4' + propagate_at_launch: yes + register: output + + - assert: + that: + - output is not changed + + - name: Remove a tag from the asg + ec2_asg_tag: + name: "{{ resource_prefix }}-asg" + state: absent + tags: + - tag_d: 'value 4' + register: output + + - assert: + that: + - "output.removed_tags | length == 1" + - output is changed + + - name: Remove a tag (without specifying the value) from the asg + ec2_asg_tag: + name: "{{ resource_prefix }}-asg" + state: absent + tags: + - tag_e: + register: output + + - assert: + that: + - "output.removed_tags | length == 1" + - output is changed + + - name: Remove a non-existent tag from the asg + ec2_asg_tag: + name: "{{ resource_prefix }}-asg" + state: absent + tags: + - tag_e: + propagate_at_launch: yes + register: output + + - assert: + that: + - output is not changed + + # ============================================================ + - name: Enable metrics collection ec2_asg: name: "{{ resource_prefix }}-asg" diff --git a/tests/unit/plugins/modules/test_ec2_asg_tag.py b/tests/unit/plugins/modules/test_ec2_asg_tag.py new file mode 100644 index 00000000000..e34eb70bafe --- /dev/null +++ b/tests/unit/plugins/modules/test_ec2_asg_tag.py @@ -0,0 +1,115 @@ +# (c) 2021 Red Hat Inc. +# 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 + +import pytest + +from ansible_collections.community.aws.plugins.modules.ec2_asg_tag import ( + compare_asg_tags, + tag_list_to_dict, + to_boto3_tag_list, +) + + +def as_tags(tags): + return tag_list_to_dict(to_boto3_tag_list(tags, 'asg-test')) + + +def test_compare_asg_tags__add_tag(): + current_tags = as_tags([ + {'env': 'production', 'propagate_at_launch': True}, + ]) + new_tags = as_tags([ + {'role': 'web', 'propagate_at_launch': True}, + ]) + + add, remove = compare_asg_tags(current_tags, new_tags, purge_tags=True) + assert len(add) == 1 + assert 'role' in add + assert len(remove) == 1 + assert 'env' in remove + + add, remove = compare_asg_tags(current_tags, new_tags, purge_tags=False) + assert len(add) == 1 + assert 'role' in add + assert len(remove) == 0 + + +def test_compare_asg_tags__existing_tag(): + current_tags = as_tags([ + {'env': 'production', 'propagate_at_launch': True}, + {'role': 'web', 'propagate_at_launch': True}, + ]) + new_tags = as_tags([ + {'role': 'web', 'propagate_at_launch': True}, + ]) + + add, remove = compare_asg_tags(current_tags, new_tags, purge_tags=True) + assert len(add) == 0 + assert len(remove) == 1 + assert 'env' in remove + + add, remove = compare_asg_tags(current_tags, new_tags, purge_tags=False) + assert len(add) == 0 + assert len(remove) == 0 + + +def test_compare_asg_tags__remove_tag(): + current_tags = as_tags([ + {'env': 'production', 'propagate_at_launch': True}, + {'role': 'web', 'propagate_at_launch': True}, + ]) + new_tags = as_tags([]) + + add, remove = compare_asg_tags(current_tags, new_tags, purge_tags=True) + assert len(add) == 0 + assert len(remove) == 2 + assert 'role' in remove + + add, remove = compare_asg_tags(current_tags, new_tags, purge_tags=False) + assert len(add) == 0 + assert len(remove) == 0 + + +def test_compare_asg_tags__empty_value(): + current_tags = as_tags([ + {'env': 'production', 'propagate_at_launch': True}, + {'role': 'web', 'propagate_at_launch': True}, + ]) + new_tags = as_tags([ + {'role': None}, + ]) + + add, remove = compare_asg_tags(current_tags, new_tags, purge_tags=True) + assert len(add) == 1 + assert 'role' in add + assert len(remove) == 1 + assert 'env' in remove + + add, remove = compare_asg_tags(current_tags, new_tags, purge_tags=False) + assert len(add) == 1 + assert 'role' in add + assert len(remove) == 0 + + +def test_compare_asg_tags__update_propagate(): + current_tags = as_tags([ + {'env': 'production', 'propagate_at_launch': True}, + {'role': 'web', 'propagate_at_launch': False}, + ]) + new_tags = as_tags([ + {'role': 'web', 'propagate_at_launch': True}, + ]) + + add, remove = compare_asg_tags(current_tags, new_tags, purge_tags=True) + assert len(add) == 1 + assert 'role' in add + assert len(remove) == 1 + assert 'env' in remove + + add, remove = compare_asg_tags(current_tags, new_tags, purge_tags=False) + assert len(add) == 1 + assert 'role' in add + assert len(remove) == 0