mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-08 10:34:10 +00:00
Compare commits
84 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
68835e97a2 | ||
![]() |
ce80c9c9cf | ||
![]() |
3c299fbfb7 | ||
![]() |
597f8ea6eb | ||
![]() |
d1181085bf | ||
![]() |
913832da48 | ||
![]() |
42f57f4a72 | ||
![]() |
d01a518c41 | ||
![]() |
65ce06b116 | ||
![]() |
468aa5e93c | ||
![]() |
5c01370e6f | ||
![]() |
21d08883a8 | ||
![]() |
59de506f20 | ||
![]() |
b34120ed81 | ||
![]() |
617978179d | ||
![]() |
0985d6fdf2 | ||
![]() |
2049fb0491 | ||
![]() |
a58fc6534b | ||
![]() |
a14f97b7aa | ||
![]() |
0a4cd5b4f2 | ||
![]() |
dca6d372df | ||
![]() |
3898c72921 | ||
![]() |
b25517efe8 | ||
![]() |
392dffd11e | ||
![]() |
510f6ea7e6 | ||
![]() |
296a0ad2f2 | ||
![]() |
487c4524ad | ||
![]() |
b2f0208fcc | ||
![]() |
84b9c3848c | ||
![]() |
9adbafdfb3 | ||
![]() |
9cf2b5101e | ||
![]() |
725fa3a48a | ||
![]() |
534dda3dc7 | ||
![]() |
b0c7df04ac | ||
![]() |
61b0e8bef5 | ||
![]() |
64f3938528 | ||
![]() |
85bc92d88e | ||
![]() |
7bcda18564 | ||
![]() |
86da36857e | ||
![]() |
530833e930 | ||
![]() |
3b0850fa9b | ||
![]() |
1366911be6 | ||
![]() |
fe276eac64 | ||
![]() |
9209ccd0de | ||
![]() |
3b2a1a37f9 | ||
![]() |
6007ba78b0 | ||
![]() |
9cb19cc342 | ||
![]() |
0f471f4e12 | ||
![]() |
68db740998 | ||
![]() |
9c0c6f25b7 | ||
![]() |
5f0077cb5b | ||
![]() |
a6a2056cca | ||
![]() |
fb1e81212f | ||
![]() |
17f811d0b4 | ||
![]() |
34398d94de | ||
![]() |
6bf94fde48 | ||
![]() |
ee18fed04b | ||
![]() |
28f56ba510 | ||
![]() |
c8d3dbb7b1 | ||
![]() |
a76a093638 | ||
![]() |
27908a8e17 | ||
![]() |
8a30f015c9 | ||
![]() |
8cac83fc96 | ||
![]() |
9ade4bb9b2 | ||
![]() |
874c91a086 | ||
![]() |
a906677440 | ||
![]() |
3f93942a24 | ||
![]() |
aeb3130b25 | ||
![]() |
8a6b364ca5 | ||
![]() |
2ade7328d1 | ||
![]() |
2bb9f4f444 | ||
![]() |
b029d983f9 | ||
![]() |
4082006039 | ||
![]() |
69aa0eaa7a | ||
![]() |
3674ada640 | ||
![]() |
48accb0a64 | ||
![]() |
70ac143cfe | ||
![]() |
b1b2d531f8 | ||
![]() |
e200783c59 | ||
![]() |
a7e57196c6 | ||
![]() |
b5f05e6cd2 | ||
![]() |
5fe5b35f21 | ||
![]() |
3610454a12 | ||
![]() |
abc4bbebe4 |
31
.github/workflows/support-bot.yml
vendored
Normal file
31
.github/workflows/support-bot.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# https://github.com/dessant/support-requests
|
||||||
|
name: "Support Requests"
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [labeled, unlabeled, reopened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
action:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: dessant/support-requests@v2
|
||||||
|
with:
|
||||||
|
github-token: ${{ github.token }}
|
||||||
|
support-label: "support"
|
||||||
|
issue-comment: |
|
||||||
|
Hi there @{issue-author} :wave:!
|
||||||
|
|
||||||
|
I closed this issue because it was labelled as a support question.
|
||||||
|
|
||||||
|
Please help us organize discussion by posting this on the http://discourse.jupyter.org/ forum.
|
||||||
|
|
||||||
|
Our goal is to sustain a positive experience for both users and developers. We use GitHub issues for specific discussions related to changing a repository's content, and let the forum be where we can more generally help and inspire each other.
|
||||||
|
|
||||||
|
Thanks you for being an active member of our community! :heart:
|
||||||
|
close-issue: true
|
||||||
|
lock-issue: false
|
||||||
|
issue-lock-reason: "off-topic"
|
41
.github/workflows/test.yml
vendored
41
.github/workflows/test.yml
vendored
@@ -14,8 +14,28 @@ on:
|
|||||||
env:
|
env:
|
||||||
# UTF-8 content may be interpreted as ascii and causes errors without this.
|
# UTF-8 content may be interpreted as ascii and causes errors without this.
|
||||||
LANG: C.UTF-8
|
LANG: C.UTF-8
|
||||||
|
PYTEST_ADDOPTS: "--verbose --color=yes"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
rest-api:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Validate REST API
|
||||||
|
uses: char0n/swagger-editor-validate@182d1a5d26ff5c2f4f452c43bd55e2c7d8064003
|
||||||
|
with:
|
||||||
|
definition-file: docs/source/_static/rest-api.yml
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: "3.9"
|
||||||
|
# in addition to the doc requirements
|
||||||
|
# the docs *tests* require pre-commit and pytest
|
||||||
|
- run: |
|
||||||
|
pip install -r docs/requirements.txt pytest pre-commit -e .
|
||||||
|
- run: |
|
||||||
|
pytest docs/
|
||||||
|
|
||||||
# Run "pytest jupyterhub/tests" in various configurations
|
# Run "pytest jupyterhub/tests" in various configurations
|
||||||
pytest:
|
pytest:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
@@ -38,9 +58,9 @@ jobs:
|
|||||||
# Tests everything when JupyterHub works against a dedicated mysql or
|
# Tests everything when JupyterHub works against a dedicated mysql or
|
||||||
# postgresql server.
|
# postgresql server.
|
||||||
#
|
#
|
||||||
# jupyter_server:
|
# nbclassic:
|
||||||
# Tests everything when the user instances are started with
|
# Tests everything when the user instances are started with
|
||||||
# jupyter_server instead of notebook.
|
# notebook instead of jupyter_server.
|
||||||
#
|
#
|
||||||
# ssl:
|
# ssl:
|
||||||
# Tests everything using internal SSL connections instead of
|
# Tests everything using internal SSL connections instead of
|
||||||
@@ -48,7 +68,7 @@ jobs:
|
|||||||
#
|
#
|
||||||
# main_dependencies:
|
# main_dependencies:
|
||||||
# Tests everything when the we use the latest available dependencies
|
# Tests everything when the we use the latest available dependencies
|
||||||
# from: ipytraitlets.
|
# from: traitlets.
|
||||||
#
|
#
|
||||||
# NOTE: Since only the value of these parameters are presented in the
|
# NOTE: Since only the value of these parameters are presented in the
|
||||||
# GitHub UI when the workflow run, we avoid using true/false as
|
# GitHub UI when the workflow run, we avoid using true/false as
|
||||||
@@ -56,6 +76,7 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- python: "3.6"
|
- python: "3.6"
|
||||||
oldest_dependencies: oldest_dependencies
|
oldest_dependencies: oldest_dependencies
|
||||||
|
nbclassic: nbclassic
|
||||||
- python: "3.6"
|
- python: "3.6"
|
||||||
subdomain: subdomain
|
subdomain: subdomain
|
||||||
- python: "3.7"
|
- python: "3.7"
|
||||||
@@ -65,7 +86,7 @@ jobs:
|
|||||||
- python: "3.8"
|
- python: "3.8"
|
||||||
db: postgres
|
db: postgres
|
||||||
- python: "3.8"
|
- python: "3.8"
|
||||||
jupyter_server: jupyter_server
|
nbclassic: nbclassic
|
||||||
- python: "3.9"
|
- python: "3.9"
|
||||||
main_dependencies: main_dependencies
|
main_dependencies: main_dependencies
|
||||||
|
|
||||||
@@ -130,9 +151,9 @@ jobs:
|
|||||||
if [ "${{ matrix.main_dependencies }}" != "" ]; then
|
if [ "${{ matrix.main_dependencies }}" != "" ]; then
|
||||||
pip install git+https://github.com/ipython/traitlets#egg=traitlets --force
|
pip install git+https://github.com/ipython/traitlets#egg=traitlets --force
|
||||||
fi
|
fi
|
||||||
if [ "${{ matrix.jupyter_server }}" != "" ]; then
|
if [ "${{ matrix.nbclassic }}" != "" ]; then
|
||||||
pip uninstall notebook --yes
|
pip uninstall jupyter_server --yes
|
||||||
pip install jupyter_server
|
pip install notebook
|
||||||
fi
|
fi
|
||||||
if [ "${{ matrix.db }}" == "mysql" ]; then
|
if [ "${{ matrix.db }}" == "mysql" ]; then
|
||||||
pip install mysql-connector-python
|
pip install mysql-connector-python
|
||||||
@@ -181,10 +202,8 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Run pytest
|
- name: Run pytest
|
||||||
# FIXME: --color=yes explicitly set because:
|
|
||||||
# https://github.com/actions/runner/issues/241
|
|
||||||
run: |
|
run: |
|
||||||
pytest -v --maxfail=2 --color=yes --cov=jupyterhub jupyterhub/tests
|
pytest --maxfail=2 --cov=jupyterhub jupyterhub/tests
|
||||||
- name: Run yarn jest test
|
- name: Run yarn jest test
|
||||||
run: |
|
run: |
|
||||||
cd jsx && yarn && yarn test
|
cd jsx && yarn && yarn test
|
||||||
@@ -194,7 +213,7 @@ jobs:
|
|||||||
|
|
||||||
docker-build:
|
docker-build:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
timeout-minutes: 10
|
timeout-minutes: 20
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
rev: v2.26.0
|
rev: v2.29.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: pyupgrade
|
||||||
args:
|
args:
|
||||||
@@ -10,15 +10,15 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: reorder-python-imports
|
- id: reorder-python-imports
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 21.8b0
|
rev: 21.9b0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
rev: v2.4.0
|
rev: v2.4.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
- repo: https://github.com/PyCQA/flake8
|
- repo: https://github.com/PyCQA/flake8
|
||||||
rev: "3.9.2"
|
rev: "4.0.1"
|
||||||
hooks:
|
hooks:
|
||||||
- id: flake8
|
- id: flake8
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
@@ -1,26 +0,0 @@
|
|||||||
# Release checklist
|
|
||||||
|
|
||||||
- [ ] Upgrade Docs prior to Release
|
|
||||||
|
|
||||||
- [ ] Change log
|
|
||||||
- [ ] New features documented
|
|
||||||
- [ ] Update the contributor list - thank you page
|
|
||||||
|
|
||||||
- [ ] Upgrade and test Reference Deployments
|
|
||||||
|
|
||||||
- [ ] Release software
|
|
||||||
|
|
||||||
- [ ] Make sure 0 issues in milestone
|
|
||||||
- [ ] Follow release process steps
|
|
||||||
- [ ] Send builds to PyPI (Warehouse) and Conda Forge
|
|
||||||
|
|
||||||
- [ ] Blog post and/or release note
|
|
||||||
|
|
||||||
- [ ] Notify users of release
|
|
||||||
|
|
||||||
- [ ] Email Jupyter and Jupyter In Education mailing lists
|
|
||||||
- [ ] Tweet (optional)
|
|
||||||
|
|
||||||
- [ ] Increment the version number for the next release
|
|
||||||
|
|
||||||
- [ ] Update roadmap
|
|
@@ -56,9 +56,11 @@ Basic principles for operation are:
|
|||||||
servers.
|
servers.
|
||||||
|
|
||||||
JupyterHub also provides a
|
JupyterHub also provides a
|
||||||
[REST API](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/HEAD/docs/rest-api.yml#/default)
|
[REST API][]
|
||||||
for administration of the Hub and its users.
|
for administration of the Hub and its users.
|
||||||
|
|
||||||
|
[rest api]: https://juptyerhub.readthedocs.io/en/latest/reference/rest-api.html
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Check prerequisites
|
### Check prerequisites
|
||||||
@@ -239,7 +241,7 @@ You can also talk with us on our JupyterHub [Gitter](https://gitter.im/jupyterhu
|
|||||||
- [Reporting Issues](https://github.com/jupyterhub/jupyterhub/issues)
|
- [Reporting Issues](https://github.com/jupyterhub/jupyterhub/issues)
|
||||||
- [JupyterHub tutorial](https://github.com/jupyterhub/jupyterhub-tutorial)
|
- [JupyterHub tutorial](https://github.com/jupyterhub/jupyterhub-tutorial)
|
||||||
- [Documentation for JupyterHub](https://jupyterhub.readthedocs.io/en/latest/) | [PDF (latest)](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf) | [PDF (stable)](https://media.readthedocs.org/pdf/jupyterhub/stable/jupyterhub.pdf)
|
- [Documentation for JupyterHub](https://jupyterhub.readthedocs.io/en/latest/) | [PDF (latest)](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf) | [PDF (stable)](https://media.readthedocs.org/pdf/jupyterhub/stable/jupyterhub.pdf)
|
||||||
- [Documentation for JupyterHub's REST API](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/HEAD/docs/rest-api.yml#/default)
|
- [Documentation for JupyterHub's REST API][rest api]
|
||||||
- [Documentation for Project Jupyter](http://jupyter.readthedocs.io/en/latest/index.html) | [PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)
|
- [Documentation for Project Jupyter](http://jupyter.readthedocs.io/en/latest/index.html) | [PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)
|
||||||
- [Project Jupyter website](https://jupyter.org)
|
- [Project Jupyter website](https://jupyter.org)
|
||||||
- [Project Jupyter community](https://jupyter.org/community)
|
- [Project Jupyter community](https://jupyter.org/community)
|
||||||
|
50
RELEASE.md
Normal file
50
RELEASE.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# How to make a release
|
||||||
|
|
||||||
|
`jupyterhub` is a package [available on
|
||||||
|
PyPI](https://pypi.org/project/jupyterhub/) and
|
||||||
|
[conda-forge](https://conda-forge.org/).
|
||||||
|
These are instructions on how to make a release on PyPI.
|
||||||
|
The PyPI release is done automatically by CI when a tag is pushed.
|
||||||
|
|
||||||
|
For you to follow along according to these instructions, you need:
|
||||||
|
|
||||||
|
- To have push rights to the [jupyterhub GitHub
|
||||||
|
repository](https://github.com/jupyterhub/jupyterhub).
|
||||||
|
|
||||||
|
## Steps to make a release
|
||||||
|
|
||||||
|
1. Checkout main and make sure it is up to date.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ORIGIN=${ORIGIN:-origin} # set to the canonical remote, e.g. 'upstream' if 'origin' is not the official repo
|
||||||
|
git checkout main
|
||||||
|
git fetch $ORIGIN main
|
||||||
|
git reset --hard $ORIGIN/main
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Make sure `docs/source/changelog.md` is up-to-date.
|
||||||
|
[github-activity][] can help with this.
|
||||||
|
|
||||||
|
1. Update the version with `tbump`.
|
||||||
|
You can see what will happen without making any changes with `tbump --dry-run ${VERSION}`
|
||||||
|
|
||||||
|
```shell
|
||||||
|
tbump ${VERSION}
|
||||||
|
```
|
||||||
|
|
||||||
|
This will tag and publish a release,
|
||||||
|
which will be finished on CI.
|
||||||
|
|
||||||
|
1. Reset the version back to dev, e.g. `2.1.0.dev` after releasing `2.0.0`
|
||||||
|
|
||||||
|
```shell
|
||||||
|
tbump --no-tag ${NEXT_VERSION}.dev
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Following the release to PyPI, an automated PR should arrive to
|
||||||
|
[conda-forge/jupyterhub-feedstock][],
|
||||||
|
check for the tests to succeed on this PR and then merge it to successfully
|
||||||
|
update the package for `conda` on the conda-forge channel.
|
||||||
|
|
||||||
|
[github-activity]: https://github.com/choldgraf/github-activity
|
||||||
|
[conda-forge/jupyterhub-feedstock]: https://github.com/conda-forge/jupyterhub-feedstock
|
5
SECURITY.md
Normal file
5
SECURITY.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Reporting a Vulnerability
|
||||||
|
|
||||||
|
If you believe you’ve found a security vulnerability in a Jupyter
|
||||||
|
project, please report it to security@ipython.org. If you prefer to
|
||||||
|
encrypt your security reports, you can use [this PGP public key](https://jupyter-notebook.readthedocs.io/en/stable/_downloads/1d303a645f2505a8fd283826fafc9908/ipython_security.asc).
|
@@ -7,13 +7,14 @@ codecov
|
|||||||
coverage
|
coverage
|
||||||
cryptography
|
cryptography
|
||||||
html5lib # needed for beautifulsoup
|
html5lib # needed for beautifulsoup
|
||||||
|
jupyterlab >=3
|
||||||
mock
|
mock
|
||||||
notebook
|
|
||||||
pre-commit
|
pre-commit
|
||||||
pytest>=3.3
|
pytest>=3.3
|
||||||
pytest-asyncio
|
pytest-asyncio
|
||||||
pytest-cov
|
pytest-cov
|
||||||
requests-mock
|
requests-mock
|
||||||
|
tbump
|
||||||
# blacklist urllib3 releases affected by https://github.com/urllib3/urllib3/issues/1683
|
# blacklist urllib3 releases affected by https://github.com/urllib3/urllib3/issues/1683
|
||||||
# I *think* this should only affect testing, not production
|
# I *think* this should only affect testing, not production
|
||||||
urllib3!=1.25.4,!=1.25.5
|
urllib3!=1.25.4,!=1.25.5
|
||||||
|
@@ -53,14 +53,6 @@ help:
|
|||||||
clean:
|
clean:
|
||||||
rm -rf $(BUILDDIR)/*
|
rm -rf $(BUILDDIR)/*
|
||||||
|
|
||||||
node_modules: package.json
|
|
||||||
npm install && touch node_modules
|
|
||||||
|
|
||||||
rest-api: source/_static/rest-api/index.html
|
|
||||||
|
|
||||||
source/_static/rest-api/index.html: rest-api.yml node_modules
|
|
||||||
npm run rest-api
|
|
||||||
|
|
||||||
metrics: source/reference/metrics.rst
|
metrics: source/reference/metrics.rst
|
||||||
|
|
||||||
source/reference/metrics.rst: generate-metrics.py
|
source/reference/metrics.rst: generate-metrics.py
|
||||||
@@ -71,7 +63,7 @@ scopes: source/rbac/scope-table.md
|
|||||||
source/rbac/scope-table.md: source/rbac/generate-scope-table.py
|
source/rbac/scope-table.md: source/rbac/generate-scope-table.py
|
||||||
python3 source/rbac/generate-scope-table.py
|
python3 source/rbac/generate-scope-table.py
|
||||||
|
|
||||||
html: rest-api metrics scopes
|
html: metrics scopes
|
||||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||||
@echo
|
@echo
|
||||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||||
|
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "jupyterhub-docs-build",
|
|
||||||
"version": "0.8.0",
|
|
||||||
"description": "build JupyterHub swagger docs",
|
|
||||||
"scripts": {
|
|
||||||
"rest-api": "bootprint openapi ./rest-api.yml source/_static/rest-api"
|
|
||||||
},
|
|
||||||
"author": "",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"devDependencies": {
|
|
||||||
"bootprint": "^1.0.0",
|
|
||||||
"bootprint-openapi": "^1.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,9 +1,7 @@
|
|||||||
-r ../requirements.txt
|
-r ../requirements.txt
|
||||||
|
|
||||||
alabaster_jupyterhub
|
alabaster_jupyterhub
|
||||||
# Temporary fix of #3021. Revert back to released autodoc-traits when
|
autodoc-traits
|
||||||
# 0.1.0 released.
|
|
||||||
https://github.com/jupyterhub/autodoc-traits/archive/d22282c1c18c6865436e06d8b329c06fe12a07f8.zip
|
|
||||||
myst-parser
|
myst-parser
|
||||||
pydata-sphinx-theme
|
pydata-sphinx-theme
|
||||||
pytablewriter>=0.56
|
pytablewriter>=0.56
|
||||||
|
@@ -2,3 +2,9 @@
|
|||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
height: 4rem !important;
|
height: 4rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* hide redundant funky-formatted swagger-ui version */
|
||||||
|
|
||||||
|
.swagger-ui .info .title small {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
@@ -1,9 +1,11 @@
|
|||||||
# see me at: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#/default
|
# see me at: https://jupyterhub.readthedocs.io/en/latest/reference/rest-api.html
|
||||||
swagger: "2.0"
|
swagger: "2.0"
|
||||||
info:
|
info:
|
||||||
title: JupyterHub
|
title: JupyterHub
|
||||||
description: The REST API for JupyterHub
|
description: The REST API for JupyterHub
|
||||||
version: 1.4.0
|
# version should match jupyterhub/_version.py
|
||||||
|
# `make scopes` ensures this is in sync
|
||||||
|
version: 2.0.0rc1
|
||||||
license:
|
license:
|
||||||
name: BSD-3-Clause
|
name: BSD-3-Clause
|
||||||
schemes: [http, https]
|
schemes: [http, https]
|
||||||
@@ -15,14 +17,17 @@ securityDefinitions:
|
|||||||
oauth2:
|
oauth2:
|
||||||
type: oauth2
|
type: oauth2
|
||||||
flow: accessCode
|
flow: accessCode
|
||||||
authorizationUrl: "/hub/api/oauth2/authorize" # what are the absolute URIs here? is oauth2 correct here or shall we use just authorizations?
|
# these must be absolute until we update to openapi 3
|
||||||
tokenUrl: "/hub/api/oauth2/token"
|
authorizationUrl: "https://hub.example/hub/api/oauth2/authorize"
|
||||||
|
tokenUrl: "https://hub.example/hub/api/oauth2/token"
|
||||||
scopes: # Generated based on scope table in jupyterhub/scopes.py
|
scopes: # Generated based on scope table in jupyterhub/scopes.py
|
||||||
(no_scope): Identify the owner of the requesting entity.
|
(no_scope): Identify the owner of the requesting entity.
|
||||||
self:
|
self:
|
||||||
The user’s own resources _(metascope for users, resolves to (no_scope)
|
The user’s own resources _(metascope for users, resolves to (no_scope)
|
||||||
for services)_
|
for services)_
|
||||||
all: Everything that the token-owning entity can access _(metascope for tokens)_
|
inherit:
|
||||||
|
Everything that the token-owning entity can access _(metascope for
|
||||||
|
tokens)_
|
||||||
admin:users:
|
admin:users:
|
||||||
Read, write, create and delete users and their authentication state,
|
Read, write, create and delete users and their authentication state,
|
||||||
not including their servers or tokens.
|
not including their servers or tokens.
|
||||||
@@ -30,6 +35,8 @@ securityDefinitions:
|
|||||||
users:
|
users:
|
||||||
Read and write permissions to user models (excluding servers, tokens
|
Read and write permissions to user models (excluding servers, tokens
|
||||||
and authentication state).
|
and authentication state).
|
||||||
|
delete:users: Delete users.
|
||||||
|
list:users: List users, including at least their names.
|
||||||
read:users:
|
read:users:
|
||||||
Read user models (excluding including servers, tokens and authentication
|
Read user models (excluding including servers, tokens and authentication
|
||||||
state).
|
state).
|
||||||
@@ -47,14 +54,18 @@ securityDefinitions:
|
|||||||
read:servers:
|
read:servers:
|
||||||
Read users’ names and their server models (excluding the server
|
Read users’ names and their server models (excluding the server
|
||||||
state).
|
state).
|
||||||
|
delete:servers: Stop and delete users' servers.
|
||||||
tokens: Read, write, create and delete user tokens.
|
tokens: Read, write, create and delete user tokens.
|
||||||
read:tokens: Read user tokens.
|
read:tokens: Read user tokens.
|
||||||
admin:groups: Read and write group information, create and delete groups.
|
admin:groups: Read and write group information, create and delete groups.
|
||||||
groups:
|
groups:
|
||||||
Read and write group information, including adding/removing users to/from
|
Read and write group information, including adding/removing users to/from
|
||||||
groups.
|
groups.
|
||||||
|
list:groups: List groups, including at least their names.
|
||||||
read:groups: Read group models.
|
read:groups: Read group models.
|
||||||
read:groups:name: Read group names.
|
read:groups:name: Read group names.
|
||||||
|
delete:groups: Delete groups.
|
||||||
|
list:services: List services, including at least their names.
|
||||||
read:services: Read service models.
|
read:services: Read service models.
|
||||||
read:services:name: Read service names.
|
read:services:name: Read service names.
|
||||||
read:hub: Read detailed information about the Hub.
|
read:hub: Read detailed information about the Hub.
|
||||||
@@ -174,7 +185,7 @@ paths:
|
|||||||
If unspecified, return all users.
|
If unspecified, return all users.
|
||||||
- name: limit
|
- name: limit
|
||||||
in: query
|
in: query
|
||||||
requred: false
|
required: false
|
||||||
type: number
|
type: number
|
||||||
description: |
|
description: |
|
||||||
Return a finite number of users.
|
Return a finite number of users.
|
||||||
@@ -779,7 +790,7 @@ paths:
|
|||||||
If unspecified, return all routes.
|
If unspecified, return all routes.
|
||||||
- name: limit
|
- name: limit
|
||||||
in: query
|
in: query
|
||||||
requred: false
|
required: false
|
||||||
type: number
|
type: number
|
||||||
description: |
|
description: |
|
||||||
Return a finite number of routes.
|
Return a finite number of routes.
|
||||||
@@ -870,7 +881,7 @@ paths:
|
|||||||
summary: Identify a user or service from an API token
|
summary: Identify a user or service from an API token
|
||||||
security:
|
security:
|
||||||
- oauth2:
|
- oauth2:
|
||||||
- (noscope)
|
- (no_scope)
|
||||||
parameters:
|
parameters:
|
||||||
- name: token
|
- name: token
|
||||||
in: path
|
in: path
|
@@ -17,11 +17,6 @@ information on:
|
|||||||
- making an API request programmatically using the requests library
|
- making an API request programmatically using the requests library
|
||||||
- learning more about JupyterHub's API
|
- learning more about JupyterHub's API
|
||||||
|
|
||||||
The same JupyterHub API spec, as found here, is available in an interactive form
|
|
||||||
`here (on swagger's petstore) <https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#!/default>`__.
|
|
||||||
The `OpenAPI Initiative`_ (fka Swagger™) is a project used to describe
|
|
||||||
and document RESTful APIs.
|
|
||||||
|
|
||||||
JupyterHub API Reference:
|
JupyterHub API Reference:
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
|
File diff suppressed because one or more lines are too long
@@ -215,7 +215,7 @@ if on_rtd:
|
|||||||
# build both metrics and rest-api, since RTD doesn't run make
|
# build both metrics and rest-api, since RTD doesn't run make
|
||||||
from subprocess import check_call as sh
|
from subprocess import check_call as sh
|
||||||
|
|
||||||
sh(['make', 'metrics', 'rest-api', 'scopes'], cwd=docs)
|
sh(['make', 'metrics', 'scopes'], cwd=docs)
|
||||||
|
|
||||||
# -- Spell checking -------------------------------------------------------
|
# -- Spell checking -------------------------------------------------------
|
||||||
|
|
||||||
|
@@ -43,7 +43,7 @@ JupyterHub performs the following functions:
|
|||||||
notebook servers
|
notebook servers
|
||||||
|
|
||||||
For convenient administration of the Hub, its users, and services,
|
For convenient administration of the Hub, its users, and services,
|
||||||
JupyterHub also provides a `REST API`_.
|
JupyterHub also provides a :doc:`REST API <reference/rest-api>`.
|
||||||
|
|
||||||
The JupyterHub team and Project Jupyter value our community, and JupyterHub
|
The JupyterHub team and Project Jupyter value our community, and JupyterHub
|
||||||
follows the Jupyter `Community Guides <https://jupyter.readthedocs.io/en/latest/community/content-community.html>`_.
|
follows the Jupyter `Community Guides <https://jupyter.readthedocs.io/en/latest/community/content-community.html>`_.
|
||||||
@@ -155,4 +155,3 @@ Questions? Suggestions?
|
|||||||
|
|
||||||
.. _JupyterHub: https://github.com/jupyterhub/jupyterhub
|
.. _JupyterHub: https://github.com/jupyterhub/jupyterhub
|
||||||
.. _Jupyter notebook: https://jupyter-notebook.readthedocs.io/en/latest/
|
.. _Jupyter notebook: https://jupyter-notebook.readthedocs.io/en/latest/
|
||||||
.. _REST API: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#!/default
|
|
||||||
|
@@ -5,8 +5,8 @@
|
|||||||
Before installing JupyterHub, you will need:
|
Before installing JupyterHub, you will need:
|
||||||
|
|
||||||
- a Linux/Unix based system
|
- a Linux/Unix based system
|
||||||
- [Python](https://www.python.org/downloads/) 3.5 or greater. An understanding
|
- [Python](https://www.python.org/downloads/) 3.6 or greater. An understanding
|
||||||
of using [`pip`](https://pip.pypa.io/en/stable/) or
|
of using [`pip`](https://pip.pypa.io) or
|
||||||
[`conda`](https://conda.io/docs/get-started.html) for
|
[`conda`](https://conda.io/docs/get-started.html) for
|
||||||
installing Python packages is helpful.
|
installing Python packages is helpful.
|
||||||
- [nodejs/npm](https://www.npmjs.com/). [Install nodejs/npm](https://docs.npmjs.com/getting-started/installing-node),
|
- [nodejs/npm](https://www.npmjs.com/). [Install nodejs/npm](https://docs.npmjs.com/getting-started/installing-node),
|
||||||
@@ -20,11 +20,11 @@ Before installing JupyterHub, you will need:
|
|||||||
For example, install it on Linux (Debian/Ubuntu) using:
|
For example, install it on Linux (Debian/Ubuntu) using:
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo apt-get install npm nodejs-legacy
|
sudo apt-get install nodejs npm
|
||||||
```
|
```
|
||||||
|
|
||||||
The `nodejs-legacy` package installs the `node` executable and is currently
|
[nodesource][] is a great resource to get more recent versions of the nodejs runtime,
|
||||||
required for npm to work on Debian/Ubuntu.
|
if your system package manager only has an old version of Node.js (e.g. 10 or older).
|
||||||
|
|
||||||
- A [pluggable authentication module (PAM)](https://en.wikipedia.org/wiki/Pluggable_authentication_module)
|
- A [pluggable authentication module (PAM)](https://en.wikipedia.org/wiki/Pluggable_authentication_module)
|
||||||
to use the [default Authenticator](./getting-started/authenticators-users-basics.md).
|
to use the [default Authenticator](./getting-started/authenticators-users-basics.md).
|
||||||
@@ -33,11 +33,17 @@ Before installing JupyterHub, you will need:
|
|||||||
- TLS certificate and key for HTTPS communication
|
- TLS certificate and key for HTTPS communication
|
||||||
- Domain name
|
- Domain name
|
||||||
|
|
||||||
|
[nodesource]: https://github.com/nodesource/distributions#table-of-contents
|
||||||
|
|
||||||
Before running the single-user notebook servers (which may be on the same
|
Before running the single-user notebook servers (which may be on the same
|
||||||
system as the Hub or not), you will need:
|
system as the Hub or not), you will need:
|
||||||
|
|
||||||
- [Jupyter Notebook](https://jupyter.readthedocs.io/en/latest/install.html)
|
- [JupyterLab][] version 3 or greater,
|
||||||
version 4 or greater
|
or [Jupyter Notebook][]
|
||||||
|
4 or greater.
|
||||||
|
|
||||||
|
[jupyterlab]: https://jupyterlab.readthedocs.io
|
||||||
|
[jupyter notebook]: https://jupyter.readthedocs.io/en/latest/install.html
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -48,14 +54,14 @@ JupyterHub can be installed with `pip` (and the proxy with `npm`) or `conda`:
|
|||||||
```bash
|
```bash
|
||||||
python3 -m pip install jupyterhub
|
python3 -m pip install jupyterhub
|
||||||
npm install -g configurable-http-proxy
|
npm install -g configurable-http-proxy
|
||||||
python3 -m pip install notebook # needed if running the notebook servers locally
|
python3 -m pip install jupyterlab notebook # needed if running the notebook servers in the same environment
|
||||||
```
|
```
|
||||||
|
|
||||||
**conda** (one command installs jupyterhub and proxy):
|
**conda** (one command installs jupyterhub and proxy):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
conda install -c conda-forge jupyterhub # installs jupyterhub and proxy
|
conda install -c conda-forge jupyterhub # installs jupyterhub and proxy
|
||||||
conda install notebook # needed if running the notebook servers locally
|
conda install jupyterlab notebook # needed if running the notebook servers in the same environment
|
||||||
```
|
```
|
||||||
|
|
||||||
Test your installation. If installed, these commands should return the packages'
|
Test your installation. If installed, these commands should return the packages'
|
||||||
@@ -74,7 +80,7 @@ To start the Hub server, run the command:
|
|||||||
jupyterhub
|
jupyterhub
|
||||||
```
|
```
|
||||||
|
|
||||||
Visit `https://localhost:8000` in your browser, and sign in with your unix
|
Visit `http://localhost:8000` in your browser, and sign in with your unix
|
||||||
credentials.
|
credentials.
|
||||||
|
|
||||||
To **allow multiple users to sign in** to the Hub server, you must start
|
To **allow multiple users to sign in** to the Hub server, you must start
|
||||||
|
@@ -5,10 +5,12 @@ from pathlib import Path
|
|||||||
from pytablewriter import MarkdownTableWriter
|
from pytablewriter import MarkdownTableWriter
|
||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
|
import jupyterhub
|
||||||
from jupyterhub.scopes import scope_definitions
|
from jupyterhub.scopes import scope_definitions
|
||||||
|
|
||||||
HERE = os.path.abspath(os.path.dirname(__file__))
|
HERE = os.path.abspath(os.path.dirname(__file__))
|
||||||
PARENT = Path(HERE).parent.parent.absolute()
|
DOCS = Path(HERE).parent.parent.absolute()
|
||||||
|
REST_API_YAML = DOCS.joinpath("source", "_static", "rest-api.yml")
|
||||||
|
|
||||||
|
|
||||||
class ScopeTableGenerator:
|
class ScopeTableGenerator:
|
||||||
@@ -98,13 +100,14 @@ class ScopeTableGenerator:
|
|||||||
|
|
||||||
def write_api(self):
|
def write_api(self):
|
||||||
"""Generates the API description in markdown format and writes it into `rest-api.yml`"""
|
"""Generates the API description in markdown format and writes it into `rest-api.yml`"""
|
||||||
filename = f"{PARENT}/rest-api.yml"
|
filename = REST_API_YAML
|
||||||
yaml = YAML(typ='rt')
|
yaml = YAML(typ='rt')
|
||||||
yaml.preserve_quotes = True
|
yaml.preserve_quotes = True
|
||||||
scope_dict = {}
|
scope_dict = {}
|
||||||
with open(filename, 'r+') as f:
|
with open(filename) as f:
|
||||||
content = yaml.load(f.read())
|
content = yaml.load(f.read())
|
||||||
f.seek(0)
|
|
||||||
|
content["info"]["version"] = jupyterhub.__version__
|
||||||
for scope in self.scopes:
|
for scope in self.scopes:
|
||||||
description = self.scopes[scope]['description']
|
description = self.scopes[scope]['description']
|
||||||
doc_description = self.scopes[scope].get('doc_description', '')
|
doc_description = self.scopes[scope].get('doc_description', '')
|
||||||
@@ -112,8 +115,9 @@ class ScopeTableGenerator:
|
|||||||
description = doc_description
|
description = doc_description
|
||||||
scope_dict[scope] = description
|
scope_dict[scope] = description
|
||||||
content['securityDefinitions']['oauth2']['scopes'] = scope_dict
|
content['securityDefinitions']['oauth2']['scopes'] = scope_dict
|
||||||
|
|
||||||
|
with open(filename, 'w') as f:
|
||||||
yaml.dump(content, f)
|
yaml.dump(content, f)
|
||||||
f.truncate()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
@@ -123,13 +123,13 @@ has,
|
|||||||
define the `server` role.
|
define the `server` role.
|
||||||
|
|
||||||
To restore the JupyterHub 1.x behavior of servers being able to do anything their owners can do,
|
To restore the JupyterHub 1.x behavior of servers being able to do anything their owners can do,
|
||||||
use the scope `all`:
|
use the scope `inherit` (for 'inheriting' the owner's permissions):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
c.JupyterHub.load_roles = [
|
c.JupyterHub.load_roles = [
|
||||||
{
|
{
|
||||||
'name': 'server',
|
'name': 'server',
|
||||||
'scopes': ['all'],
|
'scopes': ['inherit'],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
@@ -219,7 +219,7 @@ In case of the need to run the jupyterhub under /jhub/ or other location please
|
|||||||
httpd.conf amendments:
|
httpd.conf amendments:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
RewriteRule /jhub/(.*) ws://127.0.0.1:8000/jhub/$1 [NE.P,L]
|
RewriteRule /jhub/(.*) ws://127.0.0.1:8000/jhub/$1 [NE,P,L]
|
||||||
RewriteRule /jhub/(.*) http://127.0.0.1:8000/jhub/$1 [NE,P,L]
|
RewriteRule /jhub/(.*) http://127.0.0.1:8000/jhub/$1 [NE,P,L]
|
||||||
|
|
||||||
ProxyPass /jhub/ http://127.0.0.1:8000/jhub/
|
ProxyPass /jhub/ http://127.0.0.1:8000/jhub/
|
||||||
|
@@ -76,13 +76,26 @@ c.InteractiveShellApp.extensions.append("cython")
|
|||||||
|
|
||||||
### Example: Enable a Jupyter notebook configuration setting for all users
|
### Example: Enable a Jupyter notebook configuration setting for all users
|
||||||
|
|
||||||
|
:::{note}
|
||||||
|
These examples configure the Jupyter ServerApp,
|
||||||
|
which is used by JupyterLab, the default in JupyterHub 2.0.
|
||||||
|
|
||||||
|
If you are using the classing Jupyter Notebook server,
|
||||||
|
the same things should work,
|
||||||
|
with the following substitutions:
|
||||||
|
|
||||||
|
- Where you see `jupyter_server_config`, use `jupyter_notebook_config`
|
||||||
|
- Where you see `NotebookApp`, use `ServerApp`
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
To enable Jupyter notebook's internal idle-shutdown behavior (requires
|
To enable Jupyter notebook's internal idle-shutdown behavior (requires
|
||||||
notebook ≥ 5.4), set the following in the `/etc/jupyter/jupyter_notebook_config.py`
|
notebook ≥ 5.4), set the following in the `/etc/jupyter/jupyter_server_config.py`
|
||||||
file:
|
file:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# shutdown the server after no activity for an hour
|
# shutdown the server after no activity for an hour
|
||||||
c.NotebookApp.shutdown_no_activity_timeout = 60 * 60
|
c.ServerApp.shutdown_no_activity_timeout = 60 * 60
|
||||||
# shutdown kernels after no activity for 20 minutes
|
# shutdown kernels after no activity for 20 minutes
|
||||||
c.MappingKernelManager.cull_idle_timeout = 20 * 60
|
c.MappingKernelManager.cull_idle_timeout = 20 * 60
|
||||||
# check for idle kernels every two minutes
|
# check for idle kernels every two minutes
|
||||||
@@ -112,8 +125,8 @@ Assuming I have a Python 2 and Python 3 environment that I want to make
|
|||||||
sure are available, I can install their specs system-wide (in /usr/local) with:
|
sure are available, I can install their specs system-wide (in /usr/local) with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/path/to/python3 -m IPython kernel install --prefix=/usr/local
|
/path/to/python3 -m ipykernel install --prefix=/usr/local
|
||||||
/path/to/python2 -m IPython kernel install --prefix=/usr/local
|
/path/to/python2 -m ipykernel install --prefix=/usr/local
|
||||||
```
|
```
|
||||||
|
|
||||||
## Multi-user hosts vs. Containers
|
## Multi-user hosts vs. Containers
|
||||||
@@ -176,12 +189,40 @@ The number of named servers per user can be limited by setting
|
|||||||
c.JupyterHub.named_server_limit_per_user = 5
|
c.JupyterHub.named_server_limit_per_user = 5
|
||||||
```
|
```
|
||||||
|
|
||||||
## Switching to Jupyter Server
|
(classic-notebook-ui)=
|
||||||
|
|
||||||
[Jupyter Server](https://jupyter-server.readthedocs.io/en/latest/) is a new Tornado Server backend for Jupyter web applications (e.g. JupyterLab 3.0 uses this package as its default backend).
|
## Switching back to classic notebook
|
||||||
|
|
||||||
By default, the single-user notebook server uses the (old) `NotebookApp` from the [notebook](https://github.com/jupyter/notebook) package. You can switch to using Jupyter Server's `ServerApp` backend (this will likely become the default in future releases) by setting the `JUPYTERHUB_SINGLEUSER_APP` environment variable to:
|
By default the single-user server launches JupyterLab,
|
||||||
|
which is based on [Jupyter Server][].
|
||||||
|
This is the default server when running JupyterHub ≥ 2.0.
|
||||||
|
You can switch to using the legacy Jupyter Notebook server by setting the `JUPYTERHUB_SINGLEUSER_APP` environment variable
|
||||||
|
(in the single-user environment) to:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JUPYTERHUB_SINGLEUSER_APP='notebook.notebookapp.NotebookApp'
|
||||||
|
```
|
||||||
|
|
||||||
|
[jupyter server]: https://jupyter-server.readthedocs.io
|
||||||
|
[jupyter notebook]: https://jupyter-notebook.readthedocs.io
|
||||||
|
|
||||||
|
:::{versionchanged} 2.0
|
||||||
|
JupyterLab is now the default singleuser UI, if available,
|
||||||
|
which is based on the [Jupyter Server][],
|
||||||
|
no longer the legacy [Jupyter Notebook][] server.
|
||||||
|
JupyterHub prior to 2.0 launched the legacy notebook server (`jupyter notebook`),
|
||||||
|
and Jupyter server could be selected by specifying
|
||||||
|
|
||||||
|
```python
|
||||||
|
# jupyterhub_config.py
|
||||||
|
c.Spawner.cmd = ["jupyter-labhub"]
|
||||||
|
```
|
||||||
|
|
||||||
|
or for an otherwise customized Jupyter Server app,
|
||||||
|
set the environment variable:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export JUPYTERHUB_SINGLEUSER_APP='jupyter_server.serverapp.ServerApp'
|
export JUPYTERHUB_SINGLEUSER_APP='jupyter_server.serverapp.ServerApp'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
:::
|
||||||
|
@@ -16,6 +16,7 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
|
|||||||
proxy
|
proxy
|
||||||
separate-proxy
|
separate-proxy
|
||||||
rest
|
rest
|
||||||
|
rest-api
|
||||||
server-api
|
server-api
|
||||||
monitoring
|
monitoring
|
||||||
database
|
database
|
||||||
|
27
docs/source/reference/rest-api.md
Normal file
27
docs/source/reference/rest-api.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# JupyterHub REST API
|
||||||
|
|
||||||
|
Below is an interactive view of JupyterHub's OpenAPI specification.
|
||||||
|
|
||||||
|
<!-- client-rendered openapi UI copied from FastAPI -->
|
||||||
|
|
||||||
|
<link type="text/css" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
|
||||||
|
<!-- `SwaggerUIBundle` is now available on the page -->
|
||||||
|
|
||||||
|
<!-- render the ui here -->
|
||||||
|
<div id="openapi-ui"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const ui = SwaggerUIBundle({
|
||||||
|
url: '../_static/rest-api.yml',
|
||||||
|
dom_id: '#openapi-ui',
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
layout: "BaseLayout",
|
||||||
|
deepLinking: true,
|
||||||
|
showExtensions: true,
|
||||||
|
showCommonExtensions: true,
|
||||||
|
});
|
||||||
|
</script>
|
@@ -1,14 +0,0 @@
|
|||||||
:orphan:
|
|
||||||
|
|
||||||
===================
|
|
||||||
JupyterHub REST API
|
|
||||||
===================
|
|
||||||
|
|
||||||
.. this doc exists as a resolvable link target
|
|
||||||
.. which _static files are not
|
|
||||||
|
|
||||||
.. meta::
|
|
||||||
:http-equiv=refresh: 0;url=../_static/rest-api/index.html
|
|
||||||
|
|
||||||
The rest API docs are `here <../_static/rest-api/index.html>`_
|
|
||||||
if you are not redirected automatically.
|
|
@@ -302,12 +302,8 @@ or kubernetes pods.
|
|||||||
|
|
||||||
## Learn more about the API
|
## Learn more about the API
|
||||||
|
|
||||||
You can see the full [JupyterHub REST API][] for details. This REST API Spec can
|
You can see the full [JupyterHub REST API][] for details.
|
||||||
be viewed in a more [interactive style on swagger's petstore][].
|
|
||||||
Both resources contain the same information and differ only in its display.
|
|
||||||
Note: The Swagger specification is being renamed the [OpenAPI Initiative][].
|
|
||||||
|
|
||||||
[interactive style on swagger's petstore]: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#!/default
|
|
||||||
[openapi initiative]: https://www.openapis.org/
|
[openapi initiative]: https://www.openapis.org/
|
||||||
[jupyterhub rest api]: ./rest-api
|
[jupyterhub rest api]: ./rest-api
|
||||||
[jupyter notebook rest api]: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/notebook/HEAD/notebook/services/api/api.yaml
|
[jupyter notebook rest api]: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/notebook/HEAD/notebook/services/api/api.yaml
|
||||||
|
45
docs/test_docs.py
Normal file
45
docs/test_docs.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import run
|
||||||
|
|
||||||
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
|
yaml = YAML(typ="safe")
|
||||||
|
|
||||||
|
here = Path(__file__).absolute().parent
|
||||||
|
root = here.parent
|
||||||
|
|
||||||
|
|
||||||
|
def test_rest_api_version():
|
||||||
|
version_py = root.joinpath("jupyterhub", "_version.py")
|
||||||
|
rest_api_yaml = root.joinpath("docs", "source", "_static", "rest-api.yml")
|
||||||
|
ns = {}
|
||||||
|
with version_py.open() as f:
|
||||||
|
exec(f.read(), {}, ns)
|
||||||
|
jupyterhub_version = ns["__version__"]
|
||||||
|
|
||||||
|
with rest_api_yaml.open() as f:
|
||||||
|
rest_api = yaml.load(f)
|
||||||
|
rest_api_version = rest_api["info"]["version"]
|
||||||
|
|
||||||
|
assert jupyterhub_version == rest_api_version
|
||||||
|
|
||||||
|
|
||||||
|
def test_restapi_scopes():
|
||||||
|
run([sys.executable, "source/rbac/generate-scope-table.py"], cwd=here, check=True)
|
||||||
|
run(
|
||||||
|
['pre-commit', 'run', 'prettier', '--files', 'source/_static/rest-api.yml'],
|
||||||
|
cwd=here,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"diff",
|
||||||
|
"--no-pager",
|
||||||
|
"--exit-code",
|
||||||
|
str(here.joinpath("source", "_static", "rest-api.yml")),
|
||||||
|
],
|
||||||
|
cwd=here,
|
||||||
|
check=True,
|
||||||
|
)
|
@@ -29,7 +29,7 @@ def get_token():
|
|||||||
token_file = here.joinpath("service-token")
|
token_file = here.joinpath("service-token")
|
||||||
log.info(f"Loading token from {token_file}")
|
log.info(f"Loading token from {token_file}")
|
||||||
with token_file.open("r") as f:
|
with token_file.open("r") as f:
|
||||||
token = f.read()
|
token = f.read().strip()
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
@@ -5457,9 +5457,9 @@ npm-run-path@^4.0.0:
|
|||||||
path-key "^3.0.0"
|
path-key "^3.0.0"
|
||||||
|
|
||||||
nth-check@^2.0.0:
|
nth-check@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.0.0.tgz#1bb4f6dac70072fc313e8c9cd1417b5074c0a125"
|
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.0.1.tgz#2efe162f5c3da06a28959fbd3db75dbeea9f0fc2"
|
||||||
integrity sha512-i4sc/Kj8htBrAiH1viZ0TgU8Y5XqCaV/FziYK6TBczxmeKm3AEFWqqF3195yKudrarqy7Zu80Ra5dobFjn9X/Q==
|
integrity sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==
|
||||||
dependencies:
|
dependencies:
|
||||||
boolbase "^1.0.0"
|
boolbase "^1.0.0"
|
||||||
|
|
||||||
@@ -7278,9 +7278,9 @@ tiny-warning@^1.0.0, tiny-warning@^1.0.3:
|
|||||||
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
|
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
|
||||||
|
|
||||||
tmpl@1.0.x:
|
tmpl@1.0.x:
|
||||||
version "1.0.4"
|
version "1.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1"
|
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc"
|
||||||
integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=
|
integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==
|
||||||
|
|
||||||
to-fast-properties@^2.0.0:
|
to-fast-properties@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
|
@@ -1,14 +1,8 @@
|
|||||||
"""JupyterHub version info"""
|
"""JupyterHub version info"""
|
||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
# version_info updated by running `tbump`
|
||||||
version_info = (
|
version_info = (2, 0, 0, "rc1", "")
|
||||||
2,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
"b1", # release (b1, rc1, or "" for final or dev)
|
|
||||||
# "dev", # dev or nothing for beta/rc/stable releases
|
|
||||||
)
|
|
||||||
|
|
||||||
# pep 440 version: no dot before beta/rc, but before .dev
|
# pep 440 version: no dot before beta/rc, but before .dev
|
||||||
# 0.1.0rc1
|
# 0.1.0rc1
|
||||||
@@ -16,7 +10,9 @@ version_info = (
|
|||||||
# 0.1.0b1.dev
|
# 0.1.0b1.dev
|
||||||
# 0.1.0.dev
|
# 0.1.0.dev
|
||||||
|
|
||||||
__version__ = ".".join(map(str, version_info[:3])) + ".".join(version_info[3:])
|
__version__ = ".".join(map(str, version_info[:3])) + ".".join(version_info[3:]).rstrip(
|
||||||
|
"."
|
||||||
|
)
|
||||||
|
|
||||||
# Singleton flag to only log the major/minor mismatch warning once per mismatch combo.
|
# Singleton flag to only log the major/minor mismatch warning once per mismatch combo.
|
||||||
_version_mismatch_warning_logged = {}
|
_version_mismatch_warning_logged = {}
|
||||||
|
@@ -308,12 +308,14 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
|||||||
"filter": "",
|
"filter": "",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
elif 'all' in raw_scopes:
|
elif 'inherit' in raw_scopes:
|
||||||
raw_scopes = ['all']
|
raw_scopes = ['inherit']
|
||||||
scope_descriptions = [
|
scope_descriptions = [
|
||||||
{
|
{
|
||||||
"scope": "all",
|
"scope": "inherit",
|
||||||
"description": scopes.scope_definitions['all']['description'],
|
"description": scopes.scope_definitions['inherit'][
|
||||||
|
'description'
|
||||||
|
],
|
||||||
"filter": "",
|
"filter": "",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@@ -129,7 +129,7 @@ class GroupAPIHandler(_GroupAPIHandler):
|
|||||||
self.write(json.dumps(self.group_model(group)))
|
self.write(json.dumps(self.group_model(group)))
|
||||||
self.set_status(201)
|
self.set_status(201)
|
||||||
|
|
||||||
@needs_scope('admin:groups')
|
@needs_scope('delete:groups')
|
||||||
def delete(self, group_name):
|
def delete(self, group_name):
|
||||||
"""Delete a group by name"""
|
"""Delete a group by name"""
|
||||||
group = self.find_group(group_name)
|
group = self.find_group(group_name)
|
||||||
|
@@ -266,7 +266,7 @@ class UserAPIHandler(APIHandler):
|
|||||||
self.write(json.dumps(self.user_model(user)))
|
self.write(json.dumps(self.user_model(user)))
|
||||||
self.set_status(201)
|
self.set_status(201)
|
||||||
|
|
||||||
@needs_scope('admin:users')
|
@needs_scope('delete:users')
|
||||||
async def delete(self, user_name):
|
async def delete(self, user_name):
|
||||||
user = self.find_user(user_name)
|
user = self.find_user(user_name)
|
||||||
if user is None:
|
if user is None:
|
||||||
@@ -397,9 +397,11 @@ class UserTokenListAPIHandler(APIHandler):
|
|||||||
token_roles = body.get('roles')
|
token_roles = body.get('roles')
|
||||||
try:
|
try:
|
||||||
api_token = user.new_api_token(
|
api_token = user.new_api_token(
|
||||||
note=note, expires_in=body.get('expires_in', None), roles=token_roles
|
note=note,
|
||||||
|
expires_in=body.get('expires_in', None),
|
||||||
|
roles=token_roles,
|
||||||
)
|
)
|
||||||
except NameError:
|
except KeyError:
|
||||||
raise web.HTTPError(404, "Requested roles %r not found" % token_roles)
|
raise web.HTTPError(404, "Requested roles %r not found" % token_roles)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise web.HTTPError(
|
raise web.HTTPError(
|
||||||
@@ -421,6 +423,7 @@ class UserTokenListAPIHandler(APIHandler):
|
|||||||
token_model = self.token_model(orm.APIToken.find(self.db, api_token))
|
token_model = self.token_model(orm.APIToken.find(self.db, api_token))
|
||||||
token_model['token'] = api_token
|
token_model['token'] = api_token
|
||||||
self.write(json.dumps(token_model))
|
self.write(json.dumps(token_model))
|
||||||
|
self.set_status(201)
|
||||||
|
|
||||||
|
|
||||||
class UserTokenAPIHandler(APIHandler):
|
class UserTokenAPIHandler(APIHandler):
|
||||||
@@ -483,6 +486,11 @@ class UserServerAPIHandler(APIHandler):
|
|||||||
@needs_scope('servers')
|
@needs_scope('servers')
|
||||||
async def post(self, user_name, server_name=''):
|
async def post(self, user_name, server_name=''):
|
||||||
user = self.find_user(user_name)
|
user = self.find_user(user_name)
|
||||||
|
if user is None:
|
||||||
|
# this can be reached if a token has `servers`
|
||||||
|
# permission on *all* users
|
||||||
|
raise web.HTTPError(404)
|
||||||
|
|
||||||
if server_name:
|
if server_name:
|
||||||
if not self.allow_named_servers:
|
if not self.allow_named_servers:
|
||||||
raise web.HTTPError(400, "Named servers are not enabled.")
|
raise web.HTTPError(400, "Named servers are not enabled.")
|
||||||
@@ -525,7 +533,7 @@ class UserServerAPIHandler(APIHandler):
|
|||||||
self.set_header('Content-Type', 'text/plain')
|
self.set_header('Content-Type', 'text/plain')
|
||||||
self.set_status(status)
|
self.set_status(status)
|
||||||
|
|
||||||
@needs_scope('servers')
|
@needs_scope('delete:servers')
|
||||||
async def delete(self, user_name, server_name=''):
|
async def delete(self, user_name, server_name=''):
|
||||||
user = self.find_user(user_name)
|
user = self.find_user(user_name)
|
||||||
options = self.get_json_body()
|
options = self.get_json_body()
|
||||||
|
@@ -90,6 +90,7 @@ from .log import CoroutineLogFormatter, log_request
|
|||||||
from .proxy import Proxy, ConfigurableHTTPProxy
|
from .proxy import Proxy, ConfigurableHTTPProxy
|
||||||
from .traitlets import URLPrefix, Command, EntryPointType, Callable
|
from .traitlets import URLPrefix, Command, EntryPointType, Callable
|
||||||
from .utils import (
|
from .utils import (
|
||||||
|
AnyTimeoutError,
|
||||||
catch_db_error,
|
catch_db_error,
|
||||||
maybe_future,
|
maybe_future,
|
||||||
url_path_join,
|
url_path_join,
|
||||||
@@ -1518,6 +1519,25 @@ class JupyterHub(Application):
|
|||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
|
use_legacy_stopped_server_status_code = Bool(
|
||||||
|
False,
|
||||||
|
help="""
|
||||||
|
Return 503 rather than 424 when request comes in for a non-running server.
|
||||||
|
|
||||||
|
Prior to JupyterHub 2.0, we returned a 503 when any request came in for
|
||||||
|
a user server that was currently not running. By default, JupyterHub 2.0
|
||||||
|
will return a 424 - this makes operational metric dashboards more useful.
|
||||||
|
|
||||||
|
JupyterLab < 3.2 expected the 503 to know if the user server is no longer
|
||||||
|
running, and prompted the user to start their server. Set this config to
|
||||||
|
true to retain the old behavior, so JupyterLab < 3.2 can continue to show
|
||||||
|
the appropriate UI when the user server is stopped.
|
||||||
|
|
||||||
|
This option will be removed in a future release.
|
||||||
|
""",
|
||||||
|
config=True,
|
||||||
|
)
|
||||||
|
|
||||||
def init_handlers(self):
|
def init_handlers(self):
|
||||||
h = []
|
h = []
|
||||||
# load handlers from the authenticator
|
# load handlers from the authenticator
|
||||||
@@ -2050,7 +2070,7 @@ class JupyterHub(Application):
|
|||||||
if role_spec['name'] == 'admin':
|
if role_spec['name'] == 'admin':
|
||||||
app_log.warning(
|
app_log.warning(
|
||||||
"Configuration specifies both admin_users and users in the admin role specification. "
|
"Configuration specifies both admin_users and users in the admin role specification. "
|
||||||
"If admin role is present in config, c.authenticator.admin_users should not be used."
|
"If admin role is present in config, c.Authenticator.admin_users should not be used."
|
||||||
)
|
)
|
||||||
app_log.info(
|
app_log.info(
|
||||||
"Merging admin_users set with users list in admin role"
|
"Merging admin_users set with users list in admin role"
|
||||||
@@ -2089,7 +2109,7 @@ class JupyterHub(Application):
|
|||||||
)
|
)
|
||||||
Class = orm.get_class(kind)
|
Class = orm.get_class(kind)
|
||||||
orm_obj = Class.find(db, bname)
|
orm_obj = Class.find(db, bname)
|
||||||
if orm_obj:
|
if orm_obj is not None:
|
||||||
orm_role_bearers.append(orm_obj)
|
orm_role_bearers.append(orm_obj)
|
||||||
else:
|
else:
|
||||||
app_log.info(
|
app_log.info(
|
||||||
@@ -2098,6 +2118,11 @@ class JupyterHub(Application):
|
|||||||
if kind == 'users':
|
if kind == 'users':
|
||||||
orm_obj = await self._get_or_create_user(bname)
|
orm_obj = await self._get_or_create_user(bname)
|
||||||
orm_role_bearers.append(orm_obj)
|
orm_role_bearers.append(orm_obj)
|
||||||
|
elif kind == 'groups':
|
||||||
|
group = orm.Group(name=bname)
|
||||||
|
db.add(group)
|
||||||
|
db.commit()
|
||||||
|
orm_role_bearers.append(group)
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{kind} {bname} defined in config role definition {predef_role['name']} but not present in database"
|
f"{kind} {bname} defined in config role definition {predef_role['name']} but not present in database"
|
||||||
@@ -2331,7 +2356,7 @@ class JupyterHub(Application):
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
await Server.from_orm(service.orm.server).wait_up(timeout=1, http=True)
|
await Server.from_orm(service.orm.server).wait_up(timeout=1, http=True)
|
||||||
except TimeoutError:
|
except AnyTimeoutError:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"Cannot connect to %s service %s at %s",
|
"Cannot connect to %s service %s at %s",
|
||||||
service.kind,
|
service.kind,
|
||||||
@@ -2409,7 +2434,7 @@ class JupyterHub(Application):
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await user._wait_up(spawner)
|
await user._wait_up(spawner)
|
||||||
except TimeoutError:
|
except AnyTimeoutError:
|
||||||
self.log.error(
|
self.log.error(
|
||||||
"%s does not appear to be running at %s, shutting it down.",
|
"%s does not appear to be running at %s, shutting it down.",
|
||||||
spawner._log_name,
|
spawner._log_name,
|
||||||
@@ -2773,7 +2798,7 @@ class JupyterHub(Application):
|
|||||||
await gen.with_timeout(
|
await gen.with_timeout(
|
||||||
timedelta(seconds=max(init_spawners_timeout, 1)), init_spawners_future
|
timedelta(seconds=max(init_spawners_timeout, 1)), init_spawners_future
|
||||||
)
|
)
|
||||||
except gen.TimeoutError:
|
except AnyTimeoutError:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"init_spawners did not complete within %i seconds. "
|
"init_spawners did not complete within %i seconds. "
|
||||||
"Allowing to complete in the background.",
|
"Allowing to complete in the background.",
|
||||||
@@ -3036,7 +3061,7 @@ class JupyterHub(Application):
|
|||||||
await Server.from_orm(service.orm.server).wait_up(
|
await Server.from_orm(service.orm.server).wait_up(
|
||||||
http=True, timeout=1, ssl_context=ssl_context
|
http=True, timeout=1, ssl_context=ssl_context
|
||||||
)
|
)
|
||||||
except TimeoutError:
|
except AnyTimeoutError:
|
||||||
if service.managed:
|
if service.managed:
|
||||||
status = await service.spawner.poll()
|
status = await service.spawner.poll()
|
||||||
if status is not None:
|
if status is not None:
|
||||||
|
@@ -1173,3 +1173,22 @@ class DummyAuthenticator(Authenticator):
|
|||||||
return data['username']
|
return data['username']
|
||||||
return None
|
return None
|
||||||
return data['username']
|
return data['username']
|
||||||
|
|
||||||
|
|
||||||
|
class NullAuthenticator(Authenticator):
|
||||||
|
"""Null Authenticator for JupyterHub
|
||||||
|
|
||||||
|
For cases where authentication should be disabled,
|
||||||
|
e.g. only allowing access via API tokens.
|
||||||
|
|
||||||
|
.. versionadded:: 2.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# auto_login skips 'Login with...' page on Hub 0.8
|
||||||
|
auto_login = True
|
||||||
|
|
||||||
|
# for Hub 0.7, show 'login with...'
|
||||||
|
login_service = 'null'
|
||||||
|
|
||||||
|
def get_handlers(self, app):
|
||||||
|
return []
|
||||||
|
@@ -47,6 +47,7 @@ from ..metrics import TOTAL_USERS
|
|||||||
from ..objects import Server
|
from ..objects import Server
|
||||||
from ..spawner import LocalProcessSpawner
|
from ..spawner import LocalProcessSpawner
|
||||||
from ..user import User
|
from ..user import User
|
||||||
|
from ..utils import AnyTimeoutError
|
||||||
from ..utils import get_accepted_mimetype
|
from ..utils import get_accepted_mimetype
|
||||||
from ..utils import maybe_future
|
from ..utils import maybe_future
|
||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
@@ -490,7 +491,7 @@ class BaseHandler(RequestHandler):
|
|||||||
session_id = self.get_session_cookie()
|
session_id = self.get_session_cookie()
|
||||||
if session_id:
|
if session_id:
|
||||||
# clear session id
|
# clear session id
|
||||||
self.clear_cookie(SESSION_COOKIE_NAME, **kwargs)
|
self.clear_cookie(SESSION_COOKIE_NAME, path=self.base_url, **kwargs)
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
# user is logged in, clear any tokens associated with the current session
|
# user is logged in, clear any tokens associated with the current session
|
||||||
@@ -569,7 +570,9 @@ class BaseHandler(RequestHandler):
|
|||||||
so other services on this domain can read it.
|
so other services on this domain can read it.
|
||||||
"""
|
"""
|
||||||
session_id = uuid.uuid4().hex
|
session_id = uuid.uuid4().hex
|
||||||
self._set_cookie(SESSION_COOKIE_NAME, session_id, encrypted=False)
|
self._set_cookie(
|
||||||
|
SESSION_COOKIE_NAME, session_id, encrypted=False, path=self.base_url
|
||||||
|
)
|
||||||
return session_id
|
return session_id
|
||||||
|
|
||||||
def set_service_cookie(self, user):
|
def set_service_cookie(self, user):
|
||||||
@@ -1019,7 +1022,7 @@ class BaseHandler(RequestHandler):
|
|||||||
await gen.with_timeout(
|
await gen.with_timeout(
|
||||||
timedelta(seconds=self.slow_spawn_timeout), finish_spawn_future
|
timedelta(seconds=self.slow_spawn_timeout), finish_spawn_future
|
||||||
)
|
)
|
||||||
except gen.TimeoutError:
|
except AnyTimeoutError:
|
||||||
# waiting_for_response indicates server process has started,
|
# waiting_for_response indicates server process has started,
|
||||||
# but is yet to become responsive.
|
# but is yet to become responsive.
|
||||||
if spawner._spawn_pending and not spawner._waiting_for_response:
|
if spawner._spawn_pending and not spawner._waiting_for_response:
|
||||||
@@ -1166,7 +1169,7 @@ class BaseHandler(RequestHandler):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), future)
|
await gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), future)
|
||||||
except gen.TimeoutError:
|
except AnyTimeoutError:
|
||||||
# hit timeout, but stop is still pending
|
# hit timeout, but stop is still pending
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"User %s:%s server is slow to stop (timeout=%s)",
|
"User %s:%s server is slow to stop (timeout=%s)",
|
||||||
@@ -1355,7 +1358,7 @@ class UserUrlHandler(BaseHandler):
|
|||||||
|
|
||||||
**Changed Behavior as of 1.0** This handler no longer triggers a spawn. Instead, it checks if:
|
**Changed Behavior as of 1.0** This handler no longer triggers a spawn. Instead, it checks if:
|
||||||
|
|
||||||
1. server is not active, serve page prompting for spawn (status: 503)
|
1. server is not active, serve page prompting for spawn (status: 424)
|
||||||
2. server is ready (This shouldn't happen! Proxy isn't updated yet. Wait a bit and redirect.)
|
2. server is ready (This shouldn't happen! Proxy isn't updated yet. Wait a bit and redirect.)
|
||||||
3. server is active, redirect to /hub/spawn-pending to monitor launch progress
|
3. server is active, redirect to /hub/spawn-pending to monitor launch progress
|
||||||
(will redirect back when finished)
|
(will redirect back when finished)
|
||||||
@@ -1374,7 +1377,14 @@ class UserUrlHandler(BaseHandler):
|
|||||||
self.log.warning(
|
self.log.warning(
|
||||||
"Failing suspected API request to not-running server: %s", self.request.path
|
"Failing suspected API request to not-running server: %s", self.request.path
|
||||||
)
|
)
|
||||||
self.set_status(503)
|
|
||||||
|
# If we got here, the server is not running. To differentiate
|
||||||
|
# that the *server* itself is not running, rather than just the particular
|
||||||
|
# resource *in* the server is not found, we return a 424 instead of a 404.
|
||||||
|
# We allow retaining the old behavior to support older JupyterLab versions
|
||||||
|
self.set_status(
|
||||||
|
424 if not self.app.use_legacy_stopped_server_status_code else 503
|
||||||
|
)
|
||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
|
|
||||||
spawn_url = urlparse(self.request.full_url())._replace(query="")
|
spawn_url = urlparse(self.request.full_url())._replace(query="")
|
||||||
@@ -1539,15 +1549,17 @@ class UserUrlHandler(BaseHandler):
|
|||||||
self.redirect(pending_url, status=303)
|
self.redirect(pending_url, status=303)
|
||||||
return
|
return
|
||||||
|
|
||||||
# if we got here, the server is not running
|
# If we got here, the server is not running. To differentiate
|
||||||
# serve a page prompting for spawn and 503 error
|
# that the *server* itself is not running, rather than just the particular
|
||||||
# visiting /user/:name no longer triggers implicit spawn
|
# page *in* the server is not found, we return a 424 instead of a 404.
|
||||||
# without explicit user action
|
# We allow retaining the old behavior to support older JupyterLab versions
|
||||||
spawn_url = url_concat(
|
spawn_url = url_concat(
|
||||||
url_path_join(self.hub.base_url, "spawn", user.escaped_name, server_name),
|
url_path_join(self.hub.base_url, "spawn", user.escaped_name, server_name),
|
||||||
{"next": self.request.uri},
|
{"next": self.request.uri},
|
||||||
)
|
)
|
||||||
self.set_status(503)
|
self.set_status(
|
||||||
|
424 if not self.app.use_legacy_stopped_server_status_code else 503
|
||||||
|
)
|
||||||
|
|
||||||
auth_state = await user.get_auth_state()
|
auth_state = await user.get_auth_state()
|
||||||
html = await self.render_template(
|
html = await self.render_template(
|
||||||
|
@@ -44,6 +44,7 @@ from . import utils
|
|||||||
from .metrics import CHECK_ROUTES_DURATION_SECONDS
|
from .metrics import CHECK_ROUTES_DURATION_SECONDS
|
||||||
from .metrics import PROXY_POLL_DURATION_SECONDS
|
from .metrics import PROXY_POLL_DURATION_SECONDS
|
||||||
from .objects import Server
|
from .objects import Server
|
||||||
|
from .utils import AnyTimeoutError
|
||||||
from .utils import exponential_backoff
|
from .utils import exponential_backoff
|
||||||
from .utils import url_path_join
|
from .utils import url_path_join
|
||||||
from jupyterhub.traitlets import Command
|
from jupyterhub.traitlets import Command
|
||||||
@@ -718,7 +719,7 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
_check_process()
|
_check_process()
|
||||||
try:
|
try:
|
||||||
await server.wait_up(1)
|
await server.wait_up(1)
|
||||||
except TimeoutError:
|
except AnyTimeoutError:
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
@@ -57,7 +57,7 @@ def get_default_roles():
|
|||||||
{
|
{
|
||||||
'name': 'token',
|
'name': 'token',
|
||||||
'description': 'Token with same permissions as its owner',
|
'description': 'Token with same permissions as its owner',
|
||||||
'scopes': ['all'],
|
'scopes': ['inherit'],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
return default_roles
|
return default_roles
|
||||||
@@ -89,6 +89,7 @@ def expand_self_scope(name):
|
|||||||
'users:activity',
|
'users:activity',
|
||||||
'read:users:activity',
|
'read:users:activity',
|
||||||
'servers',
|
'servers',
|
||||||
|
'delete:servers',
|
||||||
'read:servers',
|
'read:servers',
|
||||||
'tokens',
|
'tokens',
|
||||||
'read:tokens',
|
'read:tokens',
|
||||||
@@ -213,7 +214,7 @@ def _check_scopes(*args, rolename=None):
|
|||||||
or
|
or
|
||||||
scopes (list): list of scopes to check
|
scopes (list): list of scopes to check
|
||||||
|
|
||||||
Raises NameError if scope does not exist
|
Raises KeyError if scope does not exist
|
||||||
"""
|
"""
|
||||||
|
|
||||||
allowed_scopes = set(scopes.scope_definitions.keys())
|
allowed_scopes = set(scopes.scope_definitions.keys())
|
||||||
@@ -227,11 +228,13 @@ def _check_scopes(*args, rolename=None):
|
|||||||
for scope in args:
|
for scope in args:
|
||||||
scopename, _, filter_ = scope.partition('!')
|
scopename, _, filter_ = scope.partition('!')
|
||||||
if scopename not in allowed_scopes:
|
if scopename not in allowed_scopes:
|
||||||
raise NameError(f"Scope '{scope}' {log_role} does not exist")
|
if scopename == "all":
|
||||||
|
raise KeyError("Draft scope 'all' is now called 'inherit'")
|
||||||
|
raise KeyError(f"Scope '{scope}' {log_role} does not exist")
|
||||||
if filter_:
|
if filter_:
|
||||||
full_filter = f"!{filter_}"
|
full_filter = f"!{filter_}"
|
||||||
if not any(f in scope for f in allowed_filters):
|
if not any(f in scope for f in allowed_filters):
|
||||||
raise NameError(
|
raise KeyError(
|
||||||
f"Scope filter '{full_filter}' in scope '{scope}' {log_role} does not exist"
|
f"Scope filter '{full_filter}' in scope '{scope}' {log_role} does not exist"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -321,7 +324,7 @@ def delete_role(db, rolename):
|
|||||||
db.commit()
|
db.commit()
|
||||||
app_log.info('Role %s has been deleted', rolename)
|
app_log.info('Role %s has been deleted', rolename)
|
||||||
else:
|
else:
|
||||||
raise NameError('Cannot remove role %r that does not exist', rolename)
|
raise KeyError('Cannot remove role %r that does not exist', rolename)
|
||||||
|
|
||||||
|
|
||||||
def existing_only(func):
|
def existing_only(func):
|
||||||
@@ -412,7 +415,7 @@ def _token_allowed_role(db, token, role):
|
|||||||
|
|
||||||
expanded_scopes = _get_subscopes(role, owner=owner)
|
expanded_scopes = _get_subscopes(role, owner=owner)
|
||||||
|
|
||||||
implicit_permissions = {'all', 'read:all'}
|
implicit_permissions = {'inherit', 'read:inherit'}
|
||||||
explicit_scopes = expanded_scopes - implicit_permissions
|
explicit_scopes = expanded_scopes - implicit_permissions
|
||||||
# ignore horizontal filters
|
# ignore horizontal filters
|
||||||
no_filter_scopes = {
|
no_filter_scopes = {
|
||||||
@@ -431,20 +434,20 @@ def _token_allowed_role(db, token, role):
|
|||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
app_log.warning(
|
app_log.warning(
|
||||||
f"Token requesting scopes exceeding owner {owner.name}: {disallowed_scopes}"
|
f"Token requesting role {role.name} with scopes not held by owner {owner.name}: {disallowed_scopes}"
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def assign_default_roles(db, entity):
|
def assign_default_roles(db, entity):
|
||||||
"""Assigns default role to an entity:
|
"""Assigns default role(s) to an entity:
|
||||||
users and services get 'user' role, or admin role if they have admin flag
|
users and services get 'user' role, or admin role if they have admin flag
|
||||||
tokens get 'token' role
|
tokens get 'token' role
|
||||||
"""
|
"""
|
||||||
if isinstance(entity, orm.Group):
|
if isinstance(entity, orm.Group):
|
||||||
pass
|
pass
|
||||||
elif isinstance(entity, orm.APIToken):
|
elif isinstance(entity, orm.APIToken):
|
||||||
app_log.debug('Assigning default roles to tokens')
|
app_log.debug('Assigning default role to token')
|
||||||
default_token_role = orm.Role.find(db, 'token')
|
default_token_role = orm.Role.find(db, 'token')
|
||||||
if not entity.roles and (entity.user or entity.service) is not None:
|
if not entity.roles and (entity.user or entity.service) is not None:
|
||||||
default_token_role.tokens.append(entity)
|
default_token_role.tokens.append(entity)
|
||||||
@@ -453,15 +456,18 @@ def assign_default_roles(db, entity):
|
|||||||
# users and services can have 'user' or 'admin' roles as default
|
# users and services can have 'user' or 'admin' roles as default
|
||||||
else:
|
else:
|
||||||
kind = type(entity).__name__
|
kind = type(entity).__name__
|
||||||
app_log.debug(f'Assigning default roles to {kind} {entity.name}')
|
app_log.debug(f'Assigning default role to {kind} {entity.name}')
|
||||||
_switch_default_role(db, entity, entity.admin)
|
_switch_default_role(db, entity, entity.admin)
|
||||||
|
|
||||||
|
|
||||||
def update_roles(db, entity, roles):
|
def update_roles(db, entity, roles):
|
||||||
"""Updates object's roles checking for requested permissions
|
"""Add roles to an entity (token, user, etc.)
|
||||||
if object is orm.APIToken
|
|
||||||
|
If it is an API token, check role permissions against token owner
|
||||||
|
prior to assignment to avoid permission expansion.
|
||||||
|
|
||||||
|
Otherwise, it just calls `grant_role` for each role.
|
||||||
"""
|
"""
|
||||||
standard_permissions = {'all', 'read:all'}
|
|
||||||
for rolename in roles:
|
for rolename in roles:
|
||||||
if isinstance(entity, orm.APIToken):
|
if isinstance(entity, orm.APIToken):
|
||||||
role = orm.Role.find(db, rolename)
|
role = orm.Role.find(db, rolename)
|
||||||
@@ -474,12 +480,11 @@ def update_roles(db, entity, roles):
|
|||||||
app_log.info('Adding role %s to token: %s', role.name, entity)
|
app_log.info('Adding role %s to token: %s', role.name, entity)
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'Requested token role {rolename} of {entity} has more permissions than the token owner'
|
f'Requested token role {rolename} for {entity} has more permissions than the token owner'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise NameError('Role %r does not exist' % rolename)
|
raise KeyError(f'Role {rolename} does not exist')
|
||||||
else:
|
else:
|
||||||
app_log.debug('Assigning default roles to %s', type(entity).__name__)
|
|
||||||
grant_role(db, entity=entity, rolename=rolename)
|
grant_role(db, entity=entity, rolename=rolename)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -30,19 +30,22 @@ scope_definitions = {
|
|||||||
'description': 'Your own resources',
|
'description': 'Your own resources',
|
||||||
'doc_description': 'The user’s own resources _(metascope for users, resolves to (no_scope) for services)_',
|
'doc_description': 'The user’s own resources _(metascope for users, resolves to (no_scope) for services)_',
|
||||||
},
|
},
|
||||||
'all': {
|
'inherit': {
|
||||||
'description': 'Anything you have access to',
|
'description': 'Anything you have access to',
|
||||||
'doc_description': 'Everything that the token-owning entity can access _(metascope for tokens)_',
|
'doc_description': 'Everything that the token-owning entity can access _(metascope for tokens)_',
|
||||||
},
|
},
|
||||||
'admin:users': {
|
'admin:users': {
|
||||||
'description': 'Read, write, create and delete users and their authentication state, not including their servers or tokens.',
|
'description': 'Read, write, create and delete users and their authentication state, not including their servers or tokens.',
|
||||||
'subscopes': ['admin:auth_state', 'users', 'read:roles:users'],
|
'subscopes': ['admin:auth_state', 'users', 'read:roles:users', 'delete:users'],
|
||||||
},
|
},
|
||||||
'admin:auth_state': {'description': 'Read a user’s authentication state.'},
|
'admin:auth_state': {'description': 'Read a user’s authentication state.'},
|
||||||
'users': {
|
'users': {
|
||||||
'description': 'Read and write permissions to user models (excluding servers, tokens and authentication state).',
|
'description': 'Read and write permissions to user models (excluding servers, tokens and authentication state).',
|
||||||
'subscopes': ['read:users', 'list:users', 'users:activity'],
|
'subscopes': ['read:users', 'list:users', 'users:activity'],
|
||||||
},
|
},
|
||||||
|
'delete:users': {
|
||||||
|
'description': "Delete users.",
|
||||||
|
},
|
||||||
'list:users': {
|
'list:users': {
|
||||||
'description': 'List users, including at least their names.',
|
'description': 'List users, including at least their names.',
|
||||||
'subscopes': ['read:users:name'],
|
'subscopes': ['read:users:name'],
|
||||||
@@ -76,12 +79,13 @@ scope_definitions = {
|
|||||||
'admin:server_state': {'description': 'Read and write users’ server state.'},
|
'admin:server_state': {'description': 'Read and write users’ server state.'},
|
||||||
'servers': {
|
'servers': {
|
||||||
'description': 'Start and stop user servers.',
|
'description': 'Start and stop user servers.',
|
||||||
'subscopes': ['read:servers'],
|
'subscopes': ['read:servers', 'delete:servers'],
|
||||||
},
|
},
|
||||||
'read:servers': {
|
'read:servers': {
|
||||||
'description': 'Read users’ names and their server models (excluding the server state).',
|
'description': 'Read users’ names and their server models (excluding the server state).',
|
||||||
'subscopes': ['read:users:name'],
|
'subscopes': ['read:users:name'],
|
||||||
},
|
},
|
||||||
|
'delete:servers': {'description': "Stop and delete users' servers."},
|
||||||
'tokens': {
|
'tokens': {
|
||||||
'description': 'Read, write, create and delete user tokens.',
|
'description': 'Read, write, create and delete user tokens.',
|
||||||
'subscopes': ['read:tokens'],
|
'subscopes': ['read:tokens'],
|
||||||
@@ -89,7 +93,7 @@ scope_definitions = {
|
|||||||
'read:tokens': {'description': 'Read user tokens.'},
|
'read:tokens': {'description': 'Read user tokens.'},
|
||||||
'admin:groups': {
|
'admin:groups': {
|
||||||
'description': 'Read and write group information, create and delete groups.',
|
'description': 'Read and write group information, create and delete groups.',
|
||||||
'subscopes': ['groups', 'read:roles:groups'],
|
'subscopes': ['groups', 'read:roles:groups', 'delete:groups'],
|
||||||
},
|
},
|
||||||
'groups': {
|
'groups': {
|
||||||
'description': 'Read and write group information, including adding/removing users to/from groups.',
|
'description': 'Read and write group information, including adding/removing users to/from groups.',
|
||||||
@@ -104,6 +108,9 @@ scope_definitions = {
|
|||||||
'subscopes': ['read:groups:name'],
|
'subscopes': ['read:groups:name'],
|
||||||
},
|
},
|
||||||
'read:groups:name': {'description': 'Read group names.'},
|
'read:groups:name': {'description': 'Read group names.'},
|
||||||
|
'delete:groups': {
|
||||||
|
'description': "Delete groups.",
|
||||||
|
},
|
||||||
'list:services': {
|
'list:services': {
|
||||||
'description': 'List services, including at least their names.',
|
'description': 'List services, including at least their names.',
|
||||||
'subscopes': ['read:services:name'],
|
'subscopes': ['read:services:name'],
|
||||||
@@ -310,13 +317,13 @@ def get_scopes_for(orm_object):
|
|||||||
|
|
||||||
owner_scopes = roles.expand_roles_to_scopes(owner)
|
owner_scopes = roles.expand_roles_to_scopes(owner)
|
||||||
|
|
||||||
if token_scopes == {'all'}:
|
if token_scopes == {'inherit'}:
|
||||||
# token_scopes is only 'all', return owner scopes as-is
|
# token_scopes is only 'inherit', return scopes inherited from owner as-is
|
||||||
# short-circuit common case where we don't need to compute an intersection
|
# short-circuit common case where we don't need to compute an intersection
|
||||||
return owner_scopes
|
return owner_scopes
|
||||||
|
|
||||||
if 'all' in token_scopes:
|
if 'inherit' in token_scopes:
|
||||||
token_scopes.remove('all')
|
token_scopes.remove('inherit')
|
||||||
token_scopes |= owner_scopes
|
token_scopes |= owner_scopes
|
||||||
|
|
||||||
intersection = _intersect_expanded_scopes(
|
intersection = _intersect_expanded_scopes(
|
||||||
|
@@ -1,7 +1,12 @@
|
|||||||
"""Make a single-user app based on the environment:
|
"""Make a single-user app based on the environment:
|
||||||
|
|
||||||
- $JUPYTERHUB_SINGLEUSER_APP, the base Application class, to be wrapped in JupyterHub authentication.
|
- $JUPYTERHUB_SINGLEUSER_APP, the base Application class, to be wrapped in JupyterHub authentication.
|
||||||
default: notebook.notebookapp.NotebookApp
|
default: jupyter_server.serverapp.ServerApp
|
||||||
|
|
||||||
|
.. versionchanged:: 2.0
|
||||||
|
|
||||||
|
Default app changed to launch `jupyter labhub`.
|
||||||
|
Use JUPYTERHUB_SINGLEUSER_APP=notebook.notebookapp.NotebookApp for the legacy 'classic' notebook server.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -9,12 +14,55 @@ from traitlets import import_item
|
|||||||
|
|
||||||
from .mixins import make_singleuser_app
|
from .mixins import make_singleuser_app
|
||||||
|
|
||||||
JUPYTERHUB_SINGLEUSER_APP = (
|
JUPYTERHUB_SINGLEUSER_APP = os.environ.get("JUPYTERHUB_SINGLEUSER_APP")
|
||||||
os.environ.get("JUPYTERHUB_SINGLEUSER_APP") or "notebook.notebookapp.NotebookApp"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
if JUPYTERHUB_SINGLEUSER_APP:
|
||||||
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
|
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
|
||||||
|
else:
|
||||||
|
App = None
|
||||||
|
_import_error = None
|
||||||
|
for JUPYTERHUB_SINGLEUSER_APP in (
|
||||||
|
"jupyter_server.serverapp.ServerApp",
|
||||||
|
"notebook.notebookapp.NotebookApp",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
|
||||||
|
except ImportError as e:
|
||||||
|
continue
|
||||||
|
if _import_error is None:
|
||||||
|
_import_error = e
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if App is None:
|
||||||
|
raise _import_error
|
||||||
|
|
||||||
|
|
||||||
SingleUserNotebookApp = make_singleuser_app(App)
|
SingleUserNotebookApp = make_singleuser_app(App)
|
||||||
|
|
||||||
main = SingleUserNotebookApp.launch_instance
|
|
||||||
|
def main():
|
||||||
|
"""Launch a jupyterhub single-user server"""
|
||||||
|
if not os.environ.get("JUPYTERHUB_SINGLEUSER_APP"):
|
||||||
|
# app not specified, launch jupyter-labhub by default,
|
||||||
|
# if jupyterlab is recent enough (3.1).
|
||||||
|
# This is a minimally extended ServerApp that does:
|
||||||
|
# 1. ensure lab extension is enabled, and
|
||||||
|
# 2. set default URL to `/lab`
|
||||||
|
import re
|
||||||
|
|
||||||
|
_version_pat = re.compile(r"(\d+)\.(\d+)")
|
||||||
|
try:
|
||||||
|
import jupyterlab
|
||||||
|
from jupyterlab.labhubapp import SingleUserLabApp
|
||||||
|
|
||||||
|
m = _version_pat.match(jupyterlab.__version__)
|
||||||
|
except Exception:
|
||||||
|
m = None
|
||||||
|
|
||||||
|
if m is not None:
|
||||||
|
version_tuple = tuple(int(v) for v in m.groups())
|
||||||
|
if version_tuple >= (3, 1):
|
||||||
|
return SingleUserLabApp.launch_instance()
|
||||||
|
|
||||||
|
return SingleUserNotebookApp.launch_instance()
|
||||||
|
@@ -15,8 +15,6 @@ from subprocess import Popen
|
|||||||
from tempfile import mkdtemp
|
from tempfile import mkdtemp
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
if os.name == 'nt':
|
|
||||||
import psutil
|
|
||||||
from async_generator import aclosing
|
from async_generator import aclosing
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
from tornado.ioloop import PeriodicCallback
|
from tornado.ioloop import PeriodicCallback
|
||||||
@@ -38,12 +36,14 @@ from .objects import Server
|
|||||||
from .traitlets import ByteSpecification
|
from .traitlets import ByteSpecification
|
||||||
from .traitlets import Callable
|
from .traitlets import Callable
|
||||||
from .traitlets import Command
|
from .traitlets import Command
|
||||||
|
from .utils import AnyTimeoutError
|
||||||
from .utils import exponential_backoff
|
from .utils import exponential_backoff
|
||||||
from .utils import maybe_future
|
from .utils import maybe_future
|
||||||
from .utils import random_port
|
from .utils import random_port
|
||||||
from .utils import url_path_join
|
from .utils import url_path_join
|
||||||
|
|
||||||
# FIXME: remove when we drop Python 3.5 support
|
if os.name == 'nt':
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
|
||||||
def _quote_safe(s):
|
def _quote_safe(s):
|
||||||
@@ -1263,7 +1263,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
return r
|
return r
|
||||||
except TimeoutError:
|
except AnyTimeoutError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@@ -972,6 +972,11 @@ async def test_bad_spawn(app, bad_spawn):
|
|||||||
assert app.users.count_active_users()['pending'] == 0
|
assert app.users.count_active_users()['pending'] == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_spawn_nosuch_user(app):
|
||||||
|
r = await api_request(app, 'users', "nosuchuser", 'server', method='post')
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
async def test_slow_bad_spawn(app, no_patience, slow_bad_spawn):
|
async def test_slow_bad_spawn(app, no_patience, slow_bad_spawn):
|
||||||
db = app.db
|
db = app.db
|
||||||
name = 'zaphod'
|
name = 'zaphod'
|
||||||
@@ -1366,8 +1371,8 @@ async def test_get_new_token_deprecated(app, headers, status):
|
|||||||
@mark.parametrize(
|
@mark.parametrize(
|
||||||
"headers, status, note, expires_in",
|
"headers, status, note, expires_in",
|
||||||
[
|
[
|
||||||
({}, 200, 'test note', None),
|
({}, 201, 'test note', None),
|
||||||
({}, 200, '', 100),
|
({}, 201, '', 100),
|
||||||
({'Authorization': 'token bad'}, 403, '', None),
|
({'Authorization': 'token bad'}, 403, '', None),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -1386,7 +1391,7 @@ async def test_get_new_token(app, headers, status, note, expires_in):
|
|||||||
app, 'users/admin/tokens', method='post', headers=headers, data=body
|
app, 'users/admin/tokens', method='post', headers=headers, data=body
|
||||||
)
|
)
|
||||||
assert r.status_code == status
|
assert r.status_code == status
|
||||||
if status != 200:
|
if status != 201:
|
||||||
return
|
return
|
||||||
# check the new-token reply
|
# check the new-token reply
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
@@ -1424,10 +1429,10 @@ async def test_get_new_token(app, headers, status, note, expires_in):
|
|||||||
@mark.parametrize(
|
@mark.parametrize(
|
||||||
"as_user, for_user, status",
|
"as_user, for_user, status",
|
||||||
[
|
[
|
||||||
('admin', 'other', 200),
|
('admin', 'other', 201),
|
||||||
('admin', 'missing', 403),
|
('admin', 'missing', 403),
|
||||||
('user', 'other', 403),
|
('user', 'other', 403),
|
||||||
('user', 'user', 200),
|
('user', 'user', 201),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_token_for_user(app, as_user, for_user, status):
|
async def test_token_for_user(app, as_user, for_user, status):
|
||||||
@@ -1448,7 +1453,7 @@ async def test_token_for_user(app, as_user, for_user, status):
|
|||||||
)
|
)
|
||||||
assert r.status_code == status
|
assert r.status_code == status
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
if status != 200:
|
if status != 201:
|
||||||
return
|
return
|
||||||
assert 'token' in reply
|
assert 'token' in reply
|
||||||
|
|
||||||
@@ -1486,7 +1491,7 @@ async def test_token_authenticator_noauth(app):
|
|||||||
data=json.dumps(data) if data else None,
|
data=json.dumps(data) if data else None,
|
||||||
noauth=True,
|
noauth=True,
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 201
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
assert 'token' in reply
|
assert 'token' in reply
|
||||||
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
||||||
@@ -1509,7 +1514,7 @@ async def test_token_authenticator_dict_noauth(app):
|
|||||||
data=json.dumps(data) if data else None,
|
data=json.dumps(data) if data else None,
|
||||||
noauth=True,
|
noauth=True,
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 201
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
assert 'token' in reply
|
assert 'token' in reply
|
||||||
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
||||||
|
@@ -3,6 +3,7 @@
|
|||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
import logging
|
import logging
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from requests import HTTPError
|
from requests import HTTPError
|
||||||
@@ -12,6 +13,8 @@ from .mocking import MockPAMAuthenticator
|
|||||||
from .mocking import MockStructGroup
|
from .mocking import MockStructGroup
|
||||||
from .mocking import MockStructPasswd
|
from .mocking import MockStructPasswd
|
||||||
from .utils import add_user
|
from .utils import add_user
|
||||||
|
from .utils import async_requests
|
||||||
|
from .utils import public_url
|
||||||
from jupyterhub import auth
|
from jupyterhub import auth
|
||||||
from jupyterhub import crypto
|
from jupyterhub import crypto
|
||||||
from jupyterhub import orm
|
from jupyterhub import orm
|
||||||
@@ -515,3 +518,12 @@ def test_deprecated_methods_subclass():
|
|||||||
assert authenticator.check_whitelist("subclass-allowed")
|
assert authenticator.check_whitelist("subclass-allowed")
|
||||||
assert not authenticator.check_allowed("otheruser")
|
assert not authenticator.check_allowed("otheruser")
|
||||||
assert not authenticator.check_whitelist("otheruser")
|
assert not authenticator.check_whitelist("otheruser")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_nullauthenticator(app):
|
||||||
|
with mock.patch.dict(
|
||||||
|
app.tornado_settings, {"authenticator": auth.NullAuthenticator(parent=app)}
|
||||||
|
):
|
||||||
|
r = await async_requests.get(public_url(app))
|
||||||
|
assert urlparse(r.url).path.endswith("/hub/login")
|
||||||
|
assert r.status_code == 403
|
||||||
|
@@ -1,16 +1,13 @@
|
|||||||
"""Tests for jupyterhub internal_ssl connections"""
|
"""Tests for jupyterhub internal_ssl connections"""
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from subprocess import check_output
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from requests.exceptions import ConnectionError
|
from requests.exceptions import ConnectionError
|
||||||
from requests.exceptions import SSLError
|
from requests.exceptions import SSLError
|
||||||
from tornado import gen
|
|
||||||
|
|
||||||
import jupyterhub
|
from ..utils import AnyTimeoutError
|
||||||
from .test_api import add_user
|
from .test_api import add_user
|
||||||
from .utils import async_requests
|
from .utils import async_requests
|
||||||
|
|
||||||
@@ -35,7 +32,7 @@ async def wait_for_spawner(spawner, timeout=10):
|
|||||||
assert status is None
|
assert status is None
|
||||||
try:
|
try:
|
||||||
await wait()
|
await wait()
|
||||||
except TimeoutError:
|
except AnyTimeoutError:
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
@@ -56,8 +56,8 @@ async def test_root_redirect(app):
|
|||||||
r = await get_page(url, app, cookies=cookies)
|
r = await get_page(url, app, cookies=cookies)
|
||||||
path = urlparse(r.url).path
|
path = urlparse(r.url).path
|
||||||
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
|
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
|
||||||
# serve "server not running" page, which has status 503
|
# serve "server not running" page, which has status 424
|
||||||
assert r.status_code == 503
|
assert r.status_code == 424
|
||||||
|
|
||||||
|
|
||||||
async def test_root_default_url_noauth(app):
|
async def test_root_default_url_noauth(app):
|
||||||
@@ -172,7 +172,7 @@ async def test_spawn_redirect(app):
|
|||||||
r = await get_page('user/' + name, app, hub=False, cookies=cookies)
|
r = await get_page('user/' + name, app, hub=False, cookies=cookies)
|
||||||
path = urlparse(r.url).path
|
path = urlparse(r.url).path
|
||||||
assert path == ujoin(app.base_url, 'hub/user/%s/' % name)
|
assert path == ujoin(app.base_url, 'hub/user/%s/' % name)
|
||||||
assert r.status_code == 503
|
assert r.status_code == 424
|
||||||
|
|
||||||
|
|
||||||
async def test_spawn_handler_access(app):
|
async def test_spawn_handler_access(app):
|
||||||
@@ -507,13 +507,13 @@ async def test_user_redirect_deprecated(app, username):
|
|||||||
print(urlparse(r.url))
|
print(urlparse(r.url))
|
||||||
path = urlparse(r.url).path
|
path = urlparse(r.url).path
|
||||||
assert path == ujoin(app.base_url, 'hub/user/%s/' % name)
|
assert path == ujoin(app.base_url, 'hub/user/%s/' % name)
|
||||||
assert r.status_code == 503
|
assert r.status_code == 424
|
||||||
|
|
||||||
r = await get_page('/user/baduser/test.ipynb', app, cookies=cookies, hub=False)
|
r = await get_page('/user/baduser/test.ipynb', app, cookies=cookies, hub=False)
|
||||||
print(urlparse(r.url))
|
print(urlparse(r.url))
|
||||||
path = urlparse(r.url).path
|
path = urlparse(r.url).path
|
||||||
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
|
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
|
||||||
assert r.status_code == 503
|
assert r.status_code == 424
|
||||||
|
|
||||||
r = await get_page('/user/baduser/test.ipynb', app, hub=False)
|
r = await get_page('/user/baduser/test.ipynb', app, hub=False)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
@@ -1061,13 +1061,20 @@ async def test_token_page(app):
|
|||||||
async def test_server_not_running_api_request(app):
|
async def test_server_not_running_api_request(app):
|
||||||
cookies = await app.login_user("bees")
|
cookies = await app.login_user("bees")
|
||||||
r = await get_page("user/bees/api/status", app, hub=False, cookies=cookies)
|
r = await get_page("user/bees/api/status", app, hub=False, cookies=cookies)
|
||||||
assert r.status_code == 503
|
assert r.status_code == 424
|
||||||
assert r.headers["content-type"] == "application/json"
|
assert r.headers["content-type"] == "application/json"
|
||||||
message = r.json()['message']
|
message = r.json()['message']
|
||||||
assert ujoin(app.base_url, "hub/spawn/bees") in message
|
assert ujoin(app.base_url, "hub/spawn/bees") in message
|
||||||
assert " /user/bees" in message
|
assert " /user/bees" in message
|
||||||
|
|
||||||
|
|
||||||
|
async def test_server_not_running_api_request_legacy_status(app):
|
||||||
|
app.use_legacy_stopped_server_status_code = True
|
||||||
|
cookies = await app.login_user("bees")
|
||||||
|
r = await get_page("user/bees/api/status", app, hub=False, cookies=cookies)
|
||||||
|
assert r.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
async def test_metrics_no_auth(app):
|
async def test_metrics_no_auth(app):
|
||||||
r = await get_page("metrics", app)
|
r = await get_page("metrics", app)
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
@@ -28,7 +28,7 @@ def test_orm_roles(db):
|
|||||||
user_role = orm.Role(name='user', scopes=['self'])
|
user_role = orm.Role(name='user', scopes=['self'])
|
||||||
db.add(user_role)
|
db.add(user_role)
|
||||||
if not token_role:
|
if not token_role:
|
||||||
token_role = orm.Role(name='token', scopes=['all'])
|
token_role = orm.Role(name='token', scopes=['inherit'])
|
||||||
db.add(token_role)
|
db.add(token_role)
|
||||||
if not service_role:
|
if not service_role:
|
||||||
service_role = orm.Role(name='service', scopes=[])
|
service_role = orm.Role(name='service', scopes=[])
|
||||||
@@ -182,6 +182,7 @@ def test_orm_roles_delete_cascade(db):
|
|||||||
'admin:users',
|
'admin:users',
|
||||||
'admin:auth_state',
|
'admin:auth_state',
|
||||||
'users',
|
'users',
|
||||||
|
'delete:users',
|
||||||
'list:users',
|
'list:users',
|
||||||
'read:users',
|
'read:users',
|
||||||
'users:activity',
|
'users:activity',
|
||||||
@@ -218,6 +219,7 @@ def test_orm_roles_delete_cascade(db):
|
|||||||
{
|
{
|
||||||
'admin:groups',
|
'admin:groups',
|
||||||
'groups',
|
'groups',
|
||||||
|
'delete:groups',
|
||||||
'list:groups',
|
'list:groups',
|
||||||
'read:groups',
|
'read:groups',
|
||||||
'read:roles:groups',
|
'read:roles:groups',
|
||||||
@@ -229,6 +231,7 @@ def test_orm_roles_delete_cascade(db):
|
|||||||
{
|
{
|
||||||
'admin:groups',
|
'admin:groups',
|
||||||
'groups',
|
'groups',
|
||||||
|
'delete:groups',
|
||||||
'list:groups',
|
'list:groups',
|
||||||
'read:groups',
|
'read:groups',
|
||||||
'read:roles:groups',
|
'read:roles:groups',
|
||||||
@@ -366,7 +369,7 @@ async def test_creating_roles(app, role, role_def, response_type, response):
|
|||||||
'info',
|
'info',
|
||||||
app_log.info('Role user scopes attribute has been changed'),
|
app_log.info('Role user scopes attribute has been changed'),
|
||||||
),
|
),
|
||||||
('non-existing', 'test-role2', 'error', NameError),
|
('non-existing', 'test-role2', 'error', KeyError),
|
||||||
('default', 'user', 'error', ValueError),
|
('default', 'user', 'error', ValueError),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -407,9 +410,9 @@ async def test_delete_roles(db, role_type, rolename, response_type, response):
|
|||||||
},
|
},
|
||||||
'existing',
|
'existing',
|
||||||
),
|
),
|
||||||
({'name': 'test-scopes-2', 'scopes': ['uses']}, NameError),
|
({'name': 'test-scopes-2', 'scopes': ['uses']}, KeyError),
|
||||||
({'name': 'test-scopes-3', 'scopes': ['users:activities']}, NameError),
|
({'name': 'test-scopes-3', 'scopes': ['users:activities']}, KeyError),
|
||||||
({'name': 'test-scopes-4', 'scopes': ['groups!goup=class-A']}, NameError),
|
({'name': 'test-scopes-4', 'scopes': ['groups!goup=class-A']}, KeyError),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_scope_existence(tmpdir, request, role, response):
|
async def test_scope_existence(tmpdir, request, role, response):
|
||||||
@@ -428,7 +431,7 @@ async def test_scope_existence(tmpdir, request, role, response):
|
|||||||
assert added_role is not None
|
assert added_role is not None
|
||||||
assert added_role.scopes == role['scopes']
|
assert added_role.scopes == role['scopes']
|
||||||
|
|
||||||
elif response == NameError:
|
elif response == KeyError:
|
||||||
with pytest.raises(response):
|
with pytest.raises(response):
|
||||||
roles.create_role(db, role)
|
roles.create_role(db, role)
|
||||||
added_role = orm.Role.find(db, role['name'])
|
added_role = orm.Role.find(db, role['name'])
|
||||||
@@ -575,7 +578,7 @@ async def test_load_roles_groups(tmpdir, request):
|
|||||||
'name': 'head',
|
'name': 'head',
|
||||||
'description': 'Whole user access',
|
'description': 'Whole user access',
|
||||||
'scopes': ['users', 'admin:users'],
|
'scopes': ['users', 'admin:users'],
|
||||||
'groups': ['group3'],
|
'groups': ['group3', "group4"],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
kwargs = {'load_groups': groups_to_load, 'load_roles': roles_to_load}
|
kwargs = {'load_groups': groups_to_load, 'load_roles': roles_to_load}
|
||||||
@@ -595,11 +598,13 @@ async def test_load_roles_groups(tmpdir, request):
|
|||||||
group1 = orm.Group.find(db, name='group1')
|
group1 = orm.Group.find(db, name='group1')
|
||||||
group2 = orm.Group.find(db, name='group2')
|
group2 = orm.Group.find(db, name='group2')
|
||||||
group3 = orm.Group.find(db, name='group3')
|
group3 = orm.Group.find(db, name='group3')
|
||||||
|
group4 = orm.Group.find(db, name='group4')
|
||||||
|
|
||||||
# test group roles
|
# test group roles
|
||||||
assert group1.roles == []
|
assert group1.roles == []
|
||||||
assert group2 in assist_role.groups
|
assert group2 in assist_role.groups
|
||||||
assert group3 in head_role.groups
|
assert group3 in head_role.groups
|
||||||
|
assert group4 in head_role.groups
|
||||||
|
|
||||||
# delete the test roles
|
# delete the test roles
|
||||||
for role in roles_to_load:
|
for role in roles_to_load:
|
||||||
@@ -658,11 +663,11 @@ async def test_load_roles_user_tokens(tmpdir, request):
|
|||||||
"headers, rolename, scopes, status",
|
"headers, rolename, scopes, status",
|
||||||
[
|
[
|
||||||
# no role requested - gets default 'token' role
|
# no role requested - gets default 'token' role
|
||||||
({}, None, None, 200),
|
({}, None, None, 201),
|
||||||
# role scopes within the user's default 'user' role
|
# role scopes within the user's default 'user' role
|
||||||
({}, 'self-reader', ['read:users'], 200),
|
({}, 'self-reader', ['read:users'], 201),
|
||||||
# role scopes outside of the user's role but within the group's role scopes of which the user is a member
|
# role scopes outside of the user's role but within the group's role scopes of which the user is a member
|
||||||
({}, 'groups-reader', ['read:groups'], 200),
|
({}, 'groups-reader', ['read:groups'], 201),
|
||||||
# non-existing role request
|
# non-existing role request
|
||||||
({}, 'non-existing', [], 404),
|
({}, 'non-existing', [], 404),
|
||||||
# role scopes outside of both user's role and group's role scopes
|
# role scopes outside of both user's role and group's role scopes
|
||||||
|
@@ -477,7 +477,7 @@ async def test_metascope_all_expansion(app, create_user_with_scopes):
|
|||||||
user = create_user_with_scopes('self')
|
user = create_user_with_scopes('self')
|
||||||
user.new_api_token()
|
user.new_api_token()
|
||||||
token = user.api_tokens[0]
|
token = user.api_tokens[0]
|
||||||
# Check 'all' expansion
|
# Check 'inherit' expansion
|
||||||
token_scope_set = get_scopes_for(token)
|
token_scope_set = get_scopes_for(token)
|
||||||
user_scope_set = get_scopes_for(user)
|
user_scope_set = get_scopes_for(user)
|
||||||
assert user_scope_set == token_scope_set
|
assert user_scope_set == token_scope_set
|
||||||
|
@@ -1,6 +1,10 @@
|
|||||||
"""Tests for jupyterhub.singleuser"""
|
"""Tests for jupyterhub.singleuser"""
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from subprocess import CalledProcessError
|
||||||
from subprocess import check_output
|
from subprocess import check_output
|
||||||
|
from unittest import mock
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -14,6 +18,12 @@ from .utils import async_requests
|
|||||||
from .utils import AsyncSession
|
from .utils import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def nullcontext():
|
||||||
|
"""Python 3.7+ contextlib.nullcontext, backport for 3.6"""
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"access_scopes, server_name, expect_success",
|
"access_scopes, server_name, expect_success",
|
||||||
[
|
[
|
||||||
@@ -171,3 +181,47 @@ def test_version():
|
|||||||
[sys.executable, '-m', 'jupyterhub.singleuser', '--version']
|
[sys.executable, '-m', 'jupyterhub.singleuser', '--version']
|
||||||
).decode('utf8', 'replace')
|
).decode('utf8', 'replace')
|
||||||
assert jupyterhub.__version__ in out
|
assert jupyterhub.__version__ in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"JUPYTERHUB_SINGLEUSER_APP",
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"notebook.notebookapp.NotebookApp",
|
||||||
|
"jupyter_server.serverapp.ServerApp",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
|
||||||
|
try:
|
||||||
|
import jupyter_server # noqa
|
||||||
|
except ImportError:
|
||||||
|
have_server = False
|
||||||
|
expect_error = "jupyter_server" in JUPYTERHUB_SINGLEUSER_APP
|
||||||
|
else:
|
||||||
|
have_server = True
|
||||||
|
expect_error = False
|
||||||
|
|
||||||
|
if expect_error:
|
||||||
|
ctx = pytest.raises(CalledProcessError)
|
||||||
|
else:
|
||||||
|
ctx = nullcontext()
|
||||||
|
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"JUPYTERHUB_SINGLEUSER_APP": JUPYTERHUB_SINGLEUSER_APP,
|
||||||
|
},
|
||||||
|
):
|
||||||
|
with ctx:
|
||||||
|
out = check_output(
|
||||||
|
[sys.executable, '-m', 'jupyterhub.singleuser', '--help-all']
|
||||||
|
).decode('utf8', 'replace')
|
||||||
|
if expect_error:
|
||||||
|
return
|
||||||
|
# use help-all output to check inheritance
|
||||||
|
if 'NotebookApp' in JUPYTERHUB_SINGLEUSER_APP or not have_server:
|
||||||
|
assert '--NotebookApp.' in out
|
||||||
|
assert '--ServerApp.' not in out
|
||||||
|
else:
|
||||||
|
assert '--ServerApp.' in out
|
||||||
|
assert '--NotebookApp.' not in out
|
||||||
|
@@ -21,6 +21,7 @@ from ..objects import Server
|
|||||||
from ..spawner import LocalProcessSpawner
|
from ..spawner import LocalProcessSpawner
|
||||||
from ..spawner import Spawner
|
from ..spawner import Spawner
|
||||||
from ..user import User
|
from ..user import User
|
||||||
|
from ..utils import AnyTimeoutError
|
||||||
from ..utils import new_token
|
from ..utils import new_token
|
||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
from .mocking import public_url
|
from .mocking import public_url
|
||||||
@@ -95,7 +96,7 @@ async def wait_for_spawner(spawner, timeout=10):
|
|||||||
assert status is None
|
assert status is None
|
||||||
try:
|
try:
|
||||||
await wait()
|
await wait()
|
||||||
except TimeoutError:
|
except AnyTimeoutError:
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
@@ -26,11 +26,41 @@ from .metrics import RUNNING_SERVERS
|
|||||||
from .metrics import TOTAL_USERS
|
from .metrics import TOTAL_USERS
|
||||||
from .objects import Server
|
from .objects import Server
|
||||||
from .spawner import LocalProcessSpawner
|
from .spawner import LocalProcessSpawner
|
||||||
|
from .utils import AnyTimeoutError
|
||||||
from .utils import make_ssl_context
|
from .utils import make_ssl_context
|
||||||
from .utils import maybe_future
|
from .utils import maybe_future
|
||||||
from .utils import url_path_join
|
from .utils import url_path_join
|
||||||
|
|
||||||
|
|
||||||
|
# detailed messages about the most common failure-to-start errors,
|
||||||
|
# which manifest timeouts during start
|
||||||
|
start_timeout_message = """
|
||||||
|
Common causes of this timeout, and debugging tips:
|
||||||
|
|
||||||
|
1. Everything is working, but it took too long.
|
||||||
|
To fix: increase `Spawner.start_timeout` configuration
|
||||||
|
to a number of seconds that is enough for spawners to finish starting.
|
||||||
|
2. The server didn't finish starting,
|
||||||
|
or it crashed due to a configuration issue.
|
||||||
|
Check the single-user server's logs for hints at what needs fixing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
http_timeout_message = """
|
||||||
|
Common causes of this timeout, and debugging tips:
|
||||||
|
|
||||||
|
1. The server didn't finish starting,
|
||||||
|
or it crashed due to a configuration issue.
|
||||||
|
Check the single-user server's logs for hints at what needs fixing.
|
||||||
|
2. The server started, but is not accessible at the specified URL.
|
||||||
|
This may be a configuration issue specific to your chosen Spawner.
|
||||||
|
Check the single-user server logs and resource to make sure the URL
|
||||||
|
is correct and accessible from the Hub.
|
||||||
|
3. (unlikely) Everything is working, but the server took too long to respond.
|
||||||
|
To fix: increase `Spawner.http_timeout` configuration
|
||||||
|
to a number of seconds that is enough for servers to become responsive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class UserDict(dict):
|
class UserDict(dict):
|
||||||
"""Like defaultdict, but for users
|
"""Like defaultdict, but for users
|
||||||
|
|
||||||
@@ -84,7 +114,7 @@ class UserDict(dict):
|
|||||||
if user.name == key:
|
if user.name == key:
|
||||||
key = user.id
|
key = user.id
|
||||||
break
|
break
|
||||||
return dict.__contains__(self, key)
|
return super().__contains__(key)
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
"""UserDict allows retrieval of user by any of:
|
"""UserDict allows retrieval of user by any of:
|
||||||
@@ -108,7 +138,7 @@ class UserDict(dict):
|
|||||||
if orm_user.id not in self:
|
if orm_user.id not in self:
|
||||||
user = self[orm_user.id] = User(orm_user, self.settings)
|
user = self[orm_user.id] = User(orm_user, self.settings)
|
||||||
return user
|
return user
|
||||||
user = dict.__getitem__(self, orm_user.id)
|
user = super().__getitem__(orm_user.id)
|
||||||
user.db = self.db
|
user.db = self.db
|
||||||
return user
|
return user
|
||||||
elif isinstance(key, int):
|
elif isinstance(key, int):
|
||||||
@@ -119,7 +149,7 @@ class UserDict(dict):
|
|||||||
raise KeyError("No such user: %s" % id)
|
raise KeyError("No such user: %s" % id)
|
||||||
user = self.add(orm_user)
|
user = self.add(orm_user)
|
||||||
else:
|
else:
|
||||||
user = dict.__getitem__(self, id)
|
user = super().__getitem__(id)
|
||||||
return user
|
return user
|
||||||
else:
|
else:
|
||||||
raise KeyError(repr(key))
|
raise KeyError(repr(key))
|
||||||
@@ -145,7 +175,7 @@ class UserDict(dict):
|
|||||||
self.db.expunge(orm_spawner)
|
self.db.expunge(orm_spawner)
|
||||||
if user.orm_user in self.db:
|
if user.orm_user in self.db:
|
||||||
self.db.expunge(user.orm_user)
|
self.db.expunge(user.orm_user)
|
||||||
dict.__delitem__(self, user.id)
|
super().__delitem__(user.id)
|
||||||
|
|
||||||
def delete(self, key):
|
def delete(self, key):
|
||||||
"""Delete a user from the cache and the database"""
|
"""Delete a user from the cache and the database"""
|
||||||
@@ -707,11 +737,11 @@ class User:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if isinstance(e, gen.TimeoutError):
|
if isinstance(e, AnyTimeoutError):
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"{user}'s server failed to start in {s} seconds, giving up".format(
|
f"{self.name}'s server failed to start"
|
||||||
user=self.name, s=spawner.start_timeout
|
f" in {spawner.start_timeout} seconds, giving up."
|
||||||
)
|
f"\n{start_timeout_message}"
|
||||||
)
|
)
|
||||||
e.reason = 'timeout'
|
e.reason = 'timeout'
|
||||||
self.settings['statsd'].incr('spawner.failure.timeout')
|
self.settings['statsd'].incr('spawner.failure.timeout')
|
||||||
@@ -764,14 +794,11 @@ class User:
|
|||||||
http=True, timeout=spawner.http_timeout, ssl_context=ssl_context
|
http=True, timeout=spawner.http_timeout, ssl_context=ssl_context
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if isinstance(e, TimeoutError):
|
if isinstance(e, AnyTimeoutError):
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"{user}'s server never showed up at {url} "
|
f"{self.name}'s server never showed up at {server.url}"
|
||||||
"after {http_timeout} seconds. Giving up".format(
|
f" after {spawner.http_timeout} seconds. Giving up."
|
||||||
user=self.name,
|
f"\n{http_timeout_message}"
|
||||||
url=server.url,
|
|
||||||
http_timeout=spawner.http_timeout,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
e.reason = 'timeout'
|
e.reason = 'timeout'
|
||||||
self.settings['statsd'].incr('spawner.failure.http_timeout')
|
self.settings['statsd'].incr('spawner.failure.http_timeout')
|
||||||
|
@@ -23,12 +23,12 @@ from operator import itemgetter
|
|||||||
|
|
||||||
from async_generator import aclosing
|
from async_generator import aclosing
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from tornado import gen
|
||||||
from tornado import ioloop
|
from tornado import ioloop
|
||||||
from tornado import web
|
from tornado import web
|
||||||
from tornado.httpclient import AsyncHTTPClient
|
from tornado.httpclient import AsyncHTTPClient
|
||||||
from tornado.httpclient import HTTPError
|
from tornado.httpclient import HTTPError
|
||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
from tornado.platform.asyncio import to_asyncio_future
|
|
||||||
|
|
||||||
# For compatibility with python versions 3.6 or earlier.
|
# For compatibility with python versions 3.6 or earlier.
|
||||||
# asyncio.Task.all_tasks() is fully moved to asyncio.all_tasks() starting with 3.9. Also applies to current_task.
|
# asyncio.Task.all_tasks() is fully moved to asyncio.all_tasks() starting with 3.9. Also applies to current_task.
|
||||||
@@ -97,6 +97,10 @@ def make_ssl_context(keyfile, certfile, cafile=None, verify=True, check_hostname
|
|||||||
return ssl_context
|
return ssl_context
|
||||||
|
|
||||||
|
|
||||||
|
# AnyTimeoutError catches TimeoutErrors coming from asyncio, tornado, stdlib
|
||||||
|
AnyTimeoutError = (gen.TimeoutError, asyncio.TimeoutError, TimeoutError)
|
||||||
|
|
||||||
|
|
||||||
async def exponential_backoff(
|
async def exponential_backoff(
|
||||||
pass_func,
|
pass_func,
|
||||||
fail_message,
|
fail_message,
|
||||||
@@ -182,7 +186,7 @@ async def exponential_backoff(
|
|||||||
if dt < max_wait:
|
if dt < max_wait:
|
||||||
scale *= scale_factor
|
scale *= scale_factor
|
||||||
await asyncio.sleep(dt)
|
await asyncio.sleep(dt)
|
||||||
raise TimeoutError(fail_message)
|
raise asyncio.TimeoutError(fail_message)
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_server(ip, port, timeout=10):
|
async def wait_for_server(ip, port, timeout=10):
|
||||||
@@ -288,6 +292,31 @@ def authenticated_403(self):
|
|||||||
raise web.HTTPError(403)
|
raise web.HTTPError(403)
|
||||||
|
|
||||||
|
|
||||||
|
def admin_only(f):
|
||||||
|
"""Deprecated!"""
|
||||||
|
# write it this way to trigger deprecation warning at decoration time,
|
||||||
|
# not on the method call
|
||||||
|
warnings.warn(
|
||||||
|
"""@jupyterhub.utils.admin_only is deprecated in JupyterHub 2.0.
|
||||||
|
|
||||||
|
Use the new `@jupyterhub.scopes.needs_scope` decorator to resolve permissions,
|
||||||
|
or check against `self.current_user.parsed_scopes`.
|
||||||
|
""",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# the original decorator
|
||||||
|
@auth_decorator
|
||||||
|
def admin_only(self):
|
||||||
|
"""Decorator for restricting access to admin users"""
|
||||||
|
user = self.current_user
|
||||||
|
if user is None or not user.admin:
|
||||||
|
raise web.HTTPError(403)
|
||||||
|
|
||||||
|
return admin_only(f)
|
||||||
|
|
||||||
|
|
||||||
@auth_decorator
|
@auth_decorator
|
||||||
def metrics_authentication(self):
|
def metrics_authentication(self):
|
||||||
"""Decorator for restricting access to metrics"""
|
"""Decorator for restricting access to metrics"""
|
||||||
|
@@ -5,3 +5,41 @@ target_version = [
|
|||||||
"py37",
|
"py37",
|
||||||
"py38",
|
"py38",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.tbump]
|
||||||
|
# Uncomment this if your project is hosted on GitHub:
|
||||||
|
github_url = "https://github.com/jupyterhub/jupyterhub"
|
||||||
|
|
||||||
|
[tool.tbump.version]
|
||||||
|
current = "2.0.0rc1"
|
||||||
|
|
||||||
|
# Example of a semver regexp.
|
||||||
|
# Make sure this matches current_version before
|
||||||
|
# using tbump
|
||||||
|
regex = '''
|
||||||
|
(?P<major>\d+)
|
||||||
|
\.
|
||||||
|
(?P<minor>\d+)
|
||||||
|
\.
|
||||||
|
(?P<patch>\d+)
|
||||||
|
(?P<pre>((a|b|rc)\d+)|)
|
||||||
|
\.?
|
||||||
|
(?P<dev>(?<=\.)dev\d*|)
|
||||||
|
'''
|
||||||
|
|
||||||
|
[tool.tbump.git]
|
||||||
|
message_template = "Bump to {new_version}"
|
||||||
|
tag_template = "{new_version}"
|
||||||
|
|
||||||
|
# For each file to patch, add a [[tool.tbump.file]] config
|
||||||
|
# section containing the path of the file, relative to the
|
||||||
|
# pyproject.toml location.
|
||||||
|
|
||||||
|
[[tool.tbump.file]]
|
||||||
|
src = "jupyterhub/_version.py"
|
||||||
|
version_template = '({major}, {minor}, {patch}, "{pre}", "{dev}")'
|
||||||
|
search = "version_info = {current_version}"
|
||||||
|
|
||||||
|
[[tool.tbump.file]]
|
||||||
|
src = "docs/source/_static/rest-api.yml"
|
||||||
|
search = "version: {current_version}"
|
||||||
|
3
setup.py
3
setup.py
@@ -100,6 +100,7 @@ setup_args = dict(
|
|||||||
'default = jupyterhub.auth:PAMAuthenticator',
|
'default = jupyterhub.auth:PAMAuthenticator',
|
||||||
'pam = jupyterhub.auth:PAMAuthenticator',
|
'pam = jupyterhub.auth:PAMAuthenticator',
|
||||||
'dummy = jupyterhub.auth:DummyAuthenticator',
|
'dummy = jupyterhub.auth:DummyAuthenticator',
|
||||||
|
'null = jupyterhub.auth:NullAuthenticator',
|
||||||
],
|
],
|
||||||
'jupyterhub.proxies': [
|
'jupyterhub.proxies': [
|
||||||
'default = jupyterhub.proxy:ConfigurableHTTPProxy',
|
'default = jupyterhub.proxy:ConfigurableHTTPProxy',
|
||||||
@@ -301,7 +302,7 @@ class develop_js_css(develop):
|
|||||||
if not self.uninstall:
|
if not self.uninstall:
|
||||||
self.distribution.run_command('js')
|
self.distribution.run_command('js')
|
||||||
self.distribution.run_command('css')
|
self.distribution.run_command('css')
|
||||||
develop.run(self)
|
super().run()
|
||||||
|
|
||||||
|
|
||||||
setup_args['cmdclass']['develop'] = develop_js_css
|
setup_args['cmdclass']['develop'] = develop_js_css
|
||||||
|
@@ -7,6 +7,6 @@ MAINTAINER Project Jupyter <jupyter@googlegroups.com>
|
|||||||
|
|
||||||
ADD install_jupyterhub /tmp/install_jupyterhub
|
ADD install_jupyterhub /tmp/install_jupyterhub
|
||||||
ARG JUPYTERHUB_VERSION=main
|
ARG JUPYTERHUB_VERSION=main
|
||||||
# install pinned jupyterhub and ensure notebook is installed
|
# install pinned jupyterhub and ensure jupyterlab is installed
|
||||||
RUN python3 /tmp/install_jupyterhub && \
|
RUN python3 /tmp/install_jupyterhub && \
|
||||||
python3 -m pip install notebook
|
python3 -m pip install jupyterlab
|
||||||
|
Reference in New Issue
Block a user