Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion config/example.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@
"name": "aws",
"aws": {
"region": "us-east-1",
"lambda-role": ""
"lambda-role": "",
"resources": {
"use-function-url": false,
"function-url-auth-type": "NONE"
}
},
"azure": {
"region": "westeurope"
Expand Down
55 changes: 37 additions & 18 deletions sebs/aws/aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n --type=py "delete_function_url" -C3

Repository: 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.py

Repository: spcl/serverless-benchmarks

Length of output: 1035


🏁 Script executed:

sed -n '365,390p' sebs/aws/config.py

Repository: spcl/serverless-benchmarks

Length of output: 1037


Integrate delete_function_url into the delete_function cleanup flow.

The method is well-implemented with proper cache cleanup (del self._function_urls[function_name]), but it has no callers in the codebase. When delete_function is called at line 432, it only invokes the AWS API but never calls delete_function_url, leaving the in-memory and on-disk _function_urls cache entry orphaned. This causes stale cache lookups in subsequent runs. Either invoke delete_function_url from delete_function, or document that cache cleanup must be called separately.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sebs/aws/aws.py` around lines 439 - 444, The delete_function_url method is
never invoked, leaving entries in the in-memory/on-disk _function_urls cache
orphaned when delete_function removes the Lambda on AWS; update delete_function
to call delete_function_url(func_name) after a successful AWS delete (or in its
cleanup/finally block) so the cache and on-disk state are removed (handle and
log any exceptions from delete_function_url and preserve the original
delete_function return semantics), referencing the existing delete_function and
delete_function_url methods and the _function_urls cache for where to apply the
change.


"""
Prepare AWS resources to store experiment results.
Allocate one bucket.
Expand Down Expand Up @@ -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
Expand Down
243 changes: 243 additions & 0 deletions sebs/aws/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__()
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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")
Expand Down Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

AWS_IAM guard is only at creation time — a cached AWS_IAM URL will bypass this check.

If a Function URL with AWS_IAM auth was created outside this tool (or in a future version), function_url() returns it from the cache at line 340-342 or from the get_function_url_config path at lines 356-360 without ever hitting the NotImplementedError guard. The caller (and ultimately FunctionURLTrigger.sync_invoke) will fail later with an unsigned request producing a 403.

Consider moving the guard to after the URL object is resolved (or rely solely on the trigger-side guard in FunctionURLTrigger.sync_invoke, which already exists), rather than only blocking the creation path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sebs/aws/config.py` around lines 344 - 349, The current NotImplementedError
check for FunctionURLAuthType.AWS_IAM runs only when creating a new URL and can
be bypassed for cached or externally-created URLs; move the AWS_IAM guard so it
executes after resolving the URL object (i.e. after function_url() returns its
value or after get_function_url_config() path) to ensure any returned URL with
auth_type == FunctionURLAuthType.AWS_IAM raises immediately; alternatively
ensure FunctionURLTrigger.sync_invoke contains the definitive guard, but if
keeping it here, inspect the resolved object’s auth_type and raise
NotImplementedError for AWS_IAM before returning the URL.

)

lambda_client = boto3_session.client(
service_name="lambda", region_name=cast(str, self._region)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the cast necessary here? Shouldn't _region be a string?

)

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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use logging to print a warning here - function now has unrestricted public access. Users should be aware of that.

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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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(
Copy link
Collaborator

Choose a reason for hiding this comment

The 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:
(1) When can the ResourceConflictException happen if we previously had ResourceNotFoundException?
(2) What other exceptions can happen that force us to repeat? IIRC, boto3 typically creates specific exception types that describe situations, e.g., another resource update is pending or too many requests. We should capture them explicitly to not blindly repeat loop iterations, when the error type indicates that all future requests to create function URLs will also fail.

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]:
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
Loading