mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 18:14:10 +00:00
Compare commits
132 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
95649a3ece | ||
![]() |
08288f5b0f | ||
![]() |
01b1ce3995 | ||
![]() |
cbe93810be | ||
![]() |
75309d9dc4 | ||
![]() |
249b4af59f | ||
![]() |
db3b2d8961 | ||
![]() |
7d44a0ffc8 | ||
![]() |
202b2590e9 | ||
![]() |
c98ef547a8 | ||
![]() |
8a866a9102 | ||
![]() |
b186bdbce3 | ||
![]() |
36fe6c6f66 | ||
![]() |
8bf559db52 | ||
![]() |
750085f627 | ||
![]() |
2dc2c99b4a | ||
![]() |
e703555888 | ||
![]() |
7e102f0511 | ||
![]() |
facde96425 | ||
![]() |
608c746a59 | ||
![]() |
a8c834410f | ||
![]() |
bda14b487a | ||
![]() |
fd5cf8c360 | ||
![]() |
03758e5b46 | ||
![]() |
e540d143bb | ||
![]() |
b2c5ad40c5 | ||
![]() |
edfdf672d8 | ||
![]() |
39f19aef49 | ||
![]() |
8813bb63d4 | ||
![]() |
7c18d6fe14 | ||
![]() |
d1fe17d3cb | ||
![]() |
b8965c2017 | ||
![]() |
733d7bc158 | ||
![]() |
88f31c29bb | ||
![]() |
3caf3cfda8 | ||
![]() |
d076c55cca | ||
![]() |
3e185022c8 | ||
![]() |
857ee2885f | ||
![]() |
cd8dd56213 | ||
![]() |
f06902aa8f | ||
![]() |
bb109c6f75 | ||
![]() |
e525ec7b5b | ||
![]() |
356b98e19f | ||
![]() |
8c803e7a53 | ||
![]() |
2e21a6f4e0 | ||
![]() |
cfd31b14e3 | ||
![]() |
f03a620424 | ||
![]() |
440ad77ad5 | ||
![]() |
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 |
26
.github/workflows/release.yml
vendored
26
.github/workflows/release.yml
vendored
@@ -129,7 +129,7 @@ jobs:
|
||||
# If GITHUB_TOKEN isn't available (e.g. in PRs) returns no tags [].
|
||||
- name: Get list of jupyterhub tags
|
||||
id: jupyterhubtags
|
||||
uses: jupyterhub/action-major-minor-tag-calculator@v1
|
||||
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub:"
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
|
||||
- name: Get list of jupyterhub-onbuild tags
|
||||
id: onbuildtags
|
||||
uses: jupyterhub/action-major-minor-tag-calculator@v1
|
||||
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:"
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
|
||||
- name: Get list of jupyterhub-demo tags
|
||||
id: demotags
|
||||
uses: jupyterhub/action-major-minor-tag-calculator@v1
|
||||
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:"
|
||||
@@ -190,3 +190,23 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }}
|
||||
|
||||
# jupyterhub/singleuser
|
||||
- name: Get list of jupyterhub/singleuser tags
|
||||
id: singleusertags
|
||||
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
prefix: "${{ env.REGISTRY }}jupyterhub/singleuser:"
|
||||
defaultTag: "${{ env.REGISTRY }}jupyterhub/singleuser:noref"
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub/singleuser
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
||||
with:
|
||||
build-args: |
|
||||
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}
|
||||
context: singleuser
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ join(fromJson(steps.singleusertags.outputs.tags)) }}
|
||||
|
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:
|
||||
# UTF-8 content may be interpreted as ascii and causes errors without this.
|
||||
LANG: C.UTF-8
|
||||
PYTEST_ADDOPTS: "--verbose --color=yes"
|
||||
|
||||
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
|
||||
pytest:
|
||||
runs-on: ubuntu-20.04
|
||||
@@ -38,9 +58,9 @@ jobs:
|
||||
# Tests everything when JupyterHub works against a dedicated mysql or
|
||||
# postgresql server.
|
||||
#
|
||||
# jupyter_server:
|
||||
# nbclassic:
|
||||
# Tests everything when the user instances are started with
|
||||
# jupyter_server instead of notebook.
|
||||
# notebook instead of jupyter_server.
|
||||
#
|
||||
# ssl:
|
||||
# Tests everything using internal SSL connections instead of
|
||||
@@ -48,7 +68,7 @@ jobs:
|
||||
#
|
||||
# main_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
|
||||
# GitHub UI when the workflow run, we avoid using true/false as
|
||||
@@ -56,6 +76,7 @@ jobs:
|
||||
include:
|
||||
- python: "3.6"
|
||||
oldest_dependencies: oldest_dependencies
|
||||
nbclassic: nbclassic
|
||||
- python: "3.6"
|
||||
subdomain: subdomain
|
||||
- python: "3.7"
|
||||
@@ -65,7 +86,7 @@ jobs:
|
||||
- python: "3.8"
|
||||
db: postgres
|
||||
- python: "3.8"
|
||||
jupyter_server: jupyter_server
|
||||
nbclassic: nbclassic
|
||||
- python: "3.9"
|
||||
main_dependencies: main_dependencies
|
||||
|
||||
@@ -130,9 +151,9 @@ jobs:
|
||||
if [ "${{ matrix.main_dependencies }}" != "" ]; then
|
||||
pip install git+https://github.com/ipython/traitlets#egg=traitlets --force
|
||||
fi
|
||||
if [ "${{ matrix.jupyter_server }}" != "" ]; then
|
||||
pip uninstall notebook --yes
|
||||
pip install jupyter_server
|
||||
if [ "${{ matrix.nbclassic }}" != "" ]; then
|
||||
pip uninstall jupyter_server --yes
|
||||
pip install notebook
|
||||
fi
|
||||
if [ "${{ matrix.db }}" == "mysql" ]; then
|
||||
pip install mysql-connector-python
|
||||
@@ -181,10 +202,8 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Run pytest
|
||||
# FIXME: --color=yes explicitly set because:
|
||||
# https://github.com/actions/runner/issues/241
|
||||
run: |
|
||||
pytest -v --maxfail=2 --color=yes --cov=jupyterhub jupyterhub/tests
|
||||
pytest --maxfail=2 --cov=jupyterhub jupyterhub/tests
|
||||
- name: Run yarn jest test
|
||||
run: |
|
||||
cd jsx && yarn && yarn test
|
||||
@@ -194,7 +213,7 @@ jobs:
|
||||
|
||||
docker-build:
|
||||
runs-on: ubuntu-20.04
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.26.0
|
||||
rev: v2.29.1
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args:
|
||||
@@ -10,15 +10,15 @@ repos:
|
||||
hooks:
|
||||
- id: reorder-python-imports
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 21.8b0
|
||||
rev: 21.11b1
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v2.4.0
|
||||
rev: v2.5.0
|
||||
hooks:
|
||||
- id: prettier
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: "3.9.2"
|
||||
rev: "4.0.1"
|
||||
hooks:
|
||||
- id: flake8
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
[rest api]: https://juptyerhub.readthedocs.io/en/latest/reference/rest-api.html
|
||||
|
||||
## Installation
|
||||
|
||||
### 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)
|
||||
- [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'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)
|
||||
- [Project Jupyter website](https://jupyter.org)
|
||||
- [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
|
||||
cryptography
|
||||
html5lib # needed for beautifulsoup
|
||||
jupyterlab >=3
|
||||
mock
|
||||
notebook
|
||||
pre-commit
|
||||
pytest>=3.3
|
||||
pytest-asyncio
|
||||
pytest-cov
|
||||
requests-mock
|
||||
tbump
|
||||
# blacklist urllib3 releases affected by https://github.com/urllib3/urllib3/issues/1683
|
||||
# I *think* this should only affect testing, not production
|
||||
urllib3!=1.25.4,!=1.25.5
|
||||
|
@@ -53,14 +53,6 @@ help:
|
||||
clean:
|
||||
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
|
||||
|
||||
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
|
||||
python3 source/rbac/generate-scope-table.py
|
||||
|
||||
html: rest-api metrics scopes
|
||||
html: metrics scopes
|
||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||
@echo
|
||||
@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
|
||||
|
||||
alabaster_jupyterhub
|
||||
# Temporary fix of #3021. Revert back to released autodoc-traits when
|
||||
# 0.1.0 released.
|
||||
https://github.com/jupyterhub/autodoc-traits/archive/d22282c1c18c6865436e06d8b329c06fe12a07f8.zip
|
||||
autodoc-traits
|
||||
myst-parser
|
||||
pydata-sphinx-theme
|
||||
pytablewriter>=0.56
|
||||
|
1196
docs/rest-api.yml
1196
docs/rest-api.yml
File diff suppressed because it is too large
Load Diff
@@ -2,3 +2,9 @@
|
||||
.navbar-brand {
|
||||
height: 4rem !important;
|
||||
}
|
||||
|
||||
/* hide redundant funky-formatted swagger-ui version */
|
||||
|
||||
.swagger-ui .info .title small {
|
||||
display: none !important;
|
||||
}
|
||||
|
1421
docs/source/_static/rest-api.yml
Normal file
1421
docs/source/_static/rest-api.yml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,3 @@
|
||||
.. _admin/upgrading:
|
||||
|
||||
====================
|
||||
Upgrading JupyterHub
|
||||
====================
|
||||
|
@@ -17,11 +17,6 @@ information on:
|
||||
- making an API request programmatically using the requests library
|
||||
- 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:
|
||||
|
||||
.. toctree::
|
||||
|
File diff suppressed because one or more lines are too long
@@ -205,7 +205,10 @@ epub_exclude_files = ['search.html']
|
||||
|
||||
# -- Intersphinx ----------------------------------------------------------
|
||||
|
||||
intersphinx_mapping = {'https://docs.python.org/3/': None}
|
||||
intersphinx_mapping = {
|
||||
'python': ('https://docs.python.org/3/', None),
|
||||
'tornado': ('https://www.tornadoweb.org/en/stable/', None),
|
||||
}
|
||||
|
||||
# -- Read The Docs --------------------------------------------------------
|
||||
|
||||
@@ -215,7 +218,7 @@ if on_rtd:
|
||||
# build both metrics and rest-api, since RTD doesn't run make
|
||||
from subprocess import check_call as sh
|
||||
|
||||
sh(['make', 'metrics', 'rest-api', 'scopes'], cwd=docs)
|
||||
sh(['make', 'metrics', 'scopes'], cwd=docs)
|
||||
|
||||
# -- Spell checking -------------------------------------------------------
|
||||
|
||||
|
BIN
docs/source/images/binder-404.png
Normal file
BIN
docs/source/images/binder-404.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 160 KiB |
BIN
docs/source/images/binderhub-form.png
Normal file
BIN
docs/source/images/binderhub-form.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 138 KiB |
BIN
docs/source/images/chp-404.png
Normal file
BIN
docs/source/images/chp-404.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 38 KiB |
BIN
docs/source/images/server-not-running.png
Normal file
BIN
docs/source/images/server-not-running.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 66 KiB |
@@ -43,7 +43,7 @@ JupyterHub performs the following functions:
|
||||
notebook servers
|
||||
|
||||
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
|
||||
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
|
||||
.. _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:
|
||||
|
||||
- a Linux/Unix based system
|
||||
- [Python](https://www.python.org/downloads/) 3.5 or greater. An understanding
|
||||
of using [`pip`](https://pip.pypa.io/en/stable/) or
|
||||
- [Python](https://www.python.org/downloads/) 3.6 or greater. An understanding
|
||||
of using [`pip`](https://pip.pypa.io) or
|
||||
[`conda`](https://conda.io/docs/get-started.html) for
|
||||
installing Python packages is helpful.
|
||||
- [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:
|
||||
|
||||
```
|
||||
sudo apt-get install npm nodejs-legacy
|
||||
sudo apt-get install nodejs npm
|
||||
```
|
||||
|
||||
The `nodejs-legacy` package installs the `node` executable and is currently
|
||||
required for npm to work on Debian/Ubuntu.
|
||||
[nodesource][] is a great resource to get more recent versions of the nodejs runtime,
|
||||
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)
|
||||
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
|
||||
- Domain name
|
||||
|
||||
[nodesource]: https://github.com/nodesource/distributions#table-of-contents
|
||||
|
||||
Before running the single-user notebook servers (which may be on the same
|
||||
system as the Hub or not), you will need:
|
||||
|
||||
- [Jupyter Notebook](https://jupyter.readthedocs.io/en/latest/install.html)
|
||||
version 4 or greater
|
||||
- [JupyterLab][] version 3 or greater,
|
||||
or [Jupyter Notebook][]
|
||||
4 or greater.
|
||||
|
||||
[jupyterlab]: https://jupyterlab.readthedocs.io
|
||||
[jupyter notebook]: https://jupyter.readthedocs.io/en/latest/install.html
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -48,14 +54,14 @@ JupyterHub can be installed with `pip` (and the proxy with `npm`) or `conda`:
|
||||
```bash
|
||||
python3 -m pip install jupyterhub
|
||||
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):
|
||||
|
||||
```bash
|
||||
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'
|
||||
@@ -74,7 +80,7 @@ To start the Hub server, run the command:
|
||||
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.
|
||||
|
||||
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 ruamel.yaml import YAML
|
||||
|
||||
import jupyterhub
|
||||
from jupyterhub.scopes import scope_definitions
|
||||
|
||||
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:
|
||||
@@ -98,22 +100,26 @@ class ScopeTableGenerator:
|
||||
|
||||
def write_api(self):
|
||||
"""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.preserve_quotes = True
|
||||
scope_dict = {}
|
||||
with open(filename, 'r+') as f:
|
||||
with open(filename) as f:
|
||||
content = yaml.load(f.read())
|
||||
f.seek(0)
|
||||
for scope in self.scopes:
|
||||
description = self.scopes[scope]['description']
|
||||
doc_description = self.scopes[scope].get('doc_description', '')
|
||||
if doc_description:
|
||||
description = doc_description
|
||||
scope_dict[scope] = description
|
||||
content['securityDefinitions']['oauth2']['scopes'] = scope_dict
|
||||
|
||||
content["info"]["version"] = jupyterhub.__version__
|
||||
for scope in self.scopes:
|
||||
description = self.scopes[scope]['description']
|
||||
doc_description = self.scopes[scope].get('doc_description', '')
|
||||
if doc_description:
|
||||
description = doc_description
|
||||
scope_dict[scope] = description
|
||||
content['components']['securitySchemes']['oauth2']['flows'][
|
||||
'authorizationCode'
|
||||
]['scopes'] = scope_dict
|
||||
|
||||
with open(filename, 'w') as f:
|
||||
yaml.dump(content, f)
|
||||
f.truncate()
|
||||
|
||||
|
||||
def main():
|
||||
|
@@ -123,13 +123,13 @@ has,
|
||||
define the `server` role.
|
||||
|
||||
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
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
'name': 'server',
|
||||
'scopes': ['all'],
|
||||
'scopes': ['inherit'],
|
||||
}
|
||||
]
|
||||
```
|
||||
|
128
docs/source/reference/api-only.md
Normal file
128
docs/source/reference/api-only.md
Normal file
@@ -0,0 +1,128 @@
|
||||
(api-only)=
|
||||
|
||||
# Deploying JupyterHub in "API only mode"
|
||||
|
||||
As a service for deploying and managing Jupyter servers for users, JupyterHub
|
||||
exposes this functionality _primarily_ via a [REST API](rest).
|
||||
For convenience, JupyterHub also ships with a _basic_ web UI built using that REST API.
|
||||
The basic web UI enables users to click a button to quickly start and stop their servers,
|
||||
and it lets admins perform some basic user and server management tasks.
|
||||
|
||||
The REST API has always provided additional functionality beyond what is available in the basic web UI.
|
||||
Similarly, we avoid implementing UI functionality that is also not available via the API.
|
||||
With JupyterHub 2.0, the basic web UI will **always** be composed using the REST API.
|
||||
In other words, no UI pages should rely on information not available via the REST API.
|
||||
Previously, some admin UI functionality could only be achieved via admin pages,
|
||||
such as paginated requests.
|
||||
|
||||
## Limited UI customization via templates
|
||||
|
||||
The JupyterHub UI is customizable via extensible HTML [templates](templates),
|
||||
but this has some limited scope to what can be customized.
|
||||
Adding some content and messages to existing pages is well supported,
|
||||
but changing the page flow and what pages are available are beyond the scope of what is customizable.
|
||||
|
||||
## Rich UI customization with REST API based apps
|
||||
|
||||
Increasingly, JupyterHub is used purely as an API for managing Jupyter servers
|
||||
for other Jupyter-based applications that might want to present a different user experience.
|
||||
If you want a fully customized user experience,
|
||||
you can now disable the Hub UI and use your own pages together with the JupyterHub REST API
|
||||
to build your own web application to serve your users,
|
||||
relying on the Hub only as an API for managing users and servers.
|
||||
|
||||
One example of such an application is [BinderHub][], which powers https://mybinder.org,
|
||||
and motivates many of these changes.
|
||||
|
||||
BinderHub is distinct from a traditional JupyterHub deployment
|
||||
because it uses temporary users created for each launch.
|
||||
Instead of presenting a login page,
|
||||
users are presented with a form to specify what environment they would like to launch:
|
||||
|
||||

|
||||
|
||||
When a launch is requested:
|
||||
|
||||
1. an image is built, if necessary
|
||||
2. a temporary user is created,
|
||||
3. a server is launched for that user, and
|
||||
4. when running, users are redirected to an already running server with an auth token in the URL
|
||||
5. after the session is over, the user is deleted
|
||||
|
||||
This means that a lot of JupyterHub's UI flow doesn't make sense:
|
||||
|
||||
- there is no way for users to login
|
||||
- the human user doesn't map onto a JupyterHub `User` in a meaningful way
|
||||
- when a server isn't running, there isn't a 'restart your server' action available because the user has been deleted
|
||||
- users do not have any access to any Hub functionality, so presenting pages for those features would be confusing
|
||||
|
||||
BinderHub is one of the motivating use cases for JupyterHub supporting being used _only_ via its API.
|
||||
We'll use BinderHub here as an example of various configuration options.
|
||||
|
||||
[binderhub]: https://binderhub.readthedocs.io
|
||||
|
||||
## Disabling Hub UI
|
||||
|
||||
`c.JupyterHub.hub_routespec` is a configuration option to specify which URL prefix should be routed to the Hub.
|
||||
The default is `/` which means that the Hub will receive all requests not already specified to be routed somewhere else.
|
||||
|
||||
There are three values that are most logical for `hub_routespec`:
|
||||
|
||||
- `/` - this is the default, and used in most deployments.
|
||||
It is also the only option prior to JupyterHub 1.4.
|
||||
- `/hub/` - this serves only Hub pages, both UI and API
|
||||
- `/hub/api` - this serves _only the Hub API_, so all Hub UI is disabled,
|
||||
aside from the OAuth confirmation page, if used.
|
||||
|
||||
If you choose a hub routespec other than `/`,
|
||||
the main JupyterHub feature you will lose is the automatic handling of requests for `/user/:username`
|
||||
when the requested server is not running.
|
||||
|
||||
JupyterHub's handling of this request shows this page,
|
||||
telling you that the server is not running,
|
||||
with a button to launch it again:
|
||||
|
||||

|
||||
|
||||
If you set `hub_routespec` to something other than `/`,
|
||||
it is likely that you also want to register another destination for `/` to handle requests to not-running servers.
|
||||
If you don't, you will see a default 404 page from the proxy:
|
||||
|
||||

|
||||
|
||||
For mybinder.org, the default "start my server" page doesn't make sense,
|
||||
because when a server is gone, there is no restart action.
|
||||
Instead, we provide hints about how to get back to a link to start a _new_ server:
|
||||
|
||||

|
||||
|
||||
To achieve this, mybinder.org registers a route for `/` that goes to a custom endpoint
|
||||
that runs nginx and only serves this static HTML error page.
|
||||
This is set with
|
||||
|
||||
```python
|
||||
c.Proxy.extra_routes = {
|
||||
"/": "http://custom-404-entpoint/",
|
||||
}
|
||||
```
|
||||
|
||||
You may want to use an alternate behavior, such as redirecting to a landing page,
|
||||
or taking some other action based on the requested page.
|
||||
|
||||
If you use `c.JupyterHub.hub_routespec = "/hub/"`,
|
||||
then all the Hub pages will be available,
|
||||
and only this default-page-404 issue will come up.
|
||||
|
||||
If you use `c.JupyterHub.hub_routespec = "/hub/api/"`,
|
||||
then only the Hub _API_ will be available,
|
||||
and all UI will be up to you.
|
||||
mybinder.org takes this last option,
|
||||
because none of the Hub UI pages really make sense.
|
||||
Binder users don't have any reason to know or care that JupyterHub happens
|
||||
to be an implementation detail of how their environment is managed.
|
||||
Seeing Hub error pages and messages in that situation is more likely to be confusing than helpful.
|
||||
|
||||
:::{versionadded} 1.4
|
||||
|
||||
`c.JupyterHub.hub_routespec` and `c.Proxy.extra_routes` are new in JupyterHub 1.4.
|
||||
:::
|
@@ -219,7 +219,7 @@ In case of the need to run the jupyterhub under /jhub/ or other location please
|
||||
httpd.conf amendments:
|
||||
|
||||
```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]
|
||||
|
||||
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
|
||||
|
||||
:::{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
|
||||
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:
|
||||
|
||||
```python
|
||||
# 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
|
||||
c.MappingKernelManager.cull_idle_timeout = 20 * 60
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
/path/to/python3 -m IPython kernel install --prefix=/usr/local
|
||||
/path/to/python2 -m IPython kernel install --prefix=/usr/local
|
||||
/path/to/python3 -m ipykernel install --prefix=/usr/local
|
||||
/path/to/python2 -m ipykernel install --prefix=/usr/local
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## 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
|
||||
export JUPYTERHUB_SINGLEUSER_APP='jupyter_server.serverapp.ServerApp'
|
||||
```
|
||||
|
||||
:::
|
||||
|
@@ -16,10 +16,12 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
|
||||
proxy
|
||||
separate-proxy
|
||||
rest
|
||||
rest-api
|
||||
server-api
|
||||
monitoring
|
||||
database
|
||||
templates
|
||||
api-only
|
||||
../events/index
|
||||
config-user-env
|
||||
config-examples
|
||||
|
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@4.1/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.
|
@@ -1,3 +1,5 @@
|
||||
(rest-api)=
|
||||
|
||||
# Using JupyterHub's REST API
|
||||
|
||||
This section will give you information on:
|
||||
@@ -302,12 +304,8 @@ or kubernetes pods.
|
||||
|
||||
## Learn more about the API
|
||||
|
||||
You can see the full [JupyterHub REST API][] for details. This REST API Spec can
|
||||
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][].
|
||||
You can see the full [JupyterHub REST API][] for details.
|
||||
|
||||
[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/
|
||||
[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
|
||||
|
@@ -1,17 +1,5 @@
|
||||
# Services
|
||||
|
||||
With version 0.7, JupyterHub adds support for **Services**.
|
||||
|
||||
This section provides the following information about Services:
|
||||
|
||||
- [Definition of a Service](#definition-of-a-service)
|
||||
- [Properties of a Service](#properties-of-a-service)
|
||||
- [Hub-Managed Services](#hub-managed-services)
|
||||
- [Launching a Hub-Managed Service](#launching-a-hub-managed-service)
|
||||
- [Externally-Managed Services](#externally-managed-services)
|
||||
- [Writing your own Services](#writing-your-own-services)
|
||||
- [Hub Authentication and Services](#hub-authentication-and-services)
|
||||
|
||||
## Definition of a Service
|
||||
|
||||
When working with JupyterHub, a **Service** is defined as a process that interacts
|
||||
@@ -115,6 +103,8 @@ parameters, which describe the environment needed to start the Service process:
|
||||
|
||||
The Hub will pass the following environment variables to launch the Service:
|
||||
|
||||
(service-env)=
|
||||
|
||||
```bash
|
||||
JUPYTERHUB_SERVICE_NAME: The name of the service
|
||||
JUPYTERHUB_API_TOKEN: API token assigned to the service
|
||||
@@ -196,18 +186,38 @@ extra slash you might get unexpected behavior. For example if your service has a
|
||||
|
||||
## Hub Authentication and Services
|
||||
|
||||
JupyterHub 0.7 introduces some utilities for using the Hub's authentication
|
||||
mechanism to govern access to your service. When a user logs into JupyterHub,
|
||||
the Hub sets a **cookie (`jupyterhub-services`)**. The service can use this
|
||||
cookie to authenticate requests.
|
||||
JupyterHub provides some utilities for using the Hub's authentication
|
||||
mechanism to govern access to your service.
|
||||
|
||||
JupyterHub ships with a reference implementation of Hub authentication that
|
||||
Requests to all JupyterHub services are made with OAuth tokens.
|
||||
These can either be requests with a token in the `Authorization` header,
|
||||
or url parameter `?token=...`,
|
||||
or browser requests which must complete the OAuth authorization code flow,
|
||||
which results in a token that should be persisted for future requests
|
||||
(persistence is up to the service,
|
||||
but an encrypted cookie confined to the service path is appropriate,
|
||||
and provided by default).
|
||||
|
||||
:::{versionchanged} 2.0
|
||||
The shared `jupyterhub-services` cookie is removed.
|
||||
OAuth must be used to authenticate browser requests with services.
|
||||
:::
|
||||
|
||||
JupyterHub includes a reference implementation of Hub authentication that
|
||||
can be used by services. You may go beyond this reference implementation and
|
||||
create custom hub-authenticating clients and services. We describe the process
|
||||
below.
|
||||
|
||||
The reference, or base, implementation is the [`HubAuth`][hubauth] class,
|
||||
which implements the requests to the Hub.
|
||||
which implements the API requests to the Hub that resolve a token to a User model.
|
||||
|
||||
There are two levels of authentication with the Hub:
|
||||
|
||||
- [`HubAuth`][hubauth] - the most basic authentication,
|
||||
for services that should only accept API requests authorized with a token.
|
||||
|
||||
- [`HubOAuth`][huboauth] - For services that should use oauth to authenticate with the Hub.
|
||||
This should be used for any service that serves pages that should be visited with a browser.
|
||||
|
||||
To use HubAuth, you must set the `.api_token`, either programmatically when constructing the class,
|
||||
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
||||
@@ -250,18 +260,17 @@ for more details.
|
||||
### Authenticating tornado services with JupyterHub
|
||||
|
||||
Since most Jupyter services are written with tornado,
|
||||
we include a mixin class, [`HubAuthenticated`][hubauthenticated],
|
||||
we include a mixin class, [`HubOAuthenticated`][huboauthenticated],
|
||||
for quickly authenticating your own tornado services with JupyterHub.
|
||||
|
||||
Tornado's `@web.authenticated` method calls a Handler's `.get_current_user`
|
||||
method to identify the user. Mixing in `HubAuthenticated` defines
|
||||
`get_current_user` to use HubAuth. If you want to configure the HubAuth
|
||||
instance beyond the default, you'll want to define an `initialize` method,
|
||||
Tornado's {py:func}`~.tornado.web.authenticated` decorator calls a Handler's {py:meth}`~.tornado.web.RequestHandler.get_current_user`
|
||||
method to identify the user. Mixing in {class}`.HubAuthenticated` defines
|
||||
{meth}`~.HubAuthenticated.get_current_user` to use HubAuth. If you want to configure the HubAuth
|
||||
instance beyond the default, you'll want to define an {py:meth}`~.tornado.web.RequestHandler.initialize` method,
|
||||
such as:
|
||||
|
||||
```python
|
||||
class MyHandler(HubAuthenticated, web.RequestHandler):
|
||||
hub_users = {'inara', 'mal'}
|
||||
class MyHandler(HubOAuthenticated, web.RequestHandler):
|
||||
|
||||
def initialize(self, hub_auth):
|
||||
self.hub_auth = hub_auth
|
||||
@@ -271,14 +280,21 @@ class MyHandler(HubAuthenticated, web.RequestHandler):
|
||||
...
|
||||
```
|
||||
|
||||
The HubAuth will automatically load the desired configuration from the Service
|
||||
environment variables.
|
||||
The HubAuth class will automatically load the desired configuration from the Service
|
||||
[environment variables](service-env).
|
||||
|
||||
If you want to limit user access, you can specify allowed users through either the
|
||||
`.hub_users` attribute or `.hub_groups`. These are sets that check against the
|
||||
username and user group list, respectively. If a user matches neither the user
|
||||
list nor the group list, they will not be allowed access. If both are left
|
||||
undefined, then any user will be allowed.
|
||||
:::{versionchanged} 2.0
|
||||
|
||||
Access scopes are used to govern access to services.
|
||||
Prior to 2.0,
|
||||
sets of users and groups could be used to grant access
|
||||
by defining `.hub_groups` or `.hub_users` on the authenticated handler.
|
||||
These are ignored if the 2.0 `.hub_scopes` is defined.
|
||||
:::
|
||||
|
||||
:::{seealso}
|
||||
{meth}`.HubAuth.check_scopes`
|
||||
:::
|
||||
|
||||
### Implementing your own Authentication with JupyterHub
|
||||
|
||||
@@ -328,7 +344,7 @@ and taking note of the following process:
|
||||
```python
|
||||
{
|
||||
"name": "inara",
|
||||
# groups may be omitted, depending on permissions
|
||||
# groups may be omitted, depending on permissions
|
||||
"groups": ["serenity", "guild"],
|
||||
# scopes is new in JupyterHub 2.0
|
||||
"scopes": [
|
||||
@@ -354,9 +370,11 @@ section on securing the notebook viewer.
|
||||
|
||||
[requests]: http://docs.python-requests.org/en/master/
|
||||
[services_auth]: ../api/services.auth.html
|
||||
[hubauth]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth
|
||||
[huboauth]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuth
|
||||
[hubauth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
|
||||
[hubauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
|
||||
[huboauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuthenticated
|
||||
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
||||
[fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi
|
||||
[fastapi]: https://fastapi.tiangolo.com
|
||||
|
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")
|
||||
log.info(f"Loading token from {token_file}")
|
||||
with token_file.open("r") as f:
|
||||
token = f.read()
|
||||
token = f.read().strip()
|
||||
return token
|
||||
|
||||
|
||||
|
@@ -5457,9 +5457,9 @@ npm-run-path@^4.0.0:
|
||||
path-key "^3.0.0"
|
||||
|
||||
nth-check@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.0.0.tgz#1bb4f6dac70072fc313e8c9cd1417b5074c0a125"
|
||||
integrity sha512-i4sc/Kj8htBrAiH1viZ0TgU8Y5XqCaV/FziYK6TBczxmeKm3AEFWqqF3195yKudrarqy7Zu80Ra5dobFjn9X/Q==
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.0.1.tgz#2efe162f5c3da06a28959fbd3db75dbeea9f0fc2"
|
||||
integrity sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==
|
||||
dependencies:
|
||||
boolbase "^1.0.0"
|
||||
|
||||
@@ -7278,9 +7278,9 @@ tiny-warning@^1.0.0, tiny-warning@^1.0.3:
|
||||
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
|
||||
|
||||
tmpl@1.0.x:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1"
|
||||
integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc"
|
||||
integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==
|
||||
|
||||
to-fast-properties@^2.0.0:
|
||||
version "2.0.0"
|
||||
|
@@ -1,14 +1,8 @@
|
||||
"""JupyterHub version info"""
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
version_info = (
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
"b1", # release (b1, rc1, or "" for final or dev)
|
||||
# "dev", # dev or nothing for beta/rc/stable releases
|
||||
)
|
||||
# version_info updated by running `tbump`
|
||||
version_info = (2, 0, 0, "", "")
|
||||
|
||||
# pep 440 version: no dot before beta/rc, but before .dev
|
||||
# 0.1.0rc1
|
||||
@@ -16,7 +10,9 @@ version_info = (
|
||||
# 0.1.0b1.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.
|
||||
_version_mismatch_warning_logged = {}
|
||||
|
@@ -308,12 +308,14 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
||||
"filter": "",
|
||||
}
|
||||
]
|
||||
elif 'all' in raw_scopes:
|
||||
raw_scopes = ['all']
|
||||
elif 'inherit' in raw_scopes:
|
||||
raw_scopes = ['inherit']
|
||||
scope_descriptions = [
|
||||
{
|
||||
"scope": "all",
|
||||
"description": scopes.scope_definitions['all']['description'],
|
||||
"scope": "inherit",
|
||||
"description": scopes.scope_definitions['inherit'][
|
||||
'description'
|
||||
],
|
||||
"filter": "",
|
||||
}
|
||||
]
|
||||
|
@@ -31,6 +31,9 @@ class APIHandler(BaseHandler):
|
||||
- methods for REST API models
|
||||
"""
|
||||
|
||||
# accept token-based authentication for API requests
|
||||
_accept_token_auth = True
|
||||
|
||||
@property
|
||||
def content_security_policy(self):
|
||||
return '; '.join([super().content_security_policy, "default-src 'none'"])
|
||||
@@ -210,6 +213,7 @@ class APIHandler(BaseHandler):
|
||||
'last_activity': isoformat(token.last_activity),
|
||||
'expires_at': isoformat(token.expires_at),
|
||||
'note': token.note,
|
||||
'session_id': token.session_id,
|
||||
'oauth_client': token.oauth_client.description
|
||||
or token.oauth_client.identifier,
|
||||
}
|
||||
|
@@ -129,7 +129,7 @@ class GroupAPIHandler(_GroupAPIHandler):
|
||||
self.write(json.dumps(self.group_model(group)))
|
||||
self.set_status(201)
|
||||
|
||||
@needs_scope('admin:groups')
|
||||
@needs_scope('delete:groups')
|
||||
def delete(self, group_name):
|
||||
"""Delete a group by name"""
|
||||
group = self.find_group(group_name)
|
||||
|
@@ -58,6 +58,14 @@ class SelfAPIHandler(APIHandler):
|
||||
|
||||
model = get_model(user)
|
||||
|
||||
# add session_id associated with token
|
||||
# added in 2.0
|
||||
token = self.get_token()
|
||||
if token:
|
||||
model["session_id"] = token.session_id
|
||||
else:
|
||||
model["session_id"] = None
|
||||
|
||||
# add scopes to identify model,
|
||||
# but not the scopes we added to ensure we could read our own model
|
||||
model["scopes"] = sorted(self.expanded_scopes.difference(_added_scopes))
|
||||
@@ -266,7 +274,7 @@ class UserAPIHandler(APIHandler):
|
||||
self.write(json.dumps(self.user_model(user)))
|
||||
self.set_status(201)
|
||||
|
||||
@needs_scope('admin:users')
|
||||
@needs_scope('delete:users')
|
||||
async def delete(self, user_name):
|
||||
user = self.find_user(user_name)
|
||||
if user is None:
|
||||
@@ -397,9 +405,11 @@ class UserTokenListAPIHandler(APIHandler):
|
||||
token_roles = body.get('roles')
|
||||
try:
|
||||
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)
|
||||
except ValueError:
|
||||
raise web.HTTPError(
|
||||
@@ -421,6 +431,7 @@ class UserTokenListAPIHandler(APIHandler):
|
||||
token_model = self.token_model(orm.APIToken.find(self.db, api_token))
|
||||
token_model['token'] = api_token
|
||||
self.write(json.dumps(token_model))
|
||||
self.set_status(201)
|
||||
|
||||
|
||||
class UserTokenAPIHandler(APIHandler):
|
||||
@@ -483,6 +494,11 @@ class UserServerAPIHandler(APIHandler):
|
||||
@needs_scope('servers')
|
||||
async def post(self, user_name, server_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 not self.allow_named_servers:
|
||||
raise web.HTTPError(400, "Named servers are not enabled.")
|
||||
@@ -525,7 +541,7 @@ class UserServerAPIHandler(APIHandler):
|
||||
self.set_header('Content-Type', 'text/plain')
|
||||
self.set_status(status)
|
||||
|
||||
@needs_scope('servers')
|
||||
@needs_scope('delete:servers')
|
||||
async def delete(self, user_name, server_name=''):
|
||||
user = self.find_user(user_name)
|
||||
options = self.get_json_body()
|
||||
|
@@ -90,6 +90,7 @@ from .log import CoroutineLogFormatter, log_request
|
||||
from .proxy import Proxy, ConfigurableHTTPProxy
|
||||
from .traitlets import URLPrefix, Command, EntryPointType, Callable
|
||||
from .utils import (
|
||||
AnyTimeoutError,
|
||||
catch_db_error,
|
||||
maybe_future,
|
||||
url_path_join,
|
||||
@@ -1518,6 +1519,25 @@ class JupyterHub(Application):
|
||||
""",
|
||||
).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):
|
||||
h = []
|
||||
# load handlers from the authenticator
|
||||
@@ -2050,7 +2070,7 @@ class JupyterHub(Application):
|
||||
if role_spec['name'] == 'admin':
|
||||
app_log.warning(
|
||||
"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(
|
||||
"Merging admin_users set with users list in admin role"
|
||||
@@ -2089,7 +2109,7 @@ class JupyterHub(Application):
|
||||
)
|
||||
Class = orm.get_class(kind)
|
||||
orm_obj = Class.find(db, bname)
|
||||
if orm_obj:
|
||||
if orm_obj is not None:
|
||||
orm_role_bearers.append(orm_obj)
|
||||
else:
|
||||
app_log.info(
|
||||
@@ -2098,6 +2118,11 @@ class JupyterHub(Application):
|
||||
if kind == 'users':
|
||||
orm_obj = await self._get_or_create_user(bname)
|
||||
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:
|
||||
raise ValueError(
|
||||
f"{kind} {bname} defined in config role definition {predef_role['name']} but not present in database"
|
||||
@@ -2331,7 +2356,7 @@ class JupyterHub(Application):
|
||||
continue
|
||||
try:
|
||||
await Server.from_orm(service.orm.server).wait_up(timeout=1, http=True)
|
||||
except TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
self.log.warning(
|
||||
"Cannot connect to %s service %s at %s",
|
||||
service.kind,
|
||||
@@ -2409,7 +2434,7 @@ class JupyterHub(Application):
|
||||
)
|
||||
try:
|
||||
await user._wait_up(spawner)
|
||||
except TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
self.log.error(
|
||||
"%s does not appear to be running at %s, shutting it down.",
|
||||
spawner._log_name,
|
||||
@@ -2773,7 +2798,7 @@ class JupyterHub(Application):
|
||||
await gen.with_timeout(
|
||||
timedelta(seconds=max(init_spawners_timeout, 1)), init_spawners_future
|
||||
)
|
||||
except gen.TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
self.log.warning(
|
||||
"init_spawners did not complete within %i seconds. "
|
||||
"Allowing to complete in the background.",
|
||||
@@ -3036,7 +3061,7 @@ class JupyterHub(Application):
|
||||
await Server.from_orm(service.orm.server).wait_up(
|
||||
http=True, timeout=1, ssl_context=ssl_context
|
||||
)
|
||||
except TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
if service.managed:
|
||||
status = await service.spawner.poll()
|
||||
if status is not None:
|
||||
|
@@ -1173,3 +1173,22 @@ class DummyAuthenticator(Authenticator):
|
||||
return data['username']
|
||||
return None
|
||||
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 ..spawner import LocalProcessSpawner
|
||||
from ..user import User
|
||||
from ..utils import AnyTimeoutError
|
||||
from ..utils import get_accepted_mimetype
|
||||
from ..utils import maybe_future
|
||||
from ..utils import url_path_join
|
||||
@@ -70,6 +71,12 @@ SESSION_COOKIE_NAME = 'jupyterhub-session-id'
|
||||
class BaseHandler(RequestHandler):
|
||||
"""Base Handler class with access to common methods and properties."""
|
||||
|
||||
# by default, only accept cookie-based authentication
|
||||
# The APIHandler base class enables token auth
|
||||
# versionadded: 2.0
|
||||
_accept_cookie_auth = True
|
||||
_accept_token_auth = False
|
||||
|
||||
async def prepare(self):
|
||||
"""Identify the user during the prepare stage of each request
|
||||
|
||||
@@ -339,6 +346,7 @@ class BaseHandler(RequestHandler):
|
||||
auth_info['auth_state'] = await user.get_auth_state()
|
||||
return await self.auth_to_user(auth_info, user)
|
||||
|
||||
@functools.lru_cache()
|
||||
def get_token(self):
|
||||
"""get token from authorization header"""
|
||||
token = self.get_auth_token()
|
||||
@@ -409,9 +417,11 @@ class BaseHandler(RequestHandler):
|
||||
async def get_current_user(self):
|
||||
"""get current username"""
|
||||
if not hasattr(self, '_jupyterhub_user'):
|
||||
user = None
|
||||
try:
|
||||
user = self.get_current_user_token()
|
||||
if user is None:
|
||||
if self._accept_token_auth:
|
||||
user = self.get_current_user_token()
|
||||
if user is None and self._accept_cookie_auth:
|
||||
user = self.get_current_user_cookie()
|
||||
if user and isinstance(user, User):
|
||||
user = await self.refresh_auth(user)
|
||||
@@ -490,7 +500,7 @@ class BaseHandler(RequestHandler):
|
||||
session_id = self.get_session_cookie()
|
||||
if 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:
|
||||
# user is logged in, clear any tokens associated with the current session
|
||||
@@ -569,7 +579,9 @@ class BaseHandler(RequestHandler):
|
||||
so other services on this domain can read it.
|
||||
"""
|
||||
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
|
||||
|
||||
def set_service_cookie(self, user):
|
||||
@@ -1019,7 +1031,7 @@ class BaseHandler(RequestHandler):
|
||||
await gen.with_timeout(
|
||||
timedelta(seconds=self.slow_spawn_timeout), finish_spawn_future
|
||||
)
|
||||
except gen.TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
# waiting_for_response indicates server process has started,
|
||||
# but is yet to become responsive.
|
||||
if spawner._spawn_pending and not spawner._waiting_for_response:
|
||||
@@ -1166,7 +1178,7 @@ class BaseHandler(RequestHandler):
|
||||
|
||||
try:
|
||||
await gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), future)
|
||||
except gen.TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
# hit timeout, but stop is still pending
|
||||
self.log.warning(
|
||||
"User %s:%s server is slow to stop (timeout=%s)",
|
||||
@@ -1355,7 +1367,7 @@ class UserUrlHandler(BaseHandler):
|
||||
|
||||
**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.)
|
||||
3. server is active, redirect to /hub/spawn-pending to monitor launch progress
|
||||
(will redirect back when finished)
|
||||
@@ -1374,7 +1386,14 @@ class UserUrlHandler(BaseHandler):
|
||||
self.log.warning(
|
||||
"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")
|
||||
|
||||
spawn_url = urlparse(self.request.full_url())._replace(query="")
|
||||
@@ -1539,15 +1558,17 @@ class UserUrlHandler(BaseHandler):
|
||||
self.redirect(pending_url, status=303)
|
||||
return
|
||||
|
||||
# if we got here, the server is not running
|
||||
# serve a page prompting for spawn and 503 error
|
||||
# visiting /user/:name no longer triggers implicit spawn
|
||||
# without explicit user action
|
||||
# If we got here, the server is not running. To differentiate
|
||||
# that the *server* itself is not running, rather than just the particular
|
||||
# page *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
|
||||
spawn_url = url_concat(
|
||||
url_path_join(self.hub.base_url, "spawn", user.escaped_name, server_name),
|
||||
{"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()
|
||||
html = await self.render_template(
|
||||
|
@@ -44,6 +44,7 @@ from . import utils
|
||||
from .metrics import CHECK_ROUTES_DURATION_SECONDS
|
||||
from .metrics import PROXY_POLL_DURATION_SECONDS
|
||||
from .objects import Server
|
||||
from .utils import AnyTimeoutError
|
||||
from .utils import exponential_backoff
|
||||
from .utils import url_path_join
|
||||
from jupyterhub.traitlets import Command
|
||||
@@ -718,7 +719,7 @@ class ConfigurableHTTPProxy(Proxy):
|
||||
_check_process()
|
||||
try:
|
||||
await server.wait_up(1)
|
||||
except TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
@@ -57,7 +57,7 @@ def get_default_roles():
|
||||
{
|
||||
'name': 'token',
|
||||
'description': 'Token with same permissions as its owner',
|
||||
'scopes': ['all'],
|
||||
'scopes': ['inherit'],
|
||||
},
|
||||
]
|
||||
return default_roles
|
||||
@@ -89,6 +89,7 @@ def expand_self_scope(name):
|
||||
'users:activity',
|
||||
'read:users:activity',
|
||||
'servers',
|
||||
'delete:servers',
|
||||
'read:servers',
|
||||
'tokens',
|
||||
'read:tokens',
|
||||
@@ -213,7 +214,7 @@ def _check_scopes(*args, rolename=None):
|
||||
or
|
||||
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())
|
||||
@@ -227,11 +228,13 @@ def _check_scopes(*args, rolename=None):
|
||||
for scope in args:
|
||||
scopename, _, filter_ = scope.partition('!')
|
||||
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_:
|
||||
full_filter = f"!{filter_}"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -321,7 +324,7 @@ def delete_role(db, rolename):
|
||||
db.commit()
|
||||
app_log.info('Role %s has been deleted', rolename)
|
||||
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):
|
||||
@@ -412,7 +415,7 @@ def _token_allowed_role(db, token, role):
|
||||
|
||||
expanded_scopes = _get_subscopes(role, owner=owner)
|
||||
|
||||
implicit_permissions = {'all', 'read:all'}
|
||||
implicit_permissions = {'inherit', 'read:inherit'}
|
||||
explicit_scopes = expanded_scopes - implicit_permissions
|
||||
# ignore horizontal filters
|
||||
no_filter_scopes = {
|
||||
@@ -431,37 +434,40 @@ def _token_allowed_role(db, token, role):
|
||||
return True
|
||||
else:
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
tokens get 'token' role
|
||||
"""
|
||||
if isinstance(entity, orm.Group):
|
||||
pass
|
||||
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')
|
||||
if not entity.roles and (entity.user or entity.service) is not None:
|
||||
default_token_role.tokens.append(entity)
|
||||
app_log.info('Added role %s to token %s', default_token_role.name, entity)
|
||||
db.commit()
|
||||
db.commit()
|
||||
# users and services can have 'user' or 'admin' roles as default
|
||||
else:
|
||||
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)
|
||||
|
||||
|
||||
def update_roles(db, entity, roles):
|
||||
"""Updates object's roles checking for requested permissions
|
||||
if object is orm.APIToken
|
||||
"""Add roles to an entity (token, user, etc.)
|
||||
|
||||
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:
|
||||
if isinstance(entity, orm.APIToken):
|
||||
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)
|
||||
else:
|
||||
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:
|
||||
raise NameError('Role %r does not exist' % rolename)
|
||||
raise KeyError(f'Role {rolename} does not exist')
|
||||
else:
|
||||
app_log.debug('Assigning default roles to %s', type(entity).__name__)
|
||||
grant_role(db, entity=entity, rolename=rolename)
|
||||
|
||||
|
||||
|
@@ -30,19 +30,22 @@ scope_definitions = {
|
||||
'description': 'Your own resources',
|
||||
'doc_description': 'The user’s own resources _(metascope for users, resolves to (no_scope) for services)_',
|
||||
},
|
||||
'all': {
|
||||
'inherit': {
|
||||
'description': 'Anything you have access to',
|
||||
'doc_description': 'Everything that the token-owning entity can access _(metascope for tokens)_',
|
||||
},
|
||||
'admin:users': {
|
||||
'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.'},
|
||||
'users': {
|
||||
'description': 'Read and write permissions to user models (excluding servers, tokens and authentication state).',
|
||||
'subscopes': ['read:users', 'list:users', 'users:activity'],
|
||||
},
|
||||
'delete:users': {
|
||||
'description': "Delete users.",
|
||||
},
|
||||
'list:users': {
|
||||
'description': 'List users, including at least their names.',
|
||||
'subscopes': ['read:users:name'],
|
||||
@@ -76,12 +79,13 @@ scope_definitions = {
|
||||
'admin:server_state': {'description': 'Read and write users’ server state.'},
|
||||
'servers': {
|
||||
'description': 'Start and stop user servers.',
|
||||
'subscopes': ['read:servers'],
|
||||
'subscopes': ['read:servers', 'delete:servers'],
|
||||
},
|
||||
'read:servers': {
|
||||
'description': 'Read users’ names and their server models (excluding the server state).',
|
||||
'subscopes': ['read:users:name'],
|
||||
},
|
||||
'delete:servers': {'description': "Stop and delete users' servers."},
|
||||
'tokens': {
|
||||
'description': 'Read, write, create and delete user tokens.',
|
||||
'subscopes': ['read:tokens'],
|
||||
@@ -89,7 +93,7 @@ scope_definitions = {
|
||||
'read:tokens': {'description': 'Read user tokens.'},
|
||||
'admin:groups': {
|
||||
'description': 'Read and write group information, create and delete groups.',
|
||||
'subscopes': ['groups', 'read:roles:groups'],
|
||||
'subscopes': ['groups', 'read:roles:groups', 'delete:groups'],
|
||||
},
|
||||
'groups': {
|
||||
'description': 'Read and write group information, including adding/removing users to/from groups.',
|
||||
@@ -104,6 +108,9 @@ scope_definitions = {
|
||||
'subscopes': ['read:groups:name'],
|
||||
},
|
||||
'read:groups:name': {'description': 'Read group names.'},
|
||||
'delete:groups': {
|
||||
'description': "Delete groups.",
|
||||
},
|
||||
'list:services': {
|
||||
'description': 'List services, including at least their names.',
|
||||
'subscopes': ['read:services:name'],
|
||||
@@ -288,7 +295,7 @@ def get_scopes_for(orm_object):
|
||||
)
|
||||
|
||||
if isinstance(orm_object, orm.APIToken):
|
||||
app_log.warning(f"Authenticated with token {orm_object}")
|
||||
app_log.debug(f"Authenticated with token {orm_object}")
|
||||
owner = orm_object.user or orm_object.service
|
||||
token_scopes = roles.expand_roles_to_scopes(orm_object)
|
||||
if orm_object.client_id != "jupyterhub":
|
||||
@@ -310,13 +317,13 @@ def get_scopes_for(orm_object):
|
||||
|
||||
owner_scopes = roles.expand_roles_to_scopes(owner)
|
||||
|
||||
if token_scopes == {'all'}:
|
||||
# token_scopes is only 'all', return owner scopes as-is
|
||||
if token_scopes == {'inherit'}:
|
||||
# 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
|
||||
return owner_scopes
|
||||
|
||||
if 'all' in token_scopes:
|
||||
token_scopes.remove('all')
|
||||
if 'inherit' in token_scopes:
|
||||
token_scopes.remove('inherit')
|
||||
token_scopes |= owner_scopes
|
||||
|
||||
intersection = _intersect_expanded_scopes(
|
||||
|
@@ -3,10 +3,24 @@
|
||||
Tokens are sent to the Hub for verification.
|
||||
The Hub replies with a JSON model describing the authenticated user.
|
||||
|
||||
``HubAuth`` can be used in any application, even outside tornado.
|
||||
This contains two levels of authentication:
|
||||
|
||||
``HubAuthenticated`` is a mixin class for tornado handlers that should
|
||||
authenticate with the Hub.
|
||||
- :class:`HubOAuth` - Use OAuth 2 to authenticate browsers with the Hub.
|
||||
This should be used for any service that should respond to browser requests
|
||||
(i.e. most services).
|
||||
|
||||
- :class:`HubAuth` - token-only authentication, for a service that only need to handle token-authenticated API requests
|
||||
|
||||
The ``Auth`` classes (:class:`HubAuth`, :class:`HubOAuth`)
|
||||
can be used in any application, even outside tornado.
|
||||
They contain reference implementations of talking to the Hub API
|
||||
to resolve a token to a user.
|
||||
|
||||
The ``Authenticated`` classes (:class:`HubAuthenticated`, :class:`HubOAuthenticated`)
|
||||
are mixins for tornado handlers that should authenticate with the Hub.
|
||||
|
||||
If you are using OAuth, you will also need to register an oauth callback handler to complete the oauth process.
|
||||
A tornado implementation is provided in :class:`HubOAuthCallbackHandler`.
|
||||
|
||||
"""
|
||||
import base64
|
||||
@@ -212,6 +226,7 @@ class HubAuth(SingletonConfigurable):
|
||||
help="""The base API URL of the Hub.
|
||||
|
||||
Typically `http://hub-ip:hub-port/hub/api`
|
||||
Default: $JUPYTERHUB_API_URL
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
@@ -227,7 +242,10 @@ class HubAuth(SingletonConfigurable):
|
||||
os.getenv('JUPYTERHUB_API_TOKEN', ''),
|
||||
help="""API key for accessing Hub API.
|
||||
|
||||
Generate with `jupyterhub token [username]` or add to JupyterHub.services config.
|
||||
Default: $JUPYTERHUB_API_TOKEN
|
||||
|
||||
Loaded from services configuration in jupyterhub_config.
|
||||
Will be auto-generated for hub-managed services.
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
@@ -236,6 +254,7 @@ class HubAuth(SingletonConfigurable):
|
||||
help="""The URL prefix for the Hub itself.
|
||||
|
||||
Typically /hub/
|
||||
Default: $JUPYTERHUB_BASE_URL
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
@@ -854,8 +873,6 @@ class HubAuthenticated:
|
||||
Examples::
|
||||
|
||||
class MyHandler(HubAuthenticated, web.RequestHandler):
|
||||
hub_users = {'inara', 'mal'}
|
||||
|
||||
def initialize(self, hub_auth):
|
||||
self.hub_auth = hub_auth
|
||||
|
||||
@@ -865,6 +882,7 @@ class HubAuthenticated:
|
||||
|
||||
"""
|
||||
|
||||
# deprecated, pre-2.0 allow sets
|
||||
hub_services = None # set of allowed services
|
||||
hub_users = None # set of allowed users
|
||||
hub_groups = None # set of allowed groups
|
||||
@@ -960,6 +978,10 @@ class HubAuthenticated:
|
||||
raise UserNotAllowed(model)
|
||||
|
||||
# proceed with the pre-2.0 way if hub_scopes is not set
|
||||
warnings.warn(
|
||||
"hub_scopes ($JUPYTERHUB not set, proceeding with pre-2.0 authentication",
|
||||
DeprecationWarning,
|
||||
)
|
||||
|
||||
if self.allow_admin and model.get('admin', False):
|
||||
app_log.debug("Allowing Hub admin %s", name)
|
||||
@@ -1023,8 +1045,8 @@ class HubAuthenticated:
|
||||
self._hub_auth_user_cache = None
|
||||
raise
|
||||
|
||||
# store tokens passed via url or header in a cookie for future requests
|
||||
url_token = self.hub_auth.get_token(self)
|
||||
# store ?token=... tokens passed via url in a cookie for future requests
|
||||
url_token = self.get_argument('token', '')
|
||||
if (
|
||||
user_model
|
||||
and url_token
|
||||
|
@@ -1,7 +1,12 @@
|
||||
"""Make a single-user app based on the environment:
|
||||
|
||||
- $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
|
||||
|
||||
@@ -9,12 +14,55 @@ from traitlets import import_item
|
||||
|
||||
from .mixins import make_singleuser_app
|
||||
|
||||
JUPYTERHUB_SINGLEUSER_APP = (
|
||||
os.environ.get("JUPYTERHUB_SINGLEUSER_APP") or "notebook.notebookapp.NotebookApp"
|
||||
)
|
||||
JUPYTERHUB_SINGLEUSER_APP = os.environ.get("JUPYTERHUB_SINGLEUSER_APP")
|
||||
|
||||
|
||||
if 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
|
||||
|
||||
App = import_item(JUPYTERHUB_SINGLEUSER_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()
|
||||
|
@@ -18,6 +18,7 @@ import sys
|
||||
import warnings
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from importlib import import_module
|
||||
from textwrap import dedent
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -606,10 +607,34 @@ class SingleUserNotebookAppMixin(Configurable):
|
||||
t = self.hub_activity_interval * (1 + 0.2 * (random.random() - 0.5))
|
||||
await asyncio.sleep(t)
|
||||
|
||||
def _log_app_versions(self):
|
||||
"""Log application versions at startup
|
||||
|
||||
Logs versions of jupyterhub and singleuser-server base versions (jupyterlab, jupyter_server, notebook)
|
||||
"""
|
||||
self.log.info(f"Starting jupyterhub single-user server version {__version__}")
|
||||
|
||||
# don't log these package versions
|
||||
seen = {"jupyterhub", "traitlets", "jupyter_core", "builtins"}
|
||||
|
||||
for cls in self.__class__.mro():
|
||||
module_name = cls.__module__.partition(".")[0]
|
||||
if module_name not in seen:
|
||||
seen.add(module_name)
|
||||
try:
|
||||
mod = import_module(module_name)
|
||||
mod_version = getattr(mod, "__version__")
|
||||
except Exception:
|
||||
mod_version = ""
|
||||
self.log.info(
|
||||
f"Extending {cls.__module__}.{cls.__name__} from {module_name} {mod_version}"
|
||||
)
|
||||
|
||||
def initialize(self, argv=None):
|
||||
# disable trash by default
|
||||
# this can be re-enabled by config
|
||||
self.config.FileContentsManager.delete_to_trash = False
|
||||
self._log_app_versions()
|
||||
return super().initialize(argv)
|
||||
|
||||
def start(self):
|
||||
@@ -715,6 +740,18 @@ class SingleUserNotebookAppMixin(Configurable):
|
||||
orig_loader = env.loader
|
||||
env.loader = ChoiceLoader([FunctionLoader(get_page), orig_loader])
|
||||
|
||||
def load_server_extensions(self):
|
||||
# Loading LabApp sets $JUPYTERHUB_API_TOKEN on load, which is incorrect
|
||||
r = super().load_server_extensions()
|
||||
# clear the token in PageConfig at this step
|
||||
# so that cookie auth is used
|
||||
# FIXME: in the future,
|
||||
# it would probably make sense to set page_config.token to the token
|
||||
# from the current request.
|
||||
if 'page_config_data' in self.web_app.settings:
|
||||
self.web_app.settings['page_config_data']['token'] = ''
|
||||
return r
|
||||
|
||||
|
||||
def detect_base_package(App):
|
||||
"""Detect the base package for an App class
|
||||
|
@@ -15,8 +15,6 @@ from subprocess import Popen
|
||||
from tempfile import mkdtemp
|
||||
from urllib.parse import urlparse
|
||||
|
||||
if os.name == 'nt':
|
||||
import psutil
|
||||
from async_generator import aclosing
|
||||
from sqlalchemy import inspect
|
||||
from tornado.ioloop import PeriodicCallback
|
||||
@@ -38,12 +36,14 @@ from .objects import Server
|
||||
from .traitlets import ByteSpecification
|
||||
from .traitlets import Callable
|
||||
from .traitlets import Command
|
||||
from .utils import AnyTimeoutError
|
||||
from .utils import exponential_backoff
|
||||
from .utils import maybe_future
|
||||
from .utils import random_port
|
||||
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):
|
||||
@@ -1263,7 +1263,7 @@ class Spawner(LoggingConfigurable):
|
||||
timeout=timeout,
|
||||
)
|
||||
return r
|
||||
except TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
return False
|
||||
|
||||
|
||||
|
@@ -972,6 +972,11 @@ async def test_bad_spawn(app, bad_spawn):
|
||||
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):
|
||||
db = app.db
|
||||
name = 'zaphod'
|
||||
@@ -1366,8 +1371,8 @@ async def test_get_new_token_deprecated(app, headers, status):
|
||||
@mark.parametrize(
|
||||
"headers, status, note, expires_in",
|
||||
[
|
||||
({}, 200, 'test note', None),
|
||||
({}, 200, '', 100),
|
||||
({}, 201, 'test note', None),
|
||||
({}, 201, '', 100),
|
||||
({'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
|
||||
)
|
||||
assert r.status_code == status
|
||||
if status != 200:
|
||||
if status != 201:
|
||||
return
|
||||
# check the new-token reply
|
||||
reply = r.json()
|
||||
@@ -1424,10 +1429,10 @@ async def test_get_new_token(app, headers, status, note, expires_in):
|
||||
@mark.parametrize(
|
||||
"as_user, for_user, status",
|
||||
[
|
||||
('admin', 'other', 200),
|
||||
('admin', 'other', 201),
|
||||
('admin', 'missing', 403),
|
||||
('user', 'other', 403),
|
||||
('user', 'user', 200),
|
||||
('user', 'user', 201),
|
||||
],
|
||||
)
|
||||
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
|
||||
reply = r.json()
|
||||
if status != 200:
|
||||
if status != 201:
|
||||
return
|
||||
assert 'token' in reply
|
||||
|
||||
@@ -1486,7 +1491,7 @@ async def test_token_authenticator_noauth(app):
|
||||
data=json.dumps(data) if data else None,
|
||||
noauth=True,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.status_code == 201
|
||||
reply = r.json()
|
||||
assert 'token' in reply
|
||||
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,
|
||||
noauth=True,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.status_code == 201
|
||||
reply = r.json()
|
||||
assert 'token' in reply
|
||||
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
||||
|
@@ -3,6 +3,7 @@
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import logging
|
||||
from unittest import mock
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
from requests import HTTPError
|
||||
@@ -12,6 +13,8 @@ from .mocking import MockPAMAuthenticator
|
||||
from .mocking import MockStructGroup
|
||||
from .mocking import MockStructPasswd
|
||||
from .utils import add_user
|
||||
from .utils import async_requests
|
||||
from .utils import public_url
|
||||
from jupyterhub import auth
|
||||
from jupyterhub import crypto
|
||||
from jupyterhub import orm
|
||||
@@ -515,3 +518,12 @@ def test_deprecated_methods_subclass():
|
||||
assert authenticator.check_whitelist("subclass-allowed")
|
||||
assert not authenticator.check_allowed("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"""
|
||||
import sys
|
||||
import time
|
||||
from subprocess import check_output
|
||||
from unittest import mock
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
from requests.exceptions import ConnectionError
|
||||
from requests.exceptions import SSLError
|
||||
from tornado import gen
|
||||
|
||||
import jupyterhub
|
||||
from ..utils import AnyTimeoutError
|
||||
from .test_api import add_user
|
||||
from .utils import async_requests
|
||||
|
||||
@@ -35,7 +32,7 @@ async def wait_for_spawner(spawner, timeout=10):
|
||||
assert status is None
|
||||
try:
|
||||
await wait()
|
||||
except TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
@@ -56,8 +56,8 @@ async def test_root_redirect(app):
|
||||
r = await get_page(url, app, cookies=cookies)
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
|
||||
# serve "server not running" page, which has status 503
|
||||
assert r.status_code == 503
|
||||
# serve "server not running" page, which has status 424
|
||||
assert r.status_code == 424
|
||||
|
||||
|
||||
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)
|
||||
path = urlparse(r.url).path
|
||||
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):
|
||||
@@ -507,13 +507,13 @@ async def test_user_redirect_deprecated(app, username):
|
||||
print(urlparse(r.url))
|
||||
path = urlparse(r.url).path
|
||||
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)
|
||||
print(urlparse(r.url))
|
||||
path = urlparse(r.url).path
|
||||
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.raise_for_status()
|
||||
@@ -578,6 +578,41 @@ async def test_login_page(app, url, params, redirected_url, form_action):
|
||||
assert action.endswith(form_action)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url, token_in",
|
||||
[
|
||||
("/home", "url"),
|
||||
("/home", "header"),
|
||||
("/login", "url"),
|
||||
("/login", "header"),
|
||||
],
|
||||
)
|
||||
async def test_page_with_token(app, user, url, token_in):
|
||||
cookies = await app.login_user(user.name)
|
||||
token = user.new_api_token()
|
||||
if token_in == "url":
|
||||
url = url_concat(url, {"token": token})
|
||||
headers = None
|
||||
elif token_in == "header":
|
||||
headers = {
|
||||
"Authorization": f"token {token}",
|
||||
}
|
||||
|
||||
# request a page with ?token= in URL shouldn't be allowed
|
||||
r = await get_page(
|
||||
url,
|
||||
app,
|
||||
headers=headers,
|
||||
allow_redirects=False,
|
||||
)
|
||||
if "/hub/login" in r.url:
|
||||
assert r.status_code == 200
|
||||
else:
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"].partition("?")[0].endswith("/hub/login")
|
||||
assert not r.cookies
|
||||
|
||||
|
||||
async def test_login_fail(app):
|
||||
name = 'wash'
|
||||
base_url = public_url(app)
|
||||
@@ -1061,13 +1096,20 @@ async def test_token_page(app):
|
||||
async def test_server_not_running_api_request(app):
|
||||
cookies = await app.login_user("bees")
|
||||
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"
|
||||
message = r.json()['message']
|
||||
assert ujoin(app.base_url, "hub/spawn/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):
|
||||
r = await get_page("metrics", app)
|
||||
assert r.status_code == 403
|
||||
|
@@ -28,7 +28,7 @@ def test_orm_roles(db):
|
||||
user_role = orm.Role(name='user', scopes=['self'])
|
||||
db.add(user_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)
|
||||
if not service_role:
|
||||
service_role = orm.Role(name='service', scopes=[])
|
||||
@@ -182,6 +182,7 @@ def test_orm_roles_delete_cascade(db):
|
||||
'admin:users',
|
||||
'admin:auth_state',
|
||||
'users',
|
||||
'delete:users',
|
||||
'list:users',
|
||||
'read:users',
|
||||
'users:activity',
|
||||
@@ -218,6 +219,7 @@ def test_orm_roles_delete_cascade(db):
|
||||
{
|
||||
'admin:groups',
|
||||
'groups',
|
||||
'delete:groups',
|
||||
'list:groups',
|
||||
'read:groups',
|
||||
'read:roles:groups',
|
||||
@@ -229,6 +231,7 @@ def test_orm_roles_delete_cascade(db):
|
||||
{
|
||||
'admin:groups',
|
||||
'groups',
|
||||
'delete:groups',
|
||||
'list:groups',
|
||||
'read:groups',
|
||||
'read:roles:groups',
|
||||
@@ -366,7 +369,7 @@ async def test_creating_roles(app, role, role_def, response_type, response):
|
||||
'info',
|
||||
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),
|
||||
],
|
||||
)
|
||||
@@ -407,9 +410,9 @@ async def test_delete_roles(db, role_type, rolename, response_type, response):
|
||||
},
|
||||
'existing',
|
||||
),
|
||||
({'name': 'test-scopes-2', 'scopes': ['uses']}, NameError),
|
||||
({'name': 'test-scopes-3', 'scopes': ['users:activities']}, NameError),
|
||||
({'name': 'test-scopes-4', 'scopes': ['groups!goup=class-A']}, NameError),
|
||||
({'name': 'test-scopes-2', 'scopes': ['uses']}, KeyError),
|
||||
({'name': 'test-scopes-3', 'scopes': ['users:activities']}, KeyError),
|
||||
({'name': 'test-scopes-4', 'scopes': ['groups!goup=class-A']}, KeyError),
|
||||
],
|
||||
)
|
||||
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.scopes == role['scopes']
|
||||
|
||||
elif response == NameError:
|
||||
elif response == KeyError:
|
||||
with pytest.raises(response):
|
||||
roles.create_role(db, role)
|
||||
added_role = orm.Role.find(db, role['name'])
|
||||
@@ -575,7 +578,7 @@ async def test_load_roles_groups(tmpdir, request):
|
||||
'name': 'head',
|
||||
'description': 'Whole user access',
|
||||
'scopes': ['users', 'admin:users'],
|
||||
'groups': ['group3'],
|
||||
'groups': ['group3', "group4"],
|
||||
},
|
||||
]
|
||||
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')
|
||||
group2 = orm.Group.find(db, name='group2')
|
||||
group3 = orm.Group.find(db, name='group3')
|
||||
group4 = orm.Group.find(db, name='group4')
|
||||
|
||||
# test group roles
|
||||
assert group1.roles == []
|
||||
assert group2 in assist_role.groups
|
||||
assert group3 in head_role.groups
|
||||
assert group4 in head_role.groups
|
||||
|
||||
# delete the test roles
|
||||
for role in roles_to_load:
|
||||
@@ -658,11 +663,11 @@ async def test_load_roles_user_tokens(tmpdir, request):
|
||||
"headers, rolename, scopes, status",
|
||||
[
|
||||
# no role requested - gets default 'token' role
|
||||
({}, None, None, 200),
|
||||
({}, None, None, 201),
|
||||
# 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
|
||||
({}, 'groups-reader', ['read:groups'], 200),
|
||||
({}, 'groups-reader', ['read:groups'], 201),
|
||||
# non-existing role request
|
||||
({}, 'non-existing', [], 404),
|
||||
# role scopes outside of both user's role and group's role scopes
|
||||
@@ -1327,3 +1332,19 @@ async def test_token_keep_roles_on_restart():
|
||||
for token in user.api_tokens:
|
||||
hub.db.delete(token)
|
||||
hub.db.commit()
|
||||
|
||||
|
||||
async def test_login_default_role(app, username):
|
||||
cookies = await app.login_user(username)
|
||||
user = app.users[username]
|
||||
# assert login new user gets 'user' role
|
||||
assert [role.name for role in user.roles] == ["user"]
|
||||
|
||||
# clear roles, keep user
|
||||
user.roles = []
|
||||
app.db.commit()
|
||||
|
||||
# login *again*; user exists, shouldn't trigger change in roles
|
||||
cookies = await app.login_user(username)
|
||||
user = app.users[username]
|
||||
assert user.roles == []
|
||||
|
@@ -477,7 +477,7 @@ async def test_metascope_all_expansion(app, create_user_with_scopes):
|
||||
user = create_user_with_scopes('self')
|
||||
user.new_api_token()
|
||||
token = user.api_tokens[0]
|
||||
# Check 'all' expansion
|
||||
# Check 'inherit' expansion
|
||||
token_scope_set = get_scopes_for(token)
|
||||
user_scope_set = get_scopes_for(user)
|
||||
assert user_scope_set == token_scope_set
|
||||
|
@@ -1,6 +1,10 @@
|
||||
"""Tests for jupyterhub.singleuser"""
|
||||
import os
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from subprocess import CalledProcessError
|
||||
from subprocess import check_output
|
||||
from unittest import mock
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
@@ -14,6 +18,12 @@ from .utils import async_requests
|
||||
from .utils import AsyncSession
|
||||
|
||||
|
||||
@contextmanager
|
||||
def nullcontext():
|
||||
"""Python 3.7+ contextlib.nullcontext, backport for 3.6"""
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_scopes, server_name, expect_success",
|
||||
[
|
||||
@@ -171,3 +181,47 @@ def test_version():
|
||||
[sys.executable, '-m', 'jupyterhub.singleuser', '--version']
|
||||
).decode('utf8', 'replace')
|
||||
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 Spawner
|
||||
from ..user import User
|
||||
from ..utils import AnyTimeoutError
|
||||
from ..utils import new_token
|
||||
from ..utils import url_path_join
|
||||
from .mocking import public_url
|
||||
@@ -95,7 +96,7 @@ async def wait_for_spawner(spawner, timeout=10):
|
||||
assert status is None
|
||||
try:
|
||||
await wait()
|
||||
except TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
@@ -26,11 +26,41 @@ from .metrics import RUNNING_SERVERS
|
||||
from .metrics import TOTAL_USERS
|
||||
from .objects import Server
|
||||
from .spawner import LocalProcessSpawner
|
||||
from .utils import AnyTimeoutError
|
||||
from .utils import make_ssl_context
|
||||
from .utils import maybe_future
|
||||
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):
|
||||
"""Like defaultdict, but for users
|
||||
|
||||
@@ -84,7 +114,7 @@ class UserDict(dict):
|
||||
if user.name == key:
|
||||
key = user.id
|
||||
break
|
||||
return dict.__contains__(self, key)
|
||||
return super().__contains__(key)
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""UserDict allows retrieval of user by any of:
|
||||
@@ -108,7 +138,7 @@ class UserDict(dict):
|
||||
if orm_user.id not in self:
|
||||
user = self[orm_user.id] = User(orm_user, self.settings)
|
||||
return user
|
||||
user = dict.__getitem__(self, orm_user.id)
|
||||
user = super().__getitem__(orm_user.id)
|
||||
user.db = self.db
|
||||
return user
|
||||
elif isinstance(key, int):
|
||||
@@ -119,7 +149,7 @@ class UserDict(dict):
|
||||
raise KeyError("No such user: %s" % id)
|
||||
user = self.add(orm_user)
|
||||
else:
|
||||
user = dict.__getitem__(self, id)
|
||||
user = super().__getitem__(id)
|
||||
return user
|
||||
else:
|
||||
raise KeyError(repr(key))
|
||||
@@ -145,7 +175,7 @@ class UserDict(dict):
|
||||
self.db.expunge(orm_spawner)
|
||||
if user.orm_user in self.db:
|
||||
self.db.expunge(user.orm_user)
|
||||
dict.__delitem__(self, user.id)
|
||||
super().__delitem__(user.id)
|
||||
|
||||
def delete(self, key):
|
||||
"""Delete a user from the cache and the database"""
|
||||
@@ -707,11 +737,11 @@ class User:
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, gen.TimeoutError):
|
||||
if isinstance(e, AnyTimeoutError):
|
||||
self.log.warning(
|
||||
"{user}'s server failed to start in {s} seconds, giving up".format(
|
||||
user=self.name, s=spawner.start_timeout
|
||||
)
|
||||
f"{self.name}'s server failed to start"
|
||||
f" in {spawner.start_timeout} seconds, giving up."
|
||||
f"\n{start_timeout_message}"
|
||||
)
|
||||
e.reason = 'timeout'
|
||||
self.settings['statsd'].incr('spawner.failure.timeout')
|
||||
@@ -764,14 +794,11 @@ class User:
|
||||
http=True, timeout=spawner.http_timeout, ssl_context=ssl_context
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, TimeoutError):
|
||||
if isinstance(e, AnyTimeoutError):
|
||||
self.log.warning(
|
||||
"{user}'s server never showed up at {url} "
|
||||
"after {http_timeout} seconds. Giving up".format(
|
||||
user=self.name,
|
||||
url=server.url,
|
||||
http_timeout=spawner.http_timeout,
|
||||
)
|
||||
f"{self.name}'s server never showed up at {server.url}"
|
||||
f" after {spawner.http_timeout} seconds. Giving up."
|
||||
f"\n{http_timeout_message}"
|
||||
)
|
||||
e.reason = 'timeout'
|
||||
self.settings['statsd'].incr('spawner.failure.http_timeout')
|
||||
|
@@ -23,12 +23,12 @@ from operator import itemgetter
|
||||
|
||||
from async_generator import aclosing
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from tornado import gen
|
||||
from tornado import ioloop
|
||||
from tornado import web
|
||||
from tornado.httpclient import AsyncHTTPClient
|
||||
from tornado.httpclient import HTTPError
|
||||
from tornado.log import app_log
|
||||
from tornado.platform.asyncio import to_asyncio_future
|
||||
|
||||
# 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.
|
||||
@@ -97,6 +97,10 @@ def make_ssl_context(keyfile, certfile, cafile=None, verify=True, check_hostname
|
||||
return ssl_context
|
||||
|
||||
|
||||
# AnyTimeoutError catches TimeoutErrors coming from asyncio, tornado, stdlib
|
||||
AnyTimeoutError = (gen.TimeoutError, asyncio.TimeoutError, TimeoutError)
|
||||
|
||||
|
||||
async def exponential_backoff(
|
||||
pass_func,
|
||||
fail_message,
|
||||
@@ -182,7 +186,7 @@ async def exponential_backoff(
|
||||
if dt < max_wait:
|
||||
scale *= scale_factor
|
||||
await asyncio.sleep(dt)
|
||||
raise TimeoutError(fail_message)
|
||||
raise asyncio.TimeoutError(fail_message)
|
||||
|
||||
|
||||
async def wait_for_server(ip, port, timeout=10):
|
||||
@@ -288,6 +292,31 @@ def authenticated_403(self):
|
||||
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
|
||||
def metrics_authentication(self):
|
||||
"""Decorator for restricting access to metrics"""
|
||||
|
@@ -5,3 +5,41 @@ target_version = [
|
||||
"py37",
|
||||
"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.0"
|
||||
|
||||
# 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}"
|
||||
|
8
setup.py
8
setup.py
@@ -46,10 +46,9 @@ def get_data_files():
|
||||
"""Get data files in share/jupyter"""
|
||||
|
||||
data_files = []
|
||||
ntrim = len(here + os.path.sep)
|
||||
|
||||
for (d, dirs, filenames) in os.walk(share_jupyterhub):
|
||||
data_files.append((d[ntrim:], [pjoin(d, f) for f in filenames]))
|
||||
rel_d = os.path.relpath(d, here)
|
||||
data_files.append((rel_d, [os.path.join(rel_d, f) for f in filenames]))
|
||||
return data_files
|
||||
|
||||
|
||||
@@ -100,6 +99,7 @@ setup_args = dict(
|
||||
'default = jupyterhub.auth:PAMAuthenticator',
|
||||
'pam = jupyterhub.auth:PAMAuthenticator',
|
||||
'dummy = jupyterhub.auth:DummyAuthenticator',
|
||||
'null = jupyterhub.auth:NullAuthenticator',
|
||||
],
|
||||
'jupyterhub.proxies': [
|
||||
'default = jupyterhub.proxy:ConfigurableHTTPProxy',
|
||||
@@ -301,7 +301,7 @@ class develop_js_css(develop):
|
||||
if not self.uninstall:
|
||||
self.distribution.run_command('js')
|
||||
self.distribution.run_command('css')
|
||||
develop.run(self)
|
||||
super().run()
|
||||
|
||||
|
||||
setup_args['cmdclass']['develop'] = develop_js_css
|
||||
|
@@ -6,7 +6,6 @@ FROM $BASE_IMAGE
|
||||
MAINTAINER Project Jupyter <jupyter@googlegroups.com>
|
||||
|
||||
ADD install_jupyterhub /tmp/install_jupyterhub
|
||||
ARG JUPYTERHUB_VERSION=main
|
||||
# install pinned jupyterhub and ensure notebook is installed
|
||||
RUN python3 /tmp/install_jupyterhub && \
|
||||
python3 -m pip install notebook
|
||||
ARG JUPYTERHUB_VERSION=git:HEAD
|
||||
# install pinned jupyterhub
|
||||
RUN python3 /tmp/install_jupyterhub
|
||||
|
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -ex
|
||||
|
||||
docker build --build-arg JUPYTERHUB_VERSION=$DOCKER_TAG -t $DOCKER_REPO:$DOCKER_TAG .
|
@@ -1,21 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -ex
|
||||
|
||||
function get_hub_version() {
|
||||
rm -f hub_version
|
||||
V=$1
|
||||
docker run --rm -v $PWD:/version -u $(id -u) -i $DOCKER_REPO:$DOCKER_TAG sh -c 'jupyterhub --version > /version/hub_version'
|
||||
hub_xyz=$(cat hub_version)
|
||||
split=( ${hub_xyz//./ } )
|
||||
hub_xy="${split[0]}.${split[1]}"
|
||||
# add .dev on hub_xy so it's 1.0.dev
|
||||
if [[ ! -z "${split[3]:-}" ]]; then
|
||||
hub_xy="${hub_xy}.${split[3]}"
|
||||
fi
|
||||
}
|
||||
# tag e.g. 0.9 with main
|
||||
get_hub_version
|
||||
docker tag $DOCKER_REPO:$DOCKER_TAG $DOCKER_REPO:$hub_xy
|
||||
docker push $DOCKER_REPO:$hub_xy
|
||||
docker tag $DOCKER_REPO:$DOCKER_TAG $DOCKER_REPO:$hub_xyz
|
||||
docker push $DOCKER_REPO:$hub_xyz
|
@@ -3,19 +3,22 @@ import os
|
||||
from subprocess import check_call
|
||||
import sys
|
||||
|
||||
V = os.environ['JUPYTERHUB_VERSION']
|
||||
version = os.environ['JUPYTERHUB_VERSION']
|
||||
|
||||
pip_install = [
|
||||
sys.executable, '-m', 'pip', 'install', '--no-cache', '--upgrade',
|
||||
'--upgrade-strategy', 'only-if-needed',
|
||||
sys.executable,
|
||||
'-m',
|
||||
'pip',
|
||||
'install',
|
||||
'--no-cache',
|
||||
'--upgrade',
|
||||
'--upgrade-strategy',
|
||||
'only-if-needed',
|
||||
]
|
||||
if V in {'main', 'HEAD'}:
|
||||
req = 'https://github.com/jupyterhub/jupyterhub/archive/HEAD.tar.gz'
|
||||
if version.startswith("git:"):
|
||||
ref = version.partition(":")[-1]
|
||||
req = f"https://github.com/jupyterhub/jupyterhub/archive/{ref}.tar.gz"
|
||||
else:
|
||||
version_info = [ int(part) for part in V.split('.') ]
|
||||
version_info[-1] += 1
|
||||
upper_bound = '.'.join(map(str, version_info))
|
||||
vs = '>=%s,<%s' % (V, upper_bound)
|
||||
req = 'jupyterhub%s' % vs
|
||||
req = f"jupyterhub=={version}"
|
||||
|
||||
check_call(pip_install + [req])
|
||||
|
Reference in New Issue
Block a user