Skip to content
This repository was archived by the owner on Mar 23, 2026. It is now read-only.
Merged
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
522 changes: 425 additions & 97 deletions localstack-core/localstack/aws/api/lambda_/__init__.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
The specifications here overwrite the default Lambda specs in botocore with the specs we got as part of our partnership with the AWS Lambda team for the co-launch of Lambda Managed Instances.
- `service-2.json` contains the new public API model (status 2025-11-24). The directory follows the `requestUri` naming `2025-11-30` (e.g., `CreateCapacityProvider`).
7,416 changes: 7,416 additions & 0 deletions localstack-core/localstack/aws/data/lambda/2025-11-30/service-2.json

Large diffs are not rendered by default.

57 changes: 37 additions & 20 deletions localstack-core/localstack/services/lambda_/api_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"""

import datetime
import hashlib
import json
import random
import re
import string
Expand Down Expand Up @@ -62,13 +64,14 @@
r"^$|arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*)"
)

# TODO: what's the difference between AWS_FUNCTION_NAME_REGEX and FUNCTION_NAME_REGEX? Can we unify?
AWS_FUNCTION_NAME_REGEX = re.compile(
"^(arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?$"
"^(arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_.]+)(:(\\$LATEST(\\.PUBLISHED)?|[a-zA-Z0-9-_]+))?$"
)

# Pattern for extracting various attributes from a full or partial ARN or just a function name.
FUNCTION_NAME_REGEX = re.compile(
r"(arn:(aws[a-zA-Z-]*):lambda:)?((?P<region>[a-z]{2}(-gov)?-[a-z]+-\d{1}):)?(?:(?P<account>\d{12}):)?(function:)?(?P<name>[a-zA-Z0-9-_\.]+)(:(?P<qualifier>\$LATEST|[a-zA-Z0-9-_]+))?"
r"(arn:(aws[a-zA-Z-]*):lambda:)?((?P<region>[a-z]{2}(-gov)?-[a-z]+-\d{1}):)?(?:(?P<account>\d{12}):)?(function:)?(?P<name>[a-zA-Z0-9-_\.]+)(:(?P<qualifier>\$LATEST(\.PUBLISHED)?|[a-zA-Z0-9-_]+))?"
) # also length 1-170 incl.
# Pattern for a lambda function handler
HANDLER_REGEX = re.compile(r"[^\s]+")
Expand All @@ -86,9 +89,11 @@
SIGNING_PROFILE_VERSION_ARN_REGEX = re.compile(
r"arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*)"
)
# Combined pattern for alias and version based on AWS error using "(|[a-zA-Z0-9$_-]+)"
QUALIFIER_REGEX = re.compile(r"(^[a-zA-Z0-9$_-]+$)")
# Combined pattern for alias and version based on AWS error using "\\$(LATEST(\\.PUBLISHED)?)|[a-zA-Z0-9-_$]+"
# This regex is based on the snapshotted validation message, just removing the double \\ before $LATEST
QUALIFIER_REGEX = re.compile(r"^\$(LATEST(\\.PUBLISHED)?)|[a-zA-Z0-9-_$]+$")
# Pattern for a version qualifier
# TODO: do we need to consider $LATEST.PUBLISHED here?
VERSION_REGEX = re.compile(r"^[0-9]+$")
# Pattern for an alias qualifier
# Rules: https://docs.aws.amazon.com/lambda/latest/dg/API_CreateAlias.html#SSS-CreateAlias-request-Name
Expand All @@ -107,36 +112,42 @@
# An unordered list of all Lambda CPU architectures supported by LocalStack.
ARCHITECTURES = [Architecture.arm64, Architecture.x86_64]

# ARN pattern returned in validation exception messages.
# Some excpetions from AWS return a '\.' in the function name regex
# pattern therefore we can sub this value in when appropriate.
ARN_NAME_PATTERN_VALIDATION_TEMPLATE = "(arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{{2}}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{{1}}:)?(\\d{{12}}:)?(function:)?([a-zA-Z0-9-_{0}]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?"
# ARN patterns returned in validation exception messages
ARN_NAME_PATTERN_GET = r"(arn:(aws[a-zA-Z-]*)?:lambda:)?((eusc-)?[a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\d{1}:)?(\d{12}:)?(function:)?([a-zA-Z0-9-_\.]+)(:(\$LATEST(\.PUBLISHED)?|[a-zA-Z0-9-_]+))?"
ARN_NAME_PATTERN_CREATE = r"(arn:(aws[a-zA-Z-]*)?:lambda:)?((eusc-)?[a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\d{1}:)?(\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\$LATEST|[a-zA-Z0-9-_]+))?"

# AWS response when invalid ARNs are used in Tag operations.
TAGGABLE_RESOURCE_ARN_PATTERN = "arn:(aws[a-zA-Z-]*):lambda:[a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:\\d{12}:(function:[a-zA-Z0-9-_]+(:(\\$LATEST|[a-zA-Z0-9-_]+))?|layer:([a-zA-Z0-9-_]+)|code-signing-config:csc-[a-z0-9]{17}|event-source-mapping:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"
TAGGABLE_RESOURCE_ARN_PATTERN = "arn:(aws[a-zA-Z-]*):lambda:(eusc-)?[a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:\\d{12}:(function:[a-zA-Z0-9-_]+(:(\\$LATEST|[a-zA-Z0-9-_]+))?|layer:([a-zA-Z0-9-_]+)|code-signing-config:csc-[a-z0-9]{17}|event-source-mapping:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|capacity-provider:[a-zA-Z0-9-_]+)"


def validate_function_name(function_name_or_arn: str, operation_type: str):
function_name, *_ = function_locators_from_arn(function_name_or_arn)
arn_name_pattern = ARN_NAME_PATTERN_VALIDATION_TEMPLATE.format("")
arn_name_pattern = ARN_NAME_PATTERN_CREATE
max_length = 170

match operation_type:
case "GetFunction" | "Invoke":
arn_name_pattern = ARN_NAME_PATTERN_VALIDATION_TEMPLATE.format(r"\.")
case "CreateFunction" if function_name == function_name_or_arn: # only a function name
if operation_type == "GetFunction" or operation_type == "Invoke":
arn_name_pattern = ARN_NAME_PATTERN_GET
elif operation_type == "CreateFunction":
# https://docs.aws.amazon.com/lambda/latest/api/API_CreateFunction.html#lambda-CreateFunction-request-FunctionName
if function_name == function_name_or_arn: # only a function name
max_length = 64
case "CreateFunction" | "DeleteFunction":
else: # full or partial ARN
max_length = 140
elif operation_type == "DeleteFunction":
max_length = 140
arn_name_pattern = ARN_NAME_PATTERN_GET

validations = []
if len(function_name_or_arn) > max_length:
constraint = f"Member must have length less than or equal to {max_length}"
if not AWS_FUNCTION_NAME_REGEX.match(function_name_or_arn) or not function_name:
constraint = f"Member must satisfy regular expression pattern: {arn_name_pattern}"
validation_msg = f"Value '{function_name_or_arn}' at 'functionName' failed to satisfy constraint: {constraint}"
validations.append(validation_msg)
if not operation_type == "CreateFunction":
# Immediately raises rather than summarizing all validations, except for CreateFunction
return validations

if not AWS_FUNCTION_NAME_REGEX.match(function_name_or_arn) or not function_name:
constraint = f"Member must satisfy regular expression pattern: {arn_name_pattern}"
if len(function_name_or_arn) > max_length:
constraint = f"Member must have length less than or equal to {max_length}"
validation_msg = f"Value '{function_name_or_arn}' at 'functionName' failed to satisfy constraint: {constraint}"
validations.append(validation_msg)

Expand All @@ -154,7 +165,7 @@ def validate_qualifier(qualifier: str):
validations.append(validation_msg)

if not QUALIFIER_REGEX.match(qualifier):
constraint = "Member must satisfy regular expression pattern: (|[a-zA-Z0-9$_-]+)"
constraint = "Member must satisfy regular expression pattern: \\$(LATEST(\\.PUBLISHED)?)|[a-zA-Z0-9-_$]+"
validation_msg = (
f"Value '{qualifier}' at 'qualifier' failed to satisfy constraint: {constraint}"
)
Expand Down Expand Up @@ -571,6 +582,12 @@ def map_config_out(
optional_kwargs["CodeSize"] = 0
optional_kwargs["CodeSha256"] = version.config.image.code_sha256

if version.config.CapacityProviderConfig:
optional_kwargs["CapacityProviderConfig"] = version.config.CapacityProviderConfig
data = json.dumps(version.config.CapacityProviderConfig, sort_keys=True).encode("utf-8")
config_sha_256 = hashlib.sha256(data).hexdigest()
optional_kwargs["ConfigSha256"] = config_sha_256

# output for an alias qualifier is completely the same except for the returned ARN
if alias_name:
function_arn = f"{':'.join(version.id.qualified_arn().split(':')[:-1])}:{alias_name}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,12 @@ def start_environment(
self, version_manager_id: str, function_version: FunctionVersion
) -> ExecutionEnvironment:
LOG.debug("Starting new environment")
initialization_type = "on-demand"
if function_version.config.CapacityProviderConfig:
initialization_type = "lambda-managed-instances"
execution_environment = ExecutionEnvironment(
function_version=function_version,
initialization_type="on-demand",
initialization_type=initialization_type,
on_timeout=self.on_timeout,
version_manager_id=version_manager_id,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from threading import RLock, Timer

from localstack import config
from localstack.aws.api.lambda_ import LogFormat
from localstack.aws.connect import connect_to
from localstack.services.lambda_.invocation.lambda_models import (
Credentials,
Expand Down Expand Up @@ -149,6 +150,22 @@ def get_environment_variables(self) -> dict[str, str]:
# LOCALSTACK_USER conditionally added below
}
# Conditionally added environment variables
# Lambda advanced logging controls:
# https://aws.amazon.com/blogs/compute/introducing-advanced-logging-controls-for-aws-lambda-functions/
logging_config = self.function_version.config.logging_config
if logging_config.get("LogFormat") == LogFormat.JSON:
env_vars["AWS_LAMBDA_LOG_FORMAT"] = logging_config["LogFormat"]
# TODO: test this (currently not implemented in LocalStack)
env_vars["AWS_LAMBDA_LOG_LEVEL"] = logging_config["ApplicationLogLevel"].capitalize()
# Lambda Managed Instances
if capacity_provider_config := self.function_version.config.CapacityProviderConfig:
# Disable dropping privileges for parity
# TODO: implement mixed permissions (maybe in RIE)
# env_vars["LOCALSTACK_USER"] = "root"
env_vars["AWS_LAMBDA_MAX_CONCURRENCY"] = capacity_provider_config[
"LambdaManagedInstancesCapacityProviderConfig"
]["PerExecutionEnvironmentMaxConcurrency"]
env_vars["TZ"] = ":/etc/localtime"
if not config.LAMBDA_DISABLE_AWS_ENDPOINT_URL:
env_vars["AWS_ENDPOINT_URL"] = (
f"http://{self.runtime_executor.get_endpoint_from_executor()}:{config.GATEWAY_LISTEN[0].port}"
Expand All @@ -159,8 +176,6 @@ def get_environment_variables(self) -> dict[str, str]:
# Will be overridden by the runtime itself unless it is a provided runtime
if self.function_version.config.runtime:
env_vars["AWS_EXECUTION_ENV"] = "AWS_Lambda_rapid"
if self.function_version.config.environment:
env_vars.update(self.function_version.config.environment)
if config.LAMBDA_INIT_DEBUG:
# Disable dropping privileges because it breaks debugging
env_vars["LOCALSTACK_USER"] = "root"
Expand All @@ -175,6 +190,10 @@ def get_environment_variables(self) -> dict[str, str]:
env_vars["LOCALSTACK_MAX_PAYLOAD_SIZE"] = int(
config.LAMBDA_LIMITS_MAX_FUNCTION_PAYLOAD_SIZE_BYTES
)

# Let users overwrite any environment variable at last (if they want so)
if self.function_version.config.environment:
env_vars.update(self.function_version.config.environment)
return env_vars

# Lifecycle methods
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from abc import ABCMeta, abstractmethod
from datetime import datetime
from pathlib import Path
from typing import IO, Literal, TypedDict
from typing import IO, Literal, Optional, TypedDict

import boto3
from botocore.exceptions import ClientError
Expand All @@ -22,12 +22,19 @@
from localstack.aws.api.lambda_ import (
AllowedPublishers,
Architecture,
CapacityProviderArn,
CapacityProviderConfig,
CapacityProviderPermissionsConfig,
CapacityProviderScalingConfig,
CapacityProviderVpcConfig,
CodeSigningPolicies,
Cors,
DestinationConfig,
FunctionUrlAuthType,
InstanceRequirements,
InvocationType,
InvokeMode,
KMSKeyArn,
LastUpdateStatus,
LoggingConfig,
PackageType,
Expand All @@ -38,12 +45,14 @@
SnapStartResponse,
State,
StateReasonCode,
Timestamp,
TracingMode,
)
from localstack.aws.connect import connect_to
from localstack.constants import AWS_REGION_US_EAST_1, INTERNAL_AWS_SECRET_ACCESS_KEY
from localstack.services.lambda_.api_utils import qualified_lambda_arn, unqualified_lambda_arn
from localstack.utils.archives import unzip
from localstack.utils.files import chmod_r
from localstack.utils.strings import long_uid, short_uid

LOG = logging.getLogger(__name__)
Expand Down Expand Up @@ -212,6 +221,7 @@ def prepare_for_execution(self) -> None:
with tempfile.NamedTemporaryFile() as file:
self._download_archive_to_file(file)
unzip(file.name, str(target_path))
chmod_r(str(target_path), 0o755)

def destroy_cached(self) -> None:
"""
Expand Down Expand Up @@ -331,7 +341,7 @@ class VpcConfig:
@dataclasses.dataclass(frozen=True)
class UpdateStatus:
status: LastUpdateStatus | None
code: str | None = None # TODO: probably not a string
code: str | None = None
reason: str | None = None


Expand Down Expand Up @@ -568,6 +578,9 @@ class VersionFunctionConfiguration:
vpc_config: VpcConfig | None = None

logging_config: LoggingConfig = dataclasses.field(default_factory=dict)
# TODO: why does `CapacityProviderConfig | None = None` fail with on Python 3.13.9:
# TypeError: unsupported operand type(s) for |: 'NoneType' and 'NoneType'
CapacityProviderConfig: Optional[CapacityProviderConfig] = None # noqa


@dataclasses.dataclass(frozen=True)
Expand All @@ -580,6 +593,18 @@ def qualified_arn(self) -> str:
return self.id.qualified_arn()


@dataclasses.dataclass
class CapacityProvider:
CapacityProviderArn: CapacityProviderArn
# State is determined dynamically
VpcConfig: CapacityProviderVpcConfig
PermissionsConfig: CapacityProviderPermissionsConfig
InstanceRequirements: InstanceRequirements
CapacityProviderScalingConfig: CapacityProviderScalingConfig
LastModified: Timestamp
KmsKeyArn: KMSKeyArn | None = None


@dataclasses.dataclass
class Function:
function_name: str
Expand Down
Loading
Loading