CloudFormation: Improve behaviour of StackInstances (#5731)
This commit is contained in:
		
							parent
							
								
									e4c0d0618c
								
							
						
					
					
						commit
						aeb507f091
					
				| @ -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 | ||||||
|  | |||||||
| @ -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 | ||||||
|  | |||||||
| @ -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) | ||||||
|  | |||||||
| @ -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() | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -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> | ||||||
|  | |||||||
							
								
								
									
										294
									
								
								tests/test_cloudformation/test_cloudformation_multi_accounts.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										294
									
								
								tests/test_cloudformation/test_cloudformation_multi_accounts.py
									
									
									
									
									
										Normal 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" | ||||||
|  |         ) | ||||||
| @ -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) | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user