mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-18 15:33:02 +00:00
consolidate disable_user_config implementation
found some fixes required to run on ServerApp to affect extensions, which were not affected before
This commit is contained in:
113
jupyterhub/singleuser/_disable_user_config.py
Normal file
113
jupyterhub/singleuser/_disable_user_config.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""
|
||||||
|
Disable user-controlled config for single-user servers
|
||||||
|
|
||||||
|
Applies patches to prevent loading configuration from the user's home directory.
|
||||||
|
|
||||||
|
Only used when launching a single-user server with disable_user_config=True.
|
||||||
|
|
||||||
|
This is where we still have some monkeypatches,
|
||||||
|
because we want to prevent loading configuration from user directories,
|
||||||
|
and `jupyter_core` functions don't allow that.
|
||||||
|
|
||||||
|
Due to extensions, we aren't able to apply patches in one place on the ServerApp,
|
||||||
|
we have to insert the patches at the lowest-level
|
||||||
|
on function objects themselves,
|
||||||
|
to ensure we modify calls to e.g. `jupyter_core.jupyter_path`
|
||||||
|
that may have been imported already!
|
||||||
|
|
||||||
|
We should perhaps ask for the necessary hooks to modify this in jupyter_core,
|
||||||
|
rather than keeing these monkey patches around.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from jupyter_core import paths
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# record patches
|
||||||
|
_original_jupyter_paths = None
|
||||||
|
_jupyter_paths_without_home = None
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
original_jupyter_path = paths.jupyter_path()
|
||||||
|
jupyter_path_without_home = list(_exclude_home(original_jupyter_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_config_dir
|
||||||
|
_original_jupyter_paths = paths.jupyter_path()
|
||||||
|
_jupyter_paths_without_home = list(_exclude_home(_original_jupyter_paths))
|
||||||
|
|
||||||
|
def get_jupyter_path_without_home(*subdirs):
|
||||||
|
# reimport because of our `__code__` patch
|
||||||
|
# affects what is resolved as the parent namespace
|
||||||
|
from jupyterhub.singleuser._disable_user_config import (
|
||||||
|
_jupyter_paths_without_home,
|
||||||
|
)
|
||||||
|
|
||||||
|
paths = list(_jupyter_paths_without_home)
|
||||||
|
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__
|
||||||
|
|
||||||
|
# same thing for config_dir,
|
||||||
|
# which applies to some things like ExtensionApp config paths
|
||||||
|
# and nbclassic.static_custom_path
|
||||||
|
|
||||||
|
# allows explicit override if $JUPYTER_CONFIG_DIR is set
|
||||||
|
# or config dir is otherwise not in $HOME
|
||||||
|
|
||||||
|
if not os.getenv("JUPYTER_CONFIG_DIR") and not list(
|
||||||
|
_exclude_home([paths.jupyter_config_dir()])
|
||||||
|
):
|
||||||
|
# patch specifically Application.config_dir
|
||||||
|
# this affects ServerApp and ExtensionApp,
|
||||||
|
# but does not affect JupyterLab's user-settings, etc.
|
||||||
|
# patching the traitlet directly affects all instances,
|
||||||
|
# already-created or future
|
||||||
|
from jupyter_core.application import JupyterApp
|
||||||
|
|
||||||
|
def get_env_config_dir(obj, cls=None):
|
||||||
|
return paths.ENV_CONFIG_PATH[0]
|
||||||
|
|
||||||
|
JupyterApp.config_dir.get = get_env_config_dir
|
||||||
|
|
||||||
|
# record disabled state on app object
|
||||||
|
serverapp.disable_user_config = True
|
@@ -16,7 +16,6 @@ from pathlib import Path
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from jupyter_core import paths
|
|
||||||
from jupyter_server.auth import Authorizer, IdentityProvider, User
|
from jupyter_server.auth import Authorizer, IdentityProvider, User
|
||||||
from jupyter_server.auth.logout import LogoutHandler
|
from jupyter_server.auth.logout import LogoutHandler
|
||||||
from jupyter_server.extension.application import ExtensionApp
|
from jupyter_server.extension.application import ExtensionApp
|
||||||
@@ -34,6 +33,8 @@ from jupyterhub.utils import (
|
|||||||
url_path_join,
|
url_path_join,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ._disable_user_config import _disable_user_config
|
||||||
|
|
||||||
SINGLEUSER_TEMPLATES_DIR = str(Path(__file__).parent.resolve().joinpath("templates"))
|
SINGLEUSER_TEMPLATES_DIR = str(Path(__file__).parent.resolve().joinpath("templates"))
|
||||||
|
|
||||||
|
|
||||||
@@ -245,11 +246,6 @@ def _fatal_errors(f):
|
|||||||
return wrapped
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
# patches
|
|
||||||
_original_jupyter_paths = None
|
|
||||||
_jupyter_paths_without_home = None
|
|
||||||
|
|
||||||
|
|
||||||
class JupyterHubSingleUser(ExtensionApp):
|
class JupyterHubSingleUser(ExtensionApp):
|
||||||
"""Jupyter Server extension entrypoint.
|
"""Jupyter Server extension entrypoint.
|
||||||
|
|
||||||
@@ -357,7 +353,7 @@ class JupyterHubSingleUser(ExtensionApp):
|
|||||||
async def notify_activity(self):
|
async def notify_activity(self):
|
||||||
"""Notify jupyterhub of activity"""
|
"""Notify jupyterhub of activity"""
|
||||||
client = self.hub_http_client
|
client = self.hub_http_client
|
||||||
last_activity = self.web_app.last_activity()
|
last_activity = self.serverapp.web_app.last_activity()
|
||||||
if not last_activity:
|
if not last_activity:
|
||||||
self.log.debug("No activity to send to the Hub")
|
self.log.debug("No activity to send to the Hub")
|
||||||
return
|
return
|
||||||
@@ -590,52 +586,6 @@ class JupyterHubSingleUser(ExtensionApp):
|
|||||||
def _defaut_disable_user_config(self):
|
def _defaut_disable_user_config(self):
|
||||||
return _bool_env("JUPYTERHUB_DISABLE_USER_CONFIG")
|
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
|
@classmethod
|
||||||
def make_serverapp(cls, **kwargs):
|
def make_serverapp(cls, **kwargs):
|
||||||
"""Instantiate the ServerApp
|
"""Instantiate the ServerApp
|
||||||
@@ -645,7 +595,7 @@ class JupyterHubSingleUser(ExtensionApp):
|
|||||||
serverapp = super().make_serverapp(**kwargs)
|
serverapp = super().make_serverapp(**kwargs)
|
||||||
if _bool_env("JUPYTERHUB_DISABLE_USER_CONFIG"):
|
if _bool_env("JUPYTERHUB_DISABLE_USER_CONFIG"):
|
||||||
# disable user-controllable config
|
# disable user-controllable config
|
||||||
cls._disable_user_config(serverapp)
|
_disable_user_config(serverapp)
|
||||||
|
|
||||||
if _bool_env("JUPYTERHUB_SINGLEUSER_TEST_EXTENSION"):
|
if _bool_env("JUPYTERHUB_SINGLEUSER_TEST_EXTENSION"):
|
||||||
serverapp.log.warning("Enabling jupyterhub test extension")
|
serverapp.log.warning("Enabling jupyterhub test extension")
|
||||||
|
@@ -45,6 +45,7 @@ from .._version import __version__, _check_version
|
|||||||
from ..log import log_request
|
from ..log import log_request
|
||||||
from ..services.auth import HubOAuth, HubOAuthCallbackHandler, HubOAuthenticated
|
from ..services.auth import HubOAuth, HubOAuthCallbackHandler, HubOAuthenticated
|
||||||
from ..utils import exponential_backoff, isoformat, make_ssl_context, url_path_join
|
from ..utils import exponential_backoff, isoformat, make_ssl_context, url_path_join
|
||||||
|
from ._disable_user_config import _disable_user_config, _exclude_home
|
||||||
|
|
||||||
|
|
||||||
def _bool_env(key):
|
def _bool_env(key):
|
||||||
@@ -169,17 +170,6 @@ flags = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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 SingleUserNotebookAppMixin(Configurable):
|
class SingleUserNotebookAppMixin(Configurable):
|
||||||
"""A Subclass of the regular NotebookApp that is aware of the parent multiuser context."""
|
"""A Subclass of the regular NotebookApp that is aware of the parent multiuser context."""
|
||||||
|
|
||||||
@@ -598,6 +588,8 @@ class SingleUserNotebookAppMixin(Configurable):
|
|||||||
self.jpserver_extensions["jupyterhub.tests.extension"] = True
|
self.jpserver_extensions["jupyterhub.tests.extension"] = True
|
||||||
|
|
||||||
def initialize(self, argv=None):
|
def initialize(self, argv=None):
|
||||||
|
if self.disable_user_config:
|
||||||
|
_disable_user_config(self)
|
||||||
# disable trash by default
|
# disable trash by default
|
||||||
# this can be re-enabled by config
|
# this can be re-enabled by config
|
||||||
self.config.FileContentsManager.delete_to_trash = False
|
self.config.FileContentsManager.delete_to_trash = False
|
||||||
|
@@ -85,6 +85,11 @@ class MockSpawner(SimpleLocalProcessSpawner):
|
|||||||
use_this_api_token = None
|
use_this_api_token = None
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
# preserve any JupyterHub env in mock spawner
|
||||||
|
for key in os.environ:
|
||||||
|
if 'JUPYTERHUB' in key and key not in self.env_keep:
|
||||||
|
self.env_keep.append(key)
|
||||||
|
|
||||||
if self.use_this_api_token:
|
if self.use_this_api_token:
|
||||||
self.api_token = self.use_this_api_token
|
self.api_token = self.use_this_api_token
|
||||||
elif self.will_resume:
|
elif self.will_resume:
|
||||||
|
@@ -176,16 +176,26 @@ async def test_disable_user_config(request, app, tmpdir, full_spawn):
|
|||||||
pprint.pprint(info)
|
pprint.pprint(info)
|
||||||
assert info['disable_user_config']
|
assert info['disable_user_config']
|
||||||
server_config = info['config']
|
server_config = info['config']
|
||||||
|
settings = info['settings']
|
||||||
assert 'TestSingleUser' not in server_config
|
assert 'TestSingleUser' not in server_config
|
||||||
# check config paths
|
# check config paths
|
||||||
norm_home = os.path.realpath(os.path.abspath(home))
|
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
|
def assert_not_in_home(path, name):
|
||||||
# nbextensions_path
|
path = os.path.realpath(os.path.abspath(path))
|
||||||
# static_custom_path
|
assert not path.startswith(
|
||||||
|
norm_home + os.path.sep
|
||||||
|
), f"{name}: {path} is in home {norm_home}"
|
||||||
|
|
||||||
|
for path in info['config_file_paths']:
|
||||||
|
assert_not_in_home(path, 'config_file_paths')
|
||||||
|
|
||||||
|
# check every path setting for lookup in $HOME
|
||||||
|
# is this too much?
|
||||||
|
for key, setting in settings.items():
|
||||||
|
if 'path' in key and isinstance(setting, list):
|
||||||
|
for path in setting:
|
||||||
|
assert_not_in_home(path, key)
|
||||||
|
|
||||||
|
|
||||||
def test_help_output():
|
def test_help_output():
|
||||||
|
Reference in New Issue
Block a user