diff --git a/tests/test_configurer.py b/tests/test_configurer.py new file mode 100644 index 0000000..df4b69f --- /dev/null +++ b/tests/test_configurer.py @@ -0,0 +1,145 @@ +""" +Test +""" + +from tljh import configurer + + +class MockConfigurer: + """ + Mock a Traitlet Configurable object. + + Equivalent to the `c` in `c.JupyterHub.some_property` method of setting + traitlet properties. If an accessed attribute doesn't exist, a new instance + of EmtpyObject is returned. This lets us set arbitrary attributes two + levels deep. + + >>> c = MockConfigurer() + >>> c.FirstLevel.second_level = 'hi' + >>> c.FirstLevel.second_level == 'hi' + True + >>> hasattr(c.FirstLevel, 'does_not_exist') + False + """ + + class _EmptyObject: + """ + Empty class for putting attributes in. + """ + pass + + def __getattr__(self, k): + if k not in self.__dict__: + self.__dict__[k] = MockConfigurer._EmptyObject() + return self.__dict__[k] + + +def test_mock_configurer(): + """ + Test the MockConfigurer's mocking ability + """ + m = MockConfigurer() + m.SomethingSomething = 'hi' + m.FirstLevel.second_level = 'boo' + + assert m.SomethingSomething == 'hi' + assert m.FirstLevel.second_level == 'boo' + + assert not hasattr(m.FirstLevel, 'non_existent') + + +def apply_mock_config(overrides): + """ + Configure a mock configurer with given overrides. + + overrides should be a dict that matches what you parse from a config.yaml + """ + c = MockConfigurer() + configurer.apply_config(overrides, c) + return c + + +def test_app_default(): + """ + Test default application with no config overrides. + """ + c = apply_mock_config({}) + # default_url is not set, so JupyterHub will pick default. + assert not hasattr(c.Spawner, 'default_url') + + +def test_app_jupyterlab(): + """ + Test setting JupyterLab as default application + """ + c = apply_mock_config({'user_environment': {'default_app': 'jupyterlab'}}) + assert c.Spawner.default_url == '/lab' + + +def test_app_nteract(): + """ + Test setting nteract as default application + """ + c = apply_mock_config({'user_environment': {'default_app': 'nteract'}}) + assert c.Spawner.default_url == '/nteract' + + +def test_auth_default(): + """ + Test default authentication settings with no overrides + """ + c = apply_mock_config({}) + + assert c.JupyterHub.authenticator_class == 'firstuseauthenticator.FirstUseAuthenticator' + # Do not auto create users who haven't been manually added by default + assert not c.FirstUseAuthenticator.create_users + + +def test_auth_dummy(): + """ + Test setting Dummy Authenticator & password + """ + c = apply_mock_config({ + 'auth': { + 'type': 'dummyauthenticator.DummyAuthenticator', + 'DummyAuthenticator': { + 'password': 'test' + } + } + }) + assert c.JupyterHub.authenticator_class == 'dummyauthenticator.DummyAuthenticator' + assert c.DummyAuthenticator.password == 'test' + + +def test_auth_firstuse(): + """ + Test setting FirstUse Authenticator options + """ + c = apply_mock_config({ + 'auth': { + 'type': 'firstuseauthenticator.FirstUseAuthenticator', + 'FirstUseAuthenticator': { + 'create_users': True + } + } + }) + assert c.JupyterHub.authenticator_class == 'firstuseauthenticator.FirstUseAuthenticator' + assert c.FirstUseAuthenticator.create_users + + +def test_auth_github(): + """ + Test using GitHub authenticator + """ + c = apply_mock_config({ + 'auth': { + 'type': 'oauthenticator.github.GitHubOAuthenticator', + 'GitHubOAuthenticator': { + 'client_id': 'something', + 'client_secret': 'something-else' + } + } + }) + assert c.JupyterHub.authenticator_class == 'oauthenticator.github.GitHubOAuthenticator' + assert c.GitHubOAuthenticator.client_id == 'something' + assert c.GitHubOAuthenticator.client_secret == 'something-else' diff --git a/tljh/configurer.py b/tljh/configurer.py index c2b8b80..4863204 100644 --- a/tljh/configurer.py +++ b/tljh/configurer.py @@ -7,18 +7,13 @@ be called many times per lifetime of a jupyterhub. Traitlets that modify the startup of JupyterHub should not be here. FIXME: A strong feeling that JSON Schema should be involved somehow. """ -import copy -import os -import yaml - # Default configuration for tljh # User provided config is merged into this default = { 'auth': { - 'type': 'firstuse', - 'dummy': {}, - 'firstuse': { - 'createUsers': False + 'type': 'firstuseauthenticator.FirstUseAuthenticator', + 'FirstUseAuthenticator': { + 'create_users': False } }, 'users': { @@ -30,20 +25,18 @@ default = { 'memory': '1G', 'cpu': None }, - 'userEnvironment': { - 'defaultApp': 'classic' + 'user_environment': { + 'default_app': 'classic' } } -def apply_yaml_config(path, c): - if os.path.exists(path): - with open(path) as f: - # FIXME: Figure out correct order of merging here - tljh_config = _merge_dictionaries(dict(default), yaml.safe_load(f)) - else: - tljh_config = copy.deepcopy(default) +def apply_config(config_overrides, c): + """ + Merge config_overrides with config defaults & apply to JupyterHub config c + """ + tljh_config = _merge_dictionaries(dict(default), config_overrides) update_auth(c, tljh_config) update_userlists(c, tljh_config) @@ -52,21 +45,37 @@ def apply_yaml_config(path, c): update_user_account_config(c, tljh_config) +def set_if_not_none(parent, key, value): + """ + Set attribute 'key' on parent if value is not None + """ + if value is not None: + setattr(parent, key, value) + + def update_auth(c, config): """ Set auth related configuration from YAML config file + + Use auth.type to determine authenticator to use. All parameters + in the config under auth.{auth.type} will be passed straight to the + authenticators themselves. """ auth = config.get('auth') - if auth['type'] == 'dummy': - c.JupyterHub.authenticator_class = 'dummyauthenticator.DummyAuthenticator' - password = auth['dummy'].get('password') - if password is not None: - c.DummyAuthenticator.password = password - return - elif auth['type'] == 'firstuse': - c.JupyterHub.authenticator_class = 'firstuseauthenticator.FirstUseAuthenticator' - c.FirstUseAuthenticator.create_users = auth['firstuse']['createUsers'] + # FIXME: Make sure this is something importable. + # FIXME: SECURITY: Class must inherit from Authenticator, to prevent us being + # used to set arbitrary properties on arbitrary types of objects! + authenticator_class = auth['type'] + # When specifying fully qualified name, use classname as key for config + authenticator_configname = authenticator_class.split('.')[-1] + c.JupyterHub.authenticator_class = authenticator_class + # Use just class name when setting config. If authenticator is dummyauthenticator.DummyAuthenticator, + # its config will be set under c.DummyAuthenticator + authenticator_parent = getattr(c, authenticator_class.split('.')[-1]) + + for k, v in auth.get(authenticator_configname, {}).items(): + set_if_not_none(authenticator_parent, k, v) def update_userlists(c, config): @@ -94,12 +103,12 @@ def update_user_environment(c, config): """ Set user environment configuration """ - user_env = config['userEnvironment'] + user_env = config['user_environment'] # Set default application users are launched into - if user_env['defaultApp'] == 'jupyterlab': + if user_env['default_app'] == 'jupyterlab': c.Spawner.default_url = '/lab' - elif user_env['defaultApp'] == 'nteract': + elif user_env['default_app'] == 'nteract': c.Spawner.default_url = '/nteract' diff --git a/tljh/installer.py b/tljh/installer.py index 42c3b56..39e7fb7 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -73,7 +73,9 @@ def ensure_jupyterhub_package(prefix): 'jupyterhub==0.9.0', 'jupyterhub-dummyauthenticator==0.3.1', 'jupyterhub-systemdspawner==0.11', - 'jupyterhub-firstuseauthenticator==0.10' + 'jupyterhub-firstuseauthenticator==0.10', + 'jupyterhub-ldapauthenticator==1.2.2', + 'oauthenticator==0.7.3', ]) diff --git a/tljh/jupyterhub_config.py b/tljh/jupyterhub_config.py index 515993d..f556a0b 100644 --- a/tljh/jupyterhub_config.py +++ b/tljh/jupyterhub_config.py @@ -4,6 +4,8 @@ JupyterHub config for the littlest jupyterhub. import os from systemdspawner import SystemdSpawner from tljh import user, configurer +import yaml +import copy INSTALL_PREFIX = os.environ.get('TLJH_INSTALL_PREFIX') USER_ENV_PREFIX = os.path.join(INSTALL_PREFIX, 'user') @@ -40,4 +42,10 @@ c.SystemdSpawner.default_shell = '/bin/bash' # Drop the '-singleuser' suffix present in the default template c.SystemdSpawner.unit_name_template = 'jupyter-{USERNAME}' -configurer.apply_yaml_config(os.path.join(INSTALL_PREFIX, 'config.yaml'), c) +config_overrides_path = os.path.join(INSTALL_PREFIX, 'config.yaml') +if os.path.exists(config_overrides_path): + with open(config_overrides_path) as f: + config_overrides = yaml.safe_load(f) +else: + config_overrides = {} +configurer.apply_config(config_overrides, c)