diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_describer.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_describer.py index 19b341d7316a1..92a9503d1b192 100644 --- a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_describer.py +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_describer.py @@ -112,6 +112,30 @@ def visit_node_intrinsic_function_fn_join( delta.after = CHANGESET_KNOWN_AFTER_APPLY return delta + def visit_node_intrinsic_function_fn_select( + self, node_intrinsic_function: NodeIntrinsicFunction + ): + # TODO: should this not _ALWAYS_ return CHANGESET_KNOWN_AFTER_APPLY? + arguments_delta = self.visit(node_intrinsic_function.arguments) + delta = PreprocEntityDelta() + if not is_nothing(arguments_delta.before): + idx = arguments_delta.before[0] + arr = arguments_delta.before[1] + try: + delta.before = arr[int(idx)] + except Exception: + delta.before = CHANGESET_KNOWN_AFTER_APPLY + + if not is_nothing(arguments_delta.after): + idx = arguments_delta.after[0] + arr = arguments_delta.after[1] + try: + delta.after = arr[int(idx)] + except Exception: + delta.after = CHANGESET_KNOWN_AFTER_APPLY + + return delta + def _register_resource_change( self, logical_id: str, 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 39f6a46dd2dd2..6b008a353d9ba 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 @@ from localstack.services.cloudformation.engine.template_deployer import REGEX_OUTPUT_APIGATEWAY from localstack.services.cloudformation.engine.v2.change_set_model import ( NodeDependsOn, + NodeIntrinsicFunction, NodeOutput, NodeResource, TerminalValueCreated, @@ -58,11 +59,17 @@ class DeferredAction(Protocol): def __call__(self) -> None: ... +@dataclass +class Deferred: + name: str + action: DeferredAction + + class ChangeSetModelExecutor(ChangeSetModelPreproc): # TODO: add typing for resolved resources and parameters. resources: Final[dict[str, ResolvedResource]] outputs: Final[list[Output]] - _deferred_actions: list[DeferredAction] + _deferred_actions: list[Deferred] def __init__(self, change_set: ChangeSet): super().__init__(change_set=change_set) @@ -84,16 +91,17 @@ def execute(self) -> ChangeSetModelExecutorResult: # perform all deferred actions such as deletions. These must happen in reverse from their # defined order so that resource dependencies are honoured # TODO: errors will stop all rollbacks; get parity on this behaviour - for action in self._deferred_actions[::-1]: - action() + for deferred in self._deferred_actions[::-1]: + LOG.debug("executing deferred action: '%s'", deferred.name) + deferred.action() return ChangeSetModelExecutorResult( resources=self.resources, outputs=self.outputs, ) - def _defer_action(self, action: DeferredAction): - self._deferred_actions.append(action) + def _defer_action(self, name: str, action: DeferredAction): + self._deferred_actions.append(Deferred(name=name, action=action)) def _get_physical_id(self, logical_resource_id, strict: bool = True) -> str | None: physical_resource_id = None @@ -292,7 +300,7 @@ def cleanup(): reason=event.message, ) - self._defer_action(cleanup) + self._defer_action(f"cleanup-from-replacement-{name}", cleanup) else: event = self._execute_resource_action( action=ChangeAction.Modify, @@ -331,7 +339,7 @@ def perform_deletion(): reason=event.message, ) - self._defer_action(perform_deletion) + self._defer_action(f"type-migration-{name}", perform_deletion) event = self._execute_resource_action( action=ChangeAction.Add, @@ -375,7 +383,7 @@ def perform_deletion(): reason=event.message, ) - self._defer_action(perform_deletion) + self._defer_action(f"remove-{name}", perform_deletion) elif not is_nothing(after): # Case: addition self._process_event( @@ -585,6 +593,15 @@ def _replace_url_outputs_if_required(value: str) -> str: return value + def _replace_url_outputs_in_delta_if_required( + self, delta: PreprocEntityDelta + ) -> PreprocEntityDelta: + if isinstance(delta.before, str): + delta.before = self._replace_url_outputs_if_required(delta.before) + if isinstance(delta.after, str): + delta.after = self._replace_url_outputs_if_required(delta.after) + return delta + def visit_terminal_value_created( self, value: TerminalValueCreated ) -> PreprocEntityDelta[str, str]: @@ -612,3 +629,17 @@ def visit_terminal_value_unchanged( else: value = terminal_value_unchanged.value return PreprocEntityDelta(before=value, after=value) + + def visit_node_intrinsic_function_fn_join( + self, node_intrinsic_function: NodeIntrinsicFunction + ) -> PreprocEntityDelta: + delta = super().visit_node_intrinsic_function_fn_join(node_intrinsic_function) + return self._replace_url_outputs_in_delta_if_required(delta) + + def visit_node_intrinsic_function_fn_sub( + self, node_intrinsic_function: NodeIntrinsicFunction + ) -> PreprocEntityDelta: + delta = super().visit_node_intrinsic_function_fn_sub(node_intrinsic_function) + return self._replace_url_outputs_in_delta_if_required(delta) + + # TODO: other intrinsic functions diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_preproc.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_preproc.py index 17e6a99fda47f..4a268fa9b0857 100644 --- a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_preproc.py +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_preproc.py @@ -630,8 +630,12 @@ def _compute_fn_or(args: list[bool]): def visit_node_intrinsic_function_fn_not( self, node_intrinsic_function: NodeIntrinsicFunction ) -> PreprocEntityDelta: - def _compute_fn_not(arg: bool) -> bool: - return not arg + def _compute_fn_not(arg: list[bool] | bool) -> bool: + # Is the argument ever a lone boolean? + if isinstance(arg, list): + return not arg[0] + else: + return not arg arguments_delta = self.visit(node_intrinsic_function.arguments) delta = self._cached_apply( diff --git a/tests/aws/services/cloudformation/resources/test_apigateway.py b/tests/aws/services/cloudformation/resources/test_apigateway.py index 5d5e1468c80fb..a79fe5a524851 100644 --- a/tests/aws/services/cloudformation/resources/test_apigateway.py +++ b/tests/aws/services/cloudformation/resources/test_apigateway.py @@ -5,7 +5,7 @@ import requests from localstack_snapshot.snapshots.transformer import SortingTransformer from tests.aws.services.apigateway.apigateway_fixtures import api_invoke_url -from tests.aws.services.cloudformation.conftest import skip_if_v2_provider, skipped_v2_items +from tests.aws.services.cloudformation.conftest import skipped_v2_items from localstack import constants from localstack.aws.api.lambda_ import Runtime @@ -166,11 +166,6 @@ def _invoke(): assert content["url"].endswith("/post") -@skip_if_v2_provider( - "Provider", - reason="The v2 provider appears to instead return the correct url: " - "https://e1i3grfiws.execute-api.us-east-1.localhost.localstack.cloud/prod/", -) @markers.aws.only_localstack def test_url_output(httpserver, deploy_cfn_template): httpserver.expect_request("").respond_with_data(b"", 200) @@ -581,9 +576,6 @@ def test_api_gateway_with_policy_as_dict(deploy_cfn_template, snapshot, aws_clie snapshot.match("rest-api", rest_api) -@skip_if_v2_provider( - "Other", reason="lambda function fails on creation due to invalid function name" -) @markers.aws.validated @markers.snapshot.skip_snapshot_verify( paths=[ diff --git a/tests/aws/services/cloudformation/resources/test_secretsmanager.py b/tests/aws/services/cloudformation/resources/test_secretsmanager.py index 9f5b561984fe6..8166fba755aee 100644 --- a/tests/aws/services/cloudformation/resources/test_secretsmanager.py +++ b/tests/aws/services/cloudformation/resources/test_secretsmanager.py @@ -4,7 +4,6 @@ import aws_cdk as cdk import botocore.exceptions import pytest -from tests.aws.services.cloudformation.conftest import skip_if_v2_provider from localstack.testing.pytest import markers from localstack.utils.strings import short_uid @@ -77,7 +76,6 @@ def test_cfn_secret_policy(deploy_cfn_template, block_public_policy, aws_client, snapshot.add_transformer(snapshot.transform.key_value("Name", "policy-name")) -@skip_if_v2_provider("Other") @markers.aws.validated def test_cdk_deployment_generates_secret_value_if_no_value_is_provided( aws_client, snapshot, infrastructure_setup diff --git a/tests/aws/services/cloudformation/resources/test_sns.py b/tests/aws/services/cloudformation/resources/test_sns.py index 37260bf159728..340804f122261 100644 --- a/tests/aws/services/cloudformation/resources/test_sns.py +++ b/tests/aws/services/cloudformation/resources/test_sns.py @@ -2,7 +2,6 @@ import aws_cdk as cdk import pytest -from tests.aws.services.cloudformation.conftest import skip_if_v2_provider from localstack.testing.aws.util import is_aws_cloud from localstack.testing.pytest import markers @@ -80,7 +79,6 @@ def test_sns_subscription(deploy_cfn_template, aws_client): assert len(subscriptions["Subscriptions"]) > 0 -@skip_if_v2_provider("Engine") @markers.aws.validated def test_deploy_stack_with_sns_topic(deploy_cfn_template, aws_client): stack = deploy_cfn_template( diff --git a/tests/aws/services/cloudformation/resources/test_sns.validation.json b/tests/aws/services/cloudformation/resources/test_sns.validation.json index 52731d78dd633..a5a2724095f4a 100644 --- a/tests/aws/services/cloudformation/resources/test_sns.validation.json +++ b/tests/aws/services/cloudformation/resources/test_sns.validation.json @@ -1,10 +1,28 @@ { + "tests/aws/services/cloudformation/resources/test_sns.py::test_deploy_stack_with_sns_topic": { + "last_validated_date": "2025-08-18T12:05:15+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 96.67, + "teardown": 0.12, + "total": 96.79 + } + }, "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_subscription_region": { "last_validated_date": "2025-05-28T10:46:56+00:00" }, "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_fifo_with_deduplication": { "last_validated_date": "2023-11-27T20:27:29+00:00" }, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_policy_resets_to_default": { + "last_validated_date": "2025-07-04T00:04:32+00:00", + "durations_in_seconds": { + "setup": 1.08, + "call": 22.27, + "teardown": 0.09, + "total": 23.44 + } + }, "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_update_attributes": { "last_validated_date": "2025-07-03T17:19:44+00:00", "durations_in_seconds": { @@ -23,15 +41,6 @@ "total": 124.06 } }, - "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_policy_resets_to_default": { - "last_validated_date": "2025-07-04T00:04:32+00:00", - "durations_in_seconds": { - "setup": 1.08, - "call": 22.27, - "teardown": 0.09, - "total": 23.44 - } - }, "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_with_attributes": { "last_validated_date": "2025-07-03T23:32:29+00:00", "durations_in_seconds": { diff --git a/tests/aws/services/cloudformation/resources/test_stepfunctions.py b/tests/aws/services/cloudformation/resources/test_stepfunctions.py index befb779c2e42e..36b807157c367 100644 --- a/tests/aws/services/cloudformation/resources/test_stepfunctions.py +++ b/tests/aws/services/cloudformation/resources/test_stepfunctions.py @@ -4,7 +4,6 @@ import pytest from localstack_snapshot.snapshots.transformer import JsonpathTransformer -from tests.aws.services.cloudformation.conftest import skip_if_v2_provider from localstack import config from localstack.testing.pytest import markers @@ -47,10 +46,6 @@ def _is_executed(): assert "hello from statemachine" in execution_desc["output"] -@skip_if_v2_provider( - "Engine", - reason="During change set describe the a Ref to a not yet deployed resource returns null which is an invalid input for Fn::Split", -) @markers.aws.validated def test_nested_statemachine_with_sync2(deploy_cfn_template, aws_client): stack = deploy_cfn_template( diff --git a/tests/aws/services/cloudformation/test_template_engine.py b/tests/aws/services/cloudformation/test_template_engine.py index 529d8321afe4c..dc485e0e5362e 100644 --- a/tests/aws/services/cloudformation/test_template_engine.py +++ b/tests/aws/services/cloudformation/test_template_engine.py @@ -365,7 +365,6 @@ def test_resolve_ssm(self, create_parameter, deploy_cfn_template): topic_name = result.outputs["TopicName"] assert topic_name == parameter_value - @skip_if_v2_provider("Resolve") @markers.aws.validated def test_resolve_ssm_with_version(self, create_parameter, deploy_cfn_template, aws_client): parameter_key = f"param-key-{short_uid()}"