mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 10:04:07 +00:00
jupyterhub-singleuser as a Jupyter Server 2.0 extension
mostly a copy (fork) of singleuser app using public APIs instead of lots of patching. opt-in via `JUPYTERHUB_SINGLEUSER_EXTENSION=1` related changes: - stop running a test single-user server in a thread. It's complicated and fragile. Instead, run it normally, and get the info we need from a custom handler registered via an extension via the `full_spawn` fixture
This commit is contained in:
9
.github/workflows/test.yml
vendored
9
.github/workflows/test.yml
vendored
@@ -92,6 +92,8 @@ jobs:
|
||||
selenium: selenium
|
||||
- python: "3.11"
|
||||
main_dependencies: main_dependencies
|
||||
- python: "3.10"
|
||||
serverextension: serverextension
|
||||
|
||||
steps:
|
||||
# NOTE: In GitHub workflows, environment variables are set by writing
|
||||
@@ -115,8 +117,8 @@ jobs:
|
||||
echo "PGPASSWORD=hub[test/:?" >> $GITHUB_ENV
|
||||
echo "JUPYTERHUB_TEST_DB_URL=postgresql://test_user:hub%5Btest%2F%3A%3F@127.0.0.1:5432/jupyterhub" >> $GITHUB_ENV
|
||||
fi
|
||||
if [ "${{ matrix.jupyter_server }}" != "" ]; then
|
||||
echo "JUPYTERHUB_SINGLEUSER_APP=jupyterhub.tests.mockserverapp.MockServerApp" >> $GITHUB_ENV
|
||||
if [ "${{ matrix.serverextension }}" != "" ]; then
|
||||
echo "JUPYTERHUB_SINGLEUSER_EXTENSION=1" >> $GITHUB_ENV
|
||||
fi
|
||||
- uses: actions/checkout@v3
|
||||
# NOTE: actions/setup-node@v3 make use of a cache within the GitHub base
|
||||
@@ -165,6 +167,9 @@ jobs:
|
||||
if [ "${{ matrix.db }}" == "postgres" ]; then
|
||||
pip install psycopg2-binary
|
||||
fi
|
||||
if [ "${{ matrix.serverextension }}" != "" ]; then
|
||||
pip install 'jupyter-server>=2'
|
||||
fi
|
||||
|
||||
pip freeze
|
||||
|
||||
|
25
examples/read-only/README.md
Normal file
25
examples/read-only/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Granting read-only access to user servers
|
||||
|
||||
Jupyter Server 2.0 adds the ability to enforce granular permissions via its Authorizer API.
|
||||
Combining this with JupyterHub's custom scopes and singleuser-server extension, you can use JupyterHub to grant users restricted (e.g. read-only) access to each others' servers,
|
||||
rather than an all-or-nothing access permission.
|
||||
|
||||
This example demonstrates granting read-only access to just one specific user's server to one specific other user,
|
||||
but you can grant access for groups, all users, etc.
|
||||
Given users `vex` and `percy`, we want `vex` to have permission to:
|
||||
|
||||
1. read and open files, and view the state of the server, but
|
||||
2. not write or edit files
|
||||
3. not start/stop the server
|
||||
4. not execute anything
|
||||
|
||||
(Jupyter Server's Authorizer API allows for even more fine-grained control)
|
||||
|
||||
To test this, you'll want two browser sessions:
|
||||
|
||||
1. login as `percy` (dummy auth means username and no password needed) and start the server
|
||||
2. in another session (another browser, or logout as percy), login as `vex` (again, any password in the example)
|
||||
3. as vex, visit http://127.0.0.1:8000/users/percy/
|
||||
|
||||
Percy can use their server as normal, but vex will only be able to read files.
|
||||
Vex won't be able to run any code, connect to kernels, or save edits to files.
|
36
examples/read-only/jupyter_server_config.py
Normal file
36
examples/read-only/jupyter_server_config.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import os
|
||||
|
||||
from jupyterhub.singleuser.extension import JupyterHubAuthorizer
|
||||
|
||||
|
||||
class GranularJupyterHubAuthorizer(JupyterHubAuthorizer):
|
||||
"""Authorizer that looks for permissions in JupyterHub scopes"""
|
||||
|
||||
def is_authorized(self, handler, user, action, resource):
|
||||
# authorize if any of these permissions are present
|
||||
# filters check for access to this specific user or server
|
||||
# group filters aren't available!
|
||||
filters = [
|
||||
f"!user={os.environ['JUPYTERHUB_USER']}",
|
||||
f"!server={os.environ['JUPYTERHUB_USER']}/{os.environ['JUPYTERHUB_SERVER_NAME']}",
|
||||
]
|
||||
required_scopes = set()
|
||||
for f in filters:
|
||||
required_scopes.update(
|
||||
{
|
||||
f"custom:jupyter_server:{action}:{resource}{f}",
|
||||
f"custom:jupyter_server:{action}:*{f}",
|
||||
}
|
||||
)
|
||||
|
||||
have_scopes = self.hub_auth.check_scopes(required_scopes, user.hub_user)
|
||||
self.log.debug(
|
||||
f"{user.username} has permissions {have_scopes} required to {action} on {resource}"
|
||||
)
|
||||
return bool(have_scopes)
|
||||
|
||||
|
||||
c = get_config() # noqa
|
||||
|
||||
|
||||
c.ServerApp.authorizer_class = GranularJupyterHubAuthorizer
|
95
examples/read-only/jupyterhub_config.py
Normal file
95
examples/read-only/jupyterhub_config.py
Normal file
@@ -0,0 +1,95 @@
|
||||
c = get_config() # noqa
|
||||
|
||||
# define custom scopes so they can be assigned to users
|
||||
# these could be
|
||||
|
||||
c.JupyterHub.custom_scopes = {
|
||||
"custom:jupyter_server:read:*": {
|
||||
"description": "read-only access to your server",
|
||||
},
|
||||
"custom:jupyter_server:write:*": {
|
||||
"description": "access to modify files on your server. Does not include execution.",
|
||||
"subscopes": ["custom:jupyter_server:read:*"],
|
||||
},
|
||||
"custom:jupyter_server:execute:*": {
|
||||
"description": "Execute permissions on servers.",
|
||||
"subscopes": [
|
||||
"custom:jupyter_server:write:*",
|
||||
"custom:jupyter_server:read:*",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
c.JupyterHub.load_roles = [
|
||||
# grant specific users read-only access to all servers
|
||||
{
|
||||
"name": "read-only-all",
|
||||
"scopes": [
|
||||
"access:servers",
|
||||
"custom:jupyter_server:read:*",
|
||||
],
|
||||
"groups": ["read-only"],
|
||||
},
|
||||
{
|
||||
"name": "read-only-read-only-percy",
|
||||
"scopes": [
|
||||
"access:servers!user=percy",
|
||||
"custom:jupyter_server:read:*!user=percy",
|
||||
],
|
||||
"users": ["vex"],
|
||||
},
|
||||
{
|
||||
"name": "admin-ui",
|
||||
"scopes": [
|
||||
"admin-ui",
|
||||
"list:users",
|
||||
"admin:servers",
|
||||
],
|
||||
"users": ["admin"],
|
||||
},
|
||||
{
|
||||
"name": "full-access",
|
||||
"scopes": [
|
||||
"access:servers",
|
||||
"custom:jupyter_server:execute:*",
|
||||
],
|
||||
"users": ["minrk"],
|
||||
},
|
||||
# all users have full access to their own servers
|
||||
{
|
||||
"name": "user",
|
||||
"scopes": [
|
||||
"custom:jupyter_server:execute:*!user",
|
||||
"custom:jupyter_server:read:*!user",
|
||||
"self",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# servers request access to themselves
|
||||
|
||||
c.Spawner.oauth_client_allowed_scopes = [
|
||||
"access:servers!server",
|
||||
"custom:jupyter_server:read:*!server",
|
||||
"custom:jupyter_server:execute:*!server",
|
||||
]
|
||||
|
||||
# enable the jupyter-server extension
|
||||
c.Spawner.environment = {
|
||||
"JUPYTERHUB_SINGLEUSER_EXTENSION": "1",
|
||||
}
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
here = Path(__file__).parent.resolve()
|
||||
|
||||
# load the server config that enables granular permissions
|
||||
c.Spawner.args = [
|
||||
f"--config={here}/jupyter_server_config.py",
|
||||
]
|
||||
|
||||
|
||||
# example boilerplate: dummy auth/spawner
|
||||
c.JupyterHub.authenticator_class = 'dummy'
|
||||
c.JupyterHub.spawner_class = 'simple'
|
||||
c.JupyterHub.ip = '127.0.0.1'
|
@@ -1,3 +1,10 @@
|
||||
from ._version import __version__, version_info
|
||||
|
||||
|
||||
def _jupyter_server_extension_points():
|
||||
from .singleuser.extension import JupyterHubSingleUser
|
||||
|
||||
return [{"module": "jupyterhub", "app": JupyterHubSingleUser}]
|
||||
|
||||
|
||||
__all__ = ["__version__", "version_info"]
|
||||
|
@@ -243,7 +243,6 @@ class HubAuth(SingletonConfigurable):
|
||||
return 'http://127.0.0.1:8081' + url_path_join(self.hub_prefix, 'api')
|
||||
|
||||
api_token = Unicode(
|
||||
os.getenv('JUPYTERHUB_API_TOKEN', ''),
|
||||
help="""API key for accessing Hub API.
|
||||
|
||||
Default: $JUPYTERHUB_API_TOKEN
|
||||
@@ -253,6 +252,10 @@ class HubAuth(SingletonConfigurable):
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
@default("api_token")
|
||||
def _default_api_token(self):
|
||||
return os.getenv('JUPYTERHUB_API_TOKEN', '')
|
||||
|
||||
hub_prefix = Unicode(
|
||||
'/hub/',
|
||||
help="""The URL prefix for the Hub itself.
|
||||
|
@@ -2,17 +2,44 @@
|
||||
|
||||
Contains default notebook-app subclass and mixins
|
||||
"""
|
||||
from .app import SingleUserNotebookApp, main
|
||||
import os
|
||||
|
||||
from .mixins import HubAuthenticatedHandler, make_singleuser_app
|
||||
|
||||
if os.environ.get("JUPYTERHUB_SINGLEUSER_EXTENSION", "") not in ("", "0"):
|
||||
# check for conflict in singleuser entrypoint environment variables
|
||||
if os.environ.get("JUPYTERHUB_SINGLEUSER_APP", "") not in {
|
||||
"",
|
||||
"jupyter_server",
|
||||
"jupyter-server",
|
||||
"extension",
|
||||
"jupyter_server.serverapp.ServerApp",
|
||||
}:
|
||||
ext = os.environ["JUPYTERHUB_SINGLEUSER_EXTENSION"]
|
||||
app = os.environ["JUPYTERHUB_SINGLEUSER_APP"]
|
||||
raise ValueError(
|
||||
f"Cannot use JUPYTERHUB_SINGLEUSER_EXTENSION=1 with JUPYTERHUB_SINGLEUSER_APP={app}."
|
||||
" Please pick one or the other."
|
||||
)
|
||||
_as_extension = True
|
||||
from .extension import main
|
||||
else:
|
||||
_as_extension = False
|
||||
try:
|
||||
from .app import SingleUserNotebookApp, main
|
||||
except ImportError:
|
||||
# check for Jupyter Server 2.0 ?
|
||||
from .extension import main
|
||||
else:
|
||||
# backward-compatibility
|
||||
JupyterHubLoginHandler = SingleUserNotebookApp.login_handler_class
|
||||
JupyterHubLogoutHandler = SingleUserNotebookApp.logout_handler_class
|
||||
OAuthCallbackHandler = SingleUserNotebookApp.oauth_callback_handler_class
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SingleUserNotebookApp",
|
||||
"main",
|
||||
"HubAuthenticatedHandler",
|
||||
"make_singleuser_app",
|
||||
]
|
||||
|
||||
# backward-compatibility
|
||||
JupyterHubLoginHandler = SingleUserNotebookApp.login_handler_class
|
||||
JupyterHubLogoutHandler = SingleUserNotebookApp.logout_handler_class
|
||||
OAuthCallbackHandler = SingleUserNotebookApp.oauth_callback_handler_class
|
||||
|
@@ -1,4 +1,4 @@
|
||||
from .app import main
|
||||
from . import main
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
@@ -14,8 +14,18 @@ from traitlets import import_item
|
||||
|
||||
from .mixins import make_singleuser_app
|
||||
|
||||
JUPYTERHUB_SINGLEUSER_APP = os.environ.get("JUPYTERHUB_SINGLEUSER_APP")
|
||||
JUPYTERHUB_SINGLEUSER_APP = os.environ.get("JUPYTERHUB_SINGLEUSER_APP", "")
|
||||
|
||||
# allow shortcut references
|
||||
_app_shortcuts = {
|
||||
"notebook": "notebook.notebookapp.NotebookApp",
|
||||
"jupyter-server": "jupyter_server.serverapp.ServerApp",
|
||||
"extension": "jupyter_server.serverapp.ServerApp",
|
||||
}
|
||||
|
||||
JUPYTERHUB_SINGLEUSER_APP = _app_shortcuts.get(
|
||||
JUPYTERHUB_SINGLEUSER_APP.replace("_", "-"), JUPYTERHUB_SINGLEUSER_APP
|
||||
)
|
||||
|
||||
if JUPYTERHUB_SINGLEUSER_APP:
|
||||
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
|
||||
|
659
jupyterhub/singleuser/extension.py
Normal file
659
jupyterhub/singleuser/extension.py
Normal file
@@ -0,0 +1,659 @@
|
||||
"""
|
||||
Integrate JupyterHub auth with Jupyter Server as an Extension
|
||||
|
||||
Requires Jupyter Server 2.0, which in turn requires Python 3.7
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
from datetime import timezone
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from jupyter_core import paths
|
||||
from jupyter_server.auth import Authorizer, IdentityProvider, User
|
||||
from jupyter_server.auth.logout import LogoutHandler
|
||||
from jupyter_server.extension.application import ExtensionApp
|
||||
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
|
||||
from tornado.httputil import url_concat
|
||||
from tornado.web import HTTPError
|
||||
from traitlets import Any, Bool, Instance, Integer, Unicode, default
|
||||
|
||||
from jupyterhub._version import __version__, _check_version
|
||||
from jupyterhub.services.auth import HubOAuth, HubOAuthCallbackHandler
|
||||
from jupyterhub.utils import (
|
||||
exponential_backoff,
|
||||
isoformat,
|
||||
make_ssl_context,
|
||||
url_path_join,
|
||||
)
|
||||
|
||||
SINGLEUSER_TEMPLATES_DIR = str(Path(__file__).parent.resolve().joinpath("templates"))
|
||||
|
||||
|
||||
def _bool_env(key):
|
||||
"""Cast an environment variable to bool
|
||||
|
||||
0, empty, or unset is False; All other values are True.
|
||||
"""
|
||||
if os.environ.get(key, "") in {"", "0"}:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def _exclude_home(path_list):
|
||||
"""Filter out any entries in a path list that are in my home directory.
|
||||
|
||||
Used to disable per-user configuration.
|
||||
"""
|
||||
home = os.path.expanduser('~/')
|
||||
for p in path_list:
|
||||
if not p.startswith(home):
|
||||
yield p
|
||||
|
||||
|
||||
class JupyterHubLogoutHandler(LogoutHandler):
|
||||
def get(self):
|
||||
hub_auth = self.identity_provider.hub_auth
|
||||
# clear single-user cookie
|
||||
hub_auth.clear_cookie(self)
|
||||
# redirect to hub to clear the rest
|
||||
self.redirect(hub_auth.hub_host + url_path_join(hub_auth.hub_prefix, "logout"))
|
||||
|
||||
|
||||
class JupyterHubUser(User):
|
||||
"""Subclass jupyter_server User to store JupyterHub user info"""
|
||||
|
||||
# not dataclass fields,
|
||||
# so these aren't returned in the identity model via the REST API.
|
||||
# The could be, though!
|
||||
hub_user: dict
|
||||
|
||||
def __init__(self, hub_user):
|
||||
self.hub_user = hub_user
|
||||
super().__init__(username=self.hub_user["name"])
|
||||
|
||||
|
||||
class JupyterHubOAuthCallbackHandler(HubOAuthCallbackHandler):
|
||||
"""Callback handler for completing OAuth with JupyterHub"""
|
||||
|
||||
def initialize(self, hub_auth):
|
||||
self.hub_auth = hub_auth
|
||||
|
||||
|
||||
class JupyterHubIdentityProvider(IdentityProvider):
|
||||
"""Identity Provider for JupyterHub OAuth
|
||||
|
||||
Replacement for JupyterHub's HubAuthenticated mixin
|
||||
"""
|
||||
|
||||
logout_handler_class = JupyterHubLogoutHandler
|
||||
|
||||
hub_auth = Instance(HubOAuth)
|
||||
|
||||
@property
|
||||
def token(self):
|
||||
return self.hub_auth.api_token
|
||||
|
||||
token_generated = False
|
||||
|
||||
@default("hub_auth")
|
||||
def _default_hub_auth(self):
|
||||
# HubAuth gets most of its config from the environment
|
||||
return HubOAuth(parent=self)
|
||||
|
||||
def _patch_get_login_url(self, handler):
|
||||
original_get_login_url = handler.get_login_url
|
||||
|
||||
def get_login_url():
|
||||
"""Return the Hub's login URL, to begin login redirect"""
|
||||
login_url = self.hub_auth.login_url
|
||||
# add state argument to OAuth url
|
||||
state = self.hub_auth.set_state_cookie(
|
||||
handler, next_url=handler.request.uri
|
||||
)
|
||||
login_url = url_concat(login_url, {'state': state})
|
||||
# temporary override at setting level,
|
||||
# to allow any subclass overrides of get_login_url to preserve their effect;
|
||||
# for example, APIHandler raises 403 to prevent redirects
|
||||
with mock.patch.dict(
|
||||
handler.application.settings, {"login_url": login_url}
|
||||
):
|
||||
self.log.debug("Redirecting to login url: %s", login_url)
|
||||
return original_get_login_url()
|
||||
|
||||
handler.get_login_url = get_login_url
|
||||
|
||||
async def get_user(self, handler):
|
||||
if hasattr(handler, "_jupyterhub_user"):
|
||||
return handler._jupyterhub_user
|
||||
self._patch_get_login_url(handler)
|
||||
user = await self.hub_auth.get_user(handler, sync=False)
|
||||
if user is None:
|
||||
handler._jupyterhub_user = None
|
||||
return None
|
||||
# check access scopes - don't allow even authenticated
|
||||
# users with no access to this service past this stage.
|
||||
self.log.debug(
|
||||
f"Checking user {user['name']} with scopes {user['scopes']} against {self.hub_auth.access_scopes}"
|
||||
)
|
||||
scopes = self.hub_auth.check_scopes(self.hub_auth.access_scopes, user)
|
||||
if scopes:
|
||||
self.log.debug(f"Allowing user {user['name']} with scopes {scopes}")
|
||||
else:
|
||||
self.log.warning(f"Not allowing user {user['name']}")
|
||||
# User is authenticated, but not authorized.
|
||||
# Override redirect so if/when tornado @web.authenticated
|
||||
# tries to redirect to login URL, 403 will be raised instead.
|
||||
# This is not the best, but avoids problems that can be caused
|
||||
# when get_current_user is allowed to raise,
|
||||
# and avoids redirect loops for users who are logged it,
|
||||
# but not allowed to access this resource.
|
||||
def raise_on_redirect(*args, **kwargs):
|
||||
raise HTTPError(403, "{kind} {name} is not allowed.".format(**user))
|
||||
|
||||
handler.redirect = raise_on_redirect
|
||||
|
||||
return None
|
||||
handler._jupyterhub_user = JupyterHubUser(user)
|
||||
return handler._jupyterhub_user
|
||||
|
||||
def get_handlers(self):
|
||||
"""Register our OAuth callback handler"""
|
||||
return [
|
||||
("/logout", self.logout_handler_class),
|
||||
(
|
||||
"/oauth_callback",
|
||||
JupyterHubOAuthCallbackHandler,
|
||||
{"hub_auth": self.hub_auth},
|
||||
),
|
||||
]
|
||||
|
||||
def validate_security(self, app, ssl_options=None):
|
||||
"""Prevent warnings about security from base class"""
|
||||
return
|
||||
|
||||
def page_config_hook(self, handler, page_config):
|
||||
"""JupyterLab page config hook
|
||||
|
||||
Adds JupyterHub info to page config.
|
||||
|
||||
Places the JupyterHub API token in PageConfig.token.
|
||||
|
||||
Only has effect on jupyterlab_server >=2.9
|
||||
"""
|
||||
user = handler.current_user
|
||||
# originally implemented in jupyterlab's LabApp
|
||||
page_config["hubUser"] = user.name if user else ""
|
||||
page_config["hubPrefix"] = hub_prefix = self.hub_auth.hub_prefix
|
||||
page_config["hubHost"] = self.hub_auth.hub_host
|
||||
page_config["shareUrl"] = url_path_join(hub_prefix, "user-redirect")
|
||||
page_config["hubServerName"] = os.environ.get("JUPYTERHUB_SERVER_NAME", "")
|
||||
page_config["token"] = self.hub_auth.get_token(handler) or ""
|
||||
return page_config
|
||||
|
||||
|
||||
class JupyterHubAuthorizer(Authorizer):
|
||||
"""Authorizer that looks for permissions in JupyterHub scopes"""
|
||||
|
||||
# TODO: https://github.com/jupyter-server/jupyter_server/pull/830
|
||||
hub_auth = Instance(HubOAuth)
|
||||
|
||||
@default("hub_auth")
|
||||
def _default_hub_auth(self):
|
||||
# HubAuth gets most of its config from the environment
|
||||
return HubOAuth(parent=self)
|
||||
|
||||
def is_authorized(self, handler, user, action, resource):
|
||||
# This is where we would implement granular scope checks,
|
||||
# but until then,
|
||||
# since the IdentityProvider doesn't allow users without access scopes,
|
||||
# there's no further check to make.
|
||||
# This scope check is redundant
|
||||
have_scopes = self.hub_auth.check_scopes(
|
||||
self.hub_auth.oauth_scopes, user.hub_user
|
||||
)
|
||||
self.log.debug(
|
||||
f"{user.username} has permissions {have_scopes} required to {action} on {resource}"
|
||||
)
|
||||
return bool(have_scopes)
|
||||
|
||||
|
||||
def _fatal_errors(f):
|
||||
"""Decorator to make errors fatal to the server app
|
||||
|
||||
Ensures our extension is loaded or the server exits,
|
||||
rather than starting a server without jupyterhub auth enabled.
|
||||
"""
|
||||
|
||||
@wraps(f)
|
||||
def wrapped(self, *args, **kwargs):
|
||||
try:
|
||||
r = f(self, *args, **kwargs)
|
||||
except Exception:
|
||||
self.log.exception("Failed to load JupyterHubSingleUser server extension")
|
||||
self.exit(1)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
# patches
|
||||
_original_jupyter_paths = None
|
||||
_jupyter_paths_without_home = None
|
||||
|
||||
|
||||
class JupyterHubSingleUser(ExtensionApp):
|
||||
"""Jupyter Server extension entrypoint.
|
||||
|
||||
Enables JupyterHub authentication
|
||||
and some JupyterHub-specific configuration from environment variables
|
||||
|
||||
Server extensions are loaded before the rest of the server is set up
|
||||
"""
|
||||
|
||||
name = app_namespace = "jupyterhub-singleuser"
|
||||
version = __version__
|
||||
load_other_extensions = os.environ.get(
|
||||
"JUPYTERHUB_SINGLEUSER_LOAD_OTHER_EXTENSIONS", "1"
|
||||
) not in {"", "0"}
|
||||
|
||||
# Most of this is _copied_ from the SingleUserNotebookApp mixin,
|
||||
# which will be deprecated over time
|
||||
# (i.e. once we can _require_ jupyter server 2.0)
|
||||
|
||||
# this is a _class_ attribute to deal with the lifecycle
|
||||
# of when it's loaded vs when it's checked
|
||||
disable_user_config = False
|
||||
|
||||
hub_auth = Instance(HubOAuth)
|
||||
|
||||
@default("hub_auth")
|
||||
def _default_hub_auth(self):
|
||||
# HubAuth gets most of its config from the environment
|
||||
return HubOAuth(parent=self)
|
||||
|
||||
# create dynamic default http client,
|
||||
# configured with any relevant ssl config
|
||||
hub_http_client = Any()
|
||||
|
||||
@default('hub_http_client')
|
||||
def _default_client(self):
|
||||
ssl_context = make_ssl_context(
|
||||
self.hub_auth.keyfile,
|
||||
self.hub_auth.certfile,
|
||||
cafile=self.hub_auth.client_ca,
|
||||
)
|
||||
AsyncHTTPClient.configure(None, defaults={"ssl_options": ssl_context})
|
||||
return AsyncHTTPClient()
|
||||
|
||||
async def check_hub_version(self):
|
||||
"""Test a connection to my Hub
|
||||
|
||||
- exit if I can't connect at all
|
||||
- check version and warn on sufficient mismatch
|
||||
"""
|
||||
client = self.hub_http_client
|
||||
RETRIES = 5
|
||||
for i in range(1, RETRIES + 1):
|
||||
try:
|
||||
resp = await client.fetch(self.hub_api_url)
|
||||
except Exception:
|
||||
self.log.exception(
|
||||
"Failed to connect to my Hub at %s (attempt %i/%i). Is it running?",
|
||||
self.hub_api_url,
|
||||
i,
|
||||
RETRIES,
|
||||
)
|
||||
await asyncio.sleep(min(2**i, 16))
|
||||
else:
|
||||
break
|
||||
else:
|
||||
self.exit(1)
|
||||
|
||||
hub_version = resp.headers.get('X-JupyterHub-Version')
|
||||
_check_version(hub_version, __version__, self.log)
|
||||
|
||||
server_name = Unicode()
|
||||
|
||||
@default('server_name')
|
||||
def _server_name_default(self):
|
||||
return os.environ.get('JUPYTERHUB_SERVER_NAME', '')
|
||||
|
||||
hub_activity_url = Unicode(
|
||||
config=True, help="URL for sending JupyterHub activity updates"
|
||||
)
|
||||
|
||||
@default('hub_activity_url')
|
||||
def _default_activity_url(self):
|
||||
return os.environ.get('JUPYTERHUB_ACTIVITY_URL', '')
|
||||
|
||||
hub_activity_interval = Integer(
|
||||
300,
|
||||
config=True,
|
||||
help="""
|
||||
Interval (in seconds) on which to update the Hub
|
||||
with our latest activity.
|
||||
""",
|
||||
)
|
||||
|
||||
@default('hub_activity_interval')
|
||||
def _default_activity_interval(self):
|
||||
env_value = os.environ.get('JUPYTERHUB_ACTIVITY_INTERVAL')
|
||||
if env_value:
|
||||
return int(env_value)
|
||||
else:
|
||||
return 300
|
||||
|
||||
_last_activity_sent = Any(allow_none=True)
|
||||
|
||||
async def notify_activity(self):
|
||||
"""Notify jupyterhub of activity"""
|
||||
client = self.hub_http_client
|
||||
last_activity = self.web_app.last_activity()
|
||||
if not last_activity:
|
||||
self.log.debug("No activity to send to the Hub")
|
||||
return
|
||||
if last_activity:
|
||||
# protect against mixed timezone comparisons
|
||||
if not last_activity.tzinfo:
|
||||
# assume naive timestamps are utc
|
||||
self.log.warning("last activity is using naive timestamps")
|
||||
last_activity = last_activity.replace(tzinfo=timezone.utc)
|
||||
|
||||
if self._last_activity_sent and last_activity < self._last_activity_sent:
|
||||
self.log.debug("No activity since %s", self._last_activity_sent)
|
||||
return
|
||||
|
||||
last_activity_timestamp = isoformat(last_activity)
|
||||
|
||||
async def notify():
|
||||
self.log.debug("Notifying Hub of activity %s", last_activity_timestamp)
|
||||
req = HTTPRequest(
|
||||
url=self.hub_activity_url,
|
||||
method='POST',
|
||||
headers={
|
||||
"Authorization": f"token {self.hub_auth.api_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body=json.dumps(
|
||||
{
|
||||
'servers': {
|
||||
self.server_name: {'last_activity': last_activity_timestamp}
|
||||
},
|
||||
'last_activity': last_activity_timestamp,
|
||||
}
|
||||
),
|
||||
)
|
||||
try:
|
||||
await client.fetch(req)
|
||||
except Exception:
|
||||
self.log.exception("Error notifying Hub of activity")
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
await exponential_backoff(
|
||||
notify,
|
||||
fail_message="Failed to notify Hub of activity",
|
||||
start_wait=1,
|
||||
max_wait=15,
|
||||
timeout=60,
|
||||
)
|
||||
self._last_activity_sent = last_activity
|
||||
|
||||
async def keep_activity_updated(self):
|
||||
if not self.hub_activity_url or not self.hub_activity_interval:
|
||||
self.log.warning("Activity events disabled")
|
||||
return
|
||||
self.log.info(
|
||||
"Updating Hub with activity every %s seconds", self.hub_activity_interval
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
await self.notify_activity()
|
||||
except Exception as e:
|
||||
self.log.exception("Error notifying Hub of activity")
|
||||
# add 20% jitter to the interval to avoid alignment
|
||||
# of lots of requests from user servers
|
||||
t = self.hub_activity_interval * (1 + 0.2 * (random.random() - 0.5))
|
||||
await asyncio.sleep(t)
|
||||
|
||||
def _log_app_versions(self):
|
||||
"""Log application versions at startup
|
||||
|
||||
Logs versions of jupyterhub and singleuser-server base versions (jupyterlab, jupyter_server, notebook)
|
||||
"""
|
||||
self.log.info(
|
||||
f"Starting jupyterhub single-user server extension version {__version__}"
|
||||
)
|
||||
|
||||
@_fatal_errors
|
||||
def load_config_file(self):
|
||||
"""Load JupyterHub singleuser config from the environment"""
|
||||
self._log_app_versions()
|
||||
if not os.environ.get('JUPYTERHUB_SERVICE_URL'):
|
||||
raise KeyError("Missing required environment $JUPYTERHUB_SERVICE_URL")
|
||||
|
||||
cfg = self.config.ServerApp
|
||||
cfg.identity_provider_class = JupyterHubIdentityProvider
|
||||
|
||||
# disable some single-user features
|
||||
cfg.open_browser = False
|
||||
cfg.trust_xheaders = True
|
||||
cfg.quit_button = False
|
||||
cfg.port_retries = 0
|
||||
cfg.answer_yes = True
|
||||
self.config.FileContentsManager.delete_to_trash = False
|
||||
|
||||
# load http server config from environment
|
||||
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
||||
if url.port:
|
||||
cfg.port = url.port
|
||||
elif url.scheme == 'http':
|
||||
cfg.port = 80
|
||||
elif url.scheme == 'https':
|
||||
cfg.port = 443
|
||||
if url.hostname:
|
||||
cfg.ip = url.hostname
|
||||
else:
|
||||
cfg.ip = "127.0.0.1"
|
||||
|
||||
cfg.base_url = os.environ.get('JUPYTERHUB_SERVICE_PREFIX') or '/'
|
||||
|
||||
# load default_url at all kinds of priority,
|
||||
# to make sure it has the desired effect
|
||||
cfg.default_url = self.default_url = self.get_default_url()
|
||||
|
||||
# Jupyter Server default: config files have higher priority than extensions,
|
||||
# by:
|
||||
# 1. load config files
|
||||
# 2. load extension config
|
||||
# 3. merge file config into extension config
|
||||
|
||||
# we invert that by merging our extension config into server config before
|
||||
# they get merged the other way
|
||||
# this way config from this extension should always have highest priority
|
||||
self.serverapp.update_config(self.config)
|
||||
|
||||
# add our custom templates
|
||||
self.config.NotebookApp.extra_template_paths.append(SINGLEUSER_TEMPLATES_DIR)
|
||||
|
||||
@default("default_url")
|
||||
def get_default_url(self):
|
||||
# 1. explicit via _user_ config (?)
|
||||
if 'default_url' in self.serverapp.config.ServerApp:
|
||||
default_url = self.serverapp.config.ServerApp.default_url
|
||||
self.log.info(f"Using default url from user config: {default_url}")
|
||||
return default_url
|
||||
|
||||
# 2. explicit via JupyterHub admin config (c.Spawner.default_url)
|
||||
default_url = os.environ.get("JUPYTERHUB_DEFAULT_URL")
|
||||
if default_url:
|
||||
self.log.info(
|
||||
f"Using default url from environment $JUPYTERHUB_DEFAULT_URL: {default_url}"
|
||||
)
|
||||
return default_url
|
||||
|
||||
# 3. look for known UI extensions
|
||||
# priority:
|
||||
# 1. lab
|
||||
# 2. nbclassic
|
||||
# 3. retro
|
||||
|
||||
extension_points = self.serverapp.extension_manager.extension_points
|
||||
for name in ["lab", "retro", "nbclassic"]:
|
||||
if name in extension_points:
|
||||
default_url = extension_points[name].app.default_url
|
||||
if default_url and default_url != "/":
|
||||
self.log.info(
|
||||
f"Using default url from server extension {name}: {default_url}"
|
||||
)
|
||||
return default_url
|
||||
|
||||
self.log.warning(
|
||||
"No default url found in config or known extensions, searching other extensions for default_url"
|
||||
)
|
||||
# 3. _any_ UI extension
|
||||
# 2. discover other extensions
|
||||
for (
|
||||
name,
|
||||
extension_point,
|
||||
) in extension_points.items():
|
||||
app = extension_point.app
|
||||
if app is self or not app:
|
||||
continue
|
||||
default_url = app.default_url
|
||||
if default_url and default_url != "/":
|
||||
self.log.info(
|
||||
f"Using default url from server extension {name}: {default_url}"
|
||||
)
|
||||
return default_url
|
||||
|
||||
self.log.warning(
|
||||
"Found no extension with a default URL, UI will likely be unavailable"
|
||||
)
|
||||
return "/"
|
||||
|
||||
def initialize_templates(self):
|
||||
"""Patch classic-noteboook page templates to add Hub-related buttons"""
|
||||
|
||||
app = self.serverapp
|
||||
|
||||
jinja_template_vars = app.jinja_template_vars
|
||||
|
||||
# override template vars
|
||||
jinja_template_vars['logo_url'] = self.hub_auth.hub_host + url_path_join(
|
||||
self.hub_auth.hub_prefix, 'logo'
|
||||
)
|
||||
jinja_template_vars[
|
||||
'hub_control_panel_url'
|
||||
] = self.hub_auth.hub_host + url_path_join(self.hub_auth.hub_prefix, 'home')
|
||||
|
||||
_activity_task = None
|
||||
|
||||
@_fatal_errors
|
||||
def initialize(self, args=None):
|
||||
# initialize takes place after
|
||||
# 1. config has been loaded
|
||||
# 2. Configurables instantiated
|
||||
# 3. serverapp.web_app set up
|
||||
|
||||
super().initialize()
|
||||
app = self.serverapp
|
||||
app.web_app.settings[
|
||||
"page_config_hook"
|
||||
] = app.identity_provider.page_config_hook
|
||||
# add jupyterhub version header
|
||||
headers = app.web_app.settings.setdefault("headers", {})
|
||||
headers["X-JupyterHub-Version"] = __version__
|
||||
|
||||
async def _start_activity():
|
||||
self._activity_task = asyncio.ensure_future(self.keep_activity_updated())
|
||||
|
||||
app.io_loop.run_sync(_start_activity)
|
||||
|
||||
async def stop_extension(self):
|
||||
if self._activity_task:
|
||||
self._activity_task.cancel()
|
||||
|
||||
disable_user_config = Bool()
|
||||
|
||||
@default("disable_user_config")
|
||||
def _defaut_disable_user_config(self):
|
||||
return _bool_env("JUPYTERHUB_DISABLE_USER_CONFIG")
|
||||
|
||||
@staticmethod
|
||||
def _disable_user_config(serverapp):
|
||||
"""
|
||||
disable user-controlled sources of configuration
|
||||
by excluding directories in their home
|
||||
from paths.
|
||||
|
||||
This _does not_ disable frontend config,
|
||||
such as UI settings persistence.
|
||||
|
||||
1. Python config file paths
|
||||
2. Search paths for extensions, etc.
|
||||
3. import path
|
||||
"""
|
||||
|
||||
# config_file_paths is a property without a setter
|
||||
# can't override on the instance
|
||||
default_config_file_paths = serverapp.config_file_paths
|
||||
config_file_paths = list(_exclude_home(default_config_file_paths))
|
||||
serverapp.__class__.config_file_paths = property(
|
||||
lambda self: config_file_paths,
|
||||
)
|
||||
# verify patch applied
|
||||
assert serverapp.config_file_paths == config_file_paths
|
||||
|
||||
# patch jupyter_path to exclude $HOME
|
||||
global _original_jupyter_paths, _jupyter_paths_without_home
|
||||
_original_jupyter_paths = paths.jupyter_path()
|
||||
_jupyter_paths_without_home = list(_exclude_home(_original_jupyter_paths))
|
||||
|
||||
def get_jupyter_path_without_home(*subdirs):
|
||||
from jupyterhub.singleuser.extension import _original_jupyter_paths
|
||||
|
||||
paths = list(_original_jupyter_paths)
|
||||
if subdirs:
|
||||
paths = [os.path.join(p, *subdirs) for p in paths]
|
||||
return paths
|
||||
|
||||
# patch `jupyter_path.__code__` to ensure all callers are patched,
|
||||
# even if they've already imported
|
||||
# this affects e.g. nbclassic.nbextension_paths
|
||||
paths.jupyter_path.__code__ = get_jupyter_path_without_home.__code__
|
||||
|
||||
# prevent loading default static custom path in nbclassic
|
||||
serverapp.config.NotebookApp.static_custom_path = []
|
||||
|
||||
@classmethod
|
||||
def make_serverapp(cls, **kwargs):
|
||||
"""Instantiate the ServerApp
|
||||
|
||||
Override to customize the ServerApp before it loads any configuration
|
||||
"""
|
||||
serverapp = super().make_serverapp(**kwargs)
|
||||
if _bool_env("JUPYTERHUB_DISABLE_USER_CONFIG"):
|
||||
# disable user-controllable config
|
||||
cls._disable_user_config(serverapp)
|
||||
|
||||
if _bool_env("JUPYTERHUB_SINGLEUSER_TEST_EXTENSION"):
|
||||
serverapp.log.warning("Enabling jupyterhub test extension")
|
||||
serverapp.jpserver_extensions["jupyterhub.tests.extension"] = True
|
||||
|
||||
return serverapp
|
||||
|
||||
|
||||
main = JupyterHubSingleUser.launch_instance
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
@@ -18,6 +18,7 @@ import sys
|
||||
import warnings
|
||||
from datetime import timezone
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -168,65 +169,12 @@ flags = {
|
||||
}
|
||||
|
||||
|
||||
page_template = """
|
||||
{% extends "templates/page.html" %}
|
||||
|
||||
{% block header_buttons %}
|
||||
{{super()}}
|
||||
|
||||
<span>
|
||||
<a href='{{hub_control_panel_url}}'
|
||||
id='jupyterhub-control-panel-link'
|
||||
class='btn btn-default btn-sm navbar-btn pull-right'
|
||||
style='margin-right: 4px; margin-left: 2px;'>
|
||||
Control Panel
|
||||
</a>
|
||||
</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block logo %}
|
||||
<img src='{{logo_url}}' alt='Jupyter Notebook'/>
|
||||
{% endblock logo %}
|
||||
|
||||
{% block script %}
|
||||
{{ super() }}
|
||||
<script type='text/javascript'>
|
||||
function _remove_redirects_param() {
|
||||
// remove ?redirects= param from URL so that
|
||||
// successful page loads don't increment the redirect loop counter
|
||||
if (window.location.search.length <= 1) {
|
||||
return;
|
||||
}
|
||||
var search_parameters = window.location.search.slice(1).split('&');
|
||||
for (var i = 0; i < search_parameters.length; i++) {
|
||||
if (search_parameters[i].split('=')[0] === 'redirects') {
|
||||
// remote token from search parameters
|
||||
search_parameters.splice(i, 1);
|
||||
var new_search = '';
|
||||
if (search_parameters.length) {
|
||||
new_search = '?' + search_parameters.join('&');
|
||||
}
|
||||
var new_url = window.location.origin +
|
||||
window.location.pathname +
|
||||
new_search +
|
||||
window.location.hash;
|
||||
window.history.replaceState({}, "", new_url);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
_remove_redirects_param();
|
||||
</script>
|
||||
{% endblock script %}
|
||||
"""
|
||||
|
||||
|
||||
def _exclude_home(path_list):
|
||||
"""Filter out any entries in a path list that are in my home directory.
|
||||
|
||||
Used to disable per-user configuration.
|
||||
"""
|
||||
home = os.path.expanduser('~')
|
||||
home = os.path.expanduser('~/')
|
||||
for p in path_list:
|
||||
if not p.startswith(home):
|
||||
yield p
|
||||
@@ -624,6 +572,21 @@ class SingleUserNotebookAppMixin(Configurable):
|
||||
f"Extending {cls.__module__}.{cls.__name__} from {module_name} {mod_version}"
|
||||
)
|
||||
|
||||
# load test extension, if we're testing
|
||||
def init_server_extension_config(self):
|
||||
# classic notebook server (notebook < 7)
|
||||
super().init_server_extension_config()
|
||||
if os.getenv("JUPYTERHUB_SINGLEUSER_TEST_EXTENSION") == "1":
|
||||
self.log.warning("Enabling jupyterhub test extension, classic edition")
|
||||
self.nbserver_extensions["jupyterhub.tests.extension"] = True
|
||||
|
||||
def find_server_extensions(self):
|
||||
# jupyter-server (lab or notebook >=7)
|
||||
super().find_server_extensions()
|
||||
if os.getenv("JUPYTERHUB_SINGLEUSER_TEST_EXTENSION") == "1":
|
||||
self.log.warning("Enabling jupyterhub test extension")
|
||||
self.jpserver_extensions["jupyterhub.tests.extension"] = True
|
||||
|
||||
def initialize(self, argv=None):
|
||||
# disable trash by default
|
||||
# this can be re-enabled by config
|
||||
@@ -785,6 +748,10 @@ class SingleUserNotebookAppMixin(Configurable):
|
||||
jinja_envs.append(settings[env_name])
|
||||
|
||||
# patch jinja env loading to get modified template, only for base page.html
|
||||
template_dir = Path(__file__).resolve().parent.joinpath("templates")
|
||||
with template_dir.joinpath("page.html").open() as f:
|
||||
page_template = f.read()
|
||||
|
||||
def get_page(name):
|
||||
if name == 'page.html':
|
||||
return page_template
|
||||
@@ -935,11 +902,18 @@ def make_singleuser_app(App):
|
||||
log = empty_parent_app.log
|
||||
|
||||
# detect base classes
|
||||
LoginHandler = empty_parent_app.login_handler_class
|
||||
LogoutHandler = empty_parent_app.logout_handler_class
|
||||
if not getattr(empty_parent_app, "login_handler_class", None) and hasattr(
|
||||
empty_parent_app, "identity_provider_class"
|
||||
):
|
||||
has_handlers = empty_parent_app.identity_provider_class(parent=empty_parent_app)
|
||||
else:
|
||||
has_handlers = empty_parent_app
|
||||
LoginHandler = has_handlers.login_handler_class
|
||||
LogoutHandler = has_handlers.logout_handler_class
|
||||
BaseHandler = _patch_app_base_handlers(empty_parent_app)
|
||||
|
||||
# create Handler classes from mixins + bases
|
||||
|
||||
class JupyterHubLoginHandler(JupyterHubLoginHandlerMixin, LoginHandler):
|
||||
pass
|
||||
|
||||
|
44
jupyterhub/singleuser/templates/page.html
Normal file
44
jupyterhub/singleuser/templates/page.html
Normal file
@@ -0,0 +1,44 @@
|
||||
{% extends "templates/page.html" %} {% block header_buttons %} {{super()}}
|
||||
|
||||
<span>
|
||||
<a
|
||||
href="{{hub_control_panel_url}}"
|
||||
id="jupyterhub-control-panel-link"
|
||||
class="btn btn-default btn-sm navbar-btn pull-right"
|
||||
style="margin-right: 4px; margin-left: 2px"
|
||||
>
|
||||
Control Panel
|
||||
</a>
|
||||
</span>
|
||||
{% endblock %} {% block logo %}
|
||||
<img src="{{logo_url}}" alt="Jupyter Notebook" />
|
||||
{% endblock logo %} {% block script %} {{ super() }}
|
||||
<script type="text/javascript">
|
||||
function _remove_redirects_param() {
|
||||
// remove ?redirects= param from URL so that
|
||||
// successful page loads don't increment the redirect loop counter
|
||||
if (window.location.search.length <= 1) {
|
||||
return;
|
||||
}
|
||||
var search_parameters = window.location.search.slice(1).split("&");
|
||||
for (var i = 0; i < search_parameters.length; i++) {
|
||||
if (search_parameters[i].split("=")[0] === "redirects") {
|
||||
// remote token from search parameters
|
||||
search_parameters.splice(i, 1);
|
||||
var new_search = "";
|
||||
if (search_parameters.length) {
|
||||
new_search = "?" + search_parameters.join("&");
|
||||
}
|
||||
var new_url =
|
||||
window.location.origin +
|
||||
window.location.pathname +
|
||||
new_search +
|
||||
window.location.hash;
|
||||
window.history.replaceState({}, "", new_url);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
_remove_redirects_param();
|
||||
</script>
|
||||
{% endblock script %}
|
@@ -173,6 +173,7 @@ async def cleanup_after(request, io_loop):
|
||||
await app.proxy.delete_user(user, name)
|
||||
except HTTPError:
|
||||
pass
|
||||
print(f"Stopping leftover server {spawner._log_name}")
|
||||
await user.stop(name)
|
||||
if user.name not in {'admin', 'user'}:
|
||||
app.users.delete(uid)
|
||||
@@ -370,6 +371,15 @@ def slow_spawn(app):
|
||||
yield
|
||||
|
||||
|
||||
@fixture
|
||||
def full_spawn(app):
|
||||
"""Fixture enabling full instrumented server via InstrumentedSpawner"""
|
||||
with mock.patch.dict(
|
||||
app.tornado_settings, {'spawner_class': mocking.InstrumentedSpawner}
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@fixture
|
||||
def never_spawn(app):
|
||||
"""Fixture enabling NeverSpawner"""
|
||||
|
54
jupyterhub/tests/extension/__init__.py
Normal file
54
jupyterhub/tests/extension/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Jupyter server extension for testing JupyterHub
|
||||
|
||||
Adds endpoints for accessing whatever state we want to check about the server
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from jupyter_server.base.handlers import JupyterHandler
|
||||
from tornado import web
|
||||
|
||||
|
||||
class JupyterHubTestHandler(JupyterHandler):
|
||||
def initialize(self, app):
|
||||
self.app = app
|
||||
|
||||
@web.authenticated
|
||||
def get(self):
|
||||
def _class_str(obj):
|
||||
"""Given an instance, return the 'module.Class' string"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if obj.__class__ is type:
|
||||
cls = obj
|
||||
else:
|
||||
cls = obj.__class__
|
||||
return f"{cls.__module__}.{cls.__name__}"
|
||||
|
||||
info = {
|
||||
"current_user": self.current_user,
|
||||
"config": self.app.config,
|
||||
"disable_user_config": getattr(self.app, "disable_user_config", None),
|
||||
"settings": self.settings,
|
||||
"config_file_paths": self.app.config_file_paths,
|
||||
}
|
||||
for attr in ("authenticator", "identity_provider"):
|
||||
info[attr] = _class_str(getattr(self.app, attr, None))
|
||||
self.set_header("content-type", "application/json")
|
||||
self.write(json.dumps(info, default=repr))
|
||||
|
||||
|
||||
def _load_jupyter_server_extension(serverapp):
|
||||
"""
|
||||
This function is called when the extension is loaded.
|
||||
"""
|
||||
serverapp.log.warning(f"Loading jupyterhub test extension for {serverapp}")
|
||||
handlers = [
|
||||
(
|
||||
serverapp.base_url + 'jupyterhub-test-info',
|
||||
JupyterHubTestHandler,
|
||||
{"app": serverapp},
|
||||
)
|
||||
]
|
||||
serverapp.web_app.add_handlers('.*$', handlers)
|
@@ -20,7 +20,7 @@ Other components
|
||||
- MockPAMAuthenticator
|
||||
- MockHub
|
||||
- MockSingleUserServer
|
||||
- StubSingleUserSpawner
|
||||
- InstrumentedSpawner
|
||||
|
||||
- public_host
|
||||
- public_url
|
||||
@@ -29,7 +29,6 @@ Other components
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from tempfile import NamedTemporaryFile
|
||||
from unittest import mock
|
||||
@@ -42,7 +41,6 @@ from traitlets import Bool, Dict, default
|
||||
from .. import metrics, orm, roles
|
||||
from ..app import JupyterHub
|
||||
from ..auth import PAMAuthenticator
|
||||
from ..singleuser import SingleUserNotebookApp
|
||||
from ..spawner import SimpleLocalProcessSpawner
|
||||
from ..utils import random_port, utcnow
|
||||
from .utils import async_requests, public_url, ssl_setup
|
||||
@@ -377,27 +375,12 @@ class MockHub(JupyterHub):
|
||||
return r.cookies
|
||||
|
||||
|
||||
# single-user-server mocking:
|
||||
|
||||
|
||||
class MockSingleUserServer(SingleUserNotebookApp):
|
||||
"""Mock-out problematic parts of single-user server when run in a thread
|
||||
|
||||
Currently:
|
||||
|
||||
- disable signal handler
|
||||
class InstrumentedSpawner(MockSpawner):
|
||||
"""
|
||||
Spawner that starts a full singleuser server
|
||||
|
||||
def init_signal(self):
|
||||
pass
|
||||
|
||||
@default("log_level")
|
||||
def _default_log_level(self):
|
||||
return 10
|
||||
|
||||
|
||||
class StubSingleUserSpawner(MockSpawner):
|
||||
"""Spawner that starts a MockSingleUserServer in a thread."""
|
||||
instrumented with the JupyterHub test extension.
|
||||
"""
|
||||
|
||||
@default("default_url")
|
||||
def _default_url(self):
|
||||
@@ -411,41 +394,10 @@ class StubSingleUserSpawner(MockSpawner):
|
||||
"""
|
||||
return "/tree"
|
||||
|
||||
_thread = None
|
||||
@default('cmd')
|
||||
def _cmd_default(self):
|
||||
return [sys.executable, '-m', 'jupyterhub.singleuser']
|
||||
|
||||
async def start(self):
|
||||
ip = self.ip = '127.0.0.1'
|
||||
port = self.port = random_port()
|
||||
env = self.get_env()
|
||||
args = self.get_args()
|
||||
evt = threading.Event()
|
||||
print(args, env)
|
||||
|
||||
def _run():
|
||||
with mock.patch.dict(os.environ, env):
|
||||
app = self._app = MockSingleUserServer()
|
||||
app.initialize(args)
|
||||
app.io_loop.add_callback(lambda: evt.set())
|
||||
assert app.hub_auth.oauth_client_id
|
||||
assert app.hub_auth.api_token
|
||||
assert app.hub_auth.oauth_scopes
|
||||
app.start()
|
||||
|
||||
self._thread = threading.Thread(target=_run)
|
||||
self._thread.start()
|
||||
ready = evt.wait(timeout=3)
|
||||
assert ready
|
||||
return (ip, port)
|
||||
|
||||
async def stop(self):
|
||||
self._app.stop()
|
||||
self._thread.join(timeout=30)
|
||||
assert not self._thread.is_alive()
|
||||
|
||||
async def poll(self):
|
||||
if self._thread is None:
|
||||
return 0
|
||||
if self._thread.is_alive():
|
||||
return None
|
||||
else:
|
||||
return 0
|
||||
def start(self):
|
||||
self.environment["JUPYTERHUB_SINGLEUSER_TEST_EXTENSION"] = "1"
|
||||
return super().start()
|
||||
|
@@ -13,7 +13,7 @@ import jupyterhub
|
||||
|
||||
from .. import orm
|
||||
from ..utils import url_path_join
|
||||
from .mocking import StubSingleUserSpawner, public_url
|
||||
from .mocking import public_url
|
||||
from .utils import AsyncSession, async_requests, get_page
|
||||
|
||||
|
||||
@@ -43,11 +43,8 @@ async def test_singleuser_auth(
|
||||
access_scopes,
|
||||
server_name,
|
||||
expect_success,
|
||||
full_spawn,
|
||||
):
|
||||
# use StubSingleUserSpawner to launch a single-user app in a thread
|
||||
app.spawner_class = StubSingleUserSpawner
|
||||
app.tornado_settings['spawner_class'] = StubSingleUserSpawner
|
||||
|
||||
# login, start the server
|
||||
cookies = await app.login_user('nandy')
|
||||
user = app.users['nandy']
|
||||
@@ -132,12 +129,10 @@ async def test_singleuser_auth(
|
||||
r = await s.get(r.url, allow_redirects=False)
|
||||
assert r.status_code == 403
|
||||
assert 'burgess' in r.text
|
||||
await user.stop(server_name)
|
||||
|
||||
|
||||
async def test_disable_user_config(app):
|
||||
# use StubSingleUserSpawner to launch a single-user app in a thread
|
||||
app.spawner_class = StubSingleUserSpawner
|
||||
app.tornado_settings['spawner_class'] = StubSingleUserSpawner
|
||||
async def test_disable_user_config(request, app, tmpdir, full_spawn):
|
||||
# login, start the server
|
||||
cookies = await app.login_user('nandy')
|
||||
user = app.users['nandy']
|
||||
@@ -148,6 +143,16 @@ async def test_disable_user_config(app):
|
||||
# start with new config:
|
||||
user.spawner.debug = True
|
||||
user.spawner.disable_user_config = True
|
||||
home_dir = tmpdir.join("home")
|
||||
home_dir.mkdir()
|
||||
# home_dir is defined on SimpleSpawner
|
||||
user.spawner.home_dir = home = str(home_dir)
|
||||
jupyter_config_dir = home_dir.join(".jupyter")
|
||||
jupyter_config_dir.mkdir()
|
||||
# verify config paths
|
||||
with jupyter_config_dir.join("jupyter_server_config.py").open("w") as f:
|
||||
f.write("c.TestSingleUser.jupyter_config_py = True")
|
||||
|
||||
await user.spawn()
|
||||
await app.proxy.add_user(user)
|
||||
|
||||
@@ -161,6 +166,27 @@ async def test_disable_user_config(app):
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
r = await async_requests.get(
|
||||
url_path_join(public_url(app, user), 'jupyterhub-test-info'), cookies=cookies
|
||||
)
|
||||
r.raise_for_status()
|
||||
info = r.json()
|
||||
import pprint
|
||||
|
||||
pprint.pprint(info)
|
||||
assert info['disable_user_config']
|
||||
server_config = info['config']
|
||||
assert 'TestSingleUser' not in server_config
|
||||
# check config paths
|
||||
norm_home = os.path.realpath(os.path.abspath(home))
|
||||
for path in info['config_file_paths']:
|
||||
path = os.path.realpath(os.path.abspath(path))
|
||||
assert not path.startswith(norm_home + os.path.sep)
|
||||
|
||||
# TODO: check legacy notebook config
|
||||
# nbextensions_path
|
||||
# static_custom_path
|
||||
|
||||
|
||||
def test_help_output():
|
||||
out = check_output(
|
||||
@@ -199,7 +225,10 @@ def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
|
||||
have_notebook = True
|
||||
|
||||
if JUPYTERHUB_SINGLEUSER_APP.startswith("notebook."):
|
||||
expect_error = not have_notebook
|
||||
expect_error = (
|
||||
os.environ.get("JUPYTERHUB_SINGLEUSER_EXTENSION") == "1"
|
||||
or not have_notebook
|
||||
)
|
||||
elif JUPYTERHUB_SINGLEUSER_APP.startswith("jupyter_server."):
|
||||
expect_error = not have_server
|
||||
else:
|
||||
@@ -232,11 +261,7 @@ def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
|
||||
assert '--NotebookApp.' not in out
|
||||
|
||||
|
||||
async def test_nbclassic_control_panel(app, user):
|
||||
# use StubSingleUserSpawner to launch a single-user app in a thread
|
||||
app.spawner_class = StubSingleUserSpawner
|
||||
app.tornado_settings['spawner_class'] = StubSingleUserSpawner
|
||||
|
||||
async def test_nbclassic_control_panel(app, user, full_spawn):
|
||||
# login, start the server
|
||||
await user.spawn()
|
||||
cookies = await app.login_user(user.name)
|
||||
|
Reference in New Issue
Block a user