10000 [Lambda DevX] Introducing Lambda Debug Mode Config by MEPalma · Pull Request #11363 · localstack/localstack · GitHub
[go: up one dir, main page]

Skip to content

[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

Merged
merged 13 commits into from
Aug 28, 2024
5 changes: 5 additions & 0 deletions localstack-core/localstack/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Copy link
Member

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 the TRACKED_ENV_VAR in localstack-core/localstack/runtime/analytics.py for telemetry insights?


# path to the lambda debug mode configuration file.
LAMBDA_DEBUG_MODE_CONFIG_PATH = os.environ.get("LAMBDA_DEBUG_MODE_CONFIG_PATH")
Copy link
Member

Choose a reason for hiding this comment

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

Can we add all new environment variables to CONFIG_ENV_VARS in config.py at the bottom such that they work using the CLI?


# whether to enable debugpy
DEVELOP = is_env_true("DEVELOP")

Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions localstack-core/localstack/runtime/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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)

Copy link
Member

Choose a reason for hiding this comment

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

The explanation for DEFAULT_LAMBDA_DEBUG_MODE_TIMEOUT_SECONDS clarifies 👍

else:
# TODO: integration timeouts should be enforced instead.
# Do not wait longer for an invoke than the maximum lambda timeout plus a buffer
Copy link
Member

Choose a reason for hiding this comment

The 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 Expand Down
Empty file.
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")
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): ...
Copy link
Member

Choose a reason for hiding this comment

The 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
Copy link
Member

Choose a reason for hiding this comment

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

Can we introduce (or unify) a helper in the api_utils to detect qualified vs. unqualified lambda ARNs?
Potentially worth adding a few unit tests.

//cc @gregfurman @dfangl related to qualifier validation #11366

Copy link
Contributor

Choose a reason for hiding this comment

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

You could use the api_utils.function_locators_from_arn which will return a tuple of (name, qualifier, account, region) from a different schema of ARN (qualified, unqualified, full, partial, just function name, etc).

So your check could be:

_, qualifier, *_ = api_utils.function_locators_from_arn(arn) 
is_arn_qualified = qualifier != None
# do stuff with the above ...

Copy link
Contributor

Choose a reason for hiding this comment

The 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 None (not a tuple) which could break some things.

Copy link
Contributor Author
@MEPalma MEPalma Aug 26, 2024

Choose a reason for hiding this comment

The 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, function_locators_from_arn is not strict enough with identifying the parts. for example for arn:aws:lambda:eu-central-1:000000000000:function it selects function as the name, and null as the qualifier. Not only is it not correct (the function name is not actually given), but it also means that this logic would continue to produce arn:aws:lambda:eu-central-1:000000000000:function:$LATEST, which seems a bit unfortunate. _to_qualified_lambda_function_arn would instead raise an UnknownLambdaArnFormat in this case, which would help the user getting the arn fixed (adding the name and/or the qualifier). function_locators_from_arn would is also too compliant with arns ending with empty qualifiers arn:aws:lambda:eu-central-1:000000000000:function:functionname:. Please do correct me if I have missing something! Otherwise, I think _to_qualified_lambda_function_arn is specific enough to this workflow that can be left as is. At the same time, I am not sure of the consequences of adding stricter checks to function_locators_from_arn.

Copy link
Contributor

Choose a reason for hiding this comment

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

Just to clarify, it's actually AWS that interprets

arn:<partition>:lambda:<region>:≤account-id>:function

as

arn:<partition>:lambda:<region>:<account-id>:function:function

So the function_locators_from_arn is keeping parity 🫠

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 api_utils? There are a couple places where I believe it could be useful in the provider.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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
Loading
Loading
0