Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ec2_asg_tag module #482

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions meta/runtime.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ action_groups:
- ec2_asg
- ec2_asg_info
- ec2_asg_lifecycle_hook
- ec2_asg_tag
- ec2_customer_gateway
- ec2_customer_gateway_info
- ec2_eip
Expand Down
237 changes: 237 additions & 0 deletions plugins/modules/ec2_asg_tag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
#!/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
jsok marked this conversation as resolved.
Show resolved Hide resolved
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

- name: Remove all tags except Name from an ASG
community.aws.ec2_asg_tag:
name: my-auto-scaling-group
state: absent
tags:
- Name: ''
purge_tags: true
'''

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


def to_boto3_tag_list(tags, group_name):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please import and use the ansible_dict_to_boto3_tag_list function from the module utility?

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):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please import and use the compare_aws_tags function from the module utility?
https://github.com/ansible-collections/amazon.aws/blob/main/plugins/module_utils/ec2.py#L783

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jillr this and the other requested changes weren't possible based on my testing.

ASG tags aren't just K/V pairs (as expected by compare_aws_tags, boto3_tag_list_to_ansible_dict and ansible_dict_to_boto3_tag_list), they're a list of dicts instead. The ramification here is that if propagate_at_launch is changed but the K/V doesn't, the module won't detect any tag changes which I believe is not the desired behaviour.

Another issue I ran into was that the to_text and to_native functions did some odd things, particularly around None values. Hence why compare_asg_tags doesn't use them like compare_aws_tags.
From memory you can't roundtrip None like to_native(to_text(None))... or maybe it was the other way around? 🤔

"""
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):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tags = {}
for tag in tag_list:
tags[tag['Key']] = tag
return tags


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()
118 changes: 118 additions & 0 deletions tests/integration/targets/ec2_asg/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,124 @@
- "output.tags | length == 1"
- output is changed

# ============================================================

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please add some tests for adding and removing tags in check_mode?

- 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: Add some tags to the asg
ec2_asg_tag:
name: "{{ resource_prefix }}-asg"
state: present
tags:
- tag_f: 'value 6'
propagate_at_launch: no
- tag_g: 'value 7'
propagate_at_launch: yes
- tag_z: 'value 24'
propagate_at_launch: yes
register: output

- assert:
that:
- "output.added_tags | length == 3"
- output is changed

- name: Remove all tags except tag_z
ec2_asg_tag:
name: "{{ resource_prefix }}-asg"
state: absent
tags:
- tag_z: ''
purge_tags: true
register: output

- assert:
that:
- "output.tags | length == 1"
- output is changed

# ============================================================

- name: Enable metrics collection
ec2_asg:
name: "{{ resource_prefix }}-asg"
Expand Down
Loading