diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a3a7848d..f119e391 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -33,8 +33,8 @@ jobs: id-token: write steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: "3.11" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index adabbf5e..3321d137 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -45,11 +45,11 @@ jobs: test-variation: podman steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: "18" @@ -127,4 +127,4 @@ jobs: pytest tests/test_dockerspawner.py --cov=dockerspawner # GitHub action reference: https://github.com/codecov/codecov-action - - uses: codecov/codecov-action@v3 + - uses: codecov/codecov-action@v4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5572323e..78c72c32 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: # Autoformat: Python code, syntax patterns are modernized - repo: https://github.com/asottile/pyupgrade - rev: v3.4.0 + rev: v3.15.0 hooks: - id: pyupgrade args: @@ -17,7 +17,7 @@ repos: # Autoformat: Python code - repo: https://github.com/PyCQA/autoflake - rev: v2.1.1 + rev: v2.2.1 hooks: - id: autoflake # args ref: https://github.com/PyCQA/autoflake#advanced-usage @@ -32,7 +32,7 @@ repos: # Autoformat: Python - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 23.10.1 hooks: - id: black @@ -44,19 +44,19 @@ repos: # Autoformat: markdown, yaml - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.0.0-alpha.9-for-vscode + rev: v3.0.3 hooks: - id: prettier # Lint: Python code - repo: https://github.com/PyCQA/flake8 - rev: "6.0.0" + rev: "6.1.0" hooks: - id: flake8 # Misc autoformatting and linting - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: end-of-file-fixer - id: requirements-txt-fixer diff --git a/dev-requirements.txt b/dev-requirements.txt index 337b1d3d..b899b3e1 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,6 @@ netifaces notebook<7 pytest>=3.6 -pytest-asyncio +# FIXME: unpin pytest-asyncio +pytest-asyncio>=0.17,<0.23 pytest-cov diff --git a/dockerspawner/_version.py b/dockerspawner/_version.py index d449722c..7c5ef208 100644 --- a/dockerspawner/_version.py +++ b/dockerspawner/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "12.2.0.dev" +__version__ = "13.1.0.dev" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/dockerspawner/dockerspawner.py b/dockerspawner/dockerspawner.py index 3443ed15..0916e369 100644 --- a/dockerspawner/dockerspawner.py +++ b/dockerspawner/dockerspawner.py @@ -2,6 +2,7 @@ A Spawner for JupyterHub that runs each user's server in a separate docker container """ import asyncio +import inspect import os import string import warnings @@ -54,6 +55,24 @@ def validate(self, obj, value): _jupyterhub_xy = "%i.%i" % (jupyterhub.version_info[:2]) +def _deep_merge(dest, src): + """Merge dict `src` into `dest`, recursively + + Modifies `dest` in-place, returns dest + """ + for key, value in src.items(): + if key in dest: + dest_value = dest[key] + if isinstance(dest_value, dict) and isinstance(value, dict): + dest[key] = _deep_merge(dest_value, value) + else: + dest[key] = value + else: + dest[key] = value + + return dest + + class DockerSpawner(Spawner): """A Spawner for JupyterHub that runs each user's server in a separate docker container""" @@ -196,13 +215,13 @@ def _ip_default(self): return "0.0.0.0" container_image = Unicode( - "jupyterhub/singleuser:%s" % _jupyterhub_xy, + "quay.io/jupyterhub/singleuser:%s" % _jupyterhub_xy, help="Deprecated, use ``DockerSpawner.image``.", config=True, ) image = Unicode( - "jupyterhub/singleuser:%s" % _jupyterhub_xy, + "quay.io/jupyterhub/singleuser:%s" % _jupyterhub_xy, config=True, help="""The image to use for single-user servers. @@ -242,43 +261,56 @@ def _ip_default(self): 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. + The callable should return a dict or list or None as above. + + If empty (default), the value from ``image`` is used and + any attempt to specify the image via user_options will result in an error. + + .. versionchanged:: 13 + Empty allowed_images means no user-specified images are allowed. + This is the default. + Prior to 13, restricting to single image required a length-1 list, + e.g. ``allowed_images = [image]``. + + .. versionadded:: 13 + To allow any image, specify ``allowed_images = "*"``. .. versionchanged:: 12.0 ``DockerSpawner.image_whitelist`` renamed to ``allowed_images`` - """, ) @validate('allowed_images') - def _allowed_images_dict(self, proposal): + def _validate_allowed_images(self, proposal): """cast allowed_images to a dict If passing a list, cast it to a {item:item} dict where the keys and values are the same. """ - allowed_images = proposal.value - if isinstance(allowed_images, list): + allowed_images = proposal["value"] + if isinstance(allowed_images, str): + if allowed_images != "*": + raise ValueError( + f"'*' (all images) is the only accepted string value for allowed_images, got {allowed_images!r}. Use a list: `[{allowed_images!r}]` if you want to allow just one image." + ) + elif isinstance(allowed_images, list): allowed_images = {item: item for item in allowed_images} return allowed_images def _get_allowed_images(self): """Evaluate allowed_images callable - Or return the list as-is if it's already a dict + Always returns a dict or None """ if callable(self.allowed_images): allowed_images = self.allowed_images(self) - if not isinstance(allowed_images, dict): - # always return a dict - allowed_images = {item: item for item in allowed_images} - return allowed_images + return self._validate_allowed_images({"value": allowed_images}) return self.allowed_images @default('options_form') def _default_options_form(self): allowed_images = self._get_allowed_images() - if len(allowed_images) <= 1: + if allowed_images == "*" or len(allowed_images) <= 1: # default form only when there are images to choose from return '' # form derived from wrapspawner.ProfileSpawner @@ -580,7 +612,8 @@ def will_resume(self): # so JupyterHub >= 0.7.1 won't cleanup our API token return not self.remove - extra_create_kwargs = Dict( + extra_create_kwargs = Union( + [Callable(), Dict()], config=True, help="""Additional args to pass for container create @@ -590,11 +623,29 @@ def will_resume(self): "user": "root" # Can also be an integer UID } - The above is equivalent to ``docker run --user root`` + The above is equivalent to ``docker run --user root``. + + If a callable, will be called with the Spawner as the only argument, + must return the same dictionary structure, and may be async. + + .. versionchanged:: 13 + + Added callable support. """, ) - extra_host_config = Dict( - config=True, help="Additional args to create_host_config for container create" + extra_host_config = Union( + [Callable(), Dict()], + config=True, + help=""" + Additional args to create_host_config for container create. + + If a callable, will be called with the Spawner as the only argument, + must return the same dictionary structure, and may be async. + + .. versionchanged:: 13 + + Added callable support. + """, ) escape = Any( @@ -654,22 +705,13 @@ def _legacy_escape(text): hub_ip_connect = Unicode( config=True, - help=dedent( - """ - If set, DockerSpawner will configure the containers to use - the specified IP to connect the hub api. This is useful - when the hub_api is bound to listen on all ports or is - running inside of a container. - """ - ), + help="DEPRECATED since JupyterHub 0.8. Use c.JupyterHub.hub_connect_ip.", ) @observe("hub_ip_connect") def _ip_connect_changed(self, change): - warnings.warn( - "DockerSpawner.hub_ip_connect is no longer needed with JupyterHub 0.8." - " Use JupyterHub.hub_connect_ip instead.", - DeprecationWarning, + self.log.warning( + f"Ignoring DockerSpawner.hub_ip_connect={change.new!r}, which has ben deprected since JupyterHub 0.8. Use c.JupyterHub.hub_connect_ip instead." ) use_internal_ip = Bool( @@ -897,6 +939,11 @@ def _object_name_default(self): """Render the name of our container/service using name_template""" return self._render_templates(self.name_template) + @observe("image") + def _image_changed(self, change): + # re-render object name if image changes + self.object_name = self._object_name_default() + def load_state(self, state): super().load_state(state) if "container_id" in state: @@ -928,30 +975,10 @@ def get_state(self): ) return state - def _public_hub_api_url(self): - proto, path = self.hub.api_url.split("://", 1) - ip, rest = path.split(":", 1) - return "{proto}://{ip}:{rest}".format( - proto=proto, ip=self.hub_ip_connect, rest=rest - ) - def _env_keep_default(self): """Don't inherit any env from the parent process""" return [] - def get_args(self): - args = super().get_args() - if self.hub_ip_connect: - # JupyterHub 0.7 specifies --hub-api-url - # on the command-line, which is hard to update - for idx, arg in enumerate(list(args)): - if arg.startswith("--hub-api-url="): - args.pop(idx) - break - - args.append("--hub-api-url=%s" % self._public_hub_api_url()) - return args - def get_env(self): env = super().get_env() env['JUPYTER_IMAGE_SPEC'] = self.image @@ -1051,8 +1078,10 @@ async def remove_object(self): async def check_allowed(self, image): allowed_images = self._get_allowed_images() - if not allowed_images: + if allowed_images == "*": return image + elif not allowed_images: + raise web.HTTPError(400, "Specifying image to launch is not allowed") if image not in allowed_images: raise web.HTTPError( 400, @@ -1134,11 +1163,19 @@ async def create_object(self): name=self.container_name, command=(await self.get_command()), ) + extra_create_kwargs = self._eval_if_callable(self.extra_create_kwargs) + if inspect.isawaitable(extra_create_kwargs): + extra_create_kwargs = await extra_create_kwargs + extra_create_kwargs = self._render_templates(extra_create_kwargs) + extra_host_config = self._eval_if_callable(self.extra_host_config) + if inspect.isawaitable(extra_host_config): + extra_host_config = await extra_host_config + extra_host_config = self._render_templates(extra_host_config) # ensure internal port is exposed create_kwargs["ports"] = {"%i/tcp" % self.port: None} - create_kwargs.update(self._render_templates(self.extra_create_kwargs)) + _deep_merge(create_kwargs, extra_create_kwargs) # build the dictionary of keyword arguments for host_config host_config = dict( @@ -1155,14 +1192,14 @@ async def create_object(self): # docker cpu units are in microseconds # cpu_period default is 100ms # cpu_quota is cpu_period * cpu_limit - cpu_period = host_config["cpu_period"] = self.extra_host_config.get( + cpu_period = host_config["cpu_period"] = extra_host_config.get( "cpu_period", 100_000 ) host_config["cpu_quota"] = int(self.cpu_limit * cpu_period) if not self.use_internal_ip: host_config["port_bindings"] = {self.port: (self.host_ip,)} - host_config.update(self._render_templates(self.extra_host_config)) + _deep_merge(host_config, extra_host_config) host_config.setdefault("network_mode", self.network_name) self.log.debug("Starting host with config: %s", host_config) @@ -1238,31 +1275,13 @@ async def pull_image(self, image): self.log.info("pulling image %s", image) await self.docker('pull', repo, tag) - async def start(self, image=None, extra_create_kwargs=None, extra_host_config=None): + async def start(self): """Start the single-user server in a docker container. - Additional arguments to create/host config/etc. can be specified - via .extra_create_kwargs and .extra_host_config attributes. - If the container exists and ``c.DockerSpawner.remove`` is ``True``, then the container is removed first. Otherwise, the existing containers will be restarted. """ - - if image: - self.log.warning("Specifying image via .start args is deprecated") - self.image = image - if extra_create_kwargs: - self.log.warning( - "Specifying extra_create_kwargs via .start args is deprecated" - ) - self.extra_create_kwargs.update(extra_create_kwargs) - if extra_host_config: - self.log.warning( - "Specifying extra_host_config via .start args is deprecated" - ) - self.extra_host_config.update(extra_host_config) - # image priority: # 1. user options (from spawn options form) # 2. self.image from config diff --git a/docs/source/changelog.md b/docs/source/changelog.md index fb3d68c1..0ccb0dcc 100644 --- a/docs/source/changelog.md +++ b/docs/source/changelog.md @@ -6,9 +6,68 @@ command line for details. ## [Unreleased] +## 13 + +### [13.0] 2023-11-21 + +([full changelog](https://github.com/jupyterhub/dockerspawner/compare/12.1.0...13.0.0)) + +13.0 Fixes security vulnerability GHSA-hfgr-h3vc-p6c2, which allowed authenticated users to spawn arbitrary images +unless `DockerSpawner.allowed_images` was specified. + +#### API and Breaking Changes + +- Add and require `DockerSpawner.allowed_images='*'` to allow any image to be spawned via `user_options`. (GHSA-hfgr-h3vc-p6c2) +- Remove deprecated, broken hub_ip_connect [#499](https://github.com/jupyterhub/dockerspawner/pull/499) ([@minrk](https://github.com/minrk)) +- Require python 3.8+ and jupyterhub 2.3.1+ [#488](https://github.com/jupyterhub/dockerspawner/pull/488) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) + +#### New features added + +- Switch default image to quay.io [#504](https://github.com/jupyterhub/dockerspawner/pull/504) ([@yuvipanda](https://github.com/yuvipanda), [@minrk](https://github.com/minrk), [@manics](https://github.com/manics)) +- allow extra_host_config and extra_create_kwargs to be callable [#500](https://github.com/jupyterhub/dockerspawner/pull/500) ([@minrk](https://github.com/minrk)) + +#### Enhancements made + +- Merge host config/create_kwargs [#501](https://github.com/jupyterhub/dockerspawner/pull/501) ([@minrk](https://github.com/minrk)) + +#### Bugs fixed + +- update object_name with current image [#466](https://github.com/jupyterhub/dockerspawner/pull/466) ([@floriandeboissieu](https://github.com/floriandeboissieu), [@minrk](https://github.com/minrk)) +- Fix imagename not to include letter ':' [#464](https://github.com/jupyterhub/dockerspawner/pull/464) ([@yamaton](https://github.com/yamaton), [@minrk](https://github.com/minrk)) +- clear object_id when removing object [#447](https://github.com/jupyterhub/dockerspawner/pull/447) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics)) + +#### Maintenance and upkeep improvements + +- pre-commit: add pyupgrade and autoflake, simplify flake8 config [#489](https://github.com/jupyterhub/dockerspawner/pull/489) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) +- Require python 3.8+ and jupyterhub 2.3.1+ [#488](https://github.com/jupyterhub/dockerspawner/pull/488) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) +- Add dependabot.yaml to bump github actions [#487](https://github.com/jupyterhub/dockerspawner/pull/487) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) +- Update release workflow and RELEASE.md, set version with tbump [#486](https://github.com/jupyterhub/dockerspawner/pull/486) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) +- Refresh test workflow and associated config, accept podmain test failure for now [#485](https://github.com/jupyterhub/dockerspawner/pull/485) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) +- Use python 3.11 on RTD [#482](https://github.com/jupyterhub/dockerspawner/pull/482) ([@minrk](https://github.com/minrk)) +- Add test strategy for JupyterHub v3.1.1 [#479](https://github.com/jupyterhub/dockerspawner/pull/479) ([@Sheila-nk](https://github.com/Sheila-nk), [@GeorgianaElena](https://github.com/GeorgianaElena), [@minrk](https://github.com/minrk)) +- test options_form and escape [#468](https://github.com/jupyterhub/dockerspawner/pull/468) ([@Sheila-nk](https://github.com/Sheila-nk), [@minrk](https://github.com/minrk)) +- test callable allowed_images and host_ip [#467](https://github.com/jupyterhub/dockerspawner/pull/467) ([@Sheila-nk](https://github.com/Sheila-nk), [@minrk](https://github.com/minrk)) +- Test jupyterhub2 [#443](https://github.com/jupyterhub/dockerspawner/pull/443) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk)) + +#### Documentation improvements + +- Add extra_create_kwargs example, plus docs readability improvements [#493](https://github.com/jupyterhub/dockerspawner/pull/493) ([@matthewwiese](https://github.com/matthewwiese), [@manics](https://github.com/manics)) +- update versions in swarm example [#454](https://github.com/jupyterhub/dockerspawner/pull/454) ([@minrk](https://github.com/minrk), [@GeorgianaElena](https://github.com/GeorgianaElena)) +- add generate-certs service to internal-ssl example [#446](https://github.com/jupyterhub/dockerspawner/pull/446) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics)) +- Add Podman to docs [#444](https://github.com/jupyterhub/dockerspawner/pull/444) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk)) + +#### Contributors to this release + +The following people contributed discussions, new ideas, code and documentation contributions, and review. +See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). + +([GitHub contributors page for this release](https://github.com/jupyterhub/dockerspawner/graphs/contributors?from=2021-07-22&to=2023-11-20&type=c)) + +@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fdockerspawner+involves%3AconsideRatio+updated%3A2021-07-22..2023-11-20&type=Issues)) | @floriandeboissieu ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fdockerspawner+involves%3Afloriandeboissieu+updated%3A2021-07-22..2023-11-20&type=Issues)) | @gatoniel ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fdockerspawner+involves%3Agatoniel+updated%3A2021-07-22..2023-11-20&type=Issues)) | @GeorgianaElena ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fdockerspawner+involves%3AGeorgianaElena+updated%3A2021-07-22..2023-11-20&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fdockerspawner+involves%3Amanics+updated%3A2021-07-22..2023-11-20&type=Issues)) | @matthewwiese ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fdockerspawner+involves%3Amatthewwiese+updated%3A2021-07-22..2023-11-20&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fdockerspawner+involves%3Aminrk+updated%3A2021-07-22..2023-11-20&type=Issues)) | @Sheila-nk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fdockerspawner+involves%3ASheila-nk+updated%3A2021-07-22..2023-11-20&type=Issues)) | @yamaton ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fdockerspawner+involves%3Ayamaton+updated%3A2021-07-22..2023-11-20&type=Issues)) | @yuvipanda ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fdockerspawner+involves%3Ayuvipanda+updated%3A2021-07-22..2023-11-20&type=Issues)) + ## 12 -### [12.1] 2021-07 +### [12.1] 2021-07-22 ([full changelog](https://github.com/jupyterhub/dockerspawner/compare/12.0.0...12.1.0)) @@ -29,7 +88,7 @@ command line for details. [@1kastner](https://github.com/search?q=repo%3Ajupyterhub%2Fdockerspawner+involves%3A1kastner+updated%3A2021-03-26..2021-07-19&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fdockerspawner+involves%3Amanics+updated%3A2021-03-26..2021-07-19&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fdockerspawner+involves%3Aminrk+updated%3A2021-03-26..2021-07-19&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyterhub%2Fdockerspawner+involves%3Awelcome+updated%3A2021-03-26..2021-07-19&type=Issues) | [@zeehio](https://github.com/search?q=repo%3Ajupyterhub%2Fdockerspawner+involves%3Azeehio+updated%3A2021-03-26..2021-07-19&type=Issues) -### [12.0] 2021-03 +### [12.0] 2021-03-26 This is a big release! @@ -292,7 +351,8 @@ Some configuration has been cleaned up to be clearer and more concise: First release -[unreleased]: https://github.com/jupyterhub/dockerspawner/compare/12.1.0...HEAD +[unreleased]: https://github.com/jupyterhub/dockerspawner/compare/13.0.0...HEAD +[13.0]: https://github.com/jupyterhub/dockerspawner/compare/12.1.0...13.0.0 [12.1]: https://github.com/jupyterhub/dockerspawner/compare/12.0.0...12.1.0 [12.0]: https://github.com/jupyterhub/dockerspawner/compare/0.11.1...12.0.0 [0.11.1]: https://github.com/jupyterhub/dockerspawner/compare/0.11.0...0.11.1 diff --git a/docs/source/docker-image.md b/docs/source/docker-image.md index 161e6af8..656e15f1 100644 --- a/docs/source/docker-image.md +++ b/docs/source/docker-image.md @@ -1,6 +1,6 @@ # Picking or building a Docker image -By default, DockerSpawner uses the `jupyterhub/singleuser` image +By default, DockerSpawner uses the `quay.io/jupyterhub/singleuser` image with the appropriate tag that pins the right version of JupyterHub, but you can also build your own image. @@ -13,7 +13,7 @@ and are encouraged as the image of choice. Make sure to pick a tag! Example: ```python -c.DockerSpawner.image = 'jupyter/scipy-notebook:67b8fb91f950' +c.DockerSpawner.image = 'quay.io/jupyter/scipy-notebook:2023-10-23' ``` The docker-stacks are moving targets with always changing versions. @@ -37,8 +37,8 @@ the appropriate JupyterHub version and the Jupyter notebook package. For instance, from the docker-stacks, pin your JupyterHub version and you are done: ```Dockerfile -FROM jupyter/scipy-notebook:67b8fb91f950 -ARG JUPYTERHUB_VERSION=1.3.0 +FROM quay.io/jupyter/scipy-notebook:67b8fb91f950 +ARG JUPYTERHUB_VERSION=4.0.2 RUN pip3 install --no-cache \ jupyterhub==$JUPYTERHUB_VERSION ``` @@ -48,10 +48,10 @@ Or for the absolute minimal JupyterHub user image starting only from the base Py **NOTE: make sure to pick the jupyterhub version you are using!** ```Dockerfile -FROM python:3.8 +FROM python:3.11 RUN pip3 install \ - 'jupyterhub==1.3.*' \ - 'notebook==6.*' + 'jupyterhub==4.*' \ + 'notebook==7.*' # create a user, since we don't want to run as root RUN useradd -m jovyan diff --git a/examples/image_form/jupyterhub_config.py b/examples/image_form/jupyterhub_config.py index 34447adc..207818fc 100644 --- a/examples/image_form/jupyterhub_config.py +++ b/examples/image_form/jupyterhub_config.py @@ -13,26 +13,11 @@ def get_options_form(spawner): c.DockerSpawner.options_form = get_options_form -from dockerspawner import DockerSpawner +# specify that DockerSpawner should accept any image from user input +c.DockerSpawner.allowed_images = "*" - -class CustomDockerSpawner(DockerSpawner): - def options_from_form(self, formdata): - options = {} - image_form_list = formdata.get("image", []) - if image_form_list and image_form_list[0]: - options["image"] = image_form_list[0].strip() - self.log.info(f"User selected image: {options['image']}") - return options - - def load_user_options(self, options): - image = options.get("image") - if image: - self.log.info(f"Loading image {image}") - self.image = image - - -c.JupyterHub.spawner_class = CustomDockerSpawner +# tell JupyterHub to use DockerSpawner +c.JupyterHub.spawner_class = "docker" # the rest of the config is testing boilerplate # to make the Hub connectable from the containers diff --git a/examples/internal-ssl/.env b/examples/internal-ssl/.env index 5a8fad4a..02d8327a 100644 --- a/examples/internal-ssl/.env +++ b/examples/internal-ssl/.env @@ -13,7 +13,7 @@ DOCKER_MACHINE_NAME=jupyterhub DOCKER_NETWORK_NAME=jupyterhub # Single-user Jupyter Notebook server container image -DOCKER_NOTEBOOK_IMAGE=jupyterhub/singleuser:4 +DOCKER_NOTEBOOK_IMAGE=quay.io/jupyterhub/singleuser:4 # Name of JupyterHub container data volume DATA_VOLUME_HOST=jupyterhub-data diff --git a/pyproject.toml b/pyproject.toml index efed1102..41a2d933 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ markers = [ github_url = "https://github.com/jupyterhub/dockerspawner" [tool.tbump.version] -current = "12.2.0.dev" +current = "13.1.0.dev" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index 655b6395..6fde6fcc 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ def run(self): setup_args = dict( name='dockerspawner', packages=['dockerspawner'], - version="12.2.0.dev", + version="13.1.0.dev", description="""Dockerspawner: A custom spawner for Jupyterhub.""", long_description="Spawn single-user servers with Docker.", author="Jupyter Development Team", diff --git a/tests/conftest.py b/tests/conftest.py index adaabdb5..b191eb77 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,7 @@ from jupyterhub.tests.conftest import event_loop # noqa: F401 from jupyterhub.tests.conftest import io_loop # noqa: F401 from jupyterhub.tests.conftest import ssl_tmpdir # noqa: F401 +from jupyterhub.tests.conftest import user # noqa: F401 from jupyterhub.tests.mocking import MockHub from dockerspawner import DockerSpawner, SwarmSpawner, SystemUserSpawner @@ -63,7 +64,7 @@ def app(jupyterhub_app): # noqa: F811 # If it's a prerelease e.g. (2, 0, 0, 'rc4', '') use full tag if len(jh_version_info) > 3 and jh_version_info[3]: tag = jupyterhub.__version__ - app.config.DockerSpawner.image = f"jupyterhub/singleuser:{tag}" + app.config.DockerSpawner.image = f"quay.io/jupyterhub/singleuser:{tag}" return app diff --git a/tests/test_dockerspawner.py b/tests/test_dockerspawner.py index 9c970c5f..51eca880 100644 --- a/tests/test_dockerspawner.py +++ b/tests/test_dockerspawner.py @@ -84,32 +84,89 @@ async def test_start_stop(dockerspawner_configured_app, remove): def allowed_images_callable(*_): - return ["jupyterhub/singleuser:1.0", "jupyterhub/singleuser:1.1"] + return ["quay.io/jupyterhub/singleuser:4.0", "quay.io/jupyterhub/singleuser:4"] @pytest.mark.parametrize( - "allowed_images, image", + "allowed_images, image, ok", [ ( { - "1.0": "jupyterhub/singleuser:1.0", - "1.1": "jupyterhub/singleuser:1.1", + "4.0": "quay.io/jupyterhub/singleuser:4.0", + "4": "quay.io/jupyterhub/singleuser:4", }, - "1.0", + "4.0", + True, + ), + ( + { + "4.0": "quay.io/jupyterhub/singleuser:4.0", + "4": "quay.io/jupyterhub/singleuser:4", + }, + None, + True, + ), + ( + [ + "quay.io/jupyterhub/singleuser:4", + "quay.io/jupyterhub/singleuser:4.0", + ], + "not-in-list", + False, + ), + ( + [ + "quay.io/jupyterhub/singleuser:4.0", + "quay.io/jupyterhub/singleuser:4", + ], + "quay.io/jupyterhub/singleuser:4", + True, + ), + ( + allowed_images_callable, + "quay.io/jupyterhub/singleuser:4.0", + True, + ), + ( + allowed_images_callable, + "quay.io/jupyterhub/singleuser:3.0", + False, + ), + ( + None, + "DEFAULT", + False, + ), + ( + None, + None, + True, + ), + ( + # explicitly allow all + "*", + "quay.io/jupyterhub/singleuser:4", + True, ), - (["jupyterhub/singleuser:1.0", "jupyterhub/singleuser:1.1.0"], "1.1.0"), - (allowed_images_callable, "1.0"), ], ) -async def test_allowed_image(dockerspawner_configured_app, allowed_images, image): +async def test_allowed_image( + user, dockerspawner_configured_app, allowed_images, image, ok +): app = dockerspawner_configured_app - name = "checker" - add_user(app.db, app, name=name) - user = app.users[name] + name = user.name assert isinstance(user.spawner, DockerSpawner) + default_image = user.spawner.image # default value + if image == "DEFAULT": + image = default_image user.spawner.remove_containers = True - user.spawner.allowed_images = allowed_images - token = user.new_api_token() + if allowed_images is not None: + user.spawner.allowed_images = allowed_images + + if image: + request_body = json.dumps({"image": image}) + else: + request_body = b"" # start the server r = await api_request( app, @@ -117,29 +174,34 @@ async def test_allowed_image(dockerspawner_configured_app, allowed_images, image name, "server", method="post", - data=json.dumps({"image": image}), + data=request_body, ) - if image not in user.spawner._get_allowed_images(): - with pytest.raises(Exception): - r.raise_for_status() + if not ok: + assert r.status_code == 400 return + else: + r.raise_for_status() + pending = r.status_code == 202 while pending: # request again - await asyncio.sleep(2) + await asyncio.sleep(1) r = await api_request(app, "users", name) user_info = r.json() pending = user_info["servers"][""]["pending"] - url = url_path_join(public_url(app, user), "api/status") - resp = await AsyncHTTPClient().fetch( - url, headers={"Authorization": "token %s" % token} - ) - assert resp.effective_url == url - resp.rethrow() + if image is None: + expected_image = default_image + elif isinstance(allowed_images, (list, dict)): + expected_image = user.spawner._get_allowed_images()[image] + else: + expected_image = image + + assert user.spawner.image == expected_image + obj = await user.spawner.get_object() + assert obj["Config"]["Image"] == expected_image - assert resp.headers['x-jupyterhub-version'].startswith(image) r = await api_request( app, "users",