mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-08 18:44:10 +00:00
Compare commits
75 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
8892270c24 | ||
![]() |
b928df6cba | ||
![]() |
3fc74bd79e | ||
![]() |
b34be77fec | ||
![]() |
d991c06098 | ||
![]() |
01a67ba156 | ||
![]() |
8831573b6c | ||
![]() |
c5bc5411fb | ||
![]() |
a13ccd7530 | ||
![]() |
e9a744e8b7 | ||
![]() |
582d43c153 | ||
![]() |
7b5550928f | ||
![]() |
83920a3258 | ||
![]() |
d1670aa443 | ||
![]() |
c6f589124e | ||
![]() |
35991e5194 | ||
![]() |
b956190393 | ||
![]() |
122c989b7a | ||
![]() |
5602575099 | ||
![]() |
4534499aad | ||
![]() |
f733a91d7c | ||
![]() |
bf3fa30a01 | ||
![]() |
2625229847 | ||
![]() |
2c3eb6d0d6 | ||
![]() |
5ff98fd1a5 | ||
![]() |
f79b71727b | ||
![]() |
d3a3b8ca19 | ||
![]() |
df9e002b9a | ||
![]() |
a4a2c9d068 | ||
![]() |
c453e5ad20 | ||
![]() |
617b879c2a | ||
![]() |
a0042e9302 | ||
![]() |
6bbfcdfe4f | ||
![]() |
25662285af | ||
![]() |
84d12e8d72 | ||
![]() |
c317cbce36 | ||
![]() |
d279604fac | ||
![]() |
70fc4ef886 | ||
![]() |
24ff91eef5 | ||
![]() |
afc6789c74 | ||
![]() |
819e5e222a | ||
![]() |
e1a4f37bbc | ||
![]() |
a73477feed | ||
![]() |
89722ee2f3 | ||
![]() |
30d4b2cef4 | ||
![]() |
ca4fce7ffb | ||
![]() |
018b2daace | ||
![]() |
fd01165cf6 | ||
![]() |
34e4719893 | ||
![]() |
c6ac9e1d15 | ||
![]() |
70b8876239 | ||
![]() |
5e34f4481a | ||
![]() |
eae5594698 | ||
![]() |
f02022a00c | ||
![]() |
f964013516 | ||
![]() |
5f7ffaf1f6 | ||
![]() |
0e7ccb7520 | ||
![]() |
c9db504a49 | ||
![]() |
716677393e | ||
![]() |
ba8484f161 | ||
![]() |
ceec84dbb4 | ||
![]() |
f2a83ec846 | ||
![]() |
7deea6083a | ||
![]() |
a169ff3548 | ||
![]() |
f84a88da21 | ||
![]() |
eecec7183e | ||
![]() |
f11705ee26 | ||
![]() |
78ac5abf23 | ||
![]() |
2beeaa0932 | ||
![]() |
90cb8423bc | ||
![]() |
3b07bd286b | ||
![]() |
73564b97ea | ||
![]() |
65cad5efad | ||
![]() |
52eb627cd6 | ||
![]() |
506e568a9a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ node_modules
|
||||
/build
|
||||
dist
|
||||
docs/_build
|
||||
docs/build
|
||||
docs/source/_static/rest-api
|
||||
.ipynb_checkpoints
|
||||
# ignore config file at the top-level of the repo
|
||||
|
34
.travis.yml
34
.travis.yml
@@ -1,5 +1,7 @@
|
||||
language: python
|
||||
sudo: false
|
||||
cache:
|
||||
- pip
|
||||
python:
|
||||
- nightly
|
||||
- 3.6
|
||||
@@ -9,8 +11,8 @@ env:
|
||||
global:
|
||||
- ASYNC_TEST_TIMEOUT=15
|
||||
services:
|
||||
- mysql
|
||||
- postgresql
|
||||
- postgres
|
||||
- docker
|
||||
|
||||
# installing dependencies
|
||||
before_install:
|
||||
@@ -19,10 +21,12 @@ before_install:
|
||||
- npm install -g configurable-http-proxy
|
||||
- |
|
||||
if [[ $JUPYTERHUB_TEST_DB_URL == mysql* ]]; then
|
||||
mysql -e 'CREATE DATABASE jupyterhub CHARACTER SET utf8 COLLATE utf8_general_ci;'
|
||||
unset MYSQL_UNIX_PORT
|
||||
DB=mysql bash ci/docker-db.sh
|
||||
DB=mysql bash ci/init-db.sh
|
||||
pip install 'mysql-connector<2.2'
|
||||
elif [[ $JUPYTERHUB_TEST_DB_URL == postgresql* ]]; then
|
||||
psql -c 'create database jupyterhub;' -U postgres
|
||||
DB=postgres bash ci/init-db.sh
|
||||
pip install psycopg2
|
||||
fi
|
||||
install:
|
||||
@@ -32,6 +36,20 @@ install:
|
||||
|
||||
# running tests
|
||||
script:
|
||||
- |
|
||||
if [[ ! -z "$JUPYTERHUB_TEST_DB_URL" ]]; then
|
||||
# if testing upgrade-db, run `jupyterhub token` with 0.7
|
||||
# to initialize an old db. Used in upgrade-tests
|
||||
export JUPYTERHUB_TEST_UPGRADE_DB_URL=${JUPYTERHUB_TEST_DB_URL}_upgrade
|
||||
# use virtualenv instead of venv because venv doesn't work here
|
||||
python -m pip install virtualenv
|
||||
python -m virtualenv old-hub-env
|
||||
./old-hub-env/bin/python -m pip install jupyterhub==0.7.2 psycopg2 'mysql-connector<2.2'
|
||||
./old-hub-env/bin/jupyterhub token kaylee \
|
||||
--JupyterHub.db_url=$JUPYTERHUB_TEST_UPGRADE_DB_URL \
|
||||
--Authenticator.whitelist="{'kaylee'}" \
|
||||
--JupyterHub.authenticator_class=jupyterhub.auth.Authenticator
|
||||
fi
|
||||
- pytest -v --maxfail=2 --cov=jupyterhub jupyterhub/tests
|
||||
after_success:
|
||||
- codecov
|
||||
@@ -42,8 +60,12 @@ matrix:
|
||||
- python: 3.6
|
||||
env: JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://localhost.jovyan.org:8000
|
||||
- python: 3.6
|
||||
env: JUPYTERHUB_TEST_DB_URL=mysql+mysqlconnector://root@127.0.0.1/jupyterhub
|
||||
env:
|
||||
- MYSQL_HOST=127.0.0.1
|
||||
- MYSQL_TCP_PORT=13306
|
||||
- JUPYTERHUB_TEST_DB_URL=mysql+mysqlconnector://root@127.0.0.1:$MYSQL_TCP_PORT/jupyterhub
|
||||
- python: 3.6
|
||||
env: JUPYTERHUB_TEST_DB_URL=postgresql://postgres@127.0.0.1/jupyterhub
|
||||
env:
|
||||
- JUPYTERHUB_TEST_DB_URL=postgresql://postgres@127.0.0.1/jupyterhub
|
||||
allow_failures:
|
||||
- python: nightly
|
||||
|
@@ -11,6 +11,7 @@ graft jupyterhub
|
||||
graft scripts
|
||||
graft share
|
||||
graft singleuser
|
||||
graft ci
|
||||
|
||||
# Documentation
|
||||
graft docs
|
||||
|
50
ci/docker-db.sh
Normal file
50
ci/docker-db.sh
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# source this file to setup postgres and mysql
|
||||
# for local testing (as similar as possible to docker)
|
||||
|
||||
set -e
|
||||
|
||||
export MYSQL_HOST=127.0.0.1
|
||||
export MYSQL_TCP_PORT=${MYSQL_TCP_PORT:-13306}
|
||||
export PGHOST=127.0.0.1
|
||||
NAME="hub-test-$DB"
|
||||
DOCKER_RUN="docker run --rm -d --name $NAME"
|
||||
|
||||
docker rm -f "$NAME" 2>/dev/null || true
|
||||
|
||||
case "$DB" in
|
||||
"mysql")
|
||||
RUN_ARGS="-e MYSQL_ALLOW_EMPTY_PASSWORD=1 -p $MYSQL_TCP_PORT:3306 mysql:5.7"
|
||||
CHECK="mysql --host $MYSQL_HOST --port $MYSQL_TCP_PORT --user root -e \q"
|
||||
;;
|
||||
"postgres")
|
||||
RUN_ARGS="-p 5432:5432 postgres:9.5"
|
||||
CHECK="psql --user postgres -c \q"
|
||||
;;
|
||||
*)
|
||||
echo '$DB must be mysql or postgres'
|
||||
exit 1
|
||||
esac
|
||||
|
||||
$DOCKER_RUN $RUN_ARGS
|
||||
|
||||
echo -n "waiting for $DB "
|
||||
for i in {1..60}; do
|
||||
if $CHECK; then
|
||||
echo 'done'
|
||||
break
|
||||
else
|
||||
echo -n '.'
|
||||
sleep 1
|
||||
fi
|
||||
done
|
||||
$CHECK
|
||||
|
||||
|
||||
echo -e "
|
||||
Set these environment variables:
|
||||
|
||||
export MYSQL_HOST=127.0.0.1
|
||||
export MYSQL_TCP_PORT=$MYSQL_TCP_PORT
|
||||
export PGHOST=127.0.0.1
|
||||
"
|
27
ci/init-db.sh
Normal file
27
ci/init-db.sh
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# initialize jupyterhub databases for testing
|
||||
|
||||
set -e
|
||||
|
||||
MYSQL="mysql --user root --host $MYSQL_HOST --port $MYSQL_TCP_PORT -e "
|
||||
PSQL="psql --user postgres -c "
|
||||
|
||||
case "$DB" in
|
||||
"mysql")
|
||||
EXTRA_CREATE='CHARACTER SET utf8 COLLATE utf8_general_ci'
|
||||
SQL="$MYSQL"
|
||||
;;
|
||||
"postgres")
|
||||
SQL="$PSQL"
|
||||
;;
|
||||
*)
|
||||
echo '$DB must be mysql or postgres'
|
||||
exit 1
|
||||
esac
|
||||
|
||||
set -x
|
||||
|
||||
$SQL 'DROP DATABASE jupyterhub;' 2>/dev/null || true
|
||||
$SQL "CREATE DATABASE jupyterhub ${EXTRA_CREATE};"
|
||||
$SQL 'DROP DATABASE jupyterhub_upgrade;' 2>/dev/null || true
|
||||
$SQL "CREATE DATABASE jupyterhub_upgrade ${EXTRA_CREATE};"
|
@@ -203,6 +203,43 @@ paths:
|
||||
description: The user's notebook server has stopped
|
||||
'202':
|
||||
description: The user's notebook server has not yet stopped as it is taking a while to stop
|
||||
/users/{name}/servers/{server_name}:
|
||||
post:
|
||||
summary: Start a user's single-user named-server notebook server
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
- name: server_name
|
||||
description: name given to a named-server
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: The user's notebook named-server has started
|
||||
'202':
|
||||
description: The user's notebook named-server has not yet started, but has been requested
|
||||
delete:
|
||||
summary: Stop a user's named-server
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
- name: server_name
|
||||
description: name given to a named-server
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: The user's notebook named-server has stopped
|
||||
'202':
|
||||
description: The user's notebook named-server has not yet stopped as it is taking a while to stop
|
||||
/users/{name}/admin-access:
|
||||
post:
|
||||
summary: Grant admin access to this user's notebook server
|
||||
|
@@ -17,7 +17,7 @@ Module: :mod:`jupyterhub.services.auth`
|
||||
:members:
|
||||
|
||||
:class:`HubOAuth`
|
||||
----------------
|
||||
-----------------
|
||||
|
||||
.. autoconfigurable:: HubOAuth
|
||||
:members:
|
||||
@@ -30,7 +30,7 @@ Module: :mod:`jupyterhub.services.auth`
|
||||
:members:
|
||||
|
||||
:class:`HubOAuthenticated`
|
||||
-------------------------
|
||||
--------------------------
|
||||
|
||||
.. autoclass:: HubOAuthenticated
|
||||
|
||||
|
@@ -5,7 +5,9 @@ its link will bring up a GitHub listing of changes. Use `git log` on the
|
||||
command line for details.
|
||||
|
||||
|
||||
## [Unreleased] 0.8
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.0] 2017-10-03
|
||||
|
||||
JupyterHub 0.8 is a big release!
|
||||
|
||||
@@ -23,7 +25,7 @@ in your Dockerfile is sufficient.
|
||||
|
||||
#### Added
|
||||
|
||||
- JupyterHub now defined a `.Proxy` API for custom
|
||||
- JupyterHub now defined a `Proxy` API for custom
|
||||
proxy implementations other than the default.
|
||||
The defaults are unchanged,
|
||||
but configuration of the proxy is now done on the `ConfigurableHTTPProxy` class instead of the top-level JupyterHub.
|
||||
@@ -32,7 +34,7 @@ in your Dockerfile is sufficient.
|
||||
(anything that uses HubAuth)
|
||||
can now accept token-authenticated requests via the Authentication header.
|
||||
- Authenticators can now store state in the Hub's database.
|
||||
To do so, the `.authenticate` method should return a dict of the form
|
||||
To do so, the `authenticate` method should return a dict of the form
|
||||
|
||||
```python
|
||||
{
|
||||
@@ -233,7 +235,8 @@ Fix removal of `/login` page in 0.4.0, breaking some OAuth providers.
|
||||
First preview release
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jupyterhub/jupyterhub/compare/0.7.2...HEAD
|
||||
[Unreleased]: https://github.com/jupyterhub/jupyterhub/compare/0.8.0...HEAD
|
||||
[0.8.0]: https://github.com/jupyterhub/jupyterhub/compare/0.7.2...0.8.0
|
||||
[0.7.2]: https://github.com/jupyterhub/jupyterhub/compare/0.7.1...0.7.2
|
||||
[0.7.1]: https://github.com/jupyterhub/jupyterhub/compare/0.7.0...0.7.1
|
||||
[0.7.0]: https://github.com/jupyterhub/jupyterhub/compare/0.6.1...0.7.0
|
||||
|
@@ -3,6 +3,7 @@
|
||||
Project Jupyter thanks the following people for their help and
|
||||
contribution on JupyterHub:
|
||||
|
||||
- Analect
|
||||
- anderbubble
|
||||
- apetresc
|
||||
- barrachri
|
||||
|
@@ -84,6 +84,7 @@ class DictionaryAuthenticator(Authenticator):
|
||||
return data['username']
|
||||
```
|
||||
|
||||
|
||||
#### Normalize usernames
|
||||
|
||||
Since the Authenticator and Spawner both use the same username,
|
||||
@@ -116,6 +117,7 @@ To only allow usernames that start with 'w':
|
||||
c.Authenticator.username_pattern = r'w.*'
|
||||
```
|
||||
|
||||
|
||||
### How to write a custom authenticator
|
||||
|
||||
You can use custom Authenticator subclasses to enable authentication
|
||||
@@ -123,6 +125,11 @@ via other mechanisms. One such example is using [GitHub OAuth][].
|
||||
|
||||
Because the username is passed from the Authenticator to the Spawner,
|
||||
a custom Authenticator and Spawner are often used together.
|
||||
For example, the Authenticator methods, [pre_spawn_start(user, spawner)][]
|
||||
and [post_spawn_stop(user, spawner)][], are hooks that can be used to do
|
||||
auth-related startup (e.g. opening PAM sessions) and cleanup
|
||||
(e.g. closing PAM sessions).
|
||||
|
||||
|
||||
See a list of custom Authenticators [on the wiki](https://github.com/jupyterhub/jupyterhub/wiki/Authenticators).
|
||||
|
||||
@@ -130,6 +137,77 @@ If you are interested in writing a custom authenticator, you can read
|
||||
[this tutorial](http://jupyterhub-tutorial.readthedocs.io/en/latest/authenticators.html).
|
||||
|
||||
|
||||
### Authentication state
|
||||
|
||||
JupyterHub 0.8 adds the ability to persist state related to authentication,
|
||||
such as auth-related tokens.
|
||||
If such state should be persisted, `.authenticate()` should return a dictionary of the form:
|
||||
|
||||
```python
|
||||
{
|
||||
'username': 'name',
|
||||
'auth_state': {
|
||||
'key': 'value',
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
where `username` is the username that has been authenticated,
|
||||
and `auth_state` is any JSON-serializable dictionary.
|
||||
|
||||
Because `auth_state` may contain sensitive information,
|
||||
it is encrypted before being stored in the database.
|
||||
To store auth_state, two conditions must be met:
|
||||
|
||||
1. persisting auth state must be enabled explicitly via configuration
|
||||
```python
|
||||
c.Authenticator.enable_auth_state = True
|
||||
```
|
||||
2. encryption must be enabled by the presence of `JUPYTERHUB_CRYPT_KEY` environment variable,
|
||||
which should be a hex-encoded 32-byte key.
|
||||
For example:
|
||||
```bash
|
||||
export JUPYTERHUB_CRYPT_KEY=$(openssl rand -hex 32)
|
||||
```
|
||||
|
||||
|
||||
JupyterHub uses [Fernet](https://cryptography.io/en/latest/fernet/) to encrypt auth_state.
|
||||
To facilitate key-rotation, `JUPYTERHUB_CRYPT_KEY` may be a semicolon-separated list of encryption keys.
|
||||
If there are multiple keys present, the **first** key is always used to persist any new auth_state.
|
||||
|
||||
|
||||
#### Using auth_state
|
||||
|
||||
Typically, if `auth_state` is persisted it is desirable to affect the Spawner environment in some way.
|
||||
This may mean defining environment variables, placing certificate in the user's home directory, etc.
|
||||
The `Authenticator.pre_spawn_start` method can be used to pass information from authenticator state
|
||||
to Spawner environment:
|
||||
|
||||
```python
|
||||
class MyAuthenticator(Authenticator):
|
||||
@gen.coroutine
|
||||
def authenticate(self, handler, data=None):
|
||||
username = yield identify_user(handler, data)
|
||||
upstream_token = yield token_for_user(username)
|
||||
return {
|
||||
'username': username,
|
||||
'auth_state': {
|
||||
'upstream_token': upstream_token,
|
||||
},
|
||||
}
|
||||
|
||||
@gen.coroutine
|
||||
def pre_spawn_start(self, user, spawner):
|
||||
"""Pass upstream_token to spawner via environment variable"""
|
||||
auth_state = yield user.get_auth_state()
|
||||
if not auth_state:
|
||||
# auth_state not enabled
|
||||
return
|
||||
spawner.environment['UPSTREAM_TOKEN'] = auth_state['upstream_token']
|
||||
```
|
||||
|
||||
|
||||
|
||||
## JupyterHub as an OAuth provider
|
||||
|
||||
Beginning with version 0.8, JupyterHub is an OAuth provider.
|
||||
@@ -140,3 +218,5 @@ Beginning with version 0.8, JupyterHub is an OAuth provider.
|
||||
[OAuth]: https://en.wikipedia.org/wiki/OAuth
|
||||
[GitHub OAuth]: https://developer.github.com/v3/oauth/
|
||||
[OAuthenticator]: https://github.com/jupyterhub/oauthenticator
|
||||
[pre_spawn_start(user, spawner)]: http://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.pre_spawn_start
|
||||
[post_spawn_stop(user, spawner)]: http://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.post_spawn_stop
|
||||
|
@@ -80,7 +80,7 @@ export CONFIGPROXY_AUTH_TOKEN=super-secret
|
||||
jupyterhub -f /etc/jupyterhub/jupyterhub_config.py &>> /var/log/jupyterhub.log
|
||||
```
|
||||
|
||||
## Using nginx reverse proxy
|
||||
## Using a reverse proxy
|
||||
|
||||
In the following example, we show configuration files for a JupyterHub server
|
||||
running locally on port `8000` but accessible from the outside on the standard
|
||||
@@ -91,9 +91,9 @@ satisfy the following:
|
||||
* JupyterHub is running on a server, accessed *only* via `HUB.DOMAIN.TLD:443`
|
||||
* On the same machine, `NO_HUB.DOMAIN.TLD` strictly serves different content,
|
||||
also on port `443`
|
||||
* `nginx` is used to manage the web servers / reverse proxy (which means that
|
||||
only nginx will be able to bind two servers to `443`)
|
||||
* After testing, the server in question should be able to score an A+ on the
|
||||
* `nginx` or `apache` is used as the public access point (which means that
|
||||
only nginx/apache will bind to `443`)
|
||||
* After testing, the server in question should be able to score at least an A on the
|
||||
Qualys SSL Labs [SSL Server Test](https://www.ssllabs.com/ssltest/)
|
||||
|
||||
Let's start out with needed JupyterHub configuration in `jupyterhub_config.py`:
|
||||
@@ -103,10 +103,27 @@ Let's start out with needed JupyterHub configuration in `jupyterhub_config.py`:
|
||||
c.JupyterHub.ip = '127.0.0.1'
|
||||
```
|
||||
|
||||
For high-quality SSL configuration, we also generate Diffie-Helman parameters.
|
||||
This can take a few minutes:
|
||||
|
||||
```bash
|
||||
openssl dhparam -out /etc/ssl/certs/dhparam.pem 4096
|
||||
```
|
||||
|
||||
### nginx
|
||||
|
||||
The **`nginx` server config file** is fairly standard fare except for the two
|
||||
`location` blocks within the `HUB.DOMAIN.TLD` config file:
|
||||
|
||||
```bash
|
||||
# top-level http config for websocket headers
|
||||
# If Upgrade is defined, Connection = upgrade
|
||||
# If Upgrade is empty, Connection = close
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
# HTTP server to redirect all 80 traffic to SSL/HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
@@ -138,35 +155,26 @@ server {
|
||||
|
||||
# Managing literal requests to the JupyterHub front end
|
||||
location / {
|
||||
proxy_pass https://127.0.0.1:8000;
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Managing WebHook/Socket requests between hub user servers and external proxy
|
||||
location ~* /(api/kernels/[^/]+/(channels|iopub|shell|stdin)|terminals/websocket)/? {
|
||||
proxy_pass https://127.0.0.1:8000;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
# websocket headers
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
}
|
||||
|
||||
# Managing requests to verify letsencrypt host
|
||||
location ~ /.well-known {
|
||||
allow all;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
If `nginx` is not running on port 443, substitute `$http_host` for `$host` on
|
||||
the lines setting the `Host` header.
|
||||
|
||||
`nginx` will now be the front facing element of JupyterHub on `443` which means
|
||||
it is also free to bind other servers, like `NO_HUB.DOMAIN.TLD` to the same port
|
||||
on the same machine and network interface. In fact, one can simply use the same
|
||||
@@ -187,6 +195,7 @@ server {
|
||||
ssl on;
|
||||
|
||||
# INSERT OTHER SSL PARAMETERS HERE AS ABOVE
|
||||
# SSL cert may differ
|
||||
|
||||
# Set the appropriate root directory
|
||||
root /var/www/html
|
||||
@@ -204,6 +213,60 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
Now just restart `nginx`, restart the JupyterHub, and enjoy accessing
|
||||
Now restart `nginx`, restart the JupyterHub, and enjoy accessing
|
||||
`https://HUB.DOMAIN.TLD` while serving other content securely on
|
||||
`https://NO_HUB.DOMAIN.TLD`.
|
||||
|
||||
|
||||
### Apache
|
||||
|
||||
As with nginx above, you can use [Apache](https://httpd.apache.org) as the reverse proxy.
|
||||
First, we will need to enable the apache modules that we are going to need:
|
||||
|
||||
```bash
|
||||
a2enmod ssl rewrite proxy proxy_http proxy_wstunnel
|
||||
```
|
||||
|
||||
Our Apache configuration is equivalent to the nginx configuration above:
|
||||
|
||||
- Redirect HTTP to HTTPS
|
||||
- Good SSL Configuration
|
||||
- Support for websockets on any proxied URL
|
||||
- JupyterHub is running locally at http://127.0.0.1:8000
|
||||
|
||||
```bash
|
||||
# redirect HTTP to HTTPS
|
||||
Listen 80
|
||||
<VirtualHost HUB.DOMAIN.TLD:80>
|
||||
ServerName HUB.DOMAIN.TLD
|
||||
Redirect / https://HUB.DOMAIN.TLD/
|
||||
</VirtualHost>
|
||||
|
||||
Listen 443
|
||||
<VirtualHost HUB.DOMAIN.TLD:443>
|
||||
|
||||
ServerName HUB.DOMAIN.TLD
|
||||
|
||||
# configure SSL
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem
|
||||
SSLProtocol All -SSLv2 -SSLv3
|
||||
SSLOpenSSLConfCmd DHParameters /etc/ssl/certs/dhparam.pem
|
||||
SSLCipherSuite EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH
|
||||
|
||||
# Use RewriteEngine to handle websocket connection upgrades
|
||||
RewriteEngine On
|
||||
RewriteCond %{HTTP:Connection} Upgrade [NC]
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteRule /(.*) ws://127.0.0.1:8000/$1 [P,L]
|
||||
|
||||
<Location "/">
|
||||
# preserve Host header to avoid cross-origin problems
|
||||
ProxyPreserveHost on
|
||||
# proxy to JupyterHub
|
||||
ProxyPass http://127.0.0.1:8000/
|
||||
ProxyPassReverse http://127.0.0.1:8000/
|
||||
</Location>
|
||||
</VirtualHost>
|
||||
```
|
||||
|
@@ -9,6 +9,7 @@ Technical Reference
|
||||
authenticators
|
||||
spawners
|
||||
services
|
||||
proxy
|
||||
rest
|
||||
upgrading
|
||||
config-examples
|
||||
|
183
docs/source/reference/proxy.md
Normal file
183
docs/source/reference/proxy.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Writing a custom Proxy implementation
|
||||
|
||||
JupyterHub 0.8 introduced the ability to write a custom implementation of the proxy.
|
||||
This enables deployments with different needs than the default proxy,
|
||||
configurable-http-proxy (CHP).
|
||||
CHP is a single-process nodejs proxy that they Hub manages by default as a subprocess
|
||||
(it can be run externally, as well, and typically is in production deployments).
|
||||
|
||||
The upside to CHP, and why we use it by default, is that it's easy to install and run (if you have nodejs, you are set!).
|
||||
The downsides are that it's a single process and does not support any persistence of the routing table.
|
||||
So if the proxy process dies, your whole JupyterHub instance is inaccessible until the Hub notices, restarts the proxy, and restores the routing table.
|
||||
For deployments that want to avoid such a single point of failure,
|
||||
or leverage existing proxy infrastructure in their chosen deployment (such as Kubernetes ingress objects),
|
||||
the Proxy API provides a way to do that.
|
||||
|
||||
In general, for a proxy to be usable by JupyterHub, it must:
|
||||
|
||||
1. support websockets without prior knowledge of the URL where websockets may occur
|
||||
2. support trie-based routing (i.e. allow different routes on `/foo` and `/foo/bar` and route based on specificity)
|
||||
3. adding or removing a route should not cause existing connections to drop
|
||||
|
||||
Optionally, if the JupyterHub deployment is to use host-based routing,
|
||||
the Proxy must additionally support routing based on the Host of the request.
|
||||
|
||||
## Subclassing Proxy
|
||||
|
||||
To start, any Proxy implementation should subclass the base Proxy class,
|
||||
as is done with custom Spawners and Authenticators.
|
||||
|
||||
```python
|
||||
from jupyterhub.proxy import Proxy
|
||||
|
||||
class MyProxy(Proxy):
|
||||
"""My Proxy implementation"""
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
## Starting and stopping the proxy
|
||||
|
||||
If your proxy should be launched when the Hub starts, you must define how to start and stop your proxy:
|
||||
|
||||
```python
|
||||
from tornado import gen
|
||||
class MyProxy(Proxy):
|
||||
...
|
||||
@gen.coroutine
|
||||
def start(self):
|
||||
"""Start the proxy"""
|
||||
|
||||
@gen.coroutine
|
||||
def stop(self):
|
||||
"""Stop the proxy"""
|
||||
```
|
||||
|
||||
These methods **may** be coroutines.
|
||||
|
||||
`c.Proxy.should_start` is a configurable flag that determines whether the Hub should call these methods when the Hub itself starts and stops.
|
||||
|
||||
|
||||
### Purely external proxies
|
||||
|
||||
Probably most custom proxies will be externally managed,
|
||||
such as Kubernetes ingress-based implementations.
|
||||
In this case, you do not need to define `start` and `stop`.
|
||||
To disable the methods, you can define `should_start = False` at the class level:
|
||||
|
||||
```python
|
||||
class MyProxy(Proxy):
|
||||
should_start = False
|
||||
```
|
||||
|
||||
|
||||
## Adding and removing routes
|
||||
|
||||
At its most basic, a Proxy implementation defines a mechanism to add, remove, and retrieve routes.
|
||||
A proxy that implements these three methods is complete.
|
||||
Each of these methods **may** be a coroutine.
|
||||
|
||||
**Definition:** routespec
|
||||
|
||||
A routespec, which will appear in these methods, is a string describing a route to be proxied,
|
||||
such as `/user/name/`. A routespec will:
|
||||
|
||||
1. always end with `/`
|
||||
2. always start with `/` if it is a path-based route `/proxy/path/`
|
||||
3. precede the leading `/` with a host for host-based routing, e.g. `host.tld/proxy/path/`
|
||||
|
||||
|
||||
### Adding a route
|
||||
|
||||
When adding a route, JupyterHub may pass a JSON-serializable dict as a `data` argument
|
||||
that should be attacked to the proxy route.
|
||||
When that route is retrieved, the `data` argument should be returned as well.
|
||||
If your proxy implementation doesn't support storing data attached to routes,
|
||||
then your Python wrapper may have to handle storing the `data` piece itself,
|
||||
e.g in a simple file or database.
|
||||
|
||||
```python
|
||||
@gen.coroutine
|
||||
def add_route(self, routespec, target, data):
|
||||
"""Proxy `routespec` to `target`.
|
||||
|
||||
Store `data` associated with the routespec
|
||||
for retrieval later.
|
||||
"""
|
||||
```
|
||||
|
||||
Adding a route for a user looks like this:
|
||||
|
||||
```python
|
||||
proxy.add_route('/user/pgeorgiou/', 'http://127.0.0.1:1227',
|
||||
{'user': 'pgeorgiou'})
|
||||
```
|
||||
|
||||
|
||||
### Removing routes
|
||||
|
||||
`delete_route()` is given a routespec to delete.
|
||||
If there is no such route, `delete_route` should still succeed,
|
||||
but a warning may be issued.
|
||||
|
||||
```python
|
||||
@gen.coroutine
|
||||
def delete_route(self, routespec):
|
||||
"""Delete the route"""
|
||||
```
|
||||
|
||||
|
||||
### Retrieving routes
|
||||
|
||||
For retrieval, you only *need* to implement a single method that retrieves all routes.
|
||||
The return value for this function should be a dictionary, keyed by `routespect`,
|
||||
of dicts whose keys are the same three arguments passed to `add_route`
|
||||
(`routespec`, `target`, `data`)
|
||||
|
||||
```python
|
||||
@gen.coroutine
|
||||
def get_all_routes(self):
|
||||
"""Return all routes, keyed by routespec""""
|
||||
```
|
||||
|
||||
```python
|
||||
{
|
||||
'/proxy/path/': {
|
||||
'routespec': '/proxy/path/',
|
||||
'target': 'http://...',
|
||||
'data': {},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### Note on activity tracking
|
||||
|
||||
JupyterHub can track activity of users, for use in services such as culling idle servers.
|
||||
As of JupyterHub 0.8, this activity tracking is the responsibility of the proxy.
|
||||
If your proxy implementation can track activity to endpoints,
|
||||
it may add a `last_activity` key to the `data` of routes retrieved in `.get_all_routes()`.
|
||||
If present, the value of `last_activity` should be an [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) UTC date string:
|
||||
|
||||
```python
|
||||
{
|
||||
'/user/pgeorgiou/': {
|
||||
'routespec': '/user/pgeorgiou/',
|
||||
'target': 'http://127.0.0.1:1227',
|
||||
'data': {
|
||||
'user': 'pgeourgiou',
|
||||
'last_activity': '2017-10-03T10:33:49.570Z',
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
If the proxy does not track activity, then only activity to the Hub itself is tracked,
|
||||
and services such as cull-idle will not work.
|
||||
|
||||
Now that `notebook-5.0` tracks activity internally,
|
||||
we can retrieve activity information from the single-user servers instead,
|
||||
removing the need to track activity in the proxy.
|
||||
But this is not yet implemented in JupyterHub 0.8.0.
|
@@ -119,6 +119,55 @@ token does **not** authorize access to the [Jupyter Notebook REST API][]
|
||||
provided by notebook servers managed by JupyterHub. A different token is used
|
||||
to access the **Jupyter Notebook** API.
|
||||
|
||||
## Enabling users to spawn multiple named-servers via the API
|
||||
|
||||
With JupyterHub version 0.8, support for multiple servers per user has landed.
|
||||
Prior to that, each user could only launch a single default server via the API
|
||||
like this:
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: token <token>" "http://127.0.0.1:8081/hub/api/users/<user>/server"
|
||||
```
|
||||
|
||||
With the named-server functionality, it's now possible to launch more than one
|
||||
specifically named servers against a given user. This could be used, for instance,
|
||||
to launch each server based on a different image.
|
||||
|
||||
First you must enable named-servers by including the following setting in the `jupyterhub_config.py` file.
|
||||
|
||||
`c.JupyterHub.allow_named_servers = True`
|
||||
|
||||
If using the [zero-to-jupyterhub-k8s](https://github.com/jupyterhub/zero-to-jupyterhub-k8s) set-up to run JupyterHub,
|
||||
then instead of editing the `jupyterhub_config.py` file directly, you could pass
|
||||
the following as part of the `config.yaml` file, as per the [tutorial](https://zero-to-jupyterhub.readthedocs.io/en/latest/):
|
||||
|
||||
```bash
|
||||
hub:
|
||||
extraConfig: |
|
||||
c.JupyterHub.allow_named_servers = True
|
||||
```
|
||||
|
||||
With that setting in place, a new named-server is activated like this:
|
||||
```bash
|
||||
curl -X POST -H "Authorization: token <token>" "http://127.0.0.1:8081/hub/api/users/<user>/servers/<serverA>"
|
||||
curl -X POST -H "Authorization: token <token>" "http://127.0.0.1:8081/hub/api/users/<user>/servers/<serverB>"
|
||||
```
|
||||
|
||||
The same servers can be stopped by substituting `DELETE` for `POST` above.
|
||||
|
||||
### Some caveats for using named-servers
|
||||
|
||||
The named-server capabilities are not fully implemented for JupyterHub as yet.
|
||||
While it's possible to start/stop a server via the API, the UI on the
|
||||
JupyterHub control-panel has not been implemented, and so it may not be obvious
|
||||
to those viewing the panel that a named-server may be running for a given user.
|
||||
|
||||
For named-servers via the API to work, the spawner used to spawn these servers
|
||||
will need to be able to handle the case of multiple servers per user and ensure
|
||||
uniqueness of names, particularly if servers are spawned via docker containers
|
||||
or kubernetes pods.
|
||||
|
||||
|
||||
## Learn more about the API
|
||||
|
||||
You can see the full [JupyterHub REST API][] for details. This REST API Spec can
|
||||
|
@@ -200,7 +200,9 @@ or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
||||
|
||||
Most of the logic for authentication implementation is found in the
|
||||
[`HubAuth.user_for_cookie`](services.auth.html#jupyterhub.services.auth.HubAuth.user_for_cookie)
|
||||
method, which makes a request of the Hub, and returns:
|
||||
and in the
|
||||
[`HubAuth.user_for_token`](services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token)
|
||||
methods, which makes a request of the Hub, and returns:
|
||||
|
||||
- None, if no user could be identified, or
|
||||
- a dict of the following form:
|
||||
@@ -252,8 +254,11 @@ def authenticated(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
cookie = request.cookies.get(auth.cookie_name)
|
||||
token = request.headers.get(auth.auth_header_name)
|
||||
if cookie:
|
||||
user = auth.user_for_cookie(cookie)
|
||||
elif token:
|
||||
user = auth.user_for_token(token)
|
||||
else:
|
||||
user = None
|
||||
if user:
|
||||
|
@@ -28,8 +28,11 @@ def authenticated(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
cookie = request.cookies.get(auth.cookie_name)
|
||||
token = request.headers.get(auth.auth_header_name)
|
||||
if cookie:
|
||||
user = auth.user_for_cookie(cookie)
|
||||
elif token:
|
||||
user = auth.user_for_token(token)
|
||||
else:
|
||||
user = None
|
||||
if user:
|
||||
|
@@ -59,7 +59,7 @@ def oauth_callback():
|
||||
# validate state field
|
||||
arg_state = request.args.get('state', None)
|
||||
cookie_state = request.cookies.get(auth.state_cookie_name)
|
||||
if arg_state != cookie_state:
|
||||
if arg_state is None or arg_state != cookie_state:
|
||||
# state doesn't match
|
||||
return 403
|
||||
|
||||
|
@@ -7,7 +7,6 @@ version_info = (
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
'b5',
|
||||
)
|
||||
|
||||
__version__ = '.'.join(map(str, version_info))
|
||||
|
@@ -12,9 +12,16 @@ config = context.config
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if 'jupyterhub' in sys.modules:
|
||||
from traitlets.config import MultipleInstanceError
|
||||
from jupyterhub.app import JupyterHub
|
||||
app = None
|
||||
if JupyterHub.initialized():
|
||||
try:
|
||||
app = JupyterHub.instance()
|
||||
except MultipleInstanceError:
|
||||
# could have been another Application
|
||||
pass
|
||||
if app is not None:
|
||||
alembic_logger = logging.getLogger('alembic')
|
||||
alembic_logger.propagate = True
|
||||
alembic_logger.parent = app.log
|
||||
|
@@ -36,6 +36,10 @@ def upgrade():
|
||||
# drop some columns no longer in use
|
||||
try:
|
||||
op.drop_column('users', 'auth_state')
|
||||
# mysql cannot drop _server_id without also dropping
|
||||
# implicitly created foreign key
|
||||
if op.get_context().dialect.name == 'mysql':
|
||||
op.drop_constraint('users_ibfk_1', 'users', type_='foreignkey')
|
||||
op.drop_column('users', '_server_id')
|
||||
except sa.exc.OperationalError:
|
||||
# this won't be a problem moving forward, but downgrade will fail
|
||||
|
@@ -114,7 +114,7 @@ class APIHandler(BaseHandler):
|
||||
if spawner.pending:
|
||||
s['pending'] = spawner.pending
|
||||
if spawner.server:
|
||||
s['url'] = user.url + name + '/'
|
||||
s['url'] = url_path_join(user.url, name, '/')
|
||||
return model
|
||||
|
||||
def group_model(self, group):
|
||||
|
@@ -185,8 +185,6 @@ class UserServerAPIHandler(APIHandler):
|
||||
user = self.find_user(name)
|
||||
if server_name and not self.allow_named_servers:
|
||||
raise web.HTTPError(400, "Named servers are not enabled.")
|
||||
if self.allow_named_servers and not server_name:
|
||||
server_name = user.default_server_name()
|
||||
spawner = user.spawners[server_name]
|
||||
pending = spawner.pending
|
||||
if pending == 'spawn':
|
||||
|
@@ -23,6 +23,7 @@ if sys.version_info[:2] < (3, 3):
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from tornado.httpclient import AsyncHTTPClient
|
||||
@@ -189,6 +190,13 @@ class UpgradeDB(Application):
|
||||
db_file = hub.db_url.split(':///', 1)[1]
|
||||
self._backup_db_file(db_file)
|
||||
self.log.info("Upgrading %s", hub.db_url)
|
||||
# run check-db-revision first
|
||||
engine = create_engine(hub.db_url)
|
||||
try:
|
||||
orm.check_db_revision(engine)
|
||||
except orm.DatabaseSchemaMismatch:
|
||||
# ignore mismatch error because that's what we are here for!
|
||||
pass
|
||||
dbutil.upgrade(hub.db_url)
|
||||
|
||||
|
||||
@@ -1219,7 +1227,7 @@ class JupyterHub(Application):
|
||||
status = yield spawner.poll()
|
||||
except Exception:
|
||||
self.log.exception("Failed to poll spawner for %s, assuming the spawner is not running.",
|
||||
user.name if name else '%s|%s' % (user.name, name))
|
||||
spawner._log_name)
|
||||
status = -1
|
||||
|
||||
if status is None:
|
||||
@@ -1230,11 +1238,13 @@ class JupyterHub(Application):
|
||||
# user not running. This is expected if server is None,
|
||||
# but indicates the user's server died while the Hub wasn't running
|
||||
# if spawner.server is defined.
|
||||
log = self.log.warning if spawner.server else self.log.debug
|
||||
log("%s not running.", user.name)
|
||||
# remove all server or servers entry from db related to the user
|
||||
if spawner.server:
|
||||
self.log.warning("%s appears to have stopped while the Hub was down", spawner._log_name)
|
||||
# remove server entry from db
|
||||
db.delete(spawner.orm_spawner.server)
|
||||
spawner.server = None
|
||||
else:
|
||||
self.log.debug("%s not running", spawner._log_name)
|
||||
db.commit()
|
||||
|
||||
user_summaries.append(_user_summary(user))
|
||||
|
@@ -20,7 +20,7 @@ from .. import __version__
|
||||
from .. import orm
|
||||
from ..objects import Server
|
||||
from ..spawner import LocalProcessSpawner
|
||||
from ..utils import default_server_name, url_path_join
|
||||
from ..utils import url_path_join
|
||||
|
||||
# pattern for the authentication token header
|
||||
auth_header_pat = re.compile(r'^(?:token|bearer)\s+([^\s]+)$', flags=re.IGNORECASE)
|
||||
@@ -380,8 +380,6 @@ class BaseHandler(RequestHandler):
|
||||
self.extra_error_html = self.spawn_home_error
|
||||
|
||||
user_server_name = user.name
|
||||
if self.allow_named_servers and not server_name:
|
||||
server_name = default_server_name(user)
|
||||
|
||||
if server_name:
|
||||
user_server_name = '%s:%s' % (user.name, server_name)
|
||||
@@ -547,6 +545,7 @@ class BaseHandler(RequestHandler):
|
||||
spawner._stop_pending = False
|
||||
toc = IOLoop.current().time()
|
||||
self.log.info("User %s server took %.3f seconds to stop", user.name, toc - tic)
|
||||
self.statsd.timing('spawner.stop', (toc - tic) * 1000)
|
||||
|
||||
try:
|
||||
yield gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), stop())
|
||||
|
@@ -20,7 +20,7 @@ class LogoutHandler(BaseHandler):
|
||||
self.clear_login_cookie()
|
||||
self.statsd.incr('logout')
|
||||
if self.authenticator.auto_login:
|
||||
self.render('logout.html')
|
||||
self.render_template('logout.html')
|
||||
else:
|
||||
self.redirect(self.settings['login_url'], permanent=False)
|
||||
|
||||
|
@@ -177,7 +177,7 @@ class Spawner(Base):
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||
|
||||
server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
server_id = Column(Integer, ForeignKey('servers.id', ondelete='SET NULL'))
|
||||
server = relationship(Server)
|
||||
|
||||
state = Column(JSONDict)
|
||||
@@ -213,7 +213,7 @@ class Service(Base):
|
||||
api_tokens = relationship("APIToken", backref="service")
|
||||
|
||||
# service-specific interface
|
||||
_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
_server_id = Column(Integer, ForeignKey('servers.id', ondelete='SET NULL'))
|
||||
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
||||
pid = Column(Integer)
|
||||
|
||||
|
@@ -13,8 +13,10 @@ authenticate with the Hub.
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import socket
|
||||
import string
|
||||
import time
|
||||
from urllib.parse import quote, urlencode
|
||||
import uuid
|
||||
@@ -531,22 +533,37 @@ class HubOAuth(HubAuth):
|
||||
-------
|
||||
state (str): The OAuth state that has been stored in the cookie (url safe, base64-encoded)
|
||||
"""
|
||||
b64_state = self.generate_state(next_url)
|
||||
extra_state = {}
|
||||
if handler.get_cookie(self.state_cookie_name):
|
||||
# oauth state cookie is already set
|
||||
# use a randomized cookie suffix to avoid collisions
|
||||
# in case of concurrent logins
|
||||
app_log.warning("Detected unused OAuth state cookies")
|
||||
cookie_suffix = ''.join(random.choice(string.ascii_letters) for i in range(8))
|
||||
cookie_name = '{}-{}'.format(self.state_cookie_name, cookie_suffix)
|
||||
extra_state['cookie_name'] = cookie_name
|
||||
else:
|
||||
cookie_name = self.state_cookie_name
|
||||
b64_state = self.generate_state(next_url, **extra_state)
|
||||
kwargs = {
|
||||
'path': self.base_url,
|
||||
'httponly': True,
|
||||
'expires_days': 1,
|
||||
# Expire oauth state cookie in ten minutes.
|
||||
# Usually this will be cleared by completed login
|
||||
# in less than a few seconds.
|
||||
# OAuth that doesn't complete shouldn't linger too long.
|
||||
'max_age': 600,
|
||||
}
|
||||
if handler.request.protocol == 'https':
|
||||
kwargs['secure'] = True
|
||||
handler.set_secure_cookie(
|
||||
self.state_cookie_name,
|
||||
cookie_name,
|
||||
b64_state,
|
||||
**kwargs
|
||||
)
|
||||
return b64_state
|
||||
|
||||
def generate_state(self, next_url=None):
|
||||
def generate_state(self, next_url=None, **extra_state):
|
||||
"""Generate a state string, given a next_url redirect target
|
||||
|
||||
Parameters
|
||||
@@ -557,16 +574,27 @@ class HubOAuth(HubAuth):
|
||||
-------
|
||||
state (str): The base64-encoded state string.
|
||||
"""
|
||||
return self._encode_state({
|
||||
state = {
|
||||
'uuid': uuid.uuid4().hex,
|
||||
'next_url': next_url
|
||||
})
|
||||
'next_url': next_url,
|
||||
}
|
||||
state.update(extra_state)
|
||||
return self._encode_state(state)
|
||||
|
||||
def get_next_url(self, b64_state=''):
|
||||
"""Get the next_url for redirection, given an encoded OAuth state"""
|
||||
state = self._decode_state(b64_state)
|
||||
return state.get('next_url') or self.base_url
|
||||
|
||||
def get_state_cookie_name(self, b64_state=''):
|
||||
"""Get the cookie name for oauth state, given an encoded OAuth state
|
||||
|
||||
Cookie name is stored in the state itself because the cookie name
|
||||
is randomized to deal with races between concurrent oauth sequences.
|
||||
"""
|
||||
state = self._decode_state(b64_state)
|
||||
return state.get('cookie_name') or self.state_cookie_name
|
||||
|
||||
def set_cookie(self, handler, access_token):
|
||||
"""Set a cookie recording OAuth result"""
|
||||
kwargs = {
|
||||
@@ -657,12 +685,11 @@ class HubAuthenticated(object):
|
||||
def get_login_url(self):
|
||||
"""Return the Hub's login URL"""
|
||||
login_url = self.hub_auth.login_url
|
||||
app_log.debug("Redirecting to login url: %s", login_url)
|
||||
if isinstance(self.hub_auth, HubOAuthenticated):
|
||||
if isinstance(self.hub_auth, HubOAuth):
|
||||
# add state argument to OAuth url
|
||||
state = self.hub_auth.set_state_cookie(self, next_url=self.request.uri)
|
||||
return url_concat(login_url, {'state': state})
|
||||
else:
|
||||
login_url = url_concat(login_url, {'state': state})
|
||||
app_log.debug("Redirecting to login url: %s", login_url)
|
||||
return login_url
|
||||
|
||||
def check_hub_user(self, model):
|
||||
@@ -770,17 +797,18 @@ class HubOAuthCallbackHandler(HubOAuthenticated, RequestHandler):
|
||||
|
||||
# validate OAuth state
|
||||
arg_state = self.get_argument("state", None)
|
||||
cookie_state = self.get_secure_cookie(self.hub_auth.state_cookie_name)
|
||||
next_url = None
|
||||
if arg_state or cookie_state:
|
||||
if arg_state is None:
|
||||
raise HTTPError("oauth state is missing. Try logging in again.")
|
||||
cookie_name = self.hub_auth.get_state_cookie_name(arg_state)
|
||||
cookie_state = self.get_secure_cookie(cookie_name)
|
||||
# clear cookie state now that we've consumed it
|
||||
self.clear_cookie(self.hub_auth.state_cookie_name)
|
||||
self.clear_cookie(cookie_name, path=self.hub_auth.base_url)
|
||||
if isinstance(cookie_state, bytes):
|
||||
cookie_state = cookie_state.decode('ascii', 'replace')
|
||||
# check that state matches
|
||||
if arg_state != cookie_state:
|
||||
app_log.debug("oauth state %r != %r", arg_state, cookie_state)
|
||||
raise HTTPError(403, "oauth state does not match")
|
||||
app_log.warning("oauth state %r != %r", arg_state, cookie_state)
|
||||
raise HTTPError(403, "oauth state does not match. Try logging in again.")
|
||||
next_url = self.hub_auth.get_next_url(cookie_state)
|
||||
# TODO: make async (in a Thread?)
|
||||
token = self.hub_auth.token_for_code(code)
|
||||
|
@@ -301,5 +301,8 @@ class Service(LoggingConfigurable):
|
||||
if not self.managed:
|
||||
raise RuntimeError("Cannot stop unmanaged service %s" % self)
|
||||
if self.spawner:
|
||||
if self.orm.server:
|
||||
self.db.delete(self.orm.server)
|
||||
self.db.commit()
|
||||
self.spawner.stop_polling()
|
||||
return self.spawner.stop()
|
||||
|
@@ -144,11 +144,13 @@ page_template = """
|
||||
{% block header_buttons %}
|
||||
{{super()}}
|
||||
|
||||
<a href='{{hub_control_panel_url}}'
|
||||
<span>
|
||||
<a href='{{hub_control_panel_url}}'
|
||||
class='btn btn-default btn-sm navbar-btn pull-right'
|
||||
style='margin-right: 4px; margin-left: 2px;'
|
||||
>
|
||||
Control Panel</a>
|
||||
style='margin-right: 4px; margin-left: 2px;'>
|
||||
Control Panel
|
||||
</a>
|
||||
</span>
|
||||
{% endblock %}
|
||||
{% block logo %}
|
||||
<img src='{{logo_url}}' alt='Jupyter Notebook'/>
|
||||
|
@@ -839,7 +839,7 @@ class LocalProcessSpawner(Spawner):
|
||||
This is the default spawner for JupyterHub.
|
||||
"""
|
||||
|
||||
INTERRUPT_TIMEOUT = Integer(10,
|
||||
interrupt_timeout = Integer(10,
|
||||
help="""
|
||||
Seconds to wait for single-user server process to halt after SIGINT.
|
||||
|
||||
@@ -847,7 +847,7 @@ class LocalProcessSpawner(Spawner):
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
TERM_TIMEOUT = Integer(5,
|
||||
term_timeout = Integer(5,
|
||||
help="""
|
||||
Seconds to wait for single-user server process to halt after SIGTERM.
|
||||
|
||||
@@ -855,7 +855,7 @@ class LocalProcessSpawner(Spawner):
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
KILL_TIMEOUT = Integer(5,
|
||||
kill_timeout = Integer(5,
|
||||
help="""
|
||||
Seconds to wait for process to halt after SIGKILL before giving up.
|
||||
|
||||
@@ -1071,7 +1071,7 @@ class LocalProcessSpawner(Spawner):
|
||||
return
|
||||
self.log.debug("Interrupting %i", self.pid)
|
||||
yield self._signal(signal.SIGINT)
|
||||
yield self.wait_for_death(self.INTERRUPT_TIMEOUT)
|
||||
yield self.wait_for_death(self.interrupt_timeout)
|
||||
|
||||
# clean shutdown failed, use TERM
|
||||
status = yield self.poll()
|
||||
@@ -1079,7 +1079,7 @@ class LocalProcessSpawner(Spawner):
|
||||
return
|
||||
self.log.debug("Terminating %i", self.pid)
|
||||
yield self._signal(signal.SIGTERM)
|
||||
yield self.wait_for_death(self.TERM_TIMEOUT)
|
||||
yield self.wait_for_death(self.term_timeout)
|
||||
|
||||
# TERM failed, use KILL
|
||||
status = yield self.poll()
|
||||
@@ -1087,7 +1087,7 @@ class LocalProcessSpawner(Spawner):
|
||||
return
|
||||
self.log.debug("Killing %i", self.pid)
|
||||
yield self._signal(signal.SIGKILL)
|
||||
yield self.wait_for_death(self.KILL_TIMEOUT)
|
||||
yield self.wait_for_death(self.kill_timeout)
|
||||
|
||||
status = yield self.poll()
|
||||
if status is None:
|
||||
|
@@ -8,9 +8,11 @@ from subprocess import check_output, Popen, PIPE
|
||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
from tornado import gen
|
||||
import pytest
|
||||
|
||||
from .mocking import MockHub
|
||||
from .test_api import add_user
|
||||
from .. import orm
|
||||
from ..app import COOKIE_SECRET_BYTES
|
||||
|
||||
@@ -161,3 +163,57 @@ def test_load_groups():
|
||||
assert gold is not None
|
||||
assert sorted([ u.name for u in gold.users ]) == sorted(to_load['gold'])
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_resume_spawners(tmpdir, request):
|
||||
if not os.getenv('JUPYTERHUB_TEST_DB_URL'):
|
||||
p = patch.dict(os.environ, {
|
||||
'JUPYTERHUB_TEST_DB_URL': 'sqlite:///%s' % tmpdir.join('jupyterhub.sqlite'),
|
||||
})
|
||||
p.start()
|
||||
request.addfinalizer(p.stop)
|
||||
@gen.coroutine
|
||||
def new_hub():
|
||||
app = MockHub()
|
||||
app.config.ConfigurableHTTPProxy.should_start = False
|
||||
yield app.initialize([])
|
||||
return app
|
||||
app = yield new_hub()
|
||||
db = app.db
|
||||
# spawn a user's server
|
||||
name = 'kurt'
|
||||
user = add_user(db, app, name=name)
|
||||
yield user.spawn()
|
||||
proc = user.spawner.proc
|
||||
assert proc is not None
|
||||
|
||||
# stop the Hub without cleaning up servers
|
||||
app.cleanup_servers = False
|
||||
yield app.stop()
|
||||
|
||||
# proc is still running
|
||||
assert proc.poll() is None
|
||||
|
||||
# resume Hub, should still be running
|
||||
app = yield new_hub()
|
||||
db = app.db
|
||||
user = app.users[name]
|
||||
assert user.running
|
||||
assert user.spawner.server is not None
|
||||
|
||||
# stop the Hub without cleaning up servers
|
||||
app.cleanup_servers = False
|
||||
yield app.stop()
|
||||
|
||||
# stop the server while the Hub is down. BAMF!
|
||||
proc.terminate()
|
||||
proc.wait(timeout=10)
|
||||
assert proc.poll() is not None
|
||||
|
||||
# resume Hub, should be stopped
|
||||
app = yield new_hub()
|
||||
db = app.db
|
||||
user = app.users[name]
|
||||
assert not user.running
|
||||
assert user.spawner.server is None
|
||||
assert list(db.query(orm.Server)) == []
|
||||
|
@@ -4,6 +4,7 @@ import shutil
|
||||
|
||||
import pytest
|
||||
from pytest import raises
|
||||
from traitlets.config import Config
|
||||
|
||||
from ..dbutil import upgrade
|
||||
from ..app import NewToken, UpgradeDB, JupyterHub
|
||||
@@ -21,29 +22,35 @@ def generate_old_db(path):
|
||||
def test_upgrade(tmpdir):
|
||||
print(tmpdir)
|
||||
db_url = generate_old_db(str(tmpdir))
|
||||
print(db_url)
|
||||
upgrade(db_url)
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_upgrade_entrypoint(tmpdir):
|
||||
generate_old_db(str(tmpdir))
|
||||
db_url = os.getenv('JUPYTERHUB_TEST_UPGRADE_DB_URL')
|
||||
if not db_url:
|
||||
# default: sqlite
|
||||
db_url = generate_old_db(str(tmpdir))
|
||||
cfg = Config()
|
||||
cfg.JupyterHub.db_url = db_url
|
||||
|
||||
tmpdir.chdir()
|
||||
tokenapp = NewToken()
|
||||
tokenapp = NewToken(config=cfg)
|
||||
tokenapp.initialize(['kaylee'])
|
||||
with raises(SystemExit):
|
||||
tokenapp.start()
|
||||
|
||||
if 'sqlite' in db_url:
|
||||
sqlite_files = glob(os.path.join(str(tmpdir), 'jupyterhub.sqlite*'))
|
||||
assert len(sqlite_files) == 1
|
||||
|
||||
upgradeapp = UpgradeDB()
|
||||
upgradeapp = UpgradeDB(config=cfg)
|
||||
yield upgradeapp.initialize([])
|
||||
upgradeapp.start()
|
||||
|
||||
# check that backup was created:
|
||||
if 'sqlite' in db_url:
|
||||
sqlite_files = glob(os.path.join(str(tmpdir), 'jupyterhub.sqlite*'))
|
||||
assert len(sqlite_files) == 2
|
||||
|
||||
# run tokenapp again, it should work
|
||||
tokenapp.start()
|
||||
|
@@ -17,6 +17,57 @@ def named_servers(app):
|
||||
app.tornado_application.settings[key] = app.tornado_settings[key] = False
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_default_server(app, named_servers):
|
||||
"""Test the default /users/:user/server handler when named servers are enabled"""
|
||||
username = 'rosie'
|
||||
user = add_user(app.db, app, name=username)
|
||||
r = yield api_request(app, 'users', username, 'server', method='post')
|
||||
assert r.status_code == 201
|
||||
assert r.text == ''
|
||||
|
||||
r = yield api_request(app, 'users', username)
|
||||
r.raise_for_status()
|
||||
|
||||
user_model = r.json()
|
||||
user_model.pop('last_activity')
|
||||
assert user_model == {
|
||||
'name': username,
|
||||
'groups': [],
|
||||
'kind': 'user',
|
||||
'admin': False,
|
||||
'pending': None,
|
||||
'server': user.url,
|
||||
'servers': {
|
||||
'': {
|
||||
'name': '',
|
||||
'url': user.url,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# now stop the server
|
||||
r = yield api_request(app, 'users', username, 'server', method='delete')
|
||||
assert r.status_code == 204
|
||||
assert r.text == ''
|
||||
|
||||
r = yield api_request(app, 'users', username)
|
||||
r.raise_for_status()
|
||||
|
||||
user_model = r.json()
|
||||
user_model.pop('last_activity')
|
||||
assert user_model == {
|
||||
'name': username,
|
||||
'groups': [],
|
||||
'kind': 'user',
|
||||
'admin': False,
|
||||
'pending': None,
|
||||
'server': None,
|
||||
'servers': {},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_create_named_server(app, named_servers):
|
||||
username = 'walnut'
|
||||
@@ -49,13 +100,13 @@ def test_create_named_server(app, named_servers):
|
||||
'kind': 'user',
|
||||
'admin': False,
|
||||
'pending': None,
|
||||
'server': None,
|
||||
'server': user.url,
|
||||
'servers': {
|
||||
name: {
|
||||
'name': name,
|
||||
'url': url_path_join(user.url, name, '/'),
|
||||
}
|
||||
for name in ['1', servername]
|
||||
for name in ['', servername]
|
||||
},
|
||||
}
|
||||
|
||||
@@ -86,13 +137,13 @@ def test_delete_named_server(app, named_servers):
|
||||
'kind': 'user',
|
||||
'admin': False,
|
||||
'pending': None,
|
||||
'server': None,
|
||||
'server': user.url,
|
||||
'servers': {
|
||||
name: {
|
||||
'name': name,
|
||||
'url': url_path_join(user.url, name, '/'),
|
||||
}
|
||||
for name in ['1']
|
||||
for name in ['']
|
||||
},
|
||||
}
|
||||
|
||||
|
@@ -14,6 +14,7 @@ from .. import objects
|
||||
from .. import crypto
|
||||
from ..user import User
|
||||
from .mocking import MockSpawner
|
||||
from ..emptyclass import EmptyClass
|
||||
|
||||
|
||||
def test_server(db):
|
||||
@@ -167,6 +168,7 @@ def test_spawn_fails(db):
|
||||
user = User(orm_user, {
|
||||
'spawner_class': BadSpawner,
|
||||
'config': None,
|
||||
'statsd': EmptyClass(),
|
||||
})
|
||||
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
|
@@ -134,8 +134,12 @@ def test_spawn_redirect(app):
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, '/user/%s/' % name)
|
||||
|
||||
# stop server to ensure /user/name is handled by the Hub
|
||||
r = yield api_request(app, 'users', name, 'server', method='delete', cookies=cookies)
|
||||
r.raise_for_status()
|
||||
|
||||
# test handing of trailing slash on `/user/name`
|
||||
r = yield get_page('user/' + name, app, cookies=cookies)
|
||||
r = yield get_page('user/' + name, app, hub=False, cookies=cookies)
|
||||
r.raise_for_status()
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, '/user/%s/' % name)
|
||||
@@ -340,6 +344,19 @@ def test_auto_login(app, request):
|
||||
r = yield async_requests.get(base_url)
|
||||
assert r.url == public_url(app, path='hub/dummy')
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_auto_login_logout(app):
|
||||
name = 'burnham'
|
||||
cookies = yield app.login_user(name)
|
||||
|
||||
with mock.patch.dict(app.tornado_application.settings, {
|
||||
'authenticator': Authenticator(auto_login=True),
|
||||
}):
|
||||
r = yield async_requests.get(public_host(app) + app.tornado_settings['logout_url'], cookies=cookies)
|
||||
r.raise_for_status()
|
||||
logout_url = public_host(app) + app.tornado_settings['logout_url']
|
||||
assert r.url == logout_url
|
||||
assert r.cookies == {}
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_logout(app):
|
||||
|
@@ -16,6 +16,7 @@ import requests_mock
|
||||
from tornado.ioloop import IOLoop
|
||||
from tornado.httpserver import HTTPServer
|
||||
from tornado.web import RequestHandler, Application, authenticated, HTTPError
|
||||
from tornado.httputil import url_concat
|
||||
|
||||
from ..services.auth import _ExpiringDict, HubAuth, HubAuthenticated
|
||||
from ..utils import url_path_join
|
||||
@@ -316,7 +317,8 @@ def test_hubauth_service_token(app, mockservice_url):
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_oauth_service(app, mockservice_url):
|
||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/')
|
||||
service = mockservice_url
|
||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
|
||||
# first request is only going to set login cookie
|
||||
# FIXME: redirect to originating URL (OAuth loses this info)
|
||||
s = requests.Session()
|
||||
@@ -326,6 +328,14 @@ def test_oauth_service(app, mockservice_url):
|
||||
s_get = lambda *args, **kwargs: async_requests.executor.submit(s.get, *args, **kwargs)
|
||||
r = yield s_get(url)
|
||||
r.raise_for_status()
|
||||
assert r.url == url
|
||||
# verify oauth cookie is set
|
||||
assert 'service-%s' % service.name in set(s.cookies.keys())
|
||||
# verify oauth state cookie has been consumed
|
||||
assert 'service-%s-oauth-state' % service.name not in set(s.cookies.keys())
|
||||
# verify oauth state cookie was set at some point
|
||||
assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
|
||||
|
||||
# second request should be authenticated
|
||||
r = yield s_get(url, allow_redirects=False)
|
||||
r.raise_for_status()
|
||||
@@ -340,7 +350,7 @@ def test_oauth_service(app, mockservice_url):
|
||||
# token-authenticated request to HubOAuth
|
||||
token = app.users[name].new_api_token()
|
||||
# token in ?token parameter
|
||||
r = yield async_requests.get(public_url(app, mockservice_url) + 'owhoami/?token=%s' % token)
|
||||
r = yield async_requests.get(url_concat(url, {'token': token}))
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert reply['name'] == name
|
||||
@@ -349,11 +359,70 @@ def test_oauth_service(app, mockservice_url):
|
||||
assert len(r.cookies) != 0
|
||||
# ensure cookie works in future requests
|
||||
r = yield async_requests.get(
|
||||
public_url(app, mockservice_url) + 'owhoami/',
|
||||
url,
|
||||
cookies=r.cookies,
|
||||
allow_redirects=False,
|
||||
)
|
||||
r.raise_for_status()
|
||||
assert r.url == url
|
||||
reply = r.json()
|
||||
assert reply['name'] == name
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_oauth_cookie_collision(app, mockservice_url):
|
||||
service = mockservice_url
|
||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/')
|
||||
print(url)
|
||||
s = requests.Session()
|
||||
name = 'mypha'
|
||||
s.cookies = yield app.login_user(name)
|
||||
# run session.get in async_requests thread
|
||||
s_get = lambda *args, **kwargs: async_requests.executor.submit(s.get, *args, **kwargs)
|
||||
state_cookie_name = 'service-%s-oauth-state' % service.name
|
||||
service_cookie_name = 'service-%s' % service.name
|
||||
oauth_1 = yield s_get(url, allow_redirects=False)
|
||||
print(oauth_1.headers)
|
||||
print(oauth_1.cookies, oauth_1.url, url)
|
||||
assert state_cookie_name in s.cookies
|
||||
state_cookies = [ s for s in s.cookies.keys() if s.startswith(state_cookie_name) ]
|
||||
# only one state cookie
|
||||
assert state_cookies == [state_cookie_name]
|
||||
state_1 = s.cookies[state_cookie_name]
|
||||
|
||||
# start second oauth login before finishing the first
|
||||
oauth_2 = yield s_get(url, allow_redirects=False)
|
||||
state_cookies = [ s for s in s.cookies.keys() if s.startswith(state_cookie_name) ]
|
||||
assert len(state_cookies) == 2
|
||||
# get the random-suffix cookie name
|
||||
state_cookie_2 = sorted(state_cookies)[-1]
|
||||
# we didn't clobber the default cookie
|
||||
assert s.cookies[state_cookie_name] == state_1
|
||||
|
||||
# finish oauth 2
|
||||
url = oauth_2.headers['Location']
|
||||
if not urlparse(url).netloc:
|
||||
url = public_host(app) + url
|
||||
r = yield s_get(url)
|
||||
r.raise_for_status()
|
||||
# after finishing, state cookie is cleared
|
||||
assert state_cookie_2 not in s.cookies
|
||||
# service login cookie is set
|
||||
assert service_cookie_name in s.cookies
|
||||
service_cookie_2 = s.cookies[service_cookie_name]
|
||||
|
||||
# finish oauth 1
|
||||
url = oauth_1.headers['Location']
|
||||
if not urlparse(url).netloc:
|
||||
url = public_host(app) + url
|
||||
r = yield s_get(url)
|
||||
r.raise_for_status()
|
||||
# after finishing, state cookie is cleared (again)
|
||||
assert state_cookie_name not in s.cookies
|
||||
# service login cookie is set (again, to a different value)
|
||||
assert service_cookie_name in s.cookies
|
||||
assert s.cookies[service_cookie_name] != service_cookie_2
|
||||
|
||||
# after completing both OAuth logins, no OAuth state cookies remain
|
||||
state_cookies = [ s for s in s.cookies.keys() if s.startswith(state_cookie_name) ]
|
||||
assert state_cookies == []
|
||||
|
@@ -51,9 +51,9 @@ def new_spawner(db, **kwargs):
|
||||
kwargs.setdefault('notebook_dir', os.getcwd())
|
||||
kwargs.setdefault('default_url', '/user/{username}/lab')
|
||||
kwargs.setdefault('oauth_client_id', 'mock-client-id')
|
||||
kwargs.setdefault('INTERRUPT_TIMEOUT', 1)
|
||||
kwargs.setdefault('TERM_TIMEOUT', 1)
|
||||
kwargs.setdefault('KILL_TIMEOUT', 1)
|
||||
kwargs.setdefault('interrupt_timeout', 1)
|
||||
kwargs.setdefault('term_timeout', 1)
|
||||
kwargs.setdefault('kill_timeout', 1)
|
||||
kwargs.setdefault('poll_interval', 1)
|
||||
return user._new_spawner('', spawner_class=LocalProcessSpawner, **kwargs)
|
||||
|
||||
@@ -299,7 +299,7 @@ def test_spawner_reuse_api_token(db, app):
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_spawner_insert_api_token(db, app):
|
||||
def test_spawner_insert_api_token(app):
|
||||
"""Token provided by spawner is not in the db
|
||||
|
||||
Insert token into db as a user-provided token.
|
||||
@@ -326,7 +326,7 @@ def test_spawner_insert_api_token(db, app):
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_spawner_bad_api_token(db, app):
|
||||
def test_spawner_bad_api_token(app):
|
||||
"""Tokens are revoked when a Spawner gets another user's token"""
|
||||
# we need two users for this one
|
||||
user = add_user(app.db, app, name='antimone')
|
||||
@@ -346,3 +346,37 @@ def test_spawner_bad_api_token(db, app):
|
||||
yield user.spawn()
|
||||
assert orm.APIToken.find(app.db, other_token) is None
|
||||
assert other_user.api_tokens == []
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_spawner_delete_server(app):
|
||||
"""Test deleting spawner.server
|
||||
|
||||
This can occur during app startup if their server has been deleted.
|
||||
"""
|
||||
db = app.db
|
||||
user = add_user(app.db, app, name='gaston')
|
||||
spawner = user.spawner
|
||||
orm_server = orm.Server()
|
||||
db.add(orm_server)
|
||||
db.commit()
|
||||
server_id = orm_server.id
|
||||
spawner.server = Server.from_orm(orm_server)
|
||||
db.commit()
|
||||
|
||||
assert spawner.server is not None
|
||||
assert spawner.orm_spawner.server is not None
|
||||
|
||||
# trigger delete via db
|
||||
db.delete(spawner.orm_spawner.server)
|
||||
db.commit()
|
||||
assert spawner.orm_spawner.server is None
|
||||
|
||||
# setting server = None also triggers delete
|
||||
spawner.server = None
|
||||
db.commit()
|
||||
# verify that the server was actually deleted from the db
|
||||
assert db.query(orm.Server).filter(orm.Server.id == server_id).first() is None
|
||||
# verify that both ORM and top-level references are None
|
||||
assert spawner.orm_spawner.server is None
|
||||
assert spawner.server is None
|
||||
|
@@ -34,18 +34,27 @@ def test_memoryspec():
|
||||
c = C()
|
||||
|
||||
c.mem = 1024
|
||||
assert isinstance(c.mem, int)
|
||||
assert c.mem == 1024
|
||||
|
||||
c.mem = '1024K'
|
||||
assert isinstance(c.mem, int)
|
||||
assert c.mem == 1024 * 1024
|
||||
|
||||
c.mem = '1024M'
|
||||
assert isinstance(c.mem, int)
|
||||
assert c.mem == 1024 * 1024 * 1024
|
||||
|
||||
c.mem = '1.5M'
|
||||
assert isinstance(c.mem, int)
|
||||
assert c.mem == 1.5 * 1024 * 1024
|
||||
|
||||
c.mem = '1024G'
|
||||
assert isinstance(c.mem, int)
|
||||
assert c.mem == 1024 * 1024 * 1024 * 1024
|
||||
|
||||
c.mem = '1024T'
|
||||
assert isinstance(c.mem, int)
|
||||
assert c.mem == 1024 * 1024 * 1024 * 1024 * 1024
|
||||
|
||||
with pytest.raises(TraitError):
|
||||
|
@@ -48,7 +48,7 @@ class ByteSpecification(Integer):
|
||||
'K': 1024,
|
||||
'M': 1024 * 1024,
|
||||
'G': 1024 * 1024 * 1024,
|
||||
'T': 1024 * 1024 * 1024 * 1024
|
||||
'T': 1024 * 1024 * 1024 * 1024,
|
||||
}
|
||||
|
||||
# Default to allowing None as a value
|
||||
@@ -62,11 +62,15 @@ class ByteSpecification(Integer):
|
||||
If it has one of the suffixes, it is converted into the appropriate
|
||||
pure byte value.
|
||||
"""
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
num = value[:-1]
|
||||
if isinstance(value, (int, float)):
|
||||
return int(value)
|
||||
|
||||
try:
|
||||
num = float(value[:-1])
|
||||
except ValueError:
|
||||
raise TraitError('{val} is not a valid memory specification. Must be an int or a string with suffix K, M, G, T'.format(val=value))
|
||||
suffix = value[-1]
|
||||
if not num.isdigit() and suffix not in ByteSpecification.UNIT_SUFFIXES:
|
||||
if suffix not in self.UNIT_SUFFIXES:
|
||||
raise TraitError('{val} is not a valid memory specification. Must be an int or a string with suffix K, M, G, T'.format(val=value))
|
||||
else:
|
||||
return int(num) * ByteSpecification.UNIT_SUFFIXES[suffix]
|
||||
return int(float(num) * self.UNIT_SUFFIXES[suffix])
|
||||
|
@@ -12,7 +12,7 @@ from tornado import gen
|
||||
from tornado.log import app_log
|
||||
from traitlets import HasTraits, Any, Dict, default
|
||||
|
||||
from .utils import url_path_join, default_server_name
|
||||
from .utils import url_path_join
|
||||
|
||||
from . import orm
|
||||
from ._version import _check_version, __version__
|
||||
@@ -421,10 +421,12 @@ class User(HasTraits):
|
||||
user=self.name, s=spawner.start_timeout,
|
||||
))
|
||||
e.reason = 'timeout'
|
||||
self.settings['statsd'].incr('spawner.failure.timeout')
|
||||
else:
|
||||
self.log.error("Unhandled error starting {user}'s server: {error}".format(
|
||||
user=self.name, error=e,
|
||||
))
|
||||
self.settings['statsd'].incr('spawner.failure.error')
|
||||
e.reason = 'error'
|
||||
try:
|
||||
yield self.stop()
|
||||
@@ -457,11 +459,13 @@ class User(HasTraits):
|
||||
)
|
||||
)
|
||||
e.reason = 'timeout'
|
||||
self.settings['statsd'].incr('spawner.failure.http_timeout')
|
||||
else:
|
||||
e.reason = 'error'
|
||||
self.log.error("Unhandled error waiting for {user}'s server to show up at {url}: {error}".format(
|
||||
user=self.name, url=server.url, error=e,
|
||||
))
|
||||
self.settings['statsd'].incr('spawner.failure.http_error')
|
||||
try:
|
||||
yield self.stop()
|
||||
except Exception:
|
||||
|
@@ -298,17 +298,3 @@ def url_path_join(*pieces):
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def default_server_name(user):
|
||||
"""Return the default name for a new server for a given user.
|
||||
|
||||
Will be the first available integer string, e.g. '1' or '2'.
|
||||
"""
|
||||
existing_names = set(user.spawners)
|
||||
# if there are 5 servers, count from 1 to 6
|
||||
for n in range(1, len(existing_names) + 2):
|
||||
name = str(n)
|
||||
if name not in existing_names:
|
||||
return name
|
||||
raise RuntimeError("It should be impossible to get here")
|
||||
|
||||
|
Reference in New Issue
Block a user