diff --git a/localstack-core/localstack/config.py b/localstack-core/localstack/config.py index f0bcea91ca21f..f5e776de2e8bd 100644 --- a/localstack-core/localstack/config.py +++ b/localstack-core/localstack/config.py @@ -433,6 +433,9 @@ def in_docker(): # When enabled it triggers specialised workflows for the debugging. LAMBDA_DEBUG_MODE = is_env_true("LAMBDA_DEBUG_MODE") +# path to the lambda debug mode configuration file. +LAMBDA_DEBUG_MODE_CONFIG_PATH = os.environ.get("LAMBDA_DEBUG_MODE_CONFIG_PATH") + # whether to enable debugpy DEVELOP = is_env_true("DEVELOP") @@ -1203,6 +1206,8 @@ def use_custom_dns(): "KINESIS_MOCK_LOG_LEVEL", "KINESIS_ON_DEMAND_STREAM_COUNT_LIMIT", "KINESIS_PERSISTENCE", + "LAMBDA_DEBUG_MODE", + "LAMBDA_DEBUG_MODE_CONFIG", "LAMBDA_DISABLE_AWS_ENDPOINT_URL", "LAMBDA_DOCKER_DNS", "LAMBDA_DOCKER_FLAGS", diff --git a/localstack-core/localstack/runtime/analytics.py b/localstack-core/localstack/runtime/analytics.py index f14efe95b9bc4..94bba45cda138 100644 --- a/localstack-core/localstack/runtime/analytics.py +++ b/localstack-core/localstack/runtime/analytics.py @@ -23,6 +23,8 @@ "KINESIS_PROVIDER", # Not functional; deprecated in 2.0.0, removed in 3.0.0 "KINESIS_ERROR_PROBABILITY", "KMS_PROVIDER", + "LAMBDA_DEBUG_MODE", + "LAMBDA_DEBUG_MODE_CONFIG_PATH", "LAMBDA_DOWNLOAD_AWS_LAYERS", "LAMBDA_EXECUTOR", # Not functional; deprecated in 2.0.0, removed in 3.0.0 "LAMBDA_STAY_OPEN_MODE", # Not functional; deprecated in 2.0.0, removed in 3.0.0 diff --git a/localstack-core/localstack/services/lambda_/invocation/docker_runtime_executor.py b/localstack-core/localstack/services/lambda_/invocation/docker_runtime_executor.py index aa66d6ceea804..d8aa7ff3ae345 100644 --- a/localstack-core/localstack/services/lambda_/invocation/docker_runtime_executor.py +++ b/localstack-core/localstack/services/lambda_/invocation/docker_runtime_executor.py @@ -43,6 +43,7 @@ ) from localstack.utils.docker_utils import DOCKER_CLIENT as CONTAINER_CLIENT from localstack.utils.files import chmod_r, rm_rf +from localstack.utils.lambda_debug_mode.lambda_debug_mode import lambda_debug_port_for from localstack.utils.net import get_free_tcp_port from localstack.utils.strings import short_uid, truncate @@ -319,6 +320,10 @@ def start(self, env_vars: dict[str, str]) -> None: platform=docker_platform(self.function_version.config.architectures[0]), additional_flags=config.LAMBDA_DOCKER_FLAGS, ) + debug_port = lambda_debug_port_for(self.function_version.qualified_arn) + if debug_port is not None: + container_config.ports.add(debug_port, debug_port) + if self.function_version.config.package_type == PackageType.Zip: if self.function_version.config.code.is_hot_reloading(): container_config.env_vars[HOT_RELOADING_ENV_VARIABLE] = "/var/task" diff --git a/localstack-core/localstack/services/lambda_/invocation/execution_environment.py b/localstack-core/localstack/services/lambda_/invocation/execution_environment.py index ba958f2c58ce6..b429eca020c86 100644 --- a/localstack-core/localstack/services/lambda_/invocation/execution_environment.py +++ b/localstack-core/localstack/services/lambda_/invocation/execution_environment.py @@ -23,9 +23,9 @@ RuntimeExecutor, get_runtime_executor, ) -from localstack.utils.lambda_debug_mode import ( +from localstack.utils.lambda_debug_mode.lambda_debug_mode import ( DEFAULT_LAMBDA_DEBUG_MODE_TIMEOUT_SECONDS, - is_lambda_debug_mode, + is_lambda_debug_timeout_enabled_for, ) from localstack.utils.strings import to_str @@ -399,6 +399,6 @@ def _get_execution_timeout_seconds(self) -> int: # Returns the timeout value in seconds to be enforced during the execution of the # lambda function. This is the configured value or the DEBUG MODE default if this # is enabled. - if is_lambda_debug_mode(): + if is_lambda_debug_timeout_enabled_for(self.function_version.qualified_arn): return DEFAULT_LAMBDA_DEBUG_MODE_TIMEOUT_SECONDS return self.function_version.config.timeout diff --git a/localstack-core/localstack/services/lambda_/invocation/executor_endpoint.py b/localstack-core/localstack/services/lambda_/invocation/executor_endpoint.py index a23c19f383d66..757dab5d08324 100644 --- a/localstack-core/localstack/services/lambda_/invocation/executor_endpoint.py +++ b/localstack-core/localstack/services/lambda_/invocation/executor_endpoint.py @@ -10,7 +10,7 @@ from localstack.http import Response, route from localstack.services.edge import ROUTER from localstack.services.lambda_.invocation.lambda_models import InvocationResult -from localstack.utils.lambda_debug_mode import ( +from localstack.utils.lambda_debug_mode.lambda_debug_mode import ( DEFAULT_LAMBDA_DEBUG_MODE_TIMEOUT_SECONDS, is_lambda_debug_mode, ) @@ -197,10 +197,18 @@ def invoke(self, payload: Dict[str, str]) -> InvocationResult: raise InvokeSendError( f"Error while sending invocation {payload} to {invocation_url}. Error Code: {response.status_code}" ) + + # Set a reference future awaiting limit to ensure this process eventually ends, + # with timeout errors being handled by the lambda evaluator. + # The following logic selects which maximum waiting time to consider depending + # on whether the application is being debugged or not. + # Note that if timeouts are enforced for the lambda function invoked at this endpoint + # (this is needs to be configured in the Lambda Debug Mode Config file), the lambda + # function will continue to enforce the expected timeouts. if is_lambda_debug_mode(): + # The value is set to a default high value to ensure eventual termination. timeout_seconds = DEFAULT_LAMBDA_DEBUG_MODE_TIMEOUT_SECONDS else: - # TODO: integration timeouts should be enforced instead. # Do not wait longer for an invoke than the maximum lambda timeout plus a buffer lambda_max_timeout_seconds = 900 invoke_timeout_buffer_seconds = 5 diff --git a/localstack-core/localstack/utils/lambda_debug_mode/__init__.py b/localstack-core/localstack/utils/lambda_debug_mode/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/utils/lambda_debug_mode/lambda_debug_mode.py b/localstack-core/localstack/utils/lambda_debug_mode/lambda_debug_mode.py new file mode 100644 index 0000000000000..2a34dfddfbe32 --- /dev/null +++ b/localstack-core/localstack/utils/lambda_debug_mode/lambda_debug_mode.py @@ -0,0 +1,31 @@ +from typing import Optional + +from localstack.aws.api.lambda_ import Arn +from localstack.utils.lambda_debug_mode.lambda_debug_mode_session import LambdaDebugModeSession + +# Specifies the fault timeout value in seconds to be used by time restricted workflows when +# Debug Mode is enabled. The value is set to one hour to ensure eventual termination of +# long-running processes. +DEFAULT_LAMBDA_DEBUG_MODE_TIMEOUT_SECONDS: int = 3_600 + + +def is_lambda_debug_mode() -> bool: + return LambdaDebugModeSession.get().is_lambda_debug_mode() + + +def lambda_debug_port_for(lambda_arn: Arn) -> Optional[int]: + if not is_lambda_debug_mode(): + return None + debug_configuration = LambdaDebugModeSession.get().debug_config_for(lambda_arn=lambda_arn) + if debug_configuration is None: + return None + return debug_configuration.debug_port + + +def is_lambda_debug_timeout_enabled_for(lambda_arn: Arn) -> bool: + if not is_lambda_debug_mode(): + return False + debug_configuration = LambdaDebugModeSession.get().debug_config_for(lambda_arn=lambda_arn) + if debug_configuration is None: + return False + return not debug_configuration.enforce_timeouts diff --git a/localstack-core/localstack/utils/lambda_debug_mode/lambda_debug_mode_config.py b/localstack-core/localstack/utils/lambda_debug_mode/lambda_debug_mode_config.py new file mode 100644 index 0000000000000..2fb2c2aae5361 --- /dev/null +++ b/localstack-core/localstack/utils/lambda_debug_mode/lambda_debug_mode_config.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import logging +from typing import Optional + +import yaml +from pydantic import BaseModel, Field, ValidationError +from yaml import Loader, MappingNode, MarkedYAMLError, SafeLoader + +from localstack.aws.api.lambda_ import Arn + +LOG = logging.getLogger(__name__) + + +class LambdaDebugConfig(BaseModel): + debug_port: Optional[int] = Field(None, alias="debug-port") + enforce_timeouts: bool = Field(False, alias="enforce-timeouts") + + +class LambdaDebugModeConfig(BaseModel): + # Bindings of Lambda function Arn and the respective debugging configuration. + functions: dict[Arn, LambdaDebugConfig] + + +class LambdaDebugModeConfigException(Exception): ... + + +class UnknownLambdaArnFormat(LambdaDebugModeConfigException): + unknown_lambda_arn: str + + def __init__(self, unknown_lambda_arn: str): + self.unknown_lambda_arn = unknown_lambda_arn + + def __str__(self): + return f"UnknownLambdaArnFormat: '{self.unknown_lambda_arn}'" + + +class PortAlreadyInUse(LambdaDebugModeConfigException): + port_number: int + + def __init__(self, port_number: int): + self.port_number = port_number + + def __str__(self): + return f"PortAlreadyInUse: '{self.port_number}'" + + +class DuplicateLambdaDebugConfig(LambdaDebugModeConfigException): + lambda_arn_debug_config_first: str + lambda_arn_debug_config_second: str + + def __init__(self, lambda_arn_debug_config_first: str, lambda_arn_debug_config_second: str): + self.lambda_arn_debug_config_first = lambda_arn_debug_config_first + self.lambda_arn_debug_config_second = lambda_arn_debug_config_second + + def __str__(self): + return ( + f"DuplicateLambdaDebugConfig: Lambda debug configuration in '{self.lambda_arn_debug_config_first}' " + f"is redefined in '{self.lambda_arn_debug_config_second}'" + ) + + +class _LambdaDebugModeConfigPostProcessingState: + ports_used: set[int] + + def __init__(self): + self.ports_used = set() + + +class _SafeLoaderWithDuplicateCheck(SafeLoader): + def __init__(self, stream): + super().__init__(stream) + self.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, + self._construct_mappings_with_duplicate_check, + ) + + @staticmethod + def _construct_mappings_with_duplicate_check(loader: Loader, node: MappingNode, deep=False): + # Constructs yaml bindings, whilst checking for duplicate mapping key definitions, raising a + # MarkedYAMLError when one is found. + mapping = dict() + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in mapping: + # Create a MarkedYAMLError to indicate the duplicate key issue + raise MarkedYAMLError( + context="while constructing a mapping", + context_mark=node.start_mark, + problem=f"found duplicate key: {key}", + problem_mark=key_node.start_mark, + ) + value = loader.construct_object(value_node, deep=deep) + mapping[key] = value + return mapping + + +def from_yaml_string(yaml_string: str) -> Optional[LambdaDebugModeConfig]: + try: + data = yaml.load(yaml_string, _SafeLoaderWithDuplicateCheck) + except yaml.YAMLError as yaml_error: + LOG.error( + f"Could not parse yaml lambda debug mode configuration file due to: {str(yaml_error)}" + ) + data = None + if not data: + return None + config = LambdaDebugModeConfig(**data) + return config + + +def post_process_lambda_debug_mode_config(config: LambdaDebugModeConfig) -> None: + _post_process_lambda_debug_mode_config( + post_processing_state=_LambdaDebugModeConfigPostProcessingState(), config=config + ) + + +def _post_process_lambda_debug_mode_config( + post_processing_state: _LambdaDebugModeConfigPostProcessingState, config: LambdaDebugModeConfig +): + config_functions = config.functions + lambda_arns = list(config_functions.keys()) + for lambda_arn in lambda_arns: + qualified_lambda_arn = _to_qualified_lambda_function_arn(lambda_arn) + if lambda_arn != qualified_lambda_arn: + if qualified_lambda_arn in config_functions: + raise DuplicateLambdaDebugConfig( + lambda_arn_debug_config_first=lambda_arn, + lambda_arn_debug_config_second=qualified_lambda_arn, + ) + config_functions[qualified_lambda_arn] = config_functions.pop(lambda_arn) + + for lambda_arn, lambda_debug_config in config_functions.items(): + _post_process_lambda_debug_config( + post_processing_state=post_processing_state, lambda_debug_config=lambda_debug_config + ) + + +def _to_qualified_lambda_function_arn(lambda_arn: Arn) -> Arn: + """ + Returns the $LATEST qualified version of a structurally unqualified version of a lambda Arn iff this + is detected to be structurally unqualified. Otherwise, it returns the given string. + Example: + - arn:aws:lambda:eu-central-1:000000000000:function:functionname:$LATEST -> + unchanged + + - arn:aws:lambda:eu-central-1:000000000000:function:functionname -> + arn:aws:lambda:eu-central-1:000000000000:function:functionname:$LATEST + + - arn:aws:lambda:eu-central-1:000000000000:function:functionname: -> + exception UnknownLambdaArnFormat + + - arn:aws:lambda:eu-central-1:000000000000:function -> + exception UnknownLambdaArnFormat + """ + + if not lambda_arn: + return lambda_arn + lambda_arn_parts = lambda_arn.split(":") + lambda_arn_parts_len = len(lambda_arn_parts) + + # The arn is qualified and with a non-empy qualifier. + is_qualified = lambda_arn_parts_len == 8 + if is_qualified and lambda_arn_parts[-1]: + return lambda_arn + + # Unknown lambda arn format. + is_unqualified = lambda_arn_parts_len == 7 + if not is_unqualified: + raise UnknownLambdaArnFormat(unknown_lambda_arn=lambda_arn) + + # Structure-wise, the arn is missing the qualifier. + qualifier = "$LATEST" + arn_tail = f":{qualifier}" if is_unqualified else qualifier + qualified_lambda_arn = lambda_arn + arn_tail + return qualified_lambda_arn + + +def _post_process_lambda_debug_config( + post_processing_state: _LambdaDebugModeConfigPostProcessingState, + lambda_debug_config: LambdaDebugConfig, +) -> None: + debug_port: Optional[int] = lambda_debug_config.debug_port + if debug_port is None: + return + if debug_port in post_processing_state.ports_used: + raise PortAlreadyInUse(port_number=debug_port) + post_processing_state.ports_used.add(debug_port) + + +def load_lambda_debug_mode_config(yaml_string: str) -> Optional[LambdaDebugModeConfig]: + # Attempt to parse the yaml string. + try: + yaml_data = yaml.load(yaml_string, _SafeLoaderWithDuplicateCheck) + except yaml.YAMLError as yaml_error: + LOG.error( + f"Could not parse yaml lambda debug mode configuration file due to: {str(yaml_error)}" + ) + yaml_data = None + if not yaml_data: + return None + + # Attempt to build the LambdaDebugModeConfig object from the yaml object. + try: + config = LambdaDebugModeConfig(**yaml_data) + except ValidationError as validation_error: + validation_errors = validation_error.errors() or list() + error_messages = [ + f"When parsing '{err.get('loc', '')}': {err.get('msg', str(err))}" + for err in validation_errors + ] + LOG.error( + f"Unable to parse lambda debug mode configuration file due to errors: {error_messages}" + ) + return None + + # Attempt to post_process the configuration. + try: + post_process_lambda_debug_mode_config(config) + except LambdaDebugModeConfigException as lambda_debug_mode_error: + LOG.error(f"Invalid lambda debug mode configuration due to: {lambda_debug_mode_error}") + config = None + + return config diff --git a/localstack-core/localstack/utils/lambda_debug_mode/lambda_debug_mode_session.py b/localstack-core/localstack/utils/lambda_debug_mode/lambda_debug_mode_session.py new file mode 100644 index 0000000000000..52b895f68def2 --- /dev/null +++ b/localstack-core/localstack/utils/lambda_debug_mode/lambda_debug_mode_session.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import logging +from typing import Optional + +from localstack.aws.api.lambda_ import Arn +from localstack.config import LAMBDA_DEBUG_MODE, LAMBDA_DEBUG_MODE_CONFIG_PATH +from localstack.utils.lambda_debug_mode.lambda_debug_mode_config import ( + LambdaDebugConfig, + LambdaDebugModeConfig, + load_lambda_debug_mode_config, +) +from localstack.utils.objects import singleton_factory + +LOG = logging.getLogger(__name__) + + +class LambdaDebugModeSession: + _is_lambda_debug_mode: bool + _config: Optional[LambdaDebugModeConfig] + + def __init__(self): + self._is_lambda_debug_mode = bool(LAMBDA_DEBUG_MODE) + self._configuration = self._load_lambda_debug_mode_config() + + @staticmethod + @singleton_factory + def get() -> LambdaDebugModeSession: + """Returns a singleton instance of the Lambda Debug Mode session.""" + return LambdaDebugModeSession() + + def _load_lambda_debug_mode_config(self) -> Optional[LambdaDebugModeConfig]: + file_path = LAMBDA_DEBUG_MODE_CONFIG_PATH + if not self._is_lambda_debug_mode or file_path is None: + return None + + yaml_configuration_string = None + try: + with open(file_path, "r") as df: + yaml_configuration_string = df.read() + except FileNotFoundError: + LOG.error(f"Error: The file lambda debug config " f"file '{file_path}' was not found.") + except IsADirectoryError: + LOG.error( + f"Error: Expected a lambda debug config file " + f"but found a directory at '{file_path}'." + ) + except PermissionError: + LOG.error( + f"Error: Permission denied while trying to read " + f"the lambda debug config file '{file_path}'." + ) + except Exception as ex: + LOG.error( + f"Error: An unexpected error occurred while reading " + f"lambda debug config '{file_path}': '{ex}'" + ) + if not yaml_configuration_string: + return None + + config = load_lambda_debug_mode_config(yaml_configuration_string) + return config + + def is_lambda_debug_mode(self) -> bool: + return self._is_lambda_debug_mode + + def debug_config_for(self, lambda_arn: Arn) -> Optional[LambdaDebugConfig]: + return self._configuration.functions.get(lambda_arn) if self._configuration else None diff --git a/tests/unit/lambda_debug_mode/__init__.py b/tests/unit/lambda_debug_mode/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/lambda_debug_mode/test_config_parsing.py b/tests/unit/lambda_debug_mode/test_config_parsing.py new file mode 100644 index 0000000000000..5dd4ad45468c5 --- /dev/null +++ b/tests/unit/lambda_debug_mode/test_config_parsing.py @@ -0,0 +1,134 @@ +import pytest + +from localstack.utils.lambda_debug_mode.lambda_debug_mode_config import ( + load_lambda_debug_mode_config, +) + +DEBUG_CONFIG_EMPTY = "" + +DEBUG_CONFIG_NULL_FUNCTIONS = """ +functions: + null +""" + +DEBUG_CONFIG_NULL_FUNCTION_CONFIG = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function:functionname:$LATEST: + null +""" + +DEBUG_CONFIG_NULL_DEBUG_PORT = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function:functionname: + debug-port: null +""" + +DEBUG_CONFIG_NULL_ENFORCE_TIMEOUTS = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function:functionname: + debug-port: null + enforce-timeouts: null +""" + +DEBUG_CONFIG_DUPLICATE_DEBUG_PORT = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function:functionname1: + debug-port: 19891 + arn:aws:lambda:eu-central-1:000000000000:function:functionname2: + debug-port: 19891 +""" + +DEBUG_CONFIG_DUPLICATE_ARN = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function:functionname: + debug-port: 19891 + arn:aws:lambda:eu-central-1:000000000000:function:functionname: + debug-port: 19892 +""" + +DEBUG_CONFIG_INVALID_MISSING_QUALIFIER_ARN = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function:functionname:: + debug-port: 19891 +""" + +DEBUG_CONFIG_INVALID_ARN_STRUCTURE = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function: + debug-port: 19891 +""" + +DEBUG_CONFIG_DUPLICATE_IMPLICIT_ARN = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function:functionname: + debug-port: 19891 + arn:aws:lambda:eu-central-1:000000000000:function:functionname:$LATEST: + debug-port: 19892 +""" + +DEBUG_CONFIG_BASE = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function:functionname:$LATEST: + debug-port: 19891 +""" + +DEBUG_CONFIG_BASE_UNQUALIFIED = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function:functionname: + debug-port: 19891 +""" + + +@pytest.mark.parametrize( + "yaml_config", + [ + DEBUG_CONFIG_EMPTY, + DEBUG_CONFIG_NULL_FUNCTIONS, + DEBUG_CONFIG_NULL_FUNCTION_CONFIG, + DEBUG_CONFIG_DUPLICATE_DEBUG_PORT, + DEBUG_CONFIG_DUPLICATE_ARN, + DEBUG_CONFIG_DUPLICATE_IMPLICIT_ARN, + DEBUG_CONFIG_NULL_ENFORCE_TIMEOUTS, + DEBUG_CONFIG_INVALID_ARN_STRUCTURE, + DEBUG_CONFIG_INVALID_MISSING_QUALIFIER_ARN, + ], + ids=[ + "empty", + "null_functions", + "null_function_config", + "duplicate_debug_port", + "deplicate_arn", + "duplicate_implicit_arn", + "null_enforce_timeouts", + "invalid_arn_structure", + "invalid_missing_qualifier_arn", + ], +) +def test_debug_config_invalid(yaml_config: str): + assert load_lambda_debug_mode_config(yaml_config) is None + + +def test_debug_config_null_debug_port(): + config = load_lambda_debug_mode_config(DEBUG_CONFIG_NULL_DEBUG_PORT) + assert list(config.functions.values())[0].debug_port is None + + +@pytest.mark.parametrize( + "yaml_config", + [ + DEBUG_CONFIG_BASE, + DEBUG_CONFIG_BASE_UNQUALIFIED, + ], + ids=[ + "base", + "base_unqualified", + ], +) +def test_debug_config_base(yaml_config): + config = load_lambda_debug_mode_config(yaml_config) + assert len(config.functions) == 1 + assert ( + "arn:aws:lambda:eu-central-1:000000000000:function:functionname:$LATEST" in config.functions + ) + assert list(config.functions.values())[0].debug_port == 19891 + assert list(config.functions.values())[0].enforce_timeouts is False