8000 Implement support for CDK Bootstrap stacks · localstack/localstack@cc92e8e · GitHub
[go: up one dir, main page]

Skip to content

Commit cc92e8e

Browse files
committed
Implement support for CDK Bootstrap stacks
## Motivation Currently CDK bootstrap fails for a variety of reasons. Without this, users who use CDK cannot test their applications with our new engine. ## Changes * Handle dynamic parameter lookup of SSM parameters * Set resource status on success/failure of a deployment * Handle the case where an Fn::Join is called without a list as a second argument, in the special case of an empty string, e.g. `Fn::Join: ["", ""] as the CDK does this * Add helper `find_stack_v2` function for finding v2 stacks * Implement `describe_stack_resources` to support our CDK bootstrap tests * Update the cdk bootstrap test skip reason as our lack of deletion causes test failures ## Testing I have not added any integration tests for this since its functionality is covered by our existing `TestCdkInit.test_cdk_bootstrap` (parametrized) tests. Unfortunately since we don't support deletions yet (see #12576), the tests fail when run together. Each test individually passes, so I've updated the skip reason to reflect the updated nature of the failure. To test manually, unskip the tests, and run ``` pytest tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.py::TestCdkInit::test_cdk_bootstrap[10] ```
1 parent 3adead2 commit cc92e8e

File tree

5 files changed

+133
-23
lines changed

5 files changed

+133
-23
lines changed

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

Lines changed: 55 additions & 2 deletions
B41A
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@
44
from dataclasses import dataclass
55
from typing import Final, Optional
66

7-
from localstack.aws.api.cloudformation import ChangeAction, StackStatus
7+
from localstack.aws.api.cloudformation import (
8+
ChangeAction,
9+
ResourceStatus,
10+
StackStatus,
11+
)
812
from localstack.constants import INTERNAL_AWS_SECRET_ACCESS_KEY
13+
from localstack.services.cloudformation.engine.parameters import resolve_ssm_parameter
914
from localstack.services.cloudformation.engine.v2.change_set_model import (
1015
NodeDependsOn,
1116
NodeOutput,
@@ -59,7 +64,25 @@ def execute(self) -> ChangeSetModelExecutorResult:
5964
)
6065

6166
def visit_node_parameter(self, node_parameter: NodeParameter) -> PreprocEntityDelta:
62-
delta = super().visit_node_parameter(node_parameter=node_parameter)
67+
delta = super().visit_node_parameter(node_parameter)
68+
69+
# handle dynamic references, e.g. references to SSM parameters
70+
# TODO: support more parameter types
71+
parameter_type: str = node_parameter.type_.value
72+
if parameter_type.startswith("AWS::SSM"):
73+
if parameter_type in [
74+
"AWS::SSM::Parameter::Value<String>",
75+
"AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>",
76+
"AWS::SSM::Parameter::Value<CommaDelimitedList>",
77+
]:
78+
delta.after = resolve_ssm_parameter(
79+
account_id=self._change_set.account_id,
80+
region_name=self._change_set.region_name,
81+
stack_parameter_value=delta.after,
82+
)
83+
else:
84+
raise Exception(f"Unsupported stack parameter type: {parameter_type}")
85+
6386
self.resolved_parameters[node_parameter.name] = delta.after
6487
return delta
6588

@@ -253,6 +276,17 @@ def _execute_resource_action(
253276
stack.set_stack_status(StackStatus.CREATE_FAILED, reason=reason)
254277
elif stack_status == StackStatus.UPDATE_IN_PROGRESS:
255278
stack.set_stack_status(StackStatus.UPDATE_FAILED, reason=reason)
279+
# update resource status
280+
stack.set_resource_status(
281+
logical_resource_id=logical_resource_id,
282+
# TODO,
283+
physical_resource_id="",
284+
resource_type=resource_type,
285+
status=ResourceStatus.CREATE_FAILED
286+
if action == ChangeAction.Add
287+
else ResourceStatus.UPDATE_FAILED,
288+
resource_status_reason=reason,
289+
)
256290
return
257291
else:
258292
event = ProgressEvent(OperationStatus.SUCCESS, resource_model={})
@@ -290,6 +324,15 @@ def _execute_resource_action(
290324
physical_resource_id = self._before_resource_physical_id(logical_resource_id)
291325
self.resources[logical_resource_id]["PhysicalResourceId"] = physical_resource_id
292326

327+
self._change_set.stack.set_resource_status(
328+
logical_resource_id=logical_resource_id,
329+
physical_resource_id=physical_resource_id,
330+
resource_type=resource_type,
331+
status=ResourceStatus.CREATE_COMPLETE
332+
if action == ChangeAction.Add
333+
else ResourceStatus.UPDATE_COMPLETE,
334+
)
335+
293336
case OperationStatus.FAILED:
294337
reason = event.message
295338
LOG.warning(
@@ -305,6 +348,16 @@ def _execute_resource_action(
305348
stack.set_stack_status(StackStatus.UPDATE_FAILED, reason=reason)
306349
else:
307350
raise NotImplementedError(f"Unhandled stack status: '{stack.status}'")
351+
stack.set_resource_status(
352+
logical_resource_id=logical_resource_id,
353+
# TODO
354+
physical_resource_id="",
355+
resource_type=resource_type,
356+
status=ResourceStatus.CREATE_FAILED
357+
if action == ChangeAction.Add
358+
else ResourceStatus.UPDATE_FAILED,
359+
resource_status_reason=reason,
360+
)
308361
case any:
309362
raise NotImplementedError(f"Event status '{any}' not handled")
310363

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,11 @@ def _compute_join(args: list[Any]) -> str:
715715
delimiter: str = str(args[0])
716716
values: list[Any] = args[1]
717717
if not isinstance(values, list):
718+
# shortcut if values is the empty string, for example:
719+
# {"Fn::Join": ["", {"Ref": <parameter>}]}
720+
# CDK bootstrap does this
721+
if values == "":
722+
return ""
718723
raise RuntimeError(f"Invalid arguments list definition for Fn::Join: '{args}'")
719724
str_values: list[str] = list()
720725
for value in values:

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
ExecutionStatus,
99
Output,
1010
Parameter,
11+
ResourceStatus,
1112
StackDriftInformation,
1213
StackDriftStatus,
14+
StackResource,
1315
StackStatus,
1416
StackStatusReason,
1517
)
@@ -46,6 +48,7 @@ class Stack:
4648
resolved_parameters: dict[str, str]
4749
resolved_resources: dict[str, ResolvedResource]
4850
resolved_outputs: dict[str, str]
51+
resource_states: dict[str, StackResource]
4952

5053
def __init__(
5154
self,
@@ -84,12 +87,33 @@ def __init__(
8487
self.resolved_parameters = {}
8588
self.resolved_resources = {}
8689
self.resolved_outputs = {}
90+
self.resource_states = {}
8791

8892
def set_stack_status(self, status: StackStatus, reason: StackStatusReason | None = None):
8993
self.status = status
9094
if reason:
9195
self.status_reason = reason
9296

97+
def set_resource_status(
98+
self,
99+
*,
100+
logical_resource_id: str,
101+
physical_resource_id: str | None,
102+
resource_type: str,
103+
status: ResourceStatus,
104+
resource_status_reason: str | None = None,
105+
):
106+
self.resource_states[logical_resource_id] = StackResource(
107+
StackName=self.stack_name,
108+
StackId=self.stack_id,
109+
LogicalResourceId=logical_resource_id,
110+
PhysicalResourceId=physical_resource_id,
111+
ResourceType=resource_type,
112+
Timestamp=datetime.now(tz=timezone.utc),
113+
ResourceStatus=status,
114+
ResourceStatusReason=resource_status_reason,
115+
)
116+
93117
def describe_details(self) -> ApiStack:
94118
result = {
95119
"ChangeSetId": self.change_set_id,

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

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import copy
12
import logging
23
from typing import Any
34

@@ -14,14 +15,17 @@
1415
DeletionMode,
1516
DescribeChangeSetOutput,
1617
DescribeStackEventsOutput,
18+
DescribeStackResourcesOutput,
1719
DescribeStacksOutput,
1820
DisableRollback,
1921
ExecuteChangeSetOutput,
2022
ExecutionStatus,
2123
IncludePropertyValues,
2224
InvalidChangeSetStatusException,
25+
LogicalResourceId,
2326
NextToken,
2427
Parameter,
28+
PhysicalResourceId,
2529
RetainExceptOnCreate,
2630
RetainResources,
2731
RoleARN,
@@ -62,6 +66,25 @@ def is_changeset_arn(change_set_name_or_id: str) -> bool:
6266
return ARN_CHANGESET_REGEX.match(change_set_name_or_id) is not None
6367

6468

69+
def find_stack_v2(state: CloudFormationStore, stack_name: str | None) -> Stack:
70+
if stack_name:
71+
if is_stack_arn(stack_name):
72+
return state.stacks_v2[stack_name]
73+
else:
74+
stack_candidates = []
75+
for stack in state.stacks_v2.values():
76+
if stack.stack_name == stack_name and stack.status != StackStatus.DELETE_COMPLETE:
77+
stack_candidates.append(stack)
78+
if len(stack_candidates) == 0:
79+
raise ValidationError(f"No stack with name {stack_name} found")
80+
elif len(stack_candidates) > 1:
81+
raise RuntimeError("Programing error, duplicate stacks found")
82+
else:
83+
return stack_candidates[0]
84+
else:
85+
raise NotImplementedError
86+
87+
6588
def find_change_set_v2(
6689
state: CloudFormationStore, change_set_name: str, stack_name: str | None = None
6790
) -> ChangeSet | None:
@@ -364,28 +387,31 @@ def describe_stacks(
364387
**kwargs,
365388
) -> DescribeStacksOutput:
366389
state = get_cloudformation_store(context.account_id, context.region)
367-
if stack_name:
368-
if is_stack_arn(stack_name):
369-
stack = state.stacks_v2[stack_name]
370-
else:
371-
stack_candidates = []
372-
for stack in state.stacks_v2.values():
373-
if (
374-
stack.stack_name == stack_name
375-
and stack.status != StackStatus.DELETE_COMPLETE
376-
):
377-
stack_candidates.append(stack)
378-
if len(stack_candidates) == 0:
379-
raise ValidationError(f"No stack with name {stack_name} found")
380-
elif len(stack_candidates) > 1:
381< F438 /code>-
raise RuntimeError("Programing error, duplicate stacks found")
382-
else:
383-
stack = stack_candidates[0]
384-
else:
385-
raise NotImplementedError
386-
390+
stack = find_stack_v2(state, stack_name)
387391
return DescribeStacksOutput(Stacks=[stack.describe_details()])
388392

393+
@handler("DescribeStackResources")
394+
def describe_stack_resources(
395+
self,
396+
context: RequestContext,
397+
stack_name: StackName = None,
398+
logical_resource_id: LogicalResourceId = None,
399+
physical_resource_id: PhysicalResourceId = None,
400+
**kwargs,
401+
) -> DescribeStackResourcesOutput:
402+
if physical_resource_id and stack_name:
403+
raise ValidationError("Cannot specify both StackName and PhysicalResourceId")
404+
state = get_cloudformation_store(context.account_id, context.region)
405+
stack = find_stack_v2(state, stack_name)
406+
# TODO: filter stack by PhysicalResourceId!
407+
statuses = []
408+
for resource_id, resource_status in stack.resource_states.items():
409+
if resource_id == logical_resource_id or logical_resource_id is None:
410+
status = copy.deepcopy(resource_status)
411+
status.setdefault("DriftInformation", {"StackResourceDriftStatus": "NOT_CHECKED"})
412+
statuses.append(status)
413+
return DescribeStackResourcesOutput(StackResources=statuses)
414+
389415
@handler("DescribeStackEvents")
390416
def describe_stack_events(
391417
self,

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616

1717

1818
class TestCdkInit:
19-
@pytest.mark.skip(reason="CFNV2:Fn::Join on empty string args")
19+
@pytest.mark.skip(
20+
reason="CFNV2:Destroy each test passes individually but because we don't delete resources, running all parameterized options fails"
21+
)
2022
@pytest.mark.parametrize("bootstrap_version", ["10", "11", "12"])
2123
@markers.aws.validated
2224
def test_cdk_bootstrap(self, deploy_cfn_template, bootstrap_version, aws_client):

0 commit comments

Comments
 (0)
0