mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-18 15:33:02 +00:00
Merge remote-tracking branch 'upstream/master' into docker-host
This commit is contained in:
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -51,6 +51,13 @@ jobs:
|
|||||||
print("OK")
|
print("OK")
|
||||||
EOF
|
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
|
- name: Publish to PyPI
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
env:
|
env:
|
||||||
|
@@ -1,10 +1,7 @@
|
|||||||
Eventlogging and Telemetry
|
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_
|
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_.
|
||||||
|
|
||||||
emitted as JSON data, defined and validated by the JSON schemas listed below.
|
|
||||||
|
|
||||||
|
|
||||||
.. _logging: https://docs.python.org/3/library/logging.html
|
.. _logging: https://docs.python.org/3/library/logging.html
|
||||||
.. _`Telemetry System`: https://github.com/jupyter/telemetry
|
.. _`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.
|
The output is a file, ``"event.log"``, with events recorded as JSON data.
|
||||||
|
|
||||||
|
|
||||||
|
.. _page:
|
||||||
.. _below:
|
|
||||||
|
|
||||||
Event schemas
|
Event schemas
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
|
||||||
server-actions.rst
|
server-actions.rst
|
||||||
|
@@ -179,3 +179,13 @@ The number of named servers per user can be limited by setting
|
|||||||
```python
|
```python
|
||||||
c.JupyterHub.named_server_limit_per_user = 5
|
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'
|
||||||
|
```
|
||||||
|
@@ -237,6 +237,13 @@ class UserAPIHandler(APIHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
await maybe_future(self.authenticator.delete_user(user))
|
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
|
# remove from registry
|
||||||
self.users.delete(user)
|
self.users.delete(user)
|
||||||
|
|
||||||
|
@@ -374,7 +374,8 @@ class JupyterHub(Application):
|
|||||||
300, help="Interval (in seconds) at which to update last-activity timestamps."
|
300, help="Interval (in seconds) at which to update last-activity timestamps."
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
proxy_check_interval = Integer(
|
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)
|
).tag(config=True)
|
||||||
service_check_interval = Integer(
|
service_check_interval = Integer(
|
||||||
60,
|
60,
|
||||||
@@ -688,6 +689,7 @@ class JupyterHub(Application):
|
|||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
_proxy_config_map = {
|
_proxy_config_map = {
|
||||||
|
'proxy_check_interval': 'check_running_interval',
|
||||||
'proxy_cmd': 'command',
|
'proxy_cmd': 'command',
|
||||||
'debug_proxy': 'debug',
|
'debug_proxy': 'debug',
|
||||||
'proxy_auth_token': 'auth_token',
|
'proxy_auth_token': 'auth_token',
|
||||||
@@ -846,15 +848,30 @@ class JupyterHub(Application):
|
|||||||
to reduce the cost of checking authentication tokens.
|
to reduce the cost of checking authentication tokens.
|
||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
cookie_secret = Bytes(
|
cookie_secret = Union(
|
||||||
|
[Bytes(), Unicode()],
|
||||||
help="""The cookie secret to use to encrypt cookies.
|
help="""The cookie secret to use to encrypt cookies.
|
||||||
|
|
||||||
Loaded from the JPY_COOKIE_SECRET env variable by default.
|
Loaded from the JPY_COOKIE_SECRET env variable by default.
|
||||||
|
|
||||||
Should be exactly 256 bits (32 bytes).
|
Should be exactly 256 bits (32 bytes).
|
||||||
"""
|
""",
|
||||||
).tag(config=True, env='JPY_COOKIE_SECRET')
|
).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')
|
@observe('cookie_secret')
|
||||||
def _cookie_secret_check(self, change):
|
def _cookie_secret_check(self, change):
|
||||||
secret = change.new
|
secret = change.new
|
||||||
|
@@ -1101,6 +1101,13 @@ class PAMAuthenticator(LocalAuthenticator):
|
|||||||
else:
|
else:
|
||||||
return super().normalize_username(username)
|
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 [
|
for _old_name, _new_name, _version in [
|
||||||
("check_group_whitelist", "check_group_allowed", "1.2"),
|
("check_group_whitelist", "check_group_allowed", "1.2"),
|
||||||
|
@@ -493,6 +493,11 @@ class BaseHandler(RequestHandler):
|
|||||||
path=url_path_join(self.base_url, 'services'),
|
path=url_path_join(self.base_url, 'services'),
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
# clear tornado cookie
|
||||||
|
self.clear_cookie(
|
||||||
|
'_xsrf',
|
||||||
|
**self.settings.get('xsrf_cookie_kwargs', {}),
|
||||||
|
)
|
||||||
# Reset _jupyterhub_user
|
# Reset _jupyterhub_user
|
||||||
self._jupyterhub_user = None
|
self._jupyterhub_user = None
|
||||||
|
|
||||||
|
@@ -3,6 +3,7 @@
|
|||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
from jinja2 import Template
|
||||||
from tornado import web
|
from tornado import web
|
||||||
from tornado.escape import url_escape
|
from tornado.escape import url_escape
|
||||||
from tornado.httputil import url_concat
|
from tornado.httputil import url_concat
|
||||||
@@ -90,17 +91,23 @@ class LoginHandler(BaseHandler):
|
|||||||
"""Render the login page."""
|
"""Render the login page."""
|
||||||
|
|
||||||
def _render(self, login_error=None, username=None):
|
def _render(self, login_error=None, username=None):
|
||||||
return self.render_template(
|
context = {
|
||||||
'login.html',
|
"next": url_escape(self.get_argument('next', default='')),
|
||||||
next=url_escape(self.get_argument('next', default='')),
|
"username": username,
|
||||||
username=username,
|
"login_error": login_error,
|
||||||
login_error=login_error,
|
"login_url": self.settings['login_url'],
|
||||||
custom_html=self.authenticator.custom_html,
|
"authenticator_login_url": url_concat(
|
||||||
login_url=self.settings['login_url'],
|
|
||||||
authenticator_login_url=url_concat(
|
|
||||||
self.authenticator.login_url(self.hub.base_url),
|
self.authenticator.login_url(self.hub.base_url),
|
||||||
{'next': self.get_argument('next', '')},
|
{'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):
|
async def get(self):
|
||||||
|
@@ -501,20 +501,24 @@ class AdminHandler(BaseHandler):
|
|||||||
# get User.col.desc() order objects
|
# get User.col.desc() order objects
|
||||||
ordered = [getattr(c, o)() for c, o in zip(cols, orders)]
|
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 = (
|
users = (
|
||||||
self.db.query(orm.User)
|
self.db.query(orm.User)
|
||||||
|
.select_entity_from(subquery)
|
||||||
.outerjoin(orm.Spawner)
|
.outerjoin(orm.Spawner)
|
||||||
.order_by(*ordered)
|
.order_by(*ordered)
|
||||||
.limit(per_page)
|
.limit(per_page)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
)
|
)
|
||||||
|
|
||||||
users = [self._user_from_orm(u) for u in users]
|
users = [self._user_from_orm(u) for u in users]
|
||||||
|
|
||||||
running = []
|
running = []
|
||||||
for u in users:
|
for u in users:
|
||||||
running.extend(s for s in u.spawners.values() if s.active)
|
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()
|
auth_state = await self.current_user.get_auth_state()
|
||||||
html = await self.render_template(
|
html = await self.render_template(
|
||||||
|
@@ -2,7 +2,9 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
|
from functools import partial
|
||||||
from http.cookies import SimpleCookie
|
from http.cookies import SimpleCookie
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from urllib.parse import urlunparse
|
from urllib.parse import urlunparse
|
||||||
@@ -132,19 +134,25 @@ def log_request(handler):
|
|||||||
status < 300 and isinstance(handler, (StaticFileHandler, HealthCheckHandler))
|
status < 300 and isinstance(handler, (StaticFileHandler, HealthCheckHandler))
|
||||||
):
|
):
|
||||||
# static-file success and 304 Found are debug-level
|
# static-file success and 304 Found are debug-level
|
||||||
log_method = access_log.debug
|
log_level = logging.DEBUG
|
||||||
elif status < 400:
|
elif status < 400:
|
||||||
log_method = access_log.info
|
log_level = logging.INFO
|
||||||
elif status < 500:
|
elif status < 500:
|
||||||
log_method = access_log.warning
|
log_level = logging.WARNING
|
||||||
else:
|
else:
|
||||||
log_method = access_log.error
|
log_level = logging.ERROR
|
||||||
|
|
||||||
uri = _scrub_uri(request.uri)
|
uri = _scrub_uri(request.uri)
|
||||||
headers = _scrub_headers(request.headers)
|
headers = _scrub_headers(request.headers)
|
||||||
|
|
||||||
request_time = 1000.0 * handler.request.request_time()
|
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:
|
try:
|
||||||
user = handler.current_user
|
user = handler.current_user
|
||||||
except (HTTPError, RuntimeError):
|
except (HTTPError, RuntimeError):
|
||||||
|
@@ -450,7 +450,11 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
Loaded from the CONFIGPROXY_AUTH_TOKEN env variable by default.
|
Loaded from the CONFIGPROXY_AUTH_TOKEN env variable by default.
|
||||||
"""
|
"""
|
||||||
).tag(config=True)
|
).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')
|
@default('auth_token')
|
||||||
def _auth_token_default(self):
|
def _auth_token_default(self):
|
||||||
|
@@ -93,6 +93,16 @@ class MockSpawner(SimpleLocalProcessSpawner):
|
|||||||
def _cmd_default(self):
|
def _cmd_default(self):
|
||||||
return [sys.executable, '-m', 'jupyterhub.tests.mocksu']
|
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
|
use_this_api_token = None
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
@@ -199,6 +199,18 @@ def test_cookie_secret_env(tmpdir, request):
|
|||||||
assert not os.path.exists(hub.cookie_secret_file)
|
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):
|
async def test_load_groups(tmpdir, request):
|
||||||
to_load = {
|
to_load = {
|
||||||
'blue': ['cyclops', 'rogue', 'wolverine'],
|
'blue': ['cyclops', 'rogue', 'wolverine'],
|
||||||
|
@@ -121,7 +121,7 @@ def auth_header(db, name):
|
|||||||
"""Return header with user's API authorization token."""
|
"""Return header with user's API authorization token."""
|
||||||
user = find_user(db, name)
|
user = find_user(db, name)
|
||||||
if user is None:
|
if user is None:
|
||||||
user = add_user(db, name=name)
|
raise KeyError(f"No such user: {name}")
|
||||||
token = user.new_api_token()
|
token = user.new_api_token()
|
||||||
return {'Authorization': 'token %s' % token}
|
return {'Authorization': 'token %s' % token}
|
||||||
|
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
alembic
|
alembic>=1.4
|
||||||
async_generator>=1.9
|
async_generator>=1.9
|
||||||
certipy>=0.1.2
|
certipy>=0.1.2
|
||||||
entrypoints
|
entrypoints
|
||||||
|
@@ -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>
|
<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">
|
<ul class="dropdown-menu">
|
||||||
{% for service in services %}
|
{% for service in services %}
|
||||||
|
{% block service scoped %}
|
||||||
<li><a class="dropdown-item" href="{{service.prefix}}">{{service.name}}</a></li>
|
<li><a class="dropdown-item" href="{{service.prefix}}">{{service.name}}</a></li>
|
||||||
|
{% endblock %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
@@ -144,7 +146,7 @@
|
|||||||
{% block login_widget %}
|
{% block login_widget %}
|
||||||
<span id="login_widget">
|
<span id="login_widget">
|
||||||
{% if user %}
|
{% 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>
|
<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 %}
|
{% else %}
|
||||||
<a id="login" role="button" class="btn-sm btn navbar-btn btn-default" href="{{login_url}}">Login</a>
|
<a id="login" role="button" class="btn-sm btn navbar-btn btn-default" href="{{login_url}}">Login</a>
|
||||||
|
Reference in New Issue
Block a user