From cffdf89327812e7099e7f9cf6d3c7bfef31a23d4 Mon Sep 17 00:00:00 2001 From: Min RK Date: Sat, 21 Mar 2015 19:12:24 -0400 Subject: [PATCH 001/231] remove logout page redirect to landing page, instead --- jupyterhub/handlers/login.py | 3 +-- share/jupyter/hub/templates/logout.html | 13 ------------- 2 files changed, 1 insertion(+), 15 deletions(-) delete mode 100644 share/jupyter/hub/templates/logout.html diff --git a/jupyterhub/handlers/login.py b/jupyterhub/handlers/login.py index 95bc314d..ceb86a34 100644 --- a/jupyterhub/handlers/login.py +++ b/jupyterhub/handlers/login.py @@ -13,8 +13,7 @@ class LogoutHandler(BaseHandler): """Log a user out by clearing their login cookie.""" def get(self): self.clear_login_cookie() - html = self.render_template('logout.html') - self.finish(html) + self.redirect(self.hub.server.base_url, permanent=False) class LoginHandler(BaseHandler): diff --git a/share/jupyter/hub/templates/logout.html b/share/jupyter/hub/templates/logout.html deleted file mode 100644 index d7aa6ddb..00000000 --- a/share/jupyter/hub/templates/logout.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "page.html" %} - -{% block login_widget %} -{% endblock %} - -{% block main %} - -
-

You have been logged out

-

Log in again...

-
- -{% endblock %} From 24fd843c3c9f4b05c29c6337b61bd6ac4c69a624 Mon Sep 17 00:00:00 2001 From: Min RK Date: Sat, 21 Mar 2015 19:50:57 -0400 Subject: [PATCH 002/231] render login form at root - redirect to server, if running (hub home, otherwise) --- jupyterhub/handlers/login.py | 18 +++++++++++------- jupyterhub/handlers/pages.py | 24 +++++++++++++++--------- share/jupyter/hub/static/less/login.less | 2 +- share/jupyter/hub/templates/index.html | 16 ---------------- share/jupyter/hub/templates/login.html | 11 +++++++---- 5 files changed, 34 insertions(+), 37 deletions(-) delete mode 100644 share/jupyter/hub/templates/index.html diff --git a/jupyterhub/handlers/login.py b/jupyterhub/handlers/login.py index ceb86a34..bc1c2ca9 100644 --- a/jupyterhub/handlers/login.py +++ b/jupyterhub/handlers/login.py @@ -19,23 +19,27 @@ class LogoutHandler(BaseHandler): class LoginHandler(BaseHandler): """Render the login page.""" - def _render(self, message=None, username=None): + def _render(self, login_error=None, username=None): return self.render_template('login.html', next=url_escape(self.get_argument('next', default='')), username=username, - message=message, - custom_html=self.authenticator.custom_html, + login_error=login_error, + custom_login_form=self.authenticator.custom_html, ) def get(self): next_url = self.get_argument('next', False) - if next_url and self.get_current_user(): + user = self.get_current_user() + if user: + if not next_url: + if user.running: + next_url = user.server.base_url + else: + next_url = self.hub.server.base_url # set new login cookie # because single-user cookie may have been cleared or incorrect self.set_login_cookie(self.get_current_user()) self.redirect(next_url, permanent=False) - elif not next_url and self.get_current_user(): - self.redirect(self.hub.server.base_url, permanent=False) else: username = self.get_argument('username', default='') self.finish(self._render(username=username)) @@ -63,7 +67,7 @@ class LoginHandler(BaseHandler): else: self.log.debug("Failed login for %s", username) html = self._render( - message={'error': 'Invalid username or password'}, + login_error='Invalid username or password', username=username, ) self.finish(html) diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index 72ee223c..a8bd58c0 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -13,19 +13,25 @@ from .base import BaseHandler class RootHandler(BaseHandler): """Render the Hub root page. - Currently redirects to home if logged in, - shows big fat login button otherwise. + If logged in, redirects to: + + - single-user server if running + - hub home, otherwise + + Otherwise, renders login page. """ def get(self): - if self.get_current_user(): - self.redirect( - url_path_join(self.hub.server.base_url, 'home'), - permanent=False, - ) + user = self.get_current_user() + if user: + if user.running: + url = user.server.base_url + else: + url = url_path_join(self.hub.server.base_url, 'home') + self.redirect(url, permanent=False) return - - html = self.render_template('index.html', + html = self.render_template('login.html', login_url=self.settings['login_url'], + custom_html=self.authenticator.custom_html, ) self.finish(html) diff --git a/share/jupyter/hub/static/less/login.less b/share/jupyter/hub/static/less/login.less index 7ef883fe..71ff1f6a 100644 --- a/share/jupyter/hub/static/less/login.less +++ b/share/jupyter/hub/static/less/login.less @@ -24,7 +24,7 @@ outline-color: @jupyter-orange; } - .message { + .login_error { color: orangered; font-weight: bold; text-align: center; diff --git a/share/jupyter/hub/templates/index.html b/share/jupyter/hub/templates/index.html deleted file mode 100644 index 49e3888a..00000000 --- a/share/jupyter/hub/templates/index.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "page.html" %} - -{% block login_widget %} -{% endblock %} - -{% block main %} - -
-
-
- Log in -
-
-
- -{% endblock %} diff --git a/share/jupyter/hub/templates/login.html b/share/jupyter/hub/templates/login.html index f4d40384..636727c7 100644 --- a/share/jupyter/hub/templates/login.html +++ b/share/jupyter/hub/templates/login.html @@ -5,18 +5,19 @@ {% block main %} +{% block login %}
{% if custom_html %} -{{custom_html}} +{{ custom_html }} {% else %}
Sign in
- {% if message %} -

- {{message.error}} + {% if login_error %} +

{% endif %} @@ -50,7 +51,9 @@
{% endif %} +{% endif %}
+{% endblock login %} {% endblock %} From 1bc8d50261bff09811a826ed27b7318904c4f784 Mon Sep 17 00:00:00 2001 From: Min RK Date: Sat, 21 Mar 2015 20:12:35 -0400 Subject: [PATCH 003/231] add "Login with..." button for custom authenticators that use external services (e.g. OAuth) --- jupyterhub/auth.py | 13 ++++++++++++- jupyterhub/handlers/base.py | 1 + share/jupyter/hub/static/less/login.less | 7 +++++++ share/jupyter/hub/templates/login.html | 7 ++++++- 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py index 67ab8c51..347c194e 100644 --- a/jupyterhub/auth.py +++ b/jupyterhub/auth.py @@ -30,7 +30,18 @@ class Authenticator(LoggingConfigurable): If empty, allow any user to attempt login. """ ) - custom_html = Unicode('') + custom_html = Unicode('', + help="""HTML login form for custom handlers. + Override in form-based custom authenticators + that don't use username+password, + or need custom branding. + """ + ) + login_service = Unicode('', + help="""Name of the login service for external + login services (e.g. 'GitHub'). + """ + ) @gen.coroutine def authenticate(self, handler, data): diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index 33e2d76d..abf763d3 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -309,6 +309,7 @@ class BaseHandler(RequestHandler): prefix=self.base_url, user=user, login_url=self.settings['login_url'], + login_service=self.authenticator.login_service, logout_url=self.settings['logout_url'], static_url=self.static_url, version_hash=self.version_hash, diff --git a/share/jupyter/hub/static/less/login.less b/share/jupyter/hub/static/less/login.less index 71ff1f6a..046c79ab 100644 --- a/share/jupyter/hub/static/less/login.less +++ b/share/jupyter/hub/static/less/login.less @@ -2,6 +2,13 @@ display: table; height: 80vh; + .service-login { + text-align: center; + display: table-cell; + vertical-align: middle; + margin: auto auto 20% auto; + } + form { display: table-cell; vertical-align: middle; diff --git a/share/jupyter/hub/templates/login.html b/share/jupyter/hub/templates/login.html index 636727c7..e38997f1 100644 --- a/share/jupyter/hub/templates/login.html +++ b/share/jupyter/hub/templates/login.html @@ -9,6 +9,12 @@
{% if custom_html %} {{ custom_html }} +{% elif login_service %} + {% else %}
@@ -51,7 +57,6 @@
{% endif %} -{% endif %}
{% endblock login %} From ab0010fa32d5081716030128c2eaa015c3fb031f Mon Sep 17 00:00:00 2001 From: Min RK Date: Sat, 21 Mar 2015 20:12:42 -0400 Subject: [PATCH 004/231] finish removing logout page --- share/jupyter/hub/static/less/logout.less | 14 -------------- share/jupyter/hub/static/less/style.less | 1 - 2 files changed, 15 deletions(-) delete mode 100644 share/jupyter/hub/static/less/logout.less diff --git a/share/jupyter/hub/static/less/logout.less b/share/jupyter/hub/static/less/logout.less deleted file mode 100644 index a0803769..00000000 --- a/share/jupyter/hub/static/less/logout.less +++ /dev/null @@ -1,14 +0,0 @@ -div.logout-main { - margin: auto; - text-align: center; -} - -div.logout-main > h1 { - font-size: 400%; - line-height: normal; -} - -div.logout-main > p { - font-size: 200%; - line-height: normal; -} diff --git a/share/jupyter/hub/static/less/style.less b/share/jupyter/hub/static/less/style.less index ce866e94..d74d7efc 100644 --- a/share/jupyter/hub/static/less/style.less +++ b/share/jupyter/hub/static/less/style.less @@ -24,5 +24,4 @@ @import "./page.less"; @import "./admin.less"; @import "./error.less"; -@import "./logout.less"; @import "./login.less"; From 50d1f78b6105b9d9ff28b36439bc6ff9623ef9b2 Mon Sep 17 00:00:00 2001 From: Min RK Date: Sat, 21 Mar 2015 20:56:29 -0400 Subject: [PATCH 005/231] add control panel link to single-user header This is done by defining the `headingcontainer` block as a function, and inserting it into the template rendering. Without monkeypatching, we could use a custom template file, but that would make the single-user server require its own template path, while it currently functions as a fully encapsulated single script. --- jupyterhub/singleuser.py | 48 +++++++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/jupyterhub/singleuser.py b/jupyterhub/singleuser.py index 600512a4..b2781240 100644 --- a/jupyterhub/singleuser.py +++ b/jupyterhub/singleuser.py @@ -151,10 +151,8 @@ class SingleUserNotebookApp(NotebookApp): def _clear_cookie_cache(self): self.log.info("Clearing cookie cache") self.tornado_settings['cookie_cache'].clear() - - def initialize(self, argv=None): - super().initialize(argv=argv) - + + def start(self): # Start a PeriodicCallback to clear cached cookies. This forces us to # revalidate our user with the Hub at least every # `cookie_cache_lifetime` seconds. @@ -163,7 +161,47 @@ class SingleUserNotebookApp(NotebookApp): self._clear_cookie_cache, self.cookie_cache_lifetime * 1e3, ).start() - + super().start() + + def initialize(self, argv=None): + self.monkeypatch_ipython() + return super().initialize(argv) + + def monkeypatch_ipython(self): + """monkeypatch IPython template rendering + + to inject hub links in the header without defining a template file + """ + from IPython.html.base.handlers import IPythonHandler + + get_template = IPythonHandler.get_template + + def get_su_template(*a, **kw): + """get a template, and replace the headercontainer block with our version""" + tpl = get_template(*a, **kw) + + super_block = tpl.blocks.get('headercontainer') + + def headercontainer(context): + """in-line definition of headercontainer block""" + if super_block: + for line in super_block(context): + yield line + + yield ( + "" + "Control Panel".format(url_path_join(self.hub_prefix, 'home')) + ) + + tpl.blocks['headercontainer'] = headercontainer + return tpl + + # apply monkeypatch + IPythonHandler.get_template = get_su_template + def init_webapp(self): # load the hub related settings into the tornado settings dict env = os.environ From 163a4db3adb24b231b81652218bb35112b52c692 Mon Sep 17 00:00:00 2001 From: Min RK Date: Sat, 21 Mar 2015 20:57:00 -0400 Subject: [PATCH 006/231] single-user login url is now the root hub page --- jupyterhub/singleuser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jupyterhub/singleuser.py b/jupyterhub/singleuser.py index b2781240..8e477c2d 100644 --- a/jupyterhub/singleuser.py +++ b/jupyterhub/singleuser.py @@ -211,7 +211,7 @@ class SingleUserNotebookApp(NotebookApp): s['hub_api_key'] = env.pop('JPY_API_TOKEN') s['hub_prefix'] = self.hub_prefix s['cookie_name'] = self.cookie_name - s['login_url'] = url_path_join(self.hub_prefix, 'login') + s['login_url'] = self.hub_prefix s['hub_api_url'] = self.hub_api_url super(SingleUserNotebookApp, self).init_webapp() From 0fe3dab40802ffc287c8db7d4f9e8696df78575b Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 31 Mar 2015 13:56:25 -0700 Subject: [PATCH 007/231] use jinja FunctionLoader instead of monkey patch to add Control Panel button --- jupyterhub/singleuser.py | 74 +++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 39 deletions(-) diff --git a/jupyterhub/singleuser.py b/jupyterhub/singleuser.py index 8e477c2d..76318e88 100644 --- a/jupyterhub/singleuser.py +++ b/jupyterhub/singleuser.py @@ -8,6 +8,7 @@ import os from urllib.parse import quote import requests +from jinja2 import ChoiceLoader, FunctionLoader from tornado import ioloop from tornado.web import HTTPError @@ -113,6 +114,20 @@ aliases.update({ 'base-url': 'SingleUserNotebookApp.base_url', }) +page_template = """ +{% extends "templates/page.html" %} + +{% block header_buttons %} +{{super()}} + + +Control Panel +{% endblock %} +""" + class SingleUserNotebookApp(NotebookApp): """A Subclass of the regular NotebookApp that is aware of the parent multiuser context.""" user = CUnicode(config=True) @@ -163,45 +178,6 @@ class SingleUserNotebookApp(NotebookApp): ).start() super().start() - def initialize(self, argv=None): - self.monkeypatch_ipython() - return super().initialize(argv) - - def monkeypatch_ipython(self): - """monkeypatch IPython template rendering - - to inject hub links in the header without defining a template file - """ - from IPython.html.base.handlers import IPythonHandler - - get_template = IPythonHandler.get_template - - def get_su_template(*a, **kw): - """get a template, and replace the headercontainer block with our version""" - tpl = get_template(*a, **kw) - - super_block = tpl.blocks.get('headercontainer') - - def headercontainer(context): - """in-line definition of headercontainer block""" - if super_block: - for line in super_block(context): - yield line - - yield ( - "" - "Control Panel".format(url_path_join(self.hub_prefix, 'home')) - ) - - tpl.blocks['headercontainer'] = headercontainer - return tpl - - # apply monkeypatch - IPythonHandler.get_template = get_su_template - def init_webapp(self): # load the hub related settings into the tornado settings dict env = os.environ @@ -213,7 +189,27 @@ class SingleUserNotebookApp(NotebookApp): s['cookie_name'] = self.cookie_name s['login_url'] = self.hub_prefix s['hub_api_url'] = self.hub_api_url + super(SingleUserNotebookApp, self).init_webapp() + self.patch_templates() + + def patch_templates(self): + """Patch page templates to add Hub-related buttons""" + env = self.web_app.settings['jinja2_env'] + + env.globals['hub_control_panel_url'] = \ + url_path_join(self.hub_prefix, 'home') + + # patch jinja env loading to modify page template + def get_page(name): + if name == 'page.html': + return page_template + + orig_loader = env.loader + env.loader = ChoiceLoader([ + FunctionLoader(get_page), + orig_loader, + ]) def main(): From 7c5e89faa626ec4707141e0cea0d756d7ec4f424 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 6 Apr 2015 10:56:36 -0700 Subject: [PATCH 008/231] use jupyter logo --- share/jupyter/hub/static/images/jupyter.png | Bin 0 -> 4473 bytes share/jupyter/hub/static/less/page.less | 4 ++-- share/jupyter/hub/templates/page.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 share/jupyter/hub/static/images/jupyter.png diff --git a/share/jupyter/hub/static/images/jupyter.png b/share/jupyter/hub/static/images/jupyter.png new file mode 100644 index 0000000000000000000000000000000000000000..54cc416db0a30265a51eaf5a0328267a5099b512 GIT binary patch literal 4473 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
- + {% block login_widget %} From 9372d5f87253824eafc4d0d872f3dc5f1c4142b6 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 6 Apr 2015 16:14:42 -0700 Subject: [PATCH 009/231] add coverage --- .coveragerc | 4 ++++ .gitignore | 3 +++ .travis.yml | 4 +++- dev-requirements.txt | 2 ++ jupyterhub/tests/mocking.py | 14 ++++++++++++++ 5 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..63568422 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,4 @@ +[run] +omit = + jupyterhub/tests/* + jupyterhub/singleuser.py diff --git a/.gitignore b/.gitignore index de3322be..693f6d80 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ share/jupyter/hub/static/css/style.min.css share/jupyter/hub/static/css/style.min.css.map *.egg-info MANIFEST +.coverage +htmlcov + diff --git a/.travis.yml b/.travis.yml index 03270c67..7cd48d22 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,4 +12,6 @@ install: - pip install -f travis-wheels/wheelhouse -r dev-requirements.txt . - pip install -f travis-wheels/wheelhouse ipython[notebook] script: - - py.test jupyterhub + - py.test --cov jupyterhub jupyterhub/tests +after_success: + - coveralls diff --git a/dev-requirements.txt b/dev-requirements.txt index 26b77f68..71546ccb 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,2 +1,4 @@ -r requirements.txt +coveralls +pytest-cov pytest diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py index 39cf9477..923b39d6 100644 --- a/jupyterhub/tests/mocking.py +++ b/jupyterhub/tests/mocking.py @@ -7,6 +7,8 @@ import threading from unittest import mock +import requests + from tornado import gen from tornado.concurrent import Future from tornado.ioloop import IOLoop @@ -126,3 +128,15 @@ class MockHub(JupyterHub): # ignore the call that will fire in atexit self.cleanup = lambda : None self.db_file.close() + + def login_user(self, name): + r = requests.post(self.proxy.public_server.url + 'hub/login', + data={ + 'username': name, + 'password': name, + }, + allow_redirects=False, + ) + assert r.cookies + return r.cookies + From d0b4e5bc2abf669a2586df0d8ae6550892bf92d8 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 6 Apr 2015 16:47:37 -0700 Subject: [PATCH 010/231] add some basic exercise for HTML pages --- jupyterhub/tests/test_pages.py | 58 ++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 jupyterhub/tests/test_pages.py diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py new file mode 100644 index 00000000..99291684 --- /dev/null +++ b/jupyterhub/tests/test_pages.py @@ -0,0 +1,58 @@ +"""Tests for HTML pages""" + +import requests + +from ..utils import url_path_join as ujoin +from .. import orm + + +def get_page(path, app, **kw): + base_url = ujoin(app.proxy.public_server.host, app.hub.server.base_url) + print(base_url) + return requests.get(ujoin(base_url, path), **kw) + +def test_root_no_auth(app, io_loop): + print(app.hub.server.is_up()) + routes = io_loop.run_sync(app.proxy.get_routes) + print(routes) + print(app.hub.server) + r = requests.get(app.proxy.public_server.host) + r.raise_for_status() + assert r.url == ujoin(app.proxy.public_server.host, app.hub.server.base_url) + +def test_root_auth(app): + cookies = app.login_user('river') + r = requests.get(app.proxy.public_server.host, cookies=cookies) + r.raise_for_status() + assert r.url == ujoin(app.proxy.public_server.host, '/user/river') + +def test_home_no_auth(app): + r = get_page('home', app, allow_redirects=False) + r.raise_for_status() + assert r.status_code == 302 + assert '/hub/login' in r.headers['Location'] + +def test_home_auth(app): + cookies = app.login_user('river') + r = get_page('home', app, cookies=cookies) + r.raise_for_status() + assert r.url.endswith('home') + +def test_admin_no_auth(app): + r = get_page('admin', app) + assert r.status_code == 403 + +def test_admin_not_admin(app): + cookies = app.login_user('wash') + r = get_page('admin', app, cookies=cookies) + assert r.status_code == 403 + +def test_admin(app): + cookies = app.login_user('river') + u = orm.User.find(app.db, 'river') + u.admin = True + app.db.commit() + r = get_page('admin', app, cookies=cookies) + r.raise_for_status() + assert r.url.endswith('/admin') + From d9fc40652d35bd930a2f4d158ffe2fd73c847c2e Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 6 Apr 2015 17:04:41 -0700 Subject: [PATCH 011/231] test shutdown API handler --- jupyterhub/tests/test_api.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 45848144..1eb21830 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -1,6 +1,7 @@ """Tests for the REST API""" import json +import time from datetime import timedelta import requests @@ -284,3 +285,18 @@ def test_get_proxy(app, io_loop): r.raise_for_status() reply = r.json() assert list(reply.keys()) == ['/'] + + +def test_shutdown(app): + r = api_request(app, 'shutdown', method='post', data=json.dumps({ + 'servers': True, + 'proxy': True, + })) + r.raise_for_status() + reply = r.json() + for i in range(100): + if app.io_loop._running: + time.sleep(0.1) + else: + break + assert not app.io_loop._running From 04b7056591099ea3b24cda0fa951e8e0458832a5 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 6 Apr 2015 17:36:41 -0700 Subject: [PATCH 012/231] fix group-whitelist checks and test it --- jupyterhub/auth.py | 6 +++--- jupyterhub/tests/test_auth.py | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py index 347c194e..9c235e9b 100644 --- a/jupyterhub/auth.py +++ b/jupyterhub/auth.py @@ -126,11 +126,11 @@ class LocalAuthenticator(Authenticator): def check_group_whitelist(self, username): if not self.group_whitelist: return False - for group in self.group_whitelist: + for grnam in self.group_whitelist: try: - group = getgrnam(self.group_whitelist) + group = getgrnam(grnam) except KeyError: - self.log.error('No such group: [%s]' % self.group_whitelist) + self.log.error('No such group: [%s]' % grnam) continue if username in group.gr_mem: return True diff --git a/jupyterhub/tests/test_auth.py b/jupyterhub/tests/test_auth.py index 8e5103c7..a467f3b9 100644 --- a/jupyterhub/tests/test_auth.py +++ b/jupyterhub/tests/test_auth.py @@ -4,7 +4,9 @@ # Distributed under the terms of the Modified BSD License. from .mocking import MockPAMAuthenticator +from unittest import mock +from jupyterhub import auth def test_pam_auth(io_loop): authenticator = MockPAMAuthenticator() @@ -39,3 +41,40 @@ def test_pam_auth_whitelist(io_loop): 'password': 'mal', })) assert authorized is None + + +class MockGroup: + def __init__(self, *names): + self.gr_mem = names + + +def test_pam_auth_group_whitelist(io_loop): + g = MockGroup('kaylee') + def getgrnam(name): + return g + + authenticator = MockPAMAuthenticator(group_whitelist={'group'}) + + with mock.patch.object(auth, 'getgrnam', getgrnam): + authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, { + 'username': 'kaylee', + 'password': 'kaylee', + })) + assert authorized == 'kaylee' + + with mock.patch.object(auth, 'getgrnam', getgrnam): + authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, { + 'username': 'mal', + 'password': 'mal', + })) + assert authorized is None + + +def test_pam_auth_no_such_group(io_loop): + authenticator = MockPAMAuthenticator(group_whitelist={'nosuchcrazygroup'}) + authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, { + 'username': 'kaylee', + 'password': 'kaylee', + })) + assert authorized is None + From 64c4d00756aabeb57a62cf2e700ea8930317986c Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 6 Apr 2015 17:41:00 -0700 Subject: [PATCH 013/231] test add_system_user --- jupyterhub/tests/test_auth.py | 46 +++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/jupyterhub/tests/test_auth.py b/jupyterhub/tests/test_auth.py index a467f3b9..f67929a2 100644 --- a/jupyterhub/tests/test_auth.py +++ b/jupyterhub/tests/test_auth.py @@ -3,10 +3,13 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -from .mocking import MockPAMAuthenticator +from subprocess import CalledProcessError from unittest import mock -from jupyterhub import auth +import pytest +from .mocking import MockPAMAuthenticator + +from jupyterhub import auth, orm def test_pam_auth(io_loop): authenticator = MockPAMAuthenticator() @@ -78,3 +81,42 @@ def test_pam_auth_no_such_group(io_loop): })) assert authorized is None + +def test_wont_add_system_user(io_loop): + user = orm.User(name='lioness4321') + authenticator = auth.PAMAuthenticator(whitelist={'mal'}) + authenticator.create_system_users = False + with pytest.raises(KeyError): + io_loop.run_sync(lambda : authenticator.add_user(user)) + +def test_cant_add_system_user(io_loop): + user = orm.User(name='lioness4321') + authenticator = auth.PAMAuthenticator(whitelist={'mal'}) + authenticator.create_system_users = True + + def check_output(cmd, *a, **kw): + raise CalledProcessError(1, cmd) + + with mock.patch.object(auth, 'check_output', check_output): + with pytest.raises(RuntimeError): + io_loop.run_sync(lambda : authenticator.add_user(user)) + +def test_add_system_user(io_loop): + user = orm.User(name='lioness4321') + authenticator = auth.PAMAuthenticator(whitelist={'mal'}) + authenticator.create_system_users = True + + def check_output(*a, **kw): + return + + record = {} + def check_call(cmd, *a, **kw): + record['cmd'] = cmd + + with mock.patch.object(auth, 'check_output', check_output), \ + mock.patch.object(auth, 'check_call', check_call): + io_loop.run_sync(lambda : authenticator.add_user(user)) + + assert user.name in record['cmd'] + + \ No newline at end of file From 34386ba3b7de61108f03d56c086b11673e3506a4 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 6 Apr 2015 17:45:39 -0700 Subject: [PATCH 014/231] more authenticator coverage --- jupyterhub/tests/test_auth.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/jupyterhub/tests/test_auth.py b/jupyterhub/tests/test_auth.py index f67929a2..d418b585 100644 --- a/jupyterhub/tests/test_auth.py +++ b/jupyterhub/tests/test_auth.py @@ -88,7 +88,8 @@ def test_wont_add_system_user(io_loop): authenticator.create_system_users = False with pytest.raises(KeyError): io_loop.run_sync(lambda : authenticator.add_user(user)) - + + def test_cant_add_system_user(io_loop): user = orm.User(name='lioness4321') authenticator = auth.PAMAuthenticator(whitelist={'mal'}) @@ -101,6 +102,7 @@ def test_cant_add_system_user(io_loop): with pytest.raises(RuntimeError): io_loop.run_sync(lambda : authenticator.add_user(user)) + def test_add_system_user(io_loop): user = orm.User(name='lioness4321') authenticator = auth.PAMAuthenticator(whitelist={'mal'}) @@ -118,5 +120,30 @@ def test_add_system_user(io_loop): io_loop.run_sync(lambda : authenticator.add_user(user)) assert user.name in record['cmd'] + + +def test_delete_user(io_loop): + user = orm.User(name='zoe') + a = MockPAMAuthenticator(whitelist={'mal'}) - \ No newline at end of file + assert 'zoe' not in a.whitelist + a.add_user(user) + assert 'zoe' in a.whitelist + a.delete_user(user) + assert 'zoe' not in a.whitelist + + +def test_urls(): + a = auth.PAMAuthenticator() + logout = a.logout_url('/base/url/') + login = a.login_url('/base/url') + assert logout == '/base/url/logout' + assert login == '/base/url/login' + + +def test_handlers(app): + a = auth.PAMAuthenticator() + handlers = a.get_handlers(app) + assert handlers[0][0] == '/login' + + From 998fc28c3235e5682212f4bdf004fb61f413e778 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 7 Apr 2015 15:48:52 -0700 Subject: [PATCH 015/231] various testing cleanup - Disable signal register during testing. It doesn't work in background threads. - Fix IOLoop instance management. Some instances were being reused across tests. --- jupyterhub/app.py | 8 +++++++- jupyterhub/tests/mocking.py | 15 ++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 094e04e6..9fd9d6b7 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -1003,6 +1003,9 @@ class JupyterHub(Application): # register cleanup on both TERM and INT atexit.register(self.atexit) + self.init_signal() + + def init_signal(self): signal.signal(signal.SIGTERM, self.sigterm) def sigterm(self, signum, frame): @@ -1027,7 +1030,10 @@ class JupyterHub(Application): if not self.io_loop: return if self.http_server: - self.io_loop.add_callback(self.http_server.stop) + if self.io_loop._running: + self.io_loop.add_callback(self.http_server.stop) + else: + self.http_server.stop() self.io_loop.add_callback(self.io_loop.stop) @gen.coroutine diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py index 923b39d6..02a38c1b 100644 --- a/jupyterhub/tests/mocking.py +++ b/jupyterhub/tests/mocking.py @@ -84,7 +84,7 @@ class MockHub(JupyterHub): """Hub with various mock bits""" db_file = None - + def _ip_default(self): return 'localhost' @@ -97,12 +97,18 @@ class MockHub(JupyterHub): def _admin_users_default(self): return {'admin'} + def init_signal(self): + pass + def start(self, argv=None): self.db_file = NamedTemporaryFile() self.db_url = 'sqlite:///' + self.db_file.name + evt = threading.Event() + @gen.coroutine def _start_co(): + assert self.io_loop._running # put initialize in start for SQLAlchemy threading reasons yield super(MockHub, self).initialize(argv=argv) # add an initial user @@ -110,16 +116,19 @@ class MockHub(JupyterHub): self.db.add(user) self.db.commit() yield super(MockHub, self).start() + yield self.hub.server.wait_up(http=True) self.io_loop.add_callback(evt.set) def _start(): - self.io_loop = IOLoop.current() + self.io_loop = IOLoop() + self.io_loop.make_current() self.io_loop.add_callback(_start_co) self.io_loop.start() self._thread = threading.Thread(target=_start) self._thread.start() - evt.wait(timeout=5) + ready = evt.wait(timeout=10) + assert ready def stop(self): super().stop() From ffece0ae79c432a39121c01f44da758ccdf0c523 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 7 Apr 2015 21:56:25 -0700 Subject: [PATCH 016/231] Demote cookie clear message to debug-level --- jupyterhub/singleuser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jupyterhub/singleuser.py b/jupyterhub/singleuser.py index 2042adcc..896dea3c 100644 --- a/jupyterhub/singleuser.py +++ b/jupyterhub/singleuser.py @@ -164,7 +164,7 @@ class SingleUserNotebookApp(NotebookApp): ioloop.IOLoop.instance().stop() def _clear_cookie_cache(self): - self.log.info("Clearing cookie cache") + self.log.debug("Clearing cookie cache") self.tornado_settings['cookie_cache'].clear() def start(self): From dbc410d6a1fef8c3e750845f3e746a8cd471b172 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 8 Apr 2015 10:20:05 -0700 Subject: [PATCH 017/231] log login / logout at info-level --- jupyterhub/handlers/login.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/jupyterhub/handlers/login.py b/jupyterhub/handlers/login.py index bc1c2ca9..b39c7b91 100644 --- a/jupyterhub/handlers/login.py +++ b/jupyterhub/handlers/login.py @@ -12,6 +12,9 @@ from .base import BaseHandler class LogoutHandler(BaseHandler): """Log a user out by clearing their login cookie.""" def get(self): + user = self.get_current_user() + if user: + self.log.info("User logged out: %s", user.name) self.clear_login_cookie() self.redirect(self.hub.server.base_url, permanent=False) @@ -64,6 +67,7 @@ class LoginHandler(BaseHandler): self.set_login_cookie(user) next_url = self.get_argument('next', default='') or self.hub.server.base_url self.redirect(next_url) + self.log.info("User logged in: %s", username) else: self.log.debug("Failed login for %s", username) html = self._render( From 6aae4be54da3038027e4b6aad6e2baf43921933f Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 8 Apr 2015 11:06:09 -0700 Subject: [PATCH 018/231] assign hub in token app avoids AttributeError on hub if there are users with running servers. Don't call init_hub, which can modify the Hub's entries in the database, which shouldn't happen in the token command. --- jupyterhub/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 9fd9d6b7..7cc28399 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -126,6 +126,7 @@ class NewToken(Application): hub = JupyterHub(parent=self) hub.load_config_file(hub.config_file) hub.init_db() + hub.hub = hub.db.query(orm.Hub).first() hub.init_users() user = orm.User.find(hub.db, self.name) if user is None: From 637cc1a7bbc27a1bc0e7630918b7e5e63ab8547e Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 8 Apr 2015 11:47:49 -0700 Subject: [PATCH 019/231] split user init into two stages - init_users populates users table - init_spawners initializes spawner objects only the first is needed by the token app --- jupyterhub/app.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 7cc28399..48f2ff37 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -633,6 +633,10 @@ class JupyterHub(Application): for user in new_users: yield gen.maybe_future(self.authenticator.add_user(user)) db.commit() + + @gen.coroutine + def init_spawners(self): + db = self.db user_summaries = [''] def _user_summary(user): @@ -861,6 +865,7 @@ class JupyterHub(Application): self.init_hub() self.init_proxy() yield self.init_users() + yield self.init_spawners() self.init_handlers() self.init_tornado_settings() self.init_tornado_application() From e883fccf2b0e3b45eb34a4de360608c8625c304d Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 8 Apr 2015 12:48:04 -0700 Subject: [PATCH 020/231] don't update last_activity on shutdown --- jupyterhub/orm.py | 1 - 1 file changed, 1 deletion(-) diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index a24dd9b6..9feb8cbb 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -429,7 +429,6 @@ class User(Base): yield self.spawner.stop() self.spawner.clear_state() self.state = self.spawner.get_state() - self.last_activity = datetime.utcnow() self.server = None inspect(self).session.commit() finally: From 2890e27052ac3585167ed566627f282b89dac249 Mon Sep 17 00:00:00 2001 From: Min RK Date: Sun, 12 Apr 2015 10:40:46 -0700 Subject: [PATCH 021/231] don't set empty values for HOME, SHELL in weird cases (probably misconfigured systems), these can be empty strings. Leave them unset in such cases. --- jupyterhub/spawner.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 61999c0d..46be3a2a 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -329,8 +329,14 @@ class LocalProcessSpawner(Spawner): def user_env(self, env): env['USER'] = self.user.name - env['HOME'] = pwd.getpwnam(self.user.name).pw_dir - env['SHELL'] = pwd.getpwnam(self.user.name).pw_shell + home = pwd.getpwnam(self.user.name).pw_dir + shell = pwd.getpwnam(self.user.name).pw_shell + # These will be empty if undefined, + # in which case don't set the env: + if home: + env['HOME'] = home + if shell: + env['SHELL'] = shell return env def _env_default(self): From 6b09ff6ef2383aa1af2c8698f095acc66c12ba48 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 16 Mar 2015 11:36:54 -0600 Subject: [PATCH 022/231] add getting started doc --- README.md | 4 + docs/getting-started.md | 325 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 docs/getting-started.md diff --git a/README.md b/README.md index 126af9af..ad11aeaa 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,10 @@ If you want multiple users to be able to sign into the server, you will need to The [wiki](https://github.com/jupyter/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges) describes how to run the server as a less privileged user, which requires more configuration of the system. +## Getting started + +see the [getting started doc](docs/getting-started.md) for some of the basics of configuring your JupyterHub deployment. + ### Some examples generate a default config file: diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 00000000..eeb67da5 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,325 @@ +# Getting started with JupyterHub + +This document describes some of the basics of configuring JupyterHub to do what you want. +JupyterHub is highly customizable, so there's a lot to cover. + + +## Installation + +See [the readme](../README.md) for help installing JupyterHub. + + +## JupyterHub's default behavior + +Let's start by describing what happens when you type `sudo jupyterhub` +after installing it, without any configuration. + + +### Authentication + +The default Authenticator that ships with JupyterHub +authenticates users with their system name and password (via [PAM][]). +Any user on the system with a password will be allowed to start a notebook server. + + +### Spawning servers + +The default Spawner starts servers locally as each user, +one for each server. These servers listen on localhost, +and start in the given user's home directory. + + +### Network + +JupterHub consists of three main categories of processes: + +- Proxy +- Hub +- Spawners + +The Proxy is the public face of the service. +Users access the server via the proxy. +By default, this is listening on all public interfaces on port 8000. +You can access the hub at: + + http://localhost:8000 + +or any other IP or domain pointing for your system. + +The other services, Hub and Spawners, all communicate with each other on localhost only. +If you are going to separate these processes across machines or containers, +you may need to tell them to listen on addresses other than localhost. + +**NOTE** this server is running without SSL encryption. +You should not run JupyterHub without HTTPS if you can help it. + + +### Files + +Starting JupyterHub will write two files to disk in the current working directory: + +- `jupyterhub.sqlite` is the sqlite database containing all of the state of the Hub. + This file allows the Hub to remember what users are running and where, + as well as other information enabling you to + You can change the location of this file with `--db=/path/to/somedb.sqlite`. +- `jupyterhub_cookie_secret` is the encryption key used for securing cookies. + This file needs to persist in order for restarting the Hub server to avoid invalidating cookies. + Conversely, deleting this file and restarting the server effectively invalidates all login cookies. + + +## How to configure JupyterHub + +JupyterHub is configured in two ways: + +- command-line arguments. see `jupyterhub -h` for information about the arguments, + or `jupyterhub --help-all` for a list of everything configurable on the command-line. +- config files. The default config file is `jupyterhub_config.py`, in the current working directory. + You can create an empty config file with `jupyterhub --generate-config` + to see all the configurable values. + You can load a specific config file with `jupyterhub -f /path/to/jupyterhub_config.py`. + + +## Networking + +When it starts, JupyterHub creates two processes: + +- a proxy (`configurable-http-proxy`) +- the Hub itself + +The proxy is the public-facing part of the application. +The default public IP is `''`, which means all interfaces on the machine. +The default port is 8000. +If you want to specify where the Hub application as a whole can be found, +modify these two values. +If you want to listen on a particular IP, +rather than all interfaces, +and you want to use https on port 443, +you can do this at the command-line: + + jupyterhub --ip=10.0.1.2 --port=443 + +Or in a config file: + +```python +c.JupyterHub.ip = '192.168.1.2' +c.JupyterHub.port = 443 +``` + +The Hub service talks to the proxy via a REST API on a separately configurable interface. +By default, this is only on localhost. If you want to run the proxy separate from the Hub, +you may need to configure this ip and port with: + +```python +# ideally a private network address +c.JupyterHub.proxy_api_ip = '10.0.1.4' +c.JupyterHub.proxy_api_port = 5432 +``` + +The Hub service also listens only on localhost by default. +The Hub needs needs to be accessible from both the proxy and all +Spawners. When spawning local servers, localhost is fine, +but if *either* the proxy or (more likely) the Spawners will be remote +or isolated in containers, the Hub must listen on an IP that is accessible. + +```python +c.JupyterHub.hub_ip = '10.0.1.4' +c.JupyterHub.hub_port = 54321 +``` + +## Security + +First of all, since JupyterHub includes authentication, +you really shouldn't run it without SSL (HTTPS). + +To enable HTTPS, specify the path to the ssl key and/or cert +(some cert files also contain the key, in which case only the cert is needed): + +```python +c.JupyterHub.ssl_key = '/path/to/my.key' +c.JupyterHub.ssl_cert = '/path/to/my.cert' +``` + +There are two other aspects of JupyterHub network security. +The Hub authenticates its requests to the proxy via an environment variable, +`CONFIGPROXY_AUTH_TOKEN`. If you want to be able to start or restart the proxy +or Hub independently of each other (not always necessary), +you must set this environment variable before starting the server: + +```bash +export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32` +``` + +If you don't set this, the Hub will generate a random key itself, +which means that any time you restart the Hub you **must also restart the proxy**. +If the proxy is a subprocess of the Hub, this should happen automatically. + +The cookie secret is another key, used to encrypt the cookies used for authentication. +If this value changes for the Hub, all single-user servers must also be restarted. +Normally, this value is stored in the file `jupyterhub_cookie_secret`, which can be specified with: + +```python +c.JupyterHub.cookie_secret_file = '/path/to/cookie_secret' +``` + +If the cookie secret file doesn't exist when the Hub starts, +a new cookie secret is generated and stored in the file. + +If you would like to avoid the need for files, +the value can be loaded from the `JPY_COOKIE_SECRET` env variable: + +```bash +export JPY_COOKIE_SECRET=`openssl rand -hex 1024` +``` + + +## Configuring Authentication + +The default Authenticator uses [PAM][] to authenticate system users with their username and password. +The default behavior of this Authenticator is to allow any users with a password on the system to login. +You can restrict which users are allowed to login with `Authenticator.whitelist`: + +```python +c.Authenticator.whitelist = {'mal', 'zoe', 'inara', 'kaylee'} +``` + +After starting the server, you can add and remove users in the whitelist via the `admin` panel, +which brings us to... + +```python +c.JupyterHub.admin_users = {'mal', 'zoe'} +``` + +Any users in the admin list are automatically added to the whitelist, if they are not already present. + +Admin users have the ability to take actions on users' behalf, +such as stopping and restarting their servers, and adding and removing new users. +If `JupyterHub.admin_access` is True (not default), +then admin users have permission to log in *as other users* on their respective machines, +for debugging. **You should make sure your users know if admin_access is enabled.** + +### adding and removing users + +The default PAMAuthenticator is one case of a special kind of authenticator, +called a LocalAuthenticator, +indicating that it manages users on the local system. +When you add a user to the Hub, a LocalAuthenticator checks if that user already exists. +Normally, there will be an error telling you that the user doesn't exist. +If you set the config value + +```python +c.LocalAuthenticator.create_system_users = True +``` + +however, adding a user to the Hub that doesn't already exist on the system will result +in the Hub creating that user via the system `useradd` mechanism. +This option is typically used on hosted deployments of JupyterHub, +to avoid the need to manually create all your users before launching the service. +It is not recommended when running JupyterHub on 'real' machines with regular users. + + +## Configuring single-user servers + +Since the single-user server is an instance of `ipython notebook`, +an entire separate multi-process application, +there is a lot you can configure, +and a lot of ways to express that configuration. + +At the JupyterHub level, you can set some values on the Spawner. +The simplest of these is `Spawner.notebook_dir`, +which lets you set the root directory for a user's server. +`~` is expanded to the user's home directory. + +```python +c.Spawner.notebook_dir = '~/notebooks' +``` + +You can also specify extra command-line arguments to the notebook server with + +```python +c.Spawner.args = ['--debug', '--profile=PHYS131'] +``` + +Since the single-user server extends the notebook server application, +it still loads configuration from the `ipython_notebook_config.py` config file. +Each user may have one of these files in `$HOME/.ipython/profile_default/`. +IPython also supports loading system-wide config files from `/etc/ipython/`, +which is the place to put configuration that you want to affect all of your users. + + +- setting working directory +- setting default page +- /etc/ipython +- custom Spawner + +## external services + +JupyterHub has a REST API that can be used + +### example: separate notebook-dir from landing url + + +An example case: + +You are hosting JupyterHub on a single cloud server, +using https on the standard https port, 443. +You want to use GitHub OAuth for login, +but need the users to exist locally on the server. +You want users' notebooks to be served from `~/notebooks`, +and you also want the landing page to be `~/notebooks/Welcome.ipynb`, +instead of the directory listing page that is IPython's default. + +Let's start out with `jupyterhub_config.py`: + +```python +c = get_config() + +import os +pjoin = os.path.join + +runtime_dir = os.path.join('/var/run/jupyterhub') +ssl_dir = pjoin(runtime_dir, 'ssl') +if not os.path.exists(ssl_dir): + os.makedirs(ssl_dir) + + +# https on :443 +c.JupyterHub.port = 443 +c.JupyterHub.ssl_key = pjoin(ssl_dir, 'ssl.key') +c.JupyterHub.ssl_cert = pjoin(ssl_dir, 'ssl.cert') + +# put the JupyterHub cookie secret and state db +# in /var/run/jupyterhub +c.JupyterHub.cookie_secret_file = pjoin(runtime_dir, 'cookie_secret') +c.JupyterHub.db_file = pjoin(runtime_dir, 'jupyterhub.sqlite') + +# use GitHub OAuthenticator for local users + +c.JupyterHub.authenticator_class = 'oauthenticator.LocalGitHubOAuthenticator' +c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL'] +# create system users that don't exist yet +c.LocalAuthenticator.create_system_users = True + +# specify users and admin +c.Authenticator.whitelist = {'rgbkrk', 'minrk', 'jhamrick'} +c.JupyterHub.admin_users = {'jhamrick', 'rgbkrk'} + +# start users in ~/assignments, +# with Welcome.ipynb as the default landing page +# this config could also be put in +# /etc/ipython/ipython_notebook_config.py +c.Spawner.notebook_dir = '~/assignments' +c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb'] +``` + +Using the GitHub Authenticator requires a few env variables, +which we will need to set when we launch the server: + +```bash +export GITHUB_CLIENT_ID=github_id +export GITHUB_CLIENT_SECRET=github_secret +export OAUTH_CALLBACK_URL=https://example.com/hub/oauth_callback +jupyterhub -f /path/to/aboveconfig.py +``` + + +[PAM]: http://en.wikipedia.org/wiki/Pluggable_authentication_module From 5a9687b02ab803ba446adf74ee916329d3be4722 Mon Sep 17 00:00:00 2001 From: "Brian E. Granger" Date: Sun, 29 Mar 2015 17:26:11 -0700 Subject: [PATCH 023/231] Editing getting started doc. --- docs/getting-started.md | 294 ++++++++++++++++++++-------------------- 1 file changed, 149 insertions(+), 145 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index eeb67da5..198f1666 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -9,59 +9,50 @@ JupyterHub is highly customizable, so there's a lot to cover. See [the readme](../README.md) for help installing JupyterHub. +## Overview + +JupyterHub is a set of processes that together provide a multiuser Jupyter Notebook server. There +are four main categories of processes run by the `jupyterhub` command line program: + +- *Single User Server*: a dedicated, single-user, Jupyter Notebook is started for each user on the system + when they log in. +- *Proxy*: the public facing part of the server that uses a dynamic proxy to route HTTP requests + to the *Hub* and *Single User Servers*. +- *Hub*: manages user accounts and authentication and coordinates *Single Users Servers* and *Spawners*. +- *Spawner*: starts *Single User Servers* when requested by the *Hub*. + + ## JupyterHub's default behavior -Let's start by describing what happens when you type `sudo jupyterhub` -after installing it, without any configuration. +To start JupyterHub in its default configuration, type the following at the command line: -### Authentication + sudo jupyterhub -The default Authenticator that ships with JupyterHub -authenticates users with their system name and password (via [PAM][]). -Any user on the system with a password will be allowed to start a notebook server. +The default Authenticator that ships with JupyterHub authenticates users with their system name and +password (via [PAM][]). Any user on the system with a password will be allowed to start a notebook server. +The default Spawner starts servers locally as each user, one dedicated server per user. These servers +listen on localhost, and start in the given user's home directory. -### Spawning servers - -The default Spawner starts servers locally as each user, -one for each server. These servers listen on localhost, -and start in the given user's home directory. - - -### Network - -JupterHub consists of three main categories of processes: - -- Proxy -- Hub -- Spawners - -The Proxy is the public face of the service. -Users access the server via the proxy. -By default, this is listening on all public interfaces on port 8000. -You can access the hub at: +By default, the *Proxy* listens on all public interfaces on port 8000. Thus you can reach JupyterHub +through: http://localhost:8000 -or any other IP or domain pointing for your system. +or any other public IP or domain pointing to your system. -The other services, Hub and Spawners, all communicate with each other on localhost only. -If you are going to separate these processes across machines or containers, -you may need to tell them to listen on addresses other than localhost. +In their default configuration, the other services, the *Hub* and *Spawners*, all communicate with each +other on localhost only. -**NOTE** this server is running without SSL encryption. -You should not run JupyterHub without HTTPS if you can help it. +**NOTE:** In its default configuration, JupyterHub runs without SSL encryption (HTTPS). You should not run +JupyterHub without SSL encryption on a public network. +By default, starting JupyterHub will write two files to disk in the current working directory: -### Files - -Starting JupyterHub will write two files to disk in the current working directory: - -- `jupyterhub.sqlite` is the sqlite database containing all of the state of the Hub. - This file allows the Hub to remember what users are running and where, - as well as other information enabling you to - You can change the location of this file with `--db=/path/to/somedb.sqlite`. +- `jupyterhub.sqlite` is the sqlite database containing all of the state of the *Hub*. + This file allows the *Hub* to remember what users are running and where, as well as other + information enabling you to restart parts of JupyterHub separately. - `jupyterhub_cookie_secret` is the encryption key used for securing cookies. This file needs to persist in order for restarting the Hub server to avoid invalidating cookies. Conversely, deleting this file and restarting the server effectively invalidates all login cookies. @@ -71,43 +62,56 @@ Starting JupyterHub will write two files to disk in the current working director JupyterHub is configured in two ways: -- command-line arguments. see `jupyterhub -h` for information about the arguments, - or `jupyterhub --help-all` for a list of everything configurable on the command-line. -- config files. The default config file is `jupyterhub_config.py`, in the current working directory. - You can create an empty config file with `jupyterhub --generate-config` - to see all the configurable values. - You can load a specific config file with `jupyterhub -f /path/to/jupyterhub_config.py`. +1. Command-line arguments +2. Configuration files + +Type the following for brief information about the command line arguments: + + jupyterhub -h + +or: + + jupyterhub --help-all + +for the full command line help. + +By default, JupyterHub will look for a configuration file (can be missing) named `jupyterhub_config.py` in +the current working directory. You can create an empty configuration file with + + + jupyterhub --generate-config + +This empty configuration file has descriptions of all configuration variables and their default values. +You can load a specific config file with: + + + jupyterhub -f /path/to/jupyterhub_config.py ## Networking -When it starts, JupyterHub creates two processes: +In most situations you will want to change the main IP address and port of the Proxy. This address determines where JupyterHub is available to your users. The default is all network interfaces `''` on port 8000. -- a proxy (`configurable-http-proxy`) -- the Hub itself +This can be done with the following command line arguments: -The proxy is the public-facing part of the application. -The default public IP is `''`, which means all interfaces on the machine. -The default port is 8000. -If you want to specify where the Hub application as a whole can be found, -modify these two values. -If you want to listen on a particular IP, -rather than all interfaces, -and you want to use https on port 443, -you can do this at the command-line: + jupyterhub --ip=192.168.1.2 --port=443 - jupyterhub --ip=10.0.1.2 --port=443 - -Or in a config file: +Or you can put the following lines in configuration file: ```python c.JupyterHub.ip = '192.168.1.2' c.JupyterHub.port = 443 ``` -The Hub service talks to the proxy via a REST API on a separately configurable interface. -By default, this is only on localhost. If you want to run the proxy separate from the Hub, -you may need to configure this ip and port with: +Port 443 is used in these examples as it is the default port for SSL/HTTPS. + +Simply configuring the main IP and port of JupyterHub should be sufficient for most deployments of +JupyterHub. However, for more customized scenarios, you can configure the following additional networking +details. + +The Hub service talks to the proxy via a REST API on a secondary port, whose network interface and port +can be configured separately. By default, this REST API listens on localhost ponly. If you want to run the +proxy separate from the Hub, you may need to configure this ip and port with: ```python # ideally a private network address @@ -115,11 +119,10 @@ c.JupyterHub.proxy_api_ip = '10.0.1.4' c.JupyterHub.proxy_api_port = 5432 ``` -The Hub service also listens only on localhost by default. -The Hub needs needs to be accessible from both the proxy and all -Spawners. When spawning local servers, localhost is fine, -but if *either* the proxy or (more likely) the Spawners will be remote -or isolated in containers, the Hub must listen on an IP that is accessible. +The Hub service also listens only on localhost by default. The Hub needs needs to be accessible from both +the proxy and all Spawners. When spawning local servers, localhost is fine, but if *either* the Proxy or +(more likely) the Spawners will be remote or isolated in containers, the Hub must listen on an IP that is +accessible. ```python c.JupyterHub.hub_ip = '10.0.1.4' @@ -128,48 +131,50 @@ c.JupyterHub.hub_port = 54321 ## Security -First of all, since JupyterHub includes authentication, -you really shouldn't run it without SSL (HTTPS). - -To enable HTTPS, specify the path to the ssl key and/or cert -(some cert files also contain the key, in which case only the cert is needed): +First of all, since JupyterHub includes authentication, you should not run it without SSL (HTTPS). This will require you to obtain an official SSL certificate or create a self-signed certificate. Once you have obtained a key and certificate you need to pass their locations to JupyterHub's configuration as follows: ```python c.JupyterHub.ssl_key = '/path/to/my.key' c.JupyterHub.ssl_cert = '/path/to/my.cert' ``` +Some cert files also contain the key, in which case only the cert is needed. It is important that these files be put in a secure location on your server. + There are two other aspects of JupyterHub network security. -The Hub authenticates its requests to the proxy via an environment variable, -`CONFIGPROXY_AUTH_TOKEN`. If you want to be able to start or restart the proxy -or Hub independently of each other (not always necessary), -you must set this environment variable before starting the server: + +The cookie secret is encryption key, used to encrypt the cookies used for authentication. If this value +changes for the Hub, all single-user servers must also be restarted. Normally, this value is stored in the +file `jupyterhub_cookie_secret`, which can be specified with: + +```python +c.JupyterHub.cookie_secret_file = '/path/to/jupyterhub_cookie_secret' +``` + +In most deployments of JupyterHub, you should point this to a secure location on the file system. If the +cookie secret file doesn't exist when the Hub starts, a new cookie secret is generated and stored in the +file. + +If you would like to avoid the need for files, the value can be loaded from the `JPY_COOKIE_SECRET` env +variable: + +```bash +export JPY_COOKIE_SECRET=`openssl rand -hex 1024` +``` + +The Hub authenticates its requests to the Proxy via an environment variable, `CONFIGPROXY_AUTH_TOKEN`. If +you want to be able to start or restart the proxy or Hub independently of each other (not always +necessary), you must set this environment variable before starting the server: + ```bash export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32` ``` -If you don't set this, the Hub will generate a random key itself, -which means that any time you restart the Hub you **must also restart the proxy**. -If the proxy is a subprocess of the Hub, this should happen automatically. +If you don't set this, the Hub will generate a random key itself, which means that any time you restart +the Hub you **must also restart the Proxy**. If the proxy is a subprocess of the Hub, this should happen +automatically (this is the default configuration). -The cookie secret is another key, used to encrypt the cookies used for authentication. -If this value changes for the Hub, all single-user servers must also be restarted. -Normally, this value is stored in the file `jupyterhub_cookie_secret`, which can be specified with: - -```python -c.JupyterHub.cookie_secret_file = '/path/to/cookie_secret' -``` - -If the cookie secret file doesn't exist when the Hub starts, -a new cookie secret is generated and stored in the file. - -If you would like to avoid the need for files, -the value can be loaded from the `JPY_COOKIE_SECRET` env variable: - -```bash -export JPY_COOKIE_SECRET=`openssl rand -hex 1024` -``` +**(Min: does the cookie secret and auth token env vars need to be only visible by root?)** What if other users can see them? ## Configuring Authentication @@ -182,91 +187,90 @@ You can restrict which users are allowed to login with `Authenticator.whitelist` c.Authenticator.whitelist = {'mal', 'zoe', 'inara', 'kaylee'} ``` -After starting the server, you can add and remove users in the whitelist via the `admin` panel, -which brings us to... +Admin users of JupyterHub have the ability to take actions on users' behalf, such as stopping and +restarting their servers, and adding and removing new users from the whitelist. Any users in the admin list are automatically added to the whitelist, if they are not already present. The set of initial Admin users can configured as follows: ```python c.JupyterHub.admin_users = {'mal', 'zoe'} ``` -Any users in the admin list are automatically added to the whitelist, if they are not already present. - -Admin users have the ability to take actions on users' behalf, -such as stopping and restarting their servers, and adding and removing new users. -If `JupyterHub.admin_access` is True (not default), -then admin users have permission to log in *as other users* on their respective machines, -for debugging. **You should make sure your users know if admin_access is enabled.** +If `JupyterHub.admin_access` is True (not default), then admin users have permission to log in *as other +users* on their respective machines, for debugging. **You should make sure your users know if admin_access +is enabled.** ### adding and removing users -The default PAMAuthenticator is one case of a special kind of authenticator, -called a LocalAuthenticator, -indicating that it manages users on the local system. -When you add a user to the Hub, a LocalAuthenticator checks if that user already exists. -Normally, there will be an error telling you that the user doesn't exist. -If you set the config value +The default PAMAuthenticator is one case of a special kind of authenticator, called a LocalAuthenticator, +indicating that it manages users on the local system. When you add a user to the Hub, a LocalAuthenticator +checks if that user already exists. Normally, there will be an error telling you that the user doesn't +exist. If you set the configuration value + ```python c.LocalAuthenticator.create_system_users = True ``` -however, adding a user to the Hub that doesn't already exist on the system will result -in the Hub creating that user via the system `useradd` mechanism. -This option is typically used on hosted deployments of JupyterHub, -to avoid the need to manually create all your users before launching the service. -It is not recommended when running JupyterHub on 'real' machines with regular users. +however, adding a user to the Hub that doesn't already exist on the system will result in the Hub creating +that user via the system `useradd` mechanism. This option is typically used on hosted deployments of +JupyterHub, to avoid the need to manually create all your users before launching the service. It is not +recommended when running JupyterHub on 'real' machines with regular users. ## Configuring single-user servers -Since the single-user server is an instance of `ipython notebook`, -an entire separate multi-process application, -there is a lot you can configure, -and a lot of ways to express that configuration. +Since the single-user server is an instance of `ipython notebook`, an entire separate multi-process +application, there is are many aspect of that server can configure, and a lot of ways to express that +configuration. -At the JupyterHub level, you can set some values on the Spawner. -The simplest of these is `Spawner.notebook_dir`, -which lets you set the root directory for a user's server. -`~` is expanded to the user's home directory. +At the JupyterHub level, you can set some values on the Spawner. The simplest of these is +`Spawner.notebook_dir`, which lets you set the root directory for a user's server. This root notebook +directory is the highest level directory users will be able to access in the notebook dashbard. In this +example the root notebook directory is set to `~/notebooks`, where `~` is expanded to the user's home +directory. ```python c.Spawner.notebook_dir = '~/notebooks' ``` -You can also specify extra command-line arguments to the notebook server with +You can also specify extra command-line arguments to the notebook server with: ```python c.Spawner.args = ['--debug', '--profile=PHYS131'] ``` -Since the single-user server extends the notebook server application, -it still loads configuration from the `ipython_notebook_config.py` config file. -Each user may have one of these files in `$HOME/.ipython/profile_default/`. -IPython also supports loading system-wide config files from `/etc/ipython/`, -which is the place to put configuration that you want to affect all of your users. +This could be used to set the users default page for the single user server: +```python +c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb'] +``` + +Since the single-user server extends the notebook server application, it still loads configuration from +the `ipython_notebook_config.py` config file. Each user may have one of these files in +`$HOME/.ipython/profile_default/`. IPython also supports loading system-wide config files from +`/etc/ipython/`, which is the place to put configuration that you want to affect all of your users. - setting working directory -- setting default page - /etc/ipython - custom Spawner -## external services +**Min, looks like you were going to add more content here...** -JupyterHub has a REST API that can be used +## File locations -### example: separate notebook-dir from landing url +Let's add a section about best practices of where to put the various files described above, including the log files (and how to configure that) -An example case: +## Example -You are hosting JupyterHub on a single cloud server, -using https on the standard https port, 443. -You want to use GitHub OAuth for login, -but need the users to exist locally on the server. -You want users' notebooks to be served from `~/notebooks`, -and you also want the landing page to be `~/notebooks/Welcome.ipynb`, -instead of the directory listing page that is IPython's default. +In the following example, we show a configuration files for a fairly standard JupyterHub deployment with the following assumptions: + +* JupyterHub is running on a single cloud server +* Using SSL on the standard HTTPS port 443 +* You want to use GitHub OAuth for login +* You need the users to exist locally on the server +* You want users' notebooks to be served from `/home` to allow users to browse for notebooks within + other users home directories +* You want the landing page for each user to be `/tree/~` to show them only their own notebooks by default Let's start out with `jupyterhub_config.py`: @@ -307,8 +311,8 @@ c.JupyterHub.admin_users = {'jhamrick', 'rgbkrk'} # with Welcome.ipynb as the default landing page # this config could also be put in # /etc/ipython/ipython_notebook_config.py -c.Spawner.notebook_dir = '~/assignments' -c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb'] +c.Spawner.notebook_dir = '/home' +c.Spawner.args = ['--NotebookApp.default_url=/tree/~'] ``` Using the GitHub Authenticator requires a few env variables, From 491ee38a3768ad0220a3600bd2696d85486d61d6 Mon Sep 17 00:00:00 2001 From: "Brian E. Granger" Date: Sun, 29 Mar 2015 17:43:42 -0700 Subject: [PATCH 024/231] More edits... --- docs/getting-started.md | 44 +++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 198f1666..e0c68d60 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -90,13 +90,13 @@ You can load a specific config file with: ## Networking -In most situations you will want to change the main IP address and port of the Proxy. This address determines where JupyterHub is available to your users. The default is all network interfaces `''` on port 8000. +In most situations you will want to change the main IP address and port of the Proxy. This address determines where JupyterHub is available to your users. The default is all network interfaces (`''`) on port 8000. This can be done with the following command line arguments: jupyterhub --ip=192.168.1.2 --port=443 -Or you can put the following lines in configuration file: +Or you can put the following lines in a configuration file: ```python c.JupyterHub.ip = '192.168.1.2' @@ -110,8 +110,8 @@ JupyterHub. However, for more customized scenarios, you can configure the follow details. The Hub service talks to the proxy via a REST API on a secondary port, whose network interface and port -can be configured separately. By default, this REST API listens on localhost ponly. If you want to run the -proxy separate from the Hub, you may need to configure this ip and port with: +can be configured separately. By default, this REST API listens on localhost only. If you want to run the +Proxy separate from the Hub, you may need to configure this IP and port with: ```python # ideally a private network address @@ -131,7 +131,7 @@ c.JupyterHub.hub_port = 54321 ## Security -First of all, since JupyterHub includes authentication, you should not run it without SSL (HTTPS). This will require you to obtain an official SSL certificate or create a self-signed certificate. Once you have obtained a key and certificate you need to pass their locations to JupyterHub's configuration as follows: +First of all, since JupyterHub includes authentication and allows arbitrary code execution, you should not run it without SSL (HTTPS). This will require you to obtain an official SSL certificate or create a self-signed certificate. Once you have obtained and installed a key and certificate you need to pass their locations to JupyterHub's configuration as follows: ```python c.JupyterHub.ssl_key = '/path/to/my.key' @@ -179,9 +179,10 @@ automatically (this is the default configuration). ## Configuring Authentication -The default Authenticator uses [PAM][] to authenticate system users with their username and password. -The default behavior of this Authenticator is to allow any users with a password on the system to login. -You can restrict which users are allowed to login with `Authenticator.whitelist`: +The default Authenticator uses [PAM][] to authenticate system users with their username and password. The +default behavior of this Authenticator is to allow any user with an account and password on the system to +login. You can restrict which users are allowed to login with `Authenticator.whitelist`: + ```python c.Authenticator.whitelist = {'mal', 'zoe', 'inara', 'kaylee'} @@ -198,7 +199,7 @@ If `JupyterHub.admin_access` is True (not default), then admin users have permis users* on their respective machines, for debugging. **You should make sure your users know if admin_access is enabled.** -### adding and removing users +### Adding and removing users The default PAMAuthenticator is one case of a special kind of authenticator, called a LocalAuthenticator, indicating that it manages users on the local system. When you add a user to the Hub, a LocalAuthenticator @@ -212,20 +213,22 @@ c.LocalAuthenticator.create_system_users = True however, adding a user to the Hub that doesn't already exist on the system will result in the Hub creating that user via the system `useradd` mechanism. This option is typically used on hosted deployments of -JupyterHub, to avoid the need to manually create all your users before launching the service. It is not -recommended when running JupyterHub on 'real' machines with regular users. +JupyterHub (using Docker), to avoid the need to manually create all your users before launching the +service. It is not recommended when running JupyterHub in situations where JupyterHub users maps directly +onto UNIX users. + ## Configuring single-user servers Since the single-user server is an instance of `ipython notebook`, an entire separate multi-process -application, there is are many aspect of that server can configure, and a lot of ways to express that +application, there are many aspect of that server can configure, and a lot of ways to express that configuration. At the JupyterHub level, you can set some values on the Spawner. The simplest of these is `Spawner.notebook_dir`, which lets you set the root directory for a user's server. This root notebook -directory is the highest level directory users will be able to access in the notebook dashbard. In this -example the root notebook directory is set to `~/notebooks`, where `~` is expanded to the user's home +directory is the highest level directory users will be able to access in the notebook dashboard. In this +example, the root notebook directory is set to `~/notebooks`, where `~` is expanded to the user's home directory. ```python @@ -253,12 +256,14 @@ the `ipython_notebook_config.py` config file. Each user may have one of these fi - /etc/ipython - custom Spawner -**Min, looks like you were going to add more content here...** ## File locations -Let's add a section about best practices of where to put the various files described above, including the log files (and how to configure that) +It is recommended to put all of the files used by JupyterHub into standard UNIX filesystem locations. +* `/srv/jupyter` for all security and runtime files +* `/etc/jupyter` for all configuration files +* `/var/log` for log files ## Example @@ -271,16 +276,18 @@ In the following example, we show a configuration files for a fairly standard Ju * You want users' notebooks to be served from `/home` to allow users to browse for notebooks within other users home directories * You want the landing page for each user to be `/tree/~` to show them only their own notebooks by default +* All runtime files are put into `/srv/jupyter` and log files in `/var/log`. Let's start out with `jupyterhub_config.py`: ```python +# jupyterhub_config.py c = get_config() import os pjoin = os.path.join -runtime_dir = os.path.join('/var/run/jupyterhub') +runtime_dir = os.path.join('/srv/jupyterhub') ssl_dir = pjoin(runtime_dir, 'ssl') if not os.path.exists(ssl_dir): os.makedirs(ssl_dir) @@ -296,6 +303,9 @@ c.JupyterHub.ssl_cert = pjoin(ssl_dir, 'ssl.cert') c.JupyterHub.cookie_secret_file = pjoin(runtime_dir, 'cookie_secret') c.JupyterHub.db_file = pjoin(runtime_dir, 'jupyterhub.sqlite') +# put the log file in /var/log +c.JupyterHub.log_file = '/var/log/jupyterhub.log' + # use GitHub OAuthenticator for local users c.JupyterHub.authenticator_class = 'oauthenticator.LocalGitHubOAuthenticator' From ca1380eb066e7fb922cfb48d69bdeb4fbbcbd4d8 Mon Sep 17 00:00:00 2001 From: "Brian E. Granger" Date: Mon, 30 Mar 2015 20:55:09 -0700 Subject: [PATCH 025/231] Addressing review comments. --- docs/getting-started.md | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index e0c68d60..758013f1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -12,15 +12,13 @@ See [the readme](../README.md) for help installing JupyterHub. ## Overview JupyterHub is a set of processes that together provide a multiuser Jupyter Notebook server. There -are four main categories of processes run by the `jupyterhub` command line program: +are three main categories of processes run by the `jupyterhub` command line program: - *Single User Server*: a dedicated, single-user, Jupyter Notebook is started for each user on the system - when they log in. + when they log in. The object that starts these processes is called a *Spawner*. - *Proxy*: the public facing part of the server that uses a dynamic proxy to route HTTP requests to the *Hub* and *Single User Servers*. -- *Hub*: manages user accounts and authentication and coordinates *Single Users Servers* and *Spawners*. -- *Spawner*: starts *Single User Servers* when requested by the *Hub*. - +- *Hub*: manages user accounts and authentication and coordinates *Single Users Servers* using a *Spawner*. ## JupyterHub's default behavior @@ -42,7 +40,7 @@ through: or any other public IP or domain pointing to your system. -In their default configuration, the other services, the *Hub* and *Spawners*, all communicate with each +In their default configuration, the other services, the *Hub* and *Single-User Servers*, all communicate with each other on localhost only. **NOTE:** In its default configuration, JupyterHub runs without SSL encryption (HTTPS). You should not run @@ -142,7 +140,7 @@ Some cert files also contain the key, in which case only the cert is needed. It There are two other aspects of JupyterHub network security. -The cookie secret is encryption key, used to encrypt the cookies used for authentication. If this value +The cookie secret is an encryption key, used to encrypt the cookies used for authentication. If this value changes for the Hub, all single-user servers must also be restarted. Normally, this value is stored in the file `jupyterhub_cookie_secret`, which can be specified with: @@ -161,6 +159,8 @@ variable: export JPY_COOKIE_SECRET=`openssl rand -hex 1024` ``` +For security reasons, this env variable should only be visible to the Hub. + The Hub authenticates its requests to the Proxy via an environment variable, `CONFIGPROXY_AUTH_TOKEN`. If you want to be able to start or restart the proxy or Hub independently of each other (not always necessary), you must set this environment variable before starting the server: @@ -170,11 +170,10 @@ necessary), you must set this environment variable before starting the server: export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32` ``` -If you don't set this, the Hub will generate a random key itself, which means that any time you restart -the Hub you **must also restart the Proxy**. If the proxy is a subprocess of the Hub, this should happen -automatically (this is the default configuration). +This env variable needs to be visible to the Hub and Proxy. If you don't set this, the Hub will generate a +random key itself, which means that any time you restart the Hub you **must also restart the Proxy**. If +the proxy is a subprocess of the Hub, this should happen automatically (this is the default configuration). -**(Min: does the cookie secret and auth token env vars need to be only visible by root?)** What if other users can see them? ## Configuring Authentication @@ -213,7 +212,7 @@ c.LocalAuthenticator.create_system_users = True however, adding a user to the Hub that doesn't already exist on the system will result in the Hub creating that user via the system `useradd` mechanism. This option is typically used on hosted deployments of -JupyterHub (using Docker), to avoid the need to manually create all your users before launching the +JupyterHub, to avoid the need to manually create all your users before launching the service. It is not recommended when running JupyterHub in situations where JupyterHub users maps directly onto UNIX users. @@ -301,7 +300,7 @@ c.JupyterHub.ssl_cert = pjoin(ssl_dir, 'ssl.cert') # put the JupyterHub cookie secret and state db # in /var/run/jupyterhub c.JupyterHub.cookie_secret_file = pjoin(runtime_dir, 'cookie_secret') -c.JupyterHub.db_file = pjoin(runtime_dir, 'jupyterhub.sqlite') +c.JupyterHub.db_url = pjoin(runtime_dir, 'jupyterhub.sqlite') # put the log file in /var/log c.JupyterHub.log_file = '/var/log/jupyterhub.log' @@ -317,12 +316,12 @@ c.LocalAuthenticator.create_system_users = True c.Authenticator.whitelist = {'rgbkrk', 'minrk', 'jhamrick'} c.JupyterHub.admin_users = {'jhamrick', 'rgbkrk'} -# start users in ~/assignments, -# with Welcome.ipynb as the default landing page +# start single-user notebook servers in ~/assignments, +# with ~/assignments/Welcome.ipynb as the default landing page # this config could also be put in # /etc/ipython/ipython_notebook_config.py -c.Spawner.notebook_dir = '/home' -c.Spawner.args = ['--NotebookApp.default_url=/tree/~'] +c.Spawner.notebook_dir = '~/assignments' +c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb'] ``` Using the GitHub Authenticator requires a few env variables, From b30be43d22deb518f35a1baa50fa80defb0ef705 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 23 Mar 2015 12:22:00 -0700 Subject: [PATCH 026/231] move admin_users from JupyterHub to Authenticator --- docs/getting-started.md | 4 ++-- jupyterhub/app.py | 24 +++++++++++++++--------- jupyterhub/auth.py | 6 ++++++ jupyterhub/tests/mocking.py | 6 +++--- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 758013f1..be019c48 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -191,7 +191,7 @@ Admin users of JupyterHub have the ability to take actions on users' behalf, suc restarting their servers, and adding and removing new users from the whitelist. Any users in the admin list are automatically added to the whitelist, if they are not already present. The set of initial Admin users can configured as follows: ```python -c.JupyterHub.admin_users = {'mal', 'zoe'} +c.Authenticator.admin_users = {'mal', 'zoe'} ``` If `JupyterHub.admin_access` is True (not default), then admin users have permission to log in *as other @@ -314,7 +314,7 @@ c.LocalAuthenticator.create_system_users = True # specify users and admin c.Authenticator.whitelist = {'rgbkrk', 'minrk', 'jhamrick'} -c.JupyterHub.admin_users = {'jhamrick', 'rgbkrk'} +c.Authenticator.admin_users = {'jhamrick', 'rgbkrk'} # start single-user notebook servers in ~/assignments, # with ~/assignments/Welcome.ipynb as the default landing page diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 48f2ff37..8c9d3537 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -351,11 +351,9 @@ class JupyterHub(Application): """ ) admin_users = Set(config=True, - help="""set of usernames of admin users - - If unspecified, only the user that launches the server will be admin. - """ + help="""DEPRECATED, use Authenticator.admin_users instead.""" ) + tornado_settings = Dict(config=True) cleanup_servers = Bool(True, config=True, @@ -580,16 +578,24 @@ class JupyterHub(Application): def init_users(self): """Load users into and from the database""" db = self.db - - if not self.admin_users: + + if self.admin_users and not self.authenticator.admin_users: + self.log.warn( + "\nJupyterHub.admin_users is deprecated." + "\nUse Authenticator.admin_users instead." + ) + self.authenticator.admin_users = self.admin_users + admin_users = self.authenticator.admin_users + + if not admin_users: # add current user as admin if there aren't any others admins = db.query(orm.User).filter(orm.User.admin==True) if admins.first() is None: - self.admin_users.add(getuser()) + admin_users.add(getuser()) new_users = [] - for name in self.admin_users: + for name in admin_users: # ensure anyone specified as admin in config is admin in db user = orm.User.find(db, name) if user is None: @@ -810,7 +816,7 @@ class JupyterHub(Application): db=self.db, proxy=self.proxy, hub=self.hub, - admin_users=self.admin_users, + admin_users=self.authenticator.admin_users, admin_access=self.admin_access, authenticator=self.authenticator, spawner_class=self.spawner_class, diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py index 9c235e9b..74427d1e 100644 --- a/jupyterhub/auth.py +++ b/jupyterhub/auth.py @@ -23,6 +23,12 @@ class Authenticator(LoggingConfigurable): """ db = Any() + admin_users = Set(config=True, + help="""set of usernames of admin users + + If unspecified, only the user that launches the server will be admin. + """ + ) whitelist = Set(config=True, help="""Username whitelist. diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py index 02a38c1b..fe8842e5 100644 --- a/jupyterhub/tests/mocking.py +++ b/jupyterhub/tests/mocking.py @@ -72,6 +72,9 @@ class NeverSpawner(MockSpawner): class MockPAMAuthenticator(PAMAuthenticator): + def _admin_users_default(self): + return {'admin'} + def system_user_exists(self, user): # skip the add-system-user bit return not user.name.startswith('dne') @@ -94,9 +97,6 @@ class MockHub(JupyterHub): def _spawner_class_default(self): return MockSpawner - def _admin_users_default(self): - return {'admin'} - def init_signal(self): pass From 30eef4d353a5beee263a369982f54ff6a514fa5f Mon Sep 17 00:00:00 2001 From: Min RK Date: Sun, 12 Apr 2015 13:55:38 -0700 Subject: [PATCH 027/231] finish up first round of getting-started --- docs/getting-started.md | 217 +++++++++++++++++++++++++--------------- 1 file changed, 134 insertions(+), 83 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index be019c48..1304ef35 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -11,8 +11,8 @@ See [the readme](../README.md) for help installing JupyterHub. ## Overview -JupyterHub is a set of processes that together provide a multiuser Jupyter Notebook server. There -are three main categories of processes run by the `jupyterhub` command line program: +JupyterHub is a set of processes that together provide a multiuser Jupyter Notebook server. +There are three main categories of processes run by the `jupyterhub` command line program: - *Single User Server*: a dedicated, single-user, Jupyter Notebook is started for each user on the system when they log in. The object that starts these processes is called a *Spawner*. @@ -27,33 +27,38 @@ To start JupyterHub in its default configuration, type the following at the comm sudo jupyterhub -The default Authenticator that ships with JupyterHub authenticates users with their system name and -password (via [PAM][]). Any user on the system with a password will be allowed to start a notebook server. +The default Authenticator that ships with JupyterHub authenticates users +with their system name and password (via [PAM][]). +Any user on the system with a password will be allowed to start a single-user notebook server. -The default Spawner starts servers locally as each user, one dedicated server per user. These servers -listen on localhost, and start in the given user's home directory. +The default Spawner starts servers locally as each user, one dedicated server per user. +These servers listen on localhost, and start in the given user's home directory. -By default, the *Proxy* listens on all public interfaces on port 8000. Thus you can reach JupyterHub -through: +By default, the *Proxy* listens on all public interfaces on port 8000. +Thus you can reach JupyterHub through: http://localhost:8000 or any other public IP or domain pointing to your system. -In their default configuration, the other services, the *Hub* and *Single-User Servers*, all communicate with each -other on localhost only. +In their default configuration, the other services, the *Hub* and *Single-User Servers*, +all communicate with each other on localhost only. -**NOTE:** In its default configuration, JupyterHub runs without SSL encryption (HTTPS). You should not run -JupyterHub without SSL encryption on a public network. +**NOTE:** In its default configuration, JupyterHub runs without SSL encryption (HTTPS). +You should not run JupyterHub without SSL encryption on a public network. +See [below](#Security) for how to configure JupyterHub to use SSL. By default, starting JupyterHub will write two files to disk in the current working directory: - `jupyterhub.sqlite` is the sqlite database containing all of the state of the *Hub*. - This file allows the *Hub* to remember what users are running and where, as well as other - information enabling you to restart parts of JupyterHub separately. + This file allows the *Hub* to remember what users are running and where, + as well as other information enabling you to restart parts of JupyterHub separately. - `jupyterhub_cookie_secret` is the encryption key used for securing cookies. This file needs to persist in order for restarting the Hub server to avoid invalidating cookies. Conversely, deleting this file and restarting the server effectively invalidates all login cookies. + The cookie secret file is discussed [below](#Security). + +The location of these files can be specified via configuration, discussed below. ## How to configure JupyterHub @@ -73,8 +78,9 @@ or: for the full command line help. -By default, JupyterHub will look for a configuration file (can be missing) named `jupyterhub_config.py` in -the current working directory. You can create an empty configuration file with +By default, JupyterHub will look for a configuration file (can be missing) +named `jupyterhub_config.py` in the current working directory. +You can create an empty configuration file with jupyterhub --generate-config @@ -85,10 +91,15 @@ You can load a specific config file with: jupyterhub -f /path/to/jupyterhub_config.py +See also: [general docs](http://ipython.org/ipython-doc/dev/development/config.html) +on the config system Jupyter uses. + ## Networking -In most situations you will want to change the main IP address and port of the Proxy. This address determines where JupyterHub is available to your users. The default is all network interfaces (`''`) on port 8000. +In most situations you will want to change the main IP address and port of the Proxy. +This address determines where JupyterHub is available to your users. +The default is all network interfaces (`''`) on port 8000. This can be done with the following command line arguments: @@ -103,13 +114,15 @@ c.JupyterHub.port = 443 Port 443 is used in these examples as it is the default port for SSL/HTTPS. -Simply configuring the main IP and port of JupyterHub should be sufficient for most deployments of -JupyterHub. However, for more customized scenarios, you can configure the following additional networking -details. +Configuring only the main IP and port of JupyterHub should be sufficient for most deployments of JupyterHub. +However, for more customized scenarios, +you can configure the following additional networking details. -The Hub service talks to the proxy via a REST API on a secondary port, whose network interface and port -can be configured separately. By default, this REST API listens on localhost only. If you want to run the -Proxy separate from the Hub, you may need to configure this IP and port with: +The Hub service talks to the proxy via a REST API on a secondary port, +whose network interface and port can be configured separately. +By default, this REST API listens on port 8081 of localhost only. +If you want to run the Proxy separate from the Hub, +you may need to configure this IP and port with: ```python # ideally a private network address @@ -117,10 +130,11 @@ c.JupyterHub.proxy_api_ip = '10.0.1.4' c.JupyterHub.proxy_api_port = 5432 ``` -The Hub service also listens only on localhost by default. The Hub needs needs to be accessible from both -the proxy and all Spawners. When spawning local servers, localhost is fine, but if *either* the Proxy or -(more likely) the Spawners will be remote or isolated in containers, the Hub must listen on an IP that is -accessible. +The Hub service also listens only on localhost by default. +The Hub needs needs to be accessible from both the proxy and all Spawners. +When spawning local servers localhost is fine, +but if *either* the Proxy or (more likely) the Spawners will be remote or isolated in containers, +the Hub must listen on an IP that is accessible. ```python c.JupyterHub.hub_ip = '10.0.1.4' @@ -129,31 +143,38 @@ c.JupyterHub.hub_port = 54321 ## Security -First of all, since JupyterHub includes authentication and allows arbitrary code execution, you should not run it without SSL (HTTPS). This will require you to obtain an official SSL certificate or create a self-signed certificate. Once you have obtained and installed a key and certificate you need to pass their locations to JupyterHub's configuration as follows: +First of all, since JupyterHub includes authentication and allows arbitrary code execution, +you should not run it without SSL (HTTPS). +This will require you to obtain an official SSL certificate or create a self-signed certificate. +Once you have obtained and installed a key and certificate +you need to pass their locations to JupyterHub's configuration as follows: ```python c.JupyterHub.ssl_key = '/path/to/my.key' c.JupyterHub.ssl_cert = '/path/to/my.cert' ``` -Some cert files also contain the key, in which case only the cert is needed. It is important that these files be put in a secure location on your server. +Some cert files also contain the key, in which case only the cert is needed. +It is important that these files be put in a secure location on your server. There are two other aspects of JupyterHub network security. -The cookie secret is an encryption key, used to encrypt the cookies used for authentication. If this value -changes for the Hub, all single-user servers must also be restarted. Normally, this value is stored in the -file `jupyterhub_cookie_secret`, which can be specified with: +The cookie secret is an encryption key, used to encrypt the cookies used for authentication. +If this value changes for the Hub, +all single-user servers must also be restarted. +Normally, this value is stored in the file `jupyterhub_cookie_secret`, +which can be specified with: ```python c.JupyterHub.cookie_secret_file = '/path/to/jupyterhub_cookie_secret' ``` -In most deployments of JupyterHub, you should point this to a secure location on the file system. If the -cookie secret file doesn't exist when the Hub starts, a new cookie secret is generated and stored in the -file. +In most deployments of JupyterHub, you should point this to a secure location on the file system. +If the cookie secret file doesn't exist when the Hub starts, +a new cookie secret is generated and stored in the file. -If you would like to avoid the need for files, the value can be loaded from the `JPY_COOKIE_SECRET` env -variable: +If you would like to avoid the need for files, +the value can be loaded in the Hub process from the `JPY_COOKIE_SECRET` env variable: ```bash export JPY_COOKIE_SECRET=`openssl rand -hex 1024` @@ -161,74 +182,92 @@ export JPY_COOKIE_SECRET=`openssl rand -hex 1024` For security reasons, this env variable should only be visible to the Hub. -The Hub authenticates its requests to the Proxy via an environment variable, `CONFIGPROXY_AUTH_TOKEN`. If -you want to be able to start or restart the proxy or Hub independently of each other (not always -necessary), you must set this environment variable before starting the server: +The Hub authenticates its requests to the Proxy via an environment variable, `CONFIGPROXY_AUTH_TOKEN`. +If you want to be able to start or restart the proxy or Hub independently of each other (not always necessary), +you must set this environment variable before starting the server (for both the Hub and Proxy): ```bash export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32` ``` -This env variable needs to be visible to the Hub and Proxy. If you don't set this, the Hub will generate a -random key itself, which means that any time you restart the Hub you **must also restart the Proxy**. If -the proxy is a subprocess of the Hub, this should happen automatically (this is the default configuration). +This env variable needs to be visible to the Hub and Proxy. +If you don't set this, the Hub will generate a random key itself, +which means that any time you restart the Hub you **must also restart the Proxy**. +If the proxy is a subprocess of the Hub, +this should happen automatically (this is the default configuration). ## Configuring Authentication -The default Authenticator uses [PAM][] to authenticate system users with their username and password. The -default behavior of this Authenticator is to allow any user with an account and password on the system to -login. You can restrict which users are allowed to login with `Authenticator.whitelist`: +The default Authenticator uses [PAM][] to authenticate system users with their username and password. +The default behavior of this Authenticator is to allow any user with an account and password on the system to login. +You can restrict which users are allowed to login with `Authenticator.whitelist`: ```python c.Authenticator.whitelist = {'mal', 'zoe', 'inara', 'kaylee'} ``` -Admin users of JupyterHub have the ability to take actions on users' behalf, such as stopping and -restarting their servers, and adding and removing new users from the whitelist. Any users in the admin list are automatically added to the whitelist, if they are not already present. The set of initial Admin users can configured as follows: +Admin users of JupyterHub have the ability to take actions on users' behalf, +such as stopping and restarting their servers, +and adding and removing new users from the whitelist. +Any users in the admin list are automatically added to the whitelist, +if they are not already present. +The set of initial Admin users can configured as follows: ```python c.Authenticator.admin_users = {'mal', 'zoe'} ``` -If `JupyterHub.admin_access` is True (not default), then admin users have permission to log in *as other -users* on their respective machines, for debugging. **You should make sure your users know if admin_access -is enabled.** +If `JupyterHub.admin_access` is True (not default), +then admin users have permission to log in *as other users* on their respective machines, for debugging. +**You should make sure your users know if admin_access is enabled.** ### Adding and removing users -The default PAMAuthenticator is one case of a special kind of authenticator, called a LocalAuthenticator, -indicating that it manages users on the local system. When you add a user to the Hub, a LocalAuthenticator -checks if that user already exists. Normally, there will be an error telling you that the user doesn't -exist. If you set the configuration value +Users can be added and removed to the Hub via the admin panel or REST API. +These users will be added to the whitelist and database. +Restarting the Hub will not require manually updating the whitelist in your config file, +as the users will be loaded from the database. +This means that after starting the Hub once, +it is not sufficient to remove users from the whitelist in your config file. +You must also remove them from the database, either by discarding the database file, +or via the admin UI. + +The default PAMAuthenticator is one case of a special kind of authenticator, +called a LocalAuthenticator, +indicating that it manages users on the local system. +When you add a user to the Hub, a LocalAuthenticator checks if that user already exists. +Normally, there will be an error telling you that the user doesn't exist. +If you set the configuration value ```python c.LocalAuthenticator.create_system_users = True ``` -however, adding a user to the Hub that doesn't already exist on the system will result in the Hub creating -that user via the system `useradd` mechanism. This option is typically used on hosted deployments of -JupyterHub, to avoid the need to manually create all your users before launching the -service. It is not recommended when running JupyterHub in situations where JupyterHub users maps directly -onto UNIX users. - +however, adding a user to the Hub that doesn't already exist on the system +will result in the Hub creating that user via the system `useradd` mechanism. +This option is typically used on hosted deployments of JupyterHub, +to avoid the need to manually create all your users before launching the service. +It is not recommended when running JupyterHub in situations where JupyterHub users maps directly onto UNIX users. ## Configuring single-user servers -Since the single-user server is an instance of `ipython notebook`, an entire separate multi-process -application, there are many aspect of that server can configure, and a lot of ways to express that -configuration. +Since the single-user server is an instance of `ipython notebook`, +an entire separate multi-process application, +there are many aspect of that server can configure, +and a lot of ways to express that configuration. -At the JupyterHub level, you can set some values on the Spawner. The simplest of these is -`Spawner.notebook_dir`, which lets you set the root directory for a user's server. This root notebook -directory is the highest level directory users will be able to access in the notebook dashboard. In this -example, the root notebook directory is set to `~/notebooks`, where `~` is expanded to the user's home -directory. +At the JupyterHub level, you can set some values on the Spawner. +The simplest of these is `Spawner.notebook_dir`, +which lets you set the root directory for a user's server. +This root notebook directory is the highest level directory users will be able to access in the notebook dashboard. +In this example, the root notebook directory is set to `~/notebooks`, +where `~` is expanded to the user's home directory. ```python c.Spawner.notebook_dir = '~/notebooks' @@ -246,22 +285,23 @@ This could be used to set the users default page for the single user server: c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb'] ``` -Since the single-user server extends the notebook server application, it still loads configuration from -the `ipython_notebook_config.py` config file. Each user may have one of these files in -`$HOME/.ipython/profile_default/`. IPython also supports loading system-wide config files from -`/etc/ipython/`, which is the place to put configuration that you want to affect all of your users. +Since the single-user server extends the notebook server application, +it still loads configuration from the `ipython_notebook_config.py` config file. +Each user may have one of these files in `$HOME/.ipython/profile_default/`. +IPython also supports loading system-wide config files from `/etc/ipython/`, +which is the place to put configuration that you want to affect all of your users. -- setting working directory -- /etc/ipython -- custom Spawner +## External services +JupyterHub has a REST API that can be used to run external services. +More detail on this API will be added in the future. ## File locations It is recommended to put all of the files used by JupyterHub into standard UNIX filesystem locations. -* `/srv/jupyter` for all security and runtime files -* `/etc/jupyter` for all configuration files +* `/srv/jupyterhub` for all security and runtime files +* `/etc/jupyterhub` for all configuration files * `/var/log` for log files ## Example @@ -270,12 +310,12 @@ In the following example, we show a configuration files for a fairly standard Ju * JupyterHub is running on a single cloud server * Using SSL on the standard HTTPS port 443 -* You want to use GitHub OAuth for login +* You want to use [GitHub OAuth][oauthenticator] for login * You need the users to exist locally on the server -* You want users' notebooks to be served from `/home` to allow users to browse for notebooks within +* You want users' notebooks to be served from `~/assignments` to allow users to browse for notebooks within other users home directories -* You want the landing page for each user to be `/tree/~` to show them only their own notebooks by default -* All runtime files are put into `/srv/jupyter` and log files in `/var/log`. +* You want the landing page for each user to be a Welcome.ipynb notebook in their assignments directory. +* All runtime files are put into `/srv/jupyterhub` and log files in `/var/log`. Let's start out with `jupyterhub_config.py`: @@ -301,6 +341,7 @@ c.JupyterHub.ssl_cert = pjoin(ssl_dir, 'ssl.cert') # in /var/run/jupyterhub c.JupyterHub.cookie_secret_file = pjoin(runtime_dir, 'cookie_secret') c.JupyterHub.db_url = pjoin(runtime_dir, 'jupyterhub.sqlite') +# or `--db=/path/to/jupyterhub.sqlite` on the command-line # put the log file in /var/log c.JupyterHub.log_file = '/var/log/jupyterhub.log' @@ -324,15 +365,25 @@ c.Spawner.notebook_dir = '~/assignments' c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb'] ``` -Using the GitHub Authenticator requires a few env variables, +Using the GitHub Authenticator [requires a few additional env variables][oauth-setup], which we will need to set when we launch the server: ```bash export GITHUB_CLIENT_ID=github_id export GITHUB_CLIENT_SECRET=github_secret export OAUTH_CALLBACK_URL=https://example.com/hub/oauth_callback +export CONFIGPROXY_AUTH_TOKEN=super-secret jupyterhub -f /path/to/aboveconfig.py ``` +# Further reading + +- TODO: troubleshooting +- [Custom Authenticators](authenticators.md) +- [Custom Spawners](spawners.md) + + +[oauth-setup]: https://github.com/jupyter/oauthenticator#setup +[oauthenticator]: https://github.com/jupyter/oauthenticator [PAM]: http://en.wikipedia.org/wiki/Pluggable_authentication_module From 494e4fe68b35b52b821bc99c2c51214d6317a509 Mon Sep 17 00:00:00 2001 From: Matthias Bussonnier Date: Thu, 16 Apr 2015 16:15:19 -0700 Subject: [PATCH 028/231] Make cookie secure if used over https --- jupyterhub/handlers/base.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index abf763d3..860d390d 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -154,18 +154,33 @@ class BaseHandler(RequestHandler): def set_server_cookie(self, user): """set the login cookie for the single-user server""" + # tornado <4.2 have a bug that consider secure==True as soon as + # 'secure' kwarg is passed to set_secure_cookie + if self.request.protocol == 'https': + kwargs = {'secure':True} + else: + kwargs = {} self.set_secure_cookie( user.server.cookie_name, user.cookie_id, path=user.server.base_url, + **kwargs ) def set_hub_cookie(self, user): """set the login cookie for the Hub""" + # tornado <4.2 have a bug that consider secure==True as soon as + # 'secure' kwarg is passed to set_secure_cookie + if self.request.protocol == 'https': + kwargs = {'secure':True} + else: + kwargs = {} self.set_secure_cookie( self.hub.server.cookie_name, user.cookie_id, - path=self.hub.server.base_url) + path=self.hub.server.base_url, + **kwargs + ) def set_login_cookie(self, user): """Set login cookies for the Hub and single-user server.""" From 5dc38b85eb6ae6c124b2511f0f6d11becb4b516d Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 15 Apr 2015 21:16:59 -0700 Subject: [PATCH 029/231] reorder server init So the Hub private interface isn't the last thing logged, which caused lots of confusion. --- jupyterhub/app.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 8c9d3537..48e4a07c 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -982,6 +982,16 @@ class JupyterHub(Application): loop.stop() return + # start the webserver + self.http_server = tornado.httpserver.HTTPServer(self.tornado_application, xheaders=True) + try: + self.http_server.listen(self.hub_port, address=self.hub_ip) + except Exception: + self.log.error("Failed to bind hub to %s", self.hub.server.bind_url) + raise + else: + self.log.info("Hub API listening on %s", self.hub.server.bind_url) + # start the proxy try: yield self.start_proxy() @@ -1003,16 +1013,7 @@ class JupyterHub(Application): pc = PeriodicCallback(self.update_last_activity, 1e3 * self.last_activity_interval) pc.start() - # start the webserver - self.http_server = tornado.httpserver.HTTPServer(self.tornado_application, xheaders=True) - try: - self.http_server.listen(self.hub_port, address=self.hub_ip) - except Exception: - self.log.error("Failed to bind hub to %s" % self.hub.server.bind_url) - raise - else: - self.log.info("Hub API listening on %s" % self.hub.server.bind_url) - + self.log.info("JupyterHub is now running at %s", self.proxy.public_server.url) # register cleanup on both TERM and INT atexit.register(self.atexit) self.init_signal() From 713f222e19975e5d48e50eb0a72685077c52ce70 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 17 Apr 2015 10:37:17 -0700 Subject: [PATCH 030/231] trust proxy headers in single-user server required for request protocol, ip checks to work properly --- jupyterhub/singleuser.py | 1 + 1 file changed, 1 insertion(+) diff --git a/jupyterhub/singleuser.py b/jupyterhub/singleuser.py index 896dea3c..ccc65a21 100644 --- a/jupyterhub/singleuser.py +++ b/jupyterhub/singleuser.py @@ -138,6 +138,7 @@ class SingleUserNotebookApp(NotebookApp): hub_api_url = Unicode(config=True) aliases = aliases open_browser = False + trust_xheaders = True login_handler_class = JupyterHubLoginHandler logout_handler_class = JupyterHubLogoutHandler From bc37c729ff4e3d02e324e1d489eec4054ea2650c Mon Sep 17 00:00:00 2001 From: Scott Sanderson Date: Mon, 20 Apr 2015 16:47:50 -0400 Subject: [PATCH 031/231] DEV: Failover for urrlib.parse.quote in PY2. --- jupyterhub/singleuser.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/jupyterhub/singleuser.py b/jupyterhub/singleuser.py index ccc65a21..c911b631 100644 --- a/jupyterhub/singleuser.py +++ b/jupyterhub/singleuser.py @@ -5,7 +5,11 @@ # Distributed under the terms of the Modified BSD License. import os -from urllib.parse import quote +try: + from urllib.parse import quote +except ImportError: + # PY2 Compat + from urllib import quote import requests from jinja2 import ChoiceLoader, FunctionLoader @@ -177,7 +181,7 @@ class SingleUserNotebookApp(NotebookApp): self._clear_cookie_cache, self.cookie_cache_lifetime * 1e3, ).start() - super().start() + super(SingleUserNotebookApp, self).start() def init_webapp(self): # load the hub related settings into the tornado settings dict From 1d6b16060b91a37014649bce25e0df51755f06d8 Mon Sep 17 00:00:00 2001 From: Scott Sanderson Date: Thu, 23 Apr 2015 11:08:32 -0400 Subject: [PATCH 032/231] DEV: Make template search path configurable. --- jupyterhub/app.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 48e4a07c..cf2c77e7 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -201,7 +201,15 @@ class JupyterHub(Application): data_files_path = Unicode(DATA_FILES_PATH, config=True, help="The location of jupyterhub data files (e.g. /usr/local/share/jupyter/hub)" ) - + + template_paths = List( + config=True, + help="Paths to search for jinja templates.", + ) + + def _template_paths_default(self): + return [os.path.join(self.data_files_path, 'templates')] + ssl_key = Unicode('', config=True, help="""Path to SSL key file for the public facing interface of the proxy @@ -792,9 +800,8 @@ class JupyterHub(Application): def init_tornado_settings(self): """Set up the tornado settings dict.""" base_url = self.hub.server.base_url - template_path = os.path.join(self.data_files_path, 'templates'), jinja_env = Environment( - loader=FileSystemLoader(template_path), + loader=FileSystemLoader(self.template_paths), **self.jinja_environment_options ) From e5d9d136da3eb8ad1cca88cc082a57f9fd1585d4 Mon Sep 17 00:00:00 2001 From: Tim Shawver Date: Thu, 23 Apr 2015 12:32:59 -0400 Subject: [PATCH 033/231] One more place where template_path needed to be changed to template_paths --- jupyterhub/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index cf2c77e7..ac9ee6b5 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -835,7 +835,7 @@ class JupyterHub(Application): static_path=os.path.join(self.data_files_path, 'static'), static_url_prefix=url_path_join(self.hub.server.base_url, 'static/'), static_handler_class=CacheControlStaticFilesHandler, - template_path=template_path, + template_path=self.template_paths, jinja2_env=jinja_env, version_hash=version_hash, ) From 74d3740921d4dd5fe60ce03f12ff94aa0a4c6a35 Mon Sep 17 00:00:00 2001 From: Scott Sanderson Date: Thu, 23 Apr 2015 18:46:56 -0400 Subject: [PATCH 034/231] DEV: Allow configuration of default headers. Applies Content-Security-Policy: frame-ancestors 'self' by default. --- jupyterhub/handlers/base.py | 11 +++++++++++ jupyterhub/tests/test_api.py | 6 ++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index 860d390d..b5354145 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -71,6 +71,17 @@ class BaseHandler(RequestHandler): self.db.rollback() super(BaseHandler, self).finish(*args, **kwargs) + def set_default_headers(self): + """ + Set any headers passed as tornado_settings['headers']. + + By default sets Content-Security-Policy of frame-ancestors 'self'. + """ + headers = self.settings.get('headers', {}) + headers.setdefault('Content-Security-Policy', "frame-ancestors 'self'") + for header_name, header_content in headers.items(): + self.set_header(header_name, header_content) + #--------------------------------------------------------------- # Login and cookie-related #--------------------------------------------------------------- diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 1eb21830..97cb42e7 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -60,11 +60,13 @@ def api_request(app, *api_path, **kwargs): if 'Authorization' not in headers: headers.update(auth_header(app.db, 'admin')) - + url = ujoin(base_url, 'api', *api_path) method = kwargs.pop('method', 'get') f = getattr(requests, method) - return f(url, **kwargs) + resp = f(url, **kwargs) + assert resp.headers['Content-Security-Policy'] == "frame-ancestors 'self'" + return resp def test_auth_api(app): db = app.db From fd6e6f1dedfcb9fc65fe00f0eb3defeee489c871 Mon Sep 17 00:00:00 2001 From: Pietro Battiston Date: Fri, 24 Apr 2015 12:34:33 +0200 Subject: [PATCH 035/231] Get url_path_join from jupyter_notebook --- jupyterhub/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index 03c7721d..80a58094 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -14,7 +14,10 @@ from tornado import web, gen, ioloop from tornado.httpclient import AsyncHTTPClient, HTTPError from tornado.log import app_log -from IPython.html.utils import url_path_join +try: + from jupyter_notebook.utils import url_path_join +except: + from IPython.html.utils import url_path_join def random_port(): From d4a4d0418388936e04d6144b57e031fdff9f955d Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 16 Apr 2015 15:54:39 -0700 Subject: [PATCH 036/231] quote usernames allow @ to be left unescaped in URLs, quote everything in cookie names --- jupyterhub/apihandlers/auth.py | 2 ++ jupyterhub/orm.py | 13 ++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py index 0525cd1d..bcc0ce6c 100644 --- a/jupyterhub/apihandlers/auth.py +++ b/jupyterhub/apihandlers/auth.py @@ -4,6 +4,7 @@ # Distributed under the terms of the Modified BSD License. import json +from urllib.parse import quote from tornado import web from .. import orm @@ -25,6 +26,7 @@ class TokenAPIHandler(APIHandler): class CookieAPIHandler(APIHandler): @token_authenticated def get(self, cookie_name, cookie_value=None): + cookie_name = quote(cookie_name, safe='') if cookie_value is None: self.log.warn("Cookie values in request body is deprecated, use `/cookie_name/cookie_value`") cookie_value = self.request.body diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 9feb8cbb..cf58eee8 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta import errno import json import socket +from urllib.parse import quote from tornado import gen from tornado.log import app_log @@ -145,7 +146,7 @@ class Proxy(Base): ) else: return "<%s [unconfigured]>" % self.__class__.__name__ - + def api_request(self, path, method='GET', body=None, client=None): """Make an authenticated API request of the proxy""" client = client or AsyncHTTPClient() @@ -299,6 +300,11 @@ class User(Base): name=self.name, ) + @property + def escaped_name(self): + """My name, escaped for use in URLs, cookies, etc.""" + return quote(self.name, safe='@') + @property def running(self): """property for whether a user has a running server""" @@ -333,9 +339,10 @@ class User(Base): db = inspect(self).session if hub is None: hub = db.query(Hub).first() + self.server = Server( - cookie_name='%s-%s' % (hub.server.cookie_name, self.name), - base_url=url_path_join(base_url, 'user', self.name), + cookie_name='%s-%s' % (hub.server.cookie_name, quote(self.name, safe='')), + base_url=url_path_join(base_url, 'user', self.escaped_name), ) db.add(self.server) db.commit() From c467c64e01d7f8195a5fff478c137275d04c2ae2 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 23 Mar 2015 16:29:15 -0700 Subject: [PATCH 037/231] move user_model handling to base APIHandler --- jupyterhub/apihandlers/base.py | 30 +++++++++++++++++++++++++ jupyterhub/apihandlers/users.py | 39 ++++----------------------------- 2 files changed, 34 insertions(+), 35 deletions(-) diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index 1c7f2557..61c83576 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -47,3 +47,33 @@ class APIHandler(BaseHandler): 'status': status_code, 'message': message or status_message, })) + + def user_model(self, user): + model = { + 'name': user.name, + 'admin': user.admin, + 'server': user.server.base_url if user.running else None, + 'pending': None, + 'last_activity': user.last_activity.isoformat(), + } + if user.spawn_pending: + model['pending'] = 'spawn' + elif user.stop_pending: + model['pending'] = 'stop' + return model + + _model_types = { + 'name': str, + 'admin': bool, + } + + def _check_user_model(self, model): + if not isinstance(model, dict): + raise web.HTTPError(400, "Invalid JSON data: %r" % model) + if not set(model).issubset(set(self._model_types)): + raise web.HTTPError(400, "Invalid JSON keys: %r" % model) + for key, value in model.items(): + if not isinstance(value, self._model_types[key]): + raise web.HTTPError(400, "user.%s must be %s, not: %r" % ( + key, self._model_types[key], type(value) + )) diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index 0b122f98..eb7337a3 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -11,39 +11,8 @@ from .. import orm from ..utils import admin_only from .base import APIHandler -class BaseUserHandler(APIHandler): - - def user_model(self, user): - model = { - 'name': user.name, - 'admin': user.admin, - 'server': user.server.base_url if user.running else None, - 'pending': None, - 'last_activity': user.last_activity.isoformat(), - } - if user.spawn_pending: - model['pending'] = 'spawn' - elif user.stop_pending: - model['pending'] = 'stop' - return model - - _model_types = { - 'name': str, - 'admin': bool, - } - - def _check_user_model(self, model): - if not isinstance(model, dict): - raise web.HTTPError(400, "Invalid JSON data: %r" % model) - if not set(model).issubset(set(self._model_types)): - raise web.HTTPError(400, "Invalid JSON keys: %r" % model) - for key, value in model.items(): - if not isinstance(value, self._model_types[key]): - raise web.HTTPError(400, "user.%s must be %s, not: %r" % ( - key, self._model_types[key], type(value) - )) -class UserListAPIHandler(BaseUserHandler): +class UserListAPIHandler(APIHandler): @admin_only def get(self): users = self.db.query(orm.User) @@ -66,7 +35,7 @@ def admin_or_self(method): return method(self, name) return m -class UserAPIHandler(BaseUserHandler): +class UserAPIHandler(APIHandler): @admin_or_self def get(self, name): @@ -135,7 +104,7 @@ class UserAPIHandler(BaseUserHandler): self.write(json.dumps(self.user_model(user))) -class UserServerAPIHandler(BaseUserHandler): +class UserServerAPIHandler(APIHandler): @gen.coroutine @admin_or_self def post(self, name): @@ -165,7 +134,7 @@ class UserServerAPIHandler(BaseUserHandler): status = 202 if user.stop_pending else 204 self.set_status(status) -class UserAdminAccessAPIHandler(BaseUserHandler): +class UserAdminAccessAPIHandler(APIHandler): """Grant admins access to single-user servers This handler sets the necessary cookie for an admin to login to a single-user server. From 80997c829700ba2bb8f9979b2c12d8bac350ad19 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 23 Mar 2015 16:29:30 -0700 Subject: [PATCH 038/231] reply with full user model in auth handlers --- jupyterhub/apihandlers/auth.py | 8 ++------ jupyterhub/tests/test_api.py | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py index 0525cd1d..a50c5bd5 100644 --- a/jupyterhub/apihandlers/auth.py +++ b/jupyterhub/apihandlers/auth.py @@ -17,9 +17,7 @@ class TokenAPIHandler(APIHandler): orm_token = orm.APIToken.find(self.db, token) if orm_token is None: raise web.HTTPError(404) - self.write(json.dumps({ - 'user' : orm_token.user.name, - })) + self.write(json.dumps(self.user_model(orm_token.user))) class CookieAPIHandler(APIHandler): @@ -33,9 +31,7 @@ class CookieAPIHandler(APIHandler): user = self._user_for_cookie(cookie_name, cookie_value) if user is None: raise web.HTTPError(404) - self.write(json.dumps({ - 'user' : user.name, - })) + self.write(json.dumps(self.user_model(user))) default_handlers = [ diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 97cb42e7..3299be9a 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -81,7 +81,7 @@ def test_auth_api(app): r = api_request(app, 'authorizations/token', api_token) assert r.status_code == 200 reply = r.json() - assert reply['user'] == user.name + assert reply['name'] == user.name # check fail r = api_request(app, 'authorizations/token', api_token, From b0ef2c4c84aa0d0c46f0f9f95df4b5808f7c9a52 Mon Sep 17 00:00:00 2001 From: Min RK Date: Sat, 2 May 2015 15:22:03 -0500 Subject: [PATCH 039/231] fix auth key in single-user check --- jupyterhub/singleuser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jupyterhub/singleuser.py b/jupyterhub/singleuser.py index c911b631..b0723b3f 100644 --- a/jupyterhub/singleuser.py +++ b/jupyterhub/singleuser.py @@ -92,7 +92,7 @@ class JupyterHubLoginHandler(LoginHandler): if not auth_data: # treat invalid token the same as no token return None - user = auth_data['user'] + user = auth_data['name'] if user == my_user: self._cached_user = user return user From 36bc07b02e3e81ba00fd65ac57e98506c80e1723 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 5 May 2015 14:32:24 -0700 Subject: [PATCH 040/231] add gitter badge --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ad11aeaa..89f66651 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # JupyterHub: A multi-user server for Jupyter notebooks +[![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/jupyter/jupyterhub?utm_source=badge&utm_medium=badge) + JupyterHub is a multi-user server that manages and proxies multiple instances of the single-user IPython Jupyter notebook server. Three actors: From 546d86e888356ae3586d03c110f0614904b1c780 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 6 May 2015 14:01:31 -0700 Subject: [PATCH 041/231] allow creating multiple users with one API request --- jupyterhub/apihandlers/users.py | 37 +++++++++++++++++++ jupyterhub/tests/test_api.py | 65 +++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index eb7337a3..e76e73b7 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -18,6 +18,43 @@ class UserListAPIHandler(APIHandler): users = self.db.query(orm.User) data = [ self.user_model(u) for u in users ] self.write(json.dumps(data)) + + @admin_only + @gen.coroutine + def post(self): + data = self.get_json_body() + print(data) + if not data or not isinstance(data, dict) or not data.get('usernames'): + raise web.HTTPError(400, "Must specify at least one user to create") + + usernames = data.pop('usernames') + self._check_user_model(data) + + for name in usernames: + user = self.find_user(name) + if user is not None: + raise web.HTTPError(400, "User %s already exists" % name) + + created = [] + for name in usernames: + user = self.user_from_username(name) + if data: + self._check_user_model(data) + if 'admin' in data: + user.admin = data['admin'] + self.db.commit() + try: + yield gen.maybe_future(self.authenticator.add_user(user)) + except Exception: + self.log.error("Failed to create user: %s" % name, exc_info=True) + self.db.delete(user) + self.db.commit() + raise web.HTTPError(400, "Failed to create user: %s" % name) + else: + created.append(user) + + self.write(json.dumps([ self.user_model(u) for u in created ])) + self.set_status(201) def admin_or_self(method): diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 3299be9a..cac9e83c 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -132,6 +132,71 @@ def test_add_user(app): assert user.name == name assert not user.admin + +def test_get_user(app): + name = 'user' + r = api_request(app, 'users', name) + assert r.status_code == 200 + user = r.json() + user.pop('last_activity') + assert user == { + 'name': name, + 'admin': False, + 'server': None, + 'pending': None, + } + + +def test_add_multi_user_bad(app): + r = api_request(app, 'users', method='post') + assert r.status_code == 400 + r = api_request(app, 'users', method='post', data='{}') + assert r.status_code == 400 + r = api_request(app, 'users', method='post', data='[]') + assert r.status_code == 400 + +def test_add_multi_user(app): + db = app.db + names = ['a', 'b'] + r = api_request(app, 'users', method='post', + data=json.dumps({'usernames': names}), + ) + assert r.status_code == 201 + reply = r.json() + r_names = [ user['name'] for user in reply ] + assert names == r_names + + for name in names: + user = find_user(db, name) + assert user is not None + assert user.name == name + assert not user.admin + + # try to create the same users again + r = api_request(app, 'users', method='post', + data=json.dumps({'usernames': names}), + ) + assert r.status_code == 400 + + +def test_add_multi_user_admin(app): + db = app.db + names = ['c', 'd'] + r = api_request(app, 'users', method='post', + data=json.dumps({'usernames': names, 'admin': True}), + ) + assert r.status_code == 201 + reply = r.json() + r_names = [ user['name'] for user in reply ] + assert names == r_names + + for name in names: + user = find_user(db, name) + assert user is not None + assert user.name == name + assert user.admin + + def test_add_user_bad(app): db = app.db name = 'dne_newuser' From da647397ac56ade367479513d68150203002da6b Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 6 May 2015 14:03:19 -0700 Subject: [PATCH 042/231] create multiple users in admin panel usernames separated by lines --- share/jupyter/hub/static/js/admin.js | 16 ++++++++++++---- share/jupyter/hub/static/js/jhapi.js | 10 ++++------ share/jupyter/hub/templates/admin.html | 13 ++++++++++--- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/share/jupyter/hub/static/js/admin.js b/share/jupyter/hub/static/js/admin.js index 51b2d378..30b22a38 100644 --- a/share/jupyter/hub/static/js/admin.js +++ b/share/jupyter/hub/static/js/admin.js @@ -42,7 +42,7 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo $("th").map(function (i, th) { th = $(th); var col = th.data('sort'); - if (!col || col.length == 0) { + if (!col || col.length === 0) { return; } var order = th.find('i').hasClass('fa-sort-desc') ? 'asc':'desc'; @@ -50,7 +50,7 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo function () { resort(col, order); } - ) + ); }); $(".time-col").map(function (i, el) { @@ -161,9 +161,17 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo $("#add-user-dialog").find(".save-button").click(function () { var dialog = $("#add-user-dialog"); - var username = dialog.find(".username-input").val(); + var lines = dialog.find(".username-input").val().split('\n'); var admin = dialog.find(".admin-checkbox").prop("checked"); - api.add_user(username, {admin: admin}, { + var usernames = []; + lines.map(function (line) { + var username = line.trim(); + if (username.length) { + usernames.push(username); + } + }); + + api.add_users(usernames, {admin: admin}, { success: function () { window.location.reload(); } diff --git a/share/jupyter/hub/static/js/jhapi.js b/share/jupyter/hub/static/js/jhapi.js index 15886e1e..754460c2 100644 --- a/share/jupyter/hub/static/js/jhapi.js +++ b/share/jupyter/hub/static/js/jhapi.js @@ -72,18 +72,16 @@ define(['jquery', 'utils'], function ($, utils) { ); }; - JHAPI.prototype.add_user = function (user, userinfo, options) { + JHAPI.prototype.add_users = function (usernames, userinfo, options) { options = options || {}; + var data = update(userinfo, {usernames: usernames}); options = update(options, { type: 'POST', dataType: null, - data: JSON.stringify(userinfo) + data: JSON.stringify(data) }); - this.api_request( - utils.url_path_join('users', user), - options - ); + this.api_request('users', options); }; JHAPI.prototype.edit_user = function (user, userinfo, options) { diff --git a/share/jupyter/hub/templates/admin.html b/share/jupyter/hub/templates/admin.html index 90a8b027..d777e781 100644 --- a/share/jupyter/hub/templates/admin.html +++ b/share/jupyter/hub/templates/admin.html @@ -86,10 +86,17 @@
{% endcall %} -{% macro user_modal(name) %} +{% macro user_modal(name, multi=False) %} {% call modal(name, btn_class='btn-primary save-button') %}
- + <{%- if multi -%} + textarea + {%- else -%} + input type="text" + {%- endif %} + class="form-control username-input" + placeholder="{%- if multi -%} usernames separated by lines{%- else -%} username {%-endif-%}"> + {%- if multi -%}{%- endif -%}
- + {{spawner_options_form}}
- +
{% endblock %} + +{% block script %} + +{% endblock %} \ No newline at end of file From 2bd7192e89c5669cbe9c0f8838a9a64e25d8d3fe Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 18 Dec 2015 10:49:23 +0100 Subject: [PATCH 118/231] add extensible `get_env` hook on Spawner to make it easier for subclasses to modify the env --- jupyterhub/spawner.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 5870b822..3b6e570c 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -183,6 +183,14 @@ class Spawner(LoggingConfigurable): """ self.api_token = '' + def get_env(self): + """Return the environment we should use + + Default returns a copy of self.env. + Use this to access the env in Spawner.start to allow extension in subclasses. + """ + return self.env.copy() + def get_args(self): """Return the arguments to be passed after self.cmd""" args = [ @@ -376,9 +384,11 @@ class LocalProcessSpawner(Spawner): env['SHELL'] = shell return env - def _env_default(self): - env = super()._env_default() - return self.user_env(env) + def get_env(self): + """Add user environment variables""" + env = super().get_env() + env = self.user_env(env) + return env @gen.coroutine def start(self): @@ -387,7 +397,7 @@ class LocalProcessSpawner(Spawner): self.user.server.ip = self.ip self.user.server.port = random_port() cmd = [] - env = self.env.copy() + env = self.get_env() cmd.extend(self.cmd) cmd.extend(self.get_args()) From d2e3a73f530dc9f67d6a12fe7618a188bbbf6f66 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 18 Dec 2015 11:15:50 +0100 Subject: [PATCH 119/231] set login cookie after starting server avoids redirect loop --- jupyterhub/handlers/pages.py | 1 + 1 file changed, 1 insertion(+) diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index fcd66cb0..83b46e60 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -89,6 +89,7 @@ class SpawnHandler(BaseHandler): form_options[key] = [ bs.decode('utf8') for bs in byte_list ] options = user.spawner.options_from_form(form_options) yield self.spawn_single_user(user, options=options) + self.set_login_cookie(user) url = user.server.base_url self.redirect(url) From 041c1a4a1e6928484075d94a3c7a1d63cb009b5b Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 18 Dec 2015 11:16:27 +0100 Subject: [PATCH 120/231] remove always-False else branch --- jupyterhub/handlers/base.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index e749731d..ec54834f 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -456,11 +456,6 @@ class UserSpawnHandler(BaseHandler): self.redirect(url_path_join(self.hub.server.base_url, 'spawn')) else: yield self.spawn_single_user(current_user) - else: - if current_user.spawner.options_form: - self.redirect(url_path_join(self.hub.server.base_url, 'spawn')) - else: - yield self.spawn_single_user(current_user) # set login cookie anew self.set_login_cookie(current_user) without_prefix = self.request.uri[len(self.hub.server.base_url):] From 647dd09f40f108450f6e581c1e32d67eebbf605d Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 18 Dec 2015 11:16:40 +0100 Subject: [PATCH 121/231] add spawn-form example --- examples/spawn-form/jupyterhub_config.py | 46 ++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 examples/spawn-form/jupyterhub_config.py diff --git a/examples/spawn-form/jupyterhub_config.py b/examples/spawn-form/jupyterhub_config.py new file mode 100644 index 00000000..0ed1bf1e --- /dev/null +++ b/examples/spawn-form/jupyterhub_config.py @@ -0,0 +1,46 @@ +""" +Example JuptyerHub config allowing users to specify environment variables and notebook-server args +""" +import shlex + +from jupyterhub.spawner import LocalProcessSpawner + +class DemoFormSpawner(LocalProcessSpawner): + def _options_form_default(self): + default_env = "YOURNAME=%s\n" % self.user.name + return """ + + + + + """.format(env=default_env) + + def options_from_form(self, formdata): + options = {} + options['env'] = env = {} + + env_lines = formdata.get('env', ['']) + for line in env_lines[0].splitlines(): + if line: + key, value = line.split('=', 1) + env[key.strip()] = value.strip() + + arg_s = formdata.get('args', [''])[0].strip() + if arg_s: + options['argv'] = shlex.split(arg_s) + return options + + def get_args(self): + """Return arguments to pass to the notebook server""" + argv = super().get_args() + if self.user_options.get('argv'): + argv.extend(self.user_options['argv']) + return argv + + def get_env(self): + env = super().get_env() + if self.user_options.get('env'): + env.update(self.user_options['env']) + return env + +c.JupyterHub.spawner_class = DemoFormSpawner From 872005f85266bcfea6194b3b306cb5fa6cd059ca Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 30 Dec 2015 14:14:46 +0100 Subject: [PATCH 122/231] document spawner options form --- docs/images/spawn-form.png | Bin 0 -> 56691 bytes docs/spawners.md | 58 +++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 docs/images/spawn-form.png diff --git a/docs/images/spawn-form.png b/docs/images/spawn-form.png new file mode 100644 index 0000000000000000000000000000000000000000..370338a35f2509ee270d7d63afa685ea392ed29b GIT binary patch literal 56691 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 aTtkRc+^NlY80htM=%%9T-`UrUpZp*64NyJ+ literal 0 HcmV?d00001 diff --git a/docs/spawners.md b/docs/spawners.md index ad76b0bc..c672ee7e 100644 --- a/docs/spawners.md +++ b/docs/spawners.md @@ -84,6 +84,64 @@ def clear_state(self): self.pid = 0 ``` +## Spawner options form + +(new in 0.4) + +Some deployments may want to offer options to users to influence how their servers are started. +This may include cluster-based deployments, where users specify what resources should be available, +or docker-based deployments where users can select from a list of base images. + +This feature is enabled by setting `Spawner.options_form`, which is an HTML form snippet +inserted unmodified into the spawn form. +If the `Spawner.options_form` is defined, when a user would start their server, they will be directed to a form page, like this: + +![spawn-form](images/spawn-form.png) + +If `Spawner.options_form` is undefined, the users server is spawned directly, and no spawn page is rendered. + +See [this example](../examples/spawn-form/jupyterhub_config.py) for a form that allows custom CLI args for the local spawner. + + +### `Spawner.options_from_form` + +Options from this form will always be a dictionary of lists of strings, e.g.: + +```python +{ + 'integer': ['5'], + 'text': ['some text'], + 'select': ['a', 'b'], +} +``` + +When formdata arrives, it is passed through `Spawner.options_from_form(formdata)`, +which is a method to turn the form data into the correct structure. +This method must return a dictionary, and is meant to interpret the lists-of-strings into the correct types, e.g. for the above form it would look like: + +```python +def options_from_form(self, formdata): + options = {} + options['integer'] = int(formdata['integer'][0]) # single integer value + options['text'] = formdata['text'][0] # single string value + options['select'] = formdata['select'] # list already correct + options['notinform'] = 'extra info' # not in the form at all + return options +``` + +which would return: + +```python +{ + 'integer': 5, + 'text': 'some text', + 'select': ['a', 'b'], + 'notinform': 'extra info', +} +``` + +When `Spawner.spawn` is called, this dict is accessible as `self.user_options`. + [Spawner]: ../jupyterhub/spawner.py From 66cbb8a61477cf9e8261c458c4ceb39a59d52b49 Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 31 Dec 2015 12:05:55 +0100 Subject: [PATCH 123/231] more testing of spawn page redirects --- jupyterhub/tests/test_pages.py | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py index ff4566b8..9ea08075 100644 --- a/jupyterhub/tests/test_pages.py +++ b/jupyterhub/tests/test_pages.py @@ -1,5 +1,7 @@ """Tests for HTML pages""" +from urllib.parse import urlparse + import requests from ..utils import url_path_join as ujoin @@ -7,6 +9,7 @@ from .. import orm import mock from .mocking import FormSpawner +from .test_api import api_request def get_page(path, app, **kw): @@ -59,10 +62,34 @@ def test_admin(app): r.raise_for_status() assert r.url.endswith('/admin') -def test_spawn_redirect(app): - cookies = app.login_user('wash') +def test_spawn_redirect(app, io_loop): + name = 'wash' + cookies = app.login_user(name) + u = app.users[orm.User.find(app.db, name)] + + # ensure wash's server isn't running: + r = api_request(app, 'users', name, 'server', method='delete', cookies=cookies) + r.raise_for_status() + status = io_loop.run_sync(u.spawner.poll) + assert status is not None + + # test spawn page when no server is running r = get_page('spawn', app, cookies=cookies) - assert r.url.endswith('/wash') + r.raise_for_status() + print(urlparse(r.url)) + path = urlparse(r.url).path + assert path == '/user/%s' % name + + # should have started server + status = io_loop.run_sync(u.spawner.poll) + assert status is None + + # test spawn page when server is already running (just redirect) + r = get_page('spawn', app, cookies=cookies) + r.raise_for_status() + print(urlparse(r.url)) + path = urlparse(r.url).path + assert path == '/user/%s' % name def test_spawn_page(app): with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}): From 91168fc22b335614d2f938217837c3311db7e62a Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 31 Dec 2015 12:06:04 +0100 Subject: [PATCH 124/231] s/users/user typo in spawn redirect --- jupyterhub/handlers/pages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index 83b46e60..4d6aeb06 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -71,7 +71,7 @@ class SpawnHandler(BaseHandler): self.finish(html) else: # not running, no form. Trigger spawn. - url = url_path_join(self.base_url, 'users', user.name) + url = url_path_join(self.base_url, 'user', user.name) self.redirect(url) @web.authenticated From 53785a985d22e7a2946af1afa6856116ca1bfe2e Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 5 Jan 2016 14:02:20 +0100 Subject: [PATCH 125/231] return after redirect to spawner form avoids double-call to redirect, which fails --- jupyterhub/handlers/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index ec54834f..0a00af84 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -454,6 +454,7 @@ class UserSpawnHandler(BaseHandler): if status is not None: if current_user.spawner.options_form: self.redirect(url_path_join(self.hub.server.base_url, 'spawn')) + return else: yield self.spawn_single_user(current_user) # set login cookie anew From c878e137aa105dbbca40409cd294244fc8161609 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 5 Jan 2016 14:05:59 +0100 Subject: [PATCH 126/231] try codecov for coverage --- .travis.yml | 3 +-- dev-requirements.txt | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index f57b87ad..09ef823f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,8 +11,7 @@ before_install: - git clone --quiet --depth 1 https://github.com/minrk/travis-wheels travis-wheels install: - pip install -f travis-wheels/wheelhouse -r dev-requirements.txt . - - pip install -f travis-wheels/wheelhouse notebook script: - py.test --cov jupyterhub jupyterhub/tests -v after_success: - - coveralls + - codecov diff --git a/dev-requirements.txt b/dev-requirements.txt index 28159b41..0fca4115 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,4 +1,5 @@ -r requirements.txt -coveralls +codecov pytest-cov pytest>=2.8 +notebook From f4de5731989a388e0b40d7ee8a3500917131d8e2 Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Mon, 4 Jan 2016 16:22:11 -0800 Subject: [PATCH 127/231] Set up docs directory for Sphinx --- docs/{ => source}/authenticators.md | 0 docs/{ => source}/changelog.md | 0 docs/{ => source}/getting-started.md | 0 docs/{ => source}/howitworks.md | 0 docs/{ => source}/spawners.md | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename docs/{ => source}/authenticators.md (100%) rename docs/{ => source}/changelog.md (100%) rename docs/{ => source}/getting-started.md (100%) rename docs/{ => source}/howitworks.md (100%) rename docs/{ => source}/spawners.md (100%) diff --git a/docs/authenticators.md b/docs/source/authenticators.md similarity index 100% rename from docs/authenticators.md rename to docs/source/authenticators.md diff --git a/docs/changelog.md b/docs/source/changelog.md similarity index 100% rename from docs/changelog.md rename to docs/source/changelog.md diff --git a/docs/getting-started.md b/docs/source/getting-started.md similarity index 100% rename from docs/getting-started.md rename to docs/source/getting-started.md diff --git a/docs/howitworks.md b/docs/source/howitworks.md similarity index 100% rename from docs/howitworks.md rename to docs/source/howitworks.md diff --git a/docs/spawners.md b/docs/source/spawners.md similarity index 100% rename from docs/spawners.md rename to docs/source/spawners.md From 9ee92a3984e43182d6ae306acb7d9811d4ab45dd Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Mon, 4 Jan 2016 16:25:46 -0800 Subject: [PATCH 128/231] Add a requirements for building docs --- docs/doc-requirements.txt | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 docs/doc-requirements.txt diff --git a/docs/doc-requirements.txt b/docs/doc-requirements.txt new file mode 100644 index 00000000..4fa33db5 --- /dev/null +++ b/docs/doc-requirements.txt @@ -0,0 +1,4 @@ +-r ../requirements.txt +sphinx +commonmark +recommonmark From 0b4fbee4180c6d8a1372b7254d4b23fde0e78c0f Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Mon, 4 Jan 2016 16:32:13 -0800 Subject: [PATCH 129/231] Add sphinx skeleton --- docs/Makefile | 192 ++++++++++++++++++++++ docs/make.bat | 263 ++++++++++++++++++++++++++++++ docs/source/conf.py | 362 ++++++++++++++++++++++++++++++++++++++++++ docs/source/index.rst | 22 +++ 4 files changed, 839 insertions(+) create mode 100644 docs/Makefile create mode 100644 docs/make.bat create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..aae144bf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,192 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " applehelp to make an Apple Help Book" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/JupyterHub.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/JupyterHub.qhc" + +applehelp: + $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp + @echo + @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." + @echo "N.B. You won't be able to view it unless you put it in" \ + "~/Library/Documentation/Help or install it in your application" \ + "bundle." + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/JupyterHub" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/JupyterHub" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..15314bee --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,263 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source +set I18NSPHINXOPTS=%SPHINXOPTS% source +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + echo. coverage to run coverage check of the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +REM Check if sphinx-build is available and fallback to Python version if any +%SPHINXBUILD% 1>NUL 2>NUL +if errorlevel 9009 goto sphinx_python +goto sphinx_ok + +:sphinx_python + +set SPHINXBUILD=python -m sphinx.__init__ +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +:sphinx_ok + + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\JupyterHub.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\JupyterHub.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "coverage" ( + %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage + if errorlevel 1 exit /b 1 + echo. + echo.Testing of coverage in the sources finished, look at the ^ +results in %BUILDDIR%/coverage/python.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +:end diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 00000000..ef045c48 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,362 @@ +# -*- coding: utf-8 -*- +# +# JupyterHub documentation build configuration file, created by +# sphinx-quickstart on Mon Jan 4 16:31:09 2016. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys +import os +import shlex + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx.ext.mathjax', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'JupyterHub' +copyright = u'2016, Project Jupyter team' +author = u'Project Jupyter team' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = u'0.1' +# The full version, including alpha/beta/rc tags. +release = u'0.1.1' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = [] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' +#html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# Now only 'ja' uses this config value +#html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +#html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'JupyterHubdoc' + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', + +# Latex figure (float) alignment +#'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'JupyterHub.tex', u'JupyterHub Documentation', + u'Project Jupyter team', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'jupyterhub', u'JupyterHub Documentation', + [author], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'JupyterHub', u'JupyterHub Documentation', + author, 'JupyterHub', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False + + +# -- Options for Epub output ---------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project +epub_author = author +epub_publisher = author +epub_copyright = copyright + +# The basename for the epub file. It defaults to the project name. +#epub_basename = project + +# The HTML theme for the epub output. Since the default themes are not optimized +# for small screen space, using the same theme for HTML and epub output is +# usually not wise. This defaults to 'epub', a theme designed to save visual +# space. +#epub_theme = 'epub' + +# The language of the text. It defaults to the language option +# or 'en' if the language is not set. +#epub_language = '' + +# The scheme of the identifier. Typical schemes are ISBN or URL. +#epub_scheme = '' + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +#epub_identifier = '' + +# A unique identification for the text. +#epub_uid = '' + +# A tuple containing the cover image and cover page html template filenames. +#epub_cover = () + +# A sequence of (type, uri, title) tuples for the guide element of content.opf. +#epub_guide = () + +# HTML files that should be inserted before the pages created by sphinx. +# The format is a list of tuples containing the path and title. +#epub_pre_files = [] + +# HTML files shat should be inserted after the pages created by sphinx. +# The format is a list of tuples containing the path and title. +#epub_post_files = [] + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ['search.html'] + +# The depth of the table of contents in toc.ncx. +#epub_tocdepth = 3 + +# Allow duplicate toc entries. +#epub_tocdup = True + +# Choose between 'default' and 'includehidden'. +#epub_tocscope = 'default' + +# Fix unsupported image types using the Pillow. +#epub_fix_images = False + +# Scale large images. +#epub_max_image_width = 0 + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#epub_show_urls = 'inline' + +# If false, no index is generated. +#epub_use_index = True + + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'https://docs.python.org/': None} diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 00000000..331a3870 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,22 @@ +.. JupyterHub documentation master file, created by + sphinx-quickstart on Mon Jan 4 16:31:09 2016. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to JupyterHub's documentation! +====================================== + +Contents: + +.. toctree:: + :maxdepth: 2 + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + From e52d2eb27d128209baed6b0e30352aa177fca16d Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Mon, 4 Jan 2016 16:50:53 -0800 Subject: [PATCH 130/231] Add Jupyter customizations --- docs/source/conf.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index ef045c48..6b99f65a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -41,7 +41,7 @@ templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ['.rst', '.md'] # The encoding of source files. #source_encoding = 'utf-8-sig' @@ -57,11 +57,14 @@ author = u'Project Jupyter team' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. -# +# Project Jupyter uses the following to autopopulate version +_version_py = '../../jupyterhub/version.py' +version_ns = {} +exec(compile(open(_version_py).read(), _version_py, 'exec'), version_ns) # The short X.Y version. -version = u'0.1' +version = '%i.%i' % version_ns['version_info'][:2] # The full version, including alpha/beta/rc tags. -release = u'0.1.1' +release = version_ns['__version__'] # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -112,7 +115,7 @@ todo_include_todos = False # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'alabaster' +html_theme = 'sphinx_rtd_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -360,3 +363,14 @@ epub_exclude_files = ['search.html'] # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = {'https://docs.python.org/': None} + +# Read The Docs +# on_rtd is whether we are on readthedocs.org, this line of code grabbed from docs.readthedocs.org +on_rtd = os.environ.get('READTHEDOCS', None) == 'True' + +if not on_rtd: # only import and set the theme if we're building docs locally + import sphinx_rtd_theme + html_theme = 'sphinx_rtd_theme' + html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + +# otherwise, readthedocs.org uses their theme by default, so no need to specify it \ No newline at end of file From bd8b8c55b2960bd03d6880e941efcb300f6771bf Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Mon, 4 Jan 2016 17:07:11 -0800 Subject: [PATCH 131/231] Add initial index file --- docs/source/index.rst | 63 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index 331a3870..7b5d6aad 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -3,14 +3,71 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to JupyterHub's documentation! -====================================== +JupyterHub +========== + +.. note:: This is the official documentation for JupyterHub. This project is + under active development. + +JupyterHub is a multi-user server that manages and proxies multiple instances +of the single-user Jupyter notebook server. + +Three actors: + +* multi-user Hub (tornado process) +* configurable http proxy (node-http-proxy) +* multiple single-user IPython notebook servers (Python/IPython/tornado) + +Basic principles: + +* Hub spawns proxy +* Proxy forwards ~all requests to hub by default +* Hub handles login, and spawns single-user servers on demand +* Hub configures proxy to forward url prefixes to single-user servers + Contents: .. toctree:: - :maxdepth: 2 + :maxdepth: 1 + :caption: User Documentation + getting-started + how-it-works + +.. toctree:: + :maxdepth: 2 + :caption: Configuration + + authenticators + spawners + +.. toctree:: + :maxdepth: 1 + :caption: Developer Documentation + + + +.. toctree:: + :maxdepth: 1 + :caption: Community documentation + + + +.. toctree:: + :maxdepth: 2 + :caption: About JupyterHub + + changelog + +.. toctree:: + :maxdepth: 1 + :caption: Questions? Suggestions? + + Jupyter mailing list + Jupyter website + Stack Overflow - Jupyter + Stack Overflow - Jupyter-notebook Indices and tables From 0c5c3eb8b1c3c3d4c232ee3ca8cf08a7ff56ed6e Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Mon, 4 Jan 2016 17:38:15 -0800 Subject: [PATCH 132/231] Add recommonmark --- docs/source/conf.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/source/conf.py b/docs/source/conf.py index 6b99f65a..2fcebb99 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -15,6 +15,7 @@ import sys import os import shlex +from recommonmark.parser import CommonMarkParser # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -41,6 +42,10 @@ templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # source_suffix = ['.rst', '.md'] +source_parsers = { + '.md': 'recommonmark.parser.CommonMarkParser', +} + source_suffix = ['.rst', '.md'] # The encoding of source files. From 0ad110f7de5c2df301c2db61a8d47b66a71026f1 Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Mon, 4 Jan 2016 18:41:45 -0800 Subject: [PATCH 133/231] Add parsers --- docs/doc-requirements.txt | 2 +- docs/source/conf.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/doc-requirements.txt b/docs/doc-requirements.txt index 4fa33db5..aa5e6ddf 100644 --- a/docs/doc-requirements.txt +++ b/docs/doc-requirements.txt @@ -1,4 +1,4 @@ -r ../requirements.txt sphinx commonmark -recommonmark +recommonmark \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index 2fcebb99..fa0b005a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -15,7 +15,8 @@ import sys import os import shlex -from recommonmark.parser import CommonMarkParser +from CommonMark import Parser, HtmlRenderer +import recommonmark.parser # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the From 04cb5fe5037b6b9e9f3ef6513f212e19522be32b Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Tue, 5 Jan 2016 16:05:17 -0800 Subject: [PATCH 134/231] Add recommonmark parser for markdown --- docs/source/conf.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index fa0b005a..b4b0f360 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -15,7 +15,8 @@ import sys import os import shlex -from CommonMark import Parser, HtmlRenderer + +# Needed for conversion from markdown to html import recommonmark.parser # If extensions (or modules to document with autodoc) are in another directory, @@ -40,13 +41,14 @@ extensions = [ # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# source_suffix = ['.rst', '.md'] +# Jupyter uses recommonmark's parser to convert markdown source_parsers = { '.md': 'recommonmark.parser.CommonMarkParser', } +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# source_suffix = ['.rst', '.md'] source_suffix = ['.rst', '.md'] # The encoding of source files. From 46a9e8b1c3ccea0c43031fdbaa316675b3865ad9 Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Tue, 5 Jan 2016 16:11:13 -0800 Subject: [PATCH 135/231] Update doc requirements --- docs/doc-requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/doc-requirements.txt b/docs/doc-requirements.txt index aa5e6ddf..aa528203 100644 --- a/docs/doc-requirements.txt +++ b/docs/doc-requirements.txt @@ -1,4 +1,3 @@ -r ../requirements.txt sphinx -commonmark -recommonmark \ No newline at end of file +recommonmark==0.4.0 \ No newline at end of file From 1bc0d208d397b47fe424a4e07e3bd70c6cea430e Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Tue, 5 Jan 2016 16:12:56 -0800 Subject: [PATCH 136/231] Move image files --- docs/{ => source}/images/spawn-form.png | Bin docs/source/index.rst | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename docs/{ => source}/images/spawn-form.png (100%) diff --git a/docs/images/spawn-form.png b/docs/source/images/spawn-form.png similarity index 100% rename from docs/images/spawn-form.png rename to docs/source/images/spawn-form.png diff --git a/docs/source/index.rst b/docs/source/index.rst index 7b5d6aad..eb3c716e 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -33,7 +33,7 @@ Contents: :caption: User Documentation getting-started - how-it-works + howitworks .. toctree:: :maxdepth: 2 From 131b695fbb9fba897c8fe74f70b87d6d6ee74ce7 Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Tue, 5 Jan 2016 17:32:25 -0800 Subject: [PATCH 137/231] Correct some links --- docs/source/authenticators.md | 6 +++--- docs/source/getting-started.md | 4 ++-- docs/source/howitworks.md | 2 +- docs/source/spawners.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/source/authenticators.md b/docs/source/authenticators.md index 6e3d370b..bc9de1fc 100644 --- a/docs/source/authenticators.md +++ b/docs/source/authenticators.md @@ -72,9 +72,9 @@ You can see an example implementation of an Authenticator that uses [GitHub OAut at [OAuthenticator][]. -[Authenticator]: ../jupyterhub/auth.py -[PAM]: http://en.wikipedia.org/wiki/Pluggable_authentication_module -[OAuth]: http://en.wikipedia.org/wiki/OAuth +[Authenticator]: https://github.com/jupyter/jupyterhub/blob/master/jupyterhub/auth.py +[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module +[OAuth]: https://en.wikipedia.org/wiki/OAuth [GitHub OAuth]: https://developer.github.com/v3/oauth/ [OAuthenticator]: https://github.com/jupyter/oauthenticator diff --git a/docs/source/getting-started.md b/docs/source/getting-started.md index bfcdda26..6957099d 100644 --- a/docs/source/getting-started.md +++ b/docs/source/getting-started.md @@ -6,7 +6,7 @@ JupyterHub is highly customizable, so there's a lot to cover. ## Installation -See [the readme](../README.md) for help installing JupyterHub. +See [the readme](https://github.com/jupyter/jupyterhub/blob/master/README.md) for help installing JupyterHub. ## Overview @@ -404,4 +404,4 @@ jupyterhub -f /path/to/aboveconfig.py [oauth-setup]: https://github.com/jupyter/oauthenticator#setup [oauthenticator]: https://github.com/jupyter/oauthenticator -[PAM]: http://en.wikipedia.org/wiki/Pluggable_authentication_module +[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module diff --git a/docs/source/howitworks.md b/docs/source/howitworks.md index 6b6d9ab5..a8ae2e60 100644 --- a/docs/source/howitworks.md +++ b/docs/source/howitworks.md @@ -51,7 +51,7 @@ Authentication is customizable via the Authenticator class. Authentication can be replaced by any mechanism, such as OAuth, Kerberos, etc. -JupyterHub only ships with [PAM](http://en.wikipedia.org/wiki/Pluggable_authentication_module) authentication, +JupyterHub only ships with [PAM](https://en.wikipedia.org/wiki/Pluggable_authentication_module) authentication, which requires the server to be run as root, or at least with access to the PAM service, which regular users typically do not have diff --git a/docs/source/spawners.md b/docs/source/spawners.md index c672ee7e..5462dae3 100644 --- a/docs/source/spawners.md +++ b/docs/source/spawners.md @@ -100,7 +100,7 @@ If the `Spawner.options_form` is defined, when a user would start their server, If `Spawner.options_form` is undefined, the users server is spawned directly, and no spawn page is rendered. -See [this example](../examples/spawn-form/jupyterhub_config.py) for a form that allows custom CLI args for the local spawner. +See [this example](https://github.com/jupyter/jupyterhub/blob/master/examples/spawn-form/jupyterhub_config.py) for a form that allows custom CLI args for the local spawner. ### `Spawner.options_from_form` From 2815f722505c35eedea3b76c704ef238eb48f317 Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Tue, 5 Jan 2016 19:45:02 -0800 Subject: [PATCH 138/231] Change mocking of slowspawner to match nospawner --- jupyterhub/tests/test_api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 7e598aea..facbb2c7 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -359,7 +359,8 @@ def test_spawn(app, io_loop): assert status == 0 def test_slow_spawn(app, io_loop): - app.tornado_application.settings['spawner_class'] = mocking.SlowSpawner + # app.tornado_application.settings['spawner_class'] = mocking.SlowSpawner + app.tornado_settings['spawner_class'] = mocking.SlowSpawner app.tornado_application.settings['slow_spawn_timeout'] = 0 app.tornado_application.settings['slow_stop_timeout'] = 0 From 4534bea86e60fa490894c0bf4a7cb79184e61125 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 6 Jan 2016 15:14:28 +0100 Subject: [PATCH 139/231] delete users via UserDict API avoids reusing user IDs when user creation fails --- jupyterhub/apihandlers/users.py | 12 +++--------- jupyterhub/user.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index 41cb6a27..3e79c252 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -53,8 +53,7 @@ class UserListAPIHandler(APIHandler): yield gen.maybe_future(self.authenticator.add_user(user)) except Exception: self.log.error("Failed to create user: %s" % name, exc_info=True) - self.db.delete(user) - self.db.commit() + del self.users[user] raise web.HTTPError(400, "Failed to create user: %s" % name) else: created.append(user) @@ -105,9 +104,7 @@ class UserAPIHandler(APIHandler): except Exception: self.log.error("Failed to create user: %s" % name, exc_info=True) # remove from registry - self.users.pop(user.id, None) - self.db.delete(user) - self.db.commit() + del self.users[user] raise web.HTTPError(400, "Failed to create user: %s" % name) self.write(json.dumps(self.user_model(user))) @@ -130,10 +127,7 @@ class UserAPIHandler(APIHandler): yield gen.maybe_future(self.authenticator.delete_user(user)) # remove from registry - self.users.pop(user.id, None) - # remove from the db - self.db.delete(user) - self.db.commit() + del self.users[user] self.set_status(204) diff --git a/jupyterhub/user.py b/jupyterhub/user.py index 10446144..071e0380 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -30,7 +30,14 @@ class UserDict(dict): def db(self): return self.db_factory() + def __contains__(self, key): + if isinstance(key, (User, orm.User)): + key = key.id + return dict.__contains__(self, key) + def __getitem__(self, key): + if isinstance(key, User): + key = key.id if isinstance(key, orm.User): # users[orm_user] returns User(orm_user) orm_user = key @@ -50,6 +57,14 @@ class UserDict(dict): return dict.__getitem__(self, id) else: raise KeyError(repr(key)) + + def __delitem__(self, key): + user = self[key] + user_id = user.id + db = self.db + db.delete(user.orm_user) + db.commit() + dict.__delitem__(self, user_id) class User(HasTraits): From a59f57e095c1701ab9b3a0f7a35ff9540a13f41a Mon Sep 17 00:00:00 2001 From: Tim Head Date: Sat, 9 Jan 2016 13:53:45 +0100 Subject: [PATCH 140/231] Handle file upload in spawner form Allow files to be uploaded in the spawner form. --- jupyterhub/handlers/pages.py | 2 ++ jupyterhub/tests/mocking.py | 2 ++ jupyterhub/tests/test_pages.py | 28 ++++++++++++++++++++++++++ share/jupyter/hub/templates/spawn.html | 4 ++-- 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index 4d6aeb06..c1ee6919 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -87,6 +87,8 @@ class SpawnHandler(BaseHandler): form_options = {} for key, byte_list in self.request.body_arguments.items(): form_options[key] = [ bs.decode('utf8') for bs in byte_list ] + for key, byte_list in self.request.files.items(): + form_options["%s_file"%key] = byte_list options = user.spawner.options_from_form(form_options) yield self.spawn_single_user(user, options=options) self.set_login_cookie(user) diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py index 0d7a3586..9491e1f9 100644 --- a/jupyterhub/tests/mocking.py +++ b/jupyterhub/tests/mocking.py @@ -83,6 +83,8 @@ class FormSpawner(MockSpawner): options['bounds'] = [int(i) for i in form_data['bounds']] if 'energy' in form_data: options['energy'] = form_data['energy'][0] + if 'hello_file' in form_data: + options['hello'] = form_data['hello_file'][0] return options diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py index 9ea08075..36df0638 100644 --- a/jupyterhub/tests/test_pages.py +++ b/jupyterhub/tests/test_pages.py @@ -119,3 +119,31 @@ def test_spawn_form(app, io_loop): 'notspecified': 5, } +def test_spawn_form_with_file(app, io_loop): + with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}): + base_url = ujoin(app.proxy.public_server.host, app.hub.server.base_url) + cookies = app.login_user('jones') + orm_u = orm.User.find(app.db, 'jones') + u = app.users[orm_u] + io_loop.run_sync(u.stop) + + r = requests.post(ujoin(base_url, 'spawn'), + cookies=cookies, + data={ + 'bounds': ['-1', '1'], + 'energy': '511keV', + }, + files={'hello': ('hello.txt', b'hello world\n')} + ) + r.raise_for_status() + print(u.spawner) + print(u.spawner.user_options) + assert u.spawner.user_options == { + 'energy': '511keV', + 'bounds': [-1, 1], + 'notspecified': 5, + 'hello': {'filename': 'hello.txt', + 'body': b'hello world\n', + 'content_type': 'application/unknown'}, + } + diff --git a/share/jupyter/hub/templates/spawn.html b/share/jupyter/hub/templates/spawn.html index 1bd26b89..e240ebfb 100644 --- a/share/jupyter/hub/templates/spawn.html +++ b/share/jupyter/hub/templates/spawn.html @@ -7,7 +7,7 @@

Spawner options

-
+ {{spawner_options_form}}
@@ -24,4 +24,4 @@ require(["jquery"], function ($) { $("#spawn_form").find("input, select, textarea, button").addClass("form-control"); }); -{% endblock %} \ No newline at end of file +{% endblock %} From 479b40d840813684aa8046e9142e7349973135a0 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 12 Jan 2016 15:22:39 +0100 Subject: [PATCH 141/231] add swagger spec for REST API --- docs/rest-api.yml | 259 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 docs/rest-api.yml diff --git a/docs/rest-api.yml b/docs/rest-api.yml new file mode 100644 index 00000000..1dd95069 --- /dev/null +++ b/docs/rest-api.yml @@ -0,0 +1,259 @@ +# see me at: http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/master/docs/rest-api.yml#/default +swagger: '2.0' +info: + title: JupyterHub + description: The REST API for JupyterHub + version: 0.4.0 +schemes: + - http +securityDefinitions: + token: + type: apiKey + name: Authorization + in: header +security: + - token: [] +basePath: /hub/api/ +produces: + - application/json +consumes: + - application/json +paths: + /users: + get: + summary: List users + responses: + '200': + description: The user list + schema: + type: array + items: + $ref: '#/definitions/User' + post: + summary: Create multiple users + parameters: + - name: data + in: body + required: true + schema: + type: object + properties: + usernames: + type: array + description: list of usernames to create + items: + type: string + admin: + description: whether the created users should be admins + type: boolean + responses: + '201': + description: The users have been created + schema: + type: array + description: The created users + items: + $ref: '#/definitions/User' + /users/{name}: + get: + summary: Get a user by name + parameters: + - name: name + description: username + in: path + required: true + type: string + responses: + '200': + description: The User model + schema: + $ref: '#/definitions/User' + post: + summary: Create a single user + parameters: + - name: name + description: username + in: path + required: true + type: string + responses: + '201': + description: The user has been created + schema: + $ref: '#/definitions/User' + delete: + summary: Delete a user + parameters: + - name: name + description: username + in: path + required: true + type: string + responses: + '204': + description: The user has been deleted + patch: + summary: Modify a user + description: Change a user's name or admin status + parameters: + - name: name + description: username + in: path + required: true + type: string + - name: data + in: body + required: true + description: Updated user info. At least one of name and admin is required. + schema: + type: object + properties: + name: + type: string + description: the new name (optional) + admin: + type: boolean + description: update admin (optional) + responses: + '200': + description: The updated user info + schema: + $ref: '#/definitions/User' + /users/{name}/server: + post: + summary: Start a user's server + parameters: + - name: name + description: username + in: path + required: true + type: string + responses: + '201': + description: The server has started + '202': + description: The server has been requested, but has not yet started + delete: + summary: Stop a user's server + parameters: + - name: name + description: username + in: path + required: true + type: string + responses: + '204': + description: The server has stopped + '202': + description: The server has been asked to stop, but is taking a while + /users/{name}/admin-access: + post: + summary: Grant an admin access to this user's server + parameters: + - name: name + description: username + in: path + required: true + type: string + responses: + '200': + description: Sets a cookie granting the requesting admin access to the user's server + /proxy: + get: + summary: Get the proxy's routing table + description: A convenience alias for getting the info directly from the proxy + responses: + '200': + description: Routing table + schema: + type: object + description: configurable-http-proxy routing table (see CHP docs for details) + post: + summary: Force the Hub to sync with the proxy + responses: + '200': + description: Success + patch: + summary: Tell the Hub about a new proxy + description: If you have started a new proxy and would like the Hub to switch over to it, this allows you to notify the Hub of the new proxy. + parameters: + - name: data + in: body + required: true + description: Any values that have changed for the new proxy. All keys are optional. + schema: + type: object + properties: + ip: + type: string + description: IP address of the new proxy + port: + type: string + description: Port of the new proxy + protocol: + type: string + description: Protocol of new proxy, if changed + auth_token: + type: string + description: CONFIGPROXY_AUTH_TOKEN for the new proxy + responses: + '200': + description: Success + /authorizations/token/{token}: + get: + summary: Identify a user from an API token + parameters: + - name: token + in: path + required: true + type: string + responses: + '200': + description: The user identified by the API token + schema: + $ref: '#!/definitions/User' + /authorizations/cookie/{cookie_name}/{cookie_value}: + get: + summary: Identify a user from a cookie + description: Used by single-user servers to hand off cookie authentication to the Hub + parameters: + - name: cookie_name + in: path + required: true + type: string + - name: cookie_value + in: path + required: true + type: string + responses: + '200': + description: The user identified by the cookie + schema: + $ref: '#!/definitions/User' + /shutdown: + post: + summary: Shutdown the Hub + responses: + '200': + description: Hub has shutdown +definitions: + User: + type: object + properties: + name: + type: string + description: The user's name + admin: + type: boolean + description: Whether the user is an admin + server: + type: string + description: The user's server's base URL, if running; null if not. + pending: + type: string + enum: ["spawn", "stop"] + description: The currently pending action, if any + last_activity: + type: string + format: ISO8601 Timestamp + description: Timestamp of last-seen activity from the user From 50a58e5e81a3d6d3ca3d259d2931de4d29349777 Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Tue, 12 Jan 2016 13:44:08 -0800 Subject: [PATCH 142/231] Update README after docs move to RTD --- README.md | 67 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 38ca5f28..af8d5c52 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,10 @@ Questions, comments? Visit our Google Group: [![Google Group](https://img.shields.io/badge/-Google%20Group-lightgrey.svg)](https://groups.google.com/forum/#!forum/jupyter) +[![Build Status](https://travis-ci.org/jupyter/jupyterhub.svg?branch=master)](https://travis-ci.org/jupyter/jupyterhub) +[![Documentation Status](https://readthedocs.org/projects/jupyterhub/badge/?version=latest)](http://jupyterhub.readthedocs.org/en/latest/?badge=latest) -JupyterHub is a multi-user server that manages and proxies multiple instances of the single-user IPython Jupyter notebook server. +JupyterHub, a multi-user server, manages and proxies multiple instances of the single-user IPython Jupyter notebook server. Three actors: @@ -22,28 +24,27 @@ Basic principles: ## Dependencies -JupyterHub requires IPython >= 3.0 (current master) and Python >= 3.3. +JupyterHub requires [IPython](https://ipython.org/install.html) >= 3.0 (current master) and [Python](https://www.python.org/downloads/) >= 3.3. -You will need nodejs/npm, which you can get from your package manager: +Install [nodejs/npm](https://www.npmjs.com/), which is available from your +package manager. For example, install on Linux (Debian/Ubuntu) using: sudo apt-get install npm nodejs-legacy -(The `nodejs-legacy` package installs the `node` executable, -which is required for npm to work on Debian/Ubuntu at this point) +(The `nodejs-legacy` package installs the `node` executable and is currently +required for npm to work on Debian/Ubuntu.) -Then install javascript dependencies: +Next, install JavaScript dependencies: sudo npm install -g configurable-http-proxy -### Optional +### Optional Installation Prerequisite (pip) -- Notes on `pip` command used in the below installation sections: - - `sudo` may be needed for `pip install`, depending on filesystem permissions. - - JupyterHub requires Python >= 3.3, so it may be required on some machines to use `pip3` instead - of `pip` (especially when you have both Python 2 and Python 3 installed on your machine). - If `pip3` is not found on your machine, you can get it by doing: +Notes on `pip` command used in the below installation sections: +- `sudo` may be needed for `pip install`, depending on the user's filesystem permissions. +- JupyterHub requires Python >= 3.3, so `pip3` may be required on some machines for package installation instead of `pip` (especially when both Python 2 and Python 3 are installed on a machine). If `pip3` is not found, install it using (on Linux Debian/Ubuntu): - sudo apt-get install python3-pip + sudo apt-get install python3-pip ## Installation @@ -52,16 +53,17 @@ JupyterHub can be installed with pip: pip3 install jupyterhub -If the `pip3 install .` command fails and complains about `lessc` being unavailable, you may need to explicitly install some additional javascript dependencies: +If the `pip3 install` command fails and complains about `lessc` being unavailable, you may need to explicitly install some additional JavaScript dependencies: npm install -If you plan to run notebook servers locally, you may also need to install the IPython notebook: +If you plan to run notebook servers locally, you may also need to install the +Jupyter ~~IPython~~ notebook: - pip3 install "ipython[notebook]" + pip3 install jupyter -This will fetch client-side javascript dependencies and compile CSS, -and install these files to `sys.prefix`/share/jupyter, as well as +This will fetch client-side JavaScript dependencies and compile CSS, +and install these files to `/share/jupyter`, as well as install any Python dependencies. @@ -73,7 +75,7 @@ For a development install, clone the repository and then install from source: cd jupyterhub pip3 install -r dev-requirements.txt -e . -In which case you may need to manually update javascript and css after some updates, with: +You may also need to manually update JavaScript and CSS after some development updates, with: python3 setup.py js # fetch updated client-side js (changes rarely) python3 setup.py css # recompile CSS from LESS sources @@ -87,22 +89,24 @@ To start the server, run the command: and then visit `http://localhost:8000`, and sign in with your unix credentials. -If you want multiple users to be able to sign into the server, you will need to run the -`jupyterhub` command as a privileged user, such as root. -The [wiki](https://github.com/jupyter/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges) describes how to run the server -as a less privileged user, which requires more configuration of the system. +To allow multiple users to sign into the server, you will need to +run the `jupyterhub` command as a *privileged user*, such as root. +The [wiki](https://github.com/jupyter/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges) +describes how to run the server as a *less privileged user*, which requires more +configuration of the system. ## Getting started -see the [getting started doc](docs/getting-started.md) for some of the basics of configuring your JupyterHub deployment. +See the [getting started document](docs/source/getting-started.md) for some of the +basics of configuring your JupyterHub deployment. ### Some examples -generate a default config file: +Generate a default config file: jupyterhub --generate-config -spawn the server on 10.0.1.2:443 with https: +Spawn the server on ``10.0.1.2:443`` with **https**: jupyterhub --ip 10.0.1.2 --port 443 --ssl-key my_ssl.key --ssl-cert my_ssl.cert @@ -111,7 +115,7 @@ which should allow plugging into a variety of authentication or process control Some examples, meant as illustration and testing of this concept: - Using GitHub OAuth instead of PAM with [OAuthenticator](https://github.com/jupyter/oauthenticator) -- Spawning single-user servers with docker, using the [DockerSpawner](https://github.com/jupyter/dockerspawner) +- Spawning single-user servers with Docker, using the [DockerSpawner](https://github.com/jupyter/dockerspawner) # Getting help @@ -119,6 +123,13 @@ We encourage you to ask questions on the mailing list: [![Google Group](https://img.shields.io/badge/-Google%20Group-lightgrey.svg)](https://groups.google.com/forum/#!forum/jupyter) -but you can participate in development discussions or get live help on Gitter: +and you may participate in development discussions or get live help on Gitter: [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/jupyter/jupyterhub?utm_source=badge&utm_medium=badge) + +## Resources +- [Project Jupyter website](https://jupyter.org) +- [Documentation for JupyterHub](http://jupyterhub.readthedocs.org/en/latest/) [[PDF](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf)] +- [Documentation for Project Jupyter](http://jupyter.readthedocs.org/en/latest/index.html) [[PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)] +- [Issues](https://github.com/jupyter/jupyterhub/issues) +- [Technical support - Jupyter Google Group](https://groups.google.com/forum/#!forum/jupyter) From 4afa3582014607226eaa6046787bee8c1f903a79 Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Tue, 12 Jan 2016 13:49:04 -0800 Subject: [PATCH 143/231] Add some minor formatting --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index af8d5c52..658bb050 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Next, install JavaScript dependencies: sudo npm install -g configurable-http-proxy -### Optional Installation Prerequisite (pip) +### (Optional) Installation Prerequisite (pip) Notes on `pip` command used in the below installation sections: - `sudo` may be needed for `pip install`, depending on the user's filesystem permissions. From 2cc49d317bb5e24298f329d960c521a8b449704f Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Tue, 12 Jan 2016 13:55:10 -0800 Subject: [PATCH 144/231] Add more wording tweaks --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 658bb050..f5ce5c9f 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,11 @@ Next, install JavaScript dependencies: ### (Optional) Installation Prerequisite (pip) -Notes on `pip` command used in the below installation sections: +Notes on the `pip` command used in the installation directions below: - `sudo` may be needed for `pip install`, depending on the user's filesystem permissions. - JupyterHub requires Python >= 3.3, so `pip3` may be required on some machines for package installation instead of `pip` (especially when both Python 2 and Python 3 are installed on a machine). If `pip3` is not found, install it using (on Linux Debian/Ubuntu): - sudo apt-get install python3-pip + sudo apt-get install python3-pip ## Installation @@ -60,7 +60,7 @@ If the `pip3 install` command fails and complains about `lessc` being unavailabl If you plan to run notebook servers locally, you may also need to install the Jupyter ~~IPython~~ notebook: - pip3 install jupyter + pip3 install jupyter This will fetch client-side JavaScript dependencies and compile CSS, and install these files to `/share/jupyter`, as well as @@ -97,7 +97,7 @@ configuration of the system. ## Getting started -See the [getting started document](docs/source/getting-started.md) for some of the +See the [getting started document](docs/source/getting-started.md) for the basics of configuring your JupyterHub deployment. ### Some examples From 887fdaf9d3aafdb8a19a88f97ed94261570f14e7 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 8 Jan 2016 15:45:57 +0100 Subject: [PATCH 145/231] add username normalization Handlers call `get_authenticated_user`, which in turn calls - authenticate - normalize_username - check_whitelist get_authenticated_user shouldn't need to be overridden. Normalization can be handled via overriding normalize_username. --- jupyterhub/apihandlers/users.py | 1 + jupyterhub/app.py | 12 +++++-- jupyterhub/auth.py | 61 ++++++++++++++++++++++++++++----- jupyterhub/handlers/base.py | 2 +- jupyterhub/tests/test_auth.py | 25 +++++++++----- 5 files changed, 80 insertions(+), 21 deletions(-) diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index 3e79c252..7c2fd151 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -34,6 +34,7 @@ class UserListAPIHandler(APIHandler): to_create = [] for name in usernames: + name = self.authenticator.normalize_username(name) user = self.find_user(name) if user is not None: self.log.warn("User %s already exists" % name) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 3b06b18f..59779577 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -630,7 +630,10 @@ class JupyterHub(Application): "\nUse Authenticator.admin_users instead." ) self.authenticator.admin_users = self.admin_users - admin_users = self.authenticator.admin_users + admin_users = [ + self.authenticator.normalize_username(name) + for name in self.authenticator.admin_users + ] if not admin_users: self.log.warning("No admin users, admin interface will be unavailable.") @@ -651,7 +654,10 @@ class JupyterHub(Application): # the admin_users config variable will never be used after this point. # only the database values will be referenced. - whitelist = self.authenticator.whitelist + whitelist = [ + self.authenticator.normalize_username(name) + for name in self.authenticator.whitelist + ] if not whitelist: self.log.info("Not using whitelist. Any authenticated user will be allowed.") @@ -671,7 +677,7 @@ class JupyterHub(Application): # but changes to the whitelist can occur in the database, # and persist across sessions. for user in db.query(orm.User): - whitelist.add(user.name) + self.authenticator.whitelist.add(user.name) # The whitelist set and the users in the db are now the same. # From this point on, any user changes should be done simultaneously diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py index 4a7898b0..5d11d8d8 100644 --- a/jupyterhub/auth.py +++ b/jupyterhub/auth.py @@ -53,6 +53,56 @@ class Authenticator(LoggingConfigurable): """ ) + def normalize_username(self, username): + """Normalize a username. + + Override in subclasses if usernames should have some normalization. + Default: cast to lowercase, lookup in username_map. + """ + username = username.lower() + return username + + def check_whitelist(self, username): + """Check a username against our whitelist. + + Return True if username is allowed, False otherwise. + No whitelist means any username should be allowed. + + Names are normalized *before* being checked against the whitelist. + """ + if not self.whitelist: + # No whitelist means any name is allowed + return True + return username in self.whitelist + + @gen.coroutine + def get_authenticated_user(self, handler, data): + """This is the outer API for authenticating a user. + + This calls `authenticate`, which should be overridden in subclasses, + normalizes the username if any normalization should be done, + and then validates the name in the whitelist. + + Subclasses should not need to override this method. + The various stages can be overridden separately: + + - authenticate turns formdata into a username + - normalize_username normalizes the username + - check_whitelist checks against the user whitelist + """ + username = yield self.authenticate(handler, data) + if username is None: + return + username = self.normalize_username(username) + if not self.validate_username(username): + self.log.warning("Disallowing invalid username %r.", username) + return + if self.check_whitelist(username): + return username + else: + self.log.warning("User %r not in whitelist.", username) + return + @gen.coroutine def authenticate(self, handler, data): """Authenticate a user with login form data. @@ -60,6 +110,8 @@ class Authenticator(LoggingConfigurable): This must be a tornado gen.coroutine. It must return the username on successful authentication, and return None on failed authentication. + + Checking the whitelist is handled separately by the caller. """ def pre_spawn_start(self, user, spawner): @@ -74,13 +126,6 @@ class Authenticator(LoggingConfigurable): Can be used to do auth-related cleanup, e.g. closing PAM sessions. """ - def check_whitelist(self, user): - """ - Return True if the whitelist is empty or user is in the whitelist. - """ - # Parens aren't necessary here, but they make this easier to parse. - return (not self.whitelist) or (user in self.whitelist) - def add_user(self, user): """Add a new user @@ -240,8 +285,6 @@ class PAMAuthenticator(LocalAuthenticator): Return None otherwise. """ username = data['username'] - if not self.check_whitelist(username): - return try: pamela.authenticate(username, data['password'], service=self.service) except pamela.PAMError as e: diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index 0a00af84..94c2e79c 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -247,7 +247,7 @@ class BaseHandler(RequestHandler): def authenticate(self, data): auth = self.authenticator if auth is not None: - result = yield auth.authenticate(self, data) + result = yield auth.get_authenticated_user(self, data) return result else: self.log.error("No authentication function, login is impossible!") diff --git a/jupyterhub/tests/test_auth.py b/jupyterhub/tests/test_auth.py index 802f3711..70471ecf 100644 --- a/jupyterhub/tests/test_auth.py +++ b/jupyterhub/tests/test_auth.py @@ -13,13 +13,13 @@ from jupyterhub import auth, orm def test_pam_auth(io_loop): authenticator = MockPAMAuthenticator() - authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, { + authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, { 'username': 'match', 'password': 'match', })) assert authorized == 'match' - authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, { + authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, { 'username': 'match', 'password': 'nomatch', })) @@ -27,19 +27,19 @@ def test_pam_auth(io_loop): def test_pam_auth_whitelist(io_loop): authenticator = MockPAMAuthenticator(whitelist={'wash', 'kaylee'}) - authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, { + authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, { 'username': 'kaylee', 'password': 'kaylee', })) assert authorized == 'kaylee' - authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, { + authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, { 'username': 'wash', 'password': 'nomatch', })) assert authorized is None - authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, { + authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, { 'username': 'mal', 'password': 'mal', })) @@ -59,14 +59,14 @@ def test_pam_auth_group_whitelist(io_loop): authenticator = MockPAMAuthenticator(group_whitelist={'group'}) with mock.patch.object(auth, 'getgrnam', getgrnam): - authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, { + authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, { 'username': 'kaylee', 'password': 'kaylee', })) assert authorized == 'kaylee' with mock.patch.object(auth, 'getgrnam', getgrnam): - authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, { + authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, { 'username': 'mal', 'password': 'mal', })) @@ -75,7 +75,7 @@ def test_pam_auth_group_whitelist(io_loop): def test_pam_auth_no_such_group(io_loop): authenticator = MockPAMAuthenticator(group_whitelist={'nosuchcrazygroup'}) - authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, { + authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, { 'username': 'kaylee', 'password': 'kaylee', })) @@ -144,3 +144,12 @@ def test_handlers(app): assert handlers[0][0] == '/login' +def test_normalize_names(io_loop): + a = MockPAMAuthenticator() + authorized = io_loop.run_sync(lambda : a.get_authenticated_user(None, { + 'username': 'ZOE', + 'password': 'ZOE', + })) + assert authorized == 'zoe' + + From beb2dae6ce0558643224d1606bea20d74eb18e46 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 8 Jan 2016 15:47:01 +0100 Subject: [PATCH 146/231] add username_map --- jupyterhub/auth.py | 10 ++++++++++ jupyterhub/tests/test_auth.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py index 5d11d8d8..27e473ff 100644 --- a/jupyterhub/auth.py +++ b/jupyterhub/auth.py @@ -53,6 +53,15 @@ class Authenticator(LoggingConfigurable): """ ) + username_map = Dict(config=True, + help="""Dictionary mapping authenticator usernames to JupyterHub users. + + Can be used to map OAuth service names to local users, for instance. + + Used in normalize_username. + """ + ) + def normalize_username(self, username): """Normalize a username. @@ -60,6 +69,7 @@ class Authenticator(LoggingConfigurable): Default: cast to lowercase, lookup in username_map. """ username = username.lower() + username = self.username_map.get(username, username) return username def check_whitelist(self, username): diff --git a/jupyterhub/tests/test_auth.py b/jupyterhub/tests/test_auth.py index 70471ecf..365c72a3 100644 --- a/jupyterhub/tests/test_auth.py +++ b/jupyterhub/tests/test_auth.py @@ -153,3 +153,19 @@ def test_normalize_names(io_loop): assert authorized == 'zoe' +def test_username_map(io_loop): + a = MockPAMAuthenticator(username_map={'wash': 'alpha'}) + authorized = io_loop.run_sync(lambda : a.get_authenticated_user(None, { + 'username': 'WASH', + 'password': 'WASH', + })) + + assert authorized == 'alpha' + + authorized = io_loop.run_sync(lambda : a.get_authenticated_user(None, { + 'username': 'Inara', + 'password': 'Inara', + })) + assert authorized == 'inara' + + From 9441fa37c529e94b2432bcc3e1ef8baef71f2810 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 8 Jan 2016 15:49:16 +0100 Subject: [PATCH 147/231] validate usernames via Authenticator.validate_username base class configurable with Authenticator.username_pattern --- jupyterhub/apihandlers/users.py | 11 +++++++++++ jupyterhub/app.py | 6 ++++++ jupyterhub/auth.py | 26 +++++++++++++++++++++++++- jupyterhub/tests/test_api.py | 11 +++++++++++ jupyterhub/tests/test_auth.py | 9 +++++++++ 5 files changed, 62 insertions(+), 1 deletion(-) diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index 7c2fd151..e27d0cdc 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -33,14 +33,25 @@ class UserListAPIHandler(APIHandler): admin = data.get('admin', False) to_create = [] + invalid_names = [] for name in usernames: name = self.authenticator.normalize_username(name) + if not self.authenticator.validate_username(name): + invalid_names.append(name) + continue user = self.find_user(name) if user is not None: self.log.warn("User %s already exists" % name) else: to_create.append(name) + if invalid_names: + if len(invalid_names) == 1: + msg = "Invalid username: %s" % invalid_names[0] + else: + msg = "Invalid usernames: %s" % ', '.join(invalid_names) + raise web.HTTPError(400, msg) + if not to_create: raise web.HTTPError(400, "All %i users already exist" % len(usernames)) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 59779577..bf321f23 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -634,6 +634,9 @@ class JupyterHub(Application): self.authenticator.normalize_username(name) for name in self.authenticator.admin_users ] + for username in admin_users: + if not self.authenticator.validate_username(username): + raise ValueError("username %r is not valid" % username) if not admin_users: self.log.warning("No admin users, admin interface will be unavailable.") @@ -658,6 +661,9 @@ class JupyterHub(Application): self.authenticator.normalize_username(name) for name in self.authenticator.whitelist ] + for username in whitelist: + if not self.authenticator.validate_username(username): + raise ValueError("username %r is not valid" % username) if not whitelist: self.log.info("Not using whitelist. Any authenticated user will be allowed.") diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py index 27e473ff..c81caf04 100644 --- a/jupyterhub/auth.py +++ b/jupyterhub/auth.py @@ -14,7 +14,7 @@ from tornado import gen import pamela from traitlets.config import LoggingConfigurable -from traitlets import Bool, Set, Unicode, Any +from traitlets import Bool, Set, Unicode, Dict, Any from .handlers.login import LoginHandler from .utils import url_path_join @@ -53,6 +53,28 @@ class Authenticator(LoggingConfigurable): """ ) + username_pattern = Unicode(config=True, + help="""Regular expression pattern for validating usernames. + + If not defined: allow any username. + """ + ) + def _username_pattern_changed(self, name, old, new): + if not new: + self.username_regex = None + self.username_regex = re.compile(new) + + username_regex = Any() + + def validate_username(self, username): + """Validate a (normalized) username. + + Return True if username is valid, False otherwise. + """ + if not self.username_regex: + return True + return bool(self.username_regex.match(username)) + username_map = Dict(config=True, help="""Dictionary mapping authenticator usernames to JupyterHub users. @@ -144,6 +166,8 @@ class Authenticator(LoggingConfigurable): Subclasses may do more extensive things, such as adding actual unix users. """ + if not self.validate_username(user.name): + raise ValueError("Invalid username: %s" % user.name) if self.whitelist: self.whitelist.add(user.name) diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index facbb2c7..413ff913 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -209,6 +209,17 @@ def test_add_multi_user_bad(app): r = api_request(app, 'users', method='post', data='[]') assert r.status_code == 400 + +def test_add_multi_user_invalid(app): + app.authenticator.username_pattern = r'w.*' + r = api_request(app, 'users', method='post', + data=json.dumps({'usernames': ['Willow', 'Andrew', 'Tara']}) + ) + app.authenticator.username_pattern = '' + assert r.status_code == 400 + assert r.json()['message'] == 'Invalid usernames: andrew, tara' + + def test_add_multi_user(app): db = app.db names = ['a', 'b'] diff --git a/jupyterhub/tests/test_auth.py b/jupyterhub/tests/test_auth.py index 365c72a3..5907244a 100644 --- a/jupyterhub/tests/test_auth.py +++ b/jupyterhub/tests/test_auth.py @@ -169,3 +169,12 @@ def test_username_map(io_loop): assert authorized == 'inara' +def test_validate_names(io_loop): + a = auth.PAMAuthenticator() + assert a.validate_username('willow') + assert a.validate_username('giles') + a = auth.PAMAuthenticator(username_pattern='w.*') + assert not a.validate_username('xander') + assert a.validate_username('willow') + + From aa93384f4748e9c51a311dd8d885374ead5ec186 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 8 Jan 2016 15:49:57 +0100 Subject: [PATCH 148/231] Include system-user creation error message in API reply when system-user creation fails --- jupyterhub/apihandlers/users.py | 4 ++-- jupyterhub/auth.py | 14 ++++++++------ jupyterhub/tests/test_auth.py | 32 ++++++++++++++++++++++++-------- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index e27d0cdc..aa4488ae 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -63,10 +63,10 @@ class UserListAPIHandler(APIHandler): self.db.commit() try: yield gen.maybe_future(self.authenticator.add_user(user)) - except Exception: + except Exception as e: self.log.error("Failed to create user: %s" % name, exc_info=True) del self.users[user] - raise web.HTTPError(400, "Failed to create user: %s" % name) + raise web.HTTPError(400, "Failed to create user %s: %s" % (name, str(e))) else: created.append(user) diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py index c81caf04..c2220b98 100644 --- a/jupyterhub/auth.py +++ b/jupyterhub/auth.py @@ -6,9 +6,10 @@ from grp import getgrnam import pipes import pwd +import re from shutil import which import sys -from subprocess import check_call +from subprocess import Popen, PIPE, STDOUT from tornado import gen import pamela @@ -271,10 +272,7 @@ class LocalAuthenticator(Authenticator): def add_user(self, user): """Add a new user - By default, this just adds the user to the whitelist. - - Subclasses may do more extensive things, - such as adding actual unix users. + If self.create_system_users, the user will attempt to be created. """ user_exists = yield gen.maybe_future(self.system_user_exists(user)) if not user_exists: @@ -300,7 +298,11 @@ class LocalAuthenticator(Authenticator): name = user.name cmd = [ arg.replace('USERNAME', name) for arg in self.add_user_cmd ] + [name] self.log.info("Creating user: %s", ' '.join(map(pipes.quote, cmd))) - check_call(cmd) + p = Popen(cmd, stdout=PIPE, stderr=STDOUT) + p.wait() + if p.returncode: + err = p.stdout.read().decode('utf8', 'replace') + raise RuntimeError("Failed to create system user %s: %s" % (name, err)) class PAMAuthenticator(LocalAuthenticator): diff --git a/jupyterhub/tests/test_auth.py b/jupyterhub/tests/test_auth.py index 5907244a..92722b07 100644 --- a/jupyterhub/tests/test_auth.py +++ b/jupyterhub/tests/test_auth.py @@ -3,7 +3,6 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -from subprocess import CalledProcessError from unittest import mock import pytest @@ -96,12 +95,24 @@ def test_cant_add_system_user(io_loop): authenticator.add_user_cmd = ['jupyterhub-fake-command'] authenticator.create_system_users = True - def check_call(cmd, *a, **kw): - raise CalledProcessError(1, cmd) + class DummyFile: + def read(self): + return b'dummy error' - with mock.patch.object(auth, 'check_call', check_call): - with pytest.raises(CalledProcessError): + class DummyPopen: + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + self.returncode = 1 + self.stdout = DummyFile() + + def wait(self): + return + + with mock.patch.object(auth, 'Popen', DummyPopen): + with pytest.raises(RuntimeError) as exc: io_loop.run_sync(lambda : authenticator.add_user(user)) + assert str(exc.value) == 'Failed to create system user lioness4321: dummy error' def test_add_system_user(io_loop): @@ -111,10 +122,15 @@ def test_add_system_user(io_loop): authenticator.add_user_cmd = ['echo', '/home/USERNAME'] record = {} - def check_call(cmd, *a, **kw): - record['cmd'] = cmd + class DummyPopen: + def __init__(self, cmd, *args, **kwargs): + record['cmd'] = cmd + self.returncode = 0 + + def wait(self): + return - with mock.patch.object(auth, 'check_call', check_call): + with mock.patch.object(auth, 'Popen', DummyPopen): io_loop.run_sync(lambda : authenticator.add_user(user)) assert record['cmd'] == ['echo', '/home/lioness4321', 'lioness4321'] From 108d710dcbac3fff6040222f5bcc179f885fe201 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 8 Jan 2016 15:59:18 +0100 Subject: [PATCH 149/231] doc: username normalization and validation --- docs/source/authenticators.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/source/authenticators.md b/docs/source/authenticators.md index bc9de1fc..24d2add1 100644 --- a/docs/source/authenticators.md +++ b/docs/source/authenticators.md @@ -63,6 +63,36 @@ For local user authentication (e.g. PAM), this lets you limit which users can login. +## Normalizing and validating usernames + +Since the Authenticator and Spawner both use the same username, +sometimes you want to transform the name coming from the authentication service +(e.g. turning email addresses into local system usernames) before adding them to the Hub service. +Authenticators can define `normalize_username`, which takes a username. +The default normalization is to cast names to lowercase + +For simple mappings, a configurable dict `Authenticator.username_map` is used to turn one name into another: + +```python +c.Authenticator.username_map = { + 'service-name': 'localname' +} + +### Validating usernames + +In most cases, there is a very limited set of acceptable usernames. +Authenticators can define `validate_username(username)`, +which should return True for a valid username and False for an invalid one. +The primary effect this has is improving error messages during user creation. + +The default behavior is to use configurable `Authenticator.username_pattern`, +which is a regular expression string for validation. + +To only allow usernames that start with 'w': + + c.Authenticator.username_pattern = r'w.*' + + ## OAuth and other non-password logins Some login mechanisms, such as [OAuth][], don't map onto username+password. From ff4019128aa89462314551e4c463cfc1721f38a6 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 13 Jan 2016 15:09:18 +0100 Subject: [PATCH 150/231] move install commands around a bit npm/less notes are only relevant for dev installs --- README.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f5ce5c9f..2400733c 100644 --- a/README.md +++ b/README.md @@ -53,18 +53,10 @@ JupyterHub can be installed with pip: pip3 install jupyterhub -If the `pip3 install` command fails and complains about `lessc` being unavailable, you may need to explicitly install some additional JavaScript dependencies: - - npm install - If you plan to run notebook servers locally, you may also need to install the Jupyter ~~IPython~~ notebook: - pip3 install jupyter - -This will fetch client-side JavaScript dependencies and compile CSS, -and install these files to `/share/jupyter`, as well as -install any Python dependencies. + pip3 install notebook ### Development install @@ -75,6 +67,12 @@ For a development install, clone the repository and then install from source: cd jupyterhub pip3 install -r dev-requirements.txt -e . +If the `pip3 install` command fails and complains about `lessc` being unavailable, you may need to explicitly install some additional JavaScript dependencies: + + npm install + +This will fetch client-side JavaScript dependencies necessary to compile CSS. + You may also need to manually update JavaScript and CSS after some development updates, with: python3 setup.py js # fetch updated client-side js (changes rarely) From 614a0806f5302e4d6a5f7657a50484929f639713 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 15 Jan 2016 14:21:45 +0100 Subject: [PATCH 151/231] use start_new_session to detach single-user servers instead of setpgrp, which causes various problems --- jupyterhub/spawner.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 3b6e570c..f5c7e2b1 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -318,9 +318,6 @@ def set_user_setuid(username): gids = [ g.gr_gid for g in grp.getgrall() if username in g.gr_mem ] def preexec(): - # don't forward signals - os.setpgrp() - # set the user and group os.setgid(gid) try: @@ -405,6 +402,7 @@ class LocalProcessSpawner(Spawner): self.log.info("Spawning %s", ' '.join(pipes.quote(s) for s in cmd)) self.proc = Popen(cmd, env=env, preexec_fn=self.make_preexec_fn(self.user.name), + start_new_session=True, # don't forward signals ) self.pid = self.proc.pid From 1a4226419f0ecdceec98c26d8c4525cd3bb04b82 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 20 Jan 2016 14:33:35 +0100 Subject: [PATCH 152/231] Base Dockerfile on debian:jessie rather than jupyter/notebook and use conda to get Python 3.5 No longer includes single-user server dependencies --- Dockerfile | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7dbcf757..95eddfa1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,28 +5,33 @@ # FROM jupyter/jupyterhub:latest # -FROM jupyter/notebook +FROM debian:jessie MAINTAINER Jupyter Project # install nodejs ENV DEBIAN_FRONTEND noninteractive -RUN apt-get -y update && apt-get -y upgrade && apt-get -y install npm nodejs nodejs-legacy +RUN apt-get -y update && apt-get -y upgrade && apt-get -y install npm nodejs nodejs-legacy wget + +# install Python with conda +RUN wget -q https://repo.continuum.io/miniconda/Miniconda3-3.9.1-Linux-x86_64.sh -O /tmp/miniconda.sh && \ + bash /tmp/miniconda.sh -f -b -p /opt/conda && \ + /opt/conda/bin/conda install --yes python=3.5 sqlalchemy tornado jinja2 traitlets requests pip && \ + rm /tmp/miniconda.sh +ENV PATH=/opt/conda/bin:$PATH + +# install any pip dependencies not already installed by conda +ADD requirements.txt /tmp/requirements.txt +RUN pip install -r /tmp/requirements.txt # install js dependencies RUN npm install -g configurable-http-proxy -RUN mkdir -p /srv/ - -# install jupyterhub -ADD requirements.txt /tmp/requirements.txt -RUN pip3 install -r /tmp/requirements.txt - WORKDIR /srv/ ADD . /srv/jupyterhub WORKDIR /srv/jupyterhub/ -RUN pip3 install . +RUN pip install . WORKDIR /srv/jupyterhub/ From 51a04258d168e85170a8552f8530f212dd33c94f Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 12 Jan 2016 16:35:36 +0100 Subject: [PATCH 153/231] build on readthedocs --- docs/{doc-requirements.txt => requirements.txt} | 0 readthedocs.yml | 5 +++++ 2 files changed, 5 insertions(+) rename docs/{doc-requirements.txt => requirements.txt} (100%) create mode 100644 readthedocs.yml diff --git a/docs/doc-requirements.txt b/docs/requirements.txt similarity index 100% rename from docs/doc-requirements.txt rename to docs/requirements.txt diff --git a/readthedocs.yml b/readthedocs.yml new file mode 100644 index 00000000..fd8fa86f --- /dev/null +++ b/readthedocs.yml @@ -0,0 +1,5 @@ +name: jupyterhub +type: sphinx +requirements_file: docs/requirements.txt +python: + version: 3 From 37d42a336f4e26c2190f367a5e43268ee452ad1e Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 13 Jan 2016 15:32:21 +0100 Subject: [PATCH 154/231] put repo on path allows autodoc to import jupyterhub without installing it --- docs/source/conf.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index b4b0f360..a23ad2df 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -66,13 +66,15 @@ author = u'Project Jupyter team' # |version| and |release|, also used in various other places throughout the # built documents. # Project Jupyter uses the following to autopopulate version -_version_py = '../../jupyterhub/version.py' -version_ns = {} -exec(compile(open(_version_py).read(), _version_py, 'exec'), version_ns) +from os.path import dirname +root = dirname(dirname(dirname(__file__))) +sys.path.insert(0, root) + +import jupyterhub # The short X.Y version. -version = '%i.%i' % version_ns['version_info'][:2] +version = '%i.%i' % jupyterhub.version_info[:2] # The full version, including alpha/beta/rc tags. -release = version_ns['__version__'] +release = jupyterhub.__version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -381,4 +383,4 @@ if not on_rtd: # only import and set the theme if we're building docs locally html_theme = 'sphinx_rtd_theme' html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] -# otherwise, readthedocs.org uses their theme by default, so no need to specify it \ No newline at end of file +# otherwise, readthedocs.org uses their theme by default, so no need to specify it From eb0a38c136647bb4bbc20828dde5064e749cb729 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 13 Jan 2016 16:10:22 +0100 Subject: [PATCH 155/231] add preliminary API docs --- docs/source/api/auth.rst | 21 ++++++++++++++ docs/source/api/index.rst | 14 ++++++++++ docs/source/api/spawner.rst | 18 ++++++++++++ docs/source/api/user.rst | 31 ++++++++++++++++++++ docs/source/conf.py | 2 +- docs/source/index.rst | 3 +- jupyterhub/auth.py | 56 +++++++++++++++++++++++++++++++++---- jupyterhub/spawner.py | 9 ++++-- 8 files changed, 144 insertions(+), 10 deletions(-) create mode 100644 docs/source/api/auth.rst create mode 100644 docs/source/api/index.rst create mode 100644 docs/source/api/spawner.rst create mode 100644 docs/source/api/user.rst diff --git a/docs/source/api/auth.rst b/docs/source/api/auth.rst new file mode 100644 index 00000000..6ea06404 --- /dev/null +++ b/docs/source/api/auth.rst @@ -0,0 +1,21 @@ +============== +Authenticators +============== + +Module: :mod:`jupyterhub.auth` +============================== + +.. automodule:: jupyterhub.auth + +.. currentmodule:: jupyterhub.auth + + + +.. autoclass:: Authenticator + :members: + +.. autoclass:: LocalAuthenticator + :members: + +.. autoclass:: PAMAuthenticator + diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst new file mode 100644 index 00000000..ccb14fd3 --- /dev/null +++ b/docs/source/api/index.rst @@ -0,0 +1,14 @@ +.. _api-index: + +#################### + The JupyterHub API +#################### + +:Release: |release| +:Date: |today| + +.. toctree:: + + auth + spawner + user diff --git a/docs/source/api/spawner.rst b/docs/source/api/spawner.rst new file mode 100644 index 00000000..15cb847d --- /dev/null +++ b/docs/source/api/spawner.rst @@ -0,0 +1,18 @@ +============== + Spawners +============== + +Module: :mod:`jupyterhub.spawner` +================================= + +.. automodule:: jupyterhub.spawner + +.. currentmodule:: jupyterhub.spawner + +:class:`Spawner` +---------------- + +.. autoclass:: Spawner + :members: options_from_form, poll, start, stop, get_args, get_env, get_state + +.. autoclass:: LocalProcessSpawner diff --git a/docs/source/api/user.rst b/docs/source/api/user.rst new file mode 100644 index 00000000..2e7b31cf --- /dev/null +++ b/docs/source/api/user.rst @@ -0,0 +1,31 @@ +============= + Users +============= + +Module: :mod:`jupyterhub.user` +============================== + +.. automodule:: jupyterhub.user + +.. currentmodule:: jupyterhub.user + +:class:`User` +------------- + +.. class:: Server + +.. autoclass:: User + :members: escaped_name + + .. attribute:: name + + The user's name + + .. attribute:: server + + The user's Server data object if running, None otherwise. + Has ``ip``, ``port`` attributes. + + .. attribute:: spawner + + The user's :class:`~.Spawner` instance. diff --git a/docs/source/conf.py b/docs/source/conf.py index a23ad2df..94aa9ad9 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -35,7 +35,7 @@ import recommonmark.parser extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', - 'sphinx.ext.mathjax', + 'sphinx.ext.napoleon', ] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/source/index.rst b/docs/source/index.rst index eb3c716e..3de21908 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -45,7 +45,8 @@ Contents: .. toctree:: :maxdepth: 1 :caption: Developer Documentation - + + api/index .. toctree:: diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py index c2220b98..7b776c8e 100644 --- a/jupyterhub/auth.py +++ b/jupyterhub/auth.py @@ -1,4 +1,4 @@ -"""Simple PAM authenticator""" +"""Base Authenticator class and the default PAM Authenticator""" # Copyright (c) IPython Development Team. # Distributed under the terms of the Modified BSD License. @@ -24,7 +24,8 @@ from .traitlets import Command class Authenticator(LoggingConfigurable): """A class for authentication. - The API is one method, `authenticate`, a tornado gen.coroutine. + The primary API is one method, `authenticate`, a tornado coroutine + for authenticating users. """ db = Any() @@ -145,6 +146,14 @@ class Authenticator(LoggingConfigurable): and return None on failed authentication. Checking the whitelist is handled separately by the caller. + + Args: + handler (tornado.web.RequestHandler): the current request handler + data (dict): The formdata of the login form. + The default form has 'username' and 'password' fields. + Return: + str: the username of the authenticated user + None: Authentication failed """ def pre_spawn_start(self, user, spawner): @@ -165,7 +174,11 @@ class Authenticator(LoggingConfigurable): By default, this just adds the user to the whitelist. Subclasses may do more extensive things, - such as adding actual unix users. + such as adding actual unix users, + but they should call super to ensure the whitelist is updated. + + Args: + user (User): The User wrapper object """ if not self.validate_username(user.name): raise ValueError("Invalid username: %s" % user.name) @@ -176,21 +189,52 @@ class Authenticator(LoggingConfigurable): """Triggered when a user is deleted. Removes the user from the whitelist. + Subclasses should call super to ensure the whitelist is updated. + + Args: + user (User): The User wrapper object """ self.whitelist.discard(user.name) def login_url(self, base_url): - """Override to register a custom login handler""" + """Override to register a custom login handler + + Generally used in combination with get_handlers. + + Args: + base_url (str): the base URL of the Hub (e.g. /hub/) + + Returns: + str: The login URL, e.g. '/hub/login' + + """ return url_path_join(base_url, 'login') def logout_url(self, base_url): - """Override to register a custom logout handler""" + """Override to register a custom logout handler. + + Generally used in combination with get_handlers. + + Args: + base_url (str): the base URL of the Hub (e.g. /hub/) + + Returns: + str: The logout URL, e.g. '/hub/logout' + """ return url_path_join(base_url, 'logout') def get_handlers(self, app): """Return any custom handlers the authenticator needs to register - (e.g. for OAuth) + (e.g. for OAuth). + + Args: + app (JupyterHub Application): + the application object, in case it needs to be accessed for info. + Returns: + list: list of ``('/url', Handler)`` tuples passed to tornado. + The Hub prefix is added to any URLs. + """ return [ ('/login', LoginHandler), diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index f5c7e2b1..60f4b29e 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -163,7 +163,7 @@ class Spawner(LoggingConfigurable): """store the state necessary for load_state A black box of extra state for custom spawners. - Should call `super`. + Subclasses should call `super`. Returns ------- @@ -333,7 +333,12 @@ def set_user_setuid(username): class LocalProcessSpawner(Spawner): - """A Spawner that just uses Popen to start local processes.""" + """A Spawner that just uses Popen to start local processes as users. + + Requires users to exist on the local system. + + This is the default spawner for JupyterHub. + """ INTERRUPT_TIMEOUT = Integer(10, config=True, help="Seconds to wait for process to halt after SIGINT before proceeding to SIGTERM" From fbf3b45d528abb4dd0af9e67f25c865640bbbf83 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 13 Jan 2016 16:56:07 +0100 Subject: [PATCH 156/231] needs sphinx 1.3 --- .gitignore | 2 ++ docs/source/conf.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 693f6d80..dd6fdf8e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ node_modules .DS_Store build dist +docs/_build +.ipynb_checkpoints # ignore config file at the top-level of the repo # but not sub-dirs /jupyterhub_config.py diff --git a/docs/source/conf.py b/docs/source/conf.py index 94aa9ad9..26a16881 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -27,7 +27,7 @@ import recommonmark.parser # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +needs_sphinx = '1.3' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom From 8146af7240cb8bfcf257deda4ab9333742c40239 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 20 Jan 2016 15:41:54 +0100 Subject: [PATCH 157/231] make jupyterhub-singleuser a script instead of a module in the package makes it easier to do `/path/to/python $(which jupyterhub-singleuser)` --- jupyterhub/singleuser.py | 237 ------------------------------- jupyterhub/tests/test_spawner.py | 2 +- scripts/jupyterhub-singleuser | 237 ++++++++++++++++++++++++++++++- 3 files changed, 236 insertions(+), 240 deletions(-) delete mode 100644 jupyterhub/singleuser.py diff --git a/jupyterhub/singleuser.py b/jupyterhub/singleuser.py deleted file mode 100644 index 21cc0ba2..00000000 --- a/jupyterhub/singleuser.py +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env python3 -"""Extend regular notebook server to be aware of multiuser things.""" - -# Copyright (c) Jupyter Development Team. -# Distributed under the terms of the Modified BSD License. - -import os -try: - from urllib.parse import quote -except ImportError: - # PY2 Compat - from urllib import quote - -import requests -from jinja2 import ChoiceLoader, FunctionLoader - -from tornado import ioloop -from tornado.web import HTTPError - - -from IPython.utils.traitlets import ( - Integer, - Unicode, - CUnicode, -) - -try: - import notebook - # 4.x -except ImportError: - from IPython.html.notebookapp import NotebookApp, aliases as notebook_aliases - from IPython.html.auth.login import LoginHandler - from IPython.html.auth.logout import LogoutHandler - - from IPython.html.utils import url_path_join - - from distutils.version import LooseVersion as V - - import IPython - if V(IPython.__version__) < V('3.0'): - raise ImportError("JupyterHub Requires IPython >= 3.0, found %s" % IPython.__version__) -else: - from notebook.notebookapp import NotebookApp, aliases as notebook_aliases - from notebook.auth.login import LoginHandler - from notebook.auth.logout import LogoutHandler - - from notebook.utils import url_path_join - - -# Define two methods to attach to AuthenticatedHandler, -# which authenticate via the central auth server. - -class JupyterHubLoginHandler(LoginHandler): - @staticmethod - def login_available(settings): - return True - - @staticmethod - def verify_token(self, cookie_name, encrypted_cookie): - """method for token verification""" - cookie_cache = self.settings['cookie_cache'] - if encrypted_cookie in cookie_cache: - # we've seen this token before, don't ask upstream again - return cookie_cache[encrypted_cookie] - - hub_api_url = self.settings['hub_api_url'] - hub_api_key = self.settings['hub_api_key'] - r = requests.get(url_path_join( - hub_api_url, "authorizations/cookie", cookie_name, quote(encrypted_cookie, safe=''), - ), - headers = {'Authorization' : 'token %s' % hub_api_key}, - ) - if r.status_code == 404: - data = None - elif r.status_code == 403: - self.log.error("I don't have permission to verify cookies, my auth token may have expired: [%i] %s", r.status_code, r.reason) - raise HTTPError(500, "Permission failure checking authorization, I may need to be restarted") - elif r.status_code >= 500: - self.log.error("Upstream failure verifying auth token: [%i] %s", r.status_code, r.reason) - raise HTTPError(502, "Failed to check authorization (upstream problem)") - elif r.status_code >= 400: - self.log.warn("Failed to check authorization: [%i] %s", r.status_code, r.reason) - raise HTTPError(500, "Failed to check authorization") - else: - data = r.json() - cookie_cache[encrypted_cookie] = data - return data - - @staticmethod - def get_user(self): - """alternative get_current_user to query the central server""" - # only allow this to be called once per handler - # avoids issues if an error is raised, - # since this may be called again when trying to render the error page - if hasattr(self, '_cached_user'): - return self._cached_user - - self._cached_user = None - my_user = self.settings['user'] - encrypted_cookie = self.get_cookie(self.cookie_name) - if encrypted_cookie: - auth_data = JupyterHubLoginHandler.verify_token(self, self.cookie_name, encrypted_cookie) - if not auth_data: - # treat invalid token the same as no token - return None - user = auth_data['name'] - if user == my_user: - self._cached_user = user - return user - else: - return None - else: - self.log.debug("No token cookie") - return None - - -class JupyterHubLogoutHandler(LogoutHandler): - def get(self): - self.redirect(url_path_join(self.settings['hub_prefix'], 'logout')) - - -# register new hub related command-line aliases -aliases = dict(notebook_aliases) -aliases.update({ - 'user' : 'SingleUserNotebookApp.user', - 'cookie-name': 'SingleUserNotebookApp.cookie_name', - 'hub-prefix': 'SingleUserNotebookApp.hub_prefix', - 'hub-api-url': 'SingleUserNotebookApp.hub_api_url', - 'base-url': 'SingleUserNotebookApp.base_url', -}) - -page_template = """ -{% extends "templates/page.html" %} - -{% block header_buttons %} -{{super()}} - - -Control Panel -{% endblock %} -""" - -class SingleUserNotebookApp(NotebookApp): - """A Subclass of the regular NotebookApp that is aware of the parent multiuser context.""" - user = CUnicode(config=True) - def _user_changed(self, name, old, new): - self.log.name = new - cookie_name = Unicode(config=True) - hub_prefix = Unicode(config=True) - hub_api_url = Unicode(config=True) - aliases = aliases - open_browser = False - trust_xheaders = True - login_handler_class = JupyterHubLoginHandler - logout_handler_class = JupyterHubLogoutHandler - - cookie_cache_lifetime = Integer( - config=True, - default_value=300, - allow_none=True, - help=""" - Time, in seconds, that we cache a validated cookie before requiring - revalidation with the hub. - """, - ) - - def _log_datefmt_default(self): - """Exclude date from default date format""" - return "%Y-%m-%d %H:%M:%S" - - def _log_format_default(self): - """override default log format to include time""" - return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s" - - def _confirm_exit(self): - # disable the exit confirmation for background notebook processes - ioloop.IOLoop.instance().stop() - - def _clear_cookie_cache(self): - self.log.debug("Clearing cookie cache") - self.tornado_settings['cookie_cache'].clear() - - def start(self): - # Start a PeriodicCallback to clear cached cookies. This forces us to - # revalidate our user with the Hub at least every - # `cookie_cache_lifetime` seconds. - if self.cookie_cache_lifetime: - ioloop.PeriodicCallback( - self._clear_cookie_cache, - self.cookie_cache_lifetime * 1e3, - ).start() - super(SingleUserNotebookApp, self).start() - - def init_webapp(self): - # load the hub related settings into the tornado settings dict - env = os.environ - s = self.tornado_settings - s['cookie_cache'] = {} - s['user'] = self.user - s['hub_api_key'] = env.pop('JPY_API_TOKEN') - s['hub_prefix'] = self.hub_prefix - s['cookie_name'] = self.cookie_name - s['login_url'] = self.hub_prefix - s['hub_api_url'] = self.hub_api_url - s['csp_report_uri'] = url_path_join(self.hub_prefix, 'security/csp-report') - - super(SingleUserNotebookApp, self).init_webapp() - self.patch_templates() - - def patch_templates(self): - """Patch page templates to add Hub-related buttons""" - env = self.web_app.settings['jinja2_env'] - - env.globals['hub_control_panel_url'] = \ - url_path_join(self.hub_prefix, 'home') - - # patch jinja env loading to modify page template - def get_page(name): - if name == 'page.html': - return page_template - - orig_loader = env.loader - env.loader = ChoiceLoader([ - FunctionLoader(get_page), - orig_loader, - ]) - - -def main(): - return SingleUserNotebookApp.launch_instance() - - -if __name__ == "__main__": - main() diff --git a/jupyterhub/tests/test_spawner.py b/jupyterhub/tests/test_spawner.py index 1d02f465..89c94a70 100644 --- a/jupyterhub/tests/test_spawner.py +++ b/jupyterhub/tests/test_spawner.py @@ -57,7 +57,7 @@ def test_spawner(db, io_loop): assert status == 1 def test_single_user_spawner(db, io_loop): - spawner = new_spawner(db, cmd=[sys.executable, '-m', 'jupyterhub.singleuser']) + spawner = new_spawner(db, cmd=['jupyterhub-singleuser']) io_loop.run_sync(spawner.start) assert spawner.user.server.ip == 'localhost' # wait for http server to come up, diff --git a/scripts/jupyterhub-singleuser b/scripts/jupyterhub-singleuser index 95374288..21cc0ba2 100755 --- a/scripts/jupyterhub-singleuser +++ b/scripts/jupyterhub-singleuser @@ -1,4 +1,237 @@ #!/usr/bin/env python3 +"""Extend regular notebook server to be aware of multiuser things.""" -from jupyterhub.singleuser import main -main() +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import os +try: + from urllib.parse import quote +except ImportError: + # PY2 Compat + from urllib import quote + +import requests +from jinja2 import ChoiceLoader, FunctionLoader + +from tornado import ioloop +from tornado.web import HTTPError + + +from IPython.utils.traitlets import ( + Integer, + Unicode, + CUnicode, +) + +try: + import notebook + # 4.x +except ImportError: + from IPython.html.notebookapp import NotebookApp, aliases as notebook_aliases + from IPython.html.auth.login import LoginHandler + from IPython.html.auth.logout import LogoutHandler + + from IPython.html.utils import url_path_join + + from distutils.version import LooseVersion as V + + import IPython + if V(IPython.__version__) < V('3.0'): + raise ImportError("JupyterHub Requires IPython >= 3.0, found %s" % IPython.__version__) +else: + from notebook.notebookapp import NotebookApp, aliases as notebook_aliases + from notebook.auth.login import LoginHandler + from notebook.auth.logout import LogoutHandler + + from notebook.utils import url_path_join + + +# Define two methods to attach to AuthenticatedHandler, +# which authenticate via the central auth server. + +class JupyterHubLoginHandler(LoginHandler): + @staticmethod + def login_available(settings): + return True + + @staticmethod + def verify_token(self, cookie_name, encrypted_cookie): + """method for token verification""" + cookie_cache = self.settings['cookie_cache'] + if encrypted_cookie in cookie_cache: + # we've seen this token before, don't ask upstream again + return cookie_cache[encrypted_cookie] + + hub_api_url = self.settings['hub_api_url'] + hub_api_key = self.settings['hub_api_key'] + r = requests.get(url_path_join( + hub_api_url, "authorizations/cookie", cookie_name, quote(encrypted_cookie, safe=''), + ), + headers = {'Authorization' : 'token %s' % hub_api_key}, + ) + if r.status_code == 404: + data = None + elif r.status_code == 403: + self.log.error("I don't have permission to verify cookies, my auth token may have expired: [%i] %s", r.status_code, r.reason) + raise HTTPError(500, "Permission failure checking authorization, I may need to be restarted") + elif r.status_code >= 500: + self.log.error("Upstream failure verifying auth token: [%i] %s", r.status_code, r.reason) + raise HTTPError(502, "Failed to check authorization (upstream problem)") + elif r.status_code >= 400: + self.log.warn("Failed to check authorization: [%i] %s", r.status_code, r.reason) + raise HTTPError(500, "Failed to check authorization") + else: + data = r.json() + cookie_cache[encrypted_cookie] = data + return data + + @staticmethod + def get_user(self): + """alternative get_current_user to query the central server""" + # only allow this to be called once per handler + # avoids issues if an error is raised, + # since this may be called again when trying to render the error page + if hasattr(self, '_cached_user'): + return self._cached_user + + self._cached_user = None + my_user = self.settings['user'] + encrypted_cookie = self.get_cookie(self.cookie_name) + if encrypted_cookie: + auth_data = JupyterHubLoginHandler.verify_token(self, self.cookie_name, encrypted_cookie) + if not auth_data: + # treat invalid token the same as no token + return None + user = auth_data['name'] + if user == my_user: + self._cached_user = user + return user + else: + return None + else: + self.log.debug("No token cookie") + return None + + +class JupyterHubLogoutHandler(LogoutHandler): + def get(self): + self.redirect(url_path_join(self.settings['hub_prefix'], 'logout')) + + +# register new hub related command-line aliases +aliases = dict(notebook_aliases) +aliases.update({ + 'user' : 'SingleUserNotebookApp.user', + 'cookie-name': 'SingleUserNotebookApp.cookie_name', + 'hub-prefix': 'SingleUserNotebookApp.hub_prefix', + 'hub-api-url': 'SingleUserNotebookApp.hub_api_url', + 'base-url': 'SingleUserNotebookApp.base_url', +}) + +page_template = """ +{% extends "templates/page.html" %} + +{% block header_buttons %} +{{super()}} + + +Control Panel +{% endblock %} +""" + +class SingleUserNotebookApp(NotebookApp): + """A Subclass of the regular NotebookApp that is aware of the parent multiuser context.""" + user = CUnicode(config=True) + def _user_changed(self, name, old, new): + self.log.name = new + cookie_name = Unicode(config=True) + hub_prefix = Unicode(config=True) + hub_api_url = Unicode(config=True) + aliases = aliases + open_browser = False + trust_xheaders = True + login_handler_class = JupyterHubLoginHandler + logout_handler_class = JupyterHubLogoutHandler + + cookie_cache_lifetime = Integer( + config=True, + default_value=300, + allow_none=True, + help=""" + Time, in seconds, that we cache a validated cookie before requiring + revalidation with the hub. + """, + ) + + def _log_datefmt_default(self): + """Exclude date from default date format""" + return "%Y-%m-%d %H:%M:%S" + + def _log_format_default(self): + """override default log format to include time""" + return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s" + + def _confirm_exit(self): + # disable the exit confirmation for background notebook processes + ioloop.IOLoop.instance().stop() + + def _clear_cookie_cache(self): + self.log.debug("Clearing cookie cache") + self.tornado_settings['cookie_cache'].clear() + + def start(self): + # Start a PeriodicCallback to clear cached cookies. This forces us to + # revalidate our user with the Hub at least every + # `cookie_cache_lifetime` seconds. + if self.cookie_cache_lifetime: + ioloop.PeriodicCallback( + self._clear_cookie_cache, + self.cookie_cache_lifetime * 1e3, + ).start() + super(SingleUserNotebookApp, self).start() + + def init_webapp(self): + # load the hub related settings into the tornado settings dict + env = os.environ + s = self.tornado_settings + s['cookie_cache'] = {} + s['user'] = self.user + s['hub_api_key'] = env.pop('JPY_API_TOKEN') + s['hub_prefix'] = self.hub_prefix + s['cookie_name'] = self.cookie_name + s['login_url'] = self.hub_prefix + s['hub_api_url'] = self.hub_api_url + s['csp_report_uri'] = url_path_join(self.hub_prefix, 'security/csp-report') + + super(SingleUserNotebookApp, self).init_webapp() + self.patch_templates() + + def patch_templates(self): + """Patch page templates to add Hub-related buttons""" + env = self.web_app.settings['jinja2_env'] + + env.globals['hub_control_panel_url'] = \ + url_path_join(self.hub_prefix, 'home') + + # patch jinja env loading to modify page template + def get_page(name): + if name == 'page.html': + return page_template + + orig_loader = env.loader + env.loader = ChoiceLoader([ + FunctionLoader(get_page), + orig_loader, + ]) + + +def main(): + return SingleUserNotebookApp.launch_instance() + + +if __name__ == "__main__": + main() From ef40bd230e54c37ad15d367ffe3ca8d6c84b1570 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 22 Jan 2016 16:02:11 +0100 Subject: [PATCH 158/231] Show error messages on spawn form when spawning fails instead of 500 --- jupyterhub/handlers/pages.py | 21 +++++++++++++++------ share/jupyter/hub/templates/spawn.html | 5 +++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index c1ee6919..f1d5c73f 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -54,6 +54,14 @@ class SpawnHandler(BaseHandler): Only enabled when Spawner.options_form is defined. """ + def _render_form(self, message=''): + user = self.get_current_user() + return self.render_template('spawn.html', + user=user, + spawner_options_form=user.spawner.options_form, + error_message=message, + ) + @web.authenticated def get(self): """GET renders form for spawning with user-specified options""" @@ -64,11 +72,7 @@ class SpawnHandler(BaseHandler): self.redirect(url) return if user.spawner.options_form: - html = self.render_template('spawn.html', - user=self.get_current_user(), - spawner_options_form=user.spawner.options_form, - ) - self.finish(html) + self.finish(self._render_form()) else: # not running, no form. Trigger spawn. url = url_path_join(self.base_url, 'user', user.name) @@ -90,7 +94,12 @@ class SpawnHandler(BaseHandler): for key, byte_list in self.request.files.items(): form_options["%s_file"%key] = byte_list options = user.spawner.options_from_form(form_options) - yield self.spawn_single_user(user, options=options) + try: + yield self.spawn_single_user(user, options=options) + except Exception as e: + self.log.error("Failed to spawn single-user server with form", exc_info=True) + self.finish(self._render_form(str(e))) + return self.set_login_cookie(user) url = user.server.base_url self.redirect(url) diff --git a/share/jupyter/hub/templates/spawn.html b/share/jupyter/hub/templates/spawn.html index e240ebfb..e8173359 100644 --- a/share/jupyter/hub/templates/spawn.html +++ b/share/jupyter/hub/templates/spawn.html @@ -7,6 +7,11 @@

Spawner options

+ {% if error_message %} +

+ Error: {{error_message}} +

+ {% endif %} {{spawner_options_form}}
From 0555ee44e76cd2224070678190fade6744770606 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 22 Jan 2016 16:02:51 +0100 Subject: [PATCH 159/231] turn on jinja autoescape now that we are putting user content on the page --- jupyterhub/app.py | 6 +++++- share/jupyter/hub/templates/spawn.html | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 0138d6ff..b8c0a1f0 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -846,9 +846,13 @@ class JupyterHub(Application): def init_tornado_settings(self): """Set up the tornado settings dict.""" base_url = self.hub.server.base_url + jinja_options = dict( + autoescape=True, + ) + jinja_options.update(self.jinja_environment_options) jinja_env = Environment( loader=FileSystemLoader(self.template_paths), - **self.jinja_environment_options + **jinja_options ) login_url = self.authenticator.login_url(base_url) diff --git a/share/jupyter/hub/templates/spawn.html b/share/jupyter/hub/templates/spawn.html index e8173359..8e451d79 100644 --- a/share/jupyter/hub/templates/spawn.html +++ b/share/jupyter/hub/templates/spawn.html @@ -13,7 +13,7 @@

{% endif %} - {{spawner_options_form}} + {{spawner_options_form | safe}}
From d437a8f06abbb95e991024c029cdb4a07cfc1885 Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Fri, 22 Jan 2016 06:23:21 -0800 Subject: [PATCH 160/231] Fix docstrings *ix -> Linux/UNIX --- jupyterhub/auth.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py index 7b776c8e..afd84075 100644 --- a/jupyterhub/auth.py +++ b/jupyterhub/auth.py @@ -241,8 +241,8 @@ class Authenticator(LoggingConfigurable): ] class LocalAuthenticator(Authenticator): - """Base class for Authenticators that work with local *ix users - + """Base class for Authenticators that work with local Linux/UNIX users + Checks for local users, and can attempt to create them if they exist. """ @@ -336,9 +336,9 @@ class LocalAuthenticator(Authenticator): return False else: return True - + def add_system_user(self, user): - """Create a new *ix user on the system. Works on FreeBSD and Linux, at least.""" + """Create a new Linux/UNIX user on the system. Works on FreeBSD and Linux, at least.""" name = user.name cmd = [ arg.replace('USERNAME', name) for arg in self.add_user_cmd ] + [name] self.log.info("Creating user: %s", ' '.join(map(pipes.quote, cmd))) @@ -350,7 +350,7 @@ class LocalAuthenticator(Authenticator): class PAMAuthenticator(LocalAuthenticator): - """Authenticate local *ix users with PAM""" + """Authenticate local Linux/UNIX users with PAM""" encoding = Unicode('utf8', config=True, help="""The encoding to use for PAM""" ) From bc4973fb437035d70e7840c73ede18cf358f8d91 Mon Sep 17 00:00:00 2001 From: evanlinde Date: Fri, 22 Jan 2016 09:47:48 -0600 Subject: [PATCH 161/231] username parameter for notebook_dir Allow specifying user-specific notebook directories outside of user's home folder --- jupyterhub/spawner.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 8993c4ea..1da13f8f 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -136,6 +136,7 @@ class Spawner(LoggingConfigurable): help="""The notebook directory for the single-user server `~` will be expanded to the user's home directory + `%U` will be expanded to the user's username """ ) @@ -204,6 +205,7 @@ class Spawner(LoggingConfigurable): if self.ip: args.append('--ip=%s' % self.ip) if self.notebook_dir: + self.notebook_dir = self.notebook_dir.replace("%U",self.user.name) args.append('--notebook-dir=%s' % self.notebook_dir) if self.debug: args.append('--debug') From 84f8f8f322bdb56bdffe7c216b4d3a8c9d3cc116 Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Fri, 22 Jan 2016 08:03:34 -0800 Subject: [PATCH 162/231] Use relative html link instead of local md --- docs/source/getting-started.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/getting-started.md b/docs/source/getting-started.md index 6957099d..6c669e1f 100644 --- a/docs/source/getting-started.md +++ b/docs/source/getting-started.md @@ -398,8 +398,8 @@ jupyterhub -f /path/to/aboveconfig.py # Further reading - TODO: troubleshooting -- [Custom Authenticators](authenticators.md) -- [Custom Spawners](spawners.md) +- [Custom Authenticators](./authenticators.html) +- [Custom Spawners](./spawners.html) [oauth-setup]: https://github.com/jupyter/oauthenticator#setup From 6a3d790f49b04c5c56d0cd7f38a4abc2fdd56896 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 25 Jan 2016 11:52:31 +0100 Subject: [PATCH 163/231] install locale in Dockerfile and do a little cleanup of temporary installation files --- Dockerfile | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 95eddfa1..b64a286b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,29 +9,35 @@ FROM debian:jessie MAINTAINER Jupyter Project -# install nodejs +# install nodejs, utf8 locale ENV DEBIAN_FRONTEND noninteractive -RUN apt-get -y update && apt-get -y upgrade && apt-get -y install npm nodejs nodejs-legacy wget +RUN apt-get -y update && \ + apt-get -y upgrade && \ + apt-get -y install npm nodejs nodejs-legacy wget locales git &&\ + /usr/sbin/update-locale LANG=C.UTF-8 && \ + locale-gen C.UTF-8 && \ + apt-get remove -y locales && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* +ENV LANG C.UTF-8 # install Python with conda RUN wget -q https://repo.continuum.io/miniconda/Miniconda3-3.9.1-Linux-x86_64.sh -O /tmp/miniconda.sh && \ bash /tmp/miniconda.sh -f -b -p /opt/conda && \ /opt/conda/bin/conda install --yes python=3.5 sqlalchemy tornado jinja2 traitlets requests pip && \ + /opt/conda/bin/pip install --upgrade pip && \ rm /tmp/miniconda.sh ENV PATH=/opt/conda/bin:$PATH -# install any pip dependencies not already installed by conda -ADD requirements.txt /tmp/requirements.txt -RUN pip install -r /tmp/requirements.txt - # install js dependencies -RUN npm install -g configurable-http-proxy +RUN npm install -g configurable-http-proxy && rm -rf ~/.npm WORKDIR /srv/ ADD . /srv/jupyterhub WORKDIR /srv/jupyterhub/ -RUN pip install . +RUN python setup.py js && pip install . && \ + rm -rf node_modules ~/.cache ~/.npm WORKDIR /srv/jupyterhub/ From babb2cf908be9da0d8a52992783c60e75571c0d1 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 25 Jan 2016 11:55:30 +0100 Subject: [PATCH 164/231] test docker builds on circle-ci --- circle.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 circle.yml diff --git a/circle.yml b/circle.yml new file mode 100644 index 00000000..0a3a1227 --- /dev/null +++ b/circle.yml @@ -0,0 +1,11 @@ +machine: + services: + - docker + +dependencies: + override: + - ls + +test: + override: + - docker build -t jupyter/jupyterhub . From 7e5914816830881ea408c0ece1f77550ea9b7f2b Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 25 Jan 2016 12:56:51 +0100 Subject: [PATCH 165/231] ignore node_modules --- .dockerignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.dockerignore b/.dockerignore index 25374c2e..cef56b08 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,3 +3,4 @@ bench jupyterhub_cookie_secret jupyterhub.sqlite jupyterhub_config.py +node_modules From 8a4305a15ca43e602986539e9ff2ddee44242aab Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 25 Jan 2016 12:57:19 +0100 Subject: [PATCH 166/231] s/chose/choose/ typo --- share/jupyter/hub/templates/admin.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/share/jupyter/hub/templates/admin.html b/share/jupyter/hub/templates/admin.html index c8101a64..5e7878fe 100644 --- a/share/jupyter/hub/templates/admin.html +++ b/share/jupyter/hub/templates/admin.html @@ -73,7 +73,7 @@ {% call modal('Shutdown Hub', btn_label='Shutdown', btn_class='btn-danger shutdown-button') %} Are you sure you want to shutdown the Hub? - You can chose to leave the proxy and/or single-user servers running by unchecking the boxes below: + You can choose to leave the proxy and/or single-user servers running by unchecking the boxes below: