diff --git a/moto/cloudformation/models.py b/moto/cloudformation/models.py index d6298906f..d9d09410d 100644 --- a/moto/cloudformation/models.py +++ b/moto/cloudformation/models.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals +from datetime import datetime import json import boto.cloudformation @@ -19,11 +20,14 @@ class FakeStack(object): self.region_name = region_name self.notification_arns = notification_arns if notification_arns else [] self.tags = tags if tags else {} - self.status = 'CREATE_COMPLETE' + self.events = [] + self._add_stack_event("CREATE_IN_PROGRESS", resource_status_reason="User Initiated") self.description = self.template_dict.get('Description') self.resource_map = self._create_resource_map() self.output_map = self._create_output_map() + self._add_stack_event("CREATE_COMPLETE") + self.status = 'CREATE_COMPLETE' def _create_resource_map(self): resource_map = ResourceMap(self.stack_id, self.name, self.parameters, self.tags, self.region_name, self.template_dict) @@ -35,6 +39,32 @@ class FakeStack(object): output_map.create() return output_map + def _add_stack_event(self, resource_status, resource_status_reason=None, resource_properties=None): + self.events.append(FakeEvent( + stack_id=self.stack_id, + stack_name=self.name, + logical_resource_id=self.name, + physical_resource_id=self.stack_id, + resource_type="AWS::CloudFormation::Stack", + resource_status=resource_status, + resource_status_reason=resource_status_reason, + resource_properties=resource_properties, + )) + + def _add_resource_event(self, logical_resource_id, resource_status, resource_status_reason=None, resource_properties=None): + # not used yet... feel free to help yourself + resource = self.resource_map[logical_resource_id] + self.events.append(FakeEvent( + stack_id=self.stack_id, + stack_name=self.name, + logical_resource_id=logical_resource_id, + physical_resource_id=resource.physical_resource_id, + resource_type=resource.type, + resource_status=resource_status, + resource_status_reason=resource_status_reason, + resource_properties=resource_properties, + )) + @property def stack_parameters(self): return self.resource_map.resolved_parameters @@ -48,16 +78,33 @@ class FakeStack(object): return self.output_map.values() def update(self, template): + self._add_stack_event("UPDATE_IN_PROGRESS", resource_status_reason="User Initiated") self.template = template self.resource_map.update(json.loads(template)) self.output_map = self._create_output_map() + self._add_stack_event("UPDATE_COMPLETE") self.status = "UPDATE_COMPLETE" def delete(self): + self._add_stack_event("DELETE_IN_PROGRESS", resource_status_reason="User Initiated") self.resource_map.delete() + self._add_stack_event("DELETE_COMPLETE") self.status = "DELETE_COMPLETE" +class FakeEvent(object): + def __init__(self, stack_id, stack_name, logical_resource_id, physical_resource_id, resource_type, resource_status, resource_status_reason=None, resource_properties=None): + self.stack_id = stack_id + self.stack_name = stack_name + self.logical_resource_id = logical_resource_id + self.physical_resource_id = physical_resource_id + self.resource_type = resource_type + self.resource_status = resource_status + self.resource_status_reason = resource_status_reason + self.resource_properties = resource_properties + self.timestamp = datetime.utcnow() + + class CloudFormationBackend(BaseBackend): def __init__(self): @@ -97,12 +144,15 @@ class CloudFormationBackend(BaseBackend): return self.stacks.values() def get_stack(self, name_or_stack_id): - if name_or_stack_id in self.stacks: - # Lookup by stack id - return self.stacks.get(name_or_stack_id) + all_stacks = dict(self.deleted_stacks, **self.stacks) + if name_or_stack_id in all_stacks: + # Lookup by stack id - deleted stacks incldued + return all_stacks[name_or_stack_id] else: - # Lookup by stack name - return [stack for stack in self.stacks.values() if stack.name == name_or_stack_id][0] + # Lookup by stack name - undeleted stacks only + for stack in self.stacks.values(): + if stack.name == name_or_stack_id: + return stack def update_stack(self, name, template): stack = self.get_stack(name) diff --git a/moto/cloudformation/responses.py b/moto/cloudformation/responses.py index e407be9d2..9cab62a63 100644 --- a/moto/cloudformation/responses.py +++ b/moto/cloudformation/responses.py @@ -91,6 +91,13 @@ class CloudFormationResponse(BaseResponse): template = self.response_template(DESCRIBE_STACK_RESOURCES_RESPONSE) return template.render(stack=stack) + def describe_stack_events(self): + stack_name = self._get_param('StackName') + stack = self.cloudformation_backend.get_stack(stack_name) + + template = self.response_template(DESCRIBE_STACK_EVENTS_RESPONSE) + return template.render(stack=stack) + def list_stacks(self): stacks = self.cloudformation_backend.list_stacks() template = self.response_template(LIST_STACKS_RESPONSE) @@ -269,6 +276,31 @@ DESCRIBE_STACK_RESOURCES_RESPONSE = """ """ +DESCRIBE_STACK_EVENTS_RESPONSE = """ + + + {% for event in stack.events %} + + {{ event.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ') }} + {{ event.resource_status }} + {{ event.stack_id }} + {{ event.event_id }} + {{ event.logical_resource_id }} + {% if event.resource_status_reason %}{{ event.resource_status_reason }}{% endif %} + {{ event.stack_name }} + {{ event.physical_resource_id }} + {% if event.resource_properties %}{{ event.resource_properties }}{% endif %} + {{ event.resource_type }} + + {% endfor %} + + + + b9b4b068-3a41-11e5-94eb-example + +""" + + LIST_STACKS_RESPONSE = """ diff --git a/tests/test_cloudformation/test_cloudformation_stack_crud.py b/tests/test_cloudformation/test_cloudformation_stack_crud.py index bbf65fae6..e45dafbfa 100644 --- a/tests/test_cloudformation/test_cloudformation_stack_crud.py +++ b/tests/test_cloudformation/test_cloudformation_stack_crud.py @@ -354,3 +354,39 @@ def test_update_stack_when_rolled_back(): ex.error_code.should.equal('ValidationError') ex.reason.should.equal('Bad Request') ex.status.should.equal(400) + +@mock_cloudformation +def test_describe_stack_events_shows_create_update_and_delete(): + conn = boto.connect_cloudformation() + stack_id = conn.create_stack("test_stack", template_body=dummy_template_json) + conn.update_stack(stack_id, template_body=dummy_template_json2) + conn.delete_stack(stack_id) + + # assert begins and ends with stack events + events = conn.describe_stack_events(stack_id) + events[0].resource_type.should.equal("AWS::CloudFormation::Stack") + events[-1].resource_type.should.equal("AWS::CloudFormation::Stack") + + # testing ordering of stack events without assuming resource events will not exist + stack_events_to_look_for = iter([ + ("CREATE_IN_PROGRESS", "User Initiated"), ("CREATE_COMPLETE", None), + ("UPDATE_IN_PROGRESS", "User Initiated"), ("UPDATE_COMPLETE", None), + ("DELETE_IN_PROGRESS", "User Initiated"), ("DELETE_COMPLETE", None)]) + try: + for event in events: + event.stack_id.should.equal(stack_id) + event.stack_name.should.equal("test_stack") + + if event.resource_type == "AWS::CloudFormation::Stack": + event.logical_resource_id.should.equal("test_stack") + event.physical_resource_id.should.equal(stack_id) + + status_to_look_for, reason_to_look_for = next(stack_events_to_look_for) + event.resource_status.should.equal(status_to_look_for) + if reason_to_look_for is not None: + event.resource_status_reason.should.equal(reason_to_look_for) + except StopIteration: + assert False, "Too many stack events" + + list(stack_events_to_look_for).should.be.empty + diff --git a/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py b/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py index 0e5d93986..4197d2628 100644 --- a/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py +++ b/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py @@ -312,3 +312,42 @@ def test_stack_tags(): expected_tag_items = set( item for items in [tag.items() for tag in tags] for item in items) observed_tag_items.should.equal(expected_tag_items) + +@mock_cloudformation +def test_stack_events(): + cf = boto3.resource('cloudformation', region_name='us-east-1') + stack = cf.create_stack( + StackName="test_stack", + TemplateBody=dummy_template_json, + ) + stack.update(TemplateBody=dummy_update_template_json) + stack = cf.Stack(stack.stack_id) + stack.delete() + + # assert begins and ends with stack events + events = list(stack.events.all()) + events[0].resource_type.should.equal("AWS::CloudFormation::Stack") + events[-1].resource_type.should.equal("AWS::CloudFormation::Stack") + + # testing ordering of stack events without assuming resource events will not exist + stack_events_to_look_for = iter([ + ("CREATE_IN_PROGRESS", "User Initiated"), ("CREATE_COMPLETE", None), + ("UPDATE_IN_PROGRESS", "User Initiated"), ("UPDATE_COMPLETE", None), + ("DELETE_IN_PROGRESS", "User Initiated"), ("DELETE_COMPLETE", None)]) + try: + for event in events: + event.stack_id.should.equal(stack.stack_id) + event.stack_name.should.equal("test_stack") + + if event.resource_type == "AWS::CloudFormation::Stack": + event.logical_resource_id.should.equal("test_stack") + event.physical_resource_id.should.equal(stack.stack_id) + + status_to_look_for, reason_to_look_for = next(stack_events_to_look_for) + event.resource_status.should.equal(status_to_look_for) + if reason_to_look_for is not None: + event.resource_status_reason.should.equal(reason_to_look_for) + except StopIteration: + assert False, "Too many stack events" + + list(stack_events_to_look_for).should.be.empty