matlab-proxy 0.19.0__py3-none-any.whl → 0.21.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of matlab-proxy might be problematic. Click here for more details.
- matlab_proxy/app.py +22 -15
- matlab_proxy/gui/asset-manifest.json +6 -6
- matlab_proxy/gui/index.html +1 -1
- matlab_proxy/gui/static/css/main.6cd0caba.css +13 -0
- matlab_proxy/gui/static/css/main.6cd0caba.css.map +1 -0
- matlab_proxy/gui/static/js/main.61c661b8.js +3 -0
- matlab_proxy/gui/static/js/{main.e07799e7.js.LICENSE.txt → main.61c661b8.js.LICENSE.txt} +0 -2
- matlab_proxy/gui/static/js/main.61c661b8.js.map +1 -0
- matlab_proxy/settings.py +1 -1
- matlab_proxy/util/mwi/logger.py +22 -2
- matlab_proxy/util/windows.py +4 -1
- {matlab_proxy-0.19.0.dist-info → matlab_proxy-0.21.0.dist-info}/METADATA +16 -15
- {matlab_proxy-0.19.0.dist-info → matlab_proxy-0.21.0.dist-info}/RECORD +36 -19
- {matlab_proxy-0.19.0.dist-info → matlab_proxy-0.21.0.dist-info}/WHEEL +1 -1
- {matlab_proxy-0.19.0.dist-info → matlab_proxy-0.21.0.dist-info}/entry_points.txt +1 -0
- matlab_proxy-0.21.0.dist-info/top_level.txt +3 -0
- matlab_proxy_manager/__init__.py +6 -0
- matlab_proxy_manager/lib/__init__.py +1 -0
- matlab_proxy_manager/lib/api.py +295 -0
- matlab_proxy_manager/storage/__init__.py +1 -0
- matlab_proxy_manager/storage/file_repository.py +144 -0
- matlab_proxy_manager/storage/interface.py +62 -0
- matlab_proxy_manager/storage/server.py +144 -0
- matlab_proxy_manager/utils/__init__.py +1 -0
- matlab_proxy_manager/utils/auth.py +77 -0
- matlab_proxy_manager/utils/constants.py +5 -0
- matlab_proxy_manager/utils/environment_variables.py +46 -0
- matlab_proxy_manager/utils/helpers.py +284 -0
- matlab_proxy_manager/utils/logger.py +73 -0
- matlab_proxy_manager/web/__init__.py +1 -0
- matlab_proxy_manager/web/app.py +447 -0
- matlab_proxy_manager/web/monitor.py +45 -0
- matlab_proxy_manager/web/watcher.py +54 -0
- tests/unit/test_app.py +1 -1
- tests/unit/util/mwi/test_logger.py +38 -4
- matlab_proxy/gui/static/css/main.da9c4eb8.css +0 -13
- matlab_proxy/gui/static/css/main.da9c4eb8.css.map +0 -1
- matlab_proxy/gui/static/js/main.e07799e7.js +0 -3
- matlab_proxy/gui/static/js/main.e07799e7.js.map +0 -1
- matlab_proxy-0.19.0.dist-info/top_level.txt +0 -2
- {matlab_proxy-0.19.0.dist-info → matlab_proxy-0.21.0.dist-info}/LICENSE.md +0 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
# Copyright 2024 The MathWorks, Inc.
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
from collections import namedtuple
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
from aiohttp import ClientSession, client_exceptions, web
|
|
13
|
+
|
|
14
|
+
import matlab_proxy.util.mwi.environment_variables as mwi_env
|
|
15
|
+
import matlab_proxy.util.system as mwi_sys
|
|
16
|
+
import matlab_proxy_manager.lib.api as mpm_lib
|
|
17
|
+
from matlab_proxy.util.event_loop import get_event_loop
|
|
18
|
+
from matlab_proxy_manager.utils import constants, helpers, logger
|
|
19
|
+
from matlab_proxy_manager.utils import environment_variables as mpm_env
|
|
20
|
+
from matlab_proxy_manager.utils.auth import authenticate_access_decorator
|
|
21
|
+
from matlab_proxy_manager.web import watcher
|
|
22
|
+
from matlab_proxy_manager.web.monitor import OrphanedProcessMonitor
|
|
23
|
+
|
|
24
|
+
# We use __all__ to list down all the public-facing APIs exported by this module
|
|
25
|
+
__all__ = ["proxy", "SHUTDOWN_EVENT"]
|
|
26
|
+
|
|
27
|
+
SHUTDOWN_EVENT = None
|
|
28
|
+
log = logger.get(init=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def init_app() -> web.Application:
|
|
32
|
+
"""
|
|
33
|
+
Initialize and configure the aiohttp web application.
|
|
34
|
+
|
|
35
|
+
This function sets up the web application with necessary configurations,
|
|
36
|
+
including creating the proxy manager data directory, setting up an idle
|
|
37
|
+
timeout monitor, and configuring client sessions.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
web.Application: The configured aiohttp web application.
|
|
41
|
+
"""
|
|
42
|
+
app = web.Application()
|
|
43
|
+
|
|
44
|
+
# Create and get the proxy manager data directory
|
|
45
|
+
try:
|
|
46
|
+
data_dir = helpers.create_and_get_proxy_manager_data_dir()
|
|
47
|
+
app["data_dir"] = data_dir
|
|
48
|
+
except Exception as ex:
|
|
49
|
+
raise RuntimeError(f"Failed to create or get data directory: {ex}") from ex
|
|
50
|
+
|
|
51
|
+
# Setup idle timeout monitor for the app
|
|
52
|
+
monitor = OrphanedProcessMonitor(app)
|
|
53
|
+
|
|
54
|
+
async def start_idle_monitor(app):
|
|
55
|
+
"""Start the idle timeout monitor."""
|
|
56
|
+
asyncio.create_task(monitor.start())
|
|
57
|
+
|
|
58
|
+
async def create_client_session(app):
|
|
59
|
+
"""Create an aiohttp client session."""
|
|
60
|
+
app["session"] = ClientSession(
|
|
61
|
+
trust_env=True, connector=aiohttp.TCPConnector(ssl=False, ttl_dns_cache=600)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def cleanup_client_session(app):
|
|
65
|
+
"""Cleanup the aiohttp client session."""
|
|
66
|
+
await app["session"].close()
|
|
67
|
+
|
|
68
|
+
app.on_startup.append(start_idle_monitor)
|
|
69
|
+
app.on_startup.append(create_client_session)
|
|
70
|
+
app.on_cleanup.append(helpers.delete_dangling_servers)
|
|
71
|
+
app.on_cleanup.append(cleanup_client_session)
|
|
72
|
+
|
|
73
|
+
app.router.add_route("*", "/{tail:.*}", proxy)
|
|
74
|
+
|
|
75
|
+
return app
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def start_app(env_vars: namedtuple):
|
|
79
|
+
"""
|
|
80
|
+
Initialize and start the web application.
|
|
81
|
+
|
|
82
|
+
This function sets up logging, initializes the web application, and starts
|
|
83
|
+
the default matlab proxy. It also sets up signal handlers for graceful shutdown
|
|
84
|
+
and starts a file watcher in a separate thread.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
Exception: If any error occurs during the application startup or runtime.
|
|
88
|
+
"""
|
|
89
|
+
# Async events are utilized to signal app termination from other modules,
|
|
90
|
+
# necessitating the use of a global variable. To avoid potential issues with variable attachment
|
|
91
|
+
# to other event loops in Python versions prior to 3.10, the variable is initialized locally
|
|
92
|
+
# rather than globally.
|
|
93
|
+
global SHUTDOWN_EVENT
|
|
94
|
+
app = init_app()
|
|
95
|
+
|
|
96
|
+
app["port"] = env_vars.mpm_port
|
|
97
|
+
app["auth_token"] = env_vars.mpm_auth_token
|
|
98
|
+
app["parent_pid"] = env_vars.mpm_parent_pid
|
|
99
|
+
|
|
100
|
+
# Start default matlab proxy
|
|
101
|
+
await _start_default_proxy(app)
|
|
102
|
+
|
|
103
|
+
web_logger = None if not mwi_env.is_web_logging_enabled() else log
|
|
104
|
+
|
|
105
|
+
# Run the app
|
|
106
|
+
runner = web.AppRunner(app, logger=web_logger, access_log=web_logger)
|
|
107
|
+
await runner.setup()
|
|
108
|
+
site = web.TCPSite(runner, port=env_vars.mpm_port)
|
|
109
|
+
await site.start()
|
|
110
|
+
log.debug("Proxy manager started at http://127.0.0.1:%d", site._port)
|
|
111
|
+
|
|
112
|
+
# Get the default event loop
|
|
113
|
+
loop = get_event_loop()
|
|
114
|
+
|
|
115
|
+
# Run the observer in a separate thread
|
|
116
|
+
loop.run_in_executor(None, watcher.start_watcher, app)
|
|
117
|
+
|
|
118
|
+
# Register signal handler for graceful shutdown
|
|
119
|
+
_register_signal_handler(loop)
|
|
120
|
+
|
|
121
|
+
SHUTDOWN_EVENT = asyncio.Event()
|
|
122
|
+
|
|
123
|
+
# Wait for receiving shutdown_event (set by interrupts or by monitoring process)
|
|
124
|
+
await SHUTDOWN_EVENT.wait()
|
|
125
|
+
|
|
126
|
+
# After receiving the shutdown signal, perform cleanup by stopping the web server
|
|
127
|
+
await runner.cleanup()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _register_signal_handler(loop):
|
|
131
|
+
"""
|
|
132
|
+
Registers signal handlers for supported termination signals to allow for graceful shutdown
|
|
133
|
+
of the application.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
loop (asyncio.AbstractEventLoop): The event loop to which the signal handlers
|
|
137
|
+
should be added.
|
|
138
|
+
"""
|
|
139
|
+
signals = mwi_sys.get_supported_termination_signals()
|
|
140
|
+
for sig_name in signals:
|
|
141
|
+
if mwi_sys.is_posix():
|
|
142
|
+
loop.add_signal_handler(sig_name, catch_signals)
|
|
143
|
+
else:
|
|
144
|
+
# loop.add_signal_handler() is not yet supported in Windows.
|
|
145
|
+
# Using the 'signal' package instead.
|
|
146
|
+
signal.signal(sig_name, catch_signals)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def catch_signals(*args):
|
|
150
|
+
"""Handle termination signals for graceful shutdown."""
|
|
151
|
+
# Poll for parent process to clean up to avoid race conditions in cleanup of matlab proxies
|
|
152
|
+
helpers.poll_for_server_deletion()
|
|
153
|
+
SHUTDOWN_EVENT.set()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def _start_default_proxy(app):
|
|
157
|
+
"""
|
|
158
|
+
Starts the default MATLAB proxy and updates the application state.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
app : The aiohttp web application.
|
|
162
|
+
"""
|
|
163
|
+
server_process = await mpm_lib.start_matlab_proxy_for_jsp(
|
|
164
|
+
parent_id=app.get("parent_pid"),
|
|
165
|
+
is_isolated_matlab=False,
|
|
166
|
+
mpm_auth_token=app.get("auth_token"),
|
|
167
|
+
)
|
|
168
|
+
if not server_process:
|
|
169
|
+
log.error("Failed to start default matlab proxy using Jupyter")
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
# Load existing matlab proxy servers into app state for consistency
|
|
173
|
+
app["servers"] = helpers.pre_load_from_state_file(app.get("data_dir"))
|
|
174
|
+
|
|
175
|
+
# Add the new/existing server to the app state
|
|
176
|
+
app["servers"][server_process.get("id")] = server_process
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@authenticate_access_decorator
|
|
180
|
+
async def proxy(req):
|
|
181
|
+
"""
|
|
182
|
+
Proxy incoming HTTP requests to the appropriate MATLAB proxy backend server.
|
|
183
|
+
|
|
184
|
+
This function handles requests by:
|
|
185
|
+
1. Redirecting paths ending with '/matlab/' to '/matlab/default/'.
|
|
186
|
+
2. Extracting client identifiers from the request path.
|
|
187
|
+
3. Routing the request to the appropriate MATLAB backend server based on the client identifier.
|
|
188
|
+
4. Handling various exceptions and providing appropriate HTTP responses.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
req (aiohttp.web.Request): The incoming HTTP request.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
aiohttp.web.Response: The HTTP response from the backend server or an error page.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
aiohttp.web.HTTPFound: If the request path needs to be redirected.
|
|
198
|
+
aiohttp.web.HTTPServiceUnavailable: If the MATLAB proxy process is not running.
|
|
199
|
+
aiohttp.web.HTTPNotFound: If the request cannot be forwarded to the MATLAB proxy.
|
|
200
|
+
"""
|
|
201
|
+
# Special keys for web socket requests
|
|
202
|
+
connection = "connection"
|
|
203
|
+
upgrade = "upgrade"
|
|
204
|
+
req_headers = req.headers.copy()
|
|
205
|
+
req_body = await req.read()
|
|
206
|
+
|
|
207
|
+
# Set content length in case of modification
|
|
208
|
+
req_headers["Content-Length"] = str(len(req_body))
|
|
209
|
+
req_headers["x-forwarded-proto"] = "http"
|
|
210
|
+
req_path = req.rel_url
|
|
211
|
+
|
|
212
|
+
# Redirect block to move /*/matlab to /*/matlab/default/
|
|
213
|
+
if str(req_path).endswith(f"{constants.MWI_BASE_URL_PREFIX}"):
|
|
214
|
+
return _redirect_to_default(req_path)
|
|
215
|
+
|
|
216
|
+
match = re.compile(r".*?/matlab/([^/]+)/(.*)").match(str(req.rel_url))
|
|
217
|
+
|
|
218
|
+
if not match:
|
|
219
|
+
# Path doesn't contain /matlab/default|<id> in the request path
|
|
220
|
+
# redirect to error page
|
|
221
|
+
log.debug("Regex match not found, match: %s", match)
|
|
222
|
+
return _render_error_page(
|
|
223
|
+
"Incorrect request path in the URL, please try with correct URL."
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
ident = match.group(1).rstrip("/")
|
|
227
|
+
log.debug("Client identifier for proxy: %s", ident)
|
|
228
|
+
|
|
229
|
+
ctx = req_headers.get(constants.HEADER_MWI_MPM_CONTEXT)
|
|
230
|
+
if not ctx:
|
|
231
|
+
log.debug("MPM Context header not found in the request")
|
|
232
|
+
return _render_error_page(
|
|
233
|
+
"Required header (MWI-MPM-CONTEXT) not found in the request"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
client_key = f"{ctx}_{ident}"
|
|
237
|
+
default_key = f"{ctx}_default"
|
|
238
|
+
group_two_rel_url = match.group(2)
|
|
239
|
+
|
|
240
|
+
backend_server = _get_backend_server(req, client_key, default_key)
|
|
241
|
+
proxy_url = f'{backend_server.get("absolute_url")}/{group_two_rel_url}'
|
|
242
|
+
log.debug("Proxy URL: %s", proxy_url)
|
|
243
|
+
|
|
244
|
+
if (
|
|
245
|
+
req_headers.get(connection, "").lower() == upgrade
|
|
246
|
+
and req_headers.get(upgrade, "").lower() == "websocket"
|
|
247
|
+
and req.method == "GET"
|
|
248
|
+
):
|
|
249
|
+
return await _handle_websocket_request(req, proxy_url)
|
|
250
|
+
try:
|
|
251
|
+
return await _handle_http_request(req, req_body, proxy_url, backend_server)
|
|
252
|
+
except web.HTTPFound:
|
|
253
|
+
log.debug("Redirection to path with /default")
|
|
254
|
+
raise
|
|
255
|
+
|
|
256
|
+
# Handles any pending HTTP requests from the browser when the MATLAB proxy process is
|
|
257
|
+
# terminated before responding to them.
|
|
258
|
+
except (
|
|
259
|
+
client_exceptions.ServerDisconnectedError,
|
|
260
|
+
client_exceptions.ClientConnectionError,
|
|
261
|
+
) as ex:
|
|
262
|
+
log.debug("MATLAB proxy process may not be running.")
|
|
263
|
+
raise web.HTTPServiceUnavailable() from ex
|
|
264
|
+
except Exception as err:
|
|
265
|
+
log.error("Failed to forward HTTP request to MATLAB proxy with error: %s", err)
|
|
266
|
+
raise web.HTTPNotFound() from err
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def _handle_websocket_request(
|
|
270
|
+
req: web.Request, proxy_url: str
|
|
271
|
+
) -> web.WebSocketResponse:
|
|
272
|
+
"""Handles a websocket request to the backend matlab proxy server
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
req (web.Request): websocket request from the client
|
|
276
|
+
proxy_url (str): backend matlab proxy server URL
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
ValueError: when an unexpected websocket message type is received
|
|
280
|
+
aiohttp.WebSocketError: For any exception raised while forwarding request from src to dest
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
web.WebSocketResponse: The response from the backend server
|
|
284
|
+
"""
|
|
285
|
+
ws_server = web.WebSocketResponse()
|
|
286
|
+
await ws_server.prepare(req)
|
|
287
|
+
|
|
288
|
+
async with aiohttp.ClientSession(
|
|
289
|
+
trust_env=True,
|
|
290
|
+
cookies=req.cookies,
|
|
291
|
+
connector=aiohttp.TCPConnector(ssl=False),
|
|
292
|
+
) as client_session:
|
|
293
|
+
try:
|
|
294
|
+
async with client_session.ws_connect(proxy_url) as ws_client:
|
|
295
|
+
|
|
296
|
+
async def ws_forward(ws_src, ws_dest):
|
|
297
|
+
async for msg in ws_src:
|
|
298
|
+
msg_type = msg.type
|
|
299
|
+
msg_data = msg.data
|
|
300
|
+
|
|
301
|
+
# When a websocket is closed by the MATLAB JSD, it sends out a few
|
|
302
|
+
# http requests to the Embedded Connector about the events that had occurred
|
|
303
|
+
# (figureWindowClosed etc.) The Embedded Connector responds by sending a
|
|
304
|
+
# message of type 'Error' with close code as Abnormal closure. When this
|
|
305
|
+
# happens, matlab-proxy can safely exit out of the loop and close the
|
|
306
|
+
# websocket connection it has with the Embedded Connector (ws_client)
|
|
307
|
+
if (
|
|
308
|
+
msg_type == aiohttp.WSMsgType.ERROR
|
|
309
|
+
and ws_src.close_code
|
|
310
|
+
== aiohttp.WSCloseCode.ABNORMAL_CLOSURE
|
|
311
|
+
):
|
|
312
|
+
log.debug(
|
|
313
|
+
"Src: %s, msg_type= %s, ws_src.close_code= %s",
|
|
314
|
+
ws_src,
|
|
315
|
+
msg_type,
|
|
316
|
+
ws_src.close_code,
|
|
317
|
+
)
|
|
318
|
+
break
|
|
319
|
+
if msg_type == aiohttp.WSMsgType.TEXT:
|
|
320
|
+
await ws_dest.send_str(msg_data)
|
|
321
|
+
elif msg_type == aiohttp.WSMsgType.BINARY:
|
|
322
|
+
await ws_dest.send_bytes(msg_data)
|
|
323
|
+
elif msg_type == aiohttp.WSMsgType.PING:
|
|
324
|
+
await ws_dest.ping()
|
|
325
|
+
elif msg_type == aiohttp.WSMsgType.PONG:
|
|
326
|
+
await ws_dest.pong()
|
|
327
|
+
elif ws_dest.closed:
|
|
328
|
+
log.debug("Destination: %s closed", ws_dest)
|
|
329
|
+
await ws_dest.close(
|
|
330
|
+
code=ws_dest.close_code, message=msg.extra
|
|
331
|
+
)
|
|
332
|
+
else:
|
|
333
|
+
raise ValueError(f"Unexpected message type: {msg}")
|
|
334
|
+
|
|
335
|
+
await asyncio.wait(
|
|
336
|
+
[
|
|
337
|
+
asyncio.create_task(ws_forward(ws_server, ws_client)),
|
|
338
|
+
asyncio.create_task(ws_forward(ws_client, ws_server)),
|
|
339
|
+
],
|
|
340
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
return ws_server
|
|
344
|
+
except Exception as err:
|
|
345
|
+
log.error("Failed to create web socket connection with error: %s", err)
|
|
346
|
+
|
|
347
|
+
code, message = (
|
|
348
|
+
aiohttp.WSCloseCode.INTERNAL_ERROR,
|
|
349
|
+
"Failed to establish websocket connection with the backend server",
|
|
350
|
+
)
|
|
351
|
+
await ws_server.close(code=code, message=message.encode("utf-8"))
|
|
352
|
+
raise aiohttp.WebSocketError(code=code, message=message)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# Helper private functions
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
async def _handle_http_request(
|
|
359
|
+
req: web.Request, req_body: Optional[bytes], proxy_url: str, backend_server: dict
|
|
360
|
+
) -> web.Response:
|
|
361
|
+
"""
|
|
362
|
+
Forwards an incoming HTTP request to a specified backend server.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
web.Response: The response from the backend server, including headers, status, and body.
|
|
366
|
+
"""
|
|
367
|
+
client_session = req.app.get("session")
|
|
368
|
+
async with client_session.request(
|
|
369
|
+
req.method,
|
|
370
|
+
proxy_url,
|
|
371
|
+
allow_redirects=True,
|
|
372
|
+
data=req_body,
|
|
373
|
+
headers=backend_server.get("headers"),
|
|
374
|
+
) as res:
|
|
375
|
+
headers = res.headers.copy()
|
|
376
|
+
body = await res.read()
|
|
377
|
+
return web.Response(headers=headers, status=res.status, body=body)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _get_backend_server(req: web.Request, client_key: str, default_key: str) -> dict:
|
|
381
|
+
"""
|
|
382
|
+
Retrieves the backend server configuration for a given client key.
|
|
383
|
+
"""
|
|
384
|
+
app = req.app
|
|
385
|
+
backend_server = app["servers"].get(client_key)
|
|
386
|
+
# Route to default matlab if the specified path doesn't exist
|
|
387
|
+
if not backend_server:
|
|
388
|
+
log.debug("Client not found in the current servers, using default matlab proxy")
|
|
389
|
+
backend_server = app["servers"].get(default_key)
|
|
390
|
+
return backend_server
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _redirect_to_default(req_path) -> None:
|
|
394
|
+
"""
|
|
395
|
+
Redirects the request to the default path.
|
|
396
|
+
|
|
397
|
+
This function constructs a new URL by appending '/default/' to the given request path
|
|
398
|
+
and raises an HTTPFound exception to redirect the client.
|
|
399
|
+
|
|
400
|
+
Raises:
|
|
401
|
+
web.HTTPFound: Redirects the client to the new URL.
|
|
402
|
+
"""
|
|
403
|
+
new_redirect_url = f'{str(req_path).rstrip("/")}/default/'
|
|
404
|
+
log.info("Redirecting to %s", new_redirect_url)
|
|
405
|
+
raise web.HTTPFound(new_redirect_url)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _render_error_page(error_msg: str) -> web.Response:
|
|
409
|
+
"""Returns 503 with error text"""
|
|
410
|
+
return web.HTTPServiceUnavailable(text=f"Error: {error_msg}")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def main() -> None:
|
|
414
|
+
"""
|
|
415
|
+
The main entry point of the application. Starts the app and run until the shutdown
|
|
416
|
+
signal to terminate the app is received.
|
|
417
|
+
"""
|
|
418
|
+
env_vars: namedtuple = _fetch_and_validate_required_env_vars()
|
|
419
|
+
loop: asyncio.AbstractEventLoop | asyncio.ProactorEventLoop = get_event_loop()
|
|
420
|
+
loop.run_until_complete(start_app(env_vars))
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _fetch_and_validate_required_env_vars() -> namedtuple:
|
|
424
|
+
EnvVars = namedtuple("EnvVars", ["mpm_port", "mpm_auth_token", "mpm_parent_pid"])
|
|
425
|
+
|
|
426
|
+
port = os.getenv(mpm_env.get_env_name_mwi_mpm_port())
|
|
427
|
+
mpm_auth_token = os.getenv(mpm_env.get_env_name_mwi_mpm_auth_token())
|
|
428
|
+
ctx = os.getenv(mpm_env.get_env_name_mwi_mpm_parent_pid())
|
|
429
|
+
|
|
430
|
+
if not ctx or not port or not mpm_auth_token:
|
|
431
|
+
print("Error: One or more required environment variables are missing.")
|
|
432
|
+
sys.exit(1)
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
mwi_mpm_port: int = int(port)
|
|
436
|
+
return EnvVars(
|
|
437
|
+
mpm_port=mwi_mpm_port, mpm_auth_token=mpm_auth_token, mpm_parent_pid=ctx
|
|
438
|
+
)
|
|
439
|
+
except ValueError as ve:
|
|
440
|
+
print("Error: Invalid type for port: ", ve)
|
|
441
|
+
sys.exit(1)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
if __name__ == "__main__":
|
|
445
|
+
# This ensures that the app is not created when the module is imported and
|
|
446
|
+
# is only started when the script is run directly or via executable invocation
|
|
447
|
+
main()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Copyright 2024 The MathWorks, Inc.
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
4
|
+
from matlab_proxy_manager.utils import logger
|
|
5
|
+
from matlab_proxy_manager.utils import helpers
|
|
6
|
+
|
|
7
|
+
log = logger.get()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OrphanedProcessMonitor:
|
|
11
|
+
"""
|
|
12
|
+
Class that provides behavior to track the idle state of the proxy manager app.
|
|
13
|
+
It periodically checks if the parent process is alive and triggers a shutdown event if not.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, app, delay: int = 1) -> None:
|
|
17
|
+
self.app = app
|
|
18
|
+
self.delay = delay
|
|
19
|
+
|
|
20
|
+
async def start(self) -> None:
|
|
21
|
+
"""
|
|
22
|
+
Starts the monitoring process. Periodically checks if the parent process is alive.
|
|
23
|
+
If the parent process is not alive, it triggers the shutdown process.
|
|
24
|
+
"""
|
|
25
|
+
while True:
|
|
26
|
+
try:
|
|
27
|
+
if not helpers.does_process_exist(self.app.get("parent_pid")):
|
|
28
|
+
log.info("Parent doesn't exist, calling self-shutdown")
|
|
29
|
+
await self.shutdown()
|
|
30
|
+
break
|
|
31
|
+
except Exception as ex:
|
|
32
|
+
log.debug("Couldn't check for parent's liveness with err: %s", ex)
|
|
33
|
+
await asyncio.sleep(self.delay)
|
|
34
|
+
|
|
35
|
+
async def shutdown(self) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Triggers the shutdown process by setting the shutdown event.
|
|
38
|
+
"""
|
|
39
|
+
from matlab_proxy_manager.web.app import SHUTDOWN_EVENT
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
# Set the shutdown async event to signal app shutdown to the app runner
|
|
43
|
+
SHUTDOWN_EVENT.set()
|
|
44
|
+
except Exception as ex:
|
|
45
|
+
log.debug("Unable to set proxy manager shutdown event, err: %s", ex)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Copyright 2024 The MathWorks, Inc.
|
|
2
|
+
from aiohttp import web
|
|
3
|
+
from watchdog.events import FileSystemEventHandler
|
|
4
|
+
from watchdog.observers import Observer
|
|
5
|
+
|
|
6
|
+
from matlab_proxy_manager.utils import logger
|
|
7
|
+
from matlab_proxy_manager.storage.file_repository import FileRepository
|
|
8
|
+
from matlab_proxy_manager.utils import helpers
|
|
9
|
+
|
|
10
|
+
log = logger.get()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FileWatcher(FileSystemEventHandler):
|
|
14
|
+
"""
|
|
15
|
+
A class to watch for file system events and update the server state accordingly.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, app: web.Application, data_dir: str) -> None:
|
|
19
|
+
"""
|
|
20
|
+
Initialize the FileWatcher with the application and directory to watch.
|
|
21
|
+
"""
|
|
22
|
+
self.app = app
|
|
23
|
+
self.data_dir = data_dir
|
|
24
|
+
super().__init__()
|
|
25
|
+
|
|
26
|
+
def on_created(self, event) -> None:
|
|
27
|
+
"""
|
|
28
|
+
Handle the event when a file or directory is created.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
self.update_server_state()
|
|
32
|
+
except Exception:
|
|
33
|
+
log.error("Error handling created event:", exc_info=True)
|
|
34
|
+
|
|
35
|
+
def update_server_state(self) -> None:
|
|
36
|
+
"""Update the server state from the repository."""
|
|
37
|
+
current_servers = {}
|
|
38
|
+
storage = FileRepository(self.data_dir)
|
|
39
|
+
servers = storage.get_all()
|
|
40
|
+
current_servers = {server.id: server.as_dict() for server in servers.values()}
|
|
41
|
+
self.app["servers"] = current_servers
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def start_watcher(app: web.Application):
|
|
45
|
+
"""
|
|
46
|
+
Start a file system watcher to monitor changes in the proxy manager data directory.
|
|
47
|
+
"""
|
|
48
|
+
path_to_watch = helpers.create_and_get_proxy_manager_data_dir()
|
|
49
|
+
log.debug("Watching dir: %s", path_to_watch)
|
|
50
|
+
event_handler = FileWatcher(app, path_to_watch)
|
|
51
|
+
observer = Observer()
|
|
52
|
+
observer.schedule(event_handler, path_to_watch, recursive=True)
|
|
53
|
+
observer.start()
|
|
54
|
+
return observer
|
tests/unit/test_app.py
CHANGED
|
@@ -447,7 +447,7 @@ async def test_matlab_proxy_404(proxy_payload, test_server):
|
|
|
447
447
|
count = 0
|
|
448
448
|
while True:
|
|
449
449
|
resp = await test_server.post(
|
|
450
|
-
"
|
|
450
|
+
"/1234.html", data=json.dumps(proxy_payload), headers=headers
|
|
451
451
|
)
|
|
452
452
|
if resp.status == HTTPStatus.SERVICE_UNAVAILABLE:
|
|
453
453
|
time.sleep(test_constants.ONE_SECOND_DELAY)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# Copyright 2020-
|
|
1
|
+
# Copyright 2020-2024 The MathWorks, Inc.
|
|
2
2
|
"""This file tests methods present in matlab_proxy/util/mwi_logger.py
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
6
|
import os
|
|
7
|
-
|
|
7
|
+
import pytest
|
|
8
8
|
from matlab_proxy.util.mwi import logger as mwi_logger
|
|
9
9
|
|
|
10
10
|
|
|
@@ -22,7 +22,7 @@ def test_get_mw_logger_name():
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def test_get_with_no_environment_variables(monkeypatch):
|
|
25
|
-
"""This test checks if the get method returns a logger with default settings"""
|
|
25
|
+
"""This test checks if the get method returns a logger with default settings if no environment variable is set"""
|
|
26
26
|
# Delete the environment variables if they do exist
|
|
27
27
|
env_names_list = mwi_logger.get_environment_variable_names()
|
|
28
28
|
monkeypatch.delenv(env_names_list[0], raising=False)
|
|
@@ -34,7 +34,7 @@ def test_get_with_no_environment_variables(monkeypatch):
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def test_get_with_environment_variables(monkeypatch, tmp_path):
|
|
37
|
-
"""This test checks if the get method returns a logger with
|
|
37
|
+
"""This test checks if the get method returns a logger with the specified settings"""
|
|
38
38
|
env_names_list = mwi_logger.get_environment_variable_names()
|
|
39
39
|
monkeypatch.setenv(env_names_list[0], "CRITICAL")
|
|
40
40
|
monkeypatch.setenv(env_names_list[1], str(tmp_path / "testing123.log"))
|
|
@@ -47,3 +47,37 @@ def test_get_with_environment_variables(monkeypatch, tmp_path):
|
|
|
47
47
|
# Verify that environment variable setting the file is respected
|
|
48
48
|
assert len(logger.handlers) == 1
|
|
49
49
|
assert os.path.basename(logger.handlers[0].baseFilename) == "testing123.log"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.parametrize(
|
|
53
|
+
"log_level, expected_level",
|
|
54
|
+
[
|
|
55
|
+
("DEBUG", logging.DEBUG),
|
|
56
|
+
("INFO", logging.INFO),
|
|
57
|
+
("WARNING", logging.WARNING),
|
|
58
|
+
("debug", logging.DEBUG),
|
|
59
|
+
("info", logging.INFO),
|
|
60
|
+
("warning", logging.WARNING),
|
|
61
|
+
],
|
|
62
|
+
)
|
|
63
|
+
def test_set_logging_configuration_known_logging_levels(
|
|
64
|
+
monkeypatch, log_level, expected_level
|
|
65
|
+
):
|
|
66
|
+
"""This test checks if the logger is set with correct level for known log levels"""
|
|
67
|
+
env_names_list = mwi_logger.get_environment_variable_names()
|
|
68
|
+
monkeypatch.setenv(env_names_list[0], log_level)
|
|
69
|
+
logger = mwi_logger.get(init=True)
|
|
70
|
+
assert (
|
|
71
|
+
logger.isEnabledFor(expected_level) == True
|
|
72
|
+
), f"Error in initialising the logger with {log_level}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.parametrize("log_level", ["ABC", "abc"])
|
|
76
|
+
def test_set_logging_configuration_unknown_logging_levels(monkeypatch, log_level):
|
|
77
|
+
"""This test checks if the logger is set with INFO level for unknown log levels"""
|
|
78
|
+
env_names_list = mwi_logger.get_environment_variable_names()
|
|
79
|
+
monkeypatch.setenv(env_names_list[0], log_level)
|
|
80
|
+
logger = mwi_logger.get(init=True)
|
|
81
|
+
assert (
|
|
82
|
+
logger.isEnabledFor(logging.INFO) == True
|
|
83
|
+
), "Error in initialising the default logger"
|