mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-14 21:43:01 +00:00
Compare commits
271 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
b1111363fd | ||
![]() |
6c99b807c2 | ||
![]() |
8d650f594e | ||
![]() |
04a0a3a2e5 | ||
![]() |
9cebfd6367 | ||
![]() |
587cd70221 | ||
![]() |
e94f5e043a | ||
![]() |
5456fb6356 | ||
![]() |
fb75b9a392 | ||
![]() |
90d341e6f7 | ||
![]() |
a0354de3c1 | ||
![]() |
2e4e1ce82f | ||
![]() |
06f646099f | ||
![]() |
3360817cb6 | ||
![]() |
e042ad0b4a | ||
![]() |
246f9f9044 | ||
![]() |
bc08f4de34 | ||
![]() |
12904ecc32 | ||
![]() |
601d371796 | ||
![]() |
30d9e09390 | ||
![]() |
7850a5d478 | ||
![]() |
f5a3b1bc5a | ||
![]() |
b2fe8e5691 | ||
![]() |
9d4c410996 | ||
![]() |
dcae92ce4a | ||
![]() |
29957b8cd8 | ||
![]() |
6299e0368c | ||
![]() |
c862b6062d | ||
![]() |
146587ffff | ||
![]() |
077d8dec9a | ||
![]() |
af8d6086fc | ||
![]() |
18f8661d73 | ||
![]() |
bd70f66c70 | ||
![]() |
ac213fc4b5 | ||
![]() |
db33549173 | ||
![]() |
e985e2b84c | ||
![]() |
1d9abf7528 | ||
![]() |
935baa8bc6 | ||
![]() |
9b77732319 | ||
![]() |
85aac0fa2d | ||
![]() |
abd6f35638 | ||
![]() |
ba4700b3f3 | ||
![]() |
05b11bd47a | ||
![]() |
71cb628563 | ||
![]() |
0d664355f0 | ||
![]() |
dd6261d031 | ||
![]() |
f3f5b69e49 | ||
![]() |
9ea4ca3646 | ||
![]() |
8ee9869ca0 | ||
![]() |
6cedd73d2a | ||
![]() |
59145ca0f7 | ||
![]() |
ab02f9c568 | ||
![]() |
a2f003ed31 | ||
![]() |
7b6dd9f5cf | ||
![]() |
0fa5c20f89 | ||
![]() |
204399ee2c | ||
![]() |
5e68dce02f | ||
![]() |
952bbea039 | ||
![]() |
630e85bfec | ||
![]() |
26f7bb51bd | ||
![]() |
a1c2a50810 | ||
![]() |
906abcc2f3 | ||
![]() |
5269370e4a | ||
![]() |
897f5f62d5 | ||
![]() |
727356870a | ||
![]() |
39aed3a5a0 | ||
![]() |
ed26578717 | ||
![]() |
22863f765f | ||
![]() |
b500bd002b | ||
![]() |
aca40b24c3 | ||
![]() |
b5fe5a80c6 | ||
![]() |
ad073dd5dd | ||
![]() |
7b815558c6 | ||
![]() |
55f58b3ba7 | ||
![]() |
e1f93a4721 | ||
![]() |
2e95f3c039 | ||
![]() |
b0ba51f209 | ||
![]() |
89e6c2110e | ||
![]() |
7dfdc23b4e | ||
![]() |
4c7df53a8a | ||
![]() |
678afd3783 | ||
![]() |
0185a08f32 | ||
![]() |
f3787dd2c8 | ||
![]() |
30f19cfc8c | ||
![]() |
a84fa38c6b | ||
![]() |
867ce4c213 | ||
![]() |
005118e09d | ||
![]() |
04ce67ee71 | ||
![]() |
31807929cb | ||
![]() |
cb4105b53e | ||
![]() |
151887dd56 | ||
![]() |
5f97487184 | ||
![]() |
4d2d677777 | ||
![]() |
6a3b3807c9 | ||
![]() |
02a52a0289 | ||
![]() |
7bd1e387df | ||
![]() |
edc0d7901f | ||
![]() |
8e561f1c12 | ||
![]() |
24d87c882f | ||
![]() |
1e333e2f29 | ||
![]() |
a507fa1c8a | ||
![]() |
90cc03b3ec | ||
![]() |
6f15113e2a | ||
![]() |
f3f08c9caa | ||
![]() |
c495c4731a | ||
![]() |
e08a50ef66 | ||
![]() |
fbcd792062 | ||
![]() |
bb81ce0160 | ||
![]() |
315087d67c | ||
![]() |
31e6a15a85 | ||
![]() |
aed99d8d19 | ||
![]() |
ec83708892 | ||
![]() |
bedac5f148 | ||
![]() |
376aa13981 | ||
![]() |
4bc8b48763 | ||
![]() |
21496890f6 | ||
![]() |
70dcd50e44 | ||
![]() |
24094567e5 | ||
![]() |
6bd0febbe1 | ||
![]() |
57075aba52 | ||
![]() |
f0260aae52 | ||
![]() |
edd8e21f71 | ||
![]() |
681d3ce2d8 | ||
![]() |
97e792ccde | ||
![]() |
a5a0543b2a | ||
![]() |
5a810ccba3 | ||
![]() |
0a6b2cdadc | ||
![]() |
08903e7af8 | ||
![]() |
78439329c0 | ||
![]() |
4dfd6bc4b9 | ||
![]() |
574cc39b5f | ||
![]() |
6fb43a8241 | ||
![]() |
84c82fe382 | ||
![]() |
5e45e76f5b | ||
![]() |
92fd819cd6 | ||
![]() |
cb5ef0c302 | ||
![]() |
34fab033fe | ||
![]() |
37f4c4429e | ||
![]() |
293410ec94 | ||
![]() |
ed6ee27dcd | ||
![]() |
ca16ddb7ad | ||
![]() |
2102c1fd1c | ||
![]() |
aa9676ec5e | ||
![]() |
5e93c7de4c | ||
![]() |
d22626906b | ||
![]() |
5f91ed044e | ||
![]() |
5c3c7493c1 | ||
![]() |
1b7965092e | ||
![]() |
ef60be5a99 | ||
![]() |
f78d652cd6 | ||
![]() |
3650575797 | ||
![]() |
0f000f6d41 | ||
![]() |
643729ac0c | ||
![]() |
91a67bf580 | ||
![]() |
c75eddb730 | ||
![]() |
0f5888ad6c | ||
![]() |
8c48f3b856 | ||
![]() |
6e7e18bc3c | ||
![]() |
3dfd7e5a84 | ||
![]() |
19ecbf3734 | ||
![]() |
eac3e8ba90 | ||
![]() |
a7a6829b69 | ||
![]() |
61299113c8 | ||
![]() |
21a57dfa0b | ||
![]() |
a7226a8231 | ||
![]() |
6e3dd21f60 | ||
![]() |
cf049730d4 | ||
![]() |
cb9ce4d3af | ||
![]() |
925ee1dfb2 | ||
![]() |
5d9122b26c | ||
![]() |
6821ad0c59 | ||
![]() |
ff7851ee2e | ||
![]() |
6940ed85b1 | ||
![]() |
3d497a7f43 | ||
![]() |
cc6968e225 | ||
![]() |
a6c517c344 | ||
![]() |
a3e08b7f52 | ||
![]() |
14c8d7dc46 | ||
![]() |
ac2590c679 | ||
![]() |
ead13c6a11 | ||
![]() |
5002ab2990 | ||
![]() |
ab3e7293a4 | ||
![]() |
062af5e5cb | ||
![]() |
92088570ea | ||
![]() |
604ccf515d | ||
![]() |
ec9b244990 | ||
![]() |
09acdc23b5 | ||
![]() |
e7808b50af | ||
![]() |
9c27095744 | ||
![]() |
690b07982e | ||
![]() |
784e5aa4ee | ||
![]() |
29187cab3a | ||
![]() |
43a72807c6 | ||
![]() |
1d1f6f1870 | ||
![]() |
505a6eb4e3 | ||
![]() |
cc49df8147 | ||
![]() |
98d60402b5 | ||
![]() |
319e8a1062 | ||
![]() |
0c5d564830 | ||
![]() |
c0404cf9d9 | ||
![]() |
f364661363 | ||
![]() |
f92d77b06d | ||
![]() |
2cf00e6aae | ||
![]() |
dfdb0cff2b | ||
![]() |
d0dad84ffa | ||
![]() |
1745937f1a | ||
![]() |
e7eb674a89 | ||
![]() |
b232633100 | ||
![]() |
6abd19c149 | ||
![]() |
0aa0ff8db7 | ||
![]() |
a907429fd4 | ||
![]() |
598b550a67 | ||
![]() |
92bb442494 | ||
![]() |
2d41f6223e | ||
![]() |
791dd5fb9f | ||
![]() |
9a0ccf4c98 | ||
![]() |
ad2abc5771 | ||
![]() |
2d99b3943f | ||
![]() |
a358132f95 | ||
![]() |
09cd37feee | ||
![]() |
0f3610e81d | ||
![]() |
3f97c438e2 | ||
![]() |
42351201d2 | ||
![]() |
907bbb8e9d | ||
![]() |
63f3d8b621 | ||
![]() |
47d6e841fd | ||
![]() |
e3bb09fabe | ||
![]() |
d4e0c01189 | ||
![]() |
50370d42b0 | ||
![]() |
aa190a80b7 | ||
![]() |
e48bae77aa | ||
![]() |
96cf0f99ed | ||
![]() |
f380968049 | ||
![]() |
02468f4625 | ||
![]() |
24611f94cf | ||
![]() |
dc75a9a4b7 | ||
![]() |
33f459a23a | ||
![]() |
bdcc251002 | ||
![]() |
86052ba7b4 | ||
![]() |
62ebcf55c9 | ||
![]() |
80ac2475a0 | ||
![]() |
5179d922f5 | ||
![]() |
26f085a8ed | ||
![]() |
b7d302cc72 | ||
![]() |
f2941e3631 | ||
![]() |
26a6401af4 | ||
![]() |
5c8ce338a1 | ||
![]() |
5addc7bbaf | ||
![]() |
da095170bf | ||
![]() |
1aab0a69bd | ||
![]() |
fc8e04b62f | ||
![]() |
c6c53b4e10 | ||
![]() |
9b0219a2d8 | ||
![]() |
6e212fa476 | ||
![]() |
58f9237b12 | ||
![]() |
74fd925219 | ||
![]() |
2696bb97d2 | ||
![]() |
9cefb27704 | ||
![]() |
5e75357b06 | ||
![]() |
79bebb4bc9 | ||
![]() |
0ed88f212b | ||
![]() |
a8c1cab5fe | ||
![]() |
e1a6b1a70f | ||
![]() |
c95ed16786 | ||
![]() |
ec784803b4 | ||
![]() |
302d7a22d3 | ||
![]() |
7c6591aefe | ||
![]() |
58c91e3fd4 | ||
![]() |
db4cf7ae62 | ||
![]() |
bc86ee1c31 | ||
![]() |
a73e6f0bf8 |
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Hi! Thanks for using JupyterHub.
|
||||||
|
|
||||||
|
If you are reporting an issue with JupyterHub, please use the [GitHub issue](https://github.com/jupyterhub/jupyterhub/issues) search feature to check if your issue has been asked already. If it has, please add your comments to the existing issue.
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Desktop (please complete the following information):**
|
||||||
|
- OS: [e.g. iOS]
|
||||||
|
- Browser [e.g. chrome, safari]
|
||||||
|
- Version [e.g. 22]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
||||||
|
|
||||||
|
- Running `jupyter troubleshoot` from the command line, if possible, and posting
|
||||||
|
its output would also be helpful.
|
||||||
|
- Running in `--debug` mode can also be helpful for troubleshooting.
|
7
.github/ISSUE_TEMPLATE/installation-and-configuration-issues.md
vendored
Normal file
7
.github/ISSUE_TEMPLATE/installation-and-configuration-issues.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
name: Installation and configuration issues
|
||||||
|
about: Installation and configuration assistance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If you are having issues with installation or configuration, you may ask for help on the JupyterHub gitter channel or file an issue here.
|
0
.github/PULL_REQUEST_TEMPLATE/.keep
vendored
Normal file
0
.github/PULL_REQUEST_TEMPLATE/.keep
vendored
Normal file
29
.github/issue_template.md
vendored
29
.github/issue_template.md
vendored
@@ -1,29 +0,0 @@
|
|||||||
Hi! Thanks for using JupyterHub.
|
|
||||||
|
|
||||||
If you are reporting an issue with JupyterHub:
|
|
||||||
|
|
||||||
- Please use the [GitHub issue](https://github.com/jupyterhub/jupyterhub/issues)
|
|
||||||
search feature to check if your issue has been asked already. If it has,
|
|
||||||
please add your comments to the existing issue.
|
|
||||||
|
|
||||||
- Where applicable, please fill out the details below to help us troubleshoot
|
|
||||||
the issue that you are facing. Please be as thorough as you are able to
|
|
||||||
provide details on the issue.
|
|
||||||
|
|
||||||
**How to reproduce the issue**
|
|
||||||
|
|
||||||
**What you expected to happen**
|
|
||||||
|
|
||||||
**What actually happens**
|
|
||||||
|
|
||||||
**Share what version of JupyterHub you are using**
|
|
||||||
|
|
||||||
Running `jupyter troubleshoot` from the command line, if possible, and posting
|
|
||||||
its output would also be helpful.
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
Insert jupyter troubleshoot output here
|
|
||||||
|
|
||||||
|
|
||||||
```
|
|
@@ -29,7 +29,7 @@ before_install:
|
|||||||
pip install 'mysql-connector<2.2'
|
pip install 'mysql-connector<2.2'
|
||||||
elif [[ $JUPYTERHUB_TEST_DB_URL == postgresql* ]]; then
|
elif [[ $JUPYTERHUB_TEST_DB_URL == postgresql* ]]; then
|
||||||
DB=postgres bash ci/init-db.sh
|
DB=postgres bash ci/init-db.sh
|
||||||
pip install psycopg2
|
pip install psycopg2-binary
|
||||||
fi
|
fi
|
||||||
install:
|
install:
|
||||||
- pip install --upgrade pip
|
- pip install --upgrade pip
|
||||||
@@ -62,5 +62,7 @@ matrix:
|
|||||||
- python: 3.6
|
- python: 3.6
|
||||||
env:
|
env:
|
||||||
- JUPYTERHUB_TEST_DB_URL=postgresql://postgres@127.0.0.1/jupyterhub
|
- JUPYTERHUB_TEST_DB_URL=postgresql://postgres@127.0.0.1/jupyterhub
|
||||||
|
- python: 3.7
|
||||||
|
dist: xenial
|
||||||
allow_failures:
|
allow_failures:
|
||||||
- python: nightly
|
- python: nightly
|
||||||
|
1
CODE_OF_CONDUCT.md
Normal file
1
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Please refer to [Project Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/master/conduct/code_of_conduct.md).
|
@@ -95,4 +95,4 @@ make html
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
open build/html/index.html
|
open build/html/index.html
|
||||||
```
|
```
|
||||||
|
@@ -35,8 +35,8 @@ RUN apt-get -y update && \
|
|||||||
ENV LANG C.UTF-8
|
ENV LANG C.UTF-8
|
||||||
|
|
||||||
# install Python + NodeJS with conda
|
# install Python + NodeJS with conda
|
||||||
RUN wget -q https://repo.continuum.io/miniconda/Miniconda3-4.4.10-Linux-x86_64.sh -O /tmp/miniconda.sh && \
|
RUN wget -q https://repo.continuum.io/miniconda/Miniconda3-4.5.1-Linux-x86_64.sh -O /tmp/miniconda.sh && \
|
||||||
echo 'bec6203dbb2f53011e974e9bf4d46e93 */tmp/miniconda.sh' | md5sum -c - && \
|
echo '0c28787e3126238df24c5d4858bd0744 */tmp/miniconda.sh' | md5sum -c - && \
|
||||||
bash /tmp/miniconda.sh -f -b -p /opt/conda && \
|
bash /tmp/miniconda.sh -f -b -p /opt/conda && \
|
||||||
/opt/conda/bin/conda install --yes -c conda-forge \
|
/opt/conda/bin/conda install --yes -c conda-forge \
|
||||||
python=3.6 sqlalchemy tornado jinja2 traitlets requests pip pycurl \
|
python=3.6 sqlalchemy tornado jinja2 traitlets requests pip pycurl \
|
||||||
|
1
PULL_REQUEST_TEMPLATE.md
Normal file
1
PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
53
README.md
53
README.md
@@ -11,8 +11,8 @@
|
|||||||
|
|
||||||
|
|
||||||
[](https://pypi.python.org/pypi/jupyterhub)
|
[](https://pypi.python.org/pypi/jupyterhub)
|
||||||
[](http://jupyterhub.readthedocs.org/en/latest/?badge=latest)
|
[](https://jupyterhub.readthedocs.org/en/latest/?badge=latest)
|
||||||
[](http://jupyterhub.readthedocs.io/en/0.7.2/?badge=0.7.2)
|
[](https://jupyterhub.readthedocs.io/en/0.7.2/?badge=0.7.2)
|
||||||
[](https://travis-ci.org/jupyterhub/jupyterhub)
|
[](https://travis-ci.org/jupyterhub/jupyterhub)
|
||||||
[](https://circleci.com/gh/jupyterhub/jupyterhub)
|
[](https://circleci.com/gh/jupyterhub/jupyterhub)
|
||||||
[](https://codecov.io/github/jupyterhub/jupyterhub?branch=master)
|
[](https://codecov.io/github/jupyterhub/jupyterhub?branch=master)
|
||||||
@@ -50,37 +50,62 @@ for administration of the Hub and its users.
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
||||||
### Check prerequisites
|
### Check prerequisites
|
||||||
|
|
||||||
A Linux/Unix based system with the following:
|
- A Linux/Unix based system
|
||||||
|
- [Python](https://www.python.org/downloads/) 3.5 or greater
|
||||||
|
- [nodejs/npm](https://www.npmjs.com/)
|
||||||
|
|
||||||
- [Python](https://www.python.org/downloads/) 3.4 or greater
|
* If you are using **`conda`**, the nodejs and npm dependencies will be installed for
|
||||||
- [nodejs/npm](https://www.npmjs.com/) Install a recent version of
|
you by conda.
|
||||||
[nodejs/npm](https://docs.npmjs.com/getting-started/installing-node)
|
|
||||||
For example, install it on Linux (Debian/Ubuntu) using:
|
|
||||||
|
|
||||||
sudo apt-get install npm nodejs-legacy
|
* If you are using **`pip`**, install a recent version of
|
||||||
|
[nodejs/npm](https://docs.npmjs.com/getting-started/installing-node).
|
||||||
|
For example, install it on Linux (Debian/Ubuntu) using:
|
||||||
|
|
||||||
The `nodejs-legacy` package installs the `node` executable and is currently
|
```
|
||||||
required for npm to work on Debian/Ubuntu.
|
sudo apt-get install npm nodejs-legacy
|
||||||
|
```
|
||||||
|
|
||||||
|
The `nodejs-legacy` package installs the `node` executable and is currently
|
||||||
|
required for npm to work on Debian/Ubuntu.
|
||||||
|
|
||||||
- TLS certificate and key for HTTPS communication
|
- TLS certificate and key for HTTPS communication
|
||||||
- Domain name
|
- Domain name
|
||||||
|
|
||||||
### Install packages
|
### Install packages
|
||||||
|
|
||||||
|
#### Using `conda`
|
||||||
|
|
||||||
|
To install JupyterHub along with its dependencies including nodejs/npm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda install -c conda-forge jupyterhub
|
||||||
|
```
|
||||||
|
|
||||||
|
If you plan to run notebook servers locally, install the Jupyter notebook
|
||||||
|
or JupyterLab:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda install notebook
|
||||||
|
conda install jupyterlab
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Using `pip`
|
||||||
|
|
||||||
JupyterHub can be installed with `pip`, and the proxy with `npm`:
|
JupyterHub can be installed with `pip`, and the proxy with `npm`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g configurable-http-proxy
|
npm install -g configurable-http-proxy
|
||||||
pip3 install jupyterhub
|
python3 -m pip install jupyterhub
|
||||||
```
|
```
|
||||||
|
|
||||||
If you plan to run notebook servers locally, you will need to install the
|
If you plan to run notebook servers locally, you will need to install the
|
||||||
[Jupyter notebook](https://jupyter.readthedocs.io/en/latest/install.html)
|
[Jupyter notebook](https://jupyter.readthedocs.io/en/latest/install.html)
|
||||||
package:
|
package:
|
||||||
|
|
||||||
pip3 install --upgrade notebook
|
python3 -m pip install --upgrade notebook
|
||||||
|
|
||||||
### Run the Hub server
|
### Run the Hub server
|
||||||
|
|
||||||
@@ -99,7 +124,7 @@ more configuration of the system.
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The [Getting Started](http://jupyterhub.readthedocs.io/en/latest/getting-started/index.html) section of the
|
The [Getting Started](https://jupyterhub.readthedocs.io/en/latest/getting-started/index.html) section of the
|
||||||
documentation explains the common steps in setting up JupyterHub.
|
documentation explains the common steps in setting up JupyterHub.
|
||||||
|
|
||||||
The [**JupyterHub tutorial**](https://github.com/jupyterhub/jupyterhub-tutorial)
|
The [**JupyterHub tutorial**](https://github.com/jupyterhub/jupyterhub-tutorial)
|
||||||
@@ -208,7 +233,7 @@ our JupyterHub [Gitter](https://gitter.im/jupyterhub/jupyterhub) channel.
|
|||||||
|
|
||||||
- [Reporting Issues](https://github.com/jupyterhub/jupyterhub/issues)
|
- [Reporting Issues](https://github.com/jupyterhub/jupyterhub/issues)
|
||||||
- [JupyterHub tutorial](https://github.com/jupyterhub/jupyterhub-tutorial)
|
- [JupyterHub tutorial](https://github.com/jupyterhub/jupyterhub-tutorial)
|
||||||
- [Documentation for JupyterHub](http://jupyterhub.readthedocs.io/en/latest/) | [PDF (latest)](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf) | [PDF (stable)](https://media.readthedocs.org/pdf/jupyterhub/stable/jupyterhub.pdf)
|
- [Documentation for JupyterHub](https://jupyterhub.readthedocs.io/en/latest/) | [PDF (latest)](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf) | [PDF (stable)](https://media.readthedocs.org/pdf/jupyterhub/stable/jupyterhub.pdf)
|
||||||
- [Documentation for JupyterHub's REST API](http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/master/docs/rest-api.yml#/default)
|
- [Documentation for JupyterHub's REST API](http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/master/docs/rest-api.yml#/default)
|
||||||
- [Documentation for Project Jupyter](http://jupyter.readthedocs.io/en/latest/index.html) | [PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)
|
- [Documentation for Project Jupyter](http://jupyter.readthedocs.io/en/latest/index.html) | [PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)
|
||||||
- [Project Jupyter website](https://jupyter.org)
|
- [Project Jupyter website](https://jupyter.org)
|
||||||
|
@@ -8,7 +8,7 @@ export MYSQL_HOST=127.0.0.1
|
|||||||
export MYSQL_TCP_PORT=${MYSQL_TCP_PORT:-13306}
|
export MYSQL_TCP_PORT=${MYSQL_TCP_PORT:-13306}
|
||||||
export PGHOST=127.0.0.1
|
export PGHOST=127.0.0.1
|
||||||
NAME="hub-test-$DB"
|
NAME="hub-test-$DB"
|
||||||
DOCKER_RUN="docker run --rm -d --name $NAME"
|
DOCKER_RUN="docker run -d --name $NAME"
|
||||||
|
|
||||||
docker rm -f "$NAME" 2>/dev/null || true
|
docker rm -f "$NAME" 2>/dev/null || true
|
||||||
|
|
||||||
@@ -47,4 +47,4 @@ Set these environment variables:
|
|||||||
export MYSQL_HOST=127.0.0.1
|
export MYSQL_HOST=127.0.0.1
|
||||||
export MYSQL_TCP_PORT=$MYSQL_TCP_PORT
|
export MYSQL_TCP_PORT=$MYSQL_TCP_PORT
|
||||||
export PGHOST=127.0.0.1
|
export PGHOST=127.0.0.1
|
||||||
"
|
"
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
mock
|
mock
|
||||||
|
beautifulsoup4
|
||||||
codecov
|
codecov
|
||||||
cryptography
|
cryptography
|
||||||
pytest-cov
|
pytest-cov
|
||||||
@@ -8,3 +9,6 @@ pytest>=3.3
|
|||||||
notebook
|
notebook
|
||||||
requests-mock
|
requests-mock
|
||||||
virtualenv
|
virtualenv
|
||||||
|
# temporary pin of attrs for jsonschema 0.3.0a1
|
||||||
|
# seems to be a pip bug
|
||||||
|
attrs>=17.4.0
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
# ReadTheDocs uses the `environment.yaml` so make sure to update that as well
|
||||||
|
# if you change the dependencies of JupyterHub in the various `requirements.txt`
|
||||||
name: jhub_docs
|
name: jhub_docs
|
||||||
channels:
|
channels:
|
||||||
- conda-forge
|
- conda-forge
|
||||||
@@ -15,3 +17,6 @@ dependencies:
|
|||||||
- pip:
|
- pip:
|
||||||
- python-oauth2
|
- python-oauth2
|
||||||
- recommonmark==0.4.0
|
- recommonmark==0.4.0
|
||||||
|
- async_generator
|
||||||
|
- prometheus_client
|
||||||
|
- attrs>=17.4.0
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
# ReadTheDocs uses the `environment.yaml` so make sure to update that as well
|
||||||
|
# if you change this file
|
||||||
-r ../requirements.txt
|
-r ../requirements.txt
|
||||||
sphinx>=1.7
|
sphinx>=1.7
|
||||||
recommonmark==0.4.0
|
recommonmark==0.4.0
|
||||||
|
@@ -3,7 +3,7 @@ swagger: '2.0'
|
|||||||
info:
|
info:
|
||||||
title: JupyterHub
|
title: JupyterHub
|
||||||
description: The REST API for JupyterHub
|
description: The REST API for JupyterHub
|
||||||
version: 0.9.0dev
|
version: 0.9.4
|
||||||
license:
|
license:
|
||||||
name: BSD-3-Clause
|
name: BSD-3-Clause
|
||||||
schemes:
|
schemes:
|
||||||
@@ -252,6 +252,17 @@ paths:
|
|||||||
$ref: '#/definitions/Token'
|
$ref: '#/definitions/Token'
|
||||||
post:
|
post:
|
||||||
summary: Create a new token for the user
|
summary: Create a new token for the user
|
||||||
|
parameters:
|
||||||
|
- name: expires_in
|
||||||
|
type: number
|
||||||
|
required: false
|
||||||
|
in: body
|
||||||
|
description: lifetime (in seconds) after which the requested token will expire.
|
||||||
|
- name: note
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
in: body
|
||||||
|
description: A note attached to the token for future bookkeeping
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: The newly created token
|
description: The newly created token
|
||||||
@@ -689,6 +700,11 @@ definitions:
|
|||||||
description: The command used to start the service (if managed)
|
description: The command used to start the service (if managed)
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
info:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
Additional information a deployment can attach to a service.
|
||||||
|
JupyterHub does not use this field.
|
||||||
Token:
|
Token:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -711,6 +727,10 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
description: Timestamp when this token was created
|
description: Timestamp when this token was created
|
||||||
|
expires_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Timestamp when this token expires. Null if there is no expiry.
|
||||||
last_activity:
|
last_activity:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
@@ -9,7 +9,54 @@ command line for details.
|
|||||||
|
|
||||||
## 0.9
|
## 0.9
|
||||||
|
|
||||||
### 0.9.0
|
### [0.9.4] 2018-09-24
|
||||||
|
|
||||||
|
JupyterHub 0.9.4 is a small bugfix release.
|
||||||
|
|
||||||
|
- Fixes an issue that required all running user servers to be restarted
|
||||||
|
when performing an upgrade from 0.8 to 0.9.
|
||||||
|
- Fixes content-type for API endpoints back to `application/json`.
|
||||||
|
It was `text/html` in 0.9.0-0.9.3.
|
||||||
|
|
||||||
|
### [0.9.3] 2018-09-12
|
||||||
|
|
||||||
|
JupyterHub 0.9.3 contains small bugfixes and improvements
|
||||||
|
|
||||||
|
- Fix token page and model handling of `expires_at`.
|
||||||
|
This field was missing from the REST API model for tokens
|
||||||
|
and could cause the token page to not render
|
||||||
|
- Add keep-alive to progress event stream to avoid proxies dropping
|
||||||
|
the connection due to inactivity
|
||||||
|
- Documentation and example improvements
|
||||||
|
- Disable quit button when using notebook 5.6
|
||||||
|
- Prototype new feature (may change prior to 1.0):
|
||||||
|
pass requesting Handler to Spawners during start,
|
||||||
|
accessible as `self.handler`
|
||||||
|
|
||||||
|
### [0.9.2] 2018-08-10
|
||||||
|
|
||||||
|
JupyterHub 0.9.2 contains small bugfixes and improvements.
|
||||||
|
|
||||||
|
- Documentation and example improvements
|
||||||
|
- Add `Spawner.consecutive_failure_limit` config for aborting the Hub if too many spawns fail in a row.
|
||||||
|
- Fix for handling SIGTERM when run with asyncio (tornado 5)
|
||||||
|
- Windows compatibility fixes
|
||||||
|
|
||||||
|
|
||||||
|
### [0.9.1] 2018-07-04
|
||||||
|
|
||||||
|
JupyterHub 0.9.1 contains a number of small bugfixes on top of 0.9.
|
||||||
|
|
||||||
|
- Use a PID file for the proxy to decrease the likelihood that a leftover proxy process will prevent JupyterHub from restarting
|
||||||
|
- `c.LocalProcessSpawner.shell_cmd` is now configurable
|
||||||
|
- API requests to stopped servers (requests to the hub for `/user/:name/api/...`) fail with 404 rather than triggering a restart of the server
|
||||||
|
- Compatibility fix for notebook 5.6.0 which will introduce further
|
||||||
|
security checks for local connections
|
||||||
|
- Managed services always use localhost to talk to the Hub if the Hub listening on all interfaces
|
||||||
|
- When using a URL prefix, the Hub route will be `JupyterHub.base_url` instead of unconditionally `/`
|
||||||
|
- additional fixes and improvements
|
||||||
|
|
||||||
|
### [0.9.0] 2018-06-15
|
||||||
|
|
||||||
JupyterHub 0.9 is a major upgrade of JupyterHub.
|
JupyterHub 0.9 is a major upgrade of JupyterHub.
|
||||||
There are several changes to the database schema,
|
There are several changes to the database schema,
|
||||||
@@ -51,7 +98,7 @@ and tornado < 5.0.
|
|||||||
Sets ip, port, base_url all at once.
|
Sets ip, port, base_url all at once.
|
||||||
- Add `JupyterHub.hub_bind_url` for setting the full host+port of the Hub.
|
- Add `JupyterHub.hub_bind_url` for setting the full host+port of the Hub.
|
||||||
`hub_bind_url` supports unix domain sockets, e.g.
|
`hub_bind_url` supports unix domain sockets, e.g.
|
||||||
`unix+http://%2Fsrv%2Fjupytrehub.sock`
|
`unix+http://%2Fsrv%2Fjupyterhub.sock`
|
||||||
- Deprecate `JupyterHub.hub_connect_port` config in favor of `JupyterHub.hub_connect_url`. `hub_connect_ip` is not deprecated
|
- Deprecate `JupyterHub.hub_connect_port` config in favor of `JupyterHub.hub_connect_url`. `hub_connect_ip` is not deprecated
|
||||||
and can still be used in the common case where only the ip address of the hub differs from the bind ip.
|
and can still be used in the common case where only the ip address of the hub differs from the bind ip.
|
||||||
|
|
||||||
@@ -93,6 +140,12 @@ and tornado < 5.0.
|
|||||||
- Add session-id cookie, enabling immediate revocation of login tokens.
|
- Add session-id cookie, enabling immediate revocation of login tokens.
|
||||||
- Authenticators may specify that users are admins by specifying the `admin` key when return the user model as a dict.
|
- Authenticators may specify that users are admins by specifying the `admin` key when return the user model as a dict.
|
||||||
- Added "Start All" button to admin page for launching all user servers at once.
|
- Added "Start All" button to admin page for launching all user servers at once.
|
||||||
|
- Services have an `info` field which is a dictionary.
|
||||||
|
This is accessible via the REST API.
|
||||||
|
- `JupyterHub.extra_handlers` allows defining additional tornado RequestHandlers attached to the Hub.
|
||||||
|
- API tokens may now expire.
|
||||||
|
Expiry is available in the REST model as `expires_at`,
|
||||||
|
and settable when creating API tokens by specifying `expires_in`.
|
||||||
|
|
||||||
|
|
||||||
#### Fixed
|
#### Fixed
|
||||||
@@ -113,6 +166,11 @@ and tornado < 5.0.
|
|||||||
- Various fixes in race conditions and performance improvements with the default proxy.
|
- Various fixes in race conditions and performance improvements with the default proxy.
|
||||||
- Fixes for CORS headers
|
- Fixes for CORS headers
|
||||||
- Stop setting `.form-control` on spawner form inputs unconditionally.
|
- Stop setting `.form-control` on spawner form inputs unconditionally.
|
||||||
|
- Better recovery from database errors and database connection issues
|
||||||
|
without having to restart the Hub.
|
||||||
|
- Fix handling of `~` character in usernames.
|
||||||
|
- Fix jupyterhub startup when `getpass.getuser()` would fail,
|
||||||
|
e.g. due to missing entry in passwd file in containers.
|
||||||
|
|
||||||
|
|
||||||
## 0.8
|
## 0.8
|
||||||
@@ -368,7 +426,12 @@ Fix removal of `/login` page in 0.4.0, breaking some OAuth providers.
|
|||||||
First preview release
|
First preview release
|
||||||
|
|
||||||
|
|
||||||
[Unreleased]: https://github.com/jupyterhub/jupyterhub/compare/0.8.1...HEAD
|
[Unreleased]: https://github.com/jupyterhub/jupyterhub/compare/0.9.4...HEAD
|
||||||
|
[0.9.4]: https://github.com/jupyterhub/jupyterhub/compare/0.9.3...0.9.4
|
||||||
|
[0.9.3]: https://github.com/jupyterhub/jupyterhub/compare/0.9.2...0.9.3
|
||||||
|
[0.9.2]: https://github.com/jupyterhub/jupyterhub/compare/0.9.1...0.9.2
|
||||||
|
[0.9.1]: https://github.com/jupyterhub/jupyterhub/compare/0.9.0...0.9.1
|
||||||
|
[0.9.0]: https://github.com/jupyterhub/jupyterhub/compare/0.8.1...0.9.0
|
||||||
[0.8.1]: https://github.com/jupyterhub/jupyterhub/compare/0.8.0...0.8.1
|
[0.8.1]: https://github.com/jupyterhub/jupyterhub/compare/0.8.0...0.8.1
|
||||||
[0.8.0]: https://github.com/jupyterhub/jupyterhub/compare/0.7.2...0.8.0
|
[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.2]: https://github.com/jupyterhub/jupyterhub/compare/0.7.1...0.7.2
|
||||||
|
@@ -35,12 +35,14 @@ author = u'Project Jupyter team'
|
|||||||
|
|
||||||
# Autopopulate version
|
# Autopopulate version
|
||||||
from os.path import dirname
|
from os.path import dirname
|
||||||
|
|
||||||
docs = dirname(dirname(__file__))
|
docs = dirname(dirname(__file__))
|
||||||
root = dirname(docs)
|
root = dirname(docs)
|
||||||
sys.path.insert(0, root)
|
sys.path.insert(0, root)
|
||||||
sys.path.insert(0, os.path.join(docs, 'sphinxext'))
|
sys.path.insert(0, os.path.join(docs, 'sphinxext'))
|
||||||
|
|
||||||
import jupyterhub
|
import jupyterhub
|
||||||
|
|
||||||
# The short X.Y version.
|
# The short X.Y version.
|
||||||
version = '%i.%i' % jupyterhub.version_info[:2]
|
version = '%i.%i' % jupyterhub.version_info[:2]
|
||||||
# The full version, including alpha/beta/rc tags.
|
# The full version, including alpha/beta/rc tags.
|
||||||
@@ -56,12 +58,10 @@ default_role = 'literal'
|
|||||||
|
|
||||||
# -- Source -------------------------------------------------------------
|
# -- Source -------------------------------------------------------------
|
||||||
|
|
||||||
source_parsers = {
|
source_parsers = {'.md': 'recommonmark.parser.CommonMarkParser'}
|
||||||
'.md': 'recommonmark.parser.CommonMarkParser',
|
|
||||||
}
|
|
||||||
|
|
||||||
source_suffix = ['.rst', '.md']
|
source_suffix = ['.rst', '.md']
|
||||||
#source_encoding = 'utf-8-sig'
|
# source_encoding = 'utf-8-sig'
|
||||||
|
|
||||||
# -- Options for HTML output ----------------------------------------------
|
# -- Options for HTML output ----------------------------------------------
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ html_sidebars = {
|
|||||||
'navigation.html',
|
'navigation.html',
|
||||||
'relations.html',
|
'relations.html',
|
||||||
'sourcelink.html',
|
'sourcelink.html',
|
||||||
],
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
htmlhelp_basename = 'JupyterHubdoc'
|
htmlhelp_basename = 'JupyterHubdoc'
|
||||||
@@ -104,38 +104,40 @@ htmlhelp_basename = 'JupyterHubdoc'
|
|||||||
# -- Options for LaTeX output ---------------------------------------------
|
# -- Options for LaTeX output ---------------------------------------------
|
||||||
|
|
||||||
latex_elements = {
|
latex_elements = {
|
||||||
#'papersize': 'letterpaper',
|
# 'papersize': 'letterpaper',
|
||||||
#'pointsize': '10pt',
|
# 'pointsize': '10pt',
|
||||||
#'preamble': '',
|
# 'preamble': '',
|
||||||
#'figure_align': 'htbp',
|
# 'figure_align': 'htbp',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Grouping the document tree into LaTeX files. List of tuples
|
# Grouping the document tree into LaTeX files. List of tuples
|
||||||
# (source start file, target name, title,
|
# (source start file, target name, title,
|
||||||
# author, documentclass [howto, manual, or own class]).
|
# author, documentclass [howto, manual, or own class]).
|
||||||
latex_documents = [
|
latex_documents = [
|
||||||
(master_doc, 'JupyterHub.tex', u'JupyterHub Documentation',
|
(
|
||||||
u'Project Jupyter team', 'manual'),
|
master_doc,
|
||||||
|
'JupyterHub.tex',
|
||||||
|
u'JupyterHub Documentation',
|
||||||
|
u'Project Jupyter team',
|
||||||
|
'manual',
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
#latex_logo = None
|
# latex_logo = None
|
||||||
#latex_use_parts = False
|
# latex_use_parts = False
|
||||||
#latex_show_pagerefs = False
|
# latex_show_pagerefs = False
|
||||||
#latex_show_urls = False
|
# latex_show_urls = False
|
||||||
#latex_appendices = []
|
# latex_appendices = []
|
||||||
#latex_domain_indices = True
|
# latex_domain_indices = True
|
||||||
|
|
||||||
|
|
||||||
# -- manual page output -------------------------------------------------
|
# -- manual page output -------------------------------------------------
|
||||||
|
|
||||||
# One entry per manual page. List of tuples
|
# One entry per manual page. List of tuples
|
||||||
# (source start file, name, description, authors, manual section).
|
# (source start file, name, description, authors, manual section).
|
||||||
man_pages = [
|
man_pages = [(master_doc, 'jupyterhub', u'JupyterHub Documentation', [author], 1)]
|
||||||
(master_doc, 'jupyterhub', u'JupyterHub Documentation',
|
|
||||||
[author], 1)
|
|
||||||
]
|
|
||||||
|
|
||||||
#man_show_urls = False
|
# man_show_urls = False
|
||||||
|
|
||||||
|
|
||||||
# -- Texinfo output -----------------------------------------------------
|
# -- Texinfo output -----------------------------------------------------
|
||||||
@@ -144,15 +146,21 @@ man_pages = [
|
|||||||
# (source start file, target name, title, author,
|
# (source start file, target name, title, author,
|
||||||
# dir menu entry, description, category)
|
# dir menu entry, description, category)
|
||||||
texinfo_documents = [
|
texinfo_documents = [
|
||||||
(master_doc, 'JupyterHub', u'JupyterHub Documentation',
|
(
|
||||||
author, 'JupyterHub', 'One line description of project.',
|
master_doc,
|
||||||
'Miscellaneous'),
|
'JupyterHub',
|
||||||
|
u'JupyterHub Documentation',
|
||||||
|
author,
|
||||||
|
'JupyterHub',
|
||||||
|
'One line description of project.',
|
||||||
|
'Miscellaneous',
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
#texinfo_appendices = []
|
# texinfo_appendices = []
|
||||||
#texinfo_domain_indices = True
|
# texinfo_domain_indices = True
|
||||||
#texinfo_show_urls = 'footnote'
|
# texinfo_show_urls = 'footnote'
|
||||||
#texinfo_no_detailmenu = False
|
# texinfo_no_detailmenu = False
|
||||||
|
|
||||||
|
|
||||||
# -- Epub output --------------------------------------------------------
|
# -- Epub output --------------------------------------------------------
|
||||||
@@ -179,6 +187,7 @@ else:
|
|||||||
# readthedocs.org uses their theme by default, so no need to specify it
|
# readthedocs.org uses their theme by default, so no need to specify it
|
||||||
# build rest-api, since RTD doesn't run make
|
# build rest-api, since RTD doesn't run make
|
||||||
from subprocess import check_call as sh
|
from subprocess import check_call as sh
|
||||||
|
|
||||||
sh(['make', 'rest-api'], cwd=docs)
|
sh(['make', 'rest-api'], cwd=docs)
|
||||||
|
|
||||||
# -- Spell checking -------------------------------------------------------
|
# -- Spell checking -------------------------------------------------------
|
||||||
@@ -190,4 +199,4 @@ except ImportError:
|
|||||||
else:
|
else:
|
||||||
extensions.append("sphinxcontrib.spelling")
|
extensions.append("sphinxcontrib.spelling")
|
||||||
|
|
||||||
spelling_word_list_filename='spelling_wordlist.txt'
|
spelling_word_list_filename = 'spelling_wordlist.txt'
|
||||||
|
@@ -3,38 +3,65 @@
|
|||||||
Project Jupyter thanks the following people for their help and
|
Project Jupyter thanks the following people for their help and
|
||||||
contribution on JupyterHub:
|
contribution on JupyterHub:
|
||||||
|
|
||||||
|
- adelcast
|
||||||
- Analect
|
- Analect
|
||||||
- anderbubble
|
- anderbubble
|
||||||
|
- anikitml
|
||||||
|
- ankitksharma
|
||||||
- apetresc
|
- apetresc
|
||||||
|
- athornton
|
||||||
- barrachri
|
- barrachri
|
||||||
|
- BerserkerTroll
|
||||||
- betatim
|
- betatim
|
||||||
- Carreau
|
- Carreau
|
||||||
|
- cfournie
|
||||||
- charnpreetsingh
|
- charnpreetsingh
|
||||||
|
- chicovenancio
|
||||||
|
- cikao
|
||||||
- ckald
|
- ckald
|
||||||
|
- cmoscardi
|
||||||
|
- consideRatio
|
||||||
|
- cqzlxl
|
||||||
- CRegenschein
|
- CRegenschein
|
||||||
- cwaldbieser
|
- cwaldbieser
|
||||||
- danielballen
|
- danielballen
|
||||||
- danoventa
|
- danoventa
|
||||||
- daradib
|
- daradib
|
||||||
|
- darky2004
|
||||||
- datapolitan
|
- datapolitan
|
||||||
- dblockow-d2dcrc
|
- dblockow-d2dcrc
|
||||||
- DeepHorizons
|
- DeepHorizons
|
||||||
|
- DerekHeldtWerle
|
||||||
- dhirschfeld
|
- dhirschfeld
|
||||||
- dietmarw
|
- dietmarw
|
||||||
|
- dingc3
|
||||||
- dmartzol
|
- dmartzol
|
||||||
- DominicFollettSmith
|
- DominicFollettSmith
|
||||||
- dsblank
|
- dsblank
|
||||||
|
- dtaniwaki
|
||||||
|
- echarles
|
||||||
- ellisonbg
|
- ellisonbg
|
||||||
|
- emmanuel
|
||||||
- evanlinde
|
- evanlinde
|
||||||
- Fokko
|
- Fokko
|
||||||
- fperez
|
- fperez
|
||||||
|
- franga2000
|
||||||
|
- GladysNalvarte
|
||||||
|
- glenak1911
|
||||||
|
- gweis
|
||||||
- iamed18
|
- iamed18
|
||||||
|
- jamescurtin
|
||||||
- JamiesHQ
|
- JamiesHQ
|
||||||
|
- JasonJWilliamsNY
|
||||||
- jbweston
|
- jbweston
|
||||||
- jdavidheiser
|
- jdavidheiser
|
||||||
- jencabral
|
- jencabral
|
||||||
- jhamrick
|
- jhamrick
|
||||||
|
- jkinkead
|
||||||
|
- johnkpark
|
||||||
- josephtate
|
- josephtate
|
||||||
|
- jzf2101
|
||||||
|
- karfai
|
||||||
- kinuax
|
- kinuax
|
||||||
- KrishnaPG
|
- KrishnaPG
|
||||||
- kroq-gar78
|
- kroq-gar78
|
||||||
@@ -44,27 +71,44 @@ contribution on JupyterHub:
|
|||||||
- minrk
|
- minrk
|
||||||
- mistercrunch
|
- mistercrunch
|
||||||
- Mistobaan
|
- Mistobaan
|
||||||
|
- mpacer
|
||||||
- mwmarkland
|
- mwmarkland
|
||||||
|
- ndly
|
||||||
- nthiery
|
- nthiery
|
||||||
|
- nxg
|
||||||
- ObiWahn
|
- ObiWahn
|
||||||
- ozancaglayan
|
- ozancaglayan
|
||||||
|
- paccorsi
|
||||||
- parente
|
- parente
|
||||||
- PeterDaveHello
|
- PeterDaveHello
|
||||||
- peterruppel
|
- peterruppel
|
||||||
|
- phill84
|
||||||
- pjamason
|
- pjamason
|
||||||
- prasadkatti
|
- prasadkatti
|
||||||
- rafael-ladislau
|
- rafael-ladislau
|
||||||
|
- rcthomas
|
||||||
- rgbkrk
|
- rgbkrk
|
||||||
|
- rkdarst
|
||||||
- robnagler
|
- robnagler
|
||||||
|
- rschroll
|
||||||
- ryanlovett
|
- ryanlovett
|
||||||
|
- sangramga
|
||||||
- Scrypy
|
- Scrypy
|
||||||
|
- schon
|
||||||
- shreddd
|
- shreddd
|
||||||
|
- Siecje
|
||||||
|
- smiller5678
|
||||||
- spoorthyv
|
- spoorthyv
|
||||||
- ssanderson
|
- ssanderson
|
||||||
|
- summerswallow
|
||||||
|
- syutbai
|
||||||
- takluyver
|
- takluyver
|
||||||
- temogen
|
- temogen
|
||||||
- ThomasMChen
|
- ThomasMChen
|
||||||
|
- Thoralf Gutierrez
|
||||||
|
- timfreund
|
||||||
- TimShawver
|
- TimShawver
|
||||||
|
- tklever
|
||||||
- Todd-Z-Li
|
- Todd-Z-Li
|
||||||
- toobaz
|
- toobaz
|
||||||
- tsaeger
|
- tsaeger
|
||||||
|
@@ -96,4 +96,4 @@ A generic implementation, which you can use for OAuth authentication
|
|||||||
with any provider, is also available.
|
with any provider, is also available.
|
||||||
|
|
||||||
[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
||||||
[OAuthenticator]: https://github.com/jupyterhub/oauthenticator
|
[OAuthenticator]: https://github.com/jupyterhub/oauthenticator
|
||||||
|
@@ -35,6 +35,10 @@ Configuring only the main IP and port of JupyterHub should be sufficient for
|
|||||||
most deployments of JupyterHub. However, more customized scenarios may need
|
most deployments of JupyterHub. However, more customized scenarios may need
|
||||||
additional networking details to be configured.
|
additional networking details to be configured.
|
||||||
|
|
||||||
|
Note that `c.JupyterHub.ip` and `c.JupyterHub.port` are single values,
|
||||||
|
not tuples or lists – JupyterHub listens to only a single IP address and
|
||||||
|
port.
|
||||||
|
|
||||||
## Set the Proxy's REST API communication URL (optional)
|
## Set the Proxy's REST API communication URL (optional)
|
||||||
|
|
||||||
By default, this REST API listens on port 8081 of `localhost` only.
|
By default, this REST API listens on port 8081 of `localhost` only.
|
||||||
@@ -86,3 +90,12 @@ configuration for, e.g. docker, is:
|
|||||||
c.JupyterHub.hub_ip = '0.0.0.0' # listen on all interfaces
|
c.JupyterHub.hub_ip = '0.0.0.0' # listen on all interfaces
|
||||||
c.JupyterHub.hub_connect_ip = '10.0.1.4' # ip as seen on the docker network. Can also be a hostname.
|
c.JupyterHub.hub_connect_ip = '10.0.1.4' # ip as seen on the docker network. Can also be a hostname.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Adjusting the hub's URL
|
||||||
|
|
||||||
|
The hub will most commonly be running on a hostname of its own. If it
|
||||||
|
is not – for example, if the hub is being reverse-proxied and being
|
||||||
|
exposed at a URL such as `https://proxy.example.org/jupyter/` – then
|
||||||
|
you will need to tell JupyterHub the base URL of the service. In such
|
||||||
|
a case, it is both necessary and sufficient to set
|
||||||
|
`c.JupyterHub.base_url = '/jupyter/'` in the configuration.
|
||||||
|
@@ -45,7 +45,7 @@ is important that these files be put in a secure location on your server, where
|
|||||||
they are not readable by regular users.
|
they are not readable by regular users.
|
||||||
|
|
||||||
If you are using a **chain certificate**, see also chained certificate for SSL
|
If you are using a **chain certificate**, see also chained certificate for SSL
|
||||||
in the JupyterHub `troubleshooting FAQ <troubleshooting>`_.
|
in the JupyterHub `Troubleshooting FAQ <../troubleshooting.html>`_.
|
||||||
|
|
||||||
Using letsencrypt
|
Using letsencrypt
|
||||||
~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~
|
||||||
@@ -72,8 +72,13 @@ would be the needed configuration:
|
|||||||
If SSL termination happens outside of the Hub
|
If SSL termination happens outside of the Hub
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
In certain cases, e.g. behind `SSL termination in NGINX <https://www.nginx.com/resources/admin-guide/nginx-ssl-termination/>`_,
|
In certain cases, for example if the hub is running behind a reverse proxy, and
|
||||||
allowing no SSL running on the hub may be the desired configuration option.
|
`SSL termination is being provided by NGINX <https://www.nginx.com/resources/admin-guide/nginx-ssl-termination/>`_,
|
||||||
|
it is reasonable to run the hub without SSL.
|
||||||
|
|
||||||
|
To achieve this, simply omit the configuration settings
|
||||||
|
``c.JupyterHub.ssl_key`` and ``c.JupyterHub.ssl_cert``
|
||||||
|
(setting them to ``None`` does not have the same effect, and is an error).
|
||||||
|
|
||||||
.. _cookie-secret:
|
.. _cookie-secret:
|
||||||
|
|
||||||
|
@@ -58,7 +58,12 @@ Contents
|
|||||||
* :doc:`reference/services`
|
* :doc:`reference/services`
|
||||||
* :doc:`reference/rest`
|
* :doc:`reference/rest`
|
||||||
* :doc:`reference/upgrading`
|
* :doc:`reference/upgrading`
|
||||||
|
* :doc:`reference/templates`
|
||||||
|
* :doc:`reference/config-user-env`
|
||||||
* :doc:`reference/config-examples`
|
* :doc:`reference/config-examples`
|
||||||
|
* :doc:`reference/config-ghoauth`
|
||||||
|
* :doc:`reference/config-proxy`
|
||||||
|
* :doc:`reference/config-sudo`
|
||||||
|
|
||||||
**API Reference**
|
**API Reference**
|
||||||
|
|
||||||
|
@@ -5,20 +5,27 @@
|
|||||||
Before installing JupyterHub, you will need:
|
Before installing JupyterHub, you will need:
|
||||||
|
|
||||||
- a Linux/Unix based system
|
- a Linux/Unix based system
|
||||||
- [Python](https://www.python.org/downloads/) 3.4 or greater. An understanding
|
- [Python](https://www.python.org/downloads/) 3.5 or greater. An understanding
|
||||||
of using [`pip`](https://pip.pypa.io/en/stable/) or
|
of using [`pip`](https://pip.pypa.io/en/stable/) or
|
||||||
[`conda`](https://conda.io/docs/get-started.html) for
|
[`conda`](https://conda.io/docs/get-started.html) for
|
||||||
installing Python packages is helpful.
|
installing Python packages is helpful.
|
||||||
- [nodejs/npm](https://www.npmjs.com/). [Install nodejs/npm](https://docs.npmjs.com/getting-started/installing-node),
|
- [nodejs/npm](https://www.npmjs.com/). [Install nodejs/npm](https://docs.npmjs.com/getting-started/installing-node),
|
||||||
using your operating system's package manager. For example, install on Linux
|
using your operating system's package manager.
|
||||||
Debian/Ubuntu using:
|
|
||||||
|
|
||||||
```bash
|
* If you are using **`conda`**, the nodejs and npm dependencies will be installed for
|
||||||
sudo apt-get install npm nodejs-legacy
|
you by conda.
|
||||||
```
|
|
||||||
|
* If you are using **`pip`**, install a recent version of
|
||||||
|
[nodejs/npm](https://docs.npmjs.com/getting-started/installing-node).
|
||||||
|
For example, install it on Linux (Debian/Ubuntu) using:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo apt-get install npm nodejs-legacy
|
||||||
|
```
|
||||||
|
|
||||||
|
The `nodejs-legacy` package installs the `node` executable and is currently
|
||||||
|
required for npm to work on Debian/Ubuntu.
|
||||||
|
|
||||||
The `nodejs-legacy` package installs the `node` executable and is currently
|
|
||||||
required for `npm` to work on Debian/Ubuntu.
|
|
||||||
- TLS certificate and key for HTTPS communication
|
- TLS certificate and key for HTTPS communication
|
||||||
- Domain name
|
- Domain name
|
||||||
|
|
||||||
|
@@ -38,6 +38,8 @@ with any provider, is also available.
|
|||||||
|
|
||||||
- ldapauthenticator for LDAP
|
- ldapauthenticator for LDAP
|
||||||
- tmpauthenticator for temporary accounts
|
- tmpauthenticator for temporary accounts
|
||||||
|
- For Shibboleth, [jhub_shibboleth_auth](https://github.com/gesiscss/jhub_shibboleth_auth)
|
||||||
|
and [jhub_remote_user_authenticator](https://github.com/cwaldbieser/jhub_remote_user_authenticator)
|
||||||
|
|
||||||
## Technical Overview of Authentication
|
## Technical Overview of Authentication
|
||||||
|
|
||||||
@@ -206,7 +208,13 @@ class MyAuthenticator(Authenticator):
|
|||||||
spawner.environment['UPSTREAM_TOKEN'] = auth_state['upstream_token']
|
spawner.environment['UPSTREAM_TOKEN'] = auth_state['upstream_token']
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## pre_spawn_start and post_spawn_stop hooks
|
||||||
|
|
||||||
|
Authenticators uses two hooks, [pre_spawn_start(user, spawner)][] and
|
||||||
|
[post_spawn_stop(user, spawner)][] to add pass additional state information
|
||||||
|
between the authenticator and a spawner. These hooks are typically used auth-related
|
||||||
|
startup, i.e. opening a PAM session, and auth-related cleanup, i.e. closing a
|
||||||
|
PAM session.
|
||||||
|
|
||||||
## JupyterHub as an OAuth provider
|
## JupyterHub as an OAuth provider
|
||||||
|
|
||||||
@@ -218,5 +226,5 @@ Beginning with version 0.8, JupyterHub is an OAuth provider.
|
|||||||
[OAuth]: https://en.wikipedia.org/wiki/OAuth
|
[OAuth]: https://en.wikipedia.org/wiki/OAuth
|
||||||
[GitHub OAuth]: https://developer.github.com/v3/oauth/
|
[GitHub OAuth]: https://developer.github.com/v3/oauth/
|
||||||
[OAuthenticator]: https://github.com/jupyterhub/oauthenticator
|
[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
|
[pre_spawn_start(user, spawner)]: https://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
|
[post_spawn_stop(user, spawner)]: https://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.post_spawn_stop
|
||||||
|
@@ -1,281 +1,8 @@
|
|||||||
# Configuration examples
|
# Configuration examples
|
||||||
|
|
||||||
This section provides examples, including configuration files and tips, for the
|
The following sections provide examples, including configuration files and tips, for the
|
||||||
following configurations:
|
following:
|
||||||
|
|
||||||
- Using GitHub OAuth
|
- Configuring GitHub OAuth
|
||||||
- Using nginx reverse proxy
|
- Using reverse proxy (nginx and Apache)
|
||||||
|
- Run JupyterHub without root privileges using `sudo`
|
||||||
## Using GitHub OAuth
|
|
||||||
|
|
||||||
In this example, we show a configuration file for a fairly standard JupyterHub
|
|
||||||
deployment with the following assumptions:
|
|
||||||
|
|
||||||
* Running JupyterHub on a single cloud server
|
|
||||||
* Using SSL on the standard HTTPS port 443
|
|
||||||
* Using GitHub OAuth (using oauthenticator) for login
|
|
||||||
* Using the default spawner (to configure other spawners, uncomment and edit
|
|
||||||
`spawner_class` as well as follow the instructions for your desired spawner)
|
|
||||||
* Users exist locally on the server
|
|
||||||
* Users' notebooks to be served from `~/assignments` to allow users to browse
|
|
||||||
for notebooks within other users' home directories
|
|
||||||
* You want the landing page for each user to be a `Welcome.ipynb` notebook in
|
|
||||||
their assignments directory.
|
|
||||||
* All runtime files are put into `/srv/jupyterhub` and log files in `/var/log`.
|
|
||||||
|
|
||||||
|
|
||||||
The `jupyterhub_config.py` file would have these settings:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# jupyterhub_config.py file
|
|
||||||
c = get_config()
|
|
||||||
|
|
||||||
import os
|
|
||||||
pjoin = os.path.join
|
|
||||||
|
|
||||||
runtime_dir = os.path.join('/srv/jupyterhub')
|
|
||||||
ssl_dir = pjoin(runtime_dir, 'ssl')
|
|
||||||
if not os.path.exists(ssl_dir):
|
|
||||||
os.makedirs(ssl_dir)
|
|
||||||
|
|
||||||
# Allows multiple single-server per user
|
|
||||||
c.JupyterHub.allow_named_servers = True
|
|
||||||
|
|
||||||
# https on :443
|
|
||||||
c.JupyterHub.port = 443
|
|
||||||
c.JupyterHub.ssl_key = pjoin(ssl_dir, 'ssl.key')
|
|
||||||
c.JupyterHub.ssl_cert = pjoin(ssl_dir, 'ssl.cert')
|
|
||||||
|
|
||||||
# put the JupyterHub cookie secret and state db
|
|
||||||
# in /var/run/jupyterhub
|
|
||||||
c.JupyterHub.cookie_secret_file = pjoin(runtime_dir, 'cookie_secret')
|
|
||||||
c.JupyterHub.db_url = pjoin(runtime_dir, 'jupyterhub.sqlite')
|
|
||||||
# or `--db=/path/to/jupyterhub.sqlite` on the command-line
|
|
||||||
|
|
||||||
# use GitHub OAuthenticator for local users
|
|
||||||
c.JupyterHub.authenticator_class = 'oauthenticator.LocalGitHubOAuthenticator'
|
|
||||||
c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']
|
|
||||||
|
|
||||||
# create system users that don't exist yet
|
|
||||||
c.LocalAuthenticator.create_system_users = True
|
|
||||||
|
|
||||||
# specify users and admin
|
|
||||||
c.Authenticator.whitelist = {'rgbkrk', 'minrk', 'jhamrick'}
|
|
||||||
c.Authenticator.admin_users = {'jhamrick', 'rgbkrk'}
|
|
||||||
|
|
||||||
# uses the default spawner
|
|
||||||
# To use a different spawner, uncomment `spawner_class` and set to desired
|
|
||||||
# spawner (e.g. SudoSpawner). Follow instructions for desired spawner
|
|
||||||
# configuration.
|
|
||||||
# c.JupyterHub.spawner_class = 'sudospawner.SudoSpawner'
|
|
||||||
|
|
||||||
# start single-user notebook servers in ~/assignments,
|
|
||||||
# with ~/assignments/Welcome.ipynb as the default landing page
|
|
||||||
# this config could also be put in
|
|
||||||
# /etc/jupyter/jupyter_notebook_config.py
|
|
||||||
c.Spawner.notebook_dir = '~/assignments'
|
|
||||||
c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb']
|
|
||||||
```
|
|
||||||
|
|
||||||
Using the GitHub Authenticator requires a few additional
|
|
||||||
environment variable to be set prior to launching JupyterHub:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export GITHUB_CLIENT_ID=github_id
|
|
||||||
export GITHUB_CLIENT_SECRET=github_secret
|
|
||||||
export OAUTH_CALLBACK_URL=https://example.com/hub/oauth_callback
|
|
||||||
export CONFIGPROXY_AUTH_TOKEN=super-secret
|
|
||||||
# append log output to log file /var/log/jupyterhub.log
|
|
||||||
jupyterhub -f /etc/jupyterhub/jupyterhub_config.py &>> /var/log/jupyterhub.log
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
|
||||||
SSL port `443`. This could be useful if the JupyterHub server machine is also
|
|
||||||
hosting other domains or content on `443`. The goal in this example is to
|
|
||||||
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` 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`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Force the proxy to only listen to connections to 127.0.0.1
|
|
||||||
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;
|
|
||||||
server_name HUB.DOMAIN.TLD;
|
|
||||||
|
|
||||||
# Tell all requests to port 80 to be 302 redirected to HTTPS
|
|
||||||
return 302 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
# HTTPS server to handle JupyterHub
|
|
||||||
server {
|
|
||||||
listen 443;
|
|
||||||
ssl on;
|
|
||||||
|
|
||||||
server_name HUB.DOMAIN.TLD;
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem;
|
|
||||||
|
|
||||||
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
|
||||||
ssl_prefer_server_ciphers on;
|
|
||||||
ssl_dhparam /etc/ssl/certs/dhparam.pem;
|
|
||||||
ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA';
|
|
||||||
ssl_session_timeout 1d;
|
|
||||||
ssl_session_cache shared:SSL:50m;
|
|
||||||
ssl_stapling on;
|
|
||||||
ssl_stapling_verify on;
|
|
||||||
add_header Strict-Transport-Security max-age=15768000;
|
|
||||||
|
|
||||||
# Managing literal requests to the JupyterHub front end
|
|
||||||
location / {
|
|
||||||
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;
|
|
||||||
|
|
||||||
# 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
|
|
||||||
server blocks as above for `NO_HUB` and simply add line for the root directory
|
|
||||||
of the site as well as the applicable location call:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name NO_HUB.DOMAIN.TLD;
|
|
||||||
|
|
||||||
# Tell all requests to port 80 to be 302 redirected to HTTPS
|
|
||||||
return 302 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443;
|
|
||||||
ssl on;
|
|
||||||
|
|
||||||
# INSERT OTHER SSL PARAMETERS HERE AS ABOVE
|
|
||||||
# SSL cert may differ
|
|
||||||
|
|
||||||
# Set the appropriate root directory
|
|
||||||
root /var/www/html
|
|
||||||
|
|
||||||
# Set URI handling
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ =404;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Managing requests to verify letsencrypt host
|
|
||||||
location ~ /.well-known {
|
|
||||||
allow all;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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>
|
|
||||||
```
|
|
||||||
|
82
docs/source/reference/config-ghoauth.md
Normal file
82
docs/source/reference/config-ghoauth.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Configure GitHub OAuth
|
||||||
|
|
||||||
|
In this example, we show a configuration file for a fairly standard JupyterHub
|
||||||
|
deployment with the following assumptions:
|
||||||
|
|
||||||
|
* Running JupyterHub on a single cloud server
|
||||||
|
* Using SSL on the standard HTTPS port 443
|
||||||
|
* Using GitHub OAuth (using oauthenticator) for login
|
||||||
|
* Using the default spawner (to configure other spawners, uncomment and edit
|
||||||
|
`spawner_class` as well as follow the instructions for your desired spawner)
|
||||||
|
* Users exist locally on the server
|
||||||
|
* Users' notebooks to be served from `~/assignments` to allow users to browse
|
||||||
|
for notebooks within other users' home directories
|
||||||
|
* You want the landing page for each user to be a `Welcome.ipynb` notebook in
|
||||||
|
their assignments directory.
|
||||||
|
* All runtime files are put into `/srv/jupyterhub` and log files in `/var/log`.
|
||||||
|
|
||||||
|
|
||||||
|
The `jupyterhub_config.py` file would have these settings:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# jupyterhub_config.py file
|
||||||
|
c = get_config()
|
||||||
|
|
||||||
|
import os
|
||||||
|
pjoin = os.path.join
|
||||||
|
|
||||||
|
runtime_dir = os.path.join('/srv/jupyterhub')
|
||||||
|
ssl_dir = pjoin(runtime_dir, 'ssl')
|
||||||
|
if not os.path.exists(ssl_dir):
|
||||||
|
os.makedirs(ssl_dir)
|
||||||
|
|
||||||
|
# Allows multiple single-server per user
|
||||||
|
c.JupyterHub.allow_named_servers = True
|
||||||
|
|
||||||
|
# https on :443
|
||||||
|
c.JupyterHub.port = 443
|
||||||
|
c.JupyterHub.ssl_key = pjoin(ssl_dir, 'ssl.key')
|
||||||
|
c.JupyterHub.ssl_cert = pjoin(ssl_dir, 'ssl.cert')
|
||||||
|
|
||||||
|
# put the JupyterHub cookie secret and state db
|
||||||
|
# in /var/run/jupyterhub
|
||||||
|
c.JupyterHub.cookie_secret_file = pjoin(runtime_dir, 'cookie_secret')
|
||||||
|
c.JupyterHub.db_url = pjoin(runtime_dir, 'jupyterhub.sqlite')
|
||||||
|
# or `--db=/path/to/jupyterhub.sqlite` on the command-line
|
||||||
|
|
||||||
|
# use GitHub OAuthenticator for local users
|
||||||
|
c.JupyterHub.authenticator_class = 'oauthenticator.LocalGitHubOAuthenticator'
|
||||||
|
c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']
|
||||||
|
|
||||||
|
# create system users that don't exist yet
|
||||||
|
c.LocalAuthenticator.create_system_users = True
|
||||||
|
|
||||||
|
# specify users and admin
|
||||||
|
c.Authenticator.whitelist = {'rgbkrk', 'minrk', 'jhamrick'}
|
||||||
|
c.Authenticator.admin_users = {'jhamrick', 'rgbkrk'}
|
||||||
|
|
||||||
|
# uses the default spawner
|
||||||
|
# To use a different spawner, uncomment `spawner_class` and set to desired
|
||||||
|
# spawner (e.g. SudoSpawner). Follow instructions for desired spawner
|
||||||
|
# configuration.
|
||||||
|
# c.JupyterHub.spawner_class = 'sudospawner.SudoSpawner'
|
||||||
|
|
||||||
|
# start single-user notebook servers in ~/assignments,
|
||||||
|
# with ~/assignments/Welcome.ipynb as the default landing page
|
||||||
|
# this config could also be put in
|
||||||
|
# /etc/jupyter/jupyter_notebook_config.py
|
||||||
|
c.Spawner.notebook_dir = '~/assignments'
|
||||||
|
c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb']
|
||||||
|
```
|
||||||
|
|
||||||
|
Using the GitHub Authenticator requires a few additional
|
||||||
|
environment variable to be set prior to launching JupyterHub:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GITHUB_CLIENT_ID=github_id
|
||||||
|
export GITHUB_CLIENT_SECRET=github_secret
|
||||||
|
export OAUTH_CALLBACK_URL=https://example.com/hub/oauth_callback
|
||||||
|
export CONFIGPROXY_AUTH_TOKEN=super-secret
|
||||||
|
# append log output to log file /var/log/jupyterhub.log
|
||||||
|
jupyterhub -f /etc/jupyterhub/jupyterhub_config.py &>> /var/log/jupyterhub.log
|
||||||
|
```
|
192
docs/source/reference/config-proxy.md
Normal file
192
docs/source/reference/config-proxy.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# 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
|
||||||
|
SSL port `443`. This could be useful if the JupyterHub server machine is also
|
||||||
|
hosting other domains or content on `443`. The goal in this example is to
|
||||||
|
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` 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`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Force the proxy to only listen to connections to 127.0.0.1
|
||||||
|
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
|
||||||
|
|
||||||
|
This **`nginx` config file** is fairly standard fare except for the two
|
||||||
|
`location` blocks within the main section for HUB.DOMAIN.tld.
|
||||||
|
To create a new site for jupyterhub in your nginx config, make a new file
|
||||||
|
in `sites.enabled`, e.g. `/etc/nginx/sites.enabled/jupyterhub.conf`:
|
||||||
|
|
||||||
|
```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;
|
||||||
|
server_name HUB.DOMAIN.TLD;
|
||||||
|
|
||||||
|
# Tell all requests to port 80 to be 302 redirected to HTTPS
|
||||||
|
return 302 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS server to handle JupyterHub
|
||||||
|
server {
|
||||||
|
listen 443;
|
||||||
|
ssl on;
|
||||||
|
|
||||||
|
server_name HUB.DOMAIN.TLD;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem;
|
||||||
|
|
||||||
|
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
ssl_dhparam /etc/ssl/certs/dhparam.pem;
|
||||||
|
ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA';
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
ssl_session_cache shared:SSL:50m;
|
||||||
|
ssl_stapling on;
|
||||||
|
ssl_stapling_verify on;
|
||||||
|
add_header Strict-Transport-Security max-age=15768000;
|
||||||
|
|
||||||
|
# Managing literal requests to the JupyterHub front end
|
||||||
|
location / {
|
||||||
|
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;
|
||||||
|
|
||||||
|
# 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
|
||||||
|
server blocks as above for `NO_HUB` and simply add line for the root directory
|
||||||
|
of the site as well as the applicable location call:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name NO_HUB.DOMAIN.TLD;
|
||||||
|
|
||||||
|
# Tell all requests to port 80 to be 302 redirected to HTTPS
|
||||||
|
return 302 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443;
|
||||||
|
ssl on;
|
||||||
|
|
||||||
|
# INSERT OTHER SSL PARAMETERS HERE AS ABOVE
|
||||||
|
# SSL cert may differ
|
||||||
|
|
||||||
|
# Set the appropriate root directory
|
||||||
|
root /var/www/html
|
||||||
|
|
||||||
|
# Set URI handling
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Managing requests to verify letsencrypt host
|
||||||
|
location ~ /.well-known {
|
||||||
|
allow all;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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>
|
||||||
|
```
|
254
docs/source/reference/config-sudo.md
Normal file
254
docs/source/reference/config-sudo.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# Run JupyterHub without root privileges using `sudo`
|
||||||
|
|
||||||
|
**Note:** Setting up `sudo` permissions involves many pieces of system
|
||||||
|
configuration. It is quite easy to get wrong and very difficult to debug.
|
||||||
|
Only do this if you are very sure you must.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
There are many Authenticators and Spawners available for JupyterHub. Some, such
|
||||||
|
as DockerSpawner or OAuthenticator, do not need any elevated permissions. This
|
||||||
|
document describes how to get the full default behavior of JupyterHub while
|
||||||
|
running notebook servers as real system users on a shared system without
|
||||||
|
running the Hub itself as root.
|
||||||
|
|
||||||
|
Since JupyterHub needs to spawn processes as other users, the simplest way
|
||||||
|
is to run it as root, spawning user servers with [setuid](http://linux.die.net/man/2/setuid).
|
||||||
|
But this isn't especially safe, because you have a process running on the
|
||||||
|
public web as root.
|
||||||
|
|
||||||
|
A **more prudent way** to run the server while preserving functionality is to
|
||||||
|
create a dedicated user with `sudo` access restricted to launching and
|
||||||
|
monitoring single-user servers.
|
||||||
|
|
||||||
|
## Create a user
|
||||||
|
|
||||||
|
To do this, first create a user that will run the Hub:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd rhea
|
||||||
|
```
|
||||||
|
|
||||||
|
This user shouldn't have a login shell or password (possible with -r).
|
||||||
|
|
||||||
|
## Set up sudospawner
|
||||||
|
|
||||||
|
Next, you will need [sudospawner](https://github.com/jupyter/sudospawner)
|
||||||
|
to enable monitoring the single-user servers with sudo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo pip install sudospawner
|
||||||
|
```
|
||||||
|
|
||||||
|
Now we have to configure sudo to allow the Hub user (`rhea`) to launch
|
||||||
|
the sudospawner script on behalf of our hub users (here `zoe` and `wash`).
|
||||||
|
We want to confine these permissions to only what we really need.
|
||||||
|
|
||||||
|
## Edit `/etc/sudoers`
|
||||||
|
|
||||||
|
To do this we add to `/etc/sudoers` (use `visudo` for safe editing of sudoers):
|
||||||
|
|
||||||
|
- specify the list of users `JUPYTER_USERS` for whom `rhea` can spawn servers
|
||||||
|
- set the command `JUPYTER_CMD` that `rhea` can execute on behalf of users
|
||||||
|
- give `rhea` permission to run `JUPYTER_CMD` on behalf of `JUPYTER_USERS`
|
||||||
|
without entering a password
|
||||||
|
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# comma-separated whitelist of users that can spawn single-user servers
|
||||||
|
# this should include all of your Hub users
|
||||||
|
Runas_Alias JUPYTER_USERS = rhea, zoe, wash
|
||||||
|
|
||||||
|
# the command(s) the Hub can run on behalf of the above users without needing a password
|
||||||
|
# the exact path may differ, depending on how sudospawner was installed
|
||||||
|
Cmnd_Alias JUPYTER_CMD = /usr/local/bin/sudospawner
|
||||||
|
|
||||||
|
# actually give the Hub user permission to run the above command on behalf
|
||||||
|
# of the above users without prompting for a password
|
||||||
|
rhea ALL=(JUPYTER_USERS) NOPASSWD:JUPYTER_CMD
|
||||||
|
```
|
||||||
|
|
||||||
|
It might be useful to modify `secure_path` to add commands in path.
|
||||||
|
|
||||||
|
As an alternative to adding every user to the `/etc/sudoers` file, you can
|
||||||
|
use a group in the last line above, instead of `JUPYTER_USERS`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rhea ALL=(%jupyterhub) NOPASSWD:JUPYTER_CMD
|
||||||
|
```
|
||||||
|
|
||||||
|
If the `jupyterhub` group exists, there will be no need to edit `/etc/sudoers`
|
||||||
|
again. A new user will gain access to the application when added to the group:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ adduser -G jupyterhub newuser
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test `sudo` setup
|
||||||
|
|
||||||
|
Test that the new user doesn't need to enter a password to run the sudospawner
|
||||||
|
command.
|
||||||
|
|
||||||
|
This should prompt for your password to switch to rhea, but *not* prompt for
|
||||||
|
any password for the second switch. It should show some help output about
|
||||||
|
logging options:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ sudo -u rhea sudo -n -u $USER /usr/local/bin/sudospawner --help
|
||||||
|
Usage: /usr/local/bin/sudospawner [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
--help show this help information
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
And this should fail:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ sudo -u rhea sudo -n -u $USER echo 'fail'
|
||||||
|
sudo: a password is required
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enable PAM for non-root
|
||||||
|
|
||||||
|
By default, [PAM authentication](http://en.wikipedia.org/wiki/Pluggable_authentication_module)
|
||||||
|
is used by JupyterHub. To use PAM, the process may need to be able to read
|
||||||
|
the shadow password database.
|
||||||
|
|
||||||
|
### Shadow group (Linux)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ls -l /etc/shadow
|
||||||
|
-rw-r----- 1 root shadow 2197 Jul 21 13:41 shadow
|
||||||
|
```
|
||||||
|
|
||||||
|
If there's already a shadow group, you are set. If its permissions are more like:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ls -l /etc/shadow
|
||||||
|
-rw------- 1 root wheel 2197 Jul 21 13:41 shadow
|
||||||
|
```
|
||||||
|
|
||||||
|
Then you may want to add a shadow group, and make the shadow file group-readable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ sudo groupadd shadow
|
||||||
|
$ sudo chgrp shadow /etc/shadow
|
||||||
|
$ sudo chmod g+r /etc/shadow
|
||||||
|
```
|
||||||
|
|
||||||
|
We want our new user to be able to read the shadow passwords, so add it to the shadow group:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ sudo usermod -a -G shadow rhea
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want jupyterhub to serve pages on a restricted port (such as port 80 for http),
|
||||||
|
then you will need to give `node` permission to do so:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo setcap 'cap_net_bind_service=+ep' /usr/bin/node
|
||||||
|
```
|
||||||
|
However, you may want to further understand the consequences of this.
|
||||||
|
|
||||||
|
You may also be interested in limiting the amount of CPU any process can use
|
||||||
|
on your server. `cpulimit` is a useful tool that is available for many Linux
|
||||||
|
distributions' packaging system. This can be used to keep any user's process
|
||||||
|
from using too much CPU cycles. You can configure it accoring to [these
|
||||||
|
instructions](http://ubuntuforums.org/showthread.php?t=992706).
|
||||||
|
|
||||||
|
|
||||||
|
### Shadow group (FreeBSD)
|
||||||
|
|
||||||
|
**NOTE:** This has not been tested and may not work as expected.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ls -l /etc/spwd.db /etc/master.passwd
|
||||||
|
-rw------- 1 root wheel 2516 Aug 22 13:35 /etc/master.passwd
|
||||||
|
-rw------- 1 root wheel 40960 Aug 22 13:35 /etc/spwd.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a shadow group if there isn't one, and make the shadow file group-readable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ sudo pw group add shadow
|
||||||
|
$ sudo chgrp shadow /etc/spwd.db
|
||||||
|
$ sudo chmod g+r /etc/spwd.db
|
||||||
|
$ sudo chgrp shadow /etc/master.passwd
|
||||||
|
$ sudo chmod g+r /etc/master.passwd
|
||||||
|
```
|
||||||
|
|
||||||
|
We want our new user to be able to read the shadow passwords, so add it to the
|
||||||
|
shadow group:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ sudo pw user mod rhea -G shadow
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test that PAM works
|
||||||
|
|
||||||
|
We can verify that PAM is working, with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ sudo -u rhea python3 -c "import pamela, getpass; print(pamela.authenticate('$USER', getpass.getpass()))"
|
||||||
|
Password: [enter your unix password]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Make a directory for JupyterHub
|
||||||
|
|
||||||
|
JupyterHub stores its state in a database, so it needs write access to a directory.
|
||||||
|
The simplest way to deal with this is to make a directory owned by your Hub user,
|
||||||
|
and use that as the CWD when launching the server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ sudo mkdir /etc/jupyterhub
|
||||||
|
$ sudo chown rhea /etc/jupyterhub
|
||||||
|
```
|
||||||
|
|
||||||
|
## Start jupyterhub
|
||||||
|
|
||||||
|
Finally, start the server as our newly configured user, `rhea`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cd /etc/jupyterhub
|
||||||
|
$ sudo -u rhea jupyterhub --JupyterHub.spawner_class=sudospawner.SudoSpawner
|
||||||
|
```
|
||||||
|
|
||||||
|
And try logging in.
|
||||||
|
|
||||||
|
### Troubleshooting: SELinux
|
||||||
|
|
||||||
|
If you still get a generic `Permission denied` `PermissionError`, it's possible SELinux is blocking you.
|
||||||
|
Here's how you can make a module to allow this.
|
||||||
|
First, put this in a file sudo_exec_selinux.te:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
module sudo_exec 1.1;
|
||||||
|
|
||||||
|
require {
|
||||||
|
type unconfined_t;
|
||||||
|
type sudo_exec_t;
|
||||||
|
class file { read entrypoint };
|
||||||
|
}
|
||||||
|
|
||||||
|
#============= unconfined_t ==============
|
||||||
|
allow unconfined_t sudo_exec_t:file entrypoint;
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run all of these commands as root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ checkmodule -M -m -o sudo_exec_selinux.mod sudo_exec_selinux.te
|
||||||
|
$ semodule_package -o sudo_exec_selinux.pp -m sudo_exec_selinux.mod
|
||||||
|
$ semodule -i sudo_exec_selinux.pp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Troubleshooting: PAM session errors
|
||||||
|
|
||||||
|
If the PAM authentication doesn't work and you see errors for
|
||||||
|
`login:session-auth`, or similar, considering updating to `master`
|
||||||
|
and/or incorporating this commit https://github.com/jupyter/jupyterhub/commit/40368b8f555f04ffdd662ffe99d32392a088b1d2
|
||||||
|
and configuration option, `c.PAMAuthenticator.open_sessions = False`.
|
147
docs/source/reference/config-user-env.md
Normal file
147
docs/source/reference/config-user-env.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# Configuring user environments
|
||||||
|
|
||||||
|
Deploying JupyterHub means you are providing Jupyter notebook environments for
|
||||||
|
multiple users. Often, this includes a desire to configure the user
|
||||||
|
environment in some way.
|
||||||
|
|
||||||
|
Since the `jupyterhub-singleuser` server extends the standard Jupyter notebook
|
||||||
|
server, most configuration and documentation that applies to Jupyter Notebook
|
||||||
|
applies to the single-user environments. Configuration of user environments
|
||||||
|
typically does not occur through JupyterHub itself, but rather through system-
|
||||||
|
wide configuration of Jupyter, which is inherited by `jupyterhub-singleuser`.
|
||||||
|
|
||||||
|
**Tip:** When searching for configuration tips for JupyterHub user
|
||||||
|
environments, try removing JupyterHub from your search because there are a lot
|
||||||
|
more people out there configuring Jupyter than JupyterHub and the
|
||||||
|
configuration is the same.
|
||||||
|
|
||||||
|
This section will focus on user environments, including:
|
||||||
|
|
||||||
|
- Installing packages
|
||||||
|
- Configuring Jupyter and IPython
|
||||||
|
- Installing kernelspecs
|
||||||
|
- Using containers vs. multi-user hosts
|
||||||
|
|
||||||
|
|
||||||
|
## Installing packages
|
||||||
|
|
||||||
|
To make packages available to users, you generally will install packages
|
||||||
|
system-wide or in a shared environment.
|
||||||
|
|
||||||
|
This installation location should always be in the same environment that
|
||||||
|
`jupyterhub-singleuser` itself is installed in, and must be *readable and
|
||||||
|
executable* by your users. If you want users to be able to install additional
|
||||||
|
packages, it must also be *writable* by your users.
|
||||||
|
|
||||||
|
If you are using a standard system Python install, you would use:
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo python3 -m pip install numpy
|
||||||
|
```
|
||||||
|
|
||||||
|
to install the numpy package in the default system Python 3 environment
|
||||||
|
(typically `/usr/local`).
|
||||||
|
|
||||||
|
You may also use conda to install packages. If you do, you should make sure
|
||||||
|
that the conda environment has appropriate permissions for users to be able to
|
||||||
|
run Python code in the env.
|
||||||
|
|
||||||
|
|
||||||
|
## Configuring Jupyter and IPython
|
||||||
|
|
||||||
|
[Jupyter](https://jupyter-notebook.readthedocs.io/en/stable/config_overview.html)
|
||||||
|
and [IPython](https://ipython.readthedocs.io/en/stable/development/config.html)
|
||||||
|
have their own configuration systems.
|
||||||
|
|
||||||
|
As a JupyterHub administrator, you will typically want to install and configure
|
||||||
|
environments for all JupyterHub users. For example, you wish for each student in
|
||||||
|
a class to have the same user environment configuration.
|
||||||
|
|
||||||
|
Jupyter and IPython support **"system-wide"** locations for configuration, which
|
||||||
|
is the logical place to put global configuration that you want to affect all
|
||||||
|
users. It's generally more efficient to configure user environments "system-wide",
|
||||||
|
and it's a good idea to avoid creating files in users' home directories.
|
||||||
|
|
||||||
|
The typical locations for these config files are:
|
||||||
|
- **system-wide** in `/etc/{jupyter|ipython}`
|
||||||
|
- **env-wide** (environment wide) in `{sys.prefix}/etc/{jupyter|ipython}`.
|
||||||
|
|
||||||
|
### Example: Enable an extension system-wide
|
||||||
|
|
||||||
|
For example, to enable the `cython` IPython extension for all of your users,
|
||||||
|
create the file `/etc/ipython/ipython_config.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.InteractiveShellApp.extensions.append("cython")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Enable a Jupyter notebook configuration setting for all users
|
||||||
|
|
||||||
|
To enable Jupyter notebook's internal idle-shutdown behavior (requires
|
||||||
|
notebook ≥ 5.4), set the following in the `/etc/jupyter/jupyter_notebook_config.py`
|
||||||
|
file:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# shutdown the server after no activity for an hour
|
||||||
|
c.NotebookApp.shutdown_no_activity_timeout = 60 * 60
|
||||||
|
# shutdown kernels after no activity for 20 minutes
|
||||||
|
c.MappingKernelManager.cull_idle_timeout = 20 * 60
|
||||||
|
# check for idle kernels every two minutes
|
||||||
|
c.MappingKernelManager.cull_interval = 2 * 60
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Installing kernelspecs
|
||||||
|
|
||||||
|
You may have multiple Jupyter kernels installed and want to make sure that
|
||||||
|
they are available to all of your users. This means installing kernelspecs
|
||||||
|
either system-wide (e.g. in /usr/local/) or in the `sys.prefix` of JupyterHub
|
||||||
|
itself.
|
||||||
|
|
||||||
|
Jupyter kernelspec installation is system wide by default, but some kernels
|
||||||
|
may default to installing kernelspecs in your home directory. These will need
|
||||||
|
to be moved system-wide to ensure that they are accessible.
|
||||||
|
|
||||||
|
You can see where your kernelspecs are with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jupyter kernelspec list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Installing kernels system-wide
|
||||||
|
|
||||||
|
Assuming I have a Python 2 and Python 3 environment that I want to make
|
||||||
|
sure are available, I can install their specs system-wide (in /usr/local) with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/path/to/python3 -m IPython kernel install --prefix=/usr/local
|
||||||
|
/path/to/python2 -m IPython kernel install --prefix=/usr/local
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Multi-user hosts vs. Containers
|
||||||
|
|
||||||
|
There are two broad categories of user environments that depend on what
|
||||||
|
Spawner you choose:
|
||||||
|
|
||||||
|
- Multi-user hosts (shared system)
|
||||||
|
- Container-based
|
||||||
|
|
||||||
|
How you configure user environments for each category can differ a bit
|
||||||
|
depending on what Spawner you are using.
|
||||||
|
|
||||||
|
The first category is a **shared system (multi-user host)** where
|
||||||
|
each user has a JupyterHub account and a home directory as well as being
|
||||||
|
a real system user. In this example, shared configuration and installation
|
||||||
|
must be in a 'system-wide' location, such as `/etc/` or `/usr/local`
|
||||||
|
or a custom prefix such as `/opt/conda`.
|
||||||
|
|
||||||
|
When JupyterHub uses **container-based** Spawners (e.g. KubeSpawner or
|
||||||
|
DockerSpawner), the 'system-wide' environment is really the container image
|
||||||
|
which you are using for users.
|
||||||
|
|
||||||
|
In both cases, you want to *avoid putting configuration in user home
|
||||||
|
directories* because users can change those configuration settings. Also,
|
||||||
|
home directories typically persist once they are created, so they are
|
||||||
|
difficult for admins to update later.
|
62
docs/source/reference/database.md
Normal file
62
docs/source/reference/database.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# The Hub's Database
|
||||||
|
|
||||||
|
JupyterHub uses a database to store information about users, services, and other
|
||||||
|
data needed for operating the Hub.
|
||||||
|
|
||||||
|
## Default SQLite database
|
||||||
|
|
||||||
|
The default database for JupyterHub is a [SQLite](https://sqlite.org) database.
|
||||||
|
We have chosen SQLite as JupyterHub's default for its lightweight simplicity
|
||||||
|
in certain uses such as testing, small deployments and workshops.
|
||||||
|
|
||||||
|
For production systems, SQLite has some disadvantages when used with JupyterHub:
|
||||||
|
|
||||||
|
- `upgrade-db` may not work, and you may need to start with a fresh database
|
||||||
|
- `downgrade-db` **will not** work if you want to rollback to an earlier
|
||||||
|
version, so backup the `jupyterhub.sqlite` file before upgrading
|
||||||
|
|
||||||
|
The sqlite documentation provides a helpful page about [when to use SQLite and
|
||||||
|
where traditional RDBMS may be a better choice](https://sqlite.org/whentouse.html).
|
||||||
|
|
||||||
|
## Using an RDBMS (PostgreSQL, MySQL)
|
||||||
|
|
||||||
|
When running a long term deployment or a production system, we recommend using
|
||||||
|
a traditional RDBMS database, such as [PostgreSQL](https://www.postgresql.org)
|
||||||
|
or [MySQL](https://www.mysql.com), that supports the SQL `ALTER TABLE`
|
||||||
|
statement.
|
||||||
|
|
||||||
|
## Notes and Tips
|
||||||
|
|
||||||
|
### SQLite
|
||||||
|
|
||||||
|
The SQLite database should not be used on NFS. SQLite uses reader/writer locks
|
||||||
|
to control access to the database. This locking mechanism might not work
|
||||||
|
correctly if the database file is kept on an NFS filesystem. This is because
|
||||||
|
`fcntl()` file locking is broken on many NFS implementations. Therefore, you
|
||||||
|
should avoid putting SQLite database files on NFS since it will not handle well
|
||||||
|
multiple processes which might try to access the file at the same time.
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
|
||||||
|
We recommend using PostgreSQL for production if you are unsure whether to use
|
||||||
|
MySQL or PostgreSQL or if you do not have a strong preference. There is
|
||||||
|
additional configuration required for MySQL that is not needed for PostgreSQL.
|
||||||
|
|
||||||
|
### MySQL / MariaDB
|
||||||
|
|
||||||
|
- You should use the `pymysql` sqlalchemy provider (the other one, MySQLdb,
|
||||||
|
isn't available for py3).
|
||||||
|
- You also need to set `pool_recycle` to some value (typically 60 - 300)
|
||||||
|
which depends on your MySQL setup. This is necessary since MySQL kills
|
||||||
|
connections serverside if they've been idle for a while, and the connection
|
||||||
|
from the hub will be idle for longer than most connections. This behavior
|
||||||
|
will lead to frustrating 'the connection has gone away' errors from
|
||||||
|
sqlalchemy if `pool_recycle` is not set.
|
||||||
|
- If you use `utf8mb4` collation with MySQL earlier than 5.7.7 or MariaDB
|
||||||
|
earlier than 10.2.1 you may get an `1709, Index column size too large` error.
|
||||||
|
To fix this you need to set `innodb_large_prefix` to enabled and
|
||||||
|
`innodb_file_format` to `Barracuda` to allow for the index sizes jupyterhub
|
||||||
|
uses. `row_format` will be set to `DYNAMIC` as long as those options are set
|
||||||
|
correctly. Later versions of MariaDB and MySQL should set these values by
|
||||||
|
default, as well as have a default `DYNAMIC` `row_format` and pose no trouble
|
||||||
|
to users.
|
@@ -11,6 +11,11 @@ Technical Reference
|
|||||||
services
|
services
|
||||||
proxy
|
proxy
|
||||||
rest
|
rest
|
||||||
|
database
|
||||||
upgrading
|
upgrading
|
||||||
templates
|
templates
|
||||||
|
config-user-env
|
||||||
config-examples
|
config-examples
|
||||||
|
config-ghoauth
|
||||||
|
config-proxy
|
||||||
|
config-sudo
|
||||||
|
@@ -1,22 +1,26 @@
|
|||||||
# Writing a custom Proxy implementation
|
# Writing a custom Proxy implementation
|
||||||
|
|
||||||
JupyterHub 0.8 introduced the ability to write a custom implementation of the proxy.
|
JupyterHub 0.8 introduced the ability to write a custom implementation of the
|
||||||
This enables deployments with different needs than the default proxy,
|
proxy. This enables deployments with different needs than the default proxy,
|
||||||
configurable-http-proxy (CHP).
|
configurable-http-proxy (CHP). CHP is a single-process nodejs proxy that the
|
||||||
CHP is a single-process nodejs proxy that they Hub manages by default as a subprocess
|
Hub manages by default as a subprocess (it can be run externally, as well, and
|
||||||
(it can be run externally, as well, and typically is in production deployments).
|
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 upside to CHP, and why we use it by default, is that it's easy to install
|
||||||
The downsides are that it's a single process and does not support any persistence of the routing table.
|
and run (if you have nodejs, you are set!). The downsides are that it's a
|
||||||
So if the proxy process dies, your whole JupyterHub instance is inaccessible until the Hub notices, restarts the proxy, and restores the routing table.
|
single process and does not support any persistence of the routing table. So
|
||||||
For deployments that want to avoid such a single point of failure,
|
if the proxy process dies, your whole JupyterHub instance is inaccessible
|
||||||
or leverage existing proxy infrastructure in their chosen deployment (such as Kubernetes ingress objects),
|
until the Hub notices, restarts the proxy, and restores the routing table. For
|
||||||
the Proxy API provides a way to do that.
|
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:
|
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
|
1. support websockets without prior knowledge of the URL where websockets may
|
||||||
2. support trie-based routing (i.e. allow different routes on `/foo` and `/foo/bar` and route based on specificity)
|
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
|
3. adding or removing a route should not cause existing connections to drop
|
||||||
|
|
||||||
Optionally, if the JupyterHub deployment is to use host-based routing,
|
Optionally, if the JupyterHub deployment is to use host-based routing,
|
||||||
@@ -35,10 +39,10 @@ class MyProxy(Proxy):
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Starting and stopping the proxy
|
## 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:
|
If your proxy should be launched when the Hub starts, you must define how
|
||||||
|
to start and stop your proxy:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
@@ -55,8 +59,8 @@ class MyProxy(Proxy):
|
|||||||
|
|
||||||
These methods **may** be coroutines.
|
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.
|
`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
|
### Purely external proxies
|
||||||
|
|
||||||
@@ -70,31 +74,30 @@ class MyProxy(Proxy):
|
|||||||
should_start = False
|
should_start = False
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
## 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.
|
||||||
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.
|
Each of these methods **may** be a coroutine.
|
||||||
|
|
||||||
**Definition:** routespec
|
**Definition:** routespec
|
||||||
|
|
||||||
A routespec, which will appear in these methods, is a string describing a route to be proxied,
|
A routespec, which will appear in these methods, is a string describing a
|
||||||
such as `/user/name/`. A routespec will:
|
route to be proxied, such as `/user/name/`. A routespec will:
|
||||||
|
|
||||||
1. always end with `/`
|
1. always end with `/`
|
||||||
2. always start with `/` if it is a path-based route `/proxy/path/`
|
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/`
|
3. precede the leading `/` with a host for host-based routing, e.g.
|
||||||
|
`host.tld/proxy/path/`
|
||||||
|
|
||||||
### Adding a route
|
### Adding a route
|
||||||
|
|
||||||
When adding a route, JupyterHub may pass a JSON-serializable dict as a `data` argument
|
When adding a route, JupyterHub may pass a JSON-serializable dict as a `data`
|
||||||
that should be attacked to the proxy route.
|
argument that should be attacked to the proxy route. When that route is
|
||||||
When that route is retrieved, the `data` argument should be returned as well.
|
retrieved, the `data` argument should be returned as well. If your proxy
|
||||||
If your proxy implementation doesn't support storing data attached to routes,
|
implementation doesn't support storing data attached to routes, then your
|
||||||
then your Python wrapper may have to handle storing the `data` piece itself,
|
Python wrapper may have to handle storing the `data` piece itself, e.g in a
|
||||||
e.g in a simple file or database.
|
simple file or database.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
@@ -113,12 +116,10 @@ proxy.add_route('/user/pgeorgiou/', 'http://127.0.0.1:1227',
|
|||||||
{'user': 'pgeorgiou'})
|
{'user': 'pgeorgiou'})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### Removing routes
|
### Removing routes
|
||||||
|
|
||||||
`delete_route()` is given a routespec to delete.
|
`delete_route()` is given a routespec to delete. If there is no such route,
|
||||||
If there is no such route, `delete_route` should still succeed,
|
`delete_route` should still succeed, but a warning may be issued.
|
||||||
but a warning may be issued.
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
@@ -126,18 +127,17 @@ def delete_route(self, routespec):
|
|||||||
"""Delete the route"""
|
"""Delete the route"""
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### Retrieving routes
|
### Retrieving routes
|
||||||
|
|
||||||
For retrieval, you only *need* to implement a single method that retrieves all routes.
|
For retrieval, you only *need* to implement a single method that retrieves all
|
||||||
The return value for this function should be a dictionary, keyed by `routespect`,
|
routes. The return value for this function should be a dictionary, keyed by
|
||||||
of dicts whose keys are the same three arguments passed to `add_route`
|
`routespect`, of dicts whose keys are the same three arguments passed to
|
||||||
(`routespec`, `target`, `data`)
|
`add_route` (`routespec`, `target`, `data`)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def get_all_routes(self):
|
def get_all_routes(self):
|
||||||
"""Return all routes, keyed by routespec""""
|
"""Return all routes, keyed by routespec"""
|
||||||
```
|
```
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -150,15 +150,15 @@ def get_all_routes(self):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Note on activity tracking
|
||||||
|
|
||||||
|
JupyterHub can track activity of users, for use in services such as culling
|
||||||
#### Note on activity tracking
|
idle servers. As of JupyterHub 0.8, this activity tracking is the
|
||||||
|
responsibility of the proxy. If your proxy implementation can track activity
|
||||||
JupyterHub can track activity of users, for use in services such as culling idle servers.
|
to endpoints, it may add a `last_activity` key to the `data` of routes
|
||||||
As of JupyterHub 0.8, this activity tracking is the responsibility of the proxy.
|
retrieved in `.get_all_routes()`. If present, the value of `last_activity`
|
||||||
If your proxy implementation can track activity to endpoints,
|
should be an [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) UTC date
|
||||||
it may add a `last_activity` key to the `data` of routes retrieved in `.get_all_routes()`.
|
string:
|
||||||
If present, the value of `last_activity` should be an [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) UTC date string:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
{
|
{
|
||||||
@@ -173,11 +173,9 @@ If present, the value of `last_activity` should be an [ISO8601](https://en.wikip
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
If the proxy does not track activity, then only activity to the Hub itself is tracked,
|
Now that `notebook-5.0` tracks activity internally, we can retrieve activity
|
||||||
and services such as cull-idle will not work.
|
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.
|
||||||
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.
|
|
||||||
|
@@ -15,7 +15,7 @@ This section provides the following information about Services:
|
|||||||
## Definition of a Service
|
## Definition of a Service
|
||||||
|
|
||||||
When working with JupyterHub, a **Service** is defined as a process that interacts
|
When working with JupyterHub, a **Service** is defined as a process that interacts
|
||||||
with the Hub's REST API. A Service may perform a specific or
|
with the Hub's REST API. A Service may perform a specific
|
||||||
action or task. For example, the following tasks can each be a unique Service:
|
action or task. For example, the following tasks can each be a unique Service:
|
||||||
|
|
||||||
- shutting down individuals' single user notebook servers that have been idle
|
- shutting down individuals' single user notebook servers that have been idle
|
||||||
@@ -204,10 +204,10 @@ which implements the requests to the Hub.
|
|||||||
To use HubAuth, you must set the `.api_token`, either programmatically when constructing the class,
|
To use HubAuth, you must set the `.api_token`, either programmatically when constructing the class,
|
||||||
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
||||||
|
|
||||||
Most of the logic for authentication implementation is found in the
|
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)
|
[`HubAuth.user_for_cookie`][HubAuth.user_for_cookie]
|
||||||
and in the
|
and in the
|
||||||
[`HubAuth.user_for_token`](services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token)
|
[`HubAuth.user_for_token`][HubAuth.user_for_token]
|
||||||
methods, which makes a request of the Hub, and returns:
|
methods, which makes a request of the Hub, and returns:
|
||||||
|
|
||||||
- None, if no user could be identified, or
|
- None, if no user could be identified, or
|
||||||
@@ -359,14 +359,16 @@ and taking note of the following process:
|
|||||||
```
|
```
|
||||||
|
|
||||||
An example of using an Externally-Managed Service and authentication is
|
An example of using an Externally-Managed Service and authentication is
|
||||||
in [nbviewer README]_ section on securing the notebook viewer,
|
in [nbviewer README][nbviewer example] section on securing the notebook viewer,
|
||||||
and an example of its configuration is found [here](https://github.com/jupyter/nbviewer/blob/master/nbviewer/providers/base.py#L94).
|
and an example of its configuration is found [here](https://github.com/jupyter/nbviewer/blob/master/nbviewer/providers/base.py#L94).
|
||||||
nbviewer can also be run as a Hub-Managed Service as described [nbviewer README]_
|
nbviewer can also be run as a Hub-Managed Service as described [nbviewer README][nbviewer example]
|
||||||
section on securing the notebook viewer.
|
section on securing the notebook viewer.
|
||||||
|
|
||||||
|
|
||||||
[requests]: http://docs.python-requests.org/en/master/
|
[requests]: http://docs.python-requests.org/en/master/
|
||||||
[services_auth]: ../api/services.auth.html
|
[services_auth]: ../api/services.auth.html
|
||||||
[HubAuth]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth
|
[HubAuth]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth
|
||||||
|
[HubAuth.user_for_cookie]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_cookie
|
||||||
|
[HubAuth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
|
||||||
[HubAuthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
|
[HubAuthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
|
||||||
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
||||||
|
@@ -46,7 +46,16 @@ Most `Spawner.start` functions will look similar to this example:
|
|||||||
def start(self):
|
def start(self):
|
||||||
self.ip = '127.0.0.1'
|
self.ip = '127.0.0.1'
|
||||||
self.port = random_port()
|
self.port = random_port()
|
||||||
yield self._actually_start_server_somehow()
|
# get environment variables,
|
||||||
|
# several of which are required for configuring the single-user server
|
||||||
|
env = self.get_env()
|
||||||
|
cmd = []
|
||||||
|
# get jupyterhub command to run,
|
||||||
|
# typically ['jupyterhub-singleuser']
|
||||||
|
cmd.extend(self.cmd)
|
||||||
|
cmd.extend(self.get_args())
|
||||||
|
|
||||||
|
yield self._actually_start_server_somehow(cmd, env)
|
||||||
return (self.ip, self.port)
|
return (self.ip, self.port)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -187,7 +196,7 @@ allocate. Attempting to use more memory than this limit will cause errors. The
|
|||||||
single-user notebook server can discover its own memory limit by looking at
|
single-user notebook server can discover its own memory limit by looking at
|
||||||
the environment variable `MEM_LIMIT`, which is specified in absolute bytes.
|
the environment variable `MEM_LIMIT`, which is specified in absolute bytes.
|
||||||
|
|
||||||
`c.Spawner.mem_guarantee`: Sometimes, a **guarantee** of a *minumum amount of
|
`c.Spawner.mem_guarantee`: Sometimes, a **guarantee** of a *minimum amount of
|
||||||
memory* is desirable. In this case, you can set `c.Spawner.mem_guarantee` to
|
memory* is desirable. In this case, you can set `c.Spawner.mem_guarantee` to
|
||||||
to provide a guarantee that at minimum this much memory will always be
|
to provide a guarantee that at minimum this much memory will always be
|
||||||
available for the single-user notebook server to use. The environment variable
|
available for the single-user notebook server to use. The environment variable
|
||||||
|
@@ -1,28 +1,55 @@
|
|||||||
# Templates
|
# Working with templates and UI
|
||||||
|
|
||||||
The pages of the JupyterHub application are generated from [Jinja](http://jinja.pocoo.org/) templates. These allow the header, for example, to be defined once and incorporated into all pages. By providing your own templates, you can have complete control over JupyterHub's appearance.
|
The pages of the JupyterHub application are generated from
|
||||||
|
[Jinja](http://jinja.pocoo.org/) templates. These allow the header, for
|
||||||
|
example, to be defined once and incorporated into all pages. By providing
|
||||||
|
your own templates, you can have complete control over JupyterHub's
|
||||||
|
appearance.
|
||||||
|
|
||||||
## Custom Templates
|
## Custom Templates
|
||||||
|
|
||||||
JupyterHub will look for custom templates in all of the paths in the `JupyterHub.template_paths` configuration option, falling back on the [default templates](https://github.com/jupyterhub/jupyterhub/tree/master/share/jupyterhub/templates) if no custom template with that name is found. (This fallback behavior is new in version 0.9; previous versions searched only those paths explicitly included in `template_paths`.) This means you can override as many or as few templates as you desire.
|
JupyterHub will look for custom templates in all of the paths in the
|
||||||
|
`JupyterHub.template_paths` configuration option, falling back on the
|
||||||
|
[default templates](https://github.com/jupyterhub/jupyterhub/tree/master/share/jupyterhub/templates)
|
||||||
|
if no custom template with that name is found. This fallback
|
||||||
|
behavior is new in version 0.9; previous versions searched only those paths
|
||||||
|
explicitly included in `template_paths`. You may override as many
|
||||||
|
or as few templates as you desire.
|
||||||
|
|
||||||
## Extending Templates
|
## Extending Templates
|
||||||
|
|
||||||
Jinja provides a mechanism to [extend templates](http://jinja.pocoo.org/docs/2.10/templates/#template-inheritance). A base template can define a `block`, and child templates can replace or supplement the material in the block. The [JupyterHub templates](https://github.com/jupyterhub/jupyterhub/tree/master/share/jupyterhub/templates) make extensive use of this feature, which allows you to customize parts of the interface easily.
|
Jinja provides a mechanism to [extend templates](http://jinja.pocoo.org/docs/2.10/templates/#template-inheritance).
|
||||||
|
A base template can define a `block`, and child templates can replace or
|
||||||
|
supplement the material in the block. The
|
||||||
|
[JupyterHub templates](https://github.com/jupyterhub/jupyterhub/tree/master/share/jupyterhub/templates)
|
||||||
|
make extensive use of blocks, which allows you to customize parts of the
|
||||||
|
interface easily.
|
||||||
|
|
||||||
In general, a child template can extend a base template, `base.html`, by beginning with
|
In general, a child template can extend a base template, `base.html`, by beginning with:
|
||||||
```
|
|
||||||
|
```html
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
```
|
```
|
||||||
This works, unless you are trying to extend the default template for the same file name. Starting in version 0.9, you may refer to the base file with a `templates/` prefix. Thus, if you are writing a custom `base.html`, start it with
|
|
||||||
```
|
This works, unless you are trying to extend the default template for the same
|
||||||
|
file name. Starting in version 0.9, you may refer to the base file with a
|
||||||
|
`templates/` prefix. Thus, if you are writing a custom `base.html`, start the
|
||||||
|
file with this block:
|
||||||
|
|
||||||
|
```html
|
||||||
{% extends "templates/base.html" %}
|
{% extends "templates/base.html" %}
|
||||||
```
|
```
|
||||||
By defining `block`s with same name as in the base template, child templates can replace those sections with custom content. The content from the base template can be included with the `{{ super() }}` directive.
|
|
||||||
|
By defining `block`s with same name as in the base template, child templates
|
||||||
|
can replace those sections with custom content. The content from the base
|
||||||
|
template can be included with the `{{ super() }}` directive.
|
||||||
|
|
||||||
### Example
|
### Example
|
||||||
|
|
||||||
To add an additional message to the spawn-pending page, below the existing text about the server starting up, place this content in a file named `spawn_pending.html` in a directory included in the `JupyterHub.template_paths` configuration option.
|
To add an additional message to the spawn-pending page, below the existing
|
||||||
|
text about the server starting up, place this content in a file named
|
||||||
|
`spawn_pending.html` in a directory included in the
|
||||||
|
`JupyterHub.template_paths` configuration option.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
{% extends "templates/spawn_pending.html" %}
|
{% extends "templates/spawn_pending.html" %}
|
||||||
@@ -32,3 +59,35 @@ To add an additional message to the spawn-pending page, below the existing text
|
|||||||
<p>Patience is a virtue.</p>
|
<p>Patience is a virtue.</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Page Announcements
|
||||||
|
|
||||||
|
To add announcements to be displayed on a page, you have two options:
|
||||||
|
|
||||||
|
- Extend the page templates as described above
|
||||||
|
- Use configuration variables
|
||||||
|
|
||||||
|
### Announcement Configuration Variables
|
||||||
|
|
||||||
|
If you set the configuration variable `JupyterHub.template_vars =
|
||||||
|
{'announcement': 'some_text}`, the given `some_text` will be placed on
|
||||||
|
the top of all pages. The more specific variables
|
||||||
|
`announcement_login`, `announcement_spawn`, `announcement_home`, and
|
||||||
|
`announcement_logout` are more specific and only show on their
|
||||||
|
respective pages (overriding the global `announcement` variable).
|
||||||
|
Note that changing these variables require a restart, unlike direct
|
||||||
|
template extension.
|
||||||
|
|
||||||
|
You can get the same effect by extending templates, which allows you
|
||||||
|
to update the messages without restarting. Set
|
||||||
|
`c.JupyterHub.template_paths` as mentioned above, and then create a
|
||||||
|
template (for example, `login.html`) with:
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% extends "templates/login.html" %}
|
||||||
|
{% set announcement = 'some message' %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Extending `page.html` puts the message on all pages, but note that
|
||||||
|
extending `page.html` take precedence over an extension of a specific
|
||||||
|
page (unlike the variable-based approach above).
|
||||||
|
@@ -2,30 +2,22 @@
|
|||||||
|
|
||||||
From time to time, you may wish to upgrade JupyterHub to take advantage
|
From time to time, you may wish to upgrade JupyterHub to take advantage
|
||||||
of new releases. Much of this process is automated using scripts,
|
of new releases. Much of this process is automated using scripts,
|
||||||
such as those generated by alembic for database upgrades. Before upgrading a
|
such as those generated by alembic for database upgrades. Whether you
|
||||||
JupyterHub deployment, it's critical to backup your data and configurations
|
are using the default SQLite database or an RDBMS, such as PostgreSQL or
|
||||||
before shutting down the JupyterHub process and server.
|
MySQL, the process follows similar steps.
|
||||||
|
|
||||||
## Databases: SQLite (default) or RDBMS (PostgreSQL, MySQL)
|
**Before upgrading a JupyterHub deployment**, it's critical to backup your data
|
||||||
|
and configurations before shutting down the JupyterHub process and server.
|
||||||
|
|
||||||
The default database for JupyterHub is a [SQLite](https://sqlite.org) database.
|
## Note about upgrading the SQLite database
|
||||||
We have chosen SQLite as JupyterHub's default for its lightweight simplicity
|
|
||||||
in certain uses such as testing, small deployments and workshops.
|
|
||||||
|
|
||||||
When running a long term deployment or a production system, we recommend using
|
When used in production systems, SQLite has some disadvantages when it
|
||||||
a traditional RDBMS database, such as [PostgreSQL](https://www.postgresql.org)
|
comes to upgrading JupyterHub. These are:
|
||||||
or [MySQL](https://www.mysql.com), that supports the SQL `ALTER TABLE`
|
|
||||||
statement.
|
|
||||||
|
|
||||||
For production systems, SQLite has some disadvantages when used with JupyterHub:
|
|
||||||
|
|
||||||
- `upgrade-db` may not work, and you may need to start with a fresh database
|
- `upgrade-db` may not work, and you may need to start with a fresh database
|
||||||
- `downgrade-db` **will not** work if you want to rollback to an earlier
|
- `downgrade-db` **will not** work if you want to rollback to an earlier
|
||||||
version, so backup the `jupyterhub.sqlite` file before upgrading
|
version, so backup the `jupyterhub.sqlite` file before upgrading
|
||||||
|
|
||||||
The sqlite documentation provides a helpful page about [when to use sqlite and
|
|
||||||
where traditional RDBMS may be a better choice](https://sqlite.org/whentouse.html).
|
|
||||||
|
|
||||||
## The upgrade process
|
## The upgrade process
|
||||||
|
|
||||||
Five fundamental process steps are needed when upgrading JupyterHub and its
|
Five fundamental process steps are needed when upgrading JupyterHub and its
|
||||||
|
@@ -166,7 +166,7 @@ startup
|
|||||||
statsd
|
statsd
|
||||||
stdin
|
stdin
|
||||||
stdout
|
stdout
|
||||||
stoppped
|
stopped
|
||||||
subclasses
|
subclasses
|
||||||
subcommand
|
subcommand
|
||||||
subdomain
|
subdomain
|
||||||
|
@@ -9,6 +9,7 @@ problem and how to resolve it.
|
|||||||
- sudospawner fails to run
|
- sudospawner fails to run
|
||||||
- What is the default behavior when none of the lists (admin, whitelist,
|
- What is the default behavior when none of the lists (admin, whitelist,
|
||||||
group whitelist) are set?
|
group whitelist) are set?
|
||||||
|
- JupyterHub Docker container not accessible at localhost
|
||||||
|
|
||||||
[*Errors*](#errors)
|
[*Errors*](#errors)
|
||||||
- 500 error after spawning my single-user server
|
- 500 error after spawning my single-user server
|
||||||
@@ -63,6 +64,17 @@ this to a particular set of users, and the admin_users lets you specify who
|
|||||||
among them may use the admin interface (not necessary, unless you need to do
|
among them may use the admin interface (not necessary, unless you need to do
|
||||||
things like inspect other users' servers, or modify the userlist at runtime).
|
things like inspect other users' servers, or modify the userlist at runtime).
|
||||||
|
|
||||||
|
### JupyterHub Docker container not accessible at localhost
|
||||||
|
|
||||||
|
Even though the command to start your Docker container exposes port 8000
|
||||||
|
(`docker run -p 8000:8000 -d --name jupyterhub jupyterhub/jupyterhub jupyterhub`),
|
||||||
|
it is possible that the IP address itself is not accessible/visible. As a result
|
||||||
|
when you try http://localhost:8000 in your browser, you are unable to connect
|
||||||
|
even though the container is running properly. One workaround is to explicitly
|
||||||
|
tell Jupyterhub to start at `0.0.0.0` which is visible to everyone. Try this
|
||||||
|
command:
|
||||||
|
`docker run -p 8000:8000 -d --name jupyterhub jupyterhub/jupyterhub jupyterhub --ip 0.0.0.0 --port 8000`
|
||||||
|
|
||||||
|
|
||||||
## Errors
|
## Errors
|
||||||
|
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
.. upgrade-dot-eight:
|
.. _upgrade-dot-eight:
|
||||||
|
|
||||||
Upgrading to JupyterHub version 0.8
|
Upgrading to JupyterHub version 0.8
|
||||||
===================================
|
===================================
|
||||||
|
@@ -7,14 +7,18 @@ from sphinx.ext.autodoc import ClassDocumenter, AttributeDocumenter
|
|||||||
|
|
||||||
class ConfigurableDocumenter(ClassDocumenter):
|
class ConfigurableDocumenter(ClassDocumenter):
|
||||||
"""Specialized Documenter subclass for traits with config=True"""
|
"""Specialized Documenter subclass for traits with config=True"""
|
||||||
|
|
||||||
objtype = 'configurable'
|
objtype = 'configurable'
|
||||||
directivetype = 'class'
|
directivetype = 'class'
|
||||||
|
|
||||||
def get_object_members(self, want_all):
|
def get_object_members(self, want_all):
|
||||||
"""Add traits with .tag(config=True) to members list"""
|
"""Add traits with .tag(config=True) to members list"""
|
||||||
check, members = super().get_object_members(want_all)
|
check, members = super().get_object_members(want_all)
|
||||||
get_traits = self.object.class_own_traits if self.options.inherited_members \
|
get_traits = (
|
||||||
else self.object.class_traits
|
self.object.class_own_traits
|
||||||
|
if self.options.inherited_members
|
||||||
|
else self.object.class_traits
|
||||||
|
)
|
||||||
trait_members = []
|
trait_members = []
|
||||||
for name, trait in sorted(get_traits(config=True).items()):
|
for name, trait in sorted(get_traits(config=True).items()):
|
||||||
# put help in __doc__ where autodoc will look for it
|
# put help in __doc__ where autodoc will look for it
|
||||||
@@ -42,10 +46,7 @@ class TraitDocumenter(AttributeDocumenter):
|
|||||||
default_s = ''
|
default_s = ''
|
||||||
else:
|
else:
|
||||||
default_s = repr(default)
|
default_s = repr(default)
|
||||||
sig = ' = {}({})'.format(
|
sig = ' = {}({})'.format(self.object.__class__.__name__, default_s)
|
||||||
self.object.__class__.__name__,
|
|
||||||
default_s,
|
|
||||||
)
|
|
||||||
return super().add_directive_header(sig)
|
return super().add_directive_header(sig)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -130,4 +130,4 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
```
|
```
|
||||||
|
@@ -1,9 +1,14 @@
|
|||||||
# Example for a Spawner.pre_spawn_hook
|
"""
|
||||||
# create a directory for the user before the spawner starts
|
Example for a Spawner.pre_spawn_hook
|
||||||
|
create a directory for the user before the spawner starts
|
||||||
|
"""
|
||||||
|
# pylint: disable=import-error
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
from jupyter_client.localinterfaces import public_ips
|
||||||
|
|
||||||
def create_dir_hook(spawner):
|
def create_dir_hook(spawner):
|
||||||
|
""" Create directory """
|
||||||
username = spawner.user.name # get the username
|
username = spawner.user.name # get the username
|
||||||
volume_path = os.path.join('/volumes/jupyterhub', username)
|
volume_path = os.path.join('/volumes/jupyterhub', username)
|
||||||
if not os.path.exists(volume_path):
|
if not os.path.exists(volume_path):
|
||||||
@@ -12,23 +17,24 @@ def create_dir_hook(spawner):
|
|||||||
# ...
|
# ...
|
||||||
|
|
||||||
def clean_dir_hook(spawner):
|
def clean_dir_hook(spawner):
|
||||||
|
""" Delete directory """
|
||||||
username = spawner.user.name # get the username
|
username = spawner.user.name # get the username
|
||||||
temp_path = os.path.join('/volumes/jupyterhub', username, 'temp')
|
temp_path = os.path.join('/volumes/jupyterhub', username, 'temp')
|
||||||
if os.path.exists(temp_path) and os.path.isdir(temp_path):
|
if os.path.exists(temp_path) and os.path.isdir(temp_path):
|
||||||
shutil.rmtree(temp_path)
|
shutil.rmtree(temp_path)
|
||||||
|
|
||||||
# attach the hook functions to the spawner
|
# attach the hook functions to the spawner
|
||||||
|
# pylint: disable=undefined-variable
|
||||||
c.Spawner.pre_spawn_hook = create_dir_hook
|
c.Spawner.pre_spawn_hook = create_dir_hook
|
||||||
c.Spawner.post_stop_hook = clean_dir_hook
|
c.Spawner.post_stop_hook = clean_dir_hook
|
||||||
|
|
||||||
# Use the DockerSpawner to serve your users' notebooks
|
# Use the DockerSpawner to serve your users' notebooks
|
||||||
c.JupyterHub.spawner_class = 'dockerspawner.DockerSpawner'
|
c.JupyterHub.spawner_class = 'dockerspawner.DockerSpawner'
|
||||||
from jupyter_client.localinterfaces import public_ips
|
|
||||||
c.JupyterHub.hub_ip = public_ips()[0]
|
c.JupyterHub.hub_ip = public_ips()[0]
|
||||||
c.DockerSpawner.hub_ip_connect = public_ips()[0]
|
c.DockerSpawner.hub_ip_connect = public_ips()[0]
|
||||||
c.DockerSpawner.container_ip = "0.0.0.0"
|
c.DockerSpawner.container_ip = "0.0.0.0"
|
||||||
|
|
||||||
# You can now mount the volume to the docker container as we've
|
# You can now mount the volume to the docker container as we've
|
||||||
# made sure the directory exists
|
# made sure the directory exists
|
||||||
|
# pylint: disable=bad-whitespace
|
||||||
c.DockerSpawner.volumes = { '/volumes/jupyterhub/{username}/': '/home/jovyan/work' }
|
c.DockerSpawner.volumes = { '/volumes/jupyterhub/{username}/': '/home/jovyan/work' }
|
||||||
|
|
||||||
|
@@ -186,10 +186,16 @@ def cull_idle(url, api_token, inactive_limit, cull_users=False, max_age=0, concu
|
|||||||
log_name, format_td(age), format_td(inactive))
|
log_name, format_td(age), format_td(inactive))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if server_name:
|
||||||
|
# culling a named server
|
||||||
|
delete_url = url + "/users/%s/servers/%s" % (
|
||||||
|
quote(user['name']), quote(server['name'])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
delete_url = url + '/users/%s/server' % quote(user['name'])
|
||||||
|
|
||||||
req = HTTPRequest(
|
req = HTTPRequest(
|
||||||
url=url + '/users/%s/server' % quote(user['name']),
|
url=delete_url, method='DELETE', headers=auth_header,
|
||||||
method='DELETE',
|
|
||||||
headers=auth_header,
|
|
||||||
)
|
)
|
||||||
resp = yield fetch(req)
|
resp = yield fetch(req)
|
||||||
if resp.code == 202:
|
if resp.code == 202:
|
||||||
|
@@ -5,7 +5,7 @@ for external services that may not be otherwise integrated with JupyterHub.
|
|||||||
The main feature this enables is using JupyterHub like a 'regular' OAuth 2
|
The main feature this enables is using JupyterHub like a 'regular' OAuth 2
|
||||||
provider for services running anywhere.
|
provider for services running anywhere.
|
||||||
|
|
||||||
There are two examples here. `whoami-oauth` uses `jupyterhub.services.HubOAuthenticated`
|
There are two examples here. `whoami-oauth` (in the service-whoami directory) uses `jupyterhub.services.HubOAuthenticated`
|
||||||
to authenticate requests with the Hub for a service run on its own host.
|
to authenticate requests with the Hub for a service run on its own host.
|
||||||
This is an implementation of OAuth 2.0 provided by the jupyterhub package,
|
This is an implementation of OAuth 2.0 provided by the jupyterhub package,
|
||||||
which configures all of the necessary URLs from environment variables.
|
which configures all of the necessary URLs from environment variables.
|
||||||
|
@@ -18,4 +18,4 @@ export JUPYTERHUB_OAUTH_CALLBACK_URL="$JUPYTERHUB_SERVICE_URL/oauth_callback"
|
|||||||
export JUPYTERHUB_HOST='http://127.0.0.1:8000'
|
export JUPYTERHUB_HOST='http://127.0.0.1:8000'
|
||||||
|
|
||||||
# launch the service
|
# launch the service
|
||||||
exec python3 whoami-oauth.py
|
exec python3 ../service-whoami/whoami-oauth.py
|
||||||
|
@@ -1,46 +0,0 @@
|
|||||||
"""An example service authenticating with the Hub.
|
|
||||||
|
|
||||||
This example service serves `/services/whoami/`,
|
|
||||||
authenticated with the Hub,
|
|
||||||
showing the user their own info.
|
|
||||||
"""
|
|
||||||
from getpass import getuser
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from tornado.ioloop import IOLoop
|
|
||||||
from tornado import log
|
|
||||||
from tornado.httpserver import HTTPServer
|
|
||||||
from tornado.web import RequestHandler, Application, authenticated
|
|
||||||
|
|
||||||
from jupyterhub.services.auth import HubOAuthenticated, HubOAuthCallbackHandler
|
|
||||||
from jupyterhub.utils import url_path_join
|
|
||||||
|
|
||||||
class WhoAmIHandler(HubOAuthenticated, RequestHandler):
|
|
||||||
hub_users = {getuser()} # the users allowed to access this service
|
|
||||||
|
|
||||||
@authenticated
|
|
||||||
def get(self):
|
|
||||||
user_model = self.get_current_user()
|
|
||||||
self.set_header('content-type', 'application/json')
|
|
||||||
self.write(json.dumps(user_model, indent=1, sort_keys=True))
|
|
||||||
|
|
||||||
def main():
|
|
||||||
log.enable_pretty_logging()
|
|
||||||
app = Application([
|
|
||||||
(os.environ['JUPYTERHUB_SERVICE_PREFIX'], WhoAmIHandler),
|
|
||||||
(url_path_join(os.environ['JUPYTERHUB_SERVICE_PREFIX'], 'oauth_callback'), HubOAuthCallbackHandler),
|
|
||||||
(r'.*', WhoAmIHandler),
|
|
||||||
], cookie_secret=os.urandom(32))
|
|
||||||
|
|
||||||
http_server = HTTPServer(app)
|
|
||||||
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
|
||||||
log.app_log.info("Running whoami service on %s", os.environ['JUPYTERHUB_SERVICE_URL'])
|
|
||||||
|
|
||||||
http_server.listen(url.port, url.hostname)
|
|
||||||
|
|
||||||
IOLoop.current().start()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
60
examples/service-announcement/README.md
Normal file
60
examples/service-announcement/README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
|
||||||
|
# Simple Announcement Service Example
|
||||||
|
|
||||||
|
This is a simple service that allows administrators to manage announcements
|
||||||
|
that appear when JupyterHub renders pages.
|
||||||
|
|
||||||
|
To run the service as a hub-managed service simply include in your JupyterHub
|
||||||
|
configuration file something like:
|
||||||
|
|
||||||
|
c.JupyterHub.services = [
|
||||||
|
{
|
||||||
|
'name': 'announcement',
|
||||||
|
'url': 'http://127.0.0.1:8888',
|
||||||
|
'command': ["python", "-m", "announcement"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
This starts the announcements service up at `/services/announcement` when
|
||||||
|
JupyterHub launches. By default the announcement text is empty.
|
||||||
|
|
||||||
|
The `announcement` module has a configurable port (default 8888) and an API
|
||||||
|
prefix setting. By default the API prefix is `JUPYTERHUB_SERVICE_PREFIX` if
|
||||||
|
that environment variable is set or `/` if it is not.
|
||||||
|
|
||||||
|
## Managing the Announcement
|
||||||
|
|
||||||
|
Admin users can set the announcement text with an API token:
|
||||||
|
|
||||||
|
$ curl -X POST -H "Authorization: token <token>" \
|
||||||
|
-d "{'announcement':'JupyterHub will be upgraded on August 14!'}" \
|
||||||
|
https://.../services/announcement
|
||||||
|
|
||||||
|
Anyone can read the announcement:
|
||||||
|
|
||||||
|
$ curl https://.../services/announcement | python -m json.tool
|
||||||
|
{
|
||||||
|
announcement: "JupyterHub will be upgraded on August 14!",
|
||||||
|
timestamp: "...",
|
||||||
|
user: "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
The time the announcement was posted is recorded in the `timestamp` field and
|
||||||
|
the user who posted the announcement is recorded in the `user` field.
|
||||||
|
|
||||||
|
To clear the announcement text, just DELETE. Only admin users can do this.
|
||||||
|
|
||||||
|
$ curl -X POST -H "Authorization: token <token>" \
|
||||||
|
https://.../services/announcement
|
||||||
|
|
||||||
|
## Seeing the Announcement in JupyterHub
|
||||||
|
|
||||||
|
To be able to render the announcement, include the provide `page.html` template
|
||||||
|
that extends the base `page.html` template. Set `c.JupyterHub.template_paths`
|
||||||
|
in JupyterHub's configuration to include the path to the extending template.
|
||||||
|
The template changes the `announcement` element and does a JQuery `$.get()` call
|
||||||
|
to retrieve the announcement text.
|
||||||
|
|
||||||
|
JupyterHub's configurable announcement template variables can be set for various
|
||||||
|
pages like login, logout, spawn, and home. Including the template provided in
|
||||||
|
this example overrides all of those.
|
73
examples/service-announcement/announcement.py
Normal file
73
examples/service-announcement/announcement.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from jupyterhub.services.auth import HubAuthenticated
|
||||||
|
from tornado import escape, gen, ioloop, web
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementRequestHandler(HubAuthenticated, web.RequestHandler):
|
||||||
|
"""Dynamically manage page announcements"""
|
||||||
|
|
||||||
|
hub_users = []
|
||||||
|
allow_admin = True
|
||||||
|
|
||||||
|
def initialize(self, storage):
|
||||||
|
"""Create storage for announcement text"""
|
||||||
|
self.storage = storage
|
||||||
|
|
||||||
|
@web.authenticated
|
||||||
|
def post(self):
|
||||||
|
"""Update announcement"""
|
||||||
|
doc = escape.json_decode(self.request.body)
|
||||||
|
self.storage["announcement"] = doc["announcement"]
|
||||||
|
self.storage["timestamp"] = datetime.datetime.now().isoformat()
|
||||||
|
self.storage["user"] = user["name"]
|
||||||
|
self.write_to_json(self.storage)
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
"""Retrieve announcement"""
|
||||||
|
self.write_to_json(self.storage)
|
||||||
|
|
||||||
|
@web.authenticated
|
||||||
|
def delete(self):
|
||||||
|
"""Clear announcement"""
|
||||||
|
self.storage["announcement"] = ""
|
||||||
|
self.write_to_json(self.storage)
|
||||||
|
|
||||||
|
def write_to_json(self, doc):
|
||||||
|
"""Write dictionary document as JSON"""
|
||||||
|
self.set_header("Content-Type", "application/json; charset=UTF-8")
|
||||||
|
self.write(escape.utf8(json.dumps(doc)))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_arguments()
|
||||||
|
application = create_application(**vars(args))
|
||||||
|
application.listen(args.port)
|
||||||
|
ioloop.IOLoop.current().start()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arguments():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--api-prefix", "-a",
|
||||||
|
default=os.environ.get("JUPYTERHUB_SERVICE_PREFIX", "/"),
|
||||||
|
help="application API prefix")
|
||||||
|
parser.add_argument("--port", "-p",
|
||||||
|
default=8888,
|
||||||
|
help="port for API to listen on",
|
||||||
|
type=int)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def create_application(api_prefix="/",
|
||||||
|
handler=AnnouncementRequestHandler,
|
||||||
|
**kwargs):
|
||||||
|
storage = dict(announcement="", timestamp="", user="")
|
||||||
|
return web.Application([(api_prefix, handler, dict(storage=storage))])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
15
examples/service-announcement/jupyterhub_config.py
Normal file
15
examples/service-announcement/jupyterhub_config.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
# To run the announcement service managed by the hub, add this.
|
||||||
|
|
||||||
|
c.JupyterHub.services = [
|
||||||
|
{
|
||||||
|
'name': 'announcement',
|
||||||
|
'url': 'http://127.0.0.1:8888',
|
||||||
|
'command': ["python", "-m", "announcement"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# The announcements need to get on the templates somehow, see page.html
|
||||||
|
# for an example of how to do this.
|
||||||
|
|
||||||
|
c.JupyterHub.template_paths = ["templates"]
|
14
examples/service-announcement/templates/page.html
Normal file
14
examples/service-announcement/templates/page.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "templates/page.html" %}
|
||||||
|
{% block announcement %}
|
||||||
|
<div class="container text-center announcement">
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
{{ super() }}
|
||||||
|
<script>
|
||||||
|
$.get("/services/announcement/", function(data) {
|
||||||
|
$(".announcement").html(data["announcement"]);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
@@ -26,6 +26,10 @@ After logging in with your local-system credentials, you should see a JSON dump
|
|||||||
|
|
||||||
This relies on the Hub starting the whoami services, via config (see [jupyterhub_config.py](./jupyterhub_config.py)).
|
This relies on the Hub starting the whoami services, via config (see [jupyterhub_config.py](./jupyterhub_config.py)).
|
||||||
|
|
||||||
|
You may set the `hub_users` configuration in the service script
|
||||||
|
to restrict access to the service to a whitelist of allowed users.
|
||||||
|
By default, any authenticated user is allowed.
|
||||||
|
|
||||||
A similar service could be run externally, by setting the JupyterHub service environment variables:
|
A similar service could be run externally, by setting the JupyterHub service environment variables:
|
||||||
|
|
||||||
JUPYTERHUB_API_TOKEN
|
JUPYTERHUB_API_TOKEN
|
||||||
|
@@ -17,7 +17,11 @@ from jupyterhub.services.auth import HubOAuthenticated, HubOAuthCallbackHandler
|
|||||||
from jupyterhub.utils import url_path_join
|
from jupyterhub.utils import url_path_join
|
||||||
|
|
||||||
class WhoAmIHandler(HubOAuthenticated, RequestHandler):
|
class WhoAmIHandler(HubOAuthenticated, RequestHandler):
|
||||||
hub_users = {getuser()} # the users allowed to access this service
|
# hub_users can be a set of users who are allowed to access the service
|
||||||
|
# `getuser()` here would mean only the user who started the service
|
||||||
|
# can access the service:
|
||||||
|
|
||||||
|
# hub_users = {getuser()}
|
||||||
|
|
||||||
@authenticated
|
@authenticated
|
||||||
def get(self):
|
def get(self):
|
||||||
|
@@ -15,7 +15,11 @@ from jupyterhub.services.auth import HubAuthenticated
|
|||||||
|
|
||||||
|
|
||||||
class WhoAmIHandler(HubAuthenticated, RequestHandler):
|
class WhoAmIHandler(HubAuthenticated, RequestHandler):
|
||||||
hub_users = {getuser()} # the users allowed to access me
|
# hub_users can be a set of users who are allowed to access the service
|
||||||
|
# `getuser()` here would mean only the user who started the service
|
||||||
|
# can access the service:
|
||||||
|
|
||||||
|
# hub_users = {getuser()}
|
||||||
|
|
||||||
@authenticated
|
@authenticated
|
||||||
def get(self):
|
def get(self):
|
||||||
@@ -37,4 +41,4 @@ def main():
|
|||||||
IOLoop.current().start()
|
IOLoop.current().start()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
@@ -11,12 +11,16 @@ function get_hub_version() {
|
|||||||
hub_xyz=$(cat hub_version)
|
hub_xyz=$(cat hub_version)
|
||||||
split=( ${hub_xyz//./ } )
|
split=( ${hub_xyz//./ } )
|
||||||
hub_xy="${split[0]}.${split[1]}"
|
hub_xy="${split[0]}.${split[1]}"
|
||||||
|
# add .dev on hub_xy so it's 1.0.dev
|
||||||
|
if [[ ! -z "${split[3]}" ]]; then
|
||||||
|
hub_xy="${hub_xy}.${split[3]}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
get_hub_version
|
get_hub_version
|
||||||
|
|
||||||
# when building master, push 0.9.0 as well
|
# when building master, push 0.9.0.dev as well
|
||||||
docker tag $DOCKER_REPO:$DOCKER_TAG $DOCKER_REPO:$hub_xyz
|
docker tag $DOCKER_REPO:$DOCKER_TAG $DOCKER_REPO:$hub_xyz
|
||||||
docker push $DOCKER_REPO:$hub_xyz
|
docker push $DOCKER_REPO:$hub_xyz
|
||||||
docker tag $ONBUILD:$DOCKER_TAG $ONBUILD:$hub_xyz
|
docker tag $ONBUILD:$DOCKER_TAG $ONBUILD:$hub_xyz
|
||||||
|
@@ -6,11 +6,18 @@
|
|||||||
version_info = (
|
version_info = (
|
||||||
0,
|
0,
|
||||||
9,
|
9,
|
||||||
0,
|
4,
|
||||||
'b1',
|
"", # release (b1, rc1, or "" for final or dev)
|
||||||
|
# "dev", # dev or nothing
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = '.'.join(map(str, version_info))
|
# pep 440 version: no dot before beta/rc, but before .dev
|
||||||
|
# 0.1.0rc1
|
||||||
|
# 0.1.0a1
|
||||||
|
# 0.1.0b1.dev
|
||||||
|
# 0.1.0.dev
|
||||||
|
|
||||||
|
__version__ = ".".join(map(str, version_info[:3])) + ".".join(version_info[3:])
|
||||||
|
|
||||||
|
|
||||||
def _check_version(hub_version, singleuser_version, log):
|
def _check_version(hub_version, singleuser_version, log):
|
||||||
|
24
jupyterhub/alembic/versions/896818069c98_token_expires.py
Normal file
24
jupyterhub/alembic/versions/896818069c98_token_expires.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""Add APIToken.expires_at
|
||||||
|
|
||||||
|
Revision ID: 896818069c98
|
||||||
|
Revises: d68c98b66cd4
|
||||||
|
Create Date: 2018-05-07 11:35:58.050542
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '896818069c98'
|
||||||
|
down_revision = 'd68c98b66cd4'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column('api_tokens', sa.Column('expires_at', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column('api_tokens', 'expires_at')
|
@@ -2,22 +2,37 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from http.client import responses
|
from http.client import responses
|
||||||
|
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from tornado import web
|
from tornado import web
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..handlers import BaseHandler
|
from ..handlers import BaseHandler
|
||||||
from ..utils import isoformat, url_path_join
|
from ..utils import isoformat, url_path_join
|
||||||
|
|
||||||
|
|
||||||
class APIHandler(BaseHandler):
|
class APIHandler(BaseHandler):
|
||||||
|
"""Base class for API endpoints
|
||||||
|
|
||||||
|
Differences from page handlers:
|
||||||
|
|
||||||
|
- JSON responses and errors
|
||||||
|
- strict referer checking for Cookie-authenticated requests
|
||||||
|
- strict content-security-policy
|
||||||
|
- methods for REST API models
|
||||||
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def content_security_policy(self):
|
def content_security_policy(self):
|
||||||
return '; '.join([super().content_security_policy, "default-src 'none'"])
|
return '; '.join([super().content_security_policy, "default-src 'none'"])
|
||||||
|
|
||||||
|
def get_content_type(self):
|
||||||
|
return 'application/json'
|
||||||
|
|
||||||
def check_referer(self):
|
def check_referer(self):
|
||||||
"""Check Origin for cross-site API requests.
|
"""Check Origin for cross-site API requests.
|
||||||
|
|
||||||
@@ -87,13 +102,20 @@ class APIHandler(BaseHandler):
|
|||||||
if reason:
|
if reason:
|
||||||
status_message = reason
|
status_message = reason
|
||||||
|
|
||||||
|
if exception and isinstance(exception, SQLAlchemyError):
|
||||||
|
self.log.warning("Rolling back session due to database error %s", exception)
|
||||||
|
self.db.rollback()
|
||||||
|
|
||||||
self.set_header('Content-Type', 'application/json')
|
self.set_header('Content-Type', 'application/json')
|
||||||
# allow setting headers from exceptions
|
if isinstance(exception, web.HTTPError):
|
||||||
# since exception handler clears headers
|
# allow setting headers from exceptions
|
||||||
headers = getattr(exception, 'headers', None)
|
# since exception handler clears headers
|
||||||
if headers:
|
headers = getattr(exception, 'headers', None)
|
||||||
for key, value in headers.items():
|
if headers:
|
||||||
self.set_header(key, value)
|
for key, value in headers.items():
|
||||||
|
self.set_header(key, value)
|
||||||
|
# Content-Length must be recalculated.
|
||||||
|
self.clear_header('Content-Length')
|
||||||
|
|
||||||
self.write(json.dumps({
|
self.write(json.dumps({
|
||||||
'status': status_code,
|
'status': status_code,
|
||||||
@@ -115,16 +137,20 @@ class APIHandler(BaseHandler):
|
|||||||
|
|
||||||
def token_model(self, token):
|
def token_model(self, token):
|
||||||
"""Get the JSON model for an APIToken"""
|
"""Get the JSON model for an APIToken"""
|
||||||
|
expires_at = None
|
||||||
if isinstance(token, orm.APIToken):
|
if isinstance(token, orm.APIToken):
|
||||||
kind = 'api_token'
|
kind = 'api_token'
|
||||||
extra = {
|
extra = {
|
||||||
'note': token.note,
|
'note': token.note,
|
||||||
}
|
}
|
||||||
|
expires_at = token.expires_at
|
||||||
elif isinstance(token, orm.OAuthAccessToken):
|
elif isinstance(token, orm.OAuthAccessToken):
|
||||||
kind = 'oauth'
|
kind = 'oauth'
|
||||||
extra = {
|
extra = {
|
||||||
'oauth_client': token.client.description or token.client.client_id,
|
'oauth_client': token.client.description or token.client.client_id,
|
||||||
}
|
}
|
||||||
|
if token.expires_at:
|
||||||
|
expires_at = datetime.fromtimestamp(token.expires_at)
|
||||||
else:
|
else:
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
"token must be an APIToken or OAuthAccessToken, not %s"
|
"token must be an APIToken or OAuthAccessToken, not %s"
|
||||||
@@ -144,6 +170,7 @@ class APIHandler(BaseHandler):
|
|||||||
'kind': kind,
|
'kind': kind,
|
||||||
'created': isoformat(token.created),
|
'created': isoformat(token.created),
|
||||||
'last_activity': isoformat(token.last_activity),
|
'last_activity': isoformat(token.last_activity),
|
||||||
|
'expires_at': isoformat(expires_at),
|
||||||
}
|
}
|
||||||
model.update(extra)
|
model.update(extra)
|
||||||
return model
|
return model
|
||||||
@@ -241,3 +268,13 @@ class APIHandler(BaseHandler):
|
|||||||
|
|
||||||
def options(self, *args, **kwargs):
|
def options(self, *args, **kwargs):
|
||||||
self.finish()
|
self.finish()
|
||||||
|
|
||||||
|
|
||||||
|
class API404(APIHandler):
|
||||||
|
"""404 for API requests
|
||||||
|
|
||||||
|
Ensures JSON 404 errors for malformed URLs
|
||||||
|
"""
|
||||||
|
async def prepare(self):
|
||||||
|
await super().prepare()
|
||||||
|
raise web.HTTPError(404)
|
||||||
|
@@ -23,6 +23,7 @@ def service_model(service):
|
|||||||
'prefix': service.server.base_url if service.server else '',
|
'prefix': service.server.base_url if service.server else '',
|
||||||
'command': service.command,
|
'command': service.command,
|
||||||
'pid': service.proc.pid if service.proc else 0,
|
'pid': service.proc.pid if service.proc else 0,
|
||||||
|
'info': service.info
|
||||||
}
|
}
|
||||||
|
|
||||||
class ServiceListAPIHandler(APIHandler):
|
class ServiceListAPIHandler(APIHandler):
|
||||||
|
@@ -4,6 +4,7 @@
|
|||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from async_generator import aclosing
|
from async_generator import aclosing
|
||||||
@@ -201,13 +202,30 @@ class UserTokenListAPIHandler(APIHandler):
|
|||||||
user = self.find_user(name)
|
user = self.find_user(name)
|
||||||
if not user:
|
if not user:
|
||||||
raise web.HTTPError(404, "No such user: %s" % name)
|
raise web.HTTPError(404, "No such user: %s" % name)
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
api_tokens = []
|
api_tokens = []
|
||||||
def sort_key(token):
|
def sort_key(token):
|
||||||
return token.last_activity or token.created
|
return token.last_activity or token.created
|
||||||
|
|
||||||
for token in sorted(user.api_tokens, key=sort_key):
|
for token in sorted(user.api_tokens, key=sort_key):
|
||||||
|
if token.expires_at and token.expires_at < now:
|
||||||
|
# exclude expired tokens
|
||||||
|
self.db.delete(token)
|
||||||
|
self.db.commit()
|
||||||
|
continue
|
||||||
api_tokens.append(self.token_model(token))
|
api_tokens.append(self.token_model(token))
|
||||||
|
|
||||||
oauth_tokens = []
|
oauth_tokens = []
|
||||||
|
# OAuth tokens use integer timestamps
|
||||||
|
now_timestamp = now.timestamp()
|
||||||
for token in sorted(user.oauth_tokens, key=sort_key):
|
for token in sorted(user.oauth_tokens, key=sort_key):
|
||||||
|
if token.expires_at and token.expires_at < now_timestamp:
|
||||||
|
# exclude expired tokens
|
||||||
|
self.db.delete(token)
|
||||||
|
self.db.commit()
|
||||||
|
continue
|
||||||
oauth_tokens.append(self.token_model(token))
|
oauth_tokens.append(self.token_model(token))
|
||||||
self.write(json.dumps({
|
self.write(json.dumps({
|
||||||
'api_tokens': api_tokens,
|
'api_tokens': api_tokens,
|
||||||
@@ -252,7 +270,7 @@ class UserTokenListAPIHandler(APIHandler):
|
|||||||
if requester is not user:
|
if requester is not user:
|
||||||
note += " by %s %s" % (kind, requester.name)
|
note += " by %s %s" % (kind, requester.name)
|
||||||
|
|
||||||
api_token = user.new_api_token(note=note)
|
api_token = user.new_api_token(note=note, expires_in=body.get('expires_in', None))
|
||||||
if requester is not user:
|
if requester is not user:
|
||||||
self.log.info("%s %s requested API token for %s", kind.title(), requester.name, user.name)
|
self.log.info("%s %s requested API token for %s", kind.title(), requester.name, user.name)
|
||||||
else:
|
else:
|
||||||
@@ -410,6 +428,9 @@ class UserAdminAccessAPIHandler(APIHandler):
|
|||||||
|
|
||||||
class SpawnProgressAPIHandler(APIHandler):
|
class SpawnProgressAPIHandler(APIHandler):
|
||||||
"""EventStream handler for pending spawns"""
|
"""EventStream handler for pending spawns"""
|
||||||
|
|
||||||
|
keepalive_interval = 8
|
||||||
|
|
||||||
def get_content_type(self):
|
def get_content_type(self):
|
||||||
return 'text/event-stream'
|
return 'text/event-stream'
|
||||||
|
|
||||||
@@ -422,6 +443,23 @@ class SpawnProgressAPIHandler(APIHandler):
|
|||||||
# raise Finish to halt the handler
|
# raise Finish to halt the handler
|
||||||
raise web.Finish()
|
raise web.Finish()
|
||||||
|
|
||||||
|
_finished = False
|
||||||
|
def on_finish(self):
|
||||||
|
self._finished = True
|
||||||
|
|
||||||
|
async def keepalive(self):
|
||||||
|
"""Write empty lines periodically
|
||||||
|
|
||||||
|
to avoid being closed by intermediate proxies
|
||||||
|
when there's a large gap between events.
|
||||||
|
"""
|
||||||
|
while not self._finished:
|
||||||
|
try:
|
||||||
|
self.write("\n\n")
|
||||||
|
except (StreamClosedError, RuntimeError):
|
||||||
|
return
|
||||||
|
await asyncio.sleep(self.keepalive_interval)
|
||||||
|
|
||||||
@admin_or_self
|
@admin_or_self
|
||||||
async def get(self, username, server_name=''):
|
async def get(self, username, server_name=''):
|
||||||
self.set_header('Cache-Control', 'no-cache')
|
self.set_header('Cache-Control', 'no-cache')
|
||||||
@@ -435,6 +473,9 @@ class SpawnProgressAPIHandler(APIHandler):
|
|||||||
# user has no such server
|
# user has no such server
|
||||||
raise web.HTTPError(404)
|
raise web.HTTPError(404)
|
||||||
spawner = user.spawners[server_name]
|
spawner = user.spawners[server_name]
|
||||||
|
|
||||||
|
# start sending keepalive to avoid proxies closing the connection
|
||||||
|
asyncio.ensure_future(self.keepalive())
|
||||||
# cases:
|
# cases:
|
||||||
# - spawner already started and ready
|
# - spawner already started and ready
|
||||||
# - spawner not running at all
|
# - spawner not running at all
|
||||||
|
@@ -9,6 +9,7 @@ import atexit
|
|||||||
import binascii
|
import binascii
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from functools import partial
|
||||||
from getpass import getuser
|
from getpass import getuser
|
||||||
import logging
|
import logging
|
||||||
from operator import itemgetter
|
from operator import itemgetter
|
||||||
@@ -26,7 +27,7 @@ if sys.version_info[:2] < (3, 3):
|
|||||||
|
|
||||||
from dateutil.parser import parse as parse_date
|
from dateutil.parser import parse as parse_date
|
||||||
from jinja2 import Environment, FileSystemLoader, PrefixLoader, ChoiceLoader
|
from jinja2 import Environment, FileSystemLoader, PrefixLoader, ChoiceLoader
|
||||||
from sqlalchemy.exc import OperationalError
|
from sqlalchemy.exc import OperationalError, SQLAlchemyError
|
||||||
|
|
||||||
from tornado.httpclient import AsyncHTTPClient
|
from tornado.httpclient import AsyncHTTPClient
|
||||||
import tornado.httpserver
|
import tornado.httpserver
|
||||||
@@ -139,7 +140,10 @@ class NewToken(Application):
|
|||||||
ab01cd23ef45
|
ab01cd23ef45
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = Unicode(getuser())
|
name = Unicode()
|
||||||
|
@default('name')
|
||||||
|
def _default_name(self):
|
||||||
|
return getuser()
|
||||||
|
|
||||||
aliases = token_aliases
|
aliases = token_aliases
|
||||||
classes = []
|
classes = []
|
||||||
@@ -273,6 +277,9 @@ class JupyterHub(Application):
|
|||||||
service_check_interval = Integer(60,
|
service_check_interval = Integer(60,
|
||||||
help="Interval (in seconds) at which to check connectivity of services with web endpoints."
|
help="Interval (in seconds) at which to check connectivity of services with web endpoints."
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
active_user_window = Integer(30 * 60,
|
||||||
|
help="Duration (in seconds) to determine the number of active users."
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
data_files_path = Unicode(DATA_FILES_PATH,
|
data_files_path = Unicode(DATA_FILES_PATH,
|
||||||
help="The location of jupyterhub data files (e.g. /usr/local/share/jupyterhub)"
|
help="The location of jupyterhub data files (e.g. /usr/local/share/jupyterhub)"
|
||||||
@@ -286,6 +293,10 @@ class JupyterHub(Application):
|
|||||||
def _template_paths_default(self):
|
def _template_paths_default(self):
|
||||||
return [os.path.join(self.data_files_path, 'templates')]
|
return [os.path.join(self.data_files_path, 'templates')]
|
||||||
|
|
||||||
|
template_vars = Dict(
|
||||||
|
help="Extra variables to be passed into jinja templates",
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
confirm_no_ssl = Bool(False,
|
confirm_no_ssl = Bool(False,
|
||||||
help="""DEPRECATED: does nothing"""
|
help="""DEPRECATED: does nothing"""
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
@@ -310,6 +321,7 @@ class JupyterHub(Application):
|
|||||||
should be accessed by users.
|
should be accessed by users.
|
||||||
|
|
||||||
.. deprecated: 0.9
|
.. deprecated: 0.9
|
||||||
|
Use JupyterHub.bind_url
|
||||||
"""
|
"""
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
@@ -325,26 +337,6 @@ class JupyterHub(Application):
|
|||||||
"""
|
"""
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
@observe('ip', 'port')
|
|
||||||
def _ip_port_changed(self, change):
|
|
||||||
urlinfo = urlparse(self.bind_url)
|
|
||||||
urlinfo = urlinfo._replace(netloc='%s:%i' % (self.ip, self.port))
|
|
||||||
self.bind_url = urlunparse(urlinfo)
|
|
||||||
|
|
||||||
bind_url = Unicode(
|
|
||||||
"http://127.0.0.1:8000",
|
|
||||||
help="""The public facing URL of the whole JupyterHub application.
|
|
||||||
|
|
||||||
This is the address on which the proxy will bind.
|
|
||||||
Sets protocol, ip, base_url
|
|
||||||
"""
|
|
||||||
).tag(config=True)
|
|
||||||
|
|
||||||
@observe('bind_url')
|
|
||||||
def _bind_url_changed(self, change):
|
|
||||||
urlinfo = urlparse(change.new)
|
|
||||||
self.base_url = urlinfo.path
|
|
||||||
|
|
||||||
base_url = URLPrefix('/',
|
base_url = URLPrefix('/',
|
||||||
help="""The base URL of the entire application.
|
help="""The base URL of the entire application.
|
||||||
|
|
||||||
@@ -361,6 +353,25 @@ class JupyterHub(Application):
|
|||||||
# call validate to ensure leading/trailing slashes
|
# call validate to ensure leading/trailing slashes
|
||||||
return JupyterHub.base_url.validate(self, urlparse(self.bind_url).path)
|
return JupyterHub.base_url.validate(self, urlparse(self.bind_url).path)
|
||||||
|
|
||||||
|
@observe('ip', 'port', 'base_url')
|
||||||
|
def _url_part_changed(self, change):
|
||||||
|
"""propagate deprecated ip/port/base_url config to the bind_url"""
|
||||||
|
urlinfo = urlparse(self.bind_url)
|
||||||
|
urlinfo = urlinfo._replace(netloc='%s:%i' % (self.ip, self.port))
|
||||||
|
urlinfo = urlinfo._replace(path=self.base_url)
|
||||||
|
bind_url = urlunparse(urlinfo)
|
||||||
|
if bind_url != self.bind_url:
|
||||||
|
self.bind_url = bind_url
|
||||||
|
|
||||||
|
bind_url = Unicode(
|
||||||
|
"http://:8000",
|
||||||
|
help="""The public facing URL of the whole JupyterHub application.
|
||||||
|
|
||||||
|
This is the address on which the proxy will bind.
|
||||||
|
Sets protocol, ip, base_url
|
||||||
|
"""
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
subdomain_host = Unicode('',
|
subdomain_host = Unicode('',
|
||||||
help="""Run single-user servers on subdomains of this host.
|
help="""Run single-user servers on subdomains of this host.
|
||||||
|
|
||||||
@@ -932,6 +943,24 @@ class JupyterHub(Application):
|
|||||||
handlers[i] = tuple(lis)
|
handlers[i] = tuple(lis)
|
||||||
return handlers
|
return handlers
|
||||||
|
|
||||||
|
extra_handlers = List(
|
||||||
|
help="""
|
||||||
|
Register extra tornado Handlers for jupyterhub.
|
||||||
|
|
||||||
|
Should be of the form ``("<regex>", Handler)``
|
||||||
|
|
||||||
|
The Hub prefix will be added, so `/my-page` will be served at `/hub/my-page`.
|
||||||
|
""",
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
default_url = Unicode(
|
||||||
|
help="""
|
||||||
|
The default URL for users when they arrive (e.g. when user directs to "/")
|
||||||
|
|
||||||
|
By default, redirects users to their own server.
|
||||||
|
""",
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
def init_handlers(self):
|
def init_handlers(self):
|
||||||
h = []
|
h = []
|
||||||
# load handlers from the authenticator
|
# load handlers from the authenticator
|
||||||
@@ -940,12 +969,15 @@ class JupyterHub(Application):
|
|||||||
h.extend(handlers.default_handlers)
|
h.extend(handlers.default_handlers)
|
||||||
h.extend(apihandlers.default_handlers)
|
h.extend(apihandlers.default_handlers)
|
||||||
|
|
||||||
|
# add any user configurable handlers.
|
||||||
|
h.extend(self.extra_handlers)
|
||||||
|
|
||||||
h.append((r'/logo', LogoHandler, {'path': self.logo_file}))
|
h.append((r'/logo', LogoHandler, {'path': self.logo_file}))
|
||||||
|
h.append((r'/api/(.*)', apihandlers.base.API404))
|
||||||
|
|
||||||
self.handlers = self.add_url_prefix(self.hub_prefix, h)
|
self.handlers = self.add_url_prefix(self.hub_prefix, h)
|
||||||
# some extra handlers, outside hub_prefix
|
# some extra handlers, outside hub_prefix
|
||||||
self.handlers.extend([
|
self.handlers.extend([
|
||||||
# add trailing / to `/hub`
|
|
||||||
(self.hub_prefix.rstrip('/'), handlers.AddSlashHandler),
|
|
||||||
# add trailing / to ``/user|services/:name`
|
# add trailing / to ``/user|services/:name`
|
||||||
(r"%s(user|services)/([^/]+)" % self.base_url, handlers.AddSlashHandler),
|
(r"%s(user|services)/([^/]+)" % self.base_url, handlers.AddSlashHandler),
|
||||||
(r"(?!%s).*" % self.hub_prefix, handlers.PrefixRedirectHandler),
|
(r"(?!%s).*" % self.hub_prefix, handlers.PrefixRedirectHandler),
|
||||||
@@ -1078,7 +1110,18 @@ class JupyterHub(Application):
|
|||||||
else:
|
else:
|
||||||
hub_args['ip'] = self.hub_ip
|
hub_args['ip'] = self.hub_ip
|
||||||
hub_args['port'] = self.hub_port
|
hub_args['port'] = self.hub_port
|
||||||
self.hub = Hub(**hub_args)
|
|
||||||
|
# routespec for the Hub is the *app* base url
|
||||||
|
# not the hub URL, so it receives requests for non-running servers
|
||||||
|
# use `/` with host-based routing so the Hub
|
||||||
|
# gets requests for all hosts
|
||||||
|
host = ''
|
||||||
|
if self.subdomain_host:
|
||||||
|
routespec = '/'
|
||||||
|
else:
|
||||||
|
routespec = self.base_url
|
||||||
|
|
||||||
|
self.hub = Hub(routespec=routespec, **hub_args)
|
||||||
|
|
||||||
if self.hub_connect_ip:
|
if self.hub_connect_ip:
|
||||||
self.hub.connect_ip = self.hub_connect_ip
|
self.hub.connect_ip = self.hub_connect_ip
|
||||||
@@ -1264,10 +1307,23 @@ class JupyterHub(Application):
|
|||||||
self.log.debug("Not duplicating token %s", orm_token)
|
self.log.debug("Not duplicating token %s", orm_token)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# purge expired tokens hourly
|
||||||
|
purge_expired_tokens_interval = 3600
|
||||||
|
|
||||||
async def init_api_tokens(self):
|
async def init_api_tokens(self):
|
||||||
"""Load predefined API tokens (for services) into database"""
|
"""Load predefined API tokens (for services) into database"""
|
||||||
await self._add_tokens(self.service_tokens, kind='service')
|
await self._add_tokens(self.service_tokens, kind='service')
|
||||||
await self._add_tokens(self.api_tokens, kind='user')
|
await self._add_tokens(self.api_tokens, kind='user')
|
||||||
|
purge_expired_tokens = partial(orm.APIToken.purge_expired, self.db)
|
||||||
|
purge_expired_tokens()
|
||||||
|
# purge expired tokens hourly
|
||||||
|
# we don't need to be prompt about this
|
||||||
|
# because expired tokens cannot be used anyway
|
||||||
|
pc = PeriodicCallback(
|
||||||
|
purge_expired_tokens,
|
||||||
|
1e3 * self.purge_expired_tokens_interval,
|
||||||
|
)
|
||||||
|
pc.start()
|
||||||
|
|
||||||
def init_services(self):
|
def init_services(self):
|
||||||
self._service_map.clear()
|
self._service_map.clear()
|
||||||
@@ -1452,12 +1508,22 @@ class JupyterHub(Application):
|
|||||||
for user in self.users.values():
|
for user in self.users.values():
|
||||||
for spawner in user.spawners.values():
|
for spawner in user.spawners.values():
|
||||||
oauth_client_ids.add(spawner.oauth_client_id)
|
oauth_client_ids.add(spawner.oauth_client_id)
|
||||||
|
# avoid deleting clients created by 0.8
|
||||||
|
# 0.9 uses `jupyterhub-user-...` for the client id, while
|
||||||
|
# 0.8 uses just `user-...`
|
||||||
|
oauth_client_ids.add(spawner.oauth_client_id.split('-', 1)[1])
|
||||||
|
|
||||||
client_store = self.oauth_provider.client_authenticator.client_store
|
client_store = self.oauth_provider.client_authenticator.client_store
|
||||||
for oauth_client in self.db.query(orm.OAuthClient):
|
for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)):
|
||||||
if oauth_client.identifier not in oauth_client_ids:
|
if oauth_client.identifier not in oauth_client_ids:
|
||||||
self.log.warning("Deleting OAuth client %s", oauth_client.identifier)
|
self.log.warning("Deleting OAuth client %s", oauth_client.identifier)
|
||||||
self.db.delete(oauth_client)
|
self.db.delete(oauth_client)
|
||||||
|
# Some deployments that create temporary users may have left *lots*
|
||||||
|
# of entries here.
|
||||||
|
# Don't try to delete them all in one transaction,
|
||||||
|
# commit at most 100 deletions at a time.
|
||||||
|
if i % 100 == 0:
|
||||||
|
self.db.commit()
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
def init_proxy(self):
|
def init_proxy(self):
|
||||||
@@ -1517,6 +1583,7 @@ class JupyterHub(Application):
|
|||||||
authenticator=self.authenticator,
|
authenticator=self.authenticator,
|
||||||
spawner_class=self.spawner_class,
|
spawner_class=self.spawner_class,
|
||||||
base_url=self.base_url,
|
base_url=self.base_url,
|
||||||
|
default_url=self.default_url,
|
||||||
cookie_secret=self.cookie_secret,
|
cookie_secret=self.cookie_secret,
|
||||||
cookie_max_age_days=self.cookie_max_age_days,
|
cookie_max_age_days=self.cookie_max_age_days,
|
||||||
redirect_to_server=self.redirect_to_server,
|
redirect_to_server=self.redirect_to_server,
|
||||||
@@ -1526,6 +1593,7 @@ class JupyterHub(Application):
|
|||||||
static_url_prefix=url_path_join(self.hub.base_url, 'static/'),
|
static_url_prefix=url_path_join(self.hub.base_url, 'static/'),
|
||||||
static_handler_class=CacheControlStaticFilesHandler,
|
static_handler_class=CacheControlStaticFilesHandler,
|
||||||
template_path=self.template_paths,
|
template_path=self.template_paths,
|
||||||
|
template_vars=self.template_vars,
|
||||||
jinja2_env=jinja_env,
|
jinja2_env=jinja_env,
|
||||||
version_hash=version_hash,
|
version_hash=version_hash,
|
||||||
subdomain_host=self.subdomain_host,
|
subdomain_host=self.subdomain_host,
|
||||||
@@ -1580,6 +1648,33 @@ class JupyterHub(Application):
|
|||||||
cfg.JupyterHub.merge(cfg.JupyterHubApp)
|
cfg.JupyterHub.merge(cfg.JupyterHubApp)
|
||||||
self.update_config(cfg)
|
self.update_config(cfg)
|
||||||
self.write_pid_file()
|
self.write_pid_file()
|
||||||
|
|
||||||
|
def _log_cls(name, cls):
|
||||||
|
"""Log a configured class
|
||||||
|
|
||||||
|
Logs the class and version (if found) of Authenticator
|
||||||
|
and Spawner
|
||||||
|
"""
|
||||||
|
# try to guess the version from the top-level module
|
||||||
|
# this will work often enough to be useful.
|
||||||
|
# no need to be perfect.
|
||||||
|
if cls.__module__:
|
||||||
|
mod = sys.modules.get(cls.__module__.split('.')[0])
|
||||||
|
version = getattr(mod, '__version__', '')
|
||||||
|
if version:
|
||||||
|
version = '-{}'.format(version)
|
||||||
|
else:
|
||||||
|
version = ''
|
||||||
|
self.log.info(
|
||||||
|
"Using %s: %s.%s%s",
|
||||||
|
name,
|
||||||
|
cls.__module__ or '',
|
||||||
|
cls.__name__,
|
||||||
|
version,
|
||||||
|
)
|
||||||
|
_log_cls("Authenticator", self.authenticator_class)
|
||||||
|
_log_cls("Spawner", self.spawner_class)
|
||||||
|
|
||||||
self.init_pycurl()
|
self.init_pycurl()
|
||||||
self.init_secrets()
|
self.init_secrets()
|
||||||
self.init_db()
|
self.init_db()
|
||||||
@@ -1712,13 +1807,18 @@ class JupyterHub(Application):
|
|||||||
spawner.last_activity = max(spawner.last_activity, dt)
|
spawner.last_activity = max(spawner.last_activity, dt)
|
||||||
else:
|
else:
|
||||||
spawner.last_activity = dt
|
spawner.last_activity = dt
|
||||||
# FIXME: Make this configurable duration. 30 minutes for now!
|
if (now - user.last_activity).total_seconds() < self.active_user_window:
|
||||||
if (now - user.last_activity).total_seconds() < 30 * 60:
|
|
||||||
active_users_count += 1
|
active_users_count += 1
|
||||||
self.statsd.gauge('users.running', users_count)
|
self.statsd.gauge('users.running', users_count)
|
||||||
self.statsd.gauge('users.active', active_users_count)
|
self.statsd.gauge('users.active', active_users_count)
|
||||||
|
|
||||||
self.db.commit()
|
try:
|
||||||
|
self.db.commit()
|
||||||
|
except SQLAlchemyError:
|
||||||
|
self.log.exception("Rolling back session due to database error")
|
||||||
|
self.db.rollback()
|
||||||
|
return
|
||||||
|
|
||||||
await self.proxy.check_routes(self.users, self._service_map, routes)
|
await self.proxy.check_routes(self.users, self._service_map, routes)
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
@@ -1827,8 +1927,7 @@ class JupyterHub(Application):
|
|||||||
|
|
||||||
def sigterm(self, signum, frame):
|
def sigterm(self, signum, frame):
|
||||||
self.log.critical("Received SIGTERM, shutting down")
|
self.log.critical("Received SIGTERM, shutting down")
|
||||||
self.io_loop.stop()
|
raise SystemExit(128 + signum)
|
||||||
self.atexit()
|
|
||||||
|
|
||||||
_atexit_ran = False
|
_atexit_ran = False
|
||||||
|
|
||||||
@@ -1838,6 +1937,7 @@ class JupyterHub(Application):
|
|||||||
return
|
return
|
||||||
self._atexit_ran = True
|
self._atexit_ran = True
|
||||||
# run the cleanup step (in a new loop, because the interrupted one is unclean)
|
# run the cleanup step (in a new loop, because the interrupted one is unclean)
|
||||||
|
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||||
IOLoop.clear_current()
|
IOLoop.clear_current()
|
||||||
loop = IOLoop()
|
loop = IOLoop()
|
||||||
loop.make_current()
|
loop.make_current()
|
||||||
|
@@ -49,7 +49,7 @@ class Authenticator(LoggingConfigurable):
|
|||||||
|
|
||||||
Encrypting auth_state requires the cryptography package.
|
Encrypting auth_state requires the cryptography package.
|
||||||
|
|
||||||
Additionally, the JUPYTERHUB_CRYPTO_KEY envirionment variable must
|
Additionally, the JUPYTERHUB_CRYPT_KEY environment variable must
|
||||||
contain one (or more, separated by ;) 32B encryption keys.
|
contain one (or more, separated by ;) 32B encryption keys.
|
||||||
These can be either base64 or hex-encoded.
|
These can be either base64 or hex-encoded.
|
||||||
|
|
||||||
|
@@ -6,6 +6,7 @@
|
|||||||
import copy
|
import copy
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from http.client import responses
|
from http.client import responses
|
||||||
|
import json
|
||||||
import math
|
import math
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
@@ -15,6 +16,7 @@ import uuid
|
|||||||
|
|
||||||
from jinja2 import TemplateNotFound
|
from jinja2 import TemplateNotFound
|
||||||
|
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
from tornado.httputil import url_concat, HTTPHeaders
|
from tornado.httputil import url_concat, HTTPHeaders
|
||||||
from tornado.ioloop import IOLoop
|
from tornado.ioloop import IOLoop
|
||||||
@@ -39,7 +41,8 @@ reasons = {
|
|||||||
'timeout': "Failed to reach your server."
|
'timeout': "Failed to reach your server."
|
||||||
" Please try again later."
|
" Please try again later."
|
||||||
" Contact admin if the issue persists.",
|
" Contact admin if the issue persists.",
|
||||||
'error': "Failed to start your server. Please contact admin.",
|
'error': "Failed to start your server on the last attempt. "
|
||||||
|
" Please contact admin if the issue persists.",
|
||||||
}
|
}
|
||||||
|
|
||||||
# constant, not configurable
|
# constant, not configurable
|
||||||
@@ -61,6 +64,10 @@ class BaseHandler(RequestHandler):
|
|||||||
def base_url(self):
|
def base_url(self):
|
||||||
return self.settings.get('base_url', '/')
|
return self.settings.get('base_url', '/')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_url(self):
|
||||||
|
return self.settings.get('default_url', '')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def version_hash(self):
|
def version_hash(self):
|
||||||
return self.settings.get('version_hash', '')
|
return self.settings.get('version_hash', '')
|
||||||
@@ -260,10 +267,17 @@ class BaseHandler(RequestHandler):
|
|||||||
|
|
||||||
def get_current_user(self):
|
def get_current_user(self):
|
||||||
"""get current username"""
|
"""get current username"""
|
||||||
user = self.get_current_user_token()
|
if not hasattr(self, '_jupyterhub_user'):
|
||||||
if user is not None:
|
try:
|
||||||
return user
|
user = self.get_current_user_token()
|
||||||
return self.get_current_user_cookie()
|
if user is None:
|
||||||
|
user = self.get_current_user_cookie()
|
||||||
|
self._jupyterhub_user = user
|
||||||
|
except Exception:
|
||||||
|
# don't let errors here raise more than once
|
||||||
|
self._jupyterhub_user = None
|
||||||
|
raise
|
||||||
|
return self._jupyterhub_user
|
||||||
|
|
||||||
def find_user(self, name):
|
def find_user(self, name):
|
||||||
"""Get a user by name
|
"""Get a user by name
|
||||||
@@ -413,10 +427,20 @@ class BaseHandler(RequestHandler):
|
|||||||
- else: /hub/home
|
- else: /hub/home
|
||||||
"""
|
"""
|
||||||
next_url = self.get_argument('next', default='')
|
next_url = self.get_argument('next', default='')
|
||||||
if (next_url + '/').startswith('%s://%s/' % (self.request.protocol, self.request.host)):
|
if (next_url + '/').startswith(
|
||||||
|
(
|
||||||
|
'%s://%s/' % (self.request.protocol, self.request.host),
|
||||||
|
'//%s/' % self.request.host,
|
||||||
|
)
|
||||||
|
):
|
||||||
# treat absolute URLs for our host as absolute paths:
|
# treat absolute URLs for our host as absolute paths:
|
||||||
next_url = urlparse(next_url).path
|
parsed = urlparse(next_url)
|
||||||
if next_url and not next_url.startswith('/'):
|
next_url = parsed.path
|
||||||
|
if parsed.query:
|
||||||
|
next_url = next_url + '?' + parsed.query
|
||||||
|
if parsed.hash:
|
||||||
|
next_url = next_url + '#' + parsed.hash
|
||||||
|
if next_url and (urlparse(next_url).netloc or not next_url.startswith('/')):
|
||||||
self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
|
self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
|
||||||
next_url = ''
|
next_url = ''
|
||||||
if next_url and next_url.startswith(url_path_join(self.base_url, 'user/')):
|
if next_url and next_url.startswith(url_path_join(self.base_url, 'user/')):
|
||||||
@@ -429,9 +453,14 @@ class BaseHandler(RequestHandler):
|
|||||||
self.request.uri, next_url,
|
self.request.uri, next_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not next_url:
|
||||||
|
# custom default URL
|
||||||
|
next_url = self.default_url
|
||||||
|
|
||||||
if not next_url:
|
if not next_url:
|
||||||
# default URL after login
|
# default URL after login
|
||||||
# if self.redirect_to_server, default login URL initiates spawn
|
# if self.redirect_to_server, default login URL initiates spawn,
|
||||||
|
# otherwise send to Hub home page (control panel)
|
||||||
if user and self.redirect_to_server:
|
if user and self.redirect_to_server:
|
||||||
next_url = user.url
|
next_url = user.url
|
||||||
else:
|
else:
|
||||||
@@ -573,7 +602,7 @@ class BaseHandler(RequestHandler):
|
|||||||
|
|
||||||
self.log.debug("Initiating spawn for %s", user_server_name)
|
self.log.debug("Initiating spawn for %s", user_server_name)
|
||||||
|
|
||||||
spawn_future = user.spawn(server_name, options)
|
spawn_future = user.spawn(server_name, options, handler=self)
|
||||||
|
|
||||||
self.log.debug("%i%s concurrent spawns",
|
self.log.debug("%i%s concurrent spawns",
|
||||||
spawn_pending_count,
|
spawn_pending_count,
|
||||||
@@ -628,6 +657,7 @@ class BaseHandler(RequestHandler):
|
|||||||
# hook up spawner._spawn_future so that other requests can await
|
# hook up spawner._spawn_future so that other requests can await
|
||||||
# this result
|
# this result
|
||||||
finish_spawn_future = spawner._spawn_future = maybe_future(finish_user_spawn())
|
finish_spawn_future = spawner._spawn_future = maybe_future(finish_user_spawn())
|
||||||
|
|
||||||
def _clear_spawn_future(f):
|
def _clear_spawn_future(f):
|
||||||
# clear spawner._spawn_future when it's done
|
# clear spawner._spawn_future when it's done
|
||||||
# keep an exception around, though, to prevent repeated implicit spawns
|
# keep an exception around, though, to prevent repeated implicit spawns
|
||||||
@@ -636,10 +666,44 @@ class BaseHandler(RequestHandler):
|
|||||||
spawner._spawn_future = None
|
spawner._spawn_future = None
|
||||||
# Now we're all done. clear _spawn_pending flag
|
# Now we're all done. clear _spawn_pending flag
|
||||||
spawner._spawn_pending = False
|
spawner._spawn_pending = False
|
||||||
|
|
||||||
finish_spawn_future.add_done_callback(_clear_spawn_future)
|
finish_spawn_future.add_done_callback(_clear_spawn_future)
|
||||||
|
|
||||||
|
# when spawn finishes (success or failure)
|
||||||
|
# update failure count and abort if consecutive failure limit
|
||||||
|
# is reached
|
||||||
|
def _track_failure_count(f):
|
||||||
|
if f.exception() is None:
|
||||||
|
# spawn succeeded, reset failure count
|
||||||
|
self.settings['failure_count'] = 0
|
||||||
|
return
|
||||||
|
# spawn failed, increment count and abort if limit reached
|
||||||
|
self.settings.setdefault('failure_count', 0)
|
||||||
|
self.settings['failure_count'] += 1
|
||||||
|
failure_count = self.settings['failure_count']
|
||||||
|
failure_limit = spawner.consecutive_failure_limit
|
||||||
|
if failure_limit and 1 < failure_count < failure_limit:
|
||||||
|
self.log.warning(
|
||||||
|
"%i consecutive spawns failed. "
|
||||||
|
"Hub will exit if failure count reaches %i before succeeding",
|
||||||
|
failure_count, failure_limit,
|
||||||
|
)
|
||||||
|
if failure_limit and failure_count >= failure_limit:
|
||||||
|
self.log.critical(
|
||||||
|
"Aborting due to %i consecutive spawn failures", failure_count
|
||||||
|
)
|
||||||
|
# abort in 2 seconds to allow pending handlers to resolve
|
||||||
|
# mostly propagating errors for the current failures
|
||||||
|
def abort():
|
||||||
|
raise SystemExit(1)
|
||||||
|
IOLoop.current().call_later(2, abort)
|
||||||
|
|
||||||
|
finish_spawn_future.add_done_callback(_track_failure_count)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), finish_spawn_future)
|
await gen.with_timeout(
|
||||||
|
timedelta(seconds=self.slow_spawn_timeout), finish_spawn_future
|
||||||
|
)
|
||||||
except gen.TimeoutError:
|
except gen.TimeoutError:
|
||||||
# waiting_for_response indicates server process has started,
|
# waiting_for_response indicates server process has started,
|
||||||
# but is yet to become responsive.
|
# but is yet to become responsive.
|
||||||
@@ -752,7 +816,7 @@ class BaseHandler(RequestHandler):
|
|||||||
@property
|
@property
|
||||||
def template_namespace(self):
|
def template_namespace(self):
|
||||||
user = self.get_current_user()
|
user = self.get_current_user()
|
||||||
return dict(
|
ns = dict(
|
||||||
base_url=self.hub.base_url,
|
base_url=self.hub.base_url,
|
||||||
prefix=self.base_url,
|
prefix=self.base_url,
|
||||||
user=user,
|
user=user,
|
||||||
@@ -762,6 +826,9 @@ class BaseHandler(RequestHandler):
|
|||||||
static_url=self.static_url,
|
static_url=self.static_url,
|
||||||
version_hash=self.version_hash,
|
version_hash=self.version_hash,
|
||||||
)
|
)
|
||||||
|
if self.settings['template_vars']:
|
||||||
|
ns.update(self.settings['template_vars'])
|
||||||
|
return ns
|
||||||
|
|
||||||
def write_error(self, status_code, **kwargs):
|
def write_error(self, status_code, **kwargs):
|
||||||
"""render custom error pages"""
|
"""render custom error pages"""
|
||||||
@@ -782,6 +849,10 @@ class BaseHandler(RequestHandler):
|
|||||||
if reason:
|
if reason:
|
||||||
message = reasons.get(reason, reason)
|
message = reasons.get(reason, reason)
|
||||||
|
|
||||||
|
if exception and isinstance(exception, SQLAlchemyError):
|
||||||
|
self.log.warning("Rolling back session due to database error %s", exception)
|
||||||
|
self.db.rollback()
|
||||||
|
|
||||||
# build template namespace
|
# build template namespace
|
||||||
ns = dict(
|
ns = dict(
|
||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
@@ -792,19 +863,27 @@ class BaseHandler(RequestHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.set_header('Content-Type', 'text/html')
|
self.set_header('Content-Type', 'text/html')
|
||||||
# allow setting headers from exceptions
|
if isinstance(exception, web.HTTPError):
|
||||||
# since exception handler clears headers
|
# allow setting headers from exceptions
|
||||||
headers = getattr(exception, 'headers', None)
|
# since exception handler clears headers
|
||||||
if headers:
|
headers = getattr(exception, 'headers', None)
|
||||||
for key, value in headers.items():
|
if headers:
|
||||||
self.set_header(key, value)
|
for key, value in headers.items():
|
||||||
|
self.set_header(key, value)
|
||||||
|
# Content-Length must be recalculated.
|
||||||
|
self.clear_header('Content-Length')
|
||||||
|
|
||||||
# render the template
|
# render the template
|
||||||
try:
|
try:
|
||||||
html = self.render_template('%s.html' % status_code, **ns)
|
html = self.render_template('%s.html' % status_code, **ns)
|
||||||
except TemplateNotFound:
|
except TemplateNotFound:
|
||||||
self.log.debug("No template for %d", status_code)
|
self.log.debug("No template for %d", status_code)
|
||||||
html = self.render_template('error.html', **ns)
|
try:
|
||||||
|
html = self.render_template('error.html', **ns)
|
||||||
|
except:
|
||||||
|
# In this case, any side effect must be avoided.
|
||||||
|
ns['no_spawner_check'] = True
|
||||||
|
html = self.render_template('error.html', **ns)
|
||||||
|
|
||||||
self.write(html)
|
self.write(html)
|
||||||
|
|
||||||
@@ -822,6 +901,11 @@ class PrefixRedirectHandler(BaseHandler):
|
|||||||
"""
|
"""
|
||||||
def get(self):
|
def get(self):
|
||||||
uri = self.request.uri
|
uri = self.request.uri
|
||||||
|
# Since self.base_url will end with trailing slash.
|
||||||
|
# Ensure uri will end with trailing slash when matching
|
||||||
|
# with self.base_url.
|
||||||
|
if not uri.endswith('/'):
|
||||||
|
uri += '/'
|
||||||
if uri.startswith(self.base_url):
|
if uri.startswith(self.base_url):
|
||||||
path = self.request.uri[len(self.base_url):]
|
path = self.request.uri[len(self.base_url):]
|
||||||
else:
|
else:
|
||||||
@@ -841,6 +925,13 @@ class UserSpawnHandler(BaseHandler):
|
|||||||
which will in turn send her to /user/alice/notebooks/mynotebook.ipynb.
|
which will in turn send her to /user/alice/notebooks/mynotebook.ipynb.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def _fail_api_request(self, user):
|
||||||
|
"""Fail an API request to a not-running server"""
|
||||||
|
self.set_status(404)
|
||||||
|
self.set_header("Content-Type", "application/json")
|
||||||
|
self.write(json.dumps({"message": "%s is not running" % user.name}))
|
||||||
|
self.finish()
|
||||||
|
|
||||||
async def get(self, name, user_path):
|
async def get(self, name, user_path):
|
||||||
if not user_path:
|
if not user_path:
|
||||||
user_path = '/'
|
user_path = '/'
|
||||||
@@ -867,6 +958,11 @@ class UserSpawnHandler(BaseHandler):
|
|||||||
# otherwise redirect users to their own server
|
# otherwise redirect users to their own server
|
||||||
should_spawn = (current_user and current_user.name == name)
|
should_spawn = (current_user and current_user.name == name)
|
||||||
|
|
||||||
|
if "api" in user_path.split("/") and not user.active:
|
||||||
|
# API request for not-running server (e.g. notebook UI left open)
|
||||||
|
# Avoid triggering a spawn.
|
||||||
|
self._fail_api_request(user)
|
||||||
|
return
|
||||||
|
|
||||||
if should_spawn:
|
if should_spawn:
|
||||||
# if spawning fails for any reason, point users to /hub/home to retry
|
# if spawning fails for any reason, point users to /hub/home to retry
|
||||||
@@ -907,7 +1003,7 @@ class UserSpawnHandler(BaseHandler):
|
|||||||
raise copy.copy(exc).with_traceback(exc.__traceback__)
|
raise copy.copy(exc).with_traceback(exc.__traceback__)
|
||||||
|
|
||||||
# check for pending spawn
|
# check for pending spawn
|
||||||
if spawner.pending and spawner._spawn_future:
|
if spawner.pending == 'spawn' and spawner._spawn_future:
|
||||||
# wait on the pending spawn
|
# wait on the pending spawn
|
||||||
self.log.debug("Waiting for %s pending %s", spawner._log_name, spawner.pending)
|
self.log.debug("Waiting for %s pending %s", spawner._log_name, spawner.pending)
|
||||||
try:
|
try:
|
||||||
@@ -917,14 +1013,20 @@ class UserSpawnHandler(BaseHandler):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# we may have waited above, check pending again:
|
# we may have waited above, check pending again:
|
||||||
|
# page could be pending spawn *or* stop
|
||||||
if spawner.pending:
|
if spawner.pending:
|
||||||
self.log.info("%s is pending %s", spawner._log_name, spawner.pending)
|
self.log.info("%s is pending %s", spawner._log_name, spawner.pending)
|
||||||
# spawn has started, but not finished
|
# spawn has started, but not finished
|
||||||
self.statsd.incr('redirects.user_spawn_pending', 1)
|
self.statsd.incr('redirects.user_spawn_pending', 1)
|
||||||
url_parts = []
|
url_parts = []
|
||||||
|
if spawner.pending == "stop":
|
||||||
|
page = "stop_pending.html"
|
||||||
|
else:
|
||||||
|
page = "spawn_pending.html"
|
||||||
html = self.render_template(
|
html = self.render_template(
|
||||||
"spawn_pending.html",
|
page,
|
||||||
user=user,
|
user=user,
|
||||||
|
spawner=spawner,
|
||||||
progress_url=spawner._progress_url,
|
progress_url=spawner._progress_url,
|
||||||
)
|
)
|
||||||
self.finish(html)
|
self.finish(html)
|
||||||
@@ -1070,6 +1172,7 @@ class AddSlashHandler(BaseHandler):
|
|||||||
self.redirect(urlunparse(dest))
|
self.redirect(urlunparse(dest))
|
||||||
|
|
||||||
default_handlers = [
|
default_handlers = [
|
||||||
|
(r'', AddSlashHandler), # add trailing / to `/hub`
|
||||||
(r'/user/([^/]+)(/.*)?', UserSpawnHandler),
|
(r'/user/([^/]+)(/.*)?', UserSpawnHandler),
|
||||||
(r'/user-redirect/(.*)?', UserRedirectHandler),
|
(r'/user-redirect/(.*)?', UserRedirectHandler),
|
||||||
(r'/security/csp-report', CSPReportHandler),
|
(r'/security/csp-report', CSPReportHandler),
|
||||||
|
@@ -3,6 +3,7 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from http.client import responses
|
from http.client import responses
|
||||||
|
|
||||||
@@ -30,7 +31,9 @@ class RootHandler(BaseHandler):
|
|||||||
"""
|
"""
|
||||||
def get(self):
|
def get(self):
|
||||||
user = self.get_current_user()
|
user = self.get_current_user()
|
||||||
if user:
|
if self.default_url:
|
||||||
|
url = self.default_url
|
||||||
|
elif user:
|
||||||
url = self.get_next_url(user)
|
url = self.get_next_url(user)
|
||||||
else:
|
else:
|
||||||
url = self.settings['login_url']
|
url = self.settings['login_url']
|
||||||
@@ -108,7 +111,11 @@ class SpawnHandler(BaseHandler):
|
|||||||
if user.spawner._spawn_future and user.spawner._spawn_future.done():
|
if user.spawner._spawn_future and user.spawner._spawn_future.done():
|
||||||
user.spawner._spawn_future = None
|
user.spawner._spawn_future = None
|
||||||
# not running, no form. Trigger spawn by redirecting to /user/:name
|
# not running, no form. Trigger spawn by redirecting to /user/:name
|
||||||
self.redirect(user.url)
|
url = user.url
|
||||||
|
if self.request.query:
|
||||||
|
# add query params
|
||||||
|
url += '?' + self.request.query
|
||||||
|
self.redirect(url)
|
||||||
|
|
||||||
@web.authenticated
|
@web.authenticated
|
||||||
async def post(self, for_user=None):
|
async def post(self, for_user=None):
|
||||||
@@ -229,12 +236,26 @@ class TokenPageHandler(BaseHandler):
|
|||||||
token.last_activity or never,
|
token.last_activity or never,
|
||||||
token.created or never,
|
token.created or never,
|
||||||
)
|
)
|
||||||
api_tokens = sorted(user.api_tokens, key=sort_key, reverse=True)
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
api_tokens = []
|
||||||
|
for token in sorted(user.api_tokens, key=sort_key, reverse=True):
|
||||||
|
if token.expires_at and token.expires_at < now:
|
||||||
|
self.db.delete(token)
|
||||||
|
self.db.commit()
|
||||||
|
continue
|
||||||
|
api_tokens.append(token)
|
||||||
|
|
||||||
# group oauth client tokens by client id
|
# group oauth client tokens by client id
|
||||||
from collections import defaultdict
|
# AccessTokens have expires_at as an integer timestamp
|
||||||
|
now_timestamp = now.timestamp()
|
||||||
oauth_tokens = defaultdict(list)
|
oauth_tokens = defaultdict(list)
|
||||||
for token in user.oauth_tokens:
|
for token in user.oauth_tokens:
|
||||||
|
if token.expires_at and token.expires_at < now_timestamp:
|
||||||
|
self.log.warning("Deleting expired token")
|
||||||
|
self.db.delete(token)
|
||||||
|
self.db.commit()
|
||||||
|
continue
|
||||||
if not token.client_id:
|
if not token.client_id:
|
||||||
# token should have been deleted when client was deleted
|
# token should have been deleted when client was deleted
|
||||||
self.log.warning("Deleting stale oauth token for %s", user.name)
|
self.log.warning("Deleting stale oauth token for %s", user.name)
|
||||||
@@ -260,7 +281,7 @@ class TokenPageHandler(BaseHandler):
|
|||||||
token = tokens[0]
|
token = tokens[0]
|
||||||
oauth_clients.append({
|
oauth_clients.append({
|
||||||
'client': token.client,
|
'client': token.client,
|
||||||
'description': token.client.description or token.client.client_id,
|
'description': token.client.description or token.client.identifier,
|
||||||
'created': created,
|
'created': created,
|
||||||
'last_activity': last_activity,
|
'last_activity': last_activity,
|
||||||
'tokens': tokens,
|
'tokens': tokens,
|
||||||
@@ -321,7 +342,7 @@ class ProxyErrorHandler(BaseHandler):
|
|||||||
|
|
||||||
|
|
||||||
default_handlers = [
|
default_handlers = [
|
||||||
(r'/?', RootHandler),
|
(r'/', RootHandler),
|
||||||
(r'/home', HomeHandler),
|
(r'/home', HomeHandler),
|
||||||
(r'/admin', AdminHandler),
|
(r'/admin', AdminHandler),
|
||||||
(r'/spawn', SpawnHandler),
|
(r'/spawn', SpawnHandler),
|
||||||
|
@@ -63,6 +63,9 @@ class Server(HasTraits):
|
|||||||
@validate('connect_url')
|
@validate('connect_url')
|
||||||
def _connect_url_add_prefix(self, proposal):
|
def _connect_url_add_prefix(self, proposal):
|
||||||
"""Ensure connect_url includes base_url"""
|
"""Ensure connect_url includes base_url"""
|
||||||
|
if not proposal.value:
|
||||||
|
# Don't add the prefix if the setting is being cleared
|
||||||
|
return proposal.value
|
||||||
urlinfo = urlparse(proposal.value)
|
urlinfo = urlparse(proposal.value)
|
||||||
if not urlinfo.path.startswith(self.base_url):
|
if not urlinfo.path.startswith(self.base_url):
|
||||||
urlinfo = urlinfo._replace(path=self.base_url)
|
urlinfo = urlinfo._replace(path=self.base_url)
|
||||||
@@ -185,6 +188,7 @@ class Hub(Server):
|
|||||||
)
|
)
|
||||||
return self
|
return self
|
||||||
public_host = Unicode()
|
public_host = Unicode()
|
||||||
|
routespec = Unicode()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def api_url(self):
|
def api_url(self):
|
||||||
|
@@ -3,7 +3,7 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
import enum
|
import enum
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ from tornado.log import app_log
|
|||||||
|
|
||||||
from sqlalchemy.types import TypeDecorator, TEXT, LargeBinary
|
from sqlalchemy.types import TypeDecorator, TEXT, LargeBinary
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
create_engine, event, inspect,
|
create_engine, event, exc, inspect, or_, select,
|
||||||
Column, Integer, ForeignKey, Unicode, Boolean,
|
Column, Integer, ForeignKey, Unicode, Boolean,
|
||||||
DateTime, Enum, Table,
|
DateTime, Enum, Table,
|
||||||
)
|
)
|
||||||
@@ -33,6 +33,9 @@ from .utils import (
|
|||||||
new_token, hash_token, compare_token,
|
new_token, hash_token, compare_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# top-level variable for easier mocking in tests
|
||||||
|
utcnow = datetime.utcnow
|
||||||
|
|
||||||
|
|
||||||
class JSONDict(TypeDecorator):
|
class JSONDict(TypeDecorator):
|
||||||
"""Represents an immutable structure as a json-encoded string.
|
"""Represents an immutable structure as a json-encoded string.
|
||||||
@@ -176,12 +179,12 @@ class User(Base):
|
|||||||
running=sum(bool(s.server) for s in self._orm_spawners),
|
running=sum(bool(s.server) for s in self._orm_spawners),
|
||||||
)
|
)
|
||||||
|
|
||||||
def new_api_token(self, token=None, generated=True, note=''):
|
def new_api_token(self, token=None, **kwargs):
|
||||||
"""Create a new API token
|
"""Create a new API token
|
||||||
|
|
||||||
If `token` is given, load that token.
|
If `token` is given, load that token.
|
||||||
"""
|
"""
|
||||||
return APIToken.new(token=token, user=self, note=note, generated=generated)
|
return APIToken.new(token=token, user=self, **kwargs)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find(cls, db, name):
|
def find(cls, db, name):
|
||||||
@@ -242,11 +245,11 @@ class Service(Base):
|
|||||||
server = relationship(Server, cascade='all')
|
server = relationship(Server, cascade='all')
|
||||||
pid = Column(Integer)
|
pid = Column(Integer)
|
||||||
|
|
||||||
def new_api_token(self, token=None, generated=True, note=''):
|
def new_api_token(self, token=None, **kwargs):
|
||||||
"""Create a new API token
|
"""Create a new API token
|
||||||
If `token` is given, load that token.
|
If `token` is given, load that token.
|
||||||
"""
|
"""
|
||||||
return APIToken.new(token=token, service=self, note=note, generated=generated)
|
return APIToken.new(token=token, service=self, **kwargs)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find(cls, db, name):
|
def find(cls, db, name):
|
||||||
@@ -348,6 +351,7 @@ class APIToken(Hashed, Base):
|
|||||||
|
|
||||||
# token metadata for bookkeeping
|
# token metadata for bookkeeping
|
||||||
created = Column(DateTime, default=datetime.utcnow)
|
created = Column(DateTime, default=datetime.utcnow)
|
||||||
|
expires_at = Column(DateTime, default=None, nullable=True)
|
||||||
last_activity = Column(DateTime)
|
last_activity = Column(DateTime)
|
||||||
note = Column(Unicode(1023))
|
note = Column(Unicode(1023))
|
||||||
|
|
||||||
@@ -369,6 +373,22 @@ class APIToken(Hashed, Base):
|
|||||||
name=name,
|
name=name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def purge_expired(cls, db):
|
||||||
|
"""Purge expired API Tokens from the database"""
|
||||||
|
now = utcnow()
|
||||||
|
deleted = False
|
||||||
|
for token in (
|
||||||
|
db.query(cls)
|
||||||
|
.filter(cls.expires_at != None)
|
||||||
|
.filter(cls.expires_at < now)
|
||||||
|
):
|
||||||
|
app_log.debug("Purging expired %s", token)
|
||||||
|
deleted = True
|
||||||
|
db.delete(token)
|
||||||
|
if deleted:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find(cls, db, token, *, kind=None):
|
def find(cls, db, token, *, kind=None):
|
||||||
"""Find a token object by value.
|
"""Find a token object by value.
|
||||||
@@ -379,6 +399,9 @@ class APIToken(Hashed, Base):
|
|||||||
`kind='service'` only returns API tokens for services
|
`kind='service'` only returns API tokens for services
|
||||||
"""
|
"""
|
||||||
prefix_match = cls.find_prefix(db, token)
|
prefix_match = cls.find_prefix(db, token)
|
||||||
|
prefix_match = prefix_match.filter(
|
||||||
|
or_(cls.expires_at == None, cls.expires_at >= utcnow())
|
||||||
|
)
|
||||||
if kind == 'user':
|
if kind == 'user':
|
||||||
prefix_match = prefix_match.filter(cls.user_id != None)
|
prefix_match = prefix_match.filter(cls.user_id != None)
|
||||||
elif kind == 'service':
|
elif kind == 'service':
|
||||||
@@ -390,7 +413,8 @@ class APIToken(Hashed, Base):
|
|||||||
return orm_token
|
return orm_token
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def new(cls, token=None, user=None, service=None, note='', generated=True):
|
def new(cls, token=None, user=None, service=None, note='', generated=True,
|
||||||
|
expires_in=None):
|
||||||
"""Generate a new API token for a user or service"""
|
"""Generate a new API token for a user or service"""
|
||||||
assert user or service
|
assert user or service
|
||||||
assert not (user and service)
|
assert not (user and service)
|
||||||
@@ -412,6 +436,8 @@ class APIToken(Hashed, Base):
|
|||||||
else:
|
else:
|
||||||
assert service.id is not None
|
assert service.id is not None
|
||||||
orm_token.service = service
|
orm_token.service = service
|
||||||
|
if expires_in is not None:
|
||||||
|
orm_token.expires_at = utcnow() + timedelta(seconds=expires_in)
|
||||||
db.add(orm_token)
|
db.add(orm_token)
|
||||||
db.commit()
|
db.commit()
|
||||||
return token
|
return token
|
||||||
@@ -549,7 +575,7 @@ def _expire_relationship(target, relationship_prop):
|
|||||||
def _notify_deleted_relationships(session, obj):
|
def _notify_deleted_relationships(session, obj):
|
||||||
"""Expire relationships when an object becomes deleted
|
"""Expire relationships when an object becomes deleted
|
||||||
|
|
||||||
Needed for
|
Needed to keep relationships up to date.
|
||||||
"""
|
"""
|
||||||
mapper = inspect(obj).mapper
|
mapper = inspect(obj).mapper
|
||||||
for prop in mapper.relationships:
|
for prop in mapper.relationships:
|
||||||
@@ -557,6 +583,52 @@ def _notify_deleted_relationships(session, obj):
|
|||||||
_expire_relationship(obj, prop)
|
_expire_relationship(obj, prop)
|
||||||
|
|
||||||
|
|
||||||
|
def register_ping_connection(engine):
|
||||||
|
"""Check connections before using them.
|
||||||
|
|
||||||
|
Avoids database errors when using stale connections.
|
||||||
|
|
||||||
|
From SQLAlchemy docs on pessimistic disconnect handling:
|
||||||
|
|
||||||
|
https://docs.sqlalchemy.org/en/rel_1_1/core/pooling.html#disconnect-handling-pessimistic
|
||||||
|
"""
|
||||||
|
@event.listens_for(engine, "engine_connect")
|
||||||
|
def ping_connection(connection, branch):
|
||||||
|
if branch:
|
||||||
|
# "branch" refers to a sub-connection of a connection,
|
||||||
|
# we don't want to bother pinging on these.
|
||||||
|
return
|
||||||
|
|
||||||
|
# turn off "close with result". This flag is only used with
|
||||||
|
# "connectionless" execution, otherwise will be False in any case
|
||||||
|
save_should_close_with_result = connection.should_close_with_result
|
||||||
|
connection.should_close_with_result = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# run a SELECT 1. use a core select() so that
|
||||||
|
# the SELECT of a scalar value without a table is
|
||||||
|
# appropriately formatted for the backend
|
||||||
|
connection.scalar(select([1]))
|
||||||
|
except exc.DBAPIError as err:
|
||||||
|
# catch SQLAlchemy's DBAPIError, which is a wrapper
|
||||||
|
# for the DBAPI's exception. It includes a .connection_invalidated
|
||||||
|
# attribute which specifies if this connection is a "disconnect"
|
||||||
|
# condition, which is based on inspection of the original exception
|
||||||
|
# by the dialect in use.
|
||||||
|
if err.connection_invalidated:
|
||||||
|
app_log.error("Database connection error, attempting to reconnect: %s", err)
|
||||||
|
# run the same SELECT again - the connection will re-validate
|
||||||
|
# itself and establish a new connection. The disconnect detection
|
||||||
|
# here also causes the whole connection pool to be invalidated
|
||||||
|
# so that all stale connections are discarded.
|
||||||
|
connection.scalar(select([1]))
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
# restore "close with result"
|
||||||
|
connection.should_close_with_result = save_should_close_with_result
|
||||||
|
|
||||||
|
|
||||||
def check_db_revision(engine):
|
def check_db_revision(engine):
|
||||||
"""Check the JupyterHub database revision
|
"""Check the JupyterHub database revision
|
||||||
|
|
||||||
@@ -635,10 +707,12 @@ def mysql_large_prefix_check(engine):
|
|||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def add_row_format(base):
|
def add_row_format(base):
|
||||||
for t in base.metadata.tables.values():
|
for t in base.metadata.tables.values():
|
||||||
t.dialect_kwargs['mysql_ROW_FORMAT'] = 'DYNAMIC'
|
t.dialect_kwargs['mysql_ROW_FORMAT'] = 'DYNAMIC'
|
||||||
|
|
||||||
|
|
||||||
def new_session_factory(url="sqlite:///:memory:",
|
def new_session_factory(url="sqlite:///:memory:",
|
||||||
reset=False,
|
reset=False,
|
||||||
expire_on_commit=False,
|
expire_on_commit=False,
|
||||||
@@ -658,6 +732,9 @@ def new_session_factory(url="sqlite:///:memory:",
|
|||||||
kwargs.setdefault('poolclass', StaticPool)
|
kwargs.setdefault('poolclass', StaticPool)
|
||||||
|
|
||||||
engine = create_engine(url, **kwargs)
|
engine = create_engine(url, **kwargs)
|
||||||
|
# enable pessimistic disconnect handling
|
||||||
|
register_ping_connection(engine)
|
||||||
|
|
||||||
if reset:
|
if reset:
|
||||||
Base.metadata.drop_all(engine)
|
Base.metadata.drop_all(engine)
|
||||||
|
|
||||||
@@ -669,7 +746,7 @@ def new_session_factory(url="sqlite:///:memory:",
|
|||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
# We set expire_on_commit=False, since we don't actually need
|
# We set expire_on_commit=False, since we don't actually need
|
||||||
# SQLAlchemy to expire objects after commiting - we don't expect
|
# SQLAlchemy to expire objects after committing - we don't expect
|
||||||
# concurrent runs of the hub talking to the same db. Turning
|
# concurrent runs of the hub talking to the same db. Turning
|
||||||
# this off gives us a major performance boost
|
# this off gives us a major performance boost
|
||||||
session_factory = sessionmaker(bind=engine,
|
session_factory = sessionmaker(bind=engine,
|
||||||
|
@@ -22,8 +22,10 @@ import asyncio
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import signal
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
from urllib.parse import quote
|
import time
|
||||||
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
from tornado.httpclient import AsyncHTTPClient, HTTPRequest, HTTPError
|
from tornado.httpclient import AsyncHTTPClient, HTTPRequest, HTTPError
|
||||||
@@ -301,13 +303,13 @@ class Proxy(LoggingConfigurable):
|
|||||||
user_routes = {path for path, r in routes.items() if 'user' in r['data']}
|
user_routes = {path for path, r in routes.items() if 'user' in r['data']}
|
||||||
futures = []
|
futures = []
|
||||||
|
|
||||||
good_routes = {'/'}
|
good_routes = {self.app.hub.routespec}
|
||||||
|
|
||||||
hub = self.app.hub
|
hub = self.hub
|
||||||
if '/' not in routes:
|
if self.app.hub.routespec not in routes:
|
||||||
futures.append(self.add_hub_route(hub))
|
futures.append(self.add_hub_route(hub))
|
||||||
else:
|
else:
|
||||||
route = routes['/']
|
route = routes[self.app.hub.routespec]
|
||||||
if route['target'] != hub.host:
|
if route['target'] != hub.host:
|
||||||
self.log.warning("Updating default route %s → %s", route['target'], hub.host)
|
self.log.warning("Updating default route %s → %s", route['target'], hub.host)
|
||||||
futures.append(self.add_hub_route(hub))
|
futures.append(self.add_hub_route(hub))
|
||||||
@@ -365,8 +367,8 @@ class Proxy(LoggingConfigurable):
|
|||||||
|
|
||||||
def add_hub_route(self, hub):
|
def add_hub_route(self, hub):
|
||||||
"""Add the default route for the Hub"""
|
"""Add the default route for the Hub"""
|
||||||
self.log.info("Adding default route for Hub: / => %s", hub.host)
|
self.log.info("Adding default route for Hub: %s => %s", hub.routespec, hub.host)
|
||||||
return self.add_route('/', self.hub.host, {'hub': True})
|
return self.add_route(hub.routespec, self.hub.host, {'hub': True})
|
||||||
|
|
||||||
async def restore_routes(self):
|
async def restore_routes(self):
|
||||||
self.log.info("Setting up routes on new proxy")
|
self.log.info("Setting up routes on new proxy")
|
||||||
@@ -437,8 +439,22 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
help="""The command to start the proxy"""
|
help="""The command to start the proxy"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
pid_file = Unicode(
|
||||||
|
"jupyterhub-proxy.pid",
|
||||||
|
config=True,
|
||||||
|
help="File in which to write the PID of the proxy process.",
|
||||||
|
)
|
||||||
|
|
||||||
_check_running_callback = Any(help="PeriodicCallback to check if the proxy is running")
|
_check_running_callback = Any(help="PeriodicCallback to check if the proxy is running")
|
||||||
|
|
||||||
|
def _check_pid(self, pid):
|
||||||
|
if os.name == 'nt':
|
||||||
|
import psutil
|
||||||
|
if not psutil.pid_exists(pid):
|
||||||
|
raise ProcessLookupError
|
||||||
|
else:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
# check for required token if proxy is external
|
# check for required token if proxy is external
|
||||||
@@ -448,9 +464,77 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
" if Proxy.should_start is False" % self.__class__.__name__
|
" if Proxy.should_start is False" % self.__class__.__name__
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _check_previous_process(self):
|
||||||
|
"""Check if there's a process leftover and shut it down if so"""
|
||||||
|
if not self.pid_file or not os.path.exists(self.pid_file):
|
||||||
|
return
|
||||||
|
pid_file = os.path.abspath(self.pid_file)
|
||||||
|
self.log.warning("Found proxy pid file: %s", pid_file)
|
||||||
|
try:
|
||||||
|
with open(pid_file, "r") as f:
|
||||||
|
pid = int(f.read().strip())
|
||||||
|
except ValueError:
|
||||||
|
self.log.warning("%s did not appear to contain a pid", pid_file)
|
||||||
|
self._remove_pid_file()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._check_pid(pid)
|
||||||
|
except ProcessLookupError:
|
||||||
|
self.log.warning("Proxy no longer running at pid=%s", pid)
|
||||||
|
self._remove_pid_file()
|
||||||
|
return
|
||||||
|
|
||||||
|
# if we got here, CHP is still running
|
||||||
|
self.log.warning("Proxy still running at pid=%s", pid)
|
||||||
|
if os.name != 'nt':
|
||||||
|
sig_list = [signal.SIGTERM] * 2 + [signal.SIGKILL]
|
||||||
|
for i in range(3):
|
||||||
|
try:
|
||||||
|
if os.name == 'nt':
|
||||||
|
self._terminate_win(pid)
|
||||||
|
else:
|
||||||
|
os.kill(pid,sig_list[i])
|
||||||
|
except ProcessLookupError:
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
try:
|
||||||
|
self._check_pid(pid)
|
||||||
|
except ProcessLookupError:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._check_pid(pid)
|
||||||
|
except ProcessLookupError:
|
||||||
|
self.log.warning("Stopped proxy at pid=%s", pid)
|
||||||
|
self._remove_pid_file()
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
raise RuntimeError("Failed to stop proxy at pid=%s", pid)
|
||||||
|
|
||||||
|
def _write_pid_file(self):
|
||||||
|
"""write pid for proxy to a file"""
|
||||||
|
self.log.debug("Writing proxy pid file: %s", self.pid_file)
|
||||||
|
with open(self.pid_file, "w") as f:
|
||||||
|
f.write(str(self.proxy_process.pid))
|
||||||
|
|
||||||
|
def _remove_pid_file(self):
|
||||||
|
"""Cleanup pid file for proxy after stopping"""
|
||||||
|
if not self.pid_file:
|
||||||
|
return
|
||||||
|
self.log.debug("Removing proxy pid file %s", self.pid_file)
|
||||||
|
try:
|
||||||
|
os.remove(self.pid_file)
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.log.debug("PID file %s already removed", self.pid_file)
|
||||||
|
pass
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
|
"""Start the proxy process"""
|
||||||
|
# check if there is a previous instance still around
|
||||||
|
self._check_previous_process()
|
||||||
|
|
||||||
|
# build the command to launch
|
||||||
public_server = Server.from_url(self.public_url)
|
public_server = Server.from_url(self.public_url)
|
||||||
api_server = Server.from_url(self.api_url)
|
api_server = Server.from_url(self.api_url)
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
@@ -488,11 +572,16 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
self.log.error(
|
self.log.error(
|
||||||
"Failed to find proxy %r\n"
|
"Failed to find proxy %r\n"
|
||||||
"The proxy can be installed with `npm install -g configurable-http-proxy`"
|
"The proxy can be installed with `npm install -g configurable-http-proxy`."
|
||||||
|
"To install `npm`, install nodejs which includes `npm`."
|
||||||
|
"If you see an `EACCES` error or permissions error, refer to the `npm` "
|
||||||
|
"documentation on How To Prevent Permissions Errors."
|
||||||
% self.command
|
% self.command
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
self._write_pid_file()
|
||||||
|
|
||||||
def _check_process():
|
def _check_process():
|
||||||
status = self.proxy_process.poll()
|
status = self.proxy_process.poll()
|
||||||
if status is not None:
|
if status is not None:
|
||||||
@@ -516,15 +605,34 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
self._check_running_callback = pc
|
self._check_running_callback = pc
|
||||||
pc.start()
|
pc.start()
|
||||||
|
|
||||||
|
def _terminate_win(self, pid):
|
||||||
|
# On Windows we spawned a shell on Popen, so we need to
|
||||||
|
# terminate all child processes as well
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
parent = psutil.Process(pid)
|
||||||
|
children = parent.children(recursive=True)
|
||||||
|
for child in children:
|
||||||
|
child.kill()
|
||||||
|
psutil.wait_procs(children, timeout=5)
|
||||||
|
|
||||||
|
def _terminate(self):
|
||||||
|
"""Terminate our process"""
|
||||||
|
if os.name == 'nt':
|
||||||
|
self._terminate_win(self.proxy_process.pid)
|
||||||
|
else:
|
||||||
|
self.proxy_process.terminate()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self.log.info("Cleaning up proxy[%i]...", self.proxy_process.pid)
|
self.log.info("Cleaning up proxy[%i]...", self.proxy_process.pid)
|
||||||
if self._check_running_callback is not None:
|
if self._check_running_callback is not None:
|
||||||
self._check_running_callback.stop()
|
self._check_running_callback.stop()
|
||||||
if self.proxy_process.poll() is None:
|
if self.proxy_process.poll() is None:
|
||||||
try:
|
try:
|
||||||
self.proxy_process.terminate()
|
self._terminate()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.error("Failed to terminate proxy process: %s", e)
|
self.log.error("Failed to terminate proxy process: %s", e)
|
||||||
|
self._remove_pid_file()
|
||||||
|
|
||||||
async def check_running(self):
|
async def check_running(self):
|
||||||
"""Check if the proxy is still running"""
|
"""Check if the proxy is still running"""
|
||||||
@@ -533,6 +641,7 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
self.log.error("Proxy stopped with exit code %r",
|
self.log.error("Proxy stopped with exit code %r",
|
||||||
'unknown' if self.proxy_process is None else self.proxy_process.poll()
|
'unknown' if self.proxy_process is None else self.proxy_process.poll()
|
||||||
)
|
)
|
||||||
|
self._remove_pid_file()
|
||||||
await self.start()
|
await self.start()
|
||||||
await self.restore_routes()
|
await self.restore_routes()
|
||||||
|
|
||||||
@@ -558,7 +667,7 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
"""
|
"""
|
||||||
# chp stores routes in unescaped form.
|
# chp stores routes in unescaped form.
|
||||||
# restore escaped-form we created it with.
|
# restore escaped-form we created it with.
|
||||||
routespec = quote(chp_path, safe='@/')
|
routespec = quote(chp_path, safe='@/~')
|
||||||
if self.host_routing:
|
if self.host_routing:
|
||||||
# host routes don't start with /
|
# host routes don't start with /
|
||||||
routespec = routespec.lstrip('/')
|
routespec = routespec.lstrip('/')
|
||||||
|
@@ -39,8 +39,10 @@ A hub-managed service with no URL::
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
import pipes
|
import pipes
|
||||||
import shutil
|
import shutil
|
||||||
|
import os
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
|
|
||||||
from traitlets import (
|
from traitlets import (
|
||||||
@@ -105,6 +107,8 @@ class _ServiceSpawner(LocalProcessSpawner):
|
|||||||
def start(self):
|
def start(self):
|
||||||
"""Start the process"""
|
"""Start the process"""
|
||||||
env = self.get_env()
|
env = self.get_env()
|
||||||
|
if os.name == 'nt':
|
||||||
|
env['SYSTEMROOT'] = os.environ['SYSTEMROOT']
|
||||||
cmd = self.cmd
|
cmd = self.cmd
|
||||||
|
|
||||||
self.log.info("Spawning %s", ' '.join(pipes.quote(s) for s in cmd))
|
self.log.info("Spawning %s", ' '.join(pipes.quote(s) for s in cmd))
|
||||||
@@ -175,6 +179,13 @@ class Service(LoggingConfigurable):
|
|||||||
If unspecified, an API token will be generated for managed services.
|
If unspecified, an API token will be generated for managed services.
|
||||||
"""
|
"""
|
||||||
).tag(input=True)
|
).tag(input=True)
|
||||||
|
|
||||||
|
info = Dict(
|
||||||
|
help="""Provide a place to include miscellaneous information about the service,
|
||||||
|
provided through the configuration
|
||||||
|
"""
|
||||||
|
).tag(input=True)
|
||||||
|
|
||||||
# Managed service API:
|
# Managed service API:
|
||||||
spawner = Any()
|
spawner = Any()
|
||||||
|
|
||||||
@@ -297,6 +308,15 @@ class Service(LoggingConfigurable):
|
|||||||
env['JUPYTERHUB_SERVICE_URL'] = self.url
|
env['JUPYTERHUB_SERVICE_URL'] = self.url
|
||||||
env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
|
env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
|
||||||
|
|
||||||
|
hub = self.hub
|
||||||
|
if self.hub.ip in ('0.0.0.0', ''):
|
||||||
|
# if the Hub is listening on all interfaces,
|
||||||
|
# tell services to connect via localhost
|
||||||
|
# since they are always local subprocesses
|
||||||
|
hub = copy.deepcopy(self.hub)
|
||||||
|
hub.connect_url = ''
|
||||||
|
hub.connect_ip = '127.0.0.1'
|
||||||
|
|
||||||
self.spawner = _ServiceSpawner(
|
self.spawner = _ServiceSpawner(
|
||||||
cmd=self.command,
|
cmd=self.command,
|
||||||
environment=env,
|
environment=env,
|
||||||
|
@@ -213,7 +213,10 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
subcommands = {}
|
subcommands = {}
|
||||||
version = __version__
|
version = __version__
|
||||||
classes = NotebookApp.classes + [HubOAuth]
|
classes = NotebookApp.classes + [HubOAuth]
|
||||||
|
|
||||||
|
# disable single-user app's localhost checking
|
||||||
|
allow_remote_access = True
|
||||||
|
|
||||||
# don't store cookie secrets
|
# don't store cookie secrets
|
||||||
cookie_secret_file = ''
|
cookie_secret_file = ''
|
||||||
# always generate a new cookie secret on launch
|
# always generate a new cookie secret on launch
|
||||||
@@ -225,7 +228,7 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
|
|
||||||
user = CUnicode().tag(config=True)
|
user = CUnicode().tag(config=True)
|
||||||
group = CUnicode().tag(config=True)
|
group = CUnicode().tag(config=True)
|
||||||
|
|
||||||
@default('user')
|
@default('user')
|
||||||
def _default_user(self):
|
def _default_user(self):
|
||||||
return os.environ.get('JUPYTERHUB_USER') or ''
|
return os.environ.get('JUPYTERHUB_USER') or ''
|
||||||
@@ -295,6 +298,7 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
# disble some single-user configurables
|
# disble some single-user configurables
|
||||||
token = ''
|
token = ''
|
||||||
open_browser = False
|
open_browser = False
|
||||||
|
quit_button = False
|
||||||
trust_xheaders = True
|
trust_xheaders = True
|
||||||
login_handler_class = JupyterHubLoginHandler
|
login_handler_class = JupyterHubLoginHandler
|
||||||
logout_handler_class = JupyterHubLogoutHandler
|
logout_handler_class = JupyterHubLogoutHandler
|
||||||
|
@@ -161,6 +161,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
admin_access = Bool(False)
|
admin_access = Bool(False)
|
||||||
api_token = Unicode()
|
api_token = Unicode()
|
||||||
oauth_client_id = Unicode()
|
oauth_client_id = Unicode()
|
||||||
|
handler = Any()
|
||||||
|
|
||||||
will_resume = Bool(False,
|
will_resume = Bool(False,
|
||||||
help="""Whether the Spawner will resume on next start
|
help="""Whether the Spawner will resume on next start
|
||||||
@@ -195,6 +196,19 @@ class Spawner(LoggingConfigurable):
|
|||||||
"""
|
"""
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
|
consecutive_failure_limit = Integer(
|
||||||
|
0,
|
||||||
|
help="""
|
||||||
|
Maximum number of consecutive failures to allow before
|
||||||
|
shutting down JupyterHub.
|
||||||
|
|
||||||
|
This helps JupyterHub recover from a certain class of problem preventing launch
|
||||||
|
in contexts where the Hub is automatically restarted (e.g. systemd, docker, kubernetes).
|
||||||
|
|
||||||
|
A limit of 0 means no limit and consecutive failures will not be tracked.
|
||||||
|
""",
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
start_timeout = Integer(60,
|
start_timeout = Integer(60,
|
||||||
help="""
|
help="""
|
||||||
Timeout (in seconds) before giving up on starting of single-user server.
|
Timeout (in seconds) before giving up on starting of single-user server.
|
||||||
@@ -703,10 +717,11 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
def run_post_stop_hook(self):
|
def run_post_stop_hook(self):
|
||||||
"""Run the post_stop_hook if defined"""
|
"""Run the post_stop_hook if defined"""
|
||||||
try:
|
if self.post_stop_hook is not None:
|
||||||
return self.post_stop_hook(self)
|
try:
|
||||||
except Exception:
|
return self.post_stop_hook(self)
|
||||||
self.log.exception("post_stop_hook failed with exception: %s", self)
|
except Exception:
|
||||||
|
self.log.exception("post_stop_hook failed with exception: %s", self)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _progress_url(self):
|
def _progress_url(self):
|
||||||
@@ -1001,7 +1016,7 @@ class LocalProcessSpawner(Spawner):
|
|||||||
which could change what the jupyterhub-singleuser launch command does.
|
which could change what the jupyterhub-singleuser launch command does.
|
||||||
Only use this for trusted users.
|
Only use this for trusted users.
|
||||||
"""
|
"""
|
||||||
)
|
).tag(config=True)
|
||||||
|
|
||||||
proc = Instance(Popen,
|
proc = Instance(Popen,
|
||||||
allow_none=True,
|
allow_none=True,
|
||||||
|
@@ -74,18 +74,20 @@ def mock_open_session(username, service, encoding):
|
|||||||
|
|
||||||
class MockSpawner(LocalProcessSpawner):
|
class MockSpawner(LocalProcessSpawner):
|
||||||
"""Base mock spawner
|
"""Base mock spawner
|
||||||
|
|
||||||
- disables user-switching that we need root permissions to do
|
- disables user-switching that we need root permissions to do
|
||||||
- spawns `jupyterhub.tests.mocksu` instead of a full single-user server
|
- spawns `jupyterhub.tests.mocksu` instead of a full single-user server
|
||||||
"""
|
"""
|
||||||
def make_preexec_fn(self, *a, **kw):
|
def make_preexec_fn(self, *a, **kw):
|
||||||
# skip the setuid stuff
|
# skip the setuid stuff
|
||||||
return
|
return
|
||||||
|
|
||||||
def _set_user_changed(self, name, old, new):
|
def _set_user_changed(self, name, old, new):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def user_env(self, env):
|
def user_env(self, env):
|
||||||
|
if self.handler:
|
||||||
|
env['HANDLER_ARGS'] = self.handler.request.query
|
||||||
return env
|
return env
|
||||||
|
|
||||||
@default('cmd')
|
@default('cmd')
|
||||||
|
@@ -27,13 +27,13 @@ class ArgsHandler(web.RequestHandler):
|
|||||||
self.write(json.dumps(sys.argv))
|
self.write(json.dumps(sys.argv))
|
||||||
|
|
||||||
def main(args):
|
def main(args):
|
||||||
|
|
||||||
app = web.Application([
|
app = web.Application([
|
||||||
(r'.*/args', ArgsHandler),
|
(r'.*/args', ArgsHandler),
|
||||||
(r'.*/env', EnvHandler),
|
(r'.*/env', EnvHandler),
|
||||||
(r'.*', EchoHandler),
|
(r'.*', EchoHandler),
|
||||||
])
|
])
|
||||||
|
|
||||||
server = httpserver.HTTPServer(app)
|
server = httpserver.HTTPServer(app)
|
||||||
server.listen(args.port)
|
server.listen(args.port)
|
||||||
try:
|
try:
|
||||||
@@ -45,4 +45,4 @@ if __name__ == '__main__':
|
|||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument('--port', type=int)
|
parser.add_argument('--port', type=int)
|
||||||
args, extra = parser.parse_known_args()
|
args, extra = parser.parse_known_args()
|
||||||
main(args)
|
main(args)
|
||||||
|
@@ -100,6 +100,8 @@ def api_request(app, *api_path, **kwargs):
|
|||||||
assert "frame-ancestors 'self'" in resp.headers['Content-Security-Policy']
|
assert "frame-ancestors 'self'" in resp.headers['Content-Security-Policy']
|
||||||
assert ujoin(app.hub.base_url, "security/csp-report") in resp.headers['Content-Security-Policy']
|
assert ujoin(app.hub.base_url, "security/csp-report") in resp.headers['Content-Security-Policy']
|
||||||
assert 'http' not in resp.headers['Content-Security-Policy']
|
assert 'http' not in resp.headers['Content-Security-Policy']
|
||||||
|
if not kwargs.get('stream', False) and resp.content:
|
||||||
|
assert resp.headers.get('content-type') == 'application/json'
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@@ -604,6 +606,32 @@ def test_spawn(app):
|
|||||||
assert app.users.count_active_users()['pending'] == 0
|
assert app.users.count_active_users()['pending'] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@mark.gen_test
|
||||||
|
def test_spawn_handler(app):
|
||||||
|
"""Test that the requesting Handler is passed to Spawner.handler"""
|
||||||
|
db = app.db
|
||||||
|
name = 'salmon'
|
||||||
|
user = add_user(db, app=app, name=name)
|
||||||
|
app_user = app.users[name]
|
||||||
|
|
||||||
|
# spawn via API with ?foo=bar
|
||||||
|
r = yield api_request(app, 'users', name, 'server', method='post', params={'foo': 'bar'})
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
# verify that request params got passed down
|
||||||
|
# implemented in MockSpawner
|
||||||
|
url = public_url(app, user)
|
||||||
|
r = yield async_requests.get(ujoin(url, 'env'))
|
||||||
|
env = r.json()
|
||||||
|
assert 'HANDLER_ARGS' in env
|
||||||
|
assert env['HANDLER_ARGS'] == 'foo=bar'
|
||||||
|
# make user spawner.handler doesn't persist after spawn finishes
|
||||||
|
assert app_user.spawner.handler is None
|
||||||
|
|
||||||
|
r = yield api_request(app, 'users', name, 'server', method='delete')
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
@mark.slow
|
@mark.slow
|
||||||
@mark.gen_test
|
@mark.gen_test
|
||||||
def test_slow_spawn(app, no_patience, slow_spawn):
|
def test_slow_spawn(app, no_patience, slow_spawn):
|
||||||
@@ -720,6 +748,8 @@ def test_progress(request, app, no_patience, slow_spawn):
|
|||||||
r = yield api_request(app, 'users', name, 'server/progress', stream=True)
|
r = yield api_request(app, 'users', name, 'server/progress', stream=True)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
request.addfinalizer(r.close)
|
request.addfinalizer(r.close)
|
||||||
|
assert r.headers['content-type'] == 'text/event-stream'
|
||||||
|
|
||||||
ex = async_requests.executor
|
ex = async_requests.executor
|
||||||
line_iter = iter(r.iter_lines(decode_unicode=True))
|
line_iter = iter(r.iter_lines(decode_unicode=True))
|
||||||
evt = yield ex.submit(next_event, line_iter)
|
evt = yield ex.submit(next_event, line_iter)
|
||||||
@@ -781,6 +811,7 @@ def test_progress_ready(request, app):
|
|||||||
r = yield api_request(app, 'users', name, 'server/progress', stream=True)
|
r = yield api_request(app, 'users', name, 'server/progress', stream=True)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
request.addfinalizer(r.close)
|
request.addfinalizer(r.close)
|
||||||
|
assert r.headers['content-type'] == 'text/event-stream'
|
||||||
ex = async_requests.executor
|
ex = async_requests.executor
|
||||||
line_iter = iter(r.iter_lines(decode_unicode=True))
|
line_iter = iter(r.iter_lines(decode_unicode=True))
|
||||||
evt = yield ex.submit(next_event, line_iter)
|
evt = yield ex.submit(next_event, line_iter)
|
||||||
@@ -800,6 +831,7 @@ def test_progress_bad(request, app, no_patience, bad_spawn):
|
|||||||
r = yield api_request(app, 'users', name, 'server/progress', stream=True)
|
r = yield api_request(app, 'users', name, 'server/progress', stream=True)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
request.addfinalizer(r.close)
|
request.addfinalizer(r.close)
|
||||||
|
assert r.headers['content-type'] == 'text/event-stream'
|
||||||
ex = async_requests.executor
|
ex = async_requests.executor
|
||||||
line_iter = iter(r.iter_lines(decode_unicode=True))
|
line_iter = iter(r.iter_lines(decode_unicode=True))
|
||||||
evt = yield ex.submit(next_event, line_iter)
|
evt = yield ex.submit(next_event, line_iter)
|
||||||
@@ -821,6 +853,7 @@ def test_progress_bad_slow(request, app, no_patience, slow_bad_spawn):
|
|||||||
r = yield api_request(app, 'users', name, 'server/progress', stream=True)
|
r = yield api_request(app, 'users', name, 'server/progress', stream=True)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
request.addfinalizer(r.close)
|
request.addfinalizer(r.close)
|
||||||
|
assert r.headers['content-type'] == 'text/event-stream'
|
||||||
ex = async_requests.executor
|
ex = async_requests.executor
|
||||||
line_iter = iter(r.iter_lines(decode_unicode=True))
|
line_iter = iter(r.iter_lines(decode_unicode=True))
|
||||||
evt = yield ex.submit(next_event, line_iter)
|
evt = yield ex.submit(next_event, line_iter)
|
||||||
@@ -1055,7 +1088,7 @@ def test_get_proxy(app):
|
|||||||
r = yield api_request(app, 'proxy')
|
r = yield api_request(app, 'proxy')
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
assert list(reply.keys()) == ['/']
|
assert list(reply.keys()) == [app.hub.routespec]
|
||||||
|
|
||||||
|
|
||||||
@mark.gen_test
|
@mark.gen_test
|
||||||
@@ -1188,14 +1221,19 @@ def test_token_as_user_deprecated(app, as_user, for_user, status):
|
|||||||
|
|
||||||
|
|
||||||
@mark.gen_test
|
@mark.gen_test
|
||||||
@mark.parametrize("headers, status, note", [
|
@mark.parametrize("headers, status, note, expires_in", [
|
||||||
({}, 200, 'test note'),
|
({}, 200, 'test note', None),
|
||||||
({}, 200, ''),
|
({}, 200, '', 100),
|
||||||
({'Authorization': 'token bad'}, 403, ''),
|
({'Authorization': 'token bad'}, 403, '', None),
|
||||||
])
|
])
|
||||||
def test_get_new_token(app, headers, status, note):
|
def test_get_new_token(app, headers, status, note, expires_in):
|
||||||
|
options = {}
|
||||||
if note:
|
if note:
|
||||||
body = json.dumps({'note': note})
|
options['note'] = note
|
||||||
|
if expires_in:
|
||||||
|
options['expires_in'] = expires_in
|
||||||
|
if options:
|
||||||
|
body = json.dumps(options)
|
||||||
else:
|
else:
|
||||||
body = ''
|
body = ''
|
||||||
# request a new token
|
# request a new token
|
||||||
@@ -1213,6 +1251,10 @@ def test_get_new_token(app, headers, status, note):
|
|||||||
assert reply['user'] == 'admin'
|
assert reply['user'] == 'admin'
|
||||||
assert reply['created']
|
assert reply['created']
|
||||||
assert 'last_activity' in reply
|
assert 'last_activity' in reply
|
||||||
|
if expires_in:
|
||||||
|
assert isinstance(reply['expires_at'], str)
|
||||||
|
else:
|
||||||
|
assert reply['expires_at'] is None
|
||||||
if note:
|
if note:
|
||||||
assert reply['note'] == note
|
assert reply['note'] == note
|
||||||
else:
|
else:
|
||||||
@@ -1525,6 +1567,7 @@ def test_get_services(app, mockservice_url):
|
|||||||
'pid': mockservice.proc.pid,
|
'pid': mockservice.proc.pid,
|
||||||
'prefix': mockservice.server.base_url,
|
'prefix': mockservice.server.base_url,
|
||||||
'url': mockservice.url,
|
'url': mockservice.url,
|
||||||
|
'info': {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1551,6 +1594,7 @@ def test_get_service(app, mockservice_url):
|
|||||||
'pid': mockservice.proc.pid,
|
'pid': mockservice.proc.pid,
|
||||||
'prefix': mockservice.server.base_url,
|
'prefix': mockservice.server.base_url,
|
||||||
'url': mockservice.url,
|
'url': mockservice.url,
|
||||||
|
'info': {},
|
||||||
}
|
}
|
||||||
|
|
||||||
r = yield api_request(app, 'services/%s' % mockservice.name,
|
r = yield api_request(app, 'services/%s' % mockservice.name,
|
||||||
|
@@ -8,19 +8,22 @@ from subprocess import check_output, Popen, PIPE
|
|||||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from tornado import gen
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from tornado import gen
|
||||||
|
from traitlets.config import Config
|
||||||
|
|
||||||
from .mocking import MockHub
|
from .mocking import MockHub
|
||||||
from .test_api import add_user
|
from .test_api import add_user
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..app import COOKIE_SECRET_BYTES
|
from ..app import COOKIE_SECRET_BYTES, JupyterHub
|
||||||
|
|
||||||
|
|
||||||
def test_help_all():
|
def test_help_all():
|
||||||
out = check_output([sys.executable, '-m', 'jupyterhub', '--help-all']).decode('utf8', 'replace')
|
out = check_output([sys.executable, '-m', 'jupyterhub', '--help-all']).decode('utf8', 'replace')
|
||||||
assert '--ip' in out
|
assert '--ip' in out
|
||||||
assert '--JupyterHub.ip' in out
|
assert '--JupyterHub.ip' in out
|
||||||
|
|
||||||
|
|
||||||
def test_token_app():
|
def test_token_app():
|
||||||
cmd = [sys.executable, '-m', 'jupyterhub', 'token']
|
cmd = [sys.executable, '-m', 'jupyterhub', 'token']
|
||||||
out = check_output(cmd + ['--help-all']).decode('utf8', 'replace')
|
out = check_output(cmd + ['--help-all']).decode('utf8', 'replace')
|
||||||
@@ -30,6 +33,7 @@ def test_token_app():
|
|||||||
out = check_output(cmd + ['user'], cwd=td).decode('utf8', 'replace').strip()
|
out = check_output(cmd + ['user'], cwd=td).decode('utf8', 'replace').strip()
|
||||||
assert re.match(r'^[a-z0-9]+$', out)
|
assert re.match(r'^[a-z0-9]+$', out)
|
||||||
|
|
||||||
|
|
||||||
def test_generate_config():
|
def test_generate_config():
|
||||||
with NamedTemporaryFile(prefix='jupyterhub_config', suffix='.py') as tf:
|
with NamedTemporaryFile(prefix='jupyterhub_config', suffix='.py') as tf:
|
||||||
cfg_file = tf.name
|
cfg_file = tf.name
|
||||||
@@ -218,3 +222,51 @@ def test_resume_spawners(tmpdir, request):
|
|||||||
assert not user.running
|
assert not user.running
|
||||||
assert user.spawner.server is None
|
assert user.spawner.server is None
|
||||||
assert list(db.query(orm.Server)) == []
|
assert list(db.query(orm.Server)) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'hub_config, expected',
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{'ip': '0.0.0.0'},
|
||||||
|
{'bind_url': 'http://0.0.0.0:8000/'},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{'port': 123, 'base_url': '/prefix'},
|
||||||
|
{
|
||||||
|
'bind_url': 'http://:123/prefix/',
|
||||||
|
'base_url': '/prefix/',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{'bind_url': 'http://0.0.0.0:12345/sub'},
|
||||||
|
{'base_url': '/sub/'},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# no config, test defaults
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
'base_url': '/',
|
||||||
|
'bind_url': 'http://:8000',
|
||||||
|
'ip': '',
|
||||||
|
'port': 8000,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
def test_url_config(hub_config, expected):
|
||||||
|
# construct the config object
|
||||||
|
cfg = Config()
|
||||||
|
for key, value in hub_config.items():
|
||||||
|
cfg.JupyterHub[key] = value
|
||||||
|
|
||||||
|
# instantiate the Hub and load config
|
||||||
|
app = JupyterHub(config=cfg)
|
||||||
|
# validate config
|
||||||
|
for key, value in hub_config.items():
|
||||||
|
if key not in expected:
|
||||||
|
assert getattr(app, key) == value
|
||||||
|
|
||||||
|
# validate additional properties
|
||||||
|
for key, value in expected.items():
|
||||||
|
assert getattr(app, key) == value
|
||||||
|
@@ -3,8 +3,10 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
@@ -99,6 +101,32 @@ def test_tokens(db):
|
|||||||
assert len(user.api_tokens) == 3
|
assert len(user.api_tokens) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_expiry(db):
|
||||||
|
user = orm.User(name='parker')
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
now = datetime.utcnow()
|
||||||
|
token = user.new_api_token(expires_in=60)
|
||||||
|
orm_token = orm.APIToken.find(db, token=token)
|
||||||
|
assert orm_token
|
||||||
|
assert orm_token.expires_at is not None
|
||||||
|
# approximate range
|
||||||
|
assert orm_token.expires_at > now + timedelta(seconds=50)
|
||||||
|
assert orm_token.expires_at < now + timedelta(seconds=70)
|
||||||
|
the_future = mock.patch('jupyterhub.orm.utcnow', lambda : now + timedelta(seconds=70))
|
||||||
|
with the_future:
|
||||||
|
found = orm.APIToken.find(db, token=token)
|
||||||
|
assert found is None
|
||||||
|
# purging shouldn't delete non-expired tokens
|
||||||
|
orm.APIToken.purge_expired(db)
|
||||||
|
assert orm.APIToken.find(db, token=token)
|
||||||
|
with the_future:
|
||||||
|
orm.APIToken.purge_expired(db)
|
||||||
|
assert orm.APIToken.find(db, token=token) is None
|
||||||
|
# after purging, make sure we aren't in the user token list
|
||||||
|
assert orm_token not in user.api_tokens
|
||||||
|
|
||||||
|
|
||||||
def test_service_tokens(db):
|
def test_service_tokens(db):
|
||||||
service = orm.Service(name='secret')
|
service = orm.Service(name='secret')
|
||||||
db.add(service)
|
db.add(service)
|
||||||
|
@@ -1,8 +1,11 @@
|
|||||||
"""Tests for HTML pages"""
|
"""Tests for HTML pages"""
|
||||||
|
|
||||||
|
import sys
|
||||||
from urllib.parse import urlencode, urlparse
|
from urllib.parse import urlencode, urlparse
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
|
from tornado.httputil import url_concat
|
||||||
|
|
||||||
from ..handlers import BaseHandler
|
from ..handlers import BaseHandler
|
||||||
from ..utils import url_path_join as ujoin
|
from ..utils import url_path_join as ujoin
|
||||||
@@ -16,6 +19,7 @@ from .mocking import FormSpawner, public_url, public_host
|
|||||||
from .test_api import api_request, add_user
|
from .test_api import api_request, add_user
|
||||||
from .utils import async_requests
|
from .utils import async_requests
|
||||||
|
|
||||||
|
|
||||||
def get_page(path, app, hub=True, **kw):
|
def get_page(path, app, hub=True, **kw):
|
||||||
if hub:
|
if hub:
|
||||||
prefix = app.hub.base_url
|
prefix = app.hub.base_url
|
||||||
@@ -53,6 +57,30 @@ def test_root_redirect(app):
|
|||||||
assert path == ujoin(app.base_url, 'user/%s/test.ipynb' % name)
|
assert path == ujoin(app.base_url, 'user/%s/test.ipynb' % name)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gen_test
|
||||||
|
def test_root_default_url_noauth(app):
|
||||||
|
with mock.patch.dict(app.tornado_settings,
|
||||||
|
{'default_url': '/foo/bar'}):
|
||||||
|
r = yield get_page('/', app, allow_redirects=False)
|
||||||
|
r.raise_for_status()
|
||||||
|
url = r.headers.get('Location', '')
|
||||||
|
path = urlparse(url).path
|
||||||
|
assert path == '/foo/bar'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gen_test
|
||||||
|
def test_root_default_url_auth(app):
|
||||||
|
name = 'wash'
|
||||||
|
cookies = yield app.login_user(name)
|
||||||
|
with mock.patch.dict(app.tornado_settings,
|
||||||
|
{'default_url': '/foo/bar'}):
|
||||||
|
r = yield get_page('/', app, cookies=cookies, allow_redirects=False)
|
||||||
|
r.raise_for_status()
|
||||||
|
url = r.headers.get('Location', '')
|
||||||
|
path = urlparse(url).path
|
||||||
|
assert path == '/foo/bar'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gen_test
|
@pytest.mark.gen_test
|
||||||
def test_home_no_auth(app):
|
def test_home_no_auth(app):
|
||||||
r = yield get_page('home', app, allow_redirects=False)
|
r = yield get_page('home', app, allow_redirects=False)
|
||||||
@@ -142,6 +170,31 @@ def test_spawn_redirect(app):
|
|||||||
assert path == ujoin(app.base_url, '/user/%s/' % name)
|
assert path == ujoin(app.base_url, '/user/%s/' % name)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gen_test
|
||||||
|
def test_spawn_handler_access(app):
|
||||||
|
name = 'winston'
|
||||||
|
cookies = yield app.login_user(name)
|
||||||
|
u = app.users[orm.User.find(app.db, name)]
|
||||||
|
|
||||||
|
status = yield u.spawner.poll()
|
||||||
|
assert status is not None
|
||||||
|
|
||||||
|
# spawn server via browser link with ?arg=value
|
||||||
|
r = yield get_page('spawn', app, cookies=cookies, params={'arg': 'value'})
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
# verify that request params got passed down
|
||||||
|
# implemented in MockSpawner
|
||||||
|
r = yield async_requests.get(ujoin(public_url(app, u), 'env'))
|
||||||
|
env = r.json()
|
||||||
|
assert 'HANDLER_ARGS' in env
|
||||||
|
assert env['HANDLER_ARGS'] == 'arg=value'
|
||||||
|
|
||||||
|
# stop server
|
||||||
|
r = yield api_request(app, 'users', name, 'server', method='delete')
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gen_test
|
@pytest.mark.gen_test
|
||||||
def test_spawn_admin_access(app, admin_access):
|
def test_spawn_admin_access(app, admin_access):
|
||||||
"""GET /user/:name as admin with admin-access spawns user's server"""
|
"""GET /user/:name as admin with admin-access spawns user's server"""
|
||||||
@@ -190,11 +243,12 @@ def test_spawn_form(app):
|
|||||||
orm_u = orm.User.find(app.db, 'jones')
|
orm_u = orm.User.find(app.db, 'jones')
|
||||||
u = app.users[orm_u]
|
u = app.users[orm_u]
|
||||||
yield u.stop()
|
yield u.stop()
|
||||||
|
next_url = ujoin(app.base_url, 'user/jones/tree')
|
||||||
r = yield async_requests.post(ujoin(base_url, 'spawn?next=/user/jones/tree'), cookies=cookies, data={
|
r = yield async_requests.post(
|
||||||
'bounds': ['-1', '1'],
|
url_concat(ujoin(base_url, 'spawn'), {'next': next_url}),
|
||||||
'energy': '511keV',
|
cookies=cookies,
|
||||||
})
|
data={'bounds': ['-1', '1'], 'energy': '511keV'},
|
||||||
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.history
|
assert r.history
|
||||||
assert u.spawner.user_options == {
|
assert u.spawner.user_options == {
|
||||||
@@ -210,13 +264,13 @@ def test_spawn_form_admin_access(app, admin_access):
|
|||||||
base_url = ujoin(public_host(app), app.hub.base_url)
|
base_url = ujoin(public_host(app), app.hub.base_url)
|
||||||
cookies = yield app.login_user('admin')
|
cookies = yield app.login_user('admin')
|
||||||
u = add_user(app.db, app=app, name='martha')
|
u = add_user(app.db, app=app, name='martha')
|
||||||
|
next_url = ujoin(app.base_url, 'user', u.name, 'tree')
|
||||||
|
|
||||||
r = yield async_requests.post(
|
r = yield async_requests.post(
|
||||||
ujoin(base_url, 'spawn/{0}?next=/user/{0}/tree'.format(u.name)),
|
url_concat(ujoin(base_url, 'spawn', u.name), {'next': next_url}),
|
||||||
cookies=cookies, data={
|
cookies=cookies,
|
||||||
'bounds': ['-3', '3'],
|
data={'bounds': ['-3', '3'], 'energy': '938MeV'},
|
||||||
'energy': '938MeV',
|
)
|
||||||
})
|
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.history
|
assert r.history
|
||||||
assert r.url.startswith(public_url(app, u))
|
assert r.url.startswith(public_url(app, u))
|
||||||
@@ -342,29 +396,50 @@ def test_login_strip(app):
|
|||||||
assert called_with == [form_data]
|
assert called_with == [form_data]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'running, next_url, location',
|
||||||
|
[
|
||||||
|
# default URL if next not specified, for both running and not
|
||||||
|
(True, '', ''),
|
||||||
|
(False, '', ''),
|
||||||
|
# next_url is respected
|
||||||
|
(False, '/hub/admin', '/hub/admin'),
|
||||||
|
(False, '/user/other', '/hub/user/other'),
|
||||||
|
(False, '/absolute', '/absolute'),
|
||||||
|
(False, '/has?query#andhash', '/has?query#andhash'),
|
||||||
|
|
||||||
|
# next_url outside is not allowed
|
||||||
|
(False, 'https://other.domain', ''),
|
||||||
|
(False, 'ftp://other.domain', ''),
|
||||||
|
(False, '//other.domain', ''),
|
||||||
|
]
|
||||||
|
)
|
||||||
@pytest.mark.gen_test
|
@pytest.mark.gen_test
|
||||||
def test_login_redirect(app):
|
def test_login_redirect(app, running, next_url, location):
|
||||||
cookies = yield app.login_user('river')
|
cookies = yield app.login_user('river')
|
||||||
user = app.users['river']
|
user = app.users['river']
|
||||||
# no next_url, server running
|
if location:
|
||||||
yield user.spawn()
|
location = ujoin(app.base_url, location)
|
||||||
r = yield get_page('login', app, cookies=cookies, allow_redirects=False)
|
else:
|
||||||
r.raise_for_status()
|
# use default url
|
||||||
assert r.status_code == 302
|
location = user.url
|
||||||
assert '/user/river' in r.headers['Location']
|
|
||||||
|
|
||||||
# no next_url, server not running
|
url = 'login'
|
||||||
yield user.stop()
|
if next_url:
|
||||||
r = yield get_page('login', app, cookies=cookies, allow_redirects=False)
|
if '//' not in next_url:
|
||||||
r.raise_for_status()
|
next_url = ujoin(app.base_url, next_url, '')
|
||||||
assert r.status_code == 302
|
url = url_concat(url, dict(next=next_url))
|
||||||
assert '/user/river' in r.headers['Location']
|
|
||||||
|
|
||||||
# next URL given, use it
|
if running and not user.active:
|
||||||
r = yield get_page('login?next=/hub/admin', app, cookies=cookies, allow_redirects=False)
|
# ensure running
|
||||||
|
yield user.spawn()
|
||||||
|
elif user.active and not running:
|
||||||
|
# ensure not running
|
||||||
|
yield user.stop()
|
||||||
|
r = yield get_page(url, app, cookies=cookies, allow_redirects=False)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.status_code == 302
|
assert r.status_code == 302
|
||||||
assert r.headers['Location'].endswith('/hub/admin')
|
assert location == r.headers['Location']
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gen_test
|
@pytest.mark.gen_test
|
||||||
@@ -447,6 +522,21 @@ def test_token_auth(app):
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gen_test
|
||||||
|
def test_oauth_token_page(app):
|
||||||
|
name = 'token'
|
||||||
|
cookies = yield app.login_user(name)
|
||||||
|
user = app.users[orm.User.find(app.db, name)]
|
||||||
|
client = orm.OAuthClient(identifier='token')
|
||||||
|
app.db.add(client)
|
||||||
|
oauth_token = orm.OAuthAccessToken(client=client, user=user, grant_type=orm.GrantType.authorization_code)
|
||||||
|
app.db.add(oauth_token)
|
||||||
|
app.db.commit()
|
||||||
|
r = yield get_page('token', app, cookies=cookies)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("error_status", [
|
@pytest.mark.parametrize("error_status", [
|
||||||
503,
|
503,
|
||||||
404,
|
404,
|
||||||
@@ -456,3 +546,109 @@ def test_token_auth(app):
|
|||||||
def test_proxy_error(app, error_status):
|
def test_proxy_error(app, error_status):
|
||||||
r = yield get_page('/error/%i' % error_status, app)
|
r = yield get_page('/error/%i' % error_status, app)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gen_test
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"announcements",
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"spawn",
|
||||||
|
"spawn,home,login",
|
||||||
|
"login,logout",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
def test_announcements(app, announcements):
|
||||||
|
"""Test announcements on various pages"""
|
||||||
|
# Default announcement - same on all pages
|
||||||
|
ann01 = "ANNOUNCE01"
|
||||||
|
template_vars = {"announcement": ann01}
|
||||||
|
announcements = announcements.split(",")
|
||||||
|
for name in announcements:
|
||||||
|
template_vars["announcement_" + name] = "ANN_" + name
|
||||||
|
|
||||||
|
def assert_announcement(name, text):
|
||||||
|
if name in announcements:
|
||||||
|
assert template_vars["announcement_" + name] in text
|
||||||
|
assert ann01 not in text
|
||||||
|
else:
|
||||||
|
assert ann01 in text
|
||||||
|
|
||||||
|
cookies = yield app.login_user("jones")
|
||||||
|
|
||||||
|
with mock.patch.dict(
|
||||||
|
app.tornado_settings,
|
||||||
|
{"template_vars": template_vars, "spawner_class": FormSpawner},
|
||||||
|
):
|
||||||
|
r = yield get_page("login", app)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert_announcement("login", r.text)
|
||||||
|
r = yield get_page("spawn", app, cookies=cookies)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert_announcement("spawn", r.text)
|
||||||
|
r = yield get_page("home", app, cookies=cookies) # hub/home
|
||||||
|
r.raise_for_status()
|
||||||
|
assert_announcement("home", r.text)
|
||||||
|
# need auto_login=True to get logout page
|
||||||
|
auto_login = app.authenticator.auto_login
|
||||||
|
app.authenticator.auto_login = True
|
||||||
|
try:
|
||||||
|
r = yield get_page("logout", app, cookies=cookies)
|
||||||
|
finally:
|
||||||
|
app.authenticator.auto_login = auto_login
|
||||||
|
r.raise_for_status()
|
||||||
|
assert_announcement("logout", r.text)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gen_test
|
||||||
|
def test_token_page(app):
|
||||||
|
name = "cake"
|
||||||
|
cookies = yield app.login_user(name)
|
||||||
|
r = yield get_page("token", app, cookies=cookies)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert urlparse(r.url).path.endswith('/hub/token')
|
||||||
|
def extract_body(r):
|
||||||
|
soup = BeautifulSoup(r.text, "html5lib")
|
||||||
|
import re
|
||||||
|
# trim empty lines
|
||||||
|
return re.sub(r"(\n\s*)+", "\n", soup.body.find(class_="container").text)
|
||||||
|
body = extract_body(r)
|
||||||
|
assert "Request new API token" in body, body
|
||||||
|
# no tokens yet, no lists
|
||||||
|
assert "API Tokens" not in body, body
|
||||||
|
assert "Authorized Applications" not in body, body
|
||||||
|
|
||||||
|
# request an API token
|
||||||
|
user = app.users[name]
|
||||||
|
token = user.new_api_token(expires_in=60, note="my-test-token")
|
||||||
|
app.db.commit()
|
||||||
|
|
||||||
|
r = yield get_page("token", app, cookies=cookies)
|
||||||
|
r.raise_for_status()
|
||||||
|
body = extract_body(r)
|
||||||
|
assert "API Tokens" in body, body
|
||||||
|
assert "my-test-token" in body, body
|
||||||
|
# no oauth tokens yet, shouldn't have that section
|
||||||
|
assert "Authorized Applications" not in body, body
|
||||||
|
|
||||||
|
# spawn the user to trigger oauth, etc.
|
||||||
|
# request an oauth token
|
||||||
|
user.spawner.cmd = [sys.executable, '-m', 'jupyterhub.singleuser']
|
||||||
|
r = yield get_page("spawn", app, cookies=cookies)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
r = yield get_page("token", app, cookies=cookies)
|
||||||
|
r.raise_for_status()
|
||||||
|
body = extract_body(r)
|
||||||
|
assert "API Tokens" in body, body
|
||||||
|
assert "Server at %s" % user.base_url in body, body
|
||||||
|
assert "Authorized Applications" in body, body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gen_test
|
||||||
|
def test_server_not_running_api_request(app):
|
||||||
|
cookies = yield app.login_user("bees")
|
||||||
|
r = yield get_page("user/bees/api/status", app, hub=False, cookies=cookies)
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert r.headers["content-type"] == "application/json"
|
||||||
|
assert r.json() == {"message": "bees is not running"}
|
||||||
|
@@ -79,7 +79,7 @@ def test_external_proxy(request):
|
|||||||
|
|
||||||
# test if api service has a root route '/'
|
# test if api service has a root route '/'
|
||||||
routes = yield app.proxy.get_all_routes()
|
routes = yield app.proxy.get_all_routes()
|
||||||
assert list(routes.keys()) == ['/']
|
assert list(routes.keys()) == [app.hub.routespec]
|
||||||
|
|
||||||
# add user to the db and start a single user server
|
# add user to the db and start a single user server
|
||||||
name = 'river'
|
name = 'river'
|
||||||
@@ -95,7 +95,7 @@ def test_external_proxy(request):
|
|||||||
if app.subdomain_host:
|
if app.subdomain_host:
|
||||||
host = '%s.%s' % (name, urlparse(app.subdomain_host).hostname)
|
host = '%s.%s' % (name, urlparse(app.subdomain_host).hostname)
|
||||||
user_spec = host + user_path
|
user_spec = host + user_path
|
||||||
assert sorted(routes.keys()) == ['/', user_spec]
|
assert sorted(routes.keys()) == [app.hub.routespec, user_spec]
|
||||||
|
|
||||||
# teardown the proxy and start a new one in the same place
|
# teardown the proxy and start a new one in the same place
|
||||||
proxy.terminate()
|
proxy.terminate()
|
||||||
@@ -113,7 +113,7 @@ def test_external_proxy(request):
|
|||||||
|
|
||||||
# check that the routes are correct
|
# check that the routes are correct
|
||||||
routes = yield app.proxy.get_all_routes()
|
routes = yield app.proxy.get_all_routes()
|
||||||
assert sorted(routes.keys()) == ['/', user_spec]
|
assert sorted(routes.keys()) == [app.hub.routespec, user_spec]
|
||||||
|
|
||||||
# teardown the proxy, and start a new one with different auth and port
|
# teardown the proxy, and start a new one with different auth and port
|
||||||
proxy.terminate()
|
proxy.terminate()
|
||||||
@@ -146,7 +146,7 @@ def test_external_proxy(request):
|
|||||||
|
|
||||||
# check that the routes are correct
|
# check that the routes are correct
|
||||||
routes = yield app.proxy.get_all_routes()
|
routes = yield app.proxy.get_all_routes()
|
||||||
assert sorted(routes.keys()) == ['/', user_spec]
|
assert sorted(routes.keys()) == [app.hub.routespec, user_spec]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gen_test
|
@pytest.mark.gen_test
|
||||||
@@ -154,6 +154,8 @@ def test_external_proxy(request):
|
|||||||
'zoe',
|
'zoe',
|
||||||
'50fia',
|
'50fia',
|
||||||
'秀樹',
|
'秀樹',
|
||||||
|
'~TestJH',
|
||||||
|
'has@',
|
||||||
])
|
])
|
||||||
def test_check_routes(app, username, disable_check_routes):
|
def test_check_routes(app, username, disable_check_routes):
|
||||||
proxy = app.proxy
|
proxy = app.proxy
|
||||||
|
@@ -11,6 +11,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
@@ -20,7 +21,8 @@ from .. import orm
|
|||||||
from .. import spawner as spawnermod
|
from .. import spawner as spawnermod
|
||||||
from ..spawner import LocalProcessSpawner, Spawner
|
from ..spawner import LocalProcessSpawner, Spawner
|
||||||
from ..user import User
|
from ..user import User
|
||||||
from ..utils import new_token
|
from ..utils import new_token, url_path_join
|
||||||
|
from .mocking import public_url
|
||||||
from .test_api import add_user
|
from .test_api import add_user
|
||||||
from .utils import async_requests
|
from .utils import async_requests
|
||||||
|
|
||||||
@@ -304,7 +306,7 @@ def test_spawner_reuse_api_token(db, app):
|
|||||||
@pytest.mark.gen_test
|
@pytest.mark.gen_test
|
||||||
def test_spawner_insert_api_token(app):
|
def test_spawner_insert_api_token(app):
|
||||||
"""Token provided by spawner is not in the db
|
"""Token provided by spawner is not in the db
|
||||||
|
|
||||||
Insert token into db as a user-provided token.
|
Insert token into db as a user-provided token.
|
||||||
"""
|
"""
|
||||||
# setup: new user, double check that they don't have any tokens registered
|
# setup: new user, double check that they don't have any tokens registered
|
||||||
@@ -379,3 +381,28 @@ def test_spawner_delete_server(app):
|
|||||||
# verify that both ORM and top-level references are None
|
# verify that both ORM and top-level references are None
|
||||||
assert spawner.orm_spawner.server is None
|
assert spawner.orm_spawner.server is None
|
||||||
assert spawner.server is None
|
assert spawner.server is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"name",
|
||||||
|
[
|
||||||
|
"has@x",
|
||||||
|
"has~x",
|
||||||
|
"has%x",
|
||||||
|
"has%40x",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@pytest.mark.gen_test
|
||||||
|
def test_spawner_routing(app, name):
|
||||||
|
"""Test routing of names with special characters"""
|
||||||
|
db = app.db
|
||||||
|
with mock.patch.dict(app.config.LocalProcessSpawner, {'cmd': [sys.executable, '-m', 'jupyterhub.tests.mocksu']}):
|
||||||
|
user = add_user(app.db, app, name=name)
|
||||||
|
yield user.spawn()
|
||||||
|
yield wait_for_spawner(user.spawner)
|
||||||
|
yield app.proxy.add_user(user)
|
||||||
|
url = url_path_join(public_url(app, user), "test/url")
|
||||||
|
r = yield async_requests.get(url, allow_redirects=False)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert r.url == url
|
||||||
|
assert r.text == urlparse(url).path
|
||||||
|
@@ -282,7 +282,7 @@ class User:
|
|||||||
@property
|
@property
|
||||||
def escaped_name(self):
|
def escaped_name(self):
|
||||||
"""My name, escaped for use in URLs, cookies, etc."""
|
"""My name, escaped for use in URLs, cookies, etc."""
|
||||||
return quote(self.name, safe='@')
|
return quote(self.name, safe='@~')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def proxy_spec(self):
|
def proxy_spec(self):
|
||||||
@@ -295,15 +295,17 @@ class User:
|
|||||||
@property
|
@property
|
||||||
def domain(self):
|
def domain(self):
|
||||||
"""Get the domain for my server."""
|
"""Get the domain for my server."""
|
||||||
# FIXME: escaped_name probably isn't escaped enough in general for a domain fragment
|
# use underscore as escape char for domains
|
||||||
return self.escaped_name + '.' + self.settings['domain']
|
return quote(self.name).replace('%', '_').lower() + '.' + self.settings['domain']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def host(self):
|
def host(self):
|
||||||
"""Get the *host* for my server (proto://domain[:port])"""
|
"""Get the *host* for my server (proto://domain[:port])"""
|
||||||
# FIXME: escaped_name probably isn't escaped enough in general for a domain fragment
|
# FIXME: escaped_name probably isn't escaped enough in general for a domain fragment
|
||||||
parsed = urlparse(self.settings['subdomain_host'])
|
parsed = urlparse(self.settings['subdomain_host'])
|
||||||
h = '%s://%s.%s' % (parsed.scheme, self.escaped_name, parsed.netloc)
|
h = '%s://%s' % (parsed.scheme, self.domain)
|
||||||
|
if parsed.port:
|
||||||
|
h += ':%i' % parsed.port
|
||||||
return h
|
return h
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -329,7 +331,7 @@ class User:
|
|||||||
url_parts.extend(['server/progress'])
|
url_parts.extend(['server/progress'])
|
||||||
return url_path_join(*url_parts)
|
return url_path_join(*url_parts)
|
||||||
|
|
||||||
async def spawn(self, server_name='', options=None):
|
async def spawn(self, server_name='', options=None, handler=None):
|
||||||
"""Start the user's spawner
|
"""Start the user's spawner
|
||||||
|
|
||||||
depending from the value of JupyterHub.allow_named_servers
|
depending from the value of JupyterHub.allow_named_servers
|
||||||
@@ -359,6 +361,9 @@ class User:
|
|||||||
spawner.server = server = Server(orm_server=orm_server)
|
spawner.server = server = Server(orm_server=orm_server)
|
||||||
assert spawner.orm_spawner.server is orm_server
|
assert spawner.orm_spawner.server is orm_server
|
||||||
|
|
||||||
|
# pass requesting handler to the spawner
|
||||||
|
# e.g. for processing GET params
|
||||||
|
spawner.handler = handler
|
||||||
# Passing user_options to the spawner
|
# Passing user_options to the spawner
|
||||||
spawner.user_options = options or {}
|
spawner.user_options = options or {}
|
||||||
# we are starting a new server, make sure it doesn't restore state
|
# we are starting a new server, make sure it doesn't restore state
|
||||||
@@ -482,6 +487,9 @@ class User:
|
|||||||
# raise original exception
|
# raise original exception
|
||||||
spawner._start_pending = False
|
spawner._start_pending = False
|
||||||
raise e
|
raise e
|
||||||
|
finally:
|
||||||
|
# clear reference to handler after start finishes
|
||||||
|
spawner.handler = None
|
||||||
spawner.start_polling()
|
spawner.start_polling()
|
||||||
|
|
||||||
# store state
|
# store state
|
||||||
@@ -550,11 +558,25 @@ class User:
|
|||||||
# remove server entry from db
|
# remove server entry from db
|
||||||
spawner.server = None
|
spawner.server = None
|
||||||
if not spawner.will_resume:
|
if not spawner.will_resume:
|
||||||
# find and remove the API token if the spawner isn't
|
# find and remove the API token and oauth client if the spawner isn't
|
||||||
# going to re-use it next time
|
# going to re-use it next time
|
||||||
orm_token = orm.APIToken.find(self.db, api_token)
|
orm_token = orm.APIToken.find(self.db, api_token)
|
||||||
if orm_token:
|
if orm_token:
|
||||||
self.db.delete(orm_token)
|
self.db.delete(orm_token)
|
||||||
|
# remove oauth client as well
|
||||||
|
# handle upgrades from 0.8, where client id will be `user-USERNAME`,
|
||||||
|
# not just `jupyterhub-user-USERNAME`
|
||||||
|
client_ids = (
|
||||||
|
spawner.oauth_client_id,
|
||||||
|
spawner.oauth_client_id.split('-', 1)[1],
|
||||||
|
)
|
||||||
|
for oauth_client in (
|
||||||
|
self.db
|
||||||
|
.query(orm.OAuthClient)
|
||||||
|
.filter(orm.OAuthClient.identifier.in_(client_ids))
|
||||||
|
):
|
||||||
|
self.log.debug("Deleting oauth client %s", oauth_client.identifier)
|
||||||
|
self.db.delete(oauth_client)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
finally:
|
finally:
|
||||||
spawner.orm_spawner.started = None
|
spawner.orm_spawner.started = None
|
||||||
|
@@ -19,7 +19,7 @@ import threading
|
|||||||
import uuid
|
import uuid
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from async_generator import aclosing, async_generator, yield_
|
from async_generator import aclosing, asynccontextmanager, async_generator, yield_
|
||||||
from tornado import gen, ioloop, web
|
from tornado import gen, ioloop, web
|
||||||
from tornado.platform.asyncio import to_asyncio_future
|
from tornado.platform.asyncio import to_asyncio_future
|
||||||
from tornado.httpclient import AsyncHTTPClient, HTTPError
|
from tornado.httpclient import AsyncHTTPClient, HTTPError
|
||||||
@@ -452,6 +452,21 @@ def maybe_future(obj):
|
|||||||
return to_asyncio_future(gen.maybe_future(obj))
|
return to_asyncio_future(gen.maybe_future(obj))
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
@async_generator
|
||||||
|
async def not_aclosing(coro):
|
||||||
|
"""An empty context manager for Python < 3.5.2
|
||||||
|
which lacks the `aclose` method on async iterators
|
||||||
|
"""
|
||||||
|
await yield_(await coro)
|
||||||
|
|
||||||
|
|
||||||
|
if sys.version_info < (3, 5, 2):
|
||||||
|
# Python 3.5.1 is missing the aclose method on async iterators,
|
||||||
|
# so we can't close them
|
||||||
|
aclosing = not_aclosing
|
||||||
|
|
||||||
|
|
||||||
@async_generator
|
@async_generator
|
||||||
async def iterate_until(deadline_future, generator):
|
async def iterate_until(deadline_future, generator):
|
||||||
"""An async generator that yields items from a generator
|
"""An async generator that yields items from a generator
|
||||||
|
@@ -4,3 +4,8 @@ conda:
|
|||||||
file: docs/environment.yml
|
file: docs/environment.yml
|
||||||
python:
|
python:
|
||||||
version: 3
|
version: 3
|
||||||
|
formats:
|
||||||
|
- htmlzip
|
||||||
|
- epub
|
||||||
|
# pdf disabled due to bug in sphinx 1.8 + recommonmark
|
||||||
|
# - pdf
|
||||||
|
15
setup.py
15
setup.py
@@ -83,6 +83,10 @@ for d, _, _ in os.walk('jupyterhub'):
|
|||||||
if os.path.exists(pjoin(d, '__init__.py')):
|
if os.path.exists(pjoin(d, '__init__.py')):
|
||||||
packages.append(d.replace(os.path.sep, '.'))
|
packages.append(d.replace(os.path.sep, '.'))
|
||||||
|
|
||||||
|
with open('README.md', encoding="utf8") as f:
|
||||||
|
readme = f.read()
|
||||||
|
|
||||||
|
|
||||||
setup_args = dict(
|
setup_args = dict(
|
||||||
name = 'jupyterhub',
|
name = 'jupyterhub',
|
||||||
scripts = glob(pjoin('scripts', '*')),
|
scripts = glob(pjoin('scripts', '*')),
|
||||||
@@ -93,10 +97,11 @@ setup_args = dict(
|
|||||||
package_data = get_package_data(),
|
package_data = get_package_data(),
|
||||||
version = ns['__version__'],
|
version = ns['__version__'],
|
||||||
description = "JupyterHub: A multi-user server for Jupyter notebooks",
|
description = "JupyterHub: A multi-user server for Jupyter notebooks",
|
||||||
long_description = "See https://jupyterhub.readthedocs.io for more info.",
|
long_description = readme,
|
||||||
|
long_description_content_type = 'text/markdown',
|
||||||
author = "Jupyter Development Team",
|
author = "Jupyter Development Team",
|
||||||
author_email = "jupyter@googlegroups.com",
|
author_email = "jupyter@googlegroups.com",
|
||||||
url = "http://jupyter.org",
|
url = "https://jupyter.org",
|
||||||
license = "BSD",
|
license = "BSD",
|
||||||
platforms = "Linux, Mac OS X",
|
platforms = "Linux, Mac OS X",
|
||||||
keywords = ['Interactive', 'Interpreter', 'Shell', 'Web'],
|
keywords = ['Interactive', 'Interpreter', 'Shell', 'Web'],
|
||||||
@@ -109,6 +114,12 @@ setup_args = dict(
|
|||||||
'Programming Language :: Python',
|
'Programming Language :: Python',
|
||||||
'Programming Language :: Python :: 3',
|
'Programming Language :: Python :: 3',
|
||||||
],
|
],
|
||||||
|
project_urls = {
|
||||||
|
'Documentation': 'https://jupyterhub.readthedocs.io',
|
||||||
|
'Funding': 'https://jupyter.org/about',
|
||||||
|
'Source': 'https://github.com/jupyterhub/jupyterhub/',
|
||||||
|
'Tracker': 'https://github.com/jupyterhub/jupyterhub/issues',
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
#---------------------------------------------------------------------------
|
#---------------------------------------------------------------------------
|
||||||
|
@@ -1,19 +1,29 @@
|
|||||||
// Copyright (c) Jupyter Development Team.
|
// Copyright (c) Jupyter Development Team.
|
||||||
// Distributed under the terms of the Modified BSD License.
|
// Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
require(["jquery", "jhapi"], function ($, JHAPI) {
|
require(["jquery", "jhapi"], function($, JHAPI) {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
var base_url = window.jhdata.base_url;
|
var base_url = window.jhdata.base_url;
|
||||||
var user = window.jhdata.user;
|
var user = window.jhdata.user;
|
||||||
var api = new JHAPI(base_url);
|
var api = new JHAPI(base_url);
|
||||||
|
|
||||||
$("#stop").click(function () {
|
$("#stop").click(function() {
|
||||||
api.stop_server(user, {
|
$("#start")
|
||||||
success: function () {
|
.attr("disabled", true)
|
||||||
$("#stop").hide();
|
.attr("title", "Your server is stopping")
|
||||||
}
|
.click(function() {
|
||||||
});
|
return false;
|
||||||
|
});
|
||||||
|
api.stop_server(user, {
|
||||||
|
success: function() {
|
||||||
|
$("#start")
|
||||||
|
.text("Start My Server")
|
||||||
|
.attr("title", "Start your server")
|
||||||
|
.attr("disabled", false)
|
||||||
|
.off("click");
|
||||||
|
$("#stop").hide();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@@ -12,7 +12,7 @@ require(["jquery", "jhapi", "moment"], function($, JHAPI, moment) {
|
|||||||
// convert ISO datestamps to nice momentjs ones
|
// convert ISO datestamps to nice momentjs ones
|
||||||
el = $(el);
|
el = $(el);
|
||||||
let m = moment(new Date(el.text().trim()));
|
let m = moment(new Date(el.text().trim()));
|
||||||
el.text(m.isValid() ? m.fromNow() : "Never");
|
el.text(m.isValid() ? m.fromNow() : el.text());
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#request-token-form").submit(function() {
|
$("#request-token-form").submit(function() {
|
||||||
|
@@ -53,20 +53,20 @@
|
|||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
</td>
|
</td>
|
||||||
<td class="server-col col-sm-2 text-center">
|
<td class="server-col col-sm-2 text-center">
|
||||||
<a role="button" class="stop-server btn btn-xs btn-danger {% if not u.running %}hidden{% endif %}">stop server</span>
|
<a role="button" class="stop-server btn btn-xs btn-danger {% if not u.running %}hidden{% endif %}">stop server</a>
|
||||||
<a role="button" class="start-server btn btn-xs btn-primary {% if u.running %}hidden{% endif %}">start server</span>
|
<a role="button" class="start-server btn btn-xs btn-primary {% if u.running %}hidden{% endif %}">start server</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="server-col col-sm-1 text-center">
|
<td class="server-col col-sm-1 text-center">
|
||||||
{% if admin_access %}
|
{% if admin_access %}
|
||||||
<a role="button" class="access-server btn btn-xs btn-primary {% if not u.running %}hidden{% endif %}">access server</span>
|
<a role="button" class="access-server btn btn-xs btn-primary {% if not u.running %}hidden{% endif %}">access server</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="edit-col col-sm-1 text-center">
|
<td class="edit-col col-sm-1 text-center">
|
||||||
<a role="button" class="edit-user btn btn-xs btn-primary">edit</span>
|
<a role="button" class="edit-user btn btn-xs btn-primary">edit</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="edit-col col-sm-1 text-center">
|
<td class="edit-col col-sm-1 text-center">
|
||||||
{% if u.name != user.name %}
|
{% if u.name != user.name %}
|
||||||
<a role="button" class="delete-user btn btn-xs btn-danger">delete</span>
|
<a role="button" class="delete-user btn btn-xs btn-danger">delete</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% endblock user_row %}
|
{% endblock user_row %}
|
||||||
|
@@ -1,4 +1,7 @@
|
|||||||
{% extends "page.html" %}
|
{% extends "page.html" %}
|
||||||
|
{% if announcement_home %}
|
||||||
|
{% set announcement = announcement_home %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
|
||||||
@@ -8,8 +11,8 @@
|
|||||||
{% if user.running %}
|
{% if user.running %}
|
||||||
<a id="stop" role="button" class="btn btn-lg btn-danger">Stop My Server</a>
|
<a id="stop" role="button" class="btn btn-lg btn-danger">Stop My Server</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a id="start"role="button" class="btn btn-lg btn-primary" href="{{ url }}">
|
<a id="start" role="button" class="btn btn-lg btn-primary" href="{{ url }}">
|
||||||
{% if not user.running %}
|
{% if not user.active %}
|
||||||
Start
|
Start
|
||||||
{% endif %}
|
{% endif %}
|
||||||
My Server
|
My Server
|
||||||
|
@@ -1,4 +1,7 @@
|
|||||||
{% extends "page.html" %}
|
{% extends "page.html" %}
|
||||||
|
{% if announcement_login %}
|
||||||
|
{% set announcement = announcement_login %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% block login_widget %}
|
{% block login_widget %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -1,8 +1,14 @@
|
|||||||
{% extends "page.html" %}
|
{% extends "page.html" %}
|
||||||
|
{% if announcement_logout %}
|
||||||
|
{% set announcement = announcement_logout %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
|
||||||
<div id="logout-main" class="container">
|
<div id="logout-main" class="container">
|
||||||
<p>
|
<p>
|
||||||
Successfully logged out.
|
Successfully logged out.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -70,7 +70,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
admin_access: false,
|
admin_access: false,
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user and user.spawner.options_form %}
|
{% if not no_spawner_check and user and user.spawner.options_form %}
|
||||||
options_form: true,
|
options_form: true,
|
||||||
{% else %}
|
{% else %}
|
||||||
options_form: false,
|
options_form: false,
|
||||||
@@ -140,6 +140,16 @@
|
|||||||
</nav>
|
</nav>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block announcement %}
|
||||||
|
{% if announcement %}
|
||||||
|
<div class="container text-center announcement">
|
||||||
|
{{ announcement | safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@@ -1,11 +1,16 @@
|
|||||||
{% extends "page.html" %}
|
{% extends "page.html" %}
|
||||||
|
{% if announcement_spawn %}
|
||||||
|
{% set announcement = announcement_spawn %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
{% block heading %}
|
||||||
<div class="row text-center">
|
<div class="row text-center">
|
||||||
<h1>Spawner options</h1>
|
<h1>Spawner Options</h1>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
<div class="row col-sm-offset-2 col-sm-8">
|
<div class="row col-sm-offset-2 col-sm-8">
|
||||||
{% if for_user and user.name != for_user.name -%}
|
{% if for_user and user.name != for_user.name -%}
|
||||||
<p>Spawning server for {{ for_user.name }}</p>
|
<p>Spawning server for {{ for_user.name }}</p>
|
||||||
|
@@ -15,6 +15,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p id="progress-message"></p>
|
<p id="progress-message"></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-8 col-md-offset-2">
|
<div class="col-md-8 col-md-offset-2">
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
<div id="progress-log"></div>
|
<div id="progress-log"></div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
32
share/jupyterhub/templates/stop_pending.html
Normal file
32
share/jupyterhub/templates/stop_pending.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "page.html" %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="text-center">
|
||||||
|
{% block message %}
|
||||||
|
<p>Your server is stopping.</p>
|
||||||
|
<p>You will be able to start it again once it has finished stopping.</p>
|
||||||
|
{% endblock message %}
|
||||||
|
<p><i class="fa fa-spinner fa-pulse fa-fw fa-3x" aria-hidden="true"></i></p>
|
||||||
|
<a role="button" id="refresh" class="btn btn-lg btn-primary" href="#">refresh</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
{{ super() }}
|
||||||
|
<script type="text/javascript">
|
||||||
|
require(["jquery"], function ($) {
|
||||||
|
$("#refresh").click(function () {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
setTimeout(function () {
|
||||||
|
window.location.reload();
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
@@ -24,7 +24,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<p>
|
|
||||||
<div id="token-area" class="col-md-6 col-md-offset-3" style="display: none;">
|
<div id="token-area" class="col-md-6 col-md-offset-3" style="display: none;">
|
||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
@@ -41,7 +40,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if api_tokens %}
|
{% if api_tokens %}
|
||||||
@@ -62,7 +60,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for token in api_tokens %}
|
{% for token in api_tokens %}
|
||||||
<tr class="token-row" data-token-id="{{token.api_id}}"">
|
<tr class="token-row" data-token-id="{{token.api_id}}">
|
||||||
{% block token_row scoped %}
|
{% block token_row scoped %}
|
||||||
<td class="note-col col-sm-5">{{token.note}}</td>
|
<td class="note-col col-sm-5">{{token.note}}</td>
|
||||||
<td class="time-col col-sm-3">
|
<td class="time-col col-sm-3">
|
||||||
@@ -73,7 +71,11 @@
|
|||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
</td>
|
</td>
|
||||||
<td class="time-col col-sm-3">
|
<td class="time-col col-sm-3">
|
||||||
|
{%- if token.created -%}
|
||||||
{{ token.created.isoformat() + 'Z' }}
|
{{ token.created.isoformat() + 'Z' }}
|
||||||
|
{%- else -%}
|
||||||
|
N/A
|
||||||
|
{%- endif -%}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-sm-1 text-center">
|
<td class="col-sm-1 text-center">
|
||||||
<button class="revoke-token-btn btn btn-xs btn-danger">revoke</button>
|
<button class="revoke-token-btn btn btn-xs btn-danger">revoke</button>
|
||||||
@@ -107,7 +109,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for client in oauth_clients %}
|
{% for client in oauth_clients %}
|
||||||
<tr class="token-row"
|
<tr class="token-row"
|
||||||
data-token-id="{{ client['token_id'] }}"">
|
data-token-id="{{ client['token_id'] }}">
|
||||||
{% block client_row scoped %}
|
{% block client_row scoped %}
|
||||||
<td class="note-col col-sm-5">{{ client['description'] }}</td>
|
<td class="note-col col-sm-5">{{ client['description'] }}</td>
|
||||||
<td class="time-col col-sm-3">
|
<td class="time-col col-sm-3">
|
||||||
@@ -118,11 +120,14 @@
|
|||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
</td>
|
</td>
|
||||||
<td class="time-col col-sm-3">
|
<td class="time-col col-sm-3">
|
||||||
|
{%- if client['created'] -%}
|
||||||
{{ client['created'].isoformat() + 'Z' }}
|
{{ client['created'].isoformat() + 'Z' }}
|
||||||
|
{%- else -%}
|
||||||
|
N/A
|
||||||
|
{%- endif -%}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-sm-1 text-center">
|
<td class="col-sm-1 text-center">
|
||||||
<button class="revoke-token-btn btn btn-xs btn-danger">revoke</a>
|
<button class="revoke-token-btn btn btn-xs btn-danger">revoke</button>
|
||||||
</button>
|
|
||||||
{% endblock client_row %}
|
{% endblock client_row %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -131,11 +136,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock main %}
|
||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
require(["token"]);
|
require(["token"]);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock script %}
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -ex
|
set -ex
|
||||||
|
|
||||||
stable=0.8
|
stable=0.9
|
||||||
|
|
||||||
for V in master $stable; do
|
for V in master $stable; do
|
||||||
docker build --build-arg JUPYTERHUB_VERSION=$V -t $DOCKER_REPO:$V .
|
docker build --build-arg JUPYTERHUB_VERSION=$V -t $DOCKER_REPO:$V .
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
set -ex
|
||||||
|
|
||||||
stable=0.8
|
stable=0.9
|
||||||
for V in master $stable; do
|
for V in master $stable; do
|
||||||
docker push $DOCKER_REPO:$V
|
docker push $DOCKER_REPO:$V
|
||||||
done
|
done
|
||||||
@@ -12,6 +13,10 @@ function get_hub_version() {
|
|||||||
hub_xyz=$(cat hub_version)
|
hub_xyz=$(cat hub_version)
|
||||||
split=( ${hub_xyz//./ } )
|
split=( ${hub_xyz//./ } )
|
||||||
hub_xy="${split[0]}.${split[1]}"
|
hub_xy="${split[0]}.${split[1]}"
|
||||||
|
# add .dev on hub_xy so it's 1.0.dev
|
||||||
|
if [[ ! -z "${split[3]}" ]]; then
|
||||||
|
hub_xy="${hub_xy}.${split[3]}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
# tag e.g. 0.8.1 with 0.8
|
# tag e.g. 0.8.1 with 0.8
|
||||||
get_hub_version $stable
|
get_hub_version $stable
|
||||||
@@ -22,3 +27,5 @@ docker push $DOCKER_REPO:$hub_xyz
|
|||||||
get_hub_version master
|
get_hub_version master
|
||||||
docker tag $DOCKER_REPO:master $DOCKER_REPO:$hub_xy
|
docker tag $DOCKER_REPO:master $DOCKER_REPO:$hub_xy
|
||||||
docker push $DOCKER_REPO:$hub_xy
|
docker push $DOCKER_REPO:$hub_xy
|
||||||
|
docker tag $DOCKER_REPO:master $DOCKER_REPO:$hub_xyz
|
||||||
|
docker push $DOCKER_REPO:$hub_xyz
|
||||||
|
Reference in New Issue
Block a user