mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-12 12:33:02 +00:00
Sync with master
This commit is contained in:
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -114,7 +114,7 @@ jobs:
|
||||
# https://github.com/jupyterhub/jupyterhub/settings/secrets/actions
|
||||
if: env.REGISTRY != 'localhost:5000/'
|
||||
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
|
||||
# If this is a tagged build this will return additional parent tags.
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@ htmlcov
|
||||
pip-wheel-metadata
|
||||
docs/source/reference/metrics.rst
|
||||
oldest-requirements.txt
|
||||
jupyterhub-proxy.pid
|
||||
|
@@ -143,6 +143,22 @@ paths:
|
||||
inactive: all users who have *no* active servers (complement of active)
|
||||
|
||||
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:
|
||||
"200":
|
||||
description: The Hub's user list
|
||||
@@ -379,7 +395,7 @@ paths:
|
||||
type: string
|
||||
- name: server_name
|
||||
description: |
|
||||
name given to a named-server.
|
||||
name given to a named-server.
|
||||
|
||||
Note that depending on your JupyterHub infrastructure there are chracterter size limitation to `server_name`. Default spawner with K8s pod will not allow Jupyter Notebooks to be spawned with a name that contains more than 253 characters (keep in mind that the pod will be spawned with extra characters to identify the user and hub).
|
||||
in: path
|
||||
@@ -540,6 +556,23 @@ paths:
|
||||
security:
|
||||
- oauth2:
|
||||
- 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:
|
||||
"200":
|
||||
description: The list of groups
|
||||
@@ -686,6 +719,23 @@ paths:
|
||||
security:
|
||||
- oauth2:
|
||||
- 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:
|
||||
"200":
|
||||
description: Routing table
|
||||
|
@@ -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'
|
||||
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
|
||||
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.
|
||||
|
||||
Each authenticator may have different ways of determining whether a user is an
|
||||
administrator. By default JupyterHub use the PAMAuthenticator which provide the
|
||||
`admin_groups` option and can determine administrator status base on a user
|
||||
groups. For example we can let any users in the `wheel` group be admin:
|
||||
administrator. By default JupyterHub uses the PAMAuthenticator which provides the
|
||||
`admin_groups` option and can set administrator status based on a user
|
||||
group. For example we can let any user in the `wheel` group be admin:
|
||||
|
||||
```python
|
||||
c.PAMAuthenticator.admin_groups = {'wheel'}
|
||||
@@ -42,9 +42,9 @@ c.PAMAuthenticator.admin_groups = {'wheel'}
|
||||
|
||||
## 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
|
||||
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
|
||||
respective machines, for debugging. **As a courtesy, you should make
|
||||
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
|
||||
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
|
||||
will not require manually updating the allowed users set in your config file,
|
||||
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,
|
||||
as the users will be loaded from the database.
|
||||
|
||||
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
|
||||
|
||||
The :class:`~jupyterhub.auth.DummyAuthenticator` is a simple authenticator that
|
||||
allows for any username/password unless if a global password has been set. If
|
||||
The `DummyAuthenticator` is a simple authenticator that
|
||||
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.
|
||||
To set a global password, add this to the config file:
|
||||
|
||||
|
@@ -44,7 +44,7 @@ jupyterhub -f /etc/jupyterhub/jupyterhub_config.py
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
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
|
||||
parameter, `c.Class.trait`, you would use the command line option,
|
||||
`--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:
|
||||
|
||||
```bash
|
||||
@@ -89,11 +89,11 @@ meant as illustration, are:
|
||||
## Run the proxy separately
|
||||
|
||||
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
|
||||
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
|
||||
automatically, so if the hub restarts, the proxy restarts, and user
|
||||
connections are interrupted. It is easy to run the proxy separately,
|
||||
|
@@ -26,7 +26,7 @@ so Breq would open `/user/breq/notebooks/foo.ipynb` and
|
||||
Seivarden would open `/user/seivarden/notebooks/foo.ipynb`, etc.
|
||||
|
||||
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
|
||||
with `/hub/user-redirect` any visitor should get the same
|
||||
URL on their own server, rather than visiting yours.
|
||||
|
@@ -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
|
||||
JupyterHub for its Data Science Education Program courses (serving over
|
||||
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.
|
||||
|
||||
### I keep hearing about Jupyter Notebook, JupyterLab, and now JupyterHub. What’s the difference?
|
||||
@@ -27,14 +27,14 @@ Here is a quick breakdown of these three tools:
|
||||
for other parts of the data science stack.
|
||||
- **JupyterHub** is an application that manages interactive computing sessions for **multiple users**.
|
||||
It also connects them with infrastructure those users wish to access. It can provide
|
||||
remote access to Jupyter Notebooks and Jupyter Lab for many people.
|
||||
remote access to Jupyter Notebooks and JupyterLab for many people.
|
||||
|
||||
## For management
|
||||
|
||||
### Briefly, what problem does JupyterHub solve for us?
|
||||
|
||||
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
|
||||
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
|
||||
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,
|
||||
University of Oslo, University of Sheffield, Université Paris Sud, University of Versailles
|
||||
@@ -99,7 +99,7 @@ that we currently suggest are:
|
||||
guide that runs on Kubernetes. Better for larger or dynamic user groups (50-10,000) or more complex
|
||||
compute/data needs.
|
||||
- [The Littlest JupyterHub](https://tljh.jupyter.org) is a lightweight JupyterHub that runs on a single
|
||||
single machine (in the cloud or under your desk). Better for smaller usergroups (4-80) or more
|
||||
single machine (in the cloud or under your desk). Better for smaller user groups (4-80) or more
|
||||
lightweight computational resources.
|
||||
|
||||
### Does JupyterHub run well in the cloud?
|
||||
@@ -123,7 +123,7 @@ level for several years, and makes a number of "default" security decisions that
|
||||
users.
|
||||
|
||||
- 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
|
||||
[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.
|
||||
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).
|
||||
|
||||
### 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
|
||||
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
|
||||
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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
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
|
||||
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.
|
||||
@@ -249,7 +249,7 @@ share their results with one another.
|
||||
|
||||
JupyterHub also provides a computational framework to share computational narratives between
|
||||
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
|
||||
interact with your work.
|
||||
|
||||
|
@@ -43,7 +43,7 @@ port.
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -82,13 +82,13 @@ c.JupyterHub.hub_ip = '10.0.1.4'
|
||||
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
|
||||
configuration for, e.g. docker, is:
|
||||
|
||||
```python
|
||||
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
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
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
|
||||
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
|
||||
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.
|
||||
@@ -114,7 +114,7 @@ interact with it.
|
||||
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
|
||||
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
|
||||
variable. Run `jupyterhub_idle_culler` manually.
|
||||
|
@@ -1,8 +1,8 @@
|
||||
# Spawners and single-user notebook servers
|
||||
|
||||
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
|
||||
to express that configuration.
|
||||
multi-process application, there are many aspects of that server that can be configured, 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
|
||||
@@ -14,7 +14,7 @@ expanded to the user's home directory.
|
||||
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']
|
||||
|
@@ -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
|
||||
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
|
||||
|
||||
With JupyterHub version 0.8, support for multiple servers per user has landed.
|
||||
|
@@ -352,6 +352,22 @@ class APIHandler(BaseHandler):
|
||||
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):
|
||||
self.finish()
|
||||
|
||||
|
@@ -37,9 +37,11 @@ class GroupListAPIHandler(_GroupAPIHandler):
|
||||
@needs_scope('read:groups')
|
||||
def get(self):
|
||||
"""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')
|
||||
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))
|
||||
|
||||
@needs_scope('admin:groups')
|
||||
|
@@ -17,7 +17,16 @@ class ProxyAPIHandler(APIHandler):
|
||||
This is the same as fetching the routing table directly from the proxy,
|
||||
but without clients needing to maintain separate
|
||||
"""
|
||||
offset, limit = self.get_api_pagination()
|
||||
|
||||
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))
|
||||
|
||||
@needs_scope('proxy')
|
||||
|
@@ -70,6 +70,7 @@ class UserListAPIHandler(APIHandler):
|
||||
)
|
||||
def get(self):
|
||||
state_filter = self.get_argument("state", None)
|
||||
offset, limit = self.get_api_pagination()
|
||||
|
||||
# post_filter
|
||||
post_filter = None
|
||||
@@ -109,12 +110,16 @@ class UserListAPIHandler(APIHandler):
|
||||
else:
|
||||
# no filter, return all users
|
||||
query = self.db.query(orm.User)
|
||||
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
data = []
|
||||
for u in query:
|
||||
if post_filter is None or post_filter(u):
|
||||
user_model = self.user_model(u)
|
||||
if user_model:
|
||||
data.append(user_model)
|
||||
|
||||
self.write(json.dumps(data))
|
||||
|
||||
@needs_scope('admin:users')
|
||||
@@ -242,11 +247,7 @@ 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)
|
||||
await user.delete_spawners()
|
||||
|
||||
# remove from registry
|
||||
self.users.delete(user)
|
||||
@@ -488,10 +489,18 @@ class UserServerAPIHandler(APIHandler):
|
||||
options = self.get_json_body()
|
||||
remove = (options or {}).get('remove', False)
|
||||
|
||||
def _remove_spawner(f=None):
|
||||
if f and f.exception():
|
||||
return
|
||||
async def _remove_spawner(f=None):
|
||||
"""Remove the spawner object
|
||||
|
||||
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)
|
||||
await maybe_future(user._delete_spawner(spawner))
|
||||
|
||||
self.db.delete(spawner.orm_spawner)
|
||||
user.spawners.pop(server_name, None)
|
||||
self.db.commit()
|
||||
@@ -512,7 +521,8 @@ class UserServerAPIHandler(APIHandler):
|
||||
self.set_header('Content-Type', 'text/plain')
|
||||
self.set_status(202)
|
||||
if remove:
|
||||
spawner._stop_future.add_done_callback(_remove_spawner)
|
||||
# schedule remove when stop completes
|
||||
asyncio.ensure_future(_remove_spawner(spawner._stop_future))
|
||||
return
|
||||
|
||||
if spawner.pending:
|
||||
@@ -530,9 +540,10 @@ class UserServerAPIHandler(APIHandler):
|
||||
|
||||
if remove:
|
||||
if stop_future:
|
||||
stop_future.add_done_callback(_remove_spawner)
|
||||
# schedule remove when stop completes
|
||||
asyncio.ensure_future(_remove_spawner(spawner._stop_future))
|
||||
else:
|
||||
_remove_spawner()
|
||||
await _remove_spawner()
|
||||
|
||||
status = 202 if spawner._stop_pending else 204
|
||||
self.set_header('Content-Type', 'text/plain')
|
||||
|
@@ -1021,6 +1021,15 @@ class JupyterHub(Application):
|
||||
""",
|
||||
).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(
|
||||
True, help="Authentication for prometheus metrics"
|
||||
).tag(config=True)
|
||||
@@ -2386,10 +2395,6 @@ class JupyterHub(Application):
|
||||
for user in self.users.values():
|
||||
for spawner in user.spawners.values():
|
||||
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)):
|
||||
if oauth_client.identifier not in oauth_client_ids:
|
||||
|
@@ -24,6 +24,7 @@ import time
|
||||
from functools import wraps
|
||||
from subprocess import Popen
|
||||
from urllib.parse import quote
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from tornado.httpclient import AsyncHTTPClient
|
||||
from tornado.httpclient import HTTPError
|
||||
@@ -44,7 +45,6 @@ from .metrics import CHECK_ROUTES_DURATION_SECONDS
|
||||
from .metrics import PROXY_POLL_DURATION_SECONDS
|
||||
from .objects import Server
|
||||
from .utils import exponential_backoff
|
||||
from .utils import make_ssl_context
|
||||
from .utils import url_path_join
|
||||
from jupyterhub.traitlets import Command
|
||||
|
||||
@@ -55,11 +55,18 @@ def _one_at_a_time(method):
|
||||
If multiple concurrent calls to this method are made,
|
||||
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)
|
||||
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 locked_method
|
||||
@@ -123,7 +130,7 @@ class Proxy(LoggingConfigurable):
|
||||
a URL as target. The hub will ensure this route is present
|
||||
in the proxy.
|
||||
|
||||
If the hub is running in host based mode (with
|
||||
If the hub is running in host based mode (with
|
||||
JupyterHub.subdomain_host set), the routespec *must*
|
||||
have a domain component (example.com/my-url/). If the
|
||||
hub is not running in host based mode, the routespec
|
||||
|
@@ -10,9 +10,11 @@ with JupyterHub authentication mixins enabled.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import secrets
|
||||
import sys
|
||||
import warnings
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
@@ -99,19 +101,26 @@ class JupyterHubLoginHandlerMixin:
|
||||
Thus shouldn't be called anymore because HubAuthenticatedHandler
|
||||
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():
|
||||
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,
|
||||
stacklevel=2,
|
||||
)
|
||||
# patch HubAuthenticated into the instance
|
||||
handler.__class__ = type(
|
||||
handler.__class__.__name__,
|
||||
(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()
|
||||
|
||||
@classmethod
|
||||
@@ -682,6 +691,97 @@ def detect_base_package(App):
|
||||
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):
|
||||
"""Make and return a singleuser notebook app
|
||||
|
||||
@@ -705,37 +805,7 @@ def make_singleuser_app(App):
|
||||
# detect base classes
|
||||
LoginHandler = empty_parent_app.login_handler_class
|
||||
LogoutHandler = empty_parent_app.logout_handler_class
|
||||
BaseHandler = getattr(empty_parent_app, "base_handler_class", None)
|
||||
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)
|
||||
BaseHandler = _patch_app_base_handlers(empty_parent_app)
|
||||
|
||||
# create Handler classes from mixins + bases
|
||||
class JupyterHubLoginHandler(JupyterHubLoginHandlerMixin, LoginHandler):
|
||||
@@ -765,4 +835,11 @@ def make_singleuser_app(App):
|
||||
logout_handler_class = JupyterHubLogoutHandler
|
||||
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
|
||||
|
@@ -1155,6 +1155,18 @@ class Spawner(LoggingConfigurable):
|
||||
"""
|
||||
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):
|
||||
"""Add a callback to fire when the single-user server stops"""
|
||||
if args or kwargs:
|
||||
|
@@ -94,16 +94,6 @@ 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):
|
||||
|
@@ -192,6 +192,30 @@ async def test_get_users(app):
|
||||
r_user_model = r.json()[0]
|
||||
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.parametrize(
|
||||
@@ -1384,15 +1408,44 @@ async def test_groups_list(app):
|
||||
reply = r.json()
|
||||
assert reply == []
|
||||
|
||||
# create a group
|
||||
# create two groups
|
||||
group = orm.Group(name='alphaflight')
|
||||
group_2 = orm.Group(name='betaflight')
|
||||
app.db.add(group)
|
||||
app.db.add(group_2)
|
||||
app.db.commit()
|
||||
|
||||
r = await api_request(app, 'groups')
|
||||
r.raise_for_status()
|
||||
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
|
||||
|
@@ -252,6 +252,35 @@ class User:
|
||||
await self.save_auth_state(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):
|
||||
"""Generator yielding all my spawners
|
||||
|
||||
@@ -810,14 +839,8 @@ class User:
|
||||
if orm_token:
|
||||
self.db.delete(orm_token)
|
||||
# remove oauth client as well
|
||||
# handle upgrades from 0.8, where client id will be `user-USERNAME`,
|
||||
# not just `jupyterhub-user-USERNAME`
|
||||
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)
|
||||
for oauth_client in self.db.query(orm.OAuthClient).filter_by(
|
||||
identifier=spawner.oauth_client_id,
|
||||
):
|
||||
self.log.debug("Deleting oauth client %s", oauth_client.identifier)
|
||||
self.db.delete(oauth_client)
|
||||
|
Reference in New Issue
Block a user