-
-
Notifications
You must be signed in to change notification settings - Fork 4.2k
CFNv2: implement describe-stack-resource #12912
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
Changes from all commits
2e99441
a6717dc
96f13be
892e1b8
d3a1aa9
18a24b6
d1907cd
beff6ee
ce1a108
f337492
cea3566
be599f7
756b0a0
948c8d5
6135c93
d5cc6a6
aabdeac
481143c
5cb7f8d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,6 +2,7 @@ | |
import logging | ||
import uuid | ||
from dataclasses import dataclass | ||
from datetime import datetime, timezone | ||
from typing import Final, Optional | ||
|
||
from localstack import config | ||
|
@@ -36,7 +37,7 @@ | |
ResourceProviderExecutor, | ||
ResourceProviderPayload, | ||
) | ||
from localstack.services.cloudformation.v2.entities import ChangeSet | ||
from localstack.services.cloudformation.v2.entities import ChangeSet, ResolvedResource | ||
|
||
LOG = logging.getLogger(__name__) | ||
|
||
|
@@ -45,14 +46,14 @@ | |
|
||
@dataclass | ||
class ChangeSetModelExecutorResult: | ||
resources: dict | ||
resources: dict[str, ResolvedResource] | ||
parameters: dict | ||
outputs: dict | ||
|
||
|
||
class ChangeSetModelExecutor(ChangeSetModelPreproc): | ||
# TODO: add typing for resolved resources and parameters. | ||
resources: Final[dict] | ||
resources: Final[dict[str, ResolvedResource]] | ||
outputs: Final[dict] | ||
resolved_parameters: Final[dict] | ||
|
||
|
@@ -409,7 +410,6 @@ def _execute_resource_action( | |
message=f"Resource type {resource_type} not supported", | ||
) | ||
|
||
self.resources.setdefault(logical_resource_id, {"Properties": {}}) | ||
match event.status: | ||
case OperationStatus.SUCCESS: | ||
# merge the resources state with the external state | ||
|
@@ -422,18 +422,24 @@ def _execute_resource_action( | |
# TODO: avoid the use of setdefault (debuggability/readability) | ||
# TODO: review the use of merge | ||
|
||
self.resources[logical_resource_id]["Properties"].update(event.resource_model) | ||
self.resources[logical_resource_id].update(extra_resource_properties) | ||
# XXX for legacy delete_stack compatibility | ||
self.resources[logical_resource_id]["LogicalResourceId"] = logical_resource_id | ||
self.resources[logical_resource_id]["Type"] = resource_type | ||
|
||
status_from_action = EventOperationFromAction[action.value] | ||
physical_resource_id = ( | ||
self._get_physical_id(logical_resource_id) | ||
extra_resource_properties["PhysicalResourceId"] | ||
if resource_provider | ||
else MOCKED_REFERENCE | ||
) | ||
self.resources[logical_resource_id]["PhysicalResourceId"] = physical_resource_id | ||
resolved_resource = ResolvedResource( | ||
Properties=event.resource_model, | ||
LogicalResourceId=logical_resource_id, | ||
Type=resource_type, | ||
LastUpdatedTimestamp=datetime.now(timezone.utc), | ||
ResourceStatus=ResourceStatus(f"{status_from_action}_COMPLETE"), | ||
PhysicalResourceId=physical_resource_id, | ||
) | ||
# TODO: do we actually need this line? | ||
resolved_resource.update(extra_resource_properties) | ||
|
||
self.resources[logical_resource_id] = resolved_resource | ||
|
||
Comment on lines
-426
to
443
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question/comment: Does this mean that we only store the resource description when the event is successful? Is that correct? If not, we could move the storage one level above, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You make an excellent point. However we don't have much test coverage of this situation. It should be fairly easy to trigger: set up a resource definition with deliberately invalid data. Then the stack will be left in a bad state, HOWEVER we don't yet model that state and behaviour. I think that's a pre-requisite. I would feel nervous to implement something as fundamental as that without test coverage. I would love it if at the end of this initiative we have correct rollback behaviour but I'm not sure we will yet. Perhaps I can work on this in a follow up? |
||
case OperationStatus.FAILED: | ||
reason = event.message | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import re | ||
|
||
from localstack.services.cloudformation.engine.v2.change_set_model import ( | ||
NodeParameters, | ||
NodeResource, | ||
NodeTemplate, | ||
is_nothing, | ||
) | ||
from localstack.services.cloudformation.engine.v2.change_set_model_preproc import ( | ||
PreprocEntityDelta, | ||
) | ||
from localstack.services.cloudformation.engine.v2.change_set_model_visitor import ( | ||
ChangeSetModelVisitor, | ||
) | ||
from localstack.services.cloudformation.engine.validations import ValidationError | ||
from localstack.services.cloudformation.v2.entities import ChangeSet | ||
|
||
VALID_LOGICAL_RESOURCE_ID_RE = re.compile(r"^[A-Za-z0-9]+$") | ||
|
||
|
||
class ChangeSetModelValidator(ChangeSetModelVisitor): | ||
def __init__(self, change_set: ChangeSet): | ||
self._change_set = change_set | ||
|
||
def validate(self): | ||
self.visit(self._change_set.update_model.node_template) | ||
|
||
def visit_node_template(self, node_template: NodeTemplate): | ||
self.visit(node_template.parameters) | ||
self.visit(node_template.resources) | ||
|
||
def visit_node_parameters(self, node_parameters: NodeParameters) -> PreprocEntityDelta: | ||
# check that all parameters have values | ||
invalid_parameters = [] | ||
for node_parameter in node_parameters.parameters: | ||
self.visit(node_parameter) | ||
if is_nothing(node_parameter.default_value.value) and is_nothing( | ||
node_parameter.dynamic_value.value | ||
): | ||
invalid_parameters.append(node_parameter.name) | ||
|
||
if invalid_parameters: | ||
raise ValidationError(f"Parameters: [{','.join(invalid_parameters)}] must have values") | ||
|
||
# continue visiting | ||
return super().visit_node_parameters(node_parameters) | ||
|
||
def visit_node_resource(self, node_resource: NodeResource) -> PreprocEntityDelta: | ||
if not VALID_LOGICAL_RESOURCE_ID_RE.match(node_resource.name): | ||
raise ValidationError( | ||
f"Template format error: Resource name {node_resource.name} is non alphanumeric." | ||
) | ||
return super().visit_node_resource(node_resource) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
import json | ||
import os | ||
|
||
import pytest | ||
from botocore.exceptions import ClientError | ||
|
||
from localstack.testing.pytest import markers | ||
|
||
|
||
@markers.aws.validated | ||
def test_describe_non_existent_stack(aws_client, deploy_cfn_template, snapshot): | ||
with pytest.raises(ClientError) as err: | ||
aws_client.cloudformation.describe_stack_resource( | ||
StackName="not-a-valid-stack", LogicalResourceId="not-a-valid-resource" | ||
) | ||
|
||
snapshot.match("error", err.value) | ||
|
||
|
||
@markers.aws.validated | ||
def test_describe_non_existent_resource(aws_client, deploy_cfn_template, snapshot): | ||
template_path = os.path.join( | ||
os.path.dirname(__file__), "../../../../../templates/ssm_parameter_defaultname.yaml" | ||
) | ||
stack = deploy_cfn_template(template_path=template_path, parameters={"Input": "myvalue"}) | ||
snapshot.add_transformer(snapshot.transform.regex(stack.stack_id, "<stack-id>")) | ||
|
||
with pytest.raises(ClientError) as err: | ||
aws_client.cloudformation.describe_stack_resource( | ||
StackName=stack.stack_id, LogicalResourceId="not-a-valid-resource" | ||
) | ||
|
||
snapshot.match("error", err.value) | ||
|
||
|
||
@markers.aws.validated | ||
def test_invalid_logical_resource_id(deploy_cfn_template, snapshot): | ||
template = { | ||
"Resources": { | ||
"my-bad-resource-id": { | ||
"Type": "AWS::SSM::Parameter", | ||
"Properties": { | ||
"Type": "String", | ||
"Value": "Foo", | ||
}, | ||
} | ||
} | ||
} | ||
with pytest.raises(ClientError) as err: | ||
deploy_cfn_template(template=json.dumps(template)) | ||
|
||
snapshot.match("error", err.value) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
{ | ||
"tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py::test_describe_non_existent_resource": { | ||
"recorded-date": "25-07-2025, 22:01:35", | ||
"recorded-content": { | ||
"error": "An error occurred (ValidationError) when calling the DescribeStackResource operation: Resource not-a-valid-resource does not exist for stack <stack-id>" | ||
} | ||
}, | ||
"tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py::test_describe_non_existent_stack": { | ||
"recorded-date": "25-07-2025, 22:02:38", | ||
"recorded-content": { | ||
"error": "An error occurred (ValidationError) when calling the DescribeStackResource operation: Stack 'not-a-valid-stack' does not exist" | ||
} | ||
}, | ||
"tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py::test_invalid_logical_resource_id": { | ||
"recorded-date": "25-07-2025, 22:21:31", | ||
"recorded-content": { | ||
"error": "An error occurred (ValidationError) when calling the CreateChangeSet operation: Template format error: Resource name my-bad-resource-id is non alphanumeric." | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
{ | ||
"tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py::test_describe_non_existent_resource": { | ||
"last_validated_date": "2025-07-25T22:01:40+00:00", | ||
"durations_in_seconds": { | ||
"setup": 1.11, | ||
"call": 10.33, | ||
"teardown": 4.37, | ||
"total": 15.81 | ||
} | ||
}, | ||
"tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py::test_describe_non_existent_stack": { | ||
"last_validated_date": "2025-07-25T22:02:38+00:00", | ||
"durations_in_seconds": { | ||
"setup": 1.04, | ||
"call": 0.2, | ||
"teardown": 0.0, | ||
"total": 1.24 | ||
} | ||
}, | ||
"tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py::test_invalid_logical_resource_id": { | ||
"last_validated_date": "2025-07-25T22:21:31+00:00", | ||
"durations_in_seconds": { | ||
"setup": 1.31, | ||
"call": 0.35, | ||
"teardown": 0.0, | ||
"total": 1.66 | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FYI: As far as I can tell, this change really just migrates the ignore introduced in #12871 to the tests executed against Pro.