Repository: localstack/localstack

Issue:
Changing LAMBDA_EXECUTOR at runtime with ENABLE_CONFIG_UPDATES=1 does not work
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

First of all, I'd like to thank you for this awesome project. Keep up the good work.

Now, to business. I have a script that tries to set the executor at runtime (from `docker-reuse` to `docker` and back again at the end) for a very specific use case. In my `docker-compose` file, I have set the `ENABLE_CONFIG_UPDATES=1` and upon POSTing the request to `/?_config_`, I see the following log message in the logs:

```shell
localstack_1  | 2021-07-26T07:03:35:INFO:localstack.services.infra: Updating value of config variable "LAMBDA_EXECUTOR": docker
````
That said, I was expecting that the next invocation of a Lambda would be using its own container, which it doesn't:

```
localstack.services.awslambda.lambda_executors: Command for docker-reuse Lambda executor:
```

### Expected Behavior

The next invocation of a Lambda would be in its own container.

### How are you starting LocalStack?

With a docker-compose file

### Steps To Reproduce

1. Create a simple `docker-compose.yml` like so:
    ```yaml
    localstack:
      image: localstack/localstack:0.12.15
      ports:
        - "4566:4566"
      environment:
        SERVICES: lambda,cloudformation,s3,cloudwatch,iam,apigateway
        DEFAULT_REGION: eu-west-1
        HOSTNAME_EXTERNAL: localstack
        LAMBDA_EXECUTOR: docker-reuse
        LAMBDA_DOCKER_NETWORK: offline-environment_default
        LAMBDA_REMOTE_DOCKER: 0
        DOCKER_HOST: unix:///var/run/docker.sock
        DEBUG: 1
        ENABLE_CONFIG_UPDATES: 1
        HOST_TMP_FOLDER: /private${TMPDIR}/localstack
        AWS_ACCESS_KEY_ID: test
        AWS_SECRET_ACCESS_KEY: test
      volumes:
        - "./localstack/scripts:/docker-entrypoint-initaws.d"
        - "./localstack/cloudformation:/cloudformation"
        - "/var/run/docker.sock:/var/run/docker.sock"
        - "/private${TMPDIR}/localstack:/tmp/localstack"
    ```
2. Create a CloudFormation template with a Lambda and an S3 bucket (and their required permissions) and place it under the `localstack/cloudformation` directory. The Lambda should be triggered by an S3 object created event (may not matter). 
3. Create a bash script that deploys said CloudFormation template and place it under the `localstack/scripts` directory with the prefix `1_`.
4. Create another bash script that changes the value of `LAMBDA_EXECUTOR` to `docker` (via POSTing to `/?_config_`) and copies a file into the bucket. Again, place the script under the `localstack/scripts` directory with the prefix `2_`.
5. Start the `docker-compose.yml`:
    ```shell
    docker-compose-up
    ```
6. Inspect the logs and verify that everything was deployed correctly and that you see the message `Updating value of config variable "LAMBDA_EXECUTOR": docker`
7. Open the Docker Dashboard or issue the `docker ps` command to confirm that Localstack invoked the Lambda using the `docker-reuse` executor. Can also check the logs and verify that the following message appears:
    ```shell
    localstack.services.awslambda.lambda_executors: Command for docker-reuse Lambda executor:
    ```


### Environment

```markdown
- OS: macOS Big Sur 11.5
- LocalStack: 0.12.15
```


Discussion:

Resolving patch:
diff --git a/localstack/services/awslambda/lambda_api.py b/localstack/services/awslambda/lambda_api.py
--- a/localstack/services/awslambda/lambda_api.py
+++ b/localstack/services/awslambda/lambda_api.py
@@ -2240,3 +2240,29 @@ def serve(port):
     LAMBDA_EXECUTOR.startup()
 
     generic_proxy.serve_flask_app(app=app, port=port)
+
+
+# Config listener
+def on_config_change(config_key: str, config_newvalue: str) -> None:
+    global LAMBDA_EXECUTOR
+    if config_key != "LAMBDA_EXECUTOR":
+        return
+    LOG.debug(
+        "Recieved config event for lambda executor! Key: {} Value: {}".format(
+            config_key, config_newvalue
+        )
+    )
+    LAMBDA_EXECUTOR.cleanup()
+    LAMBDA_EXECUTOR = lambda_executors.AVAILABLE_EXECUTORS.get(
+        config_newvalue, lambda_executors.DEFAULT_EXECUTOR
+    )
+    LAMBDA_EXECUTOR.startup()
+
+
+def register_config_listener():
+    from localstack.utils import config_listener
+
+    config_listener.CONFIG_LISTENERS.append(on_config_change)
+
+
+register_config_listener()
diff --git a/localstack/services/infra.py b/localstack/services/infra.py
--- a/localstack/services/infra.py
+++ b/localstack/services/infra.py
@@ -1,4 +1,3 @@
-import json
 import logging
 import os
 import re
@@ -11,7 +10,6 @@
 
 import boto3
 from moto import core as moto_core
-from requests.models import Response
 
 from localstack import config, constants
 from localstack.constants import (
@@ -25,14 +23,14 @@
 from localstack.services.cloudformation import cloudformation_api
 from localstack.services.dynamodbstreams import dynamodbstreams_api
 from localstack.services.firehose import firehose_api
-from localstack.services.generic_proxy import ProxyListener, start_proxy_server
+from localstack.services.generic_proxy import start_proxy_server
 from localstack.services.plugins import (
     SERVICE_PLUGINS,
     check_infra,
     record_service_health,
     wait_for_infra_shutdown,
 )
-from localstack.utils import common, persistence
+from localstack.utils import common, config_listener, persistence
 from localstack.utils.analytics import event_publisher
 from localstack.utils.analytics.profiler import log_duration
 from localstack.utils.bootstrap import canonicalize_api_names, in_ci, load_plugins, setup_logging
@@ -73,42 +71,8 @@
 SHUTDOWN_INFRA = threading.Event()
 
 
-# -----------------------
-# CONFIG UPDATE BACKDOOR
-# -----------------------
-
-
-def update_config_variable(variable, new_value):
-    if new_value is not None:
-        LOG.info('Updating value of config variable "%s": %s' % (variable, new_value))
-        setattr(config, variable, new_value)
-
-
-class ConfigUpdateProxyListener(ProxyListener):
-    """Default proxy listener that intercepts requests to retrieve or update config variables."""
-
-    def forward_request(self, method, path, data, headers):
-        if path != constants.CONFIG_UPDATE_PATH or method != "POST":
-            return True
-        response = Response()
-        data = json.loads(data)
-        variable = data.get("variable", "")
-        response._content = "{}"
-        response.status_code = 200
-        if not re.match(r"^[_a-zA-Z0-9]+$", variable):
-            response.status_code = 400
-            return response
-        new_value = data.get("value")
-        update_config_variable(variable, new_value)
-        value = getattr(config, variable, None)
-        result = {"variable": variable, "value": value}
-        response._content = json.dumps(result)
-        return response
-
-
-if config.ENABLE_CONFIG_UPDATES:
-    ProxyListener.DEFAULT_LISTENERS.append(ConfigUpdateProxyListener())
-
+# Start config update backdoor
+config_listener.start_listener()
 
 # -----------------
 # API ENTRY POINTS
@@ -250,7 +214,7 @@ def set_service_status(data):
         contained = [s for s in services if s.startswith(service)]
         if not contained:
             services.append(service)
-        update_config_variable(port_variable, port)
+        config_listener.update_config_variable(port_variable, port)
         new_service_list = ",".join(services)
         os.environ["SERVICES"] = new_service_list
         # TODO: expensive operation - check if we need to do this here for each service, should be optimized!
diff --git a/localstack/utils/config_listener.py b/localstack/utils/config_listener.py
new file mode 100644
--- /dev/null
+++ b/localstack/utils/config_listener.py
@@ -0,0 +1,64 @@
+import json
+import logging
+import re
+from typing import Callable, List
+
+from requests.models import Response
+
+from localstack import config, constants
+from localstack.services.generic_proxy import ProxyListener
+
+LOG = logging.getLogger(__name__)
+
+CONFIG_LISTENERS: List[Callable[[str, str], None]] = []
+
+
+def trigger_config_listeners(variable, new_value):
+    LOG.debug("Updating config listeners")
+    for listener in CONFIG_LISTENERS:
+        listener(variable, new_value)
+
+
+def update_config_variable(variable, new_value):
+    if new_value is not None:
+        LOG.info('Updating value of config variable "%s": %s' % (variable, new_value))
+        setattr(config, variable, new_value)
+        trigger_config_listeners(variable, new_value)
+
+
+class ConfigUpdateProxyListener(ProxyListener):
+    """Default proxy listener that intercepts requests to retrieve or update config variables."""
+
+    def forward_request(self, method, path, data, headers):
+        if path != constants.CONFIG_UPDATE_PATH or method != "POST":
+            return True
+        response = Response()
+        data = json.loads(data)
+        variable = data.get("variable", "")
+        response._content = "{}"
+        response.status_code = 200
+        if not re.match(r"^[_a-zA-Z0-9]+$", variable):
+            response.status_code = 400
+            return response
+        new_value = data.get("value")
+        update_config_variable(variable, new_value)
+        value = getattr(config, variable, None)
+        result = {"variable": variable, "value": value}
+        response._content = json.dumps(result)
+        return response
+
+
+CONFIG_UPDATE_LISTENER = ConfigUpdateProxyListener()
+
+
+def start_listener():
+    if config.ENABLE_CONFIG_UPDATES:
+        ProxyListener.DEFAULT_LISTENERS.append(CONFIG_UPDATE_LISTENER)
+
+
+def remove_listener():
+    if not config.ENABLE_CONFIG_UPDATES:
+        try:
+            ProxyListener.DEFAULT_LISTENERS.remove(CONFIG_UPDATE_LISTENER)
+        except ValueError:
+            pass


Test patch:
diff --git a/tests/integration/test_config_endpoint.py b/tests/integration/test_config_endpoint.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/test_config_endpoint.py
@@ -0,0 +1,37 @@
+import pytest
+import requests
+
+from localstack import config
+from localstack.utils import config_listener
+
+
+@pytest.fixture
+def config_endpoint():
+    cur_value = config.ENABLE_CONFIG_UPDATES
+    config.ENABLE_CONFIG_UPDATES = True
+    config_listener.start_listener()
+    yield
+    config.ENABLE_CONFIG_UPDATES = cur_value
+    config_listener.remove_listener()
+
+
+def test_config_endpoint(config_endpoint):
+    key = value = None
+
+    def custom_listener(config_key, config_value):
+        nonlocal key, value
+        key = config_key
+        value = config_value
+
+    config.FOO = None
+    body = {"variable": "FOO", "value": "BAR"}
+    config_listener.CONFIG_LISTENERS.append(custom_listener)
+    url = f"{config.get_edge_url()}/?_config_"
+    print(url)
+    response = requests.post(url, json=body)
+    assert 200 == response.status_code
+    response_body = response.json()
+    assert body == response_body
+    assert body["value"] == config.FOO
+    assert body["variable"] == key
+    assert body["value"] == value
diff --git a/tests/unit/test_misc.py b/tests/unit/test_misc.py
--- a/tests/unit/test_misc.py
+++ b/tests/unit/test_misc.py
@@ -10,7 +10,7 @@
 from localstack import config
 from localstack.services import infra
 from localstack.services.generic_proxy import GenericProxy, ProxyListener
-from localstack.utils import async_utils
+from localstack.utils import async_utils, config_listener
 from localstack.utils.aws import aws_stack
 from localstack.utils.bootstrap import PortMappings
 from localstack.utils.common import TMP_FILES, download, json_safe, load_file, now_utc, parallelize
@@ -79,7 +79,7 @@ def test_get_service_status(self):
         self.assertGreater(len(env), 0)
 
     def test_update_config_variable(self):
-        infra.update_config_variable("foo", "bar")
+        config_listener.update_config_variable("foo", "bar")
         self.assertEqual("bar", config.foo)
 
     def test_async_parallelization(self):