From e30c94416f4b9d4c65de8335c91073d7c31b5708 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 18 Sep 2023 16:00:31 +0200 Subject: [PATCH 1/2] allow extra_host_config and extra_create_kwargs to be callable for e.g. per-user device requests --- dockerspawner/dockerspawner.py | 60 +++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/dockerspawner/dockerspawner.py b/dockerspawner/dockerspawner.py index eca7fe4..51f0723 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 @@ -580,7 +581,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 +592,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( @@ -1139,11 +1159,17 @@ 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_host_config = self._eval_if_callable(self.extra_host_config) + if inspect.isawaitable(extra_host_config): + extra_host_config = await 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)) + create_kwargs.update(self._render_templates(extra_create_kwargs)) # build the dictionary of keyword arguments for host_config host_config = dict( @@ -1160,14 +1186,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)) + host_config.update(self._render_templates(extra_host_config)) host_config.setdefault("network_mode", self.network_name) self.log.debug("Starting host with config: %s", host_config) @@ -1243,31 +1269,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 From 94f819fa6a3fdb0d236d54fd75cfe15aebd60ded Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 18 Sep 2023 16:08:11 +0200 Subject: [PATCH 2/2] merge create_kwargs/host_config instead of clobbering --- dockerspawner/dockerspawner.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/dockerspawner/dockerspawner.py b/dockerspawner/dockerspawner.py index 51f0723..652cbe6 100644 --- a/dockerspawner/dockerspawner.py +++ b/dockerspawner/dockerspawner.py @@ -55,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""" @@ -1162,14 +1180,16 @@ async def create_object(self): 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(extra_create_kwargs)) + _deep_merge(create_kwargs, extra_create_kwargs) # build the dictionary of keyword arguments for host_config host_config = dict( @@ -1193,7 +1213,7 @@ async def create_object(self): if not self.use_internal_ip: host_config["port_bindings"] = {self.port: (self.host_ip,)} - host_config.update(self._render_templates(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)