From 2e9944165be7bc7380ea0131f5e26bf7f12498a1 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Fri, 25 Jul 2025 18:23:56 +0100 Subject: [PATCH 01/18] WIP --- .../engine/v2/change_set_model_executor.py | 5 +++ .../engine/v2/change_set_model_validator.py | 44 +++++++++++++++++++ .../services/cloudformation/v2/provider.py | 37 ++++++++++++++++ .../v2/ported_from_v1/api/test_resources.py | 11 +++++ .../api/test_resources.snapshot.json | 6 +++ .../v2/ported_from_v1/api/test_stacks.py | 12 ++++- .../api/test_stacks.snapshot.json | 6 +++ .../api/test_stacks.validation.json | 9 ++++ .../v2/ported_from_v1/resources/test_ec2.py | 1 - 9 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_validator.py create mode 100644 tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py create mode 100644 tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.snapshot.json diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py index 8cab080b0d984..d5032ae9f33d8 100644 --- a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py @@ -18,6 +18,7 @@ NodeDependsOn, NodeOutput, NodeParameter, + NodeParameters, NodeResource, is_nothing, ) @@ -69,6 +70,10 @@ def execute(self) -> ChangeSetModelExecutorResult: resources=self.resources, parameters=self.resolved_parameters, outputs=self.outputs ) + def visit_node_parameters(self, node_parameters: NodeParameters) -> PreprocEntityDelta: + delta = super().visit_node_parameters(node_parameters) + return delta + def visit_node_parameter(self, node_parameter: NodeParameter) -> PreprocEntityDelta: delta = super().visit_node_parameter(node_parameter) diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_validator.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_validator.py new file mode 100644 index 0000000000000..1b029be5870e6 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_validator.py @@ -0,0 +1,44 @@ +from typing import Final + +from localstack.services.cloudformation.engine.v2.change_set_model import ( + NodeParameters, +) +from localstack.services.cloudformation.engine.v2.change_set_model_preproc import ( + ChangeSetModelPreproc, + PreprocEntityDelta, +) +from localstack.services.cloudformation.engine.validations import ValidationError +from localstack.services.cloudformation.v2.entities import ChangeSet + + +class ChangeSetModelValidator(ChangeSetModelPreproc): + _before_parameters: Final[dict] + _after_parameters: Final[dict] + + def __init__( + self, + change_set: ChangeSet, + before_parameters: dict, + after_parameters: dict, + ): + super().__init__(change_set) + self._before_parameters = before_parameters + self._after_parameters = after_parameters + + def validate(self): + # validate parameters are all given + self.visit(self._change_set.update_model.node_template.parameters) + + def visit_node_parameters(self, node_parameters: NodeParameters) -> PreprocEntityDelta: + delta = super().visit_node_parameters(node_parameters) + # assert before + if self._before_parameters: + missing_values = [key for key in delta.before if delta.before[key] is None] + if missing_values: + raise ValidationError() + if self._after_parameters: + missing_values = [key for key in delta.after if delta.before[key] is None] + if missing_values: + raise ValidationError() + + return delta diff --git a/localstack-core/localstack/services/cloudformation/v2/provider.py b/localstack-core/localstack/services/cloudformation/v2/provider.py index 26f8974f557aa..dffa660f52a6b 100644 --- a/localstack-core/localstack/services/cloudformation/v2/provider.py +++ b/localstack-core/localstack/services/cloudformation/v2/provider.py @@ -21,6 +21,7 @@ DeletionMode, DescribeChangeSetOutput, DescribeStackEventsOutput, + DescribeStackResourceOutput, DescribeStackResourcesOutput, DescribeStacksOutput, DisableRollback, @@ -67,6 +68,9 @@ from localstack.services.cloudformation.engine.v2.change_set_model_transform import ( ChangeSetModelTransform, ) +from localstack.services.cloudformation.engine.v2.change_set_model_validator import ( + ChangeSetModelValidator, +) from localstack.services.cloudformation.engine.validations import ValidationError from localstack.services.cloudformation.provider import ( ARN_CHANGESET_REGEX, @@ -190,6 +194,15 @@ def _setup_change_set_model( # the transformations. update_model.before_runtime_cache.update(raw_update_model.before_runtime_cache) update_model.after_runtime_cache.update(raw_update_model.after_runtime_cache) + + # perform validations + validator = ChangeSetModelValidator( + change_set=change_set, + before_parameters=before_parameters, + after_parameters=after_parameters, + ) + validator.validate() + change_set.set_update_model(update_model) change_set.stack.processed_template = transformed_after_template @@ -643,6 +656,30 @@ def list_stacks( stacks = [select_attributes(stack, attrs) for stack in stacks] return ListStacksOutput(StackSummaries=stacks) + @handler("DescribeStackResource") + def describe_stack_resource( + self, + context: RequestContext, + stack_name: StackName, + logical_resource_id: LogicalResourceId, + **kwargs, + ) -> DescribeStackResourceOutput: + state = get_cloudformation_store(context.account_id, context.region) + stack = find_stack_v2(state, stack_name) + if not stack: + raise StackNotFoundError(stack_name) + + try: + resource = stack.resolved_resources[logical_resource_id] + except KeyError as e: + if "Unable to find details" in str(e): + raise ValidationError( + f"Resource {logical_resource_id} does not exist for stack {stack_name}" + ) + raise + + return DescribeStackResourceOutput(StackResourceDetail=details) + @handler("DescribeStackResources") def describe_stack_resources( self, diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py new file mode 100644 index 0000000000000..d0cc4f1d17579 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py @@ -0,0 +1,11 @@ +import os + +from localstack.testing.pytest import markers + + +@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) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.snapshot.json new file mode 100644 index 0000000000000..1985c68b286cc --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.snapshot.json @@ -0,0 +1,6 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py::test_describe_non_existent_resource": { + "recorded-date": "25-07-2025, 15:24:21", + "recorded-content": {} + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py index 05fad77292e8c..20ed8e5a420ac 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py @@ -6,7 +6,7 @@ import botocore.exceptions import pytest import yaml -from botocore.exceptions import WaiterError +from botocore.exceptions import ClientError, WaiterError from localstack_snapshot.snapshots.transformer import SortingTransformer from localstack.aws.api.cloudformation import Capability @@ -1061,3 +1061,13 @@ def test_stack_resource_not_found(deploy_cfn_template, aws_client, snapshot): snapshot.add_transformer(snapshot.transform.regex(stack.stack_name, "")) snapshot.match("Error", ex.value.response) + + +@markers.aws.validated +def test_no_parameters_given(aws_client, deploy_cfn_template, snapshot): + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/ssm_parameter_defaultname.yaml" + ) + with pytest.raises(ClientError) as exc_info: + deploy_cfn_template(template_path=template_path, parameters={"Input": "Foo"}) + snapshot.match("deploy-error", exc_info.value) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.snapshot.json index 979af0c8a9573..63ac9b9c14b57 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.snapshot.json +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.snapshot.json @@ -2286,5 +2286,11 @@ } } } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_no_parameters_given": { + "recorded-date": "25-07-2025, 15:34:21", + "recorded-content": { + "deploy-error": "An error occurred (ValidationError) when calling the CreateChangeSet operation: Parameters: [Input] must have values" + } } } diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.validation.json index 005063a3a34ee..fc5d362607d93 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.validation.json +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.validation.json @@ -62,6 +62,15 @@ "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_no_echo_parameter": { "last_validated_date": "2024-12-19T11:35:15+00:00" }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_no_parameters_given": { + "last_validated_date": "2025-07-25T15:34:21+00:00", + "durations_in_seconds": { + "setup": 1.24, + "call": 0.3, + "teardown": 0.0, + "total": 1.54 + } + }, "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order2": { "last_validated_date": "2024-05-21T09:48:14+00:00" }, diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py index a31bf40d39240..8cecb1c3627a0 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py @@ -128,7 +128,6 @@ def test_cfn_with_multiple_route_table_associations(deploy_cfn_template, aws_cli snapshot.add_transformer(snapshot.transform.key_value("VpcId")) -@pytest.mark.skip(reason="CFNV2:Describe") @markers.aws.validated @markers.snapshot.skip_snapshot_verify(paths=["$..DriftInformation", "$..Metadata"]) def test_internet_gateway_ref_and_attr(deploy_cfn_template, snapshot, aws_client): From a6717dc9990d6288e0cc6db90826d4310f6ecc1a Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Fri, 25 Jul 2025 22:34:19 +0100 Subject: [PATCH 02/18] Type resolved resources --- .../cloudformation/engine/v2/change_set_model_executor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py index d5032ae9f33d8..9ed2bedaef1ac 100644 --- a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py @@ -37,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__) @@ -46,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] From 96f13be1995b42b69cc6ac11ff9322df04cb5f27 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Fri, 25 Jul 2025 22:55:54 +0100 Subject: [PATCH 03/18] Restructure the way the ResolvedResource works --- .../engine/v2/change_set_model_executor.py | 24 ++++++++++++------- .../services/cloudformation/v2/entities.py | 4 ++++ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py index 9ed2bedaef1ac..a1804cce2ce62 100644 --- a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py @@ -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 @@ -414,7 +415,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 @@ -427,18 +427,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 case OperationStatus.FAILED: reason = event.message diff --git a/localstack-core/localstack/services/cloudformation/v2/entities.py b/localstack-core/localstack/services/cloudformation/v2/entities.py index 0add53bfb5e84..a568692ea5e53 100644 --- a/localstack-core/localstack/services/cloudformation/v2/entities.py +++ b/localstack-core/localstack/services/cloudformation/v2/entities.py @@ -34,8 +34,12 @@ class ResolvedResource(TypedDict): + LogicalResourceId: str Type: str Properties: dict + ResourceStatus: ResourceStatus + PhysicalResourceId: str | None + LastUpdatedTimestamp: datetime | None class Stack: From 892e1b81a482d3288dd2bb1b4b958e38e4bba2b6 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Fri, 25 Jul 2025 22:57:10 +0100 Subject: [PATCH 04/18] Handle validation of parameter values --- .../engine/v2/change_set_model_validator.py | 45 ++++++++----------- .../services/cloudformation/v2/provider.py | 2 - 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_validator.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_validator.py index 1b029be5870e6..cc67959aee4bb 100644 --- a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_validator.py +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_validator.py @@ -1,44 +1,35 @@ -from typing import Final - from localstack.services.cloudformation.engine.v2.change_set_model import ( NodeParameters, + NodeTemplate, + is_nothing, ) from localstack.services.cloudformation.engine.v2.change_set_model_preproc import ( ChangeSetModelPreproc, PreprocEntityDelta, ) from localstack.services.cloudformation.engine.validations import ValidationError -from localstack.services.cloudformation.v2.entities import ChangeSet class ChangeSetModelValidator(ChangeSetModelPreproc): - _before_parameters: Final[dict] - _after_parameters: Final[dict] - - def __init__( - self, - change_set: ChangeSet, - before_parameters: dict, - after_parameters: dict, - ): - super().__init__(change_set) - self._before_parameters = before_parameters - self._after_parameters = after_parameters - def validate(self): # validate parameters are all given self.visit(self._change_set.update_model.node_template.parameters) + def visit_node_template(self, node_template: NodeTemplate): + self.visit_node_parameters(node_template.parameters) + def visit_node_parameters(self, node_parameters: NodeParameters) -> PreprocEntityDelta: - delta = super().visit_node_parameters(node_parameters) - # assert before - if self._before_parameters: - missing_values = [key for key in delta.before if delta.before[key] is None] - if missing_values: - raise ValidationError() - if self._after_parameters: - missing_values = [key for key in delta.after if delta.before[key] is None] - if missing_values: - raise ValidationError() + # 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") - return delta + # continue visiting + return super().visit_node_parameters(node_parameters) diff --git a/localstack-core/localstack/services/cloudformation/v2/provider.py b/localstack-core/localstack/services/cloudformation/v2/provider.py index dffa660f52a6b..eaccb9f512627 100644 --- a/localstack-core/localstack/services/cloudformation/v2/provider.py +++ b/localstack-core/localstack/services/cloudformation/v2/provider.py @@ -198,8 +198,6 @@ def _setup_change_set_model( # perform validations validator = ChangeSetModelValidator( change_set=change_set, - before_parameters=before_parameters, - after_parameters=after_parameters, ) validator.validate() From d3a1aa90481afa0113bad2056a7a22edb30ca394 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Fri, 25 Jul 2025 22:57:23 +0100 Subject: [PATCH 05/18] Implement describe-stack-resource --- .../services/cloudformation/v2/provider.py | 22 +++++++++++++------ .../v2/ported_from_v1/api/test_stacks.py | 3 +-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/localstack-core/localstack/services/cloudformation/v2/provider.py b/localstack-core/localstack/services/cloudformation/v2/provider.py index eaccb9f512627..0c1a13971cb9a 100644 --- a/localstack-core/localstack/services/cloudformation/v2/provider.py +++ b/localstack-core/localstack/services/cloudformation/v2/provider.py @@ -45,6 +45,7 @@ RollbackConfiguration, StackName, StackNameOrId, + StackResourceDetail, StackStatus, StackStatusFilter, TemplateStage, @@ -669,14 +670,21 @@ def describe_stack_resource( try: resource = stack.resolved_resources[logical_resource_id] - except KeyError as e: - if "Unable to find details" in str(e): - raise ValidationError( - f"Resource {logical_resource_id} does not exist for stack {stack_name}" - ) - raise + except KeyError: + raise ValidationError( + f"Resource {logical_resource_id} does not exist for stack {stack_name}" + ) - return DescribeStackResourceOutput(StackResourceDetail=details) + resource_detail = StackResourceDetail( + StackName=stack.stack_name, + StackId=stack.stack_id, + LogicalResourceId=logical_resource_id, + PhysicalResourceId=resource["PhysicalResourceId"], + ResourceType=resource["Type"], + LastUpdatedTimestamp=resource["LastUpdatedTimestamp"], + ResourceStatus=resource["ResourceStatus"], + ) + return DescribeStackResourceOutput(StackResourceDetail=resource_detail) @handler("DescribeStackResources") def describe_stack_resources( diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py index 20ed8e5a420ac..b65f0fae31ebe 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py @@ -1044,7 +1044,6 @@ def test_no_echo_parameter(snapshot, aws_client, deploy_cfn_template): snapshot.match("describe_updated_stacks_no_echo_false", describe_stacks) -@pytest.mark.skip(reason="CFNV2:Validation") @markers.aws.validated def test_stack_resource_not_found(deploy_cfn_template, aws_client, snapshot): stack = deploy_cfn_template( @@ -1069,5 +1068,5 @@ def test_no_parameters_given(aws_client, deploy_cfn_template, snapshot): os.path.dirname(__file__), "../../../../../templates/ssm_parameter_defaultname.yaml" ) with pytest.raises(ClientError) as exc_info: - deploy_cfn_template(template_path=template_path, parameters={"Input": "Foo"}) + deploy_cfn_template(template_path=template_path) snapshot.match("deploy-error", exc_info.value) From 18a24b6a4827a22b4c7fa3c1c6a1e51030e35ef0 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Fri, 25 Jul 2025 23:02:09 +0100 Subject: [PATCH 06/18] Add test for describing non-existent resource --- .../v2/ported_from_v1/api/test_resources.py | 13 ++++++++++++- .../ported_from_v1/api/test_resources.snapshot.json | 6 ++++-- .../api/test_resources.validation.json | 11 +++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.validation.json diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py index d0cc4f1d17579..c8e7193df356d 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py @@ -1,5 +1,8 @@ import os +import pytest +from botocore.exceptions import ClientError + from localstack.testing.pytest import markers @@ -8,4 +11,12 @@ def test_describe_non_existent_resource(aws_client, deploy_cfn_template, snapsho template_path = os.path.join( os.path.dirname(__file__), "../../../../../templates/ssm_parameter_defaultname.yaml" ) - stack = deploy_cfn_template(template_path=template_path) + stack = deploy_cfn_template(template_path=template_path, parameters={"Input": "myvalue"}) + snapshot.add_transformer(snapshot.transform.regex(stack.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) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.snapshot.json index 1985c68b286cc..198fbfd09af80 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.snapshot.json +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.snapshot.json @@ -1,6 +1,8 @@ { "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py::test_describe_non_existent_resource": { - "recorded-date": "25-07-2025, 15:24:21", - "recorded-content": {} + "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 " + } } } diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.validation.json new file mode 100644 index 0000000000000..8d1da2d0d6d98 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.validation.json @@ -0,0 +1,11 @@ +{ + "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 + } + } +} From d1907cd42ca13a6a70d4896ca02ab14a3e41158a Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Fri, 25 Jul 2025 23:05:30 +0100 Subject: [PATCH 07/18] Handle error cases for describing stack resources --- .../services/cloudformation/v2/provider.py | 15 ++++++++++----- .../v2/ported_from_v1/api/test_resources.py | 10 ++++++++++ .../api/test_resources.snapshot.json | 6 ++++++ .../api/test_resources.validation.json | 9 +++++++++ 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/localstack-core/localstack/services/cloudformation/v2/provider.py b/localstack-core/localstack/services/cloudformation/v2/provider.py index 0c1a13971cb9a..1c21ede098fc1 100644 --- a/localstack-core/localstack/services/cloudformation/v2/provider.py +++ b/localstack-core/localstack/services/cloudformation/v2/provider.py @@ -98,11 +98,14 @@ def is_changeset_arn(change_set_name_or_id: str) -> bool: class StackNotFoundError(ValidationError): - def __init__(self, stack_name_or_id: str): - if is_stack_arn(stack_name_or_id): - super().__init__(f"Stack with id {stack_name_or_id} does not exist") + def __init__(self, stack_name_or_id: str, message_override: str | None = None): + if message_override: + super().__init__(message_override) else: - super().__init__(f"Stack [{stack_name_or_id}] does not exist") + if is_stack_arn(stack_name_or_id): + super().__init__(f"Stack with id {stack_name_or_id} does not exist") + else: + super().__init__(f"Stack [{stack_name_or_id}] does not exist") def find_stack_v2(state: CloudFormationStore, stack_name: str | None) -> Stack | None: @@ -666,7 +669,9 @@ def describe_stack_resource( state = get_cloudformation_store(context.account_id, context.region) stack = find_stack_v2(state, stack_name) if not stack: - raise StackNotFoundError(stack_name) + raise StackNotFoundError( + stack_name, message_override=f"Stack '{stack_name}' does not exist" + ) try: resource = stack.resolved_resources[logical_resource_id] diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py index c8e7193df356d..e7d7bf2644667 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py @@ -6,6 +6,16 @@ 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( diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.snapshot.json index 198fbfd09af80..2c7d2322a05bf 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.snapshot.json +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.snapshot.json @@ -4,5 +4,11 @@ "recorded-content": { "error": "An error occurred (ValidationError) when calling the DescribeStackResource operation: Resource not-a-valid-resource does not exist for stack " } + }, + "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" + } } } diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.validation.json index 8d1da2d0d6d98..5cc65ecfc0159 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.validation.json +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.validation.json @@ -7,5 +7,14 @@ "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 + } } } From beff6eecfffbad368ee0d9caab842ea16c04e44d Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Fri, 25 Jul 2025 23:12:24 +0100 Subject: [PATCH 08/18] Unskip tests --- .../cloudformation/v2/ported_from_v1/api/test_stacks.py | 1 - .../cloudformation/v2/ported_from_v1/resources/test_cdk.py | 1 - .../cloudformation/v2/ported_from_v1/resources/test_events.py | 1 - .../cloudformation/v2/ported_from_v1/resources/test_lambda.py | 4 ---- 4 files changed, 7 deletions(-) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py index b65f0fae31ebe..3c0ad16202fc6 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py @@ -910,7 +910,6 @@ def test_stack_deploy_order(deploy_cfn_template, aws_client, snapshot, deploy_or snapshot.match("events", filtered_events) -@pytest.mark.skip(reason="CFNV2:DescribeStack") @markers.snapshot.skip_snapshot_verify( paths=[ # TODO: this property is present in the response from LocalStack when diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.py index 538013909d4ce..0140153b6dc89 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.py +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.py @@ -99,7 +99,6 @@ def clean_resources(): class TestCdkSampleApp: - @pytest.mark.skip(reason="CFNV2:Describe") @markers.snapshot.skip_snapshot_verify( paths=[ "$..Attributes.Policy.Statement..Condition", diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py index 17f1efcac75f8..571c6cdfb3f74 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py @@ -47,7 +47,6 @@ def _assert(expected_len): _assert(0) -@pytest.mark.skip(reason="CFNV2:Describe") @markers.aws.validated def test_eventbus_policies(deploy_cfn_template, aws_client): event_bus_name = f"event-bus-{short_uid()}" diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py index ce5570b67476e..2f5e2cd70ffd2 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py @@ -157,7 +157,6 @@ def test_update_lambda_function_name(s3_create_bucket, deploy_cfn_template, aws_ aws_client.lambda_.get_function(FunctionName=function_name_2) -@pytest.mark.skip(reason="CFNV2:Describe") @markers.snapshot.skip_snapshot_verify( paths=[ "$..Metadata", @@ -728,7 +727,6 @@ def wait_logs(): assert wait_until(wait_logs) - @pytest.mark.skip(reason="CFNV2:DescribeStackResources") @markers.snapshot.skip_snapshot_verify( paths=[ # Lambda @@ -887,7 +885,6 @@ def _send_events(): sleep = 10 if os.getenv("TEST_TARGET") == "AWS_CLOUD" else 1 assert wait_until(_send_events, wait=sleep, max_retries=50) - @pytest.mark.skip(reason="CFNV2:Describe") @markers.snapshot.skip_snapshot_verify( paths=[ # Lambda @@ -1025,7 +1022,6 @@ def wait_logs(): with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException): aws_client.lambda_.get_event_source_mapping(UUID=esm_id) - @pytest.mark.skip(reason="CFNV2:Describe") @markers.snapshot.skip_snapshot_verify( paths=[ "$..Role.Description", From ce1a1087c0b51a24362cf958a6b41aae553d5dae Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Fri, 25 Jul 2025 23:17:42 +0100 Subject: [PATCH 09/18] Remove unused method override --- .../cloudformation/engine/v2/change_set_model_executor.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py index a1804cce2ce62..77105d6273d01 100644 --- a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py @@ -19,7 +19,6 @@ NodeDependsOn, NodeOutput, NodeParameter, - NodeParameters, NodeResource, is_nothing, ) @@ -71,10 +70,6 @@ def execute(self) -> ChangeSetModelExecutorResult: resources=self.resources, parameters=self.resolved_parameters, outputs=self.outputs ) - def visit_node_parameters(self, node_parameters: NodeParameters) -> PreprocEntityDelta: - delta = super().visit_node_parameters(node_parameters) - return delta - def visit_node_parameter(self, node_parameter: NodeParameter) -> PreprocEntityDelta: delta = super().visit_node_parameter(node_parameter) From f3374927b4bac85d9808fb5e930ff5b0a8599ec8 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Fri, 25 Jul 2025 23:26:27 +0100 Subject: [PATCH 10/18] Add validation of logical resource ids --- .../engine/v2/change_set_model_validator.py | 18 ++++++++++++++--- .../v2/ported_from_v1/api/test_resources.py | 20 +++++++++++++++++++ .../api/test_resources.snapshot.json | 6 ++++++ .../api/test_resources.validation.json | 9 +++++++++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_validator.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_validator.py index cc67959aee4bb..e4b3f00f6059c 100644 --- a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_validator.py +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_validator.py @@ -1,5 +1,8 @@ +import re + from localstack.services.cloudformation.engine.v2.change_set_model import ( NodeParameters, + NodeResource, NodeTemplate, is_nothing, ) @@ -9,14 +12,16 @@ ) from localstack.services.cloudformation.engine.validations import ValidationError +VALID_LOGICAL_RESOURCE_ID_RE = re.compile(r"^[A-Za-z0-9]+$") + class ChangeSetModelValidator(ChangeSetModelPreproc): def validate(self): - # validate parameters are all given - self.visit(self._change_set.update_model.node_template.parameters) + self.visit(self._change_set.update_model.node_template) def visit_node_template(self, node_template: NodeTemplate): - self.visit_node_parameters(node_template.parameters) + 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 @@ -33,3 +38,10 @@ def visit_node_parameters(self, node_parameters: NodeParameters) -> PreprocEntit # 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) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py index e7d7bf2644667..ee6f8e960568a 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.py @@ -1,3 +1,4 @@ +import json import os import pytest @@ -30,3 +31,22 @@ def test_describe_non_existent_resource(aws_client, deploy_cfn_template, snapsho ) 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) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.snapshot.json index 2c7d2322a05bf..6c6ec67947625 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.snapshot.json +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.snapshot.json @@ -10,5 +10,11 @@ "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." + } } } diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.validation.json index 5cc65ecfc0159..4bd2c1ca3dd99 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.validation.json +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_resources.validation.json @@ -16,5 +16,14 @@ "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 + } } } From cea35667aef8c628f7d150af9fd47c021c757a21 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Sat, 26 Jul 2025 05:32:47 +0100 Subject: [PATCH 11/18] Increased test timeout --- .github/workflows/aws-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/aws-tests.yml b/.github/workflows/aws-tests.yml index 6bb60c7be4a31..ea97e812fc828 100644 --- a/.github/workflows/aws-tests.yml +++ b/.github/workflows/aws-tests.yml @@ -822,7 +822,7 @@ jobs: path: dist/testselection/ - name: Run CloudFormation Engine v2 Tests - timeout-minutes: 30 + timeout-minutes: 60 env: # add the GitHub API token to avoid rate limit issues GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} From be599f7b3b7566f8730c18642f482ee60a4c87ad Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Sat, 26 Jul 2025 22:23:19 +0100 Subject: [PATCH 12/18] Validator inherits directly from visitor We don't need preprocessing in this implmenentation --- .../engine/v2/change_set_model_validator.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_validator.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_validator.py index e4b3f00f6059c..8176733b44667 100644 --- a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_validator.py +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_validator.py @@ -7,15 +7,21 @@ is_nothing, ) from localstack.services.cloudformation.engine.v2.change_set_model_preproc import ( - ChangeSetModelPreproc, 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(ChangeSetModelPreproc): +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) From 756b0a00cea3797c3114fa1455ebc1c257869d24 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Sat, 26 Jul 2025 22:24:40 +0100 Subject: [PATCH 13/18] Revert "Increased test timeout" This reverts commit cea35667aef8c628f7d150af9fd47c021c757a21. --- .github/workflows/aws-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/aws-tests.yml b/.github/workflows/aws-tests.yml index ea97e812fc828..6bb60c7be4a31 100644 --- a/.github/workflows/aws-tests.yml +++ b/.github/workflows/aws-tests.yml @@ -822,7 +822,7 @@ jobs: path: dist/testselection/ - name: Run CloudFormation Engine v2 Tests - timeout-minutes: 60 + timeout-minutes: 30 env: # add the GitHub API token to avoid rate limit issues GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 948c8d58358691cdb2a399d26daf1a0c0694911b Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Sun, 27 Jul 2025 20:03:14 +0100 Subject: [PATCH 14/18] Ignore cloudformation v2 tests for community tests against pro --- .github/workflows/tests-pro-integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests-pro-integration.yml b/.github/workflows/tests-pro-integration.yml index 92e8eb09e8d57..e6cf5b05363e1 100644 --- a/.github/workflows/tests-pro-integration.yml +++ b/.github/workflows/tests-pro-integration.yml @@ -339,7 +339,7 @@ jobs: AWS_DEFAULT_REGION: "us-east-1" JUNIT_REPORTS_FILE: "pytest-junit-community-${{ matrix.group }}.xml" TEST_PATH: "../../localstack/tests/aws/" # TODO: run tests in tests/integration - PYTEST_ARGS: "${{ env.TINYBIRD_PYTEST_ARGS }}${{ env.TESTSELECTION_PYTEST_ARGS }}--splits ${{ strategy.job-total }} --group ${{ matrix.group }} --durations-path ../../localstack/.test_durations --store-durations" + 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 tests/aws/services/cloudformation/v2" working-directory: localstack-pro run: | # Remove the host tmp folder (might contain remnant files with different permissions) From 6135c93520726ae8b4801823d9a91f7f59882416 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Sun, 27 Jul 2025 20:09:21 +0100 Subject: [PATCH 15/18] Remove accidental unskip --- .../services/cloudformation/v2/ported_from_v1/api/test_stacks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py index 3c0ad16202fc6..b65f0fae31ebe 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py @@ -910,6 +910,7 @@ def test_stack_deploy_order(deploy_cfn_template, aws_client, snapshot, deploy_or snapshot.match("events", filtered_events) +@pytest.mark.skip(reason="CFNV2:DescribeStack") @markers.snapshot.skip_snapshot_verify( paths=[ # TODO: this property is present in the response from LocalStack when From d5cc6a64deb80d1e74b7fa4235562e6a05738797 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Sun, 27 Jul 2025 22:39:54 +0100 Subject: [PATCH 16/18] Implement list_stack_resources --- .../services/cloudformation/v2/provider.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/localstack-core/localstack/services/cloudformation/v2/provider.py b/localstack-core/localstack/services/cloudformation/v2/provider.py index 1c21ede098fc1..ee2b6f23aab97 100644 --- a/localstack-core/localstack/services/cloudformation/v2/provider.py +++ b/localstack-core/localstack/services/cloudformation/v2/provider.py @@ -34,6 +34,7 @@ IncludePropertyValues, InsufficientCapabilitiesException, InvalidChangeSetStatusException, + ListStackResourcesOutput, ListStacksOutput, LogicalResourceId, NextToken, @@ -46,6 +47,7 @@ StackName, StackNameOrId, StackResourceDetail, + StackResourceSummary, StackStatus, StackStatusFilter, TemplateStage, @@ -658,6 +660,29 @@ def list_stacks( stacks = [select_attributes(stack, attrs) for stack in stacks] return ListStacksOutput(StackSummaries=stacks) + @handler("ListStackResources") + def list_stack_resources( + self, context: RequestContext, stack_name: StackName, next_token: NextToken = None, **kwargs + ) -> ListStackResourcesOutput: + result = self.describe_stack_resources(context, stack_name) + + resources = [] + for resource in result.get("StackResources", []): + resources.append( + StackResourceSummary( + LogicalResourceId=resource["LogicalResourceId"], + PhysicalResourceId=resource["PhysicalResourceId"], + ResourceType=resource["ResourceType"], + LastUpdatedTimestamp=resource["Timestamp"], + ResourceStatus=resource["ResourceStatus"], + ResourceStatusReason=resource.get("ResourceStatusReason"), + DriftInformation=resource.get("DriftInformation"), + ModuleInfo=resource.get("ModuleInfo"), + ) + ) + + return ListStackResourcesOutput(StackResourceSummaries=resources) + @handler("DescribeStackResource") def describe_stack_resource( self, From aabdeac3b65f31c46fd163b750f77ec6e34cacbc Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Sun, 27 Jul 2025 22:51:12 +0100 Subject: [PATCH 17/18] Make sure update method of queue policy correctly sets physical resource id --- .../services/sqs/resource_providers/aws_sqs_queuepolicy.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queuepolicy.py b/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queuepolicy.py index cc7bdecfa9254..40dd99191b659 100644 --- a/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queuepolicy.py +++ b/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queuepolicy.py @@ -104,7 +104,9 @@ def update( policy = json.dumps(model["PolicyDocument"]) sqs.set_queue_attributes(QueueUrl=queue, Attributes={"Policy": policy}) + model["Id"] = request.previous_state["Id"] + return ProgressEvent( status=OperationStatus.SUCCESS, - resource_model=request.desired_state, + resource_model=model, ) From 481143cbf4a51a4e5c6336dac8b6e4647f0b511d Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Mon, 28 Jul 2025 05:02:33 +0100 Subject: [PATCH 18/18] Correct skip path --- .github/workflows/tests-pro-integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests-pro-integration.yml b/.github/workflows/tests-pro-integration.yml index e6cf5b05363e1..d97f74beab71c 100644 --- a/.github/workflows/tests-pro-integration.yml +++ b/.github/workflows/tests-pro-integration.yml @@ -339,7 +339,7 @@ jobs: AWS_DEFAULT_REGION: "us-east-1" JUNIT_REPORTS_FILE: "pytest-junit-community-${{ matrix.group }}.xml" TEST_PATH: "../../localstack/tests/aws/" # TODO: run tests in tests/integration - 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 tests/aws/services/cloudformation/v2" + 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" working-directory: localstack-pro run: | # Remove the host tmp folder (might contain remnant files with different permissions)