Skip to content

Commit

Permalink
Implement JupyterHub 5.x sharing APIs in the hub client (#224)
Browse files Browse the repository at this point in the history
  • Loading branch information
aktech committed May 8, 2024
1 parent 59c098a commit 79b0288
Show file tree
Hide file tree
Showing 15 changed files with 261 additions and 43 deletions.
1 change: 1 addition & 0 deletions docs/docs/create-apps/general-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Check out their specific documentation pages for details.
| Spawner profile | Instance type (i.e. machines with CPU/RAM/GPU resources) required for running your application. Only applicable if you're using `KubeSpawner` as the spawner for JupyterHub. |
| Custom Command (required if the Framework is "Custom Command") | Python command to start an arbitrary app. |
| Allow Public Access | Toggle to share the application with anyone on the internet, including unauthenticated users. |
| Sharing | To share the application with a set of users and or groups. |

Example:

Expand Down
8 changes: 6 additions & 2 deletions jhub_apps/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from traitlets.config import LazyConfigValue

from jhub_apps import JAppsConfig
from jhub_apps.hub_client.utils import is_jupyterhub_5
from jhub_apps.spawner.spawner_creation import subclass_spawner


Expand Down Expand Up @@ -78,6 +79,7 @@ def install_jhub_apps(c, spawner_to_subclass):
"scopes": [
# declare what permissions the service should have
"list:users", # list users
"list:groups", # list groups
"read:users:activity", # read user last-activity
"read:users", # read user last-activity
"admin:servers", # start/stop servers
Expand All @@ -87,15 +89,17 @@ def install_jhub_apps(c, spawner_to_subclass):
"access:services",
"list:services",
"read:services", # read service models
],
] + ([
"shares"
] if is_jupyterhub_5() else []),
},
{
"name": "user",
# grant all users access to services
"scopes": [
"self",
"access:services",
"admin:auth_state"
"admin:auth_state",
],
},
]
Expand Down
124 changes: 118 additions & 6 deletions jhub_apps/hub_client/hub_client.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import typing
from concurrent.futures import ThreadPoolExecutor

import structlog
import os
import re
import uuid

import requests

from jhub_apps.service.models import UserOptions, SharePermissions
from jhub_apps.hub_client.utils import is_jupyterhub_5

API_URL = os.environ.get("JUPYTERHUB_API_URL")
JUPYTERHUB_API_TOKEN = os.environ.get("JUPYTERHUB_API_TOKEN")
Expand Down Expand Up @@ -82,14 +87,14 @@ def start_server(self, username, servername):
r.raise_for_status()
return r.status_code, servername

def create_server(self, username, servername, user_options=None):
def create_server(self, username: str, servername: str, user_options: UserOptions = None):
logger.info("Creating new server", user=username)
servername = self.normalize_server_name(servername)
servername = f"{servername}-{uuid.uuid4().hex[:7]}"
normalized_servername = self.normalize_server_name(servername)
unique_servername = f"{normalized_servername}-{uuid.uuid4().hex[:7]}"
logger.info("Normalized servername", servername=servername)
return self._create_server(username, servername, user_options)
return self._create_server(username, unique_servername, user_options)

def edit_server(self, username, servername, user_options=None):
def edit_server(self, username: str, servername: str, user_options: UserOptions = None):
logger.info("Editing server", server_name=servername)
server = self.get_server(username, servername)
if server:
Expand All @@ -101,15 +106,101 @@ def edit_server(self, username, servername, user_options=None):
logger.info("Now creating the server with new params", server_name=servername)
return self._create_server(username, servername, user_options)

def _create_server(self, username, servername, user_options):
def _create_server(self, username: str, servername: str, user_options: UserOptions = None):
url = f"/users/{username}/servers/{servername}"
params = user_options.model_dump()
data = {"name": servername, **params}
logger.info("Creating new server", server_name=servername)
r = requests.post(API_URL + url, headers=self._headers(), json=data)
r.raise_for_status()
if is_jupyterhub_5():
logger.info("Sharing", share_with=user_options.share_with)
self._share_server_with_multiple_entities(
username,
servername,
share_with=user_options.share_with
)
else:
logger.info("Not sharing server as JupyterHub < 5.x")
return r.status_code, servername

def _share_server(
self,
username: str,
servername: str,
share_to_user: typing.Optional[str],
share_to_group: typing.Optional[str],
):
url = f"/shares/{username}/{servername}"
if share_to_user:
data = {"user": share_to_user}
elif share_to_group:
data = {"group": share_to_group}
else:
raise ValueError("None of share_to_user or share_to_group provided")
share_with = share_to_group or share_to_user
logger.info(f"Sharing {username}/{servername} with {share_with}")
return requests.post(API_URL + url, headers=self._headers(), json=data)

def _share_server_with_multiple_entities(
self,
username: str,
servername: str,
share_with: typing.Optional[SharePermissions] = None
):
"""
:param username: owner of the servername
:param servername: servername to share
:param share_to_users: list of users to share the server with
:param share_to_groups: list of groups to share the server with
:return: mapping of dict of users + group to corresponding response json from Hub API
"""
if not share_with:
logger.info("Neither of share_to_user or share_to_group provided, NOT sharing")
return
logger.info(
f"Requested to share {username}/{servername}",
share_to_users=share_with.users, share_to_groups=share_with.groups
)
users = share_with.users or []
groups = share_with.groups or []
share_to_user_args = [(username, servername, user, None,) for user in users]
share_to_group_args = [(username, servername, None, group,) for group in groups]
executor_arguments = share_to_user_args + share_to_group_args
# Remove any previously shared access, this is useful when editing apps
self._revoke_shared_access(username, servername)
# NOTE: JupyterHub 5.x doesn't provide a way for bulk sharing, as in share with a
# set of groups and users. Since we don't have a task queue in jhub-apps at the moment,
# we're using multithreading to call JupyterHub API to share the app with multiple users/groups
# to remove any visible lag in the API request to create server.
with ThreadPoolExecutor(max_workers=10) as ex:
logger.info(f"Share executor arguments: {executor_arguments}")
response_results = list(ex.map(lambda p: self._share_server(*p), executor_arguments))

user_and_groups = users + groups
response_results_json = [resp.json() for resp in response_results]
user_group_and_response_map = dict(zip(user_and_groups, response_results_json))
logger.info("Sharing response", response=user_group_and_response_map)
return user_group_and_response_map

def _revoke_shared_access(self, username: str, servername: str):
"""Revoke all shared access to a given server"""
logger.info("Revoking shared servers access", user=username, servername=servername)
url = f"/shares/{username}/{servername}"
return requests.delete(API_URL + url, headers=self._headers())

def get_shared_servers(self, username: str):
"""List servers shared with user"""
if not is_jupyterhub_5():
logger.info("Unable to get shared servers as this feature is not available in JupyterHub < 5.x")
return []
logger.info("Getting shared servers", user=username)
url = f"/users/{username}/shared"
response = requests.get(API_URL + url, headers=self._headers())
rjson = response.json()
shared_servers = rjson["items"]
return shared_servers

def delete_server(self, username, server_name, remove=False):
if server_name is None:
# Default server and not named server
Expand All @@ -125,3 +216,24 @@ def get_services(self):
r = requests.get(API_URL + "/services", headers=self._headers())
r.raise_for_status()
return r.json()

def get_groups(self):
"""Returns all the groups in JupyterHub"""
r = requests.get(API_URL + "/groups", headers=self._headers())
r.raise_for_status()
return r.json()


def get_users_and_group_allowed_to_share_with(user):
"""Returns a list of users and groups"""
hclient = HubClient()
users = hclient.get_users()
user_names = [u["name"] for u in users if u["name"] != user.name]
groups = hclient.get_groups()
group_names = [group['name'] for group in groups]
# TODO: Filter users and groups based on what the user has access to share with
# parsed_scopes = parse_scopes(scopes)
return {
"users": user_names,
"groups": group_names
}
5 changes: 5 additions & 0 deletions jhub_apps/hub_client/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import jupyterhub


def is_jupyterhub_5():
return jupyterhub.version_info[0] == 5
3 changes: 0 additions & 3 deletions jhub_apps/service/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +0,0 @@
from .app import app

__all__ = ["app"]
7 changes: 7 additions & 0 deletions jhub_apps/service/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ class Server(BaseModel):
user_options: Optional[Any]


class SharePermissions(BaseModel):
users: List[str]
groups: List[str]


class User(BaseModel):
name: str
admin: bool
Expand All @@ -30,6 +35,7 @@ class User(BaseModel):
servers: Optional[Dict[str, Server]] = None
scopes: List[str]
auth_state: Optional[Dict] = None
share_permissions: typing.Optional[SharePermissions] = None


# https://stackoverflow.com/questions/64501193/fastapi-how-to-use-httpexception-in-responses
Expand Down Expand Up @@ -66,6 +72,7 @@ class UserOptions(BaseModel):
# Keep app alive, even when there is no activity
# So that it's not killed by idle culler
keep_alive: typing.Optional[bool] = False
share_with: typing.Optional[SharePermissions] = None


class ServerCreation(BaseModel):
Expand Down
12 changes: 11 additions & 1 deletion jhub_apps/service/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,17 @@ def get_all_shared_servers(hub_users, current_hub_user):
]
all_servers.extend(hub_user_servers_with_name)
# Filter default servers
return list(filter(lambda server: server["name"] != "", all_servers))
all_servers_without_default_jlab = list(filter(lambda server: server["name"] != "", all_servers))
# Filter servers shared with the user
hub_client = HubClient()
shared_servers = hub_client.get_shared_servers(username=current_hub_user["name"])
shared_server_names = {shared_server["server"]["name"] for shared_server in shared_servers}
shared_servers_rich = [
server for server in all_servers_without_default_jlab
if server["name"] in shared_server_names
]
return shared_servers_rich



@router.get("/server/", description="Get all servers")
Expand Down
3 changes: 3 additions & 0 deletions jhub_apps/service/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from fastapi.security import OAuth2AuthorizationCodeBearer, APIKeyCookie
from fastapi.security.api_key import APIKeyQuery

from jhub_apps.hub_client.hub_client import get_users_and_group_allowed_to_share_with, is_jupyterhub_5
from .auth import get_jhub_token_from_jwt_token
from .client import get_client
from .models import User
Expand Down Expand Up @@ -69,6 +70,8 @@ async def get_current_user(
},
)
user = User(**resp.json())
if is_jupyterhub_5():
user.share_permissions = get_users_and_group_allowed_to_share_with(user)
if any(scope in user.scopes for scope in access_scopes):
return user
else:
Expand Down
1 change: 1 addition & 0 deletions jhub_apps/service/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import base64

import structlog
import os

Expand Down
14 changes: 0 additions & 14 deletions jhub_apps/spawner/types.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import typing
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
Expand All @@ -25,19 +24,6 @@ def json(self):
}


@dataclass
class UserOptions:
jhub_app: bool
display_name: str
description: str
thumbnail: str
filepath: str
framework: str
custom_command: typing.Optional[str] = None
conda_env: typing.Optional[dict] = None
profile: typing.Optional[str] = None


class Framework(Enum):
panel = "panel"
bokeh = "bokeh"
Expand Down
2 changes: 1 addition & 1 deletion jhub_apps/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def client():
os.environ["PUBLIC_HOST"] = "/"
os.environ["JUPYTERHUB_CLIENT_ID"] = "test-client-id"
os.environ["JUPYTERHUB_OAUTH_CALLBACK_URL"] = "/"
from jhub_apps.service import app
from jhub_apps.service.app import app
from jhub_apps.service.security import get_current_user

async def mock_get_user_name():
Expand Down
7 changes: 6 additions & 1 deletion jhub_apps/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ def mock_user_options():
"ENV_VAR_KEY_2": "ENV_VAR_KEY_2",
},
"profile": "",
"public": False
"public": False,
"keep_alive": False,
"share_with": {
"users": ["alice", "john"],
"groups": ["alpha", "beta"]
}
}
return user_options

Expand Down
15 changes: 15 additions & 0 deletions jhub_apps/tests/test_hub_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from jhub_apps.hub_client.hub_client import HubClient


def test_normalize_server_name():
hub_client = HubClient()
# test escaping
assert hub_client.normalize_server_name("../../another-endpoint") == "another-endpoint"
# Test long server name
assert hub_client.normalize_server_name("x"*1000) == "x"*240
# Test all special characters
assert hub_client.normalize_server_name("server!@£$%^&*<>:~`±") == "server"
# Replace space with dash
assert hub_client.normalize_server_name("some server name") == "some-server-name"
# lowercase
assert hub_client.normalize_server_name("SOMESERVERNAME") == "someservername"
Loading

0 comments on commit 79b0288

Please sign in to comment.