-
Notifications
You must be signed in to change notification settings - Fork 97
feat(aws): add support for Lambda Function URLs #284
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -436,6 +436,13 @@ def delete_function(self, func_name: Optional[str]): | |
| except Exception: | ||
| self.logging.debug("Function {} does not exist!".format(func_name)) | ||
|
|
||
| def delete_function_url(self, func_name: str) -> bool: | ||
| """ | ||
| Delete the Function URL associated with a Lambda function. | ||
| Returns True if deleted successfully, False if it didn't exist. | ||
| """ | ||
| return self.config.resources.delete_function_url(func_name, self.session) | ||
|
Comment on lines
+439
to
+444
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: rg -n --type=py "delete_function_url" -C3Repository: spcl/serverless-benchmarks Length of output: 1532 🏁 Script executed: rg -n --type=py "\.delete_function_url\("Repository: spcl/serverless-benchmarks Length of output: 171 🏁 Script executed: rg -n --type=py "def delete_function" -A15 sebs/aws/aws.pyRepository: spcl/serverless-benchmarks Length of output: 1035 🏁 Script executed: sed -n '365,390p' sebs/aws/config.pyRepository: spcl/serverless-benchmarks Length of output: 1037 Integrate The method is well-implemented with proper cache cleanup ( 🤖 Prompt for AI Agents |
||
|
|
||
| """ | ||
| Prepare AWS resources to store experiment results. | ||
| Allocate one bucket. | ||
|
|
@@ -576,29 +583,41 @@ def download_metrics( | |
| ) | ||
|
|
||
| def create_trigger(self, func: Function, trigger_type: Trigger.TriggerType) -> Trigger: | ||
| from sebs.aws.triggers import HTTPTrigger | ||
| from sebs.aws.triggers import HTTPTrigger, FunctionURLTrigger | ||
|
|
||
| function = cast(LambdaFunction, func) | ||
|
|
||
| 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 = FunctionURLTrigger( | ||
| func_url.url, func_url.function_name, func_url.auth_type | ||
| ) | ||
| self.logging.info( | ||
| f"Created Function URL trigger for {func.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(http_api.endpoint, api_name) | ||
| self.logging.info( | ||
| f"Created HTTP API Gateway trigger for {func.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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,9 +2,11 @@ | |
| import json | ||
| import os | ||
| import time | ||
| from enum import Enum | ||
| from typing import cast, Dict, Optional, Tuple | ||
|
|
||
| import boto3 | ||
| import botocore.exceptions | ||
| from mypy_boto3_ecr import ECRClient | ||
|
|
||
| from sebs.cache import Cache | ||
|
|
@@ -13,6 +15,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): | ||
| def __init__(self, access_key: str, secret_key: str): | ||
| super().__init__() | ||
|
|
@@ -115,6 +139,45 @@ def serialize(self) -> dict: | |
| out = {"arn": self.arn, "endpoint": self.endpoint} | ||
| return out | ||
|
|
||
| class FunctionURL: | ||
| def __init__( | ||
| self, | ||
| url: str, | ||
| function_name: str, | ||
| auth_type: FunctionURLAuthType = FunctionURLAuthType.NONE, | ||
| ): | ||
| self._url = url | ||
| self._function_name = function_name | ||
| self._auth_type = auth_type | ||
|
|
||
| @property | ||
| def url(self) -> str: | ||
| return self._url | ||
|
|
||
| @property | ||
| def function_name(self) -> str: | ||
| return self._function_name | ||
|
|
||
| @property | ||
| def auth_type(self) -> FunctionURLAuthType: | ||
| return self._auth_type | ||
|
|
||
| @staticmethod | ||
| def deserialize(dct: dict) -> "AWSResources.FunctionURL": | ||
| 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: | ||
| return { | ||
| "url": self.url, | ||
| "function_name": self.function_name, | ||
| "auth_type": self.auth_type.value, | ||
| } | ||
|
|
||
| def __init__( | ||
| self, | ||
| registry: Optional[str] = None, | ||
|
|
@@ -128,6 +191,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 = False | ||
| self._function_url_auth_type: FunctionURLAuthType = FunctionURLAuthType.NONE | ||
|
|
||
| @staticmethod | ||
| def typename() -> str: | ||
|
|
@@ -149,6 +215,26 @@ def docker_password(self) -> Optional[str]: | |
| def container_repository(self) -> Optional[str]: | ||
| return self._container_repository | ||
|
|
||
| @property | ||
| def use_function_url(self) -> bool: | ||
| return self._use_function_url | ||
|
|
||
| @use_function_url.setter | ||
| def use_function_url(self, value: bool): | ||
| self._use_function_url = value | ||
|
|
||
| @property | ||
| def function_url_auth_type(self) -> FunctionURLAuthType: | ||
| return self._function_url_auth_type | ||
|
|
||
| @function_url_auth_type.setter | ||
| def function_url_auth_type(self, value: FunctionURLAuthType): | ||
| if not isinstance(value, FunctionURLAuthType): | ||
| raise TypeError( | ||
| f"function_url_auth_type must be a FunctionURLAuthType enum, got {type(value)}" | ||
| ) | ||
| self._function_url_auth_type = value | ||
|
|
||
| def lambda_role(self, boto3_session: boto3.session.Session) -> str: | ||
| if not self._lambda_role: | ||
| iam_client = boto3_session.client(service_name="iam") | ||
|
|
@@ -242,6 +328,139 @@ def http_api( | |
| self.logging.info(f"Using cached HTTP API {api_name}") | ||
| return http_api | ||
|
|
||
| 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. | ||
| """ | ||
| 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." | ||
|
Comment on lines
+344
to
+349
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a Function URL with Consider moving the guard to after the URL object is resolved (or rely solely on the trigger-side guard in 🤖 Prompt for AI Agents |
||
| ) | ||
|
|
||
| lambda_client = boto3_session.client( | ||
| service_name="lambda", region_name=cast(str, self._region) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is the |
||
| ) | ||
|
|
||
| try: | ||
| response = lambda_client.get_function_url_config(FunctionName=func.name) | ||
| self.logging.info(f"Using existing Function URL for {func.name}") | ||
| url = response["FunctionUrl"] | ||
| auth_type = FunctionURLAuthType.from_string(response["AuthType"]) | ||
| except lambda_client.exceptions.ResourceNotFoundException: | ||
| self.logging.info(f"Creating Function URL for {func.name}") | ||
|
|
||
| auth_type = self._function_url_auth_type | ||
|
|
||
| if auth_type == FunctionURLAuthType.NONE: | ||
| self.logging.warning( | ||
| f"Creating Function URL 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( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please use |
||
| FunctionName=func.name, | ||
| StatementId="FunctionURLAllowPublicAccess", | ||
| Action="lambda:InvokeFunctionUrl", | ||
| Principal="*", | ||
| FunctionUrlAuthType="NONE", | ||
| ) | ||
| except lambda_client.exceptions.ResourceConflictException: | ||
| # Permission with this StatementId already exists on the function. | ||
| # This can happen if the function was previously configured with | ||
| # a Function URL that was deleted but the permission remained, | ||
| # or if there's a concurrent creation attempt. Safe to ignore. | ||
| pass | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When does this exception happen - if we try to add a permissions to the function that already exists? That should be safe but can we add a comment explaining this to make it clear it is safe to ignore this exception |
||
|
|
||
| retries = 0 | ||
| while retries < 5: | ||
| try: | ||
| response = lambda_client.create_function_url_config( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't get the logic of this code and there's no comment explaining it: |
||
| 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.exception(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"] | ||
|
|
||
| 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]: | ||
|
|
@@ -310,13 +529,26 @@ def initialize(res: Resources, dct: dict): | |
| 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", False) | ||
| auth_type_str = dct.get("function-url-auth-type", "NONE") | ||
| ret.function_url_auth_type = FunctionURLAuthType.from_string(auth_type_str) | ||
|
|
||
| return ret | ||
|
|
||
| def serialize(self) -> dict: | ||
| out = { | ||
| **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, | ||
|
|
@@ -339,6 +571,17 @@ def update_cache(self, cache: Cache): | |
| 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: | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.