How do you properly pass arguments to JupyterHub spawner on REST API call?

I’m trying to start a single-user server via the REST API without using the default configuration. The documentation says this should be possible:

Spawn options can be passed as a JSON body when spawning via the API instead of spawn form. The structure of the options will depend on the Spawner’s configuration.

However, there is no documentation that says how that JSON should be structured. Does such documentation exist anywhere? I want to be able to be able to customize the spawner like I can in using JupyterHub on Kubernetes by changing the config.yaml to have something like:

singleuser:
  # Default Image Spec
  image:
    name: jupyter/scipy-notebook
    tag: 59b402ce701d
  # Default Resource Limits/Guarantees
  cpu:
    guarantee: 0.25
    limit: 0.5
  memory:
    guarantee: "256M"
    limit: "320M"
  # Customized
  profileList:
    - display_name: "Default"
      description: "0.25 CPU; 256M Ram"
      default: True
    - display_name: "BIG"
      description: "0.5 Whole CPUs, 512M Ram"
      kubespawner_override:
        cpu_guarantee: 0.5
        cpu_limit: 0.75
        mem_guarantee: "512M"
        mem_limit: "640M"

tl;dr: How do I structure a JSON for a named-server endpoint POST request to build a server with non-default CPU/memory specs and non-default images? And is there an example in any documentation of doing so that I simply haven’t encountered?

I can tell you what I did for saturncloud.io. The behavior here has recently changed a bit, so depending on what version of code you’re using, it could be slighlty wrong.

All my servers are named servers, but you should be able to adapt this strategy to un-named servers

post to:

/api/users//servers/

Which causes the json body to be set to spawner.user_options


the json body should be (for kubespawner)

{‘kubespawner_override’: }

image/cpu/memory are all settable via that dictionary

I also had to subclass the KubeSpawner to load user_options at the start, because at the time it wasn’t being loaded.

    def start(self):                                                                                                                                            
        options = self.user_options                                                                                                                             
        if options:                                                                                                                                             
            kubespawner_override = options.get('kubespawner_override', {})                                                                                      
            for k, v in kubespawner_override.items():                                                                                                           
                if callable(v):                                                                                                                                 
                    v = v(self)                                                                                                                                 
                    self.log.debug(                                                                                                                             
                        ".. overriding KubeSpawner value %s=%s (callable result)", k, v                                                                         
                    )                                                                                                                                           
                else:                                                                                                                                           
                    self.log.debug(                                                                                                                             
                        ".. overriding KubeSpawner value %s=%s", k, v)                                                                                          
                setattr(self, k, v)                                                                                                                             
        return super().start()                                                                                                                                  

The code has recently changed. user_options is still being passed, however now there is a load_user_options method which is setup to load just the profile list

However you can override it to do whatever you want.

It’s worth pointing out that - allowing arbitrary parameters passed via the post can be a problem - are you really ok with your users making arbitrarily sized requests? In my case, saturncloud.io is orchestrating JupyterHub - so the logic around who is allowed to do what is managed in Saturn, and I modify the JupyterHub route to only accept requests from admins (not from users), and Saturn is the admin.

2 Likes

Thanks for the response! My group decided to move ahead and build our own system for creating pods that host Jupyter Notebook servers (taking heavy inspiration from JupyterHub’s design).

Your suggestion of restricting who can hit the endpoints was exactly what I was imagining (i.e., that they could only be hit by an internal admin service that validates requests), so I’m glad I was on the right track.

It’s also good to know that you’ve been able to implement the functionality that I’ve described in standing up your JupyterHub - this will be helpful if we decide to pivot back.

Thanks again for the detailed response!

@hhuuggoo - As indicated by you I am using below to override the image, which however not working. Any clue?

curl --location --request POST 'http://127.0.0.1:8000/hub/api/users/admin/servers/serverA' \
--header 'Authorization: Bearer XXX' \
--header 'Content-Type: application/json' \
--data-raw '{
"kubespawner_override": {
"image": "jupyterhub/k8s-singleuser-sample:0.8.4"
}}
'