diff --git a/README.md b/README.md index 9841418a..6c38676a 100644 --- a/README.md +++ b/README.md @@ -240,3 +240,4 @@ However, they can be missing some of the features from the `master` branch. * [Mahla Sharifi](https://github.com/mahlashrifi) - contributed support for Java benchmarks. * [Alexander Schlieper (ETH Zurich)](https://github.com/xSurus) - improved support for Java benchmarks. * [Laurin Jahns (ETH Zurich)](https://github.com/userlaurin) - support for language variants. +* [Sharayu Rasal](https://github.com/Sharayu1418) - help with function URLs on AWS. diff --git a/configs/cpp.json b/configs/cpp.json index bb41a2f9..32d15863 100644 --- a/configs/cpp.json +++ b/configs/cpp.json @@ -54,7 +54,11 @@ "name": "aws", "aws": { "region": "us-east-1", - "lambda-role": "" + "lambda-role": "", + "resources": { + "use-function-url": true, + "function-url-auth-type": "NONE" + } }, "azure": { "region": "westeurope" diff --git a/configs/example.json b/configs/example.json index a87fff40..f320982b 100644 --- a/configs/example.json +++ b/configs/example.json @@ -46,7 +46,11 @@ "name": "aws", "aws": { "region": "us-east-1", - "lambda-role": "" + "lambda-role": "", + "resources": { + "use-function-url": true, + "function-url-auth-type": "NONE" + } }, "azure": { "region": "westeurope" diff --git a/configs/java.json b/configs/java.json index 73c3fd97..d74f7378 100644 --- a/configs/java.json +++ b/configs/java.json @@ -46,7 +46,11 @@ "name": "aws", "aws": { "region": "us-east-1", - "lambda-role": "" + "lambda-role": "", + "resources": { + "use-function-url": true, + "function-url-auth-type": "NONE" + } }, "azure": { "region": "westeurope" diff --git a/configs/nodejs.json b/configs/nodejs.json index fe64f7da..4188b537 100644 --- a/configs/nodejs.json +++ b/configs/nodejs.json @@ -54,7 +54,11 @@ "name": "aws", "aws": { "region": "us-east-1", - "lambda-role": "" + "lambda-role": "", + "resources": { + "use-function-url": true, + "function-url-auth-type": "NONE" + } }, "azure": { "region": "westeurope" @@ -99,4 +103,4 @@ } } } -} \ No newline at end of file +} diff --git a/configs/python.json b/configs/python.json index 0f60ea5a..05e545dd 100644 --- a/configs/python.json +++ b/configs/python.json @@ -54,7 +54,11 @@ "name": "aws", "aws": { "region": "us-east-1", - "lambda-role": "" + "lambda-role": "", + "resources": { + "use-function-url": true, + "function-url-auth-type": "NONE" + } }, "azure": { "region": "westeurope" diff --git a/docs/platforms.md b/docs/platforms.md index 3e3fc15f..6c993592 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -77,6 +77,33 @@ or in the JSON input configuration: } ``` +### Lambda Function URLs vs API Gateway + +SeBS supports two methods for HTTP-based function invocation on AWS Lambda: + +1. **Lambda Function URLs** (default) - Direct Lambda invocations. +2. **API Gateway HTTP API** (optional) - Traditional approach using AWS API Gateway. + +SeBS used API Gateway to trigger Lambda functions. However, API Gateway has a hard timeout limit of 29 seconds, which can be restrictive for long-running benchmarks. To overcome this limitation and simplify the architecture, we added support for Lambda Function URLs, which allow direct invocation of Lambda functions without the need for API Gateway. Since we do not rely on more complex API management features, function URLs are now the default version. + +However, API gateway can still be used to benchmarking. The switch between both options is configured in the deployment settings: + +```json +"deployment": { + "name": "aws", + "aws": { + "region": "us-east-1", + "resources": { + "use-function-url": true, + "function-url-auth-type": "NONE" + } + } +} +``` + +> [!WARNING] +> SeBS implements the "NONE" authentication mode for function URLs, making Lambda functions publicly accessible without any authentication. + ## Azure Functions Azure provides a free tier for 12 months. @@ -270,9 +297,8 @@ See the documentation on the and [OpenWhisk configuration](https://github.com/apache/openwhisk-deploy-kube/blob/master/docs/private-docker-registry.md) for details. -**Warning**: this feature is experimental and has not been tested extensively. -At the moment, it cannot be used on a `kind` cluster due to issues with -Docker authorization on invoker nodes. [See the OpenWhisk issue for details](https://github.com/apache/openwhisk-deploy-kube/issues/721). +> [!WARNING] +> This feature is experimental and has not been tested extensively. At the moment, it cannot be used on a `kind` cluster due to issues with Docker authorization on invoker nodes. [See the OpenWhisk issue for details](https://github.com/apache/openwhisk-deploy-kube/issues/721). ### Code Deployment diff --git a/sebs/aws/aws.py b/sebs/aws/aws.py index 95aa7b14..293ed02c 100644 --- a/sebs/aws/aws.py +++ b/sebs/aws/aws.py @@ -692,7 +692,7 @@ def parse_aws_report( def cleanup_resources(self, dry_run: bool = False) -> dict: """Delete allocated resources on AWS. Currently it deletes the following resources: - * Lambda functions and its HTTP API triggers. + * Lambda functions and its HTTP API/Function URL triggers. * CloudWatch log groups of the functions. * DynamoDB tables created for the benchmark. * S3 buckets and their content created for the benchmark. @@ -720,6 +720,10 @@ def cleanup_resources(self, dry_run: bool = False) -> dict: self.session, self.cache_client, dry_run ) + result["Function URLs"] = self.config.resources.cleanup_function_urls( + self.session, self.cache_client, dry_run + ) + result["CloudWatch log groups"] = self.config.resources.cleanup_cloudwatch_logs( list(functions.keys()), self.session, dry_run ) @@ -858,7 +862,7 @@ def download_metrics( f"out of {results_count} invocations" ) - def create_trigger(self, func: Function, trigger_type: Trigger.TriggerType) -> Trigger: + def create_trigger(self, function: Function, trigger_type: Trigger.TriggerType) -> Trigger: """Create a trigger for the specified function. Creates and configures a trigger based on the specified type. Currently @@ -874,32 +878,50 @@ def create_trigger(self, func: Function, trigger_type: Trigger.TriggerType) -> T Raises: RuntimeError: If trigger type is not supported """ - from sebs.aws.triggers import HTTPTrigger + from sebs.aws.triggers import HTTPTrigger, HTTPTriggerImplementation - function = cast(LambdaFunction, func) + function = cast(LambdaFunction, function) + trigger: Trigger if trigger_type == Trigger.TriggerType.HTTP: - api_name = "{}-http-api".format(function.name) - http_api = self.config.resources.http_api(api_name, function, self.session) - # https://aws.amazon.com/blogs/compute/announcing-http-apis-for-amazon-api-gateway/ - # but this is wrong - source arn must be {api-arn}/*/* - self.get_lambda_client().add_permission( - FunctionName=function.name, - StatementId=str(uuid.uuid1()), - Action="lambda:InvokeFunction", - Principal="apigateway.amazonaws.com", - SourceArn=f"{http_api.arn}/*/*", - ) - trigger = HTTPTrigger(http_api.endpoint, api_name) - self.logging.info( - f"Created HTTP trigger for {func.name} function. " - "Sleep 5 seconds to avoid cloud errors." - ) - time.sleep(5) + if self.config.resources.use_function_url: + # Use Lambda Function URL (no 29-second timeout limit) + func_url = self.config.resources.function_url(function, self.session) + trigger = HTTPTrigger( + url=func_url.url, + implementation=HTTPTriggerImplementation.FUNCTION_URL, + function_name=func_url.function_name, + auth_type=func_url.auth_type, + ) + self.logging.info(f"Created Function URL trigger for {function.name} function.") + else: + # Use API Gateway (default, for backward compatibility) + api_name = "{}-http-api".format(function.name) + http_api = self.config.resources.http_api(api_name, function, self.session) + # https://aws.amazon.com/blogs/compute/announcing-http-apis-for-amazon-api-gateway/ + # but this is wrong - source arn must be {api-arn}/*/* + self.get_lambda_client().add_permission( + FunctionName=function.name, + StatementId=str(uuid.uuid1()), + Action="lambda:InvokeFunction", + Principal="apigateway.amazonaws.com", + SourceArn=f"{http_api.arn}/*/*", + ) + trigger = HTTPTrigger( + url=http_api.endpoint, + implementation=HTTPTriggerImplementation.API_GATEWAY, + api_id=api_name, + ) + self.logging.info( + f"Created HTTP API Gateway trigger for {function.name} function. " + "Sleep 5 seconds to avoid cloud errors." + ) + time.sleep(5) + trigger.logging_handlers = self.logging_handlers elif trigger_type == Trigger.TriggerType.LIBRARY: # should already exist - return func.triggers(Trigger.TriggerType.LIBRARY)[0] + return function.triggers(Trigger.TriggerType.LIBRARY)[0] else: raise RuntimeError("Not supported!") diff --git a/sebs/aws/config.py b/sebs/aws/config.py index c14c7b45..b56b61b7 100644 --- a/sebs/aws/config.py +++ b/sebs/aws/config.py @@ -17,6 +17,7 @@ import json import os import time +from enum import Enum from typing import TYPE_CHECKING, cast, Dict, List, Optional, Tuple import boto3 @@ -30,6 +31,28 @@ from sebs.utils import LoggingHandlers +class FunctionURLAuthType(Enum): + """ + Authentication types for AWS Lambda Function URLs. + - NONE: Public access, no authentication required + - AWS_IAM: Requires IAM authentication with SigV4 signing + """ + + NONE = "NONE" + AWS_IAM = "AWS_IAM" + + @staticmethod + def from_string(value: str) -> "FunctionURLAuthType": + """Convert string to FunctionURLAuthType enum.""" + try: + return FunctionURLAuthType(value) + except ValueError: + raise ValueError( + f"Invalid auth type '{value}'. Must be one of: " + f"{[e.value for e in FunctionURLAuthType]}" + ) + + class AWSCredentials(Credentials): """AWS authentication credentials for SeBS. @@ -264,6 +287,85 @@ def serialize(self) -> dict: out = {"arn": self.arn, "endpoint": self.endpoint} return out + class FunctionURL: + + """Encapsulates single instance of function URL.""" + + def __init__( + self, + url: str, + function_name: str, + auth_type: FunctionURLAuthType = FunctionURLAuthType.NONE, + ): + """Initialize AWS function URL instace. + + Args: + url: + function_name: + auth_type: + """ + self._url = url + self._function_name = function_name + self._auth_type = auth_type + + @property + def url(self) -> str: + """URL to invoke function with. + + Returns: + URL + """ + return self._url + + @property + def function_name(self) -> str: + """Function name. + + Returns: + name + """ + return self._function_name + + @property + def auth_type(self) -> FunctionURLAuthType: + """None (public access) or AWS_IAM. + + AWS IAM requires SigV4 signing, currently not supported. + + Returns: + Authentication type. + """ + return self._auth_type + + @staticmethod + def deserialize(dct: dict) -> "AWSResources.FunctionURL": + """Deserialize JSON into instance. + + Args: + dct: cached dictionary + + Returns: + function URL instance + """ + auth_type_str = dct.get("auth_type", "NONE") + return AWSResources.FunctionURL( + dct["url"], + dct["function_name"], + FunctionURLAuthType.from_string(auth_type_str), + ) + + def serialize(self) -> dict: + """Serialize instance into JSON + + Returns: + Python dictionary + """ + return { + "url": self.url, + "function_name": self.function_name, + "auth_type": self.auth_type.value, + } + def __init__( self, registry: Optional[str] = None, @@ -284,6 +386,9 @@ def __init__( self._container_repository: Optional[str] = None self._lambda_role = "" self._http_apis: Dict[str, AWSResources.HTTPApi] = {} + self._function_urls: Dict[str, AWSResources.FunctionURL] = {} + self._use_function_url: bool = True + self._function_url_auth_type: FunctionURLAuthType = FunctionURLAuthType.NONE @staticmethod def typename() -> str: @@ -330,6 +435,40 @@ def container_repository(self) -> Optional[str]: """ return self._container_repository + @property + def use_function_url(self) -> bool: + """ + Returns: + true if HTTP triggers use function URLs + [TODO:return] + """ + return self._use_function_url + + @use_function_url.setter + def use_function_url(self, value: bool): + """ + Change HTTP Trigger type. + True => use function URL. + False => use API gateway. + """ + self._use_function_url = value + + @property + def function_url_auth_type(self) -> FunctionURLAuthType: + """ + Returns: + function URL authentication type (NONE or AWS_IAM) + """ + return self._function_url_auth_type + + @function_url_auth_type.setter + def function_url_auth_type(self, value: FunctionURLAuthType): + """ + Change function URL authentication type. + AWS_IAM requires SigV4 signing, currently not supported. + """ + self._function_url_auth_type = value + def lambda_role(self, boto3_session: boto3.session.Session) -> str: """Get or create IAM role for Lambda execution. @@ -454,7 +593,10 @@ def http_api( return http_api def cleanup_http_apis( - self, boto3_session: boto3.session.Session, cache_client: Cache, dry_run: bool = False + self, + boto3_session: boto3.session.Session, + cache_client: Cache, + dry_run: bool = False, ) -> List[str]: """Remove HTTP APIs allocated for HTTP triggers. @@ -495,6 +637,183 @@ def cleanup_http_apis( return deleted + def cleanup_function_urls( + self, + boto3_session: boto3.session.Session, + cache_client: Cache, + dry_run: bool = False, + ) -> List[str]: + """Remove Function URLs allocated for HTTP triggers. + + Args: + boto3_session: boto3 session for AWS API calls + cache_client: SeBS cache client + dry_run: when true, skip actual deletion + + Returns: + list of deleted Function URL names (function names) + """ + + deleted: List[str] = [] + deleted_functions: List[str] = [] + dry_run_tag = "[DRY-RUN] " if dry_run else "" + + dict_copy = self._function_urls.copy() + for func_name, func_url in dict_copy.items(): + + self.logging.info(f"{dry_run_tag}Deleting Function URL for: {func_name}") + + if not dry_run: + self.delete_function_url(func_name, boto3_session) + deleted.append(func_url.url) + deleted_functions.append(func_name) + + if not dry_run: + for func_name in deleted_functions: + cache_client.remove_config_key(["aws", "resources", "function-urls", func_name]) + self._function_urls.pop(func_name, None) + + return deleted + + def function_url( + self, func: LambdaFunction, boto3_session: boto3.session.Session + ) -> "AWSResources.FunctionURL": + """ + Create or retrieve a Lambda Function URL for the given function. + Function URLs provide a simpler alternative to API Gateway without the + 29-second timeout limit. + + Permissions are applied whenever the Function URL is not cached locally, + ensuring correct access policies for both newly created and existing URLs. + """ + cached_url = self._function_urls.get(func.name) + if cached_url: + self.logging.info(f"Using cached Function URL for {func.name}") + return cached_url + + # Check for unsupported auth type before attempting to create + if self._function_url_auth_type == FunctionURLAuthType.AWS_IAM: + raise NotImplementedError( + "AWS_IAM authentication for Function URLs is not yet supported. " + "SigV4 request signing is required for AWS_IAM auth type. " + "Please use auth_type='NONE' or implement SigV4 signing." + ) + + lambda_client = boto3_session.client( + service_name="lambda", region_name=cast(str, self._region) + ) + + # Try to get existing Function URL configuration from AWS + url_exists = False + try: + response = lambda_client.get_function_url_config(FunctionName=func.name) + self.logging.info(f"Found existing Function URL for {func.name}") + url = response["FunctionUrl"] + auth_type = FunctionURLAuthType.from_string(response["AuthType"]) + url_exists = True + except lambda_client.exceptions.ResourceNotFoundException: + # Function URL doesn't exist - we'll create it + self.logging.info(f"Creating Function URL for {func.name}") + auth_type = self._function_url_auth_type + + retries = 0 + while retries < 5: + try: + response = lambda_client.create_function_url_config( # type: ignore[assignment] + FunctionName=func.name, + AuthType=auth_type.value, + ) + break + except lambda_client.exceptions.ResourceConflictException: + # Function URL already exists - can happen if a concurrent process + # created it between our check and create, or if there was a race + # condition. Retrieve the existing configuration instead. + response = lambda_client.get_function_url_config(FunctionName=func.name) + break + except lambda_client.exceptions.TooManyRequestsException as e: + # AWS is throttling requests - apply exponential backoff + retries += 1 + if retries == 5: + self.logging.error("Failed to create Function URL after 5 retries!") + self.logging.error(e) + raise RuntimeError("Failed to create Function URL!") from e + else: + backoff_seconds = retries + self.logging.info( + f"Function URL creation rate limited, " + f"retrying in {backoff_seconds}s (attempt {retries}/5)..." + ) + time.sleep(backoff_seconds) + + url = response["FunctionUrl"] + + # Apply permissions for NONE auth type (applies to both new and existing URLs) + # This ensures correct permissions even if the Function URL was created externally + if auth_type == FunctionURLAuthType.NONE: + action_verb = "found" if url_exists else "created" + self.logging.warning( + f"Function URL {action_verb} with auth_type=NONE for {func.name}. " + "WARNING: This function will have unrestricted public access. " + "Anyone with the URL can invoke this function." + ) + try: + lambda_client.add_permission( + FunctionName=func.name, + StatementId="FunctionURLAllowPublicAccess", + Action="lambda:InvokeFunctionUrl", + Principal="*", + FunctionUrlAuthType="NONE", + ) + self.logging.info( + f"Applied public access permission for Function URL on {func.name}" + ) + except lambda_client.exceptions.ResourceConflictException: + # Permission with this StatementId already exists on the function. + # This is expected if the permission was previously added. + self.logging.info(f"Public access permission already exists for {func.name}") + + function_url_obj = AWSResources.FunctionURL(url, func.name, auth_type) + self._function_urls[func.name] = function_url_obj + return function_url_obj + + def delete_function_url(self, function_name: str, boto3_session: boto3.session.Session) -> bool: + """ + Delete a Lambda Function URL for the given function. + Returns True if deleted successfully, False if it didn't exist. + """ + lambda_client = boto3_session.client( + service_name="lambda", region_name=cast(str, self._region) + ) + + # Check if we have cached info about the auth type + cached_url = self._function_urls.get(function_name) + cached_auth_type = cached_url.auth_type if cached_url else None + + try: + lambda_client.delete_function_url_config(FunctionName=function_name) + self.logging.info(f"Deleted Function URL for {function_name}") + + # Only remove the public access permission if auth_type was NONE + # (AWS_IAM auth type doesn't create this permission) + if cached_auth_type is None or cached_auth_type == FunctionURLAuthType.NONE: + try: + lambda_client.remove_permission( + FunctionName=function_name, + StatementId="FunctionURLAllowPublicAccess", + ) + except lambda_client.exceptions.ResourceNotFoundException: + # Permission doesn't exist - either it was already removed, + # or the function was using AWS_IAM auth type + pass + except lambda_client.exceptions.ResourceNotFoundException: + self.logging.info(f"No Function URL found for {function_name}") + return False + else: + # Only runs if no exception was raised - cleanup cache + if function_name in self._function_urls: + del self._function_urls[function_name] + return True + def check_ecr_repository_exists( self, ecr_client: ECRClient, repository_name: str ) -> Optional[str]: @@ -616,7 +935,10 @@ def cleanup_ecr_repository( return deleted def cleanup_cloudwatch_logs( - self, function_names: List[str], boto3_session: boto3.session.Session, dry_run: bool + self, + function_names: List[str], + boto3_session: boto3.session.Session, + dry_run: bool, ) -> List[str]: """Remove CloudWatch logs for selected functions. @@ -715,6 +1037,14 @@ def initialize(res: Resources, dct: dict) -> None: for key, value in dct["http-apis"].items(): ret._http_apis[key] = AWSResources.HTTPApi.deserialize(value) + if "function-urls" in dct: + for key, value in dct["function-urls"].items(): + ret._function_urls[key] = AWSResources.FunctionURL.deserialize(value) + + ret._use_function_url = dct.get("use-function-url", True) + auth_type_str = dct.get("function-url-auth-type", "NONE") + ret.function_url_auth_type = FunctionURLAuthType.from_string(auth_type_str) + def serialize(self) -> dict: """Serialize AWS resources to dictionary. @@ -725,6 +1055,11 @@ def serialize(self) -> dict: **super().serialize(), "lambda-role": self._lambda_role, "http-apis": {key: value.serialize() for (key, value) in self._http_apis.items()}, + "function-urls": { + key: value.serialize() for (key, value) in self._function_urls.items() + }, + "use-function-url": self._use_function_url, + "function-url-auth-type": self._function_url_auth_type.value, "docker": { "registry": self.docker_registry, "username": self.docker_username, @@ -753,6 +1088,18 @@ def update_cache(self, cache: Cache) -> None: cache.update_config(val=self._lambda_role, keys=["aws", "resources", "lambda-role"]) for name, api in self._http_apis.items(): cache.update_config(val=api.serialize(), keys=["aws", "resources", "http-apis", name]) + for name, func_url in self._function_urls.items(): + cache.update_config( + val=func_url.serialize(), + keys=["aws", "resources", "function-urls", name], + ) + cache.update_config( + val=self._use_function_url, keys=["aws", "resources", "use-function-url"] + ) + cache.update_config( + val=self._function_url_auth_type.value, + keys=["aws", "resources", "function-url-auth-type"], + ) @staticmethod def deserialize(config: dict, cache: Cache, handlers: LoggingHandlers) -> Resources: diff --git a/sebs/aws/triggers.py b/sebs/aws/triggers.py index fad7fd2f..301f3606 100644 --- a/sebs/aws/triggers.py +++ b/sebs/aws/triggers.py @@ -14,12 +14,21 @@ import concurrent.futures import datetime import json +from enum import Enum from typing import Dict, Optional # noqa from sebs.aws.aws import AWS +from sebs.aws.config import FunctionURLAuthType from sebs.faas.function import ExecutionResult, Trigger +class HTTPTriggerImplementation(Enum): + """Internal implementation type for HTTP triggers.""" + + API_GATEWAY = "api_gateway" + FUNCTION_URL = "function_url" + + class LibraryTrigger(Trigger): """AWS Lambda library trigger for direct SDK invocation. @@ -189,27 +198,70 @@ def deserialize(obj: dict) -> Trigger: class HTTPTrigger(Trigger): - """AWS API Gateway HTTP trigger for Lambda functions. + """AWS HTTP trigger for Lambda functions. - This trigger uses HTTP requests to invoke Lambda functions through - AWS API Gateway. It provides both synchronous and asynchronous - invocation methods. + This trigger uses HTTP requests to invoke Lambda functions through either + AWS API Gateway or Lambda Function URLs. The implementation is transparent + to the user - both are accessed as HTTP triggers. Attributes: - url: API Gateway endpoint URL - api_id: API Gateway API ID + url: HTTP endpoint URL (API Gateway or Function URL) + implementation: Internal implementation type (API Gateway or Function URL) + api_id: API Gateway API ID (only for API Gateway implementation) + function_name: Function name (only for Function URL implementation) + auth_type: Authentication type (only for Function URL implementation) """ - def __init__(self, url: str, api_id: str) -> None: + def __init__( + self, + url: str, + implementation: HTTPTriggerImplementation = HTTPTriggerImplementation.API_GATEWAY, + api_id: Optional[str] = None, + function_name: Optional[str] = None, + auth_type: Optional[FunctionURLAuthType] = None, + ) -> None: """Initialize the HTTP trigger. Args: - url: API Gateway endpoint URL - api_id: API Gateway API ID + url: HTTP endpoint URL + implementation: Implementation type (API Gateway or Function URL) + api_id: API Gateway API ID (required for API Gateway) + function_name: Function name (required for Function URL) + auth_type: Authentication type (for Function URL, defaults to NONE) """ super().__init__() self.url = url + self._implementation = implementation self.api_id = api_id + self.function_name = function_name + self.auth_type = auth_type if auth_type is not None else FunctionURLAuthType.NONE + + @property + def implementation(self) -> HTTPTriggerImplementation: + """Get the implementation type of this HTTP trigger. + + Returns: + HTTPTriggerImplementation: API_GATEWAY or FUNCTION_URL + """ + return self._implementation + + @property + def uses_api_gateway(self) -> bool: + """Check if this trigger uses API Gateway. + + Returns: + bool: True if using API Gateway, False otherwise + """ + return self._implementation == HTTPTriggerImplementation.API_GATEWAY + + @property + def uses_function_url(self) -> bool: + """Check if this trigger uses Lambda Function URLs. + + Returns: + bool: True if using Function URLs, False otherwise + """ + return self._implementation == HTTPTriggerImplementation.FUNCTION_URL @staticmethod def typename() -> str: @@ -232,15 +284,29 @@ def trigger_type() -> Trigger.TriggerType: def sync_invoke(self, payload: dict) -> ExecutionResult: """Synchronously invoke the function via HTTP. - Sends an HTTP request to the API Gateway endpoint and waits - for the response. + Sends an HTTP request to the endpoint (API Gateway or Function URL) + and waits for the response. Args: payload: Dictionary payload to send to the function Returns: ExecutionResult: Result of the HTTP invocation + + Raises: + NotImplementedError: If using AWS_IAM auth with Function URLs """ + # Check for unsupported AWS_IAM auth with Function URLs + if ( + self._implementation == HTTPTriggerImplementation.FUNCTION_URL + and self.auth_type == FunctionURLAuthType.AWS_IAM + ): + raise NotImplementedError( + "AWS_IAM auth type requires SigV4 signing, which is not yet " + "implemented for Function URLs. Use auth_type=NONE or " + "implement SigV4 signing via botocore.auth.SigV4Auth." + ) + self.logging.debug(f"Invoke function {self.url}") return self._http_invoke(payload, self.url) @@ -263,9 +329,23 @@ def serialize(self) -> dict: """Serialize the trigger to a dictionary. Returns: - dict: Serialized trigger configuration + dict: Serialized trigger configuration including implementation details """ - return {"type": "HTTP", "url": self.url, "api-id": self.api_id} + base = { + "type": "HTTP", + "url": self.url, + "implementation": self._implementation.value, + } + + if self._implementation == HTTPTriggerImplementation.API_GATEWAY: + if self.api_id is not None: + base["api-id"] = self.api_id + else: # FUNCTION_URL + if self.function_name is not None: + base["function_name"] = self.function_name + base["auth_type"] = self.auth_type.value + + return base @staticmethod def deserialize(obj: dict) -> Trigger: @@ -277,4 +357,29 @@ def deserialize(obj: dict) -> Trigger: Returns: Trigger: Deserialized HTTPTrigger instance """ - return HTTPTrigger(obj["url"], obj["api-id"]) + # Check for implementation field (new format) + if "implementation" in obj: + impl_value = obj["implementation"] + implementation = HTTPTriggerImplementation(impl_value) + + if implementation == HTTPTriggerImplementation.API_GATEWAY: + return HTTPTrigger( + url=obj["url"], + implementation=implementation, + api_id=obj.get("api-id"), + ) + else: # FUNCTION_URL + auth_type_str = obj.get("auth_type", "NONE") + return HTTPTrigger( + url=obj["url"], + implementation=implementation, + function_name=obj.get("function_name"), + auth_type=FunctionURLAuthType.from_string(auth_type_str), + ) + else: + # Legacy format compatibility - assume API Gateway + return HTTPTrigger( + url=obj["url"], + implementation=HTTPTriggerImplementation.API_GATEWAY, + api_id=obj.get("api-id"), + ) diff --git a/tests/aws/test_function_url.py b/tests/aws/test_function_url.py new file mode 100644 index 00000000..8fe462cf --- /dev/null +++ b/tests/aws/test_function_url.py @@ -0,0 +1,214 @@ +# Copyright 2020-2025 ETH Zurich and the SeBS authors. All rights reserved. +from __future__ import annotations + +import tempfile +import unittest +from typing import TYPE_CHECKING, cast + +import sebs +from sebs.faas.function import Trigger + +if TYPE_CHECKING: + import sebs.aws + + +class AWSFunctionURL(unittest.TestCase): + """Integration tests for AWS Lambda Function URLs. + + Tests Function URL creation, invocation, caching, and cleanup. + A single deployment client and benchmark are shared across all tests + to avoid redundant Docker builds and resource initialization. + """ + + BENCHMARK_NAME = "110.dynamic-html" + RESOURCE_PREFIX = "unit" + + BASE_CONFIG = { + "deployment": { + "name": "aws", + "aws": { + "region": "us-east-1", + "resources": { + "use-function-url": True, + "function-url-auth-type": "NONE", + }, + }, + }, + "experiments": { + "deployment": "aws", + "runtime": {"language": "python", "version": "3.9"}, + "update_code": False, + "update_storage": False, + "download_results": False, + "architecture": "x64", + "container_deployment": False, + }, + } + + @classmethod + def setUpClass(cls): + cls.tmp_dir = tempfile.TemporaryDirectory() + cls.client = sebs.SeBS(cls.tmp_dir.name, cls.tmp_dir.name) + + # Shared Python deployment client and benchmark + cls.deployment_client = cls.client.get_deployment(cls.BASE_CONFIG) + cls.deployment_client.initialize(resource_prefix=cls.RESOURCE_PREFIX) + cls.experiment_config = cls.client.get_experiment_config(cls.BASE_CONFIG["experiments"]) + cls.benchmark = cls.client.get_benchmark( + cls.BENCHMARK_NAME, cls.deployment_client, cls.experiment_config + ) + cls.bench_input = cls.benchmark.prepare_input( + system_resources=cls.deployment_client.system_resources, size="test" + ) + + @classmethod + def tearDownClass(cls): + + cls.deployment_client.cleanup_resources(dry_run=False) + + # Shut down deployment client (stops containers) before removing temp dir + if hasattr(cls, "deployment_client"): + cls.deployment_client.shutdown() + cls.tmp_dir.cleanup() + + def _get_function(self, suffix: str) -> sebs.aws.LambdaFunction: + """Create/get a function with the given suffix appended to the default name.""" + name = "{}-{}".format(self.deployment_client.default_function_name(self.benchmark), suffix) + return self.deployment_client.get_function(self.benchmark, name) + + def _invoke_sync(self, func: sebs.aws.LambdaFunction, func_input: dict): + """Helper method to invoke function via Function URL and verify response.""" + triggers = func.triggers(Trigger.TriggerType.HTTP) + self.assertGreater(len(triggers), 0, "No HTTP triggers found on function") + ret = triggers[0].sync_invoke(func_input) + self.assertFalse(ret.stats.failure) + self.assertTrue(ret.request_id) + self.assertGreater(ret.times.client, ret.times.benchmark) + + # ------------------------------------------------------------------ + # Tests + # ------------------------------------------------------------------ + + def test_create_function_url_python(self): + """Test Function URL creation with Python runtime and auth_type=NONE.""" + func = self._get_function("func-url") + self.deployment_client.create_trigger(func, Trigger.TriggerType.HTTP) + + all_triggers = func.triggers(Trigger.TriggerType.HTTP) + self.assertGreater(len(all_triggers), 0) + + function_url_trigger = all_triggers[0] + from sebs.aws.triggers import HTTPTrigger + + self.assertIsInstance(function_url_trigger, HTTPTrigger) + self.assertTrue(function_url_trigger.uses_function_url) + self.assertFalse(function_url_trigger.uses_api_gateway) + self.assertTrue(function_url_trigger.url) + self.assertTrue(function_url_trigger.url.startswith("https://")) + + def test_invoke_function_url_python(self): + """Test function invocation via Function URL with Python runtime.""" + func = self._get_function("func-url") + self.deployment_client.create_trigger(func, Trigger.TriggerType.HTTP) + self._invoke_sync(func, self.bench_input) + + def test_function_url_caching(self): + """Test that Function URLs are cached and reused properly.""" + func = self._get_function("func-url") + self.deployment_client.create_trigger(func, Trigger.TriggerType.HTTP) + first_url = func.triggers(Trigger.TriggerType.HTTP)[0].url + + # Get function again — should use cached Function URL + func2 = self._get_function("func-url") + self.deployment_client.create_trigger(func2, Trigger.TriggerType.HTTP) + second_url = func2.triggers(Trigger.TriggerType.HTTP)[0].url + + self.assertEqual(first_url, second_url) + + def test_function_url_cleanup(self): + """Test that Function URLs are deleted during cleanup.""" + func = self._get_function("func-url") + self.deployment_client.create_trigger(func, Trigger.TriggerType.HTTP) + func_url = func.triggers(Trigger.TriggerType.HTTP)[0].url + self.assertTrue(func_url) + + # Dry run first to verify detection + cleanup_result = self.deployment_client.cleanup_resources(dry_run=True) + self.assertIn("Function URLs", cleanup_result) + self.assertGreaterEqual(len(cleanup_result["Function URLs"]), 1) + + self.assertIn(func_url, cleanup_result["Function URLs"]) + + def test_api_gateway_still_works(self): + """Test that API Gateway triggers still work alongside Function URLs. + + Verifies: + 1. A single function can have both API Gateway and Function URL triggers + 2. Both trigger types are distinct HTTPTrigger instances + 3. Both triggers work with invocation + """ + from sebs.aws.triggers import HTTPTrigger + + # Save original config + original_use_function_url = self.deployment_client.config.resources.use_function_url + + try: + func = self._get_function("dual-func-url") + + http_triggers = func.triggers(Trigger.TriggerType.HTTP) + + # Create API Gateway trigger first + self.deployment_client.config.resources.use_function_url = False + self.deployment_client.create_trigger(func, Trigger.TriggerType.HTTP) + + # Add Function URL trigger to the same function + self.deployment_client.config.resources.use_function_url = True + self.deployment_client.create_trigger(func, Trigger.TriggerType.HTTP) + + # Verify we have 2 HTTP triggers + http_triggers = func.triggers(Trigger.TriggerType.HTTP) + self.assertEqual(len(http_triggers), 2) + + # Find each trigger type + api_gw_trigger = None + func_url_trigger = None + + for trigger in http_triggers: + self.assertIsInstance(trigger, HTTPTrigger) + trigger_aws = cast(sebs.aws.triggers.HTTPTrigger, trigger) + if trigger_aws.uses_api_gateway: + api_gw_trigger = trigger + elif trigger_aws.uses_function_url: + func_url_trigger = trigger + + # Verify both types exist + self.assertIsNotNone(api_gw_trigger, "API Gateway trigger not found") + self.assertIsNotNone(func_url_trigger, "Function URL trigger not found") + + # Verify API Gateway trigger properties + self.assertTrue(api_gw_trigger.uses_api_gateway) + self.assertFalse(api_gw_trigger.uses_function_url) + self.assertTrue(api_gw_trigger.url.startswith("https://")) + + # Verify Function URL trigger properties + self.assertTrue(func_url_trigger.uses_function_url) + self.assertFalse(func_url_trigger.uses_api_gateway) + self.assertTrue(func_url_trigger.url.startswith("https://")) + + # Verify different URLs + self.assertNotEqual(api_gw_trigger.url, func_url_trigger.url) + + # Test invocation via both triggers + ret_api_gw = api_gw_trigger.sync_invoke(self.bench_input) + self.assertFalse(ret_api_gw.stats.failure) + + ret_func_url = func_url_trigger.sync_invoke(self.bench_input) + self.assertFalse(ret_func_url.stats.failure) + + finally: + # Restore original config + self.deployment_client.config.resources.use_function_url = original_use_function_url + + +if __name__ == "__main__": + unittest.main()