Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 configs/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
56 changes: 38 additions & 18 deletions sebs/aws/aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,13 @@ def delete_function(self, func_name: str) -> None:
except Exception:
self.logging.error("Function {} does not exist!".format(func_name))

def delete_function_url(self, func_name: str) -> bool:
Comment thread
Sharayu1418 marked this conversation as resolved.
Outdated
"""
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 thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

@staticmethod
def parse_aws_report(
log: str, requests: Union[ExecutionResult, Dict[str, ExecutionResult]]
Expand Down Expand Up @@ -874,28 +881,41 @@ 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, 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
242 changes: 242 additions & 0 deletions sebs/aws/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -264,6 +287,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 @@ -284,6 +346,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
Comment thread
mcopik marked this conversation as resolved.

@staticmethod
def typename() -> str:
Expand Down Expand Up @@ -330,6 +395,26 @@ 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:
"""Get or create IAM role for Lambda execution.

Expand Down Expand Up @@ -495,6 +580,139 @@ def cleanup_http_apis(

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.
"""
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 +694 to +699
Copy link
Copy Markdown

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.

)
Comment on lines +694 to +700
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

AWS_IAM is accepted in config but unusable at runtime.

The new enum/setter/cache flow all allow FunctionURLAuthType.AWS_IAM, but Lines 596-602 unconditionally raise NotImplementedError when the trigger is created. Either implement signed Function URL invocation end-to-end or reject AWS_IAM during config parsing so users fail fast before deployment.

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

In `@sebs/aws/config.py` around lines 596 - 602, The code currently accepts
FunctionURLAuthType.AWS_IAM in the setter/cache flow but later raises
NotImplementedError at trigger creation; fix this by failing fast during config
parsing/validation: update the setter/validation logic that assigns
self._function_url_auth_type (and any cache population code that accepts the
enum) to reject FunctionURLAuthType.AWS_IAM and raise a clear ValueError (or
ConfigError) stating that AWS_IAM is unsupported and to use auth_type='NONE' or
implement SigV4 signing; ensure the invalid value is never stored in the
cache/state and add/update tests to assert the early rejection.


lambda_client = boto3_session.client(
service_name="lambda", region_name=cast(str, self._region)
Comment thread
mcopik marked this conversation as resolved.
)

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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

def check_ecr_repository_exists(
self, ecr_client: ECRClient, repository_name: str
) -> Optional[str]:
Expand Down Expand Up @@ -715,6 +933,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", False)
auth_type_str = dct.get("function-url-auth-type", "NONE")
ret.function_url_auth_type = FunctionURLAuthType.from_string(auth_type_str)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

These two flags become sticky once a cache exists.

Because AWSResources.deserialize() prefers cached resources, persisting use-function-url and function-url-auth-type here means later config changes are ignored until the user clears the cache. Those are runtime settings, not discovered AWS resources, so they should come from the current config on each run.

Also applies to: 991-997

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

In `@sebs/aws/config.py` around lines 940 - 942, AWSResources.deserialize is
persisting runtime flags (_use_function_url and function_url_auth_type) from the
serialized dct which makes those settings "sticky" when a cached AWSResources is
reused; change deserialize so it does NOT read or set ret._use_function_url and
ret.function_url_auth_type from the cached dct (remove or ignore the
dct.get("use-function-url", ...) and dct.get("function-url-auth-type", ...)
lines), and instead ensure these runtime flags are applied from the current
runtime/config after deserialize returns (or add parameters to
AWSResources.deserialize to accept current runtime values and set them
post-cache-load). Also apply the same change for the equivalent code around the
other occurrence (lines handling the same keys at 991-997) so runtime settings
are always taken from the current config rather than the cache.


def serialize(self) -> dict:
"""Serialize AWS resources to dictionary.

Expand All @@ -725,6 +951,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,
Expand Down Expand Up @@ -753,6 +984,17 @@ 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:
Expand Down
Loading