diff --git a/AUTHORS.md b/AUTHORS.md index 55ac102d5..1771d1a78 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -48,3 +48,4 @@ Moto is written by Steve Pulec with contributions from: * [Guy Templeton](https://github.com/gjtempleton) * [Michael van Tellingen](https://github.com/mvantellingen) * [Jessie Nadler](https://github.com/nadlerjessie) +* [Alex Morken](https://github.com/alexmorken) diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index b3caf5be3..76944e3fe 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -329,10 +329,10 @@ - [ ] update_schema - [ ] update_typed_link_facet -## cloudformation - 17% implemented +## cloudformation - 20% implemented - [ ] cancel_update_stack - [ ] continue_update_rollback -- [ ] create_change_set +- [X] create_change_set - [X] create_stack - [ ] create_stack_instances - [ ] create_stack_set diff --git a/moto/cloudformation/models.py b/moto/cloudformation/models.py index e579e4c08..42809608b 100644 --- a/moto/cloudformation/models.py +++ b/moto/cloudformation/models.py @@ -9,13 +9,17 @@ from moto.compat import OrderedDict from moto.core import BaseBackend, BaseModel from .parsing import ResourceMap, OutputMap -from .utils import generate_stack_id, yaml_tag_constructor +from .utils import ( + generate_changeset_id, + generate_stack_id, + yaml_tag_constructor, +) from .exceptions import ValidationError class FakeStack(BaseModel): - def __init__(self, stack_id, name, template, parameters, region_name, notification_arns=None, tags=None, role_arn=None, cross_stack_resources=None): + def __init__(self, stack_id, name, template, parameters, region_name, notification_arns=None, tags=None, role_arn=None, cross_stack_resources=None, create_change_set=False): self.stack_id = stack_id self.name = name self.template = template @@ -26,8 +30,12 @@ class FakeStack(BaseModel): self.role_arn = role_arn self.tags = tags if tags else {} self.events = [] - self._add_stack_event("CREATE_IN_PROGRESS", - resource_status_reason="User Initiated") + if create_change_set: + self._add_stack_event("REVIEW_IN_PROGRESS", + resource_status_reason="User Initiated") + else: + self._add_stack_event("CREATE_IN_PROGRESS", + resource_status_reason="User Initiated") self.description = self.template_dict.get('Description') self.cross_stack_resources = cross_stack_resources or [] @@ -138,8 +146,9 @@ class CloudFormationBackend(BaseBackend): self.stacks = OrderedDict() self.deleted_stacks = {} self.exports = OrderedDict() + self.change_sets = OrderedDict() - def create_stack(self, name, template, parameters, region_name, notification_arns=None, tags=None, role_arn=None): + def create_stack(self, name, template, parameters, region_name, notification_arns=None, tags=None, role_arn=None, create_change_set=False): stack_id = generate_stack_id(name) new_stack = FakeStack( stack_id=stack_id, @@ -151,6 +160,7 @@ class CloudFormationBackend(BaseBackend): tags=tags, role_arn=role_arn, cross_stack_resources=self.exports, + create_change_set=create_change_set, ) self.stacks[stack_id] = new_stack self._validate_export_uniqueness(new_stack) @@ -158,6 +168,26 @@ class CloudFormationBackend(BaseBackend): self.exports[export.name] = export return new_stack + def create_change_set(self, stack_name, change_set_name, template, parameters, region_name, change_set_type, notification_arns=None, tags=None, role_arn=None): + if change_set_type == 'UPDATE': + stacks = self.stacks.values() + stack = None + for s in stacks: + if s.name == stack_name: + stack = s + if stack is None: + raise ValidationError(stack_name) + + else: + stack = self.create_stack(stack_name, template, parameters, + region_name, notification_arns, tags, + role_arn, create_change_set=True) + change_set_id = generate_changeset_id(change_set_name, region_name) + self.stacks[change_set_name] = {'Id': change_set_id, + 'StackId': stack.stack_id} + self.change_sets[change_set_id] = stack + return change_set_id, stack.stack_id + def describe_stacks(self, name_or_stack_id): stacks = self.stacks.values() if name_or_stack_id: diff --git a/moto/cloudformation/responses.py b/moto/cloudformation/responses.py index 3023fa114..93d59f686 100644 --- a/moto/cloudformation/responses.py +++ b/moto/cloudformation/responses.py @@ -4,6 +4,7 @@ import json from six.moves.urllib.parse import urlparse from moto.core.responses import BaseResponse +from moto.core.utils import amzn_request_id from moto.s3 import s3_backend from .models import cloudformation_backends from .exceptions import ValidationError @@ -77,6 +78,46 @@ class CloudFormationResponse(BaseResponse): template = self.response_template(CREATE_STACK_RESPONSE_TEMPLATE) return template.render(stack=stack) + @amzn_request_id + def create_change_set(self): + stack_name = self._get_param('StackName') + change_set_name = self._get_param('ChangeSetName') + stack_body = self._get_param('TemplateBody') + template_url = self._get_param('TemplateURL') + role_arn = self._get_param('RoleARN') + update_or_create = self._get_param('ChangeSetType', 'CREATE') + parameters_list = self._get_list_prefix("Parameters.member") + tags = {tag[0]: tag[1] for tag in self._get_list_prefix("Tags.member")} + parameters = {param['parameter_key']: param['parameter_value'] + for param in parameters_list} + if template_url: + stack_body = self._get_stack_from_s3_url(template_url) + stack_notification_arns = self._get_multi_param( + 'NotificationARNs.member') + change_set_id, stack_id = self.cloudformation_backend.create_change_set( + stack_name=stack_name, + change_set_name=change_set_name, + template=stack_body, + parameters=parameters, + region_name=self.region, + notification_arns=stack_notification_arns, + tags=tags, + role_arn=role_arn, + change_set_type=update_or_create, + ) + if self.request_json: + return json.dumps({ + 'CreateChangeSetResponse': { + 'CreateChangeSetResult': { + 'Id': change_set_id, + 'StackId': stack_id, + } + } + }) + else: + template = self.response_template(CREATE_CHANGE_SET_RESPONSE_TEMPLATE) + return template.render(stack_id=stack_id, change_set_id=change_set_id) + def describe_stacks(self): stack_name_or_id = None if self._get_param('StackName'): @@ -250,6 +291,17 @@ UPDATE_STACK_RESPONSE_TEMPLATE = """ + + {{change_set_id}} + {{ stack_id }} + + + {{ request_id }} + + +""" + DESCRIBE_STACKS_TEMPLATE = """ diff --git a/moto/cloudformation/utils.py b/moto/cloudformation/utils.py index 384ea5401..f3b8874ed 100644 --- a/moto/cloudformation/utils.py +++ b/moto/cloudformation/utils.py @@ -10,6 +10,11 @@ def generate_stack_id(stack_name): return "arn:aws:cloudformation:us-east-1:123456789:stack/{0}/{1}".format(stack_name, random_id) +def generate_changeset_id(changeset_name, region_name): + random_id = uuid.uuid4() + return 'arn:aws:cloudformation:{0}:123456789:changeSet/{1}/{2}'.format(region_name, changeset_name, random_id) + + def random_suffix(): size = 12 chars = list(range(10)) + ['A-Z'] diff --git a/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py b/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py index 182603e2c..d8b8cf142 100644 --- a/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py +++ b/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py @@ -310,6 +310,33 @@ def test_update_stack_from_s3_url(): 'TemplateBody'].should.equal(dummy_update_template) +@mock_cloudformation +@mock_s3 +def test_create_change_set_from_s3_url(): + s3 = boto3.client('s3') + s3_conn = boto3.resource('s3') + bucket = s3_conn.create_bucket(Bucket="foobar") + + key = s3_conn.Object( + 'foobar', 'template-key').put(Body=dummy_template_json) + key_url = s3.generate_presigned_url( + ClientMethod='get_object', + Params={ + 'Bucket': 'foobar', + 'Key': 'template-key' + } + ) + cf_conn = boto3.client('cloudformation', region_name='us-west-1') + response = cf_conn.create_change_set( + StackName='NewStack', + TemplateURL=key_url, + ChangeSetName='NewChangeSet', + ChangeSetType='CREATE', + ) + assert 'arn:aws:cloudformation:us-west-1:123456789:changeSet/NewChangeSet/' in response['Id'] + assert 'arn:aws:cloudformation:us-east-1:123456789:stack/NewStack' in response['StackId'] + + @mock_cloudformation def test_describe_stack_pagination(): conn = boto3.client('cloudformation', region_name='us-east-1')