From a600deb96a2391454ac913d8731469e2e2e91235 Mon Sep 17 00:00:00 2001 From: Steve Pulec Date: Thu, 28 Apr 2016 09:21:54 -0400 Subject: [PATCH] Fix merge conflicts and add EC2 Instance delete. Closes #576. --- moto/autoscaling/models.py | 36 ++- moto/cloudformation/exceptions.py | 4 +- moto/cloudformation/models.py | 4 +- moto/cloudformation/parsing.py | 59 ++++- moto/cloudformation/responses.py | 27 +- moto/ec2/models.py | 26 ++ moto/elb/models.py | 49 +++- moto/elb/responses.py | 17 +- moto/route53/models.py | 21 ++ moto/sqs/models.py | 2 +- .../test_cloudformation_stack_crud.py | 23 ++ .../test_cloudformation_stack_integration.py | 243 +++++++++++++++++- 12 files changed, 461 insertions(+), 50 deletions(-) diff --git a/moto/autoscaling/models.py b/moto/autoscaling/models.py index 5a044c038..eef22b397 100644 --- a/moto/autoscaling/models.py +++ b/moto/autoscaling/models.py @@ -3,6 +3,7 @@ from boto.ec2.blockdevicemapping import BlockDeviceType, BlockDeviceMapping from moto.core import BaseBackend from moto.ec2 import ec2_backends from moto.elb import elb_backends +from moto.elb.exceptions import LoadBalancerNotFoundError # http://docs.aws.amazon.com/AutoScaling/latest/DeveloperGuide/AS_Concepts.html#Cooldown DEFAULT_COOLDOWN = 300 @@ -80,6 +81,19 @@ class FakeLaunchConfiguration(object): ) return config + @classmethod + def update_from_cloudformation_json(cls, original_resource, new_resource_name, cloudformation_json, region_name): + cls.delete_from_cloudformation_json(original_resource.name, cloudformation_json, region_name) + return cls.create_from_cloudformation_json(new_resource_name, cloudformation_json, region_name) + + @classmethod + def delete_from_cloudformation_json(cls, resource_name, cloudformation_json, region_name): + backend = autoscaling_backends[region_name] + try: + backend.delete_launch_configuration(resource_name) + except KeyError: + pass + def delete(self, region_name): backend = autoscaling_backends[region_name] backend.delete_launch_configuration(self.name) @@ -171,9 +185,29 @@ class FakeAutoScalingGroup(object): ) return group + @classmethod + def update_from_cloudformation_json(cls, original_resource, new_resource_name, cloudformation_json, region_name): + cls.delete_from_cloudformation_json(original_resource.name, cloudformation_json, region_name) + return cls.create_from_cloudformation_json(new_resource_name, cloudformation_json, region_name) + + @classmethod + def delete_from_cloudformation_json(cls, resource_name, cloudformation_json, region_name): + backend = autoscaling_backends[region_name] + try: + backend.delete_autoscaling_group(resource_name) + except KeyError: + pass + except LoadBalancerNotFoundError: + # sometimes the ELB gets modified before the ASG, so just skip over desired capacity + backend.autoscaling_groups.pop(resource_name, None) + def delete(self, region_name): backend = autoscaling_backends[region_name] - backend.delete_autoscaling_group(self.name) + try: + backend.delete_autoscaling_group(self.name) + except LoadBalancerNotFoundError: + # sometimes the ELB gets deleted before the ASG, so just skip over desired capacity + backend.autoscaling_groups.pop(self.name, None) @property def physical_resource_id(self): diff --git a/moto/cloudformation/exceptions.py b/moto/cloudformation/exceptions.py index 1f480e321..8e34b84a8 100644 --- a/moto/cloudformation/exceptions.py +++ b/moto/cloudformation/exceptions.py @@ -14,7 +14,7 @@ class ValidationError(BadRequest): super(ValidationError, self).__init__() self.description = template.render( code="ValidationError", - messgae="Stack:{0} does not exist".format(name_or_id), + message="Stack:{0} does not exist".format(name_or_id), ) @@ -24,7 +24,7 @@ class MissingParameterError(BadRequest): super(MissingParameterError, self).__init__() self.description = template.render( code="Missing Parameter", - messgae="Missing parameter {0}".format(parameter_name), + message="Missing parameter {0}".format(parameter_name), ) diff --git a/moto/cloudformation/models.py b/moto/cloudformation/models.py index 0002e8ae5..d6298906f 100644 --- a/moto/cloudformation/models.py +++ b/moto/cloudformation/models.py @@ -51,11 +51,11 @@ class FakeStack(object): self.template = template self.resource_map.update(json.loads(template)) self.output_map = self._create_output_map() - self.status = 'UPDATE_COMPLETE' + self.status = "UPDATE_COMPLETE" def delete(self): self.resource_map.delete() - self.status = 'DELETE_COMPLETE' + self.status = "DELETE_COMPLETE" class CloudFormationBackend(BaseBackend): diff --git a/moto/cloudformation/parsing.py b/moto/cloudformation/parsing.py index 8f25d406e..fb631278a 100644 --- a/moto/cloudformation/parsing.py +++ b/moto/cloudformation/parsing.py @@ -115,7 +115,7 @@ def clean_json(resource_json, resources_map): return result if 'Fn::GetAtt' in resource_json: - resource = resources_map[resource_json['Fn::GetAtt'][0]] + resource = resources_map.get(resource_json['Fn::GetAtt'][0]) if resource is None: return resource_json try: @@ -208,15 +208,22 @@ def parse_and_create_resource(logical_id, resource_json, resources_map, region_n def parse_and_update_resource(logical_id, resource_json, resources_map, region_name): - resource_class, resource_json, resource_name = parse_resource(logical_id, resource_json, resources_map) - resource = resource_class.update_from_cloudformation_json(resource_name, resource_json, region_name) - return resource + resource_class, new_resource_json, new_resource_name = parse_resource(logical_id, resource_json, resources_map) + original_resource = resources_map[logical_id] + new_resource = resource_class.update_from_cloudformation_json( + original_resource=original_resource, + new_resource_name=new_resource_name, + cloudformation_json=new_resource_json, + region_name=region_name + ) + new_resource.type = resource_json['Type'] + new_resource.logical_resource_id = logical_id + return new_resource def parse_and_delete_resource(logical_id, resource_json, resources_map, region_name): resource_class, resource_json, resource_name = parse_resource(logical_id, resource_json, resources_map) resource_class.delete_from_cloudformation_json(resource_name, resource_json, region_name) - return None def parse_condition(condition, resources_map, condition_map): @@ -289,6 +296,8 @@ class ResourceMap(collections.Mapping): return self._parsed_resources[resource_logical_id] else: resource_json = self._resource_json_map.get(resource_logical_id) + if not resource_json: + raise KeyError(resource_logical_id) new_resource = parse_and_create_resource(resource_logical_id, resource_json, self, self._region_name) self._parsed_resources[resource_logical_id] = new_resource return new_resource @@ -369,18 +378,40 @@ class ResourceMap(collections.Mapping): parse_and_delete_resource(resource_name, resource_json, self, self._region_name) self._parsed_resources.pop(resource_name) - for resource_name in new_template: - if resource_name in old_template and new_template[resource_name] != old_template[resource_name]: + resources_to_update = set(name for name in new_template if name in old_template and new_template[name] != old_template[name]) + tries = 1 + while resources_to_update and tries < 5: + for resource_name in resources_to_update.copy(): resource_json = new_template[resource_name] - - changed_resource = parse_and_update_resource(resource_name, resource_json, self, self._region_name) - self._parsed_resources[resource_name] = changed_resource + try: + changed_resource = parse_and_update_resource(resource_name, resource_json, self, self._region_name) + except Exception as e: + # skip over dependency violations, and try again in a second pass + last_exception = e + else: + self._parsed_resources[resource_name] = changed_resource + resources_to_update.remove(resource_name) + tries += 1 + if tries == 5: + raise last_exception def delete(self): - for resource in self.resources: - parsed_resource = self._parsed_resources.pop(resource) - if parsed_resource and hasattr(parsed_resource, 'delete'): - parsed_resource.delete(self._region_name) + remaining_resources = set(self.resources) + tries = 1 + while remaining_resources and tries < 5: + for resource in remaining_resources.copy(): + parsed_resource = self._parsed_resources.get(resource) + if parsed_resource: + try: + parsed_resource.delete(self._region_name) + except Exception as e: + # skip over dependency violations, and try again in a second pass + last_exception = e + else: + remaining_resources.remove(resource) + tries += 1 + if tries == 5: + raise last_exception class OutputMap(collections.Mapping): diff --git a/moto/cloudformation/responses.py b/moto/cloudformation/responses.py index 4cdec2afc..711d6f82e 100644 --- a/moto/cloudformation/responses.py +++ b/moto/cloudformation/responses.py @@ -79,8 +79,8 @@ class CloudFormationResponse(BaseResponse): resource = stack_resource break else: - raise ValidationError(logical_resource_id) - + raise ValidationError(logical_resource_id) + template = self.response_template(DESCRIBE_STACK_RESOURCE_RESPONSE_TEMPLATE) return template.render(stack=stack, resource=resource) @@ -106,7 +106,7 @@ class CloudFormationResponse(BaseResponse): def get_template(self): name_or_stack_id = self.querystring.get('StackName')[0] stack = self.cloudformation_backend.get_stack(name_or_stack_id) - + if self.request_json: return json.dumps({ "GetTemplateResponse": { @@ -124,21 +124,24 @@ class CloudFormationResponse(BaseResponse): def update_stack(self): stack_name = self._get_param('StackName') - stack_body = self._get_param('TemplateBody') + if self._get_param('UsePreviousTemplate') == "true": + stack_body = self.cloudformation_backend.get_stack(stack_name).template + else: + stack_body = self._get_param('TemplateBody') stack = self.cloudformation_backend.update_stack( name=stack_name, template=stack_body, ) - if self.request_json: - return json.dumps({ + stack_body = { 'UpdateStackResponse': { 'UpdateStackResult': { 'StackId': stack.name, } } - }) + } + return json.dumps(stack_body) else: template = self.response_template(UPDATE_STACK_RESPONSE_TEMPLATE) return template.render(stack=stack) @@ -310,6 +313,16 @@ GET_TEMPLATE_RESPONSE_TEMPLATE = """ """ +UPDATE_STACK_RESPONSE_TEMPLATE = """ + + {{ stack.stack_id }} + + + b9b4b068-3a41-11e5-94eb-example + + +""" + DELETE_STACK_RESPONSE_TEMPLATE = """ 5ccc7dcd-744c-11e5-be70-example diff --git a/moto/ec2/models.py b/moto/ec2/models.py index b8709f374..7ad52d71c 100644 --- a/moto/ec2/models.py +++ b/moto/ec2/models.py @@ -434,6 +434,9 @@ class Instance(BotoInstance, TaggedEC2Resource): self._state_reason = StateReason("Client.UserInitiatedShutdown: User initiated shutdown", "Client.UserInitiatedShutdown") + def delete(self, region): + self.terminate() + def terminate(self, *args, **kwargs): for nic in self.nics.values(): nic.stop() @@ -1135,6 +1138,29 @@ class SecurityGroup(TaggedEC2Resource): return security_group + @classmethod + def update_from_cloudformation_json(cls, original_resource, new_resource_name, cloudformation_json, region_name): + cls._delete_security_group_given_vpc_id(original_resource.name, original_resource.vpc_id, region_name) + return cls.create_from_cloudformation_json(new_resource_name, cloudformation_json, region_name) + + @classmethod + def delete_from_cloudformation_json(cls, resource_name, cloudformation_json, region_name): + properties = cloudformation_json['Properties'] + vpc_id = properties.get('VpcId') + cls._delete_security_group_given_vpc_id(resource_name, vpc_id, region_name) + + @classmethod + def _delete_security_group_given_vpc_id(cls, resource_name, vpc_id, region_name): + ec2_backend = ec2_backends[region_name] + security_group = ec2_backend.get_security_group_from_name(resource_name, vpc_id) + if security_group: + security_group.delete(region_name) + + def delete(self, region_name): + ''' Not exposed as part of the ELB API - used for CloudFormation. ''' + backend = ec2_backends[region_name] + self.ec2_backend.delete_security_group(group_id=self.id) + @property def physical_resource_id(self): return self.id diff --git a/moto/elb/models.py b/moto/elb/models.py index 43b210b6f..d3cb802c7 100644 --- a/moto/elb/models.py +++ b/moto/elb/models.py @@ -64,20 +64,21 @@ class FakeLoadBalancer(object): self.subnets = subnets or [] self.vpc_id = vpc_id or 'vpc-56e10e3d' self.tags = {} + self.dns_name = "tests.us-east-1.elb.amazonaws.com" for port in ports: listener = FakeListener( - protocol=port['protocol'], - load_balancer_port=port['load_balancer_port'], - instance_port=port['instance_port'], - ssl_certificate_id=port.get('sslcertificate_id'), + protocol=(port.get('protocol') or port['Protocol']), + load_balancer_port=(port.get('load_balancer_port') or port['LoadBalancerPort']), + instance_port=(port.get('instance_port') or port['InstancePort']), + ssl_certificate_id=port.get('sslcertificate_id', port.get('SSLCertificateId')), ) self.listeners.append(listener) # it is unclear per the AWS documentation as to when or how backend # information gets set, so let's guess and set it here *shrug* backend = FakeBackend( - instance_port=port['instance_port'], + instance_port=(port.get('instance_port') or port['InstancePort']), ) self.backends.append(backend) @@ -88,15 +89,41 @@ class FakeLoadBalancer(object): elb_backend = elb_backends[region_name] new_elb = elb_backend.create_load_balancer( name=properties.get('LoadBalancerName', resource_name), - zones=properties.get('AvailabilityZones'), - ports=[], + zones=properties.get('AvailabilityZones', []), + ports=properties['Listeners'], + scheme=properties.get('Scheme', 'internet-facing'), ) - instance_ids = cloudformation_json.get('Instances', []) + instance_ids = properties.get('Instances', []) for instance_id in instance_ids: elb_backend.register_instances(new_elb.name, [instance_id]) + + health_check = properties.get('HealthCheck') + if health_check: + elb_backend.configure_health_check( + load_balancer_name=new_elb.name, + timeout=health_check['Timeout'], + healthy_threshold=health_check['HealthyThreshold'], + unhealthy_threshold=health_check['UnhealthyThreshold'], + interval=health_check['Interval'], + target=health_check['Target'], + ) + return new_elb + @classmethod + def update_from_cloudformation_json(cls, original_resource, new_resource_name, cloudformation_json, region_name): + cls.delete_from_cloudformation_json(original_resource.name, cloudformation_json, region_name) + return cls.create_from_cloudformation_json(new_resource_name, cloudformation_json, region_name) + + @classmethod + def delete_from_cloudformation_json(cls, resource_name, cloudformation_json, region_name): + elb_backend = elb_backends[region_name] + try: + elb_backend.delete_load_balancer(resource_name) + except KeyError: + pass + @property def physical_resource_id(self): return self.name @@ -108,7 +135,7 @@ class FakeLoadBalancer(object): elif attribute_name == 'CanonicalHostedZoneNameID': raise NotImplementedError('"Fn::GetAtt" : [ "{0}" , "CanonicalHostedZoneNameID" ]"') elif attribute_name == 'DNSName': - raise NotImplementedError('"Fn::GetAtt" : [ "{0}" , "DNSName" ]"') + return self.dns_name elif attribute_name == 'SourceSecurityGroup.GroupName': raise NotImplementedError('"Fn::GetAtt" : [ "{0}" , "SourceSecurityGroup.GroupName" ]"') elif attribute_name == 'SourceSecurityGroup.OwnerAlias': @@ -149,6 +176,10 @@ class FakeLoadBalancer(object): if key in self.tags: del self.tags[key] + def delete(self, region): + ''' Not exposed as part of the ELB API - used for CloudFormation. ''' + elb_backends[region].delete_load_balancer(self.name) + class ELBBackend(BaseBackend): diff --git a/moto/elb/responses.py b/moto/elb/responses.py index 52c649ab3..f5e2393cd 100644 --- a/moto/elb/responses.py +++ b/moto/elb/responses.py @@ -12,8 +12,7 @@ from boto.ec2.elb.policies import ( from moto.core.responses import BaseResponse from .models import elb_backends -from .exceptions import DuplicateTagKeysError, LoadBalancerNotFoundError, \ - TooManyTagsError +from .exceptions import DuplicateTagKeysError, LoadBalancerNotFoundError class ELBResponse(BaseResponse): @@ -29,16 +28,16 @@ class ELBResponse(BaseResponse): scheme = self._get_param('Scheme') subnets = self._get_multi_param("Subnets.member") - elb = self.elb_backend.create_load_balancer( + load_balancer = self.elb_backend.create_load_balancer( name=load_balancer_name, zones=availability_zones, ports=ports, scheme=scheme, subnets=subnets, ) - self._add_tags(elb) + self._add_tags(load_balancer) template = self.response_template(CREATE_LOAD_BALANCER_TEMPLATE) - return template.render() + return template.render(load_balancer=load_balancer) def create_load_balancer_listeners(self): load_balancer_name = self._get_param('LoadBalancerName') @@ -244,7 +243,7 @@ class ELBResponse(BaseResponse): load_balancer_name = self._get_param('LoadBalancerNames.member.{0}'.format(number)) elb = self.elb_backend.get_load_balancer(load_balancer_name) if not elb: - raise LoadBalancerNotFound(load_balancer_name) + raise LoadBalancerNotFoundError(load_balancer_name) key = 'Tag.member.{0}.Key'.format(number) for t_key, t_val in self.querystring.items(): @@ -263,7 +262,7 @@ class ELBResponse(BaseResponse): load_balancer_name = self._get_param('LoadBalancerNames.member.{0}'.format(number)) elb = self.elb_backend.get_load_balancer(load_balancer_name) if not elb: - raise LoadBalancerNotFound(load_balancer_name) + raise LoadBalancerNotFoundError(load_balancer_name) elbs.append(elb) template = self.response_template(DESCRIBE_TAGS_TEMPLATE) @@ -334,7 +333,7 @@ DESCRIBE_TAGS_TEMPLATE = """ - tests.us-east-1.elb.amazonaws.com + {{ load_balancer.dns_name }} 1549581b-12b7-11e3-895e-1334aEXAMPLE @@ -442,7 +441,7 @@ DESCRIBE_LOAD_BALANCERS_TEMPLATE = """ {{ record_set.name }} diff --git a/moto/sqs/models.py b/moto/sqs/models.py index 07699ecbc..4e9a3678f 100644 --- a/moto/sqs/models.py +++ b/moto/sqs/models.py @@ -137,7 +137,7 @@ class Queue(object): ) @classmethod - def update_from_cloudformation_json(cls, resource_name, cloudformation_json, region_name): + def update_from_cloudformation_json(cls, original_resource, new_resource_name, cloudformation_json, region_name): properties = cloudformation_json['Properties'] queue_name = properties['QueueName'] diff --git a/tests/test_cloudformation/test_cloudformation_stack_crud.py b/tests/test_cloudformation/test_cloudformation_stack_crud.py index 040ed4d50..26084c7b4 100644 --- a/tests/test_cloudformation/test_cloudformation_stack_crud.py +++ b/tests/test_cloudformation/test_cloudformation_stack_crud.py @@ -274,6 +274,7 @@ def test_update_stack(): conn.update_stack("test_stack", dummy_template_json2) stack = conn.describe_stacks()[0] + stack.stack_status.should.equal("UPDATE_COMPLETE") stack.get_template().should.equal({ 'GetTemplateResponse': { 'GetTemplateResult': { @@ -284,3 +285,25 @@ def test_update_stack(): } } }) + +@mock_cloudformation +def test_update_stack(): + conn = boto.connect_cloudformation() + conn.create_stack( + "test_stack", + template_body=dummy_template_json, + ) + conn.update_stack("test_stack", use_previous_template=True) + + stack = conn.describe_stacks()[0] + stack.stack_status.should.equal("UPDATE_COMPLETE") + stack.get_template().should.equal({ + 'GetTemplateResponse': { + 'GetTemplateResult': { + 'TemplateBody': dummy_template_json, + 'ResponseMetadata': { + 'RequestId': '2d06e36c-ac1d-11e0-a958-f9382b6eb86bEXAMPLE' + } + } + } + }) diff --git a/tests/test_cloudformation/test_cloudformation_stack_integration.py b/tests/test_cloudformation/test_cloudformation_stack_integration.py index 609ab4a21..07fb93292 100644 --- a/tests/test_cloudformation/test_cloudformation_stack_integration.py +++ b/tests/test_cloudformation/test_cloudformation_stack_integration.py @@ -262,10 +262,17 @@ def test_stack_elb_integration_with_attached_ec2_instances(): "Resources": { "MyELB": { "Type": "AWS::ElasticLoadBalancing::LoadBalancer", - "Instances": [{"Ref": "Ec2Instance1"}], "Properties": { + "Instances": [{"Ref": "Ec2Instance1"}], "LoadBalancerName": "test-elb", - "AvailabilityZones": ['us-east1'], + "AvailabilityZones": ['us-east-1'], + "Listeners": [ + { + "InstancePort": "80", + "LoadBalancerPort": "80", + "Protocol": "HTTP", + } + ], } }, "Ec2Instance1": { @@ -293,7 +300,99 @@ def test_stack_elb_integration_with_attached_ec2_instances(): ec2_instance = reservation.instances[0] load_balancer.instances[0].id.should.equal(ec2_instance.id) - list(load_balancer.availability_zones).should.equal(['us-east1']) + list(load_balancer.availability_zones).should.equal(['us-east-1']) + + +@mock_elb() +@mock_cloudformation() +def test_stack_elb_integration_with_health_check(): + elb_template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Resources": { + "MyELB": { + "Type": "AWS::ElasticLoadBalancing::LoadBalancer", + "Properties": { + "LoadBalancerName": "test-elb", + "AvailabilityZones": ['us-west-1'], + "HealthCheck": { + "HealthyThreshold": "3", + "Interval": "5", + "Target": "HTTP:80/healthcheck", + "Timeout": "4", + "UnhealthyThreshold": "2", + }, + "Listeners": [ + { + "InstancePort": "80", + "LoadBalancerPort": "80", + "Protocol": "HTTP", + } + ], + } + }, + }, + } + elb_template_json = json.dumps(elb_template) + + conn = boto.cloudformation.connect_to_region("us-west-1") + conn.create_stack( + "elb_stack", + template_body=elb_template_json, + ) + + elb_conn = boto.ec2.elb.connect_to_region("us-west-1") + load_balancer = elb_conn.get_all_load_balancers()[0] + health_check = load_balancer.health_check + + health_check.healthy_threshold.should.equal(3) + health_check.interval.should.equal(5) + health_check.target.should.equal("HTTP:80/healthcheck") + health_check.timeout.should.equal(4) + health_check.unhealthy_threshold.should.equal(2) + + +@mock_elb() +@mock_cloudformation() +def test_stack_elb_integration_with_update(): + elb_template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Resources": { + "MyELB": { + "Type": "AWS::ElasticLoadBalancing::LoadBalancer", + "Properties": { + "LoadBalancerName": "test-elb", + "AvailabilityZones": ['us-west-1a'], + "Listeners": [ + { + "InstancePort": "80", + "LoadBalancerPort": "80", + "Protocol": "HTTP", + } + ], + } + }, + }, + } + elb_template_json = json.dumps(elb_template) + + conn = boto.cloudformation.connect_to_region("us-west-1") + conn.create_stack( + "elb_stack", + template_body=elb_template_json, + ) + + elb_conn = boto.ec2.elb.connect_to_region("us-west-1") + load_balancer = elb_conn.get_all_load_balancers()[0] + load_balancer.availability_zones[0].should.equal('us-west-1a') + + elb_template['Resources']['MyELB']['Properties']['AvailabilityZones'] = ['us-west-1b'] + elb_template_json = json.dumps(elb_template) + conn.update_stack( + "elb_stack", + template_body=elb_template_json, + ) + load_balancer = elb_conn.get_all_load_balancers()[0] + load_balancer.availability_zones[0].should.equal('us-west-1b') @mock_ec2() @@ -451,11 +550,11 @@ def test_autoscaling_group_with_elb(): "Listeners": [{ "LoadBalancerPort": "80", "InstancePort": "80", - "Protocol": "HTTP" + "Protocol": "HTTP", }], "LoadBalancerName": "my-elb", "HealthCheck": { - "Target": "80", + "Target": "HTTP:80", "HealthyThreshold": "3", "UnhealthyThreshold": "5", "Interval": "30", @@ -498,6 +597,55 @@ def test_autoscaling_group_with_elb(): elb_resource.physical_resource_id.should.contain("my-elb") +@mock_autoscaling() +@mock_cloudformation() +def test_autoscaling_group_update(): + asg_template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Resources": { + "my-as-group": { + "Type": "AWS::AutoScaling::AutoScalingGroup", + "Properties": { + "AvailabilityZones": ['us-west-1'], + "LaunchConfigurationName": {"Ref": "my-launch-config"}, + "MinSize": "2", + "MaxSize": "2", + }, + }, + + "my-launch-config": { + "Type": "AWS::AutoScaling::LaunchConfiguration", + "Properties": { + "ImageId": "ami-1234abcd", + "UserData": "some user data", + } + }, + }, + } + asg_template_json = json.dumps(asg_template) + + conn = boto.cloudformation.connect_to_region("us-west-1") + conn.create_stack( + "asg_stack", + template_body=asg_template_json, + ) + + autoscale_conn = boto.ec2.autoscale.connect_to_region("us-west-1") + asg = autoscale_conn.get_all_groups()[0] + asg.min_size.should.equal(2) + asg.max_size.should.equal(2) + + asg_template['Resources']['my-as-group']['Properties']['MaxSize'] = 3 + asg_template_json = json.dumps(asg_template) + conn.update_stack( + "asg_stack", + template_body=asg_template_json, + ) + asg = autoscale_conn.get_all_groups()[0] + asg.min_size.should.equal(2) + asg.max_size.should.equal(3) + + @mock_ec2() @mock_cloudformation() def test_vpc_single_instance_in_subnet(): @@ -1072,6 +1220,46 @@ def test_route53_associate_health_check(): record_set.health_check.should.equal(health_check_id) +@mock_cloudformation() +@mock_route53() +def test_route53_with_update(): + route53_conn = boto.connect_route53() + + template_json = json.dumps(route53_health_check.template) + cf_conn = boto.cloudformation.connect_to_region("us-west-1") + cf_conn.create_stack( + "test_stack", + template_body=template_json, + ) + + zones = route53_conn.get_all_hosted_zones()['ListHostedZonesResponse']['HostedZones'] + list(zones).should.have.length_of(1) + zone_id = zones[0]['Id'] + + rrsets = route53_conn.get_all_rrsets(zone_id) + rrsets.should.have.length_of(1) + + record_set = rrsets[0] + record_set.resource_records.should.equal(["my.example.com"]) + + route53_health_check.template['Resources']['myDNSRecord']['Properties']['ResourceRecords'] = ["my_other.example.com"] + template_json = json.dumps(route53_health_check.template) + cf_conn.update_stack( + "test_stack", + template_body=template_json, + ) + + zones = route53_conn.get_all_hosted_zones()['ListHostedZonesResponse']['HostedZones'] + list(zones).should.have.length_of(1) + zone_id = zones[0]['Id'] + + rrsets = route53_conn.get_all_rrsets(zone_id) + rrsets.should.have.length_of(1) + + record_set = rrsets[0] + record_set.resource_records.should.equal(["my_other.example.com"]) + + @mock_cloudformation() @mock_sns() def test_sns_topic(): @@ -1382,6 +1570,51 @@ def test_security_group_ingress_separate_from_security_group_by_id_using_vpc(): security_group1.rules[0].to_port.should.equal('8080') +@mock_cloudformation +@mock_ec2 +def test_security_group_with_update(): + vpc_conn = boto.vpc.connect_to_region("us-west-1") + vpc1 = vpc_conn.create_vpc("10.0.0.0/16") + + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Resources": { + "test-security-group": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "test security group", + "VpcId": vpc1.id, + "Tags": [ + { + "Key": "sg-name", + "Value": "sg" + } + ] + }, + }, + } + } + + template_json = json.dumps(template) + cf_conn = boto.cloudformation.connect_to_region("us-west-1") + cf_conn.create_stack( + "test_stack", + template_body=template_json, + ) + security_group = vpc_conn.get_all_security_groups(filters={"tag:sg-name": "sg"})[0] + security_group.vpc_id.should.equal(vpc1.id) + + vpc2 = vpc_conn.create_vpc("10.1.0.0/16") + template['Resources']['test-security-group']['Properties']['VpcId'] = vpc2.id + template_json = json.dumps(template) + cf_conn.update_stack( + "test_stack", + template_body=template_json, + ) + security_group = vpc_conn.get_all_security_groups(filters={"tag:sg-name": "sg"})[0] + security_group.vpc_id.should.equal(vpc2.id) + + @mock_cloudformation @mock_ec2 def test_subnets_should_be_created_with_availability_zone():