diff --git a/localstack/services/cloudformation/engine/template_deployer.py b/localstack/services/cloudformation/engine/template_deployer.py index d90f8128fb743..e75554fe0333c 100644 --- a/localstack/services/cloudformation/engine/template_deployer.py +++ b/localstack/services/cloudformation/engine/template_deployer.py @@ -40,7 +40,7 @@ from localstack.utils.strings import to_bytes, to_str from localstack.utils.threads import start_worker_thread -from localstack.services.cloudformation.models import * # noqa: F401, isort:skip +from localstack.services.cloudformation.models import * # noqa: F401, F403, isort:skip from localstack.utils.urls import localstack_host ACTION_CREATE = "create" @@ -169,7 +169,9 @@ def resolve_ref( # resource resource = resources.get(ref) if not resource: - raise Exception("Should be detected earlier.") + raise Exception( + f"Resource target for `Ref {ref}` could not be found. Is there a resource with name {ref} in your stack?" + ) return resources[ref].get("PhysicalResourceId") @@ -325,7 +327,10 @@ def _resolve_refs_recursively( ) # TODO: we should check the deployment state and not try to GetAtt from a resource that is still IN_PROGRESS or hasn't started yet. if resolved_getatt is None: - raise DependencyNotYetSatisfied(resource_ids=resource_logical_id, message="") + raise DependencyNotYetSatisfied( + resource_ids=resource_logical_id, + message=f"Could not resolve attribute '{attribute_name}' on resource '{resource_logical_id}'", + ) return resolved_getatt if stripped_fn_lower == "join": @@ -361,7 +366,7 @@ def _resolve_refs_recursively( none_values = [v for v in join_values if v is None] if none_values: raise Exception( - "Cannot resolve CF fn::Join %s due to null values: %s" % (value, join_values) + f"Cannot resolve CF Fn::Join {value} due to null values: {join_values}" ) return value[keys_list[0]][0].join([str(v) for v in join_values]) @@ -375,7 +380,7 @@ def _resolve_refs_recursively( item_to_sub[1].update(attr_refs) for key, val in item_to_sub[1].items(): - val = resolve_refs_recursively( + resolved_val = resolve_refs_recursively( account_id, region_name, stack_name, @@ -385,11 +390,13 @@ def _resolve_refs_recursively( parameters, val, ) - if not isinstance(val, str): + if not isinstance(resolved_val, str): # We don't have access to the resource that's a dependency in this case, # so do the best we can with the resource ids - raise DependencyNotYetSatisfied(resource_ids=key, message="") - result = result.replace("${%s}" % key, val) + raise DependencyNotYetSatisfied( + resource_ids=key, message=f"Could not resolve {val} to terminal value type" + ) + result = result.replace("${%s}" % key, resolved_val) # resolve placeholders result = resolve_placeholders_in_string( @@ -1284,7 +1291,7 @@ def do_apply_changes_in_loop(self, changes, stack): break if not updated: raise Exception( - "Resource deployment loop completed, pending resource changes: %s" % changes + f"Resource deployment loop completed, pending resource changes: {changes}" ) # clean up references to deleted resources in stack diff --git a/tests/aws/services/cloudformation/engine/test_references.py b/tests/aws/services/cloudformation/engine/test_references.py index 9753e7e3b65fd..a4c4d89b12be1 100644 --- a/tests/aws/services/cloudformation/engine/test_references.py +++ b/tests/aws/services/cloudformation/engine/test_references.py @@ -1,8 +1,10 @@ +import json import os import pytest from localstack.testing.pytest import markers +from localstack.testing.pytest.fixtures import StackDeployError from localstack.utils.files import load_file from localstack.utils.strings import short_uid @@ -53,3 +55,42 @@ def test_fn_sub_cases(self, deploy_cfn_template, aws_client, snapshot): ) snapshot.match("outputs", deployment.outputs) + + +@markers.aws.only_localstack +def test_useful_error_when_invalid_ref(deploy_cfn_template): + """ + When trying to resolve a non-existent !Ref, make sure the error message includes the name of the !Ref + to clarify which !Ref cannot be resolved. + """ + logical_resource_id = "Topic" + ref_name = "InvalidRef" + + template = json.dumps( + { + "Resources": { + logical_resource_id: { + "Type": "AWS::SNS::Topic", + "Properties": { + "Name": { + "Ref": ref_name, + }, + }, + } + } + } + ) + + with pytest.raises(StackDeployError) as exc_info: + deploy_cfn_template(template=template) + + # get the exception error message from the events list + message = None + for event in exc_info.value.events: + if ( + event["LogicalResourceId"] == logical_resource_id + and event["ResourceStatus"] == "CREATE_FAILED" + ): + message = event["ResourceStatusReason"] + + assert ref_name in message