CloudFormation - support AWS::CloudFormation::Stack-resources (#6086)
This commit is contained in:
parent
8bf55cbe0e
commit
75d1018c28
@ -25,6 +25,7 @@ from .utils import (
|
|||||||
generate_stackset_id,
|
generate_stackset_id,
|
||||||
yaml_tag_constructor,
|
yaml_tag_constructor,
|
||||||
validate_template_cfn_lint,
|
validate_template_cfn_lint,
|
||||||
|
get_stack_from_s3_url,
|
||||||
)
|
)
|
||||||
from .exceptions import ValidationError, StackSetNotEmpty, StackSetNotFoundException
|
from .exceptions import ValidationError, StackSetNotEmpty, StackSetNotFoundException
|
||||||
|
|
||||||
@ -356,7 +357,7 @@ class FakeStackInstances(BaseModel):
|
|||||||
return self.stack_instances[i]
|
return self.stack_instances[i]
|
||||||
|
|
||||||
|
|
||||||
class FakeStack(BaseModel):
|
class FakeStack(CloudFormationModel):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
stack_id: str,
|
stack_id: str,
|
||||||
@ -532,6 +533,68 @@ class FakeStack(BaseModel):
|
|||||||
self._add_stack_event("DELETE_COMPLETE")
|
self._add_stack_event("DELETE_COMPLETE")
|
||||||
self.status = "DELETE_COMPLETE"
|
self.status = "DELETE_COMPLETE"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cloudformation_type() -> str:
|
||||||
|
return "AWS::CloudFormation::Stack"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def has_cfn_attr(cls, attr: str) -> bool: # pylint: disable=unused-argument
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def physical_resource_id(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_from_cloudformation_json( # type: ignore[misc]
|
||||||
|
cls,
|
||||||
|
resource_name: str,
|
||||||
|
cloudformation_json: Dict[str, Any],
|
||||||
|
account_id: str,
|
||||||
|
region_name: str,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> "FakeStack":
|
||||||
|
cf_backend: CloudFormationBackend = cloudformation_backends[account_id][
|
||||||
|
region_name
|
||||||
|
]
|
||||||
|
properties = cloudformation_json["Properties"]
|
||||||
|
|
||||||
|
template_body = get_stack_from_s3_url(properties["TemplateURL"], account_id)
|
||||||
|
parameters = properties.get("Parameters", {})
|
||||||
|
|
||||||
|
return cf_backend.create_stack(
|
||||||
|
name=resource_name, template=template_body, parameters=parameters
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_from_cloudformation_json( # type: ignore[misc]
|
||||||
|
cls,
|
||||||
|
original_resource: Any,
|
||||||
|
new_resource_name: str,
|
||||||
|
cloudformation_json: Any,
|
||||||
|
account_id: str,
|
||||||
|
region_name: str,
|
||||||
|
) -> "FakeStack":
|
||||||
|
cls.delete_from_cloudformation_json(
|
||||||
|
original_resource.name, cloudformation_json, account_id, region_name
|
||||||
|
)
|
||||||
|
return cls.create_from_cloudformation_json(
|
||||||
|
new_resource_name, cloudformation_json, account_id, region_name
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def delete_from_cloudformation_json( # type: ignore[misc]
|
||||||
|
cls,
|
||||||
|
resource_name: str,
|
||||||
|
cloudformation_json: Dict[str, Any],
|
||||||
|
account_id: str,
|
||||||
|
region_name: str,
|
||||||
|
) -> None:
|
||||||
|
cf_backend: CloudFormationBackend = cloudformation_backends[account_id][
|
||||||
|
region_name
|
||||||
|
]
|
||||||
|
cf_backend.delete_stack(resource_name)
|
||||||
|
|
||||||
|
|
||||||
class FakeChange(BaseModel):
|
class FakeChange(BaseModel):
|
||||||
def __init__(self, action: str, logical_resource_id: str, resource_type: str):
|
def __init__(self, action: str, logical_resource_id: str, resource_type: str):
|
||||||
|
@ -6,6 +6,7 @@ import warnings
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
import collections.abc as collections_abc
|
import collections.abc as collections_abc
|
||||||
|
from functools import lru_cache
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
Dict,
|
Dict,
|
||||||
@ -70,13 +71,6 @@ from .exceptions import (
|
|||||||
UnsupportedAttribute,
|
UnsupportedAttribute,
|
||||||
)
|
)
|
||||||
|
|
||||||
# List of supported CloudFormation models
|
|
||||||
MODEL_LIST = CloudFormationModel.__subclasses__()
|
|
||||||
MODEL_MAP = {model.cloudformation_type(): model for model in MODEL_LIST}
|
|
||||||
NAME_TYPE_MAP = {
|
|
||||||
model.cloudformation_type(): model.cloudformation_name_type()
|
|
||||||
for model in MODEL_LIST
|
|
||||||
}
|
|
||||||
CF_MODEL = TypeVar("CF_MODEL", bound=CloudFormationModel)
|
CF_MODEL = TypeVar("CF_MODEL", bound=CloudFormationModel)
|
||||||
|
|
||||||
# Just ignore these models types for now
|
# Just ignore these models types for now
|
||||||
@ -90,6 +84,25 @@ DEFAULT_REGION = "us-east-1"
|
|||||||
logger = logging.getLogger("moto")
|
logger = logging.getLogger("moto")
|
||||||
|
|
||||||
|
|
||||||
|
# List of supported CloudFormation models
|
||||||
|
@lru_cache()
|
||||||
|
def get_model_list() -> List[Type[CloudFormationModel]]:
|
||||||
|
return CloudFormationModel.__subclasses__()
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_model_map() -> Dict[str, Type[CloudFormationModel]]:
|
||||||
|
return {model.cloudformation_type(): model for model in get_model_list()}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_name_type_map() -> Dict[str, str]:
|
||||||
|
return {
|
||||||
|
model.cloudformation_type(): model.cloudformation_name_type()
|
||||||
|
for model in get_model_list()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Output(object):
|
class Output(object):
|
||||||
def __init__(self, key: str, value: str, description: str):
|
def __init__(self, key: str, value: str, description: str):
|
||||||
self.description = description
|
self.description = description
|
||||||
@ -250,18 +263,19 @@ def resource_class_from_type(resource_type: str) -> Type[CloudFormationModel]:
|
|||||||
return None # type: ignore[return-value]
|
return None # type: ignore[return-value]
|
||||||
if resource_type.startswith("Custom::"):
|
if resource_type.startswith("Custom::"):
|
||||||
return CustomModel
|
return CustomModel
|
||||||
if resource_type not in MODEL_MAP:
|
if resource_type not in get_model_map():
|
||||||
logger.warning("No Moto CloudFormation support for %s", resource_type)
|
logger.warning("No Moto CloudFormation support for %s", resource_type)
|
||||||
return None # type: ignore[return-value]
|
return None # type: ignore[return-value]
|
||||||
|
|
||||||
return MODEL_MAP.get(resource_type) # type: ignore[return-value]
|
return get_model_map()[resource_type] # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
def resource_name_property_from_type(resource_type: str) -> Optional[str]:
|
def resource_name_property_from_type(resource_type: str) -> Optional[str]:
|
||||||
for model in MODEL_LIST:
|
for model in get_model_list():
|
||||||
if model.cloudformation_type() == resource_type:
|
if model.cloudformation_type() == resource_type:
|
||||||
return model.cloudformation_name_type()
|
return model.cloudformation_name_type()
|
||||||
return NAME_TYPE_MAP.get(resource_type)
|
|
||||||
|
return get_name_type_map().get(resource_type)
|
||||||
|
|
||||||
|
|
||||||
def generate_resource_name(resource_type: str, stack_name: str, logical_id: str) -> str:
|
def generate_resource_name(resource_type: str, stack_name: str, logical_id: str) -> str:
|
||||||
@ -830,9 +844,9 @@ class ResourceMap(collections_abc.Mapping): # type: ignore[type-arg]
|
|||||||
not isinstance(parsed_resource, str)
|
not isinstance(parsed_resource, str)
|
||||||
and parsed_resource is not None
|
and parsed_resource is not None
|
||||||
):
|
):
|
||||||
if parsed_resource and hasattr(parsed_resource, "delete"):
|
try:
|
||||||
parsed_resource.delete(self._account_id, self._region_name)
|
parsed_resource.delete(self._account_id, self._region_name)
|
||||||
else:
|
except (TypeError, AttributeError):
|
||||||
if hasattr(parsed_resource, "physical_resource_id"):
|
if hasattr(parsed_resource, "physical_resource_id"):
|
||||||
resource_name = parsed_resource.physical_resource_id
|
resource_name = parsed_resource.physical_resource_id
|
||||||
else:
|
else:
|
||||||
|
@ -2,17 +2,15 @@ import json
|
|||||||
import re
|
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 yaml.parser import ParserError # pylint:disable=c-extension-no-member
|
from yaml.parser import ParserError # pylint:disable=c-extension-no-member
|
||||||
from yaml.scanner import ScannerError # pylint:disable=c-extension-no-member
|
from yaml.scanner import ScannerError # pylint:disable=c-extension-no-member
|
||||||
|
|
||||||
from moto.core.responses import BaseResponse
|
from moto.core.responses import BaseResponse
|
||||||
from moto.s3.models import s3_backends
|
|
||||||
from moto.s3.exceptions import S3ClientError
|
from moto.s3.exceptions import S3ClientError
|
||||||
from moto.utilities.aws_headers import amzn_request_id
|
from moto.utilities.aws_headers import amzn_request_id
|
||||||
from .models import cloudformation_backends, CloudFormationBackend, FakeStack
|
from .models import cloudformation_backends, CloudFormationBackend, FakeStack
|
||||||
from .exceptions import ValidationError, MissingParameterError
|
from .exceptions import ValidationError, MissingParameterError
|
||||||
from .utils import yaml_tag_constructor
|
from .utils import yaml_tag_constructor, get_stack_from_s3_url
|
||||||
|
|
||||||
|
|
||||||
def get_template_summary_response_from_template(template_body: str) -> Dict[str, Any]:
|
def get_template_summary_response_from_template(template_body: str) -> Dict[str, Any]:
|
||||||
@ -54,28 +52,7 @@ class CloudFormationResponse(BaseResponse):
|
|||||||
return cls.dispatch(request=request, full_url=full_url, headers=headers)
|
return cls.dispatch(request=request, full_url=full_url, headers=headers)
|
||||||
|
|
||||||
def _get_stack_from_s3_url(self, template_url: str) -> str:
|
def _get_stack_from_s3_url(self, template_url: str) -> str:
|
||||||
template_url_parts = urlparse(template_url)
|
return get_stack_from_s3_url(template_url, account_id=self.current_account)
|
||||||
if "localhost" in template_url:
|
|
||||||
bucket_name, key_name = template_url_parts.path.lstrip("/").split("/", 1)
|
|
||||||
else:
|
|
||||||
if template_url_parts.netloc.endswith(
|
|
||||||
"amazonaws.com"
|
|
||||||
) and template_url_parts.netloc.startswith("s3"):
|
|
||||||
# Handle when S3 url uses amazon url with bucket in path
|
|
||||||
# Also handles getting region as technically s3 is region'd
|
|
||||||
|
|
||||||
# region = template_url.netloc.split('.')[1]
|
|
||||||
bucket_name, key_name = template_url_parts.path.lstrip("/").split(
|
|
||||||
"/", 1
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
bucket_name = template_url_parts.netloc.split(".")[0]
|
|
||||||
key_name = template_url_parts.path.lstrip("/")
|
|
||||||
|
|
||||||
key = s3_backends[self.current_account]["global"].get_object(
|
|
||||||
bucket_name, key_name
|
|
||||||
)
|
|
||||||
return key.value.decode("utf-8")
|
|
||||||
|
|
||||||
def _get_params_from_list(
|
def _get_params_from_list(
|
||||||
self, parameters_list: List[Dict[str, Any]]
|
self, parameters_list: List[Dict[str, Any]]
|
||||||
|
@ -3,6 +3,7 @@ import os
|
|||||||
import string
|
import string
|
||||||
from moto.moto_api._internal import mock_random as random
|
from moto.moto_api._internal import mock_random as random
|
||||||
from typing import Any, List
|
from typing import Any, List
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
def generate_stack_id(stack_name: str, region: str, account: str) -> str:
|
def generate_stack_id(stack_name: str, region: str, account: str) -> str:
|
||||||
@ -84,3 +85,26 @@ def validate_template_cfn_lint(template: str) -> List[Any]:
|
|||||||
matches = core.run_checks(abs_filename, template, rules, regions)
|
matches = core.run_checks(abs_filename, template, rules, regions)
|
||||||
|
|
||||||
return matches
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def get_stack_from_s3_url(template_url: str, account_id: str) -> str:
|
||||||
|
from moto.s3.models import s3_backends
|
||||||
|
|
||||||
|
template_url_parts = urlparse(template_url)
|
||||||
|
if "localhost" in template_url:
|
||||||
|
bucket_name, key_name = template_url_parts.path.lstrip("/").split("/", 1)
|
||||||
|
else:
|
||||||
|
if template_url_parts.netloc.endswith(
|
||||||
|
"amazonaws.com"
|
||||||
|
) and template_url_parts.netloc.startswith("s3"):
|
||||||
|
# Handle when S3 url uses amazon url with bucket in path
|
||||||
|
# Also handles getting region as technically s3 is region'd
|
||||||
|
|
||||||
|
# region = template_url.netloc.split('.')[1]
|
||||||
|
bucket_name, key_name = template_url_parts.path.lstrip("/").split("/", 1)
|
||||||
|
else:
|
||||||
|
bucket_name = template_url_parts.netloc.split(".")[0]
|
||||||
|
key_name = template_url_parts.path.lstrip("/")
|
||||||
|
|
||||||
|
key = s3_backends[account_id]["global"].get_object(bucket_name, key_name)
|
||||||
|
return key.value.decode("utf-8")
|
||||||
|
@ -4,7 +4,7 @@ import datetime
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from moto.core import BaseBackend, BackendDict, BaseModel, CloudFormationModel
|
from moto.core import BaseBackend, BackendDict, BaseModel
|
||||||
from moto.core.utils import unix_time
|
from moto.core.utils import unix_time
|
||||||
from .comparisons import get_comparison_func
|
from .comparisons import get_comparison_func
|
||||||
|
|
||||||
@ -94,7 +94,7 @@ class Item(BaseModel):
|
|||||||
return {"Item": included}
|
return {"Item": included}
|
||||||
|
|
||||||
|
|
||||||
class Table(CloudFormationModel):
|
class Table(BaseModel):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
@ -151,45 +151,6 @@ class Table(CloudFormationModel):
|
|||||||
}
|
}
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def cloudformation_name_type() -> str:
|
|
||||||
return "TableName"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def cloudformation_type() -> str:
|
|
||||||
# https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-dynamodb-table.html
|
|
||||||
return "AWS::DynamoDB::Table"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create_from_cloudformation_json( # type: ignore[misc]
|
|
||||||
cls,
|
|
||||||
resource_name: str,
|
|
||||||
cloudformation_json: Dict[str, Any],
|
|
||||||
account_id: str,
|
|
||||||
region_name: str,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> "Table":
|
|
||||||
properties = cloudformation_json["Properties"]
|
|
||||||
key_attr = [
|
|
||||||
i["AttributeName"]
|
|
||||||
for i in properties["KeySchema"]
|
|
||||||
if i["KeyType"] == "HASH"
|
|
||||||
][0]
|
|
||||||
key_type = [
|
|
||||||
i["AttributeType"]
|
|
||||||
for i in properties["AttributeDefinitions"]
|
|
||||||
if i["AttributeName"] == key_attr
|
|
||||||
][0]
|
|
||||||
spec = {
|
|
||||||
"account_id": account_id,
|
|
||||||
"name": properties["TableName"],
|
|
||||||
"hash_key_attr": key_attr,
|
|
||||||
"hash_key_type": key_type,
|
|
||||||
}
|
|
||||||
# TODO: optional properties still missing:
|
|
||||||
# range_key_attr, range_key_type, read_capacity, write_capacity
|
|
||||||
return Table(**spec)
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return sum(
|
return sum(
|
||||||
[(len(value) if self.has_range_key else 1) for value in self.items.values()] # type: ignore
|
[(len(value) if self.has_range_key else 1) for value in self.items.values()] # type: ignore
|
||||||
@ -324,19 +285,6 @@ class Table(CloudFormationModel):
|
|||||||
item.attrs[attr].add(DynamoType(update["Value"]))
|
item.attrs[attr].add(DynamoType(update["Value"]))
|
||||||
return item
|
return item
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def has_cfn_attr(cls, attr: str) -> bool:
|
|
||||||
return attr in ["StreamArn"]
|
|
||||||
|
|
||||||
def get_cfn_attribute(self, attribute_name: str) -> str:
|
|
||||||
from moto.cloudformation.exceptions import UnformattedGetAttTemplateException
|
|
||||||
|
|
||||||
if attribute_name == "StreamArn":
|
|
||||||
region = "us-east-1"
|
|
||||||
time = "2000-01-01T00:00:00.000"
|
|
||||||
return f"arn:aws:dynamodb:{region}:{self.account_id}:table/{self.name}/stream/{time}"
|
|
||||||
raise UnformattedGetAttTemplateException()
|
|
||||||
|
|
||||||
|
|
||||||
class DynamoDBBackend(BaseBackend):
|
class DynamoDBBackend(BaseBackend):
|
||||||
def __init__(self, region_name: str, account_id: str):
|
def __init__(self, region_name: str, account_id: str):
|
||||||
|
183
tests/test_cloudformation/test_cloudformation_nested_stacks.py
Normal file
183
tests/test_cloudformation/test_cloudformation_nested_stacks.py
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import boto3
|
||||||
|
import json
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from moto import mock_cloudformation, mock_s3
|
||||||
|
|
||||||
|
|
||||||
|
@mock_cloudformation
|
||||||
|
@mock_s3
|
||||||
|
def test_create_basic_stack():
|
||||||
|
# Create inner template
|
||||||
|
cf = boto3.client("cloudformation", "us-east-1")
|
||||||
|
bucket_created_by_cf = str(uuid4())
|
||||||
|
template = get_inner_template(bucket_created_by_cf)
|
||||||
|
# Upload inner template to S3
|
||||||
|
s3 = boto3.client("s3", "us-east-1")
|
||||||
|
cf_storage_bucket = str(uuid4())
|
||||||
|
s3.create_bucket(Bucket=cf_storage_bucket)
|
||||||
|
s3.put_object(Bucket=cf_storage_bucket, Key="stack.json", Body=json.dumps(template))
|
||||||
|
|
||||||
|
# Create template that includes the inner template
|
||||||
|
stack_name = "a" + str(uuid4())[0:6]
|
||||||
|
template = {
|
||||||
|
"AWSTemplateFormatVersion": "2010-09-09",
|
||||||
|
"Resources": {
|
||||||
|
"NestedStack": {
|
||||||
|
"Type": "AWS::CloudFormation::Stack",
|
||||||
|
"Properties": {
|
||||||
|
"TemplateURL": f"https://s3.amazonaws.com/{cf_storage_bucket}/stack.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cf.create_stack(StackName=stack_name, TemplateBody=str(template))
|
||||||
|
|
||||||
|
# Verify the inner S3 bucket has been created
|
||||||
|
bucket_names = sorted([b["Name"] for b in s3.list_buckets()["Buckets"]])
|
||||||
|
assert bucket_names == sorted([cf_storage_bucket, bucket_created_by_cf])
|
||||||
|
|
||||||
|
# Verify both stacks are created
|
||||||
|
stacks = cf.list_stacks()["StackSummaries"]
|
||||||
|
assert len(stacks) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@mock_cloudformation
|
||||||
|
@mock_s3
|
||||||
|
def test_create_stack_with_params():
|
||||||
|
# Create inner template
|
||||||
|
cf = boto3.client("cloudformation", "us-east-1")
|
||||||
|
bucket_created_by_cf = str(uuid4())
|
||||||
|
inner_template = json.dumps(get_inner_template_with_params())
|
||||||
|
|
||||||
|
# Upload inner template to S3
|
||||||
|
s3 = boto3.client("s3", "us-east-1")
|
||||||
|
cf_storage_bucket = str(uuid4())
|
||||||
|
s3.create_bucket(Bucket=cf_storage_bucket)
|
||||||
|
s3.put_object(Bucket=cf_storage_bucket, Key="stack.json", Body=inner_template)
|
||||||
|
|
||||||
|
# Create template that includes the inner template
|
||||||
|
stack_name = "a" + str(uuid4())[0:6]
|
||||||
|
template = get_outer_template_with_params(cf_storage_bucket, bucket_created_by_cf)
|
||||||
|
cf.create_stack(StackName=stack_name, TemplateBody=str(template))
|
||||||
|
|
||||||
|
# Verify the inner S3 bucket has been created
|
||||||
|
bucket_names = sorted([b["Name"] for b in s3.list_buckets()["Buckets"]])
|
||||||
|
assert bucket_names == sorted([cf_storage_bucket, bucket_created_by_cf])
|
||||||
|
|
||||||
|
|
||||||
|
@mock_cloudformation
|
||||||
|
@mock_s3
|
||||||
|
def test_update_stack_with_params():
|
||||||
|
# Create inner template
|
||||||
|
cf = boto3.client("cloudformation", "us-east-1")
|
||||||
|
first_bucket = str(uuid4())
|
||||||
|
second_bucket = str(uuid4())
|
||||||
|
inner_template = json.dumps(get_inner_template_with_params())
|
||||||
|
|
||||||
|
# Upload inner template to S3
|
||||||
|
s3 = boto3.client("s3", "us-east-1")
|
||||||
|
cf_storage_bucket = str(uuid4())
|
||||||
|
s3.create_bucket(Bucket=cf_storage_bucket)
|
||||||
|
s3.put_object(Bucket=cf_storage_bucket, Key="stack.json", Body=inner_template)
|
||||||
|
|
||||||
|
# Create template that includes the inner template
|
||||||
|
stack_name = "a" + str(uuid4())[0:6]
|
||||||
|
template = get_outer_template_with_params(cf_storage_bucket, first_bucket)
|
||||||
|
cf.create_stack(StackName=stack_name, TemplateBody=str(template))
|
||||||
|
|
||||||
|
# Verify the inner S3 bucket has been created
|
||||||
|
bucket_names = sorted([b["Name"] for b in s3.list_buckets()["Buckets"]])
|
||||||
|
assert bucket_names == sorted([cf_storage_bucket, first_bucket])
|
||||||
|
|
||||||
|
# Update stack
|
||||||
|
template = get_outer_template_with_params(cf_storage_bucket, second_bucket)
|
||||||
|
cf.update_stack(StackName=stack_name, TemplateBody=str(template))
|
||||||
|
|
||||||
|
# Verify the inner S3 bucket has been created
|
||||||
|
bucket_names = sorted([b["Name"] for b in s3.list_buckets()["Buckets"]])
|
||||||
|
assert bucket_names == sorted([cf_storage_bucket, second_bucket])
|
||||||
|
|
||||||
|
|
||||||
|
@mock_cloudformation
|
||||||
|
@mock_s3
|
||||||
|
def test_delete_basic_stack():
|
||||||
|
# Create inner template
|
||||||
|
cf = boto3.client("cloudformation", "us-east-1")
|
||||||
|
bucket_created_by_cf = str(uuid4())
|
||||||
|
template = get_inner_template(bucket_created_by_cf)
|
||||||
|
|
||||||
|
# Upload inner template to S3
|
||||||
|
s3 = boto3.client("s3", "us-east-1")
|
||||||
|
cf_storage_bucket = str(uuid4())
|
||||||
|
s3.create_bucket(Bucket=cf_storage_bucket)
|
||||||
|
s3.put_object(Bucket=cf_storage_bucket, Key="stack.json", Body=json.dumps(template))
|
||||||
|
|
||||||
|
# Create template that includes the inner template
|
||||||
|
stack_name = "a" + str(uuid4())[0:6]
|
||||||
|
template = {
|
||||||
|
"AWSTemplateFormatVersion": "2010-09-09",
|
||||||
|
"Resources": {
|
||||||
|
"NestedStack": {
|
||||||
|
"Type": "AWS::CloudFormation::Stack",
|
||||||
|
"Properties": {
|
||||||
|
"TemplateURL": f"https://s3.amazonaws.com/{cf_storage_bucket}/stack.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cf.create_stack(StackName=stack_name, TemplateBody=str(template))
|
||||||
|
cf.delete_stack(StackName=stack_name)
|
||||||
|
|
||||||
|
# Verify the stack-controlled S3 bucket has been deleted
|
||||||
|
bucket_names = sorted([b["Name"] for b in s3.list_buckets()["Buckets"]])
|
||||||
|
assert bucket_names == [cf_storage_bucket]
|
||||||
|
|
||||||
|
# Verify both stacks are deleted
|
||||||
|
stacks = cf.list_stacks()["StackSummaries"]
|
||||||
|
assert len(stacks) == 2
|
||||||
|
for stack in stacks:
|
||||||
|
assert stack["StackStatus"] == "DELETE_COMPLETE"
|
||||||
|
|
||||||
|
|
||||||
|
def get_inner_template(bucket_created_by_cf):
|
||||||
|
return {
|
||||||
|
"AWSTemplateFormatVersion": "2010-09-09",
|
||||||
|
"Resources": {
|
||||||
|
"bcbcf": {
|
||||||
|
"Type": "AWS::S3::Bucket",
|
||||||
|
"Properties": {"BucketName": bucket_created_by_cf},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Outputs": {"Bucket": {"Value": {"Ref": "bcbcf"}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_inner_template_with_params():
|
||||||
|
return {
|
||||||
|
"AWSTemplateFormatVersion": "2010-09-09",
|
||||||
|
"Parameters": {
|
||||||
|
"BName": {"Description": "bucket name", "Type": "String"},
|
||||||
|
},
|
||||||
|
"Resources": {
|
||||||
|
"bcbcf": {
|
||||||
|
"Type": "AWS::S3::Bucket",
|
||||||
|
"Properties": {"BucketName": {"Ref": "BName"}},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_outer_template_with_params(cf_storage_bucket, first_bucket):
|
||||||
|
return {
|
||||||
|
"AWSTemplateFormatVersion": "2010-09-09",
|
||||||
|
"Resources": {
|
||||||
|
"NestedStack": {
|
||||||
|
"Type": "AWS::CloudFormation::Stack",
|
||||||
|
"Properties": {
|
||||||
|
"TemplateURL": f"https://s3.amazonaws.com/{cf_storage_bucket}/stack.json",
|
||||||
|
"Parameters": {"BName": first_bucket},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user