Compare commits

..

79 Commits

Author SHA1 Message Date
Min RK
bda14b487a Bump to 2.0.0rc4 2021-11-18 15:33:12 +01:00
Min RK
fd5cf8c360 Merge pull request #3687 from minrk/rc4-changelog
update 2.0 changelog
2021-11-18 15:32:27 +01:00
Min RK
03758e5b46 update 2.0 changelog
prep for rc4
2021-11-18 14:50:10 +01:00
Erik Sundell
e540d143bb Merge pull request #3685 from minrk/session-id-model
Add Session id to token/identify models
2021-11-18 13:39:34 +01:00
Erik Sundell
b2c5ad40c5 Merge pull request #3686 from minrk/login_with_token
Hub: only accept tokens in API requests
2021-11-18 13:27:41 +01:00
Min RK
edfdf672d8 Hub: only accept tokens in API requests
do not allow token-based access to pages

Tokens are only accepted via Authorization header, which doesn't make sense to pass to pages,
so disallow it explicitly to avoid surprises
2021-11-18 09:36:49 +01:00
Min RK
39f19aef49 add session_id to token model 2021-11-17 09:46:26 +01:00
Min RK
8813bb63d4 update to openapi 3.0
easier to implement oneOf schemas

document scopes, session_id in /api/user model
2021-11-17 09:44:38 +01:00
Yuvi Panda
7c18d6fe14 Merge pull request #3681 from minrk/log-app-versions
Log single-user app versions at startup
2021-11-16 00:11:32 +05:30
Erik Sundell
d1fe17d3cb Merge pull request #3682 from minrk/relpath
always use relative paths in data_files
2021-11-08 14:06:20 +01:00
Min RK
b8965c2017 always use relative paths in data_files
instead of absolute paths for sources

seems to have started failing on Windows
2021-11-08 13:29:26 +01:00
Min RK
733d7bc158 Log single-user app versions at startup
Reports jupyterlab, jupyter_server versions during startup
2021-11-08 09:21:32 +01:00
Min RK
3caf3cfda8 Bump to 2.0.0rc3 2021-11-04 15:52:37 +01:00
Erik Sundell
d076c55cca Merge pull request #3679 from minrk/forward-1.5
Forward-port fixes from 1.5.0 security release
2021-11-04 15:30:04 +01:00
Min RK
3e185022c8 changelog for 1.5.0 2021-11-04 15:04:40 +01:00
Min RK
857ee2885f jupyterlab: don't use $JUPYTERHUB_API_TOKEN in PageConfig.token 2021-11-04 15:03:12 +01:00
Min RK
cd8dd56213 Revert "store tokens passed via url or header, not only url."
This reverts commit 53c3201c17.

Only tokens in URLs should be persisted in cookies.
Tokens in headers should not have any effect on cookies.
2021-11-04 15:03:12 +01:00
Erik Sundell
f06902aa8f Merge pull request #3675 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-11-02 01:56:07 +01:00
pre-commit-ci[bot]
bb109c6f75 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 21.9b0 → 21.10b0](https://github.com/psf/black/compare/21.9b0...21.10b0)
2021-11-01 20:25:25 +00:00
Erik Sundell
e525ec7b5b Merge pull request #3674 from minrk/verify-login-role
verify that successful login assigns default role
2021-10-30 17:50:01 +02:00
Min RK
356b98e19f verify that successful login assigns default role
and that repeated login after revoked role doesn't reassign role
2021-10-30 14:30:33 +02:00
Erik Sundell
8c803e7a53 Merge pull request #3673 from minrk/main
more calculators
2021-10-30 14:21:17 +02:00
Min RK
2e21a6f4e0 more calculators 2021-10-30 14:07:04 +02:00
Min RK
cfd31b14e3 Bump to 2.0.0rc2 2021-10-30 13:36:54 +02:00
Erik Sundell
f03a620424 Merge pull request #3672 from minrk/prerelease
use v2 of jupyterhub/action-major-minor-tag-calculator
2021-10-30 13:29:43 +02:00
Min RK
440ad77ad5 use v2 of jupyterhub/action-major-minor-tag-calculator
needed for handling of prerelease tags
2021-10-30 12:42:29 +02:00
Min RK
68835e97a2 Bump to 2.0.0rc1 2021-10-30 12:37:39 +02:00
Min RK
ce80c9c9cf Merge pull request #3669 from minrk/bumpversion
use tbump to tag versions
2021-10-30 12:34:28 +02:00
Min RK
3c299fbfb7 use tbump for bumping versions
avoids needing to manually keep files in sync,
and dramatically reduces RELEASE steps
2021-10-30 12:18:14 +02:00
Min RK
597f8ea6eb Merge pull request #3670 from manics/support-bot
Add support-bot
2021-10-30 12:17:47 +02:00
Erik Sundell
d1181085bf Merge pull request #3665 from minrk/openapi-test
Tests for our openapi spec
2021-10-29 16:05:05 +02:00
Simon Li
913832da48 Add support-bot
The old support-bot was disabled https://github.com/jupyterhub/.github/issues/15
This is the recommended replacement https://github.com/dessant/support-requests/issues/8
2021-10-29 14:09:49 +01:00
Min RK
42f57f4a72 Merge pull request #3662 from minrk/2.0rc-changelog
changelog for 2.0 release candidate
2021-10-29 13:40:34 +02:00
Min RK
d01a518c41 add updating rest-api version to release
and make real release checklist, to match other repos
2021-10-29 13:13:41 +02:00
Min RK
65ce06b116 test feedback
use global PYTEST_ARGS for nicer, simpler always-on arguments for pytest
2021-10-29 13:13:41 +02:00
Min RK
468aa5e93c render openapi spec client-side
- move spec to _static/rest-api.yml, since the original yaml must be served
- copy javascript rendering code from FastAPI (uses swagger-ui)
- remove link to pet store, since there isn't a big enough difference to duplicate it
- remove bootprint rendering with node
2021-10-29 13:13:41 +02:00
Min RK
5c01370e6f set version as long as we are rewriting the file 2021-10-29 13:13:41 +02:00
Min RK
21d08883a8 resolve rest-api validation errors
- regen scopes by running generate-scopes.py
2021-10-29 13:13:41 +02:00
Min RK
59de506f20 validate the rest-api
both with github action that runs openapi validation,
and a couple local tests to verify some maintenance tasks are done
2021-10-29 13:13:41 +02:00
Erik Sundell
b34120ed81 Merge pull request #3663 from minrk/clarify-default-roles
clarify some log messages during role assignment
2021-10-29 12:19:45 +02:00
Erik Sundell
617978179d Merge pull request #3667 from minrk/autodoc-traits
use stable autodoc-traits
2021-10-29 12:16:30 +02:00
Min RK
0985d6fdf2 use stable autodoc-traits 2021-10-29 11:32:02 +02:00
Min RK
2049fb0491 clarify some log messages during role assignment
and only commit on change
2021-10-29 11:22:12 +02:00
Erik Sundell
a58fc6534b Merge pull request #3664 from minrk/create-groups-on-startup
create groups declared in roles
2021-10-28 03:30:25 +02:00
Min RK
a14f97b7aa create groups declared in roles
matches behavior of users
2021-10-27 21:00:49 +02:00
Min RK
0a4cd5b4f2 add auto-changelog for 2.0rc 2021-10-27 16:22:43 +02:00
Min RK
dca6d372df Merge pull request #3661 from minrk/owner-metascope
Rename 'all' metascope to more descriptive 'inherit'
2021-10-27 16:20:29 +02:00
pre-commit-ci[bot]
3898c72921 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2021-10-27 14:01:05 +00:00
Min RK
b25517efe8 Rename 'all' metascope to more descriptive 'inherit'
since it means 'inheriting' the owner's permissions

'all' prompted the question 'all of what, exactly?'

Additionally, fix some NameErrors that should have been KeyErrors
2021-10-27 16:00:21 +02:00
Erik Sundell
392dffd11e Merge pull request #3659 from minrk/deprecate-admin-only
deprecate instead of remove `@admin_only` auth decorator
2021-10-25 23:32:01 +02:00
Erik Sundell
510f6ea7e6 Merge pull request #3660 from minrk/scope-error
minor refinement of excessive scopes error message
2021-10-25 15:53:43 +02:00
Min RK
296a0ad2f2 minor refinement of excessive scopes error message
show the role name
2021-10-25 14:10:57 +02:00
Min RK
487c4524ad deprecate instead of remove @admin_only auth decorator
no harm in keeping it around for a deprecation cycle
2021-10-25 13:00:45 +02:00
Erik Sundell
b2f0208fcc Merge pull request #3658 from minrk/better-timeout-message
improve timeout handling and messages
2021-10-20 22:15:26 +02:00
Min RK
84b9c3848c more detailed error messages for start timeouts
these are the most common error for any number of reasons spawn may fail
2021-10-20 20:08:34 +02:00
Min RK
9adbafdfb3 consistent handling of any timeout error
some things raise standard TimeoutError, others may raise tornado gen.TimeoutError (gen.with_timeout)

For consistency, add AnyTimeoutError tuple to allow catching any timeout, no matter what kind

Where we were raising `TimeoutError`,
we should have been raising `asyncio.TimeoutError`.

The base TimeoutError is an OSError for ETIMEO, which is for system calls
2021-10-20 20:07:45 +02:00
Erik Sundell
9cf2b5101e Merge pull request #3657 from edgarcosta/patch-1
docs: fix typo in proxy config example
2021-10-20 09:12:30 +02:00
Edgar Costa
725fa3a48a typo 2021-10-19 22:39:41 -04:00
Erik Sundell
534dda3dc7 Merge pull request #3653 from minrk/admin-no-such-user
raise 404 on admin attempt to spawn nonexistent user
2021-10-15 15:23:18 +02:00
Min RK
b0c7df04ac raise 404 on admin attempt to spawn nonexistent user 2021-10-15 14:40:47 +02:00
Min RK
61b0e8bef5 2.0.0b3 2021-10-14 12:49:20 +02:00
Erik Sundell
64f3938528 Merge pull request #3649 from minrk/cl-beta-3
add 424 status code change to changelog
2021-10-13 09:52:33 +02:00
Min RK
85bc92d88e Merge pull request #3646 from joegasewicz/joegasewicz-New-user-token-returns-200-instead-of-201-1
new user token returns 200 instead of 201
2021-10-13 09:24:30 +02:00
Min RK
7bcda18564 add 424 status code change to changelog 2021-10-13 09:17:47 +02:00
Erik Sundell
86da36857e Merge pull request #3647 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-10-12 00:13:22 +02:00
pre-commit-ci[bot]
530833e930 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/PyCQA/flake8: 3.9.2 → 4.0.1](https://github.com/PyCQA/flake8/compare/3.9.2...4.0.1)
2021-10-11 19:42:10 +00:00
Joe Gasewicz
3b0850fa9b Fixed test_roles 2021-10-07 23:14:10 +01:00
josefgasewicz
1366911be6 Fixed tests & set status after writing json 2021-10-07 22:21:16 +01:00
Joe Gasewicz
fe276eac64 Update users.py
New user token returns 200 instead of 201 Fixes #3642
2021-10-07 16:31:23 +01:00
Min RK
9209ccd0de Merge pull request #3636 from yuvipanda/404
Fail suspected API requests with 424, not 503
2021-10-05 15:16:18 +02:00
YuviPanda
3b2a1a37f9 Update tests that were looking for 503s 2021-10-05 18:10:52 +05:30
YuviPanda
6007ba78b0 Preserve older 503 behavior behind a flag 2021-10-05 17:56:51 +05:30
YuviPanda
9cb19cc342 Use 424 rather than 404 to indicate non-running server
404 is also used to identify that a particular resource
(like a kernel or terminal) is not present, maybe because
it is deleted. That comes from the notebook server, while
here we are responding from JupyterHub. Saying that the
user server they are trying to request the resource (kernel, etc)
from does not exist seems right.
2021-10-05 17:44:17 +05:30
YuviPanda
0f471f4e12 Fail suspected API requests with 404, not 503
Non-running user servers making requests is a fairly
common occurance - user servers get culled while their
browser tabs are left open. So we now have a background level
of 503s responses on the hub *all* the time, making it
very difficult to detect *real* 503s, which should ideally
be closely monitored and alerted on.

I *think* 404 is a more appropriate response, as the resource
(API) being requested is no longer present.
2021-10-05 03:00:16 +05:30
Erik Sundell
68db740998 Merge pull request #3635 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-10-04 22:38:05 +02:00
pre-commit-ci[bot]
9c0c6f25b7 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.28.0 → v2.29.0](https://github.com/asottile/pyupgrade/compare/v2.28.0...v2.29.0)
2021-10-04 19:48:13 +00:00
Min RK
5f0077cb5b Merge pull request #3445 from rpwagner/patch-1
Initial SECURITY.md
2021-09-29 09:42:59 +02:00
Rick Wagner
3610454a12 adding initial security policy 2021-06-01 09:51:20 -07:00
Rick Wagner
abc4bbebe4 Initial SECURITY.md
Proposing a basic security policy, similar to the README or contributors guide, based on the [GitHub documentation](https://docs.github.com/en/code-security/security-advisories/adding-a-security-policy-to-your-repository) and current Project Jupyter recommendations. This may be better as a [default file for the organization](https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions/creating-a-default-community-health-file).
2021-04-23 23:12:51 -07:00
49 changed files with 2162 additions and 1411 deletions

View File

@@ -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:"

31
.github/workflows/support-bot.yml vendored Normal file
View 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"

View File

@@ -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
@@ -182,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

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/asottile/pyupgrade
rev: v2.28.0
rev: v2.29.0
hooks:
- id: pyupgrade
args:
@@ -10,7 +10,7 @@ repos:
hooks:
- id: reorder-python-imports
- repo: https://github.com/psf/black
rev: 21.9b0
rev: 21.10b0
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-prettier
@@ -18,7 +18,7 @@ repos:
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

View File

@@ -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

View File

@@ -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
View 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
View File

@@ -0,0 +1,5 @@
# Reporting a Vulnerability
If you believe youve 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).

View File

@@ -14,6 +14,7 @@ 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

View File

@@ -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."

View File

@@ -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"
}
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -2,3 +2,9 @@
.navbar-brand {
height: 4rem !important;
}
/* hide redundant funky-formatted swagger-ui version */
.swagger-ui .info .title small {
display: none !important;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -215,7 +215,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 -------------------------------------------------------

View File

@@ -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

View File

@@ -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():

View File

@@ -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'],
}
]
```

View File

@@ -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/

View File

@@ -16,6 +16,7 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
proxy
separate-proxy
rest
rest-api
server-api
monitoring
database

View 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>

View File

@@ -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.

View File

@@ -302,12 +302,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

45
docs/test_docs.py Normal file
View 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,
)

View File

@@ -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,
"b2", # 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, "rc4", "")
# 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 = {}

View File

@@ -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": "",
}
]

View File

@@ -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,
}

View File

@@ -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))
@@ -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.")

View File

@@ -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:

View File

@@ -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)
@@ -1021,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:
@@ -1168,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)",
@@ -1357,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)
@@ -1376,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="")
@@ -1541,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(

View File

@@ -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

View File

@@ -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
@@ -214,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())
@@ -228,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"
)
@@ -322,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):
@@ -413,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 = {
@@ -432,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)
@@ -475,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)

View File

@@ -30,7 +30,7 @@ scope_definitions = {
'description': 'Your own resources',
'doc_description': 'The users 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)_',
},
@@ -295,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":
@@ -317,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(

View File

@@ -1023,8 +1023,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

View File

@@ -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

View File

@@ -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

View File

@@ -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'])

View File

@@ -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

View File

@@ -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

View File

@@ -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=[])
@@ -369,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),
],
)
@@ -410,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):
@@ -431,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'])
@@ -578,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}
@@ -598,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:
@@ -661,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
@@ -1330,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 == []

View File

@@ -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

View File

@@ -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

View File

@@ -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
@@ -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')

View File

@@ -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"""

View File

@@ -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.0rc4"
# 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}"

View File

@@ -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