CloudFormation: Improve behaviour of StackInstances (#5731)

This commit is contained in:
Bert Blommers 2022-12-03 16:07:04 -01:00 committed by GitHub
parent e4c0d0618c
commit aeb507f091
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 693 additions and 149 deletions

View File

@ -565,7 +565,7 @@
## cloudformation ## cloudformation
<details> <details>
<summary>31% implemented</summary> <summary>34% implemented</summary>
- [ ] activate_type - [ ] activate_type
- [ ] batch_describe_type_configurations - [ ] batch_describe_type_configurations
@ -587,7 +587,7 @@
- [ ] describe_publisher - [ ] describe_publisher
- [ ] describe_stack_drift_detection_status - [ ] describe_stack_drift_detection_status
- [ ] describe_stack_events - [ ] describe_stack_events
- [ ] describe_stack_instance - [X] describe_stack_instance
- [ ] describe_stack_resource - [ ] describe_stack_resource
- [ ] describe_stack_resource_drifts - [ ] describe_stack_resource_drifts
- [ ] describe_stack_resources - [ ] describe_stack_resources
@ -608,7 +608,7 @@
- [X] list_change_sets - [X] list_change_sets
- [X] list_exports - [X] list_exports
- [ ] list_imports - [ ] list_imports
- [ ] list_stack_instances - [X] list_stack_instances
- [X] list_stack_resources - [X] list_stack_resources
- [ ] list_stack_set_operation_results - [ ] list_stack_set_operation_results
- [ ] list_stack_set_operations - [ ] list_stack_set_operations

View File

@ -38,11 +38,23 @@ cloudformation
- [X] create_stack_instances - [X] create_stack_instances
The following parameters are not yet implemented: DeploymentTargets.AccountFilterType, DeploymentTargets.AccountsUrl, OperationPreferences, CallAs
- [X] create_stack_set - [X] create_stack_set
The following parameters are not yet implemented: StackId, AdministrationRoleARN, AutoDeployment, ExecutionRoleName, CallAs, ClientRequestToken, ManagedExecution
- [ ] deactivate_type - [ ] deactivate_type
- [X] delete_change_set - [X] delete_change_set
- [X] delete_stack - [X] delete_stack
- [X] delete_stack_instances - [X] delete_stack_instances
The following parameters are not yet implemented: DeploymentTargets, OperationPreferences, RetainStacks, OperationId, CallAs
- [X] delete_stack_set - [X] delete_stack_set
- [ ] deregister_type - [ ] deregister_type
- [ ] describe_account_limits - [ ] describe_account_limits
@ -51,7 +63,7 @@ cloudformation
- [ ] describe_publisher - [ ] describe_publisher
- [ ] describe_stack_drift_detection_status - [ ] describe_stack_drift_detection_status
- [ ] describe_stack_events - [ ] describe_stack_events
- [ ] describe_stack_instance - [X] describe_stack_instance
- [ ] describe_stack_resource - [ ] describe_stack_resource
- [ ] describe_stack_resource_drifts - [ ] describe_stack_resource_drifts
- [ ] describe_stack_resources - [ ] describe_stack_resources
@ -72,7 +84,12 @@ cloudformation
- [X] list_change_sets - [X] list_change_sets
- [X] list_exports - [X] list_exports
- [ ] list_imports - [ ] list_imports
- [ ] list_stack_instances - [X] list_stack_instances
Pagination is not yet implemented.
The parameters StackInstanceAccount/StackInstanceRegion are not yet implemented.
- [X] list_stack_resources - [X] list_stack_resources
- [ ] list_stack_set_operation_results - [ ] list_stack_set_operation_results
- [ ] list_stack_set_operations - [ ] list_stack_set_operations
@ -98,6 +115,10 @@ cloudformation
- [ ] test_type - [ ] test_type
- [X] update_stack - [X] update_stack
- [X] update_stack_instances - [X] update_stack_instances
Calling this will update the parameters, but the actual resources are not updated
- [X] update_stack_set - [X] update_stack_set
- [ ] update_termination_protection - [ ] update_termination_protection
- [X] validate_template - [X] validate_template

View File

@ -38,6 +38,16 @@ class ExportNotFound(RESTError):
self.description = template.render(code="ExportNotFound", message=message) self.description = template.render(code="ExportNotFound", message=message)
class StackSetNotEmpty(RESTError):
def __init__(self) -> None:
template = Template(ERROR_RESPONSE)
message = "StackSet is not empty"
super().__init__(error_type="StackSetNotEmptyException", message=message)
self.description = template.render(
code="StackSetNotEmptyException", message=message
)
class UnsupportedAttribute(ValidationError): class UnsupportedAttribute(ValidationError):
def __init__(self, resource: str, attr: str): def __init__(self, resource: str, attr: str):
template = Template(ERROR_RESPONSE) template = Template(ERROR_RESPONSE)

View File

@ -14,6 +14,7 @@ from moto.core.utils import (
) )
from moto.moto_api._internal import mock_random from moto.moto_api._internal import mock_random
from moto.sns.models import sns_backends from moto.sns.models import sns_backends
from moto.organizations.models import organizations_backends, OrganizationsBackend
from .custom_model import CustomModel from .custom_model import CustomModel
from .parsing import ResourceMap, Output, OutputMap, Export from .parsing import ResourceMap, Output, OutputMap, Export
@ -25,7 +26,7 @@ from .utils import (
yaml_tag_constructor, yaml_tag_constructor,
validate_template_cfn_lint, validate_template_cfn_lint,
) )
from .exceptions import ValidationError from .exceptions import ValidationError, StackSetNotEmpty
class FakeStackSet(BaseModel): class FakeStackSet(BaseModel):
@ -38,6 +39,7 @@ class FakeStackSet(BaseModel):
region: str, region: str,
description: Optional[str], description: Optional[str],
parameters: Dict[str, str], parameters: Dict[str, str],
permission_model: str,
tags: Optional[Dict[str, str]] = None, tags: Optional[Dict[str, str]] = None,
admin_role: str = "AWSCloudFormationStackSetAdministrationRole", admin_role: str = "AWSCloudFormationStackSetAdministrationRole",
execution_role: str = "AWSCloudFormationStackSetExecutionRole", execution_role: str = "AWSCloudFormationStackSetExecutionRole",
@ -53,9 +55,12 @@ class FakeStackSet(BaseModel):
self.admin_role_arn = f"arn:aws:iam::{account_id}:role/{self.admin_role}" self.admin_role_arn = f"arn:aws:iam::{account_id}:role/{self.admin_role}"
self.execution_role = execution_role self.execution_role = execution_role
self.status = "ACTIVE" self.status = "ACTIVE"
self.instances = FakeStackInstances(parameters, self.id, self.name) self.instances = FakeStackInstances(
account_id, template, parameters, self.id, self.name
)
self.stack_instances = self.instances.stack_instances self.stack_instances = self.instances.stack_instances
self.operations: List[Dict[str, Any]] = [] self.operations: List[Dict[str, Any]] = []
self.permission_model = permission_model or "SELF_MANAGED"
def _create_operation( def _create_operation(
self, self,
@ -128,13 +133,35 @@ class FakeStackSet(BaseModel):
return operation return operation
def create_stack_instances( def create_stack_instances(
self, accounts: List[str], regions: List[str], parameters: List[Dict[str, Any]] self,
) -> None: accounts: List[str],
regions: List[str],
deployment_targets: Optional[Dict[str, Any]],
parameters: List[Dict[str, Any]],
) -> str:
if self.permission_model == "SERVICE_MANAGED":
if not deployment_targets:
raise ValidationError(
message="StackSets with SERVICE_MANAGED permission model can only have OrganizationalUnit as target"
)
elif "OrganizationalUnitIds" not in deployment_targets:
raise ValidationError(message="OrganizationalUnitIds are required")
if self.permission_model == "SELF_MANAGED":
if deployment_targets and "OrganizationalUnitIds" in deployment_targets:
raise ValidationError(
message="StackSets with SELF_MANAGED permission model can only have accounts as target"
)
operation_id = str(mock_random.uuid4()) operation_id = str(mock_random.uuid4())
if not parameters: if not parameters:
parameters = self.parameters # type: ignore[assignment] parameters = self.parameters # type: ignore[assignment]
self.instances.create_instances(accounts, regions, parameters) self.instances.create_instances(
accounts,
regions,
parameters, # type: ignore[arg-type]
deployment_targets or {},
permission_model=self.permission_model,
)
self._create_operation( self._create_operation(
operation_id=operation_id, operation_id=operation_id,
action="CREATE", action="CREATE",
@ -142,6 +169,7 @@ class FakeStackSet(BaseModel):
accounts=accounts, accounts=accounts,
regions=regions, regions=regions,
) )
return operation_id
def delete_stack_instances(self, accounts: List[str], regions: List[str]) -> None: def delete_stack_instances(self, accounts: List[str], regions: List[str]) -> None:
operation_id = str(mock_random.uuid4()) operation_id = str(mock_random.uuid4())
@ -172,36 +200,136 @@ class FakeStackSet(BaseModel):
return operation return operation
class FakeStackInstance(BaseModel):
def __init__(
self,
account_id: str,
region_name: str,
stackset_id: str,
stack_name: str,
name: str,
template: str,
parameters: Optional[List[Dict[str, Any]]],
permission_model: str,
):
self.account_id = account_id
self.region_name = region_name
self.stackset_id = stackset_id
self.stack_name = stack_name
self.name = name
self.template = template
self.parameters = parameters or []
self.permission_model = permission_model
# Incoming parameters can be in two formats: {key: value} or [{"": key, "": value}, ..]
if isinstance(parameters, dict):
params = parameters
elif isinstance(parameters, list):
params = {p["ParameterKey"]: p["ParameterValue"] for p in parameters}
if permission_model == "SELF_MANAGED":
self.stack = cloudformation_backends[account_id][region_name].create_stack(
name=f"StackSet:{name}", template=template, parameters=params
)
else:
stack_id = generate_stack_id(
"hiddenstackfor" + self.name, self.region_name, self.account_id
)
self.stack = FakeStack(
stack_id=stack_id,
name=self.name,
template=self.template,
parameters=params,
account_id=self.account_id,
region_name=self.region_name,
notification_arns=[],
tags=None,
role_arn=None,
cross_stack_resources={},
enable_termination_protection=False,
)
self.stack.create_resources()
def delete(self) -> None:
if self.permission_model == "SELF_MANAGED":
cloudformation_backends[self.account_id][self.region_name].delete_stack(
self.stack.name
)
else:
# Our stack is hidden - we have to delete it manually
self.stack.delete()
def to_dict(self) -> Dict[str, Any]:
return {
"StackId": generate_stack_id(
self.stack_name, self.region_name, self.account_id
),
"StackSetId": self.stackset_id,
"Region": self.region_name,
"Account": self.account_id,
"Status": "CURRENT",
"ParameterOverrides": self.parameters,
}
class FakeStackInstances(BaseModel): class FakeStackInstances(BaseModel):
def __init__( def __init__(
self, parameters: Dict[str, str], stackset_id: str, stackset_name: str self,
account_id: str,
template: str,
parameters: Dict[str, str],
stackset_id: str,
stackset_name: str,
): ):
self.account_id = account_id
self.template = template
self.parameters = parameters or {} self.parameters = parameters or {}
self.stackset_id = stackset_id self.stackset_id = stackset_id
self.stack_name = f"StackSet-{stackset_id}" self.stack_name = f"StackSet-{stackset_id}"
self.stackset_name = stackset_name self.stackset_name = stackset_name
self.stack_instances: List[Dict[str, Any]] = [] self.stack_instances: List[FakeStackInstance] = []
@property
def org_backend(self) -> OrganizationsBackend:
return organizations_backends[self.account_id]["global"]
def create_instances( def create_instances(
self, self,
accounts: List[str], accounts: List[str],
regions: List[str], regions: List[str],
parameters: Optional[List[Dict[str, Any]]], parameters: Optional[List[Dict[str, Any]]],
deployment_targets: Dict[str, Any],
permission_model: str,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
new_instances = [] targets: List[Tuple[str, str]] = []
all_accounts = self.org_backend.accounts
requested_ous = deployment_targets.get("OrganizationalUnitIds", [])
child_ous = [
ou.id for ou in self.org_backend.ou if ou.parent_id in requested_ous
]
for region in regions: for region in regions:
for account in accounts: for account in accounts:
instance = { targets.append((region, account))
"StackId": generate_stack_id(self.stack_name, region, account), for ou_id in requested_ous + child_ous:
"StackSetId": self.stackset_id, for acnt in all_accounts:
"Region": region, if acnt.parent_id == ou_id:
"Account": account, targets.append((region, acnt.id))
"Status": "CURRENT",
"ParameterOverrides": parameters or [], new_instances = []
} for region, account in targets:
new_instances.append(instance) instance = FakeStackInstance(
account_id=account,
region_name=region,
stackset_id=self.stackset_id,
stack_name=self.stack_name,
name=self.stackset_name,
template=self.template,
parameters=parameters,
permission_model=permission_model,
)
new_instances.append(instance)
self.stack_instances += new_instances self.stack_instances += new_instances
return new_instances return [i.to_dict() for i in new_instances]
def update( def update(
self, self,
@ -212,19 +340,17 @@ class FakeStackInstances(BaseModel):
for account in accounts: for account in accounts:
for region in regions: for region in regions:
instance = self.get_instance(account, region) instance = self.get_instance(account, region)
if parameters: instance.parameters = parameters or []
instance["ParameterOverrides"] = parameters
else:
instance["ParameterOverrides"] = []
def delete(self, accounts: List[str], regions: List[str]) -> None: def delete(self, accounts: List[str], regions: List[str]) -> None:
for i, instance in enumerate(self.stack_instances): for i, instance in enumerate(self.stack_instances):
if instance["Region"] in regions and instance["Account"] in accounts: if instance.region_name in regions and instance.account_id in accounts:
instance.delete()
self.stack_instances.pop(i) self.stack_instances.pop(i)
def get_instance(self, account: str, region: str) -> Dict[str, Any]: # type: ignore[return] def get_instance(self, account: str, region: str) -> FakeStackInstance: # type: ignore[return]
for i, instance in enumerate(self.stack_instances): for i, instance in enumerate(self.stack_instances):
if instance["Region"] == region and instance["Account"] == account: if instance.region_name == region and instance.account_id == account:
return self.stack_instances[i] return self.stack_instances[i]
@ -589,7 +715,11 @@ class CloudFormationBackend(BaseBackend):
template: str, template: str,
parameters: Dict[str, str], parameters: Dict[str, str],
tags: Dict[str, str], tags: Dict[str, str],
permission_model: str,
) -> FakeStackSet: ) -> FakeStackSet:
"""
The following parameters are not yet implemented: StackId, AdministrationRoleARN, AutoDeployment, ExecutionRoleName, CallAs, ClientRequestToken, ManagedExecution
"""
stackset_id = generate_stackset_id(name) stackset_id = generate_stackset_id(name)
new_stackset = FakeStackSet( new_stackset = FakeStackSet(
stackset_id=stackset_id, stackset_id=stackset_id,
@ -600,6 +730,7 @@ class CloudFormationBackend(BaseBackend):
parameters=parameters, parameters=parameters,
description=None, description=None,
tags=tags, tags=tags,
permission_model=permission_model,
) )
self.stacksets[stackset_id] = new_stackset self.stacksets[stackset_id] = new_stackset
return new_stackset return new_stackset
@ -614,12 +745,17 @@ class CloudFormationBackend(BaseBackend):
raise ValidationError(name) raise ValidationError(name)
def delete_stack_set(self, name: str) -> None: def delete_stack_set(self, name: str) -> None:
stacksets = self.stacksets.keys() stackset_to_delete: Optional[FakeStackSet] = None
if name in stacksets: if name in self.stacksets:
self.stacksets[name].delete() stackset_to_delete = self.stacksets[name]
for stackset in stacksets: for stackset in self.stacksets.values():
if self.stacksets[stackset].name == name: if stackset.name == name:
self.stacksets[stackset].delete() stackset_to_delete = stackset
if stackset_to_delete is not None:
if stackset_to_delete.stack_instances:
raise StackSetNotEmpty()
stackset_to_delete.delete()
def create_stack_instances( def create_stack_instances(
self, self,
@ -627,15 +763,20 @@ class CloudFormationBackend(BaseBackend):
accounts: List[str], accounts: List[str],
regions: List[str], regions: List[str],
parameters: List[Dict[str, str]], parameters: List[Dict[str, str]],
) -> FakeStackSet: deployment_targets: Optional[Dict[str, Any]],
) -> str:
"""
The following parameters are not yet implemented: DeploymentTargets.AccountFilterType, DeploymentTargets.AccountsUrl, OperationPreferences, CallAs
"""
stackset = self.get_stack_set(stackset_name) stackset = self.get_stack_set(stackset_name)
stackset.create_stack_instances( operation_id = stackset.create_stack_instances(
accounts=accounts, accounts=accounts,
regions=regions, regions=regions,
deployment_targets=deployment_targets,
parameters=parameters, parameters=parameters,
) )
return stackset return operation_id
def update_stack_instances( def update_stack_instances(
self, self,
@ -644,6 +785,9 @@ class CloudFormationBackend(BaseBackend):
regions: List[str], regions: List[str],
parameters: List[Dict[str, Any]], parameters: List[Dict[str, Any]],
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""
Calling this will update the parameters, but the actual resources are not updated
"""
stack_set = self.get_stack_set(stackset_name) stack_set = self.get_stack_set(stackset_name)
return stack_set.update_instances(accounts, regions, parameters) return stack_set.update_instances(accounts, regions, parameters)
@ -680,6 +824,9 @@ class CloudFormationBackend(BaseBackend):
def delete_stack_instances( def delete_stack_instances(
self, stackset_name: str, accounts: List[str], regions: List[str] self, stackset_name: str, accounts: List[str], regions: List[str]
) -> FakeStackSet: ) -> FakeStackSet:
"""
The following parameters are not yet implemented: DeploymentTargets, OperationPreferences, RetainStacks, OperationId, CallAs
"""
stackset = self.get_stack_set(stackset_name) stackset = self.get_stack_set(stackset_name)
stackset.delete_stack_instances(accounts, regions) stackset.delete_stack_instances(accounts, regions)
return stackset return stackset
@ -862,6 +1009,20 @@ class CloudFormationBackend(BaseBackend):
else: else:
return list(stacks) return list(stacks)
def describe_stack_instance(
self, stack_set_name: str, account_id: str, region: str
) -> Dict[str, Any]:
stack_set = self.get_stack_set(stack_set_name)
return stack_set.instances.get_instance(account_id, region).to_dict()
def list_stack_instances(self, stackset_name: str) -> List[Dict[str, Any]]:
"""
Pagination is not yet implemented.
The parameters StackInstanceAccount/StackInstanceRegion are not yet implemented.
"""
stack_set = self.get_stack_set(stackset_name)
return [i.to_dict() for i in stack_set.instances.stack_instances]
def list_change_sets(self) -> Iterable[FakeChangeSet]: def list_change_sets(self) -> Iterable[FakeChangeSet]:
return self.change_sets.values() return self.change_sets.values()

View File

@ -1,4 +1,5 @@
import json import json
import re
import yaml import yaml
from typing import Any, Dict, Tuple, List, Optional, Union from typing import Any, Dict, Tuple, List, Optional, Union
from urllib.parse import urlparse from urllib.parse import urlparse
@ -462,8 +463,13 @@ class CloudFormationResponse(BaseResponse):
def create_stack_set(self) -> str: def create_stack_set(self) -> str:
stackset_name = self._get_param("StackSetName") stackset_name = self._get_param("StackSetName")
if not re.match(r"^[a-zA-Z][-a-zA-Z0-9]*$", stackset_name):
raise ValidationError(
message=f"1 validation error detected: Value '{stackset_name}' at 'stackSetName' failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z][-a-zA-Z0-9]*"
)
stack_body = self._get_param("TemplateBody") stack_body = self._get_param("TemplateBody")
template_url = self._get_param("TemplateURL") template_url = self._get_param("TemplateURL")
permission_model = self._get_param("PermissionModel")
parameters_list = self._get_list_prefix("Parameters.member") parameters_list = self._get_list_prefix("Parameters.member")
tags = dict( tags = dict(
(item["key"], item["value"]) (item["key"], item["value"])
@ -481,7 +487,11 @@ class CloudFormationResponse(BaseResponse):
stack_body = self._get_stack_from_s3_url(template_url) stack_body = self._get_stack_from_s3_url(template_url)
stackset = self.cloudformation_backend.create_stack_set( stackset = self.cloudformation_backend.create_stack_set(
name=stackset_name, template=stack_body, parameters=parameters, tags=tags name=stackset_name,
template=stack_body,
parameters=parameters,
tags=tags,
permission_model=permission_model,
) )
if self.request_json: if self.request_json:
return json.dumps( return json.dumps(
@ -500,11 +510,25 @@ class CloudFormationResponse(BaseResponse):
accounts = self._get_multi_param("Accounts.member") accounts = self._get_multi_param("Accounts.member")
regions = self._get_multi_param("Regions.member") regions = self._get_multi_param("Regions.member")
parameters = self._get_multi_param("ParameterOverrides.member") parameters = self._get_multi_param("ParameterOverrides.member")
self.cloudformation_backend.create_stack_instances( deployment_targets = self._get_params().get("DeploymentTargets")
stackset_name, accounts, regions, parameters if deployment_targets and "OrganizationalUnitIds" in deployment_targets:
for ou_id in deployment_targets.get("OrganizationalUnitIds", []):
if not re.match(
r"^(ou-[a-z0-9]{4,32}-[a-z0-9]{8,32}|r-[a-z0-9]{4,32})$", ou_id
):
raise ValidationError(
message=f"1 validation error detected: Value '[{ou_id}]' at 'deploymentTargets.organizationalUnitIds' failed to satisfy constraint: Member must satisfy constraint: [Member must have length less than or equal to 68, Member must have length greater than or equal to 6, Member must satisfy regular expression pattern: ^(ou-[a-z0-9]{{4,32}}-[a-z0-9]{{8,32}}|r-[a-z0-9]{{4,32}})$]"
)
operation_id = self.cloudformation_backend.create_stack_instances(
stackset_name,
accounts,
regions,
parameters,
deployment_targets=deployment_targets,
) )
template = self.response_template(CREATE_STACK_INSTANCES_TEMPLATE) template = self.response_template(CREATE_STACK_INSTANCES_TEMPLATE)
return template.render() return template.render(operation_id=operation_id)
def delete_stack_set(self) -> str: def delete_stack_set(self) -> str:
stackset_name = self._get_param("StackSetName") stackset_name = self._get_param("StackSetName")
@ -540,9 +564,9 @@ class CloudFormationResponse(BaseResponse):
account = self._get_param("StackInstanceAccount") account = self._get_param("StackInstanceAccount")
region = self._get_param("StackInstanceRegion") region = self._get_param("StackInstanceRegion")
instance = self.cloudformation_backend.get_stack_set( instance = self.cloudformation_backend.describe_stack_instance(
stackset_name stackset_name, account, region
).instances.get_instance(account, region) )
template = self.response_template(DESCRIBE_STACK_INSTANCE_TEMPLATE) template = self.response_template(DESCRIBE_STACK_INSTANCE_TEMPLATE)
rendered = template.render(instance=instance) rendered = template.render(instance=instance)
return rendered return rendered
@ -554,9 +578,9 @@ class CloudFormationResponse(BaseResponse):
def list_stack_instances(self) -> str: def list_stack_instances(self) -> str:
stackset_name = self._get_param("StackSetName") stackset_name = self._get_param("StackSetName")
stackset = self.cloudformation_backend.get_stack_set(stackset_name) instances = self.cloudformation_backend.list_stack_instances(stackset_name)
template = self.response_template(LIST_STACK_INSTANCES_TEMPLATE) template = self.response_template(LIST_STACK_INSTANCES_TEMPLATE)
return template.render(stackset=stackset) return template.render(instances=instances)
def list_stack_set_operations(self) -> str: def list_stack_set_operations(self) -> str:
stackset_name = self._get_param("StackSetName") stackset_name = self._get_param("StackSetName")
@ -1046,7 +1070,7 @@ DELETE_STACK_SET_RESPONSE_TEMPLATE = """<DeleteStackSetResponse xmlns="http://in
CREATE_STACK_INSTANCES_TEMPLATE = """<CreateStackInstancesResponse xmlns="http://internal.amazon.com/coral/com.amazonaws.maestro.service.v20160713/"> CREATE_STACK_INSTANCES_TEMPLATE = """<CreateStackInstancesResponse xmlns="http://internal.amazon.com/coral/com.amazonaws.maestro.service.v20160713/">
<CreateStackInstancesResult> <CreateStackInstancesResult>
<OperationId>1459ad6d-63cc-4c96-a73e-example</OperationId> <OperationId>{{ operation_id }}</OperationId>
</CreateStackInstancesResult> </CreateStackInstancesResult>
<ResponseMetadata> <ResponseMetadata>
<RequestId>6b29f7e3-69be-4d32-b374-example</RequestId> <RequestId>6b29f7e3-69be-4d32-b374-example</RequestId>
@ -1057,13 +1081,13 @@ CREATE_STACK_INSTANCES_TEMPLATE = """<CreateStackInstancesResponse xmlns="http:/
LIST_STACK_INSTANCES_TEMPLATE = """<ListStackInstancesResponse xmlns="http://internal.amazon.com/coral/com.amazonaws.maestro.service.v20160713/"> LIST_STACK_INSTANCES_TEMPLATE = """<ListStackInstancesResponse xmlns="http://internal.amazon.com/coral/com.amazonaws.maestro.service.v20160713/">
<ListStackInstancesResult> <ListStackInstancesResult>
<Summaries> <Summaries>
{% for instance in stackset.stack_instances %} {% for instance in instances %}
<member> <member>
<StackId>{{ instance.StackId }}</StackId> <StackId>{{ instance["StackId"] }}</StackId>
<StackSetId>{{ instance.StackSetId }}</StackSetId> <StackSetId>{{ instance["StackSetId"] }}</StackSetId>
<Region>{{ instance.Region }}</Region> <Region>{{ instance["Region"] }}</Region>
<Account>{{ instance.Account }}</Account> <Account>{{ instance["Account"] }}</Account>
<Status>{{ instance.Status }}</Status> <Status>{{ instance["Status"] }}</Status>
</member> </member>
{% endfor %} {% endfor %}
</Summaries> </Summaries>
@ -1087,16 +1111,16 @@ DELETE_STACK_INSTANCES_TEMPLATE = """<DeleteStackInstancesResponse xmlns="http:/
DESCRIBE_STACK_INSTANCE_TEMPLATE = """<DescribeStackInstanceResponse xmlns="http://internal.amazon.com/coral/com.amazonaws.maestro.service.v20160713/"> DESCRIBE_STACK_INSTANCE_TEMPLATE = """<DescribeStackInstanceResponse xmlns="http://internal.amazon.com/coral/com.amazonaws.maestro.service.v20160713/">
<DescribeStackInstanceResult> <DescribeStackInstanceResult>
<StackInstance> <StackInstance>
<StackId>{{ instance.StackId }}</StackId> <StackId>{{ instance["StackId"] }}</StackId>
<StackSetId>{{ instance.StackSetId }}</StackSetId> <StackSetId>{{ instance["StackSetId"] }}</StackSetId>
{% if instance.ParameterOverrides %} {% if instance["ParameterOverrides"] %}
<ParameterOverrides> <ParameterOverrides>
{% for override in instance.ParameterOverrides %} {% for override in instance["ParameterOverrides"] %}
{% if override['ParameterKey'] or override['ParameterValue'] %} {% if override['ParameterKey'] or override['ParameterValue'] %}
<member> <member>
<ParameterKey>{{ override.ParameterKey }}</ParameterKey> <ParameterKey>{{ override["ParameterKey"] }}</ParameterKey>
<UsePreviousValue>false</UsePreviousValue> <UsePreviousValue>false</UsePreviousValue>
<ParameterValue>{{ override.ParameterValue }}</ParameterValue> <ParameterValue>{{ override["ParameterValue"] }}</ParameterValue>
</member> </member>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@ -1104,9 +1128,9 @@ DESCRIBE_STACK_INSTANCE_TEMPLATE = """<DescribeStackInstanceResponse xmlns="http
{% else %} {% else %}
<ParameterOverrides/> <ParameterOverrides/>
{% endif %} {% endif %}
<Region>{{ instance.Region }}</Region> <Region>{{ instance["Region"] }}</Region>
<Account>{{ instance.Account }}</Account> <Account>{{ instance["Account"] }}</Account>
<Status>{{ instance.Status }}</Status> <Status>{{ instance["Status"] }}</Status>
</StackInstance> </StackInstance>
</DescribeStackInstanceResult> </DescribeStackInstanceResult>
<ResponseMetadata> <ResponseMetadata>

View File

@ -0,0 +1,294 @@
import boto3
import json
import pytest
from botocore.exceptions import ClientError
from moto import mock_cloudformation, mock_organizations, mock_sqs, settings
from moto.core import DEFAULT_ACCOUNT_ID
from moto.cloudformation import cloudformation_backends as cf_backends
from moto.sqs import sqs_backends
from unittest import SkipTest, TestCase
from uuid import uuid4
TEMPLATE_DCT = {
"AWSTemplateFormatVersion": "2010-09-09",
"Description": "Template creating one queue",
"Resources": {
"MyQueue": {"Type": "AWS::SQS::Queue", "Properties": {"QueueName": "testqueue"}}
},
}
class TestStackSetMultipleAccounts(TestCase):
def setUp(self) -> None:
# Set up multiple organizations/accounts
# Our tests will create resources within these orgs/accounts
#
# | ROOT |
# / \
# / \
# [ou01] [ou02]
# | / | \
# | / [acct02] \
# [acct01] / \
# [ou21] [ou22]
# | |
# [acct21] [acct22]
#
if settings.TEST_SERVER_MODE:
raise SkipTest(
"These tests directly access the backend, which is not possible in ServerMode"
)
mockname = "mock-account"
mockdomain = "moto-example.org"
mockemail = "@".join([mockname, mockdomain])
self.org = boto3.client("organizations", region_name="us-east-1")
self.org.create_organization(FeatureSet="ALL")
self.acct01 = self.org.create_account(AccountName=mockname, Email=mockemail)[
"CreateAccountStatus"
]["AccountId"]
self.acct02 = self.org.create_account(AccountName=mockname, Email=mockemail)[
"CreateAccountStatus"
]["AccountId"]
self.acct21 = self.org.create_account(AccountName=mockname, Email=mockemail)[
"CreateAccountStatus"
]["AccountId"]
self.acct22 = self.org.create_account(AccountName=mockname, Email=mockemail)[
"CreateAccountStatus"
]["AccountId"]
root_id = self.org.list_roots()["Roots"][0]["Id"]
self.ou01 = self.org.create_organizational_unit(ParentId=root_id, Name="ou1")[
"OrganizationalUnit"
]["Id"]
self.ou02 = self.org.create_organizational_unit(ParentId=root_id, Name="ou2")[
"OrganizationalUnit"
]["Id"]
self.ou21 = self.org.create_organizational_unit(
ParentId=self.ou02, Name="ou021"
)["OrganizationalUnit"]["Id"]
self.ou22 = self.org.create_organizational_unit(
ParentId=self.ou02, Name="ou022"
)["OrganizationalUnit"]["Id"]
self.org.move_account(
AccountId=self.acct01, SourceParentId=root_id, DestinationParentId=self.ou01
)
self.org.move_account(
AccountId=self.acct02, SourceParentId=root_id, DestinationParentId=self.ou02
)
self.org.move_account(
AccountId=self.acct21, SourceParentId=root_id, DestinationParentId=self.ou21
)
self.org.move_account(
AccountId=self.acct22, SourceParentId=root_id, DestinationParentId=self.ou22
)
self.name = "a" + str(uuid4())[0:6]
self.client = boto3.client("cloudformation", region_name="us-east-1")
def _verify_stack_instance(self, accnt, region=None):
resp = self.client.describe_stack_instance(
StackSetName=self.name,
StackInstanceAccount=accnt,
StackInstanceRegion=region or "us-east-1",
)["StackInstance"]
resp.should.have.key("Account").equals(accnt)
def _verify_queues(self, accnt, expected, region=None):
list(sqs_backends[accnt][region or "us-east-1"].queues.keys()).should.equal(
expected
)
@mock_cloudformation
@mock_organizations
@mock_sqs
class TestServiceManagedStacks(TestStackSetMultipleAccounts):
def setUp(self) -> None:
super().setUp()
self.client.create_stack_set(
StackSetName=self.name,
Description="test creating stack set in multiple accounts",
TemplateBody=json.dumps(TEMPLATE_DCT),
PermissionModel="SERVICE_MANAGED",
AutoDeployment={"Enabled": False},
)
def test_create_instances__specifying_accounts(self):
with pytest.raises(ClientError) as exc:
self.client.create_stack_instances(
StackSetName=self.name, Accounts=["888781156701"], Regions=["us-east-1"]
)
err = exc.value.response["Error"]
err["Code"].should.equal("ValidationError")
err["Message"].should.equal(
"StackSets with SERVICE_MANAGED permission model can only have OrganizationalUnit as target"
)
def test_create_instances__specifying_only_accounts_in_deployment_targets(self):
with pytest.raises(ClientError) as exc:
self.client.create_stack_instances(
StackSetName=self.name,
DeploymentTargets={
"Accounts": ["888781156701"],
},
Regions=["us-east-1"],
)
err = exc.value.response["Error"]
err["Code"].should.equal("ValidationError")
err["Message"].should.equal("OrganizationalUnitIds are required")
def test_create_instances___with_invalid_ou(self):
with pytest.raises(ClientError) as exc:
self.client.create_stack_instances(
StackSetName=self.name,
DeploymentTargets={"OrganizationalUnitIds": ["unknown"]},
Regions=["us-east-1"],
)
err = exc.value.response["Error"]
err["Code"].should.equal("ValidationError")
err["Message"].should.equal(
"1 validation error detected: Value '[unknown]' at 'deploymentTargets.organizationalUnitIds' failed to satisfy constraint: Member must satisfy constraint: [Member must have length less than or equal to 68, Member must have length greater than or equal to 6, Member must satisfy regular expression pattern: ^(ou-[a-z0-9]{4,32}-[a-z0-9]{8,32}|r-[a-z0-9]{4,32})$]"
)
def test_create_instances__single_ou(self):
self.client.create_stack_instances(
StackSetName=self.name,
DeploymentTargets={"OrganizationalUnitIds": [self.ou01]},
Regions=["us-east-1"],
)
self._verify_stack_instance(self.acct01)
# We have a queue in our account connected to OU-01
self._verify_queues(self.acct01, ["testqueue"])
# No queue has been created in our main account, or unrelated accounts
self._verify_queues(DEFAULT_ACCOUNT_ID, [])
self._verify_queues(self.acct02, [])
self._verify_queues(self.acct21, [])
self._verify_queues(self.acct22, [])
def test_create_instances__ou_with_kiddos(self):
self.client.create_stack_instances(
StackSetName=self.name,
DeploymentTargets={"OrganizationalUnitIds": [self.ou02]},
Regions=["us-east-1"],
)
self._verify_stack_instance(self.acct02)
self._verify_stack_instance(self.acct21)
self._verify_stack_instance(self.acct22)
# No queue has been created in our main account
self._verify_queues(DEFAULT_ACCOUNT_ID, expected=[])
self._verify_queues(self.acct01, expected=[])
# But we do have a queue in our (child)-accounts of OU-02
self._verify_queues(self.acct02, ["testqueue"])
self._verify_queues(self.acct21, ["testqueue"])
self._verify_queues(self.acct22, ["testqueue"])
@mock_cloudformation
@mock_organizations
@mock_sqs
class TestSelfManagedStacks(TestStackSetMultipleAccounts):
def setUp(self) -> None:
super().setUp()
# Assumptions: All the necessary IAM roles are created
# https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/stacksets-prereqs-self-managed.html
self.client.create_stack_set(
StackSetName=self.name,
Description="test creating stack set in multiple accounts",
TemplateBody=json.dumps(TEMPLATE_DCT),
)
def test_create_instances__specifying_accounts(self):
self.client.create_stack_instances(
StackSetName=self.name, Accounts=[self.acct01], Regions=["us-east-2"]
)
self._verify_stack_instance(self.acct01, region="us-east-2")
# acct01 has a Stack
cf_backends[self.acct01]["us-east-2"].stacks.should.have.length_of(1)
# Other acounts do not
cf_backends[self.acct02]["us-east-2"].stacks.should.have.length_of(0)
cf_backends[self.acct21]["us-east-2"].stacks.should.have.length_of(0)
cf_backends[self.acct22]["us-east-2"].stacks.should.have.length_of(0)
cf_backends[DEFAULT_ACCOUNT_ID]["us-east-2"].stacks.should.have.length_of(0)
# acct01 has a queue
self._verify_queues(self.acct01, ["testqueue"], region="us-east-2")
# other accounts are empty
self._verify_queues(self.acct02, [], region="us-east-2")
self._verify_queues(self.acct21, [], region="us-east-2")
self._verify_queues(self.acct22, [], region="us-east-2")
self._verify_queues(DEFAULT_ACCOUNT_ID, [], region="us-east-2")
def test_create_instances__multiple_accounts(self):
self.client.create_stack_instances(
StackSetName=self.name,
Accounts=[self.acct01, self.acct02],
Regions=["us-east-2"],
)
# acct01 and 02 have a Stack
cf_backends[self.acct01]["us-east-2"].stacks.should.have.length_of(1)
cf_backends[self.acct02]["us-east-2"].stacks.should.have.length_of(1)
# Other acounts do not
cf_backends[self.acct21]["us-east-2"].stacks.should.have.length_of(0)
cf_backends[self.acct22]["us-east-2"].stacks.should.have.length_of(0)
cf_backends[DEFAULT_ACCOUNT_ID]["us-east-2"].stacks.should.have.length_of(0)
# acct01 and 02 have the queue
self._verify_queues(self.acct01, ["testqueue"], region="us-east-2")
self._verify_queues(self.acct02, ["testqueue"], region="us-east-2")
# other accounts are empty
self._verify_queues(self.acct21, [], region="us-east-2")
self._verify_queues(self.acct22, [], region="us-east-2")
self._verify_queues(DEFAULT_ACCOUNT_ID, [], region="us-east-2")
def test_delete_instances(self):
# Create two stack instances
self.client.create_stack_instances(
StackSetName=self.name,
Accounts=[self.acct01, self.acct02],
Regions=["us-east-2"],
)
# Delete one
self.client.delete_stack_instances(
StackSetName=self.name,
Accounts=[self.acct01],
Regions=["us-east-2"],
RetainStacks=False,
)
# Act02 still has it's stack
cf_backends[self.acct02]["us-east-2"].stacks.should.have.length_of(1)
# Other Stacks are removed
cf_backends[self.acct01]["us-east-2"].stacks.should.have.length_of(0)
cf_backends[self.acct21]["us-east-2"].stacks.should.have.length_of(0)
cf_backends[self.acct22]["us-east-2"].stacks.should.have.length_of(0)
cf_backends[DEFAULT_ACCOUNT_ID]["us-east-2"].stacks.should.have.length_of(0)
# Acct02 still has it's queue as well
self._verify_queues(self.acct02, ["testqueue"], region="us-east-2")
# Other queues are removed
self._verify_queues(self.acct01, [], region="us-east-2")
self._verify_queues(self.acct21, [], region="us-east-2")
self._verify_queues(self.acct22, [], region="us-east-2")
self._verify_queues(DEFAULT_ACCOUNT_ID, [], region="us-east-2")
def test_create_instances__single_ou(self):
with pytest.raises(ClientError) as exc:
self.client.create_stack_instances(
StackSetName=self.name,
DeploymentTargets={"OrganizationalUnitIds": [self.ou01]},
Regions=["us-east-1"],
)
err = exc.value.response["Error"]
err["Code"].should.equal("ValidationError")
err["Message"].should.equal(
"StackSets with SELF_MANAGED permission model can only have accounts as target"
)

View File

@ -341,20 +341,20 @@ def test_create_stack_with_termination_protection():
def test_describe_stack_instances(): def test_describe_stack_instances():
cf_conn = boto3.client("cloudformation", region_name="us-east-1") cf_conn = boto3.client("cloudformation", region_name="us-east-1")
cf_conn.create_stack_set( cf_conn.create_stack_set(
StackSetName="test_stack_set", TemplateBody=dummy_template_json StackSetName="teststackset", TemplateBody=dummy_template_json
) )
cf_conn.create_stack_instances( cf_conn.create_stack_instances(
StackSetName="test_stack_set", StackSetName="teststackset",
Accounts=[ACCOUNT_ID], Accounts=[ACCOUNT_ID],
Regions=["us-east-1", "us-west-2"], Regions=["us-east-1", "us-west-2"],
) )
usw2_instance = cf_conn.describe_stack_instance( usw2_instance = cf_conn.describe_stack_instance(
StackSetName="test_stack_set", StackSetName="teststackset",
StackInstanceAccount=ACCOUNT_ID, StackInstanceAccount=ACCOUNT_ID,
StackInstanceRegion="us-west-2", StackInstanceRegion="us-west-2",
) )
use1_instance = cf_conn.describe_stack_instance( use1_instance = cf_conn.describe_stack_instance(
StackSetName="test_stack_set", StackSetName="teststackset",
StackInstanceAccount=ACCOUNT_ID, StackInstanceAccount=ACCOUNT_ID,
StackInstanceRegion="us-east-1", StackInstanceRegion="us-east-1",
) )
@ -377,10 +377,10 @@ def test_describe_stack_instances():
def test_list_stacksets_length(): def test_list_stacksets_length():
cf_conn = boto3.client("cloudformation", region_name="us-east-1") cf_conn = boto3.client("cloudformation", region_name="us-east-1")
cf_conn.create_stack_set( cf_conn.create_stack_set(
StackSetName="test_stack_set", TemplateBody=dummy_template_json StackSetName="teststackset", TemplateBody=dummy_template_json
) )
cf_conn.create_stack_set( cf_conn.create_stack_set(
StackSetName="test_stack_set2", TemplateBody=dummy_template_yaml StackSetName="teststackset2", TemplateBody=dummy_template_yaml
) )
stacksets = cf_conn.list_stack_sets() stacksets = cf_conn.list_stack_sets()
stacksets.should.have.length_of(2) stacksets.should.have.length_of(2)
@ -403,11 +403,11 @@ def test_filter_stacks():
def test_list_stacksets_contents(): def test_list_stacksets_contents():
cf_conn = boto3.client("cloudformation", region_name="us-east-1") cf_conn = boto3.client("cloudformation", region_name="us-east-1")
cf_conn.create_stack_set( cf_conn.create_stack_set(
StackSetName="test_stack_set", TemplateBody=dummy_template_json StackSetName="teststackset", TemplateBody=dummy_template_json
) )
stacksets = cf_conn.list_stack_sets() stacksets = cf_conn.list_stack_sets()
stacksets["Summaries"][0].should.have.key("StackSetName").which.should.equal( stacksets["Summaries"][0].should.have.key("StackSetName").which.should.equal(
"test_stack_set" "teststackset"
) )
stacksets["Summaries"][0].should.have.key("Status").which.should.equal("ACTIVE") stacksets["Summaries"][0].should.have.key("Status").which.should.equal("ACTIVE")
@ -416,49 +416,43 @@ def test_list_stacksets_contents():
def test_stop_stack_set_operation(): def test_stop_stack_set_operation():
cf_conn = boto3.client("cloudformation", region_name="us-east-1") cf_conn = boto3.client("cloudformation", region_name="us-east-1")
cf_conn.create_stack_set( cf_conn.create_stack_set(
StackSetName="test_stack_set", TemplateBody=dummy_template_json StackSetName="teststackset", TemplateBody=dummy_template_json
) )
cf_conn.create_stack_instances( cf_conn.create_stack_instances(
StackSetName="test_stack_set", StackSetName="teststackset",
Accounts=[ACCOUNT_ID], Accounts=[ACCOUNT_ID],
Regions=["us-east-1", "us-west-1", "us-west-2"], Regions=["us-east-1", "us-west-1", "us-west-2"],
) )
operation_id = cf_conn.list_stack_set_operations(StackSetName="test_stack_set")[ operation_id = cf_conn.list_stack_set_operations(StackSetName="teststackset")[
"Summaries" "Summaries"
][-1]["OperationId"] ][-1]["OperationId"]
cf_conn.stop_stack_set_operation( cf_conn.stop_stack_set_operation(
StackSetName="test_stack_set", OperationId=operation_id StackSetName="teststackset", OperationId=operation_id
) )
list_operation = cf_conn.list_stack_set_operations(StackSetName="test_stack_set") list_operation = cf_conn.list_stack_set_operations(StackSetName="teststackset")
list_operation["Summaries"][-1]["Status"].should.equal("STOPPED") list_operation["Summaries"][-1]["Status"].should.equal("STOPPED")
@mock_cloudformation @mock_cloudformation
def test_describe_stack_set_operation(): def test_describe_stack_set_operation():
cf_conn = boto3.client("cloudformation", region_name="us-east-1") cf_conn = boto3.client("cloudformation", region_name="us-east-1")
cf_conn.create_stack_set( cf_conn.create_stack_set(StackSetName="name", TemplateBody=dummy_template_json)
StackSetName="test_stack_set", TemplateBody=dummy_template_json operation_id = cf_conn.create_stack_instances(
) StackSetName="name",
cf_conn.create_stack_instances(
StackSetName="test_stack_set",
Accounts=[ACCOUNT_ID], Accounts=[ACCOUNT_ID],
Regions=["us-east-1", "us-west-1", "us-west-2"], Regions=["us-east-1", "us-west-1", "us-west-2"],
) )["OperationId"]
operation_id = cf_conn.list_stack_set_operations(StackSetName="test_stack_set")[
"Summaries" cf_conn.stop_stack_set_operation(StackSetName="name", OperationId=operation_id)
][-1]["OperationId"]
cf_conn.stop_stack_set_operation(
StackSetName="test_stack_set", OperationId=operation_id
)
response = cf_conn.describe_stack_set_operation( response = cf_conn.describe_stack_set_operation(
StackSetName="test_stack_set", OperationId=operation_id StackSetName="name", OperationId=operation_id
) )
response["StackSetOperation"]["Status"].should.equal("STOPPED") response["StackSetOperation"]["Status"].should.equal("STOPPED")
response["StackSetOperation"]["Action"].should.equal("CREATE") response["StackSetOperation"]["Action"].should.equal("CREATE")
with pytest.raises(ClientError) as exp: with pytest.raises(ClientError) as exp:
cf_conn.describe_stack_set_operation( cf_conn.describe_stack_set_operation(
StackSetName="test_stack_set", OperationId="non_existing_operation" StackSetName="name", OperationId="non_existing_operation"
) )
exp_err = exp.value.response.get("Error") exp_err = exp.value.response.get("Error")
exp_metadata = exp.value.response.get("ResponseMetadata") exp_metadata = exp.value.response.get("ResponseMetadata")
@ -474,22 +468,22 @@ def test_describe_stack_set_operation():
def test_list_stack_set_operation_results(): def test_list_stack_set_operation_results():
cf_conn = boto3.client("cloudformation", region_name="us-east-1") cf_conn = boto3.client("cloudformation", region_name="us-east-1")
cf_conn.create_stack_set( cf_conn.create_stack_set(
StackSetName="test_stack_set", TemplateBody=dummy_template_json StackSetName="teststackset", TemplateBody=dummy_template_json
) )
cf_conn.create_stack_instances( cf_conn.create_stack_instances(
StackSetName="test_stack_set", StackSetName="teststackset",
Accounts=[ACCOUNT_ID], Accounts=[ACCOUNT_ID],
Regions=["us-east-1", "us-west-1", "us-west-2"], Regions=["us-east-1", "us-west-1", "us-west-2"],
) )
operation_id = cf_conn.list_stack_set_operations(StackSetName="test_stack_set")[ operation_id = cf_conn.list_stack_set_operations(StackSetName="teststackset")[
"Summaries" "Summaries"
][-1]["OperationId"] ][-1]["OperationId"]
cf_conn.stop_stack_set_operation( cf_conn.stop_stack_set_operation(
StackSetName="test_stack_set", OperationId=operation_id StackSetName="teststackset", OperationId=operation_id
) )
response = cf_conn.list_stack_set_operation_results( response = cf_conn.list_stack_set_operation_results(
StackSetName="test_stack_set", OperationId=operation_id StackSetName="teststackset", OperationId=operation_id
) )
response["Summaries"].should.have.length_of(3) response["Summaries"].should.have.length_of(3)
@ -501,41 +495,41 @@ def test_list_stack_set_operation_results():
def test_update_stack_instances(): def test_update_stack_instances():
cf_conn = boto3.client("cloudformation", region_name="us-east-1") cf_conn = boto3.client("cloudformation", region_name="us-east-1")
param = [ param = [
{"ParameterKey": "SomeParam", "ParameterValue": "StackSetValue"}, {"ParameterKey": "TagDescription", "ParameterValue": "StackSetValue"},
{"ParameterKey": "AnotherParam", "ParameterValue": "StackSetValue2"}, {"ParameterKey": "TagName", "ParameterValue": "StackSetValue2"},
] ]
param_overrides = [ param_overrides = [
{"ParameterKey": "SomeParam", "ParameterValue": "OverrideValue"}, {"ParameterKey": "TagDescription", "ParameterValue": "OverrideValue"},
{"ParameterKey": "AnotherParam", "ParameterValue": "OverrideValue2"}, {"ParameterKey": "TagName", "ParameterValue": "OverrideValue2"},
] ]
cf_conn.create_stack_set( cf_conn.create_stack_set(
StackSetName="test_stack_set", StackSetName="teststackset",
TemplateBody=dummy_template_yaml_with_ref, TemplateBody=dummy_template_yaml_with_ref,
Parameters=param, Parameters=param,
) )
cf_conn.create_stack_instances( cf_conn.create_stack_instances(
StackSetName="test_stack_set", StackSetName="teststackset",
Accounts=[ACCOUNT_ID], Accounts=[ACCOUNT_ID],
Regions=["us-east-1", "us-west-1", "us-west-2"], Regions=["us-east-1", "us-west-1", "us-west-2"],
) )
cf_conn.update_stack_instances( cf_conn.update_stack_instances(
StackSetName="test_stack_set", StackSetName="teststackset",
Accounts=[ACCOUNT_ID], Accounts=[ACCOUNT_ID],
Regions=["us-west-1", "us-west-2"], Regions=["us-west-1", "us-west-2"],
ParameterOverrides=param_overrides, ParameterOverrides=param_overrides,
) )
usw2_instance = cf_conn.describe_stack_instance( usw2_instance = cf_conn.describe_stack_instance(
StackSetName="test_stack_set", StackSetName="teststackset",
StackInstanceAccount=ACCOUNT_ID, StackInstanceAccount=ACCOUNT_ID,
StackInstanceRegion="us-west-2", StackInstanceRegion="us-west-2",
) )
usw1_instance = cf_conn.describe_stack_instance( usw1_instance = cf_conn.describe_stack_instance(
StackSetName="test_stack_set", StackSetName="teststackset",
StackInstanceAccount=ACCOUNT_ID, StackInstanceAccount=ACCOUNT_ID,
StackInstanceRegion="us-west-1", StackInstanceRegion="us-west-1",
) )
use1_instance = cf_conn.describe_stack_instance( use1_instance = cf_conn.describe_stack_instance(
StackSetName="test_stack_set", StackSetName="teststackset",
StackInstanceAccount=ACCOUNT_ID, StackInstanceAccount=ACCOUNT_ID,
StackInstanceRegion="us-east-1", StackInstanceRegion="us-east-1",
) )
@ -573,25 +567,26 @@ def test_update_stack_instances():
def test_delete_stack_instances(): def test_delete_stack_instances():
cf_conn = boto3.client("cloudformation", region_name="us-east-1") cf_conn = boto3.client("cloudformation", region_name="us-east-1")
cf_conn.create_stack_set( cf_conn.create_stack_set(
StackSetName="test_stack_set", TemplateBody=dummy_template_json StackSetName="teststackset", TemplateBody=dummy_template_json
) )
cf_conn.create_stack_instances( cf_conn.create_stack_instances(
StackSetName="test_stack_set", StackSetName="teststackset",
Accounts=[ACCOUNT_ID], Accounts=[ACCOUNT_ID],
Regions=["us-east-1", "us-west-2"], Regions=["us-east-1", "us-west-2"],
) )
cf_conn.delete_stack_instances( cf_conn.delete_stack_instances(
StackSetName="test_stack_set", StackSetName="teststackset",
Accounts=[ACCOUNT_ID], Accounts=[ACCOUNT_ID],
Regions=["us-east-1"], # Also delete unknown region for good measure - that should be a no-op
Regions=["us-east-1", "us-east-2"],
RetainStacks=False, RetainStacks=False,
) )
cf_conn.list_stack_instances(StackSetName="test_stack_set")[ cf_conn.list_stack_instances(StackSetName="teststackset")[
"Summaries" "Summaries"
].should.have.length_of(1) ].should.have.length_of(1)
cf_conn.list_stack_instances(StackSetName="test_stack_set")["Summaries"][0][ cf_conn.list_stack_instances(StackSetName="teststackset")["Summaries"][0][
"Region" "Region"
].should.equal("us-west-2") ].should.equal("us-west-2")
@ -600,18 +595,18 @@ def test_delete_stack_instances():
def test_create_stack_instances(): def test_create_stack_instances():
cf_conn = boto3.client("cloudformation", region_name="us-east-1") cf_conn = boto3.client("cloudformation", region_name="us-east-1")
cf_conn.create_stack_set( cf_conn.create_stack_set(
StackSetName="test_stack_set", TemplateBody=dummy_template_json StackSetName="teststackset", TemplateBody=dummy_template_json
) )
cf_conn.create_stack_instances( cf_conn.create_stack_instances(
StackSetName="test_stack_set", StackSetName="teststackset",
Accounts=[ACCOUNT_ID], Accounts=[ACCOUNT_ID],
Regions=["us-east-1", "us-west-2"], Regions=["us-east-1", "us-west-2"],
) )
cf_conn.list_stack_instances(StackSetName="test_stack_set")[ cf_conn.list_stack_instances(StackSetName="teststackset")[
"Summaries" "Summaries"
].should.have.length_of(2) ].should.have.length_of(2)
cf_conn.list_stack_instances(StackSetName="test_stack_set")["Summaries"][0][ cf_conn.list_stack_instances(StackSetName="teststackset")["Summaries"][0][
"Account" "Account"
].should.equal(ACCOUNT_ID) ].should.equal(ACCOUNT_ID)
@ -628,18 +623,18 @@ def test_create_stack_instances_with_param_overrides():
{"ParameterKey": "TagName", "ParameterValue": "OverrideValue2"}, {"ParameterKey": "TagName", "ParameterValue": "OverrideValue2"},
] ]
cf_conn.create_stack_set( cf_conn.create_stack_set(
StackSetName="test_stack_set", StackSetName="teststackset",
TemplateBody=dummy_template_yaml_with_ref, TemplateBody=dummy_template_yaml_with_ref,
Parameters=param, Parameters=param,
) )
cf_conn.create_stack_instances( cf_conn.create_stack_instances(
StackSetName="test_stack_set", StackSetName="teststackset",
Accounts=[ACCOUNT_ID], Accounts=[ACCOUNT_ID],
Regions=["us-east-1", "us-west-2"], Regions=["us-east-1", "us-west-2"],
ParameterOverrides=param_overrides, ParameterOverrides=param_overrides,
) )
usw2_instance = cf_conn.describe_stack_instance( usw2_instance = cf_conn.describe_stack_instance(
StackSetName="test_stack_set", StackSetName="teststackset",
StackInstanceAccount=ACCOUNT_ID, StackInstanceAccount=ACCOUNT_ID,
StackInstanceRegion="us-west-2", StackInstanceRegion="us-west-2",
) )
@ -670,16 +665,16 @@ def test_update_stack_set():
{"ParameterKey": "TagName", "ParameterValue": "OverrideValue2"}, {"ParameterKey": "TagName", "ParameterValue": "OverrideValue2"},
] ]
cf_conn.create_stack_set( cf_conn.create_stack_set(
StackSetName="test_stack_set", StackSetName="teststackset",
TemplateBody=dummy_template_yaml_with_ref, TemplateBody=dummy_template_yaml_with_ref,
Parameters=param, Parameters=param,
) )
cf_conn.update_stack_set( cf_conn.update_stack_set(
StackSetName="test_stack_set", StackSetName="teststackset",
TemplateBody=dummy_template_yaml_with_ref, TemplateBody=dummy_template_yaml_with_ref,
Parameters=param_overrides, Parameters=param_overrides,
) )
stackset = cf_conn.describe_stack_set(StackSetName="test_stack_set") stackset = cf_conn.describe_stack_set(StackSetName="teststackset")
stackset["StackSet"]["Parameters"][0]["ParameterValue"].should.equal( stackset["StackSet"]["Parameters"][0]["ParameterValue"].should.equal(
param_overrides[0]["ParameterValue"] param_overrides[0]["ParameterValue"]
@ -707,16 +702,16 @@ def test_update_stack_set_with_previous_value():
{"ParameterKey": "TagName", "UsePreviousValue": True}, {"ParameterKey": "TagName", "UsePreviousValue": True},
] ]
cf_conn.create_stack_set( cf_conn.create_stack_set(
StackSetName="test_stack_set", StackSetName="teststackset",
TemplateBody=dummy_template_yaml_with_ref, TemplateBody=dummy_template_yaml_with_ref,
Parameters=param, Parameters=param,
) )
cf_conn.update_stack_set( cf_conn.update_stack_set(
StackSetName="test_stack_set", StackSetName="teststackset",
TemplateBody=dummy_template_yaml_with_ref, TemplateBody=dummy_template_yaml_with_ref,
Parameters=param_overrides, Parameters=param_overrides,
) )
stackset = cf_conn.describe_stack_set(StackSetName="test_stack_set") stackset = cf_conn.describe_stack_set(StackSetName="teststackset")
stackset["StackSet"]["Parameters"][0]["ParameterValue"].should.equal( stackset["StackSet"]["Parameters"][0]["ParameterValue"].should.equal(
param_overrides[0]["ParameterValue"] param_overrides[0]["ParameterValue"]
@ -736,20 +731,20 @@ def test_update_stack_set_with_previous_value():
def test_list_stack_set_operations(): def test_list_stack_set_operations():
cf_conn = boto3.client("cloudformation", region_name="us-east-1") cf_conn = boto3.client("cloudformation", region_name="us-east-1")
cf_conn.create_stack_set( cf_conn.create_stack_set(
StackSetName="test_stack_set", TemplateBody=dummy_template_json StackSetName="teststackset", TemplateBody=dummy_template_json
) )
cf_conn.create_stack_instances( cf_conn.create_stack_instances(
StackSetName="test_stack_set", StackSetName="teststackset",
Accounts=[ACCOUNT_ID], Accounts=[ACCOUNT_ID],
Regions=["us-east-1", "us-west-2"], Regions=["us-east-1", "us-west-2"],
) )
cf_conn.update_stack_instances( cf_conn.update_stack_instances(
StackSetName="test_stack_set", StackSetName="teststackset",
Accounts=[ACCOUNT_ID], Accounts=[ACCOUNT_ID],
Regions=["us-east-1", "us-west-2"], Regions=["us-east-1", "us-west-2"],
) )
list_operation = cf_conn.list_stack_set_operations(StackSetName="test_stack_set") list_operation = cf_conn.list_stack_set_operations(StackSetName="teststackset")
list_operation["Summaries"].should.have.length_of(2) list_operation["Summaries"].should.have.length_of(2)
list_operation["Summaries"][-1]["Action"].should.equal("UPDATE") list_operation["Summaries"][-1]["Action"].should.equal("UPDATE")
@ -759,18 +754,18 @@ def test_bad_list_stack_resources():
cf_conn = boto3.client("cloudformation", region_name="us-east-1") cf_conn = boto3.client("cloudformation", region_name="us-east-1")
with pytest.raises(ClientError): with pytest.raises(ClientError):
cf_conn.list_stack_resources(StackName="test_stack_set") cf_conn.list_stack_resources(StackName="teststackset")
@mock_cloudformation @mock_cloudformation
def test_delete_stack_set_by_name(): def test_delete_stack_set_by_name():
cf_conn = boto3.client("cloudformation", region_name="us-east-1") cf_conn = boto3.client("cloudformation", region_name="us-east-1")
cf_conn.create_stack_set( cf_conn.create_stack_set(
StackSetName="test_stack_set", TemplateBody=dummy_template_json StackSetName="teststackset", TemplateBody=dummy_template_json
) )
cf_conn.delete_stack_set(StackSetName="test_stack_set") cf_conn.delete_stack_set(StackSetName="teststackset")
cf_conn.describe_stack_set(StackSetName="test_stack_set")["StackSet"][ cf_conn.describe_stack_set(StackSetName="teststackset")["StackSet"][
"Status" "Status"
].should.equal("DELETED") ].should.equal("DELETED")
@ -779,37 +774,76 @@ def test_delete_stack_set_by_name():
def test_delete_stack_set_by_id(): def test_delete_stack_set_by_id():
cf_conn = boto3.client("cloudformation", region_name="us-east-1") cf_conn = boto3.client("cloudformation", region_name="us-east-1")
response = cf_conn.create_stack_set( response = cf_conn.create_stack_set(
StackSetName="test_stack_set", TemplateBody=dummy_template_json StackSetName="teststackset", TemplateBody=dummy_template_json
) )
stack_set_id = response["StackSetId"] stack_set_id = response["StackSetId"]
cf_conn.delete_stack_set(StackSetName=stack_set_id) cf_conn.delete_stack_set(StackSetName=stack_set_id)
cf_conn.describe_stack_set(StackSetName="test_stack_set")["StackSet"][ cf_conn.describe_stack_set(StackSetName="teststackset")["StackSet"][
"Status" "Status"
].should.equal("DELETED") ].should.equal("DELETED")
@mock_cloudformation
def test_delete_stack_set__while_instances_are_running():
cf_conn = boto3.client("cloudformation", region_name="us-east-1")
cf_conn.create_stack_set(StackSetName="a", TemplateBody=json.dumps(dummy_template3))
cf_conn.create_stack_instances(
StackSetName="a",
Accounts=[ACCOUNT_ID],
Regions=["us-east-1"],
)
with pytest.raises(ClientError) as exc:
cf_conn.delete_stack_set(StackSetName="a")
err = exc.value.response["Error"]
err["Code"].should.equal("StackSetNotEmptyException")
err["Message"].should.equal("StackSet is not empty")
cf_conn.delete_stack_instances(
StackSetName="a",
Accounts=[ACCOUNT_ID],
Regions=["us-east-1"],
RetainStacks=False,
)
# This will succeed when no StackInstances are left
cf_conn.delete_stack_set(StackSetName="a")
@mock_cloudformation @mock_cloudformation
def test_create_stack_set(): def test_create_stack_set():
cf_conn = boto3.client("cloudformation", region_name="us-east-1") cf_conn = boto3.client("cloudformation", region_name="us-east-1")
response = cf_conn.create_stack_set( response = cf_conn.create_stack_set(
StackSetName="test_stack_set", TemplateBody=dummy_template_json StackSetName="teststackset", TemplateBody=dummy_template_json
) )
cf_conn.describe_stack_set(StackSetName="test_stack_set")["StackSet"][ cf_conn.describe_stack_set(StackSetName="teststackset")["StackSet"][
"TemplateBody" "TemplateBody"
].should.equal(dummy_template_json) ].should.equal(dummy_template_json)
response["StackSetId"].should_not.equal(None) response["StackSetId"].should_not.equal(None)
@mock_cloudformation
@pytest.mark.parametrize("name", ["1234", "stack_set", "-set"])
def test_create_stack_set__invalid_name(name):
client = boto3.client("cloudformation", region_name="us-east-1")
with pytest.raises(ClientError) as exc:
client.create_stack_set(StackSetName=name)
err = exc.value.response["Error"]
err["Code"].should.equal("ValidationError")
err["Message"].should.equal(
f"1 validation error detected: Value '{name}' at 'stackSetName' failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z][-a-zA-Z0-9]*"
)
@mock_cloudformation @mock_cloudformation
def test_create_stack_set_with_yaml(): def test_create_stack_set_with_yaml():
cf_conn = boto3.client("cloudformation", region_name="us-east-1") cf_conn = boto3.client("cloudformation", region_name="us-east-1")
cf_conn.create_stack_set( cf_conn.create_stack_set(
StackSetName="test_stack_set", TemplateBody=dummy_template_yaml StackSetName="teststackset", TemplateBody=dummy_template_yaml
) )
cf_conn.describe_stack_set(StackSetName="test_stack_set")["StackSet"][ cf_conn.describe_stack_set(StackSetName="teststackset")["StackSet"][
"TemplateBody" "TemplateBody"
].should.equal(dummy_template_yaml) ].should.equal(dummy_template_yaml)
@ -827,8 +861,8 @@ def test_create_stack_set_from_s3_url():
) )
cf_conn = boto3.client("cloudformation", region_name="us-west-1") cf_conn = boto3.client("cloudformation", region_name="us-west-1")
cf_conn.create_stack_set(StackSetName="stack_from_url", TemplateURL=key_url) cf_conn.create_stack_set(StackSetName="stackfromurl", TemplateURL=key_url)
cf_conn.describe_stack_set(StackSetName="stack_from_url")["StackSet"][ cf_conn.describe_stack_set(StackSetName="stackfromurl")["StackSet"][
"TemplateBody" "TemplateBody"
].should.equal(dummy_template_json) ].should.equal(dummy_template_json)
@ -841,12 +875,12 @@ def test_create_stack_set_with_ref_yaml():
{"ParameterKey": "TagName", "ParameterValue": "name_ref"}, {"ParameterKey": "TagName", "ParameterValue": "name_ref"},
] ]
cf_conn.create_stack_set( cf_conn.create_stack_set(
StackSetName="test_stack", StackSetName="teststack",
TemplateBody=dummy_template_yaml_with_ref, TemplateBody=dummy_template_yaml_with_ref,
Parameters=params, Parameters=params,
) )
cf_conn.describe_stack_set(StackSetName="test_stack")["StackSet"][ cf_conn.describe_stack_set(StackSetName="teststack")["StackSet"][
"TemplateBody" "TemplateBody"
].should.equal(dummy_template_yaml_with_ref) ].should.equal(dummy_template_yaml_with_ref)
@ -859,12 +893,12 @@ def test_describe_stack_set_params():
{"ParameterKey": "TagName", "ParameterValue": "name_ref"}, {"ParameterKey": "TagName", "ParameterValue": "name_ref"},
] ]
cf_conn.create_stack_set( cf_conn.create_stack_set(
StackSetName="test_stack", StackSetName="teststack",
TemplateBody=dummy_template_yaml_with_ref, TemplateBody=dummy_template_yaml_with_ref,
Parameters=params, Parameters=params,
) )
cf_conn.describe_stack_set(StackSetName="test_stack")["StackSet"][ cf_conn.describe_stack_set(StackSetName="teststack")["StackSet"][
"Parameters" "Parameters"
].should.equal(params) ].should.equal(params)
@ -873,7 +907,7 @@ def test_describe_stack_set_params():
def test_describe_stack_set_by_id(): def test_describe_stack_set_by_id():
cf_conn = boto3.client("cloudformation", region_name="us-east-1") cf_conn = boto3.client("cloudformation", region_name="us-east-1")
response = cf_conn.create_stack_set( response = cf_conn.create_stack_set(
StackSetName="test_stack", TemplateBody=dummy_template_json StackSetName="teststack", TemplateBody=dummy_template_json
) )
stack_set_id = response["StackSetId"] stack_set_id = response["StackSetId"]
@ -968,7 +1002,7 @@ def test_get_template_summary():
key_url = s3.generate_presigned_url( key_url = s3.generate_presigned_url(
ClientMethod="get_object", Params={"Bucket": "foobar", "Key": "template-key"} ClientMethod="get_object", Params={"Bucket": "foobar", "Key": "template-key"}
) )
conn.create_stack(StackName="stack_from_url", TemplateURL=key_url) conn.create_stack(StackName="stackfromurl", TemplateURL=key_url)
result = conn.get_template_summary(TemplateURL=key_url) result = conn.get_template_summary(TemplateURL=key_url)
result["ResourceTypes"].should.equal(["AWS::EC2::VPC"]) result["ResourceTypes"].should.equal(["AWS::EC2::VPC"])
result["Version"].should.equal("2010-09-09") result["Version"].should.equal("2010-09-09")
@ -1169,8 +1203,8 @@ def test_create_stack_from_s3_url():
) )
cf_conn = boto3.client("cloudformation", region_name="us-west-1") cf_conn = boto3.client("cloudformation", region_name="us-west-1")
cf_conn.create_stack(StackName="stack_from_url", TemplateURL=key_url) cf_conn.create_stack(StackName="stackfromurl", TemplateURL=key_url)
cf_conn.get_template(StackName="stack_from_url")["TemplateBody"].should.equal( cf_conn.get_template(StackName="stackfromurl")["TemplateBody"].should.equal(
json.loads(dummy_template_json, object_pairs_hook=OrderedDict) json.loads(dummy_template_json, object_pairs_hook=OrderedDict)
) )