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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ develop:
python3 setup.py develop

.PHONY: install
install:
install:
@echo "Installing application"
@echo "----------------------"
python3 setup.py install
Expand Down
304 changes: 1 addition & 303 deletions remoteappmanager/handlers/home_handler.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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))
42 changes: 42 additions & 0 deletions remoteappmanager/netutils.py
Original file line number Diff line number Diff line change
@@ -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))
4 changes: 4 additions & 0 deletions remoteappmanager/rest/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@ class BadRequest(RESTException):
representation is ill-formed
"""
http_code = httpstatus.BAD_REQUEST


class InternalServerError(RESTException):
pass
Loading