Compare commits

...

34 Commits

Author SHA1 Message Date
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
40 changed files with 500 additions and 184 deletions

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

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

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;
}

View File

@@ -1,9 +1,11 @@
# see me at: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#/default
# see me at: https://jupyterhub.readthedocs.io/en/latest/reference/rest-api.html
swagger: "2.0"
info:
title: JupyterHub
description: The REST API for JupyterHub
version: 1.4.0
# version should match jupyterhub/_version.py
# `make scopes` ensures this is in sync
version: 2.0.0rc1
license:
name: BSD-3-Clause
schemes: [http, https]
@@ -15,14 +17,17 @@ securityDefinitions:
oauth2:
type: oauth2
flow: accessCode
authorizationUrl: "/hub/api/oauth2/authorize" # what are the absolute URIs here? is oauth2 correct here or shall we use just authorizations?
tokenUrl: "/hub/api/oauth2/token"
# these must be absolute until we update to openapi 3
authorizationUrl: "https://hub.example/hub/api/oauth2/authorize"
tokenUrl: "https://hub.example/hub/api/oauth2/token"
scopes: # Generated based on scope table in jupyterhub/scopes.py
(no_scope): Identify the owner of the requesting entity.
self:
The users own resources _(metascope for users, resolves to (no_scope)
for services)_
all: Everything that the token-owning entity can access _(metascope for tokens)_
inherit:
Everything that the token-owning entity can access _(metascope for
tokens)_
admin:users:
Read, write, create and delete users and their authentication state,
not including their servers or tokens.
@@ -30,6 +35,8 @@ securityDefinitions:
users:
Read and write permissions to user models (excluding servers, tokens
and authentication state).
delete:users: Delete users.
list:users: List users, including at least their names.
read:users:
Read user models (excluding including servers, tokens and authentication
state).
@@ -47,14 +54,18 @@ securityDefinitions:
read:servers:
Read users names and their server models (excluding the server
state).
delete:servers: Stop and delete users' servers.
tokens: Read, write, create and delete user tokens.
read:tokens: Read user tokens.
admin:groups: Read and write group information, create and delete groups.
groups:
Read and write group information, including adding/removing users to/from
groups.
list:groups: List groups, including at least their names.
read:groups: Read group models.
read:groups:name: Read group names.
delete:groups: Delete groups.
list:services: List services, including at least their names.
read:services: Read service models.
read:services:name: Read service names.
read:hub: Read detailed information about the Hub.
@@ -174,7 +185,7 @@ paths:
If unspecified, return all users.
- name: limit
in: query
requred: false
required: false
type: number
description: |
Return a finite number of users.
@@ -779,7 +790,7 @@ paths:
If unspecified, return all routes.
- name: limit
in: query
requred: false
required: false
type: number
description: |
Return a finite number of routes.
@@ -870,7 +881,7 @@ paths:
summary: Identify a user or service from an API token
security:
- oauth2:
- (noscope)
- (no_scope)
parameters:
- name: token
in: path

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,24 @@ 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['securityDefinitions']['oauth2']['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@3/swagger-ui-bundle.js"></script>
<!-- `SwaggerUIBundle` is now available on the page -->
<!-- render the ui here -->
<div id="openapi-ui"></div>
<script>
const ui = SwaggerUIBundle({
url: '../_static/rest-api.yml',
dom_id: '#openapi-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout",
deepLinking: true,
showExtensions: true,
showCommonExtensions: true,
});
</script>

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,
"b3", # 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, "rc1", "")
# 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

@@ -397,9 +397,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(
@@ -484,6 +486,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,
@@ -2069,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"
@@ -2108,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(
@@ -2117,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"
@@ -2350,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,
@@ -2428,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,
@@ -2792,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.",
@@ -3055,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
@@ -1021,7 +1022,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 +1169,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)",

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)_',
},
@@ -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

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

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

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

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.0rc1"
# Example of a semver regexp.
# Make sure this matches current_version before
# using tbump
regex = '''
(?P<major>\d+)
\.
(?P<minor>\d+)
\.
(?P<patch>\d+)
(?P<pre>((a|b|rc)\d+)|)
\.?
(?P<dev>(?<=\.)dev\d*|)
'''
[tool.tbump.git]
message_template = "Bump to {new_version}"
tag_template = "{new_version}"
# For each file to patch, add a [[tool.tbump.file]] config
# section containing the path of the file, relative to the
# pyproject.toml location.
[[tool.tbump.file]]
src = "jupyterhub/_version.py"
version_template = '({major}, {minor}, {patch}, "{pre}", "{dev}")'
search = "version_info = {current_version}"
[[tool.tbump.file]]
src = "docs/source/_static/rest-api.yml"
search = "version: {current_version}"