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
|
# 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
1
.gitignore
vendored
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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:
|
||||||
|
|
||||||
|
@@ -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,
|
||||||
|
@@ -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.
|
||||||
|
@@ -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. What’s the difference?
|
### 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.
|
for other parts of the data science stack.
|
||||||
- **JupyterHub** is an application that manages interactive computing sessions for **multiple users**.
|
- **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
|
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
|
## For management
|
||||||
|
|
||||||
### 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
|
||||||
@@ -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
|
guide that runs on Kubernetes. Better for larger or dynamic user groups (50-10,000) or more complex
|
||||||
compute/data needs.
|
compute/data needs.
|
||||||
- [The Littlest JupyterHub](https://tljh.jupyter.org) is a lightweight JupyterHub that runs on a single
|
- [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.
|
lightweight computational resources.
|
||||||
|
|
||||||
### Does JupyterHub run well in the cloud?
|
### 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.
|
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.
|
||||||
|
|
||||||
|
@@ -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
|
||||||
|
@@ -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.
|
||||||
|
@@ -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']
|
||||||
|
@@ -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.
|
||||||
|
@@ -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()
|
||||||
|
|
||||||
|
@@ -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')
|
||||||
|
@@ -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')
|
||||||
|
@@ -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')
|
||||||
|
@@ -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:
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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:
|
||||||
|
@@ -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):
|
||||||
|
@@ -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
|
||||||
|
@@ -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)
|
||||||
|
Reference in New Issue
Block a user