mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-10 03:23:04 +00:00
Compare commits
503 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a91197635a | ||
![]() |
88706d4c27 | ||
![]() |
29fac11bfe | ||
![]() |
947ef67184 | ||
![]() |
8ede924956 | ||
![]() |
55c2d3648e | ||
![]() |
2cf8e48fb5 | ||
![]() |
ae77038a64 | ||
![]() |
ffed8f67a0 | ||
![]() |
1efd7da6ee | ||
![]() |
6e161d0140 | ||
![]() |
5f4144cc98 | ||
![]() |
f866bbcf45 | ||
![]() |
ed6231d3aa | ||
![]() |
9d38259ad7 | ||
![]() |
4b254fe5ed | ||
![]() |
f8040209b0 | ||
![]() |
e59ee33a6e | ||
![]() |
ff15ced3ce | ||
![]() |
75acd6a67b | ||
![]() |
73ac6207af | ||
![]() |
e435fe66a5 | ||
![]() |
d7569d6f8e | ||
![]() |
ba6c2cf854 | ||
![]() |
970b25d017 | ||
![]() |
671ef0d5ef | ||
![]() |
77220d6662 | ||
![]() |
7e469f911d | ||
![]() |
18393ec6b4 | ||
![]() |
28fdbeb0c0 | ||
![]() |
5664e4d318 | ||
![]() |
24c83e721f | ||
![]() |
cc73ab711e | ||
![]() |
2cfe4474ac | ||
![]() |
74766e4786 | ||
![]() |
ed461ff4a7 | ||
![]() |
184d87ff2a | ||
![]() |
06ed7dc0cf | ||
![]() |
a0b229431c | ||
![]() |
2a06c8a94c | ||
![]() |
91159d08d3 | ||
![]() |
06a83f146b | ||
![]() |
7b66d1656b | ||
![]() |
40176a667f | ||
![]() |
e02345a4e8 | ||
![]() |
1408e9f5f4 | ||
![]() |
b66d204d69 | ||
![]() |
164447717f | ||
![]() |
0472ef0533 | ||
![]() |
202efae6d8 | ||
![]() |
2e043241fb | ||
![]() |
fa61f06fed | ||
![]() |
8b19413fa1 | ||
![]() |
7c2e7692b0 | ||
![]() |
ce11959b1a | ||
![]() |
097974d57d | ||
![]() |
09ff03ca4f | ||
![]() |
313f050c42 | ||
![]() |
4862831f71 | ||
![]() |
c46beb976a | ||
![]() |
11a85d1dc5 | ||
![]() |
67c4a86376 | ||
![]() |
e00ef1aef1 | ||
![]() |
fb5f98f2fa | ||
![]() |
82a1ba8402 | ||
![]() |
7f53ad52fb | ||
![]() |
73cdd687e9 | ||
![]() |
af09bc547a | ||
![]() |
3ddc796068 | ||
![]() |
3c071467bb | ||
![]() |
0c43feee1b | ||
![]() |
5bcbc8b328 | ||
![]() |
87e4f458fb | ||
![]() |
808e8711e1 | ||
![]() |
19935254a7 | ||
![]() |
a499940309 | ||
![]() |
74544009ca | ||
![]() |
665f9fa693 | ||
![]() |
24b555185a | ||
![]() |
24f4b7b6b6 | ||
![]() |
217dffa845 | ||
![]() |
a7b796fa57 | ||
![]() |
6c5fb5fe97 | ||
![]() |
20ea322e25 | ||
![]() |
4f9664cfe2 | ||
![]() |
be211a48ef | ||
![]() |
553ee26312 | ||
![]() |
7e6111448a | ||
![]() |
ccc0294f2e | ||
![]() |
3232ad61aa | ||
![]() |
202a5bf9a5 | ||
![]() |
47136f6a3c | ||
![]() |
5d3161c6ef | ||
![]() |
9da4aa236e | ||
![]() |
d581cf54cb | ||
![]() |
fca2528332 | ||
![]() |
5edd246474 | ||
![]() |
77ed2faf31 | ||
![]() |
4a17441e5a | ||
![]() |
e1166ec834 | ||
![]() |
2a1d341586 | ||
![]() |
55a59a2e43 | ||
![]() |
e019a33509 | ||
![]() |
737dcf65eb | ||
![]() |
9deaeb1fa9 | ||
![]() |
bcfc2c1b0d | ||
![]() |
f71bacc998 | ||
![]() |
ff14b1aa71 | ||
![]() |
ebbbdcb2b1 | ||
![]() |
d0fca9e56b | ||
![]() |
517737aa0b | ||
![]() |
5dadd34a87 | ||
![]() |
df134fefd0 | ||
![]() |
47cec97e63 | ||
![]() |
0b8b87d7d0 | ||
![]() |
3bf1d72905 | ||
![]() |
8cdd449cca | ||
![]() |
6fc3c19763 | ||
![]() |
265dc07c78 | ||
![]() |
1ae039ddef | ||
![]() |
378d34b213 | ||
![]() |
9657430cac | ||
![]() |
6271535f46 | ||
![]() |
2bef5ba981 | ||
![]() |
efb1f3c824 | ||
![]() |
53050a5836 | ||
![]() |
6428ad9f0b | ||
![]() |
9068ff2239 | ||
![]() |
fc6cd33ce0 | ||
![]() |
b0b8e2d058 | ||
![]() |
6bfa402bfa | ||
![]() |
b51a0bba92 | ||
![]() |
2d3f962a1d | ||
![]() |
625242136a | ||
![]() |
f92560fed0 | ||
![]() |
8249ef69f0 | ||
![]() |
c63605425f | ||
![]() |
5b57900c0b | ||
![]() |
d0afdabd4c | ||
![]() |
618746fa00 | ||
![]() |
e7bc6c2ba9 | ||
![]() |
e9f86cd602 | ||
![]() |
6e8517f795 | ||
![]() |
5fa540bea1 | ||
![]() |
99f597887c | ||
![]() |
352526c36a | ||
![]() |
cbbed04eed | ||
![]() |
b2e7b474ff | ||
![]() |
b2756fb18c | ||
![]() |
37b88029e4 | ||
![]() |
4b7413184e | ||
![]() |
41ef0da180 | ||
![]() |
a4a8b3fa2c | ||
![]() |
02e5984f34 | ||
![]() |
b91c5a489c | ||
![]() |
c47c3b2f9e | ||
![]() |
eaa1353dcd | ||
![]() |
b9a3b0a66a | ||
![]() |
929b805fae | ||
![]() |
082f6516a1 | ||
![]() |
1aa21f1d6c | ||
![]() |
cec9702796 | ||
![]() |
f8cbda9c3c | ||
![]() |
71aee05bc0 | ||
![]() |
772de55a0d | ||
![]() |
e6f92238b1 | ||
![]() |
db76b52e35 | ||
![]() |
e6e994e843 | ||
![]() |
284e379341 | ||
![]() |
3ce1cc63af | ||
![]() |
9945a7f7be | ||
![]() |
004c964cc1 | ||
![]() |
0f0d6d12d3 | ||
![]() |
c97e4d4e2f | ||
![]() |
53d496aff5 | ||
![]() |
032ae29066 | ||
![]() |
21caa57e7b | ||
![]() |
37ee104afa | ||
![]() |
dac75ff996 | ||
![]() |
67e06e5a18 | ||
![]() |
4cbc0bad34 | ||
![]() |
9f8c1decc4 | ||
![]() |
1244533387 | ||
![]() |
8c30724f17 | ||
![]() |
50868f5bb5 | ||
![]() |
e15b6ad52e | ||
![]() |
b194135a0f | ||
![]() |
5b8a7fd191 | ||
![]() |
be272ffb2a | ||
![]() |
8ee60ce0c7 | ||
![]() |
e553bcb7e2 | ||
![]() |
c0288ec6f6 | ||
![]() |
65b83f5f00 | ||
![]() |
dcd520179c | ||
![]() |
c830d964d5 | ||
![]() |
9e5993f1da | ||
![]() |
7ed3e0506b | ||
![]() |
7045e1116c | ||
![]() |
fb56fd406f | ||
![]() |
5489395272 | ||
![]() |
6ecda96dd6 | ||
![]() |
30b8bc3664 | ||
![]() |
80ad455fc7 | ||
![]() |
21eaf0dd9f | ||
![]() |
84d2524025 | ||
![]() |
959dfb145a | ||
![]() |
998c18df42 | ||
![]() |
88b10aa2f5 | ||
![]() |
d8f5758e08 | ||
![]() |
47e45a4d3f | ||
![]() |
3e31ff4ac7 | ||
![]() |
ff30396a8e | ||
![]() |
196a7fbc65 | ||
![]() |
c66e8bb4c9 | ||
![]() |
5595146fe2 | ||
![]() |
76b688e574 | ||
![]() |
f00d0be4d6 | ||
![]() |
f9d815676f | ||
![]() |
94612d09a6 | ||
![]() |
76ed65ed82 | ||
![]() |
560bab395b | ||
![]() |
c68b846eef | ||
![]() |
5896b2c9f7 | ||
![]() |
0317fd63fa | ||
![]() |
7f6886c60f | ||
![]() |
10bdca8901 | ||
![]() |
66cb2c0f3e | ||
![]() |
0152e29946 | ||
![]() |
c6f0c07931 | ||
![]() |
51ceab9f6f | ||
![]() |
46ead8cd9d | ||
![]() |
bfb3d50936 | ||
![]() |
962307475e | ||
![]() |
80f4edcd20 | ||
![]() |
1ad4035943 | ||
![]() |
5ab735fea3 | ||
![]() |
e79cb0d376 | ||
![]() |
f728cf89c6 | ||
![]() |
8f719e21d2 | ||
![]() |
29de00ee3c | ||
![]() |
52291b0012 | ||
![]() |
e58c341290 | ||
![]() |
f988a4939e | ||
![]() |
60ee2bfc35 | ||
![]() |
42601c52cc | ||
![]() |
0679586b2c | ||
![]() |
be4201f7ee | ||
![]() |
11a73b5630 | ||
![]() |
f1efac41bf | ||
![]() |
aa6921dd5a | ||
![]() |
e94da17c3c | ||
![]() |
e2ee18fa86 | ||
![]() |
c5ec8ceba3 | ||
![]() |
3458c742cb | ||
![]() |
d1a85e53dc | ||
![]() |
d915cc3ff2 | ||
![]() |
b11c02c6e0 | ||
![]() |
49f3bb53f4 | ||
![]() |
9b7a94046b | ||
![]() |
62ef5ca2fe | ||
![]() |
028e0b0b77 | ||
![]() |
d2a42a69b0 | ||
![]() |
1f21f283df | ||
![]() |
7f35158575 | ||
![]() |
d0da677813 | ||
![]() |
a0a02688c5 | ||
![]() |
2372842b8a | ||
![]() |
7e205a9751 | ||
![]() |
e7fab5c304 | ||
![]() |
8b8b512d06 | ||
![]() |
714072dbd8 | ||
![]() |
6e8f39c22d | ||
![]() |
f3c3225124 | ||
![]() |
614bfe77d8 | ||
![]() |
1beea06ce5 | ||
![]() |
42adb44153 | ||
![]() |
d5a0202106 | ||
![]() |
3d524f2092 | ||
![]() |
409835303e | ||
![]() |
acc8d15fec | ||
![]() |
608cad6404 | ||
![]() |
571a428375 | ||
![]() |
1575adf272 | ||
![]() |
4bc6d869f3 | ||
![]() |
e5a6119505 | ||
![]() |
d80dab284d | ||
![]() |
9d556728bb | ||
![]() |
4369e2cbfa | ||
![]() |
ef4455bb67 | ||
![]() |
76c9111d80 | ||
![]() |
946ed844c5 | ||
![]() |
cceb652039 | ||
![]() |
6e988bf587 | ||
![]() |
dbc6998375 | ||
![]() |
1bdc9aa297 | ||
![]() |
73f1211286 | ||
![]() |
3fece09dda | ||
![]() |
7ad4b0c7cb | ||
![]() |
252015f50d | ||
![]() |
b3cc235c8a | ||
![]() |
47d7af8f48 | ||
![]() |
8528684dc4 | ||
![]() |
d4ce3aa731 | ||
![]() |
ec710f4d90 | ||
![]() |
14378f4cc2 | ||
![]() |
cc8e780653 | ||
![]() |
5bbf584cb7 | ||
![]() |
b5defabf49 | ||
![]() |
2d1f91e527 | ||
![]() |
1653ee77ed | ||
![]() |
10f09f4f70 | ||
![]() |
b7f277147b | ||
![]() |
f3be735eeb | ||
![]() |
3e855eb1be | ||
![]() |
98dc1f71db | ||
![]() |
703703a648 | ||
![]() |
8db8df6d7a | ||
![]() |
744430ba76 | ||
![]() |
45b858c5af | ||
![]() |
d4b5373c05 | ||
![]() |
aba55cc093 | ||
![]() |
5957a37933 | ||
![]() |
d20a33a0e4 | ||
![]() |
df35268bfe | ||
![]() |
c357d02b56 | ||
![]() |
4eb22821f2 | ||
![]() |
b92ea54eda | ||
![]() |
522ef3daea | ||
![]() |
77edffd695 | ||
![]() |
a8bc4f8a4a | ||
![]() |
66c3760b02 | ||
![]() |
fd28e224f2 | ||
![]() |
da3fedb5aa | ||
![]() |
e4e4d472b8 | ||
![]() |
bcbc68dd82 | ||
![]() |
c7df0587d2 | ||
![]() |
cd36733858 | ||
![]() |
6bf4f3b2aa | ||
![]() |
12d81ac07a | ||
![]() |
d60fa9a400 | ||
![]() |
81d423d6c6 | ||
![]() |
069b477ff3 | ||
![]() |
cf9046ea47 | ||
![]() |
71a25d4514 | ||
![]() |
2ff7d05b15 | ||
![]() |
bdb29df82a | ||
![]() |
0dbad9bd99 | ||
![]() |
2991d2d1f1 | ||
![]() |
a36a56b4ff | ||
![]() |
0e59ab003a | ||
![]() |
d67b71b7ae | ||
![]() |
8859bf8842 | ||
![]() |
4e29342711 | ||
![]() |
8a3790b01f | ||
![]() |
0d245fe4e4 | ||
![]() |
da34c6cb34 | ||
![]() |
9c0e5ba9c2 | ||
![]() |
289c3bc3c1 | ||
![]() |
3adfec0693 | ||
![]() |
137591f458 | ||
![]() |
debd297494 | ||
![]() |
10bb5ef3c0 | ||
![]() |
42e7d1a3fb | ||
![]() |
5fbd2838c9 | ||
![]() |
17dde3a2a9 | ||
![]() |
8d50554849 | ||
![]() |
493eb03345 | ||
![]() |
1beac49f4a | ||
![]() |
f230be5ede | ||
![]() |
6283e7ec83 | ||
![]() |
2438766418 | ||
![]() |
6f2e409fb9 | ||
![]() |
aa459aeb39 | ||
![]() |
9d6e8e6b6f | ||
![]() |
e882e7954c | ||
![]() |
c234463a67 | ||
![]() |
391320a590 | ||
![]() |
8648285375 | ||
![]() |
485c7b72c2 | ||
![]() |
e93cc83d58 | ||
![]() |
39b9f592b6 | ||
![]() |
1f515464fe | ||
![]() |
854d0cbb86 | ||
![]() |
87212a7414 | ||
![]() |
2338035df2 | ||
![]() |
ea132ff88d | ||
![]() |
78c14c05f3 | ||
![]() |
1d2b36e9b0 | ||
![]() |
a929ff84c7 | ||
![]() |
0d5bbc16cf | ||
![]() |
ee1fd5a469 | ||
![]() |
a702f36524 | ||
![]() |
59edc6d369 | ||
![]() |
907b77788d | ||
![]() |
914a3eaba5 | ||
![]() |
b1f048f2ef | ||
![]() |
53d76ad3a2 | ||
![]() |
7af70b92e9 | ||
![]() |
3425eca4ff | ||
![]() |
9e0bf9cd9f | ||
![]() |
3118918098 | ||
![]() |
6a995c822c | ||
![]() |
a09f535e8f | ||
![]() |
a60ac53c87 | ||
![]() |
d2c81bc1d0 | ||
![]() |
3908c6d041 | ||
![]() |
c50e1f9852 | ||
![]() |
6954e03bb4 | ||
![]() |
08eee9309e | ||
![]() |
6ed41b38ed | ||
![]() |
6b521e0b86 | ||
![]() |
1bdc66c75b | ||
![]() |
e30b2ca875 | ||
![]() |
1f3ed58570 | ||
![]() |
6a31b640c1 | ||
![]() |
ed97150311 | ||
![]() |
78eb77f157 | ||
![]() |
f152288d76 | ||
![]() |
492c5072b7 | ||
![]() |
534e251f97 | ||
![]() |
cfcd85a188 | ||
![]() |
fd3b5ebbad | ||
![]() |
1a2d5913eb | ||
![]() |
8f46d89ac0 | ||
![]() |
e82c06cf93 | ||
![]() |
392525571f | ||
![]() |
53927f0490 | ||
![]() |
ede71db11a | ||
![]() |
a2e2b1d512 | ||
![]() |
cff18992ad | ||
![]() |
b2c0b5024c | ||
![]() |
996483de94 | ||
![]() |
f4b7b85b02 | ||
![]() |
b4391d0f79 | ||
![]() |
f49cc1fcf0 | ||
![]() |
18205fbf4a | ||
![]() |
2f6ea71106 | ||
![]() |
7b6ac158cc | ||
![]() |
facf52f117 | ||
![]() |
f36796dd85 | ||
![]() |
0427f8090f | ||
![]() |
da86eaad97 | ||
![]() |
3b05135f11 | ||
![]() |
76afec8adb | ||
![]() |
06da90ac76 | ||
![]() |
7e3caf7f48 | ||
![]() |
e08552eb99 | ||
![]() |
5fb403af4b | ||
![]() |
84acdd5a7f | ||
![]() |
3e6abb7a5e | ||
![]() |
0315f986db | ||
![]() |
7735c7ddd4 | ||
![]() |
239a4c63a2 | ||
![]() |
f5bd5b7751 | ||
![]() |
287b0302d9 | ||
![]() |
44e23aad78 | ||
![]() |
606775f72d | ||
![]() |
9a6308f8d9 | ||
![]() |
0c4db2d99f | ||
![]() |
938970817c | ||
![]() |
d2a1b8e349 | ||
![]() |
4477506345 | ||
![]() |
0787489e1b | ||
![]() |
436757dd55 | ||
![]() |
a0b6d8ec6f | ||
![]() |
b92efcd7b0 | ||
![]() |
3e17b47ec3 | ||
![]() |
31c0788bd9 | ||
![]() |
dec3244758 | ||
![]() |
91e385efa7 | ||
![]() |
13313abb37 | ||
![]() |
79a51dfdce | ||
![]() |
a999ac8f07 | ||
![]() |
a3e3f24d2d | ||
![]() |
b2b85eb548 | ||
![]() |
95c5ebb090 | ||
![]() |
3d0da4f25a | ||
![]() |
bc7bb5076f | ||
![]() |
a80561bfc8 | ||
![]() |
22f86ad76c | ||
![]() |
0ae9cfa42f | ||
![]() |
ff8c4ca8a3 | ||
![]() |
ed4ed4de9d | ||
![]() |
d177b99f3a | ||
![]() |
65de8c4916 | ||
![]() |
178f9d4c51 | ||
![]() |
9433564c5b | ||
![]() |
5deba0c4ba | ||
![]() |
5234d4c7ae | ||
![]() |
1bea28026e | ||
![]() |
9a5c8ff058 | ||
![]() |
2b183c9773 | ||
![]() |
5dee864afd | ||
![]() |
6fdf931515 | ||
![]() |
d126baa443 | ||
![]() |
d1e2d593ff | ||
![]() |
800b6a6bc5 | ||
![]() |
e9bc25cce0 | ||
![]() |
8f7e25f9a1 | ||
![]() |
399def182b | ||
![]() |
f830b2a417 | ||
![]() |
cab1bca6fb | ||
![]() |
5eb7a14a33 |
@@ -19,52 +19,11 @@ jobs:
|
|||||||
name: smoke test jupyterhub
|
name: smoke test jupyterhub
|
||||||
command: |
|
command: |
|
||||||
docker run --rm -it jupyterhub/jupyterhub jupyterhub --help
|
docker run --rm -it jupyterhub/jupyterhub jupyterhub --help
|
||||||
|
|
||||||
docs:
|
|
||||||
# This is the base environment that Circle will use
|
|
||||||
docker:
|
|
||||||
- image: circleci/python:3.6-stretch
|
|
||||||
steps:
|
|
||||||
# Get our data and merge with upstream
|
|
||||||
- run: sudo apt-get update
|
|
||||||
- checkout
|
|
||||||
# Update our path
|
|
||||||
- run: echo "export PATH=~/.local/bin:$PATH" >> $BASH_ENV
|
|
||||||
# Restore cached files to speed things up
|
|
||||||
- restore_cache:
|
|
||||||
keys:
|
|
||||||
- cache-pip
|
|
||||||
# Install the packages needed to build our documentation
|
|
||||||
- run:
|
- run:
|
||||||
name: Install NodeJS
|
name: verify static files
|
||||||
command: |
|
command: |
|
||||||
# From https://github.com/nodesource/distributions/blob/master/README.md#debinstall
|
docker run --rm -it -v $PWD/dockerfiles:/io jupyterhub/jupyterhub python3 /io/test.py
|
||||||
curl -sL https://deb.nodesource.com/setup_13.x | sudo -E bash -
|
|
||||||
sudo apt-get install -y nodejs
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Install dependencies
|
|
||||||
command: |
|
|
||||||
python3 -m pip install --user -r dev-requirements.txt
|
|
||||||
python3 -m pip install --user -r docs/requirements.txt
|
|
||||||
sudo npm install -g configurable-http-proxy
|
|
||||||
sudo python3 -m pip install --editable .
|
|
||||||
|
|
||||||
# Cache some files for a speedup in subsequent builds
|
|
||||||
- save_cache:
|
|
||||||
key: cache-pip
|
|
||||||
paths:
|
|
||||||
- ~/.cache/pip
|
|
||||||
# Build the docs
|
|
||||||
- run:
|
|
||||||
name: Build docs to store
|
|
||||||
command: |
|
|
||||||
cd docs
|
|
||||||
make html
|
|
||||||
# Tell Circle to store the documentation output in a folder that we can access later
|
|
||||||
- store_artifacts:
|
|
||||||
path: docs/build/html/
|
|
||||||
destination: html
|
|
||||||
|
|
||||||
# Tell CircleCI to use this workflow when it builds the site
|
# Tell CircleCI to use this workflow when it builds the site
|
||||||
workflows:
|
workflows:
|
||||||
@@ -72,4 +31,3 @@ workflows:
|
|||||||
default:
|
default:
|
||||||
jobs:
|
jobs:
|
||||||
- build
|
- build
|
||||||
- docs
|
|
||||||
|
39
.github/ISSUE_TEMPLATE/bug_report.md
vendored
39
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
name: Issue 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 search feature to check if your issue has been asked already. If it has, please add your comments to the existing issue.
|
|
||||||
|
|
||||||
Some tips:
|
|
||||||
- Running `jupyter troubleshoot` from the command line, if possible, and posting
|
|
||||||
its output would also be helpful.
|
|
||||||
- Running JupyterHub in `--debug` mode (`jupyterhub --debug`) can also be helpful for troubleshooting.
|
|
||||||
--->
|
|
||||||
|
|
||||||
**Describe the bug**
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
<!---Add description here--->
|
|
||||||
|
|
||||||
**To Reproduce**
|
|
||||||
<!---
|
|
||||||
Please share the 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.
|
|
||||||
--->
|
|
||||||
|
|
||||||
**Compute Information**
|
|
||||||
- Operating System
|
|
||||||
- JupyterHub Version [e.g. 22]
|
|
@@ -1,22 +0,0 @@
|
|||||||
---
|
|
||||||
name: Installation and configuration questions
|
|
||||||
about: Installation and configuration assistance
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<!---
|
|
||||||
If you are reading this message, you have probably already searched the existing
|
|
||||||
GitHub issues for JupyterHub. If you haven't tried a search, we encourage you to do so.
|
|
||||||
|
|
||||||
If you are unsure where to ask your question (Jupyter, JupyterHub, JupyterLab, etc.),
|
|
||||||
please ask on our [Discourse Q&A channel](https://discourse.jupyter.org/c/questions).
|
|
||||||
|
|
||||||
If you have a quick question about JupyterHub installation or configuratation, you
|
|
||||||
may ask on the [JupyterHub gitter channel](https://gitter.im/jupyterhub/jupyterhub).
|
|
||||||
|
|
||||||
:sunny: Please be patient. We are volunteers and will address your question when we are able. :sunny:
|
|
||||||
|
|
||||||
If after trying the above steps, you still have an in-depth installation or
|
|
||||||
configuration question, such as a possible bug, please file an issue below and include
|
|
||||||
any relevant details.
|
|
||||||
--->
|
|
225
.github/workflows/test.yml
vendored
Normal file
225
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
||||||
|
# ref: https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions
|
||||||
|
#
|
||||||
|
name: Run tests
|
||||||
|
|
||||||
|
# Trigger the workflow's on all PRs but only on pushed tags or commits to
|
||||||
|
# main/master branch to avoid PRs developed in a GitHub fork's dedicated branch
|
||||||
|
# to trigger.
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
# Declare bash be used by default in this workflow's "run" steps.
|
||||||
|
#
|
||||||
|
# NOTE: bash will by default run with:
|
||||||
|
# --noprofile: Ignore ~/.profile etc.
|
||||||
|
# --norc: Ignore ~/.bashrc etc.
|
||||||
|
# -e: Exit directly on errors
|
||||||
|
# -o pipefail: Don't mask errors from a command piped into another command
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
env:
|
||||||
|
# UTF-8 content may be interpreted as ascii and causes errors without this.
|
||||||
|
LANG: C.UTF-8
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Run "pre-commit run --all-files"
|
||||||
|
pre-commit:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
timeout-minutes: 2
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: 3.8
|
||||||
|
|
||||||
|
# ref: https://github.com/pre-commit/action
|
||||||
|
- uses: pre-commit/action@v2.0.0
|
||||||
|
- name: Help message if pre-commit fail
|
||||||
|
if: ${{ failure() }}
|
||||||
|
run: |
|
||||||
|
echo "You can install pre-commit hooks to automatically run formatting"
|
||||||
|
echo "on each commit with:"
|
||||||
|
echo " pre-commit install"
|
||||||
|
echo "or you can run by hand on staged files with"
|
||||||
|
echo " pre-commit run"
|
||||||
|
echo "or after-the-fact on already committed files with"
|
||||||
|
echo " pre-commit run --all-files"
|
||||||
|
|
||||||
|
|
||||||
|
# Run "pytest jupyterhub/tests" in various configurations
|
||||||
|
pytest:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
# Keep running even if one variation of the job fail
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
# We run this job multiple times with different parameterization
|
||||||
|
# specified below, these parameters have no meaning on their own and
|
||||||
|
# gain meaning on how job steps use them.
|
||||||
|
#
|
||||||
|
# subdomain:
|
||||||
|
# Tests everything when JupyterHub is configured to add routes for
|
||||||
|
# users with dedicated subdomains like user1.jupyter.example.com
|
||||||
|
# rather than jupyter.example.com/user/user1.
|
||||||
|
#
|
||||||
|
# db: [mysql/postgres]
|
||||||
|
# Tests everything when JupyterHub works against a dedicated mysql or
|
||||||
|
# postgresql server.
|
||||||
|
#
|
||||||
|
# jupyter_server:
|
||||||
|
# Tests everything when the user instances are started with
|
||||||
|
# jupyter_server instead of notebook.
|
||||||
|
#
|
||||||
|
# ssl:
|
||||||
|
# Tests everything using internal SSL connections instead of
|
||||||
|
# unencrypted HTTP
|
||||||
|
#
|
||||||
|
# main_dependencies:
|
||||||
|
# Tests everything when the we use the latest available dependencies
|
||||||
|
# from: ipytraitlets.
|
||||||
|
#
|
||||||
|
# NOTE: Since only the value of these parameters are presented in the
|
||||||
|
# GitHub UI when the workflow run, we avoid using true/false as
|
||||||
|
# values by instead duplicating the name to signal true.
|
||||||
|
include:
|
||||||
|
- python: "3.6"
|
||||||
|
oldest_dependencies: oldest_dependencies
|
||||||
|
- python: "3.6"
|
||||||
|
subdomain: subdomain
|
||||||
|
- python: "3.7"
|
||||||
|
db: mysql
|
||||||
|
- python: "3.7"
|
||||||
|
ssl: ssl
|
||||||
|
- python: "3.8"
|
||||||
|
db: postgres
|
||||||
|
- python: "3.8"
|
||||||
|
jupyter_server: jupyter_server
|
||||||
|
- python: "3.9"
|
||||||
|
main_dependencies: main_dependencies
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# NOTE: In GitHub workflows, environment variables are set by writing
|
||||||
|
# assignment statements to a file. They will be set in the following
|
||||||
|
# steps as if would used `export MY_ENV=my-value`.
|
||||||
|
- name: Configure environment variables
|
||||||
|
run: |
|
||||||
|
if [ "${{ matrix.subdomain }}" != "" ]; then
|
||||||
|
echo "JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://localhost.jovyan.org:8000" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
if [ "${{ matrix.db }}" == "mysql" ]; then
|
||||||
|
echo "MYSQL_HOST=127.0.0.1" >> $GITHUB_ENV
|
||||||
|
echo "JUPYTERHUB_TEST_DB_URL=mysql+mysqlconnector://root@127.0.0.1:3306/jupyterhub" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
if [ "${{ matrix.ssl }}" == "ssl" ]; then
|
||||||
|
echo "SSL_ENABLED=1" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
if [ "${{ matrix.db }}" == "postgres" ]; then
|
||||||
|
echo "PGHOST=127.0.0.1" >> $GITHUB_ENV
|
||||||
|
echo "PGUSER=test_user" >> $GITHUB_ENV
|
||||||
|
echo "PGPASSWORD=hub[test/:?" >> $GITHUB_ENV
|
||||||
|
echo "JUPYTERHUB_TEST_DB_URL=postgresql://test_user:hub%5Btest%2F%3A%3F@127.0.0.1:5432/jupyterhub" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
if [ "${{ matrix.jupyter_server }}" != "" ]; then
|
||||||
|
echo "JUPYTERHUB_SINGLEUSER_APP=jupyterhub.tests.mockserverapp.MockServerApp" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
|
# NOTE: actions/setup-node@v1 make use of a cache within the GitHub base
|
||||||
|
# environment and setup in a fraction of a second.
|
||||||
|
- name: Install Node v14
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: "14"
|
||||||
|
- name: Install Node dependencies
|
||||||
|
run: |
|
||||||
|
npm install
|
||||||
|
npm install -g configurable-http-proxy
|
||||||
|
npm list
|
||||||
|
|
||||||
|
# NOTE: actions/setup-python@v2 make use of a cache within the GitHub base
|
||||||
|
# environment and setup in a fraction of a second.
|
||||||
|
- name: Install Python ${{ matrix.python }}
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python }}
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install --upgrade . -r dev-requirements.txt
|
||||||
|
|
||||||
|
if [ "${{ matrix.oldest_dependencies }}" != "" ]; then
|
||||||
|
# take any dependencies in requirements.txt such as tornado>=5.0
|
||||||
|
# and transform them to tornado==5.0 so we can run tests with
|
||||||
|
# the earliest-supported versions
|
||||||
|
cat requirements.txt | grep '>=' | sed -e 's@>=@==@g' > oldest-requirements.txt
|
||||||
|
pip install -r oldest-requirements.txt
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${{ matrix.main_dependencies }}" != "" ]; then
|
||||||
|
pip install git+https://github.com/ipython/traitlets#egg=traitlets --force
|
||||||
|
fi
|
||||||
|
if [ "${{ matrix.jupyter_server }}" != "" ]; then
|
||||||
|
pip uninstall notebook --yes
|
||||||
|
pip install jupyter_server
|
||||||
|
fi
|
||||||
|
if [ "${{ matrix.db }}" == "mysql" ]; then
|
||||||
|
pip install mysql-connector-python
|
||||||
|
fi
|
||||||
|
if [ "${{ matrix.db }}" == "postgres" ]; then
|
||||||
|
pip install psycopg2-binary
|
||||||
|
fi
|
||||||
|
|
||||||
|
pip freeze
|
||||||
|
|
||||||
|
# NOTE: If you need to debug this DB setup step, consider the following.
|
||||||
|
#
|
||||||
|
# 1. mysql/postgressql are database servers we start as docker containers,
|
||||||
|
# and we use clients named mysql/psql.
|
||||||
|
#
|
||||||
|
# 2. When we start a database server we need to pass environment variables
|
||||||
|
# explicitly as part of the `docker run` command. These environment
|
||||||
|
# variables are named differently from the similarly named environment
|
||||||
|
# variables used by the clients.
|
||||||
|
#
|
||||||
|
# - mysql server ref: https://hub.docker.com/_/mysql/
|
||||||
|
# - mysql client ref: https://dev.mysql.com/doc/refman/5.7/en/environment-variables.html
|
||||||
|
# - postgres server ref: https://hub.docker.com/_/postgres/
|
||||||
|
# - psql client ref: https://www.postgresql.org/docs/9.5/libpq-envars.html
|
||||||
|
#
|
||||||
|
# 3. When we connect, they should use 127.0.0.1 rather than the
|
||||||
|
# default way of connecting which leads to errors like below both for
|
||||||
|
# mysql and postgresql unless we set MYSQL_HOST/PGHOST to 127.0.0.1.
|
||||||
|
#
|
||||||
|
# - ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/var/run/mysqld/mysqld.sock' (2)
|
||||||
|
#
|
||||||
|
- name: Start a database server (${{ matrix.db }})
|
||||||
|
if: ${{ matrix.db }}
|
||||||
|
run: |
|
||||||
|
if [ "${{ matrix.db }}" == "mysql" ]; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y mysql-client
|
||||||
|
DB=mysql bash ci/docker-db.sh
|
||||||
|
DB=mysql bash ci/init-db.sh
|
||||||
|
fi
|
||||||
|
if [ "${{ matrix.db }}" == "postgres" ]; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y postgresql-client
|
||||||
|
DB=postgres bash ci/docker-db.sh
|
||||||
|
DB=postgres bash ci/init-db.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run pytest
|
||||||
|
# FIXME: --color=yes explicitly set because:
|
||||||
|
# https://github.com/actions/runner/issues/241
|
||||||
|
run: |
|
||||||
|
pytest -v --maxfail=2 --color=yes --cov=jupyterhub jupyterhub/tests
|
||||||
|
- name: Submit codecov report
|
||||||
|
run: |
|
||||||
|
codecov
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,5 +24,8 @@ MANIFEST
|
|||||||
.coverage.*
|
.coverage.*
|
||||||
htmlcov
|
htmlcov
|
||||||
.idea/
|
.idea/
|
||||||
|
.vscode/
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
pip-wheel-metadata
|
pip-wheel-metadata
|
||||||
|
docs/source/reference/metrics.rst
|
||||||
|
oldest-requirements.txt
|
||||||
|
@@ -1,11 +1,10 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/asottile/reorder_python_imports
|
- repo: https://github.com/asottile/reorder_python_imports
|
||||||
rev: v1.8.0
|
rev: v1.9.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: reorder-python-imports
|
- id: reorder-python-imports
|
||||||
language_version: python3.6
|
- repo: https://github.com/psf/black
|
||||||
- repo: https://github.com/ambv/black
|
rev: 20.8b1
|
||||||
rev: 19.10b0
|
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
94
.travis.yml
94
.travis.yml
@@ -1,94 +0,0 @@
|
|||||||
dist: bionic
|
|
||||||
language: python
|
|
||||||
cache:
|
|
||||||
- pip
|
|
||||||
env:
|
|
||||||
global:
|
|
||||||
- MYSQL_HOST=127.0.0.1
|
|
||||||
- MYSQL_TCP_PORT=13306
|
|
||||||
|
|
||||||
# request additional services for the jobs to access
|
|
||||||
services:
|
|
||||||
- postgresql
|
|
||||||
- docker
|
|
||||||
|
|
||||||
# install dependencies for running pytest (but not linting)
|
|
||||||
before_install:
|
|
||||||
- set -e
|
|
||||||
- nvm install 6; nvm use 6
|
|
||||||
- npm install
|
|
||||||
- npm install -g configurable-http-proxy
|
|
||||||
- |
|
|
||||||
# setup database
|
|
||||||
if [[ $JUPYTERHUB_TEST_DB_URL == mysql* ]]; then
|
|
||||||
unset MYSQL_UNIX_PORT
|
|
||||||
DB=mysql bash ci/docker-db.sh
|
|
||||||
DB=mysql bash ci/init-db.sh
|
|
||||||
# FIXME: mysql-connector-python 8.0.16 incorrectly decodes bytes to str
|
|
||||||
# ref: https://bugs.mysql.com/bug.php?id=94944
|
|
||||||
pip install 'mysql-connector-python==8.0.11'
|
|
||||||
elif [[ $JUPYTERHUB_TEST_DB_URL == postgresql* ]]; then
|
|
||||||
psql -c "CREATE USER $PGUSER WITH PASSWORD '$PGPASSWORD';" -U postgres
|
|
||||||
DB=postgres bash ci/init-db.sh
|
|
||||||
pip install psycopg2-binary
|
|
||||||
fi
|
|
||||||
|
|
||||||
# install general dependencies
|
|
||||||
install:
|
|
||||||
- pip install --upgrade pip
|
|
||||||
- pip install --upgrade --pre -r dev-requirements.txt .
|
|
||||||
- pip freeze
|
|
||||||
|
|
||||||
# run tests
|
|
||||||
script:
|
|
||||||
- pytest -v --maxfail=2 --cov=jupyterhub jupyterhub/tests
|
|
||||||
|
|
||||||
# collect test coverage information
|
|
||||||
after_success:
|
|
||||||
- codecov
|
|
||||||
|
|
||||||
# list the jobs
|
|
||||||
jobs:
|
|
||||||
include:
|
|
||||||
- name: autoformatting check
|
|
||||||
python: 3.6
|
|
||||||
# NOTE: It does not suffice to override to: null, [], or [""]. Travis will
|
|
||||||
# fall back to the default if we do.
|
|
||||||
before_install: echo "Do nothing before install."
|
|
||||||
script:
|
|
||||||
- pre-commit run --all-files
|
|
||||||
after_success: echo "Do nothing after success."
|
|
||||||
after_failure:
|
|
||||||
- |
|
|
||||||
echo "You can install pre-commit hooks to automatically run formatting"
|
|
||||||
echo "on each commit with:"
|
|
||||||
echo " pre-commit install"
|
|
||||||
echo "or you can run by hand on staged files with"
|
|
||||||
echo " pre-commit run"
|
|
||||||
echo "or after-the-fact on already committed files with"
|
|
||||||
echo " pre-commit run --all-files"
|
|
||||||
# When we run pytest, we want to run it with python>=3.5 as well as with
|
|
||||||
# various configurations. We increment the python version at the same time
|
|
||||||
# as we test new configurations in order to reduce the number of test jobs.
|
|
||||||
- name: python:3.5 + dist:xenial
|
|
||||||
python: 3.5
|
|
||||||
dist: xenial
|
|
||||||
- name: python:3.6 + subdomain
|
|
||||||
python: 3.6
|
|
||||||
env: JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://localhost.jovyan.org:8000
|
|
||||||
- name: python:3.7 + mysql
|
|
||||||
python: 3.7
|
|
||||||
env:
|
|
||||||
- JUPYTERHUB_TEST_DB_URL=mysql+mysqlconnector://root@127.0.0.1:$MYSQL_TCP_PORT/jupyterhub
|
|
||||||
- name: python:3.8 + postgresql
|
|
||||||
python: 3.8
|
|
||||||
env:
|
|
||||||
- PGUSER=jupyterhub
|
|
||||||
- PGPASSWORD=hub[test/:?
|
|
||||||
# The password in url below is url-encoded with: urllib.parse.quote($PGPASSWORD, safe='')
|
|
||||||
- JUPYTERHUB_TEST_DB_URL=postgresql://jupyterhub:hub%5Btest%2F%3A%3F@127.0.0.1/jupyterhub
|
|
||||||
- name: python:nightly
|
|
||||||
python: nightly
|
|
||||||
allow_failures:
|
|
||||||
- name: python:nightly
|
|
||||||
fast_finish: true
|
|
@@ -1,13 +1,19 @@
|
|||||||
# Contributing to JupyterHub
|
# Contributing to JupyterHub
|
||||||
|
|
||||||
Welcome! As a [Jupyter](https://jupyter.org) project,
|
Welcome! As a [Jupyter](https://jupyter.org) project,
|
||||||
you can follow the [Jupyter contributor guide](https://jupyter.readthedocs.io/en/latest/contributor/content-contributor.html).
|
you can follow the [Jupyter contributor guide](https://jupyter.readthedocs.io/en/latest/contributing/content-contributor.html).
|
||||||
|
|
||||||
Make sure to also follow [Project Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/master/conduct/code_of_conduct.md)
|
Make sure to also follow [Project Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/master/conduct/code_of_conduct.md)
|
||||||
for a friendly and welcoming collaborative environment.
|
for a friendly and welcoming collaborative environment.
|
||||||
|
|
||||||
## Setting up a development environment
|
## Setting up a development environment
|
||||||
|
|
||||||
|
<!--
|
||||||
|
https://jupyterhub.readthedocs.io/en/stable/contributing/setup.html
|
||||||
|
contains a lot of the same information. Should we merge the docs and
|
||||||
|
just have this page link to that one?
|
||||||
|
-->
|
||||||
|
|
||||||
JupyterHub requires Python >= 3.5 and nodejs.
|
JupyterHub requires Python >= 3.5 and nodejs.
|
||||||
|
|
||||||
As a Python project, a development install of JupyterHub follows standard practices for the basics (steps 1-2).
|
As a Python project, a development install of JupyterHub follows standard practices for the basics (steps 1-2).
|
||||||
@@ -60,7 +66,7 @@ pre-commit run
|
|||||||
|
|
||||||
which should run any autoformatting on your code
|
which should run any autoformatting on your code
|
||||||
and tell you about any errors it couldn't fix automatically.
|
and tell you about any errors it couldn't fix automatically.
|
||||||
You may also install [black integration](https://github.com/ambv/black#editor-integration)
|
You may also install [black integration](https://github.com/psf/black#editor-integration)
|
||||||
into your text editor to format code automatically.
|
into your text editor to format code automatically.
|
||||||
|
|
||||||
If you have already committed files before setting up the pre-commit
|
If you have already committed files before setting up the pre-commit
|
||||||
@@ -128,4 +134,4 @@ To read more about fixtures check out the
|
|||||||
[pytest docs](https://docs.pytest.org/en/latest/fixture.html)
|
[pytest docs](https://docs.pytest.org/en/latest/fixture.html)
|
||||||
for how to use the existing fixtures, and how to create new ones.
|
for how to use the existing fixtures, and how to create new ones.
|
||||||
|
|
||||||
When in doubt, feel free to ask.
|
When in doubt, feel free to [ask](https://gitter.im/jupyterhub/jupyterhub).
|
||||||
|
27
Dockerfile
27
Dockerfile
@@ -21,8 +21,7 @@
|
|||||||
# your jupyterhub_config.py will be added automatically
|
# your jupyterhub_config.py will be added automatically
|
||||||
# from your docker directory.
|
# from your docker directory.
|
||||||
|
|
||||||
# https://github.com/tianon/docker-brew-ubuntu-core/commit/d4313e13366d24a97bd178db4450f63e221803f1
|
ARG BASE_IMAGE=ubuntu:focal-20200729@sha256:6f2fb2f9fb5582f8b587837afd6ea8f37d8d1d9e41168c90f410a6ef15fa8ce5
|
||||||
ARG BASE_IMAGE=ubuntu:bionic-20191029@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d
|
|
||||||
FROM $BASE_IMAGE AS builder
|
FROM $BASE_IMAGE AS builder
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
@@ -41,19 +40,18 @@ RUN apt-get update \
|
|||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# copy only what we need to avoid unnecessary rebuilds
|
|
||||||
COPY package.json \
|
|
||||||
pyproject.toml \
|
|
||||||
README.md \
|
|
||||||
requirements.txt \
|
|
||||||
setup.py \
|
|
||||||
/src/jupyterhub/
|
|
||||||
COPY jupyterhub/ /src/jupyterhub/jupyterhub
|
|
||||||
COPY share/ /src/jupyterhub/share
|
|
||||||
|
|
||||||
WORKDIR /src/jupyterhub
|
|
||||||
RUN python3 -m pip install --upgrade setuptools pip wheel
|
RUN python3 -m pip install --upgrade setuptools pip wheel
|
||||||
RUN python3 -m pip wheel -v --wheel-dir wheelhouse .
|
|
||||||
|
# copy everything except whats in .dockerignore, its a
|
||||||
|
# compromise between needing to rebuild and maintaining
|
||||||
|
# what needs to be part of the build
|
||||||
|
COPY . /src/jupyterhub/
|
||||||
|
WORKDIR /src/jupyterhub
|
||||||
|
|
||||||
|
# Build client component packages (they will be copied into ./share and
|
||||||
|
# packaged with the built wheel.)
|
||||||
|
RUN python3 setup.py bdist_wheel
|
||||||
|
RUN python3 -m pip wheel --wheel-dir wheelhouse dist/*.whl
|
||||||
|
|
||||||
|
|
||||||
FROM $BASE_IMAGE
|
FROM $BASE_IMAGE
|
||||||
@@ -90,7 +88,6 @@ RUN npm install -g configurable-http-proxy@^4.2.0 \
|
|||||||
|
|
||||||
# install the wheels we built in the first stage
|
# install the wheels we built in the first stage
|
||||||
COPY --from=builder /src/jupyterhub/wheelhouse /tmp/wheelhouse
|
COPY --from=builder /src/jupyterhub/wheelhouse /tmp/wheelhouse
|
||||||
COPY --from=builder /src/jupyterhub/share /src/jupyterhub/share
|
|
||||||
RUN python3 -m pip install --no-cache /tmp/wheelhouse/*
|
RUN python3 -m pip install --no-cache /tmp/wheelhouse/*
|
||||||
|
|
||||||
RUN mkdir -p /srv/jupyterhub/
|
RUN mkdir -p /srv/jupyterhub/
|
||||||
|
@@ -13,7 +13,7 @@
|
|||||||
[](https://pypi.python.org/pypi/jupyterhub)
|
[](https://pypi.python.org/pypi/jupyterhub)
|
||||||
[](https://www.npmjs.com/package/jupyterhub)
|
[](https://www.npmjs.com/package/jupyterhub)
|
||||||
[](https://jupyterhub.readthedocs.org/en/latest/)
|
[](https://jupyterhub.readthedocs.org/en/latest/)
|
||||||
[](https://travis-ci.org/jupyterhub/jupyterhub)
|
[](https://travis-ci.com/jupyterhub/jupyterhub)
|
||||||
[](https://hub.docker.com/r/jupyterhub/jupyterhub/tags)
|
[](https://hub.docker.com/r/jupyterhub/jupyterhub/tags)
|
||||||
[](https://circleci.com/gh/jupyterhub/jupyterhub)<!-- CircleCI Token: b5b65862eb2617b9a8d39e79340b0a6b816da8cc -->
|
[](https://circleci.com/gh/jupyterhub/jupyterhub)<!-- CircleCI Token: b5b65862eb2617b9a8d39e79340b0a6b816da8cc -->
|
||||||
[](https://codecov.io/gh/jupyterhub/jupyterhub)
|
[](https://codecov.io/gh/jupyterhub/jupyterhub)
|
||||||
@@ -74,6 +74,7 @@ for administration of the Hub and its users.
|
|||||||
The `nodejs-legacy` package installs the `node` executable and is currently
|
The `nodejs-legacy` package installs the `node` executable and is currently
|
||||||
required for npm to work on Debian/Ubuntu.
|
required for npm to work on Debian/Ubuntu.
|
||||||
|
|
||||||
|
- If using the default PAM Authenticator, a [pluggable authentication module (PAM)](https://en.wikipedia.org/wiki/Pluggable_authentication_module).
|
||||||
- TLS certificate and key for HTTPS communication
|
- TLS certificate and key for HTTPS communication
|
||||||
- Domain name
|
- Domain name
|
||||||
|
|
||||||
|
@@ -1,59 +1,60 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# source this file to setup postgres and mysql
|
# The goal of this script is to start a database server as a docker container.
|
||||||
# for local testing (as similar as possible to docker)
|
#
|
||||||
|
# Required environment variables:
|
||||||
|
# - DB: The database server to start, either "postgres" or "mysql".
|
||||||
|
#
|
||||||
|
# - PGUSER/PGPASSWORD: For the creation of a postgresql user with associated
|
||||||
|
# password.
|
||||||
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
export MYSQL_HOST=127.0.0.1
|
# Stop and remove any existing database container
|
||||||
export MYSQL_TCP_PORT=${MYSQL_TCP_PORT:-13306}
|
DOCKER_CONTAINER="hub-test-$DB"
|
||||||
export PGHOST=127.0.0.1
|
docker rm -f "$DOCKER_CONTAINER" 2>/dev/null || true
|
||||||
NAME="hub-test-$DB"
|
|
||||||
DOCKER_RUN="docker run -d --name $NAME"
|
|
||||||
|
|
||||||
docker rm -f "$NAME" 2>/dev/null || true
|
# Prepare environment variables to startup and await readiness of either a mysql
|
||||||
|
# or postgresql server.
|
||||||
|
if [[ "$DB" == "mysql" ]]; then
|
||||||
|
# Environment variables can influence both the mysql server in the docker
|
||||||
|
# container and the mysql client.
|
||||||
|
#
|
||||||
|
# ref server: https://hub.docker.com/_/mysql/
|
||||||
|
# ref client: https://dev.mysql.com/doc/refman/5.7/en/setting-environment-variables.html
|
||||||
|
#
|
||||||
|
DOCKER_RUN_ARGS="-p 3306:3306 --env MYSQL_ALLOW_EMPTY_PASSWORD=1 mysql:5.7"
|
||||||
|
READINESS_CHECK="mysql --user root --execute \q"
|
||||||
|
elif [[ "$DB" == "postgres" ]]; then
|
||||||
|
# Environment variables can influence both the postgresql server in the
|
||||||
|
# docker container and the postgresql client (psql).
|
||||||
|
#
|
||||||
|
# ref server: https://hub.docker.com/_/postgres/
|
||||||
|
# ref client: https://www.postgresql.org/docs/9.5/libpq-envars.html
|
||||||
|
#
|
||||||
|
# POSTGRES_USER / POSTGRES_PASSWORD will create a user on startup of the
|
||||||
|
# postgres server, but PGUSER and PGPASSWORD are the environment variables
|
||||||
|
# used by the postgresql client psql, so we configure the user based on how
|
||||||
|
# we want to connect.
|
||||||
|
#
|
||||||
|
DOCKER_RUN_ARGS="-p 5432:5432 --env "POSTGRES_USER=${PGUSER}" --env "POSTGRES_PASSWORD=${PGPASSWORD}" postgres:9.5"
|
||||||
|
READINESS_CHECK="psql --command \q"
|
||||||
|
else
|
||||||
|
echo '$DB must be mysql or postgres'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
case "$DB" in
|
# Start the database server
|
||||||
"mysql")
|
docker run --detach --name "$DOCKER_CONTAINER" $DOCKER_RUN_ARGS
|
||||||
RUN_ARGS="-e MYSQL_ALLOW_EMPTY_PASSWORD=1 -p $MYSQL_TCP_PORT:3306 mysql:5.7"
|
|
||||||
CHECK="mysql --host $MYSQL_HOST --port $MYSQL_TCP_PORT --user root -e \q"
|
|
||||||
;;
|
|
||||||
"postgres")
|
|
||||||
RUN_ARGS="-p 5432:5432 postgres:9.5"
|
|
||||||
CHECK="psql --user postgres -c \q"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo '$DB must be mysql or postgres'
|
|
||||||
exit 1
|
|
||||||
esac
|
|
||||||
|
|
||||||
$DOCKER_RUN $RUN_ARGS
|
|
||||||
|
|
||||||
|
# Wait for the database server to start
|
||||||
echo -n "waiting for $DB "
|
echo -n "waiting for $DB "
|
||||||
for i in {1..60}; do
|
for i in {1..60}; do
|
||||||
if $CHECK; then
|
if $READINESS_CHECK; then
|
||||||
echo 'done'
|
echo 'done'
|
||||||
break
|
break
|
||||||
else
|
else
|
||||||
echo -n '.'
|
echo -n '.'
|
||||||
sleep 1
|
sleep 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
$CHECK
|
$READINESS_CHECK
|
||||||
|
|
||||||
case "$DB" in
|
|
||||||
"mysql")
|
|
||||||
;;
|
|
||||||
"postgres")
|
|
||||||
# create the user
|
|
||||||
psql --user postgres -c "CREATE USER $PGUSER WITH PASSWORD '$PGPASSWORD';"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
esac
|
|
||||||
|
|
||||||
echo -e "
|
|
||||||
Set these environment variables:
|
|
||||||
|
|
||||||
export MYSQL_HOST=127.0.0.1
|
|
||||||
export MYSQL_TCP_PORT=$MYSQL_TCP_PORT
|
|
||||||
export PGHOST=127.0.0.1
|
|
||||||
"
|
|
||||||
|
@@ -1,27 +1,26 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# initialize jupyterhub databases for testing
|
# The goal of this script is to initialize a running database server with clean
|
||||||
|
# databases for use during tests.
|
||||||
|
#
|
||||||
|
# Required environment variables:
|
||||||
|
# - DB: The database server to start, either "postgres" or "mysql".
|
||||||
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
MYSQL="mysql --user root --host $MYSQL_HOST --port $MYSQL_TCP_PORT -e "
|
# Prepare env vars SQL_CLIENT and EXTRA_CREATE_DATABASE_ARGS
|
||||||
PSQL="psql --user postgres -c "
|
if [[ "$DB" == "mysql" ]]; then
|
||||||
|
SQL_CLIENT="mysql --user root --execute "
|
||||||
case "$DB" in
|
EXTRA_CREATE_DATABASE_ARGS='CHARACTER SET utf8 COLLATE utf8_general_ci'
|
||||||
"mysql")
|
elif [[ "$DB" == "postgres" ]]; then
|
||||||
EXTRA_CREATE='CHARACTER SET utf8 COLLATE utf8_general_ci'
|
SQL_CLIENT="psql --command "
|
||||||
SQL="$MYSQL"
|
else
|
||||||
;;
|
echo '$DB must be mysql or postgres'
|
||||||
"postgres")
|
exit 1
|
||||||
SQL="$PSQL"
|
fi
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo '$DB must be mysql or postgres'
|
|
||||||
exit 1
|
|
||||||
esac
|
|
||||||
|
|
||||||
|
# Configure a set of databases in the database server for upgrade tests
|
||||||
set -x
|
set -x
|
||||||
|
|
||||||
for SUFFIX in '' _upgrade_072 _upgrade_081 _upgrade_094; do
|
for SUFFIX in '' _upgrade_072 _upgrade_081 _upgrade_094; do
|
||||||
$SQL "DROP DATABASE jupyterhub${SUFFIX};" 2>/dev/null || true
|
$SQL_CLIENT "DROP DATABASE jupyterhub${SUFFIX};" 2>/dev/null || true
|
||||||
$SQL "CREATE DATABASE jupyterhub${SUFFIX} ${EXTRA_CREATE:-};"
|
$SQL_CLIENT "CREATE DATABASE jupyterhub${SUFFIX} ${EXTRA_CREATE_DATABASE_ARGS:-};"
|
||||||
done
|
done
|
||||||
|
16
demo-image/Dockerfile
Normal file
16
demo-image/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Demo JupyterHub Docker image
|
||||||
|
#
|
||||||
|
# This should only be used for demo or testing and not as a base image to build on.
|
||||||
|
#
|
||||||
|
# It includes the notebook package and it uses the DummyAuthenticator and the SimpleLocalProcessSpawner.
|
||||||
|
ARG BASE_IMAGE=jupyterhub/jupyterhub-onbuild
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
|
||||||
|
# Install the notebook package
|
||||||
|
RUN python3 -m pip install notebook
|
||||||
|
|
||||||
|
# Create a demo user
|
||||||
|
RUN useradd --create-home demo
|
||||||
|
RUN chown demo .
|
||||||
|
|
||||||
|
USER demo
|
25
demo-image/README.md
Normal file
25
demo-image/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
## Demo Dockerfile
|
||||||
|
|
||||||
|
This is a demo JupyterHub Docker image to help you get a quick overview of what
|
||||||
|
JupyterHub is and how it works.
|
||||||
|
|
||||||
|
It uses the SimpleLocalProcessSpawner to spawn new user servers and
|
||||||
|
DummyAuthenticator for authentication.
|
||||||
|
The DummyAuthenticator allows you to log in with any username & password and the
|
||||||
|
SimpleLocalProcessSpawner allows starting servers without having to create a
|
||||||
|
local user for each JupyterHub user.
|
||||||
|
|
||||||
|
### Important!
|
||||||
|
|
||||||
|
This should only be used for demo or testing purposes!
|
||||||
|
It shouldn't be used as a base image to build on.
|
||||||
|
|
||||||
|
### Try it
|
||||||
|
1. `cd` to the root of your jupyterhub repo.
|
||||||
|
|
||||||
|
2. Build the demo image with `docker build -t jupyterhub-demo demo-image`.
|
||||||
|
|
||||||
|
3. Run the demo image with `docker run -d -p 8000:8000 jupyterhub-demo`.
|
||||||
|
|
||||||
|
4. Visit http://localhost:8000 and login with any username and password
|
||||||
|
5. Happy demo-ing :tada:!
|
7
demo-image/jupyterhub_config.py
Normal file
7
demo-image/jupyterhub_config.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Configuration file for jupyterhub-demo
|
||||||
|
|
||||||
|
c = get_config()
|
||||||
|
|
||||||
|
# Use DummyAuthenticator and SimpleSpawner
|
||||||
|
c.JupyterHub.spawner_class = "simple"
|
||||||
|
c.JupyterHub.authenticator_class = "dummy"
|
9
dockerfiles/test.py
Normal file
9
dockerfiles/test.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from jupyterhub._data import DATA_FILES_PATH
|
||||||
|
|
||||||
|
print(f"DATA_FILES_PATH={DATA_FILES_PATH}")
|
||||||
|
|
||||||
|
for sub_path in ("templates", "static/components", "static/css/style.min.css"):
|
||||||
|
path = os.path.join(DATA_FILES_PATH, sub_path)
|
||||||
|
assert os.path.exists(path), path
|
@@ -48,6 +48,7 @@ help:
|
|||||||
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
|
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
|
||||||
@echo " coverage to run coverage check of the documentation (if enabled)"
|
@echo " coverage to run coverage check of the documentation (if enabled)"
|
||||||
@echo " spelling to run spell check on documentation"
|
@echo " spelling to run spell check on documentation"
|
||||||
|
@echo " metrics to generate documentation for metrics by inspecting the source code"
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(BUILDDIR)/*
|
rm -rf $(BUILDDIR)/*
|
||||||
@@ -60,7 +61,12 @@ rest-api: source/_static/rest-api/index.html
|
|||||||
source/_static/rest-api/index.html: rest-api.yml node_modules
|
source/_static/rest-api/index.html: rest-api.yml node_modules
|
||||||
npm run rest-api
|
npm run rest-api
|
||||||
|
|
||||||
html: rest-api
|
metrics: source/reference/metrics.rst
|
||||||
|
|
||||||
|
source/reference/metrics.rst: generate-metrics.py
|
||||||
|
python3 generate-metrics.py
|
||||||
|
|
||||||
|
html: rest-api metrics
|
||||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||||
@echo
|
@echo
|
||||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||||
|
@@ -1,20 +0,0 @@
|
|||||||
# 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
|
|
||||||
channels:
|
|
||||||
- conda-forge
|
|
||||||
dependencies:
|
|
||||||
- pip
|
|
||||||
- nodejs
|
|
||||||
- python=3.6
|
|
||||||
- alembic
|
|
||||||
- jinja2
|
|
||||||
- pamela
|
|
||||||
- recommonmark==0.6.0
|
|
||||||
- requests
|
|
||||||
- sqlalchemy>=1
|
|
||||||
- tornado>=5.0
|
|
||||||
- traitlets>=4.1
|
|
||||||
- sphinx>=1.7
|
|
||||||
- pip:
|
|
||||||
- -r requirements.txt
|
|
57
docs/generate-metrics.py
Normal file
57
docs/generate-metrics.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import os
|
||||||
|
from os.path import join
|
||||||
|
|
||||||
|
from pytablewriter import RstSimpleTableWriter
|
||||||
|
from pytablewriter.style import Style
|
||||||
|
|
||||||
|
import jupyterhub.metrics
|
||||||
|
|
||||||
|
HERE = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
class Generator:
|
||||||
|
@classmethod
|
||||||
|
def create_writer(cls, table_name, headers, values):
|
||||||
|
writer = RstSimpleTableWriter()
|
||||||
|
writer.table_name = table_name
|
||||||
|
writer.headers = headers
|
||||||
|
writer.value_matrix = values
|
||||||
|
writer.margin = 1
|
||||||
|
[writer.set_style(header, Style(align="center")) for header in headers]
|
||||||
|
return writer
|
||||||
|
|
||||||
|
def _parse_metrics(self):
|
||||||
|
table_rows = []
|
||||||
|
for name in dir(jupyterhub.metrics):
|
||||||
|
obj = getattr(jupyterhub.metrics, name)
|
||||||
|
if obj.__class__.__module__.startswith('prometheus_client.'):
|
||||||
|
for metric in obj.describe():
|
||||||
|
table_rows.append([metric.type, metric.name, metric.documentation])
|
||||||
|
return table_rows
|
||||||
|
|
||||||
|
def prometheus_metrics(self):
|
||||||
|
generated_directory = f"{HERE}/source/reference"
|
||||||
|
if not os.path.exists(generated_directory):
|
||||||
|
os.makedirs(generated_directory)
|
||||||
|
|
||||||
|
filename = f"{generated_directory}/metrics.rst"
|
||||||
|
table_name = ""
|
||||||
|
headers = ["Type", "Name", "Description"]
|
||||||
|
values = self._parse_metrics()
|
||||||
|
writer = self.create_writer(table_name, headers, values)
|
||||||
|
|
||||||
|
title = "List of Prometheus Metrics"
|
||||||
|
underline = "============================"
|
||||||
|
content = f"{title}\n{underline}\n{writer.dumps()}"
|
||||||
|
with open(filename, 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
print(f"Generated {filename}.")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
doc_generator = Generator()
|
||||||
|
doc_generator.prometheus_metrics()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
@@ -1,10 +1,12 @@
|
|||||||
# ReadTheDocs uses the `environment.yaml` so make sure to update that as well
|
|
||||||
# if you change this file
|
|
||||||
-r ../requirements.txt
|
-r ../requirements.txt
|
||||||
|
|
||||||
alabaster_jupyterhub
|
alabaster_jupyterhub
|
||||||
autodoc-traits
|
# Temporary fix of #3021. Revert back to released autodoc-traits when
|
||||||
git+https://github.com/pandas-dev/pandas-sphinx-theme.git@master
|
# 0.1.0 released.
|
||||||
recommonmark==0.5.0
|
https://github.com/jupyterhub/autodoc-traits/archive/75885ee24636efbfebfceed1043459715049cd84.zip
|
||||||
|
pydata-sphinx-theme
|
||||||
|
pytablewriter>=0.56
|
||||||
|
recommonmark>=0.6
|
||||||
sphinx-copybutton
|
sphinx-copybutton
|
||||||
sphinx-jsonschema
|
sphinx-jsonschema
|
||||||
sphinx>=1.7
|
sphinx>=1.7
|
||||||
|
@@ -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: 1.2.0dev
|
||||||
license:
|
license:
|
||||||
name: BSD-3-Clause
|
name: BSD-3-Clause
|
||||||
schemes:
|
schemes:
|
||||||
@@ -79,6 +79,21 @@ paths:
|
|||||||
/users:
|
/users:
|
||||||
get:
|
get:
|
||||||
summary: List users
|
summary: List users
|
||||||
|
parameters:
|
||||||
|
- name: state
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
enum: ["inactive", "active", "ready"]
|
||||||
|
description: |
|
||||||
|
Return only users who have servers in the given state.
|
||||||
|
If unspecified, return all users.
|
||||||
|
|
||||||
|
active: all users with any active servers (ready OR pending)
|
||||||
|
ready: all users who have any ready servers (running, not pending)
|
||||||
|
inactive: all users who have *no* active servers (complement of active)
|
||||||
|
|
||||||
|
Added in JupyterHub 1.3
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: The Hub's user list
|
description: The Hub's user list
|
||||||
@@ -248,10 +263,13 @@ paths:
|
|||||||
when spawning via the API instead of spawn form.
|
when spawning via the API instead of spawn form.
|
||||||
The structure of the options
|
The structure of the options
|
||||||
will depend on the Spawner's configuration.
|
will depend on the Spawner's configuration.
|
||||||
|
The body itself will be available as `user_options` for the
|
||||||
|
Spawner.
|
||||||
in: body
|
in: body
|
||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
|
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: The user's notebook server has started
|
description: The user's notebook server has started
|
||||||
@@ -280,7 +298,10 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
- name: server_name
|
- name: server_name
|
||||||
description: name given to a named-server
|
description: |
|
||||||
|
name given to a named-server.
|
||||||
|
|
||||||
|
Note that depending on your JupyterHub infrastructure there are chracterter size limitation to `server_name`. Default spawner with K8s pod will not allow Jupyter Notebooks to be spawned with a name that contains more than 253 characters (keep in mind that the pod will be spawned with extra characters to identify the user and hub).
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
@@ -312,14 +333,18 @@ paths:
|
|||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
- name: remove
|
- name: body
|
||||||
description: |
|
|
||||||
Whether to fully remove the server, rather than just stop it.
|
|
||||||
Removing a server deletes things like the state of the stopped server.
|
|
||||||
in: body
|
in: body
|
||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
type: boolean
|
type: object
|
||||||
|
properties:
|
||||||
|
remove:
|
||||||
|
type: boolean
|
||||||
|
description: |
|
||||||
|
Whether to fully remove the server, rather than just stop it.
|
||||||
|
Removing a server deletes things like the state of the stopped server.
|
||||||
|
Default: false.
|
||||||
responses:
|
responses:
|
||||||
'204':
|
'204':
|
||||||
description: The user's notebook named-server has stopped
|
description: The user's notebook named-server has stopped
|
||||||
@@ -795,6 +820,9 @@ definitions:
|
|||||||
state:
|
state:
|
||||||
type: object
|
type: object
|
||||||
description: Arbitrary internal state from this server's spawner. Only available on the hub's users list or get-user-by-name method, and only if a hub admin. None otherwise.
|
description: Arbitrary internal state from this server's spawner. Only available on the hub's users list or get-user-by-name method, and only if a hub admin. None otherwise.
|
||||||
|
user_options:
|
||||||
|
type: object
|
||||||
|
description: User specified options for the user's spawned instance of a single-user server.
|
||||||
Group:
|
Group:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
@@ -1,106 +1,4 @@
|
|||||||
div#helm-chart-schema h2,
|
/* Added to avoid logo being too squeezed */
|
||||||
div#helm-chart-schema h3,
|
.navbar-brand {
|
||||||
div#helm-chart-schema h4,
|
height: 4rem !important;
|
||||||
div#helm-chart-schema h5,
|
|
||||||
div#helm-chart-schema h6 {
|
|
||||||
font-family: courier new;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3, h3 ~ * {
|
|
||||||
margin-left: 3% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
h4, h4 ~ * {
|
|
||||||
margin-left: 6% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
h5, h5 ~ * {
|
|
||||||
margin-left: 9% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
h6, h6 ~ * {
|
|
||||||
margin-left: 12% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
h7, h7 ~ * {
|
|
||||||
margin-left: 15% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
img.logo {
|
|
||||||
width:100%
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-next {
|
|
||||||
float: right;
|
|
||||||
max-width: 45%;
|
|
||||||
overflow: auto;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-next::after{
|
|
||||||
content: ' »';
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-prev {
|
|
||||||
float: left;
|
|
||||||
max-width: 45%;
|
|
||||||
overflow: auto;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-prev::before{
|
|
||||||
content: '« ';
|
|
||||||
}
|
|
||||||
|
|
||||||
.prev-next-bottom {
|
|
||||||
margin-top: 3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prev-next-top {
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar TOC and headers */
|
|
||||||
|
|
||||||
div.sphinxsidebarwrapper div {
|
|
||||||
margin-bottom: .8em;
|
|
||||||
}
|
|
||||||
div.sphinxsidebar h3 {
|
|
||||||
font-size: 1.3em;
|
|
||||||
padding-top: 0px;
|
|
||||||
font-weight: 800;
|
|
||||||
margin-left: 0px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.sphinxsidebar p.caption {
|
|
||||||
font-size: 1.2em;
|
|
||||||
margin-bottom: 0px;
|
|
||||||
margin-left: 0px !important;
|
|
||||||
font-weight: 900;
|
|
||||||
color: #767676;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.sphinxsidebar ul {
|
|
||||||
font-size: .8em;
|
|
||||||
margin-top: 0px;
|
|
||||||
padding-left: 3%;
|
|
||||||
margin-left: 0px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.relations ul {
|
|
||||||
font-size: 1em;
|
|
||||||
margin-left: 0px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
div#searchbox form {
|
|
||||||
margin-left: 0px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* body elements */
|
|
||||||
.toctree-wrapper span.caption-text {
|
|
||||||
color: #767676;
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
}
|
||||||
|
@@ -1,16 +0,0 @@
|
|||||||
{# Custom template for navigation.html
|
|
||||||
|
|
||||||
alabaster theme does not provide blocks for titles to
|
|
||||||
be overridden so this custom theme handles title and
|
|
||||||
toctree for sidebar
|
|
||||||
#}
|
|
||||||
<h3>{{ _('Table of Contents') }}</h3>
|
|
||||||
{{ toctree(includehidden=theme_sidebar_includehidden, collapse=theme_sidebar_collapse) }}
|
|
||||||
{% if theme_extra_nav_links %}
|
|
||||||
<hr />
|
|
||||||
<ul>
|
|
||||||
{% for text, uri in theme_extra_nav_links.items() %}
|
|
||||||
<li class="toctree-l1"><a href="{{ uri }}">{{ text }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
@@ -1,17 +0,0 @@
|
|||||||
{# Custom template for relations.html
|
|
||||||
|
|
||||||
alabaster theme does not provide previous/next page by default
|
|
||||||
#}
|
|
||||||
<div class="relations">
|
|
||||||
<h3>Navigation</h3>
|
|
||||||
<ul>
|
|
||||||
<li><a href="{{ pathto(master_doc) }}">Documentation Home</a><ul>
|
|
||||||
{%- if prev %}
|
|
||||||
<li><a href="{{ prev.link|e }}" title="Previous">Previous topic</a></li>
|
|
||||||
{%- endif %}
|
|
||||||
{%- if next %}
|
|
||||||
<li><a href="{{ next.link|e }}" title="Next">Next topic</a></li>
|
|
||||||
{%- endif %}
|
|
||||||
</ul>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
File diff suppressed because one or more lines are too long
@@ -1,7 +1,6 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
import os
|
import os
|
||||||
import shlex
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Set paths
|
# Set paths
|
||||||
@@ -20,10 +19,9 @@ extensions = [
|
|||||||
'autodoc_traits',
|
'autodoc_traits',
|
||||||
'sphinx_copybutton',
|
'sphinx_copybutton',
|
||||||
'sphinx-jsonschema',
|
'sphinx-jsonschema',
|
||||||
|
'recommonmark',
|
||||||
]
|
]
|
||||||
|
|
||||||
templates_path = ['_templates']
|
|
||||||
|
|
||||||
# The master toctree document.
|
# The master toctree document.
|
||||||
master_doc = 'index'
|
master_doc = 'index'
|
||||||
|
|
||||||
@@ -59,22 +57,74 @@ default_role = 'literal'
|
|||||||
import recommonmark
|
import recommonmark
|
||||||
from recommonmark.transform import AutoStructify
|
from recommonmark.transform import AutoStructify
|
||||||
|
|
||||||
|
# -- Config -------------------------------------------------------------
|
||||||
|
from jupyterhub.app import JupyterHub
|
||||||
|
from docutils import nodes
|
||||||
|
from sphinx.directives.other import SphinxDirective
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
# create a temp instance of JupyterHub just to get the output of the generate-config
|
||||||
|
# and help --all commands.
|
||||||
|
jupyterhub_app = JupyterHub()
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigDirective(SphinxDirective):
|
||||||
|
"""Generate the configuration file output for use in the documentation."""
|
||||||
|
|
||||||
|
has_content = False
|
||||||
|
required_arguments = 0
|
||||||
|
optional_arguments = 0
|
||||||
|
final_argument_whitespace = False
|
||||||
|
option_spec = {}
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
# The generated configuration file for this version
|
||||||
|
generated_config = jupyterhub_app.generate_config_file()
|
||||||
|
# post-process output
|
||||||
|
home_dir = os.environ['HOME']
|
||||||
|
generated_config = generated_config.replace(home_dir, '$HOME', 1)
|
||||||
|
par = nodes.literal_block(text=generated_config)
|
||||||
|
return [par]
|
||||||
|
|
||||||
|
|
||||||
|
class HelpAllDirective(SphinxDirective):
|
||||||
|
"""Print the output of jupyterhub help --all for use in the documentation."""
|
||||||
|
|
||||||
|
has_content = False
|
||||||
|
required_arguments = 0
|
||||||
|
optional_arguments = 0
|
||||||
|
final_argument_whitespace = False
|
||||||
|
option_spec = {}
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
# The output of the help command for this version
|
||||||
|
buffer = StringIO()
|
||||||
|
with redirect_stdout(buffer):
|
||||||
|
jupyterhub_app.print_help('--help-all')
|
||||||
|
all_help = buffer.getvalue()
|
||||||
|
# post-process output
|
||||||
|
home_dir = os.environ['HOME']
|
||||||
|
all_help = all_help.replace(home_dir, '$HOME', 1)
|
||||||
|
par = nodes.literal_block(text=all_help)
|
||||||
|
return [par]
|
||||||
|
|
||||||
|
|
||||||
def setup(app):
|
def setup(app):
|
||||||
app.add_config_value('recommonmark_config', {'enable_eval_rst': True}, True)
|
app.add_config_value('recommonmark_config', {'enable_eval_rst': True}, True)
|
||||||
app.add_stylesheet('custom.css')
|
app.add_css_file('custom.css')
|
||||||
app.add_transform(AutoStructify)
|
app.add_transform(AutoStructify)
|
||||||
|
app.add_directive('jupyterhub-generate-config', ConfigDirective)
|
||||||
|
app.add_directive('jupyterhub-help-all', HelpAllDirective)
|
||||||
|
|
||||||
|
|
||||||
source_parsers = {'.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 ----------------------------------------------
|
||||||
|
|
||||||
# The theme to use for HTML and HTML Help pages.
|
# The theme to use for HTML and HTML Help pages.
|
||||||
html_theme = 'pandas_sphinx_theme'
|
html_theme = 'pydata_sphinx_theme'
|
||||||
|
|
||||||
html_logo = '_static/images/logo/logo.png'
|
html_logo = '_static/images/logo/logo.png'
|
||||||
html_favicon = '_static/images/logo/favicon.ico'
|
html_favicon = '_static/images/logo/favicon.ico'
|
||||||
@@ -166,10 +216,10 @@ intersphinx_mapping = {'https://docs.python.org/3/': None}
|
|||||||
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
|
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
|
||||||
if on_rtd:
|
if on_rtd:
|
||||||
# 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 both metrics and 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', 'metrics', 'rest-api'], cwd=docs)
|
||||||
|
|
||||||
# -- Spell checking -------------------------------------------------------
|
# -- Spell checking -------------------------------------------------------
|
||||||
|
|
||||||
|
@@ -83,7 +83,6 @@ these will be moved at a future review of the roadmap.
|
|||||||
- (prometheus?) API for resource monitoring
|
- (prometheus?) API for resource monitoring
|
||||||
- tracking activity on single-user servers instead of the proxy
|
- tracking activity on single-user servers instead of the proxy
|
||||||
- notes and activity tracking per API token
|
- notes and activity tracking per API token
|
||||||
- UI for managing named servers
|
|
||||||
|
|
||||||
|
|
||||||
### Later
|
### Later
|
||||||
|
@@ -8,7 +8,7 @@ System requirements
|
|||||||
===================
|
===================
|
||||||
|
|
||||||
JupyterHub can only run on MacOS or Linux operating systems. If you are
|
JupyterHub can only run on MacOS or Linux operating systems. If you are
|
||||||
using Windows, we recommend using `VirtualBox <https://virtualbox.org>`_
|
using Windows, we recommend using `VirtualBox <https://virtualbox.org>`_
|
||||||
or a similar system to run `Ubuntu Linux <https://ubuntu.com>`_ for
|
or a similar system to run `Ubuntu Linux <https://ubuntu.com>`_ for
|
||||||
development.
|
development.
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ Install Python
|
|||||||
JupyterHub is written in the `Python <https://python.org>`_ programming language, and
|
JupyterHub is written in the `Python <https://python.org>`_ programming language, and
|
||||||
requires you have at least version 3.5 installed locally. If you haven’t
|
requires you have at least version 3.5 installed locally. If you haven’t
|
||||||
installed Python before, the recommended way to install it is to use
|
installed Python before, the recommended way to install it is to use
|
||||||
`miniconda <https://conda.io/miniconda.html>`_. Remember to get the ‘Python 3’ version,
|
`miniconda <https://conda.io/miniconda.html>`_. Remember to get the ‘Python 3’ version,
|
||||||
and **not** the ‘Python 2’ version!
|
and **not** the ‘Python 2’ version!
|
||||||
|
|
||||||
Install nodejs
|
Install nodejs
|
||||||
@@ -45,7 +45,13 @@ When developing JupyterHub, you need to make changes to the code & see
|
|||||||
their effects quickly. You need to do a developer install to make that
|
their effects quickly. You need to do a developer install to make that
|
||||||
happen.
|
happen.
|
||||||
|
|
||||||
1. Clone the `JupyterHub git repository <https://github.com/jupyterhub/jupyterhub>`_
|
.. note:: This guide does not attempt to dictate *how* development
|
||||||
|
environements should be isolated since that is a personal preference and can
|
||||||
|
be achieved in many ways, for example `tox`, `conda`, `docker`, etc. See this
|
||||||
|
`forum thread <https://discourse.jupyter.org/t/thoughts-on-using-tox/3497>`_ for
|
||||||
|
a more detailed discussion.
|
||||||
|
|
||||||
|
1. Clone the `JupyterHub git repository <https://github.com/jupyterhub/jupyterhub>`_
|
||||||
to your computer.
|
to your computer.
|
||||||
|
|
||||||
.. code:: bash
|
.. code:: bash
|
||||||
@@ -93,7 +99,14 @@ happen.
|
|||||||
python3 -m pip install -r dev-requirements.txt
|
python3 -m pip install -r dev-requirements.txt
|
||||||
python3 -m pip install -r requirements.txt
|
python3 -m pip install -r requirements.txt
|
||||||
|
|
||||||
5. Install the development version of JupyterHub. This lets you edit
|
5. Setup a database.
|
||||||
|
|
||||||
|
The default database engine is ``sqlite`` so if you are just trying
|
||||||
|
to get up and running quickly for local development that should be
|
||||||
|
available via `python <https://docs.python.org/3.5/library/sqlite3.html>`__.
|
||||||
|
See :doc:`/reference/database` for details on other supported databases.
|
||||||
|
|
||||||
|
6. Install the development version of JupyterHub. This lets you edit
|
||||||
JupyterHub code in a text editor & restart the JupyterHub process to
|
JupyterHub code in a text editor & restart the JupyterHub process to
|
||||||
see your code changes immediately.
|
see your code changes immediately.
|
||||||
|
|
||||||
@@ -101,13 +114,13 @@ happen.
|
|||||||
|
|
||||||
python3 -m pip install --editable .
|
python3 -m pip install --editable .
|
||||||
|
|
||||||
6. You are now ready to start JupyterHub!
|
7. You are now ready to start JupyterHub!
|
||||||
|
|
||||||
.. code:: bash
|
.. code:: bash
|
||||||
|
|
||||||
jupyterhub
|
jupyterhub
|
||||||
|
|
||||||
7. You can access JupyterHub from your browser at
|
8. You can access JupyterHub from your browser at
|
||||||
``http://localhost:8000`` now.
|
``http://localhost:8000`` now.
|
||||||
|
|
||||||
Happy developing!
|
Happy developing!
|
||||||
|
@@ -64,5 +64,5 @@ Troubleshooting Test Failures
|
|||||||
All the tests are failing
|
All the tests are failing
|
||||||
-------------------------
|
-------------------------
|
||||||
|
|
||||||
Make sure you have completed all the steps in :ref:`contributing/setup` sucessfully, and
|
Make sure you have completed all the steps in :ref:`contributing/setup` successfully, and
|
||||||
can launch ``jupyterhub`` from the terminal.
|
can launch ``jupyterhub`` from the terminal.
|
||||||
|
@@ -4,23 +4,23 @@ The default Authenticator uses [PAM][] to authenticate system users with
|
|||||||
their username and password. With the default Authenticator, any user
|
their username and password. With the default Authenticator, any user
|
||||||
with an account and password on the system will be allowed to login.
|
with an account and password on the system will be allowed to login.
|
||||||
|
|
||||||
## Create a whitelist of users
|
## Create a set of allowed users
|
||||||
|
|
||||||
You can restrict which users are allowed to login with a whitelist,
|
You can restrict which users are allowed to login with a set,
|
||||||
`Authenticator.whitelist`:
|
`Authenticator.allowed_users`:
|
||||||
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
c.Authenticator.whitelist = {'mal', 'zoe', 'inara', 'kaylee'}
|
c.Authenticator.allowed_users = {'mal', 'zoe', 'inara', 'kaylee'}
|
||||||
```
|
```
|
||||||
|
|
||||||
Users in the whitelist are added to the Hub database when the Hub is
|
Users in the `allowed_users` set are added to the Hub database when the Hub is
|
||||||
started.
|
started.
|
||||||
|
|
||||||
## Configure admins (`admin_users`)
|
## Configure admins (`admin_users`)
|
||||||
|
|
||||||
Admin users of JupyterHub, `admin_users`, can add and remove users from
|
Admin users of JupyterHub, `admin_users`, can add and remove users from
|
||||||
the user `whitelist`. `admin_users` can take actions on other users'
|
the user `allowed_users` set. `admin_users` can take actions on other users'
|
||||||
behalf, such as stopping and restarting their servers.
|
behalf, such as stopping and restarting their servers.
|
||||||
|
|
||||||
A set of initial admin users, `admin_users` can configured be as follows:
|
A set of initial admin users, `admin_users` can configured be as follows:
|
||||||
@@ -28,7 +28,7 @@ A set of initial admin users, `admin_users` can configured be as follows:
|
|||||||
```python
|
```python
|
||||||
c.Authenticator.admin_users = {'mal', 'zoe'}
|
c.Authenticator.admin_users = {'mal', 'zoe'}
|
||||||
```
|
```
|
||||||
Users in the admin list are automatically added to the user `whitelist`,
|
Users in the admin set are automatically added to the user `allowed_users` set,
|
||||||
if they are not already present.
|
if they are not already present.
|
||||||
|
|
||||||
Each authenticator may have different ways of determining whether a user is an
|
Each authenticator may have different ways of determining whether a user is an
|
||||||
@@ -53,12 +53,12 @@ sure your users know if admin_access is enabled.**
|
|||||||
|
|
||||||
Users can be added to and removed from the Hub via either the admin
|
Users can be added to and removed from the Hub via either the admin
|
||||||
panel or the REST API. When a user is **added**, the user will be
|
panel or the REST API. When a user is **added**, the user will be
|
||||||
automatically added to the whitelist and database. Restarting the Hub
|
automatically added to the allowed users set and database. Restarting the Hub
|
||||||
will not require manually updating the whitelist in your config file,
|
will not require manually updating the allowed users set in your config file,
|
||||||
as the users will be loaded from the database.
|
as the users will be loaded from the database.
|
||||||
|
|
||||||
After starting the Hub once, it is not sufficient to **remove** a user
|
After starting the Hub once, it is not sufficient to **remove** a user
|
||||||
from the whitelist in your config file. You must also remove the user
|
from the allowed users set in your config file. You must also remove the user
|
||||||
from the Hub's database, either by deleting the user from JupyterHub's
|
from the Hub's database, either by deleting the user from JupyterHub's
|
||||||
admin page, or you can clear the `jupyterhub.sqlite` database and start
|
admin page, or you can clear the `jupyterhub.sqlite` database and start
|
||||||
fresh.
|
fresh.
|
||||||
|
36
docs/source/getting-started/faq.md
Normal file
36
docs/source/getting-started/faq.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Frequently asked questions
|
||||||
|
|
||||||
|
|
||||||
|
### How do I share links to notebooks?
|
||||||
|
|
||||||
|
In short, where you see `/user/name/notebooks/foo.ipynb` use `/hub/user-redirect/notebooks/foo.ipynb` (replace `/user/name` with `/hub/user-redirect`).
|
||||||
|
|
||||||
|
Sharing links to notebooks is a common activity,
|
||||||
|
and can look different based on what you mean.
|
||||||
|
Your first instinct might be to copy the URL you see in the browser,
|
||||||
|
e.g. `hub.jupyter.org/user/yourname/notebooks/coolthing.ipynb`.
|
||||||
|
However, let's break down what this URL means:
|
||||||
|
|
||||||
|
`hub.jupyter.org/user/yourname/` is the URL prefix handled by *your server*,
|
||||||
|
which means that sharing this URL is asking the person you share the link with
|
||||||
|
to come to *your server* and look at the exact same file.
|
||||||
|
In most circumstances, this is forbidden by permissions because the person you share with does not have access to your server.
|
||||||
|
What actually happens when someone visits this URL will depend on whether your server is running and other factors.
|
||||||
|
|
||||||
|
But what is our actual goal?
|
||||||
|
A typical situation is that you have some shared or common filesystem,
|
||||||
|
such that the same path corresponds to the same document
|
||||||
|
(either the exact same document or another copy of it).
|
||||||
|
Typically, what folks want when they do sharing like this
|
||||||
|
is for each visitor to open the same file *on their own server*,
|
||||||
|
so Breq would open `/user/breq/notebooks/foo.ipynb` and
|
||||||
|
Seivarden would open `/user/seivarden/notebooks/foo.ipynb`, etc.
|
||||||
|
|
||||||
|
JupyterHub has a special URL that does exactly this!
|
||||||
|
It's called `/hub/user-redirect/...` and after the visitor logs in,
|
||||||
|
So if you replace `/user/yourname` in your URL bar
|
||||||
|
with `/hub/user-redirect` any visitor should get the same
|
||||||
|
URL on their own server, rather than visiting yours.
|
||||||
|
|
||||||
|
In JupyterLab 2.0, this should also be the result of the "Copy Shareable Link"
|
||||||
|
action in the file browser.
|
@@ -15,4 +15,5 @@ own JupyterHub.
|
|||||||
authenticators-users-basics
|
authenticators-users-basics
|
||||||
spawners-basics
|
spawners-basics
|
||||||
services-basics
|
services-basics
|
||||||
|
faq
|
||||||
institutional-faq
|
institutional-faq
|
||||||
|
@@ -21,7 +21,7 @@ Here is a quick breakdown of these three tools:
|
|||||||
* **The Jupyter Notebook** is a document specification (the `.ipynb`) file that interweaves
|
* **The Jupyter Notebook** is a document specification (the `.ipynb`) file that interweaves
|
||||||
narrative text with code cells and their outputs. It is also a graphical interface
|
narrative text with code cells and their outputs. It is also a graphical interface
|
||||||
that allows users to edit these documents. There are also several other graphical interfaces
|
that allows users to edit these documents. There are also several other graphical interfaces
|
||||||
that allow users to edit the `.ipynb` format (nteract, Jupyer Lab, Google Colab, Kaggle, etc).
|
that allow users to edit the `.ipynb` format (nteract, Jupyter Lab, Google Colab, Kaggle, etc).
|
||||||
* **JupyterLab** is a flexible and extendible user interface for interactive computing. It
|
* **JupyterLab** is a flexible and extendible user interface for interactive computing. It
|
||||||
has several extensions that are tailored for using Jupyter Notebooks, as well as extensions
|
has several extensions that are tailored for using Jupyter Notebooks, as well as extensions
|
||||||
for other parts of the data science stack.
|
for other parts of the data science stack.
|
||||||
|
@@ -80,6 +80,49 @@ To achieve this, simply omit the configuration settings
|
|||||||
``c.JupyterHub.ssl_key`` and ``c.JupyterHub.ssl_cert``
|
``c.JupyterHub.ssl_key`` and ``c.JupyterHub.ssl_cert``
|
||||||
(setting them to ``None`` does not have the same effect, and is an error).
|
(setting them to ``None`` does not have the same effect, and is an error).
|
||||||
|
|
||||||
|
.. _authentication-token:
|
||||||
|
|
||||||
|
Proxy authentication token
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
The Hub authenticates its requests to the Proxy using a secret token that
|
||||||
|
the Hub and Proxy agree upon. Note that this applies to the default
|
||||||
|
``ConfigurableHTTPProxy`` implementation. Not all proxy implementations
|
||||||
|
use an auth token.
|
||||||
|
|
||||||
|
The value of this token should be a random string (for example, generated by
|
||||||
|
``openssl rand -hex 32``). You can store it in the configuration file or an
|
||||||
|
environment variable
|
||||||
|
|
||||||
|
Generating and storing token in the configuration file
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
You can set the value in the configuration file, ``jupyterhub_config.py``:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
c.ConfigurableHTTPProxy.api_token = 'abc123...' # any random string
|
||||||
|
|
||||||
|
Generating and storing as an environment variable
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
You can pass this value of the proxy authentication token to the Hub and Proxy
|
||||||
|
using the ``CONFIGPROXY_AUTH_TOKEN`` environment variable:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
export CONFIGPROXY_AUTH_TOKEN=$(openssl rand -hex 32)
|
||||||
|
|
||||||
|
This environment variable needs to be visible to the Hub and Proxy.
|
||||||
|
|
||||||
|
Default if token is not set
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
If you don't set the Proxy authentication token, the Hub will generate a random
|
||||||
|
key itself, which means that any time you restart the Hub you **must also
|
||||||
|
restart the Proxy**. If the proxy is a subprocess of the Hub, this should happen
|
||||||
|
automatically (this is the default configuration).
|
||||||
|
|
||||||
.. _cookie-secret:
|
.. _cookie-secret:
|
||||||
|
|
||||||
Cookie secret
|
Cookie secret
|
||||||
@@ -146,41 +189,73 @@ itself, ``jupyterhub_config.py``, as a binary string:
|
|||||||
If the cookie secret value changes for the Hub, all single-user notebook
|
If the cookie secret value changes for the Hub, all single-user notebook
|
||||||
servers must also be restarted.
|
servers must also be restarted.
|
||||||
|
|
||||||
|
.. _cookies:
|
||||||
|
|
||||||
.. _authentication-token:
|
Cookies used by JupyterHub authentication
|
||||||
|
-----------------------------------------
|
||||||
|
|
||||||
Proxy authentication token
|
The following cookies are used by the Hub for handling user authentication.
|
||||||
--------------------------
|
|
||||||
|
|
||||||
The Hub authenticates its requests to the Proxy using a secret token that
|
This section was created based on this post_ from Discourse.
|
||||||
the Hub and Proxy agree upon. The value of this string should be a random
|
|
||||||
string (for example, generated by ``openssl rand -hex 32``).
|
|
||||||
|
|
||||||
Generating and storing token in the configuration file
|
.. _post: https://discourse.jupyter.org/t/how-to-force-re-login-for-users/1998/6
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
Or you can set the value in the configuration file, ``jupyterhub_config.py``:
|
jupyterhub-hub-login
|
||||||
|
~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. code-block:: python
|
This is the login token used when visiting Hub-served pages that are
|
||||||
|
protected by authentication such as the main home, the spawn form, etc.
|
||||||
|
If this cookie is set, then the user is logged in.
|
||||||
|
|
||||||
c.JupyterHub.proxy_auth_token = '0bc02bede919e99a26de1e2a7a5aadfaf6228de836ec39a05a6c6942831d8fe5'
|
Resetting the Hub cookie secret effectively revokes this cookie.
|
||||||
|
|
||||||
Generating and storing as an environment variable
|
This cookie is restricted to the path ``/hub/``.
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
You can pass this value of the proxy authentication token to the Hub and Proxy
|
jupyterhub-user-<username>
|
||||||
using the ``CONFIGPROXY_AUTH_TOKEN`` environment variable:
|
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. code-block:: bash
|
This is the cookie used for authenticating with a single-user server.
|
||||||
|
It is set by the single-user server after OAuth with the Hub.
|
||||||
|
|
||||||
export CONFIGPROXY_AUTH_TOKEN=$(openssl rand -hex 32)
|
Effectively the same as ``jupyterhub-hub-login``, but for the
|
||||||
|
single-user server instead of the Hub. It contains an OAuth access token,
|
||||||
|
which is checked with the Hub to authenticate the browser.
|
||||||
|
|
||||||
This environment variable needs to be visible to the Hub and Proxy.
|
Each OAuth access token is associated with a session id (see ``jupyterhub-session-id`` section
|
||||||
|
below).
|
||||||
|
|
||||||
Default if token is not set
|
To avoid hitting the Hub on every request, the authentication response
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
is cached. And to avoid a stale cache the cache key is comprised of both
|
||||||
|
the token and session id.
|
||||||
|
|
||||||
If you don't set the Proxy authentication token, the Hub will generate a random
|
Resetting the Hub cookie secret effectively revokes this cookie.
|
||||||
key itself, which means that any time you restart the Hub you **must also
|
|
||||||
restart the Proxy**. If the proxy is a subprocess of the Hub, this should happen
|
This cookie is restricted to the path ``/user/<username>``, so that
|
||||||
automatically (this is the default configuration).
|
only the user’s server receives it.
|
||||||
|
|
||||||
|
jupyterhub-session-id
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
This is a random string, meaningless in itself, and the only cookie
|
||||||
|
shared by the Hub and single-user servers.
|
||||||
|
|
||||||
|
Its sole purpose is to coordinate logout of the multiple OAuth cookies.
|
||||||
|
|
||||||
|
This cookie is set to ``/`` so all endpoints can receive it, or clear it, etc.
|
||||||
|
|
||||||
|
jupyterhub-user-<username>-oauth-state
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
A short-lived cookie, used solely to store and validate OAuth state.
|
||||||
|
It is only set while OAuth between the single-user server and the Hub
|
||||||
|
is processing.
|
||||||
|
|
||||||
|
If you use your browser development tools, you should see this cookie
|
||||||
|
for a very brief moment before your are logged in,
|
||||||
|
with an expiration date shorter than ``jupyterhub-hub-login`` or
|
||||||
|
``jupyterhub-user-<username>``.
|
||||||
|
|
||||||
|
This cookie should not exist after you have successfully logged in.
|
||||||
|
|
||||||
|
This cookie is restricted to the path ``/user/<username>``, so that only
|
||||||
|
the user’s server receives it.
|
||||||
|
@@ -5,7 +5,7 @@ that interacts with the Hub's REST API. A Service may perform a specific
|
|||||||
or action or task. For example, shutting down individuals' single user
|
or action or task. For example, shutting down individuals' single user
|
||||||
notebook servers that have been idle for some time is a good example of
|
notebook servers that have been idle for some time is a good example of
|
||||||
a task that could be automated by a Service. Let's look at how the
|
a task that could be automated by a Service. Let's look at how the
|
||||||
[cull_idle_servers][] script can be used as a Service.
|
[jupyterhub_idle_culler][] script can be used as a Service.
|
||||||
|
|
||||||
## Real-world example to cull idle servers
|
## Real-world example to cull idle servers
|
||||||
|
|
||||||
@@ -15,11 +15,11 @@ document will:
|
|||||||
- explain some basic information about API tokens
|
- explain some basic information about API tokens
|
||||||
- clarify that API tokens can be used to authenticate to
|
- clarify that API tokens can be used to authenticate to
|
||||||
single-user servers as of [version 0.8.0](../changelog)
|
single-user servers as of [version 0.8.0](../changelog)
|
||||||
- show how the [cull_idle_servers][] script can be:
|
- show how the [jupyterhub_idle_culler][] script can be:
|
||||||
- used in a Hub-managed service
|
- used in a Hub-managed service
|
||||||
- run as a standalone script
|
- run as a standalone script
|
||||||
|
|
||||||
Both examples for `cull_idle_servers` will communicate tasks to the
|
Both examples for `jupyterhub_idle_culler` will communicate tasks to the
|
||||||
Hub via the REST API.
|
Hub via the REST API.
|
||||||
|
|
||||||
## API Token basics
|
## API Token basics
|
||||||
@@ -78,17 +78,23 @@ single-user servers, and only cookies can be used for authentication.
|
|||||||
0.8 supports using JupyterHub API tokens to authenticate to single-user
|
0.8 supports using JupyterHub API tokens to authenticate to single-user
|
||||||
servers.
|
servers.
|
||||||
|
|
||||||
## Configure `cull-idle` to run as a Hub-Managed Service
|
## Configure the idle culler to run as a Hub-Managed Service
|
||||||
|
|
||||||
|
Install the idle culler:
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install jupyterhub-idle-culler
|
||||||
|
```
|
||||||
|
|
||||||
In `jupyterhub_config.py`, add the following dictionary for the
|
In `jupyterhub_config.py`, add the following dictionary for the
|
||||||
`cull-idle` Service to the `c.JupyterHub.services` list:
|
`idle-culler` Service to the `c.JupyterHub.services` list:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
c.JupyterHub.services = [
|
c.JupyterHub.services = [
|
||||||
{
|
{
|
||||||
'name': 'cull-idle',
|
'name': 'idle-culler',
|
||||||
'admin': True,
|
'admin': True,
|
||||||
'command': [sys.executable, 'cull_idle_servers.py', '--timeout=3600'],
|
'command': [sys.executable, '-m', 'jupyterhub_idle_culler', '--timeout=3600'],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
@@ -101,21 +107,21 @@ where:
|
|||||||
|
|
||||||
## Run `cull-idle` manually as a standalone script
|
## Run `cull-idle` manually as a standalone script
|
||||||
|
|
||||||
Now you can run your script, i.e. `cull_idle_servers`, by providing it
|
Now you can run your script by providing it
|
||||||
the API token and it will authenticate through the REST API to
|
the API token and it will authenticate through the REST API to
|
||||||
interact with it.
|
interact with it.
|
||||||
|
|
||||||
This will run `cull-idle` manually. `cull-idle` can be run as a standalone
|
This will run the idle culler service manually. It can be run as a standalone
|
||||||
script anywhere with access to the Hub, and will periodically check for idle
|
script anywhere with access to the Hub, and will periodically check for idle
|
||||||
servers and shut them down via the Hub's REST API. In order to shutdown the
|
servers and shut them down via the Hub's REST API. In order to shutdown the
|
||||||
servers, the token given to cull-idle must have admin privileges.
|
servers, the token given to cull-idle must have admin privileges.
|
||||||
|
|
||||||
Generate an API token and store it in the `JUPYTERHUB_API_TOKEN` environment
|
Generate an API token and store it in the `JUPYTERHUB_API_TOKEN` environment
|
||||||
variable. Run `cull_idle_servers.py` manually.
|
variable. Run `jupyterhub_idle_culler` manually.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export JUPYTERHUB_API_TOKEN='token'
|
export JUPYTERHUB_API_TOKEN='token'
|
||||||
python3 cull_idle_servers.py [--timeout=900] [--url=http://127.0.0.1:8081/hub/api]
|
python -m jupyterhub_idle_culler [--timeout=900] [--url=http://127.0.0.1:8081/hub/api]
|
||||||
```
|
```
|
||||||
|
|
||||||
[cull_idle_servers]: https://github.com/jupyterhub/jupyterhub/blob/master/examples/cull-idle/cull_idle_servers.py
|
[jupyterhub_idle_culler]: https://github.com/jupyterhub/jupyterhub-idle-culler
|
||||||
|
@@ -3,11 +3,11 @@ JupyterHub
|
|||||||
==========
|
==========
|
||||||
|
|
||||||
`JupyterHub`_ is the best way to serve `Jupyter notebook`_ for multiple users.
|
`JupyterHub`_ is the best way to serve `Jupyter notebook`_ for multiple users.
|
||||||
It can be used in a classes of students, a corporate data science group or scientific
|
It can be used in a class of students, a corporate data science group or scientific
|
||||||
research group. It is a multi-user **Hub** that spawns, manages, and proxies multiple
|
research group. It is a multi-user **Hub** that spawns, manages, and proxies multiple
|
||||||
instances of the single-user `Jupyter notebook`_ server.
|
instances of the single-user `Jupyter notebook`_ server.
|
||||||
|
|
||||||
To make life easier, JupyterHub have distributions. Be sure to
|
To make life easier, JupyterHub has distributions. Be sure to
|
||||||
take a look at them before continuing with the configuration of the broad
|
take a look at them before continuing with the configuration of the broad
|
||||||
original system of `JupyterHub`_. Today, you can find two main cases:
|
original system of `JupyterHub`_. Today, you can find two main cases:
|
||||||
|
|
||||||
|
@@ -3,7 +3,7 @@
|
|||||||
The combination of [JupyterHub](https://jupyterhub.readthedocs.io) and [JupyterLab](https://jupyterlab.readthedocs.io)
|
The combination of [JupyterHub](https://jupyterhub.readthedocs.io) and [JupyterLab](https://jupyterlab.readthedocs.io)
|
||||||
is a great way to make shared computing resources available to a group.
|
is a great way to make shared computing resources available to a group.
|
||||||
|
|
||||||
These instructions are a guide for a manual, 'bare metal' install of [JupyterHub](https://jupyterhub.readthedocs.io)
|
These instructions are a guide for a manual, 'bare metal' install of [JupyterHub](https://jupyterhub.readthedocs.io)
|
||||||
and [JupyterLab](https://jupyterlab.readthedocs.io). This is ideal for running on a single server: build a beast
|
and [JupyterLab](https://jupyterlab.readthedocs.io). This is ideal for running on a single server: build a beast
|
||||||
of a machine and share it within your lab, or use a virtual machine from any VPS or cloud provider.
|
of a machine and share it within your lab, or use a virtual machine from any VPS or cloud provider.
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ Your own server with administrator (root) access. This could be a local machine,
|
|||||||
or VPS. Each user who will access JupyterHub should have a standard user account on the machine. The install will be done
|
or VPS. Each user who will access JupyterHub should have a standard user account on the machine. The install will be done
|
||||||
through the command line - useful if you log into your machine remotely using SSH.
|
through the command line - useful if you log into your machine remotely using SSH.
|
||||||
|
|
||||||
This tutorial was tested on **Ubuntu 18.04**. No other Linux distributions have been tested, but the instructions
|
This tutorial was tested on **Ubuntu 18.04**. No other Linux distributions have been tested, but the instructions
|
||||||
should be reasonably straightforward to adapt.
|
should be reasonably straightforward to adapt.
|
||||||
|
|
||||||
|
|
||||||
@@ -41,9 +41,9 @@ JupyterHub+JupyterLab as a 'app' or webservice, which will connect to the kernel
|
|||||||
|
|
||||||
|
|
||||||
The default JupyterHub Authenticator uses PAM to authenticate system users with their username and password. One can
|
The default JupyterHub Authenticator uses PAM to authenticate system users with their username and password. One can
|
||||||
[choose the authenticator](https://jupyterhub.readthedocs.io/en/stable/reference/authenticators.html#authenticators)
|
[choose the authenticator](https://jupyterhub.readthedocs.io/en/stable/reference/authenticators.html#authenticators)
|
||||||
that best suits their needs. In this guide we will use the default Authenticator because it makes it easy for everyone to manage data
|
that best suits their needs. In this guide we will use the default Authenticator because it makes it easy for everyone to manage data
|
||||||
in their home folder and to mix and match different services and access methods (e.g. SSH) which all work using the
|
in their home folder and to mix and match different services and access methods (e.g. SSH) which all work using the
|
||||||
Linux system user accounts. Therefore, each user of JupyterHub will need a standard system user account.
|
Linux system user accounts. Therefore, each user of JupyterHub will need a standard system user account.
|
||||||
|
|
||||||
Another goal of this guide is to use system provided packages wherever possible. This has the advantage that these packages
|
Another goal of this guide is to use system provided packages wherever possible. This has the advantage that these packages
|
||||||
@@ -62,10 +62,10 @@ Both jupyterlab and jupyterhub will be installed into this virtualenv. Create it
|
|||||||
sudo python3 -m venv /opt/jupyterhub/
|
sudo python3 -m venv /opt/jupyterhub/
|
||||||
```
|
```
|
||||||
|
|
||||||
Now we use pip to install the required Python packages into the new virtual environment. Be sure to install
|
Now we use pip to install the required Python packages into the new virtual environment. Be sure to install
|
||||||
`wheel` first. Since we are separating the user interface from the computing kernels, we don't install
|
`wheel` first. Since we are separating the user interface from the computing kernels, we don't install
|
||||||
any Python scientific packages here. The only exception is `ipywidgets` because this is needed to allow connection
|
any Python scientific packages here. The only exception is `ipywidgets` because this is needed to allow connection
|
||||||
between interactive tools running in the kernel and the user interface.
|
between interactive tools running in the kernel and the user interface.
|
||||||
|
|
||||||
Note that we use `/opt/jupyterhub/bin/python3 -m pip install` each time - this [makes sure](https://snarky.ca/why-you-should-use-python-m-pip/)
|
Note that we use `/opt/jupyterhub/bin/python3 -m pip install` each time - this [makes sure](https://snarky.ca/why-you-should-use-python-m-pip/)
|
||||||
that the packages are installed to the correct virtual environment.
|
that the packages are installed to the correct virtual environment.
|
||||||
@@ -88,14 +88,14 @@ sudo apt install nodejs npm
|
|||||||
Then install `configurable-http-proxy`:
|
Then install `configurable-http-proxy`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install -g configurable-http-proxy
|
sudo npm install -g configurable-http-proxy
|
||||||
```
|
```
|
||||||
|
|
||||||
### Create the configuration for JupyterHub
|
### Create the configuration for JupyterHub
|
||||||
|
|
||||||
Now we start creating configuration files. To keep everything together, we put all the configuration into the folder
|
Now we start creating configuration files. To keep everything together, we put all the configuration into the folder
|
||||||
created for the virtualenv, under `/opt/jupyterhub/etc/`. For each thing needing configuration, we will create a further
|
created for the virtualenv, under `/opt/jupyterhub/etc/`. For each thing needing configuration, we will create a further
|
||||||
subfolder and necessary files.
|
subfolder and necessary files.
|
||||||
|
|
||||||
First create the folder for the JupyterHub configuration and navigate to it:
|
First create the folder for the JupyterHub configuration and navigate to it:
|
||||||
|
|
||||||
@@ -110,19 +110,19 @@ sudo /opt/jupyterhub/bin/jupyterhub --generate-config
|
|||||||
```
|
```
|
||||||
This will produce the default configuration file `/opt/jupyterhub/etc/jupyterhub/jupyterhub_config.py`
|
This will produce the default configuration file `/opt/jupyterhub/etc/jupyterhub/jupyterhub_config.py`
|
||||||
|
|
||||||
You will need to edit the configuration file to make the JupyterLab interface by the default.
|
You will need to edit the configuration file to make the JupyterLab interface by the default.
|
||||||
Set the following configuration option in your `jupyterhub_config.py` file:
|
Set the following configuration option in your `jupyterhub_config.py` file:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
c.Spawner.default_url = '/lab'
|
c.Spawner.default_url = '/lab'
|
||||||
```
|
```
|
||||||
|
|
||||||
Further configuration options may be found in the documentation.
|
Further configuration options may be found in the documentation.
|
||||||
|
|
||||||
### Setup Systemd service
|
### Setup Systemd service
|
||||||
|
|
||||||
We will setup JupyterHub to run as a system service using Systemd (which is responsible for managing all services and
|
We will setup JupyterHub to run as a system service using Systemd (which is responsible for managing all services and
|
||||||
servers that run on startup in Ubuntu). We will create a service file in a suitable location in the virtualenv folder
|
servers that run on startup in Ubuntu). We will create a service file in a suitable location in the virtualenv folder
|
||||||
and then link it to the system services. First create the folder for the service file:
|
and then link it to the system services. First create the folder for the service file:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -167,7 +167,7 @@ Then tell systemd to reload its configuration files
|
|||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
```
|
```
|
||||||
|
|
||||||
And finally enable the service
|
And finally enable the service
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo systemctl enable jupyterhub.service
|
sudo systemctl enable jupyterhub.service
|
||||||
@@ -187,7 +187,7 @@ sudo systemctl status jupyterhub.service
|
|||||||
|
|
||||||
You should now be already be able to access jupyterhub using `<your servers ip>:8000` (assuming you haven't already set
|
You should now be already be able to access jupyterhub using `<your servers ip>:8000` (assuming you haven't already set
|
||||||
up a firewall or something). However, when you log in the jupyter notebooks will be trying to use the Python virtualenv
|
up a firewall or something). However, when you log in the jupyter notebooks will be trying to use the Python virtualenv
|
||||||
that was created to install JupyterHub, this is not what we want. So on to part 2
|
that was created to install JupyterHub, this is not what we want. So on to part 2
|
||||||
|
|
||||||
## Part 2: Conda environments
|
## Part 2: Conda environments
|
||||||
|
|
||||||
@@ -199,14 +199,14 @@ instructions are copied from [here](https://docs.conda.io/projects/conda/en/late
|
|||||||
|
|
||||||
Install Anacononda public gpg key to trusted store
|
Install Anacononda public gpg key to trusted store
|
||||||
```sh
|
```sh
|
||||||
curl https://repo.anaconda.com/pkgs/misc/gpgkeys/anaconda.asc | gpg --dearmor > conda.gpg
|
curl https://repo.anaconda.com/pkgs/misc/gpgkeys/anaconda.asc | gpg --dearmor > conda.gpg
|
||||||
sudo install -o root -g root -m 644 conda.gpg /etc/apt/trusted.gpg.d/
|
sudo install -o root -g root -m 644 conda.gpg /etc/apt/trusted.gpg.d/
|
||||||
```
|
```
|
||||||
|
|
||||||
Add Debian repo
|
Add Debian repo
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo echo "deb [arch=amd64] https://repo.anaconda.com/pkgs/misc/debrepo/conda stable main" > /etc/apt/sources.list.d/conda.list
|
echo "deb [arch=amd64] https://repo.anaconda.com/pkgs/misc/debrepo/conda stable main" | sudo tee /etc/apt/sources.list.d/conda.list
|
||||||
```
|
```
|
||||||
|
|
||||||
Install conda
|
Install conda
|
||||||
@@ -239,7 +239,7 @@ be the obvious default - call it whatever you like. You can install whatever you
|
|||||||
sudo /opt/conda/bin/conda create --prefix /opt/conda/envs/python python=3.7 ipykernel
|
sudo /opt/conda/bin/conda create --prefix /opt/conda/envs/python python=3.7 ipykernel
|
||||||
```
|
```
|
||||||
|
|
||||||
Once your env is set up as desired, make it visible to Jupyter by installing the kernel spec. There are two options here:
|
Once your env is set up as desired, make it visible to Jupyter by installing the kernel spec. There are two options here:
|
||||||
|
|
||||||
1 ) Install into the JupyterHub virtualenv - this ensures it overrides the default python version. It will only be visible
|
1 ) Install into the JupyterHub virtualenv - this ensures it overrides the default python version. It will only be visible
|
||||||
to the JupyterHub installation we have just created. This is useful to avoid conda environments appearing where they are not expected.
|
to the JupyterHub installation we have just created. This is useful to avoid conda environments appearing where they are not expected.
|
||||||
@@ -258,7 +258,7 @@ sudo /opt/conda/envs/python/bin/python -m ipykernel install --prefix /usr/local/
|
|||||||
|
|
||||||
### Setting up users' own conda environments
|
### Setting up users' own conda environments
|
||||||
|
|
||||||
There is relatively little for the administrator to do here, as users will have to set up their own environments using the shell.
|
There is relatively little for the administrator to do here, as users will have to set up their own environments using the shell.
|
||||||
On login they should run `conda init` or `/opt/conda/bin/conda`. The can then use conda to set up their environment,
|
On login they should run `conda init` or `/opt/conda/bin/conda`. The can then use conda to set up their environment,
|
||||||
although they must also install `ipykernel`. Once done, they can enable their kernel using:
|
although they must also install `ipykernel`. Once done, they can enable their kernel using:
|
||||||
|
|
||||||
@@ -269,21 +269,21 @@ although they must also install `ipykernel`. Once done, they can enable their ke
|
|||||||
This will place the kernel spec into their home folder, where Jupyter will look for it on startup.
|
This will place the kernel spec into their home folder, where Jupyter will look for it on startup.
|
||||||
|
|
||||||
|
|
||||||
## Setting up a reverse proxy
|
## Setting up a reverse proxy
|
||||||
|
|
||||||
The guide so far results in JupyterHub running on port 8000. It is not generally advisable to run open web services in
|
The guide so far results in JupyterHub running on port 8000. It is not generally advisable to run open web services in
|
||||||
this way - instead, use a reverse proxy running on standard HTTP/HTTPS ports.
|
this way - instead, use a reverse proxy running on standard HTTP/HTTPS ports.
|
||||||
|
|
||||||
> **Important**: Be aware of the security implications especially if you are running a server that is accessible from the open internet
|
> **Important**: Be aware of the security implications especially if you are running a server that is accessible from the open internet
|
||||||
> i.e. not protected within an institutional intranet or private home/office network. You should set up a firewall and
|
> i.e. not protected within an institutional intranet or private home/office network. You should set up a firewall and
|
||||||
> HTTPS encryption, which is outside of the scope of this guide. For HTTPS consider using [LetsEncrypt](https://letsencrypt.org/)
|
> HTTPS encryption, which is outside of the scope of this guide. For HTTPS consider using [LetsEncrypt](https://letsencrypt.org/)
|
||||||
> or setting up a [self-signed certificate](https://www.digitalocean.com/community/tutorials/how-to-create-a-self-signed-ssl-certificate-for-nginx-in-ubuntu-18-04).
|
> or setting up a [self-signed certificate](https://www.digitalocean.com/community/tutorials/how-to-create-a-self-signed-ssl-certificate-for-nginx-in-ubuntu-18-04).
|
||||||
> Firewalls may be set up using `ufs` or `firewalld` and combined with `fail2ban`.
|
> Firewalls may be set up using `ufw` or `firewalld` and combined with `fail2ban`.
|
||||||
|
|
||||||
### Using Nginx
|
### Using Nginx
|
||||||
Nginx is a mature and established web server and reverse proxy and is easy to install using `sudo apt install nginx`.
|
Nginx is a mature and established web server and reverse proxy and is easy to install using `sudo apt install nginx`.
|
||||||
Details on using Nginx as a reverse proxy can be found elsewhere. Here, we will only outline the additional steps needed
|
Details on using Nginx as a reverse proxy can be found elsewhere. Here, we will only outline the additional steps needed
|
||||||
to setup JupyterHub with Nginx and host it at a given URL e.g. `<your-server-ip-or-url>/jupyter`.
|
to setup JupyterHub with Nginx and host it at a given URL e.g. `<your-server-ip-or-url>/jupyter`.
|
||||||
This could be useful for example if you are running several services or web pages on the same server.
|
This could be useful for example if you are running several services or web pages on the same server.
|
||||||
|
|
||||||
To achieve this needs a few tweaks to both the JupyterHub configuration and the Nginx config. First, edit the
|
To achieve this needs a few tweaks to both the JupyterHub configuration and the Nginx config. First, edit the
|
||||||
@@ -299,7 +299,7 @@ Now Nginx must be configured with a to pass all traffic from `/jupyter` to the t
|
|||||||
Add the following snippet to your nginx configuration file (e.g. `/etc/nginx/sites-available/default`).
|
Add the following snippet to your nginx configuration file (e.g. `/etc/nginx/sites-available/default`).
|
||||||
|
|
||||||
```
|
```
|
||||||
location /jupyter/ {
|
location /jupyter/ {
|
||||||
# NOTE important to also set base url of jupyterhub to /jupyter in its config
|
# NOTE important to also set base url of jupyterhub to /jupyter in its config
|
||||||
proxy_pass http://127.0.0.1:8000;
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
|
||||||
@@ -316,6 +316,15 @@ Add the following snippet to your nginx configuration file (e.g. `/etc/nginx/sit
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Also add this snippet before the *server* block:
|
||||||
|
|
||||||
|
```
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Nginx will not run if there are errors in the configuration, check your configuration using:
|
Nginx will not run if there are errors in the configuration, check your configuration using:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -331,7 +340,7 @@ sudo systemctl restart nginx.service
|
|||||||
|
|
||||||
## Getting started using your new JupyterHub
|
## Getting started using your new JupyterHub
|
||||||
|
|
||||||
Once you have setup JupyterHub and Nginx proxy as described, you can browse to your JupyterHub IP or URL
|
Once you have setup JupyterHub and Nginx proxy as described, you can browse to your JupyterHub IP or URL
|
||||||
(e.g. if your server IP address is `123.456.789.1` and you decided to host JupyterHub at the `/jupyter` URL, browse
|
(e.g. if your server IP address is `123.456.789.1` and you decided to host JupyterHub at the `/jupyter` URL, browse
|
||||||
to `123.456.789.1/jupyter`). You will find a login page where you enter your Linux username and password. On login
|
to `123.456.789.1/jupyter`). You will find a login page where you enter your Linux username and password. On login
|
||||||
you will be presented with the JupyterLab interface, with the file browser pane showing the contents of your users'
|
you will be presented with the JupyterLab interface, with the file browser pane showing the contents of your users'
|
||||||
|
@@ -26,6 +26,10 @@ Before installing JupyterHub, you will need:
|
|||||||
The `nodejs-legacy` package installs the `node` executable and is currently
|
The `nodejs-legacy` package installs the `node` executable and is currently
|
||||||
required for npm to work on Debian/Ubuntu.
|
required for npm to work on Debian/Ubuntu.
|
||||||
|
|
||||||
|
- A [pluggable authentication module (PAM)](https://en.wikipedia.org/wiki/Pluggable_authentication_module)
|
||||||
|
to use the [default Authenticator](./getting-started/authenticators-users-basics.md).
|
||||||
|
PAM is often available by default on most distributions, if this is not the case it can be installed by
|
||||||
|
using the operating system's package manager.
|
||||||
- TLS certificate and key for HTTPS communication
|
- TLS certificate and key for HTTPS communication
|
||||||
- Domain name
|
- Domain name
|
||||||
|
|
||||||
|
@@ -235,10 +235,9 @@ to Spawner environment:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
class MyAuthenticator(Authenticator):
|
class MyAuthenticator(Authenticator):
|
||||||
@gen.coroutine
|
async def authenticate(self, handler, data=None):
|
||||||
def authenticate(self, handler, data=None):
|
username = await identify_user(handler, data)
|
||||||
username = yield identify_user(handler, data)
|
upstream_token = await token_for_user(username)
|
||||||
upstream_token = yield token_for_user(username)
|
|
||||||
return {
|
return {
|
||||||
'name': username,
|
'name': username,
|
||||||
'auth_state': {
|
'auth_state': {
|
||||||
@@ -246,10 +245,9 @@ class MyAuthenticator(Authenticator):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@gen.coroutine
|
async def pre_spawn_start(self, user, spawner):
|
||||||
def pre_spawn_start(self, user, spawner):
|
|
||||||
"""Pass upstream_token to spawner via environment variable"""
|
"""Pass upstream_token to spawner via environment variable"""
|
||||||
auth_state = yield user.get_auth_state()
|
auth_state = await user.get_auth_state()
|
||||||
if not auth_state:
|
if not auth_state:
|
||||||
# auth_state not enabled
|
# auth_state not enabled
|
||||||
return
|
return
|
||||||
|
@@ -52,7 +52,7 @@ c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']
|
|||||||
c.LocalAuthenticator.create_system_users = True
|
c.LocalAuthenticator.create_system_users = True
|
||||||
|
|
||||||
# specify users and admin
|
# specify users and admin
|
||||||
c.Authenticator.whitelist = {'rgbkrk', 'minrk', 'jhamrick'}
|
c.Authenticator.allowed_users = {'rgbkrk', 'minrk', 'jhamrick'}
|
||||||
c.Authenticator.admin_users = {'jhamrick', 'rgbkrk'}
|
c.Authenticator.admin_users = {'jhamrick', 'rgbkrk'}
|
||||||
|
|
||||||
# uses the default spawner
|
# uses the default spawner
|
||||||
|
@@ -83,8 +83,12 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
# websocket headers
|
# websocket headers
|
||||||
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header X-Scheme $scheme;
|
||||||
|
|
||||||
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Managing requests to verify letsencrypt host
|
# Managing requests to verify letsencrypt host
|
||||||
@@ -139,6 +143,20 @@ Now restart `nginx`, restart the JupyterHub, and enjoy accessing
|
|||||||
`https://HUB.DOMAIN.TLD` while serving other content securely on
|
`https://HUB.DOMAIN.TLD` while serving other content securely on
|
||||||
`https://NO_HUB.DOMAIN.TLD`.
|
`https://NO_HUB.DOMAIN.TLD`.
|
||||||
|
|
||||||
|
### SELinux permissions for nginx
|
||||||
|
On distributions with SELinux enabled (e.g. Fedora), one may encounter permission errors
|
||||||
|
when the nginx service is started.
|
||||||
|
|
||||||
|
We need to allow nginx to perform network relay and connect to the jupyterhub port. The
|
||||||
|
following commands do that:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
semanage port -a -t http_port_t -p tcp 8000
|
||||||
|
setsebool -P httpd_can_network_relay 1
|
||||||
|
setsebool -P httpd_can_network_connect 1
|
||||||
|
```
|
||||||
|
Replace 8000 with the port the jupyterhub server is running from.
|
||||||
|
|
||||||
|
|
||||||
## Apache
|
## Apache
|
||||||
|
|
||||||
@@ -199,8 +217,8 @@ In case of the need to run the jupyterhub under /jhub/ or other location please
|
|||||||
|
|
||||||
httpd.conf amendments:
|
httpd.conf amendments:
|
||||||
```bash
|
```bash
|
||||||
RewriteRule /jhub/(.*) ws://127.0.0.1:8000/jhub/$1 [P,L]
|
RewriteRule /jhub/(.*) ws://127.0.0.1:8000/jhub/$1 [NE.P,L]
|
||||||
RewriteRule /jhub/(.*) http://127.0.0.1:8000/jhub/$1 [P,L]
|
RewriteRule /jhub/(.*) http://127.0.0.1:8000/jhub/$1 [NE,P,L]
|
||||||
|
|
||||||
ProxyPass /jhub/ http://127.0.0.1:8000/jhub/
|
ProxyPass /jhub/ http://127.0.0.1:8000/jhub/
|
||||||
ProxyPassReverse /jhub/ http://127.0.0.1:8000/jhub/
|
ProxyPassReverse /jhub/ http://127.0.0.1:8000/jhub/
|
||||||
|
30
docs/source/reference/config-reference.rst
Normal file
30
docs/source/reference/config-reference.rst
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
==============================
|
||||||
|
Configuration Reference
|
||||||
|
==============================
|
||||||
|
|
||||||
|
.. important::
|
||||||
|
|
||||||
|
Make sure the version of JupyterHub for this documentation matches your
|
||||||
|
installation version, as the output of this command may change between versions.
|
||||||
|
|
||||||
|
JupyterHub configuration
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
As explained in the `Configuration Basics <../getting-started/config-basics.html#generate-a-default-config-file>`_
|
||||||
|
section, the ``jupyterhub_config.py`` can be automatically generated via
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
jupyterhub --generate-config
|
||||||
|
|
||||||
|
|
||||||
|
The following contains the output of that command for reference.
|
||||||
|
|
||||||
|
.. jupyterhub-generate-config::
|
||||||
|
|
||||||
|
JupyterHub help command output
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
This section contains the output of the command ``jupyterhub --help-all``.
|
||||||
|
|
||||||
|
.. jupyterhub-help-all::
|
@@ -57,7 +57,7 @@ To do this we add to `/etc/sudoers` (use `visudo` for safe editing of sudoers):
|
|||||||
For example:
|
For example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# comma-separated whitelist of users that can spawn single-user servers
|
# comma-separated list of users that can spawn single-user servers
|
||||||
# this should include all of your Hub users
|
# this should include all of your Hub users
|
||||||
Runas_Alias JUPYTER_USERS = rhea, zoe, wash
|
Runas_Alias JUPYTER_USERS = rhea, zoe, wash
|
||||||
|
|
||||||
@@ -120,6 +120,11 @@ the shadow password database.
|
|||||||
|
|
||||||
### Shadow group (Linux)
|
### Shadow group (Linux)
|
||||||
|
|
||||||
|
**Note:** On Fedora based distributions there is no clear way to configure
|
||||||
|
the PAM database to allow sufficient access for authenticating with the target user's password
|
||||||
|
from JupyterHub. As a workaround we recommend use an
|
||||||
|
[alternative authentication method](https://github.com/jupyterhub/jupyterhub/wiki/Authenticators).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ ls -l /etc/shadow
|
$ ls -l /etc/shadow
|
||||||
-rw-r----- 1 root shadow 2197 Jul 21 13:41 shadow
|
-rw-r----- 1 root shadow 2197 Jul 21 13:41 shadow
|
||||||
|
@@ -16,6 +16,7 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
|
|||||||
proxy
|
proxy
|
||||||
separate-proxy
|
separate-proxy
|
||||||
rest
|
rest
|
||||||
|
monitoring
|
||||||
database
|
database
|
||||||
templates
|
templates
|
||||||
../events/index
|
../events/index
|
||||||
@@ -24,3 +25,4 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
|
|||||||
config-ghoauth
|
config-ghoauth
|
||||||
config-proxy
|
config-proxy
|
||||||
config-sudo
|
config-sudo
|
||||||
|
config-reference
|
||||||
|
20
docs/source/reference/monitoring.rst
Normal file
20
docs/source/reference/monitoring.rst
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
Monitoring
|
||||||
|
==========
|
||||||
|
|
||||||
|
This section covers details on monitoring the state of your JupyterHub installation.
|
||||||
|
|
||||||
|
JupyterHub expose the ``/metrics`` endpoint that returns text describing its current
|
||||||
|
operational state formatted in a way `Prometheus <https://prometheus.io/docs/introduction/overview/>`_ understands.
|
||||||
|
|
||||||
|
Prometheus is a separate open source tool that can be configured to repeatedly poll
|
||||||
|
JupyterHub's ``/metrics`` endpoint to parse and save its current state.
|
||||||
|
|
||||||
|
By doing so, Prometheus can describe JupyterHub's evolving state over time.
|
||||||
|
This evolving state can then be accessed through Prometheus that expose its underlying
|
||||||
|
storage to those allowed to access it, and be presented with dashboards by a
|
||||||
|
tool like `Grafana <https://grafana.com/docs/grafana/latest/getting-started/what-is-grafana/>`_.
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
|
||||||
|
metrics
|
@@ -57,6 +57,9 @@ generating an API token is available from the JupyterHub user interface:
|
|||||||
|
|
||||||
## Add API tokens to the config file
|
## Add API tokens to the config file
|
||||||
|
|
||||||
|
**This is deprecated. We are in no rush to remove this feature,
|
||||||
|
but please consider if service tokens are right for you.**
|
||||||
|
|
||||||
You may also add a dictionary of API tokens and usernames to the hub's
|
You may also add a dictionary of API tokens and usernames to the hub's
|
||||||
configuration file, `jupyterhub_config.py` (note that
|
configuration file, `jupyterhub_config.py` (note that
|
||||||
the **key** is the 'secret-token' while the **value** is the 'username'):
|
the **key** is the 'secret-token' while the **value** is the 'username'):
|
||||||
@@ -67,6 +70,41 @@ c.JupyterHub.api_tokens = {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Updating to admin services
|
||||||
|
|
||||||
|
The `api_tokens` configuration has been softly deprecated since the introduction of services.
|
||||||
|
We have no plans to remove it,
|
||||||
|
but users are encouraged to use service configuration instead.
|
||||||
|
|
||||||
|
If you have been using `api_tokens` to create an admin user
|
||||||
|
and a token for that user to perform some automations,
|
||||||
|
the services mechanism may be a better fit.
|
||||||
|
If you have the following configuration:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.JupyterHub.admin_users = {"service-admin",}
|
||||||
|
c.JupyterHub.api_tokens = {
|
||||||
|
"secret-token": "service-admin",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This can be updated to create an admin service, with the following configuration:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.JupyterHub.services = [
|
||||||
|
{
|
||||||
|
"name": "service-token",
|
||||||
|
"admin": True,
|
||||||
|
"api_token": "secret-token",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
The token will have the same admin permissions,
|
||||||
|
but there will no longer be a user account created to house it.
|
||||||
|
The main noticeable difference is that there will be no notebook server associated with the account
|
||||||
|
and the service will not show up in the various user list pages and APIs.
|
||||||
|
|
||||||
## Make an API request
|
## Make an API request
|
||||||
|
|
||||||
To authenticate your requests, pass the API token in the request's
|
To authenticate your requests, pass the API token in the request's
|
||||||
|
@@ -50,7 +50,7 @@ A Service may have the following properties:
|
|||||||
|
|
||||||
If a service is also to be managed by the Hub, it has a few extra options:
|
If a service is also to be managed by the Hub, it has a few extra options:
|
||||||
|
|
||||||
- `command: (str/Popen list`) - Command for JupyterHub to spawn the service.
|
- `command: (str/Popen list)` - Command for JupyterHub to spawn the service.
|
||||||
- Only use this if the service should be a subprocess.
|
- Only use this if the service should be a subprocess.
|
||||||
- If command is not specified, the Service is assumed to be managed
|
- If command is not specified, the Service is assumed to be managed
|
||||||
externally.
|
externally.
|
||||||
@@ -91,9 +91,9 @@ This example would be configured as follows in `jupyterhub_config.py`:
|
|||||||
```python
|
```python
|
||||||
c.JupyterHub.services = [
|
c.JupyterHub.services = [
|
||||||
{
|
{
|
||||||
'name': 'cull-idle',
|
'name': 'idle-culler',
|
||||||
'admin': True,
|
'admin': True,
|
||||||
'command': [sys.executable, '/path/to/cull-idle.py', '--timeout']
|
'command': [sys.executable, '-m', 'jupyterhub_idle_culler', '--timeout=3600']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
@@ -123,15 +123,14 @@ For the previous 'cull idle' Service example, these environment variables
|
|||||||
would be passed to the Service when the Hub starts the 'cull idle' Service:
|
would be passed to the Service when the Hub starts the 'cull idle' Service:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
JUPYTERHUB_SERVICE_NAME: 'cull-idle'
|
JUPYTERHUB_SERVICE_NAME: 'idle-culler'
|
||||||
JUPYTERHUB_API_TOKEN: API token assigned to the service
|
JUPYTERHUB_API_TOKEN: API token assigned to the service
|
||||||
JUPYTERHUB_API_URL: http://127.0.0.1:8080/hub/api
|
JUPYTERHUB_API_URL: http://127.0.0.1:8080/hub/api
|
||||||
JUPYTERHUB_BASE_URL: https://mydomain[:port]
|
JUPYTERHUB_BASE_URL: https://mydomain[:port]
|
||||||
JUPYTERHUB_SERVICE_PREFIX: /services/cull-idle/
|
JUPYTERHUB_SERVICE_PREFIX: /services/idle-culler/
|
||||||
```
|
```
|
||||||
|
|
||||||
See the JupyterHub GitHub repo for additional information about the
|
See the GitHub repo for additional information about the [jupyterhub_idle_culler][].
|
||||||
[`cull-idle` example](https://github.com/jupyterhub/jupyterhub/tree/master/examples/cull-idle).
|
|
||||||
|
|
||||||
## Externally-Managed Services
|
## Externally-Managed Services
|
||||||
|
|
||||||
@@ -151,6 +150,8 @@ c.JupyterHub.services = [
|
|||||||
{
|
{
|
||||||
'name': 'my-web-service',
|
'name': 'my-web-service',
|
||||||
'url': 'https://10.0.1.1:1984',
|
'url': 'https://10.0.1.1:1984',
|
||||||
|
# any secret >8 characters, you'll use api_token to
|
||||||
|
# authenticate api requests to the hub from your service
|
||||||
'api_token': 'super-secret',
|
'api_token': 'super-secret',
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -313,7 +314,7 @@ class MyHandler(HubAuthenticated, web.RequestHandler):
|
|||||||
The HubAuth will automatically load the desired configuration from the Service
|
The HubAuth will automatically load the desired configuration from the Service
|
||||||
environment variables.
|
environment variables.
|
||||||
|
|
||||||
If you want to limit user access, you can whitelist users through either the
|
If you want to limit user access, you can specify allowed users through either the
|
||||||
`.hub_users` attribute or `.hub_groups`. These are sets that check against the
|
`.hub_users` attribute or `.hub_groups`. These are sets that check against the
|
||||||
username and user group list, respectively. If a user matches neither the user
|
username and user group list, respectively. If a user matches neither the user
|
||||||
list nor the group list, they will not be allowed access. If both are left
|
list nor the group list, they will not be allowed access. If both are left
|
||||||
@@ -331,12 +332,14 @@ and taking note of the following process:
|
|||||||
1. retrieve the cookie `jupyterhub-services` from the request.
|
1. retrieve the cookie `jupyterhub-services` from the request.
|
||||||
2. Make an API request `GET /hub/api/authorizations/cookie/jupyterhub-services/cookie-value`,
|
2. Make an API request `GET /hub/api/authorizations/cookie/jupyterhub-services/cookie-value`,
|
||||||
where cookie-value is the url-encoded value of the `jupyterhub-services` cookie.
|
where cookie-value is the url-encoded value of the `jupyterhub-services` cookie.
|
||||||
This request must be authenticated with a Hub API token in the `Authorization` header.
|
This request must be authenticated with a Hub API token in the `Authorization` header,
|
||||||
|
for example using the `api_token` from your [external service's configuration](#externally-managed-services).
|
||||||
|
|
||||||
For example, with [requests][]:
|
For example, with [requests][]:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
r = requests.get(
|
r = requests.get(
|
||||||
'/'.join((["http://127.0.0.1:8081/hub/api",
|
'/'.join(["http://127.0.0.1:8081/hub/api",
|
||||||
"authorizations/cookie/jupyterhub-services",
|
"authorizations/cookie/jupyterhub-services",
|
||||||
quote(encrypted_cookie, safe=''),
|
quote(encrypted_cookie, safe=''),
|
||||||
]),
|
]),
|
||||||
@@ -360,7 +363,7 @@ 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][nbviewer example] 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/ed942b10a52b6259099e2dd687930871dc8aac22/nbviewer/providers/base.py#L95).
|
||||||
nbviewer can also be run as a Hub-Managed Service as described [nbviewer README][nbviewer example]
|
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.
|
||||||
|
|
||||||
@@ -372,3 +375,4 @@ section on securing the notebook viewer.
|
|||||||
[HubAuth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
|
[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
|
||||||
|
[jupyterhub_idle_culler]: https://github.com/jupyterhub/jupyterhub-idle-culler
|
||||||
|
@@ -27,8 +27,8 @@ Some examples include:
|
|||||||
servers using batch systems
|
servers using batch systems
|
||||||
- [YarnSpawner](https://github.com/jupyterhub/yarnspawner) for spawning notebook
|
- [YarnSpawner](https://github.com/jupyterhub/yarnspawner) for spawning notebook
|
||||||
servers in YARN containers on a Hadoop cluster
|
servers in YARN containers on a Hadoop cluster
|
||||||
- [RemoteSpawner](https://github.com/zonca/remotespawner) to spawn notebooks
|
- [SSHSpawner](https://github.com/NERSC/sshspawner) to spawn notebooks
|
||||||
and a remote server and tunnel the port via SSH
|
on a remote server using SSH
|
||||||
|
|
||||||
|
|
||||||
## Spawner control methods
|
## Spawner control methods
|
||||||
|
@@ -7,8 +7,8 @@ problem and how to resolve it.
|
|||||||
[*Behavior*](#behavior)
|
[*Behavior*](#behavior)
|
||||||
- JupyterHub proxy fails to start
|
- JupyterHub proxy fails to start
|
||||||
- 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, allowed,
|
||||||
group whitelist) are set?
|
allowed groups) are set?
|
||||||
- JupyterHub Docker container not accessible at localhost
|
- JupyterHub Docker container not accessible at localhost
|
||||||
|
|
||||||
[*Errors*](#errors)
|
[*Errors*](#errors)
|
||||||
@@ -55,14 +55,14 @@ or add:
|
|||||||
|
|
||||||
to the config file, `jupyterhub_config.py`.
|
to the config file, `jupyterhub_config.py`.
|
||||||
|
|
||||||
### What is the default behavior when none of the lists (admin, whitelist, group whitelist) are set?
|
### What is the default behavior when none of the lists (admin, allowed, allowed groups) are set?
|
||||||
|
|
||||||
When nothing is given for these lists, there will be no admins, and all users
|
When nothing is given for these lists, there will be no admins, and all users
|
||||||
who can authenticate on the system (i.e. all the unix users on the server with
|
who can authenticate on the system (i.e. all the unix users on the server with
|
||||||
a password) will be allowed to start a server. The whitelist lets you limit
|
a password) will be allowed to start a server. The allowed username set lets you limit
|
||||||
this to a particular set of users, and the admin_users lets you specify who
|
this to a particular set of users, and 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 user list at runtime).
|
||||||
|
|
||||||
### JupyterHub Docker container not accessible at localhost
|
### JupyterHub Docker container not accessible at localhost
|
||||||
|
|
||||||
@@ -75,6 +75,50 @@ tell Jupyterhub to start at `0.0.0.0` which is visible to everyone. Try this
|
|||||||
command:
|
command:
|
||||||
`docker run -p 8000:8000 -d --name jupyterhub jupyterhub/jupyterhub jupyterhub --ip 0.0.0.0 --port 8000`
|
`docker run -p 8000:8000 -d --name jupyterhub jupyterhub/jupyterhub jupyterhub --ip 0.0.0.0 --port 8000`
|
||||||
|
|
||||||
|
### How can I kill ports from JupyterHub managed services that have been orphaned?
|
||||||
|
|
||||||
|
I started JupyterHub + nbgrader on the same host without containers. When I try to restart JupyterHub + nbgrader with this configuration, errors appear that the service accounts cannot start because the ports are being used.
|
||||||
|
|
||||||
|
How can I kill the processes that are using these ports?
|
||||||
|
|
||||||
|
Run the following command:
|
||||||
|
|
||||||
|
sudo kill -9 $(sudo lsof -t -i:<service_port>)
|
||||||
|
|
||||||
|
Where `<service_port>` is the port used by the nbgrader course service. This configuration is specified in `jupyterhub_config.py`.
|
||||||
|
|
||||||
|
### Why am I getting a Spawn failed error message?
|
||||||
|
|
||||||
|
After successfully logging in to JupyterHub with a compatible authenticators, I get a 'Spawn failed' error message in the browser. The JupyterHub logs have `jupyterhub KeyError: "getpwnam(): name not found: <my_user_name>`.
|
||||||
|
|
||||||
|
This issue occurs when the authenticator requires a local system user to exist. In these cases, you need to use a spawner
|
||||||
|
that does not require an existing system user account, such as `DockerSpawner` or `KubeSpawner`.
|
||||||
|
|
||||||
|
### How can I run JupyterHub with sudo but use my current env vars and virtualenv location?
|
||||||
|
|
||||||
|
When launching JupyterHub with `sudo jupyterhub` I get import errors and my environment variables don't work.
|
||||||
|
|
||||||
|
When launching services with `sudo ...` the shell won't have the same environment variables or `PATH`s in place. The most direct way to solve this issue is to use the full path to your python environment and add environment variables. For example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo MY_ENV=abc123 \
|
||||||
|
/home/foo/venv/bin/python3 \
|
||||||
|
/srv/jupyterhub/jupyterhub
|
||||||
|
```
|
||||||
|
|
||||||
|
### How can I view the logs for JupyterHub or the user's Notebook servers when using the DockerSpawner?
|
||||||
|
|
||||||
|
Use `docker logs <container>` where `<container>` is the container name defined within `docker-compose.yml`. For example, to view the logs of the JupyterHub container use:
|
||||||
|
|
||||||
|
docker logs hub
|
||||||
|
|
||||||
|
By default, the user's notebook server is named `jupyter-<username>` where `username` is the user's username within JupyterHub's db. So if you wanted to see the logs for user `foo` you would use:
|
||||||
|
|
||||||
|
docker logs jupyter-foo
|
||||||
|
|
||||||
|
You can also tail logs to view them in real time using the `-f` option:
|
||||||
|
|
||||||
|
docker logs -f hub
|
||||||
|
|
||||||
## Errors
|
## Errors
|
||||||
|
|
||||||
@@ -108,7 +152,7 @@ You should see a similar 200 message, as above, in the Hub log when you first
|
|||||||
visit your single-user notebook server. If you don't see this message in the log, it
|
visit your single-user notebook server. If you don't see this message in the log, it
|
||||||
may mean that your single-user notebook server isn't connecting to your Hub.
|
may mean that your single-user notebook server isn't connecting to your Hub.
|
||||||
|
|
||||||
If you see 403 (forbidden) like this, it's a token problem:
|
If you see 403 (forbidden) like this, it's likely a token problem:
|
||||||
|
|
||||||
```
|
```
|
||||||
403 GET /hub/api/authorizations/cookie/jupyterhub-token-name/[secret] (@10.0.1.4) 4.14ms
|
403 GET /hub/api/authorizations/cookie/jupyterhub-token-name/[secret] (@10.0.1.4) 4.14ms
|
||||||
@@ -152,6 +196,42 @@ After this, when you start your server via JupyterHub, it will build a
|
|||||||
new container. If this was the underlying cause of the issue, you should see
|
new container. If this was the underlying cause of the issue, you should see
|
||||||
your server again.
|
your server again.
|
||||||
|
|
||||||
|
##### Proxy settings (403 GET)
|
||||||
|
|
||||||
|
When your whole JupyterHub sits behind a organization proxy (*not* a reverse proxy like NGINX as part of your setup and *not* the configurable-http-proxy) the environment variables `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy` and `https_proxy` might be set. This confuses the jupyterhub-singleuser servers: When connecting to the Hub for authorization they connect via the proxy instead of directly connecting to the Hub on localhost. The proxy might deny the request (403 GET). This results in the singleuser server thinking it has a wrong auth token. To circumvent this you should add `<hub_url>,<hub_ip>,localhost,127.0.0.1` to the environment variables `NO_PROXY` and `no_proxy`.
|
||||||
|
|
||||||
|
### Launching Jupyter Notebooks to run as an externally managed JupyterHub service with the `jupyterhub-singleuser` command returns a `JUPYTERHUB_API_TOKEN` error
|
||||||
|
|
||||||
|
[JupyterHub services](https://jupyterhub.readthedocs.io/en/stable/reference/services.html) allow processes to interact with JupyterHub's REST API. Example use-cases include:
|
||||||
|
|
||||||
|
* **Secure Testing**: provide a canonical Jupyter Notebook for testing production data to reduce the number of entry points into production systems.
|
||||||
|
* **Grading Assignments**: provide access to shared Jupyter Notebooks that may be used for management tasks such grading assignments.
|
||||||
|
* **Private Dashboards**: share dashboards with certain group members.
|
||||||
|
|
||||||
|
If possible, try to run the Jupyter Notebook as an externally managed service with one of the provided [jupyter/docker-stacks](https://github.com/jupyter/docker-stacks).
|
||||||
|
|
||||||
|
Standard JupyterHub installations include a [jupyterhub-singleuser](https://github.com/jupyterhub/jupyterhub/blob/9fdab027daa32c9017845572ad9d5ba1722dbc53/setup.py#L116) command which is built from the `jupyterhub.singleuser:main` method. The `jupyterhub-singleuser` command is the default command when JupyterHub launches single-user Jupyter Notebooks. One of the goals of this command is to make sure the version of JupyterHub installed within the Jupyter Notebook coincides with the version of the JupyterHub server itself.
|
||||||
|
|
||||||
|
If you launch a Jupyter Notebook with the `jupyterhub-singleuser` command directly from the command line the Jupyter Notebook won't have access to the `JUPYTERHUB_API_TOKEN` and will return:
|
||||||
|
|
||||||
|
```
|
||||||
|
JUPYTERHUB_API_TOKEN env is required to run jupyterhub-singleuser.
|
||||||
|
Did you launch it manually?
|
||||||
|
```
|
||||||
|
|
||||||
|
If you plan on testing `jupyterhub-singleuser` independently from JupyterHub, then you can set the api token environment variable. For example, if were to run the single-user Jupyter Notebook on the host, then:
|
||||||
|
|
||||||
|
export JUPYTERHUB_API_TOKEN=my_secret_token
|
||||||
|
jupyterhub-singleuser
|
||||||
|
|
||||||
|
With a docker container, pass in the environment variable with the run command:
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
-p 8888:8888 \
|
||||||
|
-e JUPYTERHUB_API_TOKEN=my_secret_token \
|
||||||
|
jupyter/datascience-notebook:latest
|
||||||
|
|
||||||
|
[This example](https://github.com/jupyterhub/jupyterhub/tree/master/examples/service-notebook/external) demonstrates how to combine the use of the `jupyterhub-singleuser` environment variables when launching a Notebook as an externally managed service.
|
||||||
|
|
||||||
## How do I...?
|
## How do I...?
|
||||||
|
|
||||||
@@ -193,7 +273,7 @@ where `ssl_cert` is example-chained.crt and ssl_key to your private key.
|
|||||||
|
|
||||||
Then restart JupyterHub.
|
Then restart JupyterHub.
|
||||||
|
|
||||||
See also [JupyterHub SSL encryption](getting-started.md#ssl-encryption).
|
See also [JupyterHub SSL encryption](./getting-started/security-basics.html#ssl-encryption).
|
||||||
|
|
||||||
### Install JupyterHub without a network connection
|
### Install JupyterHub without a network connection
|
||||||
|
|
||||||
@@ -252,8 +332,7 @@ notebook servers to default to JupyterLab:
|
|||||||
### How do I set up JupyterHub for a workshop (when users are not known ahead of time)?
|
### How do I set up JupyterHub for a workshop (when users are not known ahead of time)?
|
||||||
|
|
||||||
1. Set up JupyterHub using OAuthenticator for GitHub authentication
|
1. Set up JupyterHub using OAuthenticator for GitHub authentication
|
||||||
2. Configure whitelist to be an empty list in` jupyterhub_config.py`
|
2. Configure admin list to have workshop leaders be listed with administrator privileges.
|
||||||
3. Configure admin list to have workshop leaders be listed with administrator privileges.
|
|
||||||
|
|
||||||
Users will need a GitHub account to login and be authenticated by the Hub.
|
Users will need a GitHub account to login and be authenticated by the Hub.
|
||||||
|
|
||||||
@@ -281,7 +360,6 @@ Or use syslog:
|
|||||||
|
|
||||||
jupyterhub | logger -t jupyterhub
|
jupyterhub | logger -t jupyterhub
|
||||||
|
|
||||||
|
|
||||||
## Troubleshooting commands
|
## Troubleshooting commands
|
||||||
|
|
||||||
The following commands provide additional detail about installed packages,
|
The following commands provide additional detail about installed packages,
|
||||||
|
@@ -1,41 +1,4 @@
|
|||||||
# `cull-idle` Example
|
# idle-culler example
|
||||||
|
|
||||||
The `cull_idle_servers.py` file provides a script to cull and shut down idle
|
The idle culler has been moved to its own repository at
|
||||||
single-user notebook servers. This script is used when `cull-idle` is run as
|
[jupyterhub/jupyterhub-idle-culler](https://github.com/jupyterhub/jupyterhub-idle-culler).
|
||||||
a Service or when it is run manually as a standalone script.
|
|
||||||
|
|
||||||
|
|
||||||
## Configure `cull-idle` to run as a Hub-Managed Service
|
|
||||||
|
|
||||||
In `jupyterhub_config.py`, add the following dictionary for the `cull-idle`
|
|
||||||
Service to the `c.JupyterHub.services` list:
|
|
||||||
|
|
||||||
```python
|
|
||||||
c.JupyterHub.services = [
|
|
||||||
{
|
|
||||||
'name': 'cull-idle',
|
|
||||||
'admin': True,
|
|
||||||
'command': [sys.executable, 'cull_idle_servers.py', '--timeout=3600'],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
where:
|
|
||||||
|
|
||||||
- `'admin': True` indicates that the Service has 'admin' permissions, and
|
|
||||||
- `'command'` indicates that the Service will be managed by the Hub.
|
|
||||||
|
|
||||||
## Run `cull-idle` manually as a standalone script
|
|
||||||
|
|
||||||
This will run `cull-idle` manually. `cull-idle` can be run as a standalone
|
|
||||||
script anywhere with access to the Hub, and will periodically check for idle
|
|
||||||
servers and shut them down via the Hub's REST API. In order to shutdown the
|
|
||||||
servers, the token given to cull-idle must have admin privileges.
|
|
||||||
|
|
||||||
Generate an API token and store it in the `JUPYTERHUB_API_TOKEN` environment
|
|
||||||
variable. Run `cull_idle_servers.py` manually.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export JUPYTERHUB_API_TOKEN=$(jupyterhub token)
|
|
||||||
python3 cull_idle_servers.py [--timeout=900] [--url=http://127.0.0.1:8081/hub/api]
|
|
||||||
```
|
|
||||||
|
@@ -1,401 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""script to monitor and cull idle single-user servers
|
|
||||||
|
|
||||||
Caveats:
|
|
||||||
|
|
||||||
last_activity is not updated with high frequency,
|
|
||||||
so cull timeout should be greater than the sum of:
|
|
||||||
|
|
||||||
- single-user websocket ping interval (default: 30s)
|
|
||||||
- JupyterHub.last_activity_interval (default: 5 minutes)
|
|
||||||
|
|
||||||
You can run this as a service managed by JupyterHub with this in your config::
|
|
||||||
|
|
||||||
|
|
||||||
c.JupyterHub.services = [
|
|
||||||
{
|
|
||||||
'name': 'cull-idle',
|
|
||||||
'admin': True,
|
|
||||||
'command': [sys.executable, 'cull_idle_servers.py', '--timeout=3600'],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
Or run it manually by generating an API token and storing it in `JUPYTERHUB_API_TOKEN`:
|
|
||||||
|
|
||||||
export JUPYTERHUB_API_TOKEN=$(jupyterhub token)
|
|
||||||
python3 cull_idle_servers.py [--timeout=900] [--url=http://127.0.0.1:8081/hub/api]
|
|
||||||
|
|
||||||
This script uses the same ``--timeout`` and ``--max-age`` values for
|
|
||||||
culling users and users' servers. If you want a different value for
|
|
||||||
users and servers, you should add this script to the services list
|
|
||||||
twice, just with different ``name``s, different values, and one with
|
|
||||||
the ``--cull-users`` option.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from datetime import datetime
|
|
||||||
from datetime import timezone
|
|
||||||
from functools import partial
|
|
||||||
|
|
||||||
try:
|
|
||||||
from urllib.parse import quote
|
|
||||||
except ImportError:
|
|
||||||
from urllib import quote
|
|
||||||
|
|
||||||
import dateutil.parser
|
|
||||||
|
|
||||||
from tornado.gen import coroutine, multi
|
|
||||||
from tornado.locks import Semaphore
|
|
||||||
from tornado.log import app_log
|
|
||||||
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
|
|
||||||
from tornado.ioloop import IOLoop, PeriodicCallback
|
|
||||||
from tornado.options import define, options, parse_command_line
|
|
||||||
|
|
||||||
|
|
||||||
def parse_date(date_string):
|
|
||||||
"""Parse a timestamp
|
|
||||||
|
|
||||||
If it doesn't have a timezone, assume utc
|
|
||||||
|
|
||||||
Returned datetime object will always be timezone-aware
|
|
||||||
"""
|
|
||||||
dt = dateutil.parser.parse(date_string)
|
|
||||||
if not dt.tzinfo:
|
|
||||||
# assume naïve timestamps are UTC
|
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
|
||||||
return dt
|
|
||||||
|
|
||||||
|
|
||||||
def format_td(td):
|
|
||||||
"""
|
|
||||||
Nicely format a timedelta object
|
|
||||||
|
|
||||||
as HH:MM:SS
|
|
||||||
"""
|
|
||||||
if td is None:
|
|
||||||
return "unknown"
|
|
||||||
if isinstance(td, str):
|
|
||||||
return td
|
|
||||||
seconds = int(td.total_seconds())
|
|
||||||
h = seconds // 3600
|
|
||||||
seconds = seconds % 3600
|
|
||||||
m = seconds // 60
|
|
||||||
seconds = seconds % 60
|
|
||||||
return "{h:02}:{m:02}:{seconds:02}".format(h=h, m=m, seconds=seconds)
|
|
||||||
|
|
||||||
|
|
||||||
@coroutine
|
|
||||||
def cull_idle(
|
|
||||||
url, api_token, inactive_limit, cull_users=False, max_age=0, concurrency=10
|
|
||||||
):
|
|
||||||
"""Shutdown idle single-user servers
|
|
||||||
|
|
||||||
If cull_users, inactive *users* will be deleted as well.
|
|
||||||
"""
|
|
||||||
auth_header = {'Authorization': 'token %s' % api_token}
|
|
||||||
req = HTTPRequest(url=url + '/users', headers=auth_header)
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
client = AsyncHTTPClient()
|
|
||||||
|
|
||||||
if concurrency:
|
|
||||||
semaphore = Semaphore(concurrency)
|
|
||||||
|
|
||||||
@coroutine
|
|
||||||
def fetch(req):
|
|
||||||
"""client.fetch wrapped in a semaphore to limit concurrency"""
|
|
||||||
yield semaphore.acquire()
|
|
||||||
try:
|
|
||||||
return (yield client.fetch(req))
|
|
||||||
finally:
|
|
||||||
yield semaphore.release()
|
|
||||||
|
|
||||||
else:
|
|
||||||
fetch = client.fetch
|
|
||||||
|
|
||||||
resp = yield fetch(req)
|
|
||||||
users = json.loads(resp.body.decode('utf8', 'replace'))
|
|
||||||
futures = []
|
|
||||||
|
|
||||||
@coroutine
|
|
||||||
def handle_server(user, server_name, server, max_age, inactive_limit):
|
|
||||||
"""Handle (maybe) culling a single server
|
|
||||||
|
|
||||||
"server" is the entire server model from the API.
|
|
||||||
|
|
||||||
Returns True if server is now stopped (user removable),
|
|
||||||
False otherwise.
|
|
||||||
"""
|
|
||||||
log_name = user['name']
|
|
||||||
if server_name:
|
|
||||||
log_name = '%s/%s' % (user['name'], server_name)
|
|
||||||
if server.get('pending'):
|
|
||||||
app_log.warning(
|
|
||||||
"Not culling server %s with pending %s", log_name, server['pending']
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# jupyterhub < 0.9 defined 'server.url' once the server was ready
|
|
||||||
# as an *implicit* signal that the server was ready.
|
|
||||||
# 0.9 adds a dedicated, explicit 'ready' field.
|
|
||||||
# By current (0.9) definitions, servers that have no pending
|
|
||||||
# events and are not ready shouldn't be in the model,
|
|
||||||
# but let's check just to be safe.
|
|
||||||
|
|
||||||
if not server.get('ready', bool(server['url'])):
|
|
||||||
app_log.warning(
|
|
||||||
"Not culling not-ready not-pending server %s: %s", log_name, server
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
if server.get('started'):
|
|
||||||
age = now - parse_date(server['started'])
|
|
||||||
else:
|
|
||||||
# started may be undefined on jupyterhub < 0.9
|
|
||||||
age = None
|
|
||||||
|
|
||||||
# check last activity
|
|
||||||
# last_activity can be None in 0.9
|
|
||||||
if server['last_activity']:
|
|
||||||
inactive = now - parse_date(server['last_activity'])
|
|
||||||
else:
|
|
||||||
# no activity yet, use start date
|
|
||||||
# last_activity may be None with jupyterhub 0.9,
|
|
||||||
# which introduces the 'started' field which is never None
|
|
||||||
# for running servers
|
|
||||||
inactive = age
|
|
||||||
|
|
||||||
# CUSTOM CULLING TEST CODE HERE
|
|
||||||
# Add in additional server tests here. Return False to mean "don't
|
|
||||||
# cull", True means "cull immediately", or, for example, update some
|
|
||||||
# other variables like inactive_limit.
|
|
||||||
#
|
|
||||||
# Here, server['state'] is the result of the get_state method
|
|
||||||
# on the spawner. This does *not* contain the below by
|
|
||||||
# default, you may have to modify your spawner to make this
|
|
||||||
# work. The `user` variable is the user model from the API.
|
|
||||||
#
|
|
||||||
# if server['state']['profile_name'] == 'unlimited'
|
|
||||||
# return False
|
|
||||||
# inactive_limit = server['state']['culltime']
|
|
||||||
|
|
||||||
should_cull = (
|
|
||||||
inactive is not None and inactive.total_seconds() >= inactive_limit
|
|
||||||
)
|
|
||||||
if should_cull:
|
|
||||||
app_log.info(
|
|
||||||
"Culling server %s (inactive for %s)", log_name, format_td(inactive)
|
|
||||||
)
|
|
||||||
|
|
||||||
if max_age and not should_cull:
|
|
||||||
# only check started if max_age is specified
|
|
||||||
# so that we can still be compatible with jupyterhub 0.8
|
|
||||||
# which doesn't define the 'started' field
|
|
||||||
if age is not None and age.total_seconds() >= max_age:
|
|
||||||
app_log.info(
|
|
||||||
"Culling server %s (age: %s, inactive for %s)",
|
|
||||||
log_name,
|
|
||||||
format_td(age),
|
|
||||||
format_td(inactive),
|
|
||||||
)
|
|
||||||
should_cull = True
|
|
||||||
|
|
||||||
if not should_cull:
|
|
||||||
app_log.debug(
|
|
||||||
"Not culling server %s (age: %s, inactive for %s)",
|
|
||||||
log_name,
|
|
||||||
format_td(age),
|
|
||||||
format_td(inactive),
|
|
||||||
)
|
|
||||||
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(url=delete_url, method='DELETE', headers=auth_header)
|
|
||||||
resp = yield fetch(req)
|
|
||||||
if resp.code == 202:
|
|
||||||
app_log.warning("Server %s is slow to stop", log_name)
|
|
||||||
# return False to prevent culling user with pending shutdowns
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
@coroutine
|
|
||||||
def handle_user(user):
|
|
||||||
"""Handle one user.
|
|
||||||
|
|
||||||
Create a list of their servers, and async exec them. Wait for
|
|
||||||
that to be done, and if all servers are stopped, possibly cull
|
|
||||||
the user.
|
|
||||||
"""
|
|
||||||
# shutdown servers first.
|
|
||||||
# Hub doesn't allow deleting users with running servers.
|
|
||||||
# jupyterhub 0.9 always provides a 'servers' model.
|
|
||||||
# 0.8 only does this when named servers are enabled.
|
|
||||||
if 'servers' in user:
|
|
||||||
servers = user['servers']
|
|
||||||
else:
|
|
||||||
# jupyterhub < 0.9 without named servers enabled.
|
|
||||||
# create servers dict with one entry for the default server
|
|
||||||
# from the user model.
|
|
||||||
# only if the server is running.
|
|
||||||
servers = {}
|
|
||||||
if user['server']:
|
|
||||||
servers[''] = {
|
|
||||||
'last_activity': user['last_activity'],
|
|
||||||
'pending': user['pending'],
|
|
||||||
'url': user['server'],
|
|
||||||
}
|
|
||||||
server_futures = [
|
|
||||||
handle_server(user, server_name, server, max_age, inactive_limit)
|
|
||||||
for server_name, server in servers.items()
|
|
||||||
]
|
|
||||||
results = yield multi(server_futures)
|
|
||||||
if not cull_users:
|
|
||||||
return
|
|
||||||
# some servers are still running, cannot cull users
|
|
||||||
still_alive = len(results) - sum(results)
|
|
||||||
if still_alive:
|
|
||||||
app_log.debug(
|
|
||||||
"Not culling user %s with %i servers still alive",
|
|
||||||
user['name'],
|
|
||||||
still_alive,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
should_cull = False
|
|
||||||
if user.get('created'):
|
|
||||||
age = now - parse_date(user['created'])
|
|
||||||
else:
|
|
||||||
# created may be undefined on jupyterhub < 0.9
|
|
||||||
age = None
|
|
||||||
|
|
||||||
# check last activity
|
|
||||||
# last_activity can be None in 0.9
|
|
||||||
if user['last_activity']:
|
|
||||||
inactive = now - parse_date(user['last_activity'])
|
|
||||||
else:
|
|
||||||
# no activity yet, use start date
|
|
||||||
# last_activity may be None with jupyterhub 0.9,
|
|
||||||
# which introduces the 'created' field which is never None
|
|
||||||
inactive = age
|
|
||||||
|
|
||||||
should_cull = (
|
|
||||||
inactive is not None and inactive.total_seconds() >= inactive_limit
|
|
||||||
)
|
|
||||||
if should_cull:
|
|
||||||
app_log.info("Culling user %s (inactive for %s)", user['name'], inactive)
|
|
||||||
|
|
||||||
if max_age and not should_cull:
|
|
||||||
# only check created if max_age is specified
|
|
||||||
# so that we can still be compatible with jupyterhub 0.8
|
|
||||||
# which doesn't define the 'started' field
|
|
||||||
if age is not None and age.total_seconds() >= max_age:
|
|
||||||
app_log.info(
|
|
||||||
"Culling user %s (age: %s, inactive for %s)",
|
|
||||||
user['name'],
|
|
||||||
format_td(age),
|
|
||||||
format_td(inactive),
|
|
||||||
)
|
|
||||||
should_cull = True
|
|
||||||
|
|
||||||
if not should_cull:
|
|
||||||
app_log.debug(
|
|
||||||
"Not culling user %s (created: %s, last active: %s)",
|
|
||||||
user['name'],
|
|
||||||
format_td(age),
|
|
||||||
format_td(inactive),
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
req = HTTPRequest(
|
|
||||||
url=url + '/users/%s' % user['name'], method='DELETE', headers=auth_header
|
|
||||||
)
|
|
||||||
yield fetch(req)
|
|
||||||
return True
|
|
||||||
|
|
||||||
for user in users:
|
|
||||||
futures.append((user['name'], handle_user(user)))
|
|
||||||
|
|
||||||
for (name, f) in futures:
|
|
||||||
try:
|
|
||||||
result = yield f
|
|
||||||
except Exception:
|
|
||||||
app_log.exception("Error processing %s", name)
|
|
||||||
else:
|
|
||||||
if result:
|
|
||||||
app_log.debug("Finished culling %s", name)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
define(
|
|
||||||
'url',
|
|
||||||
default=os.environ.get('JUPYTERHUB_API_URL'),
|
|
||||||
help="The JupyterHub API URL",
|
|
||||||
)
|
|
||||||
define('timeout', default=600, help="The idle timeout (in seconds)")
|
|
||||||
define(
|
|
||||||
'cull_every',
|
|
||||||
default=0,
|
|
||||||
help="The interval (in seconds) for checking for idle servers to cull",
|
|
||||||
)
|
|
||||||
define(
|
|
||||||
'max_age',
|
|
||||||
default=0,
|
|
||||||
help="The maximum age (in seconds) of servers that should be culled even if they are active",
|
|
||||||
)
|
|
||||||
define(
|
|
||||||
'cull_users',
|
|
||||||
default=False,
|
|
||||||
help="""Cull users in addition to servers.
|
|
||||||
This is for use in temporary-user cases such as tmpnb.""",
|
|
||||||
)
|
|
||||||
define(
|
|
||||||
'concurrency',
|
|
||||||
default=10,
|
|
||||||
help="""Limit the number of concurrent requests made to the Hub.
|
|
||||||
|
|
||||||
Deleting a lot of users at the same time can slow down the Hub,
|
|
||||||
so limit the number of API requests we have outstanding at any given time.
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
|
|
||||||
parse_command_line()
|
|
||||||
if not options.cull_every:
|
|
||||||
options.cull_every = options.timeout // 2
|
|
||||||
api_token = os.environ['JUPYTERHUB_API_TOKEN']
|
|
||||||
|
|
||||||
try:
|
|
||||||
AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient")
|
|
||||||
except ImportError as e:
|
|
||||||
app_log.warning(
|
|
||||||
"Could not load pycurl: %s\n"
|
|
||||||
"pycurl is recommended if you have a large number of users.",
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
|
|
||||||
loop = IOLoop.current()
|
|
||||||
cull = partial(
|
|
||||||
cull_idle,
|
|
||||||
url=options.url,
|
|
||||||
api_token=api_token,
|
|
||||||
inactive_limit=options.timeout,
|
|
||||||
cull_users=options.cull_users,
|
|
||||||
max_age=options.max_age,
|
|
||||||
concurrency=options.concurrency,
|
|
||||||
)
|
|
||||||
# schedule first cull immediately
|
|
||||||
# because PeriodicCallback doesn't start until the end of the first interval
|
|
||||||
loop.add_callback(cull)
|
|
||||||
# schedule periodic cull
|
|
||||||
pc = PeriodicCallback(cull, 1e3 * options.cull_every)
|
|
||||||
pc.start()
|
|
||||||
try:
|
|
||||||
loop.start()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
@@ -1,11 +0,0 @@
|
|||||||
import sys
|
|
||||||
|
|
||||||
# run cull-idle as a service
|
|
||||||
|
|
||||||
c.JupyterHub.services = [
|
|
||||||
{
|
|
||||||
'name': 'cull-idle',
|
|
||||||
'admin': True,
|
|
||||||
'command': [sys.executable, 'cull_idle_servers.py', '--timeout=3600'],
|
|
||||||
}
|
|
||||||
]
|
|
@@ -5,13 +5,11 @@ so all URLs and requests necessary for OAuth with JupyterHub should be in one pl
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from tornado import log
|
from tornado import log
|
||||||
from tornado import web
|
from tornado import web
|
||||||
from tornado.auth import OAuth2Mixin
|
|
||||||
from tornado.httpclient import AsyncHTTPClient
|
from tornado.httpclient import AsyncHTTPClient
|
||||||
from tornado.httpclient import HTTPRequest
|
from tornado.httpclient import HTTPRequest
|
||||||
from tornado.httputil import url_concat
|
from tornado.httputil import url_concat
|
||||||
|
@@ -27,7 +27,7 @@ that environment variable is set or `/` if it is not.
|
|||||||
Admin users can set the announcement text with an API token:
|
Admin users can set the announcement text with an API token:
|
||||||
|
|
||||||
$ curl -X POST -H "Authorization: token <token>" \
|
$ curl -X POST -H "Authorization: token <token>" \
|
||||||
-d "{'announcement':'JupyterHub will be upgraded on August 14!'}" \
|
-d '{"announcement":"JupyterHub will be upgraded on August 14!"}' \
|
||||||
https://.../services/announcement
|
https://.../services/announcement
|
||||||
|
|
||||||
Anyone can read the announcement:
|
Anyone can read the announcement:
|
||||||
|
@@ -4,7 +4,6 @@ import json
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from tornado import escape
|
from tornado import escape
|
||||||
from tornado import gen
|
|
||||||
from tornado import ioloop
|
from tornado import ioloop
|
||||||
from tornado import web
|
from tornado import web
|
||||||
|
|
||||||
|
@@ -1,6 +1,3 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
c.JupyterHub.services = [
|
c.JupyterHub.services = [
|
||||||
{
|
{
|
||||||
'name': 'whoami',
|
'name': 'whoami',
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
c.JupyterHub.services = [
|
c.JupyterHub.services = [
|
||||||
|
@@ -6,7 +6,6 @@ showing the user their own info.
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from getpass import getuser
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from tornado.httpserver import HTTPServer
|
from tornado.httpserver import HTTPServer
|
||||||
@@ -25,6 +24,7 @@ class WhoAmIHandler(HubOAuthenticated, RequestHandler):
|
|||||||
# `getuser()` here would mean only the user who started the service
|
# `getuser()` here would mean only the user who started the service
|
||||||
# can access the service:
|
# can access the service:
|
||||||
|
|
||||||
|
# from getpass import getuser
|
||||||
# hub_users = {getuser()}
|
# hub_users = {getuser()}
|
||||||
|
|
||||||
@authenticated
|
@authenticated
|
||||||
|
@@ -4,7 +4,6 @@ This serves `/services/whoami/`, authenticated with the Hub, showing the user th
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from getpass import getuser
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from tornado.httpserver import HTTPServer
|
from tornado.httpserver import HTTPServer
|
||||||
@@ -21,6 +20,7 @@ class WhoAmIHandler(HubAuthenticated, RequestHandler):
|
|||||||
# `getuser()` here would mean only the user who started the service
|
# `getuser()` here would mean only the user who started the service
|
||||||
# can access the service:
|
# can access the service:
|
||||||
|
|
||||||
|
# from getpass import getuser
|
||||||
# hub_users = {getuser()}
|
# hub_users = {getuser()}
|
||||||
|
|
||||||
@authenticated
|
@authenticated
|
||||||
|
@@ -1,4 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -exuo pipefail
|
set -exuo pipefail
|
||||||
|
|
||||||
|
# build jupyterhub-onbuild image
|
||||||
docker build --build-arg BASE_IMAGE=$DOCKER_REPO:$DOCKER_TAG -t ${DOCKER_REPO}-onbuild:$DOCKER_TAG onbuild
|
docker build --build-arg BASE_IMAGE=$DOCKER_REPO:$DOCKER_TAG -t ${DOCKER_REPO}-onbuild:$DOCKER_TAG onbuild
|
||||||
|
# build jupyterhub-demo image
|
||||||
|
docker build --build-arg BASE_IMAGE=${DOCKER_REPO}-onbuild:$DOCKER_TAG -t ${DOCKER_REPO}-demo:$DOCKER_TAG demo-image
|
||||||
|
@@ -2,8 +2,11 @@
|
|||||||
set -exuo pipefail
|
set -exuo pipefail
|
||||||
|
|
||||||
export ONBUILD=${DOCKER_REPO}-onbuild
|
export ONBUILD=${DOCKER_REPO}-onbuild
|
||||||
|
export DEMO=${DOCKER_REPO}-demo
|
||||||
|
export REPOS="${DOCKER_REPO} ${ONBUILD} ${DEMO}"
|
||||||
# push ONBUILD image
|
# push ONBUILD image
|
||||||
docker push $ONBUILD:$DOCKER_TAG
|
docker push $ONBUILD:$DOCKER_TAG
|
||||||
|
docker push $DEMO:$DOCKER_TAG
|
||||||
|
|
||||||
function get_hub_version() {
|
function get_hub_version() {
|
||||||
rm -f hub_version
|
rm -f hub_version
|
||||||
@@ -20,25 +23,20 @@ function get_hub_version() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
get_hub_version
|
get_hub_version
|
||||||
|
|
||||||
# when building master, push 0.9.0.dev as well
|
for repo in ${REPOS}; do
|
||||||
docker tag $DOCKER_REPO:$DOCKER_TAG $DOCKER_REPO:$hub_xyz
|
# when building master, push 0.9.0.dev as well
|
||||||
docker push $DOCKER_REPO:$hub_xyz
|
docker tag $repo:$DOCKER_TAG $repo:$hub_xyz
|
||||||
docker tag $ONBUILD:$DOCKER_TAG $ONBUILD:$hub_xyz
|
docker push $repo:$hub_xyz
|
||||||
docker push $ONBUILD:$hub_xyz
|
|
||||||
|
|
||||||
# when building 0.9.x, push 0.9 as well
|
# when building 0.9.x, push 0.9 as well
|
||||||
docker tag $DOCKER_REPO:$DOCKER_TAG $DOCKER_REPO:$hub_xy
|
docker tag $repo:$DOCKER_TAG $repo:$hub_xy
|
||||||
docker push $DOCKER_REPO:$hub_xy
|
docker push $repo:$hub_xy
|
||||||
docker tag $ONBUILD:$DOCKER_TAG $ONBUILD:$hub_xy
|
|
||||||
docker push $ONBUILD:$hub_xyz
|
|
||||||
|
|
||||||
# if building a stable release, tag latest as well
|
# if building a stable release, tag latest as well
|
||||||
if [[ "$latest" == "1" ]]; then
|
if [[ "$latest" == "1" ]]; then
|
||||||
docker tag $DOCKER_REPO:$DOCKER_TAG $DOCKER_REPO:latest
|
docker tag $repo:$DOCKER_TAG $repo:latest
|
||||||
docker push $DOCKER_REPO:latest
|
docker push $repo:latest
|
||||||
docker tag $ONBUILD:$DOCKER_TAG $ONBUILD:latest
|
fi
|
||||||
docker push $ONBUILD:latest
|
done
|
||||||
fi
|
|
||||||
|
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
version_info = (
|
version_info = (
|
||||||
1,
|
1,
|
||||||
1,
|
3,
|
||||||
0,
|
0,
|
||||||
# "", # release (b1, rc1, or "" for final or dev)
|
"", # release (b1, rc1, or "" for final or dev)
|
||||||
# "dev", # dev or nothing for beta/rc/stable releases
|
# "dev", # dev or nothing for beta/rc/stable releases
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,6 +18,15 @@ version_info = (
|
|||||||
|
|
||||||
__version__ = ".".join(map(str, version_info[:3])) + ".".join(version_info[3:])
|
__version__ = ".".join(map(str, version_info[:3])) + ".".join(version_info[3:])
|
||||||
|
|
||||||
|
# Singleton flag to only log the major/minor mismatch warning once per mismatch combo.
|
||||||
|
_version_mismatch_warning_logged = {}
|
||||||
|
|
||||||
|
|
||||||
|
def reset_globals():
|
||||||
|
"""Used to reset globals between test cases."""
|
||||||
|
global _version_mismatch_warning_logged
|
||||||
|
_version_mismatch_warning_logged = {}
|
||||||
|
|
||||||
|
|
||||||
def _check_version(hub_version, singleuser_version, log):
|
def _check_version(hub_version, singleuser_version, log):
|
||||||
"""Compare Hub and single-user server versions"""
|
"""Compare Hub and single-user server versions"""
|
||||||
@@ -42,19 +51,27 @@ def _check_version(hub_version, singleuser_version, log):
|
|||||||
hub_major_minor = V(hub_version).version[:2]
|
hub_major_minor = V(hub_version).version[:2]
|
||||||
singleuser_major_minor = V(singleuser_version).version[:2]
|
singleuser_major_minor = V(singleuser_version).version[:2]
|
||||||
extra = ""
|
extra = ""
|
||||||
|
do_log = True
|
||||||
if singleuser_major_minor == hub_major_minor:
|
if singleuser_major_minor == hub_major_minor:
|
||||||
# patch-level mismatch or lower, log difference at debug-level
|
# patch-level mismatch or lower, log difference at debug-level
|
||||||
# because this should be fine
|
# because this should be fine
|
||||||
log_method = log.debug
|
log_method = log.debug
|
||||||
else:
|
else:
|
||||||
# log warning-level for more significant mismatch, such as 0.8 vs 0.9, etc.
|
# log warning-level for more significant mismatch, such as 0.8 vs 0.9, etc.
|
||||||
log_method = log.warning
|
key = '%s-%s' % (hub_version, singleuser_version)
|
||||||
extra = " This could cause failure to authenticate and result in redirect loops!"
|
global _version_mismatch_warning_logged
|
||||||
log_method(
|
if _version_mismatch_warning_logged.get(key):
|
||||||
"jupyterhub version %s != jupyterhub-singleuser version %s." + extra,
|
do_log = False # We already logged this warning so don't log it again.
|
||||||
hub_version,
|
else:
|
||||||
singleuser_version,
|
log_method = log.warning
|
||||||
)
|
extra = " This could cause failure to authenticate and result in redirect loops!"
|
||||||
|
_version_mismatch_warning_logged[key] = True
|
||||||
|
if do_log:
|
||||||
|
log_method(
|
||||||
|
"jupyterhub version %s != jupyterhub-singleuser version %s." + extra,
|
||||||
|
hub_version,
|
||||||
|
singleuser_version,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
log.debug(
|
log.debug(
|
||||||
"jupyterhub and jupyterhub-singleuser both on version %s" % hub_version
|
"jupyterhub and jupyterhub-singleuser both on version %s" % hub_version
|
||||||
|
@@ -201,7 +201,7 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
|||||||
def needs_oauth_confirm(self, user, oauth_client):
|
def needs_oauth_confirm(self, user, oauth_client):
|
||||||
"""Return whether the given oauth client needs to prompt for access for the given user
|
"""Return whether the given oauth client needs to prompt for access for the given user
|
||||||
|
|
||||||
Checks whitelist for oauth clients
|
Checks list for oauth clients that don't need confirmation
|
||||||
|
|
||||||
(i.e. the user's own server)
|
(i.e. the user's own server)
|
||||||
|
|
||||||
@@ -214,9 +214,9 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
|||||||
if (
|
if (
|
||||||
# it's the user's own server
|
# it's the user's own server
|
||||||
oauth_client.identifier in own_oauth_client_ids
|
oauth_client.identifier in own_oauth_client_ids
|
||||||
# or it's in the global whitelist
|
# or it's in the global no-confirm list
|
||||||
or oauth_client.identifier
|
or oauth_client.identifier
|
||||||
in self.settings.get('oauth_no_confirm_whitelist', set())
|
in self.settings.get('oauth_no_confirm_list', set())
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
# default: require confirmation
|
# default: require confirmation
|
||||||
@@ -229,7 +229,7 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
|||||||
Render oauth confirmation page:
|
Render oauth confirmation page:
|
||||||
"Server at ... would like permission to ...".
|
"Server at ... would like permission to ...".
|
||||||
|
|
||||||
Users accessing their own server or a service whitelist
|
Users accessing their own server or a blessed service
|
||||||
will skip confirmation.
|
will skip confirmation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -253,7 +253,7 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
|||||||
# Render oauth 'Authorize application...' page
|
# Render oauth 'Authorize application...' page
|
||||||
auth_state = await self.current_user.get_auth_state()
|
auth_state = await self.current_user.get_auth_state()
|
||||||
self.write(
|
self.write(
|
||||||
self.render_template(
|
await self.render_template(
|
||||||
"oauth.html",
|
"oauth.html",
|
||||||
auth_state=auth_state,
|
auth_state=auth_state,
|
||||||
scopes=scopes,
|
scopes=scopes,
|
||||||
@@ -275,9 +275,26 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
|||||||
uri, http_method, body, headers = self.extract_oauth_params()
|
uri, http_method, body, headers = self.extract_oauth_params()
|
||||||
referer = self.request.headers.get('Referer', 'no referer')
|
referer = self.request.headers.get('Referer', 'no referer')
|
||||||
full_url = self.request.full_url()
|
full_url = self.request.full_url()
|
||||||
if referer != full_url:
|
# trim protocol, which cannot be trusted with multiple layers of proxies anyway
|
||||||
|
# Referer is set by browser, but full_url can be modified by proxy layers to appear as http
|
||||||
|
# when it is actually https
|
||||||
|
referer_proto, _, stripped_referer = referer.partition("://")
|
||||||
|
referer_proto = referer_proto.lower()
|
||||||
|
req_proto, _, stripped_full_url = full_url.partition("://")
|
||||||
|
req_proto = req_proto.lower()
|
||||||
|
if referer_proto != req_proto:
|
||||||
|
self.log.warning("Protocol mismatch: %s != %s", referer, full_url)
|
||||||
|
if req_proto == "https":
|
||||||
|
# insecure origin to secure target is not allowed
|
||||||
|
raise web.HTTPError(
|
||||||
|
403, "Not allowing authorization form submitted from insecure page"
|
||||||
|
)
|
||||||
|
if stripped_referer != stripped_full_url:
|
||||||
# OAuth post must be made to the URL it came from
|
# OAuth post must be made to the URL it came from
|
||||||
self.log.error("OAuth POST from %s != %s", referer, full_url)
|
self.log.error("Original OAuth POST from %s != %s", referer, full_url)
|
||||||
|
self.log.error(
|
||||||
|
"Stripped OAuth POST from %s != %s", stripped_referer, stripped_full_url
|
||||||
|
)
|
||||||
raise web.HTTPError(
|
raise web.HTTPError(
|
||||||
403, "Authorization form must be sent from authorization page"
|
403, "Authorization form must be sent from authorization page"
|
||||||
)
|
)
|
||||||
|
@@ -3,7 +3,6 @@
|
|||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from tornado import gen
|
|
||||||
from tornado import web
|
from tornado import web
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
|
@@ -16,9 +16,9 @@ class ShutdownAPIHandler(APIHandler):
|
|||||||
@admin_only
|
@admin_only
|
||||||
def post(self):
|
def post(self):
|
||||||
"""POST /api/shutdown triggers a clean shutdown
|
"""POST /api/shutdown triggers a clean shutdown
|
||||||
|
|
||||||
POST (JSON) parameters:
|
POST (JSON) parameters:
|
||||||
|
|
||||||
- servers: specify whether single-user servers should be terminated
|
- servers: specify whether single-user servers should be terminated
|
||||||
- proxy: specify whether the proxy should be terminated
|
- proxy: specify whether the proxy should be terminated
|
||||||
"""
|
"""
|
||||||
@@ -57,7 +57,7 @@ class RootAPIHandler(APIHandler):
|
|||||||
"""GET /api/ returns info about the Hub and its API.
|
"""GET /api/ returns info about the Hub and its API.
|
||||||
|
|
||||||
It is not an authenticated endpoint.
|
It is not an authenticated endpoint.
|
||||||
|
|
||||||
For now, it just returns the version of JupyterHub itself.
|
For now, it just returns the version of JupyterHub itself.
|
||||||
"""
|
"""
|
||||||
data = {'version': __version__}
|
data = {'version': __version__}
|
||||||
@@ -70,7 +70,7 @@ class InfoAPIHandler(APIHandler):
|
|||||||
"""GET /api/info returns detailed info about the Hub and its API.
|
"""GET /api/info returns detailed info about the Hub and its API.
|
||||||
|
|
||||||
It is not an authenticated endpoint.
|
It is not an authenticated endpoint.
|
||||||
|
|
||||||
For now, it just returns the version of JupyterHub itself.
|
For now, it just returns the version of JupyterHub itself.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@@ -2,12 +2,9 @@
|
|||||||
# 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.
|
||||||
import json
|
import json
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from tornado import gen
|
|
||||||
from tornado import web
|
from tornado import web
|
||||||
|
|
||||||
from .. import orm
|
|
||||||
from ..utils import admin_only
|
from ..utils import admin_only
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
|
|
||||||
|
@@ -23,6 +23,7 @@ def service_model(service):
|
|||||||
'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,
|
'info': service.info,
|
||||||
|
'display': service.display,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@@ -9,6 +9,7 @@ from datetime import timezone
|
|||||||
|
|
||||||
from async_generator import aclosing
|
from async_generator import aclosing
|
||||||
from dateutil.parser import parse as parse_date
|
from dateutil.parser import parse as parse_date
|
||||||
|
from sqlalchemy import func
|
||||||
from tornado import web
|
from tornado import web
|
||||||
from tornado.iostream import StreamClosedError
|
from tornado.iostream import StreamClosedError
|
||||||
|
|
||||||
@@ -35,15 +36,69 @@ class SelfAPIHandler(APIHandler):
|
|||||||
user = self.get_current_user_oauth_token()
|
user = self.get_current_user_oauth_token()
|
||||||
if user is None:
|
if user is None:
|
||||||
raise web.HTTPError(403)
|
raise web.HTTPError(403)
|
||||||
self.write(json.dumps(self.user_model(user)))
|
if isinstance(user, orm.Service):
|
||||||
|
model = self.service_model(user)
|
||||||
|
else:
|
||||||
|
model = self.user_model(user)
|
||||||
|
self.write(json.dumps(model))
|
||||||
|
|
||||||
|
|
||||||
class UserListAPIHandler(APIHandler):
|
class UserListAPIHandler(APIHandler):
|
||||||
|
def _user_has_ready_spawner(self, orm_user):
|
||||||
|
"""Return True if a user has *any* ready spawners
|
||||||
|
|
||||||
|
Used for filtering from active -> ready
|
||||||
|
"""
|
||||||
|
user = self.users[orm_user]
|
||||||
|
return any(spawner.ready for spawner in user.spawners.values())
|
||||||
|
|
||||||
@admin_only
|
@admin_only
|
||||||
def get(self):
|
def get(self):
|
||||||
|
state_filter = self.get_argument("state", None)
|
||||||
|
|
||||||
|
# post_filter
|
||||||
|
post_filter = None
|
||||||
|
|
||||||
|
if state_filter in {"active", "ready"}:
|
||||||
|
# only get users with active servers
|
||||||
|
# an 'active' Spawner has a server record in the database
|
||||||
|
# which means Spawner.server != None
|
||||||
|
# it may still be in a pending start/stop state.
|
||||||
|
# join filters out users with no Spawners
|
||||||
|
query = (
|
||||||
|
self.db.query(orm.User)
|
||||||
|
# join filters out any Users with no Spawners
|
||||||
|
.join(orm.Spawner)
|
||||||
|
# this implicitly gets Users with *any* active server
|
||||||
|
.filter(orm.Spawner.server != None)
|
||||||
|
)
|
||||||
|
if state_filter == "ready":
|
||||||
|
# have to post-process query results because active vs ready
|
||||||
|
# can only be distinguished with in-memory Spawner properties
|
||||||
|
post_filter = self._user_has_ready_spawner
|
||||||
|
|
||||||
|
elif state_filter == "inactive":
|
||||||
|
# only get users with *no* active servers
|
||||||
|
# as opposed to users with *any inactive servers*
|
||||||
|
# this is the complement to the above query.
|
||||||
|
# how expensive is this with lots of servers?
|
||||||
|
query = (
|
||||||
|
self.db.query(orm.User)
|
||||||
|
.outerjoin(orm.Spawner)
|
||||||
|
.outerjoin(orm.Server)
|
||||||
|
.group_by(orm.User.id)
|
||||||
|
.having(func.count(orm.Server.id) == 0)
|
||||||
|
)
|
||||||
|
elif state_filter:
|
||||||
|
raise web.HTTPError(400, "Unrecognized state filter: %r" % state_filter)
|
||||||
|
else:
|
||||||
|
# no filter, return all users
|
||||||
|
query = self.db.query(orm.User)
|
||||||
|
|
||||||
data = [
|
data = [
|
||||||
self.user_model(u, include_servers=True, include_state=True)
|
self.user_model(u, include_servers=True, include_state=True)
|
||||||
for u in self.db.query(orm.User)
|
for u in query
|
||||||
|
if (post_filter is None or post_filter(u))
|
||||||
]
|
]
|
||||||
self.write(json.dumps(data))
|
self.write(json.dumps(data))
|
||||||
|
|
||||||
@@ -625,14 +680,14 @@ def _parse_timestamp(timestamp):
|
|||||||
|
|
||||||
- raise HTTPError(400) on parse error
|
- raise HTTPError(400) on parse error
|
||||||
- handle and strip tz info for internal consistency
|
- handle and strip tz info for internal consistency
|
||||||
(we use naïve utc timestamps everywhere)
|
(we use naive utc timestamps everywhere)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
dt = parse_date(timestamp)
|
dt = parse_date(timestamp)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise web.HTTPError(400, "Not a valid timestamp: %r", timestamp)
|
raise web.HTTPError(400, "Not a valid timestamp: %r", timestamp)
|
||||||
if dt.tzinfo:
|
if dt.tzinfo:
|
||||||
# strip timezone info to naïve UTC datetime
|
# strip timezone info to naive UTC datetime
|
||||||
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
@@ -29,6 +29,14 @@ from urllib.parse import urlunparse
|
|||||||
if sys.version_info[:2] < (3, 3):
|
if sys.version_info[:2] < (3, 3):
|
||||||
raise ValueError("Python < 3.3 not supported: %s" % sys.version)
|
raise ValueError("Python < 3.3 not supported: %s" % sys.version)
|
||||||
|
|
||||||
|
# For compatibility with python versions 3.6 or earlier.
|
||||||
|
# asyncio.Task.all_tasks() is fully moved to asyncio.all_tasks() starting with 3.9. Also applies to current_task.
|
||||||
|
try:
|
||||||
|
asyncio_all_tasks = asyncio.all_tasks
|
||||||
|
asyncio_current_task = asyncio.current_task
|
||||||
|
except AttributeError as e:
|
||||||
|
asyncio_all_tasks = asyncio.Task.all_tasks
|
||||||
|
asyncio_current_task = asyncio.Task.current_task
|
||||||
|
|
||||||
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
|
||||||
@@ -55,6 +63,7 @@ from traitlets import (
|
|||||||
Instance,
|
Instance,
|
||||||
Bytes,
|
Bytes,
|
||||||
Float,
|
Float,
|
||||||
|
Union,
|
||||||
observe,
|
observe,
|
||||||
default,
|
default,
|
||||||
validate,
|
validate,
|
||||||
@@ -76,6 +85,7 @@ from .user import UserDict
|
|||||||
from .oauth.provider import make_provider
|
from .oauth.provider import make_provider
|
||||||
from ._data import DATA_FILES_PATH
|
from ._data import DATA_FILES_PATH
|
||||||
from .log import CoroutineLogFormatter, log_request
|
from .log import CoroutineLogFormatter, log_request
|
||||||
|
from .pagination import Pagination
|
||||||
from .proxy import Proxy, ConfigurableHTTPProxy
|
from .proxy import Proxy, ConfigurableHTTPProxy
|
||||||
from .traitlets import URLPrefix, Command, EntryPointType, Callable
|
from .traitlets import URLPrefix, Command, EntryPointType, Callable
|
||||||
from .utils import (
|
from .utils import (
|
||||||
@@ -278,7 +288,7 @@ class JupyterHub(Application):
|
|||||||
|
|
||||||
@default('classes')
|
@default('classes')
|
||||||
def _load_classes(self):
|
def _load_classes(self):
|
||||||
classes = [Spawner, Authenticator, CryptKeeper]
|
classes = [Spawner, Authenticator, CryptKeeper, Pagination]
|
||||||
for name, trait in self.traits(config=True).items():
|
for name, trait in self.traits(config=True).items():
|
||||||
# load entry point groups into configurable class list
|
# load entry point groups into configurable class list
|
||||||
# so that they show up in config files, etc.
|
# so that they show up in config files, etc.
|
||||||
@@ -316,7 +326,7 @@ class JupyterHub(Application):
|
|||||||
|
|
||||||
@validate("config_file")
|
@validate("config_file")
|
||||||
def _validate_config_file(self, proposal):
|
def _validate_config_file(self, proposal):
|
||||||
if not os.path.isfile(proposal.value):
|
if not self.generate_config and not os.path.isfile(proposal.value):
|
||||||
print(
|
print(
|
||||||
"ERROR: Failed to find specified config file: {}".format(
|
"ERROR: Failed to find specified config file: {}".format(
|
||||||
proposal.value
|
proposal.value
|
||||||
@@ -561,10 +571,23 @@ class JupyterHub(Application):
|
|||||||
def _url_part_changed(self, change):
|
def _url_part_changed(self, change):
|
||||||
"""propagate deprecated ip/port/base_url config to the bind_url"""
|
"""propagate deprecated ip/port/base_url config to the bind_url"""
|
||||||
urlinfo = urlparse(self.bind_url)
|
urlinfo = urlparse(self.bind_url)
|
||||||
urlinfo = urlinfo._replace(netloc='%s:%i' % (self.ip, self.port))
|
if ':' in self.ip:
|
||||||
|
fmt = '[%s]:%i'
|
||||||
|
else:
|
||||||
|
fmt = '%s:%i'
|
||||||
|
urlinfo = urlinfo._replace(netloc=fmt % (self.ip, self.port))
|
||||||
urlinfo = urlinfo._replace(path=self.base_url)
|
urlinfo = urlinfo._replace(path=self.base_url)
|
||||||
bind_url = urlunparse(urlinfo)
|
bind_url = urlunparse(urlinfo)
|
||||||
|
|
||||||
|
# Warn if both bind_url and ip/port/base_url are set
|
||||||
if bind_url != self.bind_url:
|
if bind_url != self.bind_url:
|
||||||
|
if self.bind_url != self._bind_url_default():
|
||||||
|
self.log.warning(
|
||||||
|
"Both bind_url and ip/port/base_url have been configured. "
|
||||||
|
"JupyterHub.ip, JupyterHub.port, JupyterHub.base_url are"
|
||||||
|
" deprecated in JupyterHub 0.9,"
|
||||||
|
" please use JupyterHub.bind_url instead."
|
||||||
|
)
|
||||||
self.bind_url = bind_url
|
self.bind_url = bind_url
|
||||||
|
|
||||||
bind_url = Unicode(
|
bind_url = Unicode(
|
||||||
@@ -576,6 +599,22 @@ class JupyterHub(Application):
|
|||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
|
@validate('bind_url')
|
||||||
|
def _validate_bind_url(self, proposal):
|
||||||
|
"""ensure protocol field of bind_url matches ssl"""
|
||||||
|
v = proposal['value']
|
||||||
|
proto, sep, rest = v.partition('://')
|
||||||
|
if self.ssl_cert and proto != 'https':
|
||||||
|
return 'https' + sep + rest
|
||||||
|
elif proto != 'http' and not self.ssl_cert:
|
||||||
|
return 'http' + sep + rest
|
||||||
|
return v
|
||||||
|
|
||||||
|
@default('bind_url')
|
||||||
|
def _bind_url_default(self):
|
||||||
|
proto = 'https' if self.ssl_cert else 'http'
|
||||||
|
return proto + '://:8000'
|
||||||
|
|
||||||
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.
|
||||||
@@ -711,10 +750,10 @@ class JupyterHub(Application):
|
|||||||
help="""The ip or hostname for proxies and spawners to use
|
help="""The ip or hostname for proxies and spawners to use
|
||||||
for connecting to the Hub.
|
for connecting to the Hub.
|
||||||
|
|
||||||
Use when the bind address (`hub_ip`) is 0.0.0.0 or otherwise different
|
Use when the bind address (`hub_ip`) is 0.0.0.0, :: or otherwise different
|
||||||
from the connect address.
|
from the connect address.
|
||||||
|
|
||||||
Default: when `hub_ip` is 0.0.0.0, use `socket.gethostname()`, otherwise use `hub_ip`.
|
Default: when `hub_ip` is 0.0.0.0 or ::, use `socket.gethostname()`, otherwise use `hub_ip`.
|
||||||
|
|
||||||
Note: Some spawners or proxy implementations might not support hostnames. Check your
|
Note: Some spawners or proxy implementations might not support hostnames. Check your
|
||||||
spawner or proxy documentation to see if they have extra requirements.
|
spawner or proxy documentation to see if they have extra requirements.
|
||||||
@@ -917,6 +956,25 @@ class JupyterHub(Application):
|
|||||||
def _authenticator_default(self):
|
def _authenticator_default(self):
|
||||||
return self.authenticator_class(parent=self, db=self.db)
|
return self.authenticator_class(parent=self, db=self.db)
|
||||||
|
|
||||||
|
implicit_spawn_seconds = Float(
|
||||||
|
0,
|
||||||
|
help="""Trigger implicit spawns after this many seconds.
|
||||||
|
|
||||||
|
When a user visits a URL for a server that's not running,
|
||||||
|
they are shown a page indicating that the requested server
|
||||||
|
is not running with a button to spawn the server.
|
||||||
|
|
||||||
|
Setting this to a positive value will redirect the user
|
||||||
|
after this many seconds, effectively clicking this button
|
||||||
|
automatically for the users,
|
||||||
|
automatically beginning the spawn process.
|
||||||
|
|
||||||
|
Warning: this can result in errors and surprising behavior
|
||||||
|
when sharing access URLs to actual servers,
|
||||||
|
since the wrong server is likely to be started.
|
||||||
|
""",
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
allow_named_servers = Bool(
|
allow_named_servers = Bool(
|
||||||
False, help="Allow named single-user servers per user"
|
False, help="Allow named single-user servers per user"
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
@@ -1266,12 +1324,25 @@ class JupyterHub(Application):
|
|||||||
"""
|
"""
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
default_url = Unicode(
|
default_url = Union(
|
||||||
|
[Unicode(), Callable()],
|
||||||
help="""
|
help="""
|
||||||
The default URL for users when they arrive (e.g. when user directs to "/")
|
The default URL for users when they arrive (e.g. when user directs to "/")
|
||||||
|
|
||||||
By default, redirects users to their own server.
|
By default, redirects users to their own server.
|
||||||
"""
|
|
||||||
|
Can be a Unicode string (e.g. '/hub/home') or a callable based on the handler object:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
def default_url_fn(handler):
|
||||||
|
user = handler.current_user
|
||||||
|
if user and user.admin:
|
||||||
|
return '/hub/admin'
|
||||||
|
return '/hub/home'
|
||||||
|
|
||||||
|
c.JupyterHub.default_url = default_url_fn
|
||||||
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
user_redirect_hook = Callable(
|
user_redirect_hook = Callable(
|
||||||
@@ -1641,22 +1712,22 @@ class JupyterHub(Application):
|
|||||||
# the admin_users config variable will never be used after this point.
|
# the admin_users config variable will never be used after this point.
|
||||||
# only the database values will be referenced.
|
# only the database values will be referenced.
|
||||||
|
|
||||||
whitelist = [
|
allowed_users = [
|
||||||
self.authenticator.normalize_username(name)
|
self.authenticator.normalize_username(name)
|
||||||
for name in self.authenticator.whitelist
|
for name in self.authenticator.allowed_users
|
||||||
]
|
]
|
||||||
self.authenticator.whitelist = set(whitelist) # force normalization
|
self.authenticator.allowed_users = set(allowed_users) # force normalization
|
||||||
for username in whitelist:
|
for username in allowed_users:
|
||||||
if not self.authenticator.validate_username(username):
|
if not self.authenticator.validate_username(username):
|
||||||
raise ValueError("username %r is not valid" % username)
|
raise ValueError("username %r is not valid" % username)
|
||||||
|
|
||||||
if not whitelist:
|
if not allowed_users:
|
||||||
self.log.info(
|
self.log.info(
|
||||||
"Not using whitelist. Any authenticated user will be allowed."
|
"Not using allowed_users. Any authenticated user will be allowed."
|
||||||
)
|
)
|
||||||
|
|
||||||
# add whitelisted users to the db
|
# add allowed users to the db
|
||||||
for name in whitelist:
|
for name in allowed_users:
|
||||||
user = orm.User.find(db, name)
|
user = orm.User.find(db, name)
|
||||||
if user is None:
|
if user is None:
|
||||||
user = orm.User(name=name)
|
user = orm.User(name=name)
|
||||||
@@ -1666,13 +1737,16 @@ class JupyterHub(Application):
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Notify authenticator of all users.
|
# Notify authenticator of all users.
|
||||||
# This ensures Auth whitelist is up-to-date with the database.
|
# This ensures Authenticator.allowed_users is up-to-date with the database.
|
||||||
# This lets whitelist be used to set up initial list,
|
# This lets .allowed_users be used to set up initial list,
|
||||||
# but changes to the whitelist can occur in the database,
|
# but changes to the allowed_users set can occur in the database,
|
||||||
# and persist across sessions.
|
# and persist across sessions.
|
||||||
|
total_users = 0
|
||||||
for user in db.query(orm.User):
|
for user in db.query(orm.User):
|
||||||
try:
|
try:
|
||||||
await maybe_future(self.authenticator.add_user(user))
|
f = self.authenticator.add_user(user)
|
||||||
|
if f:
|
||||||
|
await maybe_future(f)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.log.exception("Error adding user %s already in db", user.name)
|
self.log.exception("Error adding user %s already in db", user.name)
|
||||||
if self.authenticator.delete_invalid_users:
|
if self.authenticator.delete_invalid_users:
|
||||||
@@ -1694,6 +1768,7 @@ class JupyterHub(Application):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
total_users += 1
|
||||||
# handle database upgrades where user.created is undefined.
|
# handle database upgrades where user.created is undefined.
|
||||||
# we don't want to allow user.created to be undefined,
|
# we don't want to allow user.created to be undefined,
|
||||||
# so initialize it to last_activity (if defined) or now.
|
# so initialize it to last_activity (if defined) or now.
|
||||||
@@ -1701,9 +1776,11 @@ class JupyterHub(Application):
|
|||||||
user.created = user.last_activity or datetime.utcnow()
|
user.created = user.last_activity or datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# The whitelist set and the users in the db are now the same.
|
# The allowed_users set and the users in the db are now the same.
|
||||||
# From this point on, any user changes should be done simultaneously
|
# From this point on, any user changes should be done simultaneously
|
||||||
# to the whitelist set and user db, unless the whitelist is empty (all users allowed).
|
# to the allowed_users set and user db, unless the allowed set is empty (all users allowed).
|
||||||
|
|
||||||
|
TOTAL_USERS.set(total_users)
|
||||||
|
|
||||||
async def init_groups(self):
|
async def init_groups(self):
|
||||||
"""Load predefined groups into the database"""
|
"""Load predefined groups into the database"""
|
||||||
@@ -1716,11 +1793,11 @@ class JupyterHub(Application):
|
|||||||
for username in usernames:
|
for username in usernames:
|
||||||
username = self.authenticator.normalize_username(username)
|
username = self.authenticator.normalize_username(username)
|
||||||
if not (
|
if not (
|
||||||
await maybe_future(
|
await maybe_future(self.authenticator.check_allowed(username, None))
|
||||||
self.authenticator.check_whitelist(username, None)
|
|
||||||
)
|
|
||||||
):
|
):
|
||||||
raise ValueError("Username %r is not in whitelist" % username)
|
raise ValueError(
|
||||||
|
"Username %r is not in Authenticator.allowed_users" % username
|
||||||
|
)
|
||||||
user = orm.User.find(db, name=username)
|
user = orm.User.find(db, name=username)
|
||||||
if user is None:
|
if user is None:
|
||||||
if not self.authenticator.validate_username(username):
|
if not self.authenticator.validate_username(username):
|
||||||
@@ -1744,11 +1821,14 @@ class JupyterHub(Application):
|
|||||||
if kind == 'user':
|
if kind == 'user':
|
||||||
name = self.authenticator.normalize_username(name)
|
name = self.authenticator.normalize_username(name)
|
||||||
if not (
|
if not (
|
||||||
await maybe_future(self.authenticator.check_whitelist(name, None))
|
await maybe_future(self.authenticator.check_allowed(name, None))
|
||||||
):
|
):
|
||||||
raise ValueError("Token name %r is not in whitelist" % name)
|
raise ValueError(
|
||||||
|
"Token user name %r is not in Authenticator.allowed_users"
|
||||||
|
% name
|
||||||
|
)
|
||||||
if not self.authenticator.validate_username(name):
|
if not self.authenticator.validate_username(name):
|
||||||
raise ValueError("Token name %r is not valid" % name)
|
raise ValueError("Token user name %r is not valid" % name)
|
||||||
if kind == 'service':
|
if kind == 'service':
|
||||||
if not any(service["name"] == name for service in self.services):
|
if not any(service["name"] == name for service in self.services):
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
@@ -1787,17 +1867,27 @@ class JupyterHub(Application):
|
|||||||
# purge expired tokens hourly
|
# purge expired tokens hourly
|
||||||
purge_expired_tokens_interval = 3600
|
purge_expired_tokens_interval = 3600
|
||||||
|
|
||||||
|
def purge_expired_tokens(self):
|
||||||
|
"""purge all expiring token objects from the database
|
||||||
|
|
||||||
|
run periodically
|
||||||
|
"""
|
||||||
|
# this should be all the subclasses of Expiring
|
||||||
|
for cls in (orm.APIToken, orm.OAuthAccessToken, orm.OAuthCode):
|
||||||
|
self.log.debug("Purging expired {name}s".format(name=cls.__name__))
|
||||||
|
cls.purge_expired(self.db)
|
||||||
|
|
||||||
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()
|
self.purge_expired_tokens()
|
||||||
# purge expired tokens hourly
|
# purge expired tokens hourly
|
||||||
# we don't need to be prompt about this
|
# we don't need to be prompt about this
|
||||||
# because expired tokens cannot be used anyway
|
# because expired tokens cannot be used anyway
|
||||||
pc = PeriodicCallback(
|
pc = PeriodicCallback(
|
||||||
purge_expired_tokens, 1e3 * self.purge_expired_tokens_interval
|
self.purge_expired_tokens, 1e3 * self.purge_expired_tokens_interval
|
||||||
)
|
)
|
||||||
pc.start()
|
pc.start()
|
||||||
|
|
||||||
@@ -2005,21 +2095,30 @@ class JupyterHub(Application):
|
|||||||
spawner._check_pending = False
|
spawner._check_pending = False
|
||||||
|
|
||||||
# parallelize checks for running Spawners
|
# parallelize checks for running Spawners
|
||||||
|
# run query on extant Server objects
|
||||||
|
# so this is O(running servers) not O(total users)
|
||||||
|
# Server objects can be associated with either a Spawner or a Service,
|
||||||
|
# we are only interested in the ones associated with a Spawner
|
||||||
check_futures = []
|
check_futures = []
|
||||||
for orm_user in db.query(orm.User):
|
for orm_server in db.query(orm.Server):
|
||||||
user = self.users[orm_user]
|
orm_spawner = orm_server.spawner
|
||||||
self.log.debug("Loading state for %s from db", user.name)
|
if not orm_spawner:
|
||||||
for name, orm_spawner in user.orm_spawners.items():
|
# check for orphaned Server rows
|
||||||
if orm_spawner.server is not None:
|
# this shouldn't happen if we've got our sqlachemy right
|
||||||
# spawner should be running
|
if not orm_server.service:
|
||||||
# instantiate Spawner wrapper and check if it's still alive
|
self.log.warning("deleting orphaned server %s", orm_server)
|
||||||
spawner = user.spawners[name]
|
self.db.delete(orm_server)
|
||||||
# signal that check is pending to avoid race conditions
|
self.db.commit()
|
||||||
spawner._check_pending = True
|
continue
|
||||||
f = asyncio.ensure_future(check_spawner(user, name, spawner))
|
# instantiate Spawner wrapper and check if it's still alive
|
||||||
check_futures.append(f)
|
# spawner should be running
|
||||||
|
user = self.users[orm_spawner.user]
|
||||||
TOTAL_USERS.set(len(self.users))
|
spawner = user.spawners[orm_spawner.name]
|
||||||
|
self.log.debug("Loading state for %s from db", spawner._log_name)
|
||||||
|
# signal that check is pending to avoid race conditions
|
||||||
|
spawner._check_pending = True
|
||||||
|
f = asyncio.ensure_future(check_spawner(user, spawner.name, spawner))
|
||||||
|
check_futures.append(f)
|
||||||
|
|
||||||
# it's important that we get here before the first await
|
# it's important that we get here before the first await
|
||||||
# so that we know all spawners are instantiated and in the check-pending state
|
# so that we know all spawners are instantiated and in the check-pending state
|
||||||
@@ -2029,7 +2128,7 @@ class JupyterHub(Application):
|
|||||||
self.log.debug(
|
self.log.debug(
|
||||||
"Awaiting checks for %i possibly-running spawners", len(check_futures)
|
"Awaiting checks for %i possibly-running spawners", len(check_futures)
|
||||||
)
|
)
|
||||||
await gen.multi(check_futures)
|
await asyncio.gather(*check_futures)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# only perform this query if we are going to log it
|
# only perform this query if we are going to log it
|
||||||
@@ -2096,7 +2195,7 @@ class JupyterHub(Application):
|
|||||||
def init_tornado_settings(self):
|
def init_tornado_settings(self):
|
||||||
"""Set up the tornado settings dict."""
|
"""Set up the tornado settings dict."""
|
||||||
base_url = self.hub.base_url
|
base_url = self.hub.base_url
|
||||||
jinja_options = dict(autoescape=True)
|
jinja_options = dict(autoescape=True, enable_async=True)
|
||||||
jinja_options.update(self.jinja_environment_options)
|
jinja_options.update(self.jinja_environment_options)
|
||||||
base_path = self._template_paths_default()[0]
|
base_path = self._template_paths_default()[0]
|
||||||
if base_path not in self.template_paths:
|
if base_path not in self.template_paths:
|
||||||
@@ -2108,6 +2207,14 @@ class JupyterHub(Application):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
jinja_env = Environment(loader=loader, **jinja_options)
|
jinja_env = Environment(loader=loader, **jinja_options)
|
||||||
|
# We need a sync jinja environment too, for the times we *must* use sync
|
||||||
|
# code - particularly in RequestHandler.write_error. Since *that*
|
||||||
|
# is called from inside the asyncio event loop, we can't actulaly just
|
||||||
|
# schedule it on the loop - without starting another thread with its
|
||||||
|
# own loop, which seems not worth the trouble. Instead, we create another
|
||||||
|
# environment, exactly like this one, but sync
|
||||||
|
del jinja_options['enable_async']
|
||||||
|
jinja_env_sync = Environment(loader=loader, **jinja_options)
|
||||||
|
|
||||||
login_url = url_path_join(base_url, 'login')
|
login_url = url_path_join(base_url, 'login')
|
||||||
logout_url = self.authenticator.logout_url(base_url)
|
logout_url = self.authenticator.logout_url(base_url)
|
||||||
@@ -2120,14 +2227,14 @@ class JupyterHub(Application):
|
|||||||
else:
|
else:
|
||||||
version_hash = datetime.now().strftime("%Y%m%d%H%M%S")
|
version_hash = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||||
|
|
||||||
oauth_no_confirm_whitelist = set()
|
oauth_no_confirm_list = set()
|
||||||
for service in self._service_map.values():
|
for service in self._service_map.values():
|
||||||
if service.oauth_no_confirm:
|
if service.oauth_no_confirm:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"Allowing service %s to complete OAuth without confirmation on an authorization web page",
|
"Allowing service %s to complete OAuth without confirmation on an authorization web page",
|
||||||
service.name,
|
service.name,
|
||||||
)
|
)
|
||||||
oauth_no_confirm_whitelist.add(service.oauth_client_id)
|
oauth_no_confirm_list.add(service.oauth_client_id)
|
||||||
|
|
||||||
settings = dict(
|
settings = dict(
|
||||||
log_function=log_request,
|
log_function=log_request,
|
||||||
@@ -2154,15 +2261,17 @@ class JupyterHub(Application):
|
|||||||
template_path=self.template_paths,
|
template_path=self.template_paths,
|
||||||
template_vars=self.template_vars,
|
template_vars=self.template_vars,
|
||||||
jinja2_env=jinja_env,
|
jinja2_env=jinja_env,
|
||||||
|
jinja2_env_sync=jinja_env_sync,
|
||||||
version_hash=version_hash,
|
version_hash=version_hash,
|
||||||
subdomain_host=self.subdomain_host,
|
subdomain_host=self.subdomain_host,
|
||||||
domain=self.domain,
|
domain=self.domain,
|
||||||
statsd=self.statsd,
|
statsd=self.statsd,
|
||||||
|
implicit_spawn_seconds=self.implicit_spawn_seconds,
|
||||||
allow_named_servers=self.allow_named_servers,
|
allow_named_servers=self.allow_named_servers,
|
||||||
default_server_name=self._default_server_name,
|
default_server_name=self._default_server_name,
|
||||||
named_server_limit_per_user=self.named_server_limit_per_user,
|
named_server_limit_per_user=self.named_server_limit_per_user,
|
||||||
oauth_provider=self.oauth_provider,
|
oauth_provider=self.oauth_provider,
|
||||||
oauth_no_confirm_whitelist=oauth_no_confirm_whitelist,
|
oauth_no_confirm_list=oauth_no_confirm_list,
|
||||||
concurrent_spawn_limit=self.concurrent_spawn_limit,
|
concurrent_spawn_limit=self.concurrent_spawn_limit,
|
||||||
spawn_throttle_retry_range=self.spawn_throttle_retry_range,
|
spawn_throttle_retry_range=self.spawn_throttle_retry_range,
|
||||||
active_server_limit=self.active_server_limit,
|
active_server_limit=self.active_server_limit,
|
||||||
@@ -2296,7 +2405,6 @@ class JupyterHub(Application):
|
|||||||
if init_spawners_timeout < 0:
|
if init_spawners_timeout < 0:
|
||||||
# negative timeout means forever (previous, most stable behavior)
|
# negative timeout means forever (previous, most stable behavior)
|
||||||
init_spawners_timeout = 86400
|
init_spawners_timeout = 86400
|
||||||
print(init_spawners_timeout)
|
|
||||||
|
|
||||||
init_start_time = time.perf_counter()
|
init_start_time = time.perf_counter()
|
||||||
init_spawners_future = asyncio.ensure_future(self.init_spawners())
|
init_spawners_future = asyncio.ensure_future(self.init_spawners())
|
||||||
@@ -2452,7 +2560,7 @@ class JupyterHub(Application):
|
|||||||
continue
|
continue
|
||||||
dt = parse_date(route_data['last_activity'])
|
dt = parse_date(route_data['last_activity'])
|
||||||
if dt.tzinfo:
|
if dt.tzinfo:
|
||||||
# strip timezone info to naïve UTC datetime
|
# strip timezone info to naive UTC datetime
|
||||||
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
if user.last_activity:
|
if user.last_activity:
|
||||||
@@ -2665,6 +2773,40 @@ class JupyterHub(Application):
|
|||||||
self.log.critical("Received signalnum %s, , initiating shutdown...", signum)
|
self.log.critical("Received signalnum %s, , initiating shutdown...", signum)
|
||||||
raise SystemExit(128 + signum)
|
raise SystemExit(128 + signum)
|
||||||
|
|
||||||
|
def _init_asyncio_patch(self):
|
||||||
|
"""Set default asyncio policy to be compatible with Tornado.
|
||||||
|
|
||||||
|
Tornado 6 (at least) is not compatible with the default
|
||||||
|
asyncio implementation on Windows.
|
||||||
|
|
||||||
|
Pick the older SelectorEventLoopPolicy on Windows
|
||||||
|
if the known-incompatible default policy is in use.
|
||||||
|
|
||||||
|
Do this as early as possible to make it a low priority and overrideable.
|
||||||
|
|
||||||
|
ref: https://github.com/tornadoweb/tornado/issues/2608
|
||||||
|
|
||||||
|
FIXME: If/when tornado supports the defaults in asyncio,
|
||||||
|
remove and bump tornado requirement for py38.
|
||||||
|
"""
|
||||||
|
if sys.platform.startswith("win") and sys.version_info >= (3, 8):
|
||||||
|
try:
|
||||||
|
from asyncio import (
|
||||||
|
WindowsProactorEventLoopPolicy,
|
||||||
|
WindowsSelectorEventLoopPolicy,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
# not affected
|
||||||
|
else:
|
||||||
|
if (
|
||||||
|
type(asyncio.get_event_loop_policy())
|
||||||
|
is WindowsProactorEventLoopPolicy
|
||||||
|
):
|
||||||
|
# WindowsProactorEventLoopPolicy is not compatible with Tornado 6.
|
||||||
|
# Fallback to the pre-3.8 default of WindowsSelectorEventLoopPolicy.
|
||||||
|
asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy())
|
||||||
|
|
||||||
_atexit_ran = False
|
_atexit_ran = False
|
||||||
|
|
||||||
def atexit(self):
|
def atexit(self):
|
||||||
@@ -2672,6 +2814,7 @@ class JupyterHub(Application):
|
|||||||
if self._atexit_ran:
|
if self._atexit_ran:
|
||||||
return
|
return
|
||||||
self._atexit_ran = True
|
self._atexit_ran = True
|
||||||
|
self._init_asyncio_patch()
|
||||||
# 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())
|
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||||
IOLoop.clear_current()
|
IOLoop.clear_current()
|
||||||
@@ -2682,9 +2825,7 @@ class JupyterHub(Application):
|
|||||||
async def shutdown_cancel_tasks(self, sig):
|
async def shutdown_cancel_tasks(self, sig):
|
||||||
"""Cancel all other tasks of the event loop and initiate cleanup"""
|
"""Cancel all other tasks of the event loop and initiate cleanup"""
|
||||||
self.log.critical("Received signal %s, initiating shutdown...", sig.name)
|
self.log.critical("Received signal %s, initiating shutdown...", sig.name)
|
||||||
tasks = [
|
tasks = [t for t in asyncio_all_tasks() if t is not asyncio_current_task()]
|
||||||
t for t in asyncio.Task.all_tasks() if t is not asyncio.Task.current_task()
|
|
||||||
]
|
|
||||||
|
|
||||||
if tasks:
|
if tasks:
|
||||||
self.log.debug("Cancelling pending tasks")
|
self.log.debug("Cancelling pending tasks")
|
||||||
@@ -2697,7 +2838,7 @@ class JupyterHub(Application):
|
|||||||
except StopAsyncIteration as e:
|
except StopAsyncIteration as e:
|
||||||
self.log.error("Caught StopAsyncIteration Exception", exc_info=True)
|
self.log.error("Caught StopAsyncIteration Exception", exc_info=True)
|
||||||
|
|
||||||
tasks = [t for t in asyncio.Task.all_tasks()]
|
tasks = [t for t in asyncio_all_tasks()]
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
self.log.debug("Task status: %s", t)
|
self.log.debug("Task status: %s", t)
|
||||||
await self.cleanup()
|
await self.cleanup()
|
||||||
@@ -2721,6 +2862,7 @@ class JupyterHub(Application):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def launch_instance(cls, argv=None):
|
def launch_instance(cls, argv=None):
|
||||||
self = cls.instance()
|
self = cls.instance()
|
||||||
|
self._init_asyncio_patch()
|
||||||
loop = IOLoop.current()
|
loop = IOLoop.current()
|
||||||
task = asyncio.ensure_future(self.launch_instance_async(argv))
|
task = asyncio.ensure_future(self.launch_instance_async(argv))
|
||||||
try:
|
try:
|
||||||
|
@@ -7,6 +7,7 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from functools import partial
|
||||||
from shutil import which
|
from shutil import which
|
||||||
from subprocess import PIPE
|
from subprocess import PIPE
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
@@ -101,40 +102,76 @@ class Authenticator(LoggingConfigurable):
|
|||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
whitelist = Set(
|
whitelist = Set(
|
||||||
|
help="Deprecated, use `Authenticator.allowed_users`",
|
||||||
|
config=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed_users = Set(
|
||||||
help="""
|
help="""
|
||||||
Whitelist of usernames that are allowed to log in.
|
Set of usernames that are allowed to log in.
|
||||||
|
|
||||||
Use this with supported authenticators to restrict which users can log in. This is an
|
Use this with supported authenticators to restrict which users can log in. This is an
|
||||||
additional whitelist that further restricts users, beyond whatever restrictions the
|
additional list that further restricts users, beyond whatever restrictions the
|
||||||
authenticator has in place.
|
authenticator has in place.
|
||||||
|
|
||||||
If empty, does not perform any additional restriction.
|
If empty, does not perform any additional restriction.
|
||||||
|
|
||||||
|
.. versionchanged:: 1.2
|
||||||
|
`Authenticator.whitelist` renamed to `allowed_users`
|
||||||
"""
|
"""
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
blacklist = Set(
|
blocked_users = Set(
|
||||||
help="""
|
help="""
|
||||||
Blacklist of usernames that are not allowed to log in.
|
Set of usernames that are not allowed to log in.
|
||||||
|
|
||||||
Use this with supported authenticators to restrict which users can not log in. This is an
|
Use this with supported authenticators to restrict which users can not log in. This is an
|
||||||
additional blacklist that further restricts users, beyond whatever restrictions the
|
additional block list that further restricts users, beyond whatever restrictions the
|
||||||
authenticator has in place.
|
authenticator has in place.
|
||||||
|
|
||||||
If empty, does not perform any additional restriction.
|
If empty, does not perform any additional restriction.
|
||||||
|
|
||||||
.. versionadded: 0.9
|
.. versionadded: 0.9
|
||||||
|
|
||||||
|
.. versionchanged:: 1.2
|
||||||
|
`Authenticator.blacklist` renamed to `blocked_users`
|
||||||
"""
|
"""
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
@observe('whitelist')
|
_deprecated_aliases = {
|
||||||
def _check_whitelist(self, change):
|
"whitelist": ("allowed_users", "1.2"),
|
||||||
|
"blacklist": ("blocked_users", "1.2"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@observe(*list(_deprecated_aliases))
|
||||||
|
def _deprecated_trait(self, change):
|
||||||
|
"""observer for deprecated traits"""
|
||||||
|
old_attr = change.name
|
||||||
|
new_attr, version = self._deprecated_aliases.get(old_attr)
|
||||||
|
new_value = getattr(self, new_attr)
|
||||||
|
if new_value != change.new:
|
||||||
|
# only warn if different
|
||||||
|
# protects backward-compatible config from warnings
|
||||||
|
# if they set the same value under both names
|
||||||
|
self.log.warning(
|
||||||
|
"{cls}.{old} is deprecated in JupyterHub {version}, use {cls}.{new} instead".format(
|
||||||
|
cls=self.__class__.__name__,
|
||||||
|
old=old_attr,
|
||||||
|
new=new_attr,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setattr(self, new_attr, change.new)
|
||||||
|
|
||||||
|
@observe('allowed_users')
|
||||||
|
def _check_allowed_users(self, change):
|
||||||
short_names = [name for name in change['new'] if len(name) <= 1]
|
short_names = [name for name in change['new'] if len(name) <= 1]
|
||||||
if short_names:
|
if short_names:
|
||||||
sorted_names = sorted(short_names)
|
sorted_names = sorted(short_names)
|
||||||
single = ''.join(sorted_names)
|
single = ''.join(sorted_names)
|
||||||
string_set_typo = "set('%s')" % single
|
string_set_typo = "set('%s')" % single
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"whitelist contains single-character names: %s; did you mean set([%r]) instead of %s?",
|
"Allowed set contains single-character names: %s; did you mean set([%r]) instead of %s?",
|
||||||
sorted_names[:8],
|
sorted_names[:8],
|
||||||
single,
|
single,
|
||||||
string_set_typo,
|
string_set_typo,
|
||||||
@@ -206,6 +243,7 @@ class Authenticator(LoggingConfigurable):
|
|||||||
|
|
||||||
delete_invalid_users = Bool(
|
delete_invalid_users = Bool(
|
||||||
False,
|
False,
|
||||||
|
config=True,
|
||||||
help="""Delete any users from the database that do not pass validation
|
help="""Delete any users from the database that do not pass validation
|
||||||
|
|
||||||
When JupyterHub starts, `.add_user` will be called
|
When JupyterHub starts, `.add_user` will be called
|
||||||
@@ -260,39 +298,74 @@ class Authenticator(LoggingConfigurable):
|
|||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
for method_name in (
|
self._init_deprecated_methods()
|
||||||
'check_whitelist',
|
|
||||||
'check_blacklist',
|
def _init_deprecated_methods(self):
|
||||||
'check_group_whitelist',
|
# handles deprecated signature *and* name
|
||||||
|
# with correct subclass override priority!
|
||||||
|
for old_name, new_name in (
|
||||||
|
('check_whitelist', 'check_allowed'),
|
||||||
|
('check_blacklist', 'check_blocked_users'),
|
||||||
|
('check_group_whitelist', 'check_allowed_groups'),
|
||||||
):
|
):
|
||||||
original_method = getattr(self, method_name, None)
|
old_method = getattr(self, old_name, None)
|
||||||
if original_method is None:
|
if old_method is None:
|
||||||
# no such method (check_group_whitelist is optional)
|
# no such method (check_group_whitelist is optional)
|
||||||
continue
|
continue
|
||||||
signature = inspect.signature(original_method)
|
|
||||||
if 'authentication' not in signature.parameters:
|
# allow old name to have higher priority
|
||||||
|
# if and only if it's defined in a later subclass
|
||||||
|
# than the new name
|
||||||
|
for cls in self.__class__.mro():
|
||||||
|
has_old_name = old_name in cls.__dict__
|
||||||
|
has_new_name = new_name in cls.__dict__
|
||||||
|
if has_new_name:
|
||||||
|
break
|
||||||
|
if has_old_name and not has_new_name:
|
||||||
|
warnings.warn(
|
||||||
|
"{0}.{1} should be renamed to {0}.{2} for JupyterHub >= 1.2".format(
|
||||||
|
cls.__name__, old_name, new_name
|
||||||
|
),
|
||||||
|
DeprecationWarning,
|
||||||
|
)
|
||||||
|
# use old name instead of new
|
||||||
|
# if old name is overridden in subclass
|
||||||
|
def _new_calls_old(old_name, *args, **kwargs):
|
||||||
|
return getattr(self, old_name)(*args, **kwargs)
|
||||||
|
|
||||||
|
setattr(self, new_name, partial(_new_calls_old, old_name))
|
||||||
|
break
|
||||||
|
|
||||||
|
# deprecate pre-1.0 method signatures
|
||||||
|
signature = inspect.signature(old_method)
|
||||||
|
if 'authentication' not in signature.parameters and not any(
|
||||||
|
param.kind == inspect.Parameter.VAR_KEYWORD
|
||||||
|
for param in signature.parameters.values()
|
||||||
|
):
|
||||||
# adapt to pre-1.0 signature for compatibility
|
# adapt to pre-1.0 signature for compatibility
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
"""
|
"""
|
||||||
{0}.{1} does not support the authentication argument,
|
{0}.{1} does not support the authentication argument,
|
||||||
added in JupyterHub 1.0.
|
added in JupyterHub 1.0. and is renamed to {2} in JupyterHub 1.2.
|
||||||
|
|
||||||
It should have the signature:
|
It should have the signature:
|
||||||
|
|
||||||
def {1}(self, username, authentication=None):
|
def {2}(self, username, authentication=None):
|
||||||
...
|
...
|
||||||
|
|
||||||
Adapting for compatibility.
|
Adapting for compatibility.
|
||||||
""".format(
|
""".format(
|
||||||
self.__class__.__name__, method_name
|
self.__class__.__name__, old_name, new_name
|
||||||
),
|
),
|
||||||
DeprecationWarning,
|
DeprecationWarning,
|
||||||
)
|
)
|
||||||
|
|
||||||
def wrapped_method(username, authentication=None, **kwargs):
|
def wrapped_method(
|
||||||
|
original_method, username, authentication=None, **kwargs
|
||||||
|
):
|
||||||
return original_method(username, **kwargs)
|
return original_method(username, **kwargs)
|
||||||
|
|
||||||
setattr(self, method_name, wrapped_method)
|
setattr(self, old_name, partial(wrapped_method, old_method))
|
||||||
|
|
||||||
async def run_post_auth_hook(self, handler, authentication):
|
async def run_post_auth_hook(self, handler, authentication):
|
||||||
"""
|
"""
|
||||||
@@ -326,39 +399,45 @@ class Authenticator(LoggingConfigurable):
|
|||||||
username = self.username_map.get(username, username)
|
username = self.username_map.get(username, username)
|
||||||
return username
|
return username
|
||||||
|
|
||||||
def check_whitelist(self, username, authentication=None):
|
def check_allowed(self, username, authentication=None):
|
||||||
"""Check if a username is allowed to authenticate based on whitelist configuration
|
"""Check if a username is allowed to authenticate based on configuration
|
||||||
|
|
||||||
Return True if username is allowed, False otherwise.
|
Return True if username is allowed, False otherwise.
|
||||||
No whitelist means any username is allowed.
|
No allowed_users set means any username is allowed.
|
||||||
|
|
||||||
Names are normalized *before* being checked against the whitelist.
|
Names are normalized *before* being checked against the allowed set.
|
||||||
|
|
||||||
.. versionchanged:: 1.0
|
.. versionchanged:: 1.0
|
||||||
Signature updated to accept authentication data and any future changes
|
Signature updated to accept authentication data and any future changes
|
||||||
"""
|
|
||||||
if not self.whitelist:
|
|
||||||
# No whitelist means any name is allowed
|
|
||||||
return True
|
|
||||||
return username in self.whitelist
|
|
||||||
|
|
||||||
def check_blacklist(self, username, authentication=None):
|
.. versionchanged:: 1.2
|
||||||
"""Check if a username is blocked to authenticate based on blacklist configuration
|
Renamed check_whitelist to check_allowed
|
||||||
|
"""
|
||||||
|
if not self.allowed_users:
|
||||||
|
# No allowed set means any name is allowed
|
||||||
|
return True
|
||||||
|
return username in self.allowed_users
|
||||||
|
|
||||||
|
def check_blocked_users(self, username, authentication=None):
|
||||||
|
"""Check if a username is blocked to authenticate based on Authenticator.blocked configuration
|
||||||
|
|
||||||
Return True if username is allowed, False otherwise.
|
Return True if username is allowed, False otherwise.
|
||||||
No blacklist means any username is allowed.
|
No block list means any username is allowed.
|
||||||
|
|
||||||
Names are normalized *before* being checked against the blacklist.
|
Names are normalized *before* being checked against the block list.
|
||||||
|
|
||||||
.. versionadded: 0.9
|
.. versionadded: 0.9
|
||||||
|
|
||||||
.. versionchanged:: 1.0
|
.. versionchanged:: 1.0
|
||||||
Signature updated to accept authentication data as second argument
|
Signature updated to accept authentication data as second argument
|
||||||
|
|
||||||
|
.. versionchanged:: 1.2
|
||||||
|
Renamed check_blacklist to check_blocked_users
|
||||||
"""
|
"""
|
||||||
if not self.blacklist:
|
if not self.blocked_users:
|
||||||
# No blacklist means any name is allowed
|
# No block list means any name is allowed
|
||||||
return True
|
return True
|
||||||
return username not in self.blacklist
|
return username not in self.blocked_users
|
||||||
|
|
||||||
async def get_authenticated_user(self, handler, data):
|
async def get_authenticated_user(self, handler, data):
|
||||||
"""Authenticate the user who is attempting to log in
|
"""Authenticate the user who is attempting to log in
|
||||||
@@ -367,7 +446,7 @@ class Authenticator(LoggingConfigurable):
|
|||||||
|
|
||||||
This calls `authenticate`, which should be overridden in subclasses,
|
This calls `authenticate`, which should be overridden in subclasses,
|
||||||
normalizes the username if any normalization should be done,
|
normalizes the username if any normalization should be done,
|
||||||
and then validates the name in the whitelist.
|
and then validates the name in the allowed set.
|
||||||
|
|
||||||
This is the outer API for authenticating a user.
|
This is the outer API for authenticating a user.
|
||||||
Subclasses should not override this method.
|
Subclasses should not override this method.
|
||||||
@@ -375,7 +454,7 @@ class Authenticator(LoggingConfigurable):
|
|||||||
The various stages can be overridden separately:
|
The various stages can be overridden separately:
|
||||||
- `authenticate` turns formdata into a username
|
- `authenticate` turns formdata into a username
|
||||||
- `normalize_username` normalizes the username
|
- `normalize_username` normalizes the username
|
||||||
- `check_whitelist` checks against the user whitelist
|
- `check_allowed` checks against the allowed usernames
|
||||||
|
|
||||||
.. versionchanged:: 0.8
|
.. versionchanged:: 0.8
|
||||||
return dict instead of username
|
return dict instead of username
|
||||||
@@ -389,7 +468,7 @@ class Authenticator(LoggingConfigurable):
|
|||||||
else:
|
else:
|
||||||
authenticated = {'name': authenticated}
|
authenticated = {'name': authenticated}
|
||||||
authenticated.setdefault('auth_state', None)
|
authenticated.setdefault('auth_state', None)
|
||||||
# Leave the default as None, but reevaluate later post-whitelist
|
# Leave the default as None, but reevaluate later post-allowed-check
|
||||||
authenticated.setdefault('admin', None)
|
authenticated.setdefault('admin', None)
|
||||||
|
|
||||||
# normalize the username
|
# normalize the username
|
||||||
@@ -400,20 +479,18 @@ class Authenticator(LoggingConfigurable):
|
|||||||
self.log.warning("Disallowing invalid username %r.", username)
|
self.log.warning("Disallowing invalid username %r.", username)
|
||||||
return
|
return
|
||||||
|
|
||||||
blacklist_pass = await maybe_future(
|
blocked_pass = await maybe_future(
|
||||||
self.check_blacklist(username, authenticated)
|
self.check_blocked_users(username, authenticated)
|
||||||
)
|
|
||||||
whitelist_pass = await maybe_future(
|
|
||||||
self.check_whitelist(username, authenticated)
|
|
||||||
)
|
)
|
||||||
|
allowed_pass = await maybe_future(self.check_allowed(username, authenticated))
|
||||||
|
|
||||||
if blacklist_pass:
|
if blocked_pass:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
self.log.warning("User %r in blacklist. Stop authentication", username)
|
self.log.warning("User %r blocked. Stop authentication", username)
|
||||||
return
|
return
|
||||||
|
|
||||||
if whitelist_pass:
|
if allowed_pass:
|
||||||
if authenticated['admin'] is None:
|
if authenticated['admin'] is None:
|
||||||
authenticated['admin'] = await maybe_future(
|
authenticated['admin'] = await maybe_future(
|
||||||
self.is_admin(handler, authenticated)
|
self.is_admin(handler, authenticated)
|
||||||
@@ -423,7 +500,7 @@ class Authenticator(LoggingConfigurable):
|
|||||||
|
|
||||||
return authenticated
|
return authenticated
|
||||||
else:
|
else:
|
||||||
self.log.warning("User %r not in whitelist.", username)
|
self.log.warning("User %r not allowed.", username)
|
||||||
return
|
return
|
||||||
|
|
||||||
async def refresh_user(self, user, handler=None):
|
async def refresh_user(self, user, handler=None):
|
||||||
@@ -479,7 +556,7 @@ class Authenticator(LoggingConfigurable):
|
|||||||
It must return the username on successful authentication,
|
It must return the username on successful authentication,
|
||||||
and return None on failed authentication.
|
and return None on failed authentication.
|
||||||
|
|
||||||
Checking the whitelist is handled separately by the caller.
|
Checking allowed_users/blocked_users is handled separately by the caller.
|
||||||
|
|
||||||
.. versionchanged:: 0.8
|
.. versionchanged:: 0.8
|
||||||
Allow `authenticate` to return a dict containing auth_state.
|
Allow `authenticate` to return a dict containing auth_state.
|
||||||
@@ -520,10 +597,10 @@ class Authenticator(LoggingConfigurable):
|
|||||||
|
|
||||||
This method may be a coroutine.
|
This method may be a coroutine.
|
||||||
|
|
||||||
By default, this just adds the user to the whitelist.
|
By default, this just adds the user to the allowed_users set.
|
||||||
|
|
||||||
Subclasses may do more extensive things, such as adding actual unix users,
|
Subclasses may do more extensive things, such as adding actual unix users,
|
||||||
but they should call super to ensure the whitelist is updated.
|
but they should call super to ensure the allowed_users set is updated.
|
||||||
|
|
||||||
Note that this should be idempotent, since it is called whenever the hub restarts
|
Note that this should be idempotent, since it is called whenever the hub restarts
|
||||||
for all users.
|
for all users.
|
||||||
@@ -533,19 +610,19 @@ class Authenticator(LoggingConfigurable):
|
|||||||
"""
|
"""
|
||||||
if not self.validate_username(user.name):
|
if not self.validate_username(user.name):
|
||||||
raise ValueError("Invalid username: %s" % user.name)
|
raise ValueError("Invalid username: %s" % user.name)
|
||||||
if self.whitelist:
|
if self.allowed_users:
|
||||||
self.whitelist.add(user.name)
|
self.allowed_users.add(user.name)
|
||||||
|
|
||||||
def delete_user(self, user):
|
def delete_user(self, user):
|
||||||
"""Hook called when a user is deleted
|
"""Hook called when a user is deleted
|
||||||
|
|
||||||
Removes the user from the whitelist.
|
Removes the user from the allowed_users set.
|
||||||
Subclasses should call super to ensure the whitelist is updated.
|
Subclasses should call super to ensure the allowed_users set is updated.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user (User): The User wrapper object
|
user (User): The User wrapper object
|
||||||
"""
|
"""
|
||||||
self.whitelist.discard(user.name)
|
self.allowed_users.discard(user.name)
|
||||||
|
|
||||||
auto_login = Bool(
|
auto_login = Bool(
|
||||||
False,
|
False,
|
||||||
@@ -610,6 +687,43 @@ class Authenticator(LoggingConfigurable):
|
|||||||
return [('/login', LoginHandler)]
|
return [('/login', LoginHandler)]
|
||||||
|
|
||||||
|
|
||||||
|
def _deprecated_method(old_name, new_name, version):
|
||||||
|
"""Create a deprecated method wrapper for a deprecated method name"""
|
||||||
|
|
||||||
|
def deprecated(self, *args, **kwargs):
|
||||||
|
warnings.warn(
|
||||||
|
(
|
||||||
|
"{cls}.{old_name} is deprecated in JupyterHub {version}."
|
||||||
|
" Please use {cls}.{new_name} instead."
|
||||||
|
).format(
|
||||||
|
cls=self.__class__.__name__,
|
||||||
|
old_name=old_name,
|
||||||
|
new_name=new_name,
|
||||||
|
version=version,
|
||||||
|
),
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
old_method = getattr(self, new_name)
|
||||||
|
return old_method(*args, **kwargs)
|
||||||
|
|
||||||
|
return deprecated
|
||||||
|
|
||||||
|
|
||||||
|
import types
|
||||||
|
|
||||||
|
# deprecate white/blacklist method names
|
||||||
|
for _old_name, _new_name, _version in [
|
||||||
|
("check_whitelist", "check_allowed", "1.2"),
|
||||||
|
("check_blacklist", "check_blocked_users", "1.2"),
|
||||||
|
]:
|
||||||
|
setattr(
|
||||||
|
Authenticator,
|
||||||
|
_old_name,
|
||||||
|
_deprecated_method(_old_name, _new_name, _version),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LocalAuthenticator(Authenticator):
|
class LocalAuthenticator(Authenticator):
|
||||||
"""Base class for Authenticators that work with local Linux/UNIX users
|
"""Base class for Authenticators that work with local Linux/UNIX users
|
||||||
|
|
||||||
@@ -670,36 +784,38 @@ class LocalAuthenticator(Authenticator):
|
|||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
group_whitelist = Set(
|
group_whitelist = Set(
|
||||||
help="""
|
help="""DEPRECATED: use allowed_groups""",
|
||||||
Whitelist all users from this UNIX group.
|
).tag(config=True)
|
||||||
|
|
||||||
This makes the username whitelist ineffective.
|
allowed_groups = Set(
|
||||||
|
help="""
|
||||||
|
Allow login from all users in these UNIX groups.
|
||||||
|
|
||||||
|
If set, allowed username set is ignored.
|
||||||
"""
|
"""
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
@observe('group_whitelist')
|
@observe('allowed_groups')
|
||||||
def _group_whitelist_changed(self, change):
|
def _allowed_groups_changed(self, change):
|
||||||
"""
|
"""Log a warning if mutually exclusive user and group allowed sets are specified."""
|
||||||
Log a warning if both group_whitelist and user whitelist are set.
|
if self.allowed_users:
|
||||||
"""
|
|
||||||
if self.whitelist:
|
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"Ignoring username whitelist because group whitelist supplied!"
|
"Ignoring Authenticator.allowed_users set because Authenticator.allowed_groups supplied!"
|
||||||
)
|
)
|
||||||
|
|
||||||
def check_whitelist(self, username, authentication=None):
|
def check_allowed(self, username, authentication=None):
|
||||||
if self.group_whitelist:
|
if self.allowed_groups:
|
||||||
return self.check_group_whitelist(username, authentication)
|
return self.check_allowed_groups(username, authentication)
|
||||||
else:
|
else:
|
||||||
return super().check_whitelist(username, authentication)
|
return super().check_allowed(username, authentication)
|
||||||
|
|
||||||
def check_group_whitelist(self, username, authentication=None):
|
def check_allowed_groups(self, username, authentication=None):
|
||||||
"""
|
"""
|
||||||
If group_whitelist is configured, check if authenticating user is part of group.
|
If allowed_groups is configured, check if authenticating user is part of group.
|
||||||
"""
|
"""
|
||||||
if not self.group_whitelist:
|
if not self.allowed_groups:
|
||||||
return False
|
return False
|
||||||
for grnam in self.group_whitelist:
|
for grnam in self.allowed_groups:
|
||||||
try:
|
try:
|
||||||
group = self._getgrnam(grnam)
|
group = self._getgrnam(grnam)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@@ -843,7 +959,7 @@ class PAMAuthenticator(LocalAuthenticator):
|
|||||||
Authoritative list of user groups that determine admin access.
|
Authoritative list of user groups that determine admin access.
|
||||||
Users not in these groups can still be granted admin status through admin_users.
|
Users not in these groups can still be granted admin status through admin_users.
|
||||||
|
|
||||||
White/blacklisting rules still apply.
|
allowed/blocked rules still apply.
|
||||||
"""
|
"""
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
@@ -986,6 +1102,16 @@ class PAMAuthenticator(LocalAuthenticator):
|
|||||||
return super().normalize_username(username)
|
return super().normalize_username(username)
|
||||||
|
|
||||||
|
|
||||||
|
for _old_name, _new_name, _version in [
|
||||||
|
("check_group_whitelist", "check_group_allowed", "1.2"),
|
||||||
|
]:
|
||||||
|
setattr(
|
||||||
|
LocalAuthenticator,
|
||||||
|
_old_name,
|
||||||
|
_deprecated_method(_old_name, _new_name, _version),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DummyAuthenticator(Authenticator):
|
class DummyAuthenticator(Authenticator):
|
||||||
"""Dummy Authenticator for testing
|
"""Dummy Authenticator for testing
|
||||||
|
|
||||||
|
@@ -6,7 +6,6 @@ from concurrent.futures import ThreadPoolExecutor
|
|||||||
|
|
||||||
from traitlets import Any
|
from traitlets import Any
|
||||||
from traitlets import default
|
from traitlets import default
|
||||||
from traitlets import Dict
|
|
||||||
from traitlets import Integer
|
from traitlets import Integer
|
||||||
from traitlets import List
|
from traitlets import List
|
||||||
from traitlets import observe
|
from traitlets import observe
|
||||||
|
@@ -2,7 +2,6 @@
|
|||||||
# 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.
|
||||||
import asyncio
|
import asyncio
|
||||||
import copy
|
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import random
|
import random
|
||||||
@@ -27,14 +26,12 @@ from tornado.httputil import url_concat
|
|||||||
from tornado.ioloop import IOLoop
|
from tornado.ioloop import IOLoop
|
||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
from tornado.web import addslash
|
from tornado.web import addslash
|
||||||
from tornado.web import MissingArgumentError
|
|
||||||
from tornado.web import RequestHandler
|
from tornado.web import RequestHandler
|
||||||
|
|
||||||
from .. import __version__
|
from .. import __version__
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..metrics import PROXY_ADD_DURATION_SECONDS
|
from ..metrics import PROXY_ADD_DURATION_SECONDS
|
||||||
from ..metrics import PROXY_DELETE_DURATION_SECONDS
|
from ..metrics import PROXY_DELETE_DURATION_SECONDS
|
||||||
from ..metrics import ProxyAddStatus
|
|
||||||
from ..metrics import ProxyDeleteStatus
|
from ..metrics import ProxyDeleteStatus
|
||||||
from ..metrics import RUNNING_SERVERS
|
from ..metrics import RUNNING_SERVERS
|
||||||
from ..metrics import SERVER_POLL_DURATION_SECONDS
|
from ..metrics import SERVER_POLL_DURATION_SECONDS
|
||||||
@@ -43,6 +40,7 @@ from ..metrics import SERVER_STOP_DURATION_SECONDS
|
|||||||
from ..metrics import ServerPollStatus
|
from ..metrics import ServerPollStatus
|
||||||
from ..metrics import ServerSpawnStatus
|
from ..metrics import ServerSpawnStatus
|
||||||
from ..metrics import ServerStopStatus
|
from ..metrics import ServerStopStatus
|
||||||
|
from ..metrics import TOTAL_USERS
|
||||||
from ..objects import Server
|
from ..objects import Server
|
||||||
from ..spawner import LocalProcessSpawner
|
from ..spawner import LocalProcessSpawner
|
||||||
from ..user import User
|
from ..user import User
|
||||||
@@ -456,6 +454,7 @@ class BaseHandler(RequestHandler):
|
|||||||
# not found, create and register user
|
# not found, create and register user
|
||||||
u = orm.User(name=username)
|
u = orm.User(name=username)
|
||||||
self.db.add(u)
|
self.db.add(u)
|
||||||
|
TOTAL_USERS.inc()
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
user = self._user_from_orm(u)
|
user = self._user_from_orm(u)
|
||||||
return user
|
return user
|
||||||
@@ -492,7 +491,7 @@ class BaseHandler(RequestHandler):
|
|||||||
self.clear_cookie(
|
self.clear_cookie(
|
||||||
'jupyterhub-services',
|
'jupyterhub-services',
|
||||||
path=url_path_join(self.base_url, 'services'),
|
path=url_path_join(self.base_url, 'services'),
|
||||||
**kwargs
|
**kwargs,
|
||||||
)
|
)
|
||||||
# Reset _jupyterhub_user
|
# Reset _jupyterhub_user
|
||||||
self._jupyterhub_user = None
|
self._jupyterhub_user = None
|
||||||
@@ -637,9 +636,22 @@ class BaseHandler(RequestHandler):
|
|||||||
next_url,
|
next_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# this is where we know if next_url is coming from ?next= param or we are using a default url
|
||||||
|
if next_url:
|
||||||
|
next_url_from_param = True
|
||||||
|
else:
|
||||||
|
next_url_from_param = False
|
||||||
|
|
||||||
if not next_url:
|
if not next_url:
|
||||||
# custom default URL
|
# custom default URL, usually passed because user landed on that page but was not logged in
|
||||||
next_url = default or self.default_url
|
if default:
|
||||||
|
next_url = default
|
||||||
|
else:
|
||||||
|
# As set in jupyterhub_config.py
|
||||||
|
if callable(self.default_url):
|
||||||
|
next_url = self.default_url(self)
|
||||||
|
else:
|
||||||
|
next_url = self.default_url
|
||||||
|
|
||||||
if not next_url:
|
if not next_url:
|
||||||
# default URL after login
|
# default URL after login
|
||||||
@@ -654,8 +666,45 @@ class BaseHandler(RequestHandler):
|
|||||||
next_url = url_path_join(self.hub.base_url, 'spawn')
|
next_url = url_path_join(self.hub.base_url, 'spawn')
|
||||||
else:
|
else:
|
||||||
next_url = url_path_join(self.hub.base_url, 'home')
|
next_url = url_path_join(self.hub.base_url, 'home')
|
||||||
|
|
||||||
|
if not next_url_from_param:
|
||||||
|
# when a request made with ?next=... assume all the params have already been encoded
|
||||||
|
# otherwise, preserve params from the current request across the redirect
|
||||||
|
next_url = self.append_query_parameters(next_url, exclude=['next'])
|
||||||
return next_url
|
return next_url
|
||||||
|
|
||||||
|
def append_query_parameters(self, url, exclude=None):
|
||||||
|
"""Append the current request's query parameters to the given URL.
|
||||||
|
|
||||||
|
Supports an extra optional parameter ``exclude`` that when provided must
|
||||||
|
contain a list of parameters to be ignored, i.e. these parameters will
|
||||||
|
not be added to the URL.
|
||||||
|
|
||||||
|
This is important to avoid infinite loops with the next parameter being
|
||||||
|
added over and over, for instance.
|
||||||
|
|
||||||
|
The default value for ``exclude`` is an array with "next". This is useful
|
||||||
|
as most use cases in JupyterHub (all?) won't want to include the next
|
||||||
|
parameter twice (the next parameter is added elsewhere to the query
|
||||||
|
parameters).
|
||||||
|
|
||||||
|
:param str url: a URL
|
||||||
|
:param list exclude: optional list of parameters to be ignored, defaults to
|
||||||
|
a list with "next" (to avoid redirect-loops)
|
||||||
|
:rtype (str)
|
||||||
|
"""
|
||||||
|
if exclude is None:
|
||||||
|
exclude = ['next']
|
||||||
|
if self.request.query:
|
||||||
|
query_string = [
|
||||||
|
param
|
||||||
|
for param in parse_qsl(self.request.query)
|
||||||
|
if param[0] not in exclude
|
||||||
|
]
|
||||||
|
if query_string:
|
||||||
|
url = url_concat(url, query_string)
|
||||||
|
return url
|
||||||
|
|
||||||
async def auth_to_user(self, authenticated, user=None):
|
async def auth_to_user(self, authenticated, user=None):
|
||||||
"""Persist data from .authenticate() or .refresh_user() to the User database
|
"""Persist data from .authenticate() or .refresh_user() to the User database
|
||||||
|
|
||||||
@@ -676,9 +725,10 @@ class BaseHandler(RequestHandler):
|
|||||||
raise ValueError("Username doesn't match! %s != %s" % (username, user.name))
|
raise ValueError("Username doesn't match! %s != %s" % (username, user.name))
|
||||||
|
|
||||||
if user is None:
|
if user is None:
|
||||||
new_user = username not in self.users
|
user = self.find_user(username)
|
||||||
user = self.user_from_username(username)
|
new_user = user is None
|
||||||
if new_user:
|
if new_user:
|
||||||
|
user = self.user_from_username(username)
|
||||||
await maybe_future(self.authenticator.add_user(user))
|
await maybe_future(self.authenticator.add_user(user))
|
||||||
# Only set `admin` if the authenticator returned an explicit value.
|
# Only set `admin` if the authenticator returned an explicit value.
|
||||||
if admin is not None and admin != user.admin:
|
if admin is not None and admin != user.admin:
|
||||||
@@ -877,7 +927,7 @@ class BaseHandler(RequestHandler):
|
|||||||
self.log.error(
|
self.log.error(
|
||||||
"Stopping %s to avoid inconsistent state", user_server_name
|
"Stopping %s to avoid inconsistent state", user_server_name
|
||||||
)
|
)
|
||||||
await user.stop()
|
await user.stop(server_name)
|
||||||
PROXY_ADD_DURATION_SECONDS.labels(status='failure').observe(
|
PROXY_ADD_DURATION_SECONDS.labels(status='failure').observe(
|
||||||
time.perf_counter() - proxy_add_start_time
|
time.perf_counter() - proxy_add_start_time
|
||||||
)
|
)
|
||||||
@@ -910,6 +960,9 @@ class BaseHandler(RequestHandler):
|
|||||||
self.settings['failure_count'] = 0
|
self.settings['failure_count'] = 0
|
||||||
return
|
return
|
||||||
# spawn failed, increment count and abort if limit reached
|
# spawn failed, increment count and abort if limit reached
|
||||||
|
SERVER_SPAWN_DURATION_SECONDS.labels(
|
||||||
|
status=ServerSpawnStatus.failure
|
||||||
|
).observe(time.perf_counter() - spawn_start_time)
|
||||||
self.settings.setdefault('failure_count', 0)
|
self.settings.setdefault('failure_count', 0)
|
||||||
self.settings['failure_count'] += 1
|
self.settings['failure_count'] += 1
|
||||||
failure_count = self.settings['failure_count']
|
failure_count = self.settings['failure_count']
|
||||||
@@ -942,13 +995,16 @@ class BaseHandler(RequestHandler):
|
|||||||
# 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.
|
||||||
if spawner._spawn_pending and not spawner._waiting_for_response:
|
if spawner._spawn_pending and not spawner._waiting_for_response:
|
||||||
# still in Spawner.start, which is taking a long time
|
# If slow_spawn_timeout is intentionally disabled then we
|
||||||
# we shouldn't poll while spawn is incomplete.
|
# don't need to log a warning, just return.
|
||||||
self.log.warning(
|
if self.slow_spawn_timeout > 0:
|
||||||
"User %s is slow to start (timeout=%s)",
|
# still in Spawner.start, which is taking a long time
|
||||||
user_server_name,
|
# we shouldn't poll while spawn is incomplete.
|
||||||
self.slow_spawn_timeout,
|
self.log.warning(
|
||||||
)
|
"User %s is slow to start (timeout=%s)",
|
||||||
|
user_server_name,
|
||||||
|
self.slow_spawn_timeout,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# start has finished, but the server hasn't come up
|
# start has finished, but the server hasn't come up
|
||||||
@@ -1085,7 +1141,10 @@ class BaseHandler(RequestHandler):
|
|||||||
except gen.TimeoutError:
|
except gen.TimeoutError:
|
||||||
# hit timeout, but stop is still pending
|
# hit timeout, but stop is still pending
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"User %s:%s server is slow to stop", user.name, server_name
|
"User %s:%s server is slow to stop (timeout=%s)",
|
||||||
|
user.name,
|
||||||
|
server_name,
|
||||||
|
self.slow_stop_timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
# return handle on the future for hooking up callbacks
|
# return handle on the future for hooking up callbacks
|
||||||
@@ -1108,16 +1167,36 @@ class BaseHandler(RequestHandler):
|
|||||||
"<a href='{home}'>home page</a>.".format(home=home)
|
"<a href='{home}'>home page</a>.".format(home=home)
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_template(self, name):
|
def get_template(self, name, sync=False):
|
||||||
"""Return the jinja template object for a given name"""
|
"""
|
||||||
return self.settings['jinja2_env'].get_template(name)
|
Return the jinja template object for a given name
|
||||||
|
|
||||||
def render_template(self, name, **ns):
|
If sync is True, we return a Template that is compiled without async support.
|
||||||
|
Only those can be used in synchronous code.
|
||||||
|
|
||||||
|
If sync is False, we return a Template that is compiled with async support
|
||||||
|
"""
|
||||||
|
if sync:
|
||||||
|
key = 'jinja2_env_sync'
|
||||||
|
else:
|
||||||
|
key = 'jinja2_env'
|
||||||
|
return self.settings[key].get_template(name)
|
||||||
|
|
||||||
|
def render_template(self, name, sync=False, **ns):
|
||||||
|
"""
|
||||||
|
Render jinja2 template
|
||||||
|
|
||||||
|
If sync is set to True, we return an awaitable
|
||||||
|
If sync is set to False, we render the template & return a string
|
||||||
|
"""
|
||||||
template_ns = {}
|
template_ns = {}
|
||||||
template_ns.update(self.template_namespace)
|
template_ns.update(self.template_namespace)
|
||||||
template_ns.update(ns)
|
template_ns.update(ns)
|
||||||
template = self.get_template(name)
|
template = self.get_template(name, sync)
|
||||||
return template.render(**template_ns)
|
if sync:
|
||||||
|
return template.render(**template_ns)
|
||||||
|
else:
|
||||||
|
return template.render_async(**template_ns)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def template_namespace(self):
|
def template_namespace(self):
|
||||||
@@ -1144,6 +1223,8 @@ class BaseHandler(RequestHandler):
|
|||||||
for service in self.services.values():
|
for service in self.services.values():
|
||||||
if not service.url:
|
if not service.url:
|
||||||
continue
|
continue
|
||||||
|
if not service.display:
|
||||||
|
continue
|
||||||
accessible_services.append(service)
|
accessible_services.append(service)
|
||||||
return accessible_services
|
return accessible_services
|
||||||
|
|
||||||
@@ -1190,17 +1271,19 @@ class BaseHandler(RequestHandler):
|
|||||||
# Content-Length must be recalculated.
|
# Content-Length must be recalculated.
|
||||||
self.clear_header('Content-Length')
|
self.clear_header('Content-Length')
|
||||||
|
|
||||||
# render the template
|
# render_template is async, but write_error can't be!
|
||||||
|
# so we run it sync here, instead of making a sync version of render_template
|
||||||
|
|
||||||
try:
|
try:
|
||||||
html = self.render_template('%s.html' % status_code, **ns)
|
html = self.render_template('%s.html' % status_code, sync=True, **ns)
|
||||||
except TemplateNotFound:
|
except TemplateNotFound:
|
||||||
self.log.debug("No template for %d", status_code)
|
self.log.debug("No template for %d", status_code)
|
||||||
try:
|
try:
|
||||||
html = self.render_template('error.html', **ns)
|
html = self.render_template('error.html', sync=True, **ns)
|
||||||
except:
|
except:
|
||||||
# In this case, any side effect must be avoided.
|
# In this case, any side effect must be avoided.
|
||||||
ns['no_spawner_check'] = True
|
ns['no_spawner_check'] = True
|
||||||
html = self.render_template('error.html', **ns)
|
html = self.render_template('error.html', sync=True, **ns)
|
||||||
|
|
||||||
self.write(html)
|
self.write(html)
|
||||||
|
|
||||||
@@ -1404,10 +1487,14 @@ class UserUrlHandler(BaseHandler):
|
|||||||
|
|
||||||
# if request is expecting JSON, assume it's an API request and fail with 503
|
# if request is expecting JSON, assume it's an API request and fail with 503
|
||||||
# because it won't like the redirect to the pending page
|
# because it won't like the redirect to the pending page
|
||||||
if get_accepted_mimetype(
|
if (
|
||||||
self.request.headers.get('Accept', ''),
|
get_accepted_mimetype(
|
||||||
choices=['application/json', 'text/html'],
|
self.request.headers.get('Accept', ''),
|
||||||
) == 'application/json' or 'api' in user_path.split('/'):
|
choices=['application/json', 'text/html'],
|
||||||
|
)
|
||||||
|
== 'application/json'
|
||||||
|
or 'api' in user_path.split('/')
|
||||||
|
):
|
||||||
self._fail_api_request(user_name, server_name)
|
self._fail_api_request(user_name, server_name)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1426,18 +1513,20 @@ class UserUrlHandler(BaseHandler):
|
|||||||
# serve a page prompting for spawn and 503 error
|
# serve a page prompting for spawn and 503 error
|
||||||
# visiting /user/:name no longer triggers implicit spawn
|
# visiting /user/:name no longer triggers implicit spawn
|
||||||
# without explicit user action
|
# without explicit user action
|
||||||
self.set_status(503)
|
|
||||||
spawn_url = url_concat(
|
spawn_url = url_concat(
|
||||||
url_path_join(self.hub.base_url, "spawn", user.escaped_name, server_name),
|
url_path_join(self.hub.base_url, "spawn", user.escaped_name, server_name),
|
||||||
{"next": self.request.uri},
|
{"next": self.request.uri},
|
||||||
)
|
)
|
||||||
|
self.set_status(503)
|
||||||
|
|
||||||
auth_state = await user.get_auth_state()
|
auth_state = await user.get_auth_state()
|
||||||
html = self.render_template(
|
html = await self.render_template(
|
||||||
"not_running.html",
|
"not_running.html",
|
||||||
user=user,
|
user=user,
|
||||||
server_name=server_name,
|
server_name=server_name,
|
||||||
spawn_url=spawn_url,
|
spawn_url=spawn_url,
|
||||||
auth_state=auth_state,
|
auth_state=auth_state,
|
||||||
|
implicit_spawn_seconds=self.settings.get("implicit_spawn_seconds", 0),
|
||||||
)
|
)
|
||||||
self.finish(html)
|
self.finish(html)
|
||||||
|
|
||||||
@@ -1486,7 +1575,7 @@ class UserUrlHandler(BaseHandler):
|
|||||||
if redirects:
|
if redirects:
|
||||||
self.log.warning("Redirect loop detected on %s", self.request.uri)
|
self.log.warning("Redirect loop detected on %s", self.request.uri)
|
||||||
# add capped exponential backoff where cap is 10s
|
# add capped exponential backoff where cap is 10s
|
||||||
await gen.sleep(min(1 * (2 ** redirects), 10))
|
await asyncio.sleep(min(1 * (2 ** redirects), 10))
|
||||||
# rewrite target url with new `redirects` query value
|
# rewrite target url with new `redirects` query value
|
||||||
url_parts = urlparse(target)
|
url_parts = urlparse(target)
|
||||||
query_parts = parse_qs(url_parts.query)
|
query_parts = parse_qs(url_parts.query)
|
||||||
|
@@ -72,14 +72,14 @@ class LogoutHandler(BaseHandler):
|
|||||||
Override this function to set a custom logout page.
|
Override this function to set a custom logout page.
|
||||||
"""
|
"""
|
||||||
if self.authenticator.auto_login:
|
if self.authenticator.auto_login:
|
||||||
html = self.render_template('logout.html')
|
html = await self.render_template('logout.html')
|
||||||
self.finish(html)
|
self.finish(html)
|
||||||
else:
|
else:
|
||||||
self.redirect(self.settings['login_url'], permanent=False)
|
self.redirect(self.settings['login_url'], permanent=False)
|
||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
"""Log the user out, call the custom action, forward the user
|
"""Log the user out, call the custom action, forward the user
|
||||||
to the logout page
|
to the logout page
|
||||||
"""
|
"""
|
||||||
await self.default_handle_logout()
|
await self.default_handle_logout()
|
||||||
await self.handle_logout()
|
await self.handle_logout()
|
||||||
@@ -132,7 +132,7 @@ class LoginHandler(BaseHandler):
|
|||||||
self.redirect(auto_login_url)
|
self.redirect(auto_login_url)
|
||||||
return
|
return
|
||||||
username = self.get_argument('username', default='')
|
username = self.get_argument('username', default='')
|
||||||
self.finish(self._render(username=username))
|
self.finish(await self._render(username=username))
|
||||||
|
|
||||||
async def post(self):
|
async def post(self):
|
||||||
# parse the arguments dict
|
# parse the arguments dict
|
||||||
@@ -149,7 +149,7 @@ class LoginHandler(BaseHandler):
|
|||||||
self._jupyterhub_user = user
|
self._jupyterhub_user = user
|
||||||
self.redirect(self.get_next_url(user))
|
self.redirect(self.get_next_url(user))
|
||||||
else:
|
else:
|
||||||
html = self._render(
|
html = await self._render(
|
||||||
login_error='Invalid username or password', username=data['username']
|
login_error='Invalid username or password', username=data['username']
|
||||||
)
|
)
|
||||||
self.finish(html)
|
self.finish(html)
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
from prometheus_client import CONTENT_TYPE_LATEST
|
from prometheus_client import CONTENT_TYPE_LATEST
|
||||||
from prometheus_client import generate_latest
|
from prometheus_client import generate_latest
|
||||||
from prometheus_client import REGISTRY
|
from prometheus_client import REGISTRY
|
||||||
from tornado import gen
|
|
||||||
|
|
||||||
from ..utils import metrics_authentication
|
from ..utils import metrics_authentication
|
||||||
from .base import BaseHandler
|
from .base import BaseHandler
|
||||||
|
@@ -2,22 +2,21 @@
|
|||||||
# 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.
|
||||||
import asyncio
|
import asyncio
|
||||||
import codecs
|
|
||||||
import copy
|
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from http.client import responses
|
from http.client import responses
|
||||||
|
|
||||||
from jinja2 import TemplateNotFound
|
from jinja2 import TemplateNotFound
|
||||||
from tornado import gen
|
|
||||||
from tornado import web
|
from tornado import web
|
||||||
from tornado.httputil import url_concat
|
from tornado.httputil import url_concat
|
||||||
|
from tornado.httputil import urlparse
|
||||||
|
|
||||||
from .. import __version__
|
from .. import __version__
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..metrics import SERVER_POLL_DURATION_SECONDS
|
from ..metrics import SERVER_POLL_DURATION_SECONDS
|
||||||
from ..metrics import ServerPollStatus
|
from ..metrics import ServerPollStatus
|
||||||
|
from ..pagination import Pagination
|
||||||
from ..utils import admin_only
|
from ..utils import admin_only
|
||||||
from ..utils import maybe_future
|
from ..utils import maybe_future
|
||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
@@ -41,11 +40,15 @@ class RootHandler(BaseHandler):
|
|||||||
def get(self):
|
def get(self):
|
||||||
user = self.current_user
|
user = self.current_user
|
||||||
if self.default_url:
|
if self.default_url:
|
||||||
url = self.default_url
|
# As set in jupyterhub_config.py
|
||||||
|
if callable(self.default_url):
|
||||||
|
url = self.default_url(self)
|
||||||
|
else:
|
||||||
|
url = self.default_url
|
||||||
elif user:
|
elif user:
|
||||||
url = self.get_next_url(user)
|
url = self.get_next_url(user)
|
||||||
else:
|
else:
|
||||||
url = self.settings['login_url']
|
url = url_concat(self.settings["login_url"], dict(next=self.request.uri))
|
||||||
self.redirect(url)
|
self.redirect(url)
|
||||||
|
|
||||||
|
|
||||||
@@ -68,7 +71,7 @@ class HomeHandler(BaseHandler):
|
|||||||
url = url_path_join(self.hub.base_url, 'spawn', user.escaped_name)
|
url = url_path_join(self.hub.base_url, 'spawn', user.escaped_name)
|
||||||
|
|
||||||
auth_state = await user.get_auth_state()
|
auth_state = await user.get_auth_state()
|
||||||
html = self.render_template(
|
html = await self.render_template(
|
||||||
'home.html',
|
'home.html',
|
||||||
auth_state=auth_state,
|
auth_state=auth_state,
|
||||||
user=user,
|
user=user,
|
||||||
@@ -95,7 +98,7 @@ class SpawnHandler(BaseHandler):
|
|||||||
|
|
||||||
async def _render_form(self, for_user, spawner_options_form, message=''):
|
async def _render_form(self, for_user, spawner_options_form, message=''):
|
||||||
auth_state = await for_user.get_auth_state()
|
auth_state = await for_user.get_auth_state()
|
||||||
return self.render_template(
|
return await self.render_template(
|
||||||
'spawn.html',
|
'spawn.html',
|
||||||
for_user=for_user,
|
for_user=for_user,
|
||||||
auth_state=auth_state,
|
auth_state=auth_state,
|
||||||
@@ -151,17 +154,7 @@ class SpawnHandler(BaseHandler):
|
|||||||
|
|
||||||
spawner = user.spawners[server_name]
|
spawner = user.spawners[server_name]
|
||||||
|
|
||||||
# resolve `?next=...`, falling back on the spawn-pending url
|
pending_url = self._get_pending_url(user, server_name)
|
||||||
# must not be /user/server for named servers,
|
|
||||||
# which may get handled by the default server if they aren't ready yet
|
|
||||||
|
|
||||||
pending_url = url_path_join(
|
|
||||||
self.hub.base_url, "spawn-pending", user.escaped_name, server_name
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.get_argument('next', None):
|
|
||||||
# preserve `?next=...` through spawn-pending
|
|
||||||
pending_url = url_concat(pending_url, {'next': self.get_argument('next')})
|
|
||||||
|
|
||||||
# spawner is active, redirect back to get progress, etc.
|
# spawner is active, redirect back to get progress, etc.
|
||||||
if spawner.ready:
|
if spawner.ready:
|
||||||
@@ -183,35 +176,50 @@ class SpawnHandler(BaseHandler):
|
|||||||
auth_state = await user.get_auth_state()
|
auth_state = await user.get_auth_state()
|
||||||
await spawner.run_auth_state_hook(auth_state)
|
await spawner.run_auth_state_hook(auth_state)
|
||||||
|
|
||||||
|
# Try to start server directly when query arguments are passed.
|
||||||
|
error_message = ''
|
||||||
|
query_options = {}
|
||||||
|
for key, byte_list in self.request.query_arguments.items():
|
||||||
|
query_options[key] = [bs.decode('utf8') for bs in byte_list]
|
||||||
|
|
||||||
|
# 'next' is reserved argument for redirect after spawn
|
||||||
|
query_options.pop('next', None)
|
||||||
|
|
||||||
|
if len(query_options) > 0:
|
||||||
|
try:
|
||||||
|
self.log.debug(
|
||||||
|
"Triggering spawn with supplied query arguments for %s",
|
||||||
|
spawner._log_name,
|
||||||
|
)
|
||||||
|
options = await maybe_future(spawner.options_from_query(query_options))
|
||||||
|
pending_url = self._get_pending_url(user, server_name)
|
||||||
|
return await self._wrap_spawn_single_user(
|
||||||
|
user, server_name, spawner, pending_url, options
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error(
|
||||||
|
"Failed to spawn single-user server with query arguments",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
error_message = str(e)
|
||||||
|
# fallback to behavior without failing query arguments
|
||||||
|
|
||||||
spawner_options_form = await spawner.get_options_form()
|
spawner_options_form = await spawner.get_options_form()
|
||||||
if spawner_options_form:
|
if spawner_options_form:
|
||||||
self.log.debug("Serving options form for %s", spawner._log_name)
|
self.log.debug("Serving options form for %s", spawner._log_name)
|
||||||
form = await self._render_form(
|
form = await self._render_form(
|
||||||
for_user=user, spawner_options_form=spawner_options_form
|
for_user=user,
|
||||||
|
spawner_options_form=spawner_options_form,
|
||||||
|
message=error_message,
|
||||||
)
|
)
|
||||||
self.finish(form)
|
self.finish(form)
|
||||||
else:
|
else:
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
"Triggering spawn with default options for %s", spawner._log_name
|
"Triggering spawn with default options for %s", spawner._log_name
|
||||||
)
|
)
|
||||||
# Explicit spawn request: clear _spawn_future
|
return await self._wrap_spawn_single_user(
|
||||||
# which may have been saved to prevent implicit spawns
|
user, server_name, spawner, pending_url
|
||||||
# after a failure.
|
)
|
||||||
if spawner._spawn_future and spawner._spawn_future.done():
|
|
||||||
spawner._spawn_future = None
|
|
||||||
# not running, no form. Trigger spawn and redirect back to /user/:name
|
|
||||||
f = asyncio.ensure_future(self.spawn_single_user(user, server_name))
|
|
||||||
done, pending = await asyncio.wait([f], timeout=1)
|
|
||||||
# If spawn_single_user throws an exception, raise a 500 error
|
|
||||||
# otherwise it may cause a redirect loop
|
|
||||||
if f.done() and f.exception():
|
|
||||||
exc = f.exception()
|
|
||||||
raise web.HTTPError(
|
|
||||||
500,
|
|
||||||
"Error in Authenticator.pre_spawn_start: %s %s"
|
|
||||||
% (type(exc).__name__, str(exc)),
|
|
||||||
)
|
|
||||||
self.redirect(pending_url)
|
|
||||||
|
|
||||||
@web.authenticated
|
@web.authenticated
|
||||||
async def post(self, for_user=None, server_name=''):
|
async def post(self, for_user=None, server_name=''):
|
||||||
@@ -241,8 +249,14 @@ class SpawnHandler(BaseHandler):
|
|||||||
for key, byte_list in self.request.files.items():
|
for key, byte_list in self.request.files.items():
|
||||||
form_options["%s_file" % key] = byte_list
|
form_options["%s_file" % key] = byte_list
|
||||||
try:
|
try:
|
||||||
|
self.log.debug(
|
||||||
|
"Triggering spawn with supplied form options for %s", spawner._log_name
|
||||||
|
)
|
||||||
options = await maybe_future(spawner.options_from_form(form_options))
|
options = await maybe_future(spawner.options_from_form(form_options))
|
||||||
await self.spawn_single_user(user, server_name=server_name, options=options)
|
pending_url = self._get_pending_url(user, server_name)
|
||||||
|
return await self._wrap_spawn_single_user(
|
||||||
|
user, server_name, spawner, pending_url, options
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.error(
|
self.log.error(
|
||||||
"Failed to spawn single-user server with form", exc_info=True
|
"Failed to spawn single-user server with form", exc_info=True
|
||||||
@@ -263,6 +277,47 @@ class SpawnHandler(BaseHandler):
|
|||||||
)
|
)
|
||||||
self.redirect(next_url)
|
self.redirect(next_url)
|
||||||
|
|
||||||
|
def _get_pending_url(self, user, server_name):
|
||||||
|
# resolve `?next=...`, falling back on the spawn-pending url
|
||||||
|
# must not be /user/server for named servers,
|
||||||
|
# which may get handled by the default server if they aren't ready yet
|
||||||
|
|
||||||
|
pending_url = url_path_join(
|
||||||
|
self.hub.base_url, "spawn-pending", user.escaped_name, server_name
|
||||||
|
)
|
||||||
|
|
||||||
|
pending_url = self.append_query_parameters(pending_url, exclude=['next'])
|
||||||
|
|
||||||
|
if self.get_argument('next', None):
|
||||||
|
# preserve `?next=...` through spawn-pending
|
||||||
|
pending_url = url_concat(pending_url, {'next': self.get_argument('next')})
|
||||||
|
|
||||||
|
return pending_url
|
||||||
|
|
||||||
|
async def _wrap_spawn_single_user(
|
||||||
|
self, user, server_name, spawner, pending_url, options=None
|
||||||
|
):
|
||||||
|
# Explicit spawn request: clear _spawn_future
|
||||||
|
# which may have been saved to prevent implicit spawns
|
||||||
|
# after a failure.
|
||||||
|
if spawner._spawn_future and spawner._spawn_future.done():
|
||||||
|
spawner._spawn_future = None
|
||||||
|
# not running, no form. Trigger spawn and redirect back to /user/:name
|
||||||
|
f = asyncio.ensure_future(
|
||||||
|
self.spawn_single_user(user, server_name, options=options)
|
||||||
|
)
|
||||||
|
done, pending = await asyncio.wait([f], timeout=1)
|
||||||
|
# If spawn_single_user throws an exception, raise a 500 error
|
||||||
|
# otherwise it may cause a redirect loop
|
||||||
|
if f.done() and f.exception():
|
||||||
|
exc = f.exception()
|
||||||
|
raise web.HTTPError(
|
||||||
|
500,
|
||||||
|
"Error in Authenticator.pre_spawn_start: %s %s"
|
||||||
|
% (type(exc).__name__, str(exc)),
|
||||||
|
)
|
||||||
|
return self.redirect(pending_url)
|
||||||
|
|
||||||
|
|
||||||
class SpawnPendingHandler(BaseHandler):
|
class SpawnPendingHandler(BaseHandler):
|
||||||
"""Handle /hub/spawn-pending/:user/:server
|
"""Handle /hub/spawn-pending/:user/:server
|
||||||
@@ -327,7 +382,7 @@ class SpawnPendingHandler(BaseHandler):
|
|||||||
self.hub.base_url, "spawn", user.escaped_name, server_name
|
self.hub.base_url, "spawn", user.escaped_name, server_name
|
||||||
)
|
)
|
||||||
self.set_status(500)
|
self.set_status(500)
|
||||||
html = self.render_template(
|
html = await self.render_template(
|
||||||
"not_running.html",
|
"not_running.html",
|
||||||
user=user,
|
user=user,
|
||||||
auth_state=auth_state,
|
auth_state=auth_state,
|
||||||
@@ -351,7 +406,7 @@ class SpawnPendingHandler(BaseHandler):
|
|||||||
page = "stop_pending.html"
|
page = "stop_pending.html"
|
||||||
else:
|
else:
|
||||||
page = "spawn_pending.html"
|
page = "spawn_pending.html"
|
||||||
html = self.render_template(
|
html = await self.render_template(
|
||||||
page,
|
page,
|
||||||
user=user,
|
user=user,
|
||||||
spawner=spawner,
|
spawner=spawner,
|
||||||
@@ -378,7 +433,7 @@ class SpawnPendingHandler(BaseHandler):
|
|||||||
spawn_url = url_path_join(
|
spawn_url = url_path_join(
|
||||||
self.hub.base_url, "spawn", user.escaped_name, server_name
|
self.hub.base_url, "spawn", user.escaped_name, server_name
|
||||||
)
|
)
|
||||||
html = self.render_template(
|
html = await self.render_template(
|
||||||
"not_running.html",
|
"not_running.html",
|
||||||
user=user,
|
user=user,
|
||||||
auth_state=auth_state,
|
auth_state=auth_state,
|
||||||
@@ -402,12 +457,16 @@ class AdminHandler(BaseHandler):
|
|||||||
@web.authenticated
|
@web.authenticated
|
||||||
@admin_only
|
@admin_only
|
||||||
async def get(self):
|
async def get(self):
|
||||||
|
pagination = Pagination(url=self.request.uri, config=self.config)
|
||||||
|
page, per_page, offset = pagination.get_page_args(self)
|
||||||
|
|
||||||
available = {'name', 'admin', 'running', 'last_activity'}
|
available = {'name', 'admin', 'running', 'last_activity'}
|
||||||
default_sort = ['admin', 'name']
|
default_sort = ['admin', 'name']
|
||||||
mapping = {'running': orm.Spawner.server_id}
|
mapping = {'running': orm.Spawner.server_id}
|
||||||
for name in available:
|
for name in available:
|
||||||
if name not in mapping:
|
if name not in mapping:
|
||||||
mapping[name] = getattr(orm.User, name)
|
table = orm.User if name != "last_activity" else orm.Spawner
|
||||||
|
mapping[name] = getattr(table, name)
|
||||||
|
|
||||||
default_order = {
|
default_order = {
|
||||||
'name': 'asc',
|
'name': 'asc',
|
||||||
@@ -442,16 +501,23 @@ class AdminHandler(BaseHandler):
|
|||||||
# get User.col.desc() order objects
|
# get User.col.desc() order objects
|
||||||
ordered = [getattr(c, o)() for c, o in zip(cols, orders)]
|
ordered = [getattr(c, o)() for c, o in zip(cols, orders)]
|
||||||
|
|
||||||
users = self.db.query(orm.User).outerjoin(orm.Spawner).order_by(*ordered)
|
users = (
|
||||||
|
self.db.query(orm.User)
|
||||||
|
.outerjoin(orm.Spawner)
|
||||||
|
.order_by(*ordered)
|
||||||
|
.limit(per_page)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
users = [self._user_from_orm(u) for u in users]
|
users = [self._user_from_orm(u) for u in users]
|
||||||
from itertools import chain
|
|
||||||
|
|
||||||
running = []
|
running = []
|
||||||
for u in users:
|
for u in users:
|
||||||
running.extend(s for s in u.spawners.values() if s.active)
|
running.extend(s for s in u.spawners.values() if s.active)
|
||||||
|
|
||||||
|
pagination.total = self.db.query(orm.User.id).count()
|
||||||
|
|
||||||
auth_state = await self.current_user.get_auth_state()
|
auth_state = await self.current_user.get_auth_state()
|
||||||
html = self.render_template(
|
html = await self.render_template(
|
||||||
'admin.html',
|
'admin.html',
|
||||||
current_user=self.current_user,
|
current_user=self.current_user,
|
||||||
auth_state=auth_state,
|
auth_state=auth_state,
|
||||||
@@ -462,6 +528,7 @@ class AdminHandler(BaseHandler):
|
|||||||
allow_named_servers=self.allow_named_servers,
|
allow_named_servers=self.allow_named_servers,
|
||||||
named_server_limit_per_user=self.named_server_limit_per_user,
|
named_server_limit_per_user=self.named_server_limit_per_user,
|
||||||
server_version='{} {}'.format(__version__, self.version_hash),
|
server_version='{} {}'.format(__version__, self.version_hash),
|
||||||
|
pagination=pagination,
|
||||||
)
|
)
|
||||||
self.finish(html)
|
self.finish(html)
|
||||||
|
|
||||||
@@ -540,7 +607,7 @@ class TokenPageHandler(BaseHandler):
|
|||||||
oauth_clients = sorted(oauth_clients, key=sort_key, reverse=True)
|
oauth_clients = sorted(oauth_clients, key=sort_key, reverse=True)
|
||||||
|
|
||||||
auth_state = await self.current_user.get_auth_state()
|
auth_state = await self.current_user.get_auth_state()
|
||||||
html = self.render_template(
|
html = await self.render_template(
|
||||||
'token.html',
|
'token.html',
|
||||||
api_tokens=api_tokens,
|
api_tokens=api_tokens,
|
||||||
oauth_clients=oauth_clients,
|
oauth_clients=oauth_clients,
|
||||||
@@ -552,7 +619,7 @@ class TokenPageHandler(BaseHandler):
|
|||||||
class ProxyErrorHandler(BaseHandler):
|
class ProxyErrorHandler(BaseHandler):
|
||||||
"""Handler for rendering proxy error pages"""
|
"""Handler for rendering proxy error pages"""
|
||||||
|
|
||||||
def get(self, status_code_s):
|
async def get(self, status_code_s):
|
||||||
status_code = int(status_code_s)
|
status_code = int(status_code_s)
|
||||||
status_message = responses.get(status_code, 'Unknown HTTP Error')
|
status_message = responses.get(status_code, 'Unknown HTTP Error')
|
||||||
# build template namespace
|
# build template namespace
|
||||||
@@ -576,19 +643,23 @@ class ProxyErrorHandler(BaseHandler):
|
|||||||
self.set_header('Content-Type', 'text/html')
|
self.set_header('Content-Type', 'text/html')
|
||||||
# render the template
|
# render the template
|
||||||
try:
|
try:
|
||||||
html = self.render_template('%s.html' % status_code, **ns)
|
html = await 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)
|
html = await self.render_template('error.html', **ns)
|
||||||
|
|
||||||
self.write(html)
|
self.write(html)
|
||||||
|
|
||||||
|
|
||||||
class HealthCheckHandler(BaseHandler):
|
class HealthCheckHandler(BaseHandler):
|
||||||
"""Answer to health check"""
|
"""Serve health check probes as quickly as possible"""
|
||||||
|
|
||||||
def get(self, *args):
|
# There is nothing for us to do other than return a positive
|
||||||
self.finish()
|
# HTTP status code as quickly as possible for GET or HEAD requests
|
||||||
|
def get(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
head = get
|
||||||
|
|
||||||
|
|
||||||
default_handlers = [
|
default_handlers = [
|
||||||
|
@@ -7,7 +7,7 @@ from tornado.web import StaticFileHandler
|
|||||||
|
|
||||||
class CacheControlStaticFilesHandler(StaticFileHandler):
|
class CacheControlStaticFilesHandler(StaticFileHandler):
|
||||||
"""StaticFileHandler subclass that sets Cache-Control: no-cache without `?v=`
|
"""StaticFileHandler subclass that sets Cache-Control: no-cache without `?v=`
|
||||||
|
|
||||||
rather than relying on default browser cache behavior.
|
rather than relying on default browser cache behavior.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@@ -12,6 +12,7 @@ from tornado.log import LogFormatter
|
|||||||
from tornado.web import HTTPError
|
from tornado.web import HTTPError
|
||||||
from tornado.web import StaticFileHandler
|
from tornado.web import StaticFileHandler
|
||||||
|
|
||||||
|
from .handlers.pages import HealthCheckHandler
|
||||||
from .metrics import prometheus_log_method
|
from .metrics import prometheus_log_method
|
||||||
|
|
||||||
|
|
||||||
@@ -98,8 +99,12 @@ def _scrub_headers(headers):
|
|||||||
headers = dict(headers)
|
headers = dict(headers)
|
||||||
if 'Authorization' in headers:
|
if 'Authorization' in headers:
|
||||||
auth = headers['Authorization']
|
auth = headers['Authorization']
|
||||||
if auth.startswith('token '):
|
if ' ' in auth:
|
||||||
headers['Authorization'] = 'token [secret]'
|
auth_type = auth.split(' ', 1)[0]
|
||||||
|
else:
|
||||||
|
# no space, hide the whole thing in case there was a mistake
|
||||||
|
auth_type = ''
|
||||||
|
headers['Authorization'] = '{} [secret]'.format(auth_type)
|
||||||
if 'Cookie' in headers:
|
if 'Cookie' in headers:
|
||||||
c = SimpleCookie(headers['Cookie'])
|
c = SimpleCookie(headers['Cookie'])
|
||||||
redacted = []
|
redacted = []
|
||||||
@@ -123,7 +128,9 @@ def log_request(handler):
|
|||||||
"""
|
"""
|
||||||
status = handler.get_status()
|
status = handler.get_status()
|
||||||
request = handler.request
|
request = handler.request
|
||||||
if status == 304 or (status < 300 and isinstance(handler, StaticFileHandler)):
|
if status == 304 or (
|
||||||
|
status < 300 and isinstance(handler, (StaticFileHandler, HealthCheckHandler))
|
||||||
|
):
|
||||||
# static-file success and 304 Found are debug-level
|
# static-file success and 304 Found are debug-level
|
||||||
log_method = access_log.debug
|
log_method = access_log.debug
|
||||||
elif status < 400:
|
elif status < 400:
|
||||||
|
@@ -3,9 +3,9 @@ Prometheus metrics exported by JupyterHub
|
|||||||
|
|
||||||
Read https://prometheus.io/docs/practices/naming/ for naming
|
Read https://prometheus.io/docs/practices/naming/ for naming
|
||||||
conventions for metrics & labels. We generally prefer naming them
|
conventions for metrics & labels. We generally prefer naming them
|
||||||
`<noun>_<verb>_<type_suffix>`. So a histogram that's tracking
|
`jupyterhub_<noun>_<verb>_<type_suffix>`. So a histogram that's tracking
|
||||||
the duration (in seconds) of servers spawning would be called
|
the duration (in seconds) of servers spawning would be called
|
||||||
SERVER_SPAWN_DURATION_SECONDS.
|
jupyterhub_server_spawn_duration_seconds.
|
||||||
|
|
||||||
We also create an Enum for each 'status' type label in every metric
|
We also create an Enum for each 'status' type label in every metric
|
||||||
we collect. This is to make sure that the metrics exist regardless
|
we collect. This is to make sure that the metrics exist regardless
|
||||||
@@ -14,6 +14,10 @@ create them, the metric spawn_duration_seconds{status="failure"}
|
|||||||
will not actually exist until the first failure. This makes dashboarding
|
will not actually exist until the first failure. This makes dashboarding
|
||||||
and alerting difficult, so we explicitly list statuses and create
|
and alerting difficult, so we explicitly list statuses and create
|
||||||
them manually here.
|
them manually here.
|
||||||
|
|
||||||
|
.. versionchanged:: 1.3
|
||||||
|
|
||||||
|
added ``jupyterhub_`` prefix to metric names.
|
||||||
"""
|
"""
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
@@ -21,13 +25,13 @@ from prometheus_client import Gauge
|
|||||||
from prometheus_client import Histogram
|
from prometheus_client import Histogram
|
||||||
|
|
||||||
REQUEST_DURATION_SECONDS = Histogram(
|
REQUEST_DURATION_SECONDS = Histogram(
|
||||||
'request_duration_seconds',
|
'jupyterhub_request_duration_seconds',
|
||||||
'request duration for all HTTP requests',
|
'request duration for all HTTP requests',
|
||||||
['method', 'handler', 'code'],
|
['method', 'handler', 'code'],
|
||||||
)
|
)
|
||||||
|
|
||||||
SERVER_SPAWN_DURATION_SECONDS = Histogram(
|
SERVER_SPAWN_DURATION_SECONDS = Histogram(
|
||||||
'server_spawn_duration_seconds',
|
'jupyterhub_server_spawn_duration_seconds',
|
||||||
'time taken for server spawning operation',
|
'time taken for server spawning operation',
|
||||||
['status'],
|
['status'],
|
||||||
# Use custom bucket sizes, since the default bucket ranges
|
# Use custom bucket sizes, since the default bucket ranges
|
||||||
@@ -36,25 +40,27 @@ SERVER_SPAWN_DURATION_SECONDS = Histogram(
|
|||||||
)
|
)
|
||||||
|
|
||||||
RUNNING_SERVERS = Gauge(
|
RUNNING_SERVERS = Gauge(
|
||||||
'running_servers', 'the number of user servers currently running'
|
'jupyterhub_running_servers', 'the number of user servers currently running'
|
||||||
)
|
)
|
||||||
|
|
||||||
TOTAL_USERS = Gauge('total_users', 'total number of users')
|
TOTAL_USERS = Gauge('jupyterhub_total_users', 'total number of users')
|
||||||
|
|
||||||
CHECK_ROUTES_DURATION_SECONDS = Histogram(
|
CHECK_ROUTES_DURATION_SECONDS = Histogram(
|
||||||
'check_routes_duration_seconds', 'Time taken to validate all routes in proxy'
|
'jupyterhub_check_routes_duration_seconds',
|
||||||
|
'Time taken to validate all routes in proxy',
|
||||||
)
|
)
|
||||||
|
|
||||||
HUB_STARTUP_DURATION_SECONDS = Histogram(
|
HUB_STARTUP_DURATION_SECONDS = Histogram(
|
||||||
'hub_startup_duration_seconds', 'Time taken for Hub to start'
|
'jupyterhub_hub_startup_duration_seconds', 'Time taken for Hub to start'
|
||||||
)
|
)
|
||||||
|
|
||||||
INIT_SPAWNERS_DURATION_SECONDS = Histogram(
|
INIT_SPAWNERS_DURATION_SECONDS = Histogram(
|
||||||
'init_spawners_duration_seconds', 'Time taken for spawners to initialize'
|
'jupyterhub_init_spawners_duration_seconds', 'Time taken for spawners to initialize'
|
||||||
)
|
)
|
||||||
|
|
||||||
PROXY_POLL_DURATION_SECONDS = Histogram(
|
PROXY_POLL_DURATION_SECONDS = Histogram(
|
||||||
'proxy_poll_duration_seconds', 'duration for polling all routes from proxy'
|
'jupyterhub_proxy_poll_duration_seconds',
|
||||||
|
'duration for polling all routes from proxy',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -79,7 +85,9 @@ for s in ServerSpawnStatus:
|
|||||||
|
|
||||||
|
|
||||||
PROXY_ADD_DURATION_SECONDS = Histogram(
|
PROXY_ADD_DURATION_SECONDS = Histogram(
|
||||||
'proxy_add_duration_seconds', 'duration for adding user routes to proxy', ['status']
|
'jupyterhub_proxy_add_duration_seconds',
|
||||||
|
'duration for adding user routes to proxy',
|
||||||
|
['status'],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -100,7 +108,7 @@ for s in ProxyAddStatus:
|
|||||||
|
|
||||||
|
|
||||||
SERVER_POLL_DURATION_SECONDS = Histogram(
|
SERVER_POLL_DURATION_SECONDS = Histogram(
|
||||||
'server_poll_duration_seconds',
|
'jupyterhub_server_poll_duration_seconds',
|
||||||
'time taken to poll if server is running',
|
'time taken to poll if server is running',
|
||||||
['status'],
|
['status'],
|
||||||
)
|
)
|
||||||
@@ -127,7 +135,9 @@ for s in ServerPollStatus:
|
|||||||
|
|
||||||
|
|
||||||
SERVER_STOP_DURATION_SECONDS = Histogram(
|
SERVER_STOP_DURATION_SECONDS = Histogram(
|
||||||
'server_stop_seconds', 'time taken for server stopping operation', ['status']
|
'jupyterhub_server_stop_seconds',
|
||||||
|
'time taken for server stopping operation',
|
||||||
|
['status'],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -148,7 +158,7 @@ for s in ServerStopStatus:
|
|||||||
|
|
||||||
|
|
||||||
PROXY_DELETE_DURATION_SECONDS = Histogram(
|
PROXY_DELETE_DURATION_SECONDS = Histogram(
|
||||||
'proxy_delete_duration_seconds',
|
'jupyterhub_proxy_delete_duration_seconds',
|
||||||
'duration for deleting user routes from proxy',
|
'duration for deleting user routes from proxy',
|
||||||
['status'],
|
['status'],
|
||||||
)
|
)
|
||||||
@@ -175,9 +185,9 @@ def prometheus_log_method(handler):
|
|||||||
Tornado log handler for recording RED metrics.
|
Tornado log handler for recording RED metrics.
|
||||||
|
|
||||||
We record the following metrics:
|
We record the following metrics:
|
||||||
Rate – the number of requests, per second, your services are serving.
|
Rate: the number of requests, per second, your services are serving.
|
||||||
Errors – the number of failed requests per second.
|
Errors: the number of failed requests per second.
|
||||||
Duration – The amount of time each request takes expressed as a time interval.
|
Duration: the amount of time each request takes expressed as a time interval.
|
||||||
|
|
||||||
We use a fully qualified name of the handler as a label,
|
We use a fully qualified name of the handler as a label,
|
||||||
rather than every url path to reduce cardinality.
|
rather than every url path to reduce cardinality.
|
||||||
|
@@ -2,16 +2,11 @@
|
|||||||
|
|
||||||
implements https://oauthlib.readthedocs.io/en/latest/oauth2/server.html
|
implements https://oauthlib.readthedocs.io/en/latest/oauth2/server.html
|
||||||
"""
|
"""
|
||||||
from datetime import datetime
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from oauthlib import uri_validate
|
from oauthlib import uri_validate
|
||||||
from oauthlib.oauth2 import RequestValidator
|
from oauthlib.oauth2 import RequestValidator
|
||||||
from oauthlib.oauth2 import WebApplicationServer
|
from oauthlib.oauth2 import WebApplicationServer
|
||||||
from oauthlib.oauth2.rfc6749.grant_types import authorization_code
|
from oauthlib.oauth2.rfc6749.grant_types import authorization_code
|
||||||
from oauthlib.oauth2.rfc6749.grant_types import base
|
from oauthlib.oauth2.rfc6749.grant_types import base
|
||||||
from sqlalchemy.orm import scoped_session
|
|
||||||
from tornado import web
|
|
||||||
from tornado.escape import url_escape
|
from tornado.escape import url_escape
|
||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
|
|
||||||
@@ -250,7 +245,7 @@ class JupyterHubRequestValidator(RequestValidator):
|
|||||||
client=orm_client,
|
client=orm_client,
|
||||||
code=code['code'],
|
code=code['code'],
|
||||||
# oauth has 5 minutes to complete
|
# oauth has 5 minutes to complete
|
||||||
expires_at=int(datetime.utcnow().timestamp() + 300),
|
expires_at=int(orm.OAuthCode.now() + 300),
|
||||||
# TODO: persist oauth scopes
|
# TODO: persist oauth scopes
|
||||||
# scopes=request.scopes,
|
# scopes=request.scopes,
|
||||||
user=request.user.orm_user,
|
user=request.user.orm_user,
|
||||||
@@ -261,7 +256,7 @@ class JupyterHubRequestValidator(RequestValidator):
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
def get_authorization_code_scopes(self, client_id, code, redirect_uri, request):
|
def get_authorization_code_scopes(self, client_id, code, redirect_uri, request):
|
||||||
""" Extracts scopes from saved authorization code.
|
"""Extracts scopes from saved authorization code.
|
||||||
The scopes returned by this method is used to route token requests
|
The scopes returned by this method is used to route token requests
|
||||||
based on scopes passed to Authorization Code requests.
|
based on scopes passed to Authorization Code requests.
|
||||||
With that the token endpoint knows when to include OpenIDConnect
|
With that the token endpoint knows when to include OpenIDConnect
|
||||||
@@ -347,7 +342,7 @@ class JupyterHubRequestValidator(RequestValidator):
|
|||||||
orm_access_token = orm.OAuthAccessToken(
|
orm_access_token = orm.OAuthAccessToken(
|
||||||
client=client,
|
client=client,
|
||||||
grant_type=orm.GrantType.authorization_code,
|
grant_type=orm.GrantType.authorization_code,
|
||||||
expires_at=datetime.utcnow().timestamp() + token['expires_in'],
|
expires_at=orm.OAuthAccessToken.now() + token['expires_in'],
|
||||||
refresh_token=token['refresh_token'],
|
refresh_token=token['refresh_token'],
|
||||||
# TODO: save scopes,
|
# TODO: save scopes,
|
||||||
# scopes=scopes,
|
# scopes=scopes,
|
||||||
@@ -441,7 +436,7 @@ class JupyterHubRequestValidator(RequestValidator):
|
|||||||
Method is used by:
|
Method is used by:
|
||||||
- Authorization Code Grant
|
- Authorization Code Grant
|
||||||
"""
|
"""
|
||||||
orm_code = self.db.query(orm.OAuthCode).filter_by(code=code).first()
|
orm_code = orm.OAuthCode.find(self.db, code=code)
|
||||||
if orm_code is None:
|
if orm_code is None:
|
||||||
app_log.debug("No such code: %s", code)
|
app_log.debug("No such code: %s", code)
|
||||||
return False
|
return False
|
||||||
|
@@ -53,7 +53,7 @@ class Server(HasTraits):
|
|||||||
Never used in APIs, only logging,
|
Never used in APIs, only logging,
|
||||||
since it can be non-connectable value, such as '', meaning all interfaces.
|
since it can be non-connectable value, such as '', meaning all interfaces.
|
||||||
"""
|
"""
|
||||||
if self.ip in {'', '0.0.0.0'}:
|
if self.ip in {'', '0.0.0.0', '::'}:
|
||||||
return self.url.replace(self._connect_ip, self.ip or '*', 1)
|
return self.url.replace(self._connect_ip, self.ip or '*', 1)
|
||||||
return self.url
|
return self.url
|
||||||
|
|
||||||
@@ -87,13 +87,13 @@ class Server(HasTraits):
|
|||||||
"""The address to use when connecting to this server
|
"""The address to use when connecting to this server
|
||||||
|
|
||||||
When `ip` is set to a real ip address, the same value is used.
|
When `ip` is set to a real ip address, the same value is used.
|
||||||
When `ip` refers to 'all interfaces' (e.g. '0.0.0.0'),
|
When `ip` refers to 'all interfaces' (e.g. '0.0.0.0' or '::'),
|
||||||
clients connect via hostname by default.
|
clients connect via hostname by default.
|
||||||
Setting `connect_ip` explicitly overrides any default behavior.
|
Setting `connect_ip` explicitly overrides any default behavior.
|
||||||
"""
|
"""
|
||||||
if self.connect_ip:
|
if self.connect_ip:
|
||||||
return self.connect_ip
|
return self.connect_ip
|
||||||
elif self.ip in {'', '0.0.0.0'}:
|
elif self.ip in {'', '0.0.0.0', '::'}:
|
||||||
# if listening on all interfaces, default to hostname for connect
|
# if listening on all interfaces, default to hostname for connect
|
||||||
return socket.gethostname()
|
return socket.gethostname()
|
||||||
else:
|
else:
|
||||||
@@ -149,7 +149,12 @@ class Server(HasTraits):
|
|||||||
if self.connect_url:
|
if self.connect_url:
|
||||||
parsed = urlparse(self.connect_url)
|
parsed = urlparse(self.connect_url)
|
||||||
return "{proto}://{host}".format(proto=parsed.scheme, host=parsed.netloc)
|
return "{proto}://{host}".format(proto=parsed.scheme, host=parsed.netloc)
|
||||||
return "{proto}://{ip}:{port}".format(
|
|
||||||
|
if ':' in self._connect_ip:
|
||||||
|
fmt = "{proto}://[{ip}]:{port}"
|
||||||
|
else:
|
||||||
|
fmt = "{proto}://{ip}:{port}"
|
||||||
|
return fmt.format(
|
||||||
proto=self.proto, ip=self._connect_ip, port=self._connect_port
|
proto=self.proto, ip=self._connect_ip, port=self._connect_port
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -26,6 +26,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy import Table
|
from sqlalchemy import Table
|
||||||
from sqlalchemy import Unicode
|
from sqlalchemy import Unicode
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import backref
|
||||||
from sqlalchemy.orm import interfaces
|
from sqlalchemy.orm import interfaces
|
||||||
from sqlalchemy.orm import object_session
|
from sqlalchemy.orm import object_session
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
@@ -230,7 +231,12 @@ class Spawner(Base):
|
|||||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||||
|
|
||||||
server_id = Column(Integer, ForeignKey('servers.id', ondelete='SET NULL'))
|
server_id = Column(Integer, ForeignKey('servers.id', ondelete='SET NULL'))
|
||||||
server = relationship(Server, cascade="all")
|
server = relationship(
|
||||||
|
Server,
|
||||||
|
backref=backref('spawner', uselist=False),
|
||||||
|
single_parent=True,
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
state = Column(JSONDict)
|
state = Column(JSONDict)
|
||||||
name = Column(Unicode(255))
|
name = Column(Unicode(255))
|
||||||
@@ -282,7 +288,12 @@ class Service(Base):
|
|||||||
|
|
||||||
# service-specific interface
|
# service-specific interface
|
||||||
_server_id = Column(Integer, ForeignKey('servers.id', ondelete='SET NULL'))
|
_server_id = Column(Integer, ForeignKey('servers.id', ondelete='SET NULL'))
|
||||||
server = relationship(Server, cascade='all')
|
server = relationship(
|
||||||
|
Server,
|
||||||
|
backref=backref('service', uselist=False),
|
||||||
|
single_parent=True,
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
pid = Column(Integer)
|
pid = Column(Integer)
|
||||||
|
|
||||||
def new_api_token(self, token=None, **kwargs):
|
def new_api_token(self, token=None, **kwargs):
|
||||||
@@ -300,7 +311,46 @@ class Service(Base):
|
|||||||
return db.query(cls).filter(cls.name == name).first()
|
return db.query(cls).filter(cls.name == name).first()
|
||||||
|
|
||||||
|
|
||||||
class Hashed(object):
|
class Expiring:
|
||||||
|
"""Mixin for expiring entries
|
||||||
|
|
||||||
|
Subclass must define at least expires_at property,
|
||||||
|
which should be unix timestamp or datetime object
|
||||||
|
"""
|
||||||
|
|
||||||
|
now = utcnow # funciton, must return float timestamp or datetime
|
||||||
|
expires_at = None # must be defined
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expires_in(self):
|
||||||
|
"""Property returning expiration in seconds from now
|
||||||
|
|
||||||
|
or None
|
||||||
|
"""
|
||||||
|
if self.expires_at:
|
||||||
|
delta = self.expires_at - self.now()
|
||||||
|
if isinstance(delta, timedelta):
|
||||||
|
delta = delta.total_seconds()
|
||||||
|
return delta
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def purge_expired(cls, db):
|
||||||
|
"""Purge expired API Tokens from the database"""
|
||||||
|
now = cls.now()
|
||||||
|
deleted = False
|
||||||
|
for obj in (
|
||||||
|
db.query(cls).filter(cls.expires_at != None).filter(cls.expires_at < now)
|
||||||
|
):
|
||||||
|
app_log.debug("Purging expired %s", obj)
|
||||||
|
deleted = True
|
||||||
|
db.delete(obj)
|
||||||
|
if deleted:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class Hashed(Expiring):
|
||||||
"""Mixin for tables with hashed tokens"""
|
"""Mixin for tables with hashed tokens"""
|
||||||
|
|
||||||
prefix_length = 4
|
prefix_length = 4
|
||||||
@@ -357,11 +407,21 @@ class Hashed(object):
|
|||||||
"""Start the query for matching token.
|
"""Start the query for matching token.
|
||||||
|
|
||||||
Returns an SQLAlchemy query already filtered by prefix-matches.
|
Returns an SQLAlchemy query already filtered by prefix-matches.
|
||||||
|
|
||||||
|
.. versionchanged:: 1.2
|
||||||
|
|
||||||
|
Excludes expired matches.
|
||||||
"""
|
"""
|
||||||
prefix = token[: cls.prefix_length]
|
prefix = token[: cls.prefix_length]
|
||||||
# since we can't filter on hashed values, filter on prefix
|
# since we can't filter on hashed values, filter on prefix
|
||||||
# so we aren't comparing with all tokens
|
# so we aren't comparing with all tokens
|
||||||
return db.query(cls).filter(bindparam('prefix', prefix).startswith(cls.prefix))
|
prefix_match = db.query(cls).filter(
|
||||||
|
bindparam('prefix', prefix).startswith(cls.prefix)
|
||||||
|
)
|
||||||
|
prefix_match = prefix_match.filter(
|
||||||
|
or_(cls.expires_at == None, cls.expires_at >= cls.now())
|
||||||
|
)
|
||||||
|
return prefix_match
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find(cls, db, token):
|
def find(cls, db, token):
|
||||||
@@ -397,6 +457,7 @@ class APIToken(Hashed, Base):
|
|||||||
return 'a%i' % self.id
|
return 'a%i' % self.id
|
||||||
|
|
||||||
# token metadata for bookkeeping
|
# token metadata for bookkeeping
|
||||||
|
now = datetime.utcnow # for expiry
|
||||||
created = Column(DateTime, default=datetime.utcnow)
|
created = Column(DateTime, default=datetime.utcnow)
|
||||||
expires_at = Column(DateTime, default=None, nullable=True)
|
expires_at = Column(DateTime, default=None, nullable=True)
|
||||||
last_activity = Column(DateTime)
|
last_activity = Column(DateTime)
|
||||||
@@ -417,20 +478,6 @@ class APIToken(Hashed, Base):
|
|||||||
cls=self.__class__.__name__, pre=self.prefix, kind=kind, name=name
|
cls=self.__class__.__name__, pre=self.prefix, kind=kind, 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.
|
||||||
@@ -441,9 +488,6 @@ 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':
|
||||||
@@ -486,7 +530,7 @@ class APIToken(Hashed, Base):
|
|||||||
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:
|
if expires_in is not None:
|
||||||
orm_token.expires_at = utcnow() + timedelta(seconds=expires_in)
|
orm_token.expires_at = cls.now() + timedelta(seconds=expires_in)
|
||||||
db.add(orm_token)
|
db.add(orm_token)
|
||||||
db.commit()
|
db.commit()
|
||||||
return token
|
return token
|
||||||
@@ -510,6 +554,10 @@ class OAuthAccessToken(Hashed, Base):
|
|||||||
__tablename__ = 'oauth_access_tokens'
|
__tablename__ = 'oauth_access_tokens'
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def now():
|
||||||
|
return datetime.utcnow().timestamp()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def api_id(self):
|
def api_id(self):
|
||||||
return 'o%i' % self.id
|
return 'o%i' % self.id
|
||||||
@@ -536,11 +584,12 @@ class OAuthAccessToken(Hashed, Base):
|
|||||||
last_activity = Column(DateTime, nullable=True)
|
last_activity = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<{cls}('{prefix}...', client_id={client_id!r}, user={user!r}>".format(
|
return "<{cls}('{prefix}...', client_id={client_id!r}, user={user!r}, expires_in={expires_in}>".format(
|
||||||
cls=self.__class__.__name__,
|
cls=self.__class__.__name__,
|
||||||
client_id=self.client_id,
|
client_id=self.client_id,
|
||||||
user=self.user and self.user.name,
|
user=self.user and self.user.name,
|
||||||
prefix=self.prefix,
|
prefix=self.prefix,
|
||||||
|
expires_in=self.expires_in,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -557,8 +606,9 @@ class OAuthAccessToken(Hashed, Base):
|
|||||||
return orm_token
|
return orm_token
|
||||||
|
|
||||||
|
|
||||||
class OAuthCode(Base):
|
class OAuthCode(Expiring, Base):
|
||||||
__tablename__ = 'oauth_codes'
|
__tablename__ = 'oauth_codes'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
client_id = Column(
|
client_id = Column(
|
||||||
Unicode(255), ForeignKey('oauth_clients.identifier', ondelete='CASCADE')
|
Unicode(255), ForeignKey('oauth_clients.identifier', ondelete='CASCADE')
|
||||||
@@ -570,6 +620,19 @@ class OAuthCode(Base):
|
|||||||
# state = Column(Unicode(1023))
|
# state = Column(Unicode(1023))
|
||||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def now():
|
||||||
|
return datetime.utcnow().timestamp()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find(cls, db, code):
|
||||||
|
return (
|
||||||
|
db.query(cls)
|
||||||
|
.filter(cls.code == code)
|
||||||
|
.filter(or_(cls.expires_at == None, cls.expires_at >= cls.now()))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OAuthClient(Base):
|
class OAuthClient(Base):
|
||||||
__tablename__ = 'oauth_clients'
|
__tablename__ = 'oauth_clients'
|
||||||
@@ -623,7 +686,10 @@ def _expire_relationship(target, relationship_prop):
|
|||||||
return
|
return
|
||||||
# many-to-many and one-to-many have a list of peers
|
# many-to-many and one-to-many have a list of peers
|
||||||
# many-to-one has only one
|
# many-to-one has only one
|
||||||
if relationship_prop.direction is interfaces.MANYTOONE:
|
if (
|
||||||
|
relationship_prop.direction is interfaces.MANYTOONE
|
||||||
|
or not relationship_prop.uselist
|
||||||
|
):
|
||||||
peers = [peers]
|
peers = [peers]
|
||||||
for obj in peers:
|
for obj in peers:
|
||||||
if inspect(obj).persistent:
|
if inspect(obj).persistent:
|
||||||
|
212
jupyterhub/pagination.py
Normal file
212
jupyterhub/pagination.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"""Basic class to manage pagination utils."""
|
||||||
|
# Copyright (c) Jupyter Development Team.
|
||||||
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
from traitlets import default
|
||||||
|
from traitlets import Integer
|
||||||
|
from traitlets import observe
|
||||||
|
from traitlets import Unicode
|
||||||
|
from traitlets import validate
|
||||||
|
from traitlets.config import Configurable
|
||||||
|
|
||||||
|
|
||||||
|
class Pagination(Configurable):
|
||||||
|
|
||||||
|
# configurable options
|
||||||
|
default_per_page = Integer(
|
||||||
|
100,
|
||||||
|
config=True,
|
||||||
|
help="Default number of entries per page for paginated results.",
|
||||||
|
)
|
||||||
|
|
||||||
|
max_per_page = Integer(
|
||||||
|
250,
|
||||||
|
config=True,
|
||||||
|
help="Maximum number of entries per page for paginated results.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# state variables
|
||||||
|
url = Unicode("")
|
||||||
|
page = Integer(1)
|
||||||
|
per_page = Integer(1, min=1)
|
||||||
|
|
||||||
|
@default("per_page")
|
||||||
|
def _default_per_page(self):
|
||||||
|
return self.default_per_page
|
||||||
|
|
||||||
|
@validate("per_page")
|
||||||
|
def _limit_per_page(self, proposal):
|
||||||
|
if self.max_per_page and proposal.value > self.max_per_page:
|
||||||
|
return self.max_per_page
|
||||||
|
if proposal.value <= 1:
|
||||||
|
return 1
|
||||||
|
return proposal.value
|
||||||
|
|
||||||
|
@observe("max_per_page")
|
||||||
|
def _apply_max(self, change):
|
||||||
|
if change.new:
|
||||||
|
self.per_page = min(change.new, self.per_page)
|
||||||
|
|
||||||
|
total = Integer(0)
|
||||||
|
|
||||||
|
total_pages = Integer(0)
|
||||||
|
|
||||||
|
@default("total_pages")
|
||||||
|
def _calculate_total_pages(self):
|
||||||
|
total_pages = self.total // self.per_page
|
||||||
|
if self.total % self.per_page:
|
||||||
|
# there's a remainder, add 1
|
||||||
|
total_pages += 1
|
||||||
|
return total_pages
|
||||||
|
|
||||||
|
@observe("per_page", "total")
|
||||||
|
def _update_total_pages(self, change):
|
||||||
|
"""Update total_pages when per_page or total is changed"""
|
||||||
|
self.total_pages = self._calculate_total_pages()
|
||||||
|
|
||||||
|
separator = Unicode("...")
|
||||||
|
|
||||||
|
def get_page_args(self, handler):
|
||||||
|
"""
|
||||||
|
This method gets the arguments used in the webpage to configurate the pagination
|
||||||
|
In case of no arguments, it uses the default values from this class
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- page: The page requested for paginating or the default value (1)
|
||||||
|
- per_page: The number of items to return in this page. No more than max_per_page
|
||||||
|
- offset: The offset to consider when managing pagination via the ORM
|
||||||
|
"""
|
||||||
|
page = handler.get_argument("page", 1)
|
||||||
|
per_page = handler.get_argument("per_page", self.default_per_page)
|
||||||
|
try:
|
||||||
|
self.per_page = int(per_page)
|
||||||
|
except Exception:
|
||||||
|
self.per_page = self.default_per_page
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.page = int(page)
|
||||||
|
if self.page < 1:
|
||||||
|
self.page = 1
|
||||||
|
except Exception:
|
||||||
|
self.page = 1
|
||||||
|
|
||||||
|
return self.page, self.per_page, self.per_page * (self.page - 1)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def info(self):
|
||||||
|
"""Get the pagination information."""
|
||||||
|
start = 1 + (self.page - 1) * self.per_page
|
||||||
|
end = start + self.per_page - 1
|
||||||
|
if end > self.total:
|
||||||
|
end = self.total
|
||||||
|
|
||||||
|
if start > self.total:
|
||||||
|
start = self.total
|
||||||
|
|
||||||
|
return {'total': self.total, 'start': start, 'end': end}
|
||||||
|
|
||||||
|
def calculate_pages_window(self):
|
||||||
|
"""Calculates the set of pages to render later in links() method.
|
||||||
|
It returns the list of pages to render via links for the pagination
|
||||||
|
By default, as we've observed in other applications, we're going to render
|
||||||
|
only a finite and predefined number of pages, avoiding visual fatigue related
|
||||||
|
to a long list of pages. By default, we render 7 pages plus some inactive links with the characters '...'
|
||||||
|
to point out that there are other pages that aren't explicitly rendered.
|
||||||
|
The primary way of work is to provide current webpage and 5 next pages, the last 2 ones
|
||||||
|
(in case the current page + 5 does not overflow the total lenght of pages) and the first one for reference.
|
||||||
|
"""
|
||||||
|
|
||||||
|
before_page = 2
|
||||||
|
after_page = 2
|
||||||
|
window_size = before_page + after_page + 1
|
||||||
|
|
||||||
|
# Add 1 to total_pages since our starting page is 1 and not 0
|
||||||
|
last_page = self.total_pages
|
||||||
|
|
||||||
|
pages = []
|
||||||
|
|
||||||
|
# will default window + start, end fit without truncation?
|
||||||
|
if self.total_pages > window_size + 2:
|
||||||
|
if self.page - before_page > 1:
|
||||||
|
# before_page will not reach page 1
|
||||||
|
pages.append(1)
|
||||||
|
if self.page - before_page > 2:
|
||||||
|
# before_page will not reach page 2, need separator
|
||||||
|
pages.append(self.separator)
|
||||||
|
|
||||||
|
pages.extend(range(max(1, self.page - before_page), self.page))
|
||||||
|
# we now have up to but not including self.page
|
||||||
|
|
||||||
|
if self.page + after_page + 1 >= last_page:
|
||||||
|
# after_page gets us to the end
|
||||||
|
pages.extend(range(self.page, last_page + 1))
|
||||||
|
else:
|
||||||
|
# add full after_page entries
|
||||||
|
pages.extend(range(self.page, self.page + after_page + 1))
|
||||||
|
# add separator *if* this doesn't get to last page - 1
|
||||||
|
if self.page + after_page < last_page - 1:
|
||||||
|
pages.append(self.separator)
|
||||||
|
pages.append(last_page)
|
||||||
|
|
||||||
|
return pages
|
||||||
|
|
||||||
|
else:
|
||||||
|
# everything will fit, nothing to think about
|
||||||
|
# always return at least one page
|
||||||
|
return list(range(1, last_page + 1)) or [1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def links(self):
|
||||||
|
"""Get the links for the pagination.
|
||||||
|
Getting the input from calculate_pages_window(), generates the HTML code
|
||||||
|
for the pages to render, plus the arrows to go onwards and backwards (if needed).
|
||||||
|
"""
|
||||||
|
if self.total_pages == 1:
|
||||||
|
return []
|
||||||
|
|
||||||
|
pages_to_render = self.calculate_pages_window()
|
||||||
|
|
||||||
|
links = ['<nav>']
|
||||||
|
links.append('<ul class="pagination">')
|
||||||
|
|
||||||
|
if self.page > 1:
|
||||||
|
prev_page = self.page - 1
|
||||||
|
links.append(
|
||||||
|
'<li><a href="?page={prev_page}">«</a></li>'.format(prev_page=prev_page)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
links.append(
|
||||||
|
'<li class="disabled"><span><span aria-hidden="true">«</span></span></li>'
|
||||||
|
)
|
||||||
|
|
||||||
|
for page in list(pages_to_render):
|
||||||
|
if page == self.page:
|
||||||
|
links.append(
|
||||||
|
'<li class="active"><span>{page}<span class="sr-only">(current)</span></span></li>'.format(
|
||||||
|
page=page
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif page == self.separator:
|
||||||
|
links.append(
|
||||||
|
'<li class="disabled"><span> <span aria-hidden="true">{separator}</span></span></li>'.format(
|
||||||
|
separator=self.separator
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
links.append(
|
||||||
|
'<li><a href="?page={page}">{page}</a></li>'.format(page=page)
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.page >= 1 and self.page < self.total_pages:
|
||||||
|
next_page = self.page + 1
|
||||||
|
links.append(
|
||||||
|
'<li><a href="?page={next_page}">»</a></li>'.format(next_page=next_page)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
links.append(
|
||||||
|
'<li class="disabled"><span><span aria-hidden="true">»</span></span></li>'
|
||||||
|
)
|
||||||
|
|
||||||
|
links.append('</ul>')
|
||||||
|
links.append('</nav>')
|
||||||
|
|
||||||
|
return ''.join(links)
|
@@ -24,9 +24,7 @@ import time
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from tornado import gen
|
|
||||||
from tornado.httpclient import AsyncHTTPClient
|
from tornado.httpclient import AsyncHTTPClient
|
||||||
from tornado.httpclient import HTTPError
|
from tornado.httpclient import HTTPError
|
||||||
from tornado.httpclient import HTTPRequest
|
from tornado.httpclient import HTTPRequest
|
||||||
@@ -44,6 +42,7 @@ from . import utils
|
|||||||
from .metrics import CHECK_ROUTES_DURATION_SECONDS
|
from .metrics import CHECK_ROUTES_DURATION_SECONDS
|
||||||
from .metrics import PROXY_POLL_DURATION_SECONDS
|
from .metrics import PROXY_POLL_DURATION_SECONDS
|
||||||
from .objects import Server
|
from .objects import Server
|
||||||
|
from .utils import exponential_backoff
|
||||||
from .utils import make_ssl_context
|
from .utils import make_ssl_context
|
||||||
from .utils import url_path_join
|
from .utils import url_path_join
|
||||||
from jupyterhub.traitlets import Command
|
from jupyterhub.traitlets import Command
|
||||||
@@ -292,7 +291,7 @@ class Proxy(LoggingConfigurable):
|
|||||||
if service.server:
|
if service.server:
|
||||||
futures.append(self.add_service(service))
|
futures.append(self.add_service(service))
|
||||||
# wait after submitting them all
|
# wait after submitting them all
|
||||||
await gen.multi(futures)
|
await asyncio.gather(*futures)
|
||||||
|
|
||||||
async def add_all_users(self, user_dict):
|
async def add_all_users(self, user_dict):
|
||||||
"""Update the proxy table from the database.
|
"""Update the proxy table from the database.
|
||||||
@@ -305,7 +304,7 @@ class Proxy(LoggingConfigurable):
|
|||||||
if spawner.ready:
|
if spawner.ready:
|
||||||
futures.append(self.add_user(user, name))
|
futures.append(self.add_user(user, name))
|
||||||
# wait after submitting them all
|
# wait after submitting them all
|
||||||
await gen.multi(futures)
|
await asyncio.gather(*futures)
|
||||||
|
|
||||||
@_one_at_a_time
|
@_one_at_a_time
|
||||||
async def check_routes(self, user_dict, service_dict, routes=None):
|
async def check_routes(self, user_dict, service_dict, routes=None):
|
||||||
@@ -391,7 +390,7 @@ class Proxy(LoggingConfigurable):
|
|||||||
self.log.warning("Deleting stale route %s", routespec)
|
self.log.warning("Deleting stale route %s", routespec)
|
||||||
futures.append(self.delete_route(routespec))
|
futures.append(self.delete_route(routespec))
|
||||||
|
|
||||||
await gen.multi(futures)
|
await asyncio.gather(*futures)
|
||||||
stop = time.perf_counter() # timer stops here when user is deleted
|
stop = time.perf_counter() # timer stops here when user is deleted
|
||||||
CHECK_ROUTES_DURATION_SECONDS.observe(stop - start) # histogram metric
|
CHECK_ROUTES_DURATION_SECONDS.observe(stop - start) # histogram metric
|
||||||
|
|
||||||
@@ -497,6 +496,19 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
|
|
||||||
if not psutil.pid_exists(pid):
|
if not psutil.pid_exists(pid):
|
||||||
raise ProcessLookupError
|
raise ProcessLookupError
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = psutil.Process(pid)
|
||||||
|
if self.command and self.command[0]:
|
||||||
|
process_cmd = process.cmdline()
|
||||||
|
if process_cmd and not any(
|
||||||
|
self.command[0] in clause for clause in process_cmd
|
||||||
|
):
|
||||||
|
raise ProcessLookupError
|
||||||
|
except (psutil.AccessDenied, psutil.NoSuchProcess):
|
||||||
|
# If there is a process at the proxy's PID but we don't have permissions to see it,
|
||||||
|
# then it is unlikely to actually be the proxy.
|
||||||
|
raise ProcessLookupError
|
||||||
else:
|
else:
|
||||||
os.kill(pid, 0)
|
os.kill(pid, 0)
|
||||||
|
|
||||||
@@ -574,6 +586,34 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
self.log.debug("PID file %s already removed", self.pid_file)
|
self.log.debug("PID file %s already removed", self.pid_file)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _get_ssl_options(self):
|
||||||
|
"""List of cmd proxy options to use internal SSL"""
|
||||||
|
cmd = []
|
||||||
|
proxy_api = 'proxy-api'
|
||||||
|
proxy_client = 'proxy-client'
|
||||||
|
api_key = self.app.internal_proxy_certs[proxy_api][
|
||||||
|
'keyfile'
|
||||||
|
] # Check content in next test and just patch manulaly or in the config of the file
|
||||||
|
api_cert = self.app.internal_proxy_certs[proxy_api]['certfile']
|
||||||
|
api_ca = self.app.internal_trust_bundles[proxy_api + '-ca']
|
||||||
|
|
||||||
|
client_key = self.app.internal_proxy_certs[proxy_client]['keyfile']
|
||||||
|
client_cert = self.app.internal_proxy_certs[proxy_client]['certfile']
|
||||||
|
client_ca = self.app.internal_trust_bundles[proxy_client + '-ca']
|
||||||
|
|
||||||
|
cmd.extend(['--api-ssl-key', api_key])
|
||||||
|
cmd.extend(['--api-ssl-cert', api_cert])
|
||||||
|
cmd.extend(['--api-ssl-ca', api_ca])
|
||||||
|
cmd.extend(['--api-ssl-request-cert'])
|
||||||
|
cmd.extend(['--api-ssl-reject-unauthorized'])
|
||||||
|
|
||||||
|
cmd.extend(['--client-ssl-key', client_key])
|
||||||
|
cmd.extend(['--client-ssl-cert', client_cert])
|
||||||
|
cmd.extend(['--client-ssl-ca', client_ca])
|
||||||
|
cmd.extend(['--client-ssl-request-cert'])
|
||||||
|
cmd.extend(['--client-ssl-reject-unauthorized'])
|
||||||
|
return cmd
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
"""Start the proxy process"""
|
"""Start the proxy process"""
|
||||||
# check if there is a previous instance still around
|
# check if there is a previous instance still around
|
||||||
@@ -605,27 +645,7 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
if self.ssl_cert:
|
if self.ssl_cert:
|
||||||
cmd.extend(['--ssl-cert', self.ssl_cert])
|
cmd.extend(['--ssl-cert', self.ssl_cert])
|
||||||
if self.app.internal_ssl:
|
if self.app.internal_ssl:
|
||||||
proxy_api = 'proxy-api'
|
cmd.extend(self._get_ssl_options())
|
||||||
proxy_client = 'proxy-client'
|
|
||||||
api_key = self.app.internal_proxy_certs[proxy_api]['keyfile']
|
|
||||||
api_cert = self.app.internal_proxy_certs[proxy_api]['certfile']
|
|
||||||
api_ca = self.app.internal_trust_bundles[proxy_api + '-ca']
|
|
||||||
|
|
||||||
client_key = self.app.internal_proxy_certs[proxy_client]['keyfile']
|
|
||||||
client_cert = self.app.internal_proxy_certs[proxy_client]['certfile']
|
|
||||||
client_ca = self.app.internal_trust_bundles[proxy_client + '-ca']
|
|
||||||
|
|
||||||
cmd.extend(['--api-ssl-key', api_key])
|
|
||||||
cmd.extend(['--api-ssl-cert', api_cert])
|
|
||||||
cmd.extend(['--api-ssl-ca', api_ca])
|
|
||||||
cmd.extend(['--api-ssl-request-cert'])
|
|
||||||
cmd.extend(['--api-ssl-reject-unauthorized'])
|
|
||||||
|
|
||||||
cmd.extend(['--client-ssl-key', client_key])
|
|
||||||
cmd.extend(['--client-ssl-cert', client_cert])
|
|
||||||
cmd.extend(['--client-ssl-ca', client_ca])
|
|
||||||
cmd.extend(['--client-ssl-request-cert'])
|
|
||||||
cmd.extend(['--client-ssl-reject-unauthorized'])
|
|
||||||
if self.app.statsd_host:
|
if self.app.statsd_host:
|
||||||
cmd.extend(
|
cmd.extend(
|
||||||
[
|
[
|
||||||
@@ -692,8 +712,17 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
parent = psutil.Process(pid)
|
parent = psutil.Process(pid)
|
||||||
children = parent.children(recursive=True)
|
children = parent.children(recursive=True)
|
||||||
for child in children:
|
for child in children:
|
||||||
child.kill()
|
child.terminate()
|
||||||
psutil.wait_procs(children, timeout=5)
|
gone, alive = psutil.wait_procs(children, timeout=5)
|
||||||
|
for p in alive:
|
||||||
|
p.kill()
|
||||||
|
# Clear the shell, too, if it still exists.
|
||||||
|
try:
|
||||||
|
parent.terminate()
|
||||||
|
parent.wait(timeout=5)
|
||||||
|
parent.kill()
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
pass
|
||||||
|
|
||||||
def _terminate(self):
|
def _terminate(self):
|
||||||
"""Terminate our process"""
|
"""Terminate our process"""
|
||||||
@@ -769,10 +798,36 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
method=method,
|
method=method,
|
||||||
headers={'Authorization': 'token {}'.format(self.auth_token)},
|
headers={'Authorization': 'token {}'.format(self.auth_token)},
|
||||||
body=body,
|
body=body,
|
||||||
|
connect_timeout=3, # default: 20s
|
||||||
|
request_timeout=10, # default: 20s
|
||||||
)
|
)
|
||||||
async with self.semaphore:
|
|
||||||
result = await client.fetch(req)
|
async def _wait_for_api_request():
|
||||||
return result
|
try:
|
||||||
|
async with self.semaphore:
|
||||||
|
return await client.fetch(req)
|
||||||
|
except HTTPError as e:
|
||||||
|
# Retry on potentially transient errors in CHP, typically
|
||||||
|
# numbered 500 and up. Note that CHP isn't able to emit 429
|
||||||
|
# errors.
|
||||||
|
if e.code >= 500:
|
||||||
|
self.log.warning(
|
||||||
|
"api_request to the proxy failed with status code {}, retrying...".format(
|
||||||
|
e.code
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return False # a falsy return value make exponential_backoff retry
|
||||||
|
else:
|
||||||
|
self.log.error("api_request to proxy failed: {0}".format(e))
|
||||||
|
# An unhandled error here will help the hub invoke cleanup logic
|
||||||
|
raise
|
||||||
|
|
||||||
|
result = await exponential_backoff(
|
||||||
|
_wait_for_api_request,
|
||||||
|
'Repeated api_request to proxy path "{}" failed.'.format(path),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
async def add_route(self, routespec, target, data):
|
async def add_route(self, routespec, target, data):
|
||||||
body = data or {}
|
body = data or {}
|
||||||
|
@@ -23,7 +23,6 @@ from urllib.parse import quote
|
|||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from tornado.gen import coroutine
|
|
||||||
from tornado.httputil import url_concat
|
from tornado.httputil import url_concat
|
||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
from tornado.web import HTTPError
|
from tornado.web import HTTPError
|
||||||
@@ -288,7 +287,7 @@ class HubAuth(SingletonConfigurable):
|
|||||||
|
|
||||||
def _check_hub_authorization(self, url, cache_key=None, use_cache=True):
|
def _check_hub_authorization(self, url, cache_key=None, use_cache=True):
|
||||||
"""Identify a user with the Hub
|
"""Identify a user with the Hub
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url (str): The API URL to check the Hub for authorization
|
url (str): The API URL to check the Hub for authorization
|
||||||
(e.g. http://127.0.0.1:8081/hub/api/authorizations/token/abc-def)
|
(e.g. http://127.0.0.1:8081/hub/api/authorizations/token/abc-def)
|
||||||
@@ -371,9 +370,13 @@ class HubAuth(SingletonConfigurable):
|
|||||||
)
|
)
|
||||||
app_log.warning(r.text)
|
app_log.warning(r.text)
|
||||||
msg = "Failed to check authorization"
|
msg = "Failed to check authorization"
|
||||||
# pass on error_description from oauth failure
|
# pass on error from oauth failure
|
||||||
try:
|
try:
|
||||||
description = r.json().get("error_description")
|
response = r.json()
|
||||||
|
# prefer more specific 'error_description', fallback to 'error'
|
||||||
|
description = response.get(
|
||||||
|
"error_description", response.get("error", "Unknown error")
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
@@ -600,10 +603,10 @@ class HubOAuth(HubAuth):
|
|||||||
|
|
||||||
def token_for_code(self, code):
|
def token_for_code(self, code):
|
||||||
"""Get token for OAuth temporary code
|
"""Get token for OAuth temporary code
|
||||||
|
|
||||||
This is the last step of OAuth login.
|
This is the last step of OAuth login.
|
||||||
Should be called in OAuth Callback handler.
|
Should be called in OAuth Callback handler.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
code (str): oauth code for finishing OAuth login
|
code (str): oauth code for finishing OAuth login
|
||||||
Returns:
|
Returns:
|
||||||
@@ -860,15 +863,15 @@ class HubAuthenticated(object):
|
|||||||
if kind == 'service':
|
if kind == 'service':
|
||||||
# it's a service, check hub_services
|
# it's a service, check hub_services
|
||||||
if self.hub_services and name in self.hub_services:
|
if self.hub_services and name in self.hub_services:
|
||||||
app_log.debug("Allowing whitelisted Hub service %s", name)
|
app_log.debug("Allowing Hub service %s", name)
|
||||||
return model
|
return model
|
||||||
else:
|
else:
|
||||||
app_log.warning("Not allowing Hub service %s", name)
|
app_log.warning("Not allowing Hub service %s", name)
|
||||||
raise UserNotAllowed(model)
|
raise UserNotAllowed(model)
|
||||||
|
|
||||||
if self.hub_users and name in self.hub_users:
|
if self.hub_users and name in self.hub_users:
|
||||||
# user in whitelist
|
# user in allowed list
|
||||||
app_log.debug("Allowing whitelisted Hub user %s", name)
|
app_log.debug("Allowing Hub user %s", name)
|
||||||
return model
|
return model
|
||||||
elif self.hub_groups and set(model['groups']).intersection(self.hub_groups):
|
elif self.hub_groups and set(model['groups']).intersection(self.hub_groups):
|
||||||
allowed_groups = set(model['groups']).intersection(self.hub_groups)
|
allowed_groups = set(model['groups']).intersection(self.hub_groups)
|
||||||
@@ -877,7 +880,7 @@ class HubAuthenticated(object):
|
|||||||
name,
|
name,
|
||||||
','.join(sorted(allowed_groups)),
|
','.join(sorted(allowed_groups)),
|
||||||
)
|
)
|
||||||
# group in whitelist
|
# group in allowed list
|
||||||
return model
|
return model
|
||||||
else:
|
else:
|
||||||
app_log.warning("Not allowing Hub user %s", name)
|
app_log.warning("Not allowing Hub user %s", name)
|
||||||
@@ -946,8 +949,7 @@ class HubOAuthCallbackHandler(HubOAuthenticated, RequestHandler):
|
|||||||
.. versionadded: 0.8
|
.. versionadded: 0.8
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@coroutine
|
async def get(self):
|
||||||
def get(self):
|
|
||||||
error = self.get_argument("error", False)
|
error = self.get_argument("error", False)
|
||||||
if error:
|
if error:
|
||||||
msg = self.get_argument("error_description", error)
|
msg = self.get_argument("error_description", error)
|
||||||
|
@@ -201,6 +201,10 @@ class Service(LoggingConfigurable):
|
|||||||
"""
|
"""
|
||||||
).tag(input=True)
|
).tag(input=True)
|
||||||
|
|
||||||
|
display = Bool(
|
||||||
|
True, help="""Whether to list the service on the JupyterHub UI"""
|
||||||
|
).tag(input=True)
|
||||||
|
|
||||||
oauth_no_confirm = Bool(
|
oauth_no_confirm = Bool(
|
||||||
False,
|
False,
|
||||||
help="""Skip OAuth confirmation when users access this service.
|
help="""Skip OAuth confirmation when users access this service.
|
||||||
@@ -342,7 +346,7 @@ class Service(LoggingConfigurable):
|
|||||||
env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
|
env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
|
||||||
|
|
||||||
hub = self.hub
|
hub = self.hub
|
||||||
if self.hub.ip in ('0.0.0.0', ''):
|
if self.hub.ip in ('', '0.0.0.0', '::'):
|
||||||
# if the Hub is listening on all interfaces,
|
# if the Hub is listening on all interfaces,
|
||||||
# tell services to connect via localhost
|
# tell services to connect via localhost
|
||||||
# since they are always local subprocesses
|
# since they are always local subprocesses
|
||||||
|
13
jupyterhub/singleuser/__init__.py
Normal file
13
jupyterhub/singleuser/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""JupyterHub single-user server entrypoints
|
||||||
|
|
||||||
|
Contains default notebook-app subclass and mixins
|
||||||
|
"""
|
||||||
|
from .app import main
|
||||||
|
from .app import SingleUserNotebookApp
|
||||||
|
from .mixins import HubAuthenticatedHandler
|
||||||
|
from .mixins import make_singleuser_app
|
||||||
|
|
||||||
|
# backward-compatibility
|
||||||
|
JupyterHubLoginHandler = SingleUserNotebookApp.login_handler_class
|
||||||
|
JupyterHubLogoutHandler = SingleUserNotebookApp.logout_handler_class
|
||||||
|
OAuthCallbackHandler = SingleUserNotebookApp.oauth_callback_handler_class
|
4
jupyterhub/singleuser/__main__.py
Normal file
4
jupyterhub/singleuser/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .app import main
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
20
jupyterhub/singleuser/app.py
Normal file
20
jupyterhub/singleuser/app.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""Make a single-user app based on the environment:
|
||||||
|
|
||||||
|
- $JUPYTERHUB_SINGLEUSER_APP, the base Application class, to be wrapped in JupyterHub authentication.
|
||||||
|
default: notebook.notebookapp.NotebookApp
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from traitlets import import_item
|
||||||
|
|
||||||
|
from .mixins import make_singleuser_app
|
||||||
|
|
||||||
|
JUPYTERHUB_SINGLEUSER_APP = (
|
||||||
|
os.environ.get("JUPYTERHUB_SINGLEUSER_APP") or "notebook.notebookapp.NotebookApp"
|
||||||
|
)
|
||||||
|
|
||||||
|
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
|
||||||
|
|
||||||
|
SingleUserNotebookApp = make_singleuser_app(App)
|
||||||
|
|
||||||
|
main = SingleUserNotebookApp.launch_instance
|
@@ -1,8 +1,15 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
"""Extend regular notebook server to be aware of multiuser things."""
|
"""Mixins to regular notebook server to add JupyterHub auth.
|
||||||
|
|
||||||
|
Meant to be compatible with jupyter_server and classic notebook
|
||||||
|
|
||||||
|
Use make_singleuser_app to create a compatible Application class
|
||||||
|
with JupyterHub authentication mixins enabled.
|
||||||
|
"""
|
||||||
# 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.
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import importlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
@@ -13,44 +20,34 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from jinja2 import ChoiceLoader
|
from jinja2 import ChoiceLoader
|
||||||
from jinja2 import FunctionLoader
|
from jinja2 import FunctionLoader
|
||||||
from tornado import gen
|
|
||||||
from tornado import ioloop
|
from tornado import ioloop
|
||||||
from tornado.httpclient import AsyncHTTPClient
|
from tornado.httpclient import AsyncHTTPClient
|
||||||
from tornado.httpclient import HTTPRequest
|
from tornado.httpclient import HTTPRequest
|
||||||
from tornado.web import HTTPError
|
from tornado.web import HTTPError
|
||||||
from tornado.web import RequestHandler
|
from tornado.web import RequestHandler
|
||||||
|
from traitlets import Any
|
||||||
|
from traitlets import Bool
|
||||||
|
from traitlets import Bytes
|
||||||
|
from traitlets import CUnicode
|
||||||
|
from traitlets import default
|
||||||
|
from traitlets import import_item
|
||||||
|
from traitlets import Integer
|
||||||
|
from traitlets import observe
|
||||||
|
from traitlets import TraitError
|
||||||
|
from traitlets import Unicode
|
||||||
|
from traitlets import validate
|
||||||
|
from traitlets.config import Configurable
|
||||||
|
|
||||||
try:
|
from .._version import __version__
|
||||||
import notebook
|
from .._version import _check_version
|
||||||
except ImportError:
|
from ..log import log_request
|
||||||
raise ImportError("JupyterHub single-user server requires notebook >= 4.0")
|
from ..services.auth import HubOAuth
|
||||||
|
from ..services.auth import HubOAuthCallbackHandler
|
||||||
from traitlets import (
|
from ..services.auth import HubOAuthenticated
|
||||||
Any,
|
from ..utils import exponential_backoff
|
||||||
Bool,
|
from ..utils import isoformat
|
||||||
Bytes,
|
from ..utils import make_ssl_context
|
||||||
Integer,
|
from ..utils import url_path_join
|
||||||
Unicode,
|
|
||||||
CUnicode,
|
|
||||||
default,
|
|
||||||
observe,
|
|
||||||
validate,
|
|
||||||
TraitError,
|
|
||||||
)
|
|
||||||
|
|
||||||
from notebook.notebookapp import (
|
|
||||||
NotebookApp,
|
|
||||||
aliases as notebook_aliases,
|
|
||||||
flags as notebook_flags,
|
|
||||||
)
|
|
||||||
from notebook.auth.login import LoginHandler
|
|
||||||
from notebook.auth.logout import LogoutHandler
|
|
||||||
from notebook.base.handlers import IPythonHandler
|
|
||||||
|
|
||||||
from ._version import __version__, _check_version
|
|
||||||
from .log import log_request
|
|
||||||
from .services.auth import HubOAuth, HubOAuthenticated, HubOAuthCallbackHandler
|
|
||||||
from .utils import isoformat, url_path_join, make_ssl_context, exponential_backoff
|
|
||||||
|
|
||||||
|
|
||||||
# Authenticate requests with the Hub
|
# Authenticate requests with the Hub
|
||||||
@@ -80,7 +77,7 @@ class HubAuthenticatedHandler(HubOAuthenticated):
|
|||||||
return set()
|
return set()
|
||||||
|
|
||||||
|
|
||||||
class JupyterHubLoginHandler(LoginHandler):
|
class JupyterHubLoginHandlerMixin:
|
||||||
"""LoginHandler that hooks up Hub authentication"""
|
"""LoginHandler that hooks up Hub authentication"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -113,7 +110,7 @@ class JupyterHubLoginHandler(LoginHandler):
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
class JupyterHubLogoutHandler(LogoutHandler):
|
class JupyterHubLogoutHandlerMixin:
|
||||||
def get(self):
|
def get(self):
|
||||||
self.settings['hub_auth'].clear_cookie(self)
|
self.settings['hub_auth'].clear_cookie(self)
|
||||||
self.redirect(
|
self.redirect(
|
||||||
@@ -122,7 +119,7 @@ class JupyterHubLogoutHandler(LogoutHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class OAuthCallbackHandler(HubOAuthCallbackHandler, IPythonHandler):
|
class OAuthCallbackHandlerMixin(HubOAuthCallbackHandler):
|
||||||
"""Mixin IPythonHandler to get the right error pages, etc."""
|
"""Mixin IPythonHandler to get the right error pages, etc."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -131,27 +128,22 @@ class OAuthCallbackHandler(HubOAuthCallbackHandler, IPythonHandler):
|
|||||||
|
|
||||||
|
|
||||||
# register new hub related command-line aliases
|
# register new hub related command-line aliases
|
||||||
aliases = dict(notebook_aliases)
|
aliases = {
|
||||||
aliases.update(
|
'user': 'SingleUserNotebookApp.user',
|
||||||
{
|
'group': 'SingleUserNotebookApp.group',
|
||||||
'user': 'SingleUserNotebookApp.user',
|
'cookie-name': 'HubAuth.cookie_name',
|
||||||
'group': 'SingleUserNotebookApp.group',
|
'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
|
||||||
'cookie-name': 'HubAuth.cookie_name',
|
'hub-host': 'SingleUserNotebookApp.hub_host',
|
||||||
'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
|
'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
|
||||||
'hub-host': 'SingleUserNotebookApp.hub_host',
|
'base-url': 'SingleUserNotebookApp.base_url',
|
||||||
'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
|
}
|
||||||
'base-url': 'SingleUserNotebookApp.base_url',
|
flags = {
|
||||||
}
|
'disable-user-config': (
|
||||||
)
|
{'SingleUserNotebookApp': {'disable_user_config': True}},
|
||||||
flags = dict(notebook_flags)
|
"Disable user-controlled configuration of the notebook server.",
|
||||||
flags.update(
|
)
|
||||||
{
|
}
|
||||||
'disable-user-config': (
|
|
||||||
{'SingleUserNotebookApp': {'disable_user_config': True}},
|
|
||||||
"Disable user-controlled configuration of the notebook server.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
page_template = """
|
page_template = """
|
||||||
{% extends "templates/page.html" %}
|
{% extends "templates/page.html" %}
|
||||||
@@ -216,21 +208,29 @@ def _exclude_home(path_list):
|
|||||||
yield p
|
yield p
|
||||||
|
|
||||||
|
|
||||||
class SingleUserNotebookApp(NotebookApp):
|
class SingleUserNotebookAppMixin(Configurable):
|
||||||
"""A Subclass of the regular NotebookApp that is aware of the parent multiuser context."""
|
"""A Subclass of the regular NotebookApp that is aware of the parent multiuser context."""
|
||||||
|
|
||||||
description = dedent(
|
description = dedent(
|
||||||
"""
|
"""
|
||||||
Single-user server for JupyterHub. Extends the Jupyter Notebook server.
|
Single-user server for JupyterHub. Extends the Jupyter Notebook server.
|
||||||
|
|
||||||
Meant to be invoked by JupyterHub Spawners, and not directly.
|
Meant to be invoked by JupyterHub Spawners, not directly.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
examples = ""
|
examples = ""
|
||||||
subcommands = {}
|
subcommands = {}
|
||||||
version = __version__
|
version = __version__
|
||||||
classes = NotebookApp.classes + [HubOAuth]
|
|
||||||
|
# must be set in mixin subclass
|
||||||
|
# make_singleuser_app sets these
|
||||||
|
# aliases = aliases
|
||||||
|
# flags = flags
|
||||||
|
# login_handler_class = JupyterHubLoginHandler
|
||||||
|
# logout_handler_class = JupyterHubLogoutHandler
|
||||||
|
# oauth_callback_handler_class = OAuthCallbackHandler
|
||||||
|
# classes = NotebookApp.classes + [HubOAuth]
|
||||||
|
|
||||||
# disable single-user app's localhost checking
|
# disable single-user app's localhost checking
|
||||||
allow_remote_access = True
|
allow_remote_access = True
|
||||||
@@ -323,16 +323,12 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
return url.hostname
|
return url.hostname
|
||||||
return '127.0.0.1'
|
return '127.0.0.1'
|
||||||
|
|
||||||
aliases = aliases
|
# disable some single-user configurables
|
||||||
flags = flags
|
|
||||||
|
|
||||||
# disble some single-user configurables
|
|
||||||
token = ''
|
token = ''
|
||||||
open_browser = False
|
open_browser = False
|
||||||
quit_button = False
|
quit_button = False
|
||||||
trust_xheaders = True
|
trust_xheaders = True
|
||||||
login_handler_class = JupyterHubLoginHandler
|
|
||||||
logout_handler_class = JupyterHubLogoutHandler
|
|
||||||
port_retries = (
|
port_retries = (
|
||||||
0 # disable port-retries, since the Spawner will tell us what port to use
|
0 # disable port-retries, since the Spawner will tell us what port to use
|
||||||
)
|
)
|
||||||
@@ -381,11 +377,11 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
# disable config-migration when user config is disabled
|
# disable config-migration when user config is disabled
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
super(SingleUserNotebookApp, self).migrate_config()
|
super().migrate_config()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def config_file_paths(self):
|
def config_file_paths(self):
|
||||||
path = super(SingleUserNotebookApp, self).config_file_paths
|
path = super().config_file_paths
|
||||||
|
|
||||||
if self.disable_user_config:
|
if self.disable_user_config:
|
||||||
# filter out user-writable config dirs if user config is disabled
|
# filter out user-writable config dirs if user config is disabled
|
||||||
@@ -394,7 +390,7 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def nbextensions_path(self):
|
def nbextensions_path(self):
|
||||||
path = super(SingleUserNotebookApp, self).nbextensions_path
|
path = super().nbextensions_path
|
||||||
|
|
||||||
if self.disable_user_config:
|
if self.disable_user_config:
|
||||||
path = list(_exclude_home(path))
|
path = list(_exclude_home(path))
|
||||||
@@ -437,7 +433,7 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
i,
|
i,
|
||||||
RETRIES,
|
RETRIES,
|
||||||
)
|
)
|
||||||
await gen.sleep(min(2 ** i, 16))
|
await asyncio.sleep(min(2 ** i, 16))
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
@@ -490,7 +486,7 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
# protect against mixed timezone comparisons
|
# protect against mixed timezone comparisons
|
||||||
if not last_activity.tzinfo:
|
if not last_activity.tzinfo:
|
||||||
# assume naive timestamps are utc
|
# assume naive timestamps are utc
|
||||||
self.log.warning("last activity is using naïve timestamps")
|
self.log.warning("last activity is using naive timestamps")
|
||||||
last_activity = last_activity.replace(tzinfo=timezone.utc)
|
last_activity = last_activity.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
if self._last_activity_sent and last_activity < self._last_activity_sent:
|
if self._last_activity_sent and last_activity < self._last_activity_sent:
|
||||||
@@ -562,7 +558,7 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
# start by hitting Hub to check version
|
# start by hitting Hub to check version
|
||||||
ioloop.IOLoop.current().run_sync(self.check_hub_version)
|
ioloop.IOLoop.current().run_sync(self.check_hub_version)
|
||||||
ioloop.IOLoop.current().add_callback(self.keep_activity_updated)
|
ioloop.IOLoop.current().add_callback(self.keep_activity_updated)
|
||||||
super(SingleUserNotebookApp, self).start()
|
super().start()
|
||||||
|
|
||||||
def init_hub_auth(self):
|
def init_hub_auth(self):
|
||||||
api_token = None
|
api_token = None
|
||||||
@@ -610,12 +606,17 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
'Content-Security-Policy',
|
'Content-Security-Policy',
|
||||||
';'.join(["frame-ancestors 'self'", "report-uri " + csp_report_uri]),
|
';'.join(["frame-ancestors 'self'", "report-uri " + csp_report_uri]),
|
||||||
)
|
)
|
||||||
super(SingleUserNotebookApp, self).init_webapp()
|
super().init_webapp()
|
||||||
|
|
||||||
# add OAuth callback
|
# add OAuth callback
|
||||||
self.web_app.add_handlers(
|
self.web_app.add_handlers(
|
||||||
r".*$",
|
r".*$",
|
||||||
[(urlparse(self.hub_auth.oauth_redirect_uri).path, OAuthCallbackHandler)],
|
[
|
||||||
|
(
|
||||||
|
urlparse(self.hub_auth.oauth_redirect_uri).path,
|
||||||
|
self.oauth_callback_handler_class,
|
||||||
|
)
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# apply X-JupyterHub-Version to *all* request handlers (even redirects)
|
# apply X-JupyterHub-Version to *all* request handlers (even redirects)
|
||||||
@@ -656,9 +657,82 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
env.loader = ChoiceLoader([FunctionLoader(get_page), orig_loader])
|
env.loader = ChoiceLoader([FunctionLoader(get_page), orig_loader])
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def detect_base_package(App):
|
||||||
return SingleUserNotebookApp.launch_instance(argv)
|
"""Detect the base package for an App class
|
||||||
|
|
||||||
|
Will return 'notebook' or 'jupyter_server'
|
||||||
|
based on which package App subclasses from.
|
||||||
|
|
||||||
|
Will return None if neither is identified (e.g. fork package, or duck-typing).
|
||||||
|
"""
|
||||||
|
# guess notebook or jupyter_server based on App class inheritance
|
||||||
|
for cls in App.mro():
|
||||||
|
pkg = cls.__module__.split(".", 1)[0]
|
||||||
|
if pkg in {"notebook", "jupyter_server"}:
|
||||||
|
return pkg
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def make_singleuser_app(App):
|
||||||
main()
|
"""Make and return a singleuser notebook app
|
||||||
|
|
||||||
|
given existing notebook or jupyter_server Application classes,
|
||||||
|
mix-in jupyterhub auth.
|
||||||
|
|
||||||
|
Instances of App must have the following attributes defining classes:
|
||||||
|
|
||||||
|
- .login_handler_class
|
||||||
|
- .logout_handler_class
|
||||||
|
- .base_handler_class (only required if not a subclass of the default app
|
||||||
|
in jupyter_server or notebook)
|
||||||
|
|
||||||
|
App should be a subclass of `notebook.notebookapp.NotebookApp`
|
||||||
|
or `jupyter_server.serverapp.ServerApp`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
empty_parent_app = App()
|
||||||
|
|
||||||
|
# detect base classes
|
||||||
|
LoginHandler = empty_parent_app.login_handler_class
|
||||||
|
LogoutHandler = empty_parent_app.logout_handler_class
|
||||||
|
BaseHandler = getattr(empty_parent_app, "base_handler_class", None)
|
||||||
|
if BaseHandler is None:
|
||||||
|
pkg = detect_base_package(App)
|
||||||
|
if pkg == "jupyter_server":
|
||||||
|
BaseHandler = import_item("jupyter_server.base.handlers.JupyterHandler")
|
||||||
|
elif pkg == "notebook":
|
||||||
|
BaseHandler = import_item("notebook.base.handlers.IPythonHandler")
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
"{}.base_handler_class must be defined".format(App.__name__)
|
||||||
|
)
|
||||||
|
|
||||||
|
# create Handler classes from mixins + bases
|
||||||
|
class JupyterHubLoginHandler(JupyterHubLoginHandlerMixin, LoginHandler):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class JupyterHubLogoutHandler(JupyterHubLogoutHandlerMixin, LogoutHandler):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class OAuthCallbackHandler(OAuthCallbackHandlerMixin, BaseHandler):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# create merged aliases & flags
|
||||||
|
merged_aliases = {}
|
||||||
|
merged_aliases.update(empty_parent_app.aliases or {})
|
||||||
|
merged_aliases.update(aliases)
|
||||||
|
|
||||||
|
merged_flags = {}
|
||||||
|
merged_flags.update(empty_parent_app.flags or {})
|
||||||
|
merged_flags.update(flags)
|
||||||
|
# create mixed-in App class, bringing it all together
|
||||||
|
class SingleUserNotebookApp(SingleUserNotebookAppMixin, App):
|
||||||
|
aliases = merged_aliases
|
||||||
|
flags = merged_flags
|
||||||
|
classes = empty_parent_app.classes + [HubOAuth]
|
||||||
|
|
||||||
|
login_handler_class = JupyterHubLoginHandler
|
||||||
|
logout_handler_class = JupyterHubLogoutHandler
|
||||||
|
oauth_callback_handler_class = OAuthCallbackHandler
|
||||||
|
|
||||||
|
return SingleUserNotebookApp
|
@@ -4,8 +4,6 @@ Contains base Spawner class & default implementation
|
|||||||
# 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.
|
||||||
import ast
|
import ast
|
||||||
import asyncio
|
|
||||||
import errno
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pipes
|
import pipes
|
||||||
@@ -18,8 +16,7 @@ from tempfile import mkdtemp
|
|||||||
|
|
||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
import psutil
|
import psutil
|
||||||
from async_generator import async_generator
|
from async_generator import aclosing
|
||||||
from async_generator import yield_
|
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
from tornado.ioloop import PeriodicCallback
|
from tornado.ioloop import PeriodicCallback
|
||||||
from traitlets import Any
|
from traitlets import Any
|
||||||
@@ -356,8 +353,9 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
return options_form
|
return options_form
|
||||||
|
|
||||||
def options_from_form(self, form_data):
|
options_from_form = Callable(
|
||||||
"""Interpret HTTP form data
|
help="""
|
||||||
|
Interpret HTTP form data
|
||||||
|
|
||||||
Form data will always arrive as a dict of lists of strings.
|
Form data will always arrive as a dict of lists of strings.
|
||||||
Override this function to understand single-values, numbers, etc.
|
Override this function to understand single-values, numbers, etc.
|
||||||
@@ -381,9 +379,47 @@ class Spawner(LoggingConfigurable):
|
|||||||
(with additional support for bytes in case of uploaded file data),
|
(with additional support for bytes in case of uploaded file data),
|
||||||
and any non-bytes non-jsonable values will be replaced with None
|
and any non-bytes non-jsonable values will be replaced with None
|
||||||
if the user_options are re-used.
|
if the user_options are re-used.
|
||||||
"""
|
""",
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
@default("options_from_form")
|
||||||
|
def _options_from_form(self):
|
||||||
|
return self._default_options_from_form
|
||||||
|
|
||||||
|
def _default_options_from_form(self, form_data):
|
||||||
return form_data
|
return form_data
|
||||||
|
|
||||||
|
def options_from_query(self, query_data):
|
||||||
|
"""Interpret query arguments passed to /spawn
|
||||||
|
|
||||||
|
Query arguments will always arrive as a dict of unicode strings.
|
||||||
|
Override this function to understand single-values, numbers, etc.
|
||||||
|
|
||||||
|
By default, options_from_form is called from this function. You can however override
|
||||||
|
this function if you need to process the query arguments differently.
|
||||||
|
|
||||||
|
This should coerce form data into the structure expected by self.user_options,
|
||||||
|
which must be a dict, and should be JSON-serializeable,
|
||||||
|
though it can contain bytes in addition to standard JSON data types.
|
||||||
|
|
||||||
|
This method should not have any side effects.
|
||||||
|
Any handling of `user_options` should be done in `.start()`
|
||||||
|
to ensure consistent behavior across servers
|
||||||
|
spawned via the API and form submission page.
|
||||||
|
|
||||||
|
Instances will receive this data on self.user_options, after passing through this function,
|
||||||
|
prior to `Spawner.start`.
|
||||||
|
|
||||||
|
.. versionadded:: 1.2
|
||||||
|
user_options are persisted in the JupyterHub database to be reused
|
||||||
|
on subsequent spawns if no options are given.
|
||||||
|
user_options is serialized to JSON as part of this persistence
|
||||||
|
(with additional support for bytes in case of uploaded file data),
|
||||||
|
and any non-bytes non-jsonable values will be replaced with None
|
||||||
|
if the user_options are re-used.
|
||||||
|
"""
|
||||||
|
return self.options_from_form(query_data)
|
||||||
|
|
||||||
user_options = Dict(
|
user_options = Dict(
|
||||||
help="""
|
help="""
|
||||||
Dict of user specified options for the user's spawned instance of a single-user server.
|
Dict of user specified options for the user's spawned instance of a single-user server.
|
||||||
@@ -402,11 +438,12 @@ class Spawner(LoggingConfigurable):
|
|||||||
'VIRTUAL_ENV',
|
'VIRTUAL_ENV',
|
||||||
'LANG',
|
'LANG',
|
||||||
'LC_ALL',
|
'LC_ALL',
|
||||||
|
'JUPYTERHUB_SINGLEUSER_APP',
|
||||||
],
|
],
|
||||||
help="""
|
help="""
|
||||||
Whitelist of environment variables for the single-user server to inherit from the JupyterHub process.
|
List of environment variables for the single-user server to inherit from the JupyterHub process.
|
||||||
|
|
||||||
This whitelist is used to ensure that sensitive information in the JupyterHub process's environment
|
This list is used to ensure that sensitive information in the JupyterHub process's environment
|
||||||
(such as `CONFIGPROXY_AUTH_TOKEN`) is not passed to the single-user server's process.
|
(such as `CONFIGPROXY_AUTH_TOKEN`) is not passed to the single-user server's process.
|
||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
@@ -425,7 +462,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
Environment variables that end up in the single-user server's process come from 3 sources:
|
Environment variables that end up in the single-user server's process come from 3 sources:
|
||||||
- This `environment` configurable
|
- This `environment` configurable
|
||||||
- The JupyterHub process' environment variables that are whitelisted in `env_keep`
|
- The JupyterHub process' environment variables that are listed in `env_keep`
|
||||||
- Variables to establish contact between the single-user notebook and the hub (such as JUPYTERHUB_API_TOKEN)
|
- Variables to establish contact between the single-user notebook and the hub (such as JUPYTERHUB_API_TOKEN)
|
||||||
|
|
||||||
The `environment` configurable should be set by JupyterHub administrators to add
|
The `environment` configurable should be set by JupyterHub administrators to add
|
||||||
@@ -436,6 +473,11 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
Note that the spawner class' interface is not guaranteed to be exactly same across upgrades,
|
Note that the spawner class' interface is not guaranteed to be exactly same across upgrades,
|
||||||
so if you are using the callable take care to verify it continues to work after upgrades!
|
so if you are using the callable take care to verify it continues to work after upgrades!
|
||||||
|
|
||||||
|
.. versionchanged:: 1.2
|
||||||
|
environment from this configuration has highest priority,
|
||||||
|
allowing override of 'default' env variables,
|
||||||
|
such as JUPYTERHUB_API_URL.
|
||||||
"""
|
"""
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
@@ -709,16 +751,6 @@ class Spawner(LoggingConfigurable):
|
|||||||
if key in os.environ:
|
if key in os.environ:
|
||||||
env[key] = os.environ[key]
|
env[key] = os.environ[key]
|
||||||
|
|
||||||
# config overrides. If the value is a callable, it will be called with
|
|
||||||
# one parameter - the current spawner instance - and the return value
|
|
||||||
# will be assigned to the environment variable. This will be called at
|
|
||||||
# spawn time.
|
|
||||||
for key, value in self.environment.items():
|
|
||||||
if callable(value):
|
|
||||||
env[key] = value(self)
|
|
||||||
else:
|
|
||||||
env[key] = value
|
|
||||||
|
|
||||||
env['JUPYTERHUB_API_TOKEN'] = self.api_token
|
env['JUPYTERHUB_API_TOKEN'] = self.api_token
|
||||||
# deprecated (as of 0.7.2), for old versions of singleuser
|
# deprecated (as of 0.7.2), for old versions of singleuser
|
||||||
env['JPY_API_TOKEN'] = self.api_token
|
env['JPY_API_TOKEN'] = self.api_token
|
||||||
@@ -766,6 +798,18 @@ class Spawner(LoggingConfigurable):
|
|||||||
env['JUPYTERHUB_SSL_CERTFILE'] = self.cert_paths['certfile']
|
env['JUPYTERHUB_SSL_CERTFILE'] = self.cert_paths['certfile']
|
||||||
env['JUPYTERHUB_SSL_CLIENT_CA'] = self.cert_paths['cafile']
|
env['JUPYTERHUB_SSL_CLIENT_CA'] = self.cert_paths['cafile']
|
||||||
|
|
||||||
|
# env overrides from config. If the value is a callable, it will be called with
|
||||||
|
# one parameter - the current spawner instance - and the return value
|
||||||
|
# will be assigned to the environment variable. This will be called at
|
||||||
|
# spawn time.
|
||||||
|
# Called last to ensure highest priority, in case of overriding other
|
||||||
|
# 'default' variables like the API url
|
||||||
|
for key, value in self.environment.items():
|
||||||
|
if callable(value):
|
||||||
|
env[key] = value(self)
|
||||||
|
else:
|
||||||
|
env[key] = value
|
||||||
|
|
||||||
return env
|
return env
|
||||||
|
|
||||||
async def get_url(self):
|
async def get_url(self):
|
||||||
@@ -906,14 +950,13 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
paths (dict): a list of paths for key, cert, and CA.
|
paths (dict): a list of paths for key, cert, and CA.
|
||||||
These paths will be resolvable and readable by the Hub process,
|
These paths will be resolvable and readable by the Hub process,
|
||||||
but not necessarily by the notebook server.
|
but not necessarily by the notebook server.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: a list (potentially altered) of paths for key, cert,
|
dict: a list (potentially altered) of paths for key, cert, and CA.
|
||||||
and CA.
|
These paths should be resolvable and readable by the notebook
|
||||||
These paths should be resolvable and readable
|
server to be launched.
|
||||||
by the notebook server to be launched.
|
|
||||||
|
|
||||||
|
|
||||||
`.move_certs` is called after certs for the singleuser notebook have
|
`.move_certs` is called after certs for the singleuser notebook have
|
||||||
@@ -952,7 +995,9 @@ class Spawner(LoggingConfigurable):
|
|||||||
args.append('--notebook-dir=%s' % _quote_safe(notebook_dir))
|
args.append('--notebook-dir=%s' % _quote_safe(notebook_dir))
|
||||||
if self.default_url:
|
if self.default_url:
|
||||||
default_url = self.format_string(self.default_url)
|
default_url = self.format_string(self.default_url)
|
||||||
args.append('--NotebookApp.default_url=%s' % _quote_safe(default_url))
|
args.append(
|
||||||
|
'--SingleUserNotebookApp.default_url=%s' % _quote_safe(default_url)
|
||||||
|
)
|
||||||
|
|
||||||
if self.debug:
|
if self.debug:
|
||||||
args.append('--debug')
|
args.append('--debug')
|
||||||
@@ -986,7 +1031,6 @@ class Spawner(LoggingConfigurable):
|
|||||||
def _progress_url(self):
|
def _progress_url(self):
|
||||||
return self.user.progress_url(self.name)
|
return self.user.progress_url(self.name)
|
||||||
|
|
||||||
@async_generator
|
|
||||||
async def _generate_progress(self):
|
async def _generate_progress(self):
|
||||||
"""Private wrapper of progress generator
|
"""Private wrapper of progress generator
|
||||||
|
|
||||||
@@ -998,21 +1042,17 @@ class Spawner(LoggingConfigurable):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await yield_({"progress": 0, "message": "Server requested"})
|
yield {"progress": 0, "message": "Server requested"}
|
||||||
from async_generator import aclosing
|
|
||||||
|
|
||||||
async with aclosing(self.progress()) as progress:
|
async with aclosing(self.progress()) as progress:
|
||||||
async for event in progress:
|
async for event in progress:
|
||||||
await yield_(event)
|
yield event
|
||||||
|
|
||||||
@async_generator
|
|
||||||
async def progress(self):
|
async def progress(self):
|
||||||
"""Async generator for progress events
|
"""Async generator for progress events
|
||||||
|
|
||||||
Must be an async generator
|
Must be an async generator
|
||||||
|
|
||||||
For Python 3.5-compatibility, use the async_generator package
|
|
||||||
|
|
||||||
Should yield messages of the form:
|
Should yield messages of the form:
|
||||||
|
|
||||||
::
|
::
|
||||||
@@ -1029,7 +1069,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
.. versionadded:: 0.9
|
.. versionadded:: 0.9
|
||||||
"""
|
"""
|
||||||
await yield_({"progress": 50, "message": "Spawning server..."})
|
yield {"progress": 50, "message": "Spawning server..."}
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
"""Start the single-user server
|
"""Start the single-user server
|
||||||
@@ -1040,9 +1080,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
.. versionchanged:: 0.7
|
.. versionchanged:: 0.7
|
||||||
Return ip, port instead of setting on self.user.server directly.
|
Return ip, port instead of setting on self.user.server directly.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError(
|
raise NotImplementedError("Override in subclass. Must be a coroutine.")
|
||||||
"Override in subclass. Must be a Tornado gen.coroutine."
|
|
||||||
)
|
|
||||||
|
|
||||||
async def stop(self, now=False):
|
async def stop(self, now=False):
|
||||||
"""Stop the single-user server
|
"""Stop the single-user server
|
||||||
@@ -1055,9 +1093,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
Must be a coroutine.
|
Must be a coroutine.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError(
|
raise NotImplementedError("Override in subclass. Must be a coroutine.")
|
||||||
"Override in subclass. Must be a Tornado gen.coroutine."
|
|
||||||
)
|
|
||||||
|
|
||||||
async def poll(self):
|
async def poll(self):
|
||||||
"""Check if the single-user process is running
|
"""Check if the single-user process is running
|
||||||
@@ -1083,9 +1119,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
process has not yet completed.
|
process has not yet completed.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError(
|
raise NotImplementedError("Override in subclass. Must be a coroutine.")
|
||||||
"Override in subclass. Must be a Tornado gen.coroutine."
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_poll_callback(self, callback, *args, **kwargs):
|
def add_poll_callback(self, callback, *args, **kwargs):
|
||||||
"""Add a callback to fire when the single-user server stops"""
|
"""Add a callback to fire when the single-user server stops"""
|
||||||
@@ -1580,5 +1614,5 @@ class SimpleLocalProcessSpawner(LocalProcessSpawner):
|
|||||||
return env
|
return env
|
||||||
|
|
||||||
def move_certs(self, paths):
|
def move_certs(self, paths):
|
||||||
"""No-op for installing certs"""
|
"""No-op for installing certs."""
|
||||||
return paths
|
return paths
|
||||||
|
@@ -36,7 +36,6 @@ from unittest import mock
|
|||||||
|
|
||||||
from pytest import fixture
|
from pytest import fixture
|
||||||
from pytest import raises
|
from pytest import raises
|
||||||
from tornado import gen
|
|
||||||
from tornado import ioloop
|
from tornado import ioloop
|
||||||
from tornado.httpclient import HTTPError
|
from tornado.httpclient import HTTPError
|
||||||
from tornado.platform.asyncio import AsyncIOMainLoop
|
from tornado.platform.asyncio import AsyncIOMainLoop
|
||||||
@@ -56,13 +55,16 @@ _db = None
|
|||||||
|
|
||||||
|
|
||||||
def pytest_collection_modifyitems(items):
|
def pytest_collection_modifyitems(items):
|
||||||
"""add asyncio marker to all async tests"""
|
"""This function is automatically run by pytest passing all collected test
|
||||||
|
functions.
|
||||||
|
|
||||||
|
We use it to add asyncio marker to all async tests and assert we don't use
|
||||||
|
test functions that are async generators which wouldn't make sense.
|
||||||
|
"""
|
||||||
for item in items:
|
for item in items:
|
||||||
if inspect.iscoroutinefunction(item.obj):
|
if inspect.iscoroutinefunction(item.obj):
|
||||||
item.add_marker('asyncio')
|
item.add_marker('asyncio')
|
||||||
if hasattr(inspect, 'isasyncgenfunction'):
|
assert not inspect.isasyncgenfunction(item.obj)
|
||||||
# double-check that we aren't mixing yield and async def
|
|
||||||
assert not inspect.isasyncgenfunction(item.obj)
|
|
||||||
|
|
||||||
|
|
||||||
@fixture(scope='module')
|
@fixture(scope='module')
|
||||||
@@ -74,7 +76,9 @@ def ssl_tmpdir(tmpdir_factory):
|
|||||||
def app(request, io_loop, ssl_tmpdir):
|
def app(request, io_loop, ssl_tmpdir):
|
||||||
"""Mock a jupyterhub app for testing"""
|
"""Mock a jupyterhub app for testing"""
|
||||||
mocked_app = None
|
mocked_app = None
|
||||||
ssl_enabled = getattr(request.module, "ssl_enabled", False)
|
ssl_enabled = getattr(
|
||||||
|
request.module, 'ssl_enabled', os.environ.get('SSL_ENABLED', False)
|
||||||
|
)
|
||||||
kwargs = dict()
|
kwargs = dict()
|
||||||
if ssl_enabled:
|
if ssl_enabled:
|
||||||
kwargs.update(dict(internal_ssl=True, internal_certs_location=str(ssl_tmpdir)))
|
kwargs.update(dict(internal_ssl=True, internal_certs_location=str(ssl_tmpdir)))
|
||||||
@@ -214,7 +218,7 @@ def admin_user(app, username):
|
|||||||
class MockServiceSpawner(jupyterhub.services.service._ServiceSpawner):
|
class MockServiceSpawner(jupyterhub.services.service._ServiceSpawner):
|
||||||
"""mock services for testing.
|
"""mock services for testing.
|
||||||
|
|
||||||
Shorter intervals, etc.
|
Shorter intervals, etc.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
poll_interval = 1
|
poll_interval = 1
|
||||||
@@ -244,17 +248,14 @@ def _mockservice(request, app, url=False):
|
|||||||
assert name in app._service_map
|
assert name in app._service_map
|
||||||
service = app._service_map[name]
|
service = app._service_map[name]
|
||||||
|
|
||||||
@gen.coroutine
|
async def start():
|
||||||
def start():
|
|
||||||
# wait for proxy to be updated before starting the service
|
# wait for proxy to be updated before starting the service
|
||||||
yield app.proxy.add_all_services(app._service_map)
|
await app.proxy.add_all_services(app._service_map)
|
||||||
service.start()
|
service.start()
|
||||||
|
|
||||||
io_loop.run_sync(start)
|
io_loop.run_sync(start)
|
||||||
|
|
||||||
def cleanup():
|
def cleanup():
|
||||||
import asyncio
|
|
||||||
|
|
||||||
asyncio.get_event_loop().run_until_complete(service.stop())
|
asyncio.get_event_loop().run_until_complete(service.stop())
|
||||||
app.services[:] = []
|
app.services[:] = []
|
||||||
app._service_map.clear()
|
app._service_map.clear()
|
||||||
|
@@ -36,13 +36,12 @@ from unittest import mock
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from pamela import PAMError
|
from pamela import PAMError
|
||||||
from tornado import gen
|
|
||||||
from tornado.concurrent import Future
|
|
||||||
from tornado.ioloop import IOLoop
|
from tornado.ioloop import IOLoop
|
||||||
from traitlets import Bool
|
from traitlets import Bool
|
||||||
from traitlets import default
|
from traitlets import default
|
||||||
from traitlets import Dict
|
from traitlets import Dict
|
||||||
|
|
||||||
|
from .. import metrics
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..app import JupyterHub
|
from ..app import JupyterHub
|
||||||
from ..auth import PAMAuthenticator
|
from ..auth import PAMAuthenticator
|
||||||
@@ -110,19 +109,17 @@ class SlowSpawner(MockSpawner):
|
|||||||
delay = 2
|
delay = 2
|
||||||
_start_future = None
|
_start_future = None
|
||||||
|
|
||||||
@gen.coroutine
|
async def start(self):
|
||||||
def start(self):
|
(ip, port) = await super().start()
|
||||||
(ip, port) = yield super().start()
|
|
||||||
if self._start_future is not None:
|
if self._start_future is not None:
|
||||||
yield self._start_future
|
await self._start_future
|
||||||
else:
|
else:
|
||||||
yield gen.sleep(self.delay)
|
await asyncio.sleep(self.delay)
|
||||||
return ip, port
|
return ip, port
|
||||||
|
|
||||||
@gen.coroutine
|
async def stop(self):
|
||||||
def stop(self):
|
await asyncio.sleep(self.delay)
|
||||||
yield gen.sleep(self.delay)
|
await super().stop()
|
||||||
yield super().stop()
|
|
||||||
|
|
||||||
|
|
||||||
class NeverSpawner(MockSpawner):
|
class NeverSpawner(MockSpawner):
|
||||||
@@ -134,14 +131,12 @@ class NeverSpawner(MockSpawner):
|
|||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Return a Future that will never finish"""
|
"""Return a Future that will never finish"""
|
||||||
return Future()
|
return asyncio.Future()
|
||||||
|
|
||||||
@gen.coroutine
|
async def stop(self):
|
||||||
def stop(self):
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@gen.coroutine
|
async def poll(self):
|
||||||
def poll(self):
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -173,6 +168,9 @@ class FormSpawner(MockSpawner):
|
|||||||
options['energy'] = form_data['energy'][0]
|
options['energy'] = form_data['energy'][0]
|
||||||
if 'hello_file' in form_data:
|
if 'hello_file' in form_data:
|
||||||
options['hello'] = form_data['hello_file'][0]
|
options['hello'] = form_data['hello_file'][0]
|
||||||
|
|
||||||
|
if 'illegal_argument' in form_data:
|
||||||
|
raise ValueError("You are not allowed to specify 'illegal_argument'")
|
||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
@@ -212,8 +210,7 @@ class MockPAMAuthenticator(PAMAuthenticator):
|
|||||||
# skip the add-system-user bit
|
# skip the add-system-user bit
|
||||||
return not user.name.startswith('dne')
|
return not user.name.startswith('dne')
|
||||||
|
|
||||||
@gen.coroutine
|
async def authenticate(self, *args, **kwargs):
|
||||||
def authenticate(self, *args, **kwargs):
|
|
||||||
with mock.patch.multiple(
|
with mock.patch.multiple(
|
||||||
'pamela',
|
'pamela',
|
||||||
authenticate=mock_authenticate,
|
authenticate=mock_authenticate,
|
||||||
@@ -221,7 +218,7 @@ class MockPAMAuthenticator(PAMAuthenticator):
|
|||||||
close_session=mock_open_session,
|
close_session=mock_open_session,
|
||||||
check_account=mock_check_account,
|
check_account=mock_check_account,
|
||||||
):
|
):
|
||||||
username = yield super(MockPAMAuthenticator, self).authenticate(
|
username = await super(MockPAMAuthenticator, self).authenticate(
|
||||||
*args, **kwargs
|
*args, **kwargs
|
||||||
)
|
)
|
||||||
if username is None:
|
if username is None:
|
||||||
@@ -317,14 +314,13 @@ class MockHub(JupyterHub):
|
|||||||
self.db.delete(group)
|
self.db.delete(group)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
@gen.coroutine
|
async def initialize(self, argv=None):
|
||||||
def initialize(self, argv=None):
|
|
||||||
self.pid_file = NamedTemporaryFile(delete=False).name
|
self.pid_file = NamedTemporaryFile(delete=False).name
|
||||||
self.db_file = NamedTemporaryFile()
|
self.db_file = NamedTemporaryFile()
|
||||||
self.db_url = os.getenv('JUPYTERHUB_TEST_DB_URL') or self.db_file.name
|
self.db_url = os.getenv('JUPYTERHUB_TEST_DB_URL') or self.db_file.name
|
||||||
if 'mysql' in self.db_url:
|
if 'mysql' in self.db_url:
|
||||||
self.db_kwargs['connect_args'] = {'auth_plugin': 'mysql_native_password'}
|
self.db_kwargs['connect_args'] = {'auth_plugin': 'mysql_native_password'}
|
||||||
yield super().initialize([])
|
await super().initialize([])
|
||||||
|
|
||||||
# add an initial user
|
# add an initial user
|
||||||
user = self.db.query(orm.User).filter(orm.User.name == 'user').first()
|
user = self.db.query(orm.User).filter(orm.User.name == 'user').first()
|
||||||
@@ -332,6 +328,7 @@ class MockHub(JupyterHub):
|
|||||||
user = orm.User(name='user')
|
user = orm.User(name='user')
|
||||||
self.db.add(user)
|
self.db.add(user)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
metrics.TOTAL_USERS.inc()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
super().stop()
|
super().stop()
|
||||||
@@ -355,14 +352,13 @@ class MockHub(JupyterHub):
|
|||||||
self.cleanup = lambda: None
|
self.cleanup = lambda: None
|
||||||
self.db_file.close()
|
self.db_file.close()
|
||||||
|
|
||||||
@gen.coroutine
|
async def login_user(self, name):
|
||||||
def login_user(self, name):
|
|
||||||
"""Login a user by name, returning her cookies."""
|
"""Login a user by name, returning her cookies."""
|
||||||
base_url = public_url(self)
|
base_url = public_url(self)
|
||||||
external_ca = None
|
external_ca = None
|
||||||
if self.internal_ssl:
|
if self.internal_ssl:
|
||||||
external_ca = self.external_certs['files']['ca']
|
external_ca = self.external_certs['files']['ca']
|
||||||
r = yield async_requests.post(
|
r = await async_requests.post(
|
||||||
base_url + 'hub/login',
|
base_url + 'hub/login',
|
||||||
data={'username': name, 'password': name},
|
data={'username': name, 'password': name},
|
||||||
allow_redirects=False,
|
allow_redirects=False,
|
||||||
@@ -391,10 +387,20 @@ class MockSingleUserServer(SingleUserNotebookApp):
|
|||||||
class StubSingleUserSpawner(MockSpawner):
|
class StubSingleUserSpawner(MockSpawner):
|
||||||
"""Spawner that starts a MockSingleUserServer in a thread."""
|
"""Spawner that starts a MockSingleUserServer in a thread."""
|
||||||
|
|
||||||
|
@default("default_url")
|
||||||
|
def _default_url(self):
|
||||||
|
"""Use a default_url that any jupyter server will provide
|
||||||
|
|
||||||
|
Should be:
|
||||||
|
|
||||||
|
- authenticated, so we are testing auth
|
||||||
|
- always available (i.e. in base ServerApp and NotebookApp
|
||||||
|
"""
|
||||||
|
return "/api/status"
|
||||||
|
|
||||||
_thread = None
|
_thread = None
|
||||||
|
|
||||||
@gen.coroutine
|
async def start(self):
|
||||||
def start(self):
|
|
||||||
ip = self.ip = '127.0.0.1'
|
ip = self.ip = '127.0.0.1'
|
||||||
port = self.port = random_port()
|
port = self.port = random_port()
|
||||||
env = self.get_env()
|
env = self.get_env()
|
||||||
@@ -421,14 +427,12 @@ class StubSingleUserSpawner(MockSpawner):
|
|||||||
assert ready
|
assert ready
|
||||||
return (ip, port)
|
return (ip, port)
|
||||||
|
|
||||||
@gen.coroutine
|
async def stop(self):
|
||||||
def stop(self):
|
|
||||||
self._app.stop()
|
self._app.stop()
|
||||||
self._thread.join(timeout=30)
|
self._thread.join(timeout=30)
|
||||||
assert not self._thread.is_alive()
|
assert not self._thread.is_alive()
|
||||||
|
|
||||||
@gen.coroutine
|
async def poll(self):
|
||||||
def poll(self):
|
|
||||||
if self._thread is None:
|
if self._thread is None:
|
||||||
return 0
|
return 0
|
||||||
if self._thread.is_alive():
|
if self._thread.is_alive():
|
||||||
|
17
jupyterhub/tests/mockserverapp.py
Normal file
17
jupyterhub/tests/mockserverapp.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""Example JupyterServer app subclass"""
|
||||||
|
from jupyter_server.base.handlers import JupyterHandler
|
||||||
|
from jupyter_server.serverapp import ServerApp
|
||||||
|
from tornado import web
|
||||||
|
|
||||||
|
|
||||||
|
class TreeHandler(JupyterHandler):
|
||||||
|
@web.authenticated
|
||||||
|
def get(self):
|
||||||
|
self.write("OK!")
|
||||||
|
|
||||||
|
|
||||||
|
class MockServerApp(ServerApp):
|
||||||
|
def initialize(self, argv=None):
|
||||||
|
self.default_url = "/tree"
|
||||||
|
super().initialize(argv)
|
||||||
|
self.web_app.add_handlers(".*$", [(self.base_url + "tree/?", TreeHandler)])
|
@@ -60,7 +60,7 @@ class APIHandler(web.RequestHandler):
|
|||||||
|
|
||||||
class WhoAmIHandler(HubAuthenticated, web.RequestHandler):
|
class WhoAmIHandler(HubAuthenticated, web.RequestHandler):
|
||||||
"""Reply with the name of the user who made the request.
|
"""Reply with the name of the user who made the request.
|
||||||
|
|
||||||
Uses "deprecated" cookie login
|
Uses "deprecated" cookie login
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ class WhoAmIHandler(HubAuthenticated, web.RequestHandler):
|
|||||||
|
|
||||||
class OWhoAmIHandler(HubOAuthenticated, web.RequestHandler):
|
class OWhoAmIHandler(HubOAuthenticated, web.RequestHandler):
|
||||||
"""Reply with the name of the user who made the request.
|
"""Reply with the name of the user who made the request.
|
||||||
|
|
||||||
Uses OAuth login flow
|
Uses OAuth login flow
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@@ -4,20 +4,17 @@ import json
|
|||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
from concurrent.futures import Future
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from async_generator import async_generator
|
|
||||||
from async_generator import yield_
|
|
||||||
from pytest import mark
|
from pytest import mark
|
||||||
from tornado import gen
|
|
||||||
|
|
||||||
import jupyterhub
|
import jupyterhub
|
||||||
from .. import orm
|
from .. import orm
|
||||||
|
from ..objects import Server
|
||||||
from ..utils import url_path_join as ujoin
|
from ..utils import url_path_join as ujoin
|
||||||
from ..utils import utcnow
|
from ..utils import utcnow
|
||||||
from .mocking import public_host
|
from .mocking import public_host
|
||||||
@@ -28,7 +25,6 @@ from .utils import async_requests
|
|||||||
from .utils import auth_header
|
from .utils import auth_header
|
||||||
from .utils import find_user
|
from .utils import find_user
|
||||||
|
|
||||||
|
|
||||||
# --------------------
|
# --------------------
|
||||||
# Authentication tests
|
# Authentication tests
|
||||||
# --------------------
|
# --------------------
|
||||||
@@ -183,6 +179,71 @@ async def test_get_users(app):
|
|||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@mark.user
|
||||||
|
@mark.parametrize(
|
||||||
|
"state",
|
||||||
|
("inactive", "active", "ready", "invalid"),
|
||||||
|
)
|
||||||
|
async def test_get_users_state_filter(app, state):
|
||||||
|
db = app.db
|
||||||
|
|
||||||
|
# has_one_active: one active, one inactive, zero ready
|
||||||
|
has_one_active = add_user(db, app=app, name='has_one_active')
|
||||||
|
# has_two_active: two active, ready servers
|
||||||
|
has_two_active = add_user(db, app=app, name='has_two_active')
|
||||||
|
# has_two_inactive: two spawners, neither active
|
||||||
|
has_two_inactive = add_user(db, app=app, name='has_two_inactive')
|
||||||
|
# has_zero: no Spawners registered at all
|
||||||
|
has_zero = add_user(db, app=app, name='has_zero')
|
||||||
|
|
||||||
|
test_usernames = set(
|
||||||
|
("has_one_active", "has_two_active", "has_two_inactive", "has_zero")
|
||||||
|
)
|
||||||
|
|
||||||
|
user_states = {
|
||||||
|
"inactive": ["has_two_inactive", "has_zero"],
|
||||||
|
"ready": ["has_two_active"],
|
||||||
|
"active": ["has_one_active", "has_two_active"],
|
||||||
|
"invalid": [],
|
||||||
|
}
|
||||||
|
expected = user_states[state]
|
||||||
|
|
||||||
|
def add_spawner(user, name='', active=True, ready=True):
|
||||||
|
"""Add a spawner in a requested state
|
||||||
|
|
||||||
|
If active, should turn up in an active query
|
||||||
|
If active and ready, should turn up in a ready query
|
||||||
|
If not active, should turn up in an inactive query
|
||||||
|
"""
|
||||||
|
spawner = user.spawners[name]
|
||||||
|
db.commit()
|
||||||
|
if active:
|
||||||
|
orm_server = orm.Server()
|
||||||
|
db.add(orm_server)
|
||||||
|
db.commit()
|
||||||
|
spawner.server = Server(orm_server=orm_server)
|
||||||
|
db.commit()
|
||||||
|
if not ready:
|
||||||
|
spawner._spawn_pending = True
|
||||||
|
return spawner
|
||||||
|
|
||||||
|
for name in ("", "secondary"):
|
||||||
|
add_spawner(has_two_active, name, active=True)
|
||||||
|
add_spawner(has_two_inactive, name, active=False)
|
||||||
|
|
||||||
|
add_spawner(has_one_active, active=True, ready=False)
|
||||||
|
add_spawner(has_one_active, "inactive", active=False)
|
||||||
|
|
||||||
|
r = await api_request(app, 'users?state={}'.format(state))
|
||||||
|
if state == "invalid":
|
||||||
|
assert r.status_code == 400
|
||||||
|
return
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
usernames = sorted(u["name"] for u in r.json() if u["name"] in test_usernames)
|
||||||
|
assert usernames == expected
|
||||||
|
|
||||||
|
|
||||||
@mark.user
|
@mark.user
|
||||||
async def test_get_self(app):
|
async def test_get_self(app):
|
||||||
db = app.db
|
db = app.db
|
||||||
@@ -216,6 +277,17 @@ async def test_get_self(app):
|
|||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_self_service(app, mockservice):
|
||||||
|
r = await api_request(
|
||||||
|
app, "user", headers={"Authorization": f"token {mockservice.api_token}"}
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
service_info = r.json()
|
||||||
|
|
||||||
|
assert service_info['kind'] == 'service'
|
||||||
|
assert service_info['name'] == mockservice.name
|
||||||
|
|
||||||
|
|
||||||
@mark.user
|
@mark.user
|
||||||
async def test_add_user(app):
|
async def test_add_user(app):
|
||||||
db = app.db
|
db = app.db
|
||||||
@@ -614,7 +686,7 @@ async def test_slow_spawn(app, no_patience, slow_spawn):
|
|||||||
|
|
||||||
async def wait_spawn():
|
async def wait_spawn():
|
||||||
while not app_user.running:
|
while not app_user.running:
|
||||||
await gen.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
await wait_spawn()
|
await wait_spawn()
|
||||||
assert not app_user.spawner._spawn_pending
|
assert not app_user.spawner._spawn_pending
|
||||||
@@ -623,7 +695,7 @@ async def test_slow_spawn(app, no_patience, slow_spawn):
|
|||||||
|
|
||||||
async def wait_stop():
|
async def wait_stop():
|
||||||
while app_user.spawner._stop_pending:
|
while app_user.spawner._stop_pending:
|
||||||
await gen.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
r = await api_request(app, 'users', name, 'server', method='delete')
|
r = await api_request(app, 'users', name, 'server', method='delete')
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
@@ -657,7 +729,7 @@ async def test_never_spawn(app, no_patience, never_spawn):
|
|||||||
assert app.users.count_active_users()['pending'] == 1
|
assert app.users.count_active_users()['pending'] == 1
|
||||||
|
|
||||||
while app_user.spawner.pending:
|
while app_user.spawner.pending:
|
||||||
await gen.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
print(app_user.spawner.pending)
|
print(app_user.spawner.pending)
|
||||||
|
|
||||||
assert not app_user.spawner._spawn_pending
|
assert not app_user.spawner._spawn_pending
|
||||||
@@ -683,7 +755,7 @@ async def test_slow_bad_spawn(app, no_patience, slow_bad_spawn):
|
|||||||
r = await api_request(app, 'users', name, 'server', method='post')
|
r = await api_request(app, 'users', name, 'server', method='post')
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
while user.spawner.pending:
|
while user.spawner.pending:
|
||||||
await gen.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
# spawn failed
|
# spawn failed
|
||||||
assert not user.running
|
assert not user.running
|
||||||
assert app.users.count_active_users()['pending'] == 0
|
assert app.users.count_active_users()['pending'] == 0
|
||||||
@@ -819,32 +891,12 @@ async def test_progress_bad_slow(request, app, no_patience, slow_bad_spawn):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@async_generator
|
|
||||||
async def progress_forever():
|
async def progress_forever():
|
||||||
"""progress function that yields messages forever"""
|
"""progress function that yields messages forever"""
|
||||||
for i in range(1, 10):
|
for i in range(1, 10):
|
||||||
await yield_({'progress': i, 'message': 'Stage %s' % i})
|
yield {'progress': i, 'message': 'Stage %s' % i}
|
||||||
# wait a long time before the next event
|
# wait a long time before the next event
|
||||||
await gen.sleep(10)
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
|
|
||||||
if sys.version_info >= (3, 6):
|
|
||||||
# additional progress_forever defined as native
|
|
||||||
# async generator
|
|
||||||
# to test for issues with async_generator wrappers
|
|
||||||
exec(
|
|
||||||
"""
|
|
||||||
async def progress_forever_native():
|
|
||||||
for i in range(1, 10):
|
|
||||||
yield {
|
|
||||||
'progress': i,
|
|
||||||
'message': 'Stage %s' % i,
|
|
||||||
}
|
|
||||||
# wait a long time before the next event
|
|
||||||
await gen.sleep(10)
|
|
||||||
""",
|
|
||||||
globals(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_spawn_progress_cutoff(request, app, no_patience, slow_spawn):
|
async def test_spawn_progress_cutoff(request, app, no_patience, slow_spawn):
|
||||||
@@ -855,11 +907,7 @@ async def test_spawn_progress_cutoff(request, app, no_patience, slow_spawn):
|
|||||||
db = app.db
|
db = app.db
|
||||||
name = 'geddy'
|
name = 'geddy'
|
||||||
app_user = add_user(db, app=app, name=name)
|
app_user = add_user(db, app=app, name=name)
|
||||||
if sys.version_info >= (3, 6):
|
app_user.spawner.progress = progress_forever
|
||||||
# Python >= 3.6, try native async generator
|
|
||||||
app_user.spawner.progress = globals()['progress_forever_native']
|
|
||||||
else:
|
|
||||||
app_user.spawner.progress = progress_forever
|
|
||||||
app_user.spawner.delay = 1
|
app_user.spawner.delay = 1
|
||||||
|
|
||||||
r = await api_request(app, 'users', name, 'server', method='post')
|
r = await api_request(app, 'users', name, 'server', method='post')
|
||||||
@@ -886,8 +934,8 @@ async def test_spawn_limit(app, no_patience, slow_spawn, request):
|
|||||||
# start two pending spawns
|
# start two pending spawns
|
||||||
names = ['ykka', 'hjarka']
|
names = ['ykka', 'hjarka']
|
||||||
users = [add_user(db, app=app, name=name) for name in names]
|
users = [add_user(db, app=app, name=name) for name in names]
|
||||||
users[0].spawner._start_future = Future()
|
users[0].spawner._start_future = asyncio.Future()
|
||||||
users[1].spawner._start_future = Future()
|
users[1].spawner._start_future = asyncio.Future()
|
||||||
for name in names:
|
for name in names:
|
||||||
await api_request(app, 'users', name, 'server', method='post')
|
await api_request(app, 'users', name, 'server', method='post')
|
||||||
assert app.users.count_active_users()['pending'] == 2
|
assert app.users.count_active_users()['pending'] == 2
|
||||||
@@ -895,7 +943,7 @@ async def test_spawn_limit(app, no_patience, slow_spawn, request):
|
|||||||
# ykka and hjarka's spawns are both pending. Essun should fail with 429
|
# ykka and hjarka's spawns are both pending. Essun should fail with 429
|
||||||
name = 'essun'
|
name = 'essun'
|
||||||
user = add_user(db, app=app, name=name)
|
user = add_user(db, app=app, name=name)
|
||||||
user.spawner._start_future = Future()
|
user.spawner._start_future = asyncio.Future()
|
||||||
r = await api_request(app, 'users', name, 'server', method='post')
|
r = await api_request(app, 'users', name, 'server', method='post')
|
||||||
assert r.status_code == 429
|
assert r.status_code == 429
|
||||||
|
|
||||||
@@ -903,7 +951,7 @@ async def test_spawn_limit(app, no_patience, slow_spawn, request):
|
|||||||
users[0].spawner._start_future.set_result(None)
|
users[0].spawner._start_future.set_result(None)
|
||||||
# wait for ykka to finish
|
# wait for ykka to finish
|
||||||
while not users[0].running:
|
while not users[0].running:
|
||||||
await gen.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
assert app.users.count_active_users()['pending'] == 1
|
assert app.users.count_active_users()['pending'] == 1
|
||||||
r = await api_request(app, 'users', name, 'server', method='post')
|
r = await api_request(app, 'users', name, 'server', method='post')
|
||||||
@@ -914,7 +962,7 @@ async def test_spawn_limit(app, no_patience, slow_spawn, request):
|
|||||||
for user in users[1:]:
|
for user in users[1:]:
|
||||||
user.spawner._start_future.set_result(None)
|
user.spawner._start_future.set_result(None)
|
||||||
while not all(u.running for u in users):
|
while not all(u.running for u in users):
|
||||||
await gen.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
# everybody's running, pending count should be back to 0
|
# everybody's running, pending count should be back to 0
|
||||||
assert app.users.count_active_users()['pending'] == 0
|
assert app.users.count_active_users()['pending'] == 0
|
||||||
@@ -923,7 +971,7 @@ async def test_spawn_limit(app, no_patience, slow_spawn, request):
|
|||||||
r = await api_request(app, 'users', u.name, 'server', method='delete')
|
r = await api_request(app, 'users', u.name, 'server', method='delete')
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
while any(u.spawner.active for u in users):
|
while any(u.spawner.active for u in users):
|
||||||
await gen.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
@mark.slow
|
@mark.slow
|
||||||
@@ -1001,7 +1049,7 @@ async def test_start_stop_race(app, no_patience, slow_spawn):
|
|||||||
r = await api_request(app, 'users', user.name, 'server', method='delete')
|
r = await api_request(app, 'users', user.name, 'server', method='delete')
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
while not spawner.ready:
|
while not spawner.ready:
|
||||||
await gen.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
spawner.delay = 3
|
spawner.delay = 3
|
||||||
# stop the spawner
|
# stop the spawner
|
||||||
@@ -1009,7 +1057,7 @@ async def test_start_stop_race(app, no_patience, slow_spawn):
|
|||||||
assert r.status_code == 202
|
assert r.status_code == 202
|
||||||
assert spawner.pending == 'stop'
|
assert spawner.pending == 'stop'
|
||||||
# make sure we get past deleting from the proxy
|
# make sure we get past deleting from the proxy
|
||||||
await gen.sleep(1)
|
await asyncio.sleep(1)
|
||||||
# additional stops while stopping shouldn't trigger a new stop
|
# additional stops while stopping shouldn't trigger a new stop
|
||||||
with mock.patch.object(spawner, 'stop') as m:
|
with mock.patch.object(spawner, 'stop') as m:
|
||||||
r = await api_request(app, 'users', user.name, 'server', method='delete')
|
r = await api_request(app, 'users', user.name, 'server', method='delete')
|
||||||
@@ -1021,7 +1069,7 @@ async def test_start_stop_race(app, no_patience, slow_spawn):
|
|||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
while spawner.active:
|
while spawner.active:
|
||||||
await gen.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
# start after stop is okay
|
# start after stop is okay
|
||||||
r = await api_request(app, 'users', user.name, 'server', method='post')
|
r = await api_request(app, 'users', user.name, 'server', method='post')
|
||||||
assert r.status_code == 202
|
assert r.status_code == 202
|
||||||
@@ -1514,6 +1562,7 @@ async def test_get_services(app, mockservice_url):
|
|||||||
'prefix': mockservice.server.base_url,
|
'prefix': mockservice.server.base_url,
|
||||||
'url': mockservice.url,
|
'url': mockservice.url,
|
||||||
'info': {},
|
'info': {},
|
||||||
|
'display': True,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1538,6 +1587,7 @@ async def test_get_service(app, mockservice_url):
|
|||||||
'prefix': mockservice.server.base_url,
|
'prefix': mockservice.server.base_url,
|
||||||
'url': mockservice.url,
|
'url': mockservice.url,
|
||||||
'info': {},
|
'info': {},
|
||||||
|
'display': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
r = await api_request(
|
r = await api_request(
|
||||||
|
@@ -7,13 +7,11 @@ import time
|
|||||||
from subprocess import check_output
|
from subprocess import check_output
|
||||||
from subprocess import PIPE
|
from subprocess import PIPE
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
from subprocess import run
|
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from tornado import gen
|
|
||||||
from traitlets.config import Config
|
from traitlets.config import Config
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
@@ -93,7 +91,7 @@ def test_generate_config():
|
|||||||
os.remove(cfg_file)
|
os.remove(cfg_file)
|
||||||
assert cfg_file in out
|
assert cfg_file in out
|
||||||
assert 'Spawner.cmd' in cfg_text
|
assert 'Spawner.cmd' in cfg_text
|
||||||
assert 'Authenticator.whitelist' in cfg_text
|
assert 'Authenticator.allowed_users' in cfg_text
|
||||||
|
|
||||||
|
|
||||||
async def test_init_tokens(request):
|
async def test_init_tokens(request):
|
||||||
|
@@ -1,11 +1,12 @@
|
|||||||
"""Tests for PAM authentication"""
|
"""Tests for PAM authentication"""
|
||||||
# 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.
|
||||||
import os
|
import logging
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from requests import HTTPError
|
from requests import HTTPError
|
||||||
|
from traitlets.config import Config
|
||||||
|
|
||||||
from .mocking import MockPAMAuthenticator
|
from .mocking import MockPAMAuthenticator
|
||||||
from .mocking import MockStructGroup
|
from .mocking import MockStructGroup
|
||||||
@@ -137,8 +138,8 @@ async def test_pam_auth_admin_groups():
|
|||||||
assert authorized['admin'] is False
|
assert authorized['admin'] is False
|
||||||
|
|
||||||
|
|
||||||
async def test_pam_auth_whitelist():
|
async def test_pam_auth_allowed():
|
||||||
authenticator = MockPAMAuthenticator(whitelist={'wash', 'kaylee'})
|
authenticator = MockPAMAuthenticator(allowed_users={'wash', 'kaylee'})
|
||||||
authorized = await authenticator.get_authenticated_user(
|
authorized = await authenticator.get_authenticated_user(
|
||||||
None, {'username': 'kaylee', 'password': 'kaylee'}
|
None, {'username': 'kaylee', 'password': 'kaylee'}
|
||||||
)
|
)
|
||||||
@@ -155,11 +156,11 @@ async def test_pam_auth_whitelist():
|
|||||||
assert authorized is None
|
assert authorized is None
|
||||||
|
|
||||||
|
|
||||||
async def test_pam_auth_group_whitelist():
|
async def test_pam_auth_allowed_groups():
|
||||||
def getgrnam(name):
|
def getgrnam(name):
|
||||||
return MockStructGroup('grp', ['kaylee'])
|
return MockStructGroup('grp', ['kaylee'])
|
||||||
|
|
||||||
authenticator = MockPAMAuthenticator(group_whitelist={'group'})
|
authenticator = MockPAMAuthenticator(allowed_groups={'group'})
|
||||||
|
|
||||||
with mock.patch.object(authenticator, '_getgrnam', getgrnam):
|
with mock.patch.object(authenticator, '_getgrnam', getgrnam):
|
||||||
authorized = await authenticator.get_authenticated_user(
|
authorized = await authenticator.get_authenticated_user(
|
||||||
@@ -174,7 +175,7 @@ async def test_pam_auth_group_whitelist():
|
|||||||
assert authorized is None
|
assert authorized is None
|
||||||
|
|
||||||
|
|
||||||
async def test_pam_auth_blacklist():
|
async def test_pam_auth_blocked():
|
||||||
# Null case compared to next case
|
# Null case compared to next case
|
||||||
authenticator = MockPAMAuthenticator()
|
authenticator = MockPAMAuthenticator()
|
||||||
authorized = await authenticator.get_authenticated_user(
|
authorized = await authenticator.get_authenticated_user(
|
||||||
@@ -183,33 +184,33 @@ async def test_pam_auth_blacklist():
|
|||||||
assert authorized['name'] == 'wash'
|
assert authorized['name'] == 'wash'
|
||||||
|
|
||||||
# Blacklist basics
|
# Blacklist basics
|
||||||
authenticator = MockPAMAuthenticator(blacklist={'wash'})
|
authenticator = MockPAMAuthenticator(blocked_users={'wash'})
|
||||||
authorized = await authenticator.get_authenticated_user(
|
authorized = await authenticator.get_authenticated_user(
|
||||||
None, {'username': 'wash', 'password': 'wash'}
|
None, {'username': 'wash', 'password': 'wash'}
|
||||||
)
|
)
|
||||||
assert authorized is None
|
assert authorized is None
|
||||||
|
|
||||||
# User in both white and blacklists: default deny. Make error someday?
|
# User in both allowed and blocked: default deny. Make error someday?
|
||||||
authenticator = MockPAMAuthenticator(
|
authenticator = MockPAMAuthenticator(
|
||||||
blacklist={'wash'}, whitelist={'wash', 'kaylee'}
|
blocked_users={'wash'}, allowed_users={'wash', 'kaylee'}
|
||||||
)
|
)
|
||||||
authorized = await authenticator.get_authenticated_user(
|
authorized = await authenticator.get_authenticated_user(
|
||||||
None, {'username': 'wash', 'password': 'wash'}
|
None, {'username': 'wash', 'password': 'wash'}
|
||||||
)
|
)
|
||||||
assert authorized is None
|
assert authorized is None
|
||||||
|
|
||||||
# User not in blacklist can log in
|
# User not in blocked set can log in
|
||||||
authenticator = MockPAMAuthenticator(
|
authenticator = MockPAMAuthenticator(
|
||||||
blacklist={'wash'}, whitelist={'wash', 'kaylee'}
|
blocked_users={'wash'}, allowed_users={'wash', 'kaylee'}
|
||||||
)
|
)
|
||||||
authorized = await authenticator.get_authenticated_user(
|
authorized = await authenticator.get_authenticated_user(
|
||||||
None, {'username': 'kaylee', 'password': 'kaylee'}
|
None, {'username': 'kaylee', 'password': 'kaylee'}
|
||||||
)
|
)
|
||||||
assert authorized['name'] == 'kaylee'
|
assert authorized['name'] == 'kaylee'
|
||||||
|
|
||||||
# User in whitelist, blacklist irrelevent
|
# User in allowed, blocked irrelevent
|
||||||
authenticator = MockPAMAuthenticator(
|
authenticator = MockPAMAuthenticator(
|
||||||
blacklist={'mal'}, whitelist={'wash', 'kaylee'}
|
blocked_users={'mal'}, allowed_users={'wash', 'kaylee'}
|
||||||
)
|
)
|
||||||
authorized = await authenticator.get_authenticated_user(
|
authorized = await authenticator.get_authenticated_user(
|
||||||
None, {'username': 'wash', 'password': 'wash'}
|
None, {'username': 'wash', 'password': 'wash'}
|
||||||
@@ -218,15 +219,16 @@ async def test_pam_auth_blacklist():
|
|||||||
|
|
||||||
# User in neither list
|
# User in neither list
|
||||||
authenticator = MockPAMAuthenticator(
|
authenticator = MockPAMAuthenticator(
|
||||||
blacklist={'mal'}, whitelist={'wash', 'kaylee'}
|
blocked_users={'mal'}, allowed_users={'wash', 'kaylee'}
|
||||||
)
|
)
|
||||||
authorized = await authenticator.get_authenticated_user(
|
authorized = await authenticator.get_authenticated_user(
|
||||||
None, {'username': 'simon', 'password': 'simon'}
|
None, {'username': 'simon', 'password': 'simon'}
|
||||||
)
|
)
|
||||||
assert authorized is None
|
assert authorized is None
|
||||||
|
|
||||||
# blacklist == {}
|
authenticator = MockPAMAuthenticator(
|
||||||
authenticator = MockPAMAuthenticator(blacklist=set(), whitelist={'wash', 'kaylee'})
|
blocked_users=set(), allowed_users={'wash', 'kaylee'}
|
||||||
|
)
|
||||||
authorized = await authenticator.get_authenticated_user(
|
authorized = await authenticator.get_authenticated_user(
|
||||||
None, {'username': 'kaylee', 'password': 'kaylee'}
|
None, {'username': 'kaylee', 'password': 'kaylee'}
|
||||||
)
|
)
|
||||||
@@ -253,7 +255,7 @@ async def test_deprecated_signatures():
|
|||||||
|
|
||||||
|
|
||||||
async def test_pam_auth_no_such_group():
|
async def test_pam_auth_no_such_group():
|
||||||
authenticator = MockPAMAuthenticator(group_whitelist={'nosuchcrazygroup'})
|
authenticator = MockPAMAuthenticator(allowed_groups={'nosuchcrazygroup'})
|
||||||
authorized = await authenticator.get_authenticated_user(
|
authorized = await authenticator.get_authenticated_user(
|
||||||
None, {'username': 'kaylee', 'password': 'kaylee'}
|
None, {'username': 'kaylee', 'password': 'kaylee'}
|
||||||
)
|
)
|
||||||
@@ -262,7 +264,7 @@ async def test_pam_auth_no_such_group():
|
|||||||
|
|
||||||
async def test_wont_add_system_user():
|
async def test_wont_add_system_user():
|
||||||
user = orm.User(name='lioness4321')
|
user = orm.User(name='lioness4321')
|
||||||
authenticator = auth.PAMAuthenticator(whitelist={'mal'})
|
authenticator = auth.PAMAuthenticator(allowed_users={'mal'})
|
||||||
authenticator.create_system_users = False
|
authenticator.create_system_users = False
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
await authenticator.add_user(user)
|
await authenticator.add_user(user)
|
||||||
@@ -270,7 +272,7 @@ async def test_wont_add_system_user():
|
|||||||
|
|
||||||
async def test_cant_add_system_user():
|
async def test_cant_add_system_user():
|
||||||
user = orm.User(name='lioness4321')
|
user = orm.User(name='lioness4321')
|
||||||
authenticator = auth.PAMAuthenticator(whitelist={'mal'})
|
authenticator = auth.PAMAuthenticator(allowed_users={'mal'})
|
||||||
authenticator.add_user_cmd = ['jupyterhub-fake-command']
|
authenticator.add_user_cmd = ['jupyterhub-fake-command']
|
||||||
authenticator.create_system_users = True
|
authenticator.create_system_users = True
|
||||||
|
|
||||||
@@ -296,7 +298,7 @@ async def test_cant_add_system_user():
|
|||||||
|
|
||||||
async def test_add_system_user():
|
async def test_add_system_user():
|
||||||
user = orm.User(name='lioness4321')
|
user = orm.User(name='lioness4321')
|
||||||
authenticator = auth.PAMAuthenticator(whitelist={'mal'})
|
authenticator = auth.PAMAuthenticator(allowed_users={'mal'})
|
||||||
authenticator.create_system_users = True
|
authenticator.create_system_users = True
|
||||||
authenticator.add_user_cmd = ['echo', '/home/USERNAME']
|
authenticator.add_user_cmd = ['echo', '/home/USERNAME']
|
||||||
|
|
||||||
@@ -317,13 +319,13 @@ async def test_add_system_user():
|
|||||||
|
|
||||||
async def test_delete_user():
|
async def test_delete_user():
|
||||||
user = orm.User(name='zoe')
|
user = orm.User(name='zoe')
|
||||||
a = MockPAMAuthenticator(whitelist={'mal'})
|
a = MockPAMAuthenticator(allowed_users={'mal'})
|
||||||
|
|
||||||
assert 'zoe' not in a.whitelist
|
assert 'zoe' not in a.allowed_users
|
||||||
await a.add_user(user)
|
await a.add_user(user)
|
||||||
assert 'zoe' in a.whitelist
|
assert 'zoe' in a.allowed_users
|
||||||
a.delete_user(user)
|
a.delete_user(user)
|
||||||
assert 'zoe' not in a.whitelist
|
assert 'zoe' not in a.allowed_users
|
||||||
|
|
||||||
|
|
||||||
def test_urls():
|
def test_urls():
|
||||||
@@ -461,3 +463,55 @@ async def test_post_auth_hook():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert authorized['testkey'] == 'testvalue'
|
assert authorized['testkey'] == 'testvalue'
|
||||||
|
|
||||||
|
|
||||||
|
class MyAuthenticator(auth.Authenticator):
|
||||||
|
def check_whitelist(self, username, authentication=None):
|
||||||
|
return username == "subclass-allowed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_deprecated_config(caplog):
|
||||||
|
cfg = Config()
|
||||||
|
cfg.Authenticator.whitelist = {'user'}
|
||||||
|
log = logging.getLogger("testlog")
|
||||||
|
authenticator = auth.Authenticator(config=cfg, log=log)
|
||||||
|
assert caplog.record_tuples == [
|
||||||
|
(
|
||||||
|
log.name,
|
||||||
|
logging.WARNING,
|
||||||
|
'Authenticator.whitelist is deprecated in JupyterHub 1.2, use '
|
||||||
|
'Authenticator.allowed_users instead',
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert authenticator.allowed_users == {'user'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_deprecated_methods():
|
||||||
|
cfg = Config()
|
||||||
|
cfg.Authenticator.whitelist = {'user'}
|
||||||
|
authenticator = auth.Authenticator(config=cfg)
|
||||||
|
|
||||||
|
assert authenticator.check_allowed("user")
|
||||||
|
with pytest.deprecated_call():
|
||||||
|
assert authenticator.check_whitelist("user")
|
||||||
|
assert not authenticator.check_allowed("otheruser")
|
||||||
|
with pytest.deprecated_call():
|
||||||
|
assert not authenticator.check_whitelist("otheruser")
|
||||||
|
|
||||||
|
|
||||||
|
def test_deprecated_config_subclass():
|
||||||
|
cfg = Config()
|
||||||
|
cfg.MyAuthenticator.whitelist = {'user'}
|
||||||
|
with pytest.deprecated_call():
|
||||||
|
authenticator = MyAuthenticator(config=cfg)
|
||||||
|
assert authenticator.allowed_users == {'user'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_deprecated_methods_subclass():
|
||||||
|
with pytest.deprecated_call():
|
||||||
|
authenticator = MyAuthenticator()
|
||||||
|
|
||||||
|
assert authenticator.check_allowed("subclass-allowed")
|
||||||
|
assert authenticator.check_whitelist("subclass-allowed")
|
||||||
|
assert not authenticator.check_allowed("otheruser")
|
||||||
|
assert not authenticator.check_whitelist("otheruser")
|
||||||
|
@@ -7,7 +7,6 @@ authentication can expire in a number of ways:
|
|||||||
- doesn't need refresh
|
- doesn't need refresh
|
||||||
- needs refresh and cannot be refreshed without new login
|
- needs refresh and cannot be refreshed without new login
|
||||||
"""
|
"""
|
||||||
import asyncio
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
|
@@ -1,8 +1,6 @@
|
|||||||
"""Tests for dummy authentication"""
|
"""Tests for dummy authentication"""
|
||||||
# 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.
|
||||||
import pytest
|
|
||||||
|
|
||||||
from jupyterhub.auth import DummyAuthenticator
|
from jupyterhub.auth import DummyAuthenticator
|
||||||
|
|
||||||
|
|
||||||
|
@@ -1,6 +0,0 @@
|
|||||||
"""Tests for the SSL enabled REST API."""
|
|
||||||
# Copyright (c) Jupyter Development Team.
|
|
||||||
# Distributed under the terms of the Modified BSD License.
|
|
||||||
from jupyterhub.tests.test_api import *
|
|
||||||
|
|
||||||
ssl_enabled = True
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user