Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make spinning up multiple Jupyterlabs configurable #499

Merged
merged 10 commits into from
Oct 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions docs/docs/configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
---
sidebar_position: 3
---

# Configuration

JHub Apps (JupyterHub Apps) allows for flexible configuration to suit different deployment needs. The configurations
are defined in the `jupyterhub_config.py` file, setting various attributes via:

```python
c.JAppsConfig.<CONFIG> = <CONFIG_VALUE>
```

### `bind_url`

The URL where JupyterHub binds the service.

- **Example**:
```python
c.JupyterHub.bind_url = "http://127.0.0.1:8000"
```
- **Notes**: It sets the main address JupyterHub listens on for incoming requests.

### `jupyterhub_config_path`

Specifies the path to the `jupyterhub_config.py` file. This is used internally by JHub Apps for
accessing configurations.

- **Example**:
```python
c.JAppsConfig.jupyterhub_config_path = "jupyterhub_config.py"
```

### `conda_envs`

A list of conda environments that JHub Apps can access or use. This can either be a static list
or a callable.

- **Example**:
```python
c.JAppsConfig.conda_envs = ["env1", "env2"]
```
- **Notes**: Define any necessary environments for apps that rely on specific dependencies.

### `service_workers`

Sets the number of service worker processes to be created for handling user requests.

- **Example**:
```python
c.JAppsConfig.service_workers = 1
```

### `default_url`

The default URL users are directed to after login.

- **Example**:
```python
c.JupyterHub.default_url = "/hub/home"
```

### `allowed_frameworks`

A list of frameworks that are permitted to be launched through JHub Apps.

- **Example**:
```python
c.JupyterHub.allowed_frameworks = ["jupyterlab", "bokeh"]
```
- **Notes**:
- Supports the following values for frameworks:
- `panel`
- `bokeh`
- `streamlit`
- `plotlydash`
- `voila`
- `gradio`
- `jupyterlab`
- `custom`
- Allowing JupyterLab can potentially expose user to sharing their entire filesystem, if the created JupyterLab
app is accidentally shared. It also allows the user to swap JupyterLab runtime, which could disable
system extensions and let them run arbitrary and potentially dangerous extensions.

### `blocked_frameworks`

Specifies frameworks that users are restricted from launching.

- **Example**:
```python
c.JupyterHub.blocked_frameworks = ["voila"]
```
4 changes: 3 additions & 1 deletion environment-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ channels:
dependencies:
- uvicorn
- fastapi
- python-multipart
# Later versions were yanked in PyPi, but unfortunately not on conda-forge
# https://pypi.org/project/python-multipart/0.0.14/
- python-multipart <= 0.0.12
- jupyter
- plotlydash-tornado-cmd
- bokeh-root-cmd
Expand Down
12 changes: 11 additions & 1 deletion jhub_apps/config_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from traitlets import Unicode, Union, List, Callable, Integer
from traitlets import Unicode, Union, List, Callable, Integer, Bool
from traitlets.config import SingletonConfigurable, Enum


Expand Down Expand Up @@ -48,3 +48,13 @@ class JAppsConfig(SingletonConfigurable):
2,
help="The number of workers to create for the JHub Apps FastAPI service",
).tag(config=True)

allowed_frameworks = Bool(
None,
help="Allow only a specific set of frameworks to spun up apps.",
).tag(config=True)

blocked_frameworks = Bool(
None,
help="Disallow a set of frameworks to avoid spinning up apps using those frameworks",
).tag(config=True)
12 changes: 8 additions & 4 deletions jhub_apps/service/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
get_spawner_profiles,
get_thumbnail_data_url,
get_shared_servers,
_check_if_framework_allowed,
_get_allowed_frameworks,
)
from jhub_apps.service.app_from_git import _get_app_configuration_from_git
from jhub_apps.spawner.types import FRAMEWORKS
Expand Down Expand Up @@ -147,6 +149,7 @@ async def create_server(
user: User = Depends(get_current_user),
):
# server.servername is not necessary to supply for create server
_check_if_framework_allowed(server.user_options)
server_name = server.user_options.display_name
logger.info("Creating server", server_name=server_name, user=user.name)
server.user_options.thumbnail = await get_thumbnail_data_url(
Expand Down Expand Up @@ -202,6 +205,7 @@ async def update_server(
user: User = Depends(get_current_user),
server_name=None,
):
_check_if_framework_allowed(server.user_options)
if thumbnail_data_url:
server.user_options.thumbnail = thumbnail_data_url
else:
Expand Down Expand Up @@ -245,10 +249,10 @@ async def me(user: User = Depends(get_current_user)):
@router.get("/frameworks/", description="Get all frameworks")
async def get_frameworks(user: User = Depends(get_current_user)):
logger.info("Getting all the frameworks")
frameworks = []
for framework in FRAMEWORKS:
frameworks.append(framework.json())
return frameworks
config = get_jupyterhub_config()
return [
framework for framework in FRAMEWORKS if framework.name in _get_allowed_frameworks(config)
]


@router.get("/conda-environments/", description="Get all conda environments")
Expand Down
30 changes: 29 additions & 1 deletion jhub_apps/service/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
from cachetools import cached, TTLCache
from unittest.mock import Mock

from fastapi import HTTPException, status
from jupyterhub.app import JupyterHub
from traitlets.config import LazyConfigValue

from jhub_apps.hub_client.hub_client import HubClient
from jhub_apps.spawner.types import FrameworkConf, FRAMEWORKS_MAPPING
from jhub_apps.service.models import UserOptions
from jhub_apps.spawner.types import FrameworkConf, FRAMEWORKS_MAPPING, FRAMEWORKS
from slugify import slugify


Expand Down Expand Up @@ -168,3 +170,29 @@ def get_shared_servers(current_hub_user):
if server["name"] in shared_server_names
]
return shared_servers_rich


def _check_if_framework_allowed(user_options: UserOptions):
"""Checks if spinning up apps via the provided framework is allowed.
"""
config = get_jupyterhub_config()
allowed_frameworks = _get_allowed_frameworks(config)
if user_options.framework not in allowed_frameworks:
raise HTTPException(
detail=f"Given framework {user_options.framework} is not allowed on this deployment, "
f"please contact admin.",
status_code=status.HTTP_403_FORBIDDEN,
)


def _get_allowed_frameworks(config):
"""Given the JupyterHub config, find out allowed frameworks."""
all_frameworks = {framework.name for framework in FRAMEWORKS}
allowed_frameworks = all_frameworks
if config.JAppsConfig.allowed_frameworks is not None:
allowed_frameworks_by_admin = set(config.JAppsConfig.allowed_frameworks)
allowed_frameworks = all_frameworks.intersection(allowed_frameworks_by_admin)
if config.JAppsConfig.blocked_frameworks is not None:
blocked_frameworks_by_admin = set(config.JAppsConfig.blocked_frameworks)
allowed_frameworks -= blocked_frameworks_by_admin
return allowed_frameworks
13 changes: 6 additions & 7 deletions jhub_apps/spawner/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,17 @@ def values(cls):
logo_path=STATIC_PATH.joinpath("gradio.png"),
logo=f"{LOGO_BASE_PATH}/gradio.png"
),
FrameworkConf(
name=Framework.jupyterlab.value,
display_name="JupyterLab",
logo_path=STATIC_PATH.joinpath("jupyter.png"),
logo=f"{LOGO_BASE_PATH}/jupyter.png",
),
FrameworkConf(
name=Framework.custom.value,
display_name="Custom Command",
logo_path=STATIC_PATH.joinpath("custom.png"),
logo=f"{LOGO_BASE_PATH}/custom.png"
),
FrameworkConf(
name=Framework.jupyterlab.value,
display_name="JupyterLab",
logo_path=STATIC_PATH.joinpath("jupyter.png"),
logo=f"{LOGO_BASE_PATH}/jupyter.png",
),
]

FRAMEWORKS_MAPPING = {framework.name: framework for framework in FRAMEWORKS}
49 changes: 40 additions & 9 deletions jhub_apps/tests/tests_unit/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,16 @@
from jhub_apps.hub_client.hub_client import HubClient
from jhub_apps.service.models import UserOptions, ServerCreation, Repository
from jhub_apps.service.utils import get_shared_servers
from jhub_apps.spawner.types import FRAMEWORKS
from jhub_apps.spawner.types import FRAMEWORKS, Framework
from jhub_apps.tests.common.constants import MOCK_USER

MOCK_ALLOW_ALL_FRAMEWORKS_CONFIG = Mock(
JAppsConfig=Mock(
allowed_frameworks=[f.name for f in FRAMEWORKS],
blocked_frameworks=[]
)
)


def mock_user_options():
user_options = {
Expand Down Expand Up @@ -60,9 +67,11 @@ def test_api_get_server_not_found(get_user, client):
}


@patch("jhub_apps.service.utils.get_jupyterhub_config")
@patch.object(HubClient, "create_server")
def test_api_create_server(create_server, client):
def test_api_create_server(create_server, get_jupyterhub_config, client):
from jhub_apps.service.models import UserOptions
get_jupyterhub_config.return_value = MOCK_ALLOW_ALL_FRAMEWORKS_CONFIG
create_server_response = {"user": "jovyan"}
create_server.return_value = create_server_response
user_options = mock_user_options()
Expand Down Expand Up @@ -132,10 +141,11 @@ def test_api_delete_server(delete_server, name, remove, client):
assert response.json() == create_server_response


@patch("jhub_apps.service.utils.get_jupyterhub_config")
@patch.object(HubClient, "edit_server")
def test_api_update_server(edit_server, client):
def test_api_update_server(edit_server, get_jupyterhub_config, client):
from jhub_apps.service.models import UserOptions

get_jupyterhub_config.return_value = MOCK_ALLOW_ALL_FRAMEWORKS_CONFIG
create_server_response = {"user": "jovyan"}
edit_server.return_value = create_server_response
user_options = mock_user_options()
Expand Down Expand Up @@ -196,14 +206,27 @@ def test_shared_server_filtering(hub_get_shared_servers, get_users):
get_users.assert_called_once_with()


def test_api_frameworks(client):
@pytest.mark.parametrize("allowed_frameworks, blocked_frameworks,", [
([f.name for f in FRAMEWORKS if f.name != Framework.jupyterlab.name], []),
([f.name for f in FRAMEWORKS], []),
([], [Framework.jupyterlab.name]),
([Framework.panel.name], [Framework.bokeh.name]),
])
@patch("jhub_apps.service.routes.get_jupyterhub_config")
def test_api_frameworks(get_jupyterhub_config, client, allowed_frameworks, blocked_frameworks):
get_jupyterhub_config.return_value = Mock(
JAppsConfig=Mock(
allowed_frameworks=allowed_frameworks,
blocked_frameworks=blocked_frameworks
)
)

response = client.get(
"/frameworks",
)
frameworks = []
for framework in FRAMEWORKS:
frameworks.append(framework.json())
assert response.json() == frameworks
response_json = response.json()
returned_frameworks = {f["name"] for f in response_json}
assert returned_frameworks == set(allowed_frameworks) - set(blocked_frameworks)


def test_api_status(client):
Expand All @@ -225,11 +248,19 @@ def test_open_api_docs(client):
assert rjson['info']['version']


@patch("jhub_apps.service.utils.get_jupyterhub_config")
@patch.object(HubClient, "create_server")
def test_create_server_with_git_repository(
hub_create_server,
get_jupyterhub_config,
client,
):
get_jupyterhub_config.return_value = Mock(
JAppsConfig=Mock(
allowed_frameworks=[f.name for f in FRAMEWORKS],
blocked_frameworks=[]
)
)
user_options = UserOptions(
jhub_app=True,
display_name="Test Application",
Expand Down
Loading