Merge remote-tracking branch 'upstream/master' into docker-host

This commit is contained in:
Daisuke Taniwaki
2021-02-01 22:35:46 +09:00
16 changed files with 124 additions and 28 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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'
```

View File

@@ -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)

View File

@@ -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

View File

@@ -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"),

View File

@@ -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

View File

@@ -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):

View File

@@ -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(

View File

@@ -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):

View File

@@ -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):

View File

@@ -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):

View File

@@ -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'],

View File

@@ -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}

View File

@@ -1,4 +1,4 @@
alembic
alembic>=1.4
async_generator>=1.9
certipy>=0.1.2
entrypoints

View File

@@ -130,7 +130,9 @@
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Services<span class="caret"></span></a>
<ul class="dropdown-menu">
{% for service in services %}
{% block service scoped %}
<li><a class="dropdown-item" href="{{service.prefix}}">{{service.name}}</a></li>
{% endblock %}
{% endfor %}
</ul>
</li>
@@ -144,7 +146,7 @@
{% block login_widget %}
<span id="login_widget">
{% if user %}
<p class="navbar-text">{{user.name}}</p>
<p class="navbar-text">{{user.name}}</p>
<a id="logout" role="button" class="navbar-btn btn-sm btn btn-default" href="{{logout_url}}"> <i aria-hidden="true" class="fa fa-sign-out"></i> Logout</a>
{% else %}
<a id="login" role="button" class="btn-sm btn navbar-btn btn-default" href="{{login_url}}">Login</a>