diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b54bfd4..a172ee5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,6 +51,13 @@ jobs: print("OK") EOF + # ref: https://github.com/actions/upload-artifact#readme + - uses: actions/upload-artifact@v2 + with: + name: jupyterhub-${{ github.sha }} + path: "dist/*" + if-no-files-found: error + - name: Publish to PyPI if: startsWith(github.ref, 'refs/tags/') env: diff --git a/docs/source/events/index.rst b/docs/source/events/index.rst index 90e30acb..bc086ba1 100644 --- a/docs/source/events/index.rst +++ b/docs/source/events/index.rst @@ -1,10 +1,7 @@ Eventlogging and Telemetry ========================== -JupyterHub can be configured to record structured events from a running server using Jupyter's `Telemetry System`_. The types of events that JupyterHub emits are defined by `JSON schemas`_ listed below_ - - emitted as JSON data, defined and validated by the JSON schemas listed below. - +JupyterHub can be configured to record structured events from a running server using Jupyter's `Telemetry System`_. The types of events that JupyterHub emits are defined by `JSON schemas`_ listed at the bottom of this page_. .. _logging: https://docs.python.org/3/library/logging.html .. _`Telemetry System`: https://github.com/jupyter/telemetry @@ -38,13 +35,12 @@ Here's a basic example: The output is a file, ``"event.log"``, with events recorded as JSON data. - -.. _below: +.. _page: Event schemas ------------- .. toctree:: - :maxdepth: 2 + :maxdepth: 2 - server-actions.rst + server-actions.rst diff --git a/docs/source/reference/config-user-env.md b/docs/source/reference/config-user-env.md index c085b106..4a378831 100644 --- a/docs/source/reference/config-user-env.md +++ b/docs/source/reference/config-user-env.md @@ -179,3 +179,13 @@ The number of named servers per user can be limited by setting ```python c.JupyterHub.named_server_limit_per_user = 5 ``` + +## Switching to Jupyter Server + +[Jupyter Server](https://jupyter-server.readthedocs.io/en/latest/) is a new Tornado Server backend for Jupyter web applications (e.g. JupyterLab 3.0 uses this package as its default backend). + +By default, the single-user notebook server uses the (old) `NotebookApp` from the [notebook](https://github.com/jupyter/notebook) package. You can switch to using Jupyter Server's `ServerApp` backend (this will likely become the default in future releases) by setting the `JUPYTERHUB_SINGLEUSER_APP` environment variable to: + +```bash +export JUPYTERHUB_SINGLEUSER_APP='jupyter_server.serverapp.ServerApp' +``` diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index f427455d..27e9bf0f 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -237,6 +237,13 @@ class UserAPIHandler(APIHandler): ) await maybe_future(self.authenticator.delete_user(user)) + + # allow the spawner to cleanup any persistent resources associated with the user + try: + await user.spawner.delete_forever() + except Exception as e: + self.log.error("Error cleaning up persistent resources: %s" % e) + # remove from registry self.users.delete(user) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index a3bc17e0..d5d35d5a 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -374,7 +374,8 @@ class JupyterHub(Application): 300, help="Interval (in seconds) at which to update last-activity timestamps." ).tag(config=True) proxy_check_interval = Integer( - 30, help="Interval (in seconds) at which to check if the proxy is running." + 5, + help="DEPRECATED since version 0.8: Use ConfigurableHTTPProxy.check_running_interval", ).tag(config=True) service_check_interval = Integer( 60, @@ -688,6 +689,7 @@ class JupyterHub(Application): ).tag(config=True) _proxy_config_map = { + 'proxy_check_interval': 'check_running_interval', 'proxy_cmd': 'command', 'debug_proxy': 'debug', 'proxy_auth_token': 'auth_token', @@ -846,15 +848,30 @@ class JupyterHub(Application): to reduce the cost of checking authentication tokens. """, ).tag(config=True) - cookie_secret = Bytes( + cookie_secret = Union( + [Bytes(), Unicode()], help="""The cookie secret to use to encrypt cookies. Loaded from the JPY_COOKIE_SECRET env variable by default. Should be exactly 256 bits (32 bytes). - """ + """, ).tag(config=True, env='JPY_COOKIE_SECRET') + @validate('cookie_secret') + def _validate_secret_key(self, proposal): + """Coerces strings with even number of hexadecimal characters to bytes.""" + r = proposal['value'] + if isinstance(r, str): + try: + return bytes.fromhex(r) + except ValueError: + raise ValueError( + "cookie_secret set as a string must contain an even amount of hexadecimal characters." + ) + else: + return r + @observe('cookie_secret') def _cookie_secret_check(self, change): secret = change.new diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py index 33a00bef..09d09a02 100644 --- a/jupyterhub/auth.py +++ b/jupyterhub/auth.py @@ -1101,6 +1101,13 @@ class PAMAuthenticator(LocalAuthenticator): else: return super().normalize_username(username) + def get_custom_html(self, base_url): + """Get custom HTML for the authenticator. + + .. versionadded: 1.4 + """ + return self.custom_html + for _old_name, _new_name, _version in [ ("check_group_whitelist", "check_group_allowed", "1.2"), diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index e08e083e..6818ace0 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -493,6 +493,11 @@ class BaseHandler(RequestHandler): path=url_path_join(self.base_url, 'services'), **kwargs, ) + # clear tornado cookie + self.clear_cookie( + '_xsrf', + **self.settings.get('xsrf_cookie_kwargs', {}), + ) # Reset _jupyterhub_user self._jupyterhub_user = None diff --git a/jupyterhub/handlers/login.py b/jupyterhub/handlers/login.py index 605cd580..29f2ff02 100644 --- a/jupyterhub/handlers/login.py +++ b/jupyterhub/handlers/login.py @@ -3,6 +3,7 @@ # Distributed under the terms of the Modified BSD License. import asyncio +from jinja2 import Template from tornado import web from tornado.escape import url_escape from tornado.httputil import url_concat @@ -90,17 +91,23 @@ class LoginHandler(BaseHandler): """Render the login page.""" def _render(self, login_error=None, username=None): - return self.render_template( - 'login.html', - next=url_escape(self.get_argument('next', default='')), - username=username, - login_error=login_error, - custom_html=self.authenticator.custom_html, - login_url=self.settings['login_url'], - authenticator_login_url=url_concat( + context = { + "next": url_escape(self.get_argument('next', default='')), + "username": username, + "login_error": login_error, + "login_url": self.settings['login_url'], + "authenticator_login_url": url_concat( self.authenticator.login_url(self.hub.base_url), {'next': self.get_argument('next', '')}, ), + } + custom_html = Template( + self.authenticator.get_custom_html(self.hub.base_url) + ).render(**context) + return self.render_template( + 'login.html', + **context, + custom_html=custom_html, ) async def get(self): diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index 0acbc0d5..eae9b63f 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -501,20 +501,24 @@ class AdminHandler(BaseHandler): # get User.col.desc() order objects ordered = [getattr(c, o)() for c, o in zip(cols, orders)] + query = self.db.query(orm.User).outerjoin(orm.Spawner).distinct(orm.User.id) + subquery = query.subquery("users") users = ( self.db.query(orm.User) + .select_entity_from(subquery) .outerjoin(orm.Spawner) .order_by(*ordered) .limit(per_page) .offset(offset) ) + users = [self._user_from_orm(u) for u in users] running = [] for u in users: running.extend(s for s in u.spawners.values() if s.active) - pagination.total = self.db.query(orm.User.id).count() + pagination.total = query.count() auth_state = await self.current_user.get_auth_state() html = await self.render_template( diff --git a/jupyterhub/log.py b/jupyterhub/log.py index f9fbffe8..43a38d36 100644 --- a/jupyterhub/log.py +++ b/jupyterhub/log.py @@ -2,7 +2,9 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import json +import logging import traceback +from functools import partial from http.cookies import SimpleCookie from urllib.parse import urlparse from urllib.parse import urlunparse @@ -132,19 +134,25 @@ def log_request(handler): status < 300 and isinstance(handler, (StaticFileHandler, HealthCheckHandler)) ): # static-file success and 304 Found are debug-level - log_method = access_log.debug + log_level = logging.DEBUG elif status < 400: - log_method = access_log.info + log_level = logging.INFO elif status < 500: - log_method = access_log.warning + log_level = logging.WARNING else: - log_method = access_log.error + log_level = logging.ERROR uri = _scrub_uri(request.uri) headers = _scrub_headers(request.headers) request_time = 1000.0 * handler.request.request_time() + # always log slow responses (longer than 1s) at least info-level + if request_time >= 1000 and log_level < logging.INFO: + log_level = logging.INFO + + log_method = partial(access_log.log, log_level) + try: user = handler.current_user except (HTTPError, RuntimeError): diff --git a/jupyterhub/proxy.py b/jupyterhub/proxy.py index 077ed820..cf707d6f 100644 --- a/jupyterhub/proxy.py +++ b/jupyterhub/proxy.py @@ -450,7 +450,11 @@ class ConfigurableHTTPProxy(Proxy): Loaded from the CONFIGPROXY_AUTH_TOKEN env variable by default. """ ).tag(config=True) - check_running_interval = Integer(5, config=True) + check_running_interval = Integer( + 5, + help="Interval (in seconds) at which to check if the proxy is running.", + config=True, + ) @default('auth_token') def _auth_token_default(self): diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py index 5ae6ec8c..e0f97cac 100644 --- a/jupyterhub/tests/mocking.py +++ b/jupyterhub/tests/mocking.py @@ -93,6 +93,16 @@ class MockSpawner(SimpleLocalProcessSpawner): def _cmd_default(self): return [sys.executable, '-m', 'jupyterhub.tests.mocksu'] + async def delete_forever(self): + """Called when a user is deleted. + + This can do things like request removal of resources such as persistent storage. + Only called on stopped spawners, and is likely the last action ever taken for the user. + + Will only be called once on the user's default Spawner. + """ + pass + use_this_api_token = None def start(self): diff --git a/jupyterhub/tests/test_app.py b/jupyterhub/tests/test_app.py index accdd430..a918e9b7 100644 --- a/jupyterhub/tests/test_app.py +++ b/jupyterhub/tests/test_app.py @@ -199,6 +199,18 @@ def test_cookie_secret_env(tmpdir, request): assert not os.path.exists(hub.cookie_secret_file) +def test_cookie_secret_string_(): + cfg = Config() + + cfg.JupyterHub.cookie_secret = "not hex" + with pytest.raises(ValueError): + JupyterHub(config=cfg) + + cfg.JupyterHub.cookie_secret = "abc123" + app = JupyterHub(config=cfg) + assert app.cookie_secret == binascii.a2b_hex('abc123') + + async def test_load_groups(tmpdir, request): to_load = { 'blue': ['cyclops', 'rogue', 'wolverine'], diff --git a/jupyterhub/tests/utils.py b/jupyterhub/tests/utils.py index b99ba4ea..e48d30b5 100644 --- a/jupyterhub/tests/utils.py +++ b/jupyterhub/tests/utils.py @@ -121,7 +121,7 @@ def auth_header(db, name): """Return header with user's API authorization token.""" user = find_user(db, name) if user is None: - user = add_user(db, name=name) + raise KeyError(f"No such user: {name}") token = user.new_api_token() return {'Authorization': 'token %s' % token} diff --git a/requirements.txt b/requirements.txt index 3c4481be..0338b1c0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -alembic +alembic>=1.4 async_generator>=1.9 certipy>=0.1.2 entrypoints diff --git a/share/jupyterhub/templates/page.html b/share/jupyterhub/templates/page.html index f2564b66..f40a0a84 100644 --- a/share/jupyterhub/templates/page.html +++ b/share/jupyterhub/templates/page.html @@ -130,7 +130,9 @@ @@ -144,7 +146,7 @@ {% block login_widget %} {% if user %} - + Logout {% else %} Login