diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md
index 735af6002..42367db9b 100644
--- a/IMPLEMENTATION_COVERAGE.md
+++ b/IMPLEMENTATION_COVERAGE.md
@@ -477,12 +477,12 @@
- [X] create_stack
- [ ] create_stack_instances
- [ ] create_stack_set
-- [ ] delete_change_set
+- [X] delete_change_set
- [X] delete_stack
- [ ] delete_stack_instances
- [ ] delete_stack_set
- [ ] describe_account_limits
-- [ ] describe_change_set
+- [X] describe_change_set
- [ ] describe_stack_events
- [ ] describe_stack_instance
- [ ] describe_stack_resource
@@ -495,7 +495,7 @@
- [ ] get_stack_policy
- [ ] get_template
- [ ] get_template_summary
-- [ ] list_change_sets
+- [X] list_change_sets
- [X] list_exports
- [ ] list_imports
- [ ] list_stack_instances
diff --git a/moto/cloudformation/models.py b/moto/cloudformation/models.py
index c45c5d5fe..864e98a92 100644
--- a/moto/cloudformation/models.py
+++ b/moto/cloudformation/models.py
@@ -127,6 +127,49 @@ class FakeStack(BaseModel):
self.status = "DELETE_COMPLETE"
+class FakeChange(BaseModel):
+
+ def __init__(self, action, logical_resource_id, resource_type):
+ self.action = action
+ self.logical_resource_id = logical_resource_id
+ self.resource_type = resource_type
+
+
+class FakeChangeSet(FakeStack):
+
+ def __init__(self, stack_id, stack_name, stack_template, change_set_id, change_set_name, template, parameters, region_name, notification_arns=None, tags=None, role_arn=None, cross_stack_resources=None):
+ super(FakeChangeSet, self).__init__(
+ stack_id,
+ stack_name,
+ stack_template,
+ parameters,
+ region_name,
+ notification_arns=notification_arns,
+ tags=tags,
+ role_arn=role_arn,
+ cross_stack_resources=cross_stack_resources,
+ create_change_set=True,
+ )
+ self.stack_name = stack_name
+ self.change_set_id = change_set_id
+ self.change_set_name = change_set_name
+ self.changes = self.diff(template=template, parameters=parameters)
+
+ def diff(self, template, parameters=None):
+ self.template = template
+ self._parse_template()
+ changes = []
+ resources_by_action = self.resource_map.diff(self.template_dict, parameters)
+ for action, resources in resources_by_action.items():
+ for resource_name, resource in resources.items():
+ changes.append(FakeChange(
+ action=action,
+ logical_resource_id=resource_name,
+ resource_type=resource['ResourceType'],
+ ))
+ return changes
+
+
class FakeEvent(BaseModel):
def __init__(self, stack_id, stack_name, logical_resource_id, physical_resource_id, resource_type, resource_status, resource_status_reason=None, resource_properties=None):
@@ -171,24 +214,62 @@ class CloudFormationBackend(BaseBackend):
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):
+ stack_id = None
+ stack_template = None
if change_set_type == 'UPDATE':
stacks = self.stacks.values()
stack = None
for s in stacks:
if s.name == stack_name:
stack = s
+ stack_id = stack.stack_id
+ stack_template = stack.template
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)
+ stack_id = generate_stack_id(stack_name)
+ stack_template = template
+
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
+ new_change_set = FakeChangeSet(
+ stack_id=stack_id,
+ stack_name=stack_name,
+ stack_template=stack_template,
+ change_set_id=change_set_id,
+ change_set_name=change_set_name,
+ template=template,
+ parameters=parameters,
+ region_name=region_name,
+ notification_arns=notification_arns,
+ tags=tags,
+ role_arn=role_arn,
+ cross_stack_resources=self.exports
+ )
+ self.change_sets[change_set_id] = new_change_set
+ self.stacks[stack_id] = new_change_set
+ return change_set_id, stack_id
+
+ def delete_change_set(self, change_set_name, stack_name=None):
+ if change_set_name in self.change_sets:
+ # This means arn was passed in
+ del self.change_sets[change_set_name]
+ else:
+ for cs in self.change_sets:
+ if self.change_sets[cs].change_set_name == change_set_name:
+ del self.change_sets[cs]
+
+ def describe_change_set(self, change_set_name, stack_name=None):
+ change_set = None
+ if change_set_name in self.change_sets:
+ # This means arn was passed in
+ change_set = self.change_sets[change_set_name]
+ else:
+ for cs in self.change_sets:
+ if self.change_sets[cs].change_set_name == change_set_name:
+ change_set = self.change_sets[cs]
+ if change_set is None:
+ raise ValidationError(change_set_name)
+ return change_set
def execute_change_set(self, change_set_name, stack_name=None):
stack = None
@@ -197,7 +278,7 @@ class CloudFormationBackend(BaseBackend):
stack = self.change_sets[change_set_name]
else:
for cs in self.change_sets:
- if self.change_sets[cs].name == change_set_name:
+ if self.change_sets[cs].change_set_name == change_set_name:
stack = self.change_sets[cs]
if stack is None:
raise ValidationError(stack_name)
@@ -223,6 +304,9 @@ class CloudFormationBackend(BaseBackend):
else:
return list(stacks)
+ def list_change_sets(self):
+ return self.change_sets.values()
+
def list_stacks(self):
return [
v for v in self.stacks.values()
diff --git a/moto/cloudformation/parsing.py b/moto/cloudformation/parsing.py
index 35b05d101..0be68944b 100644
--- a/moto/cloudformation/parsing.py
+++ b/moto/cloudformation/parsing.py
@@ -465,36 +465,70 @@ class ResourceMap(collections.Mapping):
ec2_models.ec2_backends[self._region_name].create_tags(
[self[resource].physical_resource_id], self.tags)
- def update(self, template, parameters=None):
+ def diff(self, template, parameters=None):
if parameters:
self.input_parameters = parameters
self.load_mapping()
self.load_parameters()
self.load_conditions()
+ old_template = self._resource_json_map
+ new_template = template['Resources']
+
+ resource_names_by_action = {
+ 'Add': set(new_template) - set(old_template),
+ 'Modify': set(name for name in new_template if name in old_template and new_template[
+ name] != old_template[name]),
+ 'Remove': set(old_template) - set(new_template)
+ }
+ resources_by_action = {
+ 'Add': {},
+ 'Modify': {},
+ 'Remove': {},
+ }
+
+ for resource_name in resource_names_by_action['Add']:
+ resources_by_action['Add'][resource_name] = {
+ 'LogicalResourceId': resource_name,
+ 'ResourceType': new_template[resource_name]['Type']
+ }
+
+ for resource_name in resource_names_by_action['Modify']:
+ resources_by_action['Modify'][resource_name] = {
+ 'LogicalResourceId': resource_name,
+ 'ResourceType': new_template[resource_name]['Type']
+ }
+
+ for resource_name in resource_names_by_action['Remove']:
+ resources_by_action['Remove'][resource_name] = {
+ 'LogicalResourceId': resource_name,
+ 'ResourceType': old_template[resource_name]['Type']
+ }
+
+ return resources_by_action
+
+ def update(self, template, parameters=None):
+ resources_by_action = self.diff(template, parameters)
+
old_template = self._resource_json_map
new_template = template['Resources']
self._resource_json_map = new_template
- new_resource_names = set(new_template) - set(old_template)
- for resource_name in new_resource_names:
+ for resource_name, resource in resources_by_action['Add'].items():
resource_json = new_template[resource_name]
new_resource = parse_and_create_resource(
resource_name, resource_json, self, self._region_name)
self._parsed_resources[resource_name] = new_resource
- removed_resource_names = set(old_template) - set(new_template)
- for resource_name in removed_resource_names:
+ for resource_name, resource in resources_by_action['Remove'].items():
resource_json = old_template[resource_name]
parse_and_delete_resource(
resource_name, resource_json, self, self._region_name)
self._parsed_resources.pop(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():
+ while resources_by_action['Modify'] and tries < 5:
+ for resource_name, resource in resources_by_action['Modify'].copy().items():
resource_json = new_template[resource_name]
try:
changed_resource = parse_and_update_resource(
@@ -505,7 +539,7 @@ class ResourceMap(collections.Mapping):
last_exception = e
else:
self._parsed_resources[resource_name] = changed_resource
- resources_to_update.remove(resource_name)
+ del resources_by_action['Modify'][resource_name]
tries += 1
if tries == 5:
raise last_exception
diff --git a/moto/cloudformation/responses.py b/moto/cloudformation/responses.py
index 9e67e931a..84805efaf 100644
--- a/moto/cloudformation/responses.py
+++ b/moto/cloudformation/responses.py
@@ -120,6 +120,31 @@ class CloudFormationResponse(BaseResponse):
template = self.response_template(CREATE_CHANGE_SET_RESPONSE_TEMPLATE)
return template.render(stack_id=stack_id, change_set_id=change_set_id)
+ def delete_change_set(self):
+ stack_name = self._get_param('StackName')
+ change_set_name = self._get_param('ChangeSetName')
+
+ self.cloudformation_backend.delete_change_set(change_set_name=change_set_name, stack_name=stack_name)
+ if self.request_json:
+ return json.dumps({
+ 'DeleteChangeSetResponse': {
+ 'DeleteChangeSetResult': {},
+ }
+ })
+ else:
+ template = self.response_template(DELETE_CHANGE_SET_RESPONSE_TEMPLATE)
+ return template.render()
+
+ def describe_change_set(self):
+ stack_name = self._get_param('StackName')
+ change_set_name = self._get_param('ChangeSetName')
+ change_set = self.cloudformation_backend.describe_change_set(
+ change_set_name=change_set_name,
+ stack_name=stack_name,
+ )
+ template = self.response_template(DESCRIBE_CHANGE_SET_RESPONSE_TEMPLATE)
+ return template.render(change_set=change_set)
+
@amzn_request_id
def execute_change_set(self):
stack_name = self._get_param('StackName')
@@ -187,6 +212,11 @@ class CloudFormationResponse(BaseResponse):
template = self.response_template(DESCRIBE_STACK_EVENTS_RESPONSE)
return template.render(stack=stack)
+ def list_change_sets(self):
+ change_sets = self.cloudformation_backend.list_change_sets()
+ template = self.response_template(LIST_CHANGE_SETS_RESPONSE)
+ return template.render(change_sets=change_sets)
+
def list_stacks(self):
stacks = self.cloudformation_backend.list_stacks()
template = self.response_template(LIST_STACKS_RESPONSE)
@@ -354,6 +384,66 @@ CREATE_CHANGE_SET_RESPONSE_TEMPLATE = """
"""
+DELETE_CHANGE_SET_RESPONSE_TEMPLATE = """
+
+
+
+ 3d3200a1-810e-3023-6cc3-example
+
+
+"""
+
+DESCRIBE_CHANGE_SET_RESPONSE_TEMPLATE = """
+
+ {{ change_set.change_set_id }}
+ {{ change_set.change_set_name }}
+ {{ change_set.stack_id }}
+ {{ change_set.stack_name }}
+ {{ change_set.description }}
+
+ {% for param_name, param_value in change_set.stack_parameters.items() %}
+
+ {{ param_name }}
+ {{ param_value }}
+
+ {% endfor %}
+
+ 2011-05-23T15:47:44Z
+ {{ change_set.execution_status }}
+ {{ change_set.status }}
+ {{ change_set.status_reason }}
+ {% if change_set.notification_arns %}
+
+ {% for notification_arn in change_set.notification_arns %}
+ {{ notification_arn }}
+ {% endfor %}
+
+ {% else %}
+
+ {% endif %}
+ {% if change_set.role_arn %}
+ {{ change_set.role_arn }}
+ {% endif %}
+ {% if change_set.changes %}
+
+ {% for change in change_set.changes %}
+
+ Resource
+
+ {{ change.action }}
+ {{ change.logical_resource_id }}
+ {{ change.resource_type }}
+
+
+ {% endfor %}
+
+ {% endif %}
+ {% if next_token %}
+ {{ next_token }}
+ {% endif %}
+
+"""
+
EXECUTE_CHANGE_SET_RESPONSE_TEMPLATE = """
@@ -479,6 +569,27 @@ DESCRIBE_STACK_EVENTS_RESPONSE = """
+
+
+ {% for change_set in change_sets %}
+
+ {{ change_set.stack_id }}
+ {{ change_set.stack_name }}
+ {{ change_set.change_set_id }}
+ {{ change_set.change_set_name }}
+ {{ change_set.execution_status }}
+ {{ change_set.status }}
+ {{ change_set.status_reason }}
+ 2011-05-23T15:47:44Z
+ {{ change_set.description }}
+
+ {% endfor %}
+
+
+"""
+
+
LIST_STACKS_RESPONSE = """
diff --git a/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py b/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py
index 4585da056..2511de6da 100644
--- a/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py
+++ b/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py
@@ -399,6 +399,32 @@ def test_create_change_set_from_s3_url():
assert 'arn:aws:cloudformation:us-east-1:123456789:stack/NewStack' in response['StackId']
+@mock_cloudformation
+def test_describe_change_set():
+ cf_conn = boto3.client('cloudformation', region_name='us-east-1')
+ cf_conn.create_change_set(
+ StackName='NewStack',
+ TemplateBody=dummy_template_json,
+ ChangeSetName='NewChangeSet',
+ ChangeSetType='CREATE',
+ )
+
+ stack = cf_conn.describe_change_set(ChangeSetName="NewChangeSet")
+ stack['ChangeSetName'].should.equal('NewChangeSet')
+ stack['StackName'].should.equal('NewStack')
+
+ cf_conn.create_change_set(
+ StackName='NewStack',
+ TemplateBody=dummy_update_template_json,
+ ChangeSetName='NewChangeSet2',
+ ChangeSetType='UPDATE',
+ )
+ stack = cf_conn.describe_change_set(ChangeSetName="NewChangeSet2")
+ stack['ChangeSetName'].should.equal('NewChangeSet2')
+ stack['StackName'].should.equal('NewStack')
+ stack['Changes'].should.have.length_of(2)
+
+
@mock_cloudformation
def test_execute_change_set_w_arn():
cf_conn = boto3.client('cloudformation', region_name='us-east-1')
@@ -420,7 +446,7 @@ def test_execute_change_set_w_name():
ChangeSetName='NewChangeSet',
ChangeSetType='CREATE',
)
- cf_conn.execute_change_set(ChangeSetName='NewStack', StackName='NewStack')
+ cf_conn.execute_change_set(ChangeSetName='NewChangeSet', StackName='NewStack')
@mock_cloudformation
@@ -489,6 +515,20 @@ def test_describe_stack_by_stack_id():
stack_by_id['StackName'].should.equal("test_stack")
+@mock_cloudformation
+def test_list_change_sets():
+ cf_conn = boto3.client('cloudformation', region_name='us-east-1')
+ cf_conn.create_change_set(
+ StackName='NewStack2',
+ TemplateBody=dummy_template_json,
+ ChangeSetName='NewChangeSet2',
+ ChangeSetType='CREATE',
+ )
+ change_set = cf_conn.list_change_sets(StackName='NewStack2')['Summaries'][0]
+ change_set['StackName'].should.equal('NewStack2')
+ change_set['ChangeSetName'].should.equal('NewChangeSet2')
+
+
@mock_cloudformation
def test_list_stacks():
cf = boto3.resource('cloudformation', region_name='us-east-1')
@@ -521,6 +561,22 @@ def test_delete_stack_from_resource():
list(cf.stacks.all()).should.have.length_of(0)
+@mock_cloudformation
+@mock_ec2
+def test_delete_change_set():
+ cf_conn = boto3.client('cloudformation', region_name='us-east-1')
+ cf_conn.create_change_set(
+ StackName='NewStack',
+ TemplateBody=dummy_template_json,
+ ChangeSetName='NewChangeSet',
+ ChangeSetType='CREATE',
+ )
+
+ cf_conn.list_change_sets(StackName='NewStack')['Summaries'].should.have.length_of(1)
+ cf_conn.delete_change_set(ChangeSetName='NewChangeSet', StackName='NewStack')
+ cf_conn.list_change_sets(StackName='NewStack')['Summaries'].should.have.length_of(0)
+
+
@mock_cloudformation
@mock_ec2
def test_delete_stack_by_name():