diff --git a/.gitignore b/.gitignore index 820de4c8..3826b80c 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ htmlcov/ .tox/ .coverage .cache +.pytest_cache nosetests.xml coverage.xml @@ -65,6 +66,7 @@ src # Pycharm .idea +# test files .pytest_cache jupyterhub.sqlite jupyterhub_cookie_secret diff --git a/.travis.yml b/.travis.yml index 976ed0c6..47c3b850 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,5 +20,6 @@ install: - pip freeze script: - pyflakes dockerspawner - - docker pull jupyterhub/singleuser:$JUPYTERHUB + - docker pull jupyterhub/singleuser:0.8 + - docker pull jupyterhub/singleuser:0.9 - travis_retry py.test --cov dockerspawner tests -v diff --git a/dockerspawner/dockerspawner.py b/dockerspawner/dockerspawner.py index 41894c7c..1af29277 100644 --- a/dockerspawner/dockerspawner.py +++ b/dockerspawner/dockerspawner.py @@ -13,11 +13,22 @@ import docker from docker.errors import APIError from docker.utils import kwargs_from_env -from tornado import gen +from tornado import gen, web from escapism import escape from jupyterhub.spawner import Spawner -from traitlets import Dict, Unicode, Bool, Int, Any, default, observe +from traitlets import ( + Any, + Bool, + Dict, + List, + Int, + Unicode, + Union, + default, + observe, + validate, +) from .volumenamingstrategy import default_format_volume_name @@ -117,6 +128,7 @@ def _container_ip_deprecated(self, change): """, config=True, ) + @default('host_ip') def _default_host_ip(self): docker_host = os.getenv('DOCKER_HOST') @@ -177,6 +189,82 @@ def _container_image_changed(self, change): """, ) + image_whitelist = Union( + [Any(), Dict(), List()], + config=True, + help=""" + List or dict of images that users can run. + + If specified, users will be presented with a form + from which they can select an image to run. + + If a dictionary, the keys will be the options presented to users + and the values the actual images that will be launched. + + If a list, will be cast to a dictionary where keys and values are the same + (i.e. a shortcut for presenting the actual images directly to users). + + If a callable, will be called with the Spawner instance as its only argument. + The user is accessible as spawner.user. + The callable should return a dict or list as above. + """, + ) + + @validate('image_whitelist') + def _image_whitelist_dict(self, proposal): + """cast image_whitelist to a dict + + If passing a list, cast it to a {item:item} + dict where the keys and values are the same. + """ + whitelist = proposal.value + if isinstance(whitelist, list): + whitelist = {item: item for item in whitelist} + return whitelist + + def _get_image_whitelist(self): + """Evaluate image_whitelist callable + + Or return the whitelist as-is if it's already a dict + """ + if callable(self.image_whitelist): + whitelist = self.image_whitelist(self) + if not isinstance(whitelist, dict): + # always return a dict + whitelist = {item: item for item in whitelist} + return whitelist + return self.image_whitelist + + @default('options_form') + def _default_options_form(self): + image_whitelist = self._get_image_whitelist() + if len(image_whitelist) <= 1: + # default form only when there are images to choose from + return '' + # form derived from wrapspawner.ProfileSpawner + option_t = '' + options = [ + option_t.format( + image=image, selected='selected' if image == self.image else '' + ) + for image in image_whitelist + ] + return """ + + + """.format( + options=options + ) + + def options_from_form(self, formdata): + """Turn options formdata into user_options""" + options = {} + if 'image' in formdata: + options['image'] = formdata['image'][0] + return options + container_prefix = Unicode(config=True, help="DEPRECATED in 0.10. Use prefix") container_name_template = Unicode( @@ -185,7 +273,7 @@ def _container_image_changed(self, change): @observe("container_name_template", "container_prefix") def _deprecate_container_alias(self, change): - new_name = change.name[len("container_"):] + new_name = change.name[len("container_") :] setattr(self, new_name, change.new) prefix = Unicode( @@ -259,9 +347,7 @@ def _deprecate_container_alias(self, change): Reusable implementations should go in dockerspawner.VolumeNamingStrategy, tests should go in ... """ - ).tag( - config=True - ) + ).tag(config=True) def default_format_volume_name(template, spawner): return template.format(username=spawner.user.name) @@ -596,9 +682,30 @@ def remove_object(self): # remove the container, as well as any associated volumes yield self.docker("remove_" + self.object_type, self.object_id, v=True) + @gen.coroutine + def check_image_whitelist(self, image): + image_whitelist = self._get_image_whitelist() + if not image_whitelist: + return image + if image not in image_whitelist: + raise web.HTTPError( + 400, + "Image %s not in whitelist: %s" % (image, ', '.join(image_whitelist)), + ) + # resolve image alias to actual image name + return image_whitelist[image] + @gen.coroutine def create_object(self): """Create the container/service object""" + # image priority: + # 1. user options (from spawn options form) + # 2. self.image from config + image_option = self.user_options.get('image') + if image_option: + # save choice in self.image + self.image = yield self.check_image_whitelist(image_option) + create_kwargs = dict( image=self.image, environment=self.get_env(), @@ -791,7 +898,10 @@ def stop(self, now=False): Consider using pause/unpause when docker-py adds support """ self.log.info( - "Stopping %s %s (id: %s)", self.object_type, self.object_name, self.object_id[:7] + "Stopping %s %s (id: %s)", + self.object_type, + self.object_name, + self.object_id[:7], ) yield self.stop_object() diff --git a/tests/conftest.py b/tests/conftest.py index 99aef951..5282ab53 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from unittest import mock from docker import from_env as docker_from_env +from docker.errors import APIError import pytest from jupyterhub.tests.mocking import MockHub @@ -40,7 +41,12 @@ def docker(): if c.name.startswith("dockerspawner-test"): c.stop() c.remove() - - for c in d.services.list(): - if c.name.startswith("dockerspawner-test"): - c.remove() + try: + services = d.services.list() + except APIError: + # e.g. services not available + return + else: + for s in services: + if s.name.startswith("dockerspawner-test"): + s.remove() diff --git a/tests/test_dockerspawner.py b/tests/test_dockerspawner.py index eb9795f8..30b7502c 100644 --- a/tests/test_dockerspawner.py +++ b/tests/test_dockerspawner.py @@ -1,6 +1,9 @@ """Tests for DockerSpawner class""" +import json + import pytest + from jupyterhub.tests.test_api import add_user, api_request from jupyterhub.tests.mocking import public_url from jupyterhub.tests.utils import async_requests @@ -30,3 +33,38 @@ def test_start_stop(app): r.raise_for_status() print(r.text) assert "kernels" in r.json() + + +@pytest.mark.gen_test +@pytest.mark.parametrize("image", ["0.8", "0.9", "nomatch"]) +def test_image_whitelist(app, image): + name = "checker" + add_user(app.db, app, name=name) + user = app.users[name] + assert isinstance(user.spawner, DockerSpawner) + user.spawner.remove_containers = True + user.spawner.image_whitelist = { + "0.9": "jupyterhub/singleuser:0.9", + "0.8": "jupyterhub/singleuser:0.8", + } + token = user.new_api_token() + # start the server + r = yield api_request( + app, "users", name, "server", method="post", data=json.dumps({"image": image}) + ) + if image not in user.spawner.image_whitelist: + with pytest.raises(Exception): + r.raise_for_status() + return + while r.status_code == 202: + # request again + r = yield api_request(app, "users", name, "server", method="post") + assert r.status_code == 201, r.text + url = url_path_join(public_url(app, user), "api/status") + r = yield async_requests.get(url, headers={"Authorization": "token %s" % token}) + r.raise_for_status() + assert r.headers['x-jupyterhub-version'].startswith(image) + r = yield api_request( + app, "users", name, "server", method="delete", + ) + r.raise_for_status()