diff --git a/samcli/cli/types.py b/samcli/cli/types.py index a3743128ade..5d85bef1d93 100644 --- a/samcli/cli/types.py +++ b/samcli/cli/types.py @@ -662,3 +662,40 @@ def restore_spaces(self): text_list[pos] = " " return "".join(text_list) + + +class TenantIdType(click.ParamType): + """ + Custom Click parameter type for tenant-id validation. + Validates tenant-id format according to AWS Lambda multi-tenancy requirements. + """ + + name = "string" + + # Tenant-id validation pattern + TENANT_ID_PATTERN = re.compile(r"^[a-zA-Z0-9\._:\/=+\-@ ]+$") + MIN_LENGTH = 1 + MAX_LENGTH = 256 + + def convert(self, value, param, ctx): + if value is None: + return None + + # Check length constraints + if len(value) < self.MIN_LENGTH or len(value) > self.MAX_LENGTH: + raise click.BadParameter( + f"{param.opts[0]} must be between {self.MIN_LENGTH} and {self.MAX_LENGTH} characters" + ) + + # Check for empty or whitespace-only strings + if not value.strip(): + raise click.BadParameter(f"{param.opts[0]} cannot be empty or contain only whitespace") + + # Check format pattern + if not self.TENANT_ID_PATTERN.match(value): + raise click.BadParameter( + f"{param.opts[0]} contains invalid characters. " + "Allowed characters are a-z, A-Z, numbers, spaces, and the characters _ . : / = + - @" + ) + + return value diff --git a/samcli/commands/local/invoke/cli.py b/samcli/commands/local/invoke/cli.py index 73f79fa9711..a58d80f7c39 100644 --- a/samcli/commands/local/invoke/cli.py +++ b/samcli/commands/local/invoke/cli.py @@ -9,6 +9,7 @@ from samcli.cli.cli_config_file import ConfigProvider, configuration_option, save_params_option from samcli.cli.main import aws_creds_options, pass_context, print_cmdline_args from samcli.cli.main import common_options as cli_framework_options +from samcli.cli.types import TenantIdType from samcli.commands._utils.option_value_processor import process_image_options from samcli.commands._utils.options import ( hook_name_click_option, @@ -74,6 +75,14 @@ help="Lambda runtime used to invoke the function." + click.style(f"\n\nRuntimes: {', '.join(get_sorted_runtimes(INIT_RUNTIMES))}", bold=True), ) +@click.option( + "--tenant-id", + type=TenantIdType(), + help="Tenant ID for multi-tenant Lambda functions. " + "Used to ensure compute isolation between different tenants. " + "Must be 1-256 characters, the allowed characters are a-z and A-Z, " + "numbers, spaces, and the characters _ . : / = + - @", +) @mount_symlinks_option @invoke_common_options @local_common_options @@ -117,6 +126,7 @@ def cli( runtime, mount_symlinks, no_memory_limit, + tenant_id, ): """ `sam local invoke` command entry point @@ -150,6 +160,7 @@ def cli( runtime, mount_symlinks, no_memory_limit, + tenant_id, ) # pragma: no cover @@ -180,6 +191,7 @@ def do_cli( # pylint: disable=R0914 runtime, mount_symlinks, no_mem_limit, + tenant_id, ): """ Implementation of the ``cli`` method, just separated out for unit testing purposes @@ -236,6 +248,7 @@ def do_cli( # pylint: disable=R0914 context.local_lambda_runner.invoke( context.function_identifier, event=event_data, + tenant_id=tenant_id, stdout=context.stdout, stderr=context.stderr, override_runtime=runtime, diff --git a/samcli/commands/local/invoke/core/options.py b/samcli/commands/local/invoke/core/options.py index a0b4b4f6cd2..054dc8faea6 100644 --- a/samcli/commands/local/invoke/core/options.py +++ b/samcli/commands/local/invoke/core/options.py @@ -36,6 +36,7 @@ "add_host", "invoke_image", "runtime", + "tenant_id", "mount_symlinks", "no_memory_limit", ] diff --git a/samcli/commands/local/lib/exceptions.py b/samcli/commands/local/lib/exceptions.py index c19b694d60c..565402a8522 100644 --- a/samcli/commands/local/lib/exceptions.py +++ b/samcli/commands/local/lib/exceptions.py @@ -46,3 +46,10 @@ class InvalidHandlerPathError(UserException): """ Raises when the handler is in an unexpected format and can't be parsed """ + + +class TenantIdValidationError(UserException): + """ + Raised when there's a tenant-id validation error (missing tenant-id for multi-tenant functions + or providing tenant-id for non-multi-tenant functions) + """ diff --git a/samcli/commands/local/lib/local_lambda.py b/samcli/commands/local/lib/local_lambda.py index f8bd4cba30f..5ed7b3613e4 100644 --- a/samcli/commands/local/lib/local_lambda.py +++ b/samcli/commands/local/lib/local_lambda.py @@ -16,6 +16,7 @@ InvalidIntermediateImageError, NoPrivilegeException, OverridesNotWellDefinedError, + TenantIdValidationError, UnsupportedInlineCodeError, ) from samcli.lib.providers.provider import Function @@ -99,6 +100,7 @@ def invoke( self, function_identifier: str, event: str, + tenant_id: Optional[str] = None, stdout: Optional[StreamWriter] = None, stderr: Optional[StreamWriter] = None, override_runtime: Optional[str] = None, @@ -158,6 +160,20 @@ def invoke( LOG.info("Invoking Container created from %s", function.imageuri) validate_architecture_runtime(function) + + # Validate tenant-id for multi-tenant functions + if function.tenancy_config and isinstance(function.tenancy_config, dict): + if not tenant_id: + raise TenantIdValidationError( + "The invoked function is enabled with tenancy configuration. " + "Add a valid tenant ID in your request and try again." + ) + elif tenant_id: + raise TenantIdValidationError( + "The invoked function is not enabled with tenancy configuration. " + "Remove the tenant ID from your request and try again." + ) + config = self.get_invoke_config(function, override_runtime) if ( @@ -173,6 +189,7 @@ def invoke( self.local_runtime.invoke( config, event, + tenant_id, debug_context=self.debug_context, stdout=stdout, stderr=stderr, diff --git a/samcli/commands/local/start_api/cli.py b/samcli/commands/local/start_api/cli.py index bd9d36ead53..be5574acf82 100644 --- a/samcli/commands/local/start_api/cli.py +++ b/samcli/commands/local/start_api/cli.py @@ -48,6 +48,10 @@ a interpreted language, local changes will be available immediately in Docker container on every invoke. For more compiled languages or projects requiring complex packing support, it is recommended to run custom building solution and point AWS SAM CLI to the directory or file containing build artifacts. + + For testing multi-tenant functions, pass tenant-id via X-Amz-Tenant-Id header. By default, each request uses a new + container providing tenant isolation. When using --warm-containers, containers are reused and do not + provide tenant isolation like production Lambda. """ diff --git a/samcli/commands/local/start_lambda/cli.py b/samcli/commands/local/start_lambda/cli.py index c82540e28cc..16da0b57001 100644 --- a/samcli/commands/local/start_lambda/cli.py +++ b/samcli/commands/local/start_lambda/cli.py @@ -39,7 +39,11 @@ Programmatically invoke your Lambda function locally using the AWS CLI or SDKs. Start a local endpoint that emulates the AWS Lambda service, and one can run their automated tests against this local Lambda endpoint. Invokes to this endpoint can be sent using the AWS CLI or - SDK and they will in turn locally execute the Lambda function specified in the request.\n + SDK and they will in turn locally execute the Lambda function specified in the request. + + For testing multi-tenant functions, pass tenant-id via the TenantId parameter in Lambda API calls. By default, each + invocation uses a new container providing tenant isolation. When using --warm-containers, containers are reused + and do not provide tenant isolation like production Lambda. """ diff --git a/samcli/commands/remote/invoke/cli.py b/samcli/commands/remote/invoke/cli.py index 9bc1469e614..f09c1323709 100644 --- a/samcli/commands/remote/invoke/cli.py +++ b/samcli/commands/remote/invoke/cli.py @@ -8,7 +8,7 @@ from samcli.cli.cli_config_file import ConfigProvider, configuration_option, save_params_option from samcli.cli.context import Context from samcli.cli.main import aws_creds_options, common_options, pass_context, print_cmdline_args -from samcli.cli.types import RemoteInvokeOutputFormatType +from samcli.cli.types import RemoteInvokeOutputFormatType, TenantIdType from samcli.commands._utils.command_exception_handler import command_exception_handler from samcli.commands._utils.options import remote_invoke_parameter_option from samcli.commands.remote.invoke.core.command import RemoteInvokeCommand @@ -66,6 +66,14 @@ type=click.File("r", encoding="utf-8"), help="The file that contains the event that will be sent to the resource.", ) +@click.option( + "--tenant-id", + type=TenantIdType(), + help="Tenant ID for multi-tenant Lambda functions. " + "Used to ensure compute isolation between different tenants. " + "Must be 1-256 characters, the allowed characters are a-z and A-Z, " + "numbers, spaces, and the characters _ . : / = + - @", +) @click.option( "--test-event-name", help="Name of the remote test event to send to the resource", @@ -94,6 +102,7 @@ def cli( resource_id: str, event: str, event_file: TextIOWrapper, + tenant_id: str, output: RemoteInvokeOutputFormat, test_event_name: str, parameter: dict, @@ -110,6 +119,7 @@ def cli( resource_id, event, event_file, + tenant_id, output, parameter, test_event_name, @@ -125,6 +135,7 @@ def do_cli( resource_id: str, event: str, event_file: TextIOWrapper, + tenant_id: str, output: RemoteInvokeOutputFormat, parameter: dict, test_event_name: str, @@ -187,7 +198,7 @@ def do_cli( EventTracker.track_event("RemoteInvokeEventType", event_type) remote_invoke_input = RemoteInvokeExecutionInfo( - payload=event, payload_file=event_file, parameters=parameter, output_format=output + payload=event, payload_file=event_file, tenant_id=tenant_id, parameters=parameter, output_format=output ) remote_invoke_context.run(remote_invoke_input=remote_invoke_input) diff --git a/samcli/commands/remote/invoke/core/options.py b/samcli/commands/remote/invoke/core/options.py index d22f223ae4f..dc1b8a7beeb 100644 --- a/samcli/commands/remote/invoke/core/options.py +++ b/samcli/commands/remote/invoke/core/options.py @@ -14,7 +14,7 @@ INPUT_EVENT_OPTIONS: List[str] = ["event", "event_file", "test_event_name"] -ADDITIONAL_OPTIONS: List[str] = ["parameter", "output"] +ADDITIONAL_OPTIONS: List[str] = ["parameter", "output", "tenant_id"] AWS_CREDENTIAL_OPTION_NAMES: List[str] = ["region", "profile"] diff --git a/samcli/lib/providers/provider.py b/samcli/lib/providers/provider.py index d9033785fb7..166e047bee7 100644 --- a/samcli/lib/providers/provider.py +++ b/samcli/lib/providers/provider.py @@ -116,6 +116,8 @@ class Function(NamedTuple): runtime_management_config: Optional[Dict] = None # LoggingConfig for Advanced logging logging_config: Optional[Dict] = None + # Function Tenancy Configuration for multi-tenant functions + tenancy_config: Optional[Dict] = None @property def full_path(self) -> str: diff --git a/samcli/lib/providers/sam_function_provider.py b/samcli/lib/providers/sam_function_provider.py index 529a060e3e8..2f578b2055a 100644 --- a/samcli/lib/providers/sam_function_provider.py +++ b/samcli/lib/providers/sam_function_provider.py @@ -522,6 +522,7 @@ def _build_function_configuration( runtime_management_config=resource_properties.get("RuntimeManagementConfig"), function_build_info=function_build_info, logging_config=resource_properties.get("LoggingConfig"), + tenancy_config=resource_properties.get("TenancyConfig"), ) @staticmethod diff --git a/samcli/lib/remote_invoke/lambda_invoke_executors.py b/samcli/lib/remote_invoke/lambda_invoke_executors.py index 423a604d3ed..b978357071e 100644 --- a/samcli/lib/remote_invoke/lambda_invoke_executors.py +++ b/samcli/lib/remote_invoke/lambda_invoke_executors.py @@ -33,6 +33,7 @@ LOG = logging.getLogger(__name__) FUNCTION_NAME = "FunctionName" PAYLOAD = "Payload" +TENANT_ID = "TenantId" EVENT_STREAM = "EventStream" PAYLOAD_CHUNK = "PayloadChunk" INVOKE_COMPLETE = "InvokeComplete" @@ -58,6 +59,15 @@ def __init__(self, lambda_client: LambdaClient, function_name: str, remote_outpu self._remote_output_format = remote_output_format self.request_parameters = {"InvocationType": "RequestResponse", "LogType": "Tail"} + def execute(self, remote_invoke_input: RemoteInvokeExecutionInfo) -> RemoteInvokeIterableResponseType: + """ + Override execute to extract tenant_id from RemoteInvokeExecutionInfo for Lambda-specific handling. + """ + if remote_invoke_input.tenant_id: + self.request_parameters[TENANT_ID] = remote_invoke_input.tenant_id + + return super().execute(remote_invoke_input) + def validate_action_parameters(self, parameters: dict) -> None: """ Validates the input boto parameters and prepares the parameters for calling the API. diff --git a/samcli/lib/remote_invoke/remote_invoke_executors.py b/samcli/lib/remote_invoke/remote_invoke_executors.py index a8d09b860a8..ef4a2aff742 100644 --- a/samcli/lib/remote_invoke/remote_invoke_executors.py +++ b/samcli/lib/remote_invoke/remote_invoke_executors.py @@ -83,6 +83,7 @@ class RemoteInvokeExecutionInfo: payload: payload string given by the customer payload_file: if file is given, this points to its location + tenant_id: tenant ID for multi-tenant Lambda functions response: response object returned from boto3 action exception: if an exception is thrown, it will be stored here @@ -91,6 +92,7 @@ class RemoteInvokeExecutionInfo: # Request related properties payload: Optional[Union[str, List, dict]] payload_file: Optional[TextIOWrapper] + tenant_id: Optional[str] parameters: dict output_format: RemoteInvokeOutputFormat @@ -103,11 +105,13 @@ def __init__( self, payload: Optional[Union[str, List, dict]], payload_file: Optional[TextIOWrapper], + tenant_id: Optional[str], parameters: dict, output_format: RemoteInvokeOutputFormat, ): self.payload = payload self.payload_file = payload_file + self.tenant_id = tenant_id self.parameters = parameters self.output_format = output_format self.response = None diff --git a/samcli/local/apigw/local_apigw_service.py b/samcli/local/apigw/local_apigw_service.py index 69b37ca0f42..7c0d4a84199 100644 --- a/samcli/local/apigw/local_apigw_service.py +++ b/samcli/local/apigw/local_apigw_service.py @@ -597,7 +597,9 @@ def _valid_identity_sources(self, request: Request, route: Route) -> bool: return True - def _invoke_lambda_function(self, lambda_function_name: str, event: dict) -> Union[str, bytes]: + def _invoke_lambda_function( + self, lambda_function_name: str, event: dict, tenant_id: Optional[str] = None + ) -> Union[str, bytes]: """ Helper method to invoke a function and setup stdout+stderr @@ -607,6 +609,8 @@ def _invoke_lambda_function(self, lambda_function_name: str, event: dict) -> Uni The name of the Lambda function to invoke event: dict The event object to pass into the Lambda function + tenant_id: Optional[str] + Tenant ID for multi-tenant Lambda functions Returns ------- @@ -617,7 +621,9 @@ def _invoke_lambda_function(self, lambda_function_name: str, event: dict) -> Uni event_str = json.dumps(event, sort_keys=True) stdout_writer = StreamWriter(stdout, auto_flush=True) - self.lambda_runner.invoke(lambda_function_name, event_str, stdout=stdout_writer, stderr=self.stderr) + self.lambda_runner.invoke( + lambda_function_name, event_str, stdout=stdout_writer, stderr=self.stderr, tenant_id=tenant_id + ) lambda_response, is_lambda_user_error_response = LambdaOutputParser.get_lambda_output(stdout) if is_lambda_user_error_response: raise LambdaResponseParseException @@ -728,8 +734,11 @@ def _request_handler(self, **kwargs): endpoint_service_error = None try: + # Extract tenant-id from HTTP request header + tenant_id = request.headers.get("X-Amz-Tenant-Id") + # invoke the route's Lambda function - lambda_response = self._invoke_lambda_function(route.function_name, route_lambda_event) + lambda_response = self._invoke_lambda_function(route.function_name, route_lambda_event, tenant_id) except FunctionNotFound: endpoint_service_error = ServiceErrorResponses.lambda_not_found_response() except UnsupportedInlineCodeError: diff --git a/samcli/local/docker/container.py b/samcli/local/docker/container.py index 45a05b9d983..34bd311928e 100644 --- a/samcli/local/docker/container.py +++ b/samcli/local/docker/container.py @@ -448,7 +448,7 @@ def start(self, input_data=None): raise ex @retry(exc=requests.exceptions.RequestException, exc_raise=ContainerResponseException) - def wait_for_http_response(self, name, event, stdout) -> Tuple[Union[str, bytes], bool]: + def wait_for_http_response(self, name, event, stdout, tenant_id=None) -> Tuple[Union[str, bytes], bool]: # TODO(sriram-mv): `aws-lambda-rie` is in a mode where the function_name is always "function" # NOTE(sriram-mv): There is a connection timeout set on the http call to `aws-lambda-rie`, however there is not # a read time out for the response received from the server. @@ -462,10 +462,18 @@ def wait_for_http_response(self, name, event, stdout) -> Tuple[Union[str, bytes] lock = threading.Lock() CONCURRENT_CALL_MANAGER[lock_key] = lock LOG.debug("Waiting to retrieve the lock (%s) to start invocation", lock_key) + + # Prepare headers for the request + headers = {} + if tenant_id is not None: + headers["X-Amz-Tenant-Id"] = tenant_id + LOG.debug("Adding tenant-id header: %s", tenant_id) + with lock: resp = requests.post( self.URL.format(host=self._container_host, port=self.rapid_port_host, function_name="function"), data=event.encode("utf-8"), + headers=headers, timeout=(self.RAPID_CONNECTION_TIMEOUT, None), ) @@ -478,7 +486,7 @@ def wait_for_http_response(self, name, event, stdout) -> Tuple[Union[str, bytes] LOG.debug("Failed to deserialize response from RIE, returning the raw response as is") return resp.content, False - def wait_for_result(self, full_path, event, stdout, stderr, start_timer=None): + def wait_for_result(self, full_path, event, stdout, stderr, start_timer=None, tenant_id=None): # NOTE(sriram-mv): Let logging happen in its own thread, so that a http request can be sent. # NOTE(sriram-mv): All logging is re-directed to stderr, so that only the lambda function return # will be written to stdout. @@ -499,7 +507,7 @@ def wait_for_result(self, full_path, event, stdout, stderr, start_timer=None): # start the timer for function timeout right before executing the function, as waiting for the socket # can take some time timer = start_timer() if start_timer else None - response, is_image = self.wait_for_http_response(full_path, event, stdout) + response, is_image = self.wait_for_http_response(full_path, event, stdout, tenant_id) if timer: timer.cancel() diff --git a/samcli/local/lambda_service/local_lambda_invoke_service.py b/samcli/local/lambda_service/local_lambda_invoke_service.py index f6c4d2e2f61..ad1b8c40797 100644 --- a/samcli/local/lambda_service/local_lambda_invoke_service.py +++ b/samcli/local/lambda_service/local_lambda_invoke_service.py @@ -7,7 +7,7 @@ from flask import Flask, request from werkzeug.routing import BaseConverter -from samcli.commands.local.lib.exceptions import UnsupportedInlineCodeError +from samcli.commands.local.lib.exceptions import TenantIdValidationError, UnsupportedInlineCodeError from samcli.lib.utils.name_utils import InvalidFunctionNameException, normalize_sam_function_identifier from samcli.lib.utils.stream_writer import StreamWriter from samcli.local.docker.exceptions import DockerContainerCreationFailedException @@ -173,6 +173,8 @@ def _invoke_request_handler(self, function_name): except InvalidFunctionNameException as e: LOG.error("Invalid function name: %s", str(e)) return LambdaErrorResponses.validation_exception(str(e)) + # Extract tenant-id from request header + tenant_id = flask_request.headers.get("X-Amz-Tenant-Id") stdout_stream_string = io.StringIO() stdout_stream_bytes = io.BytesIO() @@ -180,8 +182,14 @@ def _invoke_request_handler(self, function_name): try: self.lambda_runner.invoke( - normalized_function_name, request_data, stdout=stdout_stream_writer, stderr=self.stderr + normalized_function_name, + request_data, + stdout=stdout_stream_writer, + stderr=self.stderr, + tenant_id=tenant_id, ) + except TenantIdValidationError as e: + return LambdaErrorResponses.validation_exception(str(e)) except FunctionNotFound: LOG.debug("%s was not found to invoke.", normalized_function_name) return LambdaErrorResponses.resource_not_found(normalized_function_name) diff --git a/samcli/local/lambdafn/runtime.py b/samcli/local/lambdafn/runtime.py index 82c75c0e466..3bfca43cfdb 100644 --- a/samcli/local/lambdafn/runtime.py +++ b/samcli/local/lambdafn/runtime.py @@ -196,6 +196,7 @@ def invoke( self, function_config, event, + tenant_id=None, debug_context=None, stdout: Optional[StreamWriter] = None, stderr: Optional[StreamWriter] = None, @@ -253,7 +254,12 @@ def invoke( # starts another thread to stream logs. This method will terminate # either successfully or be killed by one of the interrupt handlers above. container.wait_for_result( - full_path=function_config.full_path, event=event, stdout=stdout, stderr=stderr, start_timer=start_timer + full_path=function_config.full_path, + event=event, + stdout=stdout, + stderr=stderr, + start_timer=start_timer, + tenant_id=tenant_id, ) except KeyboardInterrupt: diff --git a/samcli/local/rapid/aws-lambda-rie-arm64 b/samcli/local/rapid/aws-lambda-rie-arm64 index b9d7f4c621e..66429f5dd02 100755 Binary files a/samcli/local/rapid/aws-lambda-rie-arm64 and b/samcli/local/rapid/aws-lambda-rie-arm64 differ diff --git a/samcli/local/rapid/aws-lambda-rie-x86_64 b/samcli/local/rapid/aws-lambda-rie-x86_64 index 2d6c7b83f0e..100370d7277 100755 Binary files a/samcli/local/rapid/aws-lambda-rie-x86_64 and b/samcli/local/rapid/aws-lambda-rie-x86_64 differ diff --git a/schema/samcli.json b/schema/samcli.json index 2f57a48e92e..7097be9d143 100644 --- a/schema/samcli.json +++ b/schema/samcli.json @@ -414,7 +414,7 @@ "properties": { "parameters": { "title": "Parameters for the local invoke command", - "description": "Available parameters for the local invoke command:\n* terraform_plan_file:\nUsed for passing a custom plan file when executing the Terraform hook.\n* hook_name:\nHook package id to extend AWS SAM CLI commands functionality. \n\nExample: `terraform` to extend AWS SAM CLI commands functionality to support terraform applications. \n\nAvailable Hook Names: ['terraform']\n* skip_prepare_infra:\nSkip preparation stage when there are no infrastructure changes. Only used in conjunction with --hook-name.\n* event:\nJSON file containing event data passed to the Lambda function during invoke. If this option is not specified, no event is assumed. Pass in the value '-' to input JSON via stdin\n* no_event:\nDEPRECATED: By default no event is assumed.\n* runtime:\nLambda runtime used to invoke the function.\n\nRuntimes: dotnet8, dotnet6, go1.x, java25, java21, java17, java11, java8.al2, nodejs24.x, nodejs22.x, nodejs20.x, nodejs18.x, nodejs16.x, provided, provided.al2, provided.al2023, python3.9, python3.8, python3.14, python3.13, python3.12, python3.11, python3.10, ruby3.4, ruby3.3, ruby3.2\n* mount_symlinks:\nSpecify if symlinks at the top level of the code should be mounted inside the container. Activating this flag could allow access to locations outside of your workspace by using a symbolic link. By default symlinks are not mounted.\n* template_file:\nAWS SAM template which references built artifacts for resources in the template. (if applicable)\n* env_vars:\nJSON file containing values for Lambda function's environment variables.\n* parameter_overrides:\nString that contains AWS CloudFormation parameter overrides encoded as key=value pairs.\n* debug_port:\nWhen specified, Lambda function container will start in debug mode and will expose this port on localhost.\n* debugger_path:\nHost path to a debugger that will be mounted into the Lambda container.\n* debug_args:\nAdditional arguments to be passed to the debugger.\n* container_env_vars:\nJSON file containing additional environment variables to be set within the container when used in a debugging session locally.\n* docker_volume_basedir:\nSpecify the location basedir where the SAM template exists. If Docker is running on a remote machine, Path of the SAM template must be mounted on the Docker machine and modified to match the remote machine.\n* log_file:\nFile to capture output logs.\n* layer_cache_basedir:\nSpecify the location basedir where the lambda layers used by the template will be downloaded to.\n* skip_pull_image:\nSkip pulling down the latest Docker image for Lambda runtime.\n* docker_network:\nName or ID of an existing docker network for AWS Lambda docker containers to connect to, along with the default bridge network. If not specified, the Lambda containers will only connect to the default bridge docker network.\n* force_image_build:\nForce rebuilding the image used for invoking functions with layers.\n* shutdown:\nEmulate a shutdown event after invoke completes, to test extension handling of shutdown behavior.\n* container_host:\nHost of locally emulated Lambda container. This option is useful when the container runs on a different host than AWS SAM CLI. For example, if one wants to run AWS SAM CLI in a Docker container on macOS, this option could specify `host.docker.internal`\n* container_host_interface:\nIP address of the host network interface that container ports should bind to. Use 0.0.0.0 to bind to all interfaces.\n* add_host:\nPasses a hostname to IP address mapping to the Docker container's host file. This parameter can be passed multiple times.Example:--add-host example.com:127.0.0.1\n* invoke_image:\nContainer image URIs for invoking functions or starting api and function. One can specify the image URI used for the local function invocation (--invoke-image public.ecr.aws/sam/build-nodejs20.x:latest). One can also specify for each individual function with (--invoke-image Function1=public.ecr.aws/sam/build-nodejs20.x:latest). If a function does not have invoke image specified, the default AWS SAM CLI emulation image will be used.\n* no_memory_limit:\nRemoves the Memory limit during emulation. With this parameter, the underlying container will run without a --memory parameter\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* save_params:\nSave the parameters provided via the command line to the configuration file.", + "description": "Available parameters for the local invoke command:\n* terraform_plan_file:\nUsed for passing a custom plan file when executing the Terraform hook.\n* hook_name:\nHook package id to extend AWS SAM CLI commands functionality. \n\nExample: `terraform` to extend AWS SAM CLI commands functionality to support terraform applications. \n\nAvailable Hook Names: ['terraform']\n* skip_prepare_infra:\nSkip preparation stage when there are no infrastructure changes. Only used in conjunction with --hook-name.\n* event:\nJSON file containing event data passed to the Lambda function during invoke. If this option is not specified, no event is assumed. Pass in the value '-' to input JSON via stdin\n* no_event:\nDEPRECATED: By default no event is assumed.\n* runtime:\nLambda runtime used to invoke the function.\n\nRuntimes: dotnet8, dotnet6, go1.x, java25, java21, java17, java11, java8.al2, nodejs24.x, nodejs22.x, nodejs20.x, nodejs18.x, nodejs16.x, provided, provided.al2, provided.al2023, python3.9, python3.8, python3.14, python3.13, python3.12, python3.11, python3.10, ruby3.4, ruby3.3, ruby3.2\n* tenant_id:\nTenant ID for multi-tenant Lambda functions. Used to ensure compute isolation between different tenants. Must be 1-256 characters, the allowed characters are a-z and A-Z, numbers, spaces, and the characters _ . : / = + - @\n* mount_symlinks:\nSpecify if symlinks at the top level of the code should be mounted inside the container. Activating this flag could allow access to locations outside of your workspace by using a symbolic link. By default symlinks are not mounted.\n* template_file:\nAWS SAM template which references built artifacts for resources in the template. (if applicable)\n* env_vars:\nJSON file containing values for Lambda function's environment variables.\n* parameter_overrides:\nString that contains AWS CloudFormation parameter overrides encoded as key=value pairs.\n* debug_port:\nWhen specified, Lambda function container will start in debug mode and will expose this port on localhost.\n* debugger_path:\nHost path to a debugger that will be mounted into the Lambda container.\n* debug_args:\nAdditional arguments to be passed to the debugger.\n* container_env_vars:\nJSON file containing additional environment variables to be set within the container when used in a debugging session locally.\n* docker_volume_basedir:\nSpecify the location basedir where the SAM template exists. If Docker is running on a remote machine, Path of the SAM template must be mounted on the Docker machine and modified to match the remote machine.\n* log_file:\nFile to capture output logs.\n* layer_cache_basedir:\nSpecify the location basedir where the lambda layers used by the template will be downloaded to.\n* skip_pull_image:\nSkip pulling down the latest Docker image for Lambda runtime.\n* docker_network:\nName or ID of an existing docker network for AWS Lambda docker containers to connect to, along with the default bridge network. If not specified, the Lambda containers will only connect to the default bridge docker network.\n* force_image_build:\nForce rebuilding the image used for invoking functions with layers.\n* shutdown:\nEmulate a shutdown event after invoke completes, to test extension handling of shutdown behavior.\n* container_host:\nHost of locally emulated Lambda container. This option is useful when the container runs on a different host than AWS SAM CLI. For example, if one wants to run AWS SAM CLI in a Docker container on macOS, this option could specify `host.docker.internal`\n* container_host_interface:\nIP address of the host network interface that container ports should bind to. Use 0.0.0.0 to bind to all interfaces.\n* add_host:\nPasses a hostname to IP address mapping to the Docker container's host file. This parameter can be passed multiple times.Example:--add-host example.com:127.0.0.1\n* invoke_image:\nContainer image URIs for invoking functions or starting api and function. One can specify the image URI used for the local function invocation (--invoke-image public.ecr.aws/sam/build-nodejs20.x:latest). One can also specify for each individual function with (--invoke-image Function1=public.ecr.aws/sam/build-nodejs20.x:latest). If a function does not have invoke image specified, the default AWS SAM CLI emulation image will be used.\n* no_memory_limit:\nRemoves the Memory limit during emulation. With this parameter, the underlying container will run without a --memory parameter\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* save_params:\nSave the parameters provided via the command line to the configuration file.", "type": "object", "properties": { "terraform_plan_file": { @@ -476,6 +476,11 @@ "ruby3.4" ] }, + "tenant_id": { + "title": "tenant_id", + "type": "string", + "description": "Tenant ID for multi-tenant Lambda functions. Used to ensure compute isolation between different tenants. Must be 1-256 characters, the allowed characters are a-z and A-Z, numbers, spaces, and the characters _ . : / = + - @" + }, "mount_symlinks": { "title": "mount_symlinks", "type": "boolean", @@ -2258,7 +2263,7 @@ "properties": { "parameters": { "title": "Parameters for the remote invoke command", - "description": "Available parameters for the remote invoke command:\n* stack_name:\nName of the stack to get the resource information from\n* event:\nThe event that will be sent to the resource. The target parameter will depend on the resource type. For instance: 'Payload' for Lambda which can be passed as a JSON string, 'Input' for Step Functions, 'MessageBody' for SQS, and 'Data' for Kinesis data streams.\n* event_file:\nThe file that contains the event that will be sent to the resource.\n* test_event_name:\nName of the remote test event to send to the resource\n* output:\nOutput the results from the command in a given output format. The text format prints a readable AWS API response. The json format prints the full AWS API response.\n* parameter:\nAdditional parameters that can be passed to invoke the resource.\n\nLambda Function (Buffered stream): The following additional parameters can be used to invoke a lambda resource and get a buffered response: InvocationType='Event'|'RequestResponse'|'DryRun', LogType='None'|'Tail', ClientContext='base64-encoded string' Qualifier='string'.\n\nLambda Function (Response stream): The following additional parameters can be used to invoke a lambda resource with response streaming: InvocationType='RequestResponse'|'DryRun', LogType='None'|'Tail', ClientContext='base64-encoded string', Qualifier='string'.\n\nStep Functions: The following additional parameters can be used to start a state machine execution: name='string', traceHeader='string'\n\nSQS Queue: The following additional parameters can be used to send a message to an SQS queue: DelaySeconds=integer, MessageAttributes='json string', MessageSystemAttributes='json string', MessageDeduplicationId='string', MessageGroupId='string'\n\nKinesis Data Stream: The following additional parameters can be used to put a record in the kinesis data stream: PartitionKey='string', ExplicitHashKey='string', SequenceNumberForOrdering='string', StreamARN='string'\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* save_params:\nSave the parameters provided via the command line to the configuration file.", + "description": "Available parameters for the remote invoke command:\n* stack_name:\nName of the stack to get the resource information from\n* event:\nThe event that will be sent to the resource. The target parameter will depend on the resource type. For instance: 'Payload' for Lambda which can be passed as a JSON string, 'Input' for Step Functions, 'MessageBody' for SQS, and 'Data' for Kinesis data streams.\n* event_file:\nThe file that contains the event that will be sent to the resource.\n* tenant_id:\nTenant ID for multi-tenant Lambda functions. Used to ensure compute isolation between different tenants. Must be 1-256 characters, the allowed characters are a-z and A-Z, numbers, spaces, and the characters _ . : / = + - @\n* test_event_name:\nName of the remote test event to send to the resource\n* output:\nOutput the results from the command in a given output format. The text format prints a readable AWS API response. The json format prints the full AWS API response.\n* parameter:\nAdditional parameters that can be passed to invoke the resource.\n\nLambda Function (Buffered stream): The following additional parameters can be used to invoke a lambda resource and get a buffered response: InvocationType='Event'|'RequestResponse'|'DryRun', LogType='None'|'Tail', ClientContext='base64-encoded string' Qualifier='string'.\n\nLambda Function (Response stream): The following additional parameters can be used to invoke a lambda resource with response streaming: InvocationType='RequestResponse'|'DryRun', LogType='None'|'Tail', ClientContext='base64-encoded string', Qualifier='string'.\n\nStep Functions: The following additional parameters can be used to start a state machine execution: name='string', traceHeader='string'\n\nSQS Queue: The following additional parameters can be used to send a message to an SQS queue: DelaySeconds=integer, MessageAttributes='json string', MessageSystemAttributes='json string', MessageDeduplicationId='string', MessageGroupId='string'\n\nKinesis Data Stream: The following additional parameters can be used to put a record in the kinesis data stream: PartitionKey='string', ExplicitHashKey='string', SequenceNumberForOrdering='string', StreamARN='string'\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* save_params:\nSave the parameters provided via the command line to the configuration file.", "type": "object", "properties": { "stack_name": { @@ -2276,6 +2281,11 @@ "type": "string", "description": "The file that contains the event that will be sent to the resource." }, + "tenant_id": { + "title": "tenant_id", + "type": "string", + "description": "Tenant ID for multi-tenant Lambda functions. Used to ensure compute isolation between different tenants. Must be 1-256 characters, the allowed characters are a-z and A-Z, numbers, spaces, and the characters _ . : / = + - @" + }, "test_event_name": { "title": "test_event_name", "type": "string", diff --git a/tests/integration/local/invoke/invoke_integ_base.py b/tests/integration/local/invoke/invoke_integ_base.py index ba647afef38..4e014815507 100644 --- a/tests/integration/local/invoke/invoke_integ_base.py +++ b/tests/integration/local/invoke/invoke_integ_base.py @@ -44,6 +44,7 @@ def get_command_list( hook_name=None, beta_features=None, terraform_plan_file=None, + tenant_id=None, ): command_list = [get_sam_command(), "local", "invoke", function_to_invoke] @@ -89,6 +90,9 @@ def get_command_list( if terraform_plan_file: command_list += ["--terraform-plan-file", terraform_plan_file] + if tenant_id: + command_list = command_list + ["--tenant-id", tenant_id] + return command_list def get_build_command_list( diff --git a/tests/integration/local/invoke/test_integrations_cli.py b/tests/integration/local/invoke/test_integrations_cli.py index 631efbf87f7..ab9b4074f6c 100644 --- a/tests/integration/local/invoke/test_integrations_cli.py +++ b/tests/integration/local/invoke/test_integrations_cli.py @@ -363,6 +363,29 @@ def test_invoke_with_env_using_parameters(self): self.assertEqual(environ["MyRuntimeVersion"], "v0") self.assertEqual(environ["EmptyDefaultParameter"], "") + @pytest.mark.flaky(reruns=3) + def test_invoke_multi_tenant_function(self): + command_list = InvokeIntegBase.get_command_list( + "MultiTenantFunction", + template_path=self.template_path, + event_path=self.event_path, + tenant_id="test-tenant-123", + ) + + process = Popen(command_list, stdout=PIPE) + try: + stdout, _ = process.communicate(timeout=TIMEOUT) + except TimeoutExpired: + process.kill() + raise + + process_stdout = stdout.strip() + response = json.loads(process_stdout.decode("utf-8")) + body = json.loads(response["body"]) + + self.assertEqual(process.returncode, 0) + self.assertEqual(body["tenant_id"], "test-tenant-123") + @pytest.mark.flaky(reruns=3) def test_invoke_with_env_using_parameters_with_custom_region(self): custom_region = "us-west-2" diff --git a/tests/integration/local/start_api/test_start_api.py b/tests/integration/local/start_api/test_start_api.py index 4186b8890e8..1e75594f102 100644 --- a/tests/integration/local/start_api/test_start_api.py +++ b/tests/integration/local/start_api/test_start_api.py @@ -619,6 +619,18 @@ def test_invalid_lambda_json_body_response(self): self.assertEqual(response.json(), {"message": "Internal server error"}) self.assertEqual(response.raw.version, 11) + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=600, method="thread") + def test_multi_tenant_function_http_api_with_tenant_id(self): + """Test multi-tenant function via HTTP API with tenant-id header""" + response = requests.get( + self.url + "/multi-tenant-http", headers={"X-Amz-Tenant-Id": "test-tenant-789"}, timeout=300 + ) + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["tenant_id"], "test-tenant-789") + class TestStartApiWithSwaggerApis(StartApiIntegBaseClass): template_path = "/testdata/start_api/swagger-template.yaml" diff --git a/tests/integration/local/start_lambda/test_start_lambda.py b/tests/integration/local/start_lambda/test_start_lambda.py index 1093d0505a5..5b4eedd098c 100644 --- a/tests/integration/local/start_lambda/test_start_lambda.py +++ b/tests/integration/local/start_lambda/test_start_lambda.py @@ -214,6 +214,22 @@ def test_invoke_with_log_type_None(self, use_full_path): self.assertIsNone(response.get("FunctionError")) self.assertEqual(response.get("StatusCode"), 200) + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=300, method="thread") + def test_multi_tenant_function_with_tenant_id(self): + response = self.lambda_client.invoke( + FunctionName="MultiTenantFunction", TenantId="tenant-123", Payload='{"test": "data"}' + ) + + self.assertEqual(response.get("StatusCode"), 200) + payload = json.loads(response.get("Payload").read().decode("utf-8")) + + # The response is wrapped in a Lambda response format + self.assertEqual(payload.get("statusCode"), 200) + body = json.loads(payload.get("body")) + self.assertEqual(body.get("tenant_id"), "tenant-123") + self.assertEqual(body.get("message"), "Hello from multi-tenant function") + @parameterized.expand([("False"), ("True")]) @pytest.mark.flaky(reruns=3) @pytest.mark.timeout(timeout=300, method="thread") diff --git a/tests/integration/remote/invoke/remote_invoke_integ_base.py b/tests/integration/remote/invoke/remote_invoke_integ_base.py index 062223d53aa..05ee9bdf564 100644 --- a/tests/integration/remote/invoke/remote_invoke_integ_base.py +++ b/tests/integration/remote/invoke/remote_invoke_integ_base.py @@ -100,6 +100,7 @@ def get_command_list( region=None, profile=None, beta_features=None, + tenant_id=None, ): command_list = [get_sam_command(), "remote", "invoke"] @@ -118,6 +119,9 @@ def get_command_list( if output: command_list = command_list + ["--output", output] + if tenant_id: + command_list = command_list + ["--tenant-id", tenant_id] + if parameter_list: for parameter, value in parameter_list: command_list = command_list + ["--parameter", f"{parameter}={value}"] diff --git a/tests/integration/remote/invoke/test_remote_invoke.py b/tests/integration/remote/invoke/test_remote_invoke.py index e2942fea306..a1eb3581eab 100644 --- a/tests/integration/remote/invoke/test_remote_invoke.py +++ b/tests/integration/remote/invoke/test_remote_invoke.py @@ -120,6 +120,24 @@ def test_invoke_response_json_output_format(self): self.assertEqual(response_payload, {"message": "Hello world"}) self.assertEqual(remote_invoke_result_stdout["StatusCode"], 200) + def test_multi_tenant_function_with_tenant_id(self): + command_list = self.get_command_list( + stack_name=self.stack_name, + resource_id="MultiTenantFunction", + event='{"test": "data"}', + tenant_id="tenant-123", + ) + + remote_invoke_result = run_command(command_list) + + self.assertEqual(0, remote_invoke_result.process.returncode) + response_payload = json.loads(remote_invoke_result.stdout.strip().decode()) + + # Parse the Lambda response + body = json.loads(response_payload.get("body", "{}")) + self.assertEqual(body.get("tenant_id"), "tenant-123") + self.assertEqual(body.get("message"), "Hello from remote multi-tenant function") + @pytest.mark.xdist_group(name="sam_remote_invoke_sfn_resource_priority") class TestSFNPriorityInvoke(RemoteInvokeIntegBase): diff --git a/tests/integration/testdata/invoke/main.py b/tests/integration/testdata/invoke/main.py index 0f2753a6ffd..26be18d4e46 100644 --- a/tests/integration/testdata/invoke/main.py +++ b/tests/integration/testdata/invoke/main.py @@ -18,6 +18,20 @@ def handler(event, context): def intrinsics_handler(event, context): return os.environ.get("ApplicationId") +def multi_tenant_handler(event, context): + import json + # Get tenant_id from Lambda context + tenant_id = getattr(context, 'tenant_id', None) + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Hello from multi-tenant function', + 'tenant_id': tenant_id, + 'function_name': context.function_name + }) + } + def sleep_handler(event, context): time.sleep(10) return "Slept for 10s" diff --git a/tests/integration/testdata/invoke/template.yml b/tests/integration/testdata/invoke/template.yml index 76db225f5c5..47331e0675d 100644 --- a/tests/integration/testdata/invoke/template.yml +++ b/tests/integration/testdata/invoke/template.yml @@ -48,6 +48,16 @@ Resources: CodeUri: . Timeout: 600 + MultiTenantFunction: + Type: AWS::Serverless::Function + Properties: + Handler: main.multi_tenant_handler + Runtime: python3.9 + CodeUri: . + Timeout: 600 + TenancyConfig: + TenantIsolationMode: PER_TENANT + HelloWorldLambdaFunction: Type: AWS::Lambda::Function Properties: diff --git a/tests/integration/testdata/remote_invoke/lambda-fns/main.py b/tests/integration/testdata/remote_invoke/lambda-fns/main.py index 5770775adf5..9cb1e3c4036 100644 --- a/tests/integration/testdata/remote_invoke/lambda-fns/main.py +++ b/tests/integration/testdata/remote_invoke/lambda-fns/main.py @@ -52,4 +52,19 @@ def stock_seller(event, context): new_balance = current_balance + (event["qty"]*event["stock_price"]) return { "balance": new_balance + } + +def multi_tenant_handler(event, context): + import json + # Get tenant_id from Lambda context + tenant_id = getattr(context, 'tenant_id', None) + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Hello from remote multi-tenant function', + 'tenant_id': tenant_id, + 'function_name': context.function_name, + 'event': event + }) } \ No newline at end of file diff --git a/tests/integration/testdata/remote_invoke/template-single-lambda.yaml b/tests/integration/testdata/remote_invoke/template-single-lambda.yaml index 7ca9b259dba..27e47e54aa4 100644 --- a/tests/integration/testdata/remote_invoke/template-single-lambda.yaml +++ b/tests/integration/testdata/remote_invoke/template-single-lambda.yaml @@ -8,4 +8,13 @@ Resources: Properties: Handler: main.default_handler Runtime: python3.9 - CodeUri: ./lambda-fns \ No newline at end of file + CodeUri: ./lambda-fns + + MultiTenantFunction: + Type: AWS::Serverless::Function + Properties: + Handler: main.multi_tenant_handler + Runtime: python3.9 + CodeUri: ./lambda-fns + TenancyConfig: + TenantIsolationMode: PER_TENANT \ No newline at end of file diff --git a/tests/integration/testdata/start_api/main.py b/tests/integration/testdata/start_api/main.py index 4da7d4b7537..40617bd8167 100644 --- a/tests/integration/testdata/start_api/main.py +++ b/tests/integration/testdata/start_api/main.py @@ -8,6 +8,7 @@ def handler(event, context): return {"statusCode": 200, "body": json.dumps({"hello": "world"})} + def operation_name_handler(event, context): return {"statusCode": 200, "body": json.dumps({"operation_name": event["requestContext"].get("operationName", "")})} @@ -64,18 +65,23 @@ def write_to_stdout(event, context): def invalid_response_returned(event, context): return "This is invalid" + def integer_response_returned(event, context): return 2 + def invalid_v2_respose_returned(event, context): return {"statusCode": 200, "body": json.dumps({"hello": "world"}), "key": "value"} + def invalid_hash_response(event, context): return {"foo": "bar"} + def invalid_body_response(event, context): return {"errorType": "Error", "errorMessage": ""} + def base64_response(event, context): return { @@ -146,3 +152,15 @@ def multiple_headers_overrides_headers(event, context): def handle_options_cors(event, context): return {"statusCode": 204, "body": json.dumps({"hello": "world"})} + + +def multi_tenant_handler(event, context): + # Get tenant_id from Lambda context + tenant_id = getattr(context, "tenant_id", None) + + return { + "statusCode": 200, + "body": json.dumps( + {"message": "Hello from multi-tenant API", "tenant_id": tenant_id, "function_name": context.function_name} + ), + } diff --git a/tests/integration/testdata/start_api/template-http-api.yaml b/tests/integration/testdata/start_api/template-http-api.yaml index 6dca43b6f2f..f604180368d 100644 --- a/tests/integration/testdata/start_api/template-http-api.yaml +++ b/tests/integration/testdata/start_api/template-http-api.yaml @@ -359,3 +359,18 @@ Resources: Properties: Method: GET Path: /multipleheadersoverridesheaders + MultiTenantApiFunction: + Type: AWS::Serverless::Function + Properties: + Handler: main.multi_tenant_handler + Runtime: python3.9 + CodeUri: . + Timeout: 600 + TenancyConfig: + TenantIsolationMode: PER_TENANT + Events: + HttpApi: + Type: HttpApi + Properties: + Path: /multi-tenant-http + Method: get diff --git a/tests/integration/testdata/start_api/template.yaml b/tests/integration/testdata/start_api/template.yaml index bd26c21c0b3..b340315aa06 100644 --- a/tests/integration/testdata/start_api/template.yaml +++ b/tests/integration/testdata/start_api/template.yaml @@ -301,3 +301,24 @@ Resources: Properties: Path: /sleepfortensecondszipped Method: get + + MultiTenantApiFunction: + Type: AWS::Serverless::Function + Properties: + Handler: main.multi_tenant_handler + Runtime: python3.9 + CodeUri: . + Timeout: 600 + TenancyConfig: + TenantIsolationMode: PER_TENANT + Events: + RestApi: + Type: Api + Properties: + Path: /multi-tenant-rest + Method: get + HttpApi: + Type: HttpApi + Properties: + Path: /multi-tenant-http + Method: get diff --git a/tests/unit/cli/test_tenant_id_type.py b/tests/unit/cli/test_tenant_id_type.py new file mode 100644 index 00000000000..2c17d38c3f9 --- /dev/null +++ b/tests/unit/cli/test_tenant_id_type.py @@ -0,0 +1,69 @@ +# ABOUTME: Unit tests for TenantIdType CLI parameter validation +# ABOUTME: Tests tenant-id format validation and error handling + +import pytest +from click import BadParameter + +from samcli.cli.types import TenantIdType + + +class TestTenantIdType: + def setup_method(self): + self.tenant_id_type = TenantIdType() + + def test_valid_tenant_ids(self): + """Test valid tenant-id formats pass validation""" + valid_ids = [ + "customer-123", + "tenant_456", + "org.domain.com", + "user@company.com", + "path/to/resource", + "key=value+data", + "simple123", + "a", # minimum length + "a" * 256, # maximum length + "mixed-chars_123.test@domain.com/path=value+extra data", + ] + + for tenant_id in valid_ids: + result = self.tenant_id_type.convert(tenant_id, None, None) + assert result == tenant_id + + def test_invalid_tenant_ids_raise_bad_parameter(self): + """Test invalid tenant-id formats raise BadParameter with correct message""" + from unittest.mock import Mock + + mock_param = Mock() + mock_param.opts = ["--tenant-id"] + + # Test length validation + with pytest.raises(BadParameter) as exc_info: + self.tenant_id_type.convert("", mock_param, None) + assert "must be between 1 and 256 characters" in str(exc_info.value) + + with pytest.raises(BadParameter) as exc_info: + self.tenant_id_type.convert("a" * 257, mock_param, None) + assert "must be between 1 and 256 characters" in str(exc_info.value) + + # Test spaces-only validation + with pytest.raises(BadParameter) as exc_info: + self.tenant_id_type.convert(" ", mock_param, None) + assert "cannot be empty or contain only whitespace" in str(exc_info.value) + + # Test format validation + invalid_chars = ["tenant#123", "tenant|123", "tenant<123", "tenant>123"] + for tenant_id in invalid_chars: + with pytest.raises(BadParameter) as exc_info: + self.tenant_id_type.convert(tenant_id, mock_param, None) + assert "contains invalid characters" in str(exc_info.value) + assert "Allowed characters are" in str(exc_info.value) + + def test_none_value_returns_none(self): + """Test None value passes through unchanged""" + result = self.tenant_id_type.convert(None, None, None) + assert result is None + + def test_type_name(self): + """Test the parameter type name is correct""" + assert self.tenant_id_type.name == "string" diff --git a/tests/unit/commands/local/invoke/test_cli.py b/tests/unit/commands/local/invoke/test_cli.py index d18eae519e3..55a7a77e1ab 100644 --- a/tests/unit/commands/local/invoke/test_cli.py +++ b/tests/unit/commands/local/invoke/test_cli.py @@ -53,6 +53,7 @@ def setUp(self): self.overide_runtime = None self.mount_symlinks = False self.no_mem_limit = False + self.tenant_id = None self.ctx_mock = Mock() self.ctx_mock.region = self.region_name @@ -86,6 +87,7 @@ def call_cli(self): runtime=self.overide_runtime, mount_symlinks=self.mount_symlinks, no_mem_limit=self.no_mem_limit, + tenant_id=self.tenant_id, ) @patch("samcli.commands.local.cli_common.invoke_context.InvokeContext") @@ -129,6 +131,7 @@ def test_cli_must_setup_context_and_invoke(self, get_event_mock, InvokeContextMo context_mock.local_lambda_runner.invoke.assert_called_with( context_mock.function_identifier, event=event_data, + tenant_id=None, stdout=context_mock.stdout, stderr=context_mock.stderr, override_runtime=None, @@ -176,6 +179,7 @@ def test_cli_must_invoke_with_no_event(self, get_event_mock, InvokeContextMock): context_mock.local_lambda_runner.invoke.assert_called_with( context_mock.function_identifier, event="{}", + tenant_id=None, stdout=context_mock.stdout, stderr=context_mock.stderr, override_runtime=None, @@ -311,6 +315,59 @@ def test_must_raise_user_exception_on_function_no_free_ports( msg = str(ex_ctx.exception) self.assertEqual(msg, expected_exception_message) + @patch("samcli.commands.local.cli_common.invoke_context.InvokeContext") + @patch("samcli.commands.local.invoke.cli._get_event") + def test_cli_must_pass_tenant_id_to_invoke(self, get_event_mock, InvokeContextMock): + """Test that tenant_id parameter is passed through to local_lambda_runner.invoke()""" + event_data = "data" + tenant_id = "customer-123" + get_event_mock.return_value = event_data + + # Mock the __enter__ method to return a object inside a context manager + context_mock = Mock() + InvokeContextMock.return_value.__enter__.return_value = context_mock + + # Call with tenant_id + invoke_cli( + ctx=self.ctx_mock, + function_identifier=self.function_id, + template=self.template, + event=self.eventfile, + no_event=self.no_event, + env_vars=self.env_vars, + debug_port=self.debug_ports, + debug_args=self.debug_args, + debugger_path=self.debugger_path, + container_env_vars=self.container_env_vars, + docker_volume_basedir=self.docker_volume_basedir, + docker_network=self.docker_network, + log_file=self.log_file, + skip_pull_image=self.skip_pull_image, + parameter_overrides=self.parameter_overrides, + layer_cache_basedir=self.layer_cache_basedir, + force_image_build=self.force_image_build, + shutdown=self.shutdown, + container_host=self.container_host, + container_host_interface=self.container_host_interface, + add_host=self.add_host, + invoke_image=self.invoke_image, + hook_name=self.hook_name, + runtime=self.overide_runtime, + mount_symlinks=self.mount_symlinks, + no_mem_limit=self.no_mem_limit, + tenant_id=tenant_id, + ) + + # Verify tenant_id was passed to invoke + context_mock.local_lambda_runner.invoke.assert_called_with( + context_mock.function_identifier, + event=event_data, + stdout=context_mock.stdout, + stderr=context_mock.stderr, + override_runtime=None, + tenant_id=tenant_id, + ) + class TestGetEvent(TestCase): @parameterized.expand([param(STDIN_FILE_NAME), param("somefile")]) diff --git a/tests/unit/commands/local/lib/test_local_lambda.py b/tests/unit/commands/local/lib/test_local_lambda.py index cd1068beb8a..a862fead274 100644 --- a/tests/unit/commands/local/lib/test_local_lambda.py +++ b/tests/unit/commands/local/lib/test_local_lambda.py @@ -7,6 +7,7 @@ from unittest import TestCase from unittest.mock import Mock, patch from parameterized import parameterized, param +import click from samcli.lib.utils.architecture import X86_64, ARM64 @@ -14,7 +15,7 @@ from samcli.lib.providers.provider import Function, FunctionBuildInfo from samcli.lib.utils.colors import Colored from samcli.lib.utils.packagetype import ZIP, IMAGE -from samcli.local.docker.container import ContainerResponseException +from samcli.local.docker.container import ContainerResponseException, ContainerConnectionTimeoutException from samcli.local.lambdafn.exceptions import FunctionNotFound from samcli.commands.local.lib.exceptions import ( OverridesNotWellDefinedError, @@ -642,11 +643,12 @@ def test_must_work(self, patched_validate_architecture_runtime): self.local_lambda.get_invoke_config = Mock() self.local_lambda.get_invoke_config.return_value = invoke_config - self.local_lambda.invoke(name, event, stdout, stderr) + self.local_lambda.invoke(name, event, tenant_id=None, stdout=stdout, stderr=stderr) self.runtime_mock.invoke.assert_called_with( invoke_config, event, + None, debug_context=None, stdout=stdout, stderr=stderr, @@ -673,7 +675,7 @@ def test_displays_rust_disclaimer(self, patched_validate_architecture_runtime, p self.local_lambda.get_invoke_config = Mock() self.local_lambda.get_invoke_config.return_value = invoke_config - self.local_lambda.invoke(name, event, stdout, stderr) + self.local_lambda.invoke(name, event, tenant_id=None, stdout=stdout, stderr=stderr) patched_echo.assert_called_once_with(Colored().yellow(RUST_LOCAL_INVOKE_DISCLAIMER)) @@ -690,11 +692,12 @@ def test_must_work_packagetype_ZIP(self, patched_validate_architecture_runtime): self.local_lambda.get_invoke_config = Mock() self.local_lambda.get_invoke_config.return_value = invoke_config - self.local_lambda.invoke(name, event, stdout, stderr) + self.local_lambda.invoke(name, event, tenant_id=None, stdout=stdout, stderr=stderr) self.runtime_mock.invoke.assert_called_with( invoke_config, event, + None, debug_context=None, stdout=stdout, stderr=stderr, @@ -763,6 +766,21 @@ def test_must_not_raise_if_invoked_container_has_no_response(self, patched_valid # No exception raised back self.local_lambda.invoke("name", "event") + @patch("samcli.commands.local.lib.local_lambda.validate_architecture_runtime") + def test_must_not_raise_if_container_connection_timeout(self, patched_validate_architecture_runtime): + """Test that ContainerConnectionTimeoutException is handled gracefully""" + function = Mock() + function.name = "name" + function.functionname = "FunctionLogicalId" + invoke_config = "invoke_config" + + self.function_provider_mock.get.return_value = function + self.local_lambda.get_invoke_config = Mock() + self.local_lambda.get_invoke_config.return_value = invoke_config + self.runtime_mock.invoke = Mock(side_effect=ContainerConnectionTimeoutException("Connection timeout")) + # No exception raised back + self.local_lambda.invoke("name", "event") + @patch("samcli.commands.local.lib.local_lambda.validate_architecture_runtime") def test_works_if_imageuri_and_Image_packagetype(self, patched_validate_architecture_runtime): name = "name" @@ -775,10 +793,11 @@ def test_works_if_imageuri_and_Image_packagetype(self, patched_validate_architec self.function_provider_mock.get.return_value = function self.local_lambda.get_invoke_config = Mock() self.local_lambda.get_invoke_config.return_value = invoke_config - self.local_lambda.invoke(name, event, stdout, stderr) + self.local_lambda.invoke(name, event, tenant_id=None, stdout=stdout, stderr=stderr) self.runtime_mock.invoke.assert_called_with( invoke_config, event, + None, debug_context=None, stdout=stdout, stderr=stderr, @@ -797,7 +816,7 @@ def test_must_raise_if_imageuri_not_found(self): self.function_provider_mock.get.return_value = function with self.assertRaises(InvalidIntermediateImageError): - self.local_lambda.invoke(name, event, stdout, stderr) + self.local_lambda.invoke(name, event, tenant_id=None, stdout=stdout, stderr=stderr) def test_must_raise_unsupported_error_if_inlinecode_found(self): name = "name" @@ -816,7 +835,7 @@ def test_must_raise_unsupported_error_if_inlinecode_found(self): self.function_provider_mock.get.return_value = function with self.assertRaises(UnsupportedInlineCodeError): - self.local_lambda.invoke(name, event, stdout, stderr) + self.local_lambda.invoke(name, event, tenant_id=None, stdout=stdout, stderr=stderr) @patch("samcli.commands.local.lib.local_lambda.validate_architecture_runtime") def test_must_work_with_nested_stack_name(self, patched_validate_architecture_runtime): @@ -831,11 +850,12 @@ def test_must_work_with_nested_stack_name(self, patched_validate_architecture_ru self.local_lambda.get_invoke_config = Mock() self.local_lambda.get_invoke_config.return_value = invoke_config - self.local_lambda.invoke(name, event, stdout, stderr) + self.local_lambda.invoke(name, event, tenant_id=None, stdout=stdout, stderr=stderr) self.runtime_mock.invoke.assert_called_with( invoke_config, event, + None, debug_context=None, stdout=stdout, stderr=stderr, @@ -882,11 +902,12 @@ def test_must_work(self, patched_validate_architecture_runtime): self.local_lambda.get_invoke_config = Mock() self.local_lambda.get_invoke_config.return_value = invoke_config - self.local_lambda.invoke(name, event, stdout, stderr) + self.local_lambda.invoke(name, event, tenant_id=None, stdout=stdout, stderr=stderr) self.runtime_mock.invoke.assert_called_with( invoke_config, event, + None, debug_context=None, stdout=stdout, stderr=stderr, @@ -930,3 +951,131 @@ def test_must_be_off(self): ) self.assertFalse(self.local_lambda.is_debugging()) + + +class TestLocalLambda_tenant_id_validation(TestCase): + def setUp(self): + self.function_provider_mock = Mock() + self.local_lambda = LocalLambdaRunner( + local_runtime=Mock(), + function_provider=self.function_provider_mock, + cwd="cwd", + debug_context=Mock(), + real_path="real_path", + ) + + @patch("samcli.commands.local.lib.local_lambda.validate_architecture_runtime") + def test_multi_tenant_function_without_tenant_id_raises_error(self, mock_validate_arch): + """Test that multi-tenant function without tenant-id raises ClickException""" + function = Function( + function_id="id", + name="name", + functionname="functionname", + runtime="python3.9", + memory=128, + timeout=30, + handler="app.handler", + codeuri=".", + environment=None, + rolearn=None, + layers=[], + events=None, + metadata=None, + inlinecode=None, + imageuri=None, + imageconfig=None, + packagetype=ZIP, + codesign_config_arn=None, + architectures=None, + function_url_config=None, + runtime_management_config=None, + function_build_info=FunctionBuildInfo.BuildableZip, + stack_path="", + logging_config=None, + tenancy_config={"TenantIsolationMode": "PER_TENANT"}, + ) + + self.function_provider_mock.get.return_value = function + + with self.assertRaises(click.ClickException) as context: + self.local_lambda.invoke("functionname", "{}") + + self.assertIn("Add a valid tenant ID", str(context.exception)) + + @patch("samcli.commands.local.lib.local_lambda.validate_architecture_runtime") + def test_non_multi_tenant_function_with_tenant_id_raises_error(self, mock_validate_arch): + """Test that non-multi-tenant function with tenant-id raises ClickException""" + function = Function( + function_id="id", + name="name", + functionname="functionname", + runtime="python3.9", + memory=128, + timeout=30, + handler="app.handler", + codeuri=".", + environment=None, + rolearn=None, + layers=[], + events=None, + metadata=None, + inlinecode=None, + imageuri=None, + imageconfig=None, + packagetype=ZIP, + codesign_config_arn=None, + architectures=None, + function_url_config=None, + runtime_management_config=None, + function_build_info=FunctionBuildInfo.BuildableZip, + stack_path="", + logging_config=None, + tenancy_config=None, + ) + + self.function_provider_mock.get.return_value = function + + with self.assertRaises(click.ClickException) as context: + self.local_lambda.invoke("functionname", "{}", tenant_id="customer-123") + + self.assertIn("Remove the tenant ID", str(context.exception)) + + @patch("samcli.commands.local.lib.local_lambda.validate_architecture_runtime") + def test_multi_tenant_function_with_tenant_id_succeeds(self, mock_validate_arch): + """Test that multi-tenant function with tenant-id proceeds without error""" + function = Function( + function_id="id", + name="name", + functionname="functionname", + runtime="python3.9", + memory=128, + timeout=30, + handler="app.handler", + codeuri=".", + environment=None, + rolearn=None, + layers=[], + events=None, + metadata=None, + inlinecode=None, + imageuri=None, + imageconfig=None, + packagetype=ZIP, + codesign_config_arn=None, + architectures=None, + function_url_config=None, + runtime_management_config=None, + function_build_info=FunctionBuildInfo.BuildableZip, + stack_path="", + logging_config=None, + tenancy_config={"TenantIsolationMode": "PER_TENANT"}, + ) + + self.function_provider_mock.get.return_value = function + self.local_lambda.get_invoke_config = Mock(return_value="config") + + # This should not raise an exception + try: + self.local_lambda.invoke("functionname", "{}", tenant_id="customer-123") + except click.ClickException: + self.fail("invoke() raised ClickException unexpectedly") diff --git a/tests/unit/commands/remote/invoke/test_cli.py b/tests/unit/commands/remote/invoke/test_cli.py index 36d7dbabd17..0f4e32154f5 100644 --- a/tests/unit/commands/remote/invoke/test_cli.py +++ b/tests/unit/commands/remote/invoke/test_cli.py @@ -95,6 +95,7 @@ def mock_tracker(name, value): # when track_event is called, append an equivale parameter=parameter, output=output, test_event_name=None, + tenant_id=None, region=self.region, profile=self.profile, config_file=self.config_file, @@ -112,7 +113,7 @@ def mock_tracker(name, value): # when track_event is called, append an equivale ) patched_remote_invoke_execution_info.assert_called_with( - payload=event, payload_file=event_file, parameters=parameter, output_format=output + payload=event, payload_file=event_file, tenant_id=None, parameters=parameter, output_format=output ) context_mock.run.assert_called_with(remote_invoke_input=given_remote_invoke_execution_info) @@ -174,6 +175,7 @@ def mock_tracker(name, value): # when track_event is called, append an equivale parameter={}, output=RemoteInvokeOutputFormat.TEXT, test_event_name="event1", + tenant_id=None, region=self.region, profile=self.profile, config_file=self.config_file, @@ -184,6 +186,7 @@ def mock_tracker(name, value): # when track_event is called, append an equivale patched_remote_invoke_execution_info.assert_called_with( payload="stuff", payload_file=None, + tenant_id=None, parameters={}, output_format=RemoteInvokeOutputFormat.TEXT, ) @@ -242,6 +245,7 @@ def mock_tracker(name, value): # when track_event is called, append an equivale parameter={}, output=RemoteInvokeOutputFormat.TEXT, test_event_name="event1", + tenant_id=None, region=self.region, profile=self.profile, config_file=self.config_file, @@ -252,6 +256,7 @@ def mock_tracker(name, value): # when track_event is called, append an equivale patched_remote_invoke_execution_info.assert_called_with( payload="stuff", payload_file=None, + tenant_id=None, parameters={"InvocationType": "Event"}, output_format=RemoteInvokeOutputFormat.TEXT, ) @@ -310,6 +315,7 @@ def mock_tracker(name, value): # when track_event is called, append an equivale parameter={"InvocationType": "RequestResponse"}, output=RemoteInvokeOutputFormat.TEXT, test_event_name="event1", + tenant_id=None, region=self.region, profile=self.profile, config_file=self.config_file, @@ -320,6 +326,7 @@ def mock_tracker(name, value): # when track_event is called, append an equivale patched_remote_invoke_execution_info.assert_called_with( payload="stuff", payload_file=None, + tenant_id=None, parameters={"InvocationType": "RequestResponse"}, output_format=RemoteInvokeOutputFormat.TEXT, ) @@ -375,6 +382,7 @@ def mock_tracker(name, value): # when track_event is called, append an equivale parameter={}, output=RemoteInvokeOutputFormat.TEXT, test_event_name="event1", + tenant_id=None, region=self.region, profile=self.profile, config_file=self.config_file, @@ -384,6 +392,7 @@ def mock_tracker(name, value): # when track_event is called, append an equivale patched_remote_invoke_execution_info.assert_called_with( payload="Hello world", payload_file=None, + tenant_id=None, parameters={}, output_format=RemoteInvokeOutputFormat.TEXT, ) @@ -416,6 +425,7 @@ def test_raise_user_exception_invoke_not_successfull(self, exeception_to_raise, parameter={}, output=RemoteInvokeOutputFormat.TEXT, test_event_name=None, + tenant_id=None, region=self.region, profile=self.profile, config_file=self.config_file, diff --git a/tests/unit/commands/samconfig/test_samconfig.py b/tests/unit/commands/samconfig/test_samconfig.py index 1a7b929c5d1..8717f23a189 100644 --- a/tests/unit/commands/samconfig/test_samconfig.py +++ b/tests/unit/commands/samconfig/test_samconfig.py @@ -585,6 +585,7 @@ def test_local_invoke(self, do_cli_mock): None, True, True, + None, ) @patch("samcli.commands.local.invoke.cli.do_cli") @@ -653,6 +654,7 @@ def test_local_invoke_with_runtime_params(self, do_cli_mock): "python3.11", True, True, + None, ) @patch("samcli.commands.local.start_api.cli.do_cli") diff --git a/tests/unit/lib/remote_invoke/test_lambda_invoke_executors.py b/tests/unit/lib/remote_invoke/test_lambda_invoke_executors.py index e1a5093a2fb..4a6b6c86db8 100644 --- a/tests/unit/lib/remote_invoke/test_lambda_invoke_executors.py +++ b/tests/unit/lib/remote_invoke/test_lambda_invoke_executors.py @@ -111,6 +111,58 @@ def test_execute_action(self): FunctionName=self.function_name, Payload=given_payload, InvocationType="RequestResponse", LogType="Tail" ) + def test_execute_with_tenant_id(self): + """Test that tenant_id is added to request parameters when provided""" + given_payload = "test_payload" + given_tenant_id = "customer-123" + given_result = Mock() + self.lambda_client.invoke.return_value = given_result + + # Create RemoteInvokeExecutionInfo with tenant_id + remote_invoke_input = RemoteInvokeExecutionInfo( + payload=given_payload, + payload_file=None, + tenant_id=given_tenant_id, + parameters={}, + output_format=RemoteInvokeOutputFormat.JSON, + ) + + result = list(self.lambda_invoke_executor.execute(remote_invoke_input)) + + self.assertEqual(result, [RemoteInvokeResponse(given_result)]) + self.lambda_client.invoke.assert_called_with( + FunctionName=self.function_name, + Payload=given_payload, + TenantId=given_tenant_id, + InvocationType="RequestResponse", + LogType="Tail", + ) + + def test_execute_without_tenant_id(self): + """Test that TenantId is not added when tenant_id is None""" + given_payload = "test_payload" + given_result = Mock() + self.lambda_client.invoke.return_value = given_result + + # Create RemoteInvokeExecutionInfo without tenant_id + remote_invoke_input = RemoteInvokeExecutionInfo( + payload=given_payload, + payload_file=None, + tenant_id=None, + parameters={}, + output_format=RemoteInvokeOutputFormat.JSON, + ) + + result = list(self.lambda_invoke_executor.execute(remote_invoke_input)) + + self.assertEqual(result, [RemoteInvokeResponse(given_result)]) + self.lambda_client.invoke.assert_called_with( + FunctionName=self.function_name, + Payload=given_payload, + InvocationType="RequestResponse", + LogType="Tail", + ) + def _get_boto3_method(self): return self.lambda_client.invoke @@ -152,13 +204,13 @@ def setUp(self) -> None: ] ) def test_conversion(self, given_string, expected_string): - remote_invoke_execution_info = RemoteInvokeExecutionInfo(given_string, None, {}, self.output_format) + remote_invoke_execution_info = RemoteInvokeExecutionInfo(given_string, None, None, {}, self.output_format) result = self.lambda_convert_to_default_json.map(remote_invoke_execution_info) self.assertEqual(result.payload, expected_string) def test_skip_conversion_if_file_provided(self): given_payload_path = "foo/bar/event.json" - remote_invoke_execution_info = RemoteInvokeExecutionInfo(None, given_payload_path, {}, self.output_format) + remote_invoke_execution_info = RemoteInvokeExecutionInfo(None, given_payload_path, None, {}, self.output_format) self.assertTrue(remote_invoke_execution_info.is_file_provided()) result = self.lambda_convert_to_default_json.map(remote_invoke_execution_info) @@ -176,7 +228,7 @@ def test_lambda_streaming_body_response_conversion(self): given_decoded_string = "decoded string" given_streaming_body.read().decode.return_value = given_decoded_string given_test_result = {"Payload": given_streaming_body} - remote_invoke_execution_info = RemoteInvokeExecutionInfo(None, None, {}, output_format) + remote_invoke_execution_info = RemoteInvokeExecutionInfo(None, None, None, {}, output_format) remote_invoke_execution_info.response = given_test_result expected_result = {"Payload": given_decoded_string} @@ -191,7 +243,7 @@ def test_lambda_streaming_body_invalid_response_exception(self): given_decoded_string = "decoded string" given_streaming_body.read().decode.return_value = given_decoded_string given_test_result = [given_streaming_body] - remote_invoke_execution_info = RemoteInvokeExecutionInfo(None, None, {}, output_format) + remote_invoke_execution_info = RemoteInvokeExecutionInfo(None, None, None, {}, output_format) remote_invoke_execution_info.response = given_test_result with self.assertRaises(InvalideBotoResponseException): @@ -231,7 +283,7 @@ def test_lambda_streaming_body_response_conversion(self, invoke_complete_respons def test_lambda_streaming_body_invalid_response_exception(self): output_format = RemoteInvokeOutputFormat.TEXT - remote_invoke_execution_info = RemoteInvokeExecutionInfo(None, None, {}, output_format) + remote_invoke_execution_info = RemoteInvokeExecutionInfo(None, None, None, {}, output_format) remote_invoke_execution_info.response = Mock() with self.assertRaises(InvalideBotoResponseException): diff --git a/tests/unit/lib/remote_invoke/test_remote_invoke_executors.py b/tests/unit/lib/remote_invoke/test_remote_invoke_executors.py index 19589fc2b95..4be78b83912 100644 --- a/tests/unit/lib/remote_invoke/test_remote_invoke_executors.py +++ b/tests/unit/lib/remote_invoke/test_remote_invoke_executors.py @@ -23,7 +23,7 @@ def test_execution_info_payload(self): given_payload = Mock() given_parameters = {"ExampleParameter": "ExampleValue"} - test_execution_info = RemoteInvokeExecutionInfo(given_payload, None, given_parameters, self.output_format) + test_execution_info = RemoteInvokeExecutionInfo(given_payload, None, None, given_parameters, self.output_format) self.assertEqual(given_payload, test_execution_info.payload) self.assertEqual(given_parameters, test_execution_info.parameters) @@ -33,7 +33,7 @@ def test_execution_info_payload(self): def test_execution_info_payload_file(self): given_payload_file = Mock() - test_execution_info = RemoteInvokeExecutionInfo(None, given_payload_file, {}, self.output_format) + test_execution_info = RemoteInvokeExecutionInfo(None, given_payload_file, None, {}, self.output_format) self.assertIsNone(test_execution_info.payload) self.assertTrue(test_execution_info.is_file_provided()) @@ -45,7 +45,7 @@ def test_execution_info_payload_file(self): def test_execution_success(self): given_response = Mock() - test_execution_info = RemoteInvokeExecutionInfo(None, None, {}, self.output_format) + test_execution_info = RemoteInvokeExecutionInfo(None, None, None, {}, self.output_format) test_execution_info.response = given_response self.assertTrue(test_execution_info.is_succeeded()) @@ -54,7 +54,7 @@ def test_execution_success(self): def test_execution_failed(self): given_exception = Mock() - test_execution_info = RemoteInvokeExecutionInfo(None, None, {}, self.output_format) + test_execution_info = RemoteInvokeExecutionInfo(None, None, None, {}, self.output_format) test_execution_info.exception = given_exception self.assertFalse(test_execution_info.is_succeeded()) @@ -77,7 +77,9 @@ def test_execute_with_payload(self): given_payload = Mock() given_parameters = {"ExampleParameter": "ExampleValue"} given_output_format = "text" - test_execution_info = RemoteInvokeExecutionInfo(given_payload, None, given_parameters, given_output_format) + test_execution_info = RemoteInvokeExecutionInfo( + given_payload, None, None, given_parameters, given_output_format + ) with patch.object(self.boto_action_executor, "_execute_action") as patched_execute_action, patch.object( self.boto_action_executor, "_execute_action_file" @@ -94,7 +96,9 @@ def test_execute_with_payload_file(self): given_payload_file = Mock() given_parameters = {"ExampleParameter": "ExampleValue"} given_output_format = "json" - test_execution_info = RemoteInvokeExecutionInfo(None, given_payload_file, given_parameters, given_output_format) + test_execution_info = RemoteInvokeExecutionInfo( + None, given_payload_file, None, given_parameters, given_output_format + ) with patch.object(self.boto_action_executor, "_execute_action") as patched_execute_action, patch.object( self.boto_action_executor, "_execute_action_file" @@ -111,7 +115,9 @@ def test_execute_error(self): given_payload = Mock() given_parameters = {"ExampleParameter": "ExampleValue"} given_output_format = "json" - test_execution_info = RemoteInvokeExecutionInfo(given_payload, None, given_parameters, given_output_format) + test_execution_info = RemoteInvokeExecutionInfo( + given_payload, None, None, given_parameters, given_output_format + ) with patch.object(self.boto_action_executor, "_execute_action") as patched_execute_action: given_exception = ValueError() @@ -144,7 +150,9 @@ def test_execution(self): given_payload = Mock() given_parameters = {"ExampleParameter": "ExampleValue"} given_output_format = RemoteInvokeOutputFormat.JSON - test_execution_info = RemoteInvokeExecutionInfo(given_payload, None, given_parameters, given_output_format) + test_execution_info = RemoteInvokeExecutionInfo( + given_payload, None, None, given_parameters, given_output_format + ) validate_action_parameters_function = Mock() self.mock_boto_action_executor.validate_action_parameters = validate_action_parameters_function self.mock_boto_action_executor.execute.return_value = [RemoteInvokeResponse(Mock())] @@ -163,12 +171,14 @@ def test_execution_failure(self): given_payload = Mock() given_parameters = {"ExampleParameter": "ExampleValue"} given_output_format = RemoteInvokeOutputFormat.JSON - test_execution_info = RemoteInvokeExecutionInfo(given_payload, None, given_parameters, given_output_format) + test_execution_info = RemoteInvokeExecutionInfo( + given_payload, None, None, given_parameters, given_output_format + ) validate_action_parameters_function = Mock() self.mock_boto_action_executor.validate_action_parameters = validate_action_parameters_function given_result_execution_info = RemoteInvokeExecutionInfo( - given_payload, None, given_parameters, given_output_format + given_payload, None, None, given_parameters, given_output_format ) given_result_execution_info.exception = Mock() self.mock_boto_action_executor.execute.return_value = [given_result_execution_info] @@ -188,7 +198,7 @@ class TestResponseObjectToJsonStringMapper(TestCase): def test_mapper(self): output_format = RemoteInvokeOutputFormat.TEXT given_object = [{"key": "value", "key2": 123}] - test_execution_info = RemoteInvokeExecutionInfo(None, None, {}, output_format) + test_execution_info = RemoteInvokeExecutionInfo(None, None, None, {}, output_format) test_execution_info.response = given_object mapper = ResponseObjectToJsonStringMapper() diff --git a/tests/unit/lib/remote_invoke/test_stepfunctions_invoke_executors.py b/tests/unit/lib/remote_invoke/test_stepfunctions_invoke_executors.py index adac0f00e8e..8c6b50f0236 100644 --- a/tests/unit/lib/remote_invoke/test_stepfunctions_invoke_executors.py +++ b/tests/unit/lib/remote_invoke/test_stepfunctions_invoke_executors.py @@ -139,7 +139,7 @@ def test_stepfunctions_response_conversion(self): "startDate": execution_date, "stopDate": execution_date, } - remote_invoke_execution_info = RemoteInvokeExecutionInfo(None, None, {}, output_format) + remote_invoke_execution_info = RemoteInvokeExecutionInfo(None, None, None, {}, output_format) remote_invoke_execution_info.response = given_execution_result expected_result = { @@ -158,7 +158,7 @@ def test_stepfunctions_invalid_response_exception(self): given_output_string = "output string" given_output_response.read().decode.return_value = given_output_string given_test_result = [given_output_response] - remote_invoke_execution_info = RemoteInvokeExecutionInfo(None, None, {}, output_format) + remote_invoke_execution_info = RemoteInvokeExecutionInfo(None, None, None, {}, output_format) remote_invoke_execution_info.response = given_test_result with self.assertRaises(InvalideBotoResponseException): diff --git a/tests/unit/local/apigw/test_local_apigw_service.py b/tests/unit/local/apigw/test_local_apigw_service.py index 6cde64c0633..c09a06e5585 100644 --- a/tests/unit/local/apigw/test_local_apigw_service.py +++ b/tests/unit/local/apigw/test_local_apigw_service.py @@ -118,7 +118,7 @@ def test_api_request_must_invoke_lambda(self, v2_event_mock, v1_event_mock, requ result = self.api_service._request_handler() self.assertEqual(result, make_response_mock) - self.lambda_runner.invoke.assert_called_with(ANY, ANY, stdout=ANY, stderr=self.stderr) + self.lambda_runner.invoke.assert_called_with(ANY, ANY, stdout=ANY, stderr=self.stderr, tenant_id=None) v1_event_mock.assert_called_with( flask_request=ANY, port=ANY, @@ -155,7 +155,7 @@ def test_http_request_must_invoke_lambda(self, v2_event_mock, v1_event_mock, req result = self.http_service._request_handler() self.assertEqual(result, make_response_mock) - self.lambda_runner.invoke.assert_called_with(ANY, ANY, stdout=ANY, stderr=self.stderr) + self.lambda_runner.invoke.assert_called_with(ANY, ANY, stdout=ANY, stderr=self.stderr, tenant_id=None) v2_event_mock.assert_called_with( flask_request=ANY, port=ANY, binary_types=ANY, stage_name=ANY, stage_variables=ANY, route_key="test test" ) @@ -186,7 +186,7 @@ def test_http_v1_payload_request_must_invoke_lambda(self, v2_event_mock, v1_even result = self.http_service._request_handler() self.assertEqual(result, make_response_mock) - self.lambda_runner.invoke.assert_called_with(ANY, ANY, stdout=ANY, stderr=self.stderr) + self.lambda_runner.invoke.assert_called_with(ANY, ANY, stdout=ANY, stderr=self.stderr, tenant_id=None) v1_event_mock.assert_called_with( flask_request=ANY, port=ANY, @@ -223,7 +223,7 @@ def test_http_v2_payload_request_must_invoke_lambda(self, v2_event_mock, v1_even result = self.http_service._request_handler() self.assertEqual(result, make_response_mock) - self.lambda_runner.invoke.assert_called_with(ANY, ANY, stdout=ANY, stderr=self.stderr) + self.lambda_runner.invoke.assert_called_with(ANY, ANY, stdout=ANY, stderr=self.stderr, tenant_id=None) v2_event_mock.assert_called_with( flask_request=ANY, port=ANY, binary_types=ANY, stage_name=ANY, stage_variables=ANY, route_key="test test" ) @@ -253,7 +253,7 @@ def test_api_options_request_must_invoke_lambda(self, generate_mock, request_moc result = self.api_service._request_handler() self.assertEqual(result, make_response_mock) - self.lambda_runner.invoke.assert_called_with(ANY, ANY, stdout=ANY, stderr=self.stderr) + self.lambda_runner.invoke.assert_called_with(ANY, ANY, stdout=ANY, stderr=self.stderr, tenant_id=None) @patch.object(LocalApigwService, "get_request_methods_endpoints") @patch("samcli.local.apigw.local_apigw_service.LocalApigwService._generate_lambda_event") @@ -280,7 +280,7 @@ def test_http_options_request_must_invoke_lambda(self, generate_mock, request_mo result = self.http_service._request_handler() self.assertEqual(result, make_response_mock) - self.lambda_runner.invoke.assert_called_with(ANY, ANY, stdout=ANY, stderr=self.stderr) + self.lambda_runner.invoke.assert_called_with(ANY, ANY, stdout=ANY, stderr=self.stderr, tenant_id=None) @patch.object(LocalApigwService, "get_request_methods_endpoints") @patch("samcli.local.apigw.local_apigw_service.LambdaOutputParser") @@ -1039,7 +1039,7 @@ def test_authorizer_function_not_found_invokes_endpoint( # validate route lambda still invoked after Lambda auth function not found self.assertEqual(result, make_response_mock) self.lambda_runner.invoke.assert_called_with( - self.api_gateway_route.function_name, ANY, stdout=ANY, stderr=self.stderr + self.api_gateway_route.function_name, ANY, stdout=ANY, stderr=self.stderr, tenant_id=None ) @patch.object(LocalApigwService, "get_request_methods_endpoints") @@ -1083,6 +1083,60 @@ def test_lambda_invoke_fails_no_function_name_exception( "(Unable to get Lambda function because the function identifier is not defined.)" ) + @patch("samcli.local.apigw.local_apigw_service.request") + @patch.object(LocalApigwService, "get_request_methods_endpoints") + @patch("samcli.local.apigw.local_apigw_service.construct_v2_event_http") + @patch.object(LocalApigwService, "_invoke_lambda_function") + def test_request_handler_extracts_tenant_id_from_header_http_api( + self, invoke_mock, v2_event_mock, request_mock, flask_request_mock + ): + """Test that tenant-id is extracted from X-Amz-Tenant-Id header for HTTP API""" + tenant_id = "customer-123" + + # Configure the patched Flask request to return tenant_id from header + flask_request_mock.headers.get = Mock(side_effect=lambda key: tenant_id if key == "X-Amz-Tenant-Id" else None) + + self.http_service._get_current_route = Mock(return_value=self.http_gateway_route) + v2_event_mock.return_value = {} + invoke_mock.return_value = "response" + request_mock.return_value = ("GET", "/test") + + self.http_service._parse_v2_payload_format_lambda_output = Mock(return_value=("200", {}, "body")) + self.http_service.service_response = Mock(return_value="final_response") + + self.http_service._request_handler() + + invoke_mock.assert_called_once() + call_args = invoke_mock.call_args + self.assertEqual(call_args[0][2], tenant_id) + + @patch("samcli.local.apigw.local_apigw_service.request") + @patch.object(LocalApigwService, "get_request_methods_endpoints") + @patch("samcli.local.apigw.local_apigw_service.construct_v1_event") + @patch.object(LocalApigwService, "_invoke_lambda_function") + def test_request_handler_extracts_tenant_id_from_header_rest_api( + self, invoke_mock, v1_event_mock, request_mock, flask_request_mock + ): + """Test that tenant-id is extracted from X-Amz-Tenant-Id header for REST API""" + tenant_id = "customer-456" + + # Configure the patched Flask request to return tenant_id from header + flask_request_mock.headers.get = Mock(side_effect=lambda key: tenant_id if key == "X-Amz-Tenant-Id" else None) + + self.api_service._get_current_route = Mock(return_value=self.api_gateway_route) + v1_event_mock.return_value = {} + invoke_mock.return_value = "response" + request_mock.return_value = ("GET", "/test") + + self.api_service._parse_v1_payload_format_lambda_output = Mock(return_value=("200", {}, "body")) + self.api_service.service_response = Mock(return_value="final_response") + + self.api_service._request_handler() + + invoke_mock.assert_called_once() + call_args = invoke_mock.call_args + self.assertEqual(call_args[0][2], tenant_id) + class TestApiGatewayModel(TestCase): def setUp(self): diff --git a/tests/unit/local/docker/test_container.py b/tests/unit/local/docker/test_container.py index 8a31996386f..8e53490efcf 100644 --- a/tests/unit/local/docker/test_container.py +++ b/tests/unit/local/docker/test_container.py @@ -888,6 +888,7 @@ def test_wait_for_result_no_error_image_response(self, mock_requests, patched_so mock_requests.post.assert_called_with( self.container.URL.format(host=host, port=port, function_name="function"), data=b"{}", + headers={}, timeout=(self.container.RAPID_CONNECTION_TIMEOUT, None), ) stdout_mock.write_bytes.assert_called_with(rie_response) @@ -949,6 +950,7 @@ def test_wait_for_result_no_error( mock_requests.post.assert_called_with( self.container.URL.format(host=host, port=port, function_name="function"), data=b"{}", + headers={}, timeout=(self.container.RAPID_CONNECTION_TIMEOUT, None), ) if response_deserializable: @@ -989,16 +991,19 @@ def test_wait_for_result_error_retried(self, patched_sleep, mock_requests, patch call( "http://localhost:7077/2015-03-31/functions/function/invocations", data=b"{}", + headers={}, timeout=(self.timeout, None), ), call( "http://localhost:7077/2015-03-31/functions/function/invocations", data=b"{}", + headers={}, timeout=(self.timeout, None), ), call( "http://localhost:7077/2015-03-31/functions/function/invocations", data=b"{}", + headers={}, timeout=(self.timeout, None), ), ], @@ -1059,6 +1064,54 @@ def test_wait_for_result_waits_for_socket_before_post_request(self, patched_time self.assertEqual(mock_requests.post.call_count, 0) + @parameterized.expand( + [ + (True, b'{"result": "success"}', {"Content-Type": "application/json"}), + ] + ) + @patch("socket.socket") + @patch("samcli.local.docker.container.requests") + def test_wait_for_result_with_tenant_id( + self, response_deserializable, rie_response, resp_headers, mock_requests, patched_socket + ): + """Test that tenant_id is passed as header when provided""" + self.container.is_created.return_value = True + + real_container_mock = Mock() + self.mock_docker_client.containers.get.return_value = real_container_mock + + output_itr = Mock() + real_container_mock.attach.return_value = output_itr + self.container._write_container_output = Mock() + self.container._create_threading_event = Mock() + self.container._create_threading_event.return_value = Mock() + + stdout_mock = Mock() + stdout_mock.write_str = Mock() + stderr_mock = Mock() + response = Mock() + response.content = rie_response + response.headers = resp_headers + mock_requests.post.return_value = response + + patched_socket.return_value = self.socket_mock + + tenant_id = "test-tenant-123" + + self.container.wait_for_result( + event=self.event, + full_path=self.name, + stdout=stdout_mock, + stderr=stderr_mock, + tenant_id=tenant_id, + ) + + # Verify that the POST request was called with tenant_id header + mock_requests.post.assert_called_once() + call_args = mock_requests.post.call_args + self.assertIn("headers", call_args.kwargs) + self.assertEqual(call_args.kwargs["headers"]["X-Amz-Tenant-Id"], tenant_id) + def test_write_container_output_successful(self): stdout_mock = Mock(spec=StreamWriter) stderr_mock = Mock(spec=StreamWriter) 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 3f4f22a2612..4bca1620d43 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 @@ -68,7 +68,7 @@ def test_invoke_request_handler(self, lambda_output_parser_mock, service_respons self.assertEqual(response, "request response") - lambda_runner_mock.invoke.assert_called_once_with("HelloWorld", "{}", stdout=ANY, stderr=None) + lambda_runner_mock.invoke.assert_called_once_with("HelloWorld", "{}", stdout=ANY, stderr=None, tenant_id=ANY) service_response_mock.assert_called_once_with("hello world", {"Content-Type": "application/json"}, 200) @patch("samcli.local.lambda_service.local_lambda_invoke_service.LambdaErrorResponses") @@ -88,7 +88,7 @@ def test_invoke_request_handler_on_incorrect_path(self, lambda_error_responses_m self.assertEqual(response, "Couldn't find Lambda") - lambda_runner_mock.invoke.assert_called_once_with("NotFound", "{}", stdout=ANY, stderr=None) + lambda_runner_mock.invoke.assert_called_once_with("NotFound", "{}", stdout=ANY, stderr=None, tenant_id=ANY) lambda_error_responses_mock.resource_not_found.assert_called_once_with("NotFound") @@ -109,7 +109,9 @@ def test_invoke_request_function_contains_inline_code(self, lambda_error_respons self.assertEqual(response, "Inline code is not supported") - lambda_runner_mock.invoke.assert_called_once_with("FunctionWithInlineCode", "{}", stdout=ANY, stderr=None) + lambda_runner_mock.invoke.assert_called_once_with( + "FunctionWithInlineCode", "{}", stdout=ANY, stderr=None, tenant_id=ANY + ) lambda_error_responses_mock.not_implemented_locally.assert_called() @@ -131,7 +133,7 @@ def test_invoke_request_container_creation_failed(self, lambda_error_responses_m self.assertEqual(response, "Container creation failed") lambda_runner_mock.invoke.assert_called_once_with( - "FunctionContainerCreationFailed", "{}", stdout=ANY, stderr=None + "FunctionContainerCreationFailed", "{}", stdout=ANY, stderr=None, tenant_id=ANY ) lambda_error_responses_mock.container_creation_failed.assert_called() @@ -194,7 +196,7 @@ def test_invoke_request_handler_with_lambda_that_errors(self, lambda_output_pars self.assertEqual(response, "request response") - lambda_runner_mock.invoke.assert_called_once_with("HelloWorld", "{}", stdout=ANY, stderr=None) + lambda_runner_mock.invoke.assert_called_once_with("HelloWorld", "{}", stdout=ANY, stderr=None, tenant_id=ANY) service_response_mock.assert_called_once_with( "hello world", {"Content-Type": "application/json", "x-amz-function-error": "Unhandled"}, 200 ) @@ -216,7 +218,35 @@ def test_invoke_request_handler_with_no_data(self, lambda_output_parser_mock, se self.assertEqual(response, "request response") - lambda_runner_mock.invoke.assert_called_once_with("HelloWorld", "{}", stdout=ANY, stderr=None) + lambda_runner_mock.invoke.assert_called_once_with("HelloWorld", "{}", stdout=ANY, stderr=None, tenant_id=ANY) + service_response_mock.assert_called_once_with("hello world", {"Content-Type": "application/json"}, 200) + + @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_extracts_tenant_id_from_header( + self, lambda_output_parser_mock, service_response_mock + ): + """Test that tenant-id is extracted from X-Amz-Tenant-Id header and passed to invoke""" + tenant_id = "customer-789" + 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"{}" + request_mock.headers.get = Mock(side_effect=lambda key: tenant_id if key == "X-Amz-Tenant-Id" else None) + local_lambda_invoke_service.request = request_mock + + lambda_runner_mock = Mock() + service = LocalLambdaInvokeService(lambda_runner=lambda_runner_mock, port=3000, host="localhost") + + response = service._invoke_request_handler(function_name="HelloWorld") + + self.assertEqual(response, "request response") + + # Verify tenant_id was passed to lambda_runner.invoke + lambda_runner_mock.invoke.assert_called_once_with( + "HelloWorld", "{}", stdout=ANY, stderr=None, tenant_id=tenant_id + ) service_response_mock.assert_called_once_with("hello world", {"Content-Type": "application/json"}, 200) @@ -355,7 +385,7 @@ def test_invoke_request_handler_with_arn(self, lambda_output_parser_mock, servic 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) + lambda_runner_mock.invoke.assert_called_once_with("HelloWorld", "{}", stdout=ANY, stderr=None, tenant_id=ANY) service_response_mock.assert_called_once_with("hello world", {"Content-Type": "application/json"}, 200) @patch("samcli.local.lambda_service.local_lambda_invoke_service.LambdaErrorResponses") @@ -379,7 +409,7 @@ def test_invoke_request_handler_function_not_found_with_arn(self, lambda_error_r 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) + lambda_runner_mock.invoke.assert_called_once_with("NotFound", "{}", stdout=ANY, stderr=None, tenant_id=ANY) # Verify that error response uses the normalized function name lambda_error_responses_mock.resource_not_found.assert_called_once_with("NotFound") diff --git a/tests/unit/local/lambdafn/test_runtime.py b/tests/unit/local/lambdafn/test_runtime.py index dffba424664..0296eef3509 100644 --- a/tests/unit/local/lambdafn/test_runtime.py +++ b/tests/unit/local/lambdafn/test_runtime.py @@ -409,7 +409,7 @@ def test_must_run_container_and_wait_for_result(self, LambdaContainerMock): self.manager_mock.run.assert_called_with(container, ContainerContext.INVOKE) self.runtime._configure_interrupt.assert_called_with(self.full_path, self.DEFAULT_TIMEOUT, container, True) container.wait_for_result.assert_called_with( - event=event, full_path=self.full_path, stdout=stdout, stderr=stderr, start_timer=start_timer + event=event, full_path=self.full_path, stdout=stdout, stderr=stderr, start_timer=start_timer, tenant_id=None ) # Finally block @@ -771,7 +771,7 @@ def test_must_run_container_then_wait_for_result_and_container_not_stopped( self.manager_mock.run.assert_called_with(container, ContainerContext.INVOKE) self.runtime._configure_interrupt.assert_called_with(self.full_path, self.DEFAULT_TIMEOUT, container, True) container.wait_for_result.assert_called_with( - event=event, full_path=self.full_path, stdout=stdout, stderr=stderr, start_timer=start_timer + event=event, full_path=self.full_path, stdout=stdout, stderr=stderr, start_timer=start_timer, tenant_id=None ) # Finally block