Skip to content

Commit 4464cd2

Browse files
authored
CFNV2: support CDK bootstrap and deployment (#12967)
1 parent 2d08a27 commit 4464cd2

File tree

10 files changed

+1583
-56
lines changed

10 files changed

+1583
-56
lines changed

localstack-core/localstack/services/cdk/resource_providers/cdk_metadata.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,9 @@ def update(
8383
8484
"""
8585
model = request.desired_state
86+
result_model = {**model, "Id": request.previous_state["Id"]}
8687

8788
return ProgressEvent(
8889
status=OperationStatus.SUCCESS,
89-
resource_model=model,
90+
resource_model=result_model,
9091
)

localstack-core/localstack/services/cloudformation/engine/v2/change_set_model.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -728,7 +728,7 @@ def _resolve_intrinsic_function_fn_if(self, arguments: ChangeSetEntity) -> Chang
728728
)
729729
if not isinstance(node_condition, NodeCondition):
730730
raise RuntimeError()
731-
change_type = parent_change_type_of([node_condition, *arguments[1:]])
731+
change_type = parent_change_type_of([node_condition, *arguments.array[1:]])
732732
return change_type
733733

734734
def _resolve_requires_replacement(

localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_preproc.py

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -554,21 +554,38 @@ def _compute_fn_equals(args: list[Any]) -> bool:
554554
def visit_node_intrinsic_function_fn_if(
555555
self, node_intrinsic_function: NodeIntrinsicFunction
556556
) -> PreprocEntityDelta:
557-
def _compute_delta_for_if_statement(args: list[Any]) -> PreprocEntityDelta:
558-
condition_name = args[0]
559-
boolean_expression_delta = self._resolve_condition(logical_id=condition_name)
560-
return PreprocEntityDelta(
561-
before=args[1] if boolean_expression_delta.before else args[2],
562-
after=args[1] if boolean_expression_delta.after else args[2],
557+
# `if` needs to be short-circuiting i.e. if the condition is True we don't evaluate the
558+
# False branch. If the condition is False, we don't evaluate the True branch.
559+
if len(node_intrinsic_function.arguments.array) != 3:
560+
raise ValueError(
561+
f"Incorrectly constructed Fn::If usage, expected 3 arguments, found {len(node_intrinsic_function.arguments.array)}"
563562
)
564563

565-
arguments_delta = self.visit(node_intrinsic_function.arguments)
566-
delta = self._cached_apply(
567-
scope=node_intrinsic_function.scope,
568-
arguments_delta=arguments_delta,
569-
resolver=_compute_delta_for_if_statement,
570-
)
571-
return delta
564+
condition_delta = self.visit(node_intrinsic_function.arguments.array[0])
565+
if_delta = PreprocEntityDelta()
566+
if not is_nothing(condition_delta.before):
567+
node_condition = self._get_node_condition_if_exists(
568+
condition_name=condition_delta.before
569+
)
570+
condition_value = self.visit(node_condition).before
571+
if condition_value:
572+
arg_delta = self.visit(node_intrinsic_function.arguments.array[1])
573+
else:
574+
arg_delta = self.visit(node_intrinsic_function.arguments.array[2])
575+
if_delta.before = arg_delta.before
576+
577+
if not is_nothing(condition_delta.after):
578+
node_condition = self._get_node_condition_if_exists(
579+
condition_name=condition_delta.after
580+
)
581+
condition_value = self.visit(node_condition).after
582+
if condition_value:
583+
arg_delta = self.visit(node_intrinsic_function.arguments.array[1])
584+
else:
585+
arg_delta = self.visit(node_intrinsic_function.arguments.array[2])
586+
if_delta.after = arg_delta.after
587+
588+
return if_delta
572589

573590
def visit_node_intrinsic_function_fn_and(
574591
self, node_intrinsic_function: NodeIntrinsicFunction

localstack-core/localstack/services/cloudformation/v2/provider.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -418,10 +418,17 @@ def create_change_set(
418418
# is needed for the update graph building, or only looked up in downstream tasks (metadata).
419419
request_parameters = request.get("Parameters", list())
420420
# TODO: handle parameter defaults and resolution
421-
after_parameters: dict[str, Any] = {
422-
parameter["ParameterKey"]: parameter["ParameterValue"]
423-
for parameter in request_parameters
424-
}
421+
after_parameters = {}
422+
for parameter in request_parameters:
423+
key = parameter["ParameterKey"]
424+
if parameter.get("UsePreviousValue", False):
425+
# todo: what if the parameter does not exist in the before parameters
426+
after_parameters[key] = before_parameters[key]
427+
continue
428+
429+
if "ParameterValue" in parameter:
430+
after_parameters[key] = parameter["ParameterValue"]
431+
continue
425432

426433
# TODO: update this logic to always pass the clean template object if one exists. The
427434
# current issue with relaying on stack.template_original is that this appears to have
@@ -747,7 +754,9 @@ def describe_stacks(
747754
if stack_name:
748755
stack = find_stack_v2(state, stack_name)
749756
if not stack:
750-
raise StackNotFoundError(stack_name)
757+
raise StackNotFoundError(
758+
stack_name, message_override=f"Stack with id {stack_name} does not exist"
759+
)
751760
stacks = [stack]
752761
else:
753762
stacks = state.stacks_v2.values()

localstack-core/localstack/testing/pytest/fixtures.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from werkzeug import Request, Response
2222

2323
from localstack import config
24+
from localstack.aws.api.cloudformation import CreateChangeSetInput, Parameter
2425
from localstack.aws.api.ec2 import CreateSecurityGroupRequest, CreateVpcEndpointRequest, VpcEndpoint
2526
from localstack.aws.connect import ServiceLevelClientFactory
2627
from localstack.services.stores import (
@@ -1095,6 +1096,7 @@ def _deploy(
10951096
max_wait: Optional[int] = None,
10961097
delay_between_polls: Optional[int] = 2,
10971098
custom_aws_client: Optional[ServiceLevelClientFactory] = None,
1099+
raw_parameters: Optional[list[Parameter]] = None,
10981100
) -> DeployResult:
10991101
if is_update:
11001102
assert stack_name
@@ -1110,20 +1112,21 @@ def _deploy(
11101112
raise RuntimeError(f"Could not find file {os.path.realpath(template_path)}")
11111113
template_rendered = render_template(template, **(template_mapping or {}))
11121114

1113-
kwargs = dict(
1115+
kwargs = CreateChangeSetInput(
11141116
StackName=stack_name,
11151117
ChangeSetName=change_set_name,
11161118
TemplateBody=template_rendered,
11171119
Capabilities=["CAPABILITY_AUTO_EXPAND", "CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"],
11181120
ChangeSetType=("UPDATE" if is_update else "CREATE"),
1119-
Parameters=[
1120-
{
1121-
"ParameterKey": k,
1122-
"ParameterValue": v,
1123-
}
1124-
for (k, v) in (parameters or {}).items()
1125-
],
11261121
)
1122+
kwargs["Parameters"] = []
1123+
if parameters:
1124+
kwargs["Parameters"] = [
1125+
Parameter(ParameterKey=k, ParameterValue=v) for (k, v) in parameters.items()
1126+
]
1127+
elif raw_parameters:
1128+
kwargs["Parameters"] = raw_parameters
1129+
11271130
if role_arn is not None:
11281131
kwargs["RoleARN"] = role_arn
11291132

tests/aws/services/cloudformation/resources/test_cdk.py

Lines changed: 122 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,44 @@
11
import os
2+
from collections.abc import Callable
23

34
import pytest
45
from localstack_snapshot.snapshots.transformer import SortingTransformer
5-
from tests.aws.services.cloudformation.conftest import skip_if_v2_provider
6+
from tests.aws.services.cloudformation.conftest import skip_if_v1_provider
67

8+
from localstack.aws.api.cloudformation import Parameter
79
from localstack.testing.pytest import markers
810
from localstack.utils.files import load_file
911
from localstack.utils.strings import short_uid
1012

1113

1214
class TestCdkInit:
13-
@pytest.mark.parametrize("bootstrap_version", ["10", "11", "12"])
15+
@pytest.mark.parametrize(
16+
"bootstrap_version,parameters",
17+
[
18+
("10", {"FileAssetsBucketName": f"cdk-bootstrap-{short_uid()}"}),
19+
("11", {"FileAssetsBucketName": f"cdk-bootstrap-{short_uid()}"}),
20+
("12", {"FileAssetsBucketName": f"cdk-bootstrap-{short_uid()}"}),
21+
(
22+
"28",
23+
{
24+
"CloudFormationExecutionPolicies": "",
25+
"FileAssetsBucketKmsKeyId": "AWS_MANAGED_KEY",
26+
"PublicAccessBlockConfiguration": "true",
27+
"TrustedAccounts": "",
28+
"TrustedAccountsForLookup": "",
29+
},
30+
),
31+
],
32+
ids=["10", "11", "12", "28"],
33+
)
1434
@markers.aws.validated
15-
def test_cdk_bootstrap(self, deploy_cfn_template, bootstrap_version, aws_client):
35+
def test_cdk_bootstrap(self, deploy_cfn_template, aws_client, bootstrap_version, parameters):
1636
deploy_cfn_template(
1737
template_path=os.path.join(
1838
os.path.dirname(__file__),
1939
f"../../../templates/cdk_bootstrap_v{bootstrap_version}.yaml",
2040
),
21-
parameters={"FileAssetsBucketName": f"cdk-bootstrap-{short_uid()}"},
41+
parameters=parameters,
2242
)
2343
init_stack_result = deploy_cfn_template(
2444
template_path=os.path.join(
@@ -32,61 +52,136 @@ def test_cdk_bootstrap(self, deploy_cfn_template, bootstrap_version, aws_client)
3252
assert len(stack_res["StackResources"]) == 1
3353
assert stack_res["StackResources"][0]["LogicalResourceId"] == "CDKMetadata"
3454

35-
@skip_if_v2_provider(reason="CFNV2:Provider")
3655
@markers.aws.validated
37-
def test_cdk_bootstrap_redeploy(self, aws_client, cleanup_stacks, cleanup_changesets, cleanups):
56+
@pytest.mark.parametrize(
57+
"template,parameters_fn",
58+
[
59+
pytest.param(
60+
"cdk_bootstrap.yml",
61+
lambda qualifier: [
62+
{
63+
"ParameterKey": "BootstrapVariant",
64+
"ParameterValue": "AWS CDK: Default Resources",
65+
},
66+
{"ParameterKey": "TrustedAccounts", "ParameterValue": ""},
67+
{"ParameterKey": "TrustedAccountsForLookup", "ParameterValue": ""},
68+
{"ParameterKey": "CloudFormationExecutionPolicies", "ParameterValue": ""},
69+
{
70+
"ParameterKey": "FileAssetsBucketKmsKeyId",
71+
"ParameterValue": "AWS_MANAGED_KEY",
72+
},
73+
{
74+
"ParameterKey": "PublicAccessBlockConfiguration",
75+
"ParameterValue": "true",
76+
},
77+
{"ParameterKey": "Qualifier", "ParameterValue": qualifier},
78+
{
79+
"ParameterKey": "UseExamplePermissionsBoundary",
80+
"ParameterValue": "false",
81+
},
82+
],
83+
id="v20",
84+
),
85+
pytest.param(
86+
"cdk_bootstrap_v28.yaml",
87+
lambda qualifier: [
88+
{"ParameterKey": "CloudFormationExecutionPolicies", "ParameterValue": ""},
89+
{
90+
"ParameterKey": "FileAssetsBucketKmsKeyId",
91+
"ParameterValue": "AWS_MANAGED_KEY",
92+
},
93+
{
94+
"ParameterKey": "PublicAccessBlockConfiguration",
95+
"ParameterValue": "true",
96+
},
97+
{"ParameterKey": "Qualifier", "ParameterValue": qualifier},
98+
{"ParameterKey": "TrustedAccounts", "ParameterValue": ""},
99+
{"ParameterKey": "TrustedAccountsForLookup", "ParameterValue": ""},
100+
],
101+
id="v28",
102+
),
103+
],
104+
)
105+
@markers.snapshot.skip_snapshot_verify(
106+
paths=[
107+
# CFNV2:Provider
108+
"$..Description",
109+
# Wrong format, they are our internal parameter format
110+
"$..Parameters",
111+
# from the list of changes
112+
"$..Changes..Details",
113+
"$..Changes..LogicalResourceId",
114+
"$..Changes..ResourceType",
115+
"$..Changes..Scope",
116+
# provider
117+
"$..IncludeNestedStacks",
118+
"$..NotificationARNs",
119+
# CFNV2:Describe not supported yet
120+
"$..Outputs..ExportName",
121+
# mismatch between amazonaws.com and localhost.localstack.cloud
122+
"$..Outputs..OutputValue",
123+
]
124+
)
125+
@skip_if_v1_provider(reason="Changes array not in parity")
126+
def test_cdk_bootstrap_redeploy(
127+
self,
128+
aws_client,
129+
cleanup_stacks,
130+
cleanup_changesets,
131+
cleanups,
132+
snapshot,
133+
template,
134+
parameters_fn: Callable[[str], list[Parameter]],
135+
):
38136
"""Test that simulates a sequence of commands executed by CDK when running 'cdk bootstrap' twice"""
137+
snapshot.add_transformer(snapshot.transform.cloudformation_api())
138+
snapshot.add_transformer(SortingTransformer("Parameters", lambda p: p["ParameterKey"]))
139+
snapshot.add_transformer(SortingTransformer("Outputs", lambda p: p["OutputKey"]))
39140

40141
stack_name = f"CDKToolkit-{short_uid()}"
41142
change_set_name = f"cdk-deploy-change-set-{short_uid()}"
143+
qualifier = short_uid()
144+
snapshot.add_transformer(snapshot.transform.regex(qualifier, "<qualifier>"))
42145

43146
def clean_resources():
44147
cleanup_stacks([stack_name])
45148
cleanup_changesets([change_set_name])
46149

47150
cleanups.append(clean_resources)
48151

49-
template_body = load_file(
50-
os.path.join(os.path.dirname(__file__), "../../../templates/cdk_bootstrap.yml")
152+
template_path = os.path.realpath(
153+
os.path.join(os.path.dirname(__file__), f"../../../templates/{template}")
51154
)
155+
template_body = load_file(template_path)
156+
if template_body is None:
157+
raise RuntimeError(f"Template {template_path} not loaded")
158+
52159
aws_client.cloudformation.create_change_set(
53160
StackName=stack_name,
54161
ChangeSetName=change_set_name,
55162
TemplateBody=template_body,
56163
ChangeSetType="CREATE",
57164
Capabilities=["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"],
58165
Description="CDK Changeset for execution 731ed7da-8b2d-49c6-bca3-4698b6875954",
59-
Parameters=[
60-
{
61-
"ParameterKey": "BootstrapVariant",
62-
"ParameterValue": "AWS CDK: Default Resources",
63-
},
64-
{"ParameterKey": "TrustedAccounts", "ParameterValue": ""},
65-
{"ParameterKey": "TrustedAccountsForLookup", "ParameterValue": ""},
66-
{"ParameterKey": "CloudFormationExecutionPolicies", "ParameterValue": ""},
67-
{"ParameterKey": "FileAssetsBucketKmsKeyId", "ParameterValue": "AWS_MANAGED_KEY"},
68-
{"ParameterKey": "PublicAccessBlockConfiguration", "ParameterValue": "true"},
69-
{"ParameterKey": "Qualifier", "ParameterValue": "hnb659fds"},
70-
{"ParameterKey": "UseExamplePermissionsBoundary", "ParameterValue": "false"},
71-
],
166+
Parameters=parameters_fn(qualifier),
72167
)
73-
aws_client.cloudformation.describe_change_set(
168+
aws_client.cloudformation.get_waiter("change_set_create_complete").wait(
74169
StackName=stack_name, ChangeSetName=change_set_name
75170
)
76-
77-
aws_client.cloudformation.get_waiter("change_set_create_complete").wait(
171+
describe_change_set = aws_client.cloudformation.describe_change_set(
78172
StackName=stack_name, ChangeSetName=change_set_name
79173
)
174+
snapshot.match("describe-change-set", describe_change_set)
80175

81176
aws_client.cloudformation.execute_change_set(
82177
StackName=stack_name, ChangeSetName=change_set_name
83178
)
84179

85180
aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name)
86-
aws_client.cloudformation.describe_stacks(StackName=stack_name)
181+
stacks = aws_client.cloudformation.describe_stacks(StackName=stack_name)["Stacks"][0]
182+
snapshot.match("describe-stacks", stacks)
87183

88-
# When CDK toolstrap command is executed again it just confirms that the template is the same
89-
aws_client.sts.get_caller_identity()
184+
# When CDK bootstrap command is executed again it just confirms that the template is the same
90185
aws_client.cloudformation.get_template(StackName=stack_name, TemplateStage="Original")
91186

92187
# TODO: create scenario where the template is different to catch cdk behavior

0 commit comments

Comments
 (0)