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

Create apps on startup via traitlet #516

Merged
merged 60 commits into from
Jan 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
3620828
create apps on startup
Adam-D-Lewis Nov 6, 2024
292cbdc
modifications
Adam-D-Lewis Nov 7, 2024
52381cc
edits
Adam-D-Lewis Nov 7, 2024
23fb2c2
updates
Adam-D-Lewis Dec 16, 2024
e8c7582
revert this file
Adam-D-Lewis Dec 16, 2024
aee7385
refactor
Adam-D-Lewis Dec 17, 2024
db648c6
updates
Adam-D-Lewis Dec 17, 2024
b8f8c12
upadte
Adam-D-Lewis Dec 18, 2024
2f6b374
ruff fixes
Adam-D-Lewis Dec 18, 2024
48c9aa5
remove unneeded line
Adam-D-Lewis Dec 18, 2024
8cb35ff
fix bug with servername vs normalized_servername
Adam-D-Lewis Dec 18, 2024
4faf955
refactor traitlet import
Adam-D-Lewis Dec 18, 2024
74c675a
add TODO comment
Adam-D-Lewis Dec 18, 2024
880f826
save point
Adam-D-Lewis Jan 7, 2025
95b4baf
savepoint2
Adam-D-Lewis Jan 7, 2025
58d043d
Merge branch 'main' into create_apps_on_startup
Adam-D-Lewis Jan 7, 2025
641a4c1
various fixes
Adam-D-Lewis Jan 7, 2025
c054f7e
better error message
Adam-D-Lewis Jan 7, 2025
51d6ca0
pair back changes to other parts of the code
Adam-D-Lewis Jan 7, 2025
a817a57
further cleanup
Adam-D-Lewis Jan 7, 2025
a3e81ce
more cleanup
Adam-D-Lewis Jan 7, 2025
92f0ca8
cleanup
Adam-D-Lewis Jan 7, 2025
d96ae36
cleanup quote formatting
Adam-D-Lewis Jan 7, 2025
31b80de
update description
Adam-D-Lewis Jan 7, 2025
8ec20df
add return types
Adam-D-Lewis Jan 7, 2025
a1fa544
startup apps async
Adam-D-Lewis Jan 7, 2025
a8c0bc4
cleanup
Adam-D-Lewis Jan 7, 2025
b86ab31
fix pytest warning
Adam-D-Lewis Jan 8, 2025
29b15af
add test
Adam-D-Lewis Jan 8, 2025
a70cc34
add test
Adam-D-Lewis Jan 8, 2025
3f6fd8c
update comment
Adam-D-Lewis Jan 8, 2025
f069237
add retries to test
Adam-D-Lewis Jan 8, 2025
b2e5438
remove separate starting of jupyterhub
Adam-D-Lewis Jan 8, 2025
f0a8e1f
add sleep timeout to start properly
Adam-D-Lewis Jan 8, 2025
a6b54b9
remove unused imports
Adam-D-Lewis Jan 8, 2025
9f76262
autouse jhub manager
Adam-D-Lewis Jan 9, 2025
9f2e28f
Merge branch 'main' into create_apps_on_startup
Adam-D-Lewis Jan 13, 2025
5f231d6
refactor
Adam-D-Lewis Jan 13, 2025
3dce601
lint fixes
Adam-D-Lewis Jan 13, 2025
f367849
fix ui test bug
Adam-D-Lewis Jan 13, 2025
6540c23
fix TODOs
Adam-D-Lewis Jan 13, 2025
0b5d047
improve file locking so only 1 worker creates startup_apps
Adam-D-Lewis Jan 13, 2025
198cc69
clarify comment
Adam-D-Lewis Jan 13, 2025
8c1eb4b
prevent users from putting jhub_app: False on StartupApps
Adam-D-Lewis Jan 14, 2025
926b28d
minor updates
Adam-D-Lewis Jan 14, 2025
b8261aa
lint fix
Adam-D-Lewis Jan 15, 2025
fcf4977
remove redundant _get_user_servers
Adam-D-Lewis Jan 15, 2025
42fd438
correct type annotation
Adam-D-Lewis Jan 15, 2025
1346ba2
remove trailing spaces
Adam-D-Lewis Jan 15, 2025
e5246a2
create startup_apps as separate jupyterhub service
Adam-D-Lewis Jan 15, 2025
44cbfd4
allow more retries
Adam-D-Lewis Jan 15, 2025
d52fefb
fix startup_apps to work for Python 3.8+
Adam-D-Lewis Jan 15, 2025
c6084c1
lint fix
Adam-D-Lewis Jan 15, 2025
ff14b42
PR review adjustments
Adam-D-Lewis Jan 16, 2025
8748b11
remove unused imports
Adam-D-Lewis Jan 16, 2025
500ff67
change command
Adam-D-Lewis Jan 16, 2025
3679127
split docstring
Adam-D-Lewis Jan 16, 2025
e2d14ea
change docstring
Adam-D-Lewis Jan 16, 2025
cc81262
fix typo + tail /dev/null
Adam-D-Lewis Jan 16, 2025
5fd6fb7
remove trailing spaces
Adam-D-Lewis Jan 16, 2025
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
9 changes: 0 additions & 9 deletions .github/workflows/test-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,6 @@ jobs:
cat jupyter_config_profile_list >> jupyterhub_config.py
cat jupyterhub_config.py

- name: Start JupyterHub on Ubuntu
Adam-D-Lewis marked this conversation as resolved.
Show resolved Hide resolved
run: |
nohup jupyterhub -f jupyterhub_config.py > jupyterhub-logs.txt 2>&1 &
# Give it some to time to start properly
sleep 10
cat jupyterhub-logs.txt
curl http://127.0.0.1:8000/services/japps/
cat jupyterhub-logs.txt

- name: Install Playwright
run: |
pip install pytest-playwright
Expand Down
76 changes: 73 additions & 3 deletions jhub_apps/config_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,65 @@
from traitlets import Unicode, Union, List, Callable, Integer, Bool
import textwrap
import typing as t
from pydantic import BaseModel, ValidationError
from traitlets import Unicode, Union, List, Callable, Integer, TraitType, TraitError

from traitlets.config import SingletonConfigurable, Enum

from jhub_apps.service.models import StartupApp


class PydanticModelTrait(TraitType):
"""A trait type for validating Pydantic models.

This trait ensures that the input is an instance of a specific Pydantic model type.
"""

def __init__(self, model_class: t.Type[BaseModel], *args, **kwargs):
"""
Initialize the trait with a specific Pydantic model class.

Args:
model_class: The Pydantic model class to validate against
*args: Additional arguments for TraitType
**kwargs: Additional keyword arguments for TraitType
"""
super().__init__(*args, **kwargs)
self.model_class = model_class
self.info_text = f"an instance of {model_class.__name__}"

def validate(self, obj: t.Any, value: t.Any) -> BaseModel:
"""
Validate that the input is an instance of the specified Pydantic model.

Args:
obj: The object the trait is attached to
value: The value to validate

Returns:
Validated Pydantic model instance

Raises:
TraitError: If the value is not a valid instance of the model
"""
# If None is allowed and value is None, return None
if self.allow_none and value is None:
return None

# Check if value is an instance of the specified model class
if isinstance(value, self.model_class):
return value

# If not an instance, try to create an instance from a dict
if isinstance(value, dict):
try:
return self.model_class(**value)
except ValidationError as e:
# Convert Pydantic validation error to TraitError
raise TraitError(f'Could not parse input as a valid {self.model_class.__name__} Pydantic model:\n'
f'{textwrap.indent(str(e), prefix=" ")}')

raise TraitError(f'Input must be a valid {self.model_class.__name__} Pydantic model or dict object, but got {value}.')


class JAppsConfig(SingletonConfigurable):
apps_auth_type = Enum(
Expand Down Expand Up @@ -49,12 +108,23 @@ class JAppsConfig(SingletonConfigurable):
help="The number of workers to create for the JHub Apps FastAPI service",
).tag(config=True)

allowed_frameworks = Bool(
allowed_frameworks = List(
None,
Adam-D-Lewis marked this conversation as resolved.
Show resolved Hide resolved
help="Allow only a specific set of frameworks to spun up apps.",
default_value=None,
allow_none=True,
).tag(config=True)

blocked_frameworks = Bool(
blocked_frameworks = List(
None,
help="Disallow a set of frameworks to avoid spinning up apps using those frameworks",
default_value=None,
allow_none=True,
).tag(config=True)

startup_apps = List(
trait=PydanticModelTrait(StartupApp),
description="Add a server if not already created or edit an existing one to match the config. Removing items from this list won't delete any servers.",
default_value=[],
help="List of apps to start on JHub Apps Launcher startup",
).tag(config=True)
45 changes: 35 additions & 10 deletions jhub_apps/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
from jhub_apps.hub_client.utils import is_jupyterhub_5
from jhub_apps.spawner.spawner_creation import subclass_spawner

FASTAPI_SERVICE_NAME = "japps"
STARTUP_APPS_SERVICE_NAME = f"{FASTAPI_SERVICE_NAME}-initialize-startup-apps"


def _create_token_for_service():
# Use the one from environment if available
Expand Down Expand Up @@ -39,35 +42,35 @@ def install_jhub_apps(c, spawner_to_subclass, *, oauth_no_confirm=False):
c.JupyterHub.allow_named_servers = True
bind_url = c.JupyterHub.bind_url

japps_config = JAppsConfig(config=c) # validate inputs
set_defaults_for_jhub_apps_config(c)
if not isinstance(bind_url, str):
raise ValueError(f"c.JupyterHub.bind_url is not set: {c.JupyterHub.bind_url}")
if not c.JupyterHub.services:
c.JupyterHub.services = []
public_host = c.JupyterHub.bind_url
fast_api_service_name = "japps"
oauth_redirect_uri = (
f"{public_host}/services/{fast_api_service_name}/oauth_callback"
f"{public_host}/services/{FASTAPI_SERVICE_NAME}/oauth_callback"
)
c.JupyterHub.services.extend(
[
{
"name": fast_api_service_name,
"url": f"http://{c.JAppsConfig.hub_host}:10202",
"name": FASTAPI_SERVICE_NAME,
"url": f"http://{japps_config.hub_host}:10202",
"command": [
c.JAppsConfig.python_exec,
japps_config.python_exec,
"-m",
"uvicorn",
"jhub_apps.service.app:app",
"--port=10202",
"--host=0.0.0.0",
f"--workers={c.JAppsConfig.service_workers}",
f"--workers={japps_config.service_workers}",
],
"environment": {
"PUBLIC_HOST": c.JupyterHub.bind_url,
"JHUB_APP_TITLE": c.JAppsConfig.app_title,
"JHUB_APP_ICON": c.JAppsConfig.app_icon,
"JHUB_JUPYTERHUB_CONFIG": c.JAppsConfig.jupyterhub_config_path,
"JHUB_APP_TITLE": japps_config.app_title,
"JHUB_APP_ICON": japps_config.app_icon,
"JHUB_JUPYTERHUB_CONFIG": japps_config.jupyterhub_config_path,
"JHUB_APP_JWT_SECRET_KEY": _create_token_for_service(),

# Temp environment variables for Nebari Deployment
Expand All @@ -78,14 +81,36 @@ def install_jhub_apps(c, spawner_to_subclass, *, oauth_no_confirm=False):
"oauth_no_confirm": oauth_no_confirm,
"display": False,
},
{
"name": STARTUP_APPS_SERVICE_NAME,
"command": [
japps_config.python_exec,
"-m",
"jhub_apps.tasks.commands.initialize_startup_apps",
"&&",
"tail",
"-f",
"/dev/null",
],
"environment": {
"PUBLIC_HOST": c.JupyterHub.bind_url,
"JHUB_JUPYTERHUB_CONFIG": japps_config.jupyterhub_config_path,

# Temp environment variables for Nebari Deployment
"PROXY_API_SERVICE_PORT": "*",
"HUB_SERVICE_PORT": "*",
},
},
]
)

services_roles = [
{
"name": "japps-service-role", # name the role
"services": [
"japps", # assign the service to this role
# assign the services to this role,
FASTAPI_SERVICE_NAME,
STARTUP_APPS_SERVICE_NAME,
],
"scopes": [
# declare what permissions the service should have
Expand Down
35 changes: 18 additions & 17 deletions jhub_apps/hub_client/hub_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,19 +122,27 @@ def get_user(self, user=None):
return user

@requires_user_token
def get_server(self, username, servername):
def get_server(self, username, servername=None) -> typing.Optional[typing.Union[dict, typing.Iterable[dict]]]:
"""Returns the given server for the given user or all servers if servername is None"""
users = self.get_users()
filter_given_user = [user for user in users if user["name"] == username]
if not filter_given_user:
logger.info(f"No user with username: {username} found.")
return
else:
assert len(filter_given_user) == 1
given_user = filter_given_user[0]
for name, server in given_user["servers"].items():
if name == servername:
return server

if servername:
for name, server in given_user["servers"].items():
if name == servername:
return server
else:
# return all user servers
return given_user["servers"]

def normalize_server_name(self, servername):
@staticmethod
def normalize_server_name(servername):
# Convert text to lowercase
text = servername.lower()
# Remove all special characters except spaces and hyphen
Expand Down Expand Up @@ -164,17 +172,10 @@ def start_server(self, username, servername):
logger.info("Start server response", status_code=response.status_code, servername=servername)
return response

def _get_user_servers(self, username: str) -> typing.Dict:
users = self.get_users()
user_data = [user for user in users if user["name"] == username]
assert len(user_data) == 1
user_servers = user_data[0]["servers"]
return user_servers

@requires_user_token
def create_server(self, username: str, servername: str, user_options: UserOptions = None):
def create_server(self, username: str, servername: str, user_options: UserOptions = None) -> tuple[int, str]:
logger.info("Creating new server", user=username)
user_servers = self._get_user_servers(username)
user_servers = self.get_server(username)
normalized_servername = self.normalize_server_name(servername)
logger.info("User servers", user_servers=user_servers.keys())
# If server with the given name already exists
Expand All @@ -189,7 +190,7 @@ def create_server(self, username: str, servername: str, user_options: UserOption
return self._create_server(username, unique_servername, user_options)

@requires_user_token
def edit_server(self, username: str, servername: str, user_options: UserOptions = None):
def edit_server(self, username: str, servername: str, user_options: UserOptions = None) -> tuple[int, str]:
logger.info("Editing server", server_name=servername)
server = self.get_server(username, servername)
if server:
Expand All @@ -201,7 +202,7 @@ def edit_server(self, username: str, servername: str, user_options: UserOptions
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: str, servername: str, user_options: UserOptions = None):
def _create_server(self, username: str, servername: str, user_options: UserOptions = None) -> tuple[int, str]:
url = f"/users/{username}/servers/{servername}"
params = user_options.model_dump()
data = {"name": servername, **params}
Expand Down Expand Up @@ -307,7 +308,7 @@ def get_shared_servers(self, username: str = None):
return shared_servers

@requires_user_token
def delete_server(self, username, server_name, remove=False):
def delete_server(self, username, server_name, remove=False) -> int:
if server_name is None:
# Default server and not named server
server_name = ""
Expand Down
4 changes: 4 additions & 0 deletions jhub_apps/service/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
from jhub_apps.service.middlewares import create_middlewares
from jhub_apps.service.routes import router
from jhub_apps.version import get_version
import structlog

logger = structlog.get_logger(__name__)

### When managed by Jupyterhub, the actual endpoints
### will be served out prefixed by /services/:name.
Expand All @@ -17,6 +20,7 @@

STATIC_DIR = Path(__file__).parent.parent / "static"


app = FastAPI(
title="JApps Service",
version=str(get_version()),
Expand Down
15 changes: 13 additions & 2 deletions jhub_apps/service/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

from pydantic import BaseModel


# https://jupyterhub.readthedocs.io/en/stable/_static/rest-api/index.html
class Server(BaseModel):
name: str
Expand Down Expand Up @@ -80,12 +79,24 @@ class JHubAppConfig(BaseModel):


class UserOptions(JHubAppConfig):
jhub_app: bool
conda_env: typing.Optional[str] = str()
profile: typing.Optional[str] = str()
share_with: typing.Optional[SharePermissions] = None
jhub_app: bool


class ServerCreation(BaseModel):
servername: str
user_options: UserOptions

@property
def normalized_servername(self):
from jhub_apps.hub_client.hub_client import HubClient
return HubClient.normalize_server_name(self.servername)

class JHubAppUserOptions(UserOptions):
jhub_app: typing.Literal[True] = True

class StartupApp(ServerCreation):
username: str
user_options: JHubAppUserOptions
Loading
Loading