From 3b786acb86223f523099f47858ff84f239d01e3f Mon Sep 17 00:00:00 2001 From: Nathan Webster Date: Tue, 25 Sep 2018 21:39:34 +0100 Subject: EC2_ASG: Enable support for launch_templates (#45647) * Enable support for launch_templates in ec2_asg * Fix asg create with LT and no version number * Update mutually exclusive list * Better function names --- lib/ansible/modules/cloud/amazon/ec2_asg.py | 291 ++++++++++++++++++++++------ 1 file changed, 232 insertions(+), 59 deletions(-) diff --git a/lib/ansible/modules/cloud/amazon/ec2_asg.py b/lib/ansible/modules/cloud/amazon/ec2_asg.py index 2c39286c26..723a9a695f 100644 --- a/lib/ansible/modules/cloud/amazon/ec2_asg.py +++ b/lib/ansible/modules/cloud/amazon/ec2_asg.py @@ -24,7 +24,7 @@ module: ec2_asg short_description: Create or delete AWS Autoscaling Groups description: - Can create or delete AWS Autoscaling Groups - - Works with the ec2_lc module to manage Launch Configurations + - Can be used with the ec2_lc module to manage Launch Configurations version_added: "1.6" author: "Gareth Rushgrove (@garethr)" requirements: [ "boto3", "botocore" ] @@ -51,8 +51,22 @@ options: launch_config_name: description: - Name of the Launch configuration to use for the group. See the ec2_lc module for managing these. - If unspecified then the current group value will be used. - required: true + If unspecified then the current group value will be used. One of launch_config_name or launch_template must be provided. + launch_template: + description: + - Dictionary describing the Launch Template to use + suboptions: + version: + description: + - The version number of the launch template to use. Defaults to latest version if not provided. + default: "latest" + launch_template_name: + description: + - The name of the launch template. Only one of launch_template_name or launch_template_id is required. + launch_template_id: + description: + - The id of the launch template. Only one of launch_template_name or launch_template_id is required. + version_added: "2.8" min_size: description: - Minimum number of instances in group, if unspecified then the current group value will be used. @@ -87,6 +101,11 @@ options: - Check to make sure instances that are being replaced with replace_instances do not already have the current launch_config. version_added: "1.8" default: 'yes' + lt_check: + description: + - Check to make sure instances that are being replaced with replace_instances do not already have the current launch_template or launch_template version. + version_added: "2.8" + default: 'yes' vpc_zone_identifier: description: - List of VPC subnets to use @@ -182,7 +201,7 @@ extends_documentation_fragment: """ EXAMPLES = ''' -# Basic configuration +# Basic configuration with Launch Configuration - ec2_asg: name: special @@ -245,6 +264,26 @@ EXAMPLES = ''' max_size: 5 desired_capacity: 5 region: us-east-1 + +# Basic Configuration with Launch Template + +- ec2_asg: + name: special + load_balancers: [ 'lb1', 'lb2' ] + availability_zones: [ 'eu-west-1a', 'eu-west-1b' ] + launch_template: + version: '1' + launch_template_name: 'lt-example' + launch_template_id: 'lt-123456' + min_size: 1 + max_size: 10 + desired_capacity: 5 + vpc_zone_identifier: [ 'subnet-abcd1234', 'subnet-1a2b3c4d' ] + tags: + - environment: production + propagate_at_launch: no + + ''' RETURN = ''' @@ -476,6 +515,22 @@ def describe_launch_configurations(connection, launch_config_name): return pg.paginate(LaunchConfigurationNames=[launch_config_name]).build_full_result() +@AWSRetry.backoff(**backoff_params) +def describe_launch_templates(connection, launch_template): + if launch_template['launch_template_id'] is not None: + try: + lt = connection.describe_launch_templates(LaunchTemplateIds=[launch_template['launch_template_id']]) + return lt + except (botocore.exceptions.ClientError) as e: + module.fail_json(msg="No launch template found matching: %s" % launch_template) + else: + try: + lt = connection.describe_launch_templates(LaunchTemplateNames=[launch_template['launch_template_name']]) + return lt + except (botocore.exceptions.ClientError) as e: + module.fail_json(msg="No launch template found matching: %s" % launch_template) + + @AWSRetry.backoff(**backoff_params) def create_asg(connection, **params): connection.create_auto_scaling_group(**params) @@ -534,16 +589,18 @@ def terminate_asg_instance(connection, instance_id, decrement_capacity): ShouldDecrementDesiredCapacity=decrement_capacity) -def enforce_required_arguments(): +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 them here ''' missing_args = [] - for arg in ('min_size', 'max_size', 'launch_config_name'): + if module.params.get('launch_config_name') is None and module.params.get('launch_template') is None: + module.fail_json(msg="Missing either launch_config_name or launch_template for autoscaling group create") + for arg in ('min_size', 'max_size'): if module.params[arg] is None: missing_args.append(arg) if missing_args: - module.fail_json(msg="Missing required arguments for autoscaling group create/update: %s" % ",".join(missing_args)) + module.fail_json(msg="Missing required arguments for autoscaling group create: %s" % ",".join(missing_args)) def get_properties(autoscaling_group): @@ -558,11 +615,17 @@ def get_properties(autoscaling_group): instance_facts = dict() autoscaling_group_instances = autoscaling_group.get('Instances') if autoscaling_group_instances: + properties['instances'] = [i['InstanceId'] for i in autoscaling_group_instances] for i in autoscaling_group_instances: - instance_facts[i['InstanceId']] = {'health_status': i['HealthStatus'], - 'lifecycle_state': i['LifecycleState'], - 'launch_config_name': i.get('LaunchConfigurationName')} + if i.get('LaunchConfigurationName'): + instance_facts[i['InstanceId']] = {'health_status': i['HealthStatus'], + 'lifecycle_state': i['LifecycleState'], + 'launch_config_name': i['LaunchConfigurationName']} + else: + instance_facts[i['InstanceId']] = {'health_status': i['HealthStatus'], + 'lifecycle_state': i['LifecycleState'], + 'launch_template': i['LaunchTemplate']} if i['HealthStatus'] == 'Healthy' and i['LifecycleState'] == 'InService': properties['viable_instances'] += 1 if i['HealthStatus'] == 'Healthy': @@ -584,7 +647,10 @@ def get_properties(autoscaling_group): properties['created_time'] = autoscaling_group.get('CreatedTime') properties['instance_facts'] = instance_facts properties['load_balancers'] = autoscaling_group.get('LoadBalancerNames') - properties['launch_config_name'] = autoscaling_group.get('LaunchConfigurationName') + if autoscaling_group.get('LaunchConfigurationName'): + properties['launch_config_name'] = autoscaling_group.get('LaunchConfigurationName') + else: + properties['launch_template'] = autoscaling_group.get('LaunchTemplate') properties['tags'] = autoscaling_group.get('Tags') properties['min_size'] = autoscaling_group.get('MinSize') properties['max_size'] = autoscaling_group.get('MaxSize') @@ -616,6 +682,31 @@ def get_properties(autoscaling_group): return properties +def get_launch_object(connection, ec2_connection): + launch_object = dict() + launch_config_name = module.params.get('launch_config_name') + launch_template = module.params.get('launch_template') + if launch_config_name is None and launch_template is None: + return launch_object + elif launch_config_name: + try: + launch_configs = describe_launch_configurations(connection, launch_config_name) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json(msg="Failed to describe launch configurations", + exception=traceback.format_exc()) + if len(launch_configs['LaunchConfigurations']) == 0: + module.fail_json(msg="No launch config found with name %s" % launch_config_name) + launch_object = {"LaunchConfigurationName": launch_configs['LaunchConfigurations'][0]['LaunchConfigurationName']} + return launch_object + elif launch_template: + lt = describe_launch_templates(ec2_connection, launch_template)['LaunchTemplates'][0] + if launch_template['version'] is not None: + launch_object = {"LaunchTemplate": {"LaunchTemplateId": lt['LaunchTemplateId'], "Version": launch_template['version']}} + else: + launch_object = {"LaunchTemplate": {"LaunchTemplateId": lt['LaunchTemplateId'], "Version": str(lt['LatestVersionNumber'])}} + return launch_object + + def elb_dreg(asg_connection, group_name, instance_id): region, ec2_url, aws_connect_params = get_aws_connection_info(module, boto3=True) as_group = describe_autoscaling_groups(asg_connection, group_name)[0] @@ -807,6 +898,7 @@ def create_autoscaling_group(connection): target_group_arns = module.params['target_group_arns'] availability_zones = module.params['availability_zones'] launch_config_name = module.params.get('launch_config_name') + launch_template = module.params.get('launch_template') min_size = module.params['min_size'] max_size = module.params['max_size'] placement_group = module.params.get('placement_group') @@ -830,15 +922,15 @@ def create_autoscaling_group(connection): module.fail_json(msg="Failed to describe auto scaling groups.", exception=traceback.format_exc()) - if not vpc_zone_identifier and not availability_zones: - region, ec2_url, aws_connect_params = get_aws_connection_info(module, boto3=True) - ec2_connection = boto3_conn(module, - conn_type='client', - resource='ec2', - region=region, - endpoint=ec2_url, - **aws_connect_params) - elif vpc_zone_identifier: + region, ec2_url, aws_connect_params = get_aws_connection_info(module, boto3=True) + ec2_connection = boto3_conn(module, + conn_type='client', + resource='ec2', + region=region, + endpoint=ec2_url, + **aws_connect_params) + + if vpc_zone_identifier: vpc_zone_identifier = ','.join(vpc_zone_identifier) asg_tags = [] @@ -854,19 +946,13 @@ def create_autoscaling_group(connection): if not vpc_zone_identifier and not availability_zones: availability_zones = module.params['availability_zones'] = [zone['ZoneName'] for zone in ec2_connection.describe_availability_zones()['AvailabilityZones']] - enforce_required_arguments() - try: - launch_configs = describe_launch_configurations(connection, launch_config_name) - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - module.fail_json(msg="Failed to describe launch configurations", - exception=traceback.format_exc()) - if len(launch_configs['LaunchConfigurations']) == 0: - module.fail_json(msg="No launch config found with name %s" % launch_config_name) + + enforce_required_arguments_for_create() + if desired_capacity is None: desired_capacity = min_size ag = dict( AutoScalingGroupName=group_name, - LaunchConfigurationName=launch_configs['LaunchConfigurations'][0]['LaunchConfigurationName'], MinSize=min_size, MaxSize=max_size, DesiredCapacity=desired_capacity, @@ -886,6 +972,15 @@ def create_autoscaling_group(connection): if target_group_arns: ag['TargetGroupARNs'] = target_group_arns + launch_object = get_launch_object(connection, ec2_connection) + if 'LaunchConfigurationName' in launch_object: + ag['LaunchConfigurationName'] = launch_object['LaunchConfigurationName'] + elif 'LaunchTemplate' in launch_object: + ag['LaunchTemplate'] = launch_object['LaunchTemplate'] + else: + module.fail_json(msg="Missing LaunchConfigurationName or LaunchTemplate", + exception=traceback.format_exc()) + try: create_asg(connection, **ag) if metrics_collection: @@ -1035,18 +1130,8 @@ def create_autoscaling_group(connection): max_size = as_group['MaxSize'] if desired_capacity is None: desired_capacity = as_group['DesiredCapacity'] - launch_config_name = launch_config_name or as_group['LaunchConfigurationName'] - - try: - launch_configs = describe_launch_configurations(connection, launch_config_name) - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - module.fail_json(msg="Failed to describe launch configurations", - exception=traceback.format_exc()) - if len(launch_configs['LaunchConfigurations']) == 0: - module.fail_json(msg="No launch config found with name %s" % launch_config_name) ag = dict( AutoScalingGroupName=group_name, - LaunchConfigurationName=launch_configs['LaunchConfigurations'][0]['LaunchConfigurationName'], MinSize=min_size, MaxSize=max_size, DesiredCapacity=desired_capacity, @@ -1054,6 +1139,21 @@ def create_autoscaling_group(connection): HealthCheckType=health_check_type, DefaultCooldown=default_cooldown, TerminationPolicies=termination_policies) + + # Get the launch object (config or template) if one is provided in args or use the existing one attached to ASG if not. + launch_object = get_launch_object(connection, ec2_connection) + if 'LaunchConfigurationName' in launch_object: + ag['LaunchConfigurationName'] = launch_object['LaunchConfigurationName'] + elif 'LaunchTemplate' in launch_object: + ag['LaunchTemplate'] = launch_object['LaunchTemplate'] + else: + try: + ag['LaunchConfigurationName'] = as_group['LaunchConfigurationName'] + except: + launch_template = as_group['LaunchTemplate'] + # Prefer LaunchTemplateId over Name as it's more specific. Only one can be used for update_asg. + ag['LaunchTemplate'] = {"LaunchTemplateId": launch_template['LaunchTemplateId'], "Version": launch_template['Version']} + if availability_zones: ag['AvailabilityZones'] = availability_zones if vpc_zone_identifier: @@ -1168,7 +1268,18 @@ def replace(connection): max_size = module.params.get('max_size') min_size = module.params.get('min_size') desired_capacity = module.params.get('desired_capacity') - lc_check = module.params.get('lc_check') + launch_config_name = module.params.get('launch_config_name') + # Required to maintain the default value being set to 'true' + if launch_config_name: + lc_check = module.params.get('lc_check') + else: + lc_check = False + # Mirror above behaviour for Launch Templates + launch_template = module.params.get('launch_template') + if launch_template: + lt_check = module.params.get('lt_check') + else: + lt_check = False replace_instances = module.params.get('replace_instances') replace_all_instances = module.params.get('replace_all_instances') @@ -1185,12 +1296,16 @@ def replace(connection): replace_instances = instances if replace_instances: instances = replace_instances + # check to see if instances are replaceable if checking launch configs + if launch_config_name: + new_instances, old_instances = get_instances_by_launch_config(props, lc_check, instances) + elif launch_template: + new_instances, old_instances = get_instances_by_launch_template(props, lt_check, instances) - new_instances, old_instances = get_instances_by_lc(props, lc_check, instances) num_new_inst_needed = desired_capacity - len(new_instances) - if lc_check: + if lc_check or lt_check: if num_new_inst_needed == 0 and old_instances: module.debug("No new instances needed, but old instances are present. Removing old instances") terminate_batch(connection, old_instances, instances, True) @@ -1247,14 +1362,17 @@ def replace(connection): return(changed, asg_properties) -def get_instances_by_lc(props, lc_check, initial_instances): +def get_instances_by_launch_config(props, lc_check, initial_instances): new_instances = [] old_instances = [] # old instances are those that have the old launch config if lc_check: for i in props['instances']: - if props['instance_facts'][i]['launch_config_name'] == props['launch_config_name']: + # Check if migrating from launch_template to launch_config first + if 'launch_template' in props['instance_facts'][i]: + old_instances.append(i) + elif props['instance_facts'][i]['launch_config_name'] == props['launch_config_name']: new_instances.append(i) else: old_instances.append(i) @@ -1272,20 +1390,60 @@ def get_instances_by_lc(props, lc_check, initial_instances): return new_instances, old_instances -def list_purgeable_instances(props, lc_check, replace_instances, initial_instances): +def get_instances_by_launch_template(props, lt_check, initial_instances): + new_instances = [] + old_instances = [] + # old instances are those that have the old launch template or version of the same launch templatec + if lt_check: + for i in props['instances']: + # Check if migrating from launch_config_name to launch_template_name first + if 'launch_config_name' in props['instance_facts'][i]: + old_instances.append(i) + elif props['instance_facts'][i]['launch_template'] == props['launch_template']: + new_instances.append(i) + else: + old_instances.append(i) + else: + module.debug("Comparing initial instances with current: %s" % initial_instances) + for i in props['instances']: + if i not in initial_instances: + new_instances.append(i) + else: + old_instances.append(i) + module.debug("New instances: %s, %s" % (len(new_instances), new_instances)) + module.debug("Old instances: %s, %s" % (len(old_instances), old_instances)) + + return new_instances, old_instances + + +def list_purgeable_instances(props, lc_check, lt_check, replace_instances, initial_instances): instances_to_terminate = [] instances = (inst_id for inst_id in replace_instances if inst_id in props['instances']) - # check to make sure instances given are actually in the given ASG # and they have a non-current launch config - if lc_check: - for i in instances: - if props['instance_facts'][i]['launch_config_name'] != props['launch_config_name']: - instances_to_terminate.append(i) - else: - for i in instances: - if i in initial_instances: - instances_to_terminate.append(i) + if module.params.get('launch_config_name'): + if lc_check: + for i in instances: + if 'launch_template' in props['instance_facts'][i]: + instances_to_terminate.append(i) + elif props['instance_facts'][i]['launch_config_name'] != props['launch_config_name']: + instances_to_terminate.append(i) + else: + for i in instances: + if i in initial_instances: + instances_to_terminate.append(i) + elif module.params.get('launch_template'): + if lt_check: + for i in instances: + if 'launch_config_name' in props['instance_facts'][i]: + instances_to_terminate.append(i) + elif props['instance_facts'][i]['launch_template'] != props['launch_template']: + instances_to_terminate.append(i) + else: + for i in instances: + if i in initial_instances: + instances_to_terminate.append(i) + return instances_to_terminate @@ -1295,6 +1453,7 @@ def terminate_batch(connection, replace_instances, initial_instances, leftovers= desired_capacity = module.params.get('desired_capacity') group_name = module.params.get('name') lc_check = module.params.get('lc_check') + lt_check = module.params.get('lt_check') decrement_capacity = False break_loop = False @@ -1304,13 +1463,15 @@ def terminate_batch(connection, replace_instances, initial_instances, leftovers= props = get_properties(as_group) desired_size = as_group['MinSize'] - - new_instances, old_instances = get_instances_by_lc(props, lc_check, initial_instances) + if module.params.get('launch_config_name'): + new_instances, old_instances = get_instances_by_launch_config(props, lc_check, initial_instances) + else: + new_instances, old_instances = get_instances_by_launch_template(props, lt_check, initial_instances) num_new_inst_needed = desired_capacity - len(new_instances) # check to make sure instances given are actually in the given ASG # and they have a non-current launch config - instances_to_terminate = list_purgeable_instances(props, lc_check, replace_instances, initial_instances) + instances_to_terminate = list_purgeable_instances(props, lc_check, lt_check, replace_instances, initial_instances) module.debug("new instances needed: %s" % num_new_inst_needed) module.debug("new instances: %s" % new_instances) @@ -1412,6 +1573,14 @@ def main(): target_group_arns=dict(type='list'), availability_zones=dict(type='list'), launch_config_name=dict(type='str'), + launch_template=dict(type='dict', + default=None, + options=dict( + version=dict(type='str'), + launch_template_name=dict(type='str'), + launch_template_id=dict(type='str'), + ), + ), min_size=dict(type='int'), max_size=dict(type='int'), placement_group=dict(type='str'), @@ -1421,6 +1590,7 @@ def main(): replace_all_instances=dict(type='bool', default=False), replace_instances=dict(type='list', default=[]), 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=[]), @@ -1455,7 +1625,9 @@ def main(): global module module = AnsibleModule( argument_spec=argument_spec, - mutually_exclusive=[['replace_all_instances', 'replace_instances']] + mutually_exclusive=[ + ['replace_all_instances', 'replace_instances'], + ['launch_config_name', 'launch_template']] ) if not HAS_BOTO3: @@ -1464,6 +1636,7 @@ def main(): state = module.params.get('state') replace_instances = module.params.get('replace_instances') replace_all_instances = module.params.get('replace_all_instances') + region, ec2_url, aws_connect_params = get_aws_connection_info(module, boto3=True) connection = boto3_conn(module, conn_type='client', @@ -1481,7 +1654,7 @@ def main(): module.exit_json(changed=changed) # Only replace instances if asg existed at start of call - if exists and (replace_all_instances or replace_instances): + if exists and (replace_all_instances or replace_instances) and (module.params.get('launch_config_name') or module.params.get('launch_template')): replace_changed, asg_properties = replace(connection) if create_changed or replace_changed: changed = True -- cgit v1.2.1