Skip to content

Commit

Permalink
Merge pull request #8 from mj0nez/feature/make-user-form-optional
Browse files Browse the repository at this point in the history
feature: separate job_factory, introduce auto_remove option and custom server name_templates
  • Loading branch information
mxab authored Mar 28, 2024
2 parents d973b01 + 33ea473 commit 4d57047
Show file tree
Hide file tree
Showing 11 changed files with 572 additions and 115 deletions.
132 changes: 102 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,55 +1,70 @@
# Nomad Jupyter Spawner


> **Warning**
> [!WARNING]
> This project is currently in beta
Spawns a Jupyter Notebook via Jupyterhub.
A Jupyterhub plugin to spawn single-user notebook servers via [Nomad](https://www.nomadproject.io/). The project provides templates to allow users to influence how their servers are spawned (see the [showcase](#-show-case) and [recipes](#-recipes) for more details.).

Users can select and image, resource and connect it with volumes (csi / host)
After login users can select and image, resource and connect it with volumes (csi / host)

```sh
pip install jupyterhub-nomad-spawner
```

## Show Case
https://user-images.githubusercontent.com/1607547/182332760-b0f96ba2-faa8-47b6-9bd7-db93b8d31356.mp4

https://user-images.githubusercontent.com/1607547/182332760-b0f96ba2-faa8-47b6-9bd7-db93b8d31356.mp4

TODOs:

- Document setup
- Namespace support


## Usage

### Config
### Jupyterhub Configuration

```python

import json
import os
import socket

from jupyterhub.auth import DummyAuthenticator
import tarfile

c.JupyterHub.spawner_class = "nomad-spawner"
c.JupyterHub.bind_url = "http://0.0.0.0:8000"
c.JupyterHub.hub_bind_url = "http://0.0.0.0:8081"
c.JupyterHub.hub_connect_url = f"http://{os.environ.get('NOMAD_IP_api')}:{os.environ.get('NOMAD_HOST_PORT_api')}"

c.JupyterHub.hub_connect_url = (
f"http://{os.environ.get('NOMAD_IP_api')}:{os.environ.get('NOMAD_HOST_PORT_api')}"
)
c.JupyterHub.log_level = "DEBUG"
c.ConfigurableHTTPProxy.debug = True


c.JupyterHub.allow_named_servers = True
c.JupyterHub.named_server_limit_per_user = 5

c.JupyterHub.authenticator_class = DummyAuthenticator

c.NomadSpawner.mem_limit = "2G"
c.NomadSpawner.datacenters = ["dc1", "dc2", "dc3"]
c.NomadSpawner.csi_plugin_ids = ["nfs", "hostpath-plugin0"]
c.NomadSpawner.mem_limit = "2G"

```
c.NomadSpawner.common_images = ["jupyter/minimal-notebook:2023-06-26"]


### Nomad Job
def csi_volume_parameters(spawner):
if spawner.user_options["volume_csi_plugin_id"] == "nfs":
return {"gid": "1000", "uid": "1000"}
else:
return None


c.NomadSpawner.csi_volume_parameters = csi_volume_parameters

```

### Nomad Job

```hcl
Expand Down Expand Up @@ -98,47 +113,41 @@ CONSUL_HTTP_ADDR=http://host.docker.internal:8500
destination = "/local/jupyterhub_config.py"
data = <<EOF
import json
import os
import socket
from jupyterhub.auth import DummyAuthenticator
import tarfile
c.JupyterHub.spawner_class = "nomad-spawner"
c.JupyterHub.bind_url = "http://0.0.0.0:8000"
c.JupyterHub.hub_bind_url = "http://0.0.0.0:8081"
c.JupyterHub.hub_connect_url = f"http://{os.environ.get('NOMAD_IP_api')}:{os.environ.get('NOMAD_HOST_PORT_api')}"
c.JupyterHub.hub_connect_url = (
f"http://{os.environ.get('NOMAD_IP_api')}:{os.environ.get('NOMAD_HOST_PORT_api')}"
)
c.JupyterHub.log_level = "DEBUG"
c.ConfigurableHTTPProxy.debug = True
c.JupyterHub.allow_named_servers = True
c.JupyterHub.named_server_limit_per_user = 3
c.JupyterHub.named_server_limit_per_user = 5
c.JupyterHub.authenticator_class = DummyAuthenticator
c.NomadSpawner.datacenters = ["dc1"]
c.NomadSpawner.datacenters = ["dc1", "dc2", "dc3"]
c.NomadSpawner.csi_plugin_ids = ["nfs", "hostpath-plugin0"]
c.NomadSpawner.mem_limit = "2G"
c.NomadSpawner.common_images = ["jupyter/minimal-notebook:2023-06-26"]
c.NomadSpawner.common_images = ["jupyter/minimal-notebook:2022-08-20"]
def csi_volume_parameters(spawner):
if spawner.user_options["volume_csi_plugin_id"] == "nfs":
return {
"gid" : "1000",
"uid" : "1000"
}
return {"gid": "1000", "uid": "1000"}
else:
return None
c.NomadSpawner.csi_volume_parameters = csi_volume_parameters
def vault_policies(spawner):
return [f"my-policy-{spawner.user.name}"]
c.NomadSpawner.vault_policies = vault_policies
c.NomadSpawner.csi_volume_parameters = csi_volume_parameters
EOF
Expand Down Expand Up @@ -178,6 +187,69 @@ c.NomadSpawner.vault_policies = vault_policies
```

## Recipes

By default the `jupyterhub-nomad-spawner` allows users to customize the notebook servers image, the datacenters to spawn in, as well as the memory and volume type for the allocation. While these options are sufficient in most cases, `jupyterhub` operators may wish to customize the spawner's behavior and/or restrict the notebook users customization.

- using a custom job spec

```python
# must be available to your hub server
c.NomadSpawner.job_template_path = "/etc/jupyterhub/custom-job-template.hcl.j2"

```

- disabling user options

```python
# skips the options dialogue, which is used to populate `NomadSpawner.user_options`
# therefore you would also have to overwrite the default `job_factory``
c.NomadSpawner.options_form = ""
```

- using a custom job factory

```python
from jupyterhub_nomad_spawner.spawner import NomadSpawner
from jupyterhub_nomad_spawner.job_factory import (
JobData,
create_job,
)


class CustomNomadSpawner(NomadSpawner):
async def job_factory(self, _) -> str:
return create_job(
job_data=JobData(
job_name=self.job_name,
username=self.user.name,
notebook_name=self.name,
service_provider=self.service_provider,
service_name=self.service_name,
env=self.get_env(),
args=self.get_args(),
image="jupyter/minimal-notebook",
datacenters=["dc1", "dc2"],
cpu=500,
memory=512,
),
job_template_path=self.job_template_path,
)

c.JupyterHub.spawner_class = CustomNomadSpawner
```

- customizing server naming

```python
c.NomadSpawner.base_job_name = "jupyter" # used as prefix
c.NomadSpawner.name_template = "{{prefix}}-{{username}}"
```

> [!NOTE]
> Please be aware that if you have enabled named servers, the template should contain the {{notebookid}}.
###

## Development

Expand Down
9 changes: 8 additions & 1 deletion jupyterhub_nomad_spawner/job_factory.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os
from enum import Enum
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Any

from jinja2 import (
BaseLoader,
Expand Down Expand Up @@ -77,3 +77,10 @@ def create_job(job_data: JobData, job_template_path: Optional[str] = None) -> st
job_hcl = template.render(**job_data.dict())

return job_hcl


def create_job_name(jinja_template: str, data: dict[str, Any]) -> str:
env = Environment(autoescape=select_autoescape())
template = env.from_string(jinja_template)

return template.render(**data)
1 change: 0 additions & 1 deletion jupyterhub_nomad_spawner/job_options_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ def create_form(
csi_plugin_ids: Optional[List[str]],
memory_limit: Optional[int] = None,
) -> str:

env = Environment(
loader=PackageLoader("jupyterhub_nomad_spawner"), autoescape=select_autoescape()
)
Expand Down
27 changes: 24 additions & 3 deletions jupyterhub_nomad_spawner/nomad/nomad_service.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from logging import Logger, LoggerAdapter
from pathlib import Path
from typing import Dict, Optional, Tuple, Union
from typing import Dict, Optional, Tuple, Union, Any

from attrs import define
from httpx import AsyncClient
Expand Down Expand Up @@ -126,8 +126,17 @@ async def job_status(self, job_id) -> str:
job_detail = response.json()
return job_detail.get("Status", "")

async def delete_job(self, job_id: str):
response = await self.client.delete(f"/v1/job/{job_id}")
async def job_allocations(self, job_id) -> list[dict[str, Any]]:
response = await self.client.get(f"/v1/job/{job_id}/allocations")
if response.is_error:
raise NomadException(f"Error getting job allocations: {response.text}")

allocations = response.json()
return allocations

async def delete_job(self, job_id: str, purge: Optional[bool] = None):
params = {"purge": purge} if purge else None
response = await self.client.delete(f"/v1/job/{job_id}", params=params)
if response.is_error:
raise NomadException(f"Error deleting job: {response.text}")

Expand All @@ -142,3 +151,15 @@ async def get_service_address(self, service_name: str) -> Tuple[str, int]:
if len(services) > 1:
raise NomadException(f"Multiple services found for {service_name}")
return str(services[0]["Address"]), int(services[0]["Port"])

async def get_service_of_allocation(self, allocation_id: str) -> Tuple[str, int]:
response = await self.client.get(f"/v1/allocation/{allocation_id}")
if response.is_error:
raise NomadException(f"Error reading allocation: {response.text}")

allocation = response.json()

networks = allocation["Resources"]["Networks"]
host_port = networks[0]["DynamicPorts"][0]["Value"]

return str(networks[0]["IP"]), int(host_port)
Loading

0 comments on commit 4d57047

Please sign in to comment.