From a0651ccde556a441281b1a902ec994b1faf8f1bd Mon Sep 17 00:00:00 2001 From: Jessie Nadler Date: Fri, 2 Jun 2017 16:18:52 -0400 Subject: [PATCH 1/6] Add exports to CloudFormationBackend --- moto/cloudformation/models.py | 10 +++++++++- moto/cloudformation/parsing.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/moto/cloudformation/models.py b/moto/cloudformation/models.py index 0dc262b2..4a033b6d 100644 --- a/moto/cloudformation/models.py +++ b/moto/cloudformation/models.py @@ -42,7 +42,7 @@ class FakeStack(BaseModel): return resource_map def _create_output_map(self): - output_map = OutputMap(self.resource_map, self.template_dict) + output_map = OutputMap(self.resource_map, self.template_dict, self.stack_id) output_map.create() return output_map @@ -90,6 +90,10 @@ class FakeStack(BaseModel): def stack_outputs(self): return self.output_map.values() + @property + def exports(self): + return self.output_map.exports + def update(self, template, role_arn=None, parameters=None, tags=None): self._add_stack_event("UPDATE_IN_PROGRESS", resource_status_reason="User Initiated") self.template = template @@ -131,6 +135,7 @@ class CloudFormationBackend(BaseBackend): def __init__(self): self.stacks = OrderedDict() self.deleted_stacks = {} + self.exports = OrderedDict() def create_stack(self, name, template, parameters, region_name, notification_arns=None, tags=None, role_arn=None): stack_id = generate_stack_id(name) @@ -145,6 +150,8 @@ class CloudFormationBackend(BaseBackend): role_arn=role_arn, ) self.stacks[stack_id] = new_stack + for export in new_stack.exports: + self.exports[export.name] = export return new_stack def describe_stacks(self, name_or_stack_id): @@ -191,6 +198,7 @@ class CloudFormationBackend(BaseBackend): stack = self.stacks.pop(name_or_stack_id, None) stack.delete() self.deleted_stacks[stack.stack_id] = stack + [self.exports.pop(export.name) for export in stack.exports] return self.stacks.pop(name_or_stack_id, None) else: # Delete by stack name diff --git a/moto/cloudformation/parsing.py b/moto/cloudformation/parsing.py index 6d38289c..248ecc57 100644 --- a/moto/cloudformation/parsing.py +++ b/moto/cloudformation/parsing.py @@ -454,8 +454,9 @@ class ResourceMap(collections.Mapping): class OutputMap(collections.Mapping): - def __init__(self, resources, template): + def __init__(self, resources, template, stack_id): self._template = template + self._stack_id = stack_id self._output_json_map = template.get('Outputs') # Create the default resources @@ -484,6 +485,35 @@ class OutputMap(collections.Mapping): def outputs(self): return self._output_json_map.keys() if self._output_json_map else [] + @property + def exports(self): + exports = [] + if self.outputs: + for key, value in self._output_json_map.iteritems(): + if value.get('Export'): + exports.append(Export(self._stack_id, value['Export'].get('Name'), value.get('Value'))) + return exports + def create(self): for output in self.outputs: self[output] + + +class Export(object): + + def __init__(self, exporting_stack_id, name, value): + self._exporting_stack_id = exporting_stack_id + self._name = name + self._value = value + + @property + def exporting_stack_id(self): + return self._exporting_stack_id + + @property + def name(self): + return self._name + + @property + def value(self): + return self._value From de9ea10eb1faabe1e724b86af719ca6ddef7bfa5 Mon Sep 17 00:00:00 2001 From: Jessie Nadler Date: Fri, 2 Jun 2017 16:22:48 -0400 Subject: [PATCH 2/6] Add list_exports to CloudFormationResponse --- moto/cloudformation/models.py | 11 +++ moto/cloudformation/responses.py | 26 +++++++ .../test_cloudformation_stack_crud_boto3.py | 76 +++++++++++++++++++ 3 files changed, 113 insertions(+) diff --git a/moto/cloudformation/models.py b/moto/cloudformation/models.py index 4a033b6d..2b3dfee4 100644 --- a/moto/cloudformation/models.py +++ b/moto/cloudformation/models.py @@ -206,6 +206,17 @@ class CloudFormationBackend(BaseBackend): if stack.name == name_or_stack_id: self.delete_stack(stack.stack_id) + def list_exports(self, token): + all_exports = [x for x in self.exports.values()] + if token is None: + exports = all_exports[0:100] + next_token = '100' if len(all_exports) > 100 else None + else: + token = int(token) + exports = all_exports[token:token + 100] + next_token = str(token + 100) if len(all_exports) > token + 100 else None + return exports, next_token + cloudformation_backends = {} for region in boto.cloudformation.regions(): diff --git a/moto/cloudformation/responses.py b/moto/cloudformation/responses.py index 60f647ef..d66a172a 100644 --- a/moto/cloudformation/responses.py +++ b/moto/cloudformation/responses.py @@ -210,6 +210,12 @@ class CloudFormationResponse(BaseResponse): template = self.response_template(DELETE_STACK_RESPONSE_TEMPLATE) return template.render() + def list_exports(self): + token = self._get_param('NextToken') + exports, next_token = self.cloudformation_backend.list_exports(token=token) + template = self.response_template(LIST_EXPORTS_RESPONSE) + return template.render(exports=exports, next_token=next_token) + CREATE_STACK_RESPONSE_TEMPLATE = """ @@ -410,3 +416,23 @@ DELETE_STACK_RESPONSE_TEMPLATE = """ """ + +LIST_EXPORTS_RESPONSE = """ + + + {% for export in exports %} + + {{ export.exporting_stack_id }} + {{ export.name }} + {{ export.value }} + + {% endfor %} + + {% if next_token %} + {{ next_token }} + {% endif %} + + + 5ccc7dcd-744c-11e5-be70-example + +""" diff --git a/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py b/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py index 85815e9f..8b4d72ad 100644 --- a/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py +++ b/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py @@ -57,8 +57,31 @@ dummy_update_template = { } } +dummy_output_template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Stack 1", + "Resources": { + "Instance": { + "Type": "AWS::EC2::Instance", + "Properties": { + "ImageId": "ami-08111162" + } + } + }, + "Outputs" : { + "StackVPC" : { + "Description" : "The ID of the VPC", + "Value" : "VPCID", + "Export" : { + "Name" : "My VPC ID" + } + } + } +} + dummy_template_json = json.dumps(dummy_template) dummy_update_template_json = json.dumps(dummy_template) +dummy_output_template_json = json.dumps(dummy_output_template) @mock_cloudformation @@ -408,3 +431,56 @@ def test_stack_events(): assert False, "Too many stack events" list(stack_events_to_look_for).should.be.empty + + +@mock_cloudformation +def test_list_exports(): + cf_client = boto3.client('cloudformation', region_name='us-east-1') + cf_resource = boto3.resource('cloudformation', region_name='us-east-1') + stack = cf_resource.create_stack( + StackName="test_stack", + TemplateBody=dummy_output_template_json, + ) + output_value = 'VPCID' + exports = cf_client.list_exports()['Exports'] + + stack.outputs.should.have.length_of(1) + stack.outputs[0]['OutputValue'].should.equal(output_value) + + exports.should.have.length_of(1) + exports[0]['ExportingStackId'].should.equal(stack.stack_id) + exports[0]['Name'].should.equal('My VPC ID') + exports[0]['Value'].should.equal(output_value) + + +@mock_cloudformation +def test_list_exports_with_token(): + cf = boto3.client('cloudformation', region_name='us-east-1') + for i in range(101): + cf.create_stack( + StackName="test_stack", + TemplateBody=dummy_output_template_json, + ) + exports = cf.list_exports() + exports['Exports'].should.have.length_of(100) + exports.get('NextToken').should_not.be.none + + more_exports = cf.list_exports(NextToken=exports['NextToken']) + more_exports['Exports'].should.have.length_of(1) + more_exports.get('NextToken').should.be.none + + +@mock_cloudformation +def test_delete_stack_with_export(): + cf = boto3.client('cloudformation', region_name='us-east-1') + stack = cf.create_stack( + StackName="test_stack", + TemplateBody=dummy_output_template_json, + ) + + stack_id = stack['StackId'] + exports = cf.list_exports()['Exports'] + exports.should.have.length_of(1) + + cf.delete_stack(StackName=stack_id) + cf.list_exports()['Exports'].should.have.length_of(0) From c6603c6248d6690e080b6b076e330c6d818d48a9 Mon Sep 17 00:00:00 2001 From: Jessie Nadler Date: Fri, 2 Jun 2017 16:23:42 -0400 Subject: [PATCH 3/6] Validate export names are unique --- moto/cloudformation/models.py | 7 +++++++ .../test_cloudformation_stack_crud_boto3.py | 19 ++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/moto/cloudformation/models.py b/moto/cloudformation/models.py index 2b3dfee4..00cbf781 100644 --- a/moto/cloudformation/models.py +++ b/moto/cloudformation/models.py @@ -150,6 +150,7 @@ class CloudFormationBackend(BaseBackend): role_arn=role_arn, ) self.stacks[stack_id] = new_stack + self._validate_export_uniqueness(new_stack) for export in new_stack.exports: self.exports[export.name] = export return new_stack @@ -217,6 +218,12 @@ class CloudFormationBackend(BaseBackend): next_token = str(token + 100) if len(all_exports) > token + 100 else None return exports, next_token + def _validate_export_uniqueness(self, stack): + new_stack_export_names = [x.name for x in stack.exports] + export_names = self.exports.keys() + if not set(export_names).isdisjoint(new_stack_export_names): + raise ValidationError(stack.stack_id, message='Export names must be unique across a given region') + cloudformation_backends = {} for region in boto.cloudformation.regions(): diff --git a/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py b/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py index 8b4d72ad..ba324985 100644 --- a/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py +++ b/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py @@ -12,6 +12,7 @@ import sure # noqa # Ensure 'assert_raises' context manager support for Python 2.6 import tests.backport_assert_raises # noqa from nose.tools import assert_raises +import random dummy_template = { "AWSTemplateFormatVersion": "2010-09-09", @@ -457,9 +458,11 @@ def test_list_exports(): def test_list_exports_with_token(): cf = boto3.client('cloudformation', region_name='us-east-1') for i in range(101): + # Add index to ensure name is unique + dummy_output_template['Outputs']['StackVPC']['Export']['Name'] += str(i) cf.create_stack( StackName="test_stack", - TemplateBody=dummy_output_template_json, + TemplateBody=json.dumps(dummy_output_template), ) exports = cf.list_exports() exports['Exports'].should.have.length_of(100) @@ -484,3 +487,17 @@ def test_delete_stack_with_export(): cf.delete_stack(StackName=stack_id) cf.list_exports()['Exports'].should.have.length_of(0) + + +@mock_cloudformation +def test_export_names_must_be_unique(): + cf = boto3.resource('cloudformation', region_name='us-east-1') + first_stack = cf.create_stack( + StackName="test_stack", + TemplateBody=dummy_output_template_json, + ) + with assert_raises(ClientError): + cf.create_stack( + StackName="test_stack", + TemplateBody=dummy_output_template_json, + ) From 87752457a38cbfa78fd85c526dded8460f96d309 Mon Sep 17 00:00:00 2001 From: Jessie Nadler Date: Fri, 2 Jun 2017 16:57:16 -0400 Subject: [PATCH 4/6] Remove useless list comprehension --- moto/cloudformation/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moto/cloudformation/models.py b/moto/cloudformation/models.py index 00cbf781..6557af9d 100644 --- a/moto/cloudformation/models.py +++ b/moto/cloudformation/models.py @@ -208,7 +208,7 @@ class CloudFormationBackend(BaseBackend): self.delete_stack(stack.stack_id) def list_exports(self, token): - all_exports = [x for x in self.exports.values()] + all_exports = self.exports.values() if token is None: exports = all_exports[0:100] next_token = '100' if len(all_exports) > 100 else None From c0afcfade5cf3d578a598b279339f8cbc4c53c3d Mon Sep 17 00:00:00 2001 From: Jessie Nadler Date: Fri, 2 Jun 2017 17:03:16 -0400 Subject: [PATCH 5/6] Use .items() not .iteritems() --- moto/cloudformation/parsing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moto/cloudformation/parsing.py b/moto/cloudformation/parsing.py index 248ecc57..2a00984e 100644 --- a/moto/cloudformation/parsing.py +++ b/moto/cloudformation/parsing.py @@ -489,7 +489,7 @@ class OutputMap(collections.Mapping): def exports(self): exports = [] if self.outputs: - for key, value in self._output_json_map.iteritems(): + for key, value in self._output_json_map.items(): if value.get('Export'): exports.append(Export(self._stack_id, value['Export'].get('Name'), value.get('Value'))) return exports From 9d37992c64efb56b09c413edbc990825713fa09c Mon Sep 17 00:00:00 2001 From: Jessie Nadler Date: Fri, 2 Jun 2017 17:16:25 -0400 Subject: [PATCH 6/6] Make all_exports subscriptable --- moto/cloudformation/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moto/cloudformation/models.py b/moto/cloudformation/models.py index 6557af9d..c25103a4 100644 --- a/moto/cloudformation/models.py +++ b/moto/cloudformation/models.py @@ -208,7 +208,7 @@ class CloudFormationBackend(BaseBackend): self.delete_stack(stack.stack_id) def list_exports(self, token): - all_exports = self.exports.values() + all_exports = list(self.exports.values()) if token is None: exports = all_exports[0:100] next_token = '100' if len(all_exports) > 100 else None