Skip to content

[Lambda DevX] Support for Config Hot Reloading #10

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

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
InitializationType,
OtherServiceEndpoint,
)
from localstack.utils.lambda_debug_mode.lambda_debug_mode import is_lambda_debug_timeout_enabled_for

LOG = logging.getLogger(__name__)

Expand Down Expand Up @@ -75,7 +76,10 @@ def get_environment(

try:
yield execution_environment
execution_environment.release()
if is_lambda_debug_timeout_enabled_for(lambda_arn=function_version.qualified_arn):
self.stop_environment(execution_environment)
else:
execution_environment.release()
except InvalidStatusException as invalid_e:
LOG.error("InvalidStatusException: %s", invalid_e)
except Exception as e:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from __future__ import annotations

import logging
import os
import time
from threading import Event, Thread
from typing import Optional

from localstack.aws.api.lambda_ import Arn
Expand All @@ -11,61 +14,135 @@
load_lambda_debug_mode_config,
)
from localstack.utils.objects import singleton_factory
from localstack.utils.threads import TMP_THREADS

LOG = logging.getLogger(__name__)


class LambdaDebugModeSession:
_is_lambda_debug_mode: bool

_configuration_file_path: Optional[str]
_watch_thread: Optional[Thread]
_initialised_event: Optional[Event]
_config: Optional[LambdaDebugModeConfig]

def __init__(self):
self._is_lambda_debug_mode = bool(LAMBDA_DEBUG_MODE)
self._configuration = self._load_lambda_debug_mode_config()

# Disabled Lambda Debug Mode state initialisation.
self._configuration_file_path = None
self._watch_thread = None
self._initialised_event = None
self._config = None

# Lambda Debug Mode is not enabled: leave as disabled state and return.
if not self._is_lambda_debug_mode:
return

# Lambda Debug Mode is enabled.
# Instantiate the configuration requirements if a configuration file is given.
self._configuration_file_path = LAMBDA_DEBUG_MODE_CONFIG_PATH
if not self._configuration_file_path:
return

# A configuration file path is given: initialised the resources to load and watch the file.

# Signal and block on first loading to ensure this is enforced from the very first
# invocation, as this module is not loaded at startup. The LambdaDebugModeConfigWatch
# thread will then take care of updating the configuration periodically and asynchronously.
# This may somewhat slow down the first upstream thread loading this module, but not
# future calls. On the other hand, avoiding this mechanism means that first Lambda calls
# occur with no Debug configuration.
self._initialised_event = Event()

self._watch_thread = Thread(
target=self._watch_logic, args=(), daemon=True, name="LambdaDebugModeConfigWatch"
)
TMP_THREADS.append(self._watch_thread)
self._watch_thread.start()

@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

def _load_lambda_debug_mode_config(self):
yaml_configuration_string = None
try:
with open(file_path, "r") as df:
with open(self._configuration_file_path, "r") as df:
yaml_configuration_string = df.read()
except FileNotFoundError:
LOG.error("Error: The file lambda debug config " "file '%s' was not found.", file_path)
LOG.error(
"Error: The file lambda debug config " "file '%s' was not found.",
self._configuration_file_path,
)
except IsADirectoryError:
LOG.error(
"Error: Expected a lambda debug config file " "but found a directory at '%s'.",
file_path,
self._configuration_file_path,
)
except PermissionError:
LOG.error(
"Error: Permission denied while trying to read "
"the lambda debug config file '%s'.",
file_path,
self._configuration_file_path,
)
except Exception as ex:
LOG.error(
"Error: An unexpected error occurred while reading "
"lambda debug config '%s': '%s'",
file_path,
self._configuration_file_path,
ex,
)
if not yaml_configuration_string:
return None

config = load_lambda_debug_mode_config(yaml_configuration_string)
return config
self._config = load_lambda_debug_mode_config(yaml_configuration_string)
if self._config is not None:
LOG.info("Lambda Debug Mode is now enforcing the latest configuration.")
else:
LOG.warning(
"Lambda Debug Mode could not load the latest configuration due to an error, "
"check logs for more details."
)

def _config_file_epoch_last_modified_or_now(self) -> int:
try:
modified_time = os.path.getmtime(self._configuration_file_path)
return int(modified_time)
except Exception as e:
print("Lambda Debug Mode could not access the configuration file: %s", e)
epoch_now = int(time.time())
Comment on lines +115 to +117
Copy link

Choose a reason for hiding this comment

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

style: Use LOG.error instead of print for consistency with error handling elsewhere in the file

return epoch_now

def _watch_logic(self) -> None:
# TODO: consider relying on system calls (watchdog lib for cross-platform support)
# instead of monitoring last modified dates.
# Run the first load and signal as initialised.
epoch_last_loaded: int = self._config_file_epoch_last_modified_or_now()
self._load_lambda_debug_mode_config()
self._initialised_event.set()

# Monitor for file changes in an endless loop: this logic should be started as a daemon.
while True:
time.sleep(1)
epoch_last_modified = self._config_file_epoch_last_modified_or_now()
if epoch_last_modified > epoch_last_loaded:
epoch_last_loaded = epoch_last_modified
self._load_lambda_debug_mode_config()

def _get_initialised_config(self) -> Optional[LambdaDebugModeConfig]:
# Check the session is not initialising, and if so then wait for initialisation to finish.
# Note: the initialisation event is otherwise left set since after first initialisation has terminated.
if self._initialised_event is not None:
self._initialised_event.wait()
return self._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
config = self._get_initialised_config()
return config.functions.get(lambda_arn) if config else None