Skip to content
Merged
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
77 changes: 73 additions & 4 deletions localstack/aws/serving/edge.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import logging
import threading
from typing import List

from rolo.gateway.wsgi import WsgiGateway

from localstack import config
from localstack.aws.app import LocalstackAwsGateway
from localstack.config import HostAndPort
from localstack.http.hypercorn import GatewayServer
from localstack.runtime.shutdown import ON_AFTER_SERVICE_SHUTDOWN_HANDLERS
from localstack.services.plugins import SERVICE_PLUGINS
from localstack.utils.collections import ensure_list

LOG = logging.getLogger(__name__)

Expand All @@ -17,10 +21,77 @@ def serve_gateway(
Implementation of the edge.do_start_edge_proxy interface to start a Hypercorn server instance serving the
LocalstackAwsGateway.
"""
from localstack.aws.app import LocalstackAwsGateway

gateway = LocalstackAwsGateway(SERVICE_PLUGINS)

listens = ensure_list(listen)

if config.GATEWAY_SERVER == "hypercorn":
return _serve_hypercorn(gateway, listens, use_ssl, asynchronous)
elif config.GATEWAY_SERVER == "werkzeug":
return _serve_werkzeug(gateway, listens, use_ssl, asynchronous)
else:
raise ValueError(f"Unknown gateway server type {config.GATEWAY_SERVER}")


def _serve_werkzeug(
gateway: LocalstackAwsGateway, listen: List[HostAndPort], use_ssl: bool, asynchronous: bool
):
from werkzeug.serving import ThreadedWSGIServer

from .werkzeug import CustomWSGIRequestHandler

params = {
"app": WsgiGateway(gateway),
"handler": CustomWSGIRequestHandler,
}

if use_ssl:
from localstack.utils.ssl import create_ssl_cert, install_predefined_cert_if_available

install_predefined_cert_if_available()
serial_number = listen[0].port
_, cert_file_name, key_file_name = create_ssl_cert(serial_number=serial_number)
params["ssl_context"] = (cert_file_name, key_file_name)

threads = []
servers: List[ThreadedWSGIServer] = []

for host_port in listen:
kwargs = dict(params)
kwargs["host"] = host_port.host
kwargs["port"] = host_port.port
server = ThreadedWSGIServer(**kwargs)
servers.append(server)
threads.append(
threading.Thread(
target=server.serve_forever, name=f"werkzeug-server-{host_port.port}", daemon=True
)
)

def _shutdown_servers():
LOG.debug("[shutdown] Shutting down gateway servers")
for _srv in servers:
_srv.shutdown()

ON_AFTER_SERVICE_SHUTDOWN_HANDLERS.register(_shutdown_servers)

for thread in threads:
thread.start()

if not asynchronous:
for thread in threads:
return thread.join()

# FIXME: thread handling is a bit wonky
return threads[0]


def _serve_hypercorn(
gateway: LocalstackAwsGateway, listen: List[HostAndPort], use_ssl: bool, asynchronous: bool
):
from localstack.http.hypercorn import GatewayServer

# start serving gateway
server = GatewayServer(gateway, listen, use_ssl, config.GATEWAY_WORKER_COUNT)
server.start()
Expand All @@ -33,8 +104,6 @@ def _shutdown_gateway():
server.shutdown()

ON_AFTER_SERVICE_SHUTDOWN_HANDLERS.register(_shutdown_gateway)

if not asynchronous:
server.join()

return server._thread
46 changes: 43 additions & 3 deletions localstack/aws/serving/werkzeug.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
import ssl
from typing import TYPE_CHECKING, Any, Optional, Tuple

from rolo.gateway import Gateway
from rolo.gateway.wsgi import WsgiGateway
from werkzeug import run_simple
from werkzeug.serving import WSGIRequestHandler

if TYPE_CHECKING:
from _typeshed.wsgi import WSGIEnvironment

from ..gateway import Gateway
from .wsgi import WsgiGateway
from localstack import constants


def serve(gateway: Gateway, host="localhost", port=4566, use_reloader=True, **kwargs) -> None:
def serve(
gateway: Gateway,
host: str = "localhost",
port: int = constants.DEFAULT_PORT_EDGE,
use_reloader: bool = True,
ssl_creds: Optional[Tuple[Any, Any]] = None,
**kwargs,
) -> None:
"""
Serve a Gateway as a WSGI application through werkzeug. This is mostly for development purposes.

Expand All @@ -15,4 +30,29 @@ def serve(gateway: Gateway, host="localhost", port=4566, use_reloader=True, **kw
:param kwargs: any other arguments that can be passed to `werkzeug.run_simple`
"""
kwargs["threaded"] = kwargs.get("threaded", True) # make sure requests don't block
kwargs["ssl_context"] = ssl_creds
kwargs.setdefault("request_handler", CustomWSGIRequestHandler)
run_simple(host, port, WsgiGateway(gateway), use_reloader=use_reloader, **kwargs)


class CustomWSGIRequestHandler(WSGIRequestHandler):
Copy link
Contributor

Choose a reason for hiding this comment

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

q: is there any way to specify the thread name of the threaded handler? Because right now it seems it gives something truncated like:
2024-02-26T17:17:44.123 DEBUG --- [uest_thread)] l.aws.protocol.serializer

Copy link
Member Author

Choose a reason for hiding this comment

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

i looked into it and found that thread creation is controlled by socketserver.ThreadingMixin. This is the code they use:

    def process_request(self, request, client_address):
        """Start a new thread to process the request."""
        if self.block_on_close:
            vars(self).setdefault('_threads', _Threads())
        t = threading.Thread(target = self.process_request_thread,
                             args = (request, client_address))
        t.daemon = self.daemon_threads
        self._threads.append(t)
        t.start()

We could overwrite that, but the question is what to show. A running thread id would continue to increase for each request coming into to system. We could implement a thread pool on top of this actually, but all that seems a bit too much effort right now.

I'll keep it in mind though!

def make_environ(self) -> "WSGIEnvironment":
environ = super().make_environ()

# restore RAW_URI from the requestline will be something like ``GET //foo/?foo=bar%20ed HTTP/1.1``
environ["RAW_URI"] = " ".join(self.requestline.split(" ")[1:-1])

# restore raw headers for rolo
environ["asgi.headers"] = [
(k.encode("latin-1"), v.encode("latin-1")) for k, v in self.headers.raw_items()
]

# the default WSGIRequestHandler does not understand our DuplexSocket, so it will always set https, which we
# correct here
try:
is_ssl = isinstance(self.request, ssl.SSLSocket)
except AttributeError:
is_ssl = False
environ["wsgi.url_scheme"] = "https" if is_ssl else "http"

return environ
4 changes: 4 additions & 0 deletions localstack/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,9 @@ def populate_edge_configuration(

GATEWAY_WORKER_COUNT = int(os.environ.get("GATEWAY_WORKER_COUNT") or 1000)

# the gateway server that should be used (supported: hypercorn, dev: werkzeug)
GATEWAY_SERVER = os.environ.get("GATEWAY_SERVER", "").strip() or "hypercorn"

# IP of the docker bridge used to enable access between containers
DOCKER_BRIDGE_IP = os.environ.get("DOCKER_BRIDGE_IP", "").strip()

Expand Down Expand Up @@ -1132,6 +1135,7 @@ def use_custom_dns():
"EXTRA_CORS_ALLOWED_ORIGINS",
"EXTRA_CORS_EXPOSE_HEADERS",
"GATEWAY_LISTEN",
"GATEWAY_SERVER",
"GATEWAY_WORKER_THREAD_COUNT",
"HOSTNAME",
"HOSTNAME_FROM_LAMBDA",
Expand Down