Fill JupyterHub spawn form field from K8s API

Hi,
for various purposes at our university, we would like to offer people spawning a notebook a drop-down menu in the form that would list existing PVCs in their namespace.

I’m trying to implement this simple function in the custom spawner as we have a lot of stuff modified. I tried implementation in JavaScript but that failed because the @kubernetes/client-node library is meant to be server side and it is not loaded when form is active. I thought I might create a new bundle with browsify but because the library is server-side, it would not work.

Then I thought I might implement the same function in Python in a custom DockerImageChooser where the python function is a class function, list of PVCs is a variable and the function is called as c.DockerImageChooser.pvcnames = get_persistent_homes().

See relevant config part below:

hub:
  extraConfig:
    form-custom-class: |
        from traitlets import default, Unicode, List
        from tornado import gen
        from kubespawner import KubeSpawner
        from kubernetes_asyncio import config, client
        import re
        import asyncio        

        class DockerImageChooser(KubeSpawner):
          pvcsnames = List(
            trait = Unicode(),
            default_value = ["viktorias-home-nonex"],
            config = True,
            help = "Phomes."
            )

          form_template = Unicode("""
          <script>
          function customImageCheck(that) {{
            if (that.value == "custom") {{
              document.getElementById("custom").style.display = "block";
              }} else {{
              document.getElementById("custom").style.display = "none";
            }}
          }}

         ...

          <label for="phselection">Select persistent home:</label>
          <select class="data-list-input" name="phselection" required="required" style="width:190px;" id="sphid" onchange="getHomesFromKubernetes();">
                <option value="new">New</option>
                <option value="existing">Existing</option>
          </select>

          <div id="pvcnames" style="display: none;">
          <label for="phid">Select persistent home:</label>
          <select class="form-control" name="phname" id="phid" required autofocus>
              {option_template}
          </select>
          </div>

          ...""",
              config = True, help = "Form template."
          )

         option_template = Unicode("""
              <option value="{item}">{item}</option>""",
              config = True, help = "Template for html form options."
          )

          @default('options_form')
          async def _options_form(self):
            """Return the form with the drop-down menu."""
            config.load_incluster_config()
            username = "viktorias"
            namespace = "jupyterhub-" + username
            k8s_client = client.ApiClient()
            v1 = client.CoreV1Api(k8s_client)
            phomes = []
            pvcs = await v1.list_namespaced_persistent_volume_claim(namespace=namespace)
            for pvc in pvcs.items:
                if re.match(username + "-home-", pvc.metadata.name):
                    phomes.append(pvc.metadata.name)
            options = ''.join([
                self.option_template.format(item=p) for p in phomes
            ])        
            return self.form_template.format(option_template=options)


          def options_from_form(self, formdata):
            """Parse the submitted form data and turn it into the correct
               structures for self.user_options."""

            options = {}
            default_image = "jupyter/minimal-notebook:67b8fb91f950"
            default_home = self.homes[0]

            ...

            return options
    form-homes: |
      c.JupyterHub.spawner_class = DockerImageChooser
      c.DockerImageChooser.pvcsnames = DockerImageChooser.get_p_homes()

I have been working on this for a couple of days now as there were a couple of problems:

  • firstly I wanted to implement the function in JavaScript similarly as customImageCheck which failed on not being able to load the library
  • usage of kubernetes_asyncio in Python … I could not get list_namespaced_persistent_volume_claim to work as it should be awaited

I finally got the right code in order to spawn the hub container but since then, my attempts later just pass the authentication but right after, receive 500 Internal Server Error with

traitlets.traitlets.TraitError: The 'options_form' trait of a DockerImageChooser instance expected a unicode string or a callable, not the coroutine <coroutine object DockerImageChooser._options_form at 0x7fd99dd7dac0>.

I tried:

  1. Using async with
          @default('options_form')
          async def _options_form(self):
            """Return the form with the drop-down menu."""
            config.load_incluster_config()
            username = "viktorias"
            namespace = "jupyterhub-" + username
            phomes=[]
            async with client.ApiClient() as api_client:
                  v1 = client.CoreV1Api(k8s_client)
                  pvcs = await v1.list_namespaced_persistent_volume_claim(namespace=namespace)
                  await asyncio.sleep(1)
                  for pvc in pvcs.items:
                    if re.match(username + "-home-", pvc.metadata.name):
                      phomes.append(pvc.metadata.name)
            
            options = ''.join([
                self.option_template.format(item=p) for p in phomes
            ])        
            return self.form_template.format(option_template=options)
  1. Dividing the code into a separate function that was annotated with @classmethod, creating a variable in the class and then calling c.DockerImageChooser.pvcnames = get_persistent_homes() which would fill the variable
@classmethod
          async def get_ph(self, phomes):
            config.load_incluster_config()
            username = "viktorias"
            namespace = "jupyterhub-" + username
            k8s_client = client.ApiClient()
            v1 = client.CoreV1Api(k8s_client)
            pvcs = await v1.list_namespaced_persistent_volume_claim(namespace=namespace)
            for pvc in pvcs.items:
                if re.match(username + "-home-", pvc.metadata.name):
                    phomes.append(pvc.metadata.name)


          @classmethod
          def get_p_homes(self):
            pns = []
            asyncio.create_task(self.get_ph(pns))
            return pns

          option_template = Unicode("""
              <option value="{item}">{item}</option>""",
              config = True, help = "Template for html form options."
          )

          @default('options_form')
          async def _options_form(self):
            """Return the form with the drop-down menu."""
            options = ''.join([
                self.option_template.format(item=p) for p in self.pvcsnames
            ])        
            return self.form_template.format(option_template=options)

I know that defining _options_form works because I had a very similar (nearly identical) implementation of rendering a predefined list which worked.

 extraConfig:
    form-custom-class: |
        from traitlets import default, Unicode, List
        from tornado import gen
        from kubespawner import KubeSpawner

        class DockerImageChooser(KubeSpawner):
          homes = List(
            trait = Unicode(),
            default_value = ['brno3-cerit'],
            minlen = 1,
            config = True,
            help = "Mountpoints for homes."
            )
...
<div id="customHome" style="display: none;">
          <label for "home">Select home:</label>
          <select class="form-control" name="home" required autofocus>
              {option_template}
          </select>
          </div>
...
          option_template = Unicode("""
              <option value="{home}">{home}</option>""",
              config = True, help = "Template for html form options."
          )

          @default('options_form')
          def _options_form(self):
            """Return the form with the drop-down menu."""
            options = ''.join([
                self.option_template.format(home=h) for h in self.homes
            ])
            return self.form_template.format(option_template=options)

...
    form-homes: |
      c.JupyterHub.spawner_class = DockerImageChooser
      c.DockerImageChooser.homes = [
        "storage1",  "storage2", ...
      ]

I had to remove this part for now because I also can’t seem to get 2 templates working.

Therefore, my final qustions:

  • how can I query Kubernetes API (assuming serviceAccounToken is mounted) and interpolating the output as dropdown menu in the spawn form?
  • how can I define 2 lists to interpolate in the form?

Thanks a lot!!

Viktoria

Getting an async options form is tricky! The options_form config option itself can be a coroutine. However,

@default('options_form')
async def _options_form(self):

isn’t how you declare the options form itself to be async. Functions decorated by @default (or any trait decorator) cannot be async, and are generators for the default values (the return value of the function is the default value), not the default values themselves. But you can get what you want!

Typically you would define your async options form as an async function, and assign it to c.Spawner.options_form:

async def render_options_form(spawner):
    ...your function as it is

c.Spawner.options_form = render_options_form

which means you can get this async behavior without needing to use a subclass.

But since you are overriding a Spawner class, you could override the async def get_options_form method. Essentially replace:

@default('options_form')
async def _options_form(self):

with

async def get_options_form(self):

Ultimately, there’s no difference between the two - one is subclassing and overriding a method (removing configurability), the other is using existing configuration without the need for a subclass. But they have the same result.

Then you should be able to do the rest as you have done.

In addition to options_from_form, you’ll want to define load_user_options(options) to take the options from the form (or API) and actually apply them to the Spawner. e.g.

def load_user_options(self):
    options = self.user_options
    if 'image' in options:
        self.image = options['image']
        ...

For kubernetes API requests from KubeSpawner, you can also use self.api instead of creating a new client, which is an async kubernetes client (same API, but with await):

pvcs = await self.api.list_namespaced_persistent_volume_claim(...)

Now that options_form is async, this should work fine.

Hope that helps!

1 Like