Skip to content

CFn: Improve stack set support #19

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions localstack-core/localstack/services/cloudformation/api_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from localstack import config, constants
from localstack.aws.connect import connect_to
from localstack.services.cloudformation.engine.entities import Stack
from localstack.services.s3.utils import (
extract_bucket_name_and_key_from_headers_and_path,
normalize_bucket_name,
Expand All @@ -16,7 +17,12 @@
LOG = logging.getLogger(__name__)


def prepare_template_body(req_data: dict) -> str | bytes | None: # TODO: mutating and returning
# TODO: remove optional stack and make it required


def prepare_template_body(
req_data: dict, stack: Stack | None = None
) -> str | bytes | None: # TODO: mutating and returning
template_url = req_data.get("TemplateURL")
if template_url:
req_data["TemplateURL"] = convert_s3_to_local_url(template_url)
Expand All @@ -26,13 +32,13 @@ def prepare_template_body(req_data: dict) -> str | bytes | None: # TODO: mutati
if modified_template_body:
req_data.pop("TemplateURL", None)
req_data["TemplateBody"] = modified_template_body
modified_template_body = get_template_body(req_data)
modified_template_body = get_template_body(req_data, stack)
if modified_template_body:
req_data["TemplateBody"] = modified_template_body
return modified_template_body


def get_template_body(req_data: dict) -> str:
def get_template_body(req_data: dict, stack: Stack | None = None) -> str:
body = req_data.get("TemplateBody")
if body:
return body
Expand Down Expand Up @@ -60,6 +66,9 @@ def get_template_body(req_data: dict) -> str:
"Unable to fetch template body (code %s) from URL %s" % (status_code, url)
)
return to_str(response.content)
if req_data.get("UsePreviousTemplate") and stack:
return stack.template_body

raise Exception("Unable to get template body from input: %s" % req_data)


Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import logging
from typing import Optional, TypedDict

from localstack.aws.api.cloudformation import Capability, ChangeSetType, Parameter
from localstack.aws.api.cloudformation import (
Capability,
ChangeSetType,
CreateStackSetInput,
Parameter,
)
from localstack.services.cloudformation.engine.parameters import (
StackParameter,
convert_stack_parameters_to_list,
Expand All @@ -17,30 +22,62 @@
LOG = logging.getLogger(__name__)


class StackSet:
"""A stack set contains multiple stack instances."""
class StackInstance:
"""A stack instance belongs to a stack set and is specific to a region / account ID."""

# FIXME: confusing name. metadata is the complete incoming request object
def __init__(self, metadata: dict):
self.metadata = metadata
# reference to the deployed stack belonging to this stack instance
self.stack = None

@property
def account(self) -> str:
return self.metadata["Account"]

@property
def region(self) -> str:
return self.metadata["Region"]


class StackSet:
"""A stack set contains multiple stack instances."""

def __init__(self, request: CreateStackSetInput):
self.request = request
# list of stack instances
self.stack_instances = []
self.stack_instances: list[StackInstance] = []
# maps operation ID to stack set operation details
self.operations = {}

# compatibility with old API
@property
def metadata(self) -> CreateStackSetInput:
return self.request

@property
def stack_set_name(self):
return self.metadata.get("StackSetName")
return self.request.get("StackSetName")

@property
def template_url(self) -> str | None:
return self.request.get("TemplateURL")

class StackInstance:
"""A stack instance belongs to a stack set and is specific to a region / account ID."""
@property
def template_body(self) -> str | None:
return self.request.get("TemplateBody")

# FIXME: confusing name. metadata is the complete incoming request object
def __init__(self, metadata: dict):
self.metadata = metadata
# reference to the deployed stack belonging to this stack instance
self.stack = None
def get_template(self):
if body := self.template_body:
return body

raise NotImplementedError("template URL")
Comment on lines +70 to +74
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Template URL handling is not implemented. This could lead to issues if users try to use template URLs.


def get_instance(self, account: str, region: str) -> StackInstance | None:
for instance in self.stack_instances:
if instance.metadata["Account"] == account and instance.metadata["Region"] == region:
return instance
return None
Comment on lines +76 to +80
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider using a dictionary for faster lookup of stack instances instead of iterating through a list.



class StackMetadata(TypedDict):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

def extract_stack_parameter_declarations(template: dict) -> dict[str, ParameterDeclaration]:
"""
Extract and build a dict of stack parameter declarations from a CloudFormation stack templatef
Extract and build a dict of stack parameter declarations from a CloudFormation stack template

:param template: the parsed CloudFormation stack template
:return: a dictionary of declared parameters, mapping logical IDs to the corresponding parameter declaration
Expand Down
69 changes: 63 additions & 6 deletions localstack-core/localstack/services/cloudformation/provider.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import copy
import itertools
import json
import logging
import re
Expand Down Expand Up @@ -110,6 +111,7 @@
find_change_set,
find_stack,
find_stack_by_id,
find_stack_set,
get_cloudformation_store,
)
from localstack.state import StateVisitor
Expand Down Expand Up @@ -331,7 +333,7 @@ def update_stack(
if not stack:
return not_found_error(f'Unable to update non-existing stack "{stack_name}"')

api_utils.prepare_template_body(request)
api_utils.prepare_template_body(request, stack)
template = template_preparer.parse_template(request["TemplateBody"])

if (
Expand Down Expand Up @@ -530,8 +532,18 @@ def get_template_summary(
request: GetTemplateSummaryInput,
) -> GetTemplateSummaryOutput:
stack_name = request.get("StackName")
stack_set_name = request.get("StackSetName")

if stack_name:
if stack_set_name:
stack_set = find_stack_set(context.account_id, context.region, stack_set_name)
if not stack_set:
# TODO: different error?
return stack_not_found_error(stack_set_name)

template = template_preparer.parse_template(stack_set.template_body)
request["StackName"] = "tmp-stack"
stack = Stack(context.account_id, context.region, request, template)
elif stack_name:
stack = find_stack(context.account_id, context.region, stack_name)
if not stack:
return stack_not_found_error(stack_name)
Expand All @@ -555,10 +567,12 @@ def get_template_summary(
id_summaries[res_type].append(resource_id)

result["ResourceTypes"] = list(id_summaries.keys())
result["ResourceIdentifierSummaries"] = [
{"ResourceType": key, "LogicalResourceIds": values}
for key, values in id_summaries.items()
]
if not stack_set_name:
# this property is only available for stacks, not stack sets
result["ResourceIdentifierSummaries"] = [
{"ResourceType": key, "LogicalResourceIds": values}
for key, values in id_summaries.items()
]
result["Metadata"] = stack.template.get("Metadata")
result["Version"] = stack.template.get("AWSTemplateFormatVersion", "2010-09-09")
# these do not appear in the output
Expand Down Expand Up @@ -1163,6 +1177,45 @@ def create_stack_instances(
accounts = request["Accounts"]
regions = request["Regions"]

# check if any stacks exist already
for account, region, instance in itertools.product(
accounts, regions, stack_set.stack_instances
):
if instance.account == account and instance.region == region:
# record an operation
operation = {
"OperationId": op_id,
"StackSetId": stack_set.metadata["StackSetId"],
"Action": "CREATE",
"Status": "SUCCEEDED",
}
stack_set.operations[op_id] = operation
return CreateStackInstancesOutput(OperationId=op_id)

# temp: find a better home for this
def merge_parameters(*parameters_lists: list) -> list:
"""
Merge multiple lists of parameters

>>> ps1 = [{"ParameterKey": "a", "ParameterValue": "b"}]
>>> ps2 = [{"ParameterKey": "c", "ParameterValue": "d"}]
>>> ps3 = [{"ParameterKey": "a", "ParameterValue": "1"}]
>>> ps = merge_parameters(ps1, ps2, ps3)
>>> assert ps == [{"ParameterKey": "a", "ParameterValue": "1"},
... {"ParameterKey": "c", "ParameterValue": "d"}]
"""
from functools import reduce

def _merge(acc: list, new: list) -> list:
acc_d = {every["ParameterKey"]: every["ParameterValue"] for every in acc}
new_d = {every["ParameterKey"]: every["ParameterValue"] for every in new}
acc_d.update(**new_d)
return [
{"ParameterKey": key, "ParameterValue": value} for (key, value) in acc_d.items()
]

return reduce(_merge, parameters_lists, [])

stacks_to_await = []
for account in accounts:
for region in regions:
Expand All @@ -1177,6 +1230,10 @@ def create_stack_instances(
kwargs = select_attributes(sset_meta, ["TemplateBody"]) or select_attributes(
sset_meta, ["TemplateURL"]
)
kwargs["Parameters"] = merge_parameters(
sset_meta.get("Parameters", []), request.get("ParameterOverrides", [])
)
# allow overrides from the request
stack_name = f"sset-{set_name}-{account}"

# skip creation of existing stacks
Expand Down
8 changes: 8 additions & 0 deletions localstack-core/localstack/services/cloudformation/stores.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ def find_stack(account_id: str, region_name: str, stack_name: str) -> Stack | No
)[0]


def find_stack_set(account_id: str, region_name: str, stack_set_name: str) -> StackSet | None:
state = get_cloudformation_store(account_id, region_name)
for name, stack_set in state.stack_sets.items():
# TODO: stack set id?
if stack_set_name in [name, stack_set.stack_set_name]:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: This comparison might lead to unexpected matches if stack_set_name is a substring of name or stack_set_name

return stack_set
Comment on lines +64 to +69
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider adding a docstring to explain the function's purpose and parameters



def find_stack_by_id(account_id: str, region_name: str, stack_id: str) -> Stack | None:
"""
Find the stack by id.
Expand Down
13 changes: 13 additions & 0 deletions localstack-core/localstack/utils/collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
)

import cachetools
import jsonpath_ng

LOG = logging.getLogger(__name__)

Expand Down Expand Up @@ -533,3 +534,15 @@ def is_comma_delimited_list(string: str, item_regex: Optional[str] = None) -> bo
if pattern.match(string) is None:
return False
return True


def convert_in_place_at_jsonpath(params: dict, jsonpath: str, conversion_fn: Callable[[Any], Any]):
"""
Invokes a conversion function on a dictionary nested entry at a specific jsonpath with `conversion_fn`
"""
jp = jsonpath_ng.parse(jsonpath)
old_value = jp.find(params)[0].value
if not old_value:
return
Comment on lines +545 to +546
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: This check might skip valid falsy values (e.g., False, 0). Consider using 'is None' instead

new_value = conversion_fn(old_value)
jp.update(params, new_value)
31 changes: 23 additions & 8 deletions tests/aws/services/cloudformation/resources/test_stack_sets.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,15 @@ def _operation_is_ready():


@markers.aws.validated
@markers.snapshot.skip_snapshot_verify(
paths=[
"$..Parameters..NoEcho",
"$..Parameters..ParameterConstraints",
"$..Parameters..DefaultValue",
],
)
def test_create_stack_set_with_stack_instances(
account_id,
region_name,
aws_client,
snapshot,
wait_stack_set_operation,
Expand All @@ -36,20 +42,29 @@ def test_create_stack_set_with_stack_instances(
stack_set_name = f"StackSet-{short_uid()}"

template_body = load_file(
os.path.join(os.path.dirname(__file__), "../../../templates/s3_cors_bucket.yaml")
os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml")
)

topic_name = f"topic-{short_uid()}"
result = aws_client.cloudformation.create_stack_set(
StackSetName=stack_set_name,
TemplateBody=template_body,
Parameters=[{"ParameterKey": "TopicName", "ParameterValue": topic_name}],
)

snapshot.match("create_stack_set", result)

template_summary = aws_client.cloudformation.get_template_summary(
StackSetName=stack_set_name,
)
snapshot.match("template-summary", template_summary)

regions = ["us-west-2", "eu-north-1"]

create_instances_result = aws_client.cloudformation.create_stack_instances(
StackSetName=stack_set_name,
Accounts=[account_id],
Regions=[region_name],
Regions=regions,
)

snapshot.match("create_stack_instances", create_instances_result)
Expand All @@ -58,20 +73,20 @@ def test_create_stack_set_with_stack_instances(

# make sure additional calls do not result in errors
# even the stack already exists, but returns operation id instead
create_instances_result = aws_client.cloudformation.create_stack_instances(
recreate_instances_result = aws_client.cloudformation.create_stack_instances(
StackSetName=stack_set_name,
Accounts=[account_id],
Regions=[region_name],
Regions=regions,
)

assert "OperationId" in create_instances_result
snapshot.match("recreate_stack_instances", recreate_instances_result)

wait_stack_set_operation(stack_set_name, create_instances_result["OperationId"])
wait_stack_set_operation(stack_set_name, recreate_instances_result["OperationId"])

delete_instances_result = aws_client.cloudformation.delete_stack_instances(
StackSetName=stack_set_name,
Accounts=[account_id],
Regions=[region_name],
Regions=regions,
RetainStacks=False,
)
wait_stack_set_operation(stack_set_name, delete_instances_result["OperationId"])
Expand Down
Loading