diff --git a/.gitignore b/.gitignore index b682d6940c..44ddf9dc06 100644 --- a/.gitignore +++ b/.gitignore @@ -414,9 +414,11 @@ coverage.xml tests/integration/buildcmd/scratch tests/integration/testdata/buildcmd/Dotnet*/bin tests/integration/testdata/buildcmd/Dotnet*/obj +tests/integration/testdata/buildcmd/Java/**/build/ tests/integration/testdata/invoke/credential_tests/inprocess/dotnet/STS/obj tests/integration/testdata/sync/code/after/dotnet_function/src/HelloWorld/obj/ tests/integration/testdata/sync/code/before/dotnet_function/src/HelloWorld/obj/ +samcli/lib/init/templates/cookiecutter-aws-sam-hello-java-gradle/**/.gradle/ # End of https://www.gitignore.io/api/osx,node,macos,linux,python,windows,pycharm,intellij,sublimetext,visualstudiocode diff --git a/samcli/commands/local/invoke/cli.py b/samcli/commands/local/invoke/cli.py index 60a9c4580c..73f79fa971 100644 --- a/samcli/commands/local/invoke/cli.py +++ b/samcli/commands/local/invoke/cli.py @@ -190,6 +190,7 @@ def do_cli( # pylint: disable=R0914 from samcli.commands.local.lib.exceptions import NoPrivilegeException, OverridesNotWellDefinedError from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException from samcli.lib.providers.exceptions import InvalidLayerReference + from samcli.lib.utils.name_utils import InvalidFunctionNameException from samcli.local.docker.lambda_debug_settings import DebuggingNotSupported from samcli.local.docker.manager import DockerImagePullFailedException from samcli.local.lambdafn.exceptions import FunctionNotFound @@ -247,6 +248,7 @@ def do_cli( # pylint: disable=R0914 except ( InvalidSamDocumentException, OverridesNotWellDefinedError, + InvalidFunctionNameException, InvalidLayerReference, InvalidIntermediateImageError, DebuggingNotSupported, diff --git a/samcli/commands/local/lib/local_lambda.py b/samcli/commands/local/lib/local_lambda.py index 2375a92669..685ebca073 100644 --- a/samcli/commands/local/lib/local_lambda.py +++ b/samcli/commands/local/lib/local_lambda.py @@ -23,6 +23,7 @@ from samcli.lib.utils.architecture import validate_architecture_runtime from samcli.lib.utils.codeuri import resolve_code_path from samcli.lib.utils.colors import Colored +from samcli.lib.utils.name_utils import normalize_lambda_function_name from samcli.lib.utils.packagetype import IMAGE, ZIP from samcli.lib.utils.stream_writer import StreamWriter from samcli.local.docker.container import ContainerConnectionTimeoutException, ContainerResponseException @@ -127,8 +128,11 @@ def invoke( When we cannot find a function with the given name """ + # Normalize function identifier from ARN if provided + normalized_function_identifier = normalize_lambda_function_name(function_identifier) + # Generate the correct configuration based on given inputs - function = self.provider.get(function_identifier) + function = self.provider.get(normalized_function_identifier) if not function: all_function_full_paths = [f.full_path for f in self.provider.get_all()] diff --git a/samcli/lib/utils/name_utils.py b/samcli/lib/utils/name_utils.py new file mode 100644 index 0000000000..d3d0e88e19 --- /dev/null +++ b/samcli/lib/utils/name_utils.py @@ -0,0 +1,133 @@ +""" +Utilities for normalizing function names and identifiers +""" + +import re + +from samcli.lib.utils.arn_utils import ARNParts, InvalidArnValue + +# Constants for ARN parsing +PARTIAL_ARN_MIN_PARTS = 3 +FUNCTION_ALIAS_PARTS = 2 +MIN_ARN_PARTS = 5 # Minimum parts for a valid ARN structure + +LAMBDA_FUNCTION_NAME_PATTERN = ( + r"^(arn:[^:]+:lambda:[^:]*:\d{12}:function:|\d{12}:function:)?[a-zA-Z0-9-_\.]+(:[\w$-]+)?$" +) + + +class InvalidFunctionNameException(Exception): + """Exception raised when function name doesn't match AWS Lambda validation pattern""" + + pass + + +def normalize_lambda_function_name(function_identifier: str) -> str: + """ + Normalize a Lambda function identifier by extracting the function name from various formats. + + AWS Lambda supports multiple function identifier formats as documented in the AWS CLI/SDK: + + Name formats: + - Function name: my-function (name-only), my-function:v1 (with alias) + - Function ARN: arn:aws:lambda:us-west-2:123456789012:function:my-function + - Partial ARN: 123456789012:function:my-function + + This function normalizes all these formats to extract just the function name portion, + which is what SAM CLI uses internally for function lookup. + + Parameters + ---------- + function_identifier : str + The function identifier in any of the supported formats + + Returns + ------- + str + The normalized function name + + Raises + ------ + InvalidFunctionNameException + If the function identifier doesn't match AWS Lambda's validation pattern + + Examples + -------- + >>> normalize_lambda_function_name("my-function") + "my-function" + >>> normalize_lambda_function_name("my-function:v1") + "my-function" + >>> normalize_lambda_function_name("arn:aws:lambda:us-east-1:123456789012:function:my-function") + "my-function" + >>> normalize_lambda_function_name("arn:aws:lambda:us-east-1:123456789012:function:my-function:$LATEST") + "my-function" + >>> normalize_lambda_function_name("123456789012:function:my-function") + "my-function" + """ + # Handle full ARN format: arn:aws:lambda:region:account-id:function:function-name[:version] + if function_identifier.startswith("arn:"): + try: + arn_parts = ARNParts(function_identifier) + # Check if it's a Lambda ARN with function resource type + if arn_parts.service == "lambda" and arn_parts.resource_type == "function": + # For Lambda ARNs, the function name is in resource_id + # Handle versioned functions by splitting on ':' + function_name = arn_parts.resource_id.split(":")[0] if arn_parts.resource_id else "" + return function_name if function_name else function_identifier + elif arn_parts.service != "lambda": + # Non-Lambda ARNs should raise an exception + raise InvalidFunctionNameException( + f"1 validation error detected: Value '{function_identifier}' at 'functionName' " + f"failed to satisfy constraint: Member must satisfy regular expression pattern: " + f"{LAMBDA_FUNCTION_NAME_PATTERN}" + ) + except InvalidArnValue: + # Very malformed ARNs (like "arn:aws:lambda") should raise an exception + if len(function_identifier.split(":")) < MIN_ARN_PARTS: # ARNs with less than 5 parts are too malformed + raise InvalidFunctionNameException( + f"1 validation error detected: Value '{function_identifier}' at 'functionName' " + f"failed to satisfy constraint: Member must satisfy regular expression pattern: " + f"{LAMBDA_FUNCTION_NAME_PATTERN}" + ) + # Other malformed ARNs are returned unchanged + return function_identifier + + # Handle partial ARN format: account-id:function:function-name[:version] + # This format has at least 3 parts separated by colons + elif ":" in function_identifier and len(function_identifier.split(":")) >= PARTIAL_ARN_MIN_PARTS: + parts = function_identifier.split(":") + # Check if it matches the partial ARN pattern: account-id:function:function-name[:version] + if len(parts) >= PARTIAL_ARN_MIN_PARTS and parts[1] == "function" and parts[2]: + # Extract function name (3rd part) and remove any version suffix + function_name = parts[2] + return function_name if function_name else function_identifier + # Invalid partial ARNs (like empty function name) should raise exception if they look like partial ARNs + elif len(parts) >= PARTIAL_ARN_MIN_PARTS and parts[1] == "function": + # This looks like a partial ARN but has invalid structure + raise InvalidFunctionNameException( + f"1 validation error detected: Value '{function_identifier}' at 'functionName' " + f"failed to satisfy constraint: Member must satisfy regular expression pattern: " + f"{LAMBDA_FUNCTION_NAME_PATTERN}" + ) + # Other invalid partial ARNs are returned unchanged + return function_identifier + + # Handle function name with alias: my-function:alias + # This is a simple function name with a single colon for alias/version + # But exclude partial ARN patterns like "account:function" (missing function name) + elif ":" in function_identifier: + parts = function_identifier.split(":") + if len(parts) == FUNCTION_ALIAS_PARTS and parts[1] != "function" and parts[0] and parts[1]: + # Return just the function name part (before the colon) if both parts are not empty + return parts[0] + + # Validate plain function names against the pattern + if not re.match(LAMBDA_FUNCTION_NAME_PATTERN, function_identifier): + raise InvalidFunctionNameException( + f"1 validation error detected: Value '{function_identifier}' at 'functionName' " + f"failed to satisfy constraint: Member must satisfy regular expression pattern: " + f"{LAMBDA_FUNCTION_NAME_PATTERN}" + ) + + # Handle plain function name: my-function + return function_identifier diff --git a/samcli/local/lambda_service/lambda_error_responses.py b/samcli/local/lambda_service/lambda_error_responses.py index 38b91bf294..c447157ca6 100644 --- a/samcli/local/lambda_service/lambda_error_responses.py +++ b/samcli/local/lambda_service/lambda_error_responses.py @@ -19,6 +19,9 @@ class LambdaErrorResponses: # The request body could not be parsed as JSON. InvalidRequestContentException = ("InvalidRequestContent", 400) + # One or more parameters values were invalid. + ValidationException = ("ValidationException", 400) + NotImplementedException = ("NotImplemented", 501) ContainerCreationFailed = ("ContainerCreationFailed", 501) @@ -85,6 +88,29 @@ def invalid_request_content(message): exception_tuple[1], ) + @staticmethod + def validation_exception(message): + """ + Creates a Lambda Service ValidationException Response + + Parameters + ---------- + message str + Message to be added to the body of the response + + Returns + ------- + Flask.Response + A response object representing the ValidationException Error + """ + exception_tuple = LambdaErrorResponses.ValidationException + + return BaseLocalService.service_response( + LambdaErrorResponses._construct_error_response_body(LambdaErrorResponses.USER_ERROR, message), + LambdaErrorResponses._construct_headers(exception_tuple[0]), + exception_tuple[1], + ) + @staticmethod def unsupported_media_type(content_type): """ diff --git a/samcli/local/lambda_service/local_lambda_invoke_service.py b/samcli/local/lambda_service/local_lambda_invoke_service.py index 51030f6806..0b635bf50f 100644 --- a/samcli/local/lambda_service/local_lambda_invoke_service.py +++ b/samcli/local/lambda_service/local_lambda_invoke_service.py @@ -8,6 +8,7 @@ from werkzeug.routing import BaseConverter from samcli.commands.local.lib.exceptions import UnsupportedInlineCodeError +from samcli.lib.utils.name_utils import InvalidFunctionNameException, normalize_lambda_function_name from samcli.lib.utils.stream_writer import StreamWriter from samcli.local.docker.exceptions import DockerContainerCreationFailedException from samcli.local.lambdafn.exceptions import FunctionNotFound @@ -151,7 +152,7 @@ def _invoke_request_handler(self, function_name): Parameters ---------- function_name str - Name of the function to invoke + Name or ARN of the function to invoke Returns ------- @@ -166,15 +167,24 @@ def _invoke_request_handler(self, function_name): request_data = request_data.decode("utf-8") + # Normalize function name from ARN if provided + try: + normalized_function_name = normalize_lambda_function_name(function_name) + except InvalidFunctionNameException as e: + LOG.error("Invalid function name: %s", str(e)) + return LambdaErrorResponses.validation_exception(str(e)) + stdout_stream_string = io.StringIO() stdout_stream_bytes = io.BytesIO() stdout_stream_writer = StreamWriter(stdout_stream_string, stdout_stream_bytes, auto_flush=True) try: - self.lambda_runner.invoke(function_name, request_data, stdout=stdout_stream_writer, stderr=self.stderr) + self.lambda_runner.invoke( + normalized_function_name, request_data, stdout=stdout_stream_writer, stderr=self.stderr + ) except FunctionNotFound: - LOG.debug("%s was not found to invoke.", function_name) - return LambdaErrorResponses.resource_not_found(function_name) + LOG.debug("%s was not found to invoke.", normalized_function_name) + return LambdaErrorResponses.resource_not_found(normalized_function_name) except UnsupportedInlineCodeError: return LambdaErrorResponses.not_implemented_locally( "Inline code is not supported for sam local commands. Please write your code in a separate file." diff --git a/tests/unit/lib/utils/test_name_utils.py b/tests/unit/lib/utils/test_name_utils.py new file mode 100644 index 0000000000..8d8793f3c4 --- /dev/null +++ b/tests/unit/lib/utils/test_name_utils.py @@ -0,0 +1,60 @@ +""" +Tests for samcli.lib.utils.name_utils module +""" + +from unittest import TestCase + +from parameterized import parameterized + +from samcli.lib.utils.name_utils import normalize_lambda_function_name, InvalidFunctionNameException + + +class TestNormalizeLambdaFunctionName(TestCase): + """Test cases for normalize_lambda_function_name function""" + + @parameterized.expand( + [ + # Simple function names (pass-through) + ("my-function", "my-function"), + ("function-with_mixed-chars123", "function-with_mixed-chars123"), + # Full ARNs with versions/aliases + ("arn:aws:lambda:us-east-1:123456789012:function:my-function", "my-function"), + ("arn:aws:lambda:us-east-1:123456789012:function:my-function:$LATEST", "my-function"), + ("arn:aws:lambda:us-east-1:123456789012:function:my-function:1", "my-function"), + ("arn:aws-cn:lambda:cn-north-1:123456789012:function:my-function", "my-function"), + # Partial ARNs + ("123456789012:function:my-function", "my-function"), + ("123456789012:function:my-function:$LATEST", "my-function"), + # Function names with versions/aliases + ("my-function:$LATEST", "my-function"), + ("my-function:v1", "my-function"), + # Invalid partial ARN format (should pass through) + ("123456789012:invalid:my-function", "123456789012:invalid:my-function"), + ] + ) + def test_normalize_lambda_function_name(self, input_name, expected_output): + """Test normalize_lambda_function_name with various inputs""" + result = normalize_lambda_function_name(input_name) + self.assertEqual(result, expected_output) + + @parameterized.expand( + [ + ("arn:aws:s3:::my-bucket/my-key",), + ("arn:aws:lambda",), + (":HelloWorld",), + ("HelloWorld:",), + ("",), + (":",), + ("function-with-invalid-chars!",), + ("123456789012:function:",), + ] + ) + def test_raises_exception_for_invalid_function_names(self, invalid_name): + """Test that invalid function names raise InvalidFunctionNameException""" + with self.assertRaises(InvalidFunctionNameException) as exc_info: + normalize_lambda_function_name(invalid_name) + + # Check that the error message matches AWS Lambda's format + self.assertIn("1 validation error detected", str(exc_info.exception)) + self.assertIn(f"Value '{invalid_name}' at 'functionName'", str(exc_info.exception)) + self.assertIn("failed to satisfy constraint", str(exc_info.exception)) diff --git a/tests/unit/local/lambda_service/test_lambda_error_responses.py b/tests/unit/local/lambda_service/test_lambda_error_responses.py index 47190f0ea4..fa70deb0f0 100644 --- a/tests/unit/local/lambda_service/test_lambda_error_responses.py +++ b/tests/unit/local/lambda_service/test_lambda_error_responses.py @@ -26,9 +26,18 @@ def test_invalid_request_content(self, service_response_mock): response = LambdaErrorResponses.invalid_request_content("InvalidRequestContent") self.assertEqual(response, "InvalidRequestContent") + + @patch("samcli.local.services.base_local_service.BaseLocalService.service_response") + def test_validation_exception(self, service_response_mock): + service_response_mock.return_value = "ValidationException" + + response = LambdaErrorResponses.validation_exception("ValidationException") + + self.assertEqual(response, "ValidationException") + service_response_mock.assert_called_once_with( - '{"Type": "User", "Message": "InvalidRequestContent"}', - {"x-amzn-errortype": "InvalidRequestContent", "Content-Type": "application/json"}, + '{"Type": "User", "Message": "ValidationException"}', + {"x-amzn-errortype": "ValidationException", "Content-Type": "application/json"}, 400, ) diff --git a/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py b/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py index af5a2a5aa2..3f4f22a261 100644 --- a/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py +++ b/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py @@ -6,6 +6,7 @@ from samcli.local.lambda_service.local_lambda_invoke_service import LocalLambdaInvokeService, FunctionNamePathConverter from samcli.local.lambdafn.exceptions import FunctionNotFound from samcli.commands.local.lib.exceptions import UnsupportedInlineCodeError +from samcli.lib.utils.name_utils import InvalidFunctionNameException class TestLocalLambdaService(TestCase): @@ -332,3 +333,53 @@ def test_path_converter_matches_function_full_path(self): path_converter = FunctionNamePathConverter(map) full_path = "parent_stack/function_id" self.assertRegex(full_path, path_converter.regex) + + @patch("samcli.local.lambda_service.local_lambda_invoke_service.LocalLambdaInvokeService.service_response") + @patch("samcli.local.lambda_service.local_lambda_invoke_service.LambdaOutputParser") + def test_invoke_request_handler_with_arn(self, lambda_output_parser_mock, service_response_mock): + """Test that invoke request handler correctly normalizes ARN to function name""" + lambda_output_parser_mock.get_lambda_output.return_value = "hello world", False + service_response_mock.return_value = "request response" + + request_mock = Mock() + request_mock.get_data.return_value = b"{}" + local_lambda_invoke_service.request = request_mock + + lambda_runner_mock = Mock() + service = LocalLambdaInvokeService(lambda_runner=lambda_runner_mock, port=3000, host="localhost") + + # Call with ARN instead of function name + arn = "arn:aws:lambda:us-east-1:123456789012:function:HelloWorld" + response = service._invoke_request_handler(function_name=arn) + + self.assertEqual(response, "request response") + + # Verify that the lambda runner was called with the normalized function name + lambda_runner_mock.invoke.assert_called_once_with("HelloWorld", "{}", stdout=ANY, stderr=None) + service_response_mock.assert_called_once_with("hello world", {"Content-Type": "application/json"}, 200) + + @patch("samcli.local.lambda_service.local_lambda_invoke_service.LambdaErrorResponses") + def test_invoke_request_handler_function_not_found_with_arn(self, lambda_error_responses_mock): + """Test that error handling uses normalized function name when ARN is provided""" + request_mock = Mock() + request_mock.get_data.return_value = b"{}" + local_lambda_invoke_service.request = request_mock + + lambda_runner_mock = Mock() + lambda_runner_mock.invoke.side_effect = FunctionNotFound + + lambda_error_responses_mock.resource_not_found.return_value = "Couldn't find Lambda" + + service = LocalLambdaInvokeService(lambda_runner=lambda_runner_mock, port=3000, host="localhost") + + # Call with ARN instead of function name + arn = "arn:aws:lambda:us-east-1:123456789012:function:NotFound" + response = service._invoke_request_handler(function_name=arn) + + self.assertEqual(response, "Couldn't find Lambda") + + # Verify that the lambda runner was called with the normalized function name + lambda_runner_mock.invoke.assert_called_once_with("NotFound", "{}", stdout=ANY, stderr=None) + + # Verify that error response uses the normalized function name + lambda_error_responses_mock.resource_not_found.assert_called_once_with("NotFound")