diff --git a/.gitignore b/.gitignore index da59ccd..46e8713 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ src/* # py.test's .cache/ directory .cache/* .idea/* +.vscode/* diff --git a/etc/byor_config.py b/etc/byor_config.py new file mode 100644 index 0000000..d8c096b --- /dev/null +++ b/etc/byor_config.py @@ -0,0 +1,5 @@ +c = get_config() +load_subconfig('etc/base_config.py') +load_subconfig('etc/github_auth.py') + +c.JupyterHub.spawner_class = 'everware.ByorDockerSpawner' diff --git a/everware/__init__.py b/everware/__init__.py index 6243a2c..2c97277 100755 --- a/everware/__init__.py +++ b/everware/__init__.py @@ -1,5 +1,6 @@ -__version__ = "0.11.0" +__version__ = "0.12.0" from .spawner import * +from .byor_spawner import * from .authenticator import * from .user_spawn_handler import * from .user_wait_handler import * diff --git a/everware/byor_spawner.py b/everware/byor_spawner.py new file mode 100644 index 0000000..1adc5d7 --- /dev/null +++ b/everware/byor_spawner.py @@ -0,0 +1,88 @@ +from os.path import join as pjoin + +import docker +from docker.errors import DockerException +from traitlets import Int +from tornado import gen + +from .spawner import CustomDockerSpawner + + +class ByorDockerSpawner(CustomDockerSpawner): + def __init__(self, **kwargs): + CustomDockerSpawner.__init__(self, **kwargs) + self._byor_client = None + if self.options_form == self._options_form_default(): + with open(pjoin(self.config['JupyterHub']['template_paths'][0], + '_byor_options_form.html')) as form: + ByorDockerSpawner.options_form = form.read() + + @property + def client(self): + if self._byor_client is not None: + return self._byor_client + return super(ByorDockerSpawner, self).client + + @property + def byor_is_used(self): + return self.user_options.get('byor_is_needed', False) + + def _reset_byor(self): + self.container_ip = str(self.__class__.container_ip) + self._byor_client = None + + byor_timeout = Int(20, min=1, config=True, + help='Timeout for connection to BYOR Docker daemon') + + def options_from_form(self, formdata): + options = {} + options['byor_is_needed'] = formdata.pop('byor_is_needed', [''])[0].strip() == 'on' + for field in ('byor_docker_ip', 'byor_docker_port'): + options[field] = formdata.pop(field, [''])[0].strip() + options.update( + super(ByorDockerSpawner, self).options_from_form(formdata) + ) + return options + + @gen.coroutine + def _configure_byor(self): + """Configure BYOR settings or reset them if BYOR is not needed.""" + if not self.byor_is_used: + self._reset_byor() + return + byor_ip = self.user_options['byor_docker_ip'] + byor_port = self.user_options['byor_docker_port'] + try: + # version='auto' causes a connection to the daemon. + # That's why the method must be a coroutine. + self._byor_client = docker.Client('{}:{}'.format(byor_ip, byor_port), + version='auto', + timeout=self.byor_timeout) + except DockerException as e: + self._is_failed = True + message = str(e) + if 'ConnectTimeoutError' in message: + log_message = 'Connection to the Docker daemon took too long (> {} secs)'.format( + self.byor_timeout + ) + notification_message = 'BYOR timeout limit {} exceeded'.format(self.byor_timeout) + else: + log_message = "Failed to establish connection with the Docker daemon" + notification_message = log_message + self._add_to_log(log_message, level=2) + yield self.notify_about_fail(notification_message) + self._is_building = False + raise + + self.container_ip = byor_ip + + @gen.coroutine + def _prepare_for_start(self): + super(ByorDockerSpawner, self)._prepare_for_start() + yield self._configure_byor() + + @gen.coroutine + def start(self, image=None): + yield self._prepare_for_start() + ip_port = yield self._start(image) + return ip_port diff --git a/everware/spawner.py b/everware/spawner.py index 0c32fab..522cab0 100755 --- a/everware/spawner.py +++ b/everware/spawner.py @@ -60,7 +60,6 @@ def get_global_client(): def _docker(self, method, *args, **kwargs): """wrapper for calling docker methods - to be passed to ThreadPoolExecutor """ # methods that return a generator object return instantly @@ -322,7 +321,6 @@ def generate_image_name(self): self.commit_sha ) - @gen.coroutine def remove_old_container(self): try: @@ -335,14 +333,16 @@ def remove_old_container(self): except APIError as e: self.log.info("Can't erase container %s due to %s" % (self.container_name, e)) - @gen.coroutine - def start(self, image=None): - """start the single-user server in a docker container""" + def _prepare_for_start(self): self._user_log = [] self._is_up = False self._is_failed = False self._is_building = True self._is_empty = False + + @gen.coroutine + def _start(self, image): + """start the single-user server in a docker container""" try: f = self.build_image() image_name = yield gen.with_timeout( @@ -395,9 +395,14 @@ def start(self, image=None): return self.user.server.ip, self.user.server.port # jupyterhub 0.7 prefers returning ip, port @gen.coroutine + def start(self, image=None): + self._prepare_for_start() + ip_port = yield self._start(image) + return ip_port + + @gen.coroutine def stop(self, now=False): """Stop the container - Consider using pause/unpause when docker-py adds support """ self._is_empty = True diff --git a/share/static/html/_byor_options_form.html b/share/static/html/_byor_options_form.html new file mode 100644 index 0000000..6b6a4a5 --- /dev/null +++ b/share/static/html/_byor_options_form.html @@ -0,0 +1,85 @@ +