-
-
Notifications
You must be signed in to change notification settings - Fork 4.2k
[Lambda DevX] Introducing Lambda Debug Mode Config #11363
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
Changes from all commits
32478c3
8d752ba
d2aac31
d34f369
d683f53
7ab646a
8cefb59
77b2dad
878b776
2c06d40
c280c6e
a99f80b
ae01009
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 |
---|---|---|
|
@@ -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") | ||
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. Can we add all new environment variables to |
||
|
||
# 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", | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
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. nit: could we add a clarifying comment as to why we still set a timeout here in debug mode? You motivated a good reason such that we ensure the hanging container eventually gets cleaned up. 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. It's mentioned in the first sentence above, but might be worth explicitly pointing out why this also applies to debug mode (feel free to ignore if it's too nit-picky; just wanted to perverse our motivation here) 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. The explanation for |
||
else: | ||
# TODO: integration timeouts should be enforced instead. | ||
# Do not wait longer for an invoke than the maximum lambda timeout plus a buffer | ||
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. nit: should we keep the buffer explanation here (it still applies)? |
||
lambda_max_timeout_seconds = 900 | ||
invoke_timeout_buffer_seconds = 5 | ||
741A
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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") | ||
joe4dev marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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): ... | ||
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. Awesome validations and clear error messages 💯 👏👏 |
||
|
||
|
||
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 | ||
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. Can we introduce (or unify) a helper in the //cc @gregfurman @dfangl related to qualifier validation #11366 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. You could use the So your check could be: _, qualifier, *_ = api_utils.function_locators_from_arn(arn)
is_arn_qualified = qualifier != None
# do stuff with the above ... 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. Keep in mind until the above PR is merged in, an invalid/unmatched ARN will return a single 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 would choose to keep the logic of this function I have added. This logic should add the qualifier only to arns that contain all other parts. Although we don't parse or validate each part, for the majority of cases, we'd be adding or not a qualifier to correct arns. Unfortunately, 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. Just to clarify, it's actually AWS that interprets
as
So the I think what you're saying is fair, and I given how much effort the additional validations required, I am also quite pro tackling this via string splitting over regex (where possible). With that said, would you consider adding the above to 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. Thanks for the input! I'll reviews whether that "naming option" is something we should also support in this config file |
||
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we add
LAMBDA_DEBUG_MODE
to theTRACKED_ENV_VAR
inlocalstack-core/localstack/runtime/analytics.py
for telemetry insights?