diff --git a/Makefile b/Makefile index aa580f9ca..a82686f79 100644 --- a/Makefile +++ b/Makefile @@ -39,7 +39,7 @@ develop: python3 setup.py develop .PHONY: install -install: +install: @echo "Installing application" @echo "----------------------" python3 setup.py install diff --git a/remoteappmanager/handlers/home_handler.py b/remoteappmanager/handlers/home_handler.py index 7d5ce971c..4972ca8b9 100644 --- a/remoteappmanager/handlers/home_handler.py +++ b/remoteappmanager/handlers/home_handler.py @@ -1,13 +1,4 @@ -import socket -import os -from datetime import timedelta - -import errno - -from remoteappmanager.utils import url_path_join -from tornado import gen, ioloop, web -from tornado.httpclient import AsyncHTTPClient, HTTPError -from tornado.log import app_log +from tornado import gen, web from remoteappmanager.handlers.base_handler import BaseHandler @@ -21,160 +12,6 @@ def get(self): images_info = yield self._get_images_info() self.render('home.html', images_info=images_info) - @web.authenticated - @gen.coroutine - def post(self): - """POST spawns with user-specified options""" - options = self._parse_form() - - try: - action = options["action"][0] - - # Hardcode handlers on the user-submitted form key. We don't want - # to risk weird injections - handler = { - "start": self._actionhandler_start, - "stop": self._actionhandler_stop, - "view": self._actionhandler_view - }[action] - except (KeyError, IndexError): - self.log.error("Failed to retrieve action from form.", - exc_info=True) - return - - try: - yield handler(options) - except Exception as exc: - # Create a random reference number for support - ref = self.log.issue( - "Failed with POST action {}".format(action), - exc) - - images_info = yield self._get_images_info() - - # Render the home page again with the error message - # User-facing error message (less info) - message = ('Failed to {action}. ' - 'Reason: {error_type} ' - '(Ref: {ref})') - self.render('home.html', - images_info=images_info, - error_message=message.format( - action=action, - error_type=type(exc).__name__, - ref=ref)) - - # Subhandling after post - - @gen.coroutine - def _actionhandler_start(self, options): - """Sub handling. Acts in response to a "start" request from - the user.""" - container_manager = self.application.container_manager - - mapping_id = options["mapping_id"][0] - - all_apps = self.application.db.get_apps_for_user( - self.current_user.account) - - choice = [(m_id, app, policy) - for m_id, app, policy in all_apps - if m_id == mapping_id] - - if not choice: - raise ValueError("User is not allowed to run the application.") - - _, app, policy = choice[0] - - container = None - user_name = self.current_user.name - try: - container = yield self._start_container(user_name, - app, - policy, - mapping_id) - yield self._wait_for_container_ready(container) - except Exception as e: - # Clean up, if the container is running - if container is not None: - try: - yield container_manager.stop_and_remove_container( - container.docker_id) - except Exception: - self.log.exception( - "Unable to stop container {} after failure" - " to obtain a ready container".format( - container.docker_id) - ) - raise e - - # The server is up and running. Now contact the proxy and add - # the container url to it. - urlpath = url_path_join( - self.application.command_line_config.base_urlpath, - container.urlpath) - yield self.application.reverse_proxy.register( - urlpath, container.host_url) - - # Redirect the user - self.log.info('Redirecting to {}'.format(urlpath)) - self.redirect(urlpath) - - @gen.coroutine - def _actionhandler_view(self, options): - """Redirects to an already started container. - It is not different from pasting the appropriate URL in the - web browser, but we validate the container id first. - """ - url_id = options["url_id"][0] - - container_manager = self.application.container_manager - container = yield container_manager.container_from_url_id(url_id) - if not container: - self.log.warning("Could not find container for url_id {}".format( - url_id - )) - raise ValueError("Unable to view container for specified url_id") - - # make sure the container is actually running and working - yield self._wait_for_container_ready(container) - - # in case the reverse proxy is not already set up - urlpath = url_path_join( - self.application.command_line_config.base_urlpath, - container.urlpath) - yield self.application.reverse_proxy.register( - urlpath, container.host_url) - - self.log.info('Redirecting to {}'.format(urlpath)) - self.redirect(urlpath) - - @gen.coroutine - def _actionhandler_stop(self, options): - """Stops a running container. - """ - url_id = options["url_id"][0] - - app = self.application - container_manager = app.container_manager - - container = yield container_manager.container_from_url_id(url_id) - if not container: - self.log.warning("Could not find container for url_id {}".format( - url_id - )) - raise ValueError("Unable to view container for specified url_id") - - urlpath = url_path_join( - self.application.command_line_config.base_urlpath, - container.urlpath) - yield app.reverse_proxy.unregister(urlpath) - yield container_manager.stop_and_remove_container(container.docker_id) - - # We don't have fancy stuff at the moment to change the button, so - # we just reload the page. - self.redirect(self.application.command_line_config.base_urlpath) - # private @gen.coroutine @@ -212,142 +49,3 @@ def _get_images_info(self): "container": container }) return images_info - - @gen.coroutine - def _start_container(self, user_name, app, policy, mapping_id): - """Start the container. This method is a helper method that - works with low level data and helps in issuing the request to the - data container. - - Parameters - ---------- - user_name : str - the user name to be associated with the container - - app : ABCApplication - the application to start - - policy : ABCApplicationPolicy - The startup policy for the application - - Returns - ------- - Container - """ - - image_name = app.image - mount_home = policy.allow_home - volume_spec = (policy.volume_source, - policy.volume_target, - policy.volume_mode) - - manager = self.application.container_manager - volumes = {} - - if mount_home: - home_path = os.environ.get('HOME') - if home_path: - volumes[home_path] = {'bind': '/workspace', 'mode': 'rw'} - else: - self.log.warning('HOME (%s) is not available for %s', - home_path, user_name) - - if None not in volume_spec: - volume_source, volume_target, volume_mode = volume_spec - volumes[volume_source] = {'bind': volume_target, - 'mode': volume_mode} - - try: - f = manager.start_container(user_name, image_name, - mapping_id, volumes) - container = yield gen.with_timeout( - timedelta( - seconds=self.application.file_config.network_timeout - ), - f - ) - except gen.TimeoutError as e: - self.log.warning( - "{user}'s container failed to start in a reasonable time. " - "giving up".format(user=user_name) - ) - e.reason = 'timeout' - raise e - except Exception as e: - self.log.error( - "Unhandled error starting {user}'s " - "container: {error}".format(user=user_name, error=e) - ) - e.reason = 'error' - raise e - - return container - - def _parse_form(self): - """Extract the form options from the form and return them - in a practical dictionary.""" - form_options = {} - for key, byte_list in self.request.body_arguments.items(): - form_options[key] = [bs.decode('utf8') for bs in byte_list] - - return form_options - - @gen.coroutine - def _wait_for_container_ready(self, container): - """ Wait until the container is ready to be connected - - Parameters - ---------- - container: Container - The container to be connected - """ - # Note, we use the jupyterhub ORM server, but we don't use it for - # any database activity. - # Note: the end / is important. We want to get a 200 from the actual - # websockify server, not the nginx (which presents the redirection - # page). - server_url = "http://{}:{}{}/".format( - container.ip, - container.port, - url_path_join(self.application.command_line_config.base_urlpath, - container.urlpath)) - - yield _wait_for_http_server_2xx( - server_url, - self.application.file_config.network_timeout) - - -@gen.coroutine -def _wait_for_http_server_2xx(url, timeout=10): - """Wait for an HTTP Server to respond at url and respond with a 2xx code. - """ - loop = ioloop.IOLoop.current() - tic = loop.time() - client = AsyncHTTPClient() - - while loop.time() - tic < timeout: - try: - response = yield client.fetch(url, follow_redirects=True) - except HTTPError as e: - # Skip code 599 because it's expected and we don't want to - # pollute the logs. - if e.code != 599: - app_log.warning("Server at %s responded with: %s", url, e.code) - except (OSError, socket.error) as e: - if e.errno not in {errno.ECONNABORTED, - errno.ECONNREFUSED, - errno.ECONNRESET}: - app_log.warning("Failed to connect to %s (%s)", url, e) - except Exception as e: - # In case of any unexpected exception, we just log it and keep - # trying until eventually we timeout. - app_log.warning("Unknown exception occurred connecting to " - "%s (%s)", url, e) - else: - app_log.info("Server at %s responded with: %s", url, response.code) - return - - yield gen.sleep(0.1) - - raise TimeoutError("Server at {} didn't respond in {} seconds".format( - url, timeout)) diff --git a/remoteappmanager/netutils.py b/remoteappmanager/netutils.py new file mode 100644 index 000000000..6db4e625d --- /dev/null +++ b/remoteappmanager/netutils.py @@ -0,0 +1,42 @@ +import socket +import errno + +from tornado import gen, ioloop +from tornado.httpclient import AsyncHTTPClient, HTTPError +from tornado.log import app_log + + +@gen.coroutine +def wait_for_http_server_2xx(url, timeout=10): + """Wait for an HTTP Server to respond at url and respond with a 2xx code. + """ + loop = ioloop.IOLoop.current() + tic = loop.time() + client = AsyncHTTPClient() + + while loop.time() - tic < timeout: + try: + response = yield client.fetch(url, follow_redirects=True) + except HTTPError as e: + # Skip code 599 because it's expected and we don't want to + # pollute the logs. + if e.code != 599: + app_log.warning("Server at %s responded with: %s", url, e.code) + except (OSError, socket.error) as e: + if e.errno not in {errno.ECONNABORTED, + errno.ECONNREFUSED, + errno.ECONNRESET}: + app_log.warning("Failed to connect to %s (%s)", url, e) + except Exception as e: + # In case of any unexpected exception, we just log it and keep + # trying until eventually we timeout. + app_log.warning("Unknown exception occurred connecting to " + "%s (%s)", url, e) + else: + app_log.info("Server at %s responded with: %s", url, response.code) + return + + yield gen.sleep(0.1) + + raise TimeoutError("Server at {} didn't respond in {} seconds".format( + url, timeout)) diff --git a/remoteappmanager/rest/exceptions.py b/remoteappmanager/rest/exceptions.py index 5ae6f83dc..4be58acb5 100644 --- a/remoteappmanager/rest/exceptions.py +++ b/remoteappmanager/rest/exceptions.py @@ -25,3 +25,7 @@ class BadRequest(RESTException): representation is ill-formed """ http_code = httpstatus.BAD_REQUEST + + +class InternalServerError(RESTException): + pass diff --git a/remoteappmanager/restresources/container.py b/remoteappmanager/restresources/container.py index 64bf36a47..5ad389e67 100644 --- a/remoteappmanager/restresources/container.py +++ b/remoteappmanager/restresources/container.py @@ -4,11 +4,11 @@ from tornado import gen from remoteappmanager.docker.docker_labels import SIMPHONY_NS -from remoteappmanager.handlers.home_handler import _wait_for_http_server_2xx from remoteappmanager.rest import exceptions from remoteappmanager.rest.resource import Resource from remoteappmanager.docker.container import Container as DockerContainer from remoteappmanager.utils import url_path_join +from remoteappmanager.netutils import wait_for_http_server_2xx class Container(Resource): @@ -21,21 +21,41 @@ def create(self, representation): account = self.current_user.account all_apps = self.application.db.get_apps_for_user(account) + container_manager = self.application.container_manager choice = [(m_id, app, policy) for m_id, app, policy in all_apps if m_id == mapping_id] if not choice: + self.log.warning("Could not find resource " + "for mapping id {}".format(mapping_id)) raise exceptions.BadRequest() _, app, policy = choice[0] + container = None + + try: + container = yield self._start_container( + self.current_user.name, + app, + policy, + mapping_id) + yield self._wait_for_container_ready(container) + except Exception as e: + if container is not None: + try: + yield container_manager.stop_and_remove_container( + container.docker_id) + except Exception: + self.log.exception( + "Unable to stop container {} after " + " failure to obtain a ready " + "container".format( + container.docker_id)) + + raise exceptions.InternalServerError() - container = yield self._start_container(self.current_user.name, - app, - policy, - mapping_id) - yield self._wait_for_container_ready(container) urlpath = url_path_join( self.application.command_line_config.base_urlpath, container.urlpath) @@ -50,6 +70,8 @@ def retrieve(self, identifier): container = yield self._container_from_url_id(identifier) if container is None: + self.log.warning("Could not find container for id {}".format( + identifier)) raise exceptions.NotFound() return dict( @@ -62,6 +84,8 @@ def delete(self, identifier): """Stop the container.""" container = yield self._container_from_url_id(identifier) if not container: + self.log.warning("Could not find container for id {}".format( + identifier)) raise exceptions.NotFound() urlpath = url_path_join( @@ -197,7 +221,7 @@ def _wait_for_container_ready(self, container): Parameters ---------- - container: Container + container: docker.Container The container to be connected """ # Note, we use the jupyterhub ORM server, but we don't use it for @@ -211,6 +235,6 @@ def _wait_for_container_ready(self, container): url_path_join(self.application.command_line_config.base_urlpath, container.urlpath)) - yield _wait_for_http_server_2xx( + yield wait_for_http_server_2xx( server_url, self.application.file_config.network_timeout) diff --git a/remoteappmanager/static/js/home.js b/remoteappmanager/static/js/home.js index cb9be3ede..64ba226ac 100644 --- a/remoteappmanager/static/js/home.js +++ b/remoteappmanager/static/js/home.js @@ -1,12 +1,19 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -require(["jquery", "jhapi"], function ($, JHAPI) { +require(["jquery", "jhapi", "utils", "remoteappapi"], + function($, JHAPI, utils, RemoteAppAPI) { "use strict"; - var base_url = window.jhdata.base_url; - var user = window.jhdata.user; + var base_url = window.apidata.base_url; + var user = window.apidata.user; var api = new JHAPI(base_url); + var appapi = new RemoteAppAPI(base_url); + + var report_error = function (jqXHR, status, error) { + var msg = utils.log_ajax_error(jqXHR, status, error); + $(".spawn-error-msg").text(msg).show(); + }; $("#stop").click(function () { api.stop_server(user, { @@ -16,4 +23,58 @@ require(["jquery", "jhapi"], function ($, JHAPI) { }); }); + $(".start-button").click(function () { + var button = this; + var id = button.id; + $(button).find(".fa-spinner").show(); + + appapi.start_application(id, { + error: function(jqXHR, status, error) { + report_error(jqXHR, status, error); + $(button).find(".fa-spinner").hide(); + }, + statusCode: { + 201: function (data, textStatus, request) { + var location = request.getResponseHeader('Location'); + var url = utils.parse_url(location); + var arr = url.pathname.replace(/\/$/, "").split('/'); + var id = arr[arr.length-1]; + $(button).find(".fa-spinner").hide(); + + window.location = utils.url_path_join( + base_url, + "containers", + id + ) + }, + } + }); + } + ); + + $(".view-button").click(function () { + var id = this.id; + window.location = utils.url_path_join( + base_url, + "containers", + id) + } + ); + + $(".stop-button").click(function () { + var button = this; + var id = this.id; + $(button).find(".fa-spinner").show(); + appapi.stop_application(id, { + success: function () { + $(button).find(".fa-spinner").hide(); + window.location.reload() + }, + error: function(jqXHR, status, error) { + report_error(jqXHR, status, error); + $(button).find(".fa-spinner").hide(); + } + }); + }); + }); diff --git a/remoteappmanager/static/js/remoteappapi.js b/remoteappmanager/static/js/remoteappapi.js new file mode 100644 index 000000000..3de176ad6 --- /dev/null +++ b/remoteappmanager/static/js/remoteappapi.js @@ -0,0 +1,69 @@ +define(['jquery', 'utils'], function ($, utils) { + "use strict"; + + var RemoteAppAPI = function (base_url) { + this.base_url = base_url; + }; + + var default_options = { + type: 'GET', + contentType: "application/json", + cache: false, + dataType : "json", + processData: false, + success: null, + error: null + }; + + var update = function (d1, d2) { + $.map(d2, function (i, key) { + d1[key] = d2[key]; + }); + return d1; + }; + + var ajax_defaults = function (options) { + var d = {}; + update(d, default_options); + update(d, options); + return d; + }; + + RemoteAppAPI.prototype.api_request = function (path, options) { + options = options || {}; + options = ajax_defaults(options || {}); + var url = utils.url_path_join( + this.base_url, + 'api', + 'v1', + utils.encode_uri_components(path) + )+'/'; + + $.ajax(url, options); + }; + + RemoteAppAPI.prototype.start_application = function(id, options) { + options = options || {}; + options = update(options, { + type: 'POST', + dataType: null, + data: JSON.stringify({ + mapping_id: id + })}); + this.api_request( + 'containers', + options + ); + }; + + RemoteAppAPI.prototype.stop_application = function (id, options) { + options = options || {}; + options = update(options, {type: 'DELETE', dataType: null}); + this.api_request( + utils.url_path_join('containers', id), + options + ); + }; + + return RemoteAppAPI; +}); diff --git a/remoteappmanager/templates/home.html b/remoteappmanager/templates/home.html index 6b6b7a048..06577aad8 100644 --- a/remoteappmanager/templates/home.html +++ b/remoteappmanager/templates/home.html @@ -14,40 +14,32 @@

Available Applications

- {% if error_message %} -

- {{error_message}} -

- {% endif %} + {% for info in images_info %} -
-
- {% if info.image.icon_128 == '' %} - - {% else %} - - {% endif %} - {% if info.image.ui_name == '' %} -

{{info.image.name}}

- {% else %} -

{{info.image.ui_name}}

- {% endif %} - {% if info.container == None %} - -
- -
- {% else %} - -
- -
-
- -
- {% endif %} -
-
+
+ {% if info.image.icon_128 == '' %} + + {% else %} + + {% endif %} + {% if info.image.ui_name == '' %} +

{{info.image.name}}

+ {% else %} +

{{info.image.ui_name}}

+ {% endif %} + {% if info.container == None %} +
+ +
+ {% else %} +
+ +
+
+ +
+ {% endif %} +
{% endfor %}
diff --git a/remoteappmanager/templates/page.html b/remoteappmanager/templates/page.html index acf5053dc..ec4bcbc41 100644 --- a/remoteappmanager/templates/page.html +++ b/remoteappmanager/templates/page.html @@ -57,7 +57,7 @@