From 01b6f73dc4fed3792f5bcd069d5dd334ebe22b93 Mon Sep 17 00:00:00 2001 From: Mandar Kulkarni Date: Thu, 24 Mar 2022 09:54:00 -0700 Subject: [PATCH] ec2_asg_instance_refresh and ec2_asg_instance_refresh_info modules (#973) ec2_asg_instance_refresh and ec2_asg_instance_refresh_info modules SUMMARY Reviving original PR that adds Autoscaling instance refresh API support as the author has yet not updated PR based on review feedback. Issue: #135 PR being revived: #795 Fixes #135 ISSUE TYPE New Module Pull Request COMPONENT NAME ec2_asg_instance_refresh ec2_asg_instance_refreshes_info ADDITIONAL INFORMATION 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 Reviewed-by: Alina Buzachis Reviewed-by: Mandar Kulkarni Reviewed-by: Mark Woolley Reviewed-by: Jill R Reviewed-by: Joseph Torcasso --- ec2_asg_instance_refresh.py | 267 +++++++++++++++++++++++++++++++ ec2_asg_instance_refresh_info.py | 219 +++++++++++++++++++++++++ 2 files changed, 486 insertions(+) create mode 100644 ec2_asg_instance_refresh.py create mode 100644 ec2_asg_instance_refresh_info.py diff --git a/ec2_asg_instance_refresh.py b/ec2_asg_instance_refresh.py new file mode 100644 index 00000000000..faa61fa74cb --- /dev/null +++ b/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/ec2_asg_instance_refresh_info.py b/ec2_asg_instance_refresh_info.py new file mode 100644 index 00000000000..d4a12380098 --- /dev/null +++ b/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()