8000 CFn v2: implement support for CDK bootstrap (#12731) · localstack/localstack@05db0f1 · GitHub
[go: up one dir, main page]

Skip to content

Commit 05db0f1

Browse files
authored
CFn v2: implement support for CDK bootstrap (#12731)
1 parent 3adead2 commit 05db0f1

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
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-
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