diff --git a/README.md b/README.md index 5d489609..db4f7223 100644 --- a/README.md +++ b/README.md @@ -440,11 +440,11 @@ To run tests you have written for the Django webserver go into the *webserver* f python3 ./manage.py test -v 2 ``` -### 6.3. Docker API +### 6.3. API There is a RESTful API written with the python libary FastAPI for communicating with docker containers that can be found in `/docker`. -The API needs to run on `127.0.0.1:8000`. To start the API go to `/docker` and run +The API needs to run on `0.0.0.0:8000`. To start the API go to `/docker` and run ```terminal uvicorn app:app --reload @@ -456,7 +456,23 @@ You can just run the `app.py` with python from the docker folder as well. If you want to use the API, you need to provide an `AUTHORIZATION_TOKEN` in your environment variables. For each API request the value at the authorization header will be checked. You can only perform actions on the API, when this value is the same, as the value in your environment variable. -WARNING: Please keep in mind, that the `AUTHORIZATION_TOKEN` must be kept a secret, if it is revealed, you need to revoke it and set a new secret. Furthermore, think about using transport encryption to ensure that the token won't get stolen on the way. +**WARNING**: Please keep in mind, that the `AUTHORIZATION_TOKEN` must be kept a secret, if it is revealed, you need to revoke it and set a new secret. Furthermore, think about using transport encryption to ensure that the token won't get stolen on the way. + +### 6.4. Websocket + +You can receive notifications on your website about stopped container by our websocket. +Just run the `container_notification_websocket.py` file in `/docker`. +The websocket will run on port 8001. +Your webserver will automatically connect to this websocket. +Whenever a container is exited you will receive a push notification in your interface. + +#### Troubleshooting Websocket + +Check in the developer console of your favorite browser if the connection is established. +It might read "cannot connect to the websocket due to `ERR_CERT_AUTHORITY_INVALID` or similar". +This is caused by self signed certificates. +Open a new tab and enter the address of the websocket server, but with `https` protocol instead of `wss`. +Accept the risk and continue. ## 7. Tensorboard diff --git a/docker/container_notification_websocket.py b/docker/container_notification_websocket.py new file mode 100644 index 00000000..1cf7f8a8 --- /dev/null +++ b/docker/container_notification_websocket.py @@ -0,0 +1,65 @@ +import asyncio +import json +import logging +import os + +import uvicorn +from docker_manager import DockerManager +from fastapi import FastAPI, WebSocket + +logger = logging.getLogger('uvicorn.error') +path_to_log_files = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'log_files') +if not os.path.isdir(path_to_log_files): + os.makedirs(path_to_log_files) + + +class ConnectionManager: + def __init__(self): + self.active_connections = [] + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.append(websocket) + logger.info(f'got new connection of {websocket}, current connections: {self.active_connections}') + + def disconnect(self, websocket: WebSocket): + self.active_connections.remove(websocket) + + async def broadcast(self, message: str): + for connection in self.active_connections: + await connection.send_json(message) + + +manager = DockerManager(logger) +connection_manager = ConnectionManager() +app = FastAPI() + + +@app.on_event('startup') +async def startup_event(): + logger.info('started websocket') + + +@app.websocket('/wss') +async def websocket_endpoint(websocket: WebSocket): + await connection_manager.connect(websocket) + last_docker_info = None + try: + while True: + await asyncio.sleep(5) + is_exited, docker_info = manager.check_health_of_all_container() + if is_exited and last_docker_info != docker_info: + logger.info(f'Sending information about stopped container {vars(docker_info)}') + await connection_manager.broadcast(json.dumps(vars(docker_info))) + last_docker_info = docker_info + except Exception: + connection_manager.disconnect(websocket) + + +if __name__ == '__main__': + uvicorn.run('container_notification_websocket:app', + host='0.0.0.0', + port=8001, + log_config='./log_websocket.ini', + ssl_keyfile='/etc/sslzertifikat/api_cert.key', + ssl_certfile='/etc/sslzertifikat/api_cert.crt') diff --git a/docker/log_websocket.ini b/docker/log_websocket.ini new file mode 100644 index 00000000..8fd744a2 --- /dev/null +++ b/docker/log_websocket.ini @@ -0,0 +1,21 @@ +[loggers] +keys=root + +[handlers] +keys=logfile + +[formatters] +keys=logfileformatter + +[logger_root] +level=INFO +handlers=logfile + +[formatter_logfileformatter] +format=[%(asctime)s.%(msecs)03d] %(levelname)s [%(thread)d] - %(message)s + +[handler_logfile] +class=handlers.RotatingFileHandler +level=INFO +args=('log_files/websocket.log','a') +formatter=logfileformatter diff --git a/webserver/alpha_business_app/buttons.py b/webserver/alpha_business_app/buttons.py index 3fc9e2b6..d87ca430 100644 --- a/webserver/alpha_business_app/buttons.py +++ b/webserver/alpha_business_app/buttons.py @@ -6,7 +6,7 @@ from .config_merger import ConfigMerger from .config_parser import ConfigFlatDictParser -from .container_parser import parse_response_to_database +from .container_helper import parse_response_to_database from .handle_files import download_config, download_file from .handle_requests import DOCKER_API, send_get_request, send_get_request_with_streaming, send_post_request, stop_container from .models.config import Config diff --git a/webserver/alpha_business_app/container_parser.py b/webserver/alpha_business_app/container_helper.py similarity index 57% rename from webserver/alpha_business_app/container_parser.py rename to webserver/alpha_business_app/container_helper.py index 10b98b4b..30a30fc1 100644 --- a/webserver/alpha_business_app/container_parser.py +++ b/webserver/alpha_business_app/container_helper.py @@ -1,9 +1,10 @@ import copy +import json import names from .config_parser import ConfigModelParser -from .models.container import Container +from .models.container import Container, update_container def parse_response_to_database(api_response, config_dict: dict, given_name: str, user) -> None: @@ -13,7 +14,8 @@ def parse_response_to_database(api_response, config_dict: dict, given_name: str, Args: api_response (APIResponse): The converted response from the docker API. config_dict (dict): The dict the container have been started with. - given_name (str): the name the user put into the field + given_name (str): the name the user put into the field. + user (User): The user who did the request. """ started_container = api_response.content # check if the api response is correct @@ -22,7 +24,7 @@ def parse_response_to_database(api_response, config_dict: dict, given_name: str, return False, [], 'The API answer was wrong, please try' num_experiments = len(started_container) - name = names.get_first_name() if not given_name else given_name + name = names.get_first_name() if not given_name else str(given_name) # save the used config config_object = ConfigModelParser().parse_config(copy.deepcopy(config_dict)) @@ -53,3 +55,38 @@ def parse_response_to_database(api_response, config_dict: dict, given_name: str, name=current_container_name, user=user) return True, [], name + + +def get_actually_stopped_container_from_api_notification(api_response: str) -> str: + """ + Parses the notification from the websocket to a notification in view. + + Args: + api_response (str): _description_ + + Returns: + str: _description_ + """ + try: + raw_data = json.loads(json.loads(api_response)) + except json.decoder.JSONDecodeError: + print(f'Could not parse API Response to valid JSON {api_response}') + return False, [] + + polished_data = [item[1:-1].split(',') for item in raw_data['status'].split(';')] + polished_data = [(container_id[1:-1].strip(), exit_code.strip()) for container_id, exit_code in polished_data] + result = [] + for container_id, exit_code in polished_data: + try: + wanted_container = Container.objects.get(id=container_id) + except Container.DoesNotExist: + continue + if wanted_container.health_status.startswith('exit'): + continue + update_container(container_id, {'health_status': f'exited({exit_code})'}) + result += [{'container_name': wanted_container.name, 'exit_code': exit_code}] + print(result) + if not result: + return False, [] + + return True, result diff --git a/webserver/alpha_business_app/handle_requests.py b/webserver/alpha_business_app/handle_requests.py index c0552adf..6708fac6 100644 --- a/webserver/alpha_business_app/handle_requests.py +++ b/webserver/alpha_business_app/handle_requests.py @@ -26,7 +26,7 @@ def _get_api_token() -> str: except FileNotFoundError: print('No .env file found, using environment variable instead.') try: - master_secret = os.environ['API_TOKEN'] + master_secret = os.environ['API_TOKEN'].strip() except KeyError: print('Could not get API key') return 'abc' @@ -154,6 +154,10 @@ def get_api_status() -> dict: return {'api_docker_timeout': f'Docker unavailable - {current_time}'} +def websocket_url() -> str: + return 'wss://vm-midea03.eaalab.hpi.uni-potsdam.de:8001/wss' + + def _error_handling_API(response) -> APIResponse: """ Defines error codes and appropriate response messages if we get error codes from the API. diff --git a/webserver/alpha_business_app/static/js/custom.js b/webserver/alpha_business_app/static/js/custom.js index 933271b3..0fcd516c 100644 --- a/webserver/alpha_business_app/static/js/custom.js +++ b/webserver/alpha_business_app/static/js/custom.js @@ -43,7 +43,7 @@ $(document).ready(function() { $(this).addClass("d-none") }); } - }).trigger('change'); + }).trigger("change"); $("select.marketplace-selection").change(function () { // will be called when another marketplace has been selected @@ -101,12 +101,12 @@ $(document).ready(function() { function getCookie(name) { let cookieValue = null; - if (document.cookie && document.cookie !== '') { - const cookies = document.cookie.split(';'); + if (document.cookie && document.cookie !== "") { + const cookies = document.cookie.split(";"); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].trim(); // Does this cookie string begin with the name we want? - if (cookie.substring(0, name.length + 1) === (name + '=')) { + if (cookie.substring(0, name.length + 1) === (name + "=")) { cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); break; } @@ -114,12 +114,21 @@ $(document).ready(function() { } return cookieValue; } - + + function replaceOrInsert(element, identifier, data) { + if (element.length > 0) { + element.replaceWith(data); + } else { + const endOfContent = document.getElementById(identifier); + endOfContent.insertAdjacentHTML("afterend", data); + } + } + $("button.form-check").click(function () { var self = $(this); var formdata = getFormData(); - const csrftoken = getCookie('csrftoken'); + const csrftoken = getCookie("csrftoken"); $.ajax({ type: "POST", url: self.data("url"), @@ -128,8 +137,41 @@ $(document).ready(function() { formdata }, success: function (data) { - $("p.notice-field").replaceWith(data); + replaceOrInsert($("#notice-field"), "end-of-content", data); } }); }); + // var url = ""; + // var url = "ws://192.168.159.134:8001/ws"; + // var url = "wss://vm-midea03.eaalab.hpi.uni-potsdam.de:8001/wss"; + $.ajax({url: "/api_info", + success: function (data) { + var url = data["url"]; + console.log('ajax return', data["url"]) + var ws = new WebSocket(url); + ws.onopen = function (_) { + console.log("connection to ", url, "open"); + }; + ws.onmessage = function(event) { + const csrftoken = getCookie("csrftoken"); + $.ajax({ + type: "POST", + url: "/container_notification", + data: { + csrfmiddlewaretoken: csrftoken, + api_response: event.data + }, + success: function (data) { + const endOfContent = document.getElementById("main-nav-bar"); + endOfContent.insertAdjacentHTML("afterend", data); + } + }); + console.log(event.data); + }; + ws.onclose = function(_) { + console.log("connection to ", url, "closed"); + }; + } + }); + }); diff --git a/webserver/alpha_business_app/urls.py b/webserver/alpha_business_app/urls.py index f7f7a732..a1d57ecd 100644 --- a/webserver/alpha_business_app/urls.py +++ b/webserver/alpha_business_app/urls.py @@ -17,6 +17,8 @@ path('api_availability', views.api_availability, name='api_availability'), path('marketplace_changed', views.marketplace_changed, name='marketplace'), path('validate_config', views.config_validation, name='config_validation'), + path('container_notification', views.container_notification, name='container_notification'), + path('api_info', views.get_api_url, name='api_info'), # User relevant urls path('accounts/', include('django.contrib.auth.urls')) diff --git a/webserver/alpha_business_app/views.py b/webserver/alpha_business_app/views.py index 2dd16eb5..c1928cb0 100644 --- a/webserver/alpha_business_app/views.py +++ b/webserver/alpha_business_app/views.py @@ -1,16 +1,17 @@ from uuid import uuid4 from django.contrib.auth.decorators import login_required -from django.http import Http404, HttpResponse +from django.http import Http404, HttpResponse, JsonResponse from django.shortcuts import render from recommerce.configuration.config_validation import validate_config from .buttons import ButtonHandler from .config_parser import ConfigFlatDictParser +from .container_helper import get_actually_stopped_container_from_api_notification from .forms import UploadFileForm from .handle_files import handle_uploaded_file -from .handle_requests import get_api_status +from .handle_requests import get_api_status, websocket_url from .models.config import Config from .models.container import Container from .selection_manager import SelectionManager @@ -145,6 +146,17 @@ def config_validation(request) -> HttpResponse: return render(request, 'notice_field.html', {'success': 'This config is valid'}) +@login_required +def container_notification(request): + if request.method == 'POST': + is_notification_necessary, result = get_actually_stopped_container_from_api_notification(request.POST['api_response']) + return render(request, 'alert_field.html', {'warning': result, 'should_render': is_notification_necessary}) + + +def get_api_url(request): + return JsonResponse({'url': websocket_url()}, status=200, content_type='application/json') + + @login_required def marketplace_changed(request) -> HttpResponse: if not request.user.is_authenticated: diff --git a/webserver/templates/alert_field.html b/webserver/templates/alert_field.html new file mode 100644 index 00000000..ba4b39f0 --- /dev/null +++ b/webserver/templates/alert_field.html @@ -0,0 +1,9 @@ +{% if should_render %} + +{% endif %} diff --git a/webserver/templates/base.html b/webserver/templates/base.html index 21b2754e..0b47b162 100644 --- a/webserver/templates/base.html +++ b/webserver/templates/base.html @@ -14,7 +14,7 @@
-
+ {% include "alert_field.html" %} {% block content %} {% endblock content %} -
- {% include "notice_field.html" %} +
+ {% include "notice_field.html"%}