Skip to content

Commit 5535768

Browse files
authored
CFNv2: implement describe-stack-resource (#12912)
1 parent 52c267f commit 5535768

File tree

16 files changed

+283
-27
lines changed

16 files changed

+283
-27
lines changed

.github/workflows/tests-pro-integration.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ jobs:
339339
AWS_DEFAULT_REGION: "us-east-1"
340340
JUNIT_REPORTS_FILE: "pytest-junit-community-${{ matrix.group }}.xml"
341341
TEST_PATH: "../../localstack/tests/aws/" # TODO: run tests in tests/integration
342-
PYTEST_ARGS: "${{ env.TINYBIRD_PYTEST_ARGS }}${{ env.TESTSELECTION_PYTEST_ARGS }}--splits ${{ strategy.job-total }} --group ${{ matrix.group }} --durations-path ../../localstack/.test_durations --store-durations"
342+
PYTEST_ARGS: "${{ env.TINYBIRD_PYTEST_ARGS }}${{ env.TESTSELECTION_PYTEST_ARGS }}--splits ${{ strategy.job-total }} --group ${{ matrix.group }} --durations-path ../../localstack/.test_durations --store-durations --ignore ../../localstack/tests/aws/services/cloudformation/v2"
343343
working-directory: localstack-pro
344344
run: |
345345
# Remove the host tmp folder (might contain remnant files with different permissions)

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

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import uuid
44
from dataclasses import dataclass
5+
from datetime import datetime, timezone
56
from typing import Final, Optional
67

78
from localstack import config
@@ -36,7 +37,7 @@
3637
ResourceProviderExecutor,
3738
ResourceProviderPayload,
3839
)
39-
from localstack.services.cloudformation.v2.entities import ChangeSet
40+
from localstack.services.cloudformation.v2.entities import ChangeSet, ResolvedResource
4041

4142
LOG = logging.getLogger(__name__)
4243

@@ -45,14 +46,14 @@
4546

4647
@dataclass
4748
class ChangeSetModelExecutorResult:
48-
resources: dict
49+
resources: dict[str, ResolvedResource]
4950
parameters: dict
5051
outputs: dict
5152

5253

5354
class ChangeSetModelExecutor(ChangeSetModelPreproc):
5455
# TODO: add typing for resolved resources and parameters.
55-
resources: Final[dict]
56+
resources: Final[dict[str, ResolvedResource]]
5657
outputs: Final[dict]
5758
resolved_parameters: Final[dict]
5859

@@ -409,7 +410,6 @@ def _execute_resource_action(
409410
message=f"Resource type {resource_type} not supported",
410411
)
411412

412-
self.resources.setdefault(logical_resource_id, {"Properties": {}})
413413
match event.status:
414414
case OperationStatus.SUCCESS:
415415
# merge the resources state with the external state
@@ -422,18 +422,24 @@ def _execute_resource_action(
422422
# TODO: avoid the use of setdefault (debuggability/readability)
423423
# TODO: review the use of merge
424424

425-
self.resources[logical_resource_id]["Properties"].update(event.resource_model)
426-
self.resources[logical_resource_id].update(extra_resource_properties)
427-
# XXX for legacy delete_stack compatibility
428-
self.resources[logical_resource_id]["LogicalResourceId"] = logical_resource_id
429-
self.resources[logical_resource_id]["Type"] = resource_type
430-
425+
status_from_action = EventOperationFromAction[action.value]
431426
physical_resource_id = (
432-
self._get_physical_id(logical_resource_id)
427+
extra_resource_properties["PhysicalResourceId"]
433428
if resource_provider
434429
else MOCKED_REFERENCE
435430
)
436-
self.resources[logical_resource_id]["PhysicalResourceId"] = physical_resource_id
431+
resolved_resource = ResolvedResource(
432+
Properties=event.resource_model,
433+
LogicalResourceId=logical_resource_id,
434+
Type=resource_type,
435+
LastUpdatedTimestamp=datetime.now(timezone.utc),
436+
ResourceStatus=ResourceStatus(f"{status_from_action}_COMPLETE"),
437+
PhysicalResourceId=physical_resource_id,
438+
)
439+
# TODO: do we actually need this line?
440+
resolved_resource.update(extra_resource_properties)
441+
442+
self.resources[logical_resource_id] = resolved_resource
437443

438444
case OperationStatus.FAILED:
439445
reason = event.message
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import re
2+
3+
from localstack.services.cloudformation.engine.v2.change_set_model import (
4+
NodeParameters,
5+
NodeResource,
6+
NodeTemplate,
7+
is_nothing,
8+
)
9+
from localstack.services.cloudformation.engine.v2.change_set_model_preproc import (
10+
PreprocEntityDelta,
11+
)
12+
from localstack.services.cloudformation.engine.v2.change_set_model_visitor import (
13+
ChangeSetModelVisitor,
14+
)
15+
from localstack.services.cloudformation.engine.validations import ValidationError
16+
from localstack.services.cloudformation.v2.entities import ChangeSet
17+
18+
VALID_LOGICAL_RESOURCE_ID_RE = re.compile(r"^[A-Za-z0-9]+$")
19+
20+
21+
class ChangeSetModelValidator(ChangeSetModelVisitor):
22+
def __init__(self, change_set: ChangeSet):
23+
self._change_set = change_set
24+
25+
def validate(self):
26+
self.visit(self._change_set.update_model.node_template)
27+
28+
def visit_node_template(self, node_template: NodeTemplate):
29+
self.visit(node_template.parameters)
30+
self.visit(node_template.resources)
31+
32+
def visit_node_parameters(self, node_parameters: NodeParameters) -> PreprocEntityDelta:
33+
# check that all parameters have values
34+
invalid_parameters = []
35+
for node_parameter in node_parameters.parameters:
36+
self.visit(node_parameter)
37+
if is_nothing(node_parameter.default_value.value) and is_nothing(
38+
node_parameter.dynamic_value.value
39+
):
40+
invalid_parameters.append(node_parameter.name)
41+
42+
if invalid_parameters:
43+
raise ValidationError(f"Parameters: [{','.join(invalid_parameters)}] must have values")
44+
45+
# continue visiting
46+
return super().visit_node_parameters(node_parameters)
47+
48+
def visit_node_resource(self, node_resource: NodeResource) -> PreprocEntityDelta:
49+
if not VALID_LOGICAL_RESOURCE_ID_RE.match(node_resource.name):
50+
raise ValidationError(
51+
f"Template format error: Resource name {node_resource.name} is non alphanumeric."
52+
)
53+
return super().visit_node_resource(node_resource)

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,12 @@
3939

4040

4141
class ResolvedResource(TypedDict):
42+
LogicalResourceId: str
4243
Type: str
4344
Properties: dict
45+
ResourceStatus: ResourceStatus
46+
PhysicalResourceId: str | None
47+
LastUpdatedTimestamp: datetime | None
4448

4549

4650
class Stack:

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

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
DeletionMode,
3030
DescribeChangeSetOutput,
3131
DescribeStackEventsOutput,
32+
DescribeStackResourceOutput,
3233
DescribeStackResourcesOutput,
3334
DescribeStackSetOperationOutput,
3435
DescribeStacksOutput,
@@ -42,6 +43,7 @@
4243
IncludePropertyValues,
4344
InsufficientCapabilitiesException,
4445
InvalidChangeSetStatusException,
46+
ListStackResourcesOutput,
4547
ListStacksOutput,
4648
LogicalResourceId,
4749
NextToken,
@@ -53,6 +55,8 @@
5355
RollbackConfiguration,
5456
StackName,
5557
StackNameOrId,
58+
StackResourceDetail,
59+
StackResourceSummary,
5660
StackSetName,
5761
StackSetNotFoundException,
5862
StackSetOperation,
@@ -82,6 +86,9 @@
8286
from localstack.services.cloudformation.engine.v2.change_set_model_transform import (
8387
ChangeSetModelTransform,
8488
)
89+
from localstack.services.cloudformation.engine.v2.change_set_model_validator import (
90+
ChangeSetModelValidator,
91+
)
8592
from localstack.services.cloudformation.engine.validations import ValidationError
8693
from localstack.services.cloudformation.provider import (
8794
ARN_CHANGESET_REGEX,
@@ -114,11 +121,14 @@ def is_stack_set_arn(stack_set_name_or_id: str) -> bool:
114121

115122

116123
class StackNotFoundError(ValidationError):
117-
def __init__(self, stack_name_or_id: str):
118-
if is_stack_arn(stack_name_or_id):
119-
super().__init__(f"Stack with id {stack_name_or_id} does not exist")
124+
def __init__(self, stack_name_or_id: str, message_override: str | None = None):
125+
if message_override:
126+
super().__init__(message_override)
120127
else:
121-
super().__init__(f"Stack [{stack_name_or_id}] does not exist")
128+
if is_stack_arn(stack_name_or_id):
129+
super().__init__(f"Stack with id {stack_name_or_id} does not exist")
130+
else:
131+
super().__init__(f"Stack [{stack_name_or_id}] does not exist")
122132

123133

124134
class StackSetNotFoundError(StackSetNotFoundException):
@@ -234,6 +244,13 @@ def _setup_change_set_model(
234244
# the transformations.
235245
update_model.before_runtime_cache.update(raw_update_model.before_runtime_cache)
236246
update_model.after_runtime_cache.update(raw_update_model.after_runtime_cache)
247+
248+
# perform validations
249+
validator = ChangeSetModelValidator(
250+
change_set=change_set,
251+
)
252+
validator.validate()
253+
237254
change_set.set_update_model(update_model)
238255
change_set.stack.processed_template = transformed_after_template
239256

@@ -697,6 +714,62 @@ def list_stacks(
697714
stacks = [select_attributes(stack, attrs) for stack in stacks]
698715
return ListStacksOutput(StackSummaries=stacks)
699716

717+
@handler("ListStackResources")
718+
def list_stack_resources(
719+
self, context: RequestContext, stack_name: StackName, next_token: NextToken = None, **kwargs
720+
) -> ListStackResourcesOutput:
721+
result = self.describe_stack_resources(context, stack_name)
722+
723+
resources = []
724+
for resource in result.get("StackResources", []):
725+
resources.append(
726+
StackResourceSummary(
727+
LogicalResourceId=resource["LogicalResourceId"],
728+
PhysicalResourceId=resource["PhysicalResourceId"],
729+
ResourceType=resource["ResourceType"],
730+
LastUpdatedTimestamp=resource["Timestamp"],
731+
ResourceStatus=resource["ResourceStatus"],
732+
ResourceStatusReason=resource.get("ResourceStatusReason"),
733+
DriftInformation=resource.get("DriftInformation"),
734+
ModuleInfo=resource.get("ModuleInfo"),
735+
)
736+
)
737+
738+
return ListStackResourcesOutput(StackResourceSummaries=resources)
739+
740+
@handler("DescribeStackResource")
741+
def describe_stack_resource(
742+
self,
743+
context: RequestContext,
744+
stack_name: StackName,
745+
logical_resource_id: LogicalResourceId,
746+
**kwargs,
747+
) -> DescribeStackResourceOutput:
748+
state = get_cloudformation_store(context.account_id, context.region)
749+
stack = find_stack_v2(state, stack_name)
750+
if not stack:
751+
raise StackNotFoundError(
752+
stack_name, message_override=f"Stack '{stack_name}' does not exist"
753+
)
754+
755+
try:
756+
resource = stack.resolved_resources[logical_resource_id]
757+
except KeyError:
758+
raise ValidationError(
759+
f"Resource {logical_resource_id} does not exist for stack {stack_name}"
760+
)
761+
762+
resource_detail = StackResourceDetail(
763+
StackName=stack.stack_name,
764+
StackId=stack.stack_id,
765+
LogicalResourceId=logical_resource_id,
766+
PhysicalResourceId=resource["PhysicalResourceId"],
767+
ResourceType=resource["Type"],
768+
LastUpdatedTimestamp=resource["LastUpdatedTimestamp"],
769+
ResourceStatus=resource["ResourceStatus"],
770+
)
771+
return DescribeStackResourceOutput(StackResourceDetail=resource_detail)
772+
700773
@handler("DescribeStackResources")
701774
def describe_stack_resources(
702775
self,

localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queuepolicy.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ def update(
104104
policy = json.dumps(model["PolicyDocument"])
105105
sqs.set_queue_attributes(QueueUrl=queue, Attributes={"Policy": policy})
106106

107+
model["Id"] = request.previous_state["Id"]
108+
107109
return ProgressEvent(
108110
status=OperationStatus.SUCCESS,
109-
resource_model=request.desired_state,
111+
resource_model=model,
110112
)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import json
2+
import os
3+
4+
import pytest
5+
from botocore.exceptions import ClientError
6+
7+
from localstack.testing.pytest import markers
8+
9+
10+
@markers.aws.validated
11+
def test_describe_non_existent_stack(aws_client, deploy_cfn_template, snapshot):
12+
with pytest.raises(ClientError) as err:
13+
aws_client.cloudformation.describe_stack_resource(
14+
StackName="not-a-valid-stack", LogicalResourceId="not-a-valid-resource"
15+
)
16+
17+
snapshot.match("error", err.value)
18+
19+
20+
@markers.aws.validated
21+
def test_describe_non_existent_resource(aws_client, deploy_cfn_template, snapshot):
22+
template_path = os.path.join(
23+
os.path.dirname(__file__), "../../../../../templates/ssm_parameter_defaultname.yaml"
24+
)
25+
stack = deploy_cfn_template(template_path=template_path, parameters={"Input": "myvalue"})
26+
snapshot.add_transformer(snapshot.transform.regex(stack.stack_id, "<stack-id>"))
27+
28+
with pytest.raises(ClientError) as err:
29+
aws_client.cloudformation.describe_stack_resource(
30+
StackName=stack.stack_id, LogicalResourceId="not-a-valid-resource"
31+
)
32+
33+
snapshot.match("error", err.value)
34+
35+
36+
@markers.aws.validated
37+
def test_invalid_logical_resource_id(deploy_cfn_template, snapshot):
38+
template = {
39+
"Resources": {
40+
"my-bad-resource-id": {
41+
"Type": "AWS::SSM::Parameter",
42+
"Properties": {
43+
"Type": "String",
44+
"Value": "Foo",
45+
},
46+
}
47+
}
48+
}
49+
with pytest.raises(ClientError) as err:
50+
deploy_cfn_template(template=json.dumps(template))
51+
52+
snapshot.match("error", err.value)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py::test_describe_non_existent_resource": {
3+
"recorded-date": "25-07-2025, 22:01:35",
4+
"recorded-content": {
5+
"error": "An error occurred (ValidationError) when calling the DescribeStackResource operation: Resource not-a-valid-resource does not exist for stack <stack-id>"
6+
}
7+
},
8+
"tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py::test_describe_non_existent_stack": {
9+
"recorded-date": "25-07-2025, 22:02:38",
10+
"recorded-content": {
11+
"error": "An error occurred (ValidationError) when calling the DescribeStackResource operation: Stack 'not-a-valid-stack' does not exist"
12+
}
13+
},
14+
"tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py::test_invalid_logical_resource_id": {
15+
"recorded-date": "25-07-2025, 22:21:31",
16+
"recorded-content": {
17+
"error": "An error occurred (ValidationError) when calling the CreateChangeSet operation: Template format error: Resource name my-bad-resource-id is non alphanumeric."
18+
}
19+
}
20+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py::test_describe_non_existent_resource": {
3+
"last_validated_date": "2025-07-25T22:01:40+00:00",
4+
"durations_in_seconds": {
5+
"setup": 1.11,
6+
"call": 10.33,
7+
"teardown": 4.37,
8+
"total": 15.81
9+
}
10+
},
11+
"tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py::test_describe_non_existent_stack": {
12+
"last_validated_date": "2025-07-25T22:02:38+00:00",
13+
"durations_in_seconds": {
14+
"setup": 1.04,
15+
"call": 0.2,
16+
"teardown": 0.0,
17+
"total": 1.24
18+
}
19+
},
20+
"tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py::test_invalid_logical_resource_id": {
21+
"last_validated_date": "2025-07-25T22:21:31+00:00",
22+
"durations_in_seconds": {
23+
"setup": 1.31,
24+
"call": 0.35,
25+
"teardown": 0.0,
26+
"total": 1.66
27+
}
28+
}
29+
}

0 commit comments

Comments
 (0)