Sync with master

This commit is contained in:
Min RK
2021-05-11 10:52:46 +02:00
22 changed files with 387 additions and 110 deletions

View File

@@ -114,7 +114,7 @@ jobs:
# https://github.com/jupyterhub/jupyterhub/settings/secrets/actions # https://github.com/jupyterhub/jupyterhub/settings/secrets/actions
if: env.REGISTRY != 'localhost:5000/' if: env.REGISTRY != 'localhost:5000/'
run: | run: |
docker login -u "${{ secrets.DOCKER_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}" docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}"
# https://github.com/jupyterhub/action-major-minor-tag-calculator # https://github.com/jupyterhub/action-major-minor-tag-calculator
# If this is a tagged build this will return additional parent tags. # If this is a tagged build this will return additional parent tags.

1
.gitignore vendored
View File

@@ -29,3 +29,4 @@ htmlcov
pip-wheel-metadata pip-wheel-metadata
docs/source/reference/metrics.rst docs/source/reference/metrics.rst
oldest-requirements.txt oldest-requirements.txt
jupyterhub-proxy.pid

View File

@@ -143,6 +143,22 @@ paths:
inactive: all users who have *no* active servers (complement of active) inactive: all users who have *no* active servers (complement of active)
Added in JupyterHub 1.3 Added in JupyterHub 1.3
- name: offset
in: query
required: false
type: number
description: |
Return a number users starting at the given offset.
Can be used with limit to paginate.
If unspecified, return all users.
- name: limit
in: query
requred: false
type: number
description: |
Return a finite number of users.
Can be used with offset to paginate.
If unspecified, use api_page_default_limit.
responses: responses:
"200": "200":
description: The Hub's user list description: The Hub's user list
@@ -540,6 +556,23 @@ paths:
security: security:
- oauth2: - oauth2:
- read:groups - read:groups
parameters:
- name: offset
in: query
required: false
type: number
description: |
Return a number of groups starting at the specified offset.
Can be used with limit to paginate.
If unspecified, return all groups.
- name: limit
in: query
required: false
type: number
description: |
Return a finite number of groups.
Can be used with offset to paginate.
If unspecified, use api_page_default_limit.
responses: responses:
"200": "200":
description: The list of groups description: The list of groups
@@ -686,6 +719,23 @@ paths:
security: security:
- oauth2: - oauth2:
- proxy - proxy
parameters:
- name: offset
in: query
required: false
type: number
description: |
Return a number of routes starting at the given offset.
Can be used with limit to paginate.
If unspecified, return all routes.
- name: limit
in: query
requred: false
type: number
description: |
Return a finite number of routes.
Can be used with offset to paginate.
If unspecified, use api_page_default_limit
responses: responses:
"200": "200":
description: Routing table description: Routing table

View File

@@ -22,7 +22,7 @@ Admin users of JupyterHub, `admin_users`, can add and remove users from
the user `allowed_users` set. `admin_users` can take actions on other users' the user `allowed_users` set. `admin_users` can take actions on other users'
behalf, such as stopping and restarting their servers. behalf, such as stopping and restarting their servers.
A set of initial admin users, `admin_users` can configured be as follows: A set of initial admin users, `admin_users` can be configured as follows:
```python ```python
c.Authenticator.admin_users = {'mal', 'zoe'} c.Authenticator.admin_users = {'mal', 'zoe'}
@@ -32,9 +32,9 @@ Users in the admin set are automatically added to the user `allowed_users` set,
if they are not already present. if they are not already present.
Each authenticator may have different ways of determining whether a user is an Each authenticator may have different ways of determining whether a user is an
administrator. By default JupyterHub use the PAMAuthenticator which provide the administrator. By default JupyterHub uses the PAMAuthenticator which provides the
`admin_groups` option and can determine administrator status base on a user `admin_groups` option and can set administrator status based on a user
groups. For example we can let any users in the `wheel` group be admin: group. For example we can let any user in the `wheel` group be admin:
```python ```python
c.PAMAuthenticator.admin_groups = {'wheel'} c.PAMAuthenticator.admin_groups = {'wheel'}
@@ -42,9 +42,9 @@ c.PAMAuthenticator.admin_groups = {'wheel'}
## Give admin access to other users' notebook servers (`admin_access`) ## Give admin access to other users' notebook servers (`admin_access`)
Since the default `JupyterHub.admin_access` setting is False, the admins Since the default `JupyterHub.admin_access` setting is `False`, the admins
do not have permission to log in to the single user notebook servers do not have permission to log in to the single user notebook servers
owned by _other users_. If `JupyterHub.admin_access` is set to True, owned by _other users_. If `JupyterHub.admin_access` is set to `True`,
then admins have permission to log in _as other users_ on their then admins have permission to log in _as other users_ on their
respective machines, for debugging. **As a courtesy, you should make respective machines, for debugging. **As a courtesy, you should make
sure your users know if admin_access is enabled.** sure your users know if admin_access is enabled.**
@@ -53,8 +53,8 @@ sure your users know if admin_access is enabled.**
Users can be added to and removed from the Hub via either the admin Users can be added to and removed from the Hub via either the admin
panel or the REST API. When a user is **added**, the user will be panel or the REST API. When a user is **added**, the user will be
automatically added to the allowed users set and database. Restarting the Hub automatically added to the `allowed_users` set and database. Restarting the Hub
will not require manually updating the allowed users set in your config file, will not require manually updating the `allowed_users` set in your config file,
as the users will be loaded from the database. as the users will be loaded from the database.
After starting the Hub once, it is not sufficient to **remove** a user After starting the Hub once, it is not sufficient to **remove** a user
@@ -107,8 +107,8 @@ with any provider, is also available.
## Use DummyAuthenticator for testing ## Use DummyAuthenticator for testing
The :class:`~jupyterhub.auth.DummyAuthenticator` is a simple authenticator that The `DummyAuthenticator` is a simple authenticator that
allows for any username/password unless if a global password has been set. If allows for any username/password unless a global password has been set. If
set, it will allow for any username as long as the correct password is provided. set, it will allow for any username as long as the correct password is provided.
To set a global password, add this to the config file: To set a global password, add this to the config file:

View File

@@ -44,7 +44,7 @@ jupyterhub -f /etc/jupyterhub/jupyterhub_config.py
``` ```
The IPython documentation provides additional information on the The IPython documentation provides additional information on the
[config system](http://ipython.readthedocs.io/en/stable/development/config) [config system](http://ipython.readthedocs.io/en/stable/development/config.html)
that Jupyter uses. that Jupyter uses.
## Configure using command line options ## Configure using command line options
@@ -63,11 +63,11 @@ would enter:
jupyterhub --ip 10.0.1.2 --port 443 --ssl-key my_ssl.key --ssl-cert my_ssl.cert jupyterhub --ip 10.0.1.2 --port 443 --ssl-key my_ssl.key --ssl-cert my_ssl.cert
``` ```
All configurable options may technically be set on the command-line, All configurable options may technically be set on the command line,
though some are inconvenient to type. To set a particular configuration though some are inconvenient to type. To set a particular configuration
parameter, `c.Class.trait`, you would use the command line option, parameter, `c.Class.trait`, you would use the command line option,
`--Class.trait`, when starting JupyterHub. For example, to configure the `--Class.trait`, when starting JupyterHub. For example, to configure the
`c.Spawner.notebook_dir` trait from the command-line, use the `c.Spawner.notebook_dir` trait from the command line, use the
`--Spawner.notebook_dir` option: `--Spawner.notebook_dir` option:
```bash ```bash
@@ -89,11 +89,11 @@ meant as illustration, are:
## Run the proxy separately ## Run the proxy separately
This is _not_ strictly necessary, but useful in many cases. If you This is _not_ strictly necessary, but useful in many cases. If you
use a custom proxy (e.g. Traefik), this also not needed. use a custom proxy (e.g. Traefik), this is also not needed.
Connections to user servers go through the proxy, and _not_ the hub Connections to user servers go through the proxy, and _not_ the hub
itself. If the proxy stays running when the hub restarts (for itself. If the proxy stays running when the hub restarts (for
maintenance, re-configuration, etc.), then use connections are not maintenance, re-configuration, etc.), then user connections are not
interrupted. For simplicity, by default the hub starts the proxy interrupted. For simplicity, by default the hub starts the proxy
automatically, so if the hub restarts, the proxy restarts, and user automatically, so if the hub restarts, the proxy restarts, and user
connections are interrupted. It is easy to run the proxy separately, connections are interrupted. It is easy to run the proxy separately,

View File

@@ -26,7 +26,7 @@ so Breq would open `/user/breq/notebooks/foo.ipynb` and
Seivarden would open `/user/seivarden/notebooks/foo.ipynb`, etc. Seivarden would open `/user/seivarden/notebooks/foo.ipynb`, etc.
JupyterHub has a special URL that does exactly this! JupyterHub has a special URL that does exactly this!
It's called `/hub/user-redirect/...` and after the visitor logs in, It's called `/hub/user-redirect/...`.
So if you replace `/user/yourname` in your URL bar So if you replace `/user/yourname` in your URL bar
with `/hub/user-redirect` any visitor should get the same with `/hub/user-redirect` any visitor should get the same
URL on their own server, rather than visiting yours. URL on their own server, rather than visiting yours.

View File

@@ -11,7 +11,7 @@ Yes! JupyterHub has been used at-scale for large pools of users, as well
as complex and high-performance computing. For example, UC Berkeley uses as complex and high-performance computing. For example, UC Berkeley uses
JupyterHub for its Data Science Education Program courses (serving over JupyterHub for its Data Science Education Program courses (serving over
3,000 students). The Pangeo project uses JupyterHub to provide access 3,000 students). The Pangeo project uses JupyterHub to provide access
to scalable cloud computing with Dask. JupyterHub is stable customizable to scalable cloud computing with Dask. JupyterHub is stable and customizable
to the use-cases of large organizations. to the use-cases of large organizations.
### I keep hearing about Jupyter Notebook, JupyterLab, and now JupyterHub. Whats the difference? ### I keep hearing about Jupyter Notebook, JupyterLab, and now JupyterHub. Whats the difference?
@@ -34,7 +34,7 @@ Here is a quick breakdown of these three tools:
### Briefly, what problem does JupyterHub solve for us? ### Briefly, what problem does JupyterHub solve for us?
JupyterHub provides a shared platform for data science and collaboration. JupyterHub provides a shared platform for data science and collaboration.
It allows users to utilize familiar data science workflows (such as the scientific python stack, It allows users to utilize familiar data science workflows (such as the scientific Python stack,
the R tidyverse, and Jupyter Notebooks) on institutional infrastructure. It also allows administrators the R tidyverse, and Jupyter Notebooks) on institutional infrastructure. It also allows administrators
some control over access to resources, security, environments, and authentication. some control over access to resources, security, environments, and authentication.
@@ -55,7 +55,7 @@ industry, and government research labs. It is most-commonly used by two kinds of
- Large teams (e.g., a department, a large class, or a large group of remote users) to provide - Large teams (e.g., a department, a large class, or a large group of remote users) to provide
access to organizational hardware, data, and analytics environments at scale. access to organizational hardware, data, and analytics environments at scale.
Here are a sample of organizations that use JupyterHub: Here is a sample of organizations that use JupyterHub:
- **Universities and colleges**: UC Berkeley, UC San Diego, Cal Poly SLO, Harvard University, University of Chicago, - **Universities and colleges**: UC Berkeley, UC San Diego, Cal Poly SLO, Harvard University, University of Chicago,
University of Oslo, University of Sheffield, Université Paris Sud, University of Versailles University of Oslo, University of Sheffield, Université Paris Sud, University of Versailles
@@ -123,7 +123,7 @@ level for several years, and makes a number of "default" security decisions that
users. users.
- For security considerations in the base JupyterHub application, - For security considerations in the base JupyterHub application,
[see the JupyterHub security page](https://jupyterhub.readthedocs.io/en/stable/reference/websecurity.html) [see the JupyterHub security page](https://jupyterhub.readthedocs.io/en/stable/reference/websecurity.html).
- For security considerations when deploying JupyterHub on Kubernetes, see the - For security considerations when deploying JupyterHub on Kubernetes, see the
[JupyterHub on Kubernetes security page](https://zero-to-jupyterhub.readthedocs.io/en/latest/security.html). [JupyterHub on Kubernetes security page](https://zero-to-jupyterhub.readthedocs.io/en/latest/security.html).
@@ -183,7 +183,7 @@ how those resources are controlled is taken care of by the non-JupyterHub applic
Yes - JupyterHub can provide access to many kinds of computing infrastructure. Yes - JupyterHub can provide access to many kinds of computing infrastructure.
Especially when combined with other open-source schedulers such as Dask, you can manage fairly Especially when combined with other open-source schedulers such as Dask, you can manage fairly
complex computing infrastructure from the interactive sessions of a JupyterHub. For example complex computing infrastructures from the interactive sessions of a JupyterHub. For example
[see the Dask HPC page](https://docs.dask.org/en/latest/setup/hpc.html). [see the Dask HPC page](https://docs.dask.org/en/latest/setup/hpc.html).
### How much resources do user sessions take? ### How much resources do user sessions take?
@@ -192,7 +192,7 @@ This is highly configurable by the administrator. If you wish for your users to
data analytics environments for prototyping and light data exploring, you can restrict their data analytics environments for prototyping and light data exploring, you can restrict their
memory and CPU based on the resources that you have available. If you'd like your JupyterHub memory and CPU based on the resources that you have available. If you'd like your JupyterHub
to serve as a gateway to high-performance compute or data resources, you may increase the to serve as a gateway to high-performance compute or data resources, you may increase the
resources available on user machines, or connect them with computing infrastructure elsewhere. resources available on user machines, or connect them with computing infrastructures elsewhere.
### Can I customize the look and feel of a JupyterHub? ### Can I customize the look and feel of a JupyterHub?
@@ -217,7 +217,7 @@ your JupyterHub with the various services and tools that you wish to provide to
### How well does JupyterHub scale? What are JupyterHub's limitations? ### How well does JupyterHub scale? What are JupyterHub's limitations?
JupyterHub works well at both a small scale (e.g., a single VM or machine) as well as a JupyterHub works well at both a small scale (e.g., a single VM or machine) as well as a
high scale (e.g., a scalable Kubernetes cluster). It can be used for teams as small a 2, and high scale (e.g., a scalable Kubernetes cluster). It can be used for teams as small as 2, and
for user bases as large as 10,000. The scalability of JupyterHub largely depends on the for user bases as large as 10,000. The scalability of JupyterHub largely depends on the
infrastructure on which it is deployed. JupyterHub has been designed to be lightweight and infrastructure on which it is deployed. JupyterHub has been designed to be lightweight and
flexible, so you can tailor your JupyterHub deployment to your needs. flexible, so you can tailor your JupyterHub deployment to your needs.
@@ -249,7 +249,7 @@ share their results with one another.
JupyterHub also provides a computational framework to share computational narratives between JupyterHub also provides a computational framework to share computational narratives between
different levels of an organization. For example, data scientists can share Jupyter Notebooks different levels of an organization. For example, data scientists can share Jupyter Notebooks
rendered as [voila dashboards](https://voila.readthedocs.io/en/stable/) with those who are not rendered as [Voilà dashboards](https://voila.readthedocs.io/en/stable/) with those who are not
familiar with programming, or create publicly-available interactive analyses to allow others to familiar with programming, or create publicly-available interactive analyses to allow others to
interact with your work. interact with your work.

View File

@@ -43,7 +43,7 @@ port.
By default, this REST API listens on port 8001 of `localhost` only. By default, this REST API listens on port 8001 of `localhost` only.
The Hub service talks to the proxy via a REST API on a secondary port. The The Hub service talks to the proxy via a REST API on a secondary port. The
API URL can be configured separately and override the default settings. API URL can be configured separately to override the default settings.
### Set api_url ### Set api_url
@@ -82,13 +82,13 @@ c.JupyterHub.hub_ip = '10.0.1.4'
c.JupyterHub.hub_port = 54321 c.JupyterHub.hub_port = 54321
``` ```
**Added in 0.8:** The `c.JupyterHub.hub_connect_ip` setting is the ip address or **Added in 0.8:** The `c.JupyterHub.hub_connect_ip` setting is the IP address or
hostname that other services should use to connect to the Hub. A common hostname that other services should use to connect to the Hub. A common
configuration for, e.g. docker, is: configuration for, e.g. docker, is:
```python ```python
c.JupyterHub.hub_ip = '0.0.0.0' # listen on all interfaces c.JupyterHub.hub_ip = '0.0.0.0' # listen on all interfaces
c.JupyterHub.hub_connect_ip = '10.0.1.4' # ip as seen on the docker network. Can also be a hostname. c.JupyterHub.hub_connect_ip = '10.0.1.4' # IP as seen on the docker network. Can also be a hostname.
``` ```
## Adjusting the hub's URL ## Adjusting the hub's URL

View File

@@ -2,7 +2,7 @@
When working with JupyterHub, a **Service** is defined as a process When working with JupyterHub, a **Service** is defined as a process
that interacts with the Hub's REST API. A Service may perform a specific that interacts with the Hub's REST API. A Service may perform a specific
or action or task. For example, shutting down individuals' single user action or task. For example, shutting down individuals' single user
notebook servers that have been idle for some time is a good example of notebook servers that have been idle for some time is a good example of
a task that could be automated by a Service. Let's look at how the a task that could be automated by a Service. Let's look at how the
[jupyterhub_idle_culler][] script can be used as a Service. [jupyterhub_idle_culler][] script can be used as a Service.
@@ -114,7 +114,7 @@ interact with it.
This will run the idle culler service manually. It can be run as a standalone This will run the idle culler service manually. It can be run as a standalone
script anywhere with access to the Hub, and will periodically check for idle script anywhere with access to the Hub, and will periodically check for idle
servers and shut them down via the Hub's REST API. In order to shutdown the servers and shut them down via the Hub's REST API. In order to shutdown the
servers, the token given to cull-idle must have admin privileges. servers, the token given to `cull-idle` must have admin privileges.
Generate an API token and store it in the `JUPYTERHUB_API_TOKEN` environment Generate an API token and store it in the `JUPYTERHUB_API_TOKEN` environment
variable. Run `jupyterhub_idle_culler` manually. variable. Run `jupyterhub_idle_culler` manually.

View File

@@ -1,8 +1,8 @@
# Spawners and single-user notebook servers # Spawners and single-user notebook servers
Since the single-user server is an instance of `jupyter notebook`, an entire separate Since the single-user server is an instance of `jupyter notebook`, an entire separate
multi-process application, there are many aspect of that server can configure, and a lot of ways multi-process application, there are many aspects of that server that can be configured, and a lot
to express that configuration. of ways to express that configuration.
At the JupyterHub level, you can set some values on the Spawner. The simplest of these is 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 `Spawner.notebook_dir`, which lets you set the root directory for a user's server. This root
@@ -14,7 +14,7 @@ expanded to the user's home directory.
c.Spawner.notebook_dir = '~/notebooks' 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 ```python
c.Spawner.args = ['--debug', '--profile=PHYS131'] c.Spawner.args = ['--debug', '--profile=PHYS131']

View File

@@ -158,6 +158,27 @@ provided by notebook servers managed by JupyterHub if one of the following is tr
1. The token is for the same user as the owner of the notebook 1. The token is for the same user as the owner of the notebook
2. The token is tied to an admin user or service **and** `c.JupyterHub.admin_access` is set to `True` 2. The token is tied to an admin user or service **and** `c.JupyterHub.admin_access` is set to `True`
## Paginating API requests
Pagination is available through the `offset` and `limit` query parameters on
certain endpoints, which can be used to return ideally sized windows of results.
Here's example code demonstrating pagination on the `GET /users`
endpoint to fetch the first 20 records.
```python
import requests
api_url = 'http://127.0.0.1:8081/hub/api'
r = requests.get(api_url + '/users?offset=0&limit=20')
r.raise_for_status()
r.json()
```
By default, pagination limit will be specified by the `JupyterHub.api_page_default_limit` config variable.
Pagination is enabled on the `GET /users`, `GET /groups`, and `GET /proxy` REST endpoints.
## Enabling users to spawn multiple named-servers via the API ## Enabling users to spawn multiple named-servers via the API
With JupyterHub version 0.8, support for multiple servers per user has landed. With JupyterHub version 0.8, support for multiple servers per user has landed.

View File

@@ -352,6 +352,22 @@ class APIHandler(BaseHandler):
400, ("group names must be str, not %r", type(groupname)) 400, ("group names must be str, not %r", type(groupname))
) )
def get_api_pagination(self):
default_limit = self.settings["app"].api_page_default_limit
max_limit = self.settings["app"].api_page_max_limit
offset = self.get_argument("offset", None)
limit = self.get_argument("limit", default_limit)
try:
offset = abs(int(offset)) if offset is not None else 0
limit = abs(int(limit))
if limit > max_limit:
limit = max_limit
except Exception as e:
raise web.HTTPError(
400, "Invalid argument type, offset and limit must be integers"
)
return offset, limit
def options(self, *args, **kwargs): def options(self, *args, **kwargs):
self.finish() self.finish()

View File

@@ -37,9 +37,11 @@ class GroupListAPIHandler(_GroupAPIHandler):
@needs_scope('read:groups') @needs_scope('read:groups')
def get(self): def get(self):
"""List groups""" """List groups"""
groups = self.db.query(orm.Group) query = self.db.query(orm.Group)
offset, limit = self.get_api_pagination()
query = query.offset(offset).limit(limit)
scope_filter = self.get_scope_filter('read:groups') scope_filter = self.get_scope_filter('read:groups')
data = [self.group_model(g) for g in groups if scope_filter(g, kind='group')] data = [self.group_model(g) for g in query if scope_filter(g, kind='group')]
self.write(json.dumps(data)) self.write(json.dumps(data))
@needs_scope('admin:groups') @needs_scope('admin:groups')

View File

@@ -17,7 +17,16 @@ class ProxyAPIHandler(APIHandler):
This is the same as fetching the routing table directly from the proxy, This is the same as fetching the routing table directly from the proxy,
but without clients needing to maintain separate but without clients needing to maintain separate
""" """
offset, limit = self.get_api_pagination()
routes = await self.proxy.get_all_routes() routes = await self.proxy.get_all_routes()
routes = {
key: routes[key]
for key in list(routes.keys())[offset:limit]
if key in routes
}
self.write(json.dumps(routes)) self.write(json.dumps(routes))
@needs_scope('proxy') @needs_scope('proxy')

View File

@@ -70,6 +70,7 @@ class UserListAPIHandler(APIHandler):
) )
def get(self): def get(self):
state_filter = self.get_argument("state", None) state_filter = self.get_argument("state", None)
offset, limit = self.get_api_pagination()
# post_filter # post_filter
post_filter = None post_filter = None
@@ -109,12 +110,16 @@ class UserListAPIHandler(APIHandler):
else: else:
# no filter, return all users # no filter, return all users
query = self.db.query(orm.User) query = self.db.query(orm.User)
query = query.offset(offset).limit(limit)
data = [] data = []
for u in query: for u in query:
if post_filter is None or post_filter(u): if post_filter is None or post_filter(u):
user_model = self.user_model(u) user_model = self.user_model(u)
if user_model: if user_model:
data.append(user_model) data.append(user_model)
self.write(json.dumps(data)) self.write(json.dumps(data))
@needs_scope('admin:users') @needs_scope('admin:users')
@@ -242,11 +247,7 @@ 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 await user.delete_spawners()
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)
@@ -488,10 +489,18 @@ class UserServerAPIHandler(APIHandler):
options = self.get_json_body() options = self.get_json_body()
remove = (options or {}).get('remove', False) remove = (options or {}).get('remove', False)
def _remove_spawner(f=None): async def _remove_spawner(f=None):
if f and f.exception(): """Remove the spawner object
return
only called after it stops successfully
"""
if f:
# await f, stop on error,
# leaving resources in the db in case of failure to stop
await f
self.log.info("Deleting spawner %s", spawner._log_name) self.log.info("Deleting spawner %s", spawner._log_name)
await maybe_future(user._delete_spawner(spawner))
self.db.delete(spawner.orm_spawner) self.db.delete(spawner.orm_spawner)
user.spawners.pop(server_name, None) user.spawners.pop(server_name, None)
self.db.commit() self.db.commit()
@@ -512,7 +521,8 @@ class UserServerAPIHandler(APIHandler):
self.set_header('Content-Type', 'text/plain') self.set_header('Content-Type', 'text/plain')
self.set_status(202) self.set_status(202)
if remove: if remove:
spawner._stop_future.add_done_callback(_remove_spawner) # schedule remove when stop completes
asyncio.ensure_future(_remove_spawner(spawner._stop_future))
return return
if spawner.pending: if spawner.pending:
@@ -530,9 +540,10 @@ class UserServerAPIHandler(APIHandler):
if remove: if remove:
if stop_future: if stop_future:
stop_future.add_done_callback(_remove_spawner) # schedule remove when stop completes
asyncio.ensure_future(_remove_spawner(spawner._stop_future))
else: else:
_remove_spawner() await _remove_spawner()
status = 202 if spawner._stop_pending else 204 status = 202 if spawner._stop_pending else 204
self.set_header('Content-Type', 'text/plain') self.set_header('Content-Type', 'text/plain')

View File

@@ -1021,6 +1021,15 @@ class JupyterHub(Application):
""", """,
).tag(config=True) ).tag(config=True)
api_page_default_limit = Integer(
50,
help="The default amount of records returned by a paginated endpoint",
).tag(config=True)
api_page_max_limit = Integer(
200, help="The maximum amount of records that can be returned at once"
)
authenticate_prometheus = Bool( authenticate_prometheus = Bool(
True, help="Authentication for prometheus metrics" True, help="Authentication for prometheus metrics"
).tag(config=True) ).tag(config=True)
@@ -2386,10 +2395,6 @@ class JupyterHub(Application):
for user in self.users.values(): for user in self.users.values():
for spawner in user.spawners.values(): for spawner in user.spawners.values():
oauth_client_ids.add(spawner.oauth_client_id) oauth_client_ids.add(spawner.oauth_client_id)
# avoid deleting clients created by 0.8
# 0.9 uses `jupyterhub-user-...` for the client id, while
# 0.8 uses just `user-...`
oauth_client_ids.add(spawner.oauth_client_id.split('-', 1)[1])
for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)): for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)):
if oauth_client.identifier not in oauth_client_ids: if oauth_client.identifier not in oauth_client_ids:

View File

@@ -24,6 +24,7 @@ import time
from functools import wraps from functools import wraps
from subprocess import Popen from subprocess import Popen
from urllib.parse import quote from urllib.parse import quote
from weakref import WeakKeyDictionary
from tornado.httpclient import AsyncHTTPClient from tornado.httpclient import AsyncHTTPClient
from tornado.httpclient import HTTPError from tornado.httpclient import HTTPError
@@ -44,7 +45,6 @@ from .metrics import CHECK_ROUTES_DURATION_SECONDS
from .metrics import PROXY_POLL_DURATION_SECONDS from .metrics import PROXY_POLL_DURATION_SECONDS
from .objects import Server from .objects import Server
from .utils import exponential_backoff from .utils import exponential_backoff
from .utils import make_ssl_context
from .utils import url_path_join from .utils import url_path_join
from jupyterhub.traitlets import Command from jupyterhub.traitlets import Command
@@ -55,11 +55,18 @@ def _one_at_a_time(method):
If multiple concurrent calls to this method are made, If multiple concurrent calls to this method are made,
queue them instead of allowing them to be concurrently outstanding. queue them instead of allowing them to be concurrently outstanding.
""" """
method._lock = asyncio.Lock() # use weak dict for locks
# so that the lock is always acquired within the current asyncio loop
# should only be relevant in testing, where eventloops are created and destroyed often
method._locks = WeakKeyDictionary()
@wraps(method) @wraps(method)
async def locked_method(*args, **kwargs): async def locked_method(*args, **kwargs):
async with method._lock: loop = asyncio.get_event_loop()
lock = method._locks.get(loop, None)
if lock is None:
lock = method._locks[loop] = asyncio.Lock()
async with lock:
return await method(*args, **kwargs) return await method(*args, **kwargs)
return locked_method return locked_method

View File

@@ -10,9 +10,11 @@ with JupyterHub authentication mixins enabled.
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
import asyncio import asyncio
import json import json
import logging
import os import os
import random import random
import secrets import secrets
import sys
import warnings import warnings
from datetime import datetime from datetime import datetime
from datetime import timezone from datetime import timezone
@@ -99,19 +101,26 @@ class JupyterHubLoginHandlerMixin:
Thus shouldn't be called anymore because HubAuthenticatedHandler Thus shouldn't be called anymore because HubAuthenticatedHandler
should have already overridden get_current_user(). should have already overridden get_current_user().
Keep here to prevent unlikely circumstance from losing auth. Keep here to protect uncommon circumstance of multiple BaseHandlers
from missing auth.
e.g. when multiple BaseHandler classes are used.
""" """
if HubAuthenticatedHandler not in handler.__class__.mro(): if HubAuthenticatedHandler not in handler.__class__.mro():
warnings.warn( warnings.warn(
f"Expected to see HubAuthenticatedHandler in {handler.__class__}.mro()", f"Expected to see HubAuthenticatedHandler in {handler.__class__}.mro(),"
" patching in at call time. Hub authentication is still applied.",
RuntimeWarning, RuntimeWarning,
stacklevel=2, stacklevel=2,
) )
# patch HubAuthenticated into the instance
handler.__class__ = type( handler.__class__ = type(
handler.__class__.__name__, handler.__class__.__name__,
(HubAuthenticatedHandler, handler.__class__), (HubAuthenticatedHandler, handler.__class__),
{}, {},
) )
# patch into the class itself so this doesn't happen again for the same class
patch_base_handler(handler.__class__)
return handler.get_current_user() return handler.get_current_user()
@classmethod @classmethod
@@ -682,6 +691,97 @@ def detect_base_package(App):
return None return None
def _nice_cls_repr(cls):
"""Nice repr of classes, e.g. 'module.submod.Class'
Also accepts tuples of classes
"""
return f"{cls.__module__}.{cls.__name__}"
def patch_base_handler(BaseHandler, log=None):
"""Patch HubAuthenticated into a base handler class
so anything inheriting from BaseHandler uses Hub authentication.
This works *even after* subclasses have imported and inherited from BaseHandler.
.. versionadded: 1.5
Made available as an importable utility
"""
if log is None:
log = logging.getLogger()
if HubAuthenticatedHandler not in BaseHandler.__bases__:
new_bases = (HubAuthenticatedHandler,) + BaseHandler.__bases__
log.info(
"Patching auth into {mod}.{name}({old_bases}) -> {name}({new_bases})".format(
mod=BaseHandler.__module__,
name=BaseHandler.__name__,
old_bases=', '.join(
_nice_cls_repr(cls) for cls in BaseHandler.__bases__
),
new_bases=', '.join(_nice_cls_repr(cls) for cls in new_bases),
)
)
BaseHandler.__bases__ = new_bases
# We've now inserted our class as a parent of BaseHandler,
# but we also need to ensure BaseHandler *itself* doesn't
# override the public tornado API methods we have inserted.
# If they are defined in BaseHandler, explicitly replace them with our methods.
for name in ("get_current_user", "get_login_url"):
if name in BaseHandler.__dict__:
log.debug(
f"Overriding {BaseHandler}.{name} with HubAuthenticatedHandler.{name}"
)
method = getattr(HubAuthenticatedHandler, name)
setattr(BaseHandler, name, method)
return BaseHandler
def _patch_app_base_handlers(app):
"""Patch Hub Authentication into the base handlers of an app
Patches HubAuthenticatedHandler into:
- App.base_handler_class (if defined)
- jupyter_server's JupyterHandler (if already imported)
- notebook's IPythonHandler (if already imported)
"""
BaseHandler = app_base_handler = getattr(app, "base_handler_class", None)
base_handlers = []
if BaseHandler is not None:
base_handlers.append(BaseHandler)
# patch juptyer_server and notebook handlers if they have been imported
for base_handler_name in [
"jupyter_server.base.handlers.JupyterHandler",
"notebook.base.handlers.IPythonHandler",
]:
modname, _ = base_handler_name.rsplit(".", 1)
if modname in sys.modules:
base_handlers.append(import_item(base_handler_name))
if not base_handlers:
pkg = detect_base_package(app.__class__)
if pkg == "jupyter_server":
BaseHandler = import_item("jupyter_server.base.handlers.JupyterHandler")
elif pkg == "notebook":
BaseHandler = import_item("notebook.base.handlers.IPythonHandler")
else:
raise ValueError(
"{}.base_handler_class must be defined".format(app.__class__.__name__)
)
base_handlers.append(BaseHandler)
# patch-in HubAuthenticatedHandler to base handler classes
for BaseHandler in base_handlers:
patch_base_handler(BaseHandler)
# return the first entry
return base_handlers[0]
def make_singleuser_app(App): def make_singleuser_app(App):
"""Make and return a singleuser notebook app """Make and return a singleuser notebook app
@@ -705,37 +805,7 @@ def make_singleuser_app(App):
# detect base classes # detect base classes
LoginHandler = empty_parent_app.login_handler_class LoginHandler = empty_parent_app.login_handler_class
LogoutHandler = empty_parent_app.logout_handler_class LogoutHandler = empty_parent_app.logout_handler_class
BaseHandler = getattr(empty_parent_app, "base_handler_class", None) BaseHandler = _patch_app_base_handlers(empty_parent_app)
if BaseHandler is None:
pkg = detect_base_package(App)
if pkg == "jupyter_server":
BaseHandler = import_item("jupyter_server.base.handlers.JupyterHandler")
elif pkg == "notebook":
BaseHandler = import_item("notebook.base.handlers.IPythonHandler")
else:
raise ValueError(
"{}.base_handler_class must be defined".format(App.__name__)
)
# patch-in HubAuthenticatedHandler to BaseHandler,
# so anything inheriting from BaseHandler uses Hub authentication
if HubAuthenticatedHandler not in BaseHandler.__bases__:
new_bases = (HubAuthenticatedHandler,) + BaseHandler.__bases__
log.debug(
f"Patching {BaseHandler}{BaseHandler.__bases__} -> {BaseHandler}{new_bases}"
)
BaseHandler.__bases__ = new_bases
# We've now inserted our class as a parent of BaseHandler,
# but we also need to ensure BaseHandler *itself* doesn't
# override the public tornado API methods we have inserted.
# If they are defined in BaseHandler, explicitly replace them with our methods.
for name in ("get_current_user", "get_login_url"):
if name in BaseHandler.__dict__:
log.debug(
f"Overriding {BaseHandler}.{name} with HubAuthenticatedHandler.{name}"
)
method = getattr(HubAuthenticatedHandler, name)
setattr(BaseHandler, name, method)
# create Handler classes from mixins + bases # create Handler classes from mixins + bases
class JupyterHubLoginHandler(JupyterHubLoginHandlerMixin, LoginHandler): class JupyterHubLoginHandler(JupyterHubLoginHandlerMixin, LoginHandler):
@@ -765,4 +835,11 @@ def make_singleuser_app(App):
logout_handler_class = JupyterHubLogoutHandler logout_handler_class = JupyterHubLogoutHandler
oauth_callback_handler_class = OAuthCallbackHandler oauth_callback_handler_class = OAuthCallbackHandler
def initialize(self, *args, **kwargs):
result = super().initialize(*args, **kwargs)
# run patch again after initialize, so extensions have already been loaded
# probably a no-op most of the time
_patch_app_base_handlers(self)
return result
return SingleUserNotebookApp return SingleUserNotebookApp

View File

@@ -1155,6 +1155,18 @@ class Spawner(LoggingConfigurable):
""" """
raise NotImplementedError("Override in subclass. Must be a coroutine.") raise NotImplementedError("Override in subclass. Must be a coroutine.")
def delete_forever(self):
"""Called when a user or server is deleted.
This can do things like request removal of resources such as persistent storage.
Only called on stopped spawners, and is usually the last action ever taken for the user.
Will only be called once on each Spawner, immediately prior to removal.
Stopping a server does *not* call this method.
"""
pass
def add_poll_callback(self, callback, *args, **kwargs): def add_poll_callback(self, callback, *args, **kwargs):
"""Add a callback to fire when the single-user server stops""" """Add a callback to fire when the single-user server stops"""
if args or kwargs: if args or kwargs:

View File

@@ -94,16 +94,6 @@ 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):

View File

@@ -192,6 +192,30 @@ async def test_get_users(app):
r_user_model = r.json()[0] r_user_model = r.json()[0]
assert r_user_model['name'] == user_model['name'] assert r_user_model['name'] == user_model['name']
# Tests offset for pagination
r = await api_request(app, 'users?offset=1')
assert r.status_code == 200
users = sorted(r.json(), key=lambda d: d['name'])
users = [normalize_user(u) for u in users]
assert users == [fill_user({'name': 'user', 'admin': False})]
r = await api_request(app, 'users?offset=20')
assert r.status_code == 200
assert r.json() == []
# Test limit for pagination
r = await api_request(app, 'users?limit=1')
assert r.status_code == 200
users = sorted(r.json(), key=lambda d: d['name'])
users = [normalize_user(u) for u in users]
assert users == [fill_user({'name': 'admin', 'admin': True})]
r = await api_request(app, 'users?limit=0')
assert r.status_code == 200
assert r.json() == []
@mark.user @mark.user
@mark.parametrize( @mark.parametrize(
@@ -1384,15 +1408,44 @@ async def test_groups_list(app):
reply = r.json() reply = r.json()
assert reply == [] assert reply == []
# create a group # create two groups
group = orm.Group(name='alphaflight') group = orm.Group(name='alphaflight')
group_2 = orm.Group(name='betaflight')
app.db.add(group) app.db.add(group)
app.db.add(group_2)
app.db.commit() app.db.commit()
r = await api_request(app, 'groups') r = await api_request(app, 'groups')
r.raise_for_status() r.raise_for_status()
reply = r.json() reply = r.json()
assert reply == [{'kind': 'group', 'name': 'alphaflight', 'users': [], 'roles': []}] assert reply == [
{'kind': 'group', 'name': 'alphaflight', 'users': [], 'roles': []},
{'kind': 'group', 'name': 'betaflight', 'users': [], 'roles': []},
]
# Test offset for pagination
r = await api_request(app, "groups?offset=1")
r.raise_for_status()
reply = r.json()
assert r.status_code == 200
assert reply == [{'kind': 'group', 'name': 'betaflight', 'users': [], 'roles': []}]
r = await api_request(app, "groups?offset=10")
r.raise_for_status()
reply = r.json()
assert reply == []
# Test limit for pagination
r = await api_request(app, "groups?limit=1")
r.raise_for_status()
reply = r.json()
assert r.status_code == 200
assert reply == [{'kind': 'group', 'name': 'alphaflight', 'users': []}]
r = await api_request(app, "groups?limit=0")
r.raise_for_status()
reply = r.json()
assert reply == []
@mark.group @mark.group

View File

@@ -252,6 +252,35 @@ class User:
await self.save_auth_state(auth_state) await self.save_auth_state(auth_state)
return auth_state return auth_state
async def delete_spawners(self):
"""Call spawner cleanup methods
Allows the spawner to cleanup persistent resources
"""
for name in self.orm_user.orm_spawners.keys():
await self._delete_spawner(name)
async def _delete_spawner(self, name_or_spawner):
"""Delete a single spawner"""
# always ensure full Spawner
# this may instantiate the Spawner if it wasn't already running,
# just to delete it
if isinstance(name_or_spawner, str):
spawner = self.spawners[name_or_spawner]
else:
spawner = name_or_spawner
if spawner.active:
raise RuntimeError(
f"Spawner {spawner._log_name} is active and cannot be deleted."
)
try:
await maybe_future(spawner.delete_forever())
except Exception as e:
self.log.exception(
f"Error cleaning up persistent resources on {spawner._log_name}"
)
def all_spawners(self, include_default=True): def all_spawners(self, include_default=True):
"""Generator yielding all my spawners """Generator yielding all my spawners
@@ -810,14 +839,8 @@ class User:
if orm_token: if orm_token:
self.db.delete(orm_token) self.db.delete(orm_token)
# remove oauth client as well # remove oauth client as well
# handle upgrades from 0.8, where client id will be `user-USERNAME`, for oauth_client in self.db.query(orm.OAuthClient).filter_by(
# not just `jupyterhub-user-USERNAME` identifier=spawner.oauth_client_id,
client_ids = (
spawner.oauth_client_id,
spawner.oauth_client_id.split('-', 1)[1],
)
for oauth_client in self.db.query(orm.OAuthClient).filter(
orm.OAuthClient.identifier.in_(client_ids)
): ):
self.log.debug("Deleting oauth client %s", oauth_client.identifier) self.log.debug("Deleting oauth client %s", oauth_client.identifier)
self.db.delete(oauth_client) self.db.delete(oauth_client)