Skip to content

Commit a6f9444

Browse files
authored
Is207/reverse proxy webserver (#318)
- 1st version of the reverse proxy subsystem (see ``src/simcore_service_webserver/reverse_proxy``) - configurable upon setup - well decoupled - unit tests in ``tests/unit/test_reverse_proxy.py`` - customized handlers : default, jupyter and preview (drafts) - still not integrated w/ other subsystems in webserver (will do in separated pull-request) - connected to #207
1 parent 04fa857 commit a6f9444

File tree

14 files changed

+888
-9
lines changed

14 files changed

+888
-9
lines changed

services/director/src/simcore_service_director/registry_proxy.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ def __registry_request(path, method="GET"):
9595
# r = s.get(api_url, verify=False) #getattr(s, method.lower())(api_url)
9696
request_result = getattr(_SESSION, method.lower())(api_url)
9797
_logger.info("Request status: %s",request_result.status_code)
98-
if request_result.status_code > 399:
98+
if request_result.status_code > 399:
9999
request_result.raise_for_status()
100100

101101
return request_result
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
""" director - subsystem that communicates with director service
2+
3+
"""
4+
5+
import logging
6+
7+
from aiohttp import web
8+
9+
from . import director_config
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
# SETTINGS ----------------------------------------------------
15+
THIS_MODULE_NAME = __name__.split(".")[-1]
16+
17+
# --------------------------------------------------------------
18+
19+
20+
21+
def setup(app: web.Application):
22+
"""Setup the directory sub-system in the application a la aiohttp fashion
23+
24+
"""
25+
logger.debug("Setting up %s ...", __name__)
26+
27+
_cfg = director_config.get_from(app)
28+
29+
# TODO: create instance of director's client-sdk
30+
31+
# TODO: inject in application
32+
33+
34+
35+
36+
# alias
37+
setup_director = setup
38+
39+
__all__ = (
40+
'setup_director'
41+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
""" director - subsystem's configuration
2+
3+
- defines schema for this subsystem's section in configuration file
4+
- helpers functions to get/set configuration from app configuration
5+
6+
TODO: add validation, get/set app config
7+
"""
8+
from typing import Dict
9+
10+
import trafaret as T
11+
from aiohttp import web
12+
13+
from .application_keys import APP_CONFIG_KEY
14+
15+
16+
THIS_SERVICE_NAME = 'director'
17+
18+
19+
schema = T.Dict({
20+
T.Key("host", default=THIS_SERVICE_NAME): T.String(),
21+
"port": T.Int()
22+
})
23+
24+
25+
def get_from(app: web.Application) -> Dict:
26+
""" Gets section from application's config
27+
28+
"""
29+
return app[APP_CONFIG_KEY][THIS_SERVICE_NAME]
30+
31+
32+
33+
# alias
34+
DIRECTOR_SERVICE = THIS_SERVICE_NAME
35+
director_schema = schema
36+
37+
38+
__all__ = (
39+
"DIRECTOR_SERVICE",
40+
"director_schema"
41+
)

services/web/server/src/simcore_service_webserver/interactive_services_manager.py

+16
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
""" Manages lifespan of interactive services.
22
3+
- uses director's client-sdk to communicate with the director service
4+
35
"""
46
# pylint: disable=W0703
57
# pylint: disable=C0111
@@ -20,6 +22,10 @@ def session_connect(session_id):
2022

2123

2224
async def session_disconnected(session_id):
25+
""" Stops all running services when session disconnects
26+
27+
TODO: rename on_session_disconnected because is a reaction to that event
28+
"""
2329
log.debug("Session disconnection of session %s", session_id)
2430
try:
2531
director = director_sdk.get_director()
@@ -50,6 +56,12 @@ async def retrieve_list_of_services():
5056

5157

5258
async def start_service(session_id, service_key, service_uuid, service_version=None):
59+
""" Starts a service registered in the container's registry
60+
61+
:param str service_key: The key (url) of the service (required)
62+
:param str service_uuid: The uuid to assign the service with (required)
63+
:param str service_version: The tag/version of the service
64+
"""
5365
if not service_version:
5466
service_version = "latest"
5567
log.debug("Starting service %s:%s with uuid %s", service_key, service_version, service_uuid)
@@ -68,6 +80,10 @@ async def start_service(session_id, service_key, service_uuid, service_version=N
6880

6981

7082
async def stop_service(session_id, service_uuid):
83+
""" Stops and removes a running service
84+
85+
:param str service_uuid: The uuid to assign the service with (required)
86+
"""
7187
log.debug("Stopping service with uuid %s", service_uuid)
7288
try:
7389
director = director_sdk.get_director()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
""" reverse proxy subsystem
2+
3+
Dynamically reroutes communication between web-server client and dynamic-backend services (or dyb's)
4+
5+
Use case
6+
- All requests to `/x/{serviceId}/{proxyPath}` are re-routed to resolved dyb service
7+
- dy-services are managed by the director service who monitors and controls its lifetime
8+
- a client-sdk to query the director is passed upon setup
9+
- Customized reverse proxy handlers for dy-jupyter, dy-modeling and dy-3dvis
10+
11+
"""
12+
import logging
13+
14+
from aiohttp import web
15+
16+
from .abc import ServiceResolutionPolicy
17+
from .routing import ReverseChooser
18+
from .handlers import jupyter, paraview
19+
from .settings import URL_PATH
20+
21+
logger = logging.getLogger(__name__)
22+
23+
MODULE_NAME = __name__.split(".")[-1]
24+
25+
26+
def setup(app: web.Application, service_resolver: ServiceResolutionPolicy):
27+
"""Sets up reverse-proxy subsystem in the application (a la aiohttp)
28+
29+
"""
30+
logger.debug("Setting up %s ...", __name__)
31+
32+
chooser = ReverseChooser(resolver=service_resolver)
33+
34+
# Registers reverse proxy handlers customized for specific service types
35+
chooser.register_handler(jupyter.handler,
36+
image_name=jupyter.SUPPORTED_IMAGE_NAME)
37+
38+
chooser.register_handler(paraview.handler,
39+
image_name=paraview.SUPPORTED_IMAGE_NAME)
40+
41+
# /x/{serviceId}/{proxyPath:.*}
42+
app.router.add_route(method='*', path=URL_PATH,
43+
handler=chooser.do_route, name=MODULE_NAME)
44+
45+
# chooser has same lifetime as the application
46+
app[__name__] = {"chooser": chooser}
47+
48+
49+
# alias
50+
setup_reverse_proxy = setup
51+
52+
__all__ = (
53+
'setup_reverse_proxy'
54+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
2+
import abc
3+
4+
from yarl import URL
5+
6+
from .settings import PROXY_MOUNTPOINT
7+
8+
9+
class ServiceResolutionPolicy(metaclass=abc.ABCMeta):
10+
""" Implements an interface to identify and
11+
resolve the location of a dynamic backend service
12+
"""
13+
base_mountpoint = PROXY_MOUNTPOINT
14+
15+
@abc.abstractmethod
16+
async def get_image_name(self, service_identifier: str) -> str:
17+
"""
18+
Identifies a type of service. This normally corresponds
19+
to the name of the docker image
20+
"""
21+
pass
22+
23+
@abc.abstractmethod
24+
async def find_url(self, service_identifier: str) -> URL:
25+
"""
26+
Return the complete url (including the mountpoint) of
27+
the service in the backend
28+
29+
This access should be accesible by the proxy server
30+
31+
E.g. 'http://127.0.0.1:58873/x/ae1q8/'
32+
"""
33+
pass
34+
35+
# TODO: on_closed signal to notify sub-system that the service
36+
# has closed and can raise HTTPServiceAnavailable
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
""" Handlers customized for services
2+
3+
"""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
""" Default implementation of reverse-proxy
2+
3+
TODO: https://stackoverflow.com/questions/46788964/trying-to-build-a-proxy-with-aiohttp
4+
TODO: https://github.com/weargoggles/aioproxy/blob/master/aioproxy.py
5+
6+
- another possibility: always request director and thisone will redirect to real server...
7+
CONS: will double #calls
8+
PROS: location of the dyb service can change at will!
9+
"""
10+
import logging
11+
import time
12+
13+
import aiohttp
14+
from aiohttp import web
15+
16+
from yarl import URL
17+
logger = logging.getLogger(__name__)
18+
19+
20+
CHUNK = 32768
21+
22+
23+
async def handler(request: web.Request, service_url: str, **_kwargs) -> web.StreamResponse:
24+
# FIXME: Taken tmp from https://github.com/weargoggles/aioproxy/blob/master/aioproxy.py
25+
start = time.time()
26+
try:
27+
# FIXME: service_url should be service_endpoint or service_origins
28+
tarfind_url = URL(service_url).origin().with_path(
29+
request.path).with_query(request.query)
30+
async with aiohttp.client.request(
31+
request.method, tarfind_url,
32+
headers=request.headers,
33+
chunked=CHUNK,
34+
# response_class=ReverseProxyResponse,
35+
) as r:
36+
logger.debug('opened backend request in %d ms',
37+
((time.time() - start) * 1000))
38+
response = aiohttp.web.StreamResponse(status=r.status,
39+
headers=r.headers)
40+
await response.prepare(request)
41+
content = r.content
42+
while True:
43+
chunk = await content.read(CHUNK)
44+
if not chunk:
45+
break
46+
await response.write(chunk)
47+
48+
logger.debug('finished sending content in %d ms',
49+
((time.time() - start) * 1000,))
50+
await response.write_eof()
51+
return response
52+
except Exception:
53+
logger.debug("reverse proxy %s", request, exec_info=True)
54+
raise web.HTTPServiceUnavailable(reason="Cannot talk to spawner",
55+
content_type="application/json")
56+
57+
# except web.HttpStatus as status:
58+
# return status.as_response()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
""" Reverse-proxy customized for jupyter notebooks
2+
3+
TODO: document
4+
"""
5+
6+
import asyncio
7+
import logging
8+
import pprint
9+
10+
import aiohttp
11+
from aiohttp import client, web
12+
13+
# TODO: find actual name in registry
14+
SUPPORTED_IMAGE_NAME = "jupyter"
15+
SUPPORTED_IMAGE_TAG = "==0.1.0"
16+
17+
logger = logging.getLogger(__name__)
18+
19+
20+
async def handler(req: web.Request, service_url: str, **_kwargs) -> web.StreamResponse:
21+
# Resolved url pointing to backend jupyter service
22+
tarfind_url = service_url + req.path_qs
23+
24+
reqH = req.headers.copy()
25+
if reqH['connection'] == 'Upgrade' and reqH['upgrade'] == 'websocket' and req.method == 'GET':
26+
27+
ws_server = web.WebSocketResponse()
28+
await ws_server.prepare(req)
29+
logger.info('##### WS_SERVER %s', pprint.pformat(ws_server))
30+
31+
client_session = aiohttp.ClientSession(cookies=req.cookies)
32+
async with client_session.ws_connect(
33+
tarfind_url,
34+
) as ws_client:
35+
logger.info('##### WS_CLIENT %s', pprint.pformat(ws_client))
36+
37+
async def ws_forward(ws_from, ws_to):
38+
async for msg in ws_from:
39+
logger.info('>>> msg: %s', pprint.pformat(msg))
40+
mt = msg.type
41+
md = msg.data
42+
if mt == aiohttp.WSMsgType.TEXT:
43+
await ws_to.send_str(md)
44+
elif mt == aiohttp.WSMsgType.BINARY:
45+
await ws_to.send_bytes(md)
46+
elif mt == aiohttp.WSMsgType.PING:
47+
await ws_to.ping()
48+
elif mt == aiohttp.WSMsgType.PONG:
49+
await ws_to.pong()
50+
elif ws_to.closed:
51+
await ws_to.close(code=ws_to.close_code, message=msg.extra)
52+
else:
53+
raise ValueError(
54+
'unexpected message type: %s' % pprint.pformat(msg))
55+
56+
await asyncio.wait([ws_forward(ws_server, ws_client), ws_forward(ws_client, ws_server)], return_when=asyncio.FIRST_COMPLETED)
57+
58+
return ws_server
59+
else:
60+
61+
async with client.request(
62+
req.method, tarfind_url,
63+
headers=reqH,
64+
allow_redirects=False,
65+
data=await req.read()
66+
) as res:
67+
headers = res.headers.copy()
68+
body = await res.read()
69+
return web.Response(
70+
headers=headers,
71+
status=res.status,
72+
body=body
73+
)
74+
return ws_server
75+
76+
77+
if __name__ == "__main__":
78+
# dummies for manual testing
79+
BASE_URL = 'http://0.0.0.0:8888'
80+
MOUNT_POINT = '/x/fakeUuid'
81+
82+
def adapter(req: web.Request):
83+
return handler(req, service_url=BASE_URL)
84+
85+
app = web.Application()
86+
app.router.add_route('*', MOUNT_POINT + '/{proxyPath:.*}', adapter)
87+
web.run_app(app, port=3984)

0 commit comments

Comments
 (0)