mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-17 15:03:02 +00:00
Merge remote-tracking branch 'origin/main' into jupyterhub-public-url
This commit is contained in:
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -150,7 +150,7 @@ jobs:
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub
|
||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub-onbuild
|
||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
|
||||
@@ -192,7 +192,7 @@ jobs:
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub-demo
|
||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
|
||||
@@ -216,7 +216,7 @@ jobs:
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub/singleuser
|
||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
build-args: |
|
||||
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}
|
||||
|
14
.github/workflows/test.yml
vendored
14
.github/workflows/test.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
||||
# unencrypted HTTP
|
||||
#
|
||||
# main_dependencies:
|
||||
# Tests everything when the we use the latest available dependencies
|
||||
# Tests everything when we use the latest available dependencies
|
||||
# from: traitlets.
|
||||
#
|
||||
# NOTE: Since only the value of these parameters are presented in the
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
# Python versions available at:
|
||||
# https://github.com/actions/python-versions/blob/HEAD/versions-manifest.json
|
||||
include:
|
||||
- python: "3.7"
|
||||
- python: "3.8"
|
||||
oldest_dependencies: oldest_dependencies
|
||||
legacy_notebook: legacy_notebook
|
||||
- python: "3.8"
|
||||
@@ -90,6 +90,9 @@ jobs:
|
||||
- python: "3.11"
|
||||
ssl: ssl
|
||||
serverextension: serverextension
|
||||
- python: "3.11"
|
||||
jupyverse: jupyverse
|
||||
subset: singleuser
|
||||
- python: "3.11"
|
||||
subdomain: subdomain
|
||||
noextension: noextension
|
||||
@@ -130,6 +133,9 @@ jobs:
|
||||
elif [ "${{ matrix.noextension }}" != "" ]; then
|
||||
echo "JUPYTERHUB_SINGLEUSER_EXTENSION=0" >> $GITHUB_ENV
|
||||
fi
|
||||
if [ "${{ matrix.jupyverse }}" != "" ]; then
|
||||
echo "JUPYTERHUB_SINGLEUSER_APP=jupyverse" >> $GITHUB_ENV
|
||||
fi
|
||||
- uses: actions/checkout@v3
|
||||
# NOTE: actions/setup-node@v3 make use of a cache within the GitHub base
|
||||
# environment and setup in a fraction of a second.
|
||||
@@ -176,6 +182,10 @@ jobs:
|
||||
if [ "${{ matrix.jupyter_server }}" != "" ]; then
|
||||
pip install "jupyter_server==${{ matrix.jupyter_server }}"
|
||||
fi
|
||||
if [ "${{ matrix.jupyverse }}" != "" ]; then
|
||||
pip install "jupyverse[jupyterlab,auth-jupyterhub]"
|
||||
pip install -e .
|
||||
fi
|
||||
if [ "${{ matrix.db }}" == "mysql" ]; then
|
||||
pip install mysqlclient
|
||||
fi
|
||||
|
@@ -16,7 +16,7 @@ ci:
|
||||
repos:
|
||||
# Autoformat: Python code, syntax patterns are modernized
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.4.0
|
||||
rev: v3.10.1
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args:
|
||||
@@ -24,7 +24,7 @@ repos:
|
||||
|
||||
# Autoformat: Python code
|
||||
- repo: https://github.com/PyCQA/autoflake
|
||||
rev: v2.1.1
|
||||
rev: v2.2.1
|
||||
hooks:
|
||||
- id: autoflake
|
||||
# args ref: https://github.com/PyCQA/autoflake#advanced-usage
|
||||
@@ -39,13 +39,13 @@ repos:
|
||||
|
||||
# Autoformat: Python code
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.3.0
|
||||
rev: 23.7.0
|
||||
hooks:
|
||||
- id: black
|
||||
|
||||
# Autoformat: markdown, yaml, javascript (see the file .prettierignore)
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v3.0.0-alpha.9-for-vscode
|
||||
rev: v3.0.3
|
||||
hooks:
|
||||
- id: prettier
|
||||
|
||||
@@ -61,6 +61,6 @@ repos:
|
||||
|
||||
# Linting: Python code (see the file .flake8)
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: "6.0.0"
|
||||
rev: "6.1.0"
|
||||
hooks:
|
||||
- id: flake8
|
||||
|
@@ -21,7 +21,7 @@ fi
|
||||
# Configure a set of databases in the database server for upgrade tests
|
||||
# this list must be in sync with versions in test_db.py:test_upgrade
|
||||
set -x
|
||||
for SUFFIX in '' _upgrade_110 _upgrade_122 _upgrade_130 _upgrade_150 _upgrade_211; do
|
||||
for SUFFIX in '' _upgrade_110 _upgrade_122 _upgrade_130 _upgrade_150 _upgrade_211 _upgrade_311; do
|
||||
$SQL_CLIENT "DROP DATABASE jupyterhub${SUFFIX};" 2>/dev/null || true
|
||||
$SQL_CLIENT "CREATE DATABASE jupyterhub${SUFFIX} ${EXTRA_CREATE_DATABASE_ARGS:-};"
|
||||
done
|
||||
|
@@ -6,7 +6,7 @@ info:
|
||||
description: The REST API for JupyterHub
|
||||
license:
|
||||
name: BSD-3-Clause
|
||||
version: 4.1.0.dev
|
||||
version: 5.0.0.dev
|
||||
servers:
|
||||
- url: /hub/api
|
||||
security:
|
||||
@@ -1498,6 +1498,9 @@ components:
|
||||
read:groups: Read group models.
|
||||
read:groups:name: Read group names.
|
||||
delete:groups: Delete groups.
|
||||
admin:services:
|
||||
Create, read, update, delete services, not including services
|
||||
defined from config files.
|
||||
list:services: List services, including at least their names.
|
||||
read:services: Read service models.
|
||||
read:services:name: Read service names.
|
||||
|
@@ -173,3 +173,46 @@ python3 setup.py js # fetch updated client-side js
|
||||
python3 setup.py css # recompile CSS from LESS sources
|
||||
python3 setup.py jsx # build React admin app
|
||||
```
|
||||
|
||||
### Failed to bind XXX to `http://127.0.0.1:<port>/<path>`
|
||||
|
||||
This error can happen when there's already an application or a service using this
|
||||
port.
|
||||
|
||||
Use the following command to find out which service is using this port.
|
||||
|
||||
```bash
|
||||
lsof -P -i TCP:<port> -sTCP:LISTEN
|
||||
```
|
||||
|
||||
If nothing shows up, it likely means there's a system service that uses it but
|
||||
your current user cannot list it. Reuse the same command with sudo.
|
||||
|
||||
```bash
|
||||
sudo lsof -P -i TCP:<port> -sTCP:LISTEN
|
||||
```
|
||||
|
||||
Depending on the result of the above commands, the most simple solution is to
|
||||
configure JupyterHub to use a different port for the service that is failing.
|
||||
|
||||
As an example, the following is a frequently seen issue:
|
||||
|
||||
`Failed to bind hub to http://127.0.0.1:8081/hub/`
|
||||
|
||||
Using the procedure described above, start with:
|
||||
|
||||
```bash
|
||||
lsof -P -i TCP:8081 -sTCP:LISTEN
|
||||
```
|
||||
|
||||
and if nothing shows up:
|
||||
|
||||
```bash
|
||||
sudo lsof -P -i TCP:8081 -sTCP:LISTEN
|
||||
```
|
||||
|
||||
Finally, depending on your findings, you can apply the following change and start JupyterHub again:
|
||||
|
||||
```python
|
||||
c.JupyterHub.hub_port = 9081 # Or any other free port
|
||||
```
|
||||
|
@@ -145,7 +145,7 @@ There is additional configuration required for MySQL that is not needed for Post
|
||||
|
||||
For example, to connect to a postgres database with psycopg2:
|
||||
|
||||
1. install psycopg2: `pip instal psycopg2` (or `psycopg2-binary` to avoid compilation, which is [not recommended for production][psycopg2-binary])
|
||||
1. install psycopg2: `pip install psycopg2` (or `psycopg2-binary` to avoid compilation, which is [not recommended for production][psycopg2-binary])
|
||||
2. set authentication via environment variables `PGUSER` and `PGPASSWORD`
|
||||
3. configure [](JupyterHub.db_url):
|
||||
|
||||
|
@@ -1,3 +1,5 @@
|
||||
(jupyterhub-oauth)=
|
||||
|
||||
# JupyterHub and OAuth
|
||||
|
||||
JupyterHub uses [OAuth 2](https://oauth.net/2/) as an internal mechanism for authenticating users.
|
||||
|
@@ -45,7 +45,7 @@ additional packages.
|
||||
|
||||
## Configuring Jupyter and IPython
|
||||
|
||||
[Jupyter](https://jupyter-notebook.readthedocs.io/en/stable/config_overview.html)
|
||||
[Jupyter](https://jupyter-notebook.readthedocs.io/en/stable/configuring/config_overview.html)
|
||||
and [IPython](https://ipython.readthedocs.io/en/stable/development/config.html)
|
||||
have their own configuration systems.
|
||||
|
||||
@@ -212,13 +212,31 @@ By default, the single-user server launches JupyterLab,
|
||||
which is based on [Jupyter Server][].
|
||||
|
||||
This is the default server when running JupyterHub ≥ 2.0.
|
||||
To switch to using the legacy Jupyter Notebook server, you can set the `JUPYTERHUB_SINGLEUSER_APP` environment variable
|
||||
To switch to using the legacy Jupyter Notebook server (notebook < 7.0), you can set the `JUPYTERHUB_SINGLEUSER_APP` environment variable
|
||||
(in the single-user environment) to:
|
||||
|
||||
```bash
|
||||
export JUPYTERHUB_SINGLEUSER_APP='notebook.notebookapp.NotebookApp'
|
||||
```
|
||||
|
||||
:::{note}
|
||||
|
||||
```
|
||||
JUPYTERHUB_SINGLEUSER_APP='notebook.notebookapp.NotebookApp'
|
||||
```
|
||||
|
||||
is only valid for notebook < 7. notebook v7 is based on jupyter-server,
|
||||
and the default jupyter-server application must be used.
|
||||
Selecting the new notebook UI is no longer a matter of selecting the server app to launch,
|
||||
but only the default URL for users to visit.
|
||||
To use notebook v7 with JupyterHub, leave the default singleuser app config alone (or specify `JUPYTERHUB_SINGLEUSER_APP=jupyter-server`) and set the default _URL_ for user servers:
|
||||
|
||||
```python
|
||||
c.Spawner.default_url = '/tree/'
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
[jupyter server]: https://jupyter-server.readthedocs.io
|
||||
[jupyter notebook]: https://jupyter-notebook.readthedocs.io
|
||||
|
||||
|
@@ -24,6 +24,7 @@ such as:
|
||||
|
||||
- Checking which users are active
|
||||
- Adding or removing users
|
||||
- Adding or removing services
|
||||
- Stopping or starting single user notebook servers
|
||||
- Authenticating services
|
||||
- Communicating with an individual Jupyter server's REST API
|
||||
|
@@ -10,6 +10,34 @@ command line for details.
|
||||
|
||||
## 4.0
|
||||
|
||||
### 4.0.2 - 2023-08-10
|
||||
|
||||
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/4.0.1...4.0.2))
|
||||
|
||||
#### Enhancements made
|
||||
|
||||
- avoid counting failed requests to not-running servers as 'activity' [#4491](https://github.com/jupyterhub/jupyterhub/pull/4491) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||
- improve permission-denied errors for various cases [#4489](https://github.com/jupyterhub/jupyterhub/pull/4489) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||
|
||||
#### Bugs fixed
|
||||
|
||||
- set root_dir when using singleuser extension [#4503](https://github.com/jupyterhub/jupyterhub/pull/4503) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics))
|
||||
- Allow setting custom log_function in tornado_settings in SingleUserServer [#4475](https://github.com/jupyterhub/jupyterhub/pull/4475) ([@grios-stratio](https://github.com/grios-stratio), [@minrk](https://github.com/minrk))
|
||||
|
||||
#### Documentation improvements
|
||||
|
||||
- doc: update notebook config URL [#4523](https://github.com/jupyterhub/jupyterhub/pull/4523) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics))
|
||||
- document how to use notebook v7 with jupyterhub [#4522](https://github.com/jupyterhub/jupyterhub/pull/4522) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics))
|
||||
|
||||
#### Contributors to this release
|
||||
|
||||
The following people contributed discussions, new ideas, code and documentation contributions, and review.
|
||||
See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports).
|
||||
|
||||
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2023-06-08&to=2023-08-10&type=c))
|
||||
|
||||
@agelosnm ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aagelosnm+updated%3A2023-06-08..2023-08-10&type=Issues)) | @consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2023-06-08..2023-08-10&type=Issues)) | @diocas ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adiocas+updated%3A2023-06-08..2023-08-10&type=Issues)) | @grios-stratio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Agrios-stratio+updated%3A2023-06-08..2023-08-10&type=Issues)) | @jhgoebbert ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajhgoebbert+updated%3A2023-06-08..2023-08-10&type=Issues)) | @jtpio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajtpio+updated%3A2023-06-08..2023-08-10&type=Issues)) | @kosmonavtus ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Akosmonavtus+updated%3A2023-06-08..2023-08-10&type=Issues)) | @kreuzert ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Akreuzert+updated%3A2023-06-08..2023-08-10&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2023-06-08..2023-08-10&type=Issues)) | @martinRenou ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AmartinRenou+updated%3A2023-06-08..2023-08-10&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2023-06-08..2023-08-10&type=Issues)) | @opoplawski ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aopoplawski+updated%3A2023-06-08..2023-08-10&type=Issues)) | @Ph0tonic ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3APh0tonic+updated%3A2023-06-08..2023-08-10&type=Issues)) | @sgaist ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asgaist+updated%3A2023-06-08..2023-08-10&type=Issues)) | @trungleduc ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Atrungleduc+updated%3A2023-06-08..2023-08-10&type=Issues)) | @yuvipanda ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ayuvipanda+updated%3A2023-06-08..2023-08-10&type=Issues))
|
||||
|
||||
### 4.0.1 - 2023-06-08
|
||||
|
||||
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/4.0.0...4.0.1))
|
||||
|
@@ -16,8 +16,6 @@ Please submit pull requests to update information or to add new institutions or
|
||||
|
||||
- [BIDS - Berkeley Institute for Data Science](https://bids.berkeley.edu/)
|
||||
|
||||
- [Teaching with Jupyter notebooks and JupyterHub](https://bids.berkeley.edu/resources/videos/teaching-ipythonjupyter-notebooks-and-jupyterhub)
|
||||
|
||||
- [Data 8](http://data8.org/)
|
||||
|
||||
- [GitHub organization](https://github.com/data-8)
|
||||
|
@@ -18,3 +18,17 @@ tool like [Grafana](https://grafana.com).
|
||||
|
||||
/reference/metrics
|
||||
```
|
||||
|
||||
## Customizing the metrics prefix
|
||||
|
||||
JupyterHub metrics all have a `jupyterhub_` prefix.
|
||||
As of JupyterHub 5.0, this can be overridden with `$JUPYTERHUB_METRICS_PREFIX` environment variable
|
||||
in the Hub's environment.
|
||||
|
||||
For example,
|
||||
|
||||
```bash
|
||||
export JUPYTERHUB_METRICS_PREFIX=jupyterhub_prod
|
||||
```
|
||||
|
||||
would result in the metric `jupyterhub_prod_active_users`, etc.
|
||||
|
@@ -4,20 +4,19 @@
|
||||
|
||||
## Definition of a Service
|
||||
|
||||
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
|
||||
action or task. For example, the following tasks can each be a unique Service:
|
||||
When working with JupyterHub, a **Service** is defined as something (usually a process) that can interact with the Hub's REST API.
|
||||
A Service may perform a specific action or task.
|
||||
For example, the following tasks can each be a unique Service:
|
||||
|
||||
- shutting down individuals' single user notebook servers that have been idle
|
||||
for some time
|
||||
- registering additional web servers which should use the Hub's authentication
|
||||
and be served behind the Hub's proxy.
|
||||
- shutting down individuals' single user notebook servers that have been idle for some time
|
||||
- an additional web application which uses the Hub as an OAuth provider to authenticate and authorize user access
|
||||
- a script run once in a while, which performs any API action
|
||||
- automating requests to running user servers, such as activity data collection
|
||||
|
||||
Two key features help define a Service:
|
||||
Two key features help differentiate Services:
|
||||
|
||||
- Is the Service **managed** by JupyterHub?
|
||||
- Does the Service have a web server that should be added to the proxy's
|
||||
table?
|
||||
- Does the Service have a web server that should be added to the proxy's table?
|
||||
|
||||
Currently, these characteristics distinguish two types of Services:
|
||||
|
||||
@@ -30,24 +29,32 @@ Currently, these characteristics distinguish two types of Services:
|
||||
A Service may have the following properties:
|
||||
|
||||
- `name: str` - the name of the service
|
||||
- `admin: bool (default - false)` - whether the service should have
|
||||
administrative privileges
|
||||
- `url: str (default - None)` - The URL where the service is/should be. If a
|
||||
url is specified for where the Service runs its own web server,
|
||||
the service will be added to the proxy at `/services/:name`
|
||||
- `api_token: str (default - None)` - For Externally-Managed Services you need to specify
|
||||
an API token to perform API requests to the Hub
|
||||
- `url: str (default - None)` - The URL where the service should be running (from the proxy's perspective).
|
||||
Typically a localhost URL for Hub-managed services.
|
||||
If a url is specified,
|
||||
the service will be added to the proxy at `/services/:name`.
|
||||
- `api_token: str (default - None)` - For Externally-Managed Services,
|
||||
you need to specify an API token to perform API requests to the Hub.
|
||||
For Hub-managed services, this token is generated at startup,
|
||||
and available via `$JUPYTERHUB_API_TOKEN`.
|
||||
For OAuth services, this is the client secret.
|
||||
- `display: bool (default - True)` - When set to true, display a link to the
|
||||
service's URL under the 'Services' dropdown in user's hub home page.
|
||||
|
||||
service's URL under the 'Services' dropdown in users' hub home page.
|
||||
Only has an effect if `url` is also specified.
|
||||
- `oauth_no_confirm: bool (default - False)` - When set to true,
|
||||
skip the OAuth confirmation page when users access this service.
|
||||
|
||||
By default, when users authenticate with a service using JupyterHub,
|
||||
they are prompted to confirm that they want to grant that service
|
||||
access to their credentials.
|
||||
Skipping the confirmation page is useful for admin-managed services that are considered part of the Hub
|
||||
and shouldn't need extra prompts for login.
|
||||
- `oauth_client_id: str (default - 'service-$name')` -
|
||||
This never needs to be set, but you can specify a service's OAuth client id.
|
||||
It must start with `service-`.
|
||||
- `oauth_redirect_uri: str (default: '/services/:name/oauth_redirect')` -
|
||||
Set the OAuth redirect URI.
|
||||
Required if the redirect URI differs from the default or the service is not to be added to the proxy at `/services/:name`
|
||||
(i.e. `url` is not set, but there is still a public web service using OAuth).
|
||||
|
||||
If a service is also to be managed by the Hub, it has a few extra options:
|
||||
|
||||
@@ -55,19 +62,19 @@ If a service is also to be managed by the Hub, it has a few extra options:
|
||||
externally. - If a command is specified for launching the Service, the Service will
|
||||
be started and managed by the Hub.
|
||||
- `environment: dict` - additional environment variables for the Service.
|
||||
- `user: str` - the name of a system user to manage the Service. If
|
||||
unspecified, run as the same user as the Hub.
|
||||
- `user: str` - the name of a system user to manage the Service.
|
||||
If unspecified, run as the same user as the Hub.
|
||||
|
||||
## Hub-Managed Services
|
||||
|
||||
A **Hub-Managed Service** is started by the Hub, and the Hub is responsible
|
||||
for the Service's actions. A Hub-Managed Service can only be a local
|
||||
for the Service's operation. A Hub-Managed Service can only be a local
|
||||
subprocess of the Hub. The Hub will take care of starting the process and
|
||||
restart the service if the service stops.
|
||||
|
||||
While Hub-Managed Services share some similarities with notebook Spawners,
|
||||
While Hub-Managed Services share some similarities with single-user server Spawners,
|
||||
there are no plans for Hub-Managed Services to support the same spawning
|
||||
abstractions as a notebook Spawner.
|
||||
abstractions as a Spawner.
|
||||
|
||||
If you wish to run a Service in a Docker container or other deployment
|
||||
environments, the Service can be registered as an
|
||||
@@ -156,8 +163,8 @@ to perform its API requests. Each Externally-Managed Service will need a
|
||||
unique API token, because the Hub authenticates each API request and the API
|
||||
token is used to identify the originating Service or user.
|
||||
|
||||
A configuration example of an Externally-Managed Service with admin access and
|
||||
running its own web server is:
|
||||
A configuration example of an Externally-Managed Service running its own web
|
||||
server is:
|
||||
|
||||
```python
|
||||
c.JupyterHub.services = [
|
||||
@@ -174,6 +181,147 @@ c.JupyterHub.services = [
|
||||
In this case, the `url` field will be passed along to the Service as
|
||||
`JUPYTERHUB_SERVICE_URL`.
|
||||
|
||||
## Service credentials
|
||||
|
||||
A service has direct access to the Hub API via its `api_token`.
|
||||
Exactly what actions the service can take are governed by the service's [role assignments](define-role-target):
|
||||
|
||||
```python
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
"name": "user-lister",
|
||||
"command": ["python3", "/path/to/user-lister"],
|
||||
}
|
||||
]
|
||||
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "list-users",
|
||||
"scopes": ["list:users", "read:users"],
|
||||
"services": ["user-lister"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
When a service has a configured URL or explicit `oauth_client_id` or `oauth_redirect_uri`, it can operate as an [OAuth client](jupyterhub-oauth).
|
||||
When a user visits an oauth-authenticated service,
|
||||
completion of authentication results in issuing an oauth token.
|
||||
|
||||
This token is:
|
||||
|
||||
- owned by the authenticated user
|
||||
- associated with the oauth client of the service
|
||||
- governed by the service's `oauth_client_allowed_scopes` configuration
|
||||
|
||||
This token enables the service to act _on behalf of_ the user.
|
||||
|
||||
When an oauthenticated service makes a request to the Hub (or other Hub-authenticated service), it has two credentials available to authenticate the request:
|
||||
|
||||
- the service's own `api_token`, which acts _as_ the service,
|
||||
and is governed by the service's own role assignments.
|
||||
- the user's oauth token issued to the service during the oauth flow,
|
||||
which acts _as_ the user.
|
||||
|
||||
Choosing which one to use depends on "who" should be considered taking the action represented by the request.
|
||||
|
||||
A service's own permissions governs how it can act without any involvement of a user.
|
||||
The service's `oauth_client_allowed_scopes` configuration allows individual users to _delegate_ permission for the service to act on their behalf.
|
||||
This allows services to have little to no permissions of their own,
|
||||
but allow users to take actions _via_ the service,
|
||||
using their own credentials.
|
||||
|
||||
An example of such a service would be a web application for instructors,
|
||||
presenting a dashboard of actions which can be taken for students in their courses.
|
||||
The service would need no permission to do anything with the JupyterHub API on its own,
|
||||
but it could employ the user's oauth credentials to list users,
|
||||
manage student servers, etc.
|
||||
|
||||
This service might look like:
|
||||
|
||||
```python
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
"name": "grader-dashboard",
|
||||
"command": ["python3", "/path/to/grader-dashboard"],
|
||||
"url": "http://127.0.0.1:12345",
|
||||
"oauth_client_allowed_scopes": [
|
||||
"list:users",
|
||||
"read:users",
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "grader",
|
||||
"scopes": [
|
||||
"list:users!group=class-a",
|
||||
"read:users!group=class-a",
|
||||
"servers!group=class-a",
|
||||
"access:servers!group=class-a",
|
||||
"access:services",
|
||||
],
|
||||
"groups": ["graders"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
In this example, the `grader-dashboard` service does not have permission to take any actions with the Hub API on its own because it has not been assigned any role.
|
||||
But when a grader accesses the service,
|
||||
the dashboard will have a token with permission to list and read information about any users that the grader can access.
|
||||
The dashboard will _not_ have permission to do additional things as the grader.
|
||||
|
||||
The dashboard will be able to:
|
||||
|
||||
- list users in class A (`list:users!group=class-a`)
|
||||
- read information about users in class A (`read:users!group=class-a`)
|
||||
|
||||
The dashboard will _not_ be able to:
|
||||
|
||||
- start, stop, or access user servers (`servers`, `access:servers`), even though the grader has this permission (it's not in `oauth_client_allowed_scopes`)
|
||||
- take any action without the grader granting permission via oauth
|
||||
|
||||
## Adding or removing services at runtime
|
||||
|
||||
Only externally-managed services can be added at runtime by using JupyterHub’s REST API.
|
||||
|
||||
### Add a new service
|
||||
|
||||
To add a new service, send a POST request to this endpoint
|
||||
|
||||
```
|
||||
POST /hub/api/services/:servicename
|
||||
```
|
||||
|
||||
**Required scope: `admin:services`**
|
||||
|
||||
**Payload**: The payload should contain the definition of the service to be created. The endpoint supports the same properties as externally-managed services defined in the config file.
|
||||
|
||||
**Possible responses**
|
||||
|
||||
- `201 Created`: The service and related objects are created (and started in case of a Hub-managed one) successfully.
|
||||
- `400 Bad Request`: The payload is invalid or JupyterHub can not create the service.
|
||||
- `409 Conflict`: The service with the same name already exists.
|
||||
|
||||
### Remove an existing service
|
||||
|
||||
To remove an existing service, send a DELETE request to this endpoint
|
||||
|
||||
```
|
||||
DELETE /hub/api/services/:servicename
|
||||
```
|
||||
|
||||
**Required scope: `admin:services`**
|
||||
|
||||
**Payload**: `None`
|
||||
|
||||
**Possible responses**
|
||||
|
||||
- `200 OK`: The service and related objects are removed (and stopped in case of a Hub-managed one) successfully.
|
||||
- `400 Bad Request`: JupyterHub can not remove the service.
|
||||
- `404 Not Found`: The requested service does not exist.
|
||||
- `405 Not Allowed`: The requested service is created from the config file, it can not be removed at runtime.
|
||||
|
||||
## Writing your own Services
|
||||
|
||||
When writing your own services, you have a few decisions to make (in addition
|
||||
@@ -237,16 +385,14 @@ There are two levels of authentication with the Hub:
|
||||
This should be used for any service that serves pages that should be visited with a browser.
|
||||
|
||||
To use HubAuth, you must set the `.api_token` instance variable. This can be
|
||||
done either programmatically when constructing the class, or via the
|
||||
done via the HubAuth constructor, direct assignment to a HubAuth object, or via the
|
||||
`JUPYTERHUB_API_TOKEN` environment variable. A number of the examples in the
|
||||
root of the jupyterhub git repository set the `JUPYTERHUB_API_TOKEN` variable
|
||||
so consider having a look at those for futher reading
|
||||
so consider having a look at those for further reading
|
||||
([cull-idle](https://github.com/jupyterhub/jupyterhub/tree/master/examples/cull-idle),
|
||||
[external-oauth](https://github.com/jupyterhub/jupyterhub/tree/master/examples/external-oauth),
|
||||
[service-notebook](https://github.com/jupyterhub/jupyterhub/tree/master/examples/service-notebook)
|
||||
and [service-whoiami](https://github.com/jupyterhub/jupyterhub/tree/master/examples/service-whoami))
|
||||
|
||||
(TODO: Where is this API TOKen set?)
|
||||
and [service-whoami](https://github.com/jupyterhub/jupyterhub/tree/master/examples/service-whoami))
|
||||
|
||||
Most of the logic for authentication implementation is found in the
|
||||
{meth}`.HubAuth.user_for_token` methods,
|
||||
@@ -299,7 +445,7 @@ for more details.
|
||||
### Authenticating tornado services with JupyterHub
|
||||
|
||||
Since most Jupyter services are written with tornado,
|
||||
we include a mixin class, [`HubOAuthenticated`][huboauthenticated],
|
||||
we include a mixin class, {class}`.HubOAuthenticated`,
|
||||
for quickly authenticating your own tornado services with JupyterHub.
|
||||
|
||||
Tornado's {py:func}`~.tornado.web.authenticated` decorator calls a Handler's {py:meth}`~.tornado.web.RequestHandler.get_current_user`
|
||||
|
@@ -31,6 +31,7 @@ Some examples include:
|
||||
- [SSHSpawner](https://github.com/NERSC/sshspawner) to spawn notebooks
|
||||
on a remote server using SSH
|
||||
- [KubeSpawner](https://github.com/jupyterhub/kubespawner) to spawn notebook servers on kubernetes cluster.
|
||||
- [NomadSpawner](https://github.com/mxab/jupyterhub-nomad-spawner) to spawn a notebook server as a Nomad job inside HashiCorp's Nomad cluster
|
||||
|
||||
## Spawner control methods
|
||||
|
||||
|
@@ -22,6 +22,21 @@ started.
|
||||
If this configuration value is not set, then **all authenticated users will be allowed into your hub**.
|
||||
```
|
||||
|
||||
## One Time Passwords ( request_otp )
|
||||
|
||||
By setting `request_otp` to true, the login screen will show and additional password input field
|
||||
to accept an OTP:
|
||||
|
||||
```python
|
||||
c.Authenticator.request_otp = True
|
||||
```
|
||||
|
||||
By default, the prompt label is `OTP:`, but this can be changed by setting `otp_prompt`:
|
||||
|
||||
```python
|
||||
c.Authenticator.otp_prompt = 'Google Authenticator:'
|
||||
```
|
||||
|
||||
## Configure admins (`admin_users`)
|
||||
|
||||
```{note}
|
||||
|
@@ -60,8 +60,9 @@ The essential pieces for using JupyterHub as an OAuth provider are:
|
||||
"name": "my-service",
|
||||
# the oauth client id of your service
|
||||
# must be unique but isn't private
|
||||
# can be randomly generated or hand-written
|
||||
"oauth_client_id": "abc123",
|
||||
# can be randomly generated or hand-written, but must
|
||||
# begin with service-
|
||||
"oauth_client_id": "service-abc123",
|
||||
# the API token and client secret of the service
|
||||
# should be generated securely,
|
||||
# e.g. via `openssl rand -hex 32`
|
||||
@@ -77,7 +78,7 @@ The essential pieces for using JupyterHub as an OAuth provider are:
|
||||
|
||||
The relevant OAuth URLs and keys for using JupyterHub as an OAuth provider are:
|
||||
|
||||
1. the client_id, used in oauth requests
|
||||
1. the client_id, used in oauth requests. This must begin with the characters `service-`
|
||||
2. the api token registered with jupyterhub is the client_secret for oauth requests
|
||||
3. oauth url of the Hub, which is "/hub/api/oauth2/authorize", e.g. `https://myhub.horse/hub/api/oauth2/authorize`
|
||||
4. a redirect handler to receive the authenticated response
|
||||
|
@@ -9,7 +9,6 @@ if not api_token:
|
||||
)
|
||||
|
||||
# tell JupyterHub to register the service as an external oauth client
|
||||
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'external-oauth',
|
||||
@@ -18,3 +17,26 @@ c.JupyterHub.services = [
|
||||
'oauth_redirect_uri': 'http://127.0.0.1:5555/oauth_callback',
|
||||
}
|
||||
]
|
||||
|
||||
# Grant all JupyterHub users ability to access services
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
'name': 'user',
|
||||
'description': 'Allow all users to access all services',
|
||||
'scopes': ['access:services', 'self'],
|
||||
}
|
||||
]
|
||||
|
||||
# Boilerplate to make sure the example runs - this is not relevant
|
||||
# to external oauth services.
|
||||
|
||||
# Allow authentication with any username and any password
|
||||
from jupyterhub.auth import DummyAuthenticator
|
||||
|
||||
c.JupyterHub.authenticator_class = DummyAuthenticator
|
||||
|
||||
# Optionally set a global password that all users must use
|
||||
# c.DummyAuthenticator.password = "your_password"
|
||||
|
||||
# only listen on localhost for testing.
|
||||
c.JupyterHub.bind_url = 'http://127.0.0.1:8000'
|
||||
|
@@ -21,14 +21,6 @@ import "./server-dashboard.css";
|
||||
import { timeSince } from "../../util/timeSince";
|
||||
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
||||
|
||||
const AccessServerButton = ({ url }) => (
|
||||
<a href={url || ""}>
|
||||
<button className="btn btn-primary btn-xs" style={{ marginRight: 20 }}>
|
||||
Access Server
|
||||
</button>
|
||||
</a>
|
||||
);
|
||||
|
||||
const RowListItem = ({ text }) => (
|
||||
<span className="server-dashboard-row-list-item">{text}</span>
|
||||
);
|
||||
@@ -56,7 +48,6 @@ const ServerDashboard = (props) => {
|
||||
|
||||
var [errorAlert, setErrorAlert] = useState(null);
|
||||
var [sortMethod, setSortMethod] = useState(null);
|
||||
var [disabledButtons, setDisabledButtons] = useState({});
|
||||
var [collapseStates, setCollapseStates] = useState({});
|
||||
|
||||
var user_data = useSelector((state) => state.user_data),
|
||||
@@ -128,15 +119,15 @@ const ServerDashboard = (props) => {
|
||||
user_data = sortMethod(user_data);
|
||||
}
|
||||
|
||||
const StopServerButton = ({ serverName, userName }) => {
|
||||
const ServerButton = ({ server, user, action, name, extraClass }) => {
|
||||
var [isDisabled, setIsDisabled] = useState(false);
|
||||
return (
|
||||
<button
|
||||
className="btn btn-danger btn-xs stop-button"
|
||||
disabled={isDisabled}
|
||||
className={`btn btn-xs ${extraClass}`}
|
||||
disabled={isDisabled || server.pending}
|
||||
onClick={() => {
|
||||
setIsDisabled(true);
|
||||
stopServer(userName, serverName)
|
||||
action(user.name, server.name)
|
||||
.then((res) => {
|
||||
if (res.status < 300) {
|
||||
updateUsers(...slice)
|
||||
@@ -152,103 +143,87 @@ const ServerDashboard = (props) => {
|
||||
setErrorAlert(`Failed to update users list.`);
|
||||
});
|
||||
} else {
|
||||
setErrorAlert(`Failed to stop server.`);
|
||||
setErrorAlert(`Failed to ${name.toLowerCase()}.`);
|
||||
setIsDisabled(false);
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorAlert(`Failed to stop server.`);
|
||||
setErrorAlert(`Failed to ${name.toLowerCase()}.`);
|
||||
setIsDisabled(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Stop Server
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteServerButton = ({ serverName, userName }) => {
|
||||
if (serverName === "") {
|
||||
const StopServerButton = ({ server, user }) => {
|
||||
if (!server.ready) {
|
||||
return null;
|
||||
}
|
||||
return ServerButton({
|
||||
server,
|
||||
user,
|
||||
action: stopServer,
|
||||
name: "Stop Server",
|
||||
extraClass: "btn-danger stop-button",
|
||||
});
|
||||
};
|
||||
const DeleteServerButton = ({ server, user }) => {
|
||||
if (server.name === "") {
|
||||
// It's not possible to delete unnamed servers
|
||||
return null;
|
||||
}
|
||||
if (server.ready || server.pending) {
|
||||
return null;
|
||||
}
|
||||
return ServerButton({
|
||||
server,
|
||||
user,
|
||||
action: deleteServer,
|
||||
name: "Delete Server",
|
||||
extraClass: "btn-danger stop-button",
|
||||
});
|
||||
};
|
||||
|
||||
const StartServerButton = ({ server, user }) => {
|
||||
if (server.ready) {
|
||||
return null;
|
||||
}
|
||||
return ServerButton({
|
||||
server,
|
||||
user,
|
||||
action: startServer,
|
||||
name: server.pending ? "Server is pending" : "Start Server",
|
||||
extraClass: "btn-success start-button",
|
||||
});
|
||||
};
|
||||
|
||||
const SpawnPageButton = ({ server, user }) => {
|
||||
if (server.ready) {
|
||||
return null;
|
||||
}
|
||||
var [isDisabled, setIsDisabled] = useState(false);
|
||||
return (
|
||||
<button
|
||||
className="btn btn-danger btn-xs stop-button"
|
||||
// It's not possible to delete unnamed servers
|
||||
disabled={isDisabled}
|
||||
onClick={() => {
|
||||
setIsDisabled(true);
|
||||
deleteServer(userName, serverName)
|
||||
.then((res) => {
|
||||
if (res.status < 300) {
|
||||
updateUsers(...slice)
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(
|
||||
data.items,
|
||||
data._pagination,
|
||||
name_filter,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsDisabled(false);
|
||||
setErrorAlert(`Failed to update users list.`);
|
||||
});
|
||||
} else {
|
||||
setErrorAlert(`Failed to delete server.`);
|
||||
setIsDisabled(false);
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorAlert(`Failed to delete server.`);
|
||||
setIsDisabled(false);
|
||||
});
|
||||
}}
|
||||
<a
|
||||
href={`${base_url}spawn/${user.name}${
|
||||
server.name ? "/" + server.name : ""
|
||||
}`}
|
||||
>
|
||||
Delete Server
|
||||
</button>
|
||||
<button className="btn btn-light btn-xs">Spawn Page</button>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const StartServerButton = ({ serverName, userName }) => {
|
||||
var [isDisabled, setIsDisabled] = useState(false);
|
||||
const AccessServerButton = ({ server }) => {
|
||||
if (!server.ready) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className="btn btn-success btn-xs start-button"
|
||||
disabled={isDisabled}
|
||||
onClick={() => {
|
||||
setIsDisabled(true);
|
||||
startServer(userName, serverName)
|
||||
.then((res) => {
|
||||
if (res.status < 300) {
|
||||
updateUsers(...slice)
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(
|
||||
data.items,
|
||||
data._pagination,
|
||||
name_filter,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorAlert(`Failed to update users list.`);
|
||||
setIsDisabled(false);
|
||||
});
|
||||
} else {
|
||||
setErrorAlert(`Failed to start server.`);
|
||||
setIsDisabled(false);
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorAlert(`Failed to start server.`);
|
||||
setIsDisabled(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Start Server
|
||||
</button>
|
||||
<a href={server.url || ""}>
|
||||
<button className="btn btn-primary btn-xs">Access Server</button>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -358,43 +333,16 @@ const ServerDashboard = (props) => {
|
||||
<td data-testid="user-row-last-activity">
|
||||
{server.last_activity ? timeSince(server.last_activity) : "Never"}
|
||||
</td>
|
||||
<td data-testid="user-row-server-activity">
|
||||
{server.ready ? (
|
||||
// Stop Single-user server
|
||||
<>
|
||||
<StopServerButton serverName={server.name} userName={user.name} />
|
||||
<AccessServerButton url={server.url} />
|
||||
</>
|
||||
) : (
|
||||
// Start Single-user server
|
||||
<>
|
||||
<StartServerButton
|
||||
serverName={server.name}
|
||||
userName={user.name}
|
||||
style={{ marginRight: 20 }}
|
||||
/>
|
||||
<DeleteServerButton
|
||||
serverName={server.name}
|
||||
userName={user.name}
|
||||
/>
|
||||
<a
|
||||
href={`${base_url}spawn/${user.name}${
|
||||
server.name ? "/" + server.name : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className="btn btn-secondary btn-xs"
|
||||
style={{ marginRight: 20 }}
|
||||
>
|
||||
Spawn Page
|
||||
</button>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<td data-testid="user-row-server-activity" className="actions">
|
||||
<StartServerButton server={server} user={user} />
|
||||
<StopServerButton server={server} user={user} />
|
||||
<DeleteServerButton server={server} user={user} />
|
||||
<AccessServerButton server={server} />
|
||||
<SpawnPageButton server={server} user={user} />
|
||||
</td>
|
||||
<EditUserCell user={user} />
|
||||
</tr>,
|
||||
<tr>
|
||||
<tr key={`${userServerName}-detail`}>
|
||||
<td
|
||||
colSpan={6}
|
||||
style={{ padding: 0 }}
|
||||
@@ -514,9 +462,11 @@ const ServerDashboard = (props) => {
|
||||
<tbody>
|
||||
<tr className="noborder">
|
||||
<td>
|
||||
<Button variant="light" className="add-users-button">
|
||||
<Link to="/add-users">Add Users</Link>
|
||||
</Button>
|
||||
<Link to="/add-users">
|
||||
<Button variant="light" className="add-users-button">
|
||||
Add Users
|
||||
</Button>
|
||||
</Link>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
@@ -611,6 +561,7 @@ const ServerDashboard = (props) => {
|
||||
Shutdown Hub
|
||||
</Button>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{servers.flatMap(([user, server]) => serverRow(user, server))}
|
||||
</tbody>
|
||||
|
@@ -2,7 +2,13 @@ import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { render, screen, fireEvent, getByText } from "@testing-library/react";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
getByText,
|
||||
getAllByRole,
|
||||
} from "@testing-library/react";
|
||||
import { HashRouter, Switch } from "react-router-dom";
|
||||
import { Provider, useSelector } from "react-redux";
|
||||
import { createStore } from "redux";
|
||||
@@ -697,3 +703,59 @@ test("Server delete button exists for named servers", async () => {
|
||||
expect(delete_button).toBeEnabled();
|
||||
}
|
||||
});
|
||||
|
||||
test("Start server and confirm pending state", async () => {
|
||||
let spy = mockAsync();
|
||||
|
||||
let mockStartServer = jest.fn(() => {
|
||||
return new Promise(async (resolve) =>
|
||||
clock.setTimeout(() => {
|
||||
resolve({ status: 200 });
|
||||
}, 100),
|
||||
);
|
||||
});
|
||||
|
||||
let mockUpdateUsers = jest.fn(() => Promise.resolve(mockAppState()));
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<Provider store={createStore(mockReducers, {})}>
|
||||
<HashRouter>
|
||||
<Switch>
|
||||
<ServerDashboard
|
||||
updateUsers={mockUpdateUsers}
|
||||
shutdownHub={spy}
|
||||
startServer={mockStartServer}
|
||||
stopServer={spy}
|
||||
startAll={spy}
|
||||
stopAll={spy}
|
||||
/>
|
||||
</Switch>
|
||||
</HashRouter>
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
|
||||
let actions = screen.getAllByTestId("user-row-server-activity")[1];
|
||||
let buttons = getAllByRole(actions, "button");
|
||||
|
||||
expect(buttons.length).toBe(2);
|
||||
expect(buttons[0].textContent).toBe("Start Server");
|
||||
expect(buttons[1].textContent).toBe("Spawn Page");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(buttons[0]);
|
||||
});
|
||||
expect(mockUpdateUsers.mock.calls).toHaveLength(1);
|
||||
|
||||
expect(buttons.length).toBe(2);
|
||||
expect(buttons[0].textContent).toBe("Start Server");
|
||||
expect(buttons[0]).toBeDisabled();
|
||||
expect(buttons[1].textContent).toBe("Spawn Page");
|
||||
expect(buttons[1]).toBeEnabled();
|
||||
|
||||
await act(async () => {
|
||||
await clock.tick(100);
|
||||
});
|
||||
expect(mockUpdateUsers.mock.calls).toHaveLength(2);
|
||||
});
|
||||
|
@@ -7,7 +7,7 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.server-dashboard-container .add-users-button {
|
||||
.server-dashboard-container .btn-light {
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
@@ -38,3 +38,11 @@ tr.noborder > td {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.table > tbody > tr.user-row > td {
|
||||
vertical-align: inherit;
|
||||
}
|
||||
|
||||
.user-row .actions > * {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
7
jsx/testing/group.json
Normal file
7
jsx/testing/group.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"items": [
|
||||
{ "kind": "group", "name": "testgroup", "users": [] },
|
||||
{ "kind": "group", "name": "testgroup2", "users": ["foo", "bar"] }
|
||||
],
|
||||
"_pagination": { "offset": 0, "limit": 50, "total": 2, "next": null }
|
||||
}
|
21
jsx/testing/index.html
Normal file
21
jsx/testing/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<title>JupyterHub</title>
|
||||
<meta http-equiv="X-UA-Compatible" content="chrome=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<link rel="stylesheet" href="/static/css/style.min.css" type="text/css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="react-admin-hook">
|
||||
<script id="jupyterhub-admin-config">
|
||||
window.api_page_limit = parseInt("50");
|
||||
window.base_url = "/";
|
||||
</script>
|
||||
<script src="admin-react.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
142
jsx/testing/user.json
Normal file
142
jsx/testing/user.json
Normal file
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"last_activity": "2022-08-04T23:01:40.770831Z",
|
||||
"groups": [],
|
||||
"created": "2022-08-04T23:01:15.074531Z",
|
||||
"roles": ["user"],
|
||||
"auth_state": null,
|
||||
"pending": null,
|
||||
"kind": "user",
|
||||
"server": null,
|
||||
"name": "userA",
|
||||
"servers": {
|
||||
"": {
|
||||
"name": "",
|
||||
"last_activity": "2022-08-04T23:01:40.770831Z",
|
||||
"started": null,
|
||||
"pending": null,
|
||||
"ready": false,
|
||||
"stopped": true,
|
||||
"url": "/user/usera/",
|
||||
"user_options": null,
|
||||
"progress_url": "/hub/api/users/usera/server/progress",
|
||||
"state": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"last_activity": "2022-08-05T16:43:44.442068Z",
|
||||
"groups": [],
|
||||
"admin": true,
|
||||
"created": "2022-08-04T23:01:27.819148Z",
|
||||
"roles": ["user", "admin"],
|
||||
"auth_state": null,
|
||||
"pending": null,
|
||||
"kind": "user",
|
||||
"server": null,
|
||||
"name": "userB",
|
||||
"servers": {
|
||||
"": {
|
||||
"name": "",
|
||||
"last_activity": "2022-08-05T16:43:44.442068Z",
|
||||
"started": null,
|
||||
"pending": null,
|
||||
"ready": true,
|
||||
"stopped": false,
|
||||
"url": "/user/userb/",
|
||||
"user_options": null,
|
||||
"progress_url": "/hub/api/users/userb/server/progress",
|
||||
"state": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"last_activity": "2022-08-05T16:43:44.442068Z",
|
||||
"groups": [],
|
||||
"created": "2022-08-04T23:01:27.819148Z",
|
||||
"roles": ["user"],
|
||||
"auth_state": null,
|
||||
"pending": "spawn",
|
||||
"kind": "user",
|
||||
"server": null,
|
||||
"name": "userC",
|
||||
"servers": {
|
||||
"": {
|
||||
"last_activity": "2023-06-11T16:22:02.228468Z",
|
||||
"name": "",
|
||||
"pending": "spawn",
|
||||
"progress_url": "/hub/api/users/userc/server/progress",
|
||||
"ready": false,
|
||||
"started": "2023-06-11T16:22:02.228468Z",
|
||||
"state": { "pid": 68137 },
|
||||
"stopped": false,
|
||||
"url": "/user/userc/",
|
||||
"user_options": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"last_activity": "2023-06-11T15:19:49.786502Z",
|
||||
"groups": [],
|
||||
"created": "2023-05-14T09:05:48.996574Z",
|
||||
"roles": ["user"],
|
||||
"auth_state": null,
|
||||
"pending": null,
|
||||
"kind": "user",
|
||||
"server": "/user/userD/",
|
||||
"name": "userD",
|
||||
"servers": {
|
||||
"": {
|
||||
"name": "",
|
||||
"last_activity": "2023-06-11T13:39:27.017000Z",
|
||||
"started": "2023-06-11T13:39:24.679829Z",
|
||||
"pending": null,
|
||||
"ready": true,
|
||||
"stopped": false,
|
||||
"url": "/user/userd/",
|
||||
"user_options": {},
|
||||
"progress_url": "/hub/api/users/userd/server/progress",
|
||||
"state": { "pid": 41517 }
|
||||
},
|
||||
"serverA": {
|
||||
"name": "serverA",
|
||||
"last_activity": "2023-05-14T13:59:06.931642Z",
|
||||
"started": null,
|
||||
"pending": null,
|
||||
"ready": false,
|
||||
"stopped": true,
|
||||
"url": "/user/userd/servera/",
|
||||
"user_options": null,
|
||||
"progress_url": "/hub/api/users/userd/servers/servera/progress",
|
||||
"state": {}
|
||||
},
|
||||
"serverB": {
|
||||
"last_activity": "2023-06-11T16:22:02.228468Z",
|
||||
"name": "serverB",
|
||||
"pending": "spawn",
|
||||
"progress_url": "/hub/api/users/userb/servers/serverb/progress",
|
||||
"ready": false,
|
||||
"started": "2023-06-11T16:22:02.228468Z",
|
||||
"state": { "pid": 68137 },
|
||||
"stopped": false,
|
||||
"url": "/user/userd/serverb/",
|
||||
"user_options": {}
|
||||
},
|
||||
"serverC": {
|
||||
"name": "serverC",
|
||||
"last_activity": "2023-05-14T13:59:06.931642Z",
|
||||
"started": null,
|
||||
"pending": null,
|
||||
"ready": true,
|
||||
"stopped": false,
|
||||
"url": "/user/userd/serverc/",
|
||||
"user_options": null,
|
||||
"progress_url": "/hub/api/users/userd/servers/serverc/progress",
|
||||
"state": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"_pagination": { "offset": 0, "limit": 50, "total": 4, "next": null }
|
||||
}
|
@@ -1,5 +1,7 @@
|
||||
const webpack = require("webpack");
|
||||
const path = require("path");
|
||||
const user_json = require("./testing/user.json");
|
||||
const group_json = require("./testing/group.json");
|
||||
|
||||
module.exports = {
|
||||
entry: path.resolve(__dirname, "src", "App.jsx"),
|
||||
@@ -33,31 +35,21 @@ module.exports = {
|
||||
},
|
||||
plugins: [new webpack.HotModuleReplacementPlugin()],
|
||||
devServer: {
|
||||
static: {
|
||||
directory: path.resolve(__dirname, "build"),
|
||||
client: {
|
||||
overlay: false,
|
||||
},
|
||||
static: ["build", "testing", "../share/jupyterhub"],
|
||||
port: 9000,
|
||||
onBeforeSetupMiddleware: (devServer) => {
|
||||
const app = devServer.app;
|
||||
|
||||
var user_data = JSON.parse(
|
||||
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]',
|
||||
);
|
||||
var group_data = JSON.parse(
|
||||
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]',
|
||||
);
|
||||
|
||||
// get user_data
|
||||
app.get("/hub/api/users", (req, res) => {
|
||||
res
|
||||
.set("Content-Type", "application/json")
|
||||
.send(JSON.stringify(user_data));
|
||||
res.set("Content-Type", "application/json").send(user_json);
|
||||
});
|
||||
// get group_data
|
||||
app.get("/hub/api/groups", (req, res) => {
|
||||
res
|
||||
.set("Content-Type", "application/json")
|
||||
.send(JSON.stringify(group_data));
|
||||
res.set("Content-Type", "application/json").send(group_json);
|
||||
});
|
||||
// add users to group
|
||||
app.post("/hub/api/groups/*/users", (req, res) => {
|
||||
|
16077
jsx/yarn.lock
16077
jsx/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
# version_info updated by running `tbump`
|
||||
version_info = (4, 1, 0, "", "dev")
|
||||
version_info = (5, 0, 0, "", "dev")
|
||||
|
||||
# pep 440 version: no dot before beta/rc, but before .dev
|
||||
# 0.1.0rc1
|
||||
|
@@ -0,0 +1,51 @@
|
||||
"""Add from_config column to the services table
|
||||
|
||||
Revision ID: 3c2384c5aae1
|
||||
Revises: 0eee8c825d24
|
||||
Create Date: 2023-02-27 16:22:26.196231
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '3c2384c5aae1'
|
||||
down_revision = '0eee8c825d24'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from jupyterhub.orm import JSONDict, JSONList
|
||||
|
||||
COL_DATA = [
|
||||
{'name': 'url', 'type': sa.Unicode(length=2047)},
|
||||
{'name': 'oauth_client_allowed_scopes', 'type': JSONDict()},
|
||||
{'name': 'info', 'type': JSONDict()},
|
||||
{'name': 'display', 'type': sa.Boolean},
|
||||
{'name': 'oauth_no_confirm', 'type': sa.Boolean},
|
||||
{'name': 'command', 'type': JSONList()},
|
||||
{'name': 'cwd', 'type': sa.Unicode(length=2047)},
|
||||
{'name': 'environment', 'type': JSONDict()},
|
||||
{'name': 'user', 'type': sa.Unicode(255)},
|
||||
]
|
||||
|
||||
|
||||
def upgrade():
|
||||
engine = op.get_bind().engine
|
||||
tables = sa.inspect(engine).get_table_names()
|
||||
if 'services' in tables:
|
||||
op.add_column(
|
||||
'services',
|
||||
sa.Column('from_config', sa.Boolean, default=True),
|
||||
)
|
||||
op.execute('UPDATE services SET from_config = true')
|
||||
for item in COL_DATA:
|
||||
op.add_column(
|
||||
'services',
|
||||
sa.Column(item['name'], item['type'], nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('services', sa.Column('from_config'))
|
||||
for item in COL_DATA:
|
||||
op.drop_column('services', sa.Column(item['name']))
|
@@ -13,16 +13,37 @@ depends_on = None
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import Column, ForeignKey, Table
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy import Column, ForeignKey, Table, text
|
||||
from sqlalchemy.orm import raiseload, relationship, selectinload
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from jupyterhub import orm, roles, scopes
|
||||
from jupyterhub import orm, roles
|
||||
|
||||
|
||||
def access_scopes(oauth_client: orm.OAuthClient, db: Session):
|
||||
"""Return scope(s) required to access an oauth client
|
||||
This is a clone of `scopes.access_scopes` without using
|
||||
the `orm.Service`
|
||||
"""
|
||||
scopes = set()
|
||||
if oauth_client.identifier == "jupyterhub":
|
||||
return frozenset()
|
||||
spawner = oauth_client.spawner
|
||||
if spawner:
|
||||
scopes.add(f"access:servers!server={spawner.user.name}/{spawner.name}")
|
||||
else:
|
||||
statement = "SELECT * FROM services WHERE oauth_client_id = :identifier"
|
||||
service = db.execute(
|
||||
text(statement), {"identifier": oauth_client.identifier}
|
||||
).fetchall()
|
||||
if len(service) > 0:
|
||||
scopes.add(f"access:services!service={service[0].name}")
|
||||
|
||||
return frozenset(scopes)
|
||||
|
||||
|
||||
def upgrade():
|
||||
c = op.get_bind()
|
||||
|
||||
tables = sa.inspect(c.engine).get_table_names()
|
||||
|
||||
# oauth codes are short lived, no need to upgrade them
|
||||
@@ -62,7 +83,10 @@ def upgrade():
|
||||
|
||||
# tokens have roles, evaluate to scopes
|
||||
db = Session(bind=c)
|
||||
for token in db.query(orm.APIToken):
|
||||
|
||||
for token in db.query(orm.APIToken).options(
|
||||
selectinload(orm.APIToken.roles), raiseload("*")
|
||||
):
|
||||
token.scopes = list(roles.roles_to_scopes(token.roles))
|
||||
db.commit()
|
||||
# drop token-role relationship
|
||||
@@ -100,7 +124,7 @@ def upgrade():
|
||||
db = Session(bind=c)
|
||||
for oauth_client in db.query(orm.OAuthClient):
|
||||
allowed_scopes = set(roles.roles_to_scopes(oauth_client.allowed_roles))
|
||||
allowed_scopes.update(scopes.access_scopes(oauth_client))
|
||||
allowed_scopes.update(access_scopes(oauth_client, db))
|
||||
oauth_client.allowed_scopes = sorted(allowed_scopes)
|
||||
db.commit()
|
||||
# drop token-role relationship
|
||||
|
@@ -89,6 +89,11 @@ class APIHandler(BaseHandler):
|
||||
if not hasattr(self, '_jupyterhub_user'):
|
||||
# called too early to check if we're token-authenticated
|
||||
return
|
||||
if self._jupyterhub_user is None and 'Origin' not in self.request.headers:
|
||||
# don't raise xsrf if auth failed
|
||||
# don't apply this shortcut to actual cross-site requests, which have an 'Origin' header,
|
||||
# which would reveal if there are credentials present
|
||||
return
|
||||
if getattr(self, '_token_authenticated', False):
|
||||
# if token-authenticated, ignore XSRF
|
||||
return
|
||||
@@ -263,17 +268,43 @@ class APIHandler(BaseHandler):
|
||||
return self._include_stopped_servers
|
||||
|
||||
def user_model(self, user):
|
||||
"""Get the JSON model for a User object"""
|
||||
"""Get the JSON model for a User object
|
||||
|
||||
User may be either a high-level User wrapper,
|
||||
or a low-level orm.User.
|
||||
"""
|
||||
is_orm = False
|
||||
if isinstance(user, orm.User):
|
||||
user = self.users[user.id]
|
||||
if user.id in self.users:
|
||||
# if it's an 'active' user, it's in the users dict,
|
||||
# get the wrapper so we can get 'pending' state, etc.
|
||||
user = self.users[user.id]
|
||||
else:
|
||||
# don't create wrapper of low-level orm object
|
||||
is_orm = True
|
||||
|
||||
if is_orm:
|
||||
# if it's not in the users dict,
|
||||
# we know it has no running servers
|
||||
running = False
|
||||
spawners = {}
|
||||
if not is_orm:
|
||||
running = user.running
|
||||
spawners = user.spawners
|
||||
|
||||
include_stopped_servers = self.include_stopped_servers
|
||||
# TODO: we shouldn't fetch fields we can't read and then filter them out,
|
||||
# which may be wasted database queries
|
||||
# we should check and then fetch.
|
||||
# but that's tricky for e.g. server filters
|
||||
|
||||
model = {
|
||||
'kind': 'user',
|
||||
'name': user.name,
|
||||
'admin': user.admin,
|
||||
'roles': [r.name for r in user.roles],
|
||||
'groups': [g.name for g in user.groups],
|
||||
'server': user.url if user.running else None,
|
||||
'server': user.url if running else None,
|
||||
'pending': None,
|
||||
'created': isoformat(user.created),
|
||||
'last_activity': isoformat(user.last_activity),
|
||||
@@ -303,12 +334,12 @@ class APIHandler(BaseHandler):
|
||||
model, access_map, user, kind='user', keys=allowed_keys
|
||||
)
|
||||
if model:
|
||||
if '' in user.spawners and 'pending' in allowed_keys:
|
||||
model['pending'] = user.spawners[''].pending
|
||||
if '' in spawners and 'pending' in allowed_keys:
|
||||
model['pending'] = spawners[''].pending
|
||||
|
||||
servers = {}
|
||||
scope_filter = self.get_scope_filter('read:servers')
|
||||
for name, spawner in user.spawners.items():
|
||||
for name, spawner in spawners.items():
|
||||
# include 'active' servers, not just ready
|
||||
# (this includes pending events)
|
||||
if (spawner.active or include_stopped_servers) and scope_filter(
|
||||
@@ -390,6 +421,23 @@ class APIHandler(BaseHandler):
|
||||
|
||||
_group_model_types = {'name': str, 'users': list, 'roles': list}
|
||||
|
||||
_service_model_types = {
|
||||
'name': str,
|
||||
'admin': bool,
|
||||
'url': str,
|
||||
'oauth_client_allowed_scopes': list,
|
||||
'api_token': str,
|
||||
'info': dict,
|
||||
'display': bool,
|
||||
'oauth_no_confirm': bool,
|
||||
'command': list,
|
||||
'cwd': str,
|
||||
'environment': dict,
|
||||
'user': str,
|
||||
'oauth_client_id': str,
|
||||
'oauth_redirect_uri': str,
|
||||
}
|
||||
|
||||
def _check_model(self, model, model_types, name):
|
||||
"""Check a model provided by a REST API request
|
||||
|
||||
@@ -428,6 +476,15 @@ class APIHandler(BaseHandler):
|
||||
400, ("group names must be str, not %r", type(groupname))
|
||||
)
|
||||
|
||||
def _check_service_model(self, model):
|
||||
"""Check a request-provided service model from a REST API"""
|
||||
self._check_model(model, self._service_model_types, 'service')
|
||||
service_name = model.get('name')
|
||||
if not isinstance(service_name, str):
|
||||
raise web.HTTPError(
|
||||
400, ("Service name must be str, not %r", type(service_name))
|
||||
)
|
||||
|
||||
def get_api_pagination(self):
|
||||
default_limit = self.settings["api_page_default_limit"]
|
||||
max_limit = self.settings["api_page_max_limit"]
|
||||
@@ -475,7 +532,7 @@ class APIHandler(BaseHandler):
|
||||
if next_offset < total_count:
|
||||
# if there's a next page
|
||||
next_url_parsed = urlparse(self.request.full_url())
|
||||
query = parse_qs(next_url_parsed.query)
|
||||
query = parse_qs(next_url_parsed.query, keep_blank_values=True)
|
||||
query['offset'] = [next_offset]
|
||||
query['limit'] = [limit]
|
||||
next_url_parsed = next_url_parsed._replace(
|
||||
|
@@ -5,8 +5,14 @@ Currently GET-only, no actions can be taken to modify services.
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import json
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from ..scopes import Scope, needs_scope
|
||||
from tornado import web
|
||||
|
||||
from .. import orm
|
||||
from ..roles import get_default_roles
|
||||
from ..scopes import Scope, _check_token_scopes, needs_scope
|
||||
from ..services.service import Service
|
||||
from .base import APIHandler
|
||||
|
||||
|
||||
@@ -25,9 +31,171 @@ class ServiceListAPIHandler(APIHandler):
|
||||
class ServiceAPIHandler(APIHandler):
|
||||
@needs_scope('read:services', 'read:services:name', 'read:roles:services')
|
||||
def get(self, service_name):
|
||||
if service_name not in self.services:
|
||||
raise web.HTTPError(404, f"No such service: {service_name}")
|
||||
service = self.services[service_name]
|
||||
self.write(json.dumps(self.service_model(service)))
|
||||
|
||||
def _check_service_scopes(self, spec: dict):
|
||||
user = self.current_user
|
||||
requested_scopes = []
|
||||
if spec.get('admin'):
|
||||
default_roles = get_default_roles()
|
||||
admin_scopes = [
|
||||
role['scopes'] for role in default_roles if role['name'] == 'admin'
|
||||
]
|
||||
requested_scopes.extend(admin_scopes[0])
|
||||
|
||||
requested_client_scope = spec.get('oauth_client_allowed_scopes')
|
||||
if requested_client_scope is not None:
|
||||
requested_scopes.extend(requested_client_scope)
|
||||
if len(requested_scopes) > 0:
|
||||
try:
|
||||
_check_token_scopes(requested_scopes, user, None)
|
||||
except ValueError as e:
|
||||
raise web.HTTPError(400, str(e))
|
||||
|
||||
async def add_service(self, spec: dict) -> Service:
|
||||
"""Add a new service and related objects to the database
|
||||
|
||||
Args:
|
||||
spec (dict): The service specification
|
||||
|
||||
Raises:
|
||||
web.HTTPError: Raise if the service is not created
|
||||
|
||||
Returns:
|
||||
Service: Returns the service instance.
|
||||
|
||||
"""
|
||||
|
||||
self._check_service_model(spec)
|
||||
self._check_service_scopes(spec)
|
||||
|
||||
service_name = spec["name"]
|
||||
managed = bool(spec.get('command'))
|
||||
if managed:
|
||||
msg = f"Can not create managed service {service_name} at runtime"
|
||||
self.log.error(msg, exc_info=True)
|
||||
raise web.HTTPError(400, msg)
|
||||
try:
|
||||
new_service = self.service_from_spec(spec)
|
||||
except Exception:
|
||||
msg = f"Failed to create service {service_name}"
|
||||
self.log.error(msg, exc_info=True)
|
||||
raise web.HTTPError(400, msg)
|
||||
|
||||
if new_service is None:
|
||||
raise web.HTTPError(400, f"Failed to create service {service_name}")
|
||||
|
||||
if new_service.api_token:
|
||||
# Add api token to database
|
||||
await self.app._add_tokens(
|
||||
{new_service.api_token: new_service.name}, kind='service'
|
||||
)
|
||||
if new_service.url:
|
||||
# Start polling for external service
|
||||
service_status = await self.app.start_service(service_name, new_service)
|
||||
if not service_status:
|
||||
self.log.error(
|
||||
'Failed to start service %s',
|
||||
service_name,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if new_service.oauth_no_confirm:
|
||||
oauth_no_confirm_list = self.settings.get('oauth_no_confirm_list')
|
||||
msg = f"Allowing service {new_service.name} to complete OAuth without confirmation on an authorization web page"
|
||||
self.log.warning(msg)
|
||||
oauth_no_confirm_list.add(new_service.oauth_client_id)
|
||||
|
||||
return new_service
|
||||
|
||||
@needs_scope('admin:services')
|
||||
async def post(self, service_name: str):
|
||||
data = self.get_json_body()
|
||||
service, _ = self.find_service(service_name)
|
||||
|
||||
if service is not None:
|
||||
raise web.HTTPError(409, f"Service {service_name} already exists")
|
||||
|
||||
if not data or not isinstance(data, dict):
|
||||
raise web.HTTPError(400, "Invalid service data")
|
||||
|
||||
data['name'] = service_name
|
||||
new_service = await self.add_service(data)
|
||||
self.write(json.dumps(self.service_model(new_service)))
|
||||
self.set_status(201)
|
||||
|
||||
@needs_scope('admin:services')
|
||||
async def delete(self, service_name: str):
|
||||
service, orm_service = self.find_service(service_name)
|
||||
|
||||
if service is None:
|
||||
raise web.HTTPError(404, f"Service {service_name} does not exist")
|
||||
|
||||
if service.from_config:
|
||||
raise web.HTTPError(
|
||||
405, f"Service {service_name} is not modifiable at runtime"
|
||||
)
|
||||
try:
|
||||
await self.remove_service(service, orm_service)
|
||||
self.services.pop(service_name)
|
||||
except Exception:
|
||||
msg = f"Failed to remove service {service_name}"
|
||||
self.log.error(msg, exc_info=True)
|
||||
raise web.HTTPError(400, msg)
|
||||
|
||||
self.set_status(200)
|
||||
|
||||
async def remove_service(self, service: Service, orm_service: orm.Service) -> None:
|
||||
"""Remove a service and all related objects from the database.
|
||||
|
||||
Args:
|
||||
service (Service): the service object to be removed
|
||||
|
||||
orm_service (orm.Service): The `orm.Service` object linked
|
||||
with `service`
|
||||
"""
|
||||
if service.managed:
|
||||
await service.stop()
|
||||
|
||||
if service.oauth_client:
|
||||
self.oauth_provider.remove_client(service.oauth_client_id)
|
||||
|
||||
if orm_service._server_id is not None:
|
||||
orm_server = (
|
||||
self.db.query(orm.Server).filter_by(id=orm_service._server_id).first()
|
||||
)
|
||||
if orm_server is not None:
|
||||
self.db.delete(orm_server)
|
||||
|
||||
if service.oauth_no_confirm:
|
||||
oauth_no_confirm_list = self.settings.get('oauth_no_confirm_list')
|
||||
oauth_no_confirm_list.discard(service.oauth_client_id)
|
||||
|
||||
self.db.delete(orm_service)
|
||||
self.db.commit()
|
||||
|
||||
def service_from_spec(self, spec) -> Optional[Service]:
|
||||
"""Create service from api request"""
|
||||
service = self.app.service_from_spec(spec, from_config=False)
|
||||
self.db.commit()
|
||||
return service
|
||||
|
||||
def find_service(
|
||||
self, name: str
|
||||
) -> Tuple[Optional[Service], Optional[orm.Service]]:
|
||||
"""Get a service by name
|
||||
return None if no such service
|
||||
"""
|
||||
orm_service = orm.Service.find(db=self.db, name=name)
|
||||
if orm_service is not None:
|
||||
service = self.services.get(name)
|
||||
return service, orm_service
|
||||
|
||||
return (None, None)
|
||||
|
||||
|
||||
default_handlers = [
|
||||
(r"/api/services", ServiceListAPIHandler),
|
||||
|
@@ -2,12 +2,14 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from async_generator import aclosing
|
||||
from dateutil.parser import parse as parse_date
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
from tornado import web
|
||||
from tornado.iostream import StreamClosedError
|
||||
|
||||
@@ -89,6 +91,10 @@ class UserListAPIHandler(APIHandler):
|
||||
# post_filter
|
||||
post_filter = None
|
||||
|
||||
# starting query
|
||||
# fetch users and groups, which will be used for filters
|
||||
query = self.db.query(orm.User).outerjoin(orm.Group, orm.User.groups)
|
||||
|
||||
if state_filter in {"active", "ready"}:
|
||||
# only get users with active servers
|
||||
# an 'active' Spawner has a server record in the database
|
||||
@@ -96,9 +102,9 @@ class UserListAPIHandler(APIHandler):
|
||||
# it may still be in a pending start/stop state.
|
||||
# join filters out users with no Spawners
|
||||
query = (
|
||||
self.db.query(orm.User)
|
||||
query
|
||||
# join filters out any Users with no Spawners
|
||||
.join(orm.Spawner)
|
||||
.join(orm.Spawner, orm.User._orm_spawners)
|
||||
# this implicitly gets Users with *any* active server
|
||||
.filter(orm.Spawner.server != None)
|
||||
)
|
||||
@@ -113,9 +119,8 @@ class UserListAPIHandler(APIHandler):
|
||||
# this is the complement to the above query.
|
||||
# how expensive is this with lots of servers?
|
||||
query = (
|
||||
self.db.query(orm.User)
|
||||
.outerjoin(orm.Spawner)
|
||||
.outerjoin(orm.Server)
|
||||
query.outerjoin(orm.Spawner, orm.User._orm_spawners)
|
||||
.outerjoin(orm.Server, orm.Spawner.server)
|
||||
.group_by(orm.User.id)
|
||||
.having(func.count(orm.Server.id) == 0)
|
||||
)
|
||||
@@ -123,7 +128,16 @@ class UserListAPIHandler(APIHandler):
|
||||
raise web.HTTPError(400, "Unrecognized state filter: %r" % state_filter)
|
||||
else:
|
||||
# no filter, return all users
|
||||
query = self.db.query(orm.User)
|
||||
query = query.outerjoin(orm.Spawner, orm.User._orm_spawners).outerjoin(
|
||||
orm.Server, orm.Spawner.server
|
||||
)
|
||||
|
||||
# apply eager load options
|
||||
query = query.options(
|
||||
selectinload(orm.User.roles),
|
||||
selectinload(orm.User.groups),
|
||||
joinedload(orm.User._orm_spawners),
|
||||
)
|
||||
|
||||
sub_scope = self.parsed_scopes['list:users']
|
||||
if sub_scope != scopes.Scope.ALL:
|
||||
@@ -710,19 +724,32 @@ class SpawnProgressAPIHandler(APIHandler):
|
||||
# - spawner not running at all
|
||||
# - spawner failed
|
||||
# - spawner pending start (what we expect)
|
||||
url = url_path_join(user.url, url_escape_path(server_name), '/')
|
||||
ready_event = {
|
||||
'progress': 100,
|
||||
'ready': True,
|
||||
'message': f"Server ready at {url}",
|
||||
'html_message': 'Server ready at <a href="{0}">{0}</a>'.format(url),
|
||||
'url': url,
|
||||
}
|
||||
failed_event = {'progress': 100, 'failed': True, 'message': "Spawn failed"}
|
||||
|
||||
async def get_ready_event():
|
||||
url = url_path_join(user.url, url_escape_path(server_name), '/')
|
||||
ready_event = {
|
||||
'progress': 100,
|
||||
'ready': True,
|
||||
'message': f"Server ready at {url}",
|
||||
'html_message': 'Server ready at <a href="{0}">{0}</a>'.format(url),
|
||||
'url': url,
|
||||
}
|
||||
original_ready_event = ready_event.copy()
|
||||
if spawner.progress_ready_hook:
|
||||
try:
|
||||
ready_event = spawner.progress_ready_hook(spawner, ready_event)
|
||||
if inspect.isawaitable(ready_event):
|
||||
ready_event = await ready_event
|
||||
except Exception as e:
|
||||
self.log.exception(f"Error in ready_event hook: {e}")
|
||||
ready_event = original_ready_event
|
||||
return ready_event
|
||||
|
||||
if spawner.ready:
|
||||
# spawner already ready. Trigger progress-completion immediately
|
||||
self.log.info("Server %s is already started", spawner._log_name)
|
||||
ready_event = await get_ready_event()
|
||||
await self.send_event(ready_event)
|
||||
return
|
||||
|
||||
@@ -766,6 +793,7 @@ class SpawnProgressAPIHandler(APIHandler):
|
||||
if spawner.ready:
|
||||
# spawner is ready, signal completion and redirect
|
||||
self.log.info("Server %s is ready", spawner._log_name)
|
||||
ready_event = await get_ready_event()
|
||||
await self.send_event(ready_event)
|
||||
else:
|
||||
# what happened? Maybe spawn failed?
|
||||
|
@@ -21,6 +21,7 @@ from functools import partial
|
||||
from getpass import getuser
|
||||
from operator import itemgetter
|
||||
from textwrap import dedent
|
||||
from typing import Optional
|
||||
from urllib.parse import unquote, urlparse, urlunparse
|
||||
|
||||
if sys.version_info[:2] < (3, 3):
|
||||
@@ -32,6 +33,7 @@ from dateutil.parser import parse as parse_date
|
||||
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader
|
||||
from jupyter_telemetry.eventlog import EventLog
|
||||
from sqlalchemy.exc import OperationalError, SQLAlchemyError
|
||||
from sqlalchemy.orm import joinedload
|
||||
from tornado import gen, web
|
||||
from tornado.httpclient import AsyncHTTPClient
|
||||
from tornado.ioloop import IOLoop, PeriodicCallback
|
||||
@@ -91,6 +93,8 @@ from .utils import (
|
||||
maybe_future,
|
||||
print_ps_info,
|
||||
print_stacks,
|
||||
subdomain_hook_idna,
|
||||
subdomain_hook_legacy,
|
||||
url_path_join,
|
||||
)
|
||||
|
||||
@@ -769,6 +773,72 @@ class JupyterHub(Application):
|
||||
return ''
|
||||
return urlparse(self.subdomain_host).hostname
|
||||
|
||||
subdomain_hook = Union(
|
||||
[Callable(), Unicode()],
|
||||
default_value="idna",
|
||||
config=True,
|
||||
help="""
|
||||
Hook for constructing subdomains for users and services.
|
||||
Only used when `JupyterHub.subdomain_host` is set.
|
||||
|
||||
There are two predefined hooks, which can be selected by name:
|
||||
|
||||
- 'legacy' (deprecated)
|
||||
- 'idna' (default, more robust. No change for _most_ usernames)
|
||||
|
||||
Otherwise, should be a function which must not be async.
|
||||
A custom subdomain_hook should have the signature:
|
||||
|
||||
def subdomain_hook(name, domain, kind) -> str:
|
||||
...
|
||||
|
||||
and should return a unique, valid domain name for all usernames.
|
||||
|
||||
- `name` is the original name, which may need escaping to be safe as a domain name label
|
||||
- `domain` is the domain of the Hub itself
|
||||
- `kind` will be one of 'user' or 'service'
|
||||
|
||||
JupyterHub itself puts very little limit on usernames
|
||||
to accommodate a wide variety of Authenticators,
|
||||
but your identity provider is likely much more strict,
|
||||
allowing you to make assumptions about the name.
|
||||
|
||||
The default behavior is to have all services
|
||||
on a single `services.{domain}` subdomain,
|
||||
and each user on `{username}.{domain}`.
|
||||
This is the 'legacy' scheme,
|
||||
and doesn't work for all usernames.
|
||||
|
||||
The 'idna' scheme is a new scheme that should produce a valid domain name for any user,
|
||||
using IDNA encoding for unicode usernames, and a truncate-and-hash approach for
|
||||
any usernames that can't be easily encoded into a domain component.
|
||||
|
||||
.. versionadded:: 5.0
|
||||
""",
|
||||
)
|
||||
|
||||
@default("subdomain_hook")
|
||||
def _default_subdomain_hook(self):
|
||||
return subdomain_hook_idna
|
||||
|
||||
@validate("subdomain_hook")
|
||||
def _subdomain_hook(self, proposal):
|
||||
# shortcut `subdomain_hook = "idna"` config
|
||||
hook = proposal.value
|
||||
if hook == "idna":
|
||||
return subdomain_hook_idna
|
||||
if hook == "legacy":
|
||||
if self.subdomain_host:
|
||||
self.log.warning(
|
||||
"Using deprecated 'legacy' subdomain hook. JupyterHub.subdomain_hook = 'idna' is the new default, added in JupyterHub 5."
|
||||
)
|
||||
return subdomain_hook_legacy
|
||||
if not callable(hook):
|
||||
raise ValueError(
|
||||
f"subdomain_hook must be 'idna', 'legacy', or a callable, got {hook!r}"
|
||||
)
|
||||
return hook
|
||||
|
||||
logo_file = Unicode(
|
||||
'',
|
||||
help="Specify path to a logo image to override the Jupyter logo in the banner.",
|
||||
@@ -2073,21 +2143,20 @@ class JupyterHub(Application):
|
||||
|
||||
TOTAL_USERS.set(total_users)
|
||||
|
||||
async def _get_or_create_user(self, username):
|
||||
async def _get_or_create_user(self, username, hint):
|
||||
"""Create user if username is found in config but user does not exist"""
|
||||
if not (await maybe_future(self.authenticator.check_allowed(username, None))):
|
||||
raise ValueError(
|
||||
"Username %r is not in Authenticator.allowed_users" % username
|
||||
)
|
||||
user = orm.User.find(self.db, name=username)
|
||||
if user is None:
|
||||
if not self.authenticator.validate_username(username):
|
||||
raise ValueError("Username %r is not valid" % username)
|
||||
self.log.info(f"Creating user {username}")
|
||||
self.log.info(f"Creating user {username} found in {hint}")
|
||||
user = orm.User(name=username)
|
||||
self.db.add(user)
|
||||
roles.assign_default_roles(self.db, entity=user)
|
||||
self.db.commit()
|
||||
f = self.authenticator.add_user(user)
|
||||
if f:
|
||||
await maybe_future(f)
|
||||
return user
|
||||
|
||||
async def init_groups(self):
|
||||
@@ -2096,7 +2165,9 @@ class JupyterHub(Application):
|
||||
|
||||
if self.authenticator.manage_groups and self.load_groups:
|
||||
raise ValueError("Group management has been offloaded to the authenticator")
|
||||
|
||||
for name, contents in self.load_groups.items():
|
||||
self.log.debug("Loading group %s", name)
|
||||
group = orm.Group.find(db, name)
|
||||
|
||||
if group is None:
|
||||
@@ -2112,9 +2183,11 @@ class JupyterHub(Application):
|
||||
if 'users' in contents:
|
||||
for username in contents['users']:
|
||||
username = self.authenticator.normalize_username(username)
|
||||
user = await self._get_or_create_user(username)
|
||||
user = await self._get_or_create_user(
|
||||
username, hint=f"group: {name}"
|
||||
)
|
||||
if group not in user.groups:
|
||||
self.log.debug(f"Adding user {username} to group {name}")
|
||||
self.log.debug(f"Adding user {username} to group {name}")
|
||||
group.users.append(user)
|
||||
|
||||
if 'properties' in contents:
|
||||
@@ -2142,8 +2215,9 @@ class JupyterHub(Application):
|
||||
roles_with_new_permissions = []
|
||||
for role_spec in self.load_roles:
|
||||
role_name = role_spec['name']
|
||||
self.log.debug("Loading role %s", role_name)
|
||||
if role_name in default_roles_dict:
|
||||
self.log.debug(f"Overriding default role {role_name}")
|
||||
self.log.debug("Overriding default role %s", role_name)
|
||||
# merge custom role spec with default role spec when overriding
|
||||
# so the new role can be partially defined
|
||||
default_role_spec = default_roles_dict.pop(role_name)
|
||||
@@ -2230,34 +2304,33 @@ class JupyterHub(Application):
|
||||
for name in role_spec[kind]:
|
||||
if kind == 'users':
|
||||
name = self.authenticator.normalize_username(name)
|
||||
if not (
|
||||
await maybe_future(
|
||||
self.authenticator.check_allowed(name, None)
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
f"Username {name} is not in Authenticator.allowed_users"
|
||||
)
|
||||
Class = orm.get_class(kind)
|
||||
orm_obj = Class.find(db, name)
|
||||
if orm_obj is not None:
|
||||
orm_role_bearers.append(orm_obj)
|
||||
else:
|
||||
self.log.info(
|
||||
f"Found unexisting {kind} {name} in role definition {role_name}"
|
||||
)
|
||||
if kind == 'users':
|
||||
orm_obj = await self._get_or_create_user(name)
|
||||
orm_obj = await self._get_or_create_user(
|
||||
name, hint=f"role: {role_name}"
|
||||
)
|
||||
orm_role_bearers.append(orm_obj)
|
||||
elif kind == 'groups':
|
||||
self.log.info(
|
||||
f"Creating group {name} found in role: {role_name}"
|
||||
)
|
||||
group = orm.Group(name=name)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
orm_role_bearers.append(group)
|
||||
else:
|
||||
elif kind == "services":
|
||||
raise ValueError(
|
||||
f"{kind} {name} defined in config role definition {role_name} but not present in database"
|
||||
f"Found undefined service {name} in role {role_name}. Define it first in c.JupyterHub.services."
|
||||
)
|
||||
else:
|
||||
# this can't happen now, but keep the `else` in case we introduce a problem
|
||||
# in the declaration of `kinds` above
|
||||
raise ValueError(f"Unhandled role member kind: {kind}")
|
||||
|
||||
# Ensure all with admin role have admin flag
|
||||
if role_name == 'admin':
|
||||
orm_obj.admin = True
|
||||
@@ -2279,22 +2352,53 @@ class JupyterHub(Application):
|
||||
|
||||
db.commit()
|
||||
if self.authenticator.allowed_users:
|
||||
self.log.debug(
|
||||
f"Assigning {len(self.authenticator.allowed_users)} allowed_users to the user role"
|
||||
)
|
||||
allowed_users = db.query(orm.User).filter(
|
||||
user_role = orm.Role.find(db, "user")
|
||||
self.log.debug("Assigning allowed_users to the user role")
|
||||
# query only those that need the user role _and don't have it_
|
||||
needs_user_role = db.query(orm.User).filter(
|
||||
orm.User.name.in_(self.authenticator.allowed_users)
|
||||
& ~orm.User.roles.any(id=user_role.id)
|
||||
)
|
||||
for user in allowed_users:
|
||||
roles.grant_role(db, user, 'user')
|
||||
if self.log.isEnabledFor(logging.DEBUG):
|
||||
# filter on isEnabledFor to skip the extra `count()` query if we aren't going to log it
|
||||
self.log.debug(
|
||||
f"Assigning {needs_user_role.count()} allowed_users to the user role"
|
||||
)
|
||||
|
||||
for user in needs_user_role:
|
||||
roles.grant_role(db, user, user_role)
|
||||
|
||||
admin_role = orm.Role.find(db, 'admin')
|
||||
for kind in admin_role_objects:
|
||||
Class = orm.get_class(kind)
|
||||
for admin_obj in db.query(Class).filter_by(admin=True):
|
||||
|
||||
# sync obj.admin with admin role
|
||||
# query only those objects that do not match config
|
||||
# to avoid expensive query for no-op updates
|
||||
|
||||
# always: in admin role sets admin = True
|
||||
for is_admin in db.query(Class).filter(
|
||||
(Class.admin == False) & Class.roles.any(id=admin_role.id)
|
||||
):
|
||||
self.log.info(f"Setting admin=True on {is_admin}")
|
||||
is_admin.admin = True
|
||||
|
||||
# iterate over users with admin=True
|
||||
# who are not in the admin role.
|
||||
for not_admin_obj in db.query(Class).filter(
|
||||
(Class.admin == True) & ~Class.roles.any(id=admin_role.id)
|
||||
):
|
||||
if has_admin_role_spec[kind]:
|
||||
admin_obj.admin = admin_role in admin_obj.roles
|
||||
# role membership specified exactly in config,
|
||||
# already populated above.
|
||||
# make sure user.admin matches admin role
|
||||
# setting .admin=False for anyone no longer in admin role
|
||||
self.log.warning(f"Removing admin=True from {not_admin_obj}")
|
||||
not_admin_obj.admin = False
|
||||
else:
|
||||
roles.grant_role(db, admin_obj, 'admin')
|
||||
# no admin role membership declared,
|
||||
# populate admin role from admin attribute (the old way, only additive)
|
||||
roles.grant_role(db, not_admin_obj, admin_role)
|
||||
db.commit()
|
||||
# make sure that on hub upgrade, all users, services and tokens have at least one role (update with default)
|
||||
if getattr(self, '_rbac_upgrade', False):
|
||||
@@ -2317,20 +2421,12 @@ class JupyterHub(Application):
|
||||
for token, name in token_dict.items():
|
||||
if kind == 'user':
|
||||
name = self.authenticator.normalize_username(name)
|
||||
if not (
|
||||
await maybe_future(self.authenticator.check_allowed(name, None))
|
||||
):
|
||||
raise ValueError(
|
||||
"Token user name %r is not in Authenticator.allowed_users"
|
||||
% name
|
||||
)
|
||||
if not self.authenticator.validate_username(name):
|
||||
raise ValueError("Token user name %r is not valid" % name)
|
||||
if kind == 'service':
|
||||
if not any(service["name"] == name for service in self.services):
|
||||
if not any(service_name == name for service_name in self._service_map):
|
||||
self.log.warning(
|
||||
"Warning: service '%s' not in services, creating implicitly. It is recommended to register services using services list."
|
||||
% name
|
||||
f"service {name} not in services, creating implicitly. It is recommended to register services using services list."
|
||||
)
|
||||
orm_token = orm.APIToken.find(db, token)
|
||||
if orm_token is None:
|
||||
@@ -2391,127 +2487,234 @@ class JupyterHub(Application):
|
||||
)
|
||||
pc.start()
|
||||
|
||||
def init_services(self):
|
||||
self._service_map.clear()
|
||||
def service_from_orm(
|
||||
self,
|
||||
orm_service: orm.Service,
|
||||
) -> Service:
|
||||
"""Create the service instance and related objects from
|
||||
ORM data.
|
||||
|
||||
Args:
|
||||
orm_service (orm.Service): The `orm.Service` object
|
||||
|
||||
Returns:
|
||||
Service: the created service
|
||||
"""
|
||||
|
||||
name = orm_service.name
|
||||
if self.domain:
|
||||
domain = 'services.' + self.domain
|
||||
parsed = urlparse(self.subdomain_host)
|
||||
host = f'{parsed.scheme}://services.{parsed.netloc}'
|
||||
parsed_host = urlparse(self.subdomain_host)
|
||||
domain = self.subdomain_hook(name, self.domain, kind="service")
|
||||
host = f"{parsed_host.scheme}://{domain}"
|
||||
if parsed_host.port:
|
||||
host = f"{host}:{parsed_host.port}"
|
||||
else:
|
||||
domain = host = ''
|
||||
|
||||
for spec in self.services:
|
||||
if 'name' not in spec:
|
||||
raise ValueError('service spec must have a name: %r' % spec)
|
||||
name = spec['name']
|
||||
# get/create orm
|
||||
orm_service = orm.Service.find(self.db, name=name)
|
||||
if orm_service is None:
|
||||
# not found, create a new one
|
||||
orm_service = orm.Service(name=name)
|
||||
self.db.add(orm_service)
|
||||
if spec.get('admin', False):
|
||||
self.log.warning(
|
||||
f"Service {name} sets `admin: True`, which is deprecated in JupyterHub 2.0."
|
||||
" You can assign now assign roles via `JupyterHub.load_roles` configuration."
|
||||
" If you specify services in the admin role configuration, "
|
||||
"the Service admin flag will be ignored."
|
||||
service = Service(
|
||||
parent=self,
|
||||
app=self,
|
||||
base_url=self.base_url,
|
||||
db=self.db,
|
||||
orm=orm_service,
|
||||
roles=orm_service.roles,
|
||||
domain=domain,
|
||||
host=host,
|
||||
hub=self.hub,
|
||||
)
|
||||
traits = service.traits(input=True)
|
||||
for key, trait in traits.items():
|
||||
if not trait.metadata.get("in_db", True):
|
||||
continue
|
||||
orm_value = getattr(orm_service, key)
|
||||
if orm_value is not None:
|
||||
setattr(service, key, orm_value)
|
||||
|
||||
if orm_service.oauth_client is not None:
|
||||
service.oauth_client_id = orm_service.oauth_client.identifier
|
||||
service.oauth_redirect_uri = orm_service.oauth_client.redirect_uri
|
||||
|
||||
self._service_map[name] = service
|
||||
|
||||
return service
|
||||
|
||||
def service_from_spec(
|
||||
self,
|
||||
spec: Dict,
|
||||
from_config=True,
|
||||
) -> Optional[Service]:
|
||||
"""Create the service instance and related objects from
|
||||
config data.
|
||||
|
||||
Args:
|
||||
spec (Dict): The spec of service, defined in the config file.
|
||||
from_config (bool, optional): `True` if the service will be created
|
||||
from the config file, `False` if it is created from REST API.
|
||||
Defaults to `True`.
|
||||
|
||||
Returns:
|
||||
Optional[Service]: The created service
|
||||
"""
|
||||
|
||||
if 'name' not in spec:
|
||||
raise ValueError(f'service spec must have a name: {spec}')
|
||||
|
||||
name = spec['name']
|
||||
|
||||
if self.domain:
|
||||
parsed_host = urlparse(self.subdomain_host)
|
||||
domain = self.subdomain_hook(name, self.domain, kind="service")
|
||||
host = f"{parsed_host.scheme}://{domain}"
|
||||
if parsed_host.port:
|
||||
host = f"{host}:{parsed_host.port}"
|
||||
else:
|
||||
domain = host = ''
|
||||
|
||||
# get/create orm
|
||||
orm_service = orm.Service.find(self.db, name=name)
|
||||
if orm_service is None:
|
||||
# not found, create a new one
|
||||
orm_service = orm.Service(name=name, from_config=from_config)
|
||||
self.db.add(orm_service)
|
||||
if spec.get('admin', False):
|
||||
self.log.warning(
|
||||
f"Service {name} sets `admin: True`, which is deprecated in JupyterHub 2.0."
|
||||
" You can assign now assign roles via `JupyterHub.load_roles` configuration."
|
||||
" If you specify services in the admin role configuration, "
|
||||
"the Service admin flag will be ignored."
|
||||
)
|
||||
roles.update_roles(self.db, entity=orm_service, roles=['admin'])
|
||||
else:
|
||||
# Do nothing if the config file tries to modify a API-base service
|
||||
# or vice versa.
|
||||
if orm_service.from_config != from_config:
|
||||
if from_config:
|
||||
self.log.error(
|
||||
f"The service {name} from the config file is trying to modify a runtime-created service with the same name"
|
||||
)
|
||||
roles.update_roles(self.db, entity=orm_service, roles=['admin'])
|
||||
orm_service.admin = spec.get('admin', False)
|
||||
self.db.commit()
|
||||
service = Service(
|
||||
parent=self,
|
||||
app=self,
|
||||
base_url=self.base_url,
|
||||
db=self.db,
|
||||
orm=orm_service,
|
||||
roles=orm_service.roles,
|
||||
domain=domain,
|
||||
host=host,
|
||||
hub=self.hub,
|
||||
else:
|
||||
self.log.error(
|
||||
f"The runtime-created service {name} is trying to modify a config-based service with the same name"
|
||||
)
|
||||
return
|
||||
orm_service.admin = spec.get('admin', False)
|
||||
|
||||
self.db.commit()
|
||||
service = Service(
|
||||
parent=self,
|
||||
app=self,
|
||||
base_url=self.base_url,
|
||||
db=self.db,
|
||||
orm=orm_service,
|
||||
roles=orm_service.roles,
|
||||
domain=domain,
|
||||
host=host,
|
||||
hub=self.hub,
|
||||
)
|
||||
|
||||
traits = service.traits(input=True)
|
||||
for key, value in spec.items():
|
||||
trait = traits.get(key)
|
||||
if trait is None:
|
||||
raise AttributeError("No such service field: %s" % key)
|
||||
setattr(service, key, value)
|
||||
# also set the value on the orm object
|
||||
# unless it's marked as not in the db
|
||||
# (e.g. on the oauth object)
|
||||
if trait.metadata.get("in_db", True):
|
||||
setattr(orm_service, key, value)
|
||||
|
||||
if service.api_token:
|
||||
self.service_tokens[service.api_token] = service.name
|
||||
elif service.managed:
|
||||
# generate new token
|
||||
# TODO: revoke old tokens?
|
||||
service.api_token = service.orm.new_api_token(note="generated at startup")
|
||||
|
||||
if service.url:
|
||||
parsed = urlparse(service.url)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise ValueError(
|
||||
f"Unsupported scheme in URL for service {name}: {service.url}. Must be http[s]"
|
||||
)
|
||||
|
||||
port = None
|
||||
if parsed.port is not None:
|
||||
port = parsed.port
|
||||
elif parsed.scheme == 'http':
|
||||
port = 80
|
||||
elif parsed.scheme == 'https':
|
||||
port = 443
|
||||
|
||||
server = service.orm.server = orm.Server(
|
||||
proto=parsed.scheme,
|
||||
ip=parsed.hostname,
|
||||
port=port,
|
||||
cookie_name=service.oauth_client_id,
|
||||
base_url=service.prefix,
|
||||
)
|
||||
self.db.add(server)
|
||||
else:
|
||||
service.orm.server = None
|
||||
|
||||
traits = service.traits(input=True)
|
||||
for key, value in spec.items():
|
||||
if key not in traits:
|
||||
raise AttributeError("No such service field: %s" % key)
|
||||
setattr(service, key, value)
|
||||
|
||||
if service.api_token:
|
||||
self.service_tokens[service.api_token] = service.name
|
||||
elif service.managed:
|
||||
# generate new token
|
||||
# TODO: revoke old tokens?
|
||||
service.api_token = service.orm.new_api_token(
|
||||
note="generated at startup"
|
||||
)
|
||||
|
||||
if service.url:
|
||||
parsed = urlparse(service.url)
|
||||
if parsed.port is not None:
|
||||
port = parsed.port
|
||||
elif parsed.scheme == 'http':
|
||||
port = 80
|
||||
elif parsed.scheme == 'https':
|
||||
port = 443
|
||||
server = service.orm.server = orm.Server(
|
||||
proto=parsed.scheme,
|
||||
ip=parsed.hostname,
|
||||
port=port,
|
||||
cookie_name=service.oauth_client_id,
|
||||
base_url=service.prefix,
|
||||
)
|
||||
self.db.add(server)
|
||||
|
||||
else:
|
||||
service.orm.server = None
|
||||
|
||||
if service.oauth_available:
|
||||
allowed_scopes = set()
|
||||
if service.oauth_client_allowed_scopes:
|
||||
allowed_scopes.update(service.oauth_client_allowed_scopes)
|
||||
if service.oauth_roles:
|
||||
if not allowed_scopes:
|
||||
# DEPRECATED? It's still convenient and valid,
|
||||
# e.g. 'admin'
|
||||
allowed_roles = list(
|
||||
self.db.query(orm.Role).filter(
|
||||
orm.Role.name.in_(service.oauth_roles)
|
||||
)
|
||||
if service.oauth_available:
|
||||
allowed_scopes = set()
|
||||
if service.oauth_client_allowed_scopes:
|
||||
allowed_scopes.update(service.oauth_client_allowed_scopes)
|
||||
if service.oauth_roles:
|
||||
if not allowed_scopes:
|
||||
# DEPRECATED? It's still convenient and valid,
|
||||
# e.g. 'admin'
|
||||
allowed_roles = list(
|
||||
self.db.query(orm.Role).filter(
|
||||
orm.Role.name.in_(service.oauth_roles)
|
||||
)
|
||||
allowed_scopes.update(roles.roles_to_scopes(allowed_roles))
|
||||
else:
|
||||
self.log.warning(
|
||||
f"Ignoring oauth_roles for {service.name}: {service.oauth_roles},"
|
||||
f" using oauth_client_allowed_scopes={allowed_scopes}."
|
||||
)
|
||||
oauth_client = self.oauth_provider.add_client(
|
||||
client_id=service.oauth_client_id,
|
||||
client_secret=service.api_token,
|
||||
redirect_uri=service.oauth_redirect_uri,
|
||||
description="JupyterHub service %s" % service.name,
|
||||
)
|
||||
service.orm.oauth_client = oauth_client
|
||||
# add access-scopes, derived from OAuthClient itself
|
||||
allowed_scopes.update(scopes.access_scopes(oauth_client))
|
||||
oauth_client.allowed_scopes = sorted(allowed_scopes)
|
||||
)
|
||||
allowed_scopes.update(roles.roles_to_scopes(allowed_roles))
|
||||
else:
|
||||
self.log.warning(
|
||||
f"Ignoring oauth_roles for {service.name}: {service.oauth_roles},"
|
||||
f" using oauth_client_allowed_scopes={allowed_scopes}."
|
||||
)
|
||||
oauth_client = self.oauth_provider.add_client(
|
||||
client_id=service.oauth_client_id,
|
||||
client_secret=service.api_token,
|
||||
redirect_uri=service.oauth_redirect_uri,
|
||||
description="JupyterHub service %s" % service.name,
|
||||
)
|
||||
service.orm.oauth_client = oauth_client
|
||||
# add access-scopes, derived from OAuthClient itself
|
||||
allowed_scopes.update(scopes.access_scopes(oauth_client))
|
||||
oauth_client.allowed_scopes = sorted(allowed_scopes)
|
||||
else:
|
||||
if service.oauth_client:
|
||||
self.db.delete(service.oauth_client)
|
||||
|
||||
self._service_map[name] = service
|
||||
|
||||
return service
|
||||
|
||||
def init_services(self):
|
||||
self._service_map.clear()
|
||||
for spec in self.services:
|
||||
self.service_from_spec(spec, from_config=True)
|
||||
|
||||
for service_orm in self.db.query(orm.Service):
|
||||
if service_orm.from_config:
|
||||
# delete config-based services from db
|
||||
# that are not in current config file:
|
||||
if service_orm.name not in self._service_map:
|
||||
self.db.delete(service_orm)
|
||||
else:
|
||||
if service.oauth_client:
|
||||
self.db.delete(service.oauth_client)
|
||||
self.service_from_orm(service_orm)
|
||||
|
||||
self._service_map[name] = service
|
||||
|
||||
# delete services from db not in service config:
|
||||
for service in self.db.query(orm.Service):
|
||||
if service.name not in self._service_map:
|
||||
self.db.delete(service)
|
||||
self.db.commit()
|
||||
|
||||
async def check_services_health(self):
|
||||
"""Check connectivity of all services"""
|
||||
for name, service in self._service_map.items():
|
||||
if not service.url:
|
||||
# no URL to check, nothing to do
|
||||
continue
|
||||
try:
|
||||
await Server.from_orm(service.orm.server).wait_up(timeout=1, http=True)
|
||||
@@ -2628,19 +2831,22 @@ class JupyterHub(Application):
|
||||
# Server objects can be associated with either a Spawner or a Service,
|
||||
# we are only interested in the ones associated with a Spawner
|
||||
check_futures = []
|
||||
for orm_server in db.query(orm.Server):
|
||||
orm_spawner = orm_server.spawner
|
||||
if not orm_spawner:
|
||||
# check for orphaned Server rows
|
||||
# this shouldn't happen if we've got our sqlachemy right
|
||||
if not orm_server.service:
|
||||
self.log.warning("deleting orphaned server %s", orm_server)
|
||||
self.db.delete(orm_server)
|
||||
self.db.commit()
|
||||
continue
|
||||
|
||||
for orm_user, orm_spawner in (
|
||||
self.db.query(orm.User, orm.Spawner)
|
||||
# join filters out any Users with no Spawners
|
||||
.join(orm.Spawner, orm.User._orm_spawners)
|
||||
# this gets Users with *any* active server
|
||||
.filter(orm.Spawner.server != None)
|
||||
# pre-load relationships to avoid O(N active servers) queries
|
||||
.options(
|
||||
joinedload(orm.User._orm_spawners),
|
||||
joinedload(orm.Spawner.server),
|
||||
)
|
||||
):
|
||||
# instantiate Spawner wrapper and check if it's still alive
|
||||
# spawner should be running
|
||||
user = self.users[orm_spawner.user]
|
||||
user = self.users[orm_user]
|
||||
spawner = user.spawners[orm_spawner.name]
|
||||
self.log.debug("Loading state for %s from db", spawner._log_name)
|
||||
# signal that check is pending to avoid race conditions
|
||||
@@ -2689,7 +2895,6 @@ class JupyterHub(Application):
|
||||
for user in self.users.values():
|
||||
for spawner in user.spawners.values():
|
||||
oauth_client_ids.add(spawner.oauth_client_id)
|
||||
|
||||
for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)):
|
||||
if oauth_client.identifier not in oauth_client_ids:
|
||||
self.log.warning("Deleting OAuth client %s", oauth_client.identifier)
|
||||
@@ -2796,6 +3001,7 @@ class JupyterHub(Application):
|
||||
static_path=os.path.join(self.data_files_path, 'static'),
|
||||
static_url_prefix=url_path_join(self.hub.base_url, 'static/'),
|
||||
static_handler_class=CacheControlStaticFilesHandler,
|
||||
subdomain_hook=self.subdomain_hook,
|
||||
template_path=self.template_paths,
|
||||
template_vars=self.template_vars,
|
||||
jinja2_env=jinja_env,
|
||||
@@ -3132,6 +3338,72 @@ class JupyterHub(Application):
|
||||
|
||||
await self.proxy.check_routes(self.users, self._service_map, routes)
|
||||
|
||||
async def start_service(
|
||||
self,
|
||||
service_name: str,
|
||||
service: Service,
|
||||
ssl_context: Optional[ssl.SSLContext] = None,
|
||||
) -> bool:
|
||||
"""Start a managed service or poll for external service
|
||||
|
||||
Args:
|
||||
service_name (str): Name of the service.
|
||||
service (Service): The service object.
|
||||
|
||||
Returns:
|
||||
boolean: Returns `True` if the service is started successfully,
|
||||
returns `False` otherwise.
|
||||
"""
|
||||
if ssl_context is None:
|
||||
ssl_context = make_ssl_context(
|
||||
self.internal_ssl_key,
|
||||
self.internal_ssl_cert,
|
||||
cafile=self.internal_ssl_ca,
|
||||
purpose=ssl.Purpose.CLIENT_AUTH,
|
||||
)
|
||||
|
||||
msg = f'{service_name} at {service.url}' if service.url else service_name
|
||||
if service.managed:
|
||||
self.log.info("Starting managed service %s", msg)
|
||||
try:
|
||||
await service.start()
|
||||
except Exception as e:
|
||||
self.log.critical(
|
||||
"Failed to start service %s", service_name, exc_info=True
|
||||
)
|
||||
return False
|
||||
else:
|
||||
self.log.info("Adding external service %s", msg)
|
||||
|
||||
if service.url:
|
||||
tries = 10 if service.managed else 1
|
||||
for i in range(tries):
|
||||
try:
|
||||
await Server.from_orm(service.orm.server).wait_up(
|
||||
http=True, timeout=1, ssl_context=ssl_context
|
||||
)
|
||||
except AnyTimeoutError:
|
||||
if service.managed:
|
||||
status = await service.spawner.poll()
|
||||
if status is not None:
|
||||
self.log.error(
|
||||
"Service %s exited with status %s",
|
||||
service_name,
|
||||
status,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
else:
|
||||
self.log.error(
|
||||
"Cannot connect to %s service %s at %s. Is it running?",
|
||||
service.kind,
|
||||
service_name,
|
||||
service.url,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
async def start(self):
|
||||
"""Start the whole thing"""
|
||||
self.io_loop = loop = IOLoop.current()
|
||||
@@ -3217,55 +3489,29 @@ class JupyterHub(Application):
|
||||
|
||||
# start the service(s)
|
||||
for service_name, service in self._service_map.items():
|
||||
msg = f'{service_name} at {service.url}' if service.url else service_name
|
||||
if service.managed:
|
||||
self.log.info("Starting managed service %s", msg)
|
||||
try:
|
||||
await service.start()
|
||||
except Exception as e:
|
||||
self.log.critical(
|
||||
"Failed to start service %s", service_name, exc_info=True
|
||||
)
|
||||
service_ready = await self.start_service(service_name, service, ssl_context)
|
||||
if not service_ready:
|
||||
if service.from_config:
|
||||
# Stop the application if a config-based service failed to start.
|
||||
self.exit(1)
|
||||
else:
|
||||
self.log.info("Adding external service %s", msg)
|
||||
|
||||
if service.url:
|
||||
tries = 10 if service.managed else 1
|
||||
for i in range(tries):
|
||||
try:
|
||||
await Server.from_orm(service.orm.server).wait_up(
|
||||
http=True, timeout=1, ssl_context=ssl_context
|
||||
)
|
||||
except AnyTimeoutError:
|
||||
if service.managed:
|
||||
status = await service.spawner.poll()
|
||||
if status is not None:
|
||||
self.log.error(
|
||||
"Service %s exited with status %s",
|
||||
service_name,
|
||||
status,
|
||||
)
|
||||
break
|
||||
else:
|
||||
break
|
||||
else:
|
||||
# Only warn for database-based service, so that admin can connect
|
||||
# to hub to remove the service.
|
||||
self.log.error(
|
||||
"Cannot connect to %s service %s at %s. Is it running?",
|
||||
service.kind,
|
||||
"Failed to reach externally managed service %s",
|
||||
service_name,
|
||||
service.url,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
await self.proxy.check_routes(self.users, self._service_map)
|
||||
|
||||
if self.service_check_interval and any(
|
||||
s.url for s in self._service_map.values()
|
||||
):
|
||||
pc = PeriodicCallback(
|
||||
# Check services health
|
||||
self._check_services_health_callback = None
|
||||
if self.service_check_interval:
|
||||
self._check_services_health_callback = PeriodicCallback(
|
||||
self.check_services_health, 1e3 * self.service_check_interval
|
||||
)
|
||||
pc.start()
|
||||
self._check_services_health_callback.start()
|
||||
|
||||
if self.last_activity_interval:
|
||||
pc = PeriodicCallback(
|
||||
|
@@ -157,6 +157,25 @@ class Authenticator(LoggingConfigurable):
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
otp_prompt = Any(
|
||||
"OTP:",
|
||||
help="""
|
||||
The prompt string for the extra OTP (One Time Password) field.
|
||||
|
||||
.. versionadded:: 5.0
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
request_otp = Bool(
|
||||
False,
|
||||
config=True,
|
||||
help="""
|
||||
Prompt for OTP (One Time Password) in the login form.
|
||||
|
||||
.. versionadded:: 5.0
|
||||
""",
|
||||
)
|
||||
|
||||
_deprecated_aliases = {
|
||||
"whitelist": ("allowed_users", "1.2"),
|
||||
"blacklist": ("blocked_users", "1.2"),
|
||||
@@ -485,6 +504,8 @@ class Authenticator(LoggingConfigurable):
|
||||
- `authenticate` turns formdata into a username
|
||||
- `normalize_username` normalizes the username
|
||||
- `check_allowed` checks against the allowed usernames
|
||||
- `check_blocked_users` check against the blocked usernames
|
||||
- `is_admin` check if a user is an admin
|
||||
|
||||
.. versionchanged:: 0.8
|
||||
return dict instead of username
|
||||
@@ -603,8 +624,7 @@ class Authenticator(LoggingConfigurable):
|
||||
The Authenticator may return a dict instead, which MUST have a
|
||||
key `name` holding the username, and MAY have additional keys:
|
||||
|
||||
- `auth_state`, a dictionary of of auth state that will be
|
||||
persisted;
|
||||
- `auth_state`, a dictionary of auth state that will be persisted;
|
||||
- `admin`, the admin setting value for the user
|
||||
- `groups`, the list of group names the user should be a member of,
|
||||
if Authenticator.manage_groups is True.
|
||||
@@ -1103,9 +1123,16 @@ class PAMAuthenticator(LocalAuthenticator):
|
||||
Return None otherwise.
|
||||
"""
|
||||
username = data['username']
|
||||
password = data["password"]
|
||||
if "otp" in data:
|
||||
# OTP given, pass as tuple (requires pamela 1.1)
|
||||
password = (data["password"], data["otp"])
|
||||
try:
|
||||
pamela.authenticate(
|
||||
username, data['password'], service=self.service, encoding=self.encoding
|
||||
username,
|
||||
password,
|
||||
service=self.service,
|
||||
encoding=self.encoding,
|
||||
)
|
||||
except pamela.PAMError as e:
|
||||
if handler is not None:
|
||||
|
@@ -236,11 +236,13 @@ class BaseHandler(RequestHandler):
|
||||
def check_xsrf_cookie(self):
|
||||
try:
|
||||
return super().check_xsrf_cookie()
|
||||
except Exception as e:
|
||||
# ensure _juptyerhub_user is defined on rejected requests
|
||||
except web.HTTPError as e:
|
||||
# ensure _jupyterhub_user is defined on rejected requests
|
||||
if not hasattr(self, "_jupyterhub_user"):
|
||||
self._jupyterhub_user = None
|
||||
self._resolve_roles_and_scopes()
|
||||
# rewrite message because we use this on methods other than POST
|
||||
e.log_message = e.log_message.replace("POST", self.request.method)
|
||||
raise
|
||||
|
||||
@property
|
||||
@@ -1443,6 +1445,12 @@ class UserUrlHandler(BaseHandler):
|
||||
# accept token auth for API requests that are probably to non-running servers
|
||||
_accept_token_auth = True
|
||||
|
||||
# don't consider these redirects 'activity'
|
||||
# if the redirect is followed and the subsequent action taken,
|
||||
# _that_ is activity
|
||||
def _record_activity(self, obj, timestamp=None):
|
||||
return False
|
||||
|
||||
def _fail_api_request(self, user_name='', server_name=''):
|
||||
"""Fail an API request to a not-running server"""
|
||||
self.log.warning(
|
||||
|
@@ -105,6 +105,7 @@ class LoginHandler(BaseHandler):
|
||||
'next': self.get_argument('next', ''),
|
||||
},
|
||||
),
|
||||
"authenticator": self.authenticator,
|
||||
"xsrf": self.xsrf_token.decode('ascii'),
|
||||
}
|
||||
custom_html = Template(
|
||||
|
@@ -3,9 +3,11 @@ Prometheus metrics exported by JupyterHub
|
||||
|
||||
Read https://prometheus.io/docs/practices/naming/ for naming
|
||||
conventions for metrics & labels. We generally prefer naming them
|
||||
`jupyterhub_<noun>_<verb>_<type_suffix>`. So a histogram that's tracking
|
||||
`<noun>_<verb>_<type_suffix>`. So a histogram that's tracking
|
||||
the duration (in seconds) of servers spawning would be called
|
||||
jupyterhub_server_spawn_duration_seconds.
|
||||
server_spawn_duration_seconds.
|
||||
A namespace prefix is always added, so this metric is accessed as
|
||||
`jupyterhub_server_spawn_duration_seconds` by default.
|
||||
|
||||
We also create an Enum for each 'status' type label in every metric
|
||||
we collect. This is to make sure that the metrics exist regardless
|
||||
@@ -19,6 +21,8 @@ them manually here.
|
||||
|
||||
added ``jupyterhub_`` prefix to metric names.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from enum import Enum
|
||||
|
||||
@@ -30,49 +34,66 @@ from traitlets.config import LoggingConfigurable
|
||||
from . import orm
|
||||
from .utils import utcnow
|
||||
|
||||
metrics_prefix = os.getenv('JUPYTERHUB_METRICS_PREFIX', 'jupyterhub')
|
||||
|
||||
REQUEST_DURATION_SECONDS = Histogram(
|
||||
'jupyterhub_request_duration_seconds',
|
||||
'request duration for all HTTP requests',
|
||||
'request_duration_seconds',
|
||||
'Request duration for all HTTP requests',
|
||||
['method', 'handler', 'code'],
|
||||
namespace=metrics_prefix,
|
||||
)
|
||||
|
||||
SERVER_SPAWN_DURATION_SECONDS = Histogram(
|
||||
'jupyterhub_server_spawn_duration_seconds',
|
||||
'time taken for server spawning operation',
|
||||
'server_spawn_duration_seconds',
|
||||
'Time taken for server spawning operation',
|
||||
['status'],
|
||||
# Use custom bucket sizes, since the default bucket ranges
|
||||
# are meant for quick running processes. Spawns can take a while!
|
||||
buckets=[0.5, 1, 2.5, 5, 10, 15, 30, 60, 120, 180, 300, 600, float("inf")],
|
||||
namespace=metrics_prefix,
|
||||
)
|
||||
|
||||
RUNNING_SERVERS = Gauge(
|
||||
'jupyterhub_running_servers', 'the number of user servers currently running'
|
||||
'running_servers',
|
||||
'The number of user servers currently running',
|
||||
namespace=metrics_prefix,
|
||||
)
|
||||
|
||||
TOTAL_USERS = Gauge('jupyterhub_total_users', 'total number of users')
|
||||
TOTAL_USERS = Gauge(
|
||||
'total_users',
|
||||
'Total number of users',
|
||||
namespace=metrics_prefix,
|
||||
)
|
||||
|
||||
ACTIVE_USERS = Gauge(
|
||||
'jupyterhub_active_users',
|
||||
'number of users who were active in the given time period',
|
||||
'active_users',
|
||||
'Number of users who were active in the given time period',
|
||||
['period'],
|
||||
namespace=metrics_prefix,
|
||||
)
|
||||
|
||||
CHECK_ROUTES_DURATION_SECONDS = Histogram(
|
||||
'jupyterhub_check_routes_duration_seconds',
|
||||
'check_routes_duration_seconds',
|
||||
'Time taken to validate all routes in proxy',
|
||||
namespace=metrics_prefix,
|
||||
)
|
||||
|
||||
HUB_STARTUP_DURATION_SECONDS = Histogram(
|
||||
'jupyterhub_hub_startup_duration_seconds', 'Time taken for Hub to start'
|
||||
'hub_startup_duration_seconds',
|
||||
'Time taken for Hub to start',
|
||||
namespace=metrics_prefix,
|
||||
)
|
||||
|
||||
INIT_SPAWNERS_DURATION_SECONDS = Histogram(
|
||||
'jupyterhub_init_spawners_duration_seconds', 'Time taken for spawners to initialize'
|
||||
'init_spawners_duration_seconds',
|
||||
'Time taken for spawners to initialize',
|
||||
namespace=metrics_prefix,
|
||||
)
|
||||
|
||||
PROXY_POLL_DURATION_SECONDS = Histogram(
|
||||
'jupyterhub_proxy_poll_duration_seconds',
|
||||
'duration for polling all routes from proxy',
|
||||
'proxy_poll_duration_seconds',
|
||||
'Duration for polling all routes from proxy',
|
||||
namespace=metrics_prefix,
|
||||
)
|
||||
|
||||
|
||||
@@ -97,9 +118,10 @@ for s in ServerSpawnStatus:
|
||||
|
||||
|
||||
PROXY_ADD_DURATION_SECONDS = Histogram(
|
||||
'jupyterhub_proxy_add_duration_seconds',
|
||||
'duration for adding user routes to proxy',
|
||||
'proxy_add_duration_seconds',
|
||||
'Duration for adding user routes to proxy',
|
||||
['status'],
|
||||
namespace=metrics_prefix,
|
||||
)
|
||||
|
||||
|
||||
@@ -120,9 +142,10 @@ for s in ProxyAddStatus:
|
||||
|
||||
|
||||
SERVER_POLL_DURATION_SECONDS = Histogram(
|
||||
'jupyterhub_server_poll_duration_seconds',
|
||||
'time taken to poll if server is running',
|
||||
'server_poll_duration_seconds',
|
||||
'Time taken to poll if server is running',
|
||||
['status'],
|
||||
namespace=metrics_prefix,
|
||||
)
|
||||
|
||||
|
||||
@@ -147,9 +170,10 @@ for s in ServerPollStatus:
|
||||
|
||||
|
||||
SERVER_STOP_DURATION_SECONDS = Histogram(
|
||||
'jupyterhub_server_stop_seconds',
|
||||
'time taken for server stopping operation',
|
||||
'server_stop_seconds',
|
||||
'Time taken for server stopping operation',
|
||||
['status'],
|
||||
namespace=metrics_prefix,
|
||||
)
|
||||
|
||||
|
||||
@@ -170,9 +194,10 @@ for s in ServerStopStatus:
|
||||
|
||||
|
||||
PROXY_DELETE_DURATION_SECONDS = Histogram(
|
||||
'jupyterhub_proxy_delete_duration_seconds',
|
||||
'duration for deleting user routes from proxy',
|
||||
'proxy_delete_duration_seconds',
|
||||
'Duration for deleting user routes from proxy',
|
||||
['status'],
|
||||
namespace=metrics_prefix,
|
||||
)
|
||||
|
||||
|
||||
@@ -239,7 +264,7 @@ class PeriodicMetricsCollector(LoggingConfigurable):
|
||||
help="""
|
||||
Enable active_users prometheus metric.
|
||||
|
||||
Populates a `jupyterhub_active_users` prometheus metric, with a label `period` that counts the time period
|
||||
Populates a `active_users` prometheus metric, with a label `period` that counts the time period
|
||||
over which these many users were active. Periods are 24h (24 hours), 7d (7 days) and 30d (30 days).
|
||||
""",
|
||||
config=True,
|
||||
|
@@ -668,6 +668,18 @@ class JupyterHubOAuthServer(WebApplicationServer):
|
||||
self.db.commit()
|
||||
return orm_client
|
||||
|
||||
def remove_client(self, client_id):
|
||||
"""Remove a client by its id if it is existed."""
|
||||
orm_client = (
|
||||
self.db.query(orm.OAuthClient).filter_by(identifier=client_id).one_or_none()
|
||||
)
|
||||
if orm_client is not None:
|
||||
self.db.delete(orm_client)
|
||||
self.db.commit()
|
||||
app_log.info("Removed client %s", client_id)
|
||||
else:
|
||||
app_log.warning("No such client %s", client_id)
|
||||
|
||||
def fetch_by_client_id(self, client_id):
|
||||
"""Find a client by its id"""
|
||||
client = self.db.query(orm.OAuthClient).filter_by(identifier=client_id).first()
|
||||
|
@@ -29,9 +29,9 @@ from sqlalchemy import (
|
||||
)
|
||||
from sqlalchemy.orm import (
|
||||
Session,
|
||||
backref,
|
||||
declarative_base,
|
||||
interfaces,
|
||||
joinedload,
|
||||
object_session,
|
||||
relationship,
|
||||
sessionmaker,
|
||||
@@ -139,6 +139,9 @@ class Server(Base):
|
||||
base_url = Column(Unicode(255), default='/')
|
||||
cookie_name = Column(Unicode(255), default='cookie')
|
||||
|
||||
service = relationship("Service", back_populates="server", uselist=False)
|
||||
spawner = relationship("Spawner", back_populates="server", uselist=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Server({self.ip}:{self.port})>"
|
||||
|
||||
@@ -178,9 +181,11 @@ class Role(Base):
|
||||
name = Column(Unicode(255), unique=True)
|
||||
description = Column(Unicode(1023))
|
||||
scopes = Column(JSONList, default=[])
|
||||
users = relationship('User', secondary='user_role_map', backref='roles')
|
||||
services = relationship('Service', secondary='service_role_map', backref='roles')
|
||||
groups = relationship('Group', secondary='group_role_map', backref='roles')
|
||||
users = relationship('User', secondary='user_role_map', back_populates='roles')
|
||||
services = relationship(
|
||||
'Service', secondary='service_role_map', back_populates='roles'
|
||||
)
|
||||
groups = relationship('Group', secondary='group_role_map', back_populates='roles')
|
||||
|
||||
def __repr__(self):
|
||||
return "<{} {} ({}) - scopes: {}>".format(
|
||||
@@ -213,15 +218,14 @@ class Group(Base):
|
||||
__tablename__ = 'groups'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(Unicode(255), unique=True)
|
||||
users = relationship('User', secondary='user_group_map', backref='groups')
|
||||
users = relationship('User', secondary='user_group_map', back_populates='groups')
|
||||
properties = Column(JSONDict, default={})
|
||||
roles = relationship(
|
||||
'Role', secondary='group_role_map', back_populates='groups', lazy="selectin"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s %s (%i users)>" % (
|
||||
self.__class__.__name__,
|
||||
self.name,
|
||||
len(self.users),
|
||||
)
|
||||
return f"<{self.__class__.__name__} {self.name}>"
|
||||
|
||||
@classmethod
|
||||
def find(cls, db, name):
|
||||
@@ -258,8 +262,15 @@ class User(Base):
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(Unicode(255), unique=True)
|
||||
|
||||
roles = relationship(
|
||||
'Role',
|
||||
secondary='user_role_map',
|
||||
back_populates='users',
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
_orm_spawners = relationship(
|
||||
"Spawner", backref="user", cascade="all, delete-orphan"
|
||||
"Spawner", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -270,9 +281,17 @@ class User(Base):
|
||||
created = Column(DateTime, default=datetime.utcnow)
|
||||
last_activity = Column(DateTime, nullable=True)
|
||||
|
||||
api_tokens = relationship("APIToken", backref="user", cascade="all, delete-orphan")
|
||||
api_tokens = relationship(
|
||||
"APIToken", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
groups = relationship(
|
||||
"Group",
|
||||
secondary='user_group_map',
|
||||
back_populates="users",
|
||||
lazy="selectin",
|
||||
)
|
||||
oauth_codes = relationship(
|
||||
"OAuthCode", backref="user", cascade="all, delete-orphan"
|
||||
"OAuthCode", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
cookie_id = Column(Unicode(255), default=new_token, nullable=False, unique=True)
|
||||
# User.state is actually Spawner state
|
||||
@@ -312,11 +331,13 @@ class Spawner(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||
user = relationship("User", back_populates="_orm_spawners")
|
||||
|
||||
server_id = Column(Integer, ForeignKey('servers.id', ondelete='SET NULL'))
|
||||
server = relationship(
|
||||
Server,
|
||||
backref=backref('spawner', uselist=False),
|
||||
back_populates="spawner",
|
||||
lazy="joined",
|
||||
single_parent=True,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
@@ -338,7 +359,7 @@ class Spawner(Base):
|
||||
)
|
||||
oauth_client = relationship(
|
||||
'OAuthClient',
|
||||
backref=backref("spawner", uselist=False),
|
||||
back_populates="spawner",
|
||||
cascade="all, delete-orphan",
|
||||
single_parent=True,
|
||||
)
|
||||
@@ -379,16 +400,39 @@ class Service(Base):
|
||||
# common user interface:
|
||||
name = Column(Unicode(255), unique=True)
|
||||
admin = Column(Boolean(create_constraint=False), default=False)
|
||||
roles = relationship(
|
||||
'Role', secondary='service_role_map', back_populates='services', lazy="selectin"
|
||||
)
|
||||
|
||||
url = Column(Unicode(2047), nullable=True)
|
||||
|
||||
oauth_client_allowed_scopes = Column(JSONList, nullable=True)
|
||||
|
||||
info = Column(JSONDict, nullable=True)
|
||||
|
||||
display = Column(Boolean, nullable=True)
|
||||
|
||||
oauth_no_confirm = Column(Boolean, nullable=True)
|
||||
|
||||
command = Column(JSONList, nullable=True)
|
||||
|
||||
cwd = Column(Unicode(4095), nullable=True)
|
||||
|
||||
environment = Column(JSONDict, nullable=True)
|
||||
|
||||
user = Column(Unicode(255), nullable=True)
|
||||
|
||||
from_config = Column(Boolean, default=True)
|
||||
|
||||
api_tokens = relationship(
|
||||
"APIToken", backref="service", cascade="all, delete-orphan"
|
||||
"APIToken", back_populates="service", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# service-specific interface
|
||||
_server_id = Column(Integer, ForeignKey('servers.id', ondelete='SET NULL'))
|
||||
server = relationship(
|
||||
Server,
|
||||
backref=backref('service', uselist=False),
|
||||
back_populates="service",
|
||||
single_parent=True,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
@@ -402,9 +446,10 @@ class Service(Base):
|
||||
ondelete='SET NULL',
|
||||
),
|
||||
)
|
||||
|
||||
oauth_client = relationship(
|
||||
'OAuthClient',
|
||||
backref=backref("service", uselist=False),
|
||||
back_populates="service",
|
||||
cascade="all, delete-orphan",
|
||||
single_parent=True,
|
||||
)
|
||||
@@ -543,7 +588,10 @@ class Hashed(Expiring):
|
||||
`kind='user'` only returns API tokens for users
|
||||
`kind='service'` only returns API tokens for services
|
||||
"""
|
||||
prefix_match = cls.find_prefix(db, token)
|
||||
prefix_match = cls.find_prefix(db, token).options(
|
||||
joinedload(cls.user), joinedload(cls.service)
|
||||
)
|
||||
|
||||
for orm_token in prefix_match:
|
||||
if orm_token.match(token):
|
||||
return orm_token
|
||||
@@ -579,6 +627,10 @@ class APIToken(Hashed, Base):
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
user = relationship("User", back_populates="api_tokens")
|
||||
service = relationship("Service", back_populates="api_tokens")
|
||||
oauth_client = relationship("OAuthClient", back_populates="access_tokens")
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
hashed = Column(Unicode(255), unique=True)
|
||||
prefix = Column(Unicode(16), index=True)
|
||||
@@ -784,12 +836,20 @@ class OAuthCode(Expiring, Base):
|
||||
client_id = Column(
|
||||
Unicode(255), ForeignKey('oauth_clients.identifier', ondelete='CASCADE')
|
||||
)
|
||||
client = relationship(
|
||||
"OAuthClient",
|
||||
back_populates="codes",
|
||||
)
|
||||
code = Column(Unicode(36))
|
||||
expires_at = Column(Integer)
|
||||
redirect_uri = Column(Unicode(1023))
|
||||
session_id = Column(Unicode(255))
|
||||
# state = Column(Unicode(1023))
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||
user = relationship(
|
||||
"User",
|
||||
back_populates="oauth_codes",
|
||||
)
|
||||
|
||||
scopes = Column(JSONList, default=[])
|
||||
|
||||
@@ -803,6 +863,10 @@ class OAuthCode(Expiring, Base):
|
||||
db.query(cls)
|
||||
.filter(cls.code == code)
|
||||
.filter(or_(cls.expires_at == None, cls.expires_at >= cls.now()))
|
||||
.options(
|
||||
# load user with the code
|
||||
joinedload(cls.user, innerjoin=True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -824,10 +888,22 @@ class OAuthClient(Base):
|
||||
def client_id(self):
|
||||
return self.identifier
|
||||
|
||||
access_tokens = relationship(
|
||||
APIToken, backref='oauth_client', cascade='all, delete-orphan'
|
||||
spawner = relationship(
|
||||
"Spawner",
|
||||
back_populates="oauth_client",
|
||||
uselist=False,
|
||||
)
|
||||
service = relationship(
|
||||
"Service",
|
||||
back_populates="oauth_client",
|
||||
uselist=False,
|
||||
)
|
||||
access_tokens = relationship(
|
||||
APIToken, back_populates='oauth_client', cascade='all, delete-orphan'
|
||||
)
|
||||
codes = relationship(
|
||||
OAuthCode, back_populates='client', cascade='all, delete-orphan'
|
||||
)
|
||||
codes = relationship(OAuthCode, backref='client', cascade='all, delete-orphan')
|
||||
|
||||
# these are the scopes an oauth client is allowed to request
|
||||
# *not* the scopes of the client itself
|
||||
|
@@ -34,6 +34,7 @@ def get_default_roles():
|
||||
'admin-ui',
|
||||
'admin:users',
|
||||
'admin:servers',
|
||||
'admin:services',
|
||||
'tokens',
|
||||
'admin:groups',
|
||||
'list:services',
|
||||
|
@@ -123,6 +123,10 @@ scope_definitions = {
|
||||
'delete:groups': {
|
||||
'description': "Delete groups.",
|
||||
},
|
||||
'admin:services': {
|
||||
'description': 'Create, read, update, delete services, not including services defined from config files.',
|
||||
'subscopes': ['list:services', 'read:services', 'read:roles:services'],
|
||||
},
|
||||
'list:services': {
|
||||
'description': 'List services, including at least their names.',
|
||||
'subscopes': ['read:services:name'],
|
||||
@@ -435,7 +439,7 @@ def _expand_self_scope(username):
|
||||
|
||||
@lru_cache(maxsize=65535)
|
||||
def _expand_scope(scope):
|
||||
"""Returns a scope and all all subscopes
|
||||
"""Returns a scope and all subscopes
|
||||
|
||||
Arguments:
|
||||
scope (str): the scope to expand
|
||||
@@ -845,6 +849,15 @@ def needs_scope(*scopes):
|
||||
def scope_decorator(func):
|
||||
@functools.wraps(func)
|
||||
def _auth_func(self, *args, **kwargs):
|
||||
if not self.current_user:
|
||||
# not authenticated at all, fail with more generic message
|
||||
# this is the most likely permission error - missing or mis-specified credentials,
|
||||
# don't indicate that they have insufficient permissions.
|
||||
raise web.HTTPError(
|
||||
403,
|
||||
"Missing or invalid credentials.",
|
||||
)
|
||||
|
||||
sig = inspect.signature(func)
|
||||
bound_sig = sig.bind(self, *args, **kwargs)
|
||||
bound_sig.apply_defaults()
|
||||
@@ -853,6 +866,11 @@ def needs_scope(*scopes):
|
||||
self.expanded_scopes = {}
|
||||
self.parsed_scopes = {}
|
||||
|
||||
try:
|
||||
end_point = self.request.path
|
||||
except AttributeError:
|
||||
end_point = self.__name__
|
||||
|
||||
s_kwargs = {}
|
||||
for resource in {'user', 'server', 'group', 'service'}:
|
||||
resource_name = resource + '_name'
|
||||
@@ -860,14 +878,10 @@ def needs_scope(*scopes):
|
||||
resource_value = bound_sig.arguments[resource_name]
|
||||
s_kwargs[resource] = resource_value
|
||||
for scope in scopes:
|
||||
app_log.debug("Checking access via scope %s", scope)
|
||||
app_log.debug("Checking access to %s via scope %s", end_point, scope)
|
||||
has_access = _check_scope_access(self, scope, **s_kwargs)
|
||||
if has_access:
|
||||
return func(self, *args, **kwargs)
|
||||
try:
|
||||
end_point = self.request.path
|
||||
except AttributeError:
|
||||
end_point = self.__name__
|
||||
app_log.warning(
|
||||
"Not authorizing access to {}. Requires any of [{}], not derived from scopes [{}]".format(
|
||||
end_point, ", ".join(scopes), ", ".join(self.expanded_scopes)
|
||||
|
@@ -596,6 +596,9 @@ class HubAuth(SingletonConfigurable):
|
||||
- in URL parameters: ?token=<token>
|
||||
- in header: Authorization: token <token>
|
||||
- in cookie (stored after oauth), if in_cookie is True
|
||||
|
||||
Args:
|
||||
handler (tornado.web.RequestHandler): the current request handler
|
||||
"""
|
||||
|
||||
user_token = handler.get_argument('token', '')
|
||||
@@ -623,6 +626,9 @@ class HubAuth(SingletonConfigurable):
|
||||
"""Get the jupyterhub session id
|
||||
|
||||
from the jupyterhub-session-id cookie.
|
||||
|
||||
Args:
|
||||
handler (tornado.web.RequestHandler): the current request handler
|
||||
"""
|
||||
return handler.get_cookie('jupyterhub-session-id', '')
|
||||
|
||||
@@ -949,7 +955,11 @@ class HubOAuth(HubAuth):
|
||||
handler.set_secure_cookie(self.cookie_name, access_token, **kwargs)
|
||||
|
||||
def clear_cookie(self, handler):
|
||||
"""Clear the OAuth cookie"""
|
||||
"""Clear the OAuth cookie
|
||||
|
||||
Args:
|
||||
handler (tornado.web.RequestHandler): the current request handler
|
||||
"""
|
||||
handler.clear_cookie(self.cookie_name, path=self.base_url)
|
||||
|
||||
|
||||
|
@@ -180,9 +180,12 @@ class Service(LoggingConfigurable):
|
||||
- user: str
|
||||
The name of a system user to become.
|
||||
If unspecified, run as the same user as the Hub.
|
||||
|
||||
"""
|
||||
|
||||
# inputs:
|
||||
# traits tagged with `input=True` are accepted as input from configuration / API
|
||||
# input traits are also persisted to the db UNLESS they are also tagged with `in_db=False`
|
||||
|
||||
name = Unicode(
|
||||
help="""The name of the service.
|
||||
|
||||
@@ -205,7 +208,7 @@ class Service(LoggingConfigurable):
|
||||
|
||||
DEPRECATED in 3.0: use oauth_client_allowed_scopes
|
||||
"""
|
||||
).tag(input=True)
|
||||
).tag(input=True, in_db=False)
|
||||
|
||||
oauth_client_allowed_scopes = List(
|
||||
help="""OAuth allowed scopes.
|
||||
@@ -225,7 +228,7 @@ class Service(LoggingConfigurable):
|
||||
|
||||
If unspecified, an API token will be generated for managed services.
|
||||
"""
|
||||
).tag(input=True)
|
||||
).tag(input=True, in_db=False)
|
||||
|
||||
info = Dict(
|
||||
help="""Provide a place to include miscellaneous information about the service,
|
||||
@@ -310,7 +313,7 @@ class Service(LoggingConfigurable):
|
||||
You shouldn't generally need to change this.
|
||||
Default: `service-<name>`
|
||||
"""
|
||||
).tag(input=True)
|
||||
).tag(input=True, in_db=False)
|
||||
|
||||
@default('oauth_client_id')
|
||||
def _default_client_id(self):
|
||||
@@ -331,7 +334,7 @@ class Service(LoggingConfigurable):
|
||||
You shouldn't generally need to change this.
|
||||
Default: `/services/:name/oauth_callback`
|
||||
"""
|
||||
).tag(input=True)
|
||||
).tag(input=True, in_db=False)
|
||||
|
||||
@default('oauth_redirect_uri')
|
||||
def _default_redirect_uri(self):
|
||||
@@ -362,6 +365,14 @@ class Service(LoggingConfigurable):
|
||||
def prefix(self):
|
||||
return url_path_join(self.base_url, 'services', self.name + '/')
|
||||
|
||||
@property
|
||||
def href(self):
|
||||
"""Convenient 'href' to use for links to this service"""
|
||||
if self.domain:
|
||||
return f"//{self.domain}{self.prefix}"
|
||||
else:
|
||||
return self.prefix
|
||||
|
||||
@property
|
||||
def proxy_spec(self):
|
||||
if not self.server:
|
||||
@@ -371,6 +382,11 @@ class Service(LoggingConfigurable):
|
||||
else:
|
||||
return self.server.base_url
|
||||
|
||||
@property
|
||||
def from_config(self):
|
||||
"""Is the service defined from config file?"""
|
||||
return self.orm.from_config
|
||||
|
||||
def __repr__(self):
|
||||
return "<{cls}(name={name}{managed})>".format(
|
||||
cls=self.__class__.__name__,
|
||||
|
@@ -67,9 +67,10 @@ else:
|
||||
from .app import SingleUserNotebookApp, main
|
||||
|
||||
# backward-compatibility
|
||||
JupyterHubLoginHandler = SingleUserNotebookApp.login_handler_class
|
||||
JupyterHubLogoutHandler = SingleUserNotebookApp.logout_handler_class
|
||||
OAuthCallbackHandler = SingleUserNotebookApp.oauth_callback_handler_class
|
||||
if SingleUserNotebookApp is not None:
|
||||
JupyterHubLoginHandler = SingleUserNotebookApp.login_handler_class
|
||||
JupyterHubLogoutHandler = SingleUserNotebookApp.logout_handler_class
|
||||
OAuthCallbackHandler = SingleUserNotebookApp.oauth_callback_handler_class
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
@@ -6,7 +6,7 @@
|
||||
.. versionchanged:: 2.0
|
||||
|
||||
Default app changed to launch `jupyter labhub`.
|
||||
Use JUPYTERHUB_SINGLEUSER_APP=notebook.notebookapp.NotebookApp for the legacy 'classic' notebook server.
|
||||
Use JUPYTERHUB_SINGLEUSER_APP='notebook' for the legacy 'classic' notebook server (requires notebook<7).
|
||||
"""
|
||||
import os
|
||||
|
||||
@@ -26,9 +26,27 @@ _app_shortcuts = {
|
||||
JUPYTERHUB_SINGLEUSER_APP = _app_shortcuts.get(
|
||||
JUPYTERHUB_SINGLEUSER_APP.replace("_", "-"), JUPYTERHUB_SINGLEUSER_APP
|
||||
)
|
||||
JUPYVERSE = JUPYTERHUB_SINGLEUSER_APP == "jupyverse"
|
||||
|
||||
if JUPYTERHUB_SINGLEUSER_APP:
|
||||
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
|
||||
if JUPYTERHUB_SINGLEUSER_APP in {"notebook", _app_shortcuts["notebook"]}:
|
||||
# better error for notebook v7, which uses jupyter-server
|
||||
# when the legacy notebook server is requested
|
||||
try:
|
||||
from notebook import __version__
|
||||
except ImportError:
|
||||
# will raise later
|
||||
pass
|
||||
else:
|
||||
# check if this failed because of notebook v7
|
||||
_notebook_major_version = int(__version__.split(".", 1)[0])
|
||||
if _notebook_major_version >= 7:
|
||||
raise ImportError(
|
||||
f"JUPYTERHUB_SINGLEUSER_APP={JUPYTERHUB_SINGLEUSER_APP} is not valid with notebook>=7 (have notebook=={__version__}).\n"
|
||||
f"Leave $JUPYTERHUB_SINGLEUSER_APP unspecified (or use the default JUPYTERHUB_SINGLEUSER_APP=jupyter-server), "
|
||||
'and set `c.Spawner.default_url = "/tree"` to make notebook v7 the default UI.'
|
||||
)
|
||||
App = None if JUPYVERSE else import_item(JUPYTERHUB_SINGLEUSER_APP)
|
||||
else:
|
||||
App = None
|
||||
_import_error = None
|
||||
@@ -48,7 +66,10 @@ else:
|
||||
raise _import_error
|
||||
|
||||
|
||||
SingleUserNotebookApp = make_singleuser_app(App)
|
||||
if App is None:
|
||||
SingleUserNotebookApp = None
|
||||
else:
|
||||
SingleUserNotebookApp = make_singleuser_app(App)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -75,4 +96,9 @@ def main():
|
||||
if version_tuple >= (3, 1):
|
||||
return SingleUserLabApp.launch_instance()
|
||||
|
||||
if JUPYVERSE:
|
||||
from fps_auth_jupyterhub import launch
|
||||
|
||||
return launch()
|
||||
|
||||
return SingleUserNotebookApp.launch_instance()
|
||||
|
@@ -483,6 +483,11 @@ class JupyterHubSingleUser(ExtensionApp):
|
||||
cfg.answer_yes = True
|
||||
self.config.FileContentsManager.delete_to_trash = False
|
||||
|
||||
# load Spawner.notebook_dir configuration, if given
|
||||
root_dir = os.getenv("JUPYTERHUB_ROOT_DIR", None)
|
||||
if root_dir:
|
||||
cfg.root_dir = os.path.expanduser(root_dir)
|
||||
|
||||
# load http server config from environment
|
||||
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
||||
if url.port:
|
||||
@@ -639,7 +644,7 @@ class JupyterHubSingleUser(ExtensionApp):
|
||||
disable_user_config = Bool()
|
||||
|
||||
@default("disable_user_config")
|
||||
def _defaut_disable_user_config(self):
|
||||
def _default_disable_user_config(self):
|
||||
return _bool_env("JUPYTERHUB_DISABLE_USER_CONFIG")
|
||||
|
||||
@classmethod
|
||||
|
@@ -274,8 +274,6 @@ class Spawner(LoggingConfigurable):
|
||||
api_token = Unicode()
|
||||
oauth_client_id = Unicode()
|
||||
|
||||
oauth_scopes = List(Unicode())
|
||||
|
||||
@property
|
||||
def oauth_scopes(self):
|
||||
warnings.warn(
|
||||
@@ -840,6 +838,27 @@ class Spawner(LoggingConfigurable):
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
progress_ready_hook = Any(
|
||||
help="""
|
||||
An optional hook function that you can implement to modify the
|
||||
ready event, which will be shown to the user on the spawn progress page when their server
|
||||
is ready.
|
||||
|
||||
This can be set independent of any concrete spawner implementation.
|
||||
|
||||
This maybe a coroutine.
|
||||
|
||||
Example::
|
||||
|
||||
async def my_ready_hook(spawner, ready_event):
|
||||
ready_event["html_message"] = f"Server {spawner.name} is ready for {spawner.user.name}"
|
||||
return ready_event
|
||||
|
||||
c.Spawner.progress_ready_hook = my_ready_hook
|
||||
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
pre_spawn_hook = Any(
|
||||
help="""
|
||||
An optional hook function that you can implement to do some
|
||||
@@ -1735,7 +1754,7 @@ class LocalProcessSpawner(Spawner):
|
||||
self.clear_state()
|
||||
return 0
|
||||
|
||||
# We use pustil.pid_exists on windows
|
||||
# We use psutil.pid_exists on windows
|
||||
if os.name == 'nt':
|
||||
alive = psutil.pid_exists(self.pid)
|
||||
else:
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import re
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
@@ -143,6 +144,53 @@ async def test_login_with_invalid_credantials(app, browser, username, pass_w):
|
||||
await expect(browser).to_have_url(re.compile(".*/hub/login"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("request_otp", [True, False])
|
||||
async def test_login_otp(request, app, browser, username, request_otp):
|
||||
def _reset():
|
||||
app.authenticator.request_otp = False
|
||||
|
||||
request.addfinalizer(_reset)
|
||||
|
||||
app.authenticator.request_otp = request_otp
|
||||
login_url = url_concat(
|
||||
url_path_join(public_host(app), app.hub.base_url, "login"),
|
||||
{"next": ujoin(public_url(app), "/hub/home/")},
|
||||
)
|
||||
await browser.goto(login_url)
|
||||
# check for otp element
|
||||
otp_label = browser.locator("label[for=otp_input]")
|
||||
otp_input = browser.locator("input#otp_input")
|
||||
if not request_otp:
|
||||
# request_otp is False, no OTP prompt
|
||||
assert await otp_label.count() == 0
|
||||
assert await otp_input.count() == 0
|
||||
return
|
||||
|
||||
await expect(otp_label).to_be_visible()
|
||||
await expect(otp_input).to_be_visible()
|
||||
await expect(otp_label).to_have_text(app.authenticator.otp_prompt)
|
||||
|
||||
# fill it out
|
||||
await browser.get_by_label("Username:").fill(username)
|
||||
await browser.get_by_label("Password:").fill(username)
|
||||
await browser.get_by_label("OTP:").fill("otp!")
|
||||
|
||||
# submit form
|
||||
with mock.patch(
|
||||
"jupyterhub.tests.mocking.mock_authenticate",
|
||||
spec=True,
|
||||
return_value={"username": username},
|
||||
) as mock_otp_auth:
|
||||
await browser.get_by_role("button", name="Sign in").click()
|
||||
expected_url = ujoin(public_url(app), "/hub/home/")
|
||||
await expect(browser).to_have_url(expected_url)
|
||||
|
||||
# check that OTP was passed
|
||||
assert mock_otp_auth.called
|
||||
assert mock_otp_auth.call_args.args[0] == username
|
||||
assert mock_otp_auth.call_args.args[1] == (username, "otp!")
|
||||
|
||||
|
||||
# SPAWNING
|
||||
|
||||
|
||||
@@ -985,7 +1033,7 @@ async def test_start_stop_server_on_admin_page(
|
||||
|
||||
async def click_spawn_page(browser, username):
|
||||
"""spawn the server for one user via the Spawn page button, index = 0 or 1"""
|
||||
spawn_btn_xpath = f'//a[contains(@href, "spawn/{username}")]/button[contains(@class, "secondary")]'
|
||||
spawn_btn_xpath = f'//a[contains(@href, "spawn/{username}")]/button[contains(@class, "btn-light")]'
|
||||
spawn_btn = browser.locator(spawn_btn_xpath)
|
||||
await expect(spawn_btn).to_be_enabled()
|
||||
async with browser.expect_navigation(url=f"**/user/{username}/"):
|
||||
@@ -993,7 +1041,7 @@ async def test_start_stop_server_on_admin_page(
|
||||
|
||||
async def click_access_server(browser, username):
|
||||
"""access to the server for users via the Access Server button"""
|
||||
access_btn_xpath = f'//a[contains(@href, "user/{username}")]/button[contains(@class, "primary")]'
|
||||
access_btn_xpath = f'//a[contains(@href, "user/{username}")]/button[contains(@class, "btn-primary")]'
|
||||
access_btn = browser.locator(access_btn_xpath)
|
||||
await expect(access_btn).to_be_enabled()
|
||||
await access_btn.click()
|
||||
|
@@ -34,6 +34,7 @@ from subprocess import TimeoutExpired
|
||||
from unittest import mock
|
||||
|
||||
from pytest import fixture, raises
|
||||
from sqlalchemy import event
|
||||
from tornado.httpclient import HTTPError
|
||||
from tornado.platform.asyncio import AsyncIOMainLoop
|
||||
|
||||
@@ -166,7 +167,10 @@ async def cleanup_after(request, io_loop):
|
||||
app = MockHub.instance()
|
||||
if app.db_file.closed:
|
||||
return
|
||||
for uid, user in list(app.users.items()):
|
||||
|
||||
# cleanup users
|
||||
for orm_user in app.db.query(orm.User):
|
||||
user = app.users[orm_user]
|
||||
for name, spawner in list(user.spawners.items()):
|
||||
if spawner.active:
|
||||
try:
|
||||
@@ -176,10 +180,20 @@ async def cleanup_after(request, io_loop):
|
||||
print(f"Stopping leftover server {spawner._log_name}")
|
||||
await user.stop(name)
|
||||
if user.name not in {'admin', 'user'}:
|
||||
app.users.delete(uid)
|
||||
app.users.delete(user.id)
|
||||
# delete groups
|
||||
for group in app.db.query(orm.Group):
|
||||
app.db.delete(group)
|
||||
|
||||
# clear services
|
||||
for name, service in app._service_map.items():
|
||||
if service.managed:
|
||||
service.stop()
|
||||
for orm_service in app.db.query(orm.Service):
|
||||
if orm_service.oauth_client:
|
||||
app.oauth_provider.remove_client(orm_service.oauth_client_id)
|
||||
app.db.delete(orm_service)
|
||||
app._service_map.clear()
|
||||
app.db.commit()
|
||||
|
||||
|
||||
@@ -261,10 +275,7 @@ class MockServiceSpawner(jupyterhub.services.service._ServiceSpawner):
|
||||
poll_interval = 1
|
||||
|
||||
|
||||
_mock_service_counter = 0
|
||||
|
||||
|
||||
async def _mockservice(request, app, external=False, url=False):
|
||||
async def _mockservice(request, app, name, external=False, url=False):
|
||||
"""
|
||||
Add a service to the application
|
||||
|
||||
@@ -280,9 +291,6 @@ async def _mockservice(request, app, external=False, url=False):
|
||||
If True, register the service at a URL
|
||||
(as opposed to headless, API-only).
|
||||
"""
|
||||
global _mock_service_counter
|
||||
_mock_service_counter += 1
|
||||
name = 'mock-service-%i' % _mock_service_counter
|
||||
spec = {'name': name, 'command': mockservice_cmd, 'admin': True}
|
||||
if url:
|
||||
if app.internal_ssl:
|
||||
@@ -329,22 +337,33 @@ async def _mockservice(request, app, external=False, url=False):
|
||||
return service
|
||||
|
||||
|
||||
_service_name_counter = 0
|
||||
|
||||
|
||||
@fixture
|
||||
async def mockservice(request, app):
|
||||
def service_name():
|
||||
global _service_name_counter
|
||||
_service_name_counter += 1
|
||||
name = f'test-service-{_service_name_counter}'
|
||||
return name
|
||||
|
||||
|
||||
@fixture
|
||||
async def mockservice(request, app, service_name):
|
||||
"""Mock a service with no external service url"""
|
||||
yield await _mockservice(request, app, url=False)
|
||||
yield await _mockservice(request, app, name=service_name, url=False)
|
||||
|
||||
|
||||
@fixture
|
||||
async def mockservice_external(request, app):
|
||||
async def mockservice_external(request, app, service_name):
|
||||
"""Mock an externally managed service (don't start anything)"""
|
||||
yield await _mockservice(request, app, external=True, url=False)
|
||||
yield await _mockservice(request, app, name=service_name, external=True, url=False)
|
||||
|
||||
|
||||
@fixture
|
||||
async def mockservice_url(request, app):
|
||||
async def mockservice_url(request, app, service_name):
|
||||
"""Mock a service with its own url to test external services"""
|
||||
yield await _mockservice(request, app, url=True)
|
||||
yield await _mockservice(request, app, name=service_name, url=True)
|
||||
|
||||
|
||||
@fixture
|
||||
@@ -484,3 +503,66 @@ def preserve_scopes():
|
||||
scope_definitions = copy.deepcopy(scopes.scope_definitions)
|
||||
yield scope_definitions
|
||||
scopes.scope_definitions = scope_definitions
|
||||
|
||||
|
||||
# collect db query counts and report the top N tests by db query count
|
||||
@fixture(autouse=True)
|
||||
def count_db_executions(request, record_property):
|
||||
if 'app' in request.fixturenames:
|
||||
app = request.getfixturevalue("app")
|
||||
initial_count = app.db_query_count
|
||||
yield
|
||||
# populate property, collected later in pytest_terminal_summary
|
||||
record_property("db_executions", app.db_query_count - initial_count)
|
||||
elif 'db' in request.fixturenames:
|
||||
# some use the 'db' fixture directly for one-off database tests
|
||||
count = 0
|
||||
engine = request.getfixturevalue("db").get_bind()
|
||||
|
||||
@event.listens_for(engine, "before_execute")
|
||||
def before_execute(conn, clauseelement, multiparams, params, execution_options):
|
||||
nonlocal count
|
||||
count += 1
|
||||
|
||||
yield
|
||||
record_property("db_executions", count)
|
||||
else:
|
||||
# nothing to do, still have to yield
|
||||
yield
|
||||
|
||||
|
||||
def pytest_terminal_summary(terminalreporter, exitstatus, config):
|
||||
# collect db_executions property
|
||||
# populated by the count_db_executions fixture
|
||||
db_counts = {}
|
||||
for report in terminalreporter.getreports(""):
|
||||
properties = dict(report.user_properties)
|
||||
db_executions = properties.get("db_executions", 0)
|
||||
if db_executions:
|
||||
db_counts[report.nodeid] = db_executions
|
||||
|
||||
total_queries = sum(db_counts.values())
|
||||
if total_queries == 0:
|
||||
# nothing to report (e.g. test subset)
|
||||
return
|
||||
n = min(10, len(db_counts))
|
||||
terminalreporter.section(f"top {n} database queries")
|
||||
terminalreporter.line(f"{total_queries:<6} (total)")
|
||||
for nodeid in sorted(db_counts, key=db_counts.get, reverse=True)[:n]:
|
||||
queries = db_counts[nodeid]
|
||||
if queries:
|
||||
terminalreporter.line(f"{queries:<6} {nodeid}")
|
||||
|
||||
|
||||
@fixture
|
||||
def service_data(service_name):
|
||||
"""Data used to create service at runtime"""
|
||||
return {
|
||||
"name": service_name,
|
||||
"oauth_client_id": f"service-{service_name}",
|
||||
"api_token": f"api_token-{service_name}",
|
||||
"oauth_redirect_uri": "http://127.0.0.1:5555/oauth_callback-from-api",
|
||||
"oauth_no_confirm": True,
|
||||
"oauth_client_allowed_scopes": ["inherit"],
|
||||
"info": {'foo': 'bar'},
|
||||
}
|
||||
|
@@ -30,6 +30,7 @@ class JupyterHubTestHandler(JupyterHandler):
|
||||
info = {
|
||||
"current_user": self.current_user,
|
||||
"config": self.app.config,
|
||||
"root_dir": self.contents_manager.root_dir,
|
||||
"disable_user_config": getattr(self.app, "disable_user_config", None),
|
||||
"settings": self.settings,
|
||||
"config_file_paths": self.app.config_file_paths,
|
||||
|
@@ -35,6 +35,7 @@ from unittest import mock
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pamela import PAMError
|
||||
from sqlalchemy import event
|
||||
from tornado.httputil import url_concat
|
||||
from traitlets import Bool, Dict, default
|
||||
|
||||
@@ -308,6 +309,14 @@ class MockHub(JupyterHub):
|
||||
self.db.delete(role)
|
||||
self.db.commit()
|
||||
|
||||
# count db requests
|
||||
self.db_query_count = 0
|
||||
engine = self.db.get_bind()
|
||||
|
||||
@event.listens_for(engine, "before_execute")
|
||||
def before_execute(conn, clauseelement, multiparams, params, execution_options):
|
||||
self.db_query_count += 1
|
||||
|
||||
async def initialize(self, argv=None):
|
||||
self.pid_file = NamedTemporaryFile(delete=False).name
|
||||
self.db_file = NamedTemporaryFile()
|
||||
|
@@ -4,10 +4,12 @@ import json
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timedelta
|
||||
from unittest import mock
|
||||
from urllib.parse import quote, urlparse
|
||||
from urllib.parse import parse_qs, quote, urlparse
|
||||
|
||||
import pytest
|
||||
from pytest import fixture, mark
|
||||
from tornado.httputil import url_concat
|
||||
|
||||
@@ -122,6 +124,41 @@ async def test_xsrf_check(app, username, method, path, xsrf_in_url):
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"auth, expected_message",
|
||||
[
|
||||
("", "Missing or invalid credentials"),
|
||||
("cookie_no_xsrf", "'_xsrf' argument missing from GET"),
|
||||
("cookie_xsrf_mismatch", "XSRF cookie does not match GET argument"),
|
||||
("token_no_scope", "requires any of [list:users]"),
|
||||
("cookie_no_scope", "requires any of [list:users]"),
|
||||
],
|
||||
)
|
||||
async def test_permission_error_messages(app, user, auth, expected_message):
|
||||
# 1. no credentials, should be 403 and not mention xsrf
|
||||
|
||||
url = public_url(app, path="hub/api/users")
|
||||
|
||||
kwargs = {}
|
||||
kwargs["headers"] = headers = {}
|
||||
kwargs["params"] = params = {}
|
||||
if auth == "token_no_scope":
|
||||
token = user.new_api_token()
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
elif "cookie" in auth:
|
||||
cookies = kwargs["cookies"] = await app.login_user(user.name)
|
||||
if auth == "cookie_no_scope":
|
||||
params["_xsrf"] = cookies["_xsrf"]
|
||||
if auth == "cookie_xsrf_mismatch":
|
||||
params["_xsrf"] = "somethingelse"
|
||||
|
||||
r = await async_requests.get(url, **kwargs)
|
||||
assert r.status_code == 403
|
||||
response = r.json()
|
||||
message = response["message"]
|
||||
assert expected_message in message
|
||||
|
||||
|
||||
# --------------
|
||||
# User API tests
|
||||
# --------------
|
||||
@@ -231,20 +268,22 @@ def max_page_limit(app):
|
||||
@mark.user
|
||||
@mark.role
|
||||
@mark.parametrize(
|
||||
"n, offset, limit, accepts_pagination, expected_count",
|
||||
"n, offset, limit, accepts_pagination, expected_count, include_stopped_servers",
|
||||
[
|
||||
(10, None, None, False, 10),
|
||||
(10, None, None, True, 10),
|
||||
(10, 5, None, True, 5),
|
||||
(10, 5, None, False, 5),
|
||||
(10, 5, 1, True, 1),
|
||||
(10, 10, 10, True, 0),
|
||||
(10, None, None, False, 10, False),
|
||||
(10, None, None, True, 10, False),
|
||||
(10, 5, None, True, 5, False),
|
||||
(10, 5, None, False, 5, False),
|
||||
(10, None, 5, True, 5, True),
|
||||
(10, 5, 1, True, 1, True),
|
||||
(10, 10, 10, True, 0, False),
|
||||
( # default page limit, pagination expected
|
||||
30,
|
||||
None,
|
||||
None,
|
||||
True,
|
||||
'default',
|
||||
False,
|
||||
),
|
||||
(
|
||||
# default max page limit, pagination not expected
|
||||
@@ -253,6 +292,7 @@ def max_page_limit(app):
|
||||
None,
|
||||
False,
|
||||
'max',
|
||||
False,
|
||||
),
|
||||
(
|
||||
# limit exceeded
|
||||
@@ -261,6 +301,7 @@ def max_page_limit(app):
|
||||
500,
|
||||
False,
|
||||
'max',
|
||||
False,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -273,6 +314,7 @@ async def test_get_users_pagination(
|
||||
expected_count,
|
||||
default_page_limit,
|
||||
max_page_limit,
|
||||
include_stopped_servers,
|
||||
):
|
||||
db = app.db
|
||||
|
||||
@@ -299,6 +341,11 @@ async def test_get_users_pagination(
|
||||
if limit:
|
||||
params['limit'] = limit
|
||||
url = url_concat(url, params)
|
||||
if include_stopped_servers:
|
||||
# assumes limit is set. There doesn't seem to be a way to set valueless query
|
||||
# params using url_cat
|
||||
url += "&include_stopped_servers"
|
||||
|
||||
headers = auth_header(db, 'admin')
|
||||
if accepts_pagination:
|
||||
headers['Accept'] = PAGINATION_MEDIA_TYPE
|
||||
@@ -311,6 +358,11 @@ async def test_get_users_pagination(
|
||||
"_pagination",
|
||||
}
|
||||
pagination = response["_pagination"]
|
||||
if include_stopped_servers and pagination["next"]:
|
||||
next_query = parse_qs(
|
||||
urlparse(pagination["next"]["url"]).query, keep_blank_values=True
|
||||
)
|
||||
assert "include_stopped_servers" in next_query
|
||||
users = response["items"]
|
||||
else:
|
||||
users = response
|
||||
@@ -1110,6 +1162,92 @@ async def test_progress_ready(request, app):
|
||||
assert evt['url'] == app_user.url
|
||||
|
||||
|
||||
async def test_progress_ready_hook_async_func(request, app):
|
||||
"""Test progress ready hook in Spawner class with an async function"""
|
||||
db = app.db
|
||||
name = 'saga'
|
||||
app_user = add_user(db, app=app, name=name)
|
||||
html_message = 'customized html message'
|
||||
spawner = app_user.spawner
|
||||
|
||||
async def custom_progress_ready_hook(spawner, ready_event):
|
||||
ready_event['html_message'] = html_message
|
||||
return ready_event
|
||||
|
||||
spawner.progress_ready_hook = custom_progress_ready_hook
|
||||
r = await api_request(app, 'users', name, 'server', method='post')
|
||||
r.raise_for_status()
|
||||
r = await api_request(app, 'users', name, 'server/progress', stream=True)
|
||||
r.raise_for_status()
|
||||
request.addfinalizer(r.close)
|
||||
assert r.headers['content-type'] == 'text/event-stream'
|
||||
ex = async_requests.executor
|
||||
line_iter = iter(r.iter_lines(decode_unicode=True))
|
||||
evt = await ex.submit(next_event, line_iter)
|
||||
assert evt['progress'] == 100
|
||||
assert evt['ready']
|
||||
assert evt['url'] == app_user.url
|
||||
assert evt['html_message'] == html_message
|
||||
|
||||
|
||||
async def test_progress_ready_hook_sync_func(request, app):
|
||||
"""Test progress ready hook in Spawner class with a sync function"""
|
||||
db = app.db
|
||||
name = 'saga'
|
||||
app_user = add_user(db, app=app, name=name)
|
||||
html_message = 'customized html message'
|
||||
spawner = app_user.spawner
|
||||
|
||||
def custom_progress_ready_hook(spawner, ready_event):
|
||||
ready_event['html_message'] = html_message
|
||||
return ready_event
|
||||
|
||||
spawner.progress_ready_hook = custom_progress_ready_hook
|
||||
r = await api_request(app, 'users', name, 'server', method='post')
|
||||
r.raise_for_status()
|
||||
r = await api_request(app, 'users', name, 'server/progress', stream=True)
|
||||
r.raise_for_status()
|
||||
request.addfinalizer(r.close)
|
||||
assert r.headers['content-type'] == 'text/event-stream'
|
||||
ex = async_requests.executor
|
||||
line_iter = iter(r.iter_lines(decode_unicode=True))
|
||||
evt = await ex.submit(next_event, line_iter)
|
||||
assert evt['progress'] == 100
|
||||
assert evt['ready']
|
||||
assert evt['url'] == app_user.url
|
||||
assert evt['html_message'] == html_message
|
||||
|
||||
|
||||
async def test_progress_ready_hook_async_func_exception(request, app):
|
||||
"""Test progress ready hook in Spawner class with an exception in
|
||||
an async function
|
||||
"""
|
||||
db = app.db
|
||||
name = 'saga'
|
||||
app_user = add_user(db, app=app, name=name)
|
||||
html_message = 'Server ready at <a href="{0}">{0}</a>'.format(app_user.url)
|
||||
spawner = app_user.spawner
|
||||
|
||||
async def custom_progress_ready_hook(spawner, ready_event):
|
||||
ready_event["html_message"] = "."
|
||||
raise Exception()
|
||||
|
||||
spawner.progress_ready_hook = custom_progress_ready_hook
|
||||
r = await api_request(app, 'users', name, 'server', method='post')
|
||||
r.raise_for_status()
|
||||
r = await api_request(app, 'users', name, 'server/progress', stream=True)
|
||||
r.raise_for_status()
|
||||
request.addfinalizer(r.close)
|
||||
assert r.headers['content-type'] == 'text/event-stream'
|
||||
ex = async_requests.executor
|
||||
line_iter = iter(r.iter_lines(decode_unicode=True))
|
||||
evt = await ex.submit(next_event, line_iter)
|
||||
assert evt['progress'] == 100
|
||||
assert evt['ready']
|
||||
assert evt['url'] == app_user.url
|
||||
assert evt['html_message'] == html_message
|
||||
|
||||
|
||||
async def test_progress_bad(request, app, bad_spawn):
|
||||
"""Test progress API when spawner has already failed"""
|
||||
db = app.db
|
||||
@@ -1717,7 +1855,7 @@ async def test_group_get(app):
|
||||
app.db.commit()
|
||||
group = orm.Group.find(app.db, name='alphaflight')
|
||||
user = add_user(app.db, app=app, name='sasquatch')
|
||||
group.users.append(user)
|
||||
group.users.append(user.orm_user)
|
||||
app.db.commit()
|
||||
|
||||
r = await api_request(app, 'groups/runaways')
|
||||
@@ -1875,7 +2013,7 @@ async def test_group_properties(app, group):
|
||||
|
||||
@mark.group
|
||||
async def test_auth_managed_groups(request, app, group, user):
|
||||
group.users.append(user)
|
||||
group.users.append(user.orm_user)
|
||||
app.db.commit()
|
||||
app.authenticator.manage_groups = True
|
||||
request.addfinalizer(lambda: setattr(app.authenticator, "manage_groups", False))
|
||||
@@ -1941,7 +2079,7 @@ async def test_get_services(app, mockservice_url):
|
||||
async def test_get_service(app, mockservice_url):
|
||||
mockservice = mockservice_url
|
||||
db = app.db
|
||||
r = await api_request(app, 'services/%s' % mockservice.name)
|
||||
r = await api_request(app, f"services/{mockservice.name}")
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -1960,19 +2098,271 @@ async def test_get_service(app, mockservice_url):
|
||||
}
|
||||
r = await api_request(
|
||||
app,
|
||||
'services/%s' % mockservice.name,
|
||||
f"services/{mockservice.name}",
|
||||
headers={'Authorization': 'token %s' % mockservice.api_token},
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
r = await api_request(
|
||||
app, 'services/%s' % mockservice.name, headers=auth_header(db, 'user')
|
||||
app, f"services/{mockservice.name}", headers=auth_header(db, 'user')
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
r = await api_request(app, "services/nosuchservice")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service_admin_user(create_user_with_scopes):
|
||||
return create_user_with_scopes('admin:services')
|
||||
|
||||
|
||||
@mark.services
|
||||
async def test_create_service(app, service_admin_user, service_name, service_data):
|
||||
db = app.db
|
||||
r = await api_request(
|
||||
app,
|
||||
f'services/{service_name}',
|
||||
headers=auth_header(db, service_admin_user.name),
|
||||
data=json.dumps(service_data),
|
||||
method='post',
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 201
|
||||
assert r.json()['name'] == service_name
|
||||
orm_service = orm.Service.find(db, service_name)
|
||||
assert orm_service is not None
|
||||
|
||||
oath_client = (
|
||||
db.query(orm.OAuthClient)
|
||||
.filter_by(identifier=service_data['oauth_client_id'])
|
||||
.first()
|
||||
)
|
||||
assert oath_client.redirect_uri == service_data['oauth_redirect_uri']
|
||||
|
||||
assert service_name in app._service_map
|
||||
assert (
|
||||
app._service_map[service_name].oauth_no_confirm
|
||||
== service_data['oauth_no_confirm']
|
||||
)
|
||||
|
||||
|
||||
@mark.services
|
||||
async def test_create_service_no_role(app, service_name, service_data):
|
||||
db = app.db
|
||||
r = await api_request(
|
||||
app,
|
||||
f'services/{service_name}',
|
||||
headers=auth_header(db, 'user'),
|
||||
data=json.dumps(service_data),
|
||||
method='post',
|
||||
)
|
||||
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@mark.services
|
||||
async def test_create_service_conflict(
|
||||
app, service_admin_user, mockservice, service_data, service_name
|
||||
):
|
||||
db = app.db
|
||||
app.services = [{'name': service_name}]
|
||||
app.init_services()
|
||||
|
||||
r = await api_request(
|
||||
app,
|
||||
f'services/{service_name}',
|
||||
headers=auth_header(db, service_admin_user.name),
|
||||
data=json.dumps(service_data),
|
||||
method='post',
|
||||
)
|
||||
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
@mark.services
|
||||
async def test_create_service_duplication(
|
||||
app, service_admin_user, service_name, service_data
|
||||
):
|
||||
db = app.db
|
||||
|
||||
r = await api_request(
|
||||
app,
|
||||
f'services/{service_name}',
|
||||
headers=auth_header(db, service_admin_user.name),
|
||||
data=json.dumps(service_data),
|
||||
method='post',
|
||||
)
|
||||
assert r.status_code == 201
|
||||
|
||||
r = await api_request(
|
||||
app,
|
||||
f'services/{service_name}',
|
||||
headers=auth_header(db, service_admin_user.name),
|
||||
data=json.dumps(service_data),
|
||||
method='post',
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
@mark.services
|
||||
async def test_create_managed_service(
|
||||
app, service_admin_user, service_name, service_data
|
||||
):
|
||||
db = app.db
|
||||
managed_service_data = deepcopy(service_data)
|
||||
managed_service_data['command'] = ['foo']
|
||||
r = await api_request(
|
||||
app,
|
||||
f'services/{service_name}',
|
||||
headers=auth_header(db, service_admin_user.name),
|
||||
data=json.dumps(managed_service_data),
|
||||
method='post',
|
||||
)
|
||||
|
||||
assert r.status_code == 400
|
||||
assert 'Can not create managed service' in r.json()['message']
|
||||
orm_service = orm.Service.find(db, service_name)
|
||||
assert orm_service is None
|
||||
|
||||
|
||||
@mark.services
|
||||
async def test_create_admin_service(app, admin_user, service_name, service_data):
|
||||
db = app.db
|
||||
managed_service_data = deepcopy(service_data)
|
||||
managed_service_data['admin'] = True
|
||||
r = await api_request(
|
||||
app,
|
||||
f'services/{service_name}',
|
||||
headers=auth_header(db, admin_user.name),
|
||||
data=json.dumps(managed_service_data),
|
||||
method='post',
|
||||
)
|
||||
|
||||
assert r.status_code == 201
|
||||
orm_service = orm.Service.find(db, service_name)
|
||||
assert orm_service is not None
|
||||
|
||||
|
||||
@mark.services
|
||||
async def test_create_admin_service_without_admin_right(
|
||||
app, service_admin_user, service_data, service_name
|
||||
):
|
||||
db = app.db
|
||||
managed_service_data = deepcopy(service_data)
|
||||
managed_service_data['admin'] = True
|
||||
r = await api_request(
|
||||
app,
|
||||
f'services/{service_name}',
|
||||
headers=auth_header(db, service_admin_user.name),
|
||||
data=json.dumps(managed_service_data),
|
||||
method='post',
|
||||
)
|
||||
|
||||
assert r.status_code == 400
|
||||
assert 'Not assigning requested scopes' in r.json()['message']
|
||||
orm_service = orm.Service.find(db, service_name)
|
||||
assert orm_service is None
|
||||
|
||||
|
||||
@mark.services
|
||||
async def test_create_service_with_scope(
|
||||
app, create_user_with_scopes, service_name, service_data
|
||||
):
|
||||
db = app.db
|
||||
managed_service_data = deepcopy(service_data)
|
||||
managed_service_data['oauth_client_allowed_scopes'] = ["admin:users"]
|
||||
managed_service_data['oauth_client_id'] = "service-client-with-scope"
|
||||
user_with_scope = create_user_with_scopes('admin:services', 'admin:users')
|
||||
r = await api_request(
|
||||
app,
|
||||
f'services/{service_name}',
|
||||
headers=auth_header(db, user_with_scope.name),
|
||||
data=json.dumps(managed_service_data),
|
||||
method='post',
|
||||
)
|
||||
|
||||
assert r.status_code == 201
|
||||
orm_service = orm.Service.find(db, service_name)
|
||||
assert orm_service is not None
|
||||
|
||||
|
||||
@mark.services
|
||||
async def test_create_service_without_requested_scope(
|
||||
app,
|
||||
service_admin_user,
|
||||
service_data,
|
||||
service_name,
|
||||
):
|
||||
db = app.db
|
||||
managed_service_data = deepcopy(service_data)
|
||||
managed_service_data['oauth_client_allowed_scopes'] = ["admin:users"]
|
||||
r = await api_request(
|
||||
app,
|
||||
f'services/{service_name}',
|
||||
headers=auth_header(db, service_admin_user.name),
|
||||
data=json.dumps(managed_service_data),
|
||||
method='post',
|
||||
)
|
||||
|
||||
assert r.status_code == 400
|
||||
assert 'Not assigning requested scopes' in r.json()['message']
|
||||
orm_service = orm.Service.find(db, service_name)
|
||||
assert orm_service is None
|
||||
|
||||
|
||||
@mark.services
|
||||
async def test_delete_service(app, service_admin_user, service_name, service_data):
|
||||
db = app.db
|
||||
r = await api_request(
|
||||
app,
|
||||
f'services/{service_name}',
|
||||
headers=auth_header(db, service_admin_user.name),
|
||||
data=json.dumps(service_data),
|
||||
method='post',
|
||||
)
|
||||
assert r.status_code == 201
|
||||
|
||||
r = await api_request(
|
||||
app,
|
||||
f'services/{service_name}',
|
||||
headers=auth_header(db, service_admin_user.name),
|
||||
method='delete',
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
orm_service = orm.Service.find(db, service_name)
|
||||
assert orm_service is None
|
||||
|
||||
oath_client = (
|
||||
db.query(orm.OAuthClient)
|
||||
.filter_by(identifier=service_data['oauth_client_id'])
|
||||
.first()
|
||||
)
|
||||
assert oath_client is None
|
||||
|
||||
assert service_name not in app._service_map
|
||||
|
||||
r = await api_request(app, f"services/{service_name}", method="delete")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@mark.services
|
||||
async def test_delete_service_from_config(app, service_admin_user, mockservice):
|
||||
db = app.db
|
||||
service_name = mockservice.name
|
||||
r = await api_request(
|
||||
app,
|
||||
f'services/{service_name}',
|
||||
headers=auth_header(db, service_admin_user.name),
|
||||
method='delete',
|
||||
)
|
||||
assert r.status_code == 405
|
||||
assert r.json()['message'] == f'Service {service_name} is not modifiable at runtime'
|
||||
|
||||
|
||||
async def test_root_api(app):
|
||||
base_url = app.hub.url
|
||||
url = ujoin(base_url, 'api')
|
||||
kwargs = {}
|
||||
if app.internal_ssl:
|
||||
kwargs['cert'] = (app.internal_ssl_cert, app.internal_ssl_key)
|
||||
|
@@ -220,7 +220,7 @@ def test_cookie_secret_env(tmpdir, request):
|
||||
assert not os.path.exists(hub.cookie_secret_file)
|
||||
|
||||
|
||||
def test_cookie_secret_string_():
|
||||
def test_cookie_secret_string():
|
||||
cfg = Config()
|
||||
|
||||
cfg.JupyterHub.cookie_secret = "not hex"
|
||||
@@ -251,7 +251,6 @@ async def test_load_groups(tmpdir, request):
|
||||
hub.init_db()
|
||||
db = hub.db
|
||||
await hub.init_role_creation()
|
||||
|
||||
await hub.init_users()
|
||||
await hub.init_groups()
|
||||
|
||||
@@ -271,18 +270,41 @@ async def test_load_groups(tmpdir, request):
|
||||
)
|
||||
|
||||
|
||||
async def test_resume_spawners(tmpdir, request):
|
||||
if not os.getenv('JUPYTERHUB_TEST_DB_URL'):
|
||||
p = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'JUPYTERHUB_TEST_DB_URL': 'sqlite:///%s'
|
||||
% tmpdir.join('jupyterhub.sqlite')
|
||||
},
|
||||
)
|
||||
p.start()
|
||||
request.addfinalizer(p.stop)
|
||||
@pytest.fixture
|
||||
def persist_db(tmpdir):
|
||||
"""ensure db will persist (overrides default sqlite://:memory:)"""
|
||||
if os.getenv('JUPYTERHUB_TEST_DB_URL'):
|
||||
# already using a db, no need
|
||||
yield
|
||||
return
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{'JUPYTERHUB_TEST_DB_URL': f"sqlite:///{tmpdir.join('jupyterhub.sqlite')}"},
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def new_hub(request, tmpdir, persist_db):
|
||||
"""Fixture to launch a new hub for testing"""
|
||||
|
||||
async def new_hub():
|
||||
kwargs = {}
|
||||
ssl_enabled = getattr(request.module, "ssl_enabled", False)
|
||||
if ssl_enabled:
|
||||
kwargs['internal_certs_location'] = str(tmpdir)
|
||||
app = MockHub(test_clean_db=False, **kwargs)
|
||||
app.config.ConfigurableHTTPProxy.should_start = False
|
||||
app.config.ConfigurableHTTPProxy.auth_token = 'unused'
|
||||
request.addfinalizer(app.stop)
|
||||
await app.initialize([])
|
||||
|
||||
return app
|
||||
|
||||
return new_hub
|
||||
|
||||
|
||||
async def test_resume_spawners(tmpdir, request, new_hub):
|
||||
async def new_hub():
|
||||
kwargs = {}
|
||||
ssl_enabled = getattr(request.module, "ssl_enabled", False)
|
||||
@@ -435,3 +457,81 @@ def test_launch_instance(request, argv, sys_argv):
|
||||
assert hub.argv == sys_argv[1:]
|
||||
else:
|
||||
assert hub.argv == argv
|
||||
|
||||
|
||||
async def test_user_creation(tmpdir, request):
|
||||
allowed_users = {"in-allowed", "in-group-in-allowed", "in-role-in-allowed"}
|
||||
groups = {
|
||||
"group": {
|
||||
"users": ["in-group", "in-group-in-allowed"],
|
||||
}
|
||||
}
|
||||
roles = [
|
||||
{
|
||||
"name": "therole",
|
||||
"users": ["in-role", "in-role-in-allowed"],
|
||||
}
|
||||
]
|
||||
|
||||
cfg = Config()
|
||||
cfg.Authenticator.allowed_users = allowed_users
|
||||
cfg.JupyterHub.load_groups = groups
|
||||
cfg.JupyterHub.load_roles = roles
|
||||
ssl_enabled = getattr(request.module, "ssl_enabled", False)
|
||||
kwargs = dict(config=cfg)
|
||||
if ssl_enabled:
|
||||
kwargs['internal_certs_location'] = str(tmpdir)
|
||||
hub = MockHub(**kwargs)
|
||||
hub.init_db()
|
||||
|
||||
await hub.init_role_creation()
|
||||
await hub.init_role_assignment()
|
||||
await hub.init_users()
|
||||
await hub.init_groups()
|
||||
assert hub.authenticator.allowed_users == {
|
||||
"admin", # added by default config
|
||||
"in-allowed",
|
||||
"in-group-in-allowed",
|
||||
"in-role-in-allowed",
|
||||
"in-group",
|
||||
"in-role",
|
||||
}
|
||||
|
||||
|
||||
async def test_recreate_service_from_database(
|
||||
request, new_hub, service_name, service_data
|
||||
):
|
||||
# create a hub and add a service (not from config)
|
||||
app = await new_hub()
|
||||
app.service_from_spec(service_data, from_config=False)
|
||||
app.stop()
|
||||
|
||||
# new hub, should load service from db
|
||||
app = await new_hub()
|
||||
assert service_name in app._service_map
|
||||
|
||||
# verify keys
|
||||
service = app._service_map[service_name]
|
||||
for key, value in service_data.items():
|
||||
if key in {'api_token'}:
|
||||
# skip some keys
|
||||
continue
|
||||
assert getattr(service, key) == value
|
||||
|
||||
assert (
|
||||
service_data['oauth_client_id'] in app.tornado_settings['oauth_no_confirm_list']
|
||||
)
|
||||
oauth_client = (
|
||||
app.db.query(orm.OAuthClient)
|
||||
.filter_by(identifier=service_data['oauth_client_id'])
|
||||
.first()
|
||||
)
|
||||
assert oauth_client.redirect_uri == service_data['oauth_redirect_uri']
|
||||
|
||||
# delete service from db, start one more
|
||||
app.db.delete(service.orm)
|
||||
app.db.commit()
|
||||
|
||||
# start one more, service should be gone
|
||||
app = await new_hub()
|
||||
assert service_name not in app._service_map
|
||||
|
@@ -105,7 +105,8 @@ async def test_pam_auth_admin_groups():
|
||||
_getgrouplist=getgrouplist,
|
||||
):
|
||||
authorized = await authenticator.get_authenticated_user(
|
||||
None, {'username': 'also_group_admin', 'password': 'also_group_admin'}
|
||||
None,
|
||||
{'username': 'also_group_admin', 'password': 'also_group_admin'},
|
||||
)
|
||||
assert authorized['name'] == 'also_group_admin'
|
||||
assert authorized['admin'] is True
|
||||
@@ -118,7 +119,8 @@ async def test_pam_auth_admin_groups():
|
||||
_getgrouplist=getgrouplist,
|
||||
):
|
||||
authorized = await authenticator.get_authenticated_user(
|
||||
None, {'username': 'override_admin', 'password': 'override_admin'}
|
||||
None,
|
||||
{'username': 'override_admin', 'password': 'override_admin'},
|
||||
)
|
||||
assert authorized['name'] == 'override_admin'
|
||||
assert authorized['admin'] is True
|
||||
|
@@ -44,7 +44,9 @@ def generate_old_db(env_dir, hub_version, db_url):
|
||||
|
||||
# changes to this version list must also be reflected
|
||||
# in ci/init-db.sh
|
||||
@pytest.mark.parametrize('hub_version', ["1.1.0", "1.2.2", "1.3.0", "1.5.0", "2.1.1"])
|
||||
@pytest.mark.parametrize(
|
||||
'hub_version', ['1.1.0', '1.2.2', '1.3.0', '1.5.0', '2.1.1', '3.1.1']
|
||||
)
|
||||
async def test_upgrade(tmpdir, hub_version):
|
||||
db_url = os.getenv('JUPYTERHUB_TEST_DB_URL')
|
||||
if db_url:
|
||||
|
@@ -10,6 +10,18 @@ from ..utils import utcnow
|
||||
from .utils import add_user, api_request, get_page
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"metric_object, expected_names",
|
||||
[
|
||||
(metrics.TOTAL_USERS, ['jupyterhub_total_users']),
|
||||
(metrics.REQUEST_DURATION_SECONDS, ['jupyterhub_request_duration_seconds']),
|
||||
],
|
||||
)
|
||||
def test_metric_names(metric_object, expected_names):
|
||||
for metric, expected_name in zip(metric_object.describe(), expected_names):
|
||||
assert metric.name == expected_name
|
||||
|
||||
|
||||
async def test_total_users(app):
|
||||
num_users = app.db.query(orm.User).count()
|
||||
sample = metrics.TOTAL_USERS.collect()[0].samples[0]
|
||||
|
@@ -549,7 +549,11 @@ def test_expiring_oauth_code(app, user):
|
||||
db = app.db
|
||||
code = "abc123"
|
||||
now = orm.OAuthCode.now
|
||||
orm_code = orm.OAuthCode(code=code, expires_at=now() + 30)
|
||||
client = orm.OAuthClient(
|
||||
identifier="expiring_oauth_code", secret="expiring_oauth_code-yyy"
|
||||
)
|
||||
db.add(client)
|
||||
orm_code = orm.OAuthCode(code=code, expires_at=now() + 30, client=client, user=user)
|
||||
db.add(orm_code)
|
||||
db.commit()
|
||||
|
||||
|
@@ -220,15 +220,17 @@ async def test_spawn_other_user(
|
||||
cookies = await app.login_user(username)
|
||||
requester = app.users[username]
|
||||
name = user.name
|
||||
assert username != user.name
|
||||
|
||||
if has_access:
|
||||
if has_access == "group":
|
||||
group.users.append(user)
|
||||
group.users.append(user.orm_user)
|
||||
app.db.commit()
|
||||
scopes = [
|
||||
f"access:servers!group={group.name}",
|
||||
f"servers!group={group.name}",
|
||||
]
|
||||
assert group in user.orm_user.groups
|
||||
elif has_access == "all":
|
||||
scopes = ["access:servers", "servers"]
|
||||
elif has_access == "user":
|
||||
@@ -305,7 +307,7 @@ async def test_spawn_page_access(
|
||||
requester = app.users[username]
|
||||
if has_access:
|
||||
if has_access == "group":
|
||||
group.users.append(user)
|
||||
group.users.append(user.orm_user)
|
||||
app.db.commit()
|
||||
scopes = [
|
||||
f"access:servers!group={group.name}",
|
||||
@@ -408,7 +410,7 @@ async def test_spawn_form_other_user(
|
||||
requester = app.users[username]
|
||||
if has_access:
|
||||
if has_access == "group":
|
||||
group.users.append(user)
|
||||
group.users.append(user.orm_user)
|
||||
app.db.commit()
|
||||
scopes = [
|
||||
f"access:servers!group={group.name}",
|
||||
@@ -635,7 +637,7 @@ async def test_other_user_url(app, username, user, group, create_temp_role, has_
|
||||
other_user_url = f"/user/{other_user.name}"
|
||||
if has_access:
|
||||
if has_access == "group":
|
||||
group.users.append(other_user)
|
||||
group.users.append(other_user.orm_user)
|
||||
app.db.commit()
|
||||
scopes = [f"access:servers!group={group.name}"]
|
||||
elif has_access == "all":
|
||||
|
@@ -236,6 +236,16 @@ def test_orm_roles_delete_cascade(db):
|
||||
['tokens!group=hobbits'],
|
||||
{'tokens!group=hobbits', 'read:tokens!group=hobbits'},
|
||||
),
|
||||
(
|
||||
['admin:services'],
|
||||
{
|
||||
'read:roles:services',
|
||||
'read:services:name',
|
||||
'admin:services',
|
||||
'list:services',
|
||||
'read:services',
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_expanded_scopes(db, scopes, expected_scopes):
|
||||
|
@@ -383,7 +383,7 @@ async def test_user_filter_with_group(app, create_user_with_scopes):
|
||||
group = orm.Group(name=group_name)
|
||||
app.db.add(group)
|
||||
for user in {user1, user2}:
|
||||
group.users.append(user)
|
||||
group.users.append(user.orm_user)
|
||||
app.db.commit()
|
||||
|
||||
r = await api_request(app, 'users', headers=auth_header(app.db, user1.name))
|
||||
@@ -548,7 +548,7 @@ async def test_server_state_access(
|
||||
if not group:
|
||||
group = orm.Group(name=group_name)
|
||||
app.db.add(group)
|
||||
group.users.append(user)
|
||||
group.users.append(user.orm_user)
|
||||
app.db.commit()
|
||||
server_names = ['bianca', 'terry']
|
||||
for server_name in server_names:
|
||||
@@ -974,7 +974,7 @@ async def test_list_users_filter(
|
||||
# create users:
|
||||
for i in (1, 2):
|
||||
user = add_user(app.db, app, name=f'in-{i}')
|
||||
group.users.append(user)
|
||||
group.users.append(user.orm_user)
|
||||
add_user(app.db, app, name=f'out-{i}')
|
||||
app.db.commit()
|
||||
|
||||
|
@@ -2,6 +2,7 @@
|
||||
import os
|
||||
import sys
|
||||
from contextlib import nullcontext
|
||||
from pprint import pprint
|
||||
from subprocess import CalledProcessError, check_output
|
||||
from unittest import mock
|
||||
from urllib.parse import urlencode, urlparse
|
||||
@@ -16,6 +17,8 @@ from ..utils import url_path_join
|
||||
from .mocking import public_url
|
||||
from .utils import AsyncSession, async_requests, get_page
|
||||
|
||||
IS_JUPYVERSE = os.environ.get("JUPYTERHUB_SINGLEUSER_APP") == "jupyverse"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_scopes, server_name, expect_success",
|
||||
@@ -62,6 +65,8 @@ async def test_singleuser_auth(
|
||||
await user.spawn(server_name)
|
||||
await app.proxy.add_user(user, server_name)
|
||||
spawner = user.spawners[server_name]
|
||||
if IS_JUPYVERSE:
|
||||
spawner.default_url = "/lab"
|
||||
url = url_path_join(public_url(app, user), server_name)
|
||||
|
||||
# no cookies, redirects to login page
|
||||
@@ -132,6 +137,9 @@ async def test_singleuser_auth(
|
||||
await user.stop(server_name)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
IS_JUPYVERSE, reason="jupyverse doesn't look up directories for configuration files"
|
||||
)
|
||||
async def test_disable_user_config(request, app, tmpdir, full_spawn):
|
||||
# login, start the server
|
||||
cookies = await app.login_user('nandy')
|
||||
@@ -171,9 +179,7 @@ async def test_disable_user_config(request, app, tmpdir, full_spawn):
|
||||
)
|
||||
r.raise_for_status()
|
||||
info = r.json()
|
||||
import pprint
|
||||
|
||||
pprint.pprint(info)
|
||||
pprint(info)
|
||||
assert info['disable_user_config']
|
||||
server_config = info['config']
|
||||
settings = info['settings']
|
||||
@@ -198,6 +204,83 @@ async def test_disable_user_config(request, app, tmpdir, full_spawn):
|
||||
assert_not_in_home(path, key)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extension", [True, False])
|
||||
@pytest.mark.parametrize("notebook_dir", ["", "~", "~/sub", "ABS"])
|
||||
@pytest.mark.skipif(
|
||||
IS_JUPYVERSE, reason="jupyverse has not notebook directory configuration"
|
||||
)
|
||||
async def test_notebook_dir(
|
||||
request, app, tmpdir, user, full_spawn, extension, notebook_dir
|
||||
):
|
||||
if extension:
|
||||
try:
|
||||
import jupyter_server # noqa
|
||||
except ImportError:
|
||||
pytest.skip("needs jupyter-server 2")
|
||||
else:
|
||||
if jupyter_server.version_info < (2,):
|
||||
pytest.skip("needs jupyter-server 2")
|
||||
|
||||
token = user.new_api_token(scopes=["access:servers!user"])
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
spawner = user.spawner
|
||||
if extension:
|
||||
user.spawner.environment["JUPYTERHUB_SINGLEUSER_EXTENSION"] = "1"
|
||||
else:
|
||||
user.spawner.environment["JUPYTERHUB_SINGLEUSER_EXTENSION"] = "0"
|
||||
|
||||
home_dir = tmpdir.join("home").mkdir()
|
||||
sub_dir = home_dir.join("sub").mkdir()
|
||||
with sub_dir.join("subfile.txt").open("w") as f:
|
||||
f.write("txt\n")
|
||||
abs_dir = tmpdir.join("abs").mkdir()
|
||||
with abs_dir.join("absfile.txt").open("w") as f:
|
||||
f.write("absfile\n")
|
||||
|
||||
if notebook_dir:
|
||||
expected_root_dir = notebook_dir.replace("ABS", str(abs_dir)).replace(
|
||||
"~", str(home_dir)
|
||||
)
|
||||
else:
|
||||
expected_root_dir = str(home_dir)
|
||||
|
||||
spawner.notebook_dir = notebook_dir.replace("ABS", str(abs_dir))
|
||||
|
||||
# home_dir is defined on SimpleSpawner
|
||||
user.spawner.home_dir = home = str(home_dir)
|
||||
spawner.environment["HOME"] = home
|
||||
await user.spawn()
|
||||
await app.proxy.add_user(user)
|
||||
url = public_url(app, user)
|
||||
r = await async_requests.get(
|
||||
url_path_join(public_url(app, user), 'jupyterhub-test-info'), headers=headers
|
||||
)
|
||||
r.raise_for_status()
|
||||
info = r.json()
|
||||
pprint(info)
|
||||
|
||||
assert info["root_dir"] == expected_root_dir
|
||||
# secondary check: make sure it has the intended effect on root_dir
|
||||
r = await async_requests.get(
|
||||
url_path_join(public_url(app, user), 'api/contents/'), headers=headers
|
||||
)
|
||||
r.raise_for_status()
|
||||
root_contents = sorted(item['name'] for item in r.json()['content'])
|
||||
|
||||
# check contents
|
||||
if not notebook_dir or notebook_dir == "~":
|
||||
# use any to avoid counting possible automatically created files in $HOME
|
||||
assert 'sub' in root_contents
|
||||
elif notebook_dir == "ABS":
|
||||
assert 'absfile.txt' in root_contents
|
||||
elif notebook_dir == "~/sub":
|
||||
assert 'subfile.txt' in root_contents
|
||||
else:
|
||||
raise ValueError(f"No contents check for {notebook_dir=}")
|
||||
|
||||
|
||||
@pytest.mark.skipif(IS_JUPYVERSE, reason="jupyverse has no --help-all")
|
||||
def test_help_output():
|
||||
out = check_output(
|
||||
[sys.executable, '-m', 'jupyterhub.singleuser', '--help-all']
|
||||
@@ -205,6 +288,7 @@ def test_help_output():
|
||||
assert 'JupyterHub' in out
|
||||
|
||||
|
||||
@pytest.mark.skipif(IS_JUPYVERSE, reason="jupyverse has not --version")
|
||||
def test_version():
|
||||
out = check_output(
|
||||
[sys.executable, '-m', 'jupyterhub.singleuser', '--version']
|
||||
@@ -271,6 +355,7 @@ def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
|
||||
assert '--NotebookApp.' not in out
|
||||
|
||||
|
||||
@pytest.mark.skipif(IS_JUPYVERSE, reason="nbclassic specific test")
|
||||
async def test_nbclassic_control_panel(app, user, full_spawn):
|
||||
# login, start the server
|
||||
await user.spawn()
|
||||
|
@@ -122,3 +122,46 @@ def test_browser_protocol(x_scheme, x_forwarded_proto, forwarded, expected):
|
||||
|
||||
proto = utils.get_browser_protocol(request)
|
||||
assert proto == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name, expected",
|
||||
[
|
||||
("safe", "safe"),
|
||||
("has--doubledash", "u-hasdoubl--cb052ae"),
|
||||
("uhasdoubl--cb052ae", "u-uhasdoub--3c0d1c9"),
|
||||
("üni", "xn--ni-wka"),
|
||||
("xn--ni-wka", "u-xnniwka--ceb4edd"),
|
||||
("x", "x"),
|
||||
("-pre", "u-pre--0e46e7b"),
|
||||
("É", "u-x--a755f65"),
|
||||
("é", "xn--9ca"),
|
||||
("a" * 64, "u-aaaaaaaa--ffe054f"),
|
||||
("a.b", "u-ab--2e7336d"),
|
||||
],
|
||||
)
|
||||
def test_subdomain_hook_idna(name, expected):
|
||||
expected_domain = expected + ".domain"
|
||||
resolved = utils.subdomain_hook_idna(name, "domain", "user")
|
||||
assert resolved == expected_domain
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name, expected",
|
||||
[
|
||||
("safe", "safe"),
|
||||
("üni", "_c3_bcni"),
|
||||
("x", "x"),
|
||||
("É", "_c3_89"),
|
||||
("é", "_c3_a9"),
|
||||
# bad cases:
|
||||
("a.b", "a.b"),
|
||||
("has--doubledash", "has--doubledash"),
|
||||
("-pre", "-pre"),
|
||||
("a" * 64, "a" * 64),
|
||||
],
|
||||
)
|
||||
def test_subdomain_hook_legacy(name, expected):
|
||||
expected_domain = expected + ".domain"
|
||||
resolved = utils.subdomain_hook_legacy(name, "domain", "user")
|
||||
assert resolved == expected_domain
|
||||
|
@@ -1,11 +1,9 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import json
|
||||
import string
|
||||
import warnings
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from functools import lru_cache
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
from sqlalchemy import inspect
|
||||
@@ -21,8 +19,10 @@ from .objects import Server
|
||||
from .spawner import LocalProcessSpawner
|
||||
from .utils import (
|
||||
AnyTimeoutError,
|
||||
_strict_dns_safe,
|
||||
make_ssl_context,
|
||||
maybe_future,
|
||||
subdomain_hook_legacy,
|
||||
url_escape_path,
|
||||
url_path_join,
|
||||
)
|
||||
@@ -55,42 +55,6 @@ Common causes of this timeout, and debugging tips:
|
||||
to a number of seconds that is enough for servers to become responsive.
|
||||
"""
|
||||
|
||||
# set of chars that are safe in dns labels
|
||||
# (allow '.' because we don't mind multiple levels of subdomains)
|
||||
_dns_safe = set(string.ascii_letters + string.digits + '-.')
|
||||
# don't escape % because it's the escape char and we handle it separately
|
||||
_dns_needs_replace = _dns_safe | {"%"}
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def _dns_quote(name):
|
||||
"""Escape a name for use in a dns label
|
||||
|
||||
this is _NOT_ fully domain-safe, but works often enough for realistic usernames.
|
||||
Fully safe would be full IDNA encoding,
|
||||
PLUS escaping non-IDNA-legal ascii,
|
||||
PLUS some encoding of boundary conditions
|
||||
"""
|
||||
# escape name for subdomain label
|
||||
label = quote(name, safe="").lower()
|
||||
# some characters are not handled by quote,
|
||||
# because they are legal in URLs but not domains,
|
||||
# specifically _ and ~ (starting in 3.7).
|
||||
# Escape these in the same way (%{hex_codepoint}).
|
||||
unique_chars = set(label)
|
||||
for c in unique_chars:
|
||||
if c not in _dns_needs_replace:
|
||||
label = label.replace(c, f"%{ord(c):x}")
|
||||
|
||||
# underscore is our escape char -
|
||||
# it's not officially legal in hostnames,
|
||||
# but is valid in _domain_ names (?),
|
||||
# and always works in practice.
|
||||
# FIXME: We should consider switching to proper IDNA encoding
|
||||
# for 3.0.
|
||||
label = label.replace("%", "_")
|
||||
return label
|
||||
|
||||
|
||||
class UserDict(dict):
|
||||
"""Like defaultdict, but for users
|
||||
@@ -559,8 +523,19 @@ class User:
|
||||
@property
|
||||
def domain(self):
|
||||
"""Get the domain for my server."""
|
||||
hook = self.settings.get("subdomain_hook", subdomain_hook_legacy)
|
||||
return hook(self.name, self.settings['domain'], kind='user')
|
||||
|
||||
return _dns_quote(self.name) + '.' + self.settings['domain']
|
||||
@property
|
||||
def dns_safe_name(self):
|
||||
"""Get a dns-safe encoding of my name
|
||||
|
||||
- always safe value for a single DNS label
|
||||
- max 40 characters, leaving room for additional components
|
||||
|
||||
.. versionadded:: 5.0
|
||||
"""
|
||||
return _strict_dns_safe(self.name, max_length=40)
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
|
@@ -8,19 +8,23 @@ import functools
|
||||
import hashlib
|
||||
import inspect
|
||||
import random
|
||||
import re
|
||||
import secrets
|
||||
import socket
|
||||
import ssl
|
||||
import string
|
||||
import sys
|
||||
import threading
|
||||
import uuid
|
||||
import warnings
|
||||
from binascii import b2a_hex
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
from hmac import compare_digest
|
||||
from operator import itemgetter
|
||||
from urllib.parse import quote
|
||||
|
||||
import idna
|
||||
from async_generator import aclosing
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from tornado import gen, ioloop, web
|
||||
@@ -779,3 +783,153 @@ def get_browser_protocol(request):
|
||||
|
||||
# no forwarded headers
|
||||
return request.protocol
|
||||
|
||||
|
||||
# set of chars that are safe in dns labels
|
||||
# (allow '.' because we don't mind multiple levels of subdomains)
|
||||
_dns_safe = set(string.ascii_letters + string.digits + '-.')
|
||||
# don't escape % because it's the escape char and we handle it separately
|
||||
_dns_needs_replace = _dns_safe | {"%"}
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def _dns_quote(name):
|
||||
"""Escape a name for use in a dns label
|
||||
|
||||
this is _NOT_ fully domain-safe, but works often enough for realistic usernames.
|
||||
Fully safe would be full IDNA encoding,
|
||||
PLUS escaping non-IDNA-legal ascii,
|
||||
PLUS some encoding of boundary conditions
|
||||
"""
|
||||
# escape name for subdomain label
|
||||
label = quote(name, safe="").lower()
|
||||
# some characters are not handled by quote,
|
||||
# because they are legal in URLs but not domains,
|
||||
# specifically _ and ~ (starting in 3.7).
|
||||
# Escape these in the same way (%{hex_codepoint}).
|
||||
unique_chars = set(label)
|
||||
for c in unique_chars:
|
||||
if c not in _dns_needs_replace:
|
||||
label = label.replace(c, f"%{ord(c):x}")
|
||||
|
||||
# underscore is our escape char -
|
||||
# it's not officially legal in hostnames,
|
||||
# but is valid in _domain_ names (?),
|
||||
# and seems to always work in practice.
|
||||
label = label.replace("%", "_")
|
||||
return label
|
||||
|
||||
|
||||
def subdomain_hook_legacy(name, domain, kind):
|
||||
"""Legacy (default) hook for subdomains
|
||||
|
||||
Users are at '$user.$host' where $user is _mostly_ DNS-safe.
|
||||
Services are all simultaneously on 'services.$host`.
|
||||
"""
|
||||
if kind == "user":
|
||||
# backward-compatibility
|
||||
return f"{_dns_quote(name)}.{domain}"
|
||||
elif kind == "service":
|
||||
return f"services.{domain}"
|
||||
else:
|
||||
raise ValueError(f"kind must be 'service' or 'user', not {kind!r}")
|
||||
|
||||
|
||||
# strict dns-safe characters (excludes '-')
|
||||
_strict_dns_safe = set(string.ascii_lowercase) | set(string.digits)
|
||||
|
||||
|
||||
def _trim_and_hash(name):
|
||||
"""Always-safe fallback for a DNS label
|
||||
|
||||
Produces a valid and unique DNS label for any string
|
||||
|
||||
- prefix with 'u-' to avoid collisions and first-character rules
|
||||
- Selects the first N characters that are safe ('x' if none are safe)
|
||||
- suffix with truncated hash of true name
|
||||
- length is guaranteed to be < 32 characters
|
||||
leaving room for additional components to build a DNS label.
|
||||
Will currently be between 12-19 characters:
|
||||
4 (prefix, delimiters) + 7 (hash) + 1-8 (name stub)
|
||||
"""
|
||||
name_hash = hashlib.sha256(name.encode('utf8')).hexdigest()[:7]
|
||||
|
||||
safe_chars = [c for c in name.lower() if c in _strict_dns_safe]
|
||||
name_stub = ''.join(safe_chars[:8])
|
||||
# We MUST NOT put the `--` in the 3rd and 4th position (RFC 5891)
|
||||
# which is reserved for IDNs
|
||||
# It would be if name_stub were empty, so put 'x' here
|
||||
# (value doesn't matter, as uniqueness is in the hash - the stub is more of a hint, anyway)
|
||||
if not name_stub:
|
||||
name_stub = "x"
|
||||
return f"u-{name_stub}--{name_hash}"
|
||||
|
||||
|
||||
# A host name (label) can start or end with a letter or a number
|
||||
# this pattern doesn't need to handle the boundary conditions,
|
||||
# which are handled more simply with starts/endswith
|
||||
_dns_re = re.compile(r'^[a-z0-9-]{1,63}$', flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def _is_dns_safe(label, max_length=63):
|
||||
# A host name (label) MUST NOT consist of all numeric values
|
||||
if label.isnumeric():
|
||||
return False
|
||||
# A host name (label) can be up to 63 characters
|
||||
if not 0 < len(label) <= max_length:
|
||||
return False
|
||||
# A host name (label) MUST NOT start or end with a '-' (dash)
|
||||
if label.startswith('-') or label.endswith('-'):
|
||||
return False
|
||||
return bool(_dns_re.match(label))
|
||||
|
||||
|
||||
def _strict_dns_safe_encode(name, max_length=63):
|
||||
"""Will encode a username to a guaranteed-safe DNS label
|
||||
|
||||
- if it contains '--' at all, jump to the end and take the hash route to avoid collisions with escaped
|
||||
- if safe, use it
|
||||
- if not, use IDNA encoding
|
||||
- if a safe encoding cannot be produced, use stripped safe characters + '--{hash}`
|
||||
- allow specifying a max_length, to give room for additional components,
|
||||
if used as only a _part_ of a DNS label.
|
||||
"""
|
||||
# short-circuit: avoid accepting already-encoded results
|
||||
# which all include '--'
|
||||
if '--' in name:
|
||||
return _trim_and_hash(name)
|
||||
|
||||
# if name is already safe (and can't collide with an escaped result) use it
|
||||
if _is_dns_safe(name, max_length=max_length):
|
||||
return name
|
||||
|
||||
# next: use IDNA encoding, if applicable
|
||||
try:
|
||||
idna_name = idna.encode(name).decode("ascii")
|
||||
except ValueError:
|
||||
idna_name = None
|
||||
|
||||
if idna_name and idna_name != name and _is_dns_safe(idna_name):
|
||||
return idna_name
|
||||
|
||||
# fallback, always works: trim to safe characters and hash
|
||||
return _trim_and_hash(name)
|
||||
|
||||
|
||||
def subdomain_hook_idna(name, domain, kind):
|
||||
"""New, reliable subdomain hook
|
||||
|
||||
More reliable than previous, should always produce valid domains
|
||||
|
||||
- uses IDNA encoding for simple unicode names
|
||||
- separate domain for each service
|
||||
- uses stripped name and hash, where above schemes fail to produce a valid domain
|
||||
"""
|
||||
safe_name = _strict_dns_safe_encode(name)
|
||||
if kind == 'user':
|
||||
# 'user' namespace is special-cased as the default
|
||||
# for aesthetics and backward-compatibility for names that don't need escaping
|
||||
suffix = ""
|
||||
else:
|
||||
suffix = f"--{kind}"
|
||||
return f"{safe_name}{suffix}.{domain}"
|
||||
|
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
# ref: https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html
|
||||
[project]
|
||||
name = "jupyterhub"
|
||||
version = "4.1.0.dev"
|
||||
version = "5.0.0.dev"
|
||||
dynamic = ["readme", "dependencies"]
|
||||
description = "JupyterHub: A multi-user server for Jupyter notebooks"
|
||||
authors = [
|
||||
@@ -15,7 +15,7 @@ authors = [
|
||||
]
|
||||
keywords = ["Interactive", "Interpreter", "Shell", "Web", "Jupyter"]
|
||||
license = { text = "BSD-3-Clause" }
|
||||
requires-python = ">=3.7"
|
||||
requires-python = ">=3.8"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
@@ -140,7 +140,7 @@ target_version = [
|
||||
github_url = "https://github.com/jupyterhub/jupyterhub"
|
||||
|
||||
[tool.tbump.version]
|
||||
current = "4.1.0.dev"
|
||||
current = "5.0.0.dev"
|
||||
|
||||
# Example of a semver regexp.
|
||||
# Make sure this matches current_version before
|
||||
|
@@ -1,16 +1,17 @@
|
||||
alembic>=1.4
|
||||
async_generator>=1.9
|
||||
certipy>=0.1.2
|
||||
idna
|
||||
importlib_metadata>=3.6; python_version < '3.10'
|
||||
jinja2>=2.11.0
|
||||
jupyter_telemetry>=0.1.0
|
||||
oauthlib>=3.0
|
||||
packaging
|
||||
pamela; sys_platform != 'win32'
|
||||
prometheus_client>=0.4.0
|
||||
pamela>=1.1.0; sys_platform != 'win32'
|
||||
prometheus_client>=0.5.0
|
||||
psutil>=5.6.5; sys_platform == 'win32'
|
||||
python-dateutil
|
||||
requests
|
||||
SQLAlchemy>=1.4
|
||||
SQLAlchemy>=1.4.1
|
||||
tornado>=5.1
|
||||
traitlets>=4.3.2
|
||||
|
@@ -52,7 +52,6 @@
|
||||
class="form-control"
|
||||
name="username"
|
||||
val="{{username}}"
|
||||
tabindex="1"
|
||||
autofocus="autofocus"
|
||||
/>
|
||||
<label for='password_input'>Password:</label>
|
||||
@@ -62,8 +61,16 @@
|
||||
autocomplete="current-password"
|
||||
name="password"
|
||||
id="password_input"
|
||||
tabindex="2"
|
||||
/>
|
||||
{% if authenticator.request_otp %}
|
||||
<label for='otp_input'>{{ authenticator.otp_prompt }}</label>
|
||||
<input
|
||||
class="form-control"
|
||||
autocomplete="one-time-password"
|
||||
name="otp"
|
||||
id="otp_input"
|
||||
/>
|
||||
{% endif %}
|
||||
|
||||
<div class="feedback-container">
|
||||
<input
|
||||
|
@@ -132,7 +132,7 @@
|
||||
<ul class="dropdown-menu">
|
||||
{% for service in services %}
|
||||
{% block service scoped %}
|
||||
<li><a class="dropdown-item" href="{{service.prefix}}">{{service.name}}</a></li>
|
||||
<li><a class="dropdown-item" href="{{service.href}}">{{service.name}}</a></li>
|
||||
{% endblock %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
Reference in New Issue
Block a user