diff --git a/docs/source/reference/spawners.md b/docs/source/reference/spawners.md index f23ff127..9638c689 100644 --- a/docs/source/reference/spawners.md +++ b/docs/source/reference/spawners.md @@ -2,7 +2,7 @@ # Spawners -A [Spawner][] starts each single-user notebook server. +A [Spawner](#Spawner) starts each single-user notebook server. The Spawner represents an abstract interface to a process, and a custom Spawner needs to be able to take three actions: @@ -37,7 +37,7 @@ Some examples include: ### Spawner.start -`Spawner.start` should start a single-user server for a single user. +[](#Spawner.start) should start a single-user server for a single user. Information about the user can be retrieved from `self.user`, an object encapsulating the user's name, authentication, and server info. @@ -68,11 +68,11 @@ async def start(self): When `Spawner.start` returns, the single-user server process should actually be running, not just requested. JupyterHub can handle `Spawner.start` being very slow (such as PBS-style batch queues, or instantiating whole AWS instances) -via relaxing the `Spawner.start_timeout` config value. +via relaxing the [](#Spawner.start_timeout) config value. #### Note on IPs and ports -`Spawner.ip` and `Spawner.port` attributes set the _bind_ URL, +[](#Spawner.ip) and [](#Spawner.port) attributes set the _bind_ URL, which the single-user server should listen on (passed to the single-user process via the `JUPYTERHUB_SERVICE_URL` environment variable). The _return_ value is the IP and port (or full URL) the Hub should _connect to_. @@ -124,7 +124,7 @@ If both attributes are not present, the Exception will be shown to the user as u ### Spawner.poll -`Spawner.poll` checks if the spawner is still running. +[](#Spawner.poll) checks if the spawner is still running. It should return `None` if it is still running, and an integer exit status, otherwise. @@ -133,7 +133,7 @@ to check if the local process is still running. On Windows, it uses `psutil.pid_ ### Spawner.stop -`Spawner.stop` should stop the process. It must be a tornado coroutine, which should return when the process has finished exiting. +[](#Spawner.stop) should stop the process. It must be a tornado coroutine, which should return when the process has finished exiting. ## Spawner state @@ -168,15 +168,14 @@ def clear_state(self): ## Spawner options form -(new in 0.4) - Some deployments may want to offer options to users to influence how their servers are started. -This may include cluster-based deployments, where users specify what resources should be available, -or docker-based deployments where users can select from a list of base images. +This may include cluster-based deployments, where users specify what memory or cpu resources should be available, +or container-based deployments where users can select from a list of base images, +or more complex configurations where users select a "profile" representing a bundle of settings to be applied together. -This feature is enabled by setting `Spawner.options_form`, which is an HTML form snippet +This feature is enabled by setting [](#Spawner.options_form), which is an HTML form snippet inserted unmodified into the spawn form. -If the `Spawner.options_form` is defined, when a user tries to start their server, they will be directed to a form page, like this: +If the `Spawner.options_form` is defined, when a user tries to start their server they will be directed to a form page, like this: ![spawn-form](/images/spawn-form.png) @@ -186,28 +185,40 @@ See [this example](https://github.com/jupyterhub/jupyterhub/blob/HEAD/examples/s ### `Spawner.options_from_form` -Options from this form will always be a dictionary of lists of strings, e.g.: +Inputs from an HTML form always arrive as a dictionary of lists of strings, e.g.: ```python -{ +formdata = { 'integer': ['5'], + 'checkbox': ['on'], 'text': ['some text'], 'select': ['a', 'b'], } ``` -When `formdata` arrives, it is passed through `Spawner.options_from_form(formdata)`, -which is a method to turn the form data into the correct structure. -This method must return a dictionary, and is meant to interpret the lists-of-strings into the correct types. For example, the `options_from_form` for the above form would look like: +When `formdata` arrives, it is passed through [](#Spawner.options_from_form): ```python -def options_from_form(self, formdata): +spawner.user_options = [await] spawner.options_from_form(formdata[, spawner=spawner]) +``` + +to create `spawner.user_options`. + +[](#Spawner.options_from_form) is a configurable function to turn the HTTP form data into the correct structure for [](#Spawner.user_options). +`options_from_form` must return a dictionary, and is meant to interpret the lists-of-strings a web form produces into the correct types. +For example, the `options_from_form` for the above form might look like: + +```python +def options_from_form(formdata): options = {} options['integer'] = int(formdata['integer'][0]) # single integer value + options['checkbox'] = formdata['checkbox'] == ['on'] options['text'] = formdata['text'][0] # single string value options['select'] = formdata['select'] # list already correct options['notinform'] = 'extra info' # not in the form at all return options + +c.Spawner.options_from_form = options_from_form ``` which would return: @@ -215,15 +226,114 @@ which would return: ```python { 'integer': 5, + 'checkbox': True, 'text': 'some text', 'select': ['a', 'b'], 'notinform': 'extra info', } ``` -When `Spawner.start` is called, this dictionary is accessible as `self.user_options`. +### Applying user options -[spawner]: https://github.com/jupyterhub/jupyterhub/blob/HEAD/jupyterhub/spawner.py +The base Spawner class doesn't do anything with `user_options`, that is also up to your deployment and/or chosen Spawner. +This is because exposing any options to users has security implications, +so it is part of your Spawner and/or deployment configuration to expose the options you trust your users to set. + +[](#Spawner.apply_user_options) is the hook for taking `user_options` and applying whatever configuration it may represent. + +```python +def apply_user_options(spawner, user_options): + if "image" in user_options and isinstance(user_options["image"], str): + spawner.image = user_options["image"] + +c.Spawner.apply_user_options = apply_user_options +``` + +:::{versionadded} 5.3 +JupyterHub 5.3 introduces [](#Spawner.apply_user_options) configuration. +Previously, [](#Spawner.user_options) could only be consumed during [](#Spawner.start), +at which point `user_options` is available to the Spawner instance as `self.user_options`. +This approach requires subclassing, so it was not possible to apply new `user_options` via configuration. +In JupyterHub 5.3, it is possible to fully expose user options, +and for some simple cases, fully with _declarative_ configuration. +::: + +### Declarative configuration for user options + +While [](#Spawner.options_from_form) and [](#Spawner.apply_user_options) are callables by nature, +some simple cases can be represented by declarative configuration, +which is most conveniently expressed in e.g. the yaml of the JupyterHub helm chart. +The cases currently handled are: + +```python +c.Spawner.options_form = """ + + +""" +c.Spawner.options_from_form = "simple" +c.Spawner.apply_user_options = {"image_input": "image", "debug_checkbox": "debug"} +``` + +The `simple` options_from_form does the very simplest interpretation of an html form, +casting the lists of strings to single strings by getting the first item when there is only one. +The only non-string handling it has is casting the checkbox value of `on` to True. + +So it turns this formdata: + +```python +{ + "image_input": ["my_image"], + "debug_checkbox": ["on"], +} +``` + +into this `.user_options` + +```python +{ + "image_input": "my_image", + "debug_checkbox": True +} +``` + +When `apply_user_options` is a dictionary, any input in `user_options` is looked up in this dictionary, +and assigned to the corresponding Spawner attribute. +Strings are passed through traitlets' `from_string` logic (what is used for setting values on the command-line), +which means you can set numbers and things this way as well, +even though `options_from_form` leaves these as strings. + +So in the above configuration, we have exposed `Spawner.debug` and `Spawner.image` without needing to write any functions. +In jupyterhub helm chart yaml, this would look like: + +```yaml +hub: + config: + KubeSpawner: + options_form: | + + + options_from_form: simple + apply_user_options: + image_input: image + debug_checkbox: debug +``` + +### Setting `user_options` directly via the REST API + +In addition to going through the options form, `user_options` may be set directly, via the REST API. +The body of a POST request to spawn a server may be a JSON dictionary, +which will be used to set `user_options` directly. +When used this way, neither `options_form` nor `options_from_form` are involved, +`user_options` is set directly, and only `apply_user_options` is called. + +``` +POST /hub/api/users/servers/:name +{ + "option": 5, + "bool": True, + "string": "value" +} +``` ## Writing a custom spawner diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 16d173b7..23d44551 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -623,7 +623,8 @@ class Spawner(LoggingConfigurable): though it can contain bytes in addition to standard JSON data types. This method should not have any side effects. - Any handling of `user_options` should be done in `.start()` + Any handling of `user_options` should be done in `.apply_user_options()` (JupyterHub 5.3) + or `.start()` (JupyterHub 5.2 or older) to ensure consistent behavior across servers spawned via the API and form submission page. @@ -642,9 +643,47 @@ class Spawner(LoggingConfigurable): @default("options_from_form") def _options_from_form(self): - return self._default_options_from_form + return self._passthrough_options_from_form - def _default_options_from_form(self, form_data): + @validate("options_from_form") + def _validate_options_from_form(self, proposal): + # coerce special string values to callable + if proposal.value == "passthrough": + return self._passthrough_options_from_form + elif proposal.value == "simple": + return self._simple_options_from_form + else: + return proposal.value + + def _passthrough_options_from_form(self, form_data): + """The longstanding default behavior for options_from_form + + explicit opt-in via `options_from_form = 'passthrough'` + + .. versionadded:: 5.3 + """ + return form_data + + def _simple_options_from_form(self, form_data): + """Simple options_from_form + + Enable via `options_from_form = 'simple' + + Assumes only scalar form inputs (no multiple-choice, no numbers) + and default checkboxes for booleans ('on' -> True) + + .. versionadded:: 5.3 + """ + user_options = {} + for key, value_list in form_data.items(): + if len(value_list) > 1: + value = value_list + else: + value = value_list[0] + if value == "on": + # default for checkbox + value = True + user_options[key] = value return form_data def run_options_from_form(self, form_data): @@ -685,6 +724,80 @@ class Spawner(LoggingConfigurable): """ return self.options_from_form(query_data) + apply_user_options = Union( + [Callable(), Dict()], + config=True, + default_value=None, + allow_none=True, + help=""" + Apply inputs from user_options to the Spawner + + Typically takes values in user_options, validates them, and updates Spawner attributes:: + + def apply_user_options(spawner, user_options): + if "image" in user_options and isinstance(user_options["image"], str): + spawner.image = user_options["image"] + + c.Spawner.apply_user_options = apply_user_options + + Default: do nothing + + Typically a callalble which takes `(spawner: Spawner, user_options: dict)`, + but for simple cases this can be a dict mapping user option fields to Spawner attribute names, + e.g.:: + + c.Spawner.apply_user_options = {"image_input": "image"} + + allows users to specify the image attribute, but not any others. + + .. note:: + + Because `user_options` is user input + and may be set directly via the REST API, + no assumptions should be made on its structure or contents. + An empty dict should always be supported. + Make sure to validate any inputs before applying them! + + .. versionadded:: 5.3 + + Prior to 5.3, applying user options must be done in `Spawner.start()`. + """, + ) + + def _run_apply_user_options(self, user_options): + if isinstance(self.apply_user_options, dict): + return self._apply_user_options_dict(user_options) + elif self.apply_user_options: + return self.apply_user_options(self, user_options) + elif user_options: + keys = list(user_options) + self.log.warning( + f"Received unhandled user_options for {self._log_name}: {', '.join(keys)}" + ) + + def _apply_user_options_dict(self, user_options): + """if apply_user_options is a dict + + Allows fully declarative apply_user_options configuration + for simple cases where users may set attributes directly + from values in user_options. + """ + traits = self.traits() + for key, value in user_options.items(): + attr = self.apply_user_options.get(key, None) + if attr is None: + self.log.warning(f"Unhandled user option {key} for {self._log_name}") + elif hasattr(self, attr): + # require traits? I think not, but we should require declaration, at least + # use trait from_string for string coercion, though + if isinstance(value, str) and attr in traits: + value = traits[attr].from_string(value) + setattr(self, attr, value) + else: + self.log.error( + f"No such Spawner attribute {attr} for user option {key} on {self._log_name}" + ) + user_options = Dict( help=""" Dict of user specified options for the user's spawned instance of a single-user server. diff --git a/jupyterhub/user.py b/jupyterhub/user.py index aba33720..42fe5e81 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -862,6 +862,11 @@ class User: db.commit() spawner.user_options = options + # apply user options + r = spawner._run_apply_user_options(spawner.user_options) + if inspect.isawaitable(r): + await r + # we are starting a new server, make sure it doesn't restore state spawner.clear_state()