Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests-pro-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ../../localstack/tests/aws/services/cloudformation/v2"
Copy link
Member

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.

working-directory: localstack-pro
run: |
# Remove the host tmp folder (might contain remnant files with different permissions)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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__)

Expand All @@ -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]

Expand Down Expand Up @@ -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
Expand All @@ -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
Copy link
Member

@pinzon pinzon Jul 28, 2025

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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
Expand Down
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
Expand Up @@ -39,8 +39,12 @@


class ResolvedResource(TypedDict):
LogicalResourceId: str
Type: str
Properties: dict
ResourceStatus: ResourceStatus
PhysicalResourceId: str | None
LastUpdatedTimestamp: datetime | None


class Stack:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
DeletionMode,
DescribeChangeSetOutput,
DescribeStackEventsOutput,
DescribeStackResourceOutput,
DescribeStackResourcesOutput,
DescribeStackSetOperationOutput,
DescribeStacksOutput,
Expand All @@ -42,6 +43,7 @@
IncludePropertyValues,
InsufficientCapabilitiesException,
InvalidChangeSetStatusException,
ListStackResourcesOutput,
ListStacksOutput,
LogicalResourceId,
NextToken,
Expand All @@ -53,6 +55,8 @@
RollbackConfiguration,
StackName,
StackNameOrId,
StackResourceDetail,
StackResourceSummary,
StackSetName,
StackSetNotFoundException,
StackSetOperation,
Expand Down Expand Up @@ -82,6 +86,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,
Expand Down Expand Up @@ -114,11 +121,14 @@ def is_stack_set_arn(stack_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")


class StackSetNotFoundError(StackSetNotFoundException):
Expand Down Expand Up @@ -234,6 +244,13 @@ 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,
)
validator.validate()

change_set.set_update_model(update_model)
change_set.stack.processed_template = transformed_after_template

Expand Down Expand Up @@ -697,6 +714,62 @@ 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,
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, message_override=f"Stack '{stack_name}' does not exist"
)

try:
resource = stack.resolved_resources[logical_resource_id]
except KeyError:
raise ValidationError(
f"Resource {logical_resource_id} does not exist for stack {stack_name}"
)

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(
self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
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
}
}
}
Loading
Loading